refactor: harden task authority and room mutations

This commit is contained in:
ANG-Server
2026-07-23 00:59:00 +08:00
parent 3f14230e15
commit 968a672fbd
24 changed files with 748 additions and 64 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<void> {
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<void> {
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub), dto);
res.status(HttpStatus.OK).json({
success: true,
data,

View File

@@ -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<UserRoomDecorRow[]>;
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<UserRoomDecorRow>;
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,

View File

@@ -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;
}

View 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='任务活动校验审计';

View File

@@ -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<TaskBoardPayload> {
return await this.taskProgressStore.getBoard(userId);
}
async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
if (!CLIENT_ACTIVITY_TYPES.includes(activity)) {
async recordClientActivity(
userId: bigint,
dto: ReportTaskActivityDto,
context: { clientIp?: string; userAgent?: string } = {},
): Promise<TaskBoardPayload> {
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<TaskBoardPayload> {

View 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;
}

View 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';

View 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);
}
}

View 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);
}
}
}

View 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));
}
}
}

View File

@@ -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<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: '任务进度已更新' });
}

View File

@@ -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'],

View File

@@ -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;

View File

@@ -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`;

View File

@@ -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'],
};

View 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;
}

View File

@@ -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;

View File

@@ -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<RoomDecorPlacements>,
) {}
constructor(private readonly dataSource: DataSource) {}
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
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<RoomDecorPlacements> {
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<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,
});
}
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<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 {

View File

@@ -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<bigint, RoomDecorPlacements> = new Map();
private userDecorIndex: Map<string, bigint> = new Map();
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[]> {
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 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 {

View File

@@ -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<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;
}
/**
* 获取键对应的值
*

View File

@@ -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;
}
}
/**
* 获取键对应的值
*

View File

@@ -45,6 +45,13 @@ export interface IRedisService {
*/
set(key: string, value: string, ttl?: number): Promise<void>;
/**
* 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。
*
* @returns 成功占位返回 true键已存在返回 false
*/
setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean>;
/**
* 设置键值对并指定过期时间
*