Files
whale-town-end-v2/src/business/auth/register.service.ts

714 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 注册业务服务
*
* 功能描述:
* - 处理用户注册相关的业务逻辑和流程控制
* - 整合核心服务,提供完整的注册功能
* - 处理业务规则、数据格式化和错误处理
* - 集成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, Optional } 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 { InvitationCodesService } from '../invitation/invitation_codes.service';
import { InvitationCode } from '../invitation/invitation_code.entity';
// 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,
@Optional() private readonly invitationCodesService?: InvitationCodesService,
) {}
/**
* 用户注册
*
* @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)}`;
let reservation: InvitationCode | undefined;
let userCreated = false;
try {
if (this.invitationCodesService) {
reservation = await this.invitationCodesService.reserve(registerRequest.invitation_code || '');
}
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);
userCreated = true;
if (reservation) {
try {
await this.invitationCodesService!.recordUsage(reservation.id, authResult.user.id, registerRequest.email || '');
} catch (error) {
this.logger.error('记录邀请码使用明细失败,不中断已完成的用户注册', {
operation: 'register',
operationId,
invitationCodeId: reservation.id.toString(),
gameUserId: authResult.user.id.toString(),
error: error instanceof Error ? error.message : String(error),
});
}
}
// 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) {
if (reservation && !userCreated) {
await this.invitationCodesService?.release(reservation.id).catch(() => undefined);
}
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, invitationCode: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
if (this.invitationCodesService) await this.invitationCodesService.validate(invitationCode);
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;
}
}
}