From 968a672fbd2e6c2d1d05c06466b083eb630326f3 Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:59:00 +0800 Subject: [PATCH] refactor: harden task authority and room mutations --- .../dto/reset_room_decor_placements.dto.ts | 16 ++ .../dto/save_room_decor_placement.dto.ts | 13 ++ .../room_decor/room_decor.controller.ts | 10 +- src/business/room_decor/room_decor.service.ts | 42 ++-- .../tasks/dto/report_task_activity.dto.ts | 11 +- .../migrations/create-task-activity-audit.sql | 21 ++ src/business/tasks/task.service.ts | 18 +- .../tasks/task_activity_audit.entity.ts | 54 +++++ .../tasks/task_activity_audit.store.ts | 22 ++ .../task_activity_audit_database.service.ts | 17 ++ .../task_activity_audit_memory.service.ts | 15 ++ .../tasks/task_activity_authority.service.ts | 218 ++++++++++++++++++ src/business/tasks/tasks.controller.ts | 10 +- src/business/tasks/tasks.module.ts | 17 +- .../create-player-assets-tables.sql | 10 + .../add-room-decor-layout-revision.sql | 14 ++ .../db/player_assets/player_assets.module.ts | 7 +- .../room_decor_layout_state.entity.ts | 19 ++ .../room_decor_placements.entity.ts | 3 + .../room_decor_placements.service.ts | 157 +++++++++++-- .../room_decor_placements_memory.service.ts | 83 ++++++- src/core/redis/file_redis.service.ts | 16 ++ src/core/redis/real_redis.service.ts | 12 + src/core/redis/redis.interface.ts | 7 + 24 files changed, 748 insertions(+), 64 deletions(-) create mode 100644 src/business/room_decor/dto/reset_room_decor_placements.dto.ts create mode 100644 src/business/tasks/migrations/create-task-activity-audit.sql create mode 100644 src/business/tasks/task_activity_audit.entity.ts create mode 100644 src/business/tasks/task_activity_audit.store.ts create mode 100644 src/business/tasks/task_activity_audit_database.service.ts create mode 100644 src/business/tasks/task_activity_audit_memory.service.ts create mode 100644 src/business/tasks/task_activity_authority.service.ts create mode 100644 src/core/db/player_assets/migrations/add-room-decor-layout-revision.sql create mode 100644 src/core/db/player_assets/room_decor_layout_state.entity.ts diff --git a/src/business/room_decor/dto/reset_room_decor_placements.dto.ts b/src/business/room_decor/dto/reset_room_decor_placements.dto.ts new file mode 100644 index 0000000..f10da66 --- /dev/null +++ b/src/business/room_decor/dto/reset_room_decor_placements.dto.ts @@ -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; +} diff --git a/src/business/room_decor/dto/save_room_decor_placement.dto.ts b/src/business/room_decor/dto/save_room_decor_placement.dto.ts index 54e5883..d907295 100644 --- a/src/business/room_decor/dto/save_room_decor_placement.dto.ts +++ b/src/business/room_decor/dto/save_room_decor_placement.dto.ts @@ -40,4 +40,17 @@ export class SaveRoomDecorPlacementDto { @Min(-1000, { message: '层级超出范围' }) @Max(1000, { message: '层级超出范围' }) 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; } diff --git a/src/business/room_decor/room_decor.controller.ts b/src/business/room_decor/room_decor.controller.ts index 23b6232..99fbe7f 100644 --- a/src/business/room_decor/room_decor.controller.ts +++ b/src/business/room_decor/room_decor.controller.ts @@ -5,6 +5,7 @@ import { JwtPayload } from '../../core/login_core/login_core.service'; import { CurrentUser } from '../../gateway/auth/current_user.decorator'; import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; +import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto'; import { RoomDecorService } from './room_decor.service'; @ApiTags('room-decor') @@ -57,8 +58,13 @@ export class RoomDecorController { }) @SwaggerApiResponse({ status: 200, description: '房间布局已重置' }) @Post('me/decor-placements/reset') - async resetPlacements(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise { - const data = await this.roomDecorService.resetPlacements(BigInt(user.sub)); + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + async resetPlacements( + @CurrentUser() user: JwtPayload, + @Body() dto: ResetRoomDecorPlacementsDto, + @Res() res: Response, + ): Promise { + const data = await this.roomDecorService.resetPlacements(BigInt(user.sub), dto); res.status(HttpStatus.OK).json({ success: true, data, diff --git a/src/business/room_decor/room_decor.service.ts b/src/business/room_decor/room_decor.service.ts index eeeaa7b..bc7b376 100644 --- a/src/business/room_decor/room_decor.service.ts +++ b/src/business/room_decor/room_decor.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { InventoryService } from '../player/inventory.service'; import { SocialService } from '../social/social.service'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; +import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto'; import { ROOM_DECOR_BED_DEFAULT_SCALE, ROOM_DECOR_DEFINITIONS, @@ -21,11 +22,13 @@ interface UserRoomDecorRow { scale: number; rotation_degrees?: number; z_index: number; + mutation_revision?: number; } interface IRoomDecorPlacementsService { - listPlacements(userId: bigint): Promise; - savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise; + getSnapshot(userId: bigint): Promise<{ revision: number; placements: UserRoomDecorRow[] }>; + savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<{ revision: number; placement: UserRoomDecorRow }>; + resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto): Promise<{ revision: number; placements: UserRoomDecorRow[] }>; } interface RoomDecorPayloadPlacement { @@ -44,10 +47,11 @@ export class RoomDecorService { ) {} async getInventory(userId: bigint) { - const [inventory, placements] = await Promise.all([ + const [inventory, snapshot] = await Promise.all([ 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 rows = inventory.room_decor_ids.map((decorId) => { const definition = findRoomDecorDefinition(decorId); @@ -67,6 +71,7 @@ export class RoomDecorService { .filter((row) => findRoomDecorDefinition(row.decor_id)) .map((row) => this.toPayload(row)), 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, decor_id: decorId, position_x: positionX, @@ -99,15 +104,19 @@ export class RoomDecorService { rotation_degrees: rotationDegrees, 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) { 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.roomDecorPlacementsService.listPlacements(ownerId), + this.roomDecorPlacementsService.getSnapshot(ownerId), ]); + const placements = snapshot.placements; const ownedDecorIds = new Set(inventory.room_decor_ids); return { owner, @@ -116,22 +125,12 @@ export class RoomDecorService { .filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id))) .map((row) => this.toPayload(row)), definitions: ROOM_DECOR_DEFINITIONS, + revision: snapshot.revision, }; } - async resetPlacements(userId: bigint) { - const inventory = await this.inventoryService.listInventory(userId, 'room_decor'); - 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, - }); - })); + async resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto) { + await this.roomDecorPlacementsService.resetPlacements(userId, mutation); return this.getInventory(userId); } @@ -150,6 +149,7 @@ export class RoomDecorService { scale: placement.scale, rotation_degrees: placement.rotation_degrees, z_index: row.z_index ?? definition?.default_z_index ?? 0, + mutation_revision: row.mutation_revision ?? 0, category: definition?.category ?? 'floor', placement_surface: definition?.placement_surface ?? 'floor', placement_bounds: definition?.placement_bounds, diff --git a/src/business/tasks/dto/report_task_activity.dto.ts b/src/business/tasks/dto/report_task_activity.dto.ts index 18bb077..32db413 100644 --- a/src/business/tasks/dto/report_task_activity.dto.ts +++ b/src/business/tasks/dto/report_task_activity.dto.ts @@ -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'; export class ReportTaskActivityDto { @@ -9,4 +9,13 @@ export class ReportTaskActivityDto { @IsString() @MaxLength(64) target_id?: string; + + @IsString() + @MaxLength(64) + @Matches(/^[A-Za-z0-9_-]{16,64}$/) + nonce: string; + + @IsOptional() + @IsISO8601() + occurred_at?: string; } diff --git a/src/business/tasks/migrations/create-task-activity-audit.sql b/src/business/tasks/migrations/create-task-activity-audit.sql new file mode 100644 index 0000000..7d3043d --- /dev/null +++ b/src/business/tasks/migrations/create-task-activity-audit.sql @@ -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='任务活动校验审计'; diff --git a/src/business/tasks/task.service.ts b/src/business/tasks/task.service.ts index 5463246..589bc3e 100644 --- a/src/business/tasks/task.service.ts +++ b/src/business/tasks/task.service.ts @@ -1,4 +1,6 @@ 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 { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types'; @@ -12,17 +14,25 @@ const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [ @Injectable() 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 { return await this.taskProgressStore.getBoard(userId); } - async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise { - if (!CLIENT_ACTIVITY_TYPES.includes(activity)) { + async recordClientActivity( + userId: bigint, + dto: ReportTaskActivityDto, + context: { clientIp?: string; userAgent?: string } = {}, + ): Promise { + if (!CLIENT_ACTIVITY_TYPES.includes(dto.activity)) { 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 { diff --git a/src/business/tasks/task_activity_audit.entity.ts b/src/business/tasks/task_activity_audit.entity.ts new file mode 100644 index 0000000..9eba9b8 --- /dev/null +++ b/src/business/tasks/task_activity_audit.entity.ts @@ -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; +} diff --git a/src/business/tasks/task_activity_audit.store.ts b/src/business/tasks/task_activity_audit.store.ts new file mode 100644 index 0000000..ccd28b7 --- /dev/null +++ b/src/business/tasks/task_activity_audit.store.ts @@ -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; +} + +export const TASK_ACTIVITY_AUDIT_STORE = 'TASK_ACTIVITY_AUDIT_STORE'; diff --git a/src/business/tasks/task_activity_audit_database.service.ts b/src/business/tasks/task_activity_audit_database.service.ts new file mode 100644 index 0000000..9ee45f9 --- /dev/null +++ b/src/business/tasks/task_activity_audit_database.service.ts @@ -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, + ) {} + + async append(record: TaskActivityAuditRecord): Promise { + await this.repository.insert(record); + } +} diff --git a/src/business/tasks/task_activity_audit_memory.service.ts b/src/business/tasks/task_activity_audit_memory.service.ts new file mode 100644 index 0000000..3b2afa9 --- /dev/null +++ b/src/business/tasks/task_activity_audit_memory.service.ts @@ -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 = []; + private static readonly MAX_RECORDS = 5000; + + async append(record: TaskActivityAuditRecord): Promise { + 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); + } + } +} diff --git a/src/business/tasks/task_activity_authority.service.ts b/src/business/tasks/task_activity_authority.service.ts new file mode 100644 index 0000000..3c5a4ae --- /dev/null +++ b/src/business/tasks/task_activity_authority.service.ts @@ -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([ + 'map_visited', + 'notice_viewed', + 'course_board_opened', + 'facility_interacted', +]); +const MAP_TASK_TARGETS: Record = { + whale_port: 'square', + work_zone: 'work_zone', + whale_cafe: 'whale_cafe', +}; +const FACILITY_POINTS: Record = { + 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 { + 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 { + 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 { + 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)); + } + } +} diff --git a/src/business/tasks/tasks.controller.ts b/src/business/tasks/tasks.controller.ts index 8656454..f62745d 100644 --- a/src/business/tasks/tasks.controller.ts +++ b/src/business/tasks/tasks.controller.ts @@ -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 { Response } from 'express'; +import { Request, Response } from 'express'; import { CurrentUser } from '../../gateway/auth/current_user.decorator'; import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; import { JwtPayload } from '../../core/login_core/login_core.service'; @@ -29,9 +29,13 @@ export class TasksController { async reportActivity( @CurrentUser() user: JwtPayload, @Body() dto: ReportTaskActivityDto, + @Req() req: Request, @Res() res: Response, ): Promise { - 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: '任务进度已更新' }); } diff --git a/src/business/tasks/tasks.module.ts b/src/business/tasks/tasks.module.ts index b4da581..1022b35 100644 --- a/src/business/tasks/tasks.module.ts +++ b/src/business/tasks/tasks.module.ts @@ -1,7 +1,14 @@ import { DynamicModule, Global, Module } from '@nestjs/common'; 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 { RedisModule } from '../../core/redis/redis.module'; 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 { TaskProgressMemoryService } from './task_progress_memory.service'; import { TaskService } from './task.service'; @@ -14,11 +21,14 @@ export class TasksModule { return { module: TasksModule, global: true, - imports: [LoginCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress])], + imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress, TaskActivityAudit])], controllers: [TasksController], providers: [ TaskProgressDatabaseService, { provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService }, + TaskActivityAuditDatabaseService, + { provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditDatabaseService }, + TaskActivityAuthorityService, TaskService, ], exports: [TaskService, 'ITaskProgressStore'], @@ -29,11 +39,14 @@ export class TasksModule { return { module: TasksModule, global: true, - imports: [LoginCoreModule], + imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule], controllers: [TasksController], providers: [ TaskProgressMemoryService, { provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService }, + TaskActivityAuditMemoryService, + { provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditMemoryService }, + TaskActivityAuthorityService, TaskService, ], exports: [TaskService, 'ITaskProgressStore'], diff --git a/src/core/db/player_assets/create-player-assets-tables.sql b/src/core/db/player_assets/create-player-assets-tables.sql index 5bd31bf..bfd6d61 100644 --- a/src/core/db/player_assets/create-player-assets-tables.sql +++ b/src/core/db/player_assets/create-player-assets-tables.sql @@ -21,9 +21,19 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` ( `scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放', `rotation_degrees` FLOAT 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 '创建时间', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`), KEY `idx_room_decor_placements_user_id` (`user_id`) ) 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; diff --git a/src/core/db/player_assets/migrations/add-room-decor-layout-revision.sql b/src/core/db/player_assets/migrations/add-room-decor-layout-revision.sql new file mode 100644 index 0000000..9dfc5a1 --- /dev/null +++ b/src/core/db/player_assets/migrations/add-room-decor-layout-revision.sql @@ -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`; diff --git a/src/core/db/player_assets/player_assets.module.ts b/src/core/db/player_assets/player_assets.module.ts index 8f131d5..18b0bb7 100644 --- a/src/core/db/player_assets/player_assets.module.ts +++ b/src/core/db/player_assets/player_assets.module.ts @@ -4,6 +4,7 @@ import { PlayerAssets } from './player_assets.entity'; import { PlayerAssetsMemoryService } from './player_assets_memory.service'; import { PlayerAssetsService } from './player_assets.service'; import { RoomDecorPlacements } from './room_decor_placements.entity'; +import { RoomDecorLayoutState } from './room_decor_layout_state.entity'; import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service'; import { RoomDecorPlacementsService } from './room_decor_placements.service'; @@ -13,12 +14,12 @@ export class PlayerAssetsModule { static forDatabase(): DynamicModule { return { module: PlayerAssetsModule, - imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements])], + imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements, RoomDecorLayoutState])], providers: [ PlayerAssetsService, RoomDecorPlacementsService, { provide: 'IPlayerAssetsService', useClass: PlayerAssetsService }, - { provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsService }, + { provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsService }, ], exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'], }; @@ -31,7 +32,7 @@ export class PlayerAssetsModule { PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, { provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService }, - { provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsMemoryService }, + { provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsMemoryService }, ], exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'], }; diff --git a/src/core/db/player_assets/room_decor_layout_state.entity.ts b/src/core/db/player_assets/room_decor_layout_state.entity.ts new file mode 100644 index 0000000..b3b14c8 --- /dev/null +++ b/src/core/db/player_assets/room_decor_layout_state.entity.ts @@ -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; +} diff --git a/src/core/db/player_assets/room_decor_placements.entity.ts b/src/core/db/player_assets/room_decor_placements.entity.ts index 8102964..15a8d8c 100644 --- a/src/core/db/player_assets/room_decor_placements.entity.ts +++ b/src/core/db/player_assets/room_decor_placements.entity.ts @@ -31,6 +31,9 @@ export class RoomDecorPlacements { @Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' }) z_index: number; + @Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '最后修改该摆件的布局版本' }) + mutation_revision: number; + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' }) created_at: Date; diff --git a/src/core/db/player_assets/room_decor_placements.service.ts b/src/core/db/player_assets/room_decor_placements.service.ts index e69407f..b64fd4a 100644 --- a/src/core/db/player_assets/room_decor_placements.service.ts +++ b/src/core/db/player_assets/room_decor_placements.service.ts @@ -1,42 +1,149 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; 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'; @Injectable() export class RoomDecorPlacementsService { - constructor( - @InjectRepository(RoomDecorPlacements) - private readonly placementsRepository: Repository, - ) {} + constructor(private readonly dataSource: DataSource) {} async listPlacements(userId: bigint): Promise { - return await this.placementsRepository.find({ + return await this.dataSource.getRepository(RoomDecorPlacements).find({ where: { user_id: userId }, order: { created_at: 'ASC', id: 'ASC' }, }); } - async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise { - const decorId = this.normalizeDecorId(placement.decor_id); - let row = await this.placementsRepository.findOne({ - where: { user_id: userId, decor_id: decorId }, + async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> { + return await this.dataSource.transaction('REPEATABLE READ', async (manager) => { + await this.ensureLayoutState(manager, userId); + 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 }; }); - if (!row) { - row = new RoomDecorPlacements(); - row.user_id = userId; - row.decor_id = decorId; - row.created_at = new Date(); + } + + 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) { + row = new RoomDecorPlacements(); + row.user_id = userId; + row.decor_id = decorId; + row.created_at = new Date(); + } + row.placed = placement.placed; + row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; + row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; + row.scale = Number(placement.scale ?? row.scale ?? 1); + row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0); + row.z_index = Number(placement.z_index ?? row.z_index ?? 0); + row.mutation_revision = placement.mutation_revision; + row.updated_at = new Date(); + 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 { + 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 { + 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, + 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, + ): void { + if (mutation.layout_revision !== state.revision || mutation.mutation_revision !== state.revision + 1) { + throw new ConflictException({ + message: '装修布局版本已过期,请刷新后重试', + current_revision: state.revision, + }); } - row.placed = placement.placed; - row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; - row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; - row.scale = Number(placement.scale ?? row.scale ?? 1); - row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0); - row.z_index = Number(placement.z_index ?? row.z_index ?? 0); - row.updated_at = new Date(); - return await this.placementsRepository.save(row); + } + + private async advanceLayoutState( + manager: EntityManager, + state: RoomDecorLayoutState, + revision: number, + mutationId: string, + mutationScope: string, + ): Promise { + 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 { diff --git a/src/core/db/player_assets/room_decor_placements_memory.service.ts b/src/core/db/player_assets/room_decor_placements_memory.service.ts index a73a302..0c8806e 100644 --- a/src/core/db/player_assets/room_decor_placements_memory.service.ts +++ b/src/core/db/player_assets/room_decor_placements_memory.service.ts @@ -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 { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto'; import { RoomDecorPlacements } from './room_decor_placements.entity'; @Injectable() @@ -7,6 +8,11 @@ export class RoomDecorPlacementsMemoryService { private placements: Map = new Map(); private userDecorIndex: Map = new Map(); private currentId: bigint = BigInt(1); + private layoutStates: Map = new Map(); async listPlacements(userId: bigint): Promise { return Array.from(this.placements.values()) @@ -17,11 +23,28 @@ export class RoomDecorPlacementsMemoryService { }); } - async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise { + 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 mutationScope = `decor:${decorId}`; const key = this.indexKey(userId, decorId); const existingId = this.userDecorIndex.get(key); 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) { row.id = this.currentId++; row.user_id = userId; @@ -36,8 +59,62 @@ export class RoomDecorPlacementsMemoryService { row.scale = Number(placement.scale ?? row.scale ?? 1); row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0); row.z_index = Number(placement.z_index ?? row.z_index ?? 0); + row.mutation_revision = placement.mutation_revision; 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 { diff --git a/src/core/redis/file_redis.service.ts b/src/core/redis/file_redis.service.ts index 7d158a8..ec42acf 100644 --- a/src/core/redis/file_redis.service.ts +++ b/src/core/redis/file_redis.service.ts @@ -265,6 +265,22 @@ export class FileRedisService implements IRedisService, OnModuleDestroy { this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`); } + async setIfAbsent(key: string, value: string, ttl?: number): Promise { + 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; + } + /** * 获取键对应的值 * diff --git a/src/core/redis/real_redis.service.ts b/src/core/redis/real_redis.service.ts index 6090965..cc97d9e 100644 --- a/src/core/redis/real_redis.service.ts +++ b/src/core/redis/real_redis.service.ts @@ -134,6 +134,18 @@ export class RealRedisService implements IRedisService, OnModuleDestroy { } } + async setIfAbsent(key: string, value: string, ttl?: number): Promise { + 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; + } + } + /** * 获取键对应的值 * diff --git a/src/core/redis/redis.interface.ts b/src/core/redis/redis.interface.ts index 7124fbc..2ce378e 100644 --- a/src/core/redis/redis.interface.ts +++ b/src/core/redis/redis.interface.ts @@ -45,6 +45,13 @@ export interface IRedisService { */ set(key: string, value: string, ttl?: number): Promise; + /** + * 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。 + * + * @returns 成功占位返回 true,键已存在返回 false + */ + setIfAbsent(key: string, value: string, ttl?: number): Promise; + /** * 设置键值对并指定过期时间 *