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