feat: add in-game social test lab
This commit is contained in:
281
src/business/admin/test_lab.service.ts
Normal file
281
src/business/admin/test_lab.service.ts
Normal file
@@ -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<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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user