Initial WhaleTown V2 backend

This commit is contained in:
2026-07-20 02:00:52 +08:00
commit c995891c1f
265 changed files with 75689 additions and 0 deletions

View File

@@ -0,0 +1,687 @@
/**
* 注册业务服务
*
* 功能描述:
* - 处理用户注册相关的业务逻辑和流程控制
* - 整合核心服务,提供完整的注册功能
* - 处理业务规则、数据格式化和错误处理
* - 集成Zulip账号创建和关联
*
* 职责分离:
* - 专注于注册业务流程和规则实现
* - 调用核心服务完成具体功能
* - 为控制器层提供注册业务接口
* - 处理注册相关的邮箱验证和Zulip集成
*
* 最近修改:
* - 2026-01-15: 代码规范优化 - 清理未使用的导入TokenPair增强userId非空验证 (修改者: moyin)
* - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑
*
* @author moyin
* @version 1.0.1
* @since 2026-01-12
* @lastModified 2026-01-15
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service';
import { Users } from '../../core/db/users/users.entity';
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
import { AccountProfilePayload, AccountProfileService } from './account_profile.service';
// Import the interface types we need
interface IZulipAccountsService {
findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<any>;
create(createDto: any): Promise<any>;
deleteByGameUserId(gameUserId: string): Promise<boolean>;
}
// 常量定义
const ERROR_CODES = {
REGISTER_FAILED: 'REGISTER_FAILED',
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
} as const;
const MESSAGES = {
REGISTER_SUCCESS: '注册成功',
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
CODE_SENT: '验证码已发送,请查收',
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
VERIFICATION_CODE_ERROR: '验证码错误',
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
} as const;
/**
* 注册响应数据接口
*/
export interface RegisterResponse {
/** 用户信息 */
user: {
id: string;
username: string;
nickname: string;
email?: string;
phone?: string;
avatar_url?: string;
role: number;
created_at: Date;
};
/** 游戏内账号资料 */
profile?: AccountProfilePayload['profile'];
/** 访问令牌 */
access_token: string;
/** 刷新令牌 */
refresh_token: string;
/** 访问令牌过期时间(秒) */
expires_in: number;
/** 令牌类型 */
token_type: string;
/** 是否为新用户 */
is_new_user?: boolean;
/** 消息 */
message: string;
}
/**
* 通用响应接口
*/
export interface ApiResponse<T = any> {
/** 是否成功 */
success: boolean;
/** 响应数据 */
data?: T;
/** 消息 */
message: string;
/** 错误代码 */
error_code?: string;
}
@Injectable()
export class RegisterService {
private readonly logger = new Logger(RegisterService.name);
constructor(
private readonly loginCoreService: LoginCoreService,
private readonly zulipAccountService: ZulipAccountService,
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly accountProfileService: AccountProfileService,
) {}
/**
* 用户注册
*
* @param registerRequest 注册请求
* @returns 注册响应
*/
async register(registerRequest: RegisterRequest): Promise<ApiResponse<RegisterResponse>> {
const startTime = Date.now();
const operationId = `register_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
try {
this.logger.log(`开始用户注册流程`, {
operation: 'register',
operationId,
username: registerRequest.username,
email: registerRequest.email,
timestamp: new Date().toISOString(),
});
// 1. 初始化Zulip管理员客户端
const zulipUnavailableForLocalDebug = this.isZulipUnavailableForLocalDebug();
if (!zulipUnavailableForLocalDebug) {
await this.initializeZulipAdminClient();
} else {
this.logger.warn('本地调试模式跳过Zulip管理员客户端初始化', {
operation: 'register',
operationId,
});
}
// 2. 调用核心服务进行注册
const authResult = await this.loginCoreService.register(registerRequest);
// 3. 创建Zulip账号使用相同的邮箱和密码- 异步处理,不影响注册流程
if (registerRequest.email && registerRequest.password && !zulipUnavailableForLocalDebug) {
// 异步创建Zulip账号不阻塞注册流程
this.createZulipAccountWithRetry(
authResult.user,
registerRequest.password,
operationId
).then(success => {
if (success) {
this.logger.log(`Zulip账号异步创建成功`, {
operation: 'register',
operationId,
gameUserId: authResult.user.id.toString(),
email: registerRequest.email,
});
}
}).catch(err => {
// 错误已在重试方法中记录这里只是确保Promise不会未处理
this.logger.warn(`Zulip账号异步创建最终失败`, {
operation: 'register',
operationId,
gameUserId: authResult.user.id.toString(),
email: registerRequest.email,
});
});
this.logger.log(`Zulip账号创建已提交到后台异步处理`, {
operation: 'register',
operationId,
gameUserId: authResult.user.id.toString(),
email: registerRequest.email,
});
} else {
this.logger.log(`跳过Zulip账号创建缺少邮箱或密码`, {
operation: 'register',
username: registerRequest.username,
hasEmail: !!registerRequest.email,
hasPassword: !!registerRequest.password,
zulipUnavailableForLocalDebug,
});
}
// 4. 生成JWT令牌对
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
const profile = await this.accountProfileService.ensureProfile(authResult.user.id, registerRequest.skin_id);
await this.accountProfileService.sendWelcomeEmailAfterInitialCharacterCreation(authResult.user, profile);
// 5. 格式化响应数据
const response: RegisterResponse = {
user: this.formatUserInfo(authResult.user),
profile: (await this.accountProfileService.formatAccountProfileAsync(authResult.user, profile)).profile,
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: true,
message: MESSAGES.REGISTER_SUCCESS
};
const duration = Date.now() - startTime;
this.logger.log(`用户注册成功`, {
operation: 'register',
operationId,
gameUserId: authResult.user.id.toString(),
username: authResult.user.username,
email: authResult.user.email,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
data: response,
message: response.message
};
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error(`用户注册失败`, {
operation: 'register',
operationId,
username: registerRequest.username,
email: registerRequest.email,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
message: err.message || '注册失败',
error_code: ERROR_CODES.REGISTER_FAILED
};
}
}
/**
* 发送邮箱验证码
*
* @param email 邮箱地址
* @returns 响应结果
*/
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`发送邮箱验证码: ${email}`);
// 调用核心服务发送验证码
const result = await this.loginCoreService.sendEmailVerification(email);
this.logger.log(`邮箱验证码已发送: ${email}`);
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT);
} catch (error) {
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED
};
}
}
/**
* 验证邮箱验证码
*
* @param email 邮箱地址
* @param code 验证码
* @returns 响应结果
*/
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
try {
this.logger.log(`验证邮箱验证码: ${email}`);
// 调用核心服务验证验证码
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
if (isValid) {
this.logger.log(`邮箱验证成功: ${email}`);
return {
success: true,
message: MESSAGES.EMAIL_VERIFICATION_SUCCESS
};
} else {
return {
success: false,
message: MESSAGES.VERIFICATION_CODE_ERROR,
error_code: ERROR_CODES.INVALID_VERIFICATION_CODE
};
}
} catch (error) {
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '邮箱验证失败',
error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED
};
}
}
/**
* 重新发送邮箱验证码
*
* @param email 邮箱地址
* @returns 响应结果
*/
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`重新发送邮箱验证码: ${email}`);
// 调用核心服务重新发送验证码
const result = await this.loginCoreService.resendEmailVerification(email);
this.logger.log(`邮箱验证码已重新发送: ${email}`);
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT);
} catch (error) {
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '重新发送验证码失败',
error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED
};
}
}
/**
* 格式化用户信息
*
* @param user 用户实体
* @returns 格式化的用户信息
*/
private formatUserInfo(user: Users) {
return {
id: user.id.toString(), // 将bigint转换为字符串
username: user.username,
nickname: user.nickname,
email: user.email,
phone: user.phone,
avatar_url: user.avatar_url,
role: user.role,
created_at: user.created_at
};
}
/**
* 处理测试模式响应
*
* @param result 核心服务返回的结果
* @param successMessage 成功时的消息
* @param emailMessage 邮件发送成功时的消息
* @returns 格式化的响应
* @private
*/
private handleTestModeResponse(
result: { code: string; isTestMode: boolean },
successMessage: string,
emailMessage?: string
): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> {
if (result.isTestMode) {
return {
success: false,
data: {
verification_code: result.code,
is_test_mode: true
},
message: MESSAGES.TEST_MODE_WARNING,
error_code: ERROR_CODES.TEST_MODE_ONLY
};
} else {
return {
success: true,
data: {
is_test_mode: false
},
message: emailMessage || successMessage
};
}
}
/**
* 初始化Zulip管理员客户端
*
* 功能描述:
* 使用环境变量中的管理员凭证初始化Zulip客户端
*
* 业务逻辑:
* 1. 从环境变量获取管理员配置
* 2. 验证配置完整性
* 3. 初始化ZulipAccountService的管理员客户端
*
* @throws Error 当配置缺失或初始化失败时
* @private
*/
private async initializeZulipAdminClient(): Promise<void> {
try {
// 从环境变量获取管理员配置
const adminConfig = {
realm: process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '',
username: process.env.ZULIP_BOT_EMAIL || process.env.ZULIP_ADMIN_EMAIL || '',
apiKey: process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '',
};
// 验证配置完整性
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
throw new Error('Zulip管理员配置不完整请检查环境变量');
}
// 初始化管理员客户端
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
if (!initialized) {
throw new Error('Zulip管理员客户端初始化失败');
}
} catch (error) {
const err = error as Error;
this.logger.error('Zulip管理员客户端初始化失败', {
operation: 'initializeZulipAdminClient',
error: err.message,
}, err.stack);
throw error;
}
}
/**
* 本地调试时允许没有真实Zulip服务。
*
* @private
*/
private isZulipUnavailableForLocalDebug(): boolean {
const serverUrl = process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '';
const apiKey = process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '';
const degradedModeEnabled = process.env.ZULIP_DEGRADED_MODE_ENABLED === 'true';
const hasPlaceholderServer = serverUrl.includes('your-zulip-server.com');
const hasPlaceholderApiKey = !apiKey || apiKey === 'your_bot_api_key';
return degradedModeEnabled || hasPlaceholderServer || hasPlaceholderApiKey;
}
/**
* 带重试机制的异步创建Zulip账号
*
* 功能描述:
* 异步创建Zulip账号失败时自动重试最多3次
* 所有错误只记录日志,不影响用户注册流程
*
* @param gameUser 游戏用户信息
* @param password 用户密码
* @param operationId 操作ID用于日志追踪
* @param maxRetries 最大重试次数默认3次
* @returns Promise<boolean> 是否创建成功
* @private
*/
private async createZulipAccountWithRetry(
gameUser: Users,
password: string,
operationId: string,
maxRetries: number = 3
): Promise<boolean> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
this.logger.log(`尝试创建Zulip账号 (第${attempt}/${maxRetries}次)`, {
operation: 'createZulipAccountWithRetry',
operationId,
attempt,
maxRetries,
gameUserId: gameUser.id.toString(),
email: gameUser.email,
});
await this.createZulipAccountForUser(gameUser, password);
this.logger.log(`Zulip账号创建成功 (第${attempt}次尝试)`, {
operation: 'createZulipAccountWithRetry',
operationId,
attempt,
gameUserId: gameUser.id.toString(),
email: gameUser.email,
});
return true;
} catch (error) {
lastError = error as Error;
this.logger.warn(`Zulip账号创建失败 (第${attempt}/${maxRetries}次尝试)`, {
operation: 'createZulipAccountWithRetry',
operationId,
attempt,
maxRetries,
gameUserId: gameUser.id.toString(),
email: gameUser.email,
error: lastError.message,
});
// 如果不是最后一次尝试,等待后重试
if (attempt < maxRetries) {
const delayMs = attempt * 1000; // 递增延迟1秒、2秒、3秒
this.logger.log(`等待${delayMs}ms后重试`, {
operation: 'createZulipAccountWithRetry',
operationId,
attempt,
delayMs,
});
await this.delay(delayMs);
}
}
}
// 所有重试都失败
this.logger.error(`Zulip账号创建最终失败已尝试${maxRetries}`, {
operation: 'createZulipAccountWithRetry',
operationId,
maxRetries,
gameUserId: gameUser.id.toString(),
email: gameUser.email,
finalError: lastError?.message,
note: '用户注册已成功但Zulip账号创建失败。用户可以正常使用游戏但无法使用聊天功能。',
}, lastError?.stack);
return false;
}
/**
* 延迟工具方法
*
* @param ms 延迟毫秒数
* @returns Promise<void>
* @private
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 为用户创建或绑定Zulip账号
*
* 功能描述:
* 为新注册的游戏用户创建对应的Zulip账号或绑定已有账号并建立关联
*
* 业务逻辑:
* 1. 检查是否已存在Zulip账号关联
* 2. 尝试创建Zulip账号如果已存在则自动绑定
* 3. 获取或生成API Key并存储到Redis
* 4. 在数据库中创建关联记录
* 5. 建立内存关联(用于当前会话)
*
* @param gameUser 游戏用户信息
* @param password 用户密码(明文)
* @throws Error 当Zulip账号创建/绑定失败时
* @private
*/
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
const startTime = Date.now();
this.logger.log('开始为用户创建或绑定Zulip账号', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
email: gameUser.email,
nickname: gameUser.nickname,
});
try {
// 1. 检查是否已存在Zulip账号关联
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
if (existingAccount) {
this.logger.warn('用户已存在Zulip账号关联跳过创建', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
existingZulipUserId: existingAccount.zulipUserId,
});
return;
}
// 2. 尝试创建或绑定Zulip账号
const createResult = await this.zulipAccountService.createZulipAccount({
email: gameUser.email,
fullName: gameUser.nickname,
password: password,
});
if (!createResult.success) {
throw new Error(createResult.error || 'Zulip账号创建/绑定失败');
}
// 验证必须获取到 userId数据库字段 NOT NULL
if (createResult.userId === undefined || createResult.userId === null) {
throw new Error('Zulip账号创建成功但未能获取用户ID无法建立关联');
}
// 3. 处理API Key
let finalApiKey = createResult.apiKey;
// 如果是绑定已有账号但没有API Key尝试重新获取
if (createResult.isExistingUser && !finalApiKey) {
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
createResult.email!,
password
);
if (apiKeyResult.success) {
finalApiKey = apiKeyResult.apiKey;
} else {
this.logger.warn('无法获取已有Zulip账号的API Key', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
zulipEmail: createResult.email,
error: apiKeyResult.error,
});
}
}
// 4. 存储API Key到Redis
if (finalApiKey) {
await this.apiKeySecurityService.storeApiKey(
gameUser.id.toString(),
finalApiKey
);
}
// 5. 在数据库中创建关联记录
await this.zulipAccountsService.create({
gameUserId: gameUser.id.toString(),
zulipUserId: createResult.userId, // 已在上面验证不为 undefined
zulipEmail: createResult.email!,
zulipFullName: gameUser.nickname,
zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '',
status: 'active',
});
// 注意不在注册时建立内存关联Zulip客户端将在WebSocket连接时创建
const duration = Date.now() - startTime;
this.logger.log('Zulip账号创建/绑定和关联成功', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
zulipUserId: createResult.userId,
zulipEmail: createResult.email,
isExistingUser: createResult.isExistingUser,
hasApiKey: !!finalApiKey,
duration,
});
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('为用户创建/绑定Zulip账号失败', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
email: gameUser.email,
error: err.message,
duration,
}, err.stack);
// 清理可能创建的部分数据
try {
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
} catch (cleanupError) {
this.logger.warn('清理Zulip账号关联数据失败', {
operation: 'createZulipAccountForUser',
gameUserId: gameUser.id.toString(),
cleanupError: (cleanupError as Error).message,
});
}
throw error;
}
}
}