forked from xiangwang25/whale-town-end-v2
refactor: harden task authority and room mutations
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsInt, IsString, Length, Matches, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetRoomDecorPlacementsDto {
|
||||||
|
@IsInt({ message: '布局版本必须是整数' })
|
||||||
|
@Min(0, { message: '布局版本不能小于0' })
|
||||||
|
layout_revision!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'mutation版本必须是整数' })
|
||||||
|
@Min(1, { message: 'mutation版本必须大于0' })
|
||||||
|
mutation_revision!: number;
|
||||||
|
|
||||||
|
@IsString({ message: 'mutation ID必须是字符串' })
|
||||||
|
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
|
||||||
|
mutation_id!: string;
|
||||||
|
}
|
||||||
@@ -40,4 +40,17 @@ export class SaveRoomDecorPlacementDto {
|
|||||||
@Min(-1000, { message: '层级超出范围' })
|
@Min(-1000, { message: '层级超出范围' })
|
||||||
@Max(1000, { message: '层级超出范围' })
|
@Max(1000, { message: '层级超出范围' })
|
||||||
z_index?: number;
|
z_index?: number;
|
||||||
|
|
||||||
|
@IsInt({ message: '布局版本必须是整数' })
|
||||||
|
@Min(0, { message: '布局版本不能小于0' })
|
||||||
|
layout_revision!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'mutation版本必须是整数' })
|
||||||
|
@Min(1, { message: 'mutation版本必须大于0' })
|
||||||
|
mutation_revision!: number;
|
||||||
|
|
||||||
|
@IsString({ message: 'mutation ID必须是字符串' })
|
||||||
|
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
|
||||||
|
mutation_id!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { JwtPayload } from '../../core/login_core/login_core.service';
|
|||||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
|
||||||
import { RoomDecorService } from './room_decor.service';
|
import { RoomDecorService } from './room_decor.service';
|
||||||
|
|
||||||
@ApiTags('room-decor')
|
@ApiTags('room-decor')
|
||||||
@@ -57,8 +58,13 @@ export class RoomDecorController {
|
|||||||
})
|
})
|
||||||
@SwaggerApiResponse({ status: 200, description: '房间布局已重置' })
|
@SwaggerApiResponse({ status: 200, description: '房间布局已重置' })
|
||||||
@Post('me/decor-placements/reset')
|
@Post('me/decor-placements/reset')
|
||||||
async resetPlacements(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub));
|
async resetPlacements(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ResetRoomDecorPlacementsDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub), dto);
|
||||||
res.status(HttpStatus.OK).json({
|
res.status(HttpStatus.OK).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
|||||||
import { InventoryService } from '../player/inventory.service';
|
import { InventoryService } from '../player/inventory.service';
|
||||||
import { SocialService } from '../social/social.service';
|
import { SocialService } from '../social/social.service';
|
||||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
|
||||||
import {
|
import {
|
||||||
ROOM_DECOR_BED_DEFAULT_SCALE,
|
ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||||
ROOM_DECOR_DEFINITIONS,
|
ROOM_DECOR_DEFINITIONS,
|
||||||
@@ -21,11 +22,13 @@ interface UserRoomDecorRow {
|
|||||||
scale: number;
|
scale: number;
|
||||||
rotation_degrees?: number;
|
rotation_degrees?: number;
|
||||||
z_index: number;
|
z_index: number;
|
||||||
|
mutation_revision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IRoomDecorPlacementsService {
|
interface IRoomDecorPlacementsService {
|
||||||
listPlacements(userId: bigint): Promise<UserRoomDecorRow[]>;
|
getSnapshot(userId: bigint): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
|
||||||
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<UserRoomDecorRow>;
|
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<{ revision: number; placement: UserRoomDecorRow }>;
|
||||||
|
resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomDecorPayloadPlacement {
|
interface RoomDecorPayloadPlacement {
|
||||||
@@ -44,10 +47,11 @@ export class RoomDecorService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getInventory(userId: bigint) {
|
async getInventory(userId: bigint) {
|
||||||
const [inventory, placements] = await Promise.all([
|
const [inventory, snapshot] = await Promise.all([
|
||||||
this.inventoryService.listInventory(userId, 'room_decor'),
|
this.inventoryService.listInventory(userId, 'room_decor'),
|
||||||
this.roomDecorPlacementsService.listPlacements(userId),
|
this.roomDecorPlacementsService.getSnapshot(userId),
|
||||||
]);
|
]);
|
||||||
|
const placements = snapshot.placements;
|
||||||
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
|
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
|
||||||
const rows = inventory.room_decor_ids.map((decorId) => {
|
const rows = inventory.room_decor_ids.map((decorId) => {
|
||||||
const definition = findRoomDecorDefinition(decorId);
|
const definition = findRoomDecorDefinition(decorId);
|
||||||
@@ -67,6 +71,7 @@ export class RoomDecorService {
|
|||||||
.filter((row) => findRoomDecorDefinition(row.decor_id))
|
.filter((row) => findRoomDecorDefinition(row.decor_id))
|
||||||
.map((row) => this.toPayload(row)),
|
.map((row) => this.toPayload(row)),
|
||||||
definitions: ROOM_DECOR_DEFINITIONS,
|
definitions: ROOM_DECOR_DEFINITIONS,
|
||||||
|
revision: snapshot.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ export class RoomDecorService {
|
|||||||
? '墙面挂饰只能摆放在墙面网格内'
|
? '墙面挂饰只能摆放在墙面网格内'
|
||||||
: '地面家具只能摆放在地面网格内');
|
: '地面家具只能摆放在地面网格内');
|
||||||
}
|
}
|
||||||
const row = await this.roomDecorPlacementsService.savePlacement(userId, {
|
const result = await this.roomDecorPlacementsService.savePlacement(userId, {
|
||||||
...placement,
|
...placement,
|
||||||
decor_id: decorId,
|
decor_id: decorId,
|
||||||
position_x: positionX,
|
position_x: positionX,
|
||||||
@@ -99,15 +104,19 @@ export class RoomDecorService {
|
|||||||
rotation_degrees: rotationDegrees,
|
rotation_degrees: rotationDegrees,
|
||||||
z_index: placement.z_index ?? definition.default_z_index,
|
z_index: placement.z_index ?? definition.default_z_index,
|
||||||
});
|
});
|
||||||
return this.toPayload(row);
|
return {
|
||||||
|
...this.toPayload(result.placement),
|
||||||
|
layout_revision: result.revision,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRoomView(viewerId: bigint, ownerId: bigint) {
|
async getRoomView(viewerId: bigint, ownerId: bigint) {
|
||||||
const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId);
|
const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId);
|
||||||
const [inventory, placements] = await Promise.all([
|
const [inventory, snapshot] = await Promise.all([
|
||||||
this.inventoryService.listInventory(ownerId, 'room_decor'),
|
this.inventoryService.listInventory(ownerId, 'room_decor'),
|
||||||
this.roomDecorPlacementsService.listPlacements(ownerId),
|
this.roomDecorPlacementsService.getSnapshot(ownerId),
|
||||||
]);
|
]);
|
||||||
|
const placements = snapshot.placements;
|
||||||
const ownedDecorIds = new Set(inventory.room_decor_ids);
|
const ownedDecorIds = new Set(inventory.room_decor_ids);
|
||||||
return {
|
return {
|
||||||
owner,
|
owner,
|
||||||
@@ -116,22 +125,12 @@ export class RoomDecorService {
|
|||||||
.filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id)))
|
.filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id)))
|
||||||
.map((row) => this.toPayload(row)),
|
.map((row) => this.toPayload(row)),
|
||||||
definitions: ROOM_DECOR_DEFINITIONS,
|
definitions: ROOM_DECOR_DEFINITIONS,
|
||||||
|
revision: snapshot.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPlacements(userId: bigint) {
|
async resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto) {
|
||||||
const inventory = await this.inventoryService.listInventory(userId, 'room_decor');
|
await this.roomDecorPlacementsService.resetPlacements(userId, mutation);
|
||||||
await Promise.all(inventory.room_decor_ids.map(async (decorId) => {
|
|
||||||
const definition = findRoomDecorDefinition(decorId);
|
|
||||||
if (!definition) return;
|
|
||||||
await this.roomDecorPlacementsService.savePlacement(userId, {
|
|
||||||
decor_id: decorId,
|
|
||||||
placed: false,
|
|
||||||
scale: definition.default_scale,
|
|
||||||
rotation_degrees: definition.default_rotation_degrees,
|
|
||||||
z_index: definition.default_z_index,
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
return this.getInventory(userId);
|
return this.getInventory(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +149,7 @@ export class RoomDecorService {
|
|||||||
scale: placement.scale,
|
scale: placement.scale,
|
||||||
rotation_degrees: placement.rotation_degrees,
|
rotation_degrees: placement.rotation_degrees,
|
||||||
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
||||||
|
mutation_revision: row.mutation_revision ?? 0,
|
||||||
category: definition?.category ?? 'floor',
|
category: definition?.category ?? 'floor',
|
||||||
placement_surface: definition?.placement_surface ?? 'floor',
|
placement_surface: definition?.placement_surface ?? 'floor',
|
||||||
placement_bounds: definition?.placement_bounds,
|
placement_bounds: definition?.placement_bounds,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsIn, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
import { TASK_ACTIVITY_TYPES } from '../task_catalog';
|
import { TASK_ACTIVITY_TYPES } from '../task_catalog';
|
||||||
|
|
||||||
export class ReportTaskActivityDto {
|
export class ReportTaskActivityDto {
|
||||||
@@ -9,4 +9,13 @@ export class ReportTaskActivityDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
target_id?: string;
|
target_id?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]{16,64}$/)
|
||||||
|
nonce: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
occurred_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/business/tasks/migrations/create-task-activity-audit.sql
Normal file
21
src/business/tasks/migrations/create-task-activity-audit.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `task_activity_audit` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL,
|
||||||
|
`nonce` varchar(64) NOT NULL,
|
||||||
|
`activity` varchar(40) NOT NULL,
|
||||||
|
`requested_target_id` varchar(80) NULL,
|
||||||
|
`resolved_target_id` varchar(80) NULL,
|
||||||
|
`accepted` boolean NOT NULL DEFAULT FALSE,
|
||||||
|
`reason` varchar(120) NOT NULL,
|
||||||
|
`session_id` varchar(80) NULL,
|
||||||
|
`map_id` varchar(40) NULL,
|
||||||
|
`position_x` float NULL,
|
||||||
|
`position_y` float NULL,
|
||||||
|
`client_ip` varchar(64) NULL,
|
||||||
|
`user_agent` varchar(255) NULL,
|
||||||
|
`client_occurred_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_task_activity_audit_user_created` (`user_id`, `created_at`),
|
||||||
|
KEY `idx_task_activity_audit_nonce` (`user_id`, `nonce`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务活动校验审计';
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
|
||||||
|
import { TaskActivityAuthorityService } from './task_activity_authority.service';
|
||||||
import { TaskActivityType } from './task_catalog';
|
import { TaskActivityType } from './task_catalog';
|
||||||
import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types';
|
import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types';
|
||||||
|
|
||||||
@@ -12,17 +14,25 @@ const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskService {
|
export class TaskService {
|
||||||
constructor(@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore) {}
|
constructor(
|
||||||
|
@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore,
|
||||||
|
private readonly taskActivityAuthority: TaskActivityAuthorityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
return await this.taskProgressStore.getBoard(userId);
|
return await this.taskProgressStore.getBoard(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
async recordClientActivity(
|
||||||
if (!CLIENT_ACTIVITY_TYPES.includes(activity)) {
|
userId: bigint,
|
||||||
|
dto: ReportTaskActivityDto,
|
||||||
|
context: { clientIp?: string; userAgent?: string } = {},
|
||||||
|
): Promise<TaskBoardPayload> {
|
||||||
|
if (!CLIENT_ACTIVITY_TYPES.includes(dto.activity)) {
|
||||||
throw new BadRequestException('该任务活动只能由服务器业务记录');
|
throw new BadRequestException('该任务活动只能由服务器业务记录');
|
||||||
}
|
}
|
||||||
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim());
|
const resolved = await this.taskActivityAuthority.resolve(userId, dto, context);
|
||||||
|
return await this.taskProgressStore.recordActivity(userId, resolved.activity, resolved.targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
||||||
|
|||||||
54
src/business/tasks/task_activity_audit.entity.ts
Normal file
54
src/business/tasks/task_activity_audit.entity.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('task_activity_audit')
|
||||||
|
@Index('idx_task_activity_audit_user_created', ['user_id', 'created_at'])
|
||||||
|
@Index('idx_task_activity_audit_nonce', ['user_id', 'nonce'])
|
||||||
|
export class TaskActivityAudit {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: false })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: false })
|
||||||
|
nonce: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40, nullable: false })
|
||||||
|
activity: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
requested_target_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
resolved_target_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false })
|
||||||
|
accepted: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: false })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
session_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40, nullable: true })
|
||||||
|
map_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
position_x: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
position_y: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
client_ip: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
user_agent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
client_occurred_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
22
src/business/tasks/task_activity_audit.store.ts
Normal file
22
src/business/tasks/task_activity_audit.store.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface TaskActivityAuditRecord {
|
||||||
|
user_id: bigint;
|
||||||
|
nonce: string;
|
||||||
|
activity: string;
|
||||||
|
requested_target_id: string | null;
|
||||||
|
resolved_target_id: string | null;
|
||||||
|
accepted: boolean;
|
||||||
|
reason: string;
|
||||||
|
session_id: string | null;
|
||||||
|
map_id: string | null;
|
||||||
|
position_x: number | null;
|
||||||
|
position_y: number | null;
|
||||||
|
client_ip: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
client_occurred_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskActivityAuditStore {
|
||||||
|
append(record: TaskActivityAuditRecord): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_ACTIVITY_AUDIT_STORE = 'TASK_ACTIVITY_AUDIT_STORE';
|
||||||
17
src/business/tasks/task_activity_audit_database.service.ts
Normal file
17
src/business/tasks/task_activity_audit_database.service.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { TaskActivityAudit } from './task_activity_audit.entity';
|
||||||
|
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuditDatabaseService implements TaskActivityAuditStore {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TaskActivityAudit)
|
||||||
|
private readonly repository: Repository<TaskActivityAudit>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async append(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
await this.repository.insert(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/business/tasks/task_activity_audit_memory.service.ts
Normal file
15
src/business/tasks/task_activity_audit_memory.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuditMemoryService implements TaskActivityAuditStore {
|
||||||
|
private readonly records: Array<TaskActivityAuditRecord & { created_at: Date }> = [];
|
||||||
|
private static readonly MAX_RECORDS = 5000;
|
||||||
|
|
||||||
|
async append(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
this.records.push({ ...record, created_at: new Date() });
|
||||||
|
if (this.records.length > TaskActivityAuditMemoryService.MAX_RECORDS) {
|
||||||
|
this.records.splice(0, this.records.length - TaskActivityAuditMemoryService.MAX_RECORDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/business/tasks/task_activity_authority.service.ts
Normal file
218
src/business/tasks/task_activity_authority.service.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { ConflictException, HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { IRedisService } from '../../core/redis/redis.interface';
|
||||||
|
import { Position } from '../../core/location_broadcast_core/position.interface';
|
||||||
|
import { LocationBroadcastCore } from '../../core/location_broadcast_core/location_broadcast_core.service';
|
||||||
|
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
|
||||||
|
import { TaskActivityType } from './task_catalog';
|
||||||
|
import {
|
||||||
|
TASK_ACTIVITY_AUDIT_STORE,
|
||||||
|
TaskActivityAuditRecord,
|
||||||
|
TaskActivityAuditStore,
|
||||||
|
} from './task_activity_audit.store';
|
||||||
|
|
||||||
|
interface TaskActivityRequestContext {
|
||||||
|
clientIp?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActivity {
|
||||||
|
activity: TaskActivityType;
|
||||||
|
targetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FacilityPoint {
|
||||||
|
mapId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_REQUIRED_ACTIVITIES = new Set<TaskActivityType>([
|
||||||
|
'map_visited',
|
||||||
|
'notice_viewed',
|
||||||
|
'course_board_opened',
|
||||||
|
'facility_interacted',
|
||||||
|
]);
|
||||||
|
const MAP_TASK_TARGETS: Record<string, string> = {
|
||||||
|
whale_port: 'square',
|
||||||
|
work_zone: 'work_zone',
|
||||||
|
whale_cafe: 'whale_cafe',
|
||||||
|
};
|
||||||
|
const FACILITY_POINTS: Record<string, FacilityPoint[]> = {
|
||||||
|
welcome_board: [{ mapId: 'whale_port', x: 1470, y: 408, radius: 220 }],
|
||||||
|
notice_board: [{ mapId: 'whale_port', x: 701, y: 1529, radius: 240 }],
|
||||||
|
course_board: [{ mapId: 'work_zone', x: 1778, y: 1366, radius: 240 }],
|
||||||
|
npc: [
|
||||||
|
{ mapId: 'whale_port', x: 1081, y: 445, radius: 220 },
|
||||||
|
{ mapId: 'whale_port', x: 455, y: 1397, radius: 220 },
|
||||||
|
{ mapId: 'whale_cafe', x: 296, y: 221, radius: 240 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const NONCE_TTL_SECONDS = 10 * 60;
|
||||||
|
const SESSION_FRESHNESS_MS = 30 * 1000;
|
||||||
|
const RATE_LIMIT_PER_MINUTE = 30;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuthorityService {
|
||||||
|
private readonly logger = new Logger(TaskActivityAuthorityService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('REDIS_SERVICE') private readonly redisService: IRedisService,
|
||||||
|
private readonly locationBroadcastCore: LocationBroadcastCore,
|
||||||
|
@Inject(TASK_ACTIVITY_AUDIT_STORE) private readonly auditStore: TaskActivityAuditStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
userId: bigint,
|
||||||
|
dto: ReportTaskActivityDto,
|
||||||
|
context: TaskActivityRequestContext = {},
|
||||||
|
): Promise<ResolvedActivity> {
|
||||||
|
const requestedTargetId = dto.target_id?.trim() || undefined;
|
||||||
|
const auditBase: TaskActivityAuditRecord = {
|
||||||
|
user_id: userId,
|
||||||
|
nonce: dto.nonce,
|
||||||
|
activity: dto.activity,
|
||||||
|
requested_target_id: requestedTargetId ?? null,
|
||||||
|
resolved_target_id: null,
|
||||||
|
accepted: false,
|
||||||
|
reason: 'pending',
|
||||||
|
session_id: null,
|
||||||
|
map_id: null,
|
||||||
|
position_x: null,
|
||||||
|
position_y: null,
|
||||||
|
client_ip: context.clientIp?.slice(0, 64) || null,
|
||||||
|
user_agent: context.userAgent?.slice(0, 255) || null,
|
||||||
|
client_occurred_at: dto.occurred_at ? new Date(dto.occurred_at) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.enforceRateLimit(userId);
|
||||||
|
const nonceAccepted = await this.redisService.setIfAbsent(
|
||||||
|
`tasks:activity:nonce:${userId.toString()}:${dto.nonce}`,
|
||||||
|
'1',
|
||||||
|
NONCE_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
if (!nonceAccepted) {
|
||||||
|
throw new ConflictException('任务活动 nonce 已使用');
|
||||||
|
}
|
||||||
|
|
||||||
|
let position: Position | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
if (SESSION_REQUIRED_ACTIVITIES.has(dto.activity)) {
|
||||||
|
[sessionId, position] = await Promise.all([
|
||||||
|
this.redisService.get(`user:${userId.toString()}:session`),
|
||||||
|
this.locationBroadcastCore.getUserPosition(userId.toString()),
|
||||||
|
]);
|
||||||
|
auditBase.session_id = sessionId;
|
||||||
|
auditBase.map_id = position?.mapId ?? null;
|
||||||
|
auditBase.position_x = position?.x ?? null;
|
||||||
|
auditBase.position_y = position?.y ?? null;
|
||||||
|
this.assertActiveSession(sessionId, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = this.resolveActivity(dto.activity, requestedTargetId, position);
|
||||||
|
await this.appendAudit({
|
||||||
|
...auditBase,
|
||||||
|
resolved_target_id: resolved.targetId ?? null,
|
||||||
|
accepted: true,
|
||||||
|
reason: 'accepted',
|
||||||
|
});
|
||||||
|
return resolved;
|
||||||
|
} catch (error) {
|
||||||
|
await this.appendAudit({
|
||||||
|
...auditBase,
|
||||||
|
reason: error instanceof Error ? error.message.slice(0, 120) : 'unknown_error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enforceRateLimit(userId: bigint): Promise<void> {
|
||||||
|
const minuteBucket = Math.floor(Date.now() / 60000);
|
||||||
|
const key = `tasks:activity:rate:${userId.toString()}:${minuteBucket}`;
|
||||||
|
const count = await this.redisService.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await this.redisService.expire(key, 120);
|
||||||
|
}
|
||||||
|
if (count > RATE_LIMIT_PER_MINUTE) {
|
||||||
|
throw new HttpException('任务活动上报过于频繁', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertActiveSession(sessionId: string | null, position: Position | null): asserts position is Position {
|
||||||
|
if (!sessionId || !position) {
|
||||||
|
throw new ConflictException('游戏会话尚未就绪');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(position.timestamp) || Date.now() - position.timestamp > SESSION_FRESHNESS_MS) {
|
||||||
|
throw new ConflictException('游戏会话位置已过期');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveActivity(
|
||||||
|
activity: TaskActivityType,
|
||||||
|
requestedTargetId: string | undefined,
|
||||||
|
position: Position | null,
|
||||||
|
): ResolvedActivity {
|
||||||
|
if (activity === 'map_visited') {
|
||||||
|
const targetId = MAP_TASK_TARGETS[position?.mapId ?? ''];
|
||||||
|
if (!targetId) {
|
||||||
|
throw new ConflictException('当前地图不计入任务进度');
|
||||||
|
}
|
||||||
|
if (requestedTargetId && requestedTargetId !== targetId) {
|
||||||
|
throw new ConflictException('地图活动与当前会话不一致');
|
||||||
|
}
|
||||||
|
return { activity, targetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'notice_viewed') {
|
||||||
|
this.assertNearFacility(position, 'notice_board');
|
||||||
|
return { activity, targetId: 'notice_board' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'course_board_opened') {
|
||||||
|
this.assertNearFacility(position, 'course_board');
|
||||||
|
return { activity, targetId: 'course_board' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'facility_interacted') {
|
||||||
|
if (!requestedTargetId || !FACILITY_POINTS[requestedTargetId]) {
|
||||||
|
throw new ConflictException('设施活动目标无效');
|
||||||
|
}
|
||||||
|
this.assertNearFacility(position, requestedTargetId);
|
||||||
|
return { activity, targetId: requestedTargetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activity, targetId: requestedTargetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNearFacility(position: Position | null, facilityId: string): void {
|
||||||
|
if (!position) {
|
||||||
|
throw new ConflictException('游戏会话尚未就绪');
|
||||||
|
}
|
||||||
|
const matched = FACILITY_POINTS[facilityId].some((point) => {
|
||||||
|
if (point.mapId !== position.mapId) return false;
|
||||||
|
const deltaX = position.x - point.x;
|
||||||
|
const deltaY = position.y - point.y;
|
||||||
|
return deltaX * deltaX + deltaY * deltaY <= point.radius * point.radius;
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
throw new ConflictException('玩家不在目标设施交互范围内');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendAudit(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.auditStore.append(record);
|
||||||
|
this.logger.log({
|
||||||
|
operation: 'task_activity',
|
||||||
|
userId: record.user_id.toString(),
|
||||||
|
activity: record.activity,
|
||||||
|
accepted: record.accepted,
|
||||||
|
reason: record.reason,
|
||||||
|
mapId: record.map_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('任务活动审计写入失败', error instanceof Error ? error.stack : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, HttpStatus, Param, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
import { Body, Controller, Get, HttpStatus, Param, Post, Req, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||||
@@ -29,9 +29,13 @@ export class TasksController {
|
|||||||
async reportActivity(
|
async reportActivity(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Body() dto: ReportTaskActivityDto,
|
@Body() dto: ReportTaskActivityDto,
|
||||||
|
@Req() req: Request,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto.activity, dto.target_id);
|
const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto, {
|
||||||
|
clientIp: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
});
|
||||||
res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' });
|
res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { DynamicModule, Global, Module } from '@nestjs/common';
|
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LocationBroadcastCoreModule } from '../../core/location_broadcast_core/location_broadcast_core.module';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { RedisModule } from '../../core/redis/redis.module';
|
||||||
import { PlayerTaskProgress } from './player_task_progress.entity';
|
import { PlayerTaskProgress } from './player_task_progress.entity';
|
||||||
|
import { TaskActivityAudit } from './task_activity_audit.entity';
|
||||||
|
import { TASK_ACTIVITY_AUDIT_STORE } from './task_activity_audit.store';
|
||||||
|
import { TaskActivityAuditDatabaseService } from './task_activity_audit_database.service';
|
||||||
|
import { TaskActivityAuditMemoryService } from './task_activity_audit_memory.service';
|
||||||
|
import { TaskActivityAuthorityService } from './task_activity_authority.service';
|
||||||
import { TaskProgressDatabaseService } from './task_progress_database.service';
|
import { TaskProgressDatabaseService } from './task_progress_database.service';
|
||||||
import { TaskProgressMemoryService } from './task_progress_memory.service';
|
import { TaskProgressMemoryService } from './task_progress_memory.service';
|
||||||
import { TaskService } from './task.service';
|
import { TaskService } from './task.service';
|
||||||
@@ -14,11 +21,14 @@ export class TasksModule {
|
|||||||
return {
|
return {
|
||||||
module: TasksModule,
|
module: TasksModule,
|
||||||
global: true,
|
global: true,
|
||||||
imports: [LoginCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress])],
|
imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress, TaskActivityAudit])],
|
||||||
controllers: [TasksController],
|
controllers: [TasksController],
|
||||||
providers: [
|
providers: [
|
||||||
TaskProgressDatabaseService,
|
TaskProgressDatabaseService,
|
||||||
{ provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService },
|
{ provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService },
|
||||||
|
TaskActivityAuditDatabaseService,
|
||||||
|
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditDatabaseService },
|
||||||
|
TaskActivityAuthorityService,
|
||||||
TaskService,
|
TaskService,
|
||||||
],
|
],
|
||||||
exports: [TaskService, 'ITaskProgressStore'],
|
exports: [TaskService, 'ITaskProgressStore'],
|
||||||
@@ -29,11 +39,14 @@ export class TasksModule {
|
|||||||
return {
|
return {
|
||||||
module: TasksModule,
|
module: TasksModule,
|
||||||
global: true,
|
global: true,
|
||||||
imports: [LoginCoreModule],
|
imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule],
|
||||||
controllers: [TasksController],
|
controllers: [TasksController],
|
||||||
providers: [
|
providers: [
|
||||||
TaskProgressMemoryService,
|
TaskProgressMemoryService,
|
||||||
{ provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService },
|
{ provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService },
|
||||||
|
TaskActivityAuditMemoryService,
|
||||||
|
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditMemoryService },
|
||||||
|
TaskActivityAuthorityService,
|
||||||
TaskService,
|
TaskService,
|
||||||
],
|
],
|
||||||
exports: [TaskService, 'ITaskProgressStore'],
|
exports: [TaskService, 'ITaskProgressStore'],
|
||||||
|
|||||||
@@ -21,9 +21,19 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` (
|
|||||||
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
|
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
|
||||||
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
|
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
|
||||||
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
|
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
|
||||||
|
`mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本',
|
||||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`),
|
UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`),
|
||||||
KEY `idx_room_decor_placements_user_id` (`user_id`)
|
KEY `idx_room_decor_placements_user_id` (`user_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
|
||||||
|
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
|
||||||
|
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
|
||||||
|
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE `room_decor_placements`
|
||||||
|
ADD COLUMN IF NOT EXISTS `mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本' AFTER `z_index`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
|
||||||
|
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
|
||||||
|
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
|
||||||
|
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `room_decor_layout_state`
|
||||||
|
ADD COLUMN IF NOT EXISTS `last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域' AFTER `last_mutation_id`;
|
||||||
@@ -4,6 +4,7 @@ import { PlayerAssets } from './player_assets.entity';
|
|||||||
import { PlayerAssetsMemoryService } from './player_assets_memory.service';
|
import { PlayerAssetsMemoryService } from './player_assets_memory.service';
|
||||||
import { PlayerAssetsService } from './player_assets.service';
|
import { PlayerAssetsService } from './player_assets.service';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
|
||||||
import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service';
|
import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service';
|
||||||
import { RoomDecorPlacementsService } from './room_decor_placements.service';
|
import { RoomDecorPlacementsService } from './room_decor_placements.service';
|
||||||
|
|
||||||
@@ -13,12 +14,12 @@ export class PlayerAssetsModule {
|
|||||||
static forDatabase(): DynamicModule {
|
static forDatabase(): DynamicModule {
|
||||||
return {
|
return {
|
||||||
module: PlayerAssetsModule,
|
module: PlayerAssetsModule,
|
||||||
imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements])],
|
imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements, RoomDecorLayoutState])],
|
||||||
providers: [
|
providers: [
|
||||||
PlayerAssetsService,
|
PlayerAssetsService,
|
||||||
RoomDecorPlacementsService,
|
RoomDecorPlacementsService,
|
||||||
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsService },
|
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsService },
|
||||||
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsService },
|
{ provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsService },
|
||||||
],
|
],
|
||||||
exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
||||||
};
|
};
|
||||||
@@ -31,7 +32,7 @@ export class PlayerAssetsModule {
|
|||||||
PlayerAssetsMemoryService,
|
PlayerAssetsMemoryService,
|
||||||
RoomDecorPlacementsMemoryService,
|
RoomDecorPlacementsMemoryService,
|
||||||
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService },
|
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService },
|
||||||
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsMemoryService },
|
{ provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsMemoryService },
|
||||||
],
|
],
|
||||||
exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
||||||
};
|
};
|
||||||
|
|||||||
19
src/core/db/player_assets/room_decor_layout_state.entity.ts
Normal file
19
src/core/db/player_assets/room_decor_layout_state.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_decor_layout_state')
|
||||||
|
export class RoomDecorLayoutState {
|
||||||
|
@PrimaryColumn({ type: 'bigint', comment: '关联users.id' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '当前布局版本' })
|
||||||
|
revision: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true, comment: '最后一次幂等mutation ID' })
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: true, comment: '最后一次mutation作用域' })
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export class RoomDecorPlacements {
|
|||||||
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
|
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
|
||||||
z_index: number;
|
z_index: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '最后修改该摆件的布局版本' })
|
||||||
|
mutation_revision: number;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,51 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
|
||||||
|
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomDecorPlacementsService {
|
export class RoomDecorPlacementsService {
|
||||||
constructor(
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
@InjectRepository(RoomDecorPlacements)
|
|
||||||
private readonly placementsRepository: Repository<RoomDecorPlacements>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
||||||
return await this.placementsRepository.find({
|
return await this.dataSource.getRepository(RoomDecorPlacements).find({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId },
|
||||||
order: { created_at: 'ASC', id: 'ASC' },
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> {
|
async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
const decorId = this.normalizeDecorId(placement.decor_id);
|
return await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||||
let row = await this.placementsRepository.findOne({
|
await this.ensureLayoutState(manager, userId);
|
||||||
where: { user_id: userId, decor_id: decorId },
|
const state = await manager.getRepository(RoomDecorLayoutState).findOneByOrFail({ user_id: userId });
|
||||||
|
const placements = await manager.getRepository(RoomDecorPlacements).find({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
return { revision: state.revision, placements };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlacement(
|
||||||
|
userId: bigint,
|
||||||
|
placement: SaveRoomDecorPlacementDto,
|
||||||
|
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockLayoutState(manager, userId);
|
||||||
|
const placementsRepository = manager.getRepository(RoomDecorPlacements);
|
||||||
|
const decorId = this.normalizeDecorId(placement.decor_id);
|
||||||
|
const mutationScope = `decor:${decorId}`;
|
||||||
|
let row = await placementsRepository.findOne({
|
||||||
|
where: { user_id: userId, decor_id: decorId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (this.isIdempotentRetry(state, placement, mutationScope)) {
|
||||||
|
if (!row) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
this.assertNextRevision(state, placement);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
row = new RoomDecorPlacements();
|
row = new RoomDecorPlacements();
|
||||||
row.user_id = userId;
|
row.user_id = userId;
|
||||||
@@ -35,8 +58,92 @@ export class RoomDecorPlacementsService {
|
|||||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||||
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||||
|
row.mutation_revision = placement.mutation_revision;
|
||||||
row.updated_at = new Date();
|
row.updated_at = new Date();
|
||||||
return await this.placementsRepository.save(row);
|
const saved = await placementsRepository.save(row);
|
||||||
|
await this.advanceLayoutState(manager, state, placement.mutation_revision, placement.mutation_id, mutationScope);
|
||||||
|
return { revision: placement.mutation_revision, placement: saved };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPlacements(
|
||||||
|
userId: bigint,
|
||||||
|
mutation: ResetRoomDecorPlacementsDto,
|
||||||
|
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockLayoutState(manager, userId);
|
||||||
|
if (!this.isIdempotentRetry(state, mutation, 'reset')) {
|
||||||
|
this.assertNextRevision(state, mutation);
|
||||||
|
await manager.getRepository(RoomDecorPlacements).createQueryBuilder()
|
||||||
|
.update(RoomDecorPlacements)
|
||||||
|
.set({
|
||||||
|
placed: false,
|
||||||
|
position_x: null,
|
||||||
|
position_y: null,
|
||||||
|
mutation_revision: mutation.mutation_revision,
|
||||||
|
})
|
||||||
|
.where('user_id = :userId', { userId: userId.toString() })
|
||||||
|
.execute();
|
||||||
|
await this.advanceLayoutState(manager, state, mutation.mutation_revision, mutation.mutation_id, 'reset');
|
||||||
|
}
|
||||||
|
const placements = await manager.getRepository(RoomDecorPlacements).find({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
return { revision: state.revision, placements };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLayoutState(manager: EntityManager, userId: bigint): Promise<void> {
|
||||||
|
await manager.getRepository(RoomDecorLayoutState).createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.values({ user_id: userId, revision: 0, last_mutation_id: null, last_mutation_scope: null })
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockLayoutState(manager: EntityManager, userId: bigint): Promise<RoomDecorLayoutState> {
|
||||||
|
await this.ensureLayoutState(manager, userId);
|
||||||
|
return await manager.getRepository(RoomDecorLayoutState).findOneOrFail({
|
||||||
|
where: { user_id: userId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIdempotentRetry(
|
||||||
|
state: RoomDecorLayoutState,
|
||||||
|
mutation: Pick<SaveRoomDecorPlacementDto, 'mutation_revision' | 'mutation_id'>,
|
||||||
|
mutationScope: string,
|
||||||
|
): boolean {
|
||||||
|
return mutation.mutation_revision === state.revision
|
||||||
|
&& mutation.mutation_id === state.last_mutation_id
|
||||||
|
&& mutationScope === state.last_mutation_scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNextRevision(
|
||||||
|
state: RoomDecorLayoutState,
|
||||||
|
mutation: Pick<SaveRoomDecorPlacementDto, 'layout_revision' | 'mutation_revision'>,
|
||||||
|
): void {
|
||||||
|
if (mutation.layout_revision !== state.revision || mutation.mutation_revision !== state.revision + 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: '装修布局版本已过期,请刷新后重试',
|
||||||
|
current_revision: state.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceLayoutState(
|
||||||
|
manager: EntityManager,
|
||||||
|
state: RoomDecorLayoutState,
|
||||||
|
revision: number,
|
||||||
|
mutationId: string,
|
||||||
|
mutationScope: string,
|
||||||
|
): Promise<void> {
|
||||||
|
state.revision = revision;
|
||||||
|
state.last_mutation_id = mutationId;
|
||||||
|
state.last_mutation_scope = mutationScope;
|
||||||
|
state.updated_at = new Date();
|
||||||
|
await manager.getRepository(RoomDecorLayoutState).save(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeDecorId(decorId: string): string {
|
private normalizeDecorId(decorId: string): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -7,6 +8,11 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
private placements: Map<bigint, RoomDecorPlacements> = new Map();
|
private placements: Map<bigint, RoomDecorPlacements> = new Map();
|
||||||
private userDecorIndex: Map<string, bigint> = new Map();
|
private userDecorIndex: Map<string, bigint> = new Map();
|
||||||
private currentId: bigint = BigInt(1);
|
private currentId: bigint = BigInt(1);
|
||||||
|
private layoutStates: Map<bigint, {
|
||||||
|
revision: number;
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
||||||
return Array.from(this.placements.values())
|
return Array.from(this.placements.values())
|
||||||
@@ -17,11 +23,28 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> {
|
async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
|
return { revision: state.revision, placements: await this.listPlacements(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlacement(
|
||||||
|
userId: bigint,
|
||||||
|
placement: SaveRoomDecorPlacementDto,
|
||||||
|
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
const decorId = this.normalizeDecorId(placement.decor_id);
|
const decorId = this.normalizeDecorId(placement.decor_id);
|
||||||
|
const mutationScope = `decor:${decorId}`;
|
||||||
const key = this.indexKey(userId, decorId);
|
const key = this.indexKey(userId, decorId);
|
||||||
const existingId = this.userDecorIndex.get(key);
|
const existingId = this.userDecorIndex.get(key);
|
||||||
const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements();
|
const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements();
|
||||||
|
if (placement.mutation_revision === state.revision
|
||||||
|
&& placement.mutation_id === state.last_mutation_id
|
||||||
|
&& mutationScope === state.last_mutation_scope) {
|
||||||
|
if (!existingId) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
this.assertNextRevision(state, placement.layout_revision, placement.mutation_revision);
|
||||||
if (!existingId) {
|
if (!existingId) {
|
||||||
row.id = this.currentId++;
|
row.id = this.currentId++;
|
||||||
row.user_id = userId;
|
row.user_id = userId;
|
||||||
@@ -36,8 +59,62 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||||
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||||
|
row.mutation_revision = placement.mutation_revision;
|
||||||
row.updated_at = new Date();
|
row.updated_at = new Date();
|
||||||
return row;
|
state.revision = placement.mutation_revision;
|
||||||
|
state.last_mutation_id = placement.mutation_id;
|
||||||
|
state.last_mutation_scope = mutationScope;
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPlacements(
|
||||||
|
userId: bigint,
|
||||||
|
mutation: ResetRoomDecorPlacementsDto,
|
||||||
|
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
|
if (!(mutation.mutation_revision === state.revision
|
||||||
|
&& mutation.mutation_id === state.last_mutation_id
|
||||||
|
&& state.last_mutation_scope === 'reset')) {
|
||||||
|
this.assertNextRevision(state, mutation.layout_revision, mutation.mutation_revision);
|
||||||
|
for (const row of this.placements.values()) {
|
||||||
|
if (row.user_id !== userId) continue;
|
||||||
|
row.placed = false;
|
||||||
|
row.position_x = null;
|
||||||
|
row.position_y = null;
|
||||||
|
row.mutation_revision = mutation.mutation_revision;
|
||||||
|
row.updated_at = new Date();
|
||||||
|
}
|
||||||
|
state.revision = mutation.mutation_revision;
|
||||||
|
state.last_mutation_id = mutation.mutation_id;
|
||||||
|
state.last_mutation_scope = 'reset';
|
||||||
|
}
|
||||||
|
return { revision: state.revision, placements: await this.listPlacements(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLayoutState(userId: bigint): {
|
||||||
|
revision: number;
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
} {
|
||||||
|
let state = this.layoutStates.get(userId);
|
||||||
|
if (!state) {
|
||||||
|
state = { revision: 0, last_mutation_id: null, last_mutation_scope: null };
|
||||||
|
this.layoutStates.set(userId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNextRevision(
|
||||||
|
state: { revision: number },
|
||||||
|
layoutRevision: number,
|
||||||
|
mutationRevision: number,
|
||||||
|
): void {
|
||||||
|
if (layoutRevision !== state.revision || mutationRevision !== state.revision + 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: '装修布局版本已过期,请刷新后重试',
|
||||||
|
current_revision: state.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private indexKey(userId: bigint, decorId: string): string {
|
private indexKey(userId: bigint, decorId: string): string {
|
||||||
|
|||||||
@@ -265,6 +265,22 @@ export class FileRedisService implements IRedisService, OnModuleDestroy {
|
|||||||
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||||
|
const existing = this.data.get(key);
|
||||||
|
if (existing && (!existing.expireAt || existing.expireAt > Date.now())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
this.data.delete(key);
|
||||||
|
}
|
||||||
|
this.data.set(key, {
|
||||||
|
value,
|
||||||
|
expireAt: ttl && ttl > 0 ? Date.now() + ttl * 1000 : undefined,
|
||||||
|
});
|
||||||
|
await this.saveData();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取键对应的值
|
* 获取键对应的值
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ export class RealRedisService implements IRedisService, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = ttl && ttl > 0
|
||||||
|
? await this.redis.set(key, value, 'EX', ttl, 'NX')
|
||||||
|
: await this.redis.set(key, value, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`原子设置Redis键失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取键对应的值
|
* 获取键对应的值
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export interface IRedisService {
|
|||||||
*/
|
*/
|
||||||
set(key: string, value: string, ttl?: number): Promise<void>;
|
set(key: string, value: string, ttl?: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。
|
||||||
|
*
|
||||||
|
* @returns 成功占位返回 true,键已存在返回 false
|
||||||
|
*/
|
||||||
|
setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置键值对并指定过期时间
|
* 设置键值对并指定过期时间
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user