forked from xiangwang25/whale-town-end-v2
Compare commits
1 Commits
feature-ta
...
feature-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3341b6a3b |
@@ -15,6 +15,11 @@ ADMIN_BOOTSTRAP_ENABLED=false
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
ADMIN_NICKNAME=
|
ADMIN_NICKNAME=
|
||||||
|
TEST_ADMIN_AUTO_PROVISION=true
|
||||||
|
TEST_ADMIN_USERNAME=admin
|
||||||
|
TEST_ADMIN_PASSWORD=
|
||||||
|
TEST_ADMIN_NICKNAME=测试管理员
|
||||||
|
TEST_LAB_ENABLED=false
|
||||||
|
|
||||||
# Local storage mode
|
# Local storage mode
|
||||||
USE_MEMORY_STORAGE=true
|
USE_MEMORY_STORAGE=true
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ADMIN_BOOTSTRAP_ENABLED=false
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
ADMIN_NICKNAME=
|
ADMIN_NICKNAME=
|
||||||
|
TEST_LAB_ENABLED=false
|
||||||
|
|
||||||
# Persistent storage
|
# Persistent storage
|
||||||
USE_MEMORY_STORAGE=false
|
USE_MEMORY_STORAGE=false
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import { AdminOperationLogMemoryService } from './admin_operation_log_memory.ser
|
|||||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
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,
|
LoggerModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SessionCoreModule,
|
SessionCoreModule,
|
||||||
|
AuthModule,
|
||||||
|
LoginCoreModule,
|
||||||
|
ChatGatewayModule,
|
||||||
UserProfilesModule,
|
UserProfilesModule,
|
||||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||||
// 注册AdminOperationLog实体
|
// 注册AdminOperationLog实体
|
||||||
@@ -63,7 +72,8 @@ function isDatabaseConfigured(): boolean {
|
|||||||
controllers: [
|
controllers: [
|
||||||
AdminController,
|
AdminController,
|
||||||
AdminDatabaseController,
|
AdminDatabaseController,
|
||||||
AdminOperationLogController
|
AdminOperationLogController,
|
||||||
|
TestLabController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminService,
|
AdminService,
|
||||||
@@ -75,12 +85,15 @@ function isDatabaseConfigured(): boolean {
|
|||||||
: AdminOperationLogMemoryService,
|
: AdminOperationLogMemoryService,
|
||||||
},
|
},
|
||||||
AdminDatabaseExceptionFilter,
|
AdminDatabaseExceptionFilter,
|
||||||
AdminOperationLogInterceptor
|
AdminOperationLogInterceptor,
|
||||||
|
TestLabService,
|
||||||
|
TestLabGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AdminService,
|
AdminService,
|
||||||
DatabaseManagementService,
|
DatabaseManagementService,
|
||||||
AdminOperationLogService
|
AdminOperationLogService,
|
||||||
|
TestLabService,
|
||||||
], // 导出服务供其他模块使用
|
], // 导出服务供其他模块使用
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
67
src/business/admin/test_lab.controller.ts
Normal file
67
src/business/admin/test_lab.controller.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/business/admin/test_lab.dto.ts
Normal file
92
src/business/admin/test_lab.dto.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
73
src/business/admin/test_lab.guard.ts
Normal file
73
src/business/admin/test_lab.guard.ts
Normal file
@@ -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<Users>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试实验室既可由旧管理端 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<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<TestLabRequest>();
|
||||||
|
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<void> {
|
||||||
|
if (!/^\d+$/.test(userId)) throw new UnauthorizedException('管理员身份无效');
|
||||||
|
const user = await this.users.findOne(BigInt(userId));
|
||||||
|
if (user.role !== 9) throw new ForbiddenException('仅管理员可使用测试实验室');
|
||||||
|
}
|
||||||
|
}
|
||||||
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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/business/admin/test_lab_presence.registry.ts
Normal file
46
src/business/admin/test_lab_presence.registry.ts
Normal file
@@ -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<string, TestLabPresence>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -58,5 +58,18 @@ export class SocialDatabaseStore implements SocialStore {
|
|||||||
async markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { 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 markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { 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<number> { 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 markAllNotificationsRead(userId: bigint): Promise<number> { 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<number> { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
async countUnreadNotifications(userId: bigint): Promise<number> { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
||||||
|
async purgeUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
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<void> { 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) })]); }
|
async cleanupExpired(now: Date): Promise<void> { 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) })]); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,5 +74,16 @@ export class SocialMemoryStore implements SocialStore {
|
|||||||
async markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { 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 markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { 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<number> { 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 markAllNotificationsRead(userId: bigint): Promise<number> { 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<number> { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
async countUnreadNotifications(userId: bigint): Promise<number> { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
||||||
|
async purgeUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
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<string, unknown> | null)?.testLab);
|
||||||
|
}
|
||||||
async cleanupExpired(now: Date): Promise<void> { 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); }
|
async cleanupExpired(now: Date): Promise<void> { 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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { AccountProfileService } from '../auth/account_profile.service';
|
import { AccountProfileService } from '../auth/account_profile.service';
|
||||||
|
import { getTestLabPresence } from '../admin/test_lab_presence.registry';
|
||||||
import { ChatSessionService } from '../chat/services/chat_session.service';
|
import { ChatSessionService } from '../chat/services/chat_session.service';
|
||||||
import { FriendRequestStatus } from './social.entities';
|
import { FriendRequestStatus } from './social.entities';
|
||||||
import { SOCIAL_STORE, SocialStore } from './social.store';
|
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 };
|
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) {
|
async acceptFriendRequest(userId: bigint, requestId: bigint) {
|
||||||
const request = await this.store.findFriendRequest(requestId);
|
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('好友请求不存在或已过期');
|
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 个字符');
|
if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符');
|
||||||
await this.assertNotBlockedEitherWay(senderId, targetId);
|
await this.assertNotBlockedEitherWay(senderId, targetId);
|
||||||
const targetSocket = await this.sessions.getSocketIdByUserId(targetId.toString());
|
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');
|
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) });
|
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;
|
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) {
|
async listConversation(userId: bigint, otherUserId: bigint, limit: number, before?: Date) {
|
||||||
await this.assertNotBlockedEitherWay(userId, otherUserId);
|
await this.assertNotBlockedEitherWay(userId, otherUserId);
|
||||||
const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before);
|
const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before);
|
||||||
@@ -362,6 +403,10 @@ export class SocialService {
|
|||||||
await this.store.cleanupExpired(new Date());
|
await this.store.cleanupExpired(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purgeTestUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
await this.store.purgeUsers(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) {
|
private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) {
|
||||||
const [user, profile, socketId] = await Promise.all([
|
const [user, profile, socketId] = await Promise.all([
|
||||||
this.usersService.findOne(targetId),
|
this.usersService.findOne(targetId),
|
||||||
@@ -370,14 +415,15 @@ export class SocialService {
|
|||||||
]);
|
]);
|
||||||
const tags = this.profileTags(profile);
|
const tags = this.profileTags(profile);
|
||||||
const session = socketId ? await this.sessions.getSession(socketId) : null;
|
const session = socketId ? await this.sessions.getSession(socketId) : null;
|
||||||
|
const testPresence = getTestLabPresence(targetId);
|
||||||
return {
|
return {
|
||||||
id: targetId.toString(),
|
id: targetId.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
avatarUrl: user.avatar_url || '',
|
avatarUrl: user.avatar_url || '',
|
||||||
skinId: profile.skin_id || '',
|
skinId: profile.skin_id || '',
|
||||||
online: Boolean(socketId),
|
online: Boolean(socketId) || Boolean(testPresence?.online),
|
||||||
currentArea: session?.currentMap || profile.current_map,
|
currentArea: testPresence?.mapId || session?.currentMap || profile.current_map,
|
||||||
bio: String(profile.bio || '').slice(0, 160),
|
bio: String(profile.bio || '').slice(0, 160),
|
||||||
interests: this.validInterests(tags.interests),
|
interests: this.validInterests(tags.interests),
|
||||||
privacy: includePrivate ? this.privacy(profile) : undefined,
|
privacy: includePrivate ? this.privacy(profile) : undefined,
|
||||||
@@ -412,10 +458,22 @@ export class SocialService {
|
|||||||
private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') {
|
private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') {
|
||||||
const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString());
|
const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString());
|
||||||
const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString());
|
const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString());
|
||||||
if (!targetSocketId || !sourceSocketId) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动');
|
const targetTestPresence = getTestLabPresence(targetId);
|
||||||
const [target, source, profile] = await Promise.all([this.sessions.getSession(targetSocketId), this.sessions.getSession(sourceSocketId), this.ensureProfile(targetId)]);
|
const sourceTestPresence = getTestLabPresence(sourceId);
|
||||||
if (!target || !source || target.currentMap !== source.currentMap) throw new ForbiddenException('陌生玩家仅可在同一地图互动');
|
const [target, source, profile] = await Promise.all([
|
||||||
const distance = Math.hypot(Number(target.position?.x || 0) - Number(source.position?.x || 0), Number(target.position?.y || 0) - Number(source.position?.y || 0));
|
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('请靠近该玩家后再互动');
|
if (distance > NEARBY_DISTANCE) throw new ForbiddenException('请靠近该玩家后再互动');
|
||||||
const privacy = this.privacy(profile);
|
const privacy = this.privacy(profile);
|
||||||
const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests';
|
const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests';
|
||||||
|
|||||||
@@ -38,5 +38,6 @@ export interface SocialStore {
|
|||||||
markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null>;
|
markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null>;
|
||||||
markAllNotificationsRead(userId: bigint): Promise<number>;
|
markAllNotificationsRead(userId: bigint): Promise<number>;
|
||||||
countUnreadNotifications(userId: bigint): Promise<number>;
|
countUnreadNotifications(userId: bigint): Promise<number>;
|
||||||
|
purgeUsers(userIds: bigint[]): Promise<void>;
|
||||||
cleanupExpired(now: Date): Promise<void>;
|
cleanupExpired(now: Date): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export class AdminCoreService implements OnModuleInit {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.ensureDevelopmentTestAdmin();
|
||||||
await this.bootstrapAdminIfEnabled();
|
await this.bootstrapAdminIfEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +318,50 @@ export class AdminCoreService implements OnModuleInit {
|
|||||||
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发/测试专用管理员。密码必须由本地或 CI 环境注入,不能作为生产回退值。
|
||||||
|
*/
|
||||||
|
private async ensureDevelopmentTestAdmin(): Promise<void> {
|
||||||
|
const environment = this.configService.get<string>('NODE_ENV', 'development');
|
||||||
|
if (environment !== 'development' && environment !== 'test') return;
|
||||||
|
if (this.configService.get<string>('TEST_ADMIN_AUTO_PROVISION', 'true') !== 'true') return;
|
||||||
|
|
||||||
|
const username = this.configService.get<string>('TEST_ADMIN_USERNAME', 'admin')?.trim();
|
||||||
|
const password = this.configService.get<string>('TEST_ADMIN_PASSWORD')
|
||||||
|
|| this.configService.get<string>('ADMIN_PASSWORD');
|
||||||
|
const nickname = this.configService.get<string>('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 {
|
private getAdminTokenSecret(): string {
|
||||||
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
||||||
if (!secret || secret.length < 16) {
|
if (!secret || secret.length < 16) {
|
||||||
|
|||||||
18
src/core/db/users/migrations/add-is-test-account.sql
Normal file
18
src/core/db/users/migrations/add-is-test-account.sql
Normal file
@@ -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;
|
||||||
@@ -36,7 +36,8 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
Length,
|
Length,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsEnum
|
IsEnum,
|
||||||
|
IsBoolean
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { UserStatus } from './user_status.enum';
|
import { UserStatus } from './user_status.enum';
|
||||||
import { USER_ROLES, FIELD_LIMITS } from './users.constants';
|
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}字符之间` })
|
@Length(1, FIELD_LIMITS.USERNAME_MAX_LENGTH, { message: `用户名长度需在1-${FIELD_LIMITS.USERNAME_MAX_LENGTH}字符之间` })
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
is_test_account?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 邮箱地址
|
* 邮箱地址
|
||||||
*
|
*
|
||||||
@@ -272,4 +277,4 @@ export class CreateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
|
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
|
||||||
status?: UserStatus = UserStatus.ACTIVE;
|
status?: UserStatus = UserStatus.ACTIVE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,6 +358,17 @@ export class Users {
|
|||||||
})
|
})
|
||||||
role: number;
|
role: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅供开发/测试实验室创建的合成玩家使用。生产业务不得据此授予权限。
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false,
|
||||||
|
default: false,
|
||||||
|
comment: '是否为测试实验室账号'
|
||||||
|
})
|
||||||
|
is_test_account: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户状态
|
* 用户状态
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export class UsersService extends BaseUsersService {
|
|||||||
user.github_id = createUserDto.github_id || null;
|
user.github_id = createUserDto.github_id || null;
|
||||||
user.avatar_url = createUserDto.avatar_url || null;
|
user.avatar_url = createUserDto.avatar_url || null;
|
||||||
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
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.email_verified = createUserDto.email_verified || false;
|
||||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||||
|
|
||||||
@@ -711,4 +712,4 @@ export class UsersService extends BaseUsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export class UsersMemoryService extends BaseUsersService {
|
|||||||
user.github_id = createUserDto.github_id || null;
|
user.github_id = createUserDto.github_id || null;
|
||||||
user.avatar_url = createUserDto.avatar_url || null;
|
user.avatar_url = createUserDto.avatar_url || null;
|
||||||
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
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.email_verified = createUserDto.email_verified || false;
|
||||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||||
user.created_at = new Date();
|
user.created_at = new Date();
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/commo
|
|||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
import { ChatService } from '../../business/chat/chat.service';
|
import { ChatService } from '../../business/chat/chat.service';
|
||||||
import { SocialService } from '../../business/social/social.service';
|
import { SocialService } from '../../business/social/social.service';
|
||||||
|
import {
|
||||||
|
getTestLabPresence,
|
||||||
|
getTestLabPresences,
|
||||||
|
removeTestLabPresence,
|
||||||
|
TestLabPresence,
|
||||||
|
upsertTestLabPresence,
|
||||||
|
} from '../../business/admin/test_lab_presence.registry';
|
||||||
|
|
||||||
/** WebSocket 服务器默认端口 */
|
/** WebSocket 服务器默认端口 */
|
||||||
const DEFAULT_WEBSOCKET_PORT = 3001;
|
const DEFAULT_WEBSOCKET_PORT = 3001;
|
||||||
@@ -911,6 +918,58 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return players;
|
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) {
|
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))
|
const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id))
|
||||||
.filter((player) => activeUserIds.has(String(player.userId)));
|
.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, {
|
this.sendMessage(ws, {
|
||||||
t: 'map_players_snapshot',
|
t: 'map_players_snapshot',
|
||||||
mapId: normalizedMapId,
|
mapId: normalizedMapId,
|
||||||
players,
|
players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user