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: '层级超出范围' })
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 { 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> {
|
||||
|
||||
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 { 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: '任务进度已更新' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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'],
|
||||
};
|
||||
|
||||
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: '摆放层级' })
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键对应的值
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
* 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。
|
||||
*
|
||||
* @returns 成功占位返回 true,键已存在返回 false
|
||||
*/
|
||||
setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 设置键值对并指定过期时间
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user