282 lines
12 KiB
TypeScript
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) });
|
|
}
|
|
}
|
|
}
|