2 Commits

Author SHA1 Message Date
ANG-Server
a3341b6a3b feat: add in-game social test lab 2026-07-22 12:16:31 +08:00
ANG-Server
fe52ab1696 feat: add task reward economy APIs 2026-07-22 00:45:59 +08:00
33 changed files with 1651 additions and 16 deletions

View File

@@ -15,6 +15,11 @@ ADMIN_BOOTSTRAP_ENABLED=false
ADMIN_USERNAME=
ADMIN_PASSWORD=
ADMIN_NICKNAME=
TEST_ADMIN_AUTO_PROVISION=true
TEST_ADMIN_USERNAME=admin
TEST_ADMIN_PASSWORD=
TEST_ADMIN_NICKNAME=测试管理员
TEST_LAB_ENABLED=false
# Local storage mode
USE_MEMORY_STORAGE=true

View File

@@ -14,6 +14,7 @@ ADMIN_BOOTSTRAP_ENABLED=false
ADMIN_USERNAME=
ADMIN_PASSWORD=
ADMIN_NICKNAME=
TEST_LAB_ENABLED=false
# Persistent storage
USE_MEMORY_STORAGE=false

View File

@@ -31,6 +31,7 @@ import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
import { SocialModule } from './business/social/social.module';
import { TasksModule } from './business/tasks/tasks.module';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
@@ -88,6 +89,7 @@ function isDatabaseConfigured(): boolean {
UserProfilesModule.forRoot(),
PlayerAssetsModule.forRoot(),
UserWalletsModule.forRoot(),
TasksModule.forRoot(),
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
ZulipAccountsModule.forRoot(),
LoginCoreModule,

View File

@@ -38,6 +38,12 @@ import { AdminOperationLogMemoryService } from './admin_operation_log_memory.ser
import { AdminOperationLog } from './admin_operation_log.entity';
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
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,
UsersModule,
SessionCoreModule,
AuthModule,
LoginCoreModule,
ChatGatewayModule,
UserProfilesModule,
// 注意ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
// 注册AdminOperationLog实体
@@ -63,7 +72,8 @@ function isDatabaseConfigured(): boolean {
controllers: [
AdminController,
AdminDatabaseController,
AdminOperationLogController
AdminOperationLogController,
TestLabController,
],
providers: [
AdminService,
@@ -75,12 +85,15 @@ function isDatabaseConfigured(): boolean {
: AdminOperationLogMemoryService,
},
AdminDatabaseExceptionFilter,
AdminOperationLogInterceptor
AdminOperationLogInterceptor,
TestLabService,
TestLabGuard,
],
exports: [
AdminService,
DatabaseManagementService,
AdminOperationLogService
AdminOperationLogService,
TestLabService,
], // 导出服务供其他模块使用
})
export class AdminModule {}

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

View 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';
}

View 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('仅管理员可使用测试实验室');
}
}

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

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

View File

@@ -40,6 +40,7 @@ import { LoginCoreService } from '../../core/login_core/login_core.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
import { AccountProfileService } from '../auth/account_profile.service';
import { TaskService } from '../tasks/task.service';
// ========== 接口定义 ==========
@@ -262,6 +263,7 @@ export class ChatService {
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
private readonly accountProfileService: AccountProfileService,
private readonly taskService: TaskService,
) {
this.logger.log('ChatService初始化完成');
}
@@ -445,6 +447,11 @@ export class ChatService {
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
}
if (normalizedScope === 'global') {
await this.taskService.recordActivity(BigInt(session.userId), 'public_message_sent')
.catch((error: unknown) => this.logger.warn('记录公共聊天任务失败', { error: error instanceof Error ? error.message : String(error) }));
}
this.logger.log('聊天消息发送完成', {
operation: 'sendChatMessage',
messageId,

View File

@@ -1,9 +1,10 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
import { InventoryService } from '../player/inventory.service';
import { EconomyService } from '../player/economy.service';
import { PlayerStateService } from '../player/player_state.service';
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
import { TaskService } from '../tasks/task.service';
interface IUserWalletsService {
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
@@ -51,11 +52,14 @@ export interface MallCatalogPayload {
@Injectable()
export class MallService {
private readonly logger = new Logger(MallService.name);
constructor(
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
private readonly inventoryService: InventoryService,
private readonly economyService: EconomyService,
private readonly playerStateService: PlayerStateService,
private readonly taskService: TaskService,
) {}
async getWallet(userId: bigint) {
@@ -123,6 +127,10 @@ export class MallService {
}
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
if (!alreadyOwned) {
await this.taskService.recordActivity(userId, 'skin_purchased', item.itemId)
.catch((error: unknown) => this.logger.warn(`记录首次皮肤任务失败: ${error instanceof Error ? error.message : String(error)}`));
}
const [inventory, snapshot] = await Promise.all([
this.inventoryService.listInventory(userId),
this.playerStateService.getSnapshot(userId),

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { AccountProfileService } from '../auth/account_profile.service';
import { getTestLabPresence } from '../admin/test_lab_presence.registry';
import { ChatSessionService } from '../chat/services/chat_session.service';
import { FriendRequestStatus } from './social.entities';
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 };
}
/** 仅供测试实验室调用:使用真实社交记录,但合成玩家无需真实 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) {
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('好友请求不存在或已过期');
@@ -274,7 +298,10 @@ export class SocialService {
if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符');
await this.assertNotBlockedEitherWay(senderId, targetId);
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');
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;
}
/** 测试假人私聊:仍写入正式私聊存储,但不要求假人拥有真实 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) {
await this.assertNotBlockedEitherWay(userId, otherUserId);
const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before);
@@ -362,6 +403,10 @@ export class SocialService {
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) {
const [user, profile, socketId] = await Promise.all([
this.usersService.findOne(targetId),
@@ -370,14 +415,15 @@ export class SocialService {
]);
const tags = this.profileTags(profile);
const session = socketId ? await this.sessions.getSession(socketId) : null;
const testPresence = getTestLabPresence(targetId);
return {
id: targetId.toString(),
username: user.username,
nickname: user.nickname,
avatarUrl: user.avatar_url || '',
skinId: profile.skin_id || '',
online: Boolean(socketId),
currentArea: session?.currentMap || profile.current_map,
online: Boolean(socketId) || Boolean(testPresence?.online),
currentArea: testPresence?.mapId || session?.currentMap || profile.current_map,
bio: String(profile.bio || '').slice(0, 160),
interests: this.validInterests(tags.interests),
privacy: includePrivate ? this.privacy(profile) : undefined,
@@ -412,10 +458,22 @@ export class SocialService {
private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') {
const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString());
const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString());
if (!targetSocketId || !sourceSocketId) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动');
const [target, source, profile] = await Promise.all([this.sessions.getSession(targetSocketId), this.sessions.getSession(sourceSocketId), this.ensureProfile(targetId)]);
if (!target || !source || target.currentMap !== source.currentMap) throw new ForbiddenException('陌生玩家仅可在同一地图互动');
const distance = Math.hypot(Number(target.position?.x || 0) - Number(source.position?.x || 0), Number(target.position?.y || 0) - Number(source.position?.y || 0));
const targetTestPresence = getTestLabPresence(targetId);
const sourceTestPresence = getTestLabPresence(sourceId);
const [target, source, profile] = await Promise.all([
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('请靠近该玩家后再互动');
const privacy = this.privacy(profile);
const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests';

View File

@@ -38,5 +38,6 @@ export interface SocialStore {
markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null>;
markAllNotificationsRead(userId: bigint): Promise<number>;
countUnreadNotifications(userId: bigint): Promise<number>;
purgeUsers(userIds: bigint[]): Promise<void>;
cleanupExpired(now: Date): Promise<void>;
}

View File

@@ -0,0 +1,12 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { TASK_ACTIVITY_TYPES } from '../task_catalog';
export class ReportTaskActivityDto {
@IsIn(TASK_ACTIVITY_TYPES)
activity: typeof TASK_ACTIVITY_TYPES[number];
@IsOptional()
@IsString()
@MaxLength(64)
target_id?: string;
}

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `player_task_progress` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint NOT NULL COMMENT '关联users.id',
`task_id` varchar(80) NOT NULL COMMENT '静态任务ID',
`cycle_key` varchar(32) NOT NULL COMMENT '任务周期键',
`progress` int NOT NULL DEFAULT 0 COMMENT '当前进度',
`activity_state` json NOT NULL COMMENT '去重活动目标等状态',
`completed_at` timestamp NULL DEFAULT NULL COMMENT '完成时间',
`claimed_at` timestamp NULL DEFAULT NULL COMMENT '领奖时间',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_player_task_progress_task_cycle` (`user_id`, `task_id`, `cycle_key`),
KEY `idx_player_task_progress_user_cycle` (`user_id`, `cycle_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='玩家任务进度表';

View File

@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('player_task_progress')
@Index('uq_player_task_progress_task_cycle', ['user_id', 'task_id', 'cycle_key'], { unique: true })
@Index('idx_player_task_progress_user_cycle', ['user_id', 'cycle_key'])
export class PlayerTaskProgress {
@PrimaryGeneratedColumn({ type: 'bigint', comment: '主键ID' })
id: bigint;
@Column({ type: 'bigint', nullable: false, comment: '关联users.id' })
user_id: bigint;
@Column({ type: 'varchar', length: 80, nullable: false, comment: '静态任务ID' })
task_id: string;
@Column({ type: 'varchar', length: 32, nullable: false, comment: '任务周期键' })
cycle_key: string;
@Column({ type: 'int', nullable: false, default: 0, comment: '当前进度' })
progress: number;
@Column({ type: 'json', nullable: false, comment: '去重活动目标等状态' })
activity_state: Record<string, unknown>;
@Column({ type: 'timestamp', nullable: true, comment: '完成时间' })
completed_at: Date | null;
@Column({ type: 'timestamp', nullable: true, comment: '领奖时间' })
claimed_at: Date | null;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
created_at: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', comment: '更新时间' })
updated_at: Date;
}

View File

@@ -0,0 +1,35 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { TaskActivityType } from './task_catalog';
import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types';
const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [
'guide_opened',
'notice_viewed',
'map_visited',
'course_board_opened',
'facility_interacted',
];
@Injectable()
export class TaskService {
constructor(@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore) {}
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
return await this.taskProgressStore.getBoard(userId);
}
async recordClientActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
if (!CLIENT_ACTIVITY_TYPES.includes(activity)) {
throw new BadRequestException('该任务活动只能由服务器业务记录');
}
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim());
}
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim());
}
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
return await this.taskProgressStore.claim(userId, taskId.trim());
}
}

View File

@@ -0,0 +1,189 @@
export const NEWBIE_CYCLE_KEY = 'newbie';
export const TASK_ACTIVITY_TYPES = [
'guide_opened',
'notice_viewed',
'map_visited',
'course_board_opened',
'facility_interacted',
'public_message_sent',
'skin_purchased',
] as const;
export type TaskActivityType = typeof TASK_ACTIVITY_TYPES[number];
export type TaskGroup = 'newbie' | 'weekly';
export type TaskProgressMode = 'count' | 'unique_target';
export interface TaskDefinition {
id: string;
group: TaskGroup;
title: string;
description: string;
reward: number;
target: number;
activity?: TaskActivityType;
progress_mode?: TaskProgressMode;
allowed_targets?: string[];
optional?: boolean;
bonus?: boolean;
sort_order: number;
}
export interface WeeklyCycle {
key: string;
starts_at: string;
ends_at: string;
}
export interface TaskProgressState {
targets?: string[];
}
export const NEWBIE_TASKS: TaskDefinition[] = [
{
id: 'newbie_guide',
group: 'newbie',
title: '翻阅新人手册',
description: '打开新人引导,了解鲸镇的基本操作。',
reward: 40,
target: 1,
activity: 'guide_opened',
sort_order: 10,
},
{
id: 'newbie_notice',
group: 'newbie',
title: '查看镇务公告',
description: '在广场查看一次公告栏。',
reward: 60,
target: 1,
activity: 'notice_viewed',
sort_order: 20,
},
{
id: 'newbie_work_zone',
group: 'newbie',
title: '探索打工区',
description: '前往打工区,看看小镇的工作与学习入口。',
reward: 80,
target: 1,
activity: 'map_visited',
allowed_targets: ['work_zone'],
sort_order: 30,
},
{
id: 'newbie_course_board',
group: 'newbie',
title: '浏览课程板',
description: '在打工区打开 Datawhale 课程看板。',
reward: 120,
target: 1,
activity: 'course_board_opened',
sort_order: 40,
},
{
id: 'newbie_first_skin',
group: 'newbie',
title: '选择你的形象',
description: '在鲸鱼商城购买任意一款皮肤。此任务可跳过。',
reward: 100,
target: 1,
activity: 'skin_purchased',
optional: true,
sort_order: 50,
},
];
export const WEEKLY_TASKS: TaskDefinition[] = [
{
id: 'weekly_explore',
group: 'weekly',
title: '海风巡游',
description: '探索两个不同的开放地图。',
reward: 100,
target: 2,
activity: 'map_visited',
progress_mode: 'unique_target',
allowed_targets: ['square', 'work_zone', 'whale_cafe'],
sort_order: 10,
},
{
id: 'weekly_course',
group: 'weekly',
title: '本周学习计划',
description: '打开一次 Datawhale 课程看板。',
reward: 100,
target: 1,
activity: 'course_board_opened',
sort_order: 20,
},
{
id: 'weekly_interact',
group: 'weekly',
title: '和小镇打招呼',
description: '与两个不同的公共设施或 NPC 互动。',
reward: 100,
target: 2,
activity: 'facility_interacted',
progress_mode: 'unique_target',
allowed_targets: ['welcome_board', 'notice_board', 'npc'],
sort_order: 30,
},
{
id: 'weekly_public_message',
group: 'weekly',
title: '分享此刻',
description: '在公共频道成功发送一条消息。',
reward: 100,
target: 1,
activity: 'public_message_sent',
sort_order: 40,
},
];
export const WEEKLY_COMPLETION_BONUS: TaskDefinition = {
id: 'weekly_completion_bonus',
group: 'weekly',
title: '本周任务书结算',
description: '完成本周全部四项任务后领取额外奖励。',
reward: 200,
target: 1,
bonus: true,
sort_order: 90,
};
export function getCurrentWeeklyCycle(now: Date = new Date()): WeeklyCycle {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = Object.fromEntries(formatter.formatToParts(now)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]));
const year = Number(parts.year);
const month = Number(parts.month);
const day = Number(parts.day);
const chinaDateAsUtc = Date.UTC(year, month - 1, day);
const weekday = new Date(chinaDateAsUtc).getUTCDay();
const daysSinceMonday = (weekday + 6) % 7;
const mondayAsUtc = chinaDateAsUtc - daysSinceMonday * 24 * 60 * 60 * 1000;
const monday = new Date(mondayAsUtc);
const cycleDate = monday.toISOString().slice(0, 10);
const startsAt = new Date(mondayAsUtc - 8 * 60 * 60 * 1000);
const endsAt = new Date(startsAt.getTime() + 7 * 24 * 60 * 60 * 1000);
return {
key: `weekly:${cycleDate}`,
starts_at: startsAt.toISOString(),
ends_at: endsAt.toISOString(),
};
}
export function getTaskDefinitions(): TaskDefinition[] {
return [...NEWBIE_TASKS, ...WEEKLY_TASKS, WEEKLY_COMPLETION_BONUS];
}
export function getTaskCycleKey(definition: TaskDefinition, cycle: WeeklyCycle): string {
return definition.group === 'weekly' ? cycle.key : NEWBIE_CYCLE_KEY;
}

View File

@@ -0,0 +1,140 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { UserWalletsService } from '../../core/db/user_wallets/user_wallets.service';
import {
getCurrentWeeklyCycle,
getTaskCycleKey,
getTaskDefinitions,
NEWBIE_CYCLE_KEY,
TaskActivityType,
TaskDefinition,
TaskProgressState,
WEEKLY_COMPLETION_BONUS,
WEEKLY_TASKS,
WeeklyCycle,
} from './task_catalog';
import { PlayerTaskProgress } from './player_task_progress.entity';
import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types';
@Injectable()
export class TaskProgressDatabaseService implements TaskProgressStore {
constructor(
@InjectRepository(PlayerTaskProgress) private readonly progressRepository: Repository<PlayerTaskProgress>,
private readonly dataSource: DataSource,
private readonly walletService: UserWalletsService,
) {}
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
const cycle = getCurrentWeeklyCycle();
await this.ensureRows(this.progressRepository, userId, cycle);
const rows = await this.findRows(this.progressRepository, userId, cycle);
await this.syncWeeklyBonus(this.progressRepository, rows);
return buildTaskBoard(rows, cycle);
}
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
const cycle = getCurrentWeeklyCycle();
return await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(PlayerTaskProgress);
await this.ensureRows(repository, userId, cycle);
const rows = await this.findRows(repository, userId, cycle, true);
for (const definition of getTaskDefinitions()) {
if (definition.bonus || definition.activity !== activity) continue;
const row = this.findRow(rows, definition, cycle);
this.applyActivity(definition, row, targetId);
}
await repository.save(rows);
await this.syncWeeklyBonus(repository, rows);
return buildTaskBoard(rows, cycle);
});
}
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
const cycle = getCurrentWeeklyCycle();
const definition = getTaskDefinitions().find((item) => item.id === taskId);
if (!definition) throw new BadRequestException('任务不存在');
return await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(PlayerTaskProgress);
await this.ensureRows(repository, userId, cycle);
const rows = await this.findRows(repository, userId, cycle, true);
await this.syncWeeklyBonus(repository, rows);
const row = this.findRow(rows, definition, cycle);
if (!row.completed_at) throw new BadRequestException('任务尚未完成');
if (row.claimed_at) throw new ConflictException('任务奖励已领取');
const walletResult = await this.walletService.earnInTransaction(
manager,
userId,
definition.reward,
'task_reward',
`${getTaskCycleKey(definition, cycle)}:${definition.id}`,
`任务奖励:${definition.title}`,
);
row.claimed_at = new Date();
await repository.save(row);
return {
board: buildTaskBoard(rows, cycle),
wallet: {
user_id: userId.toString(),
balance: walletResult.wallet.balance,
currency: 'whale_coin',
},
};
});
}
private async ensureRows(repository: Repository<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle): Promise<void> {
const values = getTaskDefinitions().map((definition) => ({
user_id: userId,
task_id: definition.id,
cycle_key: getTaskCycleKey(definition, cycle),
progress: 0,
activity_state: {},
completed_at: null,
claimed_at: null,
}));
await repository.createQueryBuilder().insert().values(values).orIgnore().execute();
}
private async findRows(repository: Repository<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle, lock = false): Promise<PlayerTaskProgress[]> {
return await repository.find({
where: { user_id: userId, cycle_key: In([cycle.key, NEWBIE_CYCLE_KEY]) },
...(lock ? { lock: { mode: 'pessimistic_write' as const } } : {}),
});
}
private findRow(rows: PlayerTaskProgress[], definition: TaskDefinition, cycle: WeeklyCycle): PlayerTaskProgress {
const cycleKey = getTaskCycleKey(definition, cycle);
const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey);
if (!row) throw new Error(`任务进度缺失: ${definition.id}`);
return row;
}
private applyActivity(definition: TaskDefinition, row: PlayerTaskProgress, targetId?: string): void {
if (row.completed_at) return;
if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return;
if (definition.progress_mode === 'unique_target') {
if (!targetId) return;
const state = row.activity_state as TaskProgressState;
const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : [];
if (targets.includes(targetId)) return;
targets.push(targetId);
row.activity_state = { ...state, targets };
row.progress = Math.min(definition.target, targets.length);
} else {
row.progress = Math.min(definition.target, row.progress + 1);
}
if (row.progress >= definition.target) row.completed_at = new Date();
}
private async syncWeeklyBonus(repository: Repository<PlayerTaskProgress>, rows: PlayerTaskProgress[]): Promise<void> {
const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id);
if (!bonus || bonus.completed_at) return;
const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at));
if (complete) {
bonus.progress = 1;
bonus.completed_at = new Date();
await repository.save(bonus);
}
}
}

View File

@@ -0,0 +1,147 @@
import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common';
import { PlayerWalletPayload } from '../player/player.types';
import {
getCurrentWeeklyCycle,
getTaskCycleKey,
getTaskDefinitions,
NEWBIE_CYCLE_KEY,
TaskActivityType,
TaskDefinition,
TaskProgressState,
WEEKLY_COMPLETION_BONUS,
WEEKLY_TASKS,
} from './task_catalog';
import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types';
interface IUserWalletsService {
earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
}
interface MemoryProgressRow extends TaskProgressRow {
user_id: bigint;
created_at: Date;
updated_at: Date;
}
@Injectable()
export class TaskProgressMemoryService implements TaskProgressStore {
private readonly rows = new Map<string, MemoryProgressRow>();
constructor(@Inject('IUserWalletsService') private readonly walletService: IUserWalletsService) {}
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
const cycle = getCurrentWeeklyCycle();
const rows = this.ensureRows(userId, cycle.key);
this.syncWeeklyBonus(rows);
return buildTaskBoard(rows, cycle);
}
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
const cycle = getCurrentWeeklyCycle();
const rows = this.ensureRows(userId, cycle.key);
for (const definition of getTaskDefinitions()) {
if (definition.bonus || definition.activity !== activity) continue;
const row = this.findRow(rows, definition, cycle.key);
this.applyActivity(definition, row, targetId);
}
this.syncWeeklyBonus(rows);
return buildTaskBoard(rows, cycle);
}
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
const cycle = getCurrentWeeklyCycle();
const rows = this.ensureRows(userId, cycle.key);
this.syncWeeklyBonus(rows);
const definition = getTaskDefinitions().find((item) => item.id === taskId);
if (!definition) {
throw new BadRequestException('任务不存在');
}
const row = this.findRow(rows, definition, cycle.key);
if (!row.completed_at) {
throw new BadRequestException('任务尚未完成');
}
if (row.claimed_at) {
throw new ConflictException('任务奖励已领取');
}
const result = await this.walletService.earn(
userId,
definition.reward,
'task_reward',
`${getTaskCycleKey(definition, cycle)}:${definition.id}`,
`任务奖励:${definition.title}`,
);
row.claimed_at = new Date();
row.updated_at = new Date();
const wallet: PlayerWalletPayload = {
user_id: userId.toString(),
balance: result.wallet.balance,
currency: 'whale_coin',
};
return { board: buildTaskBoard(rows, cycle), wallet };
}
private ensureRows(userId: bigint, weeklyCycleKey: string): MemoryProgressRow[] {
for (const definition of getTaskDefinitions()) {
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
const key = this.rowKey(userId, definition.id, cycleKey);
if (!this.rows.has(key)) {
const now = new Date();
this.rows.set(key, {
user_id: userId,
task_id: definition.id,
cycle_key: cycleKey,
progress: 0,
activity_state: {},
completed_at: null,
claimed_at: null,
created_at: now,
updated_at: now,
});
}
}
return getTaskDefinitions().map((definition) => {
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
return this.rows.get(this.rowKey(userId, definition.id, cycleKey)) as MemoryProgressRow;
});
}
private findRow(rows: MemoryProgressRow[], definition: TaskDefinition, weeklyCycleKey: string): MemoryProgressRow {
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey);
if (!row) throw new Error(`任务进度缺失: ${definition.id}`);
return row;
}
private applyActivity(definition: TaskDefinition, row: MemoryProgressRow, targetId?: string): void {
if (row.completed_at) return;
if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return;
if (definition.progress_mode === 'unique_target') {
if (!targetId) return;
const state = row.activity_state as TaskProgressState;
const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : [];
if (targets.includes(targetId)) return;
targets.push(targetId);
row.activity_state = { ...state, targets };
row.progress = Math.min(definition.target, targets.length);
} else {
row.progress = Math.min(definition.target, row.progress + 1);
}
if (row.progress >= definition.target) row.completed_at = new Date();
row.updated_at = new Date();
}
private syncWeeklyBonus(rows: MemoryProgressRow[]): void {
const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id);
if (!bonus || bonus.completed_at) return;
const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at));
if (complete) {
bonus.progress = 1;
bonus.completed_at = new Date();
bonus.updated_at = new Date();
}
}
private rowKey(userId: bigint, taskId: string, cycleKey: string): string {
return `${userId.toString()}:${taskId}:${cycleKey}`;
}
}

View File

@@ -0,0 +1,48 @@
import { Body, Controller, Get, HttpStatus, Param, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
import { JwtPayload } from '../../core/login_core/login_core.service';
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
import { TaskService } from './task.service';
@ApiTags('tasks')
@ApiBearerAuth()
@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class TasksController {
constructor(private readonly taskService: TaskService) {}
@Get('board')
@ApiOperation({ summary: '获取玩家任务书' })
@SwaggerApiResponse({ status: 200, description: '任务书获取成功' })
async getBoard(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
const data = await this.taskService.getBoard(BigInt(user.sub));
res.status(HttpStatus.OK).json({ success: true, data, message: '任务书获取成功' });
}
@Post('activities')
@ApiOperation({ summary: '上报客户端白名单任务活动' })
@ApiBody({ type: ReportTaskActivityDto })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async reportActivity(
@CurrentUser() user: JwtPayload,
@Body() dto: ReportTaskActivityDto,
@Res() res: Response,
): Promise<void> {
const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto.activity, dto.target_id);
res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' });
}
@Post(':taskId/claim')
@ApiOperation({ summary: '领取任务奖励' })
async claim(
@CurrentUser() user: JwtPayload,
@Param('taskId') taskId: string,
@Res() res: Response,
): Promise<void> {
const data = await this.taskService.claim(BigInt(user.sub), taskId);
res.status(HttpStatus.OK).json({ success: true, data, message: '任务奖励已领取' });
}
}

View File

@@ -0,0 +1,51 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { PlayerTaskProgress } from './player_task_progress.entity';
import { TaskProgressDatabaseService } from './task_progress_database.service';
import { TaskProgressMemoryService } from './task_progress_memory.service';
import { TaskService } from './task.service';
import { TasksController } from './tasks.controller';
@Global()
@Module({})
export class TasksModule {
static forDatabase(): DynamicModule {
return {
module: TasksModule,
global: true,
imports: [LoginCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress])],
controllers: [TasksController],
providers: [
TaskProgressDatabaseService,
{ provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService },
TaskService,
],
exports: [TaskService, 'ITaskProgressStore'],
};
}
static forMemory(): DynamicModule {
return {
module: TasksModule,
global: true,
imports: [LoginCoreModule],
controllers: [TasksController],
providers: [
TaskProgressMemoryService,
{ provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService },
TaskService,
],
exports: [TaskService, 'ITaskProgressStore'],
};
}
static forRoot(useMemory?: boolean): DynamicModule {
const shouldUseMemory = useMemory ?? (
process.env.NODE_ENV === 'test' ||
process.env.USE_MEMORY_STORAGE === 'true' ||
!process.env.DB_HOST
);
return shouldUseMemory ? this.forMemory() : this.forDatabase();
}
}

View File

@@ -0,0 +1,78 @@
import { PlayerWalletPayload } from '../player/player.types';
import { getTaskCycleKey, NEWBIE_TASKS, TaskActivityType, TaskDefinition, WEEKLY_COMPLETION_BONUS, WEEKLY_TASKS, WeeklyCycle } from './task_catalog';
export interface TaskProgressRow {
task_id: string;
cycle_key: string;
progress: number;
activity_state: Record<string, unknown>;
completed_at: Date | null;
claimed_at: Date | null;
}
export interface TaskPayload {
id: string;
title: string;
description: string;
reward: number;
target: number;
progress: number;
optional: boolean;
bonus: boolean;
completed: boolean;
claimed: boolean;
claimable: boolean;
}
export interface TaskBoardPayload {
weekly_cycle: WeeklyCycle;
newbie_tasks: TaskPayload[];
weekly_tasks: TaskPayload[];
weekly_bonus: TaskPayload;
}
export interface TaskClaimResult {
board: TaskBoardPayload;
wallet: PlayerWalletPayload;
}
export interface TaskProgressStore {
getBoard(userId: bigint): Promise<TaskBoardPayload>;
recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload>;
claim(userId: bigint, taskId: string): Promise<TaskClaimResult>;
}
export function toTaskPayload(definition: TaskDefinition, row: TaskProgressRow): TaskPayload {
const completed = row.completed_at != null;
const claimed = row.claimed_at != null;
return {
id: definition.id,
title: definition.title,
description: definition.description,
reward: definition.reward,
target: definition.target,
progress: Math.min(definition.target, Math.max(0, row.progress)),
optional: Boolean(definition.optional),
bonus: Boolean(definition.bonus),
completed,
claimed,
claimable: completed && !claimed,
};
}
export function buildTaskBoard(rows: TaskProgressRow[], cycle: WeeklyCycle): TaskBoardPayload {
const rowsByKey = new Map(rows.map((row) => [`${row.task_id}:${row.cycle_key}`, row]));
const rowFor = (definition: TaskDefinition): TaskProgressRow => {
const row = rowsByKey.get(`${definition.id}:${getTaskCycleKey(definition, cycle)}`);
if (!row) {
throw new Error(`任务进度缺失: ${definition.id}`);
}
return row;
};
return {
weekly_cycle: cycle,
newbie_tasks: NEWBIE_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))),
weekly_tasks: WEEKLY_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))),
weekly_bonus: toTaskPayload(WEEKLY_COMPLETION_BONUS, rowFor(WEEKLY_COMPLETION_BONUS)),
};
}

View File

@@ -100,6 +100,7 @@ export class AdminCoreService implements OnModuleInit {
* ```
*/
async onModuleInit(): Promise<void> {
await this.ensureDevelopmentTestAdmin();
await this.bootstrapAdminIfEnabled();
}
@@ -317,6 +318,50 @@ export class AdminCoreService implements OnModuleInit {
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 {
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
if (!secret || secret.length < 16) {

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserWallets } from './user_wallets.entity';
import { WalletTransactions } from './wallet_transactions.entity';
@@ -96,6 +96,60 @@ export class UserWalletsService {
};
}
async earnInTransaction(
manager: EntityManager,
userId: bigint,
amount: number,
referenceType: string,
referenceId: string,
note?: string,
): Promise<EarnWalletResult> {
if (!Number.isInteger(amount) || amount < 0) {
throw new BadRequestException('鲸币收入数量不正确');
}
const walletRepository = manager.getRepository(UserWallets);
const transactionRepository = manager.getRepository(WalletTransactions);
let wallet = await walletRepository.findOne({
where: { user_id: userId },
lock: { mode: 'pessimistic_write' },
});
if (!wallet) {
wallet = walletRepository.create({
user_id: userId,
balance: DEFAULT_INITIAL_WHALE_COINS,
created_at: new Date(),
updated_at: new Date(),
});
wallet = await walletRepository.save(wallet);
await transactionRepository.save(transactionRepository.create({
user_id: userId,
type: 'grant',
amount: DEFAULT_INITIAL_WHALE_COINS,
balance_after: wallet.balance,
reference_type: 'registration',
reference_id: 'initial_wallet',
note: '新用户初始鲸币',
created_at: new Date(),
}));
}
wallet.balance += amount;
wallet.updated_at = new Date();
const savedWallet = await walletRepository.save(wallet);
const transaction = await transactionRepository.save(transactionRepository.create({
user_id: userId,
type: 'earn',
amount,
balance_after: savedWallet.balance,
reference_type: referenceType,
reference_id: referenceId,
note: note || null,
created_at: new Date(),
}));
return { wallet: savedWallet, transaction };
}
private async createTransaction(
userId: bigint,
type: string,

View 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;

View File

@@ -36,7 +36,8 @@ import {
IsOptional,
Length,
IsNotEmpty,
IsEnum
IsEnum,
IsBoolean
} from 'class-validator';
import { UserStatus } from './user_status.enum';
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}字符之间` })
username: string;
@IsOptional()
@IsBoolean()
is_test_account?: boolean;
/**
* 邮箱地址
*
@@ -272,4 +277,4 @@ export class CreateUserDto {
@IsOptional()
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
status?: UserStatus = UserStatus.ACTIVE;
}
}

View File

@@ -358,6 +358,17 @@ export class Users {
})
role: number;
/**
* 仅供开发/测试实验室创建的合成玩家使用。生产业务不得据此授予权限。
*/
@Column({
type: 'boolean',
nullable: false,
default: false,
comment: '是否为测试实验室账号'
})
is_test_account: boolean;
/**
* 用户状态
*

View File

@@ -157,6 +157,7 @@ export class UsersService extends BaseUsersService {
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
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.status = createUserDto.status || UserStatus.ACTIVE;
@@ -711,4 +712,4 @@ export class UsersService extends BaseUsersService {
});
}
}
}
}

View File

@@ -257,6 +257,7 @@ export class UsersMemoryService extends BaseUsersService {
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
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.status = createUserDto.status || UserStatus.ACTIVE;
user.created_at = new Date();

View File

@@ -34,6 +34,13 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/commo
import * as WebSocket from 'ws';
import { ChatService } from '../../business/chat/chat.service';
import { SocialService } from '../../business/social/social.service';
import {
getTestLabPresence,
getTestLabPresences,
removeTestLabPresence,
TestLabPresence,
upsertTestLabPresence,
} from '../../business/admin/test_lab_presence.registry';
/** WebSocket 服务器默认端口 */
const DEFAULT_WEBSOCKET_PORT = 3001;
@@ -911,6 +918,58 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
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) {
@@ -976,10 +1035,22 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
});
const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id))
.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, {
t: 'map_players_snapshot',
mapId: normalizedMapId,
players,
players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)),
});
}