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,750 @@
/**
* 登录业务服务
*
* 功能描述:
* - 处理用户登录相关的业务逻辑和流程控制
* - 整合核心服务,提供完整的登录功能
* - 处理业务规则、数据格式化和错误处理
* - 管理JWT令牌刷新和验证码登录
*
* 职责分离:
* - 专注于登录业务流程和规则实现
* - 调用核心服务完成具体功能
* - 为控制器层提供登录业务接口
* - JWT技术实现已移至Core层符合架构分层原则
*
* 最近修改:
* - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块符合架构分层原则
*
* @author moyin
* @version 1.1.0
* @since 2025-12-17
* @lastModified 2026-01-12
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { LoginCoreService, LoginRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } 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 = {
LOGIN_FAILED: 'LOGIN_FAILED',
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
SEND_CODE_FAILED: 'SEND_CODE_FAILED',
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED',
VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
DEBUG_VERIFICATION_CODE_FAILED: 'DEBUG_VERIFICATION_CODE_FAILED',
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
} as const;
const MESSAGES = {
LOGIN_SUCCESS: '登录成功',
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
PASSWORD_RESET_SUCCESS: '密码重置成功',
PASSWORD_CHANGE_SUCCESS: '密码修改成功',
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
DEBUG_INFO_SUCCESS: '调试信息获取成功',
CODE_SENT: '验证码已发送,请查收',
VERIFICATION_CODE_ERROR: '验证码错误',
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
} as const;
// JWT相关接口已移至Core层通过import导入
/**
* 登录响应数据接口
*/
export interface LoginResponse {
/** 用户信息 */
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 LoginService {
private readonly logger = new Logger(LoginService.name);
constructor(
private readonly loginCoreService: LoginCoreService,
private readonly zulipAccountService: ZulipAccountService,
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly accountProfileService: AccountProfileService,
) {}
/**
* 用户登录
*
* 功能描述:
* 处理用户登录请求验证用户凭据并生成JWT令牌
*
* 业务逻辑:
* 1. 调用核心服务进行用户认证
* 2. 生成JWT访问令牌和刷新令牌
* 3. 记录登录日志和安全审计
* 4. 返回用户信息和令牌
*
* @param loginRequest 登录请求数据
* @returns Promise<ApiResponse<LoginResponse>> 登录响应
*
* @throws BadRequestException 当登录参数无效时
* @throws UnauthorizedException 当用户凭据错误时
* @throws InternalServerErrorException 当系统错误时
*/
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
const startTime = Date.now();
try {
this.logger.log('用户登录尝试', {
operation: 'login',
identifier: loginRequest.identifier,
timestamp: new Date().toISOString(),
});
// 1. 调用核心服务进行认证
const authResult = await this.loginCoreService.login(loginRequest);
// 2. 验证和更新Zulip API Key如果用户有Zulip账号关联
try {
const isZulipValid = await this.validateAndUpdateZulipApiKey(authResult.user);
if (!isZulipValid) {
// 尝试重新生成API Key需要密码
const regenerated = await this.regenerateZulipApiKey(authResult.user, loginRequest.password);
if (regenerated) {
this.logger.log('用户Zulip API Key已重新生成', {
operation: 'login',
userId: authResult.user.id.toString(),
});
} else {
this.logger.warn('用户Zulip API Key重新生成失败', {
operation: 'login',
userId: authResult.user.id.toString(),
});
}
}
} catch (zulipError) {
// Zulip验证失败不影响登录流程只记录日志
const err = zulipError as Error;
this.logger.warn('Zulip API Key验证失败但不影响登录', {
operation: 'login',
userId: authResult.user.id.toString(),
zulipError: err.message,
});
}
// 3. 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
const profile = await this.accountProfileService.ensureProfile(authResult.user.id);
// 4. 格式化响应数据
const response: LoginResponse = {
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: authResult.isNewUser,
message: MESSAGES.LOGIN_SUCCESS
};
const duration = Date.now() - startTime;
this.logger.log('用户登录成功', {
operation: 'login',
userId: authResult.user.id.toString(),
username: authResult.user.username,
isNewUser: authResult.isNewUser,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
data: response,
message: MESSAGES.LOGIN_SUCCESS
};
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('用户登录失败', {
operation: 'login',
identifier: loginRequest.identifier,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
message: err.message || '登录失败',
error_code: ERROR_CODES.LOGIN_FAILED
};
}
}
/**
* GitHub OAuth登录
*
* @param oauthRequest OAuth请求
* @returns 登录响应
*/
async githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<ApiResponse<LoginResponse>> {
try {
this.logger.log(`GitHub OAuth登录尝试: ${oauthRequest.github_id}`);
// 调用核心服务进行OAuth认证
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
// 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: authResult.isNewUser ? MESSAGES.GITHUB_BIND_SUCCESS : MESSAGES.GITHUB_LOGIN_SUCCESS
};
this.logger.log(`GitHub OAuth成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
return {
success: true,
data: response,
message: response.message
};
} catch (error) {
this.logger.error(`GitHub OAuth失败: ${oauthRequest.github_id}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : 'GitHub登录失败',
error_code: ERROR_CODES.GITHUB_OAUTH_FAILED
};
}
}
/**
* 发送密码重置验证码
*
* @param identifier 邮箱或手机号
* @returns 响应结果
*/
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`发送密码重置验证码: ${identifier}`);
// 调用核心服务发送验证码
const result = await this.loginCoreService.sendPasswordResetCode(identifier);
this.logger.log(`密码重置验证码已发送: ${identifier}`);
// 处理测试模式响应
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: MESSAGES.CODE_SENT
};
}
} catch (error) {
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: ERROR_CODES.SEND_CODE_FAILED
};
}
}
/**
* 重置密码
*
* @param resetRequest 重置请求
* @returns 响应结果
*/
async resetPassword(resetRequest: PasswordResetRequest): Promise<ApiResponse> {
try {
this.logger.log(`密码重置尝试: ${resetRequest.identifier}`);
// 调用核心服务重置密码
await this.loginCoreService.resetPassword(resetRequest);
this.logger.log(`密码重置成功: ${resetRequest.identifier}`);
return {
success: true,
message: MESSAGES.PASSWORD_RESET_SUCCESS
};
} catch (error) {
this.logger.error(`密码重置失败: ${resetRequest.identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '密码重置失败',
error_code: ERROR_CODES.RESET_PASSWORD_FAILED
};
}
}
/**
* 修改密码
*
* @param userId 用户ID
* @param oldPassword 旧密码
* @param newPassword 新密码
* @returns 响应结果
*/
async changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<ApiResponse> {
try {
this.logger.log(`修改密码尝试: 用户ID ${userId}`);
// 调用核心服务修改密码
await this.loginCoreService.changePassword(userId, oldPassword, newPassword);
this.logger.log(`修改密码成功: 用户ID ${userId}`);
return {
success: true,
message: MESSAGES.PASSWORD_CHANGE_SUCCESS
};
} catch (error) {
this.logger.error(`修改密码失败: 用户ID ${userId}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '密码修改失败',
error_code: ERROR_CODES.CHANGE_PASSWORD_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 loginRequest 验证码登录请求
* @returns 登录响应
*/
async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise<ApiResponse<LoginResponse>> {
try {
this.logger.log(`验证码登录尝试: ${loginRequest.identifier}`);
// 调用核心服务进行验证码认证
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
// 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
};
this.logger.log(`验证码登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
return {
success: true,
data: response,
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
};
} catch (error) {
this.logger.error(`验证码登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '验证码登录失败',
error_code: ERROR_CODES.VERIFICATION_CODE_LOGIN_FAILED
};
}
}
/**
* 发送登录验证码
*
* @param identifier 邮箱或手机号
* @returns 响应结果
*/
async sendLoginVerificationCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`发送登录验证码: ${identifier}`);
// 调用核心服务发送验证码
const result = await this.loginCoreService.sendLoginVerificationCode(identifier);
this.logger.log(`登录验证码已发送: ${identifier}`);
// 处理测试模式响应
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: MESSAGES.CODE_SENT
};
}
} catch (error) {
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: ERROR_CODES.SEND_LOGIN_CODE_FAILED
};
}
}
/**
* 刷新访问令牌
*
* 功能描述:
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
*
* 业务逻辑:
* 1. 验证刷新令牌的有效性和格式
* 2. 检查用户状态是否正常
* 3. 生成新的JWT令牌对
* 4. 返回新的访问令牌和刷新令牌
*
* @param refreshToken 刷新令牌字符串
* @returns Promise<ApiResponse<TokenPair>> 新的令牌对
*
* @throws UnauthorizedException 当刷新令牌无效或已过期时
* @throws NotFoundException 当用户不存在或已被禁用时
*
* @example
* ```typescript
* const result = await loginService.refreshAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
* ```
*/
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
try {
this.logger.log(`刷新访问令牌尝试`);
// 调用核心服务刷新令牌
const tokenPair = await this.loginCoreService.refreshAccessToken(refreshToken);
this.logger.log(`访问令牌刷新成功`);
return {
success: true,
data: tokenPair,
message: MESSAGES.TOKEN_REFRESH_SUCCESS
};
} catch (error) {
this.logger.error(`访问令牌刷新失败`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '令牌刷新失败',
error_code: ERROR_CODES.TOKEN_REFRESH_FAILED
};
}
}
/**
* 调试验证码信息
* 仅用于开发和调试
*
* @param email 邮箱地址
* @returns 验证码调试信息
*/
async debugVerificationCode(email: string): Promise<any> {
try {
this.logger.log(`调试验证码信息: ${email}`);
const debugInfo = await this.loginCoreService.debugVerificationCode(email);
return {
success: true,
data: debugInfo,
message: MESSAGES.DEBUG_INFO_SUCCESS
};
} 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.DEBUG_VERIFICATION_CODE_FAILED
};
}
}
/**
* 验证并更新用户的Zulip API Key
*
* 功能描述:
* 在用户登录时验证其Zulip账号的API Key是否有效如果无效则重新获取
*
* 业务逻辑:
* 1. 查找用户的Zulip账号关联
* 2. 从Redis获取API Key
* 3. 验证API Key是否有效
* 4. 如果无效重新生成API Key并更新存储
*
* @param user 用户信息
* @returns Promise<boolean> 是否验证/更新成功
* @private
*/
private async validateAndUpdateZulipApiKey(user: Users): Promise<boolean> {
const startTime = Date.now();
this.logger.log('开始验证用户Zulip API Key', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
username: user.username,
email: user.email,
});
try {
// 1. 查找用户的Zulip账号关联
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
if (!zulipAccount) {
this.logger.log('用户没有Zulip账号关联跳过验证', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
});
return true; // 没有关联不算错误
}
// 2. 从Redis获取API Key
const apiKeyResult = await this.apiKeySecurityService.getApiKey(user.id.toString());
if (!apiKeyResult.success || !apiKeyResult.apiKey) {
this.logger.warn('用户Zulip API Key不存在需要重新生成', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
error: apiKeyResult.message,
});
return false; // 需要重新生成
}
// 3. 验证API Key是否有效
const validationResult = await this.zulipAccountService.validateZulipAccount(
zulipAccount.zulipEmail,
apiKeyResult.apiKey
);
if (validationResult.success && validationResult.isValid) {
this.logger.log('用户Zulip API Key验证成功', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
});
return true;
}
// 4. API Key无效需要重新生成
this.logger.warn('用户Zulip API Key无效需要重新生成', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
validationError: validationResult.error,
});
return false; // 需要重新生成
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('验证用户Zulip API Key失败', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
error: err.message,
duration,
}, err.stack);
return false;
}
}
/**
* 重新生成并更新用户的Zulip API Key
*
* 功能描述:
* 使用用户密码重新生成Zulip API Key并更新存储
*
* @param user 用户信息
* @param password 用户密码(明文)
* @returns Promise<boolean> 是否更新成功
* @private
*/
private async regenerateZulipApiKey(user: Users, password: string): Promise<boolean> {
const startTime = Date.now();
this.logger.log('开始重新生成用户Zulip API Key', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
email: user.email,
});
try {
// 1. 查找用户的Zulip账号关联
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
if (!zulipAccount) {
this.logger.warn('用户没有Zulip账号关联无法重新生成API Key', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
});
return false;
}
// 2. 重新生成API Key
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
zulipAccount.zulipEmail,
password
);
if (!apiKeyResult.success) {
this.logger.error('重新生成Zulip API Key失败', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
error: apiKeyResult.error,
});
return false;
}
// 3. 更新Redis中的API Key
await this.apiKeySecurityService.storeApiKey(
user.id.toString(),
apiKeyResult.apiKey!
);
// 注意不在登录时建立内存关联Zulip客户端将在WebSocket连接时创建
const duration = Date.now() - startTime;
this.logger.log('重新生成Zulip API Key成功', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
duration,
});
return true;
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('重新生成Zulip API Key失败', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
error: err.message,
duration,
}, err.stack);
return false;
}
}
}