Files
whale-town-end-v2/src/business/admin/test_lab.service.ts
2026-07-22 12:16:31 +08:00

282 lines
12 KiB
TypeScript

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<Users> & { username: string; nickname: string }): Promise<Users>;
findOne(id: bigint): Promise<Users>;
findAll(limit: number, offset: number): Promise<Users[]>;
remove(id: bigint): Promise<unknown>;
};
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<void> {
if (this.isTestEnvironment()) {
await this.clearTestAccounts('startup');
}
}
isEnabled(): boolean {
return this.isTestEnvironment() && this.config.get<string>('TEST_LAB_ENABLED', 'false') === 'true';
}
async status() {
this.assertEnabled();
const actors = await this.listActorsInternal();
return {
enabled: this.isEnabled(),
environment: this.config.get<string>('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<string>('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<Users> {
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<Users> {
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<number> {
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<void> {
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<void> {
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<void> {
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) });
}
}
}