From a3341b6a3b62aadb0b835300ad0b68f4935ed4af Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:16:31 +0800 Subject: [PATCH] feat: add in-game social test lab --- .env.example | 5 + .env.production.example | 1 + src/business/admin/admin.module.ts | 19 +- src/business/admin/test_lab.controller.ts | 67 +++++ src/business/admin/test_lab.dto.ts | 92 ++++++ src/business/admin/test_lab.guard.ts | 73 +++++ src/business/admin/test_lab.service.ts | 281 ++++++++++++++++++ .../admin/test_lab_presence.registry.ts | 46 +++ src/business/social/social.database-store.ts | 13 + src/business/social/social.memory-store.ts | 11 + src/business/social/social.service.ts | 72 ++++- src/business/social/social.store.ts | 1 + src/core/admin_core/admin_core.service.ts | 45 +++ .../users/migrations/add-is-test-account.sql | 18 ++ src/core/db/users/users.dto.ts | 9 +- src/core/db/users/users.entity.ts | 11 + src/core/db/users/users.service.ts | 3 +- src/core/db/users/users_memory.service.ts | 1 + src/gateway/chat/chat.gateway.ts | 73 ++++- 19 files changed, 827 insertions(+), 14 deletions(-) create mode 100644 src/business/admin/test_lab.controller.ts create mode 100644 src/business/admin/test_lab.dto.ts create mode 100644 src/business/admin/test_lab.guard.ts create mode 100644 src/business/admin/test_lab.service.ts create mode 100644 src/business/admin/test_lab_presence.registry.ts create mode 100644 src/core/db/users/migrations/add-is-test-account.sql diff --git a/.env.example b/.env.example index b7c6a4f..e46b110 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ ADMIN_BOOTSTRAP_ENABLED=false ADMIN_USERNAME= ADMIN_PASSWORD= ADMIN_NICKNAME= +TEST_ADMIN_AUTO_PROVISION=true +TEST_ADMIN_USERNAME=admin +TEST_ADMIN_PASSWORD= +TEST_ADMIN_NICKNAME=测试管理员 +TEST_LAB_ENABLED=false # Local storage mode USE_MEMORY_STORAGE=true diff --git a/.env.production.example b/.env.production.example index ac006f1..5d77301 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,6 +14,7 @@ ADMIN_BOOTSTRAP_ENABLED=false ADMIN_USERNAME= ADMIN_PASSWORD= ADMIN_NICKNAME= +TEST_LAB_ENABLED=false # Persistent storage USE_MEMORY_STORAGE=false diff --git a/src/business/admin/admin.module.ts b/src/business/admin/admin.module.ts index c66e067..5704a2a 100644 --- a/src/business/admin/admin.module.ts +++ b/src/business/admin/admin.module.ts @@ -38,6 +38,12 @@ import { AdminOperationLogMemoryService } from './admin_operation_log_memory.ser import { AdminOperationLog } from './admin_operation_log.entity'; import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter'; import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor'; +import { TestLabController } from './test_lab.controller'; +import { TestLabService } from './test_lab.service'; +import { TestLabGuard } from './test_lab.guard'; +import { ChatGatewayModule } from '../../gateway/chat/chat.gateway.module'; +import { AuthModule } from '../auth/auth.module'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; /** * 检查数据库配置是否完整 @@ -55,6 +61,9 @@ function isDatabaseConfigured(): boolean { LoggerModule, UsersModule, SessionCoreModule, + AuthModule, + LoginCoreModule, + ChatGatewayModule, UserProfilesModule, // 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入 // 注册AdminOperationLog实体 @@ -63,7 +72,8 @@ function isDatabaseConfigured(): boolean { controllers: [ AdminController, AdminDatabaseController, - AdminOperationLogController + AdminOperationLogController, + TestLabController, ], providers: [ AdminService, @@ -75,12 +85,15 @@ function isDatabaseConfigured(): boolean { : AdminOperationLogMemoryService, }, AdminDatabaseExceptionFilter, - AdminOperationLogInterceptor + AdminOperationLogInterceptor, + TestLabService, + TestLabGuard, ], exports: [ AdminService, DatabaseManagementService, - AdminOperationLogService + AdminOperationLogService, + TestLabService, ], // 导出服务供其他模块使用 }) export class AdminModule {} diff --git a/src/business/admin/test_lab.controller.ts b/src/business/admin/test_lab.controller.ts new file mode 100644 index 0000000..83c8265 --- /dev/null +++ b/src/business/admin/test_lab.controller.ts @@ -0,0 +1,67 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards, ValidationPipe } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { TestLabService } from './test_lab.service'; +import { CreateTestLabActorDto, TestLabMessageDto, TestLabRoomPolicyDto, TestLabSocialActionDto, UpdateTestLabActorDto } from './test_lab.dto'; +import { Req } from '@nestjs/common'; +import { TestLabGuard, TestLabRequest } from './test_lab.guard'; + +@ApiTags('admin-test-lab') +@ApiBearerAuth('JWT-auth') +@UseGuards(TestLabGuard) +@Controller('admin/test-lab') +export class TestLabController { + constructor(private readonly testLab: TestLabService) {} + + @Get('status') + @ApiOperation({ summary: '获取测试实验室状态' }) + async status() { + return { success: true, data: await this.testLab.status() }; + } + + @Post('actors') + @ApiOperation({ summary: '创建并上线测试假人' }) + async create(@Body(new ValidationPipe({ transform: true })) dto: CreateTestLabActorDto, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.createActor(dto, this.admin(request)) }; + } + + @Patch('actors/:userId') + @ApiOperation({ summary: '更新测试假人的在线、位置或外观' }) + async update(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: UpdateTestLabActorDto, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.updateActor(userId, dto, this.admin(request)) }; + } + + @Patch('actors/:userId/room-policy') + @ApiOperation({ summary: '设置测试假人房间访问策略' }) + async roomPolicy(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabRoomPolicyDto, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.setRoomPolicy(userId, dto, this.admin(request)) }; + } + + @Post('actors/:userId/messages') + @ApiOperation({ summary: '以测试假人身份发送公共消息或私聊' }) + async message(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabMessageDto, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.sendMessage(userId, dto, this.admin(request)) }; + } + + @Post('actors/:userId/social') + @ApiOperation({ summary: '以测试假人身份执行好友或拉黑操作' }) + async social(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabSocialActionDto, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.socialAction(userId, dto, this.admin(request)) }; + } + + @Delete('actors/:userId') + @ApiOperation({ summary: '删除单个测试假人及其测试数据' }) + async remove(@Param('userId') userId: string, @Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.removeActor(userId, this.admin(request)) }; + } + + @Delete('actors') + @ApiOperation({ summary: '清空全部测试假人及关联测试数据' }) + async clear(@Req() request: TestLabRequest) { + return { success: true, data: await this.testLab.clear(this.admin(request)) }; + } + + private admin(request: TestLabRequest) { + if (!request.admin) throw new Error('管理员身份缺失'); + return request.admin; + } +} diff --git a/src/business/admin/test_lab.dto.ts b/src/business/admin/test_lab.dto.ts new file mode 100644 index 0000000..ed985b0 --- /dev/null +++ b/src/business/admin/test_lab.dto.ts @@ -0,0 +1,92 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, Length, Matches, Max, Min, ValidateIf } from 'class-validator'; +import { MALL_SKIN_ITEMS } from '../mall/mall_catalog'; + +export const TEST_LAB_MAP_IDS = ['whale_port', 'work_zone', 'whale_cafe'] as const; +export type TestLabMapId = (typeof TEST_LAB_MAP_IDS)[number]; +export const TEST_LAB_SKIN_IDS = MALL_SKIN_ITEMS.map((item) => item.skinId).filter((skinId): skinId is string => Boolean(skinId)); +const TEST_LAB_NICKNAME_PATTERN = /^[\u4E00-\u9FFF A-Za-z0-9_-]+$/; + +export class CreateTestLabActorDto { + @IsString() + @IsNotEmpty() + @Length(1, 32) + @Matches(TEST_LAB_NICKNAME_PATTERN, { message: '昵称仅支持中文、字母、数字、空格、下划线和连字符' }) + nickname: string; + + @IsIn(TEST_LAB_MAP_IDS) + mapId: TestLabMapId; + + @Type(() => Number) + @IsNumber() + @Min(-8192) + @Max(8192) + x: number; + + @Type(() => Number) + @IsNumber() + @Min(-8192) + @Max(8192) + y: number; + + @IsOptional() + @IsIn(TEST_LAB_SKIN_IDS) + skinId?: string; +} + +export class UpdateTestLabActorDto { + @IsOptional() + @IsBoolean() + online?: boolean; + + @ValidateIf((value) => value.mapId !== undefined) + @IsIn(TEST_LAB_MAP_IDS) + mapId?: TestLabMapId; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-8192) + @Max(8192) + x?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-8192) + @Max(8192) + y?: number; + + @IsOptional() + @IsIn(TEST_LAB_SKIN_IDS) + skinId?: string; +} + +export class TestLabMessageDto { + @IsIn(['local', 'global', 'private']) + scope: 'local' | 'global' | 'private'; + + @IsString() + @IsNotEmpty() + @Length(1, 1000) + content: string; + + @ValidateIf((value) => value.scope === 'private') + @IsString() + @IsNotEmpty() + targetUserId?: string; +} + +export class TestLabSocialActionDto { + @IsIn(['friend_request', 'friend_accept', 'friend_reject', 'block']) + action: 'friend_request' | 'friend_accept' | 'friend_reject' | 'block'; + + @IsString() + @IsNotEmpty() + targetUserId: string; +} + +export class TestLabRoomPolicyDto { + @IsIn(['friends', 'public', 'closed']) + roomVisitPolicy: 'friends' | 'public' | 'closed'; +} diff --git a/src/business/admin/test_lab.guard.ts b/src/business/admin/test_lab.guard.ts new file mode 100644 index 0000000..fcce381 --- /dev/null +++ b/src/business/admin/test_lab.guard.ts @@ -0,0 +1,73 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; +import { AdminAuthPayload, AdminCoreService } from '../../core/admin_core/admin_core.service'; +import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service'; +import { Users } from '../../core/db/users/users.entity'; + +export interface TestLabRequest extends Request { + admin?: AdminAuthPayload; + user?: JwtPayload; +} + +type UsersLookup = { + findOne(id: bigint): Promise; +}; + +/** + * 测试实验室既可由旧管理端 token 调用,也可由游戏内管理员的登录 token 调用。 + * 两条路径最终都会回查当前用户 role,避免客户端角色字段或旧 token 被单独信任。 + */ +@Injectable() +export class TestLabGuard implements CanActivate { + constructor( + private readonly adminCore: AdminCoreService, + private readonly loginCore: LoginCoreService, + @Inject('UsersService') private readonly users: UsersLookup, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + try { + const admin = this.adminCore.verifyToken(token); + await this.assertCurrentAdministrator(admin.adminId); + request.admin = admin; + return true; + } catch (_adminTokenError) { + // 继续尝试游戏登录 token;两类 token 使用不同签名,不会相互放行。 + } + + try { + const user = await this.loginCore.verifyToken(token, 'access'); + if (user.role !== 9) throw new ForbiddenException('仅管理员可使用测试实验室'); + await this.assertCurrentAdministrator(user.sub); + request.user = user; + request.admin = { + adminId: user.sub, + username: user.username, + role: 9, + iat: user.iat || Math.floor(Date.now() / 1000), + exp: user.exp || Math.floor(Date.now() / 1000), + }; + return true; + } catch (error) { + if (error instanceof ForbiddenException) throw error; + throw new UnauthorizedException('需要有效的管理员登录凭据'); + } + } + + private extractBearerToken(request: Request): string { + const authorization = request.headers.authorization; + if (!authorization || Array.isArray(authorization)) throw new UnauthorizedException('缺少Authorization头'); + const [scheme, token] = authorization.split(' '); + if (scheme !== 'Bearer' || !token) throw new UnauthorizedException('Authorization格式错误'); + return token; + } + + private async assertCurrentAdministrator(userId: string): Promise { + if (!/^\d+$/.test(userId)) throw new UnauthorizedException('管理员身份无效'); + const user = await this.users.findOne(BigInt(userId)); + if (user.role !== 9) throw new ForbiddenException('仅管理员可使用测试实验室'); + } +} diff --git a/src/business/admin/test_lab.service.ts b/src/business/admin/test_lab.service.ts new file mode 100644 index 0000000..d66f97e --- /dev/null +++ b/src/business/admin/test_lab.service.ts @@ -0,0 +1,281 @@ +import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { randomUUID } from 'crypto'; +import { AccountProfileService } from '../auth/account_profile.service'; +import { SocialService } from '../social/social.service'; +import { ChatWebSocketGateway } from '../../gateway/chat/chat.gateway'; +import { Users } from '../../core/db/users/users.entity'; +import { AdminOperationLogService } from './admin_operation_log.service'; +import { + clearTestLabPresences, + getAllTestLabPresences, + getTestLabPresence, + TestLabPresence, +} from './test_lab_presence.registry'; +import { CreateTestLabActorDto, TestLabMessageDto, TestLabRoomPolicyDto, TestLabSocialActionDto, UpdateTestLabActorDto } from './test_lab.dto'; + +type UserStore = { + create(input: Partial & { username: string; nickname: string }): Promise; + findOne(id: bigint): Promise; + findAll(limit: number, offset: number): Promise; + remove(id: bigint): Promise; +}; + +type AdminActor = { adminId: string; username: string }; + +const MAX_TEST_ACTORS = 20; + +@Injectable() +export class TestLabService implements OnModuleInit { + private readonly logger = new Logger(TestLabService.name); + + constructor( + private readonly config: ConfigService, + @Inject('UsersService') private readonly users: UserStore, + private readonly accountProfiles: AccountProfileService, + private readonly social: SocialService, + private readonly gateway: ChatWebSocketGateway, + private readonly audit: AdminOperationLogService, + @Optional() private readonly dataSource?: DataSource, + ) {} + + async onModuleInit(): Promise { + if (this.isTestEnvironment()) { + await this.clearTestAccounts('startup'); + } + } + + isEnabled(): boolean { + return this.isTestEnvironment() && this.config.get('TEST_LAB_ENABLED', 'false') === 'true'; + } + + async status() { + this.assertEnabled(); + const actors = await this.listActorsInternal(); + return { + enabled: this.isEnabled(), + environment: this.config.get('NODE_ENV', 'development'), + maxActors: MAX_TEST_ACTORS, + actors, + onlinePlayers: this.gateway.getOnlineWorldPlayers(), + }; + } + + async createActor(dto: CreateTestLabActorDto, admin: AdminActor) { + this.assertEnabled(); + const actors = await this.listActorsInternal(); + if (actors.length >= MAX_TEST_ACTORS) throw new BadRequestException(`最多创建 ${MAX_TEST_ACTORS} 个测试假人`); + + const nickname = dto.nickname.trim(); + const user = await this.users.create({ + username: `test_actor_${randomUUID().replace(/-/g, '').slice(0, 18)}`, + nickname, + role: 1, + email_verified: true, + is_test_account: true, + }); + await this.accountProfiles.ensureProfile(user.id, dto.skinId || 'classic_whale'); + await this.accountProfiles.updateAccountProfile(user.id, { settings: { room_visit_policy: 'public' } }); + + const presence: TestLabPresence = { + userId: user.id.toString(), + username: user.username, + nickname: user.nickname, + mapId: dto.mapId, + x: dto.x, + y: dto.y, + skinId: dto.skinId || 'classic_whale', + avatarId: 'default', + online: true, + }; + this.gateway.setTestLabPresence(presence); + await this.record(admin, 'CREATE', 'test_lab_actor', user.id.toString(), '创建测试假人'); + return await this.serializeActor(user, presence); + } + + async updateActor(userId: string, dto: UpdateTestLabActorDto, admin: AdminActor) { + this.assertEnabled(); + const user = await this.requireActor(userId); + const current = getTestLabPresence(user.id) || this.offlinePresence(user); + const next: TestLabPresence = { + ...current, + online: dto.online ?? current.online, + mapId: dto.mapId ?? current.mapId, + x: dto.x ?? current.x, + y: dto.y ?? current.y, + skinId: dto.skinId ?? current.skinId, + }; + this.gateway.setTestLabPresence(next); + await this.record(admin, 'UPDATE', 'test_lab_actor', userId, '更新测试假人状态'); + return await this.serializeActor(user, next); + } + + async setRoomPolicy(userId: string, dto: TestLabRoomPolicyDto, admin: AdminActor) { + this.assertEnabled(); + const user = await this.requireActor(userId); + await this.accountProfiles.updateAccountProfile(user.id, { settings: { room_visit_policy: dto.roomVisitPolicy } }); + await this.record(admin, 'UPDATE', 'test_lab_actor', userId, '更新测试假人房间访问策略'); + return await this.serializeActor(user, getTestLabPresence(user.id) || this.offlinePresence(user)); + } + + async sendMessage(userId: string, dto: TestLabMessageDto, admin: AdminActor) { + this.assertEnabled(); + const actor = await this.requireOnlineActor(userId); + const content = dto.content.trim(); + if (dto.scope === 'private') { + const targetId = this.requireOnlineTarget(dto.targetUserId || ''); + await this.social.sendTestDirectMessage(actor.id, BigInt(targetId), content); + } else { + this.gateway.broadcastTestLabChat(getTestLabPresence(actor.id) as TestLabPresence, content, dto.scope); + } + await this.record(admin, 'CREATE', 'test_lab_message', userId, `测试假人发送${dto.scope === 'private' ? '私聊' : '公共消息'}`); + return { success: true }; + } + + async socialAction(userId: string, dto: TestLabSocialActionDto, admin: AdminActor) { + this.assertEnabled(); + const actor = await this.requireOnlineActor(userId); + const targetId = this.requireOnlineTarget(dto.targetUserId); + const target = BigInt(targetId); + let result: unknown; + if (dto.action === 'friend_request') result = await this.social.createTestFriendRequest(actor.id, target); + else if (dto.action === 'friend_accept') result = await this.social.respondToTestFriendRequest(actor.id, target, true); + else if (dto.action === 'friend_reject') result = await this.social.respondToTestFriendRequest(actor.id, target, false); + else result = await this.social.blockUser(actor.id, target); + await this.record(admin, 'UPDATE', 'test_lab_social', userId, `测试假人执行${dto.action}`); + return result; + } + + async removeActor(userId: string, admin: AdminActor) { + this.assertEnabled(); + const user = await this.requireActor(userId); + this.gateway.removeTestLabActor(user.id.toString()); + await this.removeAccounts([user]); + await this.record(admin, 'DELETE', 'test_lab_actor', userId, '删除测试假人'); + return { success: true }; + } + + async clear(admin: AdminActor) { + this.assertEnabled(); + const count = await this.clearTestAccounts('manual'); + await this.record(admin, 'DELETE', 'test_lab_actor', undefined, `清空 ${count} 个测试假人`); + return { success: true, count }; + } + + private isTestEnvironment(): boolean { + const environment = this.config.get('NODE_ENV', 'development'); + return environment === 'development' || environment === 'test'; + } + + private assertEnabled(): void { + if (!this.isEnabled()) throw new ForbiddenException('测试实验室仅在已启用的开发/测试环境可用'); + } + + private async listActorsInternal() { + const users = await this.users.findAll(MAX_TEST_ACTORS + 20, 0); + return Promise.all(users + .filter((user) => user.is_test_account === true) + .map((user) => this.serializeActor(user, getTestLabPresence(user.id) || this.offlinePresence(user)))); + } + + private async requireActor(userId: string): Promise { + if (!/^\d+$/.test(userId)) throw new BadRequestException('测试假人用户 ID 无效'); + const user = await this.users.findOne(BigInt(userId)); + if (user.is_test_account !== true) throw new ForbiddenException('目标不是测试实验室账号'); + return user; + } + + private async requireOnlineActor(userId: string): Promise { + const actor = await this.requireActor(userId); + if (!getTestLabPresence(actor.id)?.online) throw new BadRequestException('测试假人当前不在线'); + return actor; + } + + private requireOnlineTarget(userId: string): string { + if (!/^\d+$/.test(userId)) throw new BadRequestException('目标用户 ID 无效'); + if (!this.gateway.getOnlineWorldPlayers().some((player) => player.userId === userId)) { + throw new BadRequestException('目标玩家当前不在线'); + } + return userId; + } + + private offlinePresence(user: Users): TestLabPresence { + return { + userId: user.id.toString(), username: user.username, nickname: user.nickname, + mapId: 'whale_port', x: 1280, y: 960, skinId: 'classic_whale', avatarId: 'default', online: false, + }; + } + + private async serializeActor(user: Users, presence: TestLabPresence) { + const account = await this.accountProfiles.getAccountProfile(user.id); + const configuredPolicy = account.profile.settings?.room_visit_policy; + const roomVisitPolicy = configuredPolicy === 'friends' || configuredPolicy === 'public' || configuredPolicy === 'closed' + ? configuredPolicy + : 'friends'; + return { + userId: user.id.toString(), username: user.username, nickname: user.nickname, + mapId: presence.mapId, x: presence.x, y: presence.y, + skinId: presence.skinId, avatarId: presence.avatarId, online: presence.online, roomVisitPolicy, + }; + } + + private async clearTestAccounts(reason: 'startup' | 'manual'): Promise { + const users = (await this.users.findAll(MAX_TEST_ACTORS + 20, 0)).filter((user) => user.is_test_account === true); + if (users.length === 0) { + if (reason === 'startup') clearTestLabPresences(); + return 0; + } + for (const presence of getAllTestLabPresences()) this.gateway.removeTestLabActor(presence.userId); + await this.removeAccounts(users); + this.logger.log(`测试实验室已清理 ${users.length} 个假人账号`, { reason }); + return users.length; + } + + private async removeAccounts(users: Users[]): Promise { + const ids = users.map((user) => user.id); + for (const user of users) this.gateway.removeTestLabActor(user.id.toString()); + await this.social.purgeTestUsers(ids); + await this.purgeDomainRows(ids); + for (const user of users) await this.users.remove(user.id); + } + + private async purgeDomainRows(ids: bigint[]): Promise { + if (!this.dataSource || ids.length === 0) return; + const values = ids.map((id) => id.toString()); + const placeholders = values.map(() => '?').join(', '); + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + for (const table of ['room_decor_placements', 'user_assets', 'wallet_transactions', 'user_wallets', 'player_task_progress', 'user_profiles']) { + try { + await runner.query(`DELETE FROM \`${table}\` WHERE \`user_id\` IN (${placeholders})`, values); + } catch (error) { + this.logger.debug(`测试实验室清理跳过不可用表 ${table}`, { error: error instanceof Error ? error.message : String(error) }); + } + } + } finally { + await runner.release(); + } + } + + private async record(admin: AdminActor, operation: 'CREATE' | 'UPDATE' | 'DELETE', targetType: string, targetId: string | undefined, description: string): Promise { + try { + await this.audit.createLog({ + adminUserId: admin.adminId, + adminUsername: admin.username, + operationType: operation, + targetType, + targetId, + operationDescription: description, + httpMethodPath: '/admin/test-lab', + operationResult: 'SUCCESS', + durationMs: 0, + requestId: randomUUID(), + context: { testLab: true }, + }); + } catch (error) { + this.logger.warn('测试实验室操作日志写入失败', { error: error instanceof Error ? error.message : String(error) }); + } + } +} diff --git a/src/business/admin/test_lab_presence.registry.ts b/src/business/admin/test_lab_presence.registry.ts new file mode 100644 index 0000000..fec3719 --- /dev/null +++ b/src/business/admin/test_lab_presence.registry.ts @@ -0,0 +1,46 @@ +export type TestLabPresence = { + userId: string; + username: string; + nickname: string; + mapId: string; + x: number; + y: number; + skinId: string; + avatarId: string; + online: boolean; +}; + +const presences = new Map(); + +export function upsertTestLabPresence(presence: TestLabPresence): TestLabPresence { + const normalized = { ...presence, userId: String(presence.userId), online: Boolean(presence.online) }; + presences.set(normalized.userId, normalized); + return { ...normalized }; +} + +export function getTestLabPresence(userId: string | bigint): TestLabPresence | null { + const presence = presences.get(String(userId)); + return presence ? { ...presence } : null; +} + +export function getTestLabPresences(mapId?: string): TestLabPresence[] { + return [...presences.values()] + .filter((presence) => presence.online && (!mapId || presence.mapId === mapId)) + .map((presence) => ({ ...presence })); +} + +export function getAllTestLabPresences(): TestLabPresence[] { + return [...presences.values()].map((presence) => ({ ...presence })); +} + +export function removeTestLabPresence(userId: string | bigint): TestLabPresence | null { + const existing = presences.get(String(userId)); + presences.delete(String(userId)); + return existing ? { ...existing } : null; +} + +export function clearTestLabPresences(): TestLabPresence[] { + const existing = getTestLabPresences(); + presences.clear(); + return existing; +} diff --git a/src/business/social/social.database-store.ts b/src/business/social/social.database-store.ts index 65ef8c4..ab82898 100644 --- a/src/business/social/social.database-store.ts +++ b/src/business/social/social.database-store.ts @@ -58,5 +58,18 @@ export class SocialDatabaseStore implements SocialStore { async markNotificationRead(userId: bigint, id: bigint): Promise { const notification = await this.notifications.findOne({ where: { id, user_id: userId } }); if (!notification) return null; notification.read_at = new Date(); return this.notifications.save(notification); } async markAllNotificationsRead(userId: bigint): Promise { const result = await this.notifications.createQueryBuilder().update(SocialNotification).set({ read_at: new Date() }).where('user_id = :userId AND read_at IS NULL', { userId }).execute(); return result.affected || 0; } async countUnreadNotifications(userId: bigint): Promise { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); } + async purgeUsers(userIds: bigint[]): Promise { + if (userIds.length === 0) return; + const ids = userIds; + await Promise.all([ + this.friendships.createQueryBuilder().delete().where('user_low_id IN (:...ids) OR user_high_id IN (:...ids)', { ids }).execute(), + this.requests.createQueryBuilder().delete().where('requester_id IN (:...ids) OR recipient_id IN (:...ids)', { ids }).execute(), + this.blocks.createQueryBuilder().delete().where('user_id IN (:...ids) OR blocked_user_id IN (:...ids)', { ids }).execute(), + this.messages.createQueryBuilder().delete().where('sender_id IN (:...ids) OR recipient_id IN (:...ids)', { ids }).execute(), + this.reports.createQueryBuilder().delete().where('reporter_id IN (:...ids) OR reported_user_id IN (:...ids)', { ids }).execute(), + this.unlocks.createQueryBuilder().delete().where('user_id IN (:...ids)', { ids }).execute(), + this.notifications.createQueryBuilder().delete().where('user_id IN (:...ids)', { ids }).orWhere("JSON_CONTAINS(action_metadata, JSON_OBJECT('testLab', true))").execute(), + ]); + } async cleanupExpired(now: Date): Promise { await Promise.all([this.requests.delete({ status: FriendRequestStatus.PENDING, expires_at: LessThan(now) }), this.messages.delete({ expires_at: LessThan(now) }), this.notifications.delete({ expires_at: LessThan(now) })]); } } diff --git a/src/business/social/social.memory-store.ts b/src/business/social/social.memory-store.ts index 566e67d..aaa7b4f 100644 --- a/src/business/social/social.memory-store.ts +++ b/src/business/social/social.memory-store.ts @@ -74,5 +74,16 @@ export class SocialMemoryStore implements SocialStore { async markNotificationRead(userId: bigint, id: bigint): Promise { const item = this.notifications.find((entry) => entry.user_id === userId && entry.id === id); if (item) { item.read_at = new Date(); item.updated_at = new Date(); } return item || null; } async markAllNotificationsRead(userId: bigint): Promise { let affected = 0; for (const item of this.notifications) if (item.user_id === userId && !item.read_at) { item.read_at = new Date(); item.updated_at = new Date(); affected++; } return affected; } async countUnreadNotifications(userId: bigint): Promise { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; } + async purgeUsers(userIds: bigint[]): Promise { + const ids = new Set(userIds.map((id) => id.toString())); + const has = (id: bigint) => ids.has(id.toString()); + this.friendships = this.friendships.filter((item) => !has(item.user_low_id) && !has(item.user_high_id)); + this.requests = this.requests.filter((item) => !has(item.requester_id) && !has(item.recipient_id)); + this.blocks = this.blocks.filter((item) => !has(item.user_id) && !has(item.blocked_user_id)); + this.messages = this.messages.filter((item) => !has(item.sender_id) && !has(item.recipient_id)); + this.reports = this.reports.filter((item) => !has(item.reporter_id) && !has(item.reported_user_id)); + this.unlocks = this.unlocks.filter((item) => !has(item.user_id)); + this.notifications = this.notifications.filter((item) => !has(item.user_id) && !(item.action_metadata as Record | null)?.testLab); + } async cleanupExpired(now: Date): Promise { this.requests = this.requests.filter((item) => item.status !== FriendRequestStatus.PENDING || item.expires_at > now); this.messages = this.messages.filter((item) => item.expires_at > now); this.notifications = this.notifications.filter((item) => item.expires_at > now); } } diff --git a/src/business/social/social.service.ts b/src/business/social/social.service.ts index 55aac37..0b9331b 100644 --- a/src/business/social/social.service.ts +++ b/src/business/social/social.service.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { AccountProfileService } from '../auth/account_profile.service'; +import { getTestLabPresence } from '../admin/test_lab_presence.registry'; import { ChatSessionService } from '../chat/services/chat_session.service'; import { FriendRequestStatus } from './social.entities'; import { SOCIAL_STORE, SocialStore } from './social.store'; @@ -197,6 +198,29 @@ export class SocialService { return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at }; } + /** 仅供测试实验室调用:使用真实社交记录,但合成玩家无需真实 WebSocket 会话。 */ + async createTestFriendRequest(requesterId: bigint, targetId: bigint) { + this.assertDistinct(requesterId, targetId); + await this.assertNotBlockedEitherWay(requesterId, targetId); + if (await this.areFriends(requesterId, targetId)) throw new BadRequestException('已经是好友'); + if (await this.store.findPendingFriendRequest(requesterId, targetId)) throw new BadRequestException('好友请求已发送'); + + await this.usersService.findOne(targetId); + const request = await this.store.createFriendRequest(requesterId, targetId, new Date(Date.now() + FRIEND_REQUEST_MS)); + const requester = await this.buildProfile(targetId, requesterId, false); + await this.createNotification(targetId, 'friend_request', '新的好友申请', `${requester.nickname} 想与你成为好友`, { requestId: request.id.toString(), userId: requesterId.toString(), testLab: true }); + await this.sendToUser(targetId, { t: 'friend_request_received', request: { id: request.id.toString(), requester } }); + return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at }; + } + + async respondToTestFriendRequest(actorId: bigint, requesterId: bigint, accept: boolean) { + const request = await this.store.findPendingFriendRequest(requesterId, actorId); + if (!request) throw new NotFoundException('不存在待处理的好友申请'); + return accept + ? this.acceptFriendRequest(actorId, request.id) + : this.rejectFriendRequest(actorId, request.id); + } + async acceptFriendRequest(userId: bigint, requestId: bigint) { const request = await this.store.findFriendRequest(requestId); if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING || request.expires_at <= new Date()) throw new NotFoundException('好友请求不存在或已过期'); @@ -274,7 +298,10 @@ export class SocialService { if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符'); await this.assertNotBlockedEitherWay(senderId, targetId); const targetSocket = await this.sessions.getSocketIdByUserId(targetId.toString()); - if (!targetSocket) throw new BadRequestException('对方当前不在线'); + const targetTestPresence = getTestLabPresence(targetId.toString()); + // 测试实验室假人没有真实 WebSocket 连接,但其合成在线状态应与普通在线玩家 + // 一致地通过私聊在线校验。消息仍只写入现有的私聊存储,不触发外部副作用。 + if (!targetSocket && !targetTestPresence?.online) throw new BadRequestException('对方当前不在线'); if (!(await this.areFriends(senderId, targetId))) await this.assertNearbyAllowed(senderId, targetId, 'private'); const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) }); @@ -285,6 +312,20 @@ export class SocialService { return payload.message; } + /** 测试假人私聊:仍写入正式私聊存储,但不要求假人拥有真实 socket。 */ + async sendTestDirectMessage(senderId: bigint, targetId: bigint, content: string) { + this.assertDistinct(senderId, targetId); + const normalizedContent = content.trim(); + if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符'); + await this.assertNotBlockedEitherWay(senderId, targetId); + await this.usersService.findOne(targetId); + const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) }); + const sender = await this.buildProfile(targetId, senderId, false); + const payload = { t: 'dm_message', message: { id: message.id.toString(), senderId: senderId.toString(), recipientId: targetId.toString(), content: message.content, createdAt: message.created_at, sender } }; + await this.sendToUser(targetId, payload); + return payload.message; + } + async listConversation(userId: bigint, otherUserId: bigint, limit: number, before?: Date) { await this.assertNotBlockedEitherWay(userId, otherUserId); const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before); @@ -362,6 +403,10 @@ export class SocialService { await this.store.cleanupExpired(new Date()); } + async purgeTestUsers(userIds: bigint[]): Promise { + await this.store.purgeUsers(userIds); + } + private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) { const [user, profile, socketId] = await Promise.all([ this.usersService.findOne(targetId), @@ -370,14 +415,15 @@ export class SocialService { ]); const tags = this.profileTags(profile); const session = socketId ? await this.sessions.getSession(socketId) : null; + const testPresence = getTestLabPresence(targetId); return { id: targetId.toString(), username: user.username, nickname: user.nickname, avatarUrl: user.avatar_url || '', skinId: profile.skin_id || '', - online: Boolean(socketId), - currentArea: session?.currentMap || profile.current_map, + online: Boolean(socketId) || Boolean(testPresence?.online), + currentArea: testPresence?.mapId || session?.currentMap || profile.current_map, bio: String(profile.bio || '').slice(0, 160), interests: this.validInterests(tags.interests), privacy: includePrivate ? this.privacy(profile) : undefined, @@ -412,10 +458,22 @@ export class SocialService { private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') { const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString()); const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString()); - if (!targetSocketId || !sourceSocketId) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动'); - const [target, source, profile] = await Promise.all([this.sessions.getSession(targetSocketId), this.sessions.getSession(sourceSocketId), this.ensureProfile(targetId)]); - if (!target || !source || target.currentMap !== source.currentMap) throw new ForbiddenException('陌生玩家仅可在同一地图互动'); - const distance = Math.hypot(Number(target.position?.x || 0) - Number(source.position?.x || 0), Number(target.position?.y || 0) - Number(source.position?.y || 0)); + const targetTestPresence = getTestLabPresence(targetId); + const sourceTestPresence = getTestLabPresence(sourceId); + const [target, source, profile] = await Promise.all([ + targetSocketId ? this.sessions.getSession(targetSocketId) : Promise.resolve(null), + sourceSocketId ? this.sessions.getSession(sourceSocketId) : Promise.resolve(null), + this.ensureProfile(targetId), + ]); + const targetPosition = targetTestPresence?.online + ? { mapId: targetTestPresence.mapId, x: targetTestPresence.x, y: targetTestPresence.y } + : target ? { mapId: target.currentMap, x: Number(target.position?.x || 0), y: Number(target.position?.y || 0) } : null; + const sourcePosition = sourceTestPresence?.online + ? { mapId: sourceTestPresence.mapId, x: sourceTestPresence.x, y: sourceTestPresence.y } + : source ? { mapId: source.currentMap, x: Number(source.position?.x || 0), y: Number(source.position?.y || 0) } : null; + if (!targetPosition || !sourcePosition) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动'); + if (targetPosition.mapId !== sourcePosition.mapId) throw new ForbiddenException('陌生玩家仅可在同一地图互动'); + const distance = Math.hypot(targetPosition.x - sourcePosition.x, targetPosition.y - sourcePosition.y); if (distance > NEARBY_DISTANCE) throw new ForbiddenException('请靠近该玩家后再互动'); const privacy = this.privacy(profile); const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests'; diff --git a/src/business/social/social.store.ts b/src/business/social/social.store.ts index 60742bd..427d27e 100644 --- a/src/business/social/social.store.ts +++ b/src/business/social/social.store.ts @@ -38,5 +38,6 @@ export interface SocialStore { markNotificationRead(userId: bigint, id: bigint): Promise; markAllNotificationsRead(userId: bigint): Promise; countUnreadNotifications(userId: bigint): Promise; + purgeUsers(userIds: bigint[]): Promise; cleanupExpired(now: Date): Promise; } diff --git a/src/core/admin_core/admin_core.service.ts b/src/core/admin_core/admin_core.service.ts index 24fb1c1..749618e 100644 --- a/src/core/admin_core/admin_core.service.ts +++ b/src/core/admin_core/admin_core.service.ts @@ -100,6 +100,7 @@ export class AdminCoreService implements OnModuleInit { * ``` */ async onModuleInit(): Promise { + await this.ensureDevelopmentTestAdmin(); await this.bootstrapAdminIfEnabled(); } @@ -317,6 +318,50 @@ export class AdminCoreService implements OnModuleInit { this.logger.log(`管理员账号已创建:${username} (role=9)`); } + /** + * 开发/测试专用管理员。密码必须由本地或 CI 环境注入,不能作为生产回退值。 + */ + private async ensureDevelopmentTestAdmin(): Promise { + const environment = this.configService.get('NODE_ENV', 'development'); + if (environment !== 'development' && environment !== 'test') return; + if (this.configService.get('TEST_ADMIN_AUTO_PROVISION', 'true') !== 'true') return; + + const username = this.configService.get('TEST_ADMIN_USERNAME', 'admin')?.trim(); + const password = this.configService.get('TEST_ADMIN_PASSWORD') + || this.configService.get('ADMIN_PASSWORD'); + const nickname = this.configService.get('TEST_ADMIN_NICKNAME', '测试管理员'); + + if (!username || !password) { + this.logger.warn('测试管理员未创建:请在开发/测试环境配置 TEST_ADMIN_PASSWORD'); + return; + } + + const existing = await this.usersService.findByUsername(username); + if (existing) { + if (existing.role !== 9) { + this.logger.warn(`测试管理员用户名已被普通账号占用,拒绝提权:${username}`); + } + return; + } + + try { + this.validatePasswordStrength(password); + } catch (error) { + const reason = error instanceof Error ? error.message : '密码不满足强度要求'; + this.logger.warn(`测试管理员未创建:TEST_ADMIN_PASSWORD 配置无效(${reason})`); + return; + } + await this.usersService.create({ + username, + password_hash: await this.hashPassword(password), + nickname, + role: 9, + email_verified: true, + is_test_account: false, + }); + this.logger.log(`开发/测试管理员账号已创建:${username}`); + } + private getAdminTokenSecret(): string { const secret = this.configService.get('ADMIN_TOKEN_SECRET'); if (!secret || secret.length < 16) { diff --git a/src/core/db/users/migrations/add-is-test-account.sql b/src/core/db/users/migrations/add-is-test-account.sql new file mode 100644 index 0000000..3988520 --- /dev/null +++ b/src/core/db/users/migrations/add-is-test-account.sql @@ -0,0 +1,18 @@ +ALTER TABLE `users` + ADD COLUMN IF NOT EXISTS `is_test_account` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为开发测试实验室账号' AFTER `role`; + +SET @test_account_index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'users' + AND index_name = 'idx_users_is_test_account' +); +SET @test_account_index_sql := IF( + @test_account_index_exists = 0, + 'CREATE INDEX `idx_users_is_test_account` ON `users` (`is_test_account`)', + 'SELECT 1' +); +PREPARE test_account_index_stmt FROM @test_account_index_sql; +EXECUTE test_account_index_stmt; +DEALLOCATE PREPARE test_account_index_stmt; diff --git a/src/core/db/users/users.dto.ts b/src/core/db/users/users.dto.ts index 3077255..a77abbf 100644 --- a/src/core/db/users/users.dto.ts +++ b/src/core/db/users/users.dto.ts @@ -36,7 +36,8 @@ import { IsOptional, Length, IsNotEmpty, - IsEnum + IsEnum, + IsBoolean } from 'class-validator'; import { UserStatus } from './user_status.enum'; import { USER_ROLES, FIELD_LIMITS } from './users.constants'; @@ -91,6 +92,10 @@ export class CreateUserDto { @Length(1, FIELD_LIMITS.USERNAME_MAX_LENGTH, { message: `用户名长度需在1-${FIELD_LIMITS.USERNAME_MAX_LENGTH}字符之间` }) username: string; + @IsOptional() + @IsBoolean() + is_test_account?: boolean; + /** * 邮箱地址 * @@ -272,4 +277,4 @@ export class CreateUserDto { @IsOptional() @IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' }) status?: UserStatus = UserStatus.ACTIVE; -} \ No newline at end of file +} diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts index 9c49d85..e37df35 100644 --- a/src/core/db/users/users.entity.ts +++ b/src/core/db/users/users.entity.ts @@ -358,6 +358,17 @@ export class Users { }) role: number; + /** + * 仅供开发/测试实验室创建的合成玩家使用。生产业务不得据此授予权限。 + */ + @Column({ + type: 'boolean', + nullable: false, + default: false, + comment: '是否为测试实验室账号' + }) + is_test_account: boolean; + /** * 用户状态 * diff --git a/src/core/db/users/users.service.ts b/src/core/db/users/users.service.ts index b332329..6d6a655 100644 --- a/src/core/db/users/users.service.ts +++ b/src/core/db/users/users.service.ts @@ -157,6 +157,7 @@ export class UsersService extends BaseUsersService { user.github_id = createUserDto.github_id || null; user.avatar_url = createUserDto.avatar_url || null; user.role = createUserDto.role || USER_ROLES.NORMAL_USER; + user.is_test_account = createUserDto.is_test_account === true; user.email_verified = createUserDto.email_verified || false; user.status = createUserDto.status || UserStatus.ACTIVE; @@ -711,4 +712,4 @@ export class UsersService extends BaseUsersService { }); } } -} \ No newline at end of file +} diff --git a/src/core/db/users/users_memory.service.ts b/src/core/db/users/users_memory.service.ts index f5ac34d..5f58cfe 100644 --- a/src/core/db/users/users_memory.service.ts +++ b/src/core/db/users/users_memory.service.ts @@ -257,6 +257,7 @@ export class UsersMemoryService extends BaseUsersService { user.github_id = createUserDto.github_id || null; user.avatar_url = createUserDto.avatar_url || null; user.role = createUserDto.role || USER_ROLES.NORMAL_USER; + user.is_test_account = createUserDto.is_test_account === true; user.email_verified = createUserDto.email_verified || false; user.status = createUserDto.status || UserStatus.ACTIVE; user.created_at = new Date(); diff --git a/src/gateway/chat/chat.gateway.ts b/src/gateway/chat/chat.gateway.ts index 8c33273..4a0a65d 100644 --- a/src/gateway/chat/chat.gateway.ts +++ b/src/gateway/chat/chat.gateway.ts @@ -34,6 +34,13 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/commo import * as WebSocket from 'ws'; import { ChatService } from '../../business/chat/chat.service'; import { SocialService } from '../../business/social/social.service'; +import { + getTestLabPresence, + getTestLabPresences, + removeTestLabPresence, + TestLabPresence, + upsertTestLabPresence, +} from '../../business/admin/test_lab_presence.registry'; /** WebSocket 服务器默认端口 */ const DEFAULT_WEBSOCKET_PORT = 3001; @@ -911,6 +918,58 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha return players; } + /** 管理端测试实验室使用:返回真实在线玩家,绝不包含合成假人。 */ + public getOnlineWorldPlayers(): Array<{ userId: string; username: string; mapId: string }> { + return [...this.clients.values()] + .filter((client) => client.authenticated && client.worldReady && client.userId && client.currentMap) + .map((client) => ({ userId: String(client.userId), username: String(client.username || ''), mapId: String(client.currentMap) })); + } + + public setTestLabPresence(presence: TestLabPresence): void { + const previous = getTestLabPresence(presence.userId); + const current = upsertTestLabPresence(presence); + if (previous?.online && (!current.online || previous.mapId !== current.mapId)) { + this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId }); + } + if (!current.online) return; + const payload = { + t: previous?.online && previous.mapId === current.mapId ? 'position_update' : 'player_joined', + userId: current.userId, + username: current.nickname, + mapId: current.mapId, + x: current.x, + y: current.y, + skinId: current.skinId, + avatarId: current.avatarId, + appearance: { skinId: current.skinId, avatarId: current.avatarId }, + }; + this.broadcastToMap(current.mapId, payload); + } + + public removeTestLabActor(userId: string): void { + const previous = removeTestLabPresence(userId); + if (previous?.online) { + this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId }); + } + } + + public broadcastTestLabChat(actor: TestLabPresence, content: string, scope: 'local' | 'global' = 'local'): void { + const payload = { + t: 'chat_render', + from: actor.nickname, + fromUserId: actor.userId, + txt: content, + bubble: true, + timestamp: new Date().toISOString(), + messageId: `test_${Date.now()}_${actor.userId}`, + mapId: actor.mapId, + scope, + testLab: true, + }; + if (scope === 'global') this.broadcastToAll(payload); + else this.broadcastToMap(actor.mapId, payload); + } + // ========== 私有辅助方法 ========== private sendMessage(ws: ExtendedWebSocket, data: any) { @@ -976,10 +1035,22 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha }); const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id)) .filter((player) => activeUserIds.has(String(player.userId))); + const testPlayers = getTestLabPresences(normalizedMapId).map((player) => ({ + userId: player.userId, + username: player.nickname, + mapId: player.mapId, + x: player.x, + y: player.y, + skinId: player.skinId, + avatarId: player.avatarId, + appearance: { skinId: player.skinId, avatarId: player.avatarId }, + cafeCompanion: null, + movementLocked: false, + })); this.sendMessage(ws, { t: 'map_players_snapshot', mapId: normalizedMapId, - players, + players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)), }); }