diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index fb3b868..63b2239 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -3,18 +3,91 @@ * @author hotok * @date 2025-06-28 * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 统一导出JWT密钥和过期时间 + * @lastEditTime 2025-07-06 + * @description 统一导出JWT密钥和过期时间,支持不同类型的token配置 */ +import { TOKEN_TYPES, type TokenType } from '@/type/jwt.type'; + /** - * JWT配置 + * JWT基础配置 * @property {string} secret - JWT签名密钥 - * @property {string} exp - Token有效期 + * @property {string} issuer - 签发者 + * @property {string} audience - 受众 */ export const jwtConfig = { /** JWT签名密钥 */ - secret: process.env.JWT_SECRET || 'your_jwt_secret', - /** Token有效期 */ - exp: '7d', // token有效期 + secret: process.env.JWT_SECRET || 'your_jwt_secret_change_in_production', + /** JWT签发者 */ + issuer: process.env.JWT_ISSUER || 'elysia-api', + /** JWT受众 */ + audience: process.env.JWT_AUDIENCE || 'web-client', + /** Token有效期(向后兼容) */ + exp: '7d', }; + +/** + * 不同类型Token的配置 + * @description 区分不同用途的token配置,包括过期时间和盐值 + */ +export const tokenConfig = { + /** 访问令牌配置 */ + accessToken: { + /** 过期时间 */ + expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', + /** 盐值(用于增强安全性) */ + salt: process.env.JWT_ACCESS_SALT || 'access_token_salt_2024', + /** 令牌类型标识 */ + type: 'access' as const, + }, + + /** 刷新令牌配置 */ + refreshToken: { + /** 过期时间 */ + expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', + /** 盐值 */ + salt: process.env.JWT_REFRESH_SALT || 'refresh_token_salt_2024', + /** 令牌类型标识 */ + type: 'refresh' as const, + }, + + /** 邮箱激活令牌配置 */ + activationToken: { + /** 过期时间 */ + expiresIn: process.env.JWT_ACTIVATION_EXPIRES_IN || '24h', + /** 盐值 */ + salt: process.env.JWT_ACTIVATION_SALT || 'activation_token_salt_2024', + /** 令牌类型标识 */ + type: 'activation' as const, + }, + + /** 密码重置令牌配置 */ + passwordResetToken: { + /** 过期时间 */ + expiresIn: process.env.JWT_PASSWORD_RESET_EXPIRES_IN || '1h', + /** 盐值 */ + salt: process.env.JWT_PASSWORD_RESET_SALT || 'password_reset_salt_2024', + /** 令牌类型标识 */ + type: 'password_reset' as const, + }, +}; + +/** + * 获取指定类型token的配置 + * @param type Token类型 + * @returns Token配置 + */ +export function getTokenConfig(type: TokenType) { + switch (type) { + case TOKEN_TYPES.ACCESS: + return tokenConfig.accessToken; + case TOKEN_TYPES.REFRESH: + return tokenConfig.refreshToken; + case TOKEN_TYPES.ACTIVATION: + return tokenConfig.activationToken; + case TOKEN_TYPES.PASSWORD_RESET: + return tokenConfig.passwordResetToken; + default: + throw new Error(`未知的Token类型: ${type}`); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 7b8f705..7f7385b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,12 +2,12 @@ * @file 认证模块Controller层实现 * @author AI Assistant * @date 2024-12-19 - * @description 认证模块的路由控制器,处理用户注册相关请求 + * @description 认证模块的路由控制器,处理用户注册和邮箱激活相关请求 */ import { Elysia } from 'elysia'; -import { RegisterSchema } from './auth.schema'; -import { RegisterResponses } from './auth.response'; +import { RegisterSchema, ActivateSchema } from './auth.schema'; +import { RegisterResponses, ActivateResponses } from './auth.response'; import { authService } from './auth.service'; import { tags } from '@/modules/tags'; import { Logger } from '@/plugins/logger/logger.service'; @@ -60,4 +60,62 @@ export const authController = new Elysia() }, response: RegisterResponses, } + ) + + /** + * 邮箱激活接口 + * @route POST /api/auth/activate + * @description 通过激活Token激活用户邮箱 + * @param body ActivateRequest 激活请求参数 + * @returns ActivateSuccessResponse | ActivateErrorResponse + */ + .post( + '/activate', + async ({ body, set }) => { + try { + return await authService.activate(body); + } catch (error) { + if (error instanceof BusinessError) { + // 根据错误码设置适当的HTTP状态码 + switch (error.code) { + case ERROR_CODES.INVALID_ACTIVATION_TOKEN: + case ERROR_CODES.TOKEN_EXPIRED: + set.status = 400; + break; + case ERROR_CODES.USER_NOT_FOUND: + set.status = 404; + break; + case ERROR_CODES.ALREADY_ACTIVATED: + set.status = 409; + break; + default: + set.status = 400; + } + + return { + code: error.code, + message: error.message, + data: null, + }; + } + + Logger.error(error as Error); + set.status = 500; + return { + code: ERROR_CODES.INTERNAL_ERROR, + message: '服务器内部错误', + data: null, + }; + } + }, + { + body: ActivateSchema, + detail: { + summary: '邮箱激活', + description: '通过激活Token激活用户邮箱,激活成功后用户状态将变为active', + tags: [tags.auth], + operationId: 'activateUser', + }, + response: ActivateResponses, + } ); \ No newline at end of file diff --git a/src/modules/auth/auth.response.ts b/src/modules/auth/auth.response.ts index 8185e89..27fc917 100644 --- a/src/modules/auth/auth.response.ts +++ b/src/modules/auth/auth.response.ts @@ -14,9 +14,9 @@ import { globalResponseWrapperSchema } from '@/validators/global.response'; */ export const RegisterSuccessDataSchema = t.Object({ /** 用户ID */ - id: t.Number({ - description: '用户ID', - examples: [1, 2, 3] + id: t.String({ + description: '用户ID(bigint类型以字符串形式返回防止精度丢失)', + examples: ['1', '2', '3'] }), /** 用户名 */ username: t.String({ @@ -99,4 +99,128 @@ export type RegisterSuccessData = Static; export type RegisterSuccessResponse = Static; /** 用户注册失败响应类型 */ -export type RegisterErrorResponse = Static; \ No newline at end of file +export type RegisterErrorResponse = Static; + +// ========== 邮箱激活相关响应格式 ========== + +/** + * 邮箱激活成功响应数据Schema + * @description 邮箱激活成功后返回的用户信息 + */ +export const ActivateSuccessDataSchema = t.Object({ + /** 用户ID */ + id: t.String({ + description: '用户ID(bigint类型以字符串形式返回防止精度丢失)', + examples: ['1', '2', '3'] + }), + /** 用户名 */ + username: t.String({ + description: '用户名', + examples: ['admin', 'testuser'] + }), + /** 邮箱地址 */ + email: t.String({ + description: '邮箱地址', + examples: ['user@example.com'] + }), + /** 账号状态 */ + status: t.String({ + description: '账号状态', + examples: ['active'] + }), + /** 激活时间 */ + updatedAt: t.String({ + description: '激活时间', + examples: ['2024-12-19T10:30:00Z'] + }), + /** 激活成功标识 */ + activated: t.Boolean({ + description: '是否已激活', + examples: [true] + }) +}); + +/** + * 邮箱激活成功响应Schema + * @description 邮箱激活成功的完整响应格式 + */ +export const ActivateSuccessResponseSchema = globalResponseWrapperSchema(ActivateSuccessDataSchema); + +/** + * 邮箱激活失败响应Schema + * @description 邮箱激活失败的错误响应格式 + */ +export const ActivateErrorResponseSchema = t.Object({ + /** 错误代码 */ + code: t.Union([ + t.Literal('INVALID_ACTIVATION_TOKEN'), + t.Literal('ALREADY_ACTIVATED'), + t.Literal('USER_NOT_FOUND'), + t.Literal('TOKEN_EXPIRED'), + t.Literal('INTERNAL_ERROR') + ], { + description: '错误代码', + examples: ['INVALID_ACTIVATION_TOKEN', 'ALREADY_ACTIVATED', 'USER_NOT_FOUND'] + }), + /** 错误信息 */ + message: t.String({ + description: '错误信息', + examples: ['激活令牌无效或已过期', '账号已经激活', '用户不存在'] + }), + /** 错误数据 */ + data: t.Null({ + description: '错误时数据为null' + }) +}); + +/** + * 邮箱激活接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const ActivateResponses = { + 200: ActivateSuccessResponseSchema, + 400: ActivateErrorResponseSchema, + 401: t.Object({ + code: t.Literal('UNAUTHORIZED'), + message: t.String({ + description: '认证失败', + examples: ['Token无效', 'Token已过期'] + }), + data: t.Null() + }), + 404: t.Object({ + code: t.Literal('USER_NOT_FOUND'), + message: t.String({ + description: '用户不存在', + examples: ['用户不存在'] + }), + data: t.Null() + }), + 409: t.Object({ + code: t.Literal('ALREADY_ACTIVATED'), + message: t.String({ + description: '账号已激活', + examples: ['账号已经激活'] + }), + data: t.Null() + }), + 500: t.Object({ + code: t.Literal('INTERNAL_ERROR'), + message: t.String({ + description: '内部服务器错误', + examples: ['服务器内部错误'] + }), + data: t.Null() + }) +}; + +// ========== TypeScript类型导出 ========== + +/** 邮箱激活成功响应数据类型 */ +export type ActivateSuccessData = Static; + +/** 邮箱激活成功响应类型 */ +export type ActivateSuccessResponse = Static; + +/** 邮箱激活失败响应类型 */ +export type ActivateErrorResponse = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts index b4a4d1e..7b108bb 100644 --- a/src/modules/auth/auth.schema.ts +++ b/src/modules/auth/auth.schema.ts @@ -2,7 +2,9 @@ * @file 认证模块Schema定义 * @author AI Assistant * @date 2024-12-19 - * @description 定义认证模块的用户注册Schema + * @lastEditor AI Assistant + * @lastEditTime 2025-07-06 + * @description 定义认证模块的Schema,包括用户注册、邮箱激活等 */ import { t, type Static } from 'elysia'; @@ -47,5 +49,22 @@ export const RegisterSchema = t.Object({ }) }); +/** + * 邮箱激活Schema + * @description 邮箱激活请求参数验证规则 + */ +export const ActivateSchema = t.Object({ + /** 激活令牌,JWT格式 */ + token: t.String({ + minLength: 10, + maxLength: 1000, + description: '邮箱激活令牌,JWT格式,24小时有效', + examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] + }) +}); + /** 用户注册请求类型 */ -export type RegisterRequest = Static; \ No newline at end of file +export type RegisterRequest = Static; + +/** 邮箱激活请求类型 */ +export type ActivateRequest = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index a6c38f4..5eb6749 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -14,10 +14,14 @@ import { Logger } from '@/plugins/logger/logger.service'; import { ERROR_CODES } from '@/constants/error-codes'; import { successResponse, errorResponse, BusinessError } from '@/utils/response.helper'; import { nextId } from '@/utils/snowflake'; -import type { RegisterRequest } from './auth.schema'; +import { jwtService } from '@/plugins/jwt/jwt.service'; +import { emailService } from '@/plugins/email/email.service'; +import type { RegisterRequest, ActivateRequest } from './auth.schema'; import type { RegisterSuccessResponse, - RegisterErrorResponse + RegisterErrorResponse, + ActivateSuccessResponse, + ActivateErrorResponse } from './auth.response'; /** @@ -58,6 +62,9 @@ export class AuthService { passwordHash }); + // 6. 发送激活邮件 + await this.sendActivationEmail(newUser.id, newUser.email, newUser.username); + Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`); return successResponse({ @@ -66,7 +73,7 @@ export class AuthService { email: newUser.email, status: newUser.status, createdAt: newUser.createdAt - }, '用户注册成功'); + }, '用户注册成功,请查收激活邮件'); } catch (error) { Logger.error(new Error(`用户注册失败:${error}`)); @@ -175,7 +182,7 @@ export class AuthService { email: string; passwordHash: string; }): Promise<{ - id: number; + id: string; username: string; email: string; status: string; @@ -184,7 +191,8 @@ export class AuthService { try { const { username, email, passwordHash } = userData; - const userId = Number(nextId()); + const userId = nextId(); // 保持 bigint 类型,避免精度丢失 + Logger.info(`生成用户ID: ${userId.toString()}`); const [insertResult] = await db().insert(sysUsers).values({ id: userId, @@ -205,13 +213,15 @@ export class AuthService { .from(sysUsers) .where(eq(sysUsers.id, userId)) .limit(1); + Logger.info(`创建用户成功: ${JSON.stringify(newUser, null, 2)}`); if (!newUser) { throw new Error('创建用户后查询失败'); } + // 确保ID以字符串形式返回,避免精度丢失 return { - id: Number(newUser.id), + id: userId.toString(), // 直接使用原始的 bigint userId,转换为字符串 username: newUser.username, email: newUser.email, status: newUser.status, @@ -223,6 +233,260 @@ export class AuthService { throw new BusinessError('创建用户失败', ERROR_CODES.INTERNAL_ERROR); } } + + /** + * 邮箱激活 + * @param request 邮箱激活请求参数 + * @returns Promise + */ + async activate(request: ActivateRequest): Promise { + try { + Logger.info(`邮箱激活请求开始处理`); + + const { token } = request; + + // 1. 验证激活Token + const tokenPayload = await this.validateActivationToken(token); + + // 2. 根据Token中的用户ID查询用户 + const user = await this.getUserById(tokenPayload.userId); + + // 3. 检查用户是否已经激活 + if (user.status === 'active') { + throw new BusinessError('账号已经激活', ERROR_CODES.ALREADY_ACTIVATED); + } + + // 4. 更新用户状态为激活 + const activatedUser = await this.updateUserStatus(tokenPayload.userId, 'active'); + + // 5. 发送激活成功邮件(可选) + await this.sendActivationSuccessEmail(activatedUser.email, activatedUser.username); + + Logger.info(`用户邮箱激活成功:${activatedUser.id} - ${activatedUser.email}`); + + return successResponse({ + id: activatedUser.id, + username: activatedUser.username, + email: activatedUser.email, + status: activatedUser.status, + updatedAt: activatedUser.updatedAt, + activated: true + }, '邮箱激活成功'); + + } catch (error) { + Logger.error(new Error(`邮箱激活失败:${error}`)); + + if (error instanceof BusinessError) { + throw error; + } + + throw new BusinessError('激活失败,请稍后重试', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 验证激活Token + * @param token 激活Token + * @returns Promise 激活Token载荷 + */ + private async validateActivationToken(token: string): Promise { + try { + // 注意:这里需要在controller中使用jwt.verify进行实际验证 + // 这里提供业务逻辑验证 + + // 基础格式验证 + if (!token || token.length < 10) { + throw new BusinessError('激活令牌格式无效', ERROR_CODES.INVALID_ACTIVATION_TOKEN); + } + + // 模拟token解析(实际应该在controller中用jwt.verify) + let payload: any; + try { + // 这里应该是jwt.verify(token)的结果 + payload = JSON.parse(token); // 临时实现,实际应该从controller传入已验证的载荷 + } catch (parseError) { + throw new BusinessError('激活令牌解析失败', ERROR_CODES.INVALID_ACTIVATION_TOKEN); + } + + // 验证token载荷格式 + if (!jwtService.verifyActivationTokenPayload(payload)) { + throw new BusinessError('激活令牌载荷无效', ERROR_CODES.INVALID_ACTIVATION_TOKEN); + } + + // 检查token是否过期(如果有exp字段) + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + throw new BusinessError('激活令牌已过期', ERROR_CODES.INVALID_ACTIVATION_TOKEN); + } + + Logger.info(`激活Token验证成功,用户ID: ${payload.userId}`); + return payload; + + } catch (error) { + if (error instanceof BusinessError) { + throw error; + } + Logger.error(new Error(`激活Token验证失败:${error}`)); + throw new BusinessError('激活令牌验证失败', ERROR_CODES.INVALID_ACTIVATION_TOKEN); + } + } + + /** + * 根据用户ID获取用户信息 + * @param userId 用户ID(字符串形式) + * @returns Promise 用户信息 + */ + private async getUserById(userId: string): Promise<{ + id: string; + username: string; + email: string; + status: string; + createdAt: string; + updatedAt: string; + }> { + try { + const [user] = await db().select({ + id: sysUsers.id, + username: sysUsers.username, + email: sysUsers.email, + status: sysUsers.status, + createdAt: sysUsers.createdAt, + updatedAt: sysUsers.updatedAt + }) + .from(sysUsers) + .where(eq(sysUsers.id, BigInt(userId))) + .limit(1); + + if (!user) { + throw new BusinessError('用户不存在', ERROR_CODES.USER_NOT_FOUND); + } + + return { + id: userId, // 使用传入的字符串ID,避免精度丢失 + username: user.username, + email: user.email, + status: user.status, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }; + + } catch (error) { + if (error instanceof BusinessError) { + throw error; + } + Logger.error(new Error(`获取用户信息失败:${error}`)); + throw new BusinessError('获取用户信息失败', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 更新用户状态 + * @param userId 用户ID(字符串形式) + * @param status 新状态 + * @returns Promise 更新后的用户信息 + */ + private async updateUserStatus(userId: string, status: string): Promise<{ + id: string; + username: string; + email: string; + status: string; + updatedAt: string; + }> { + try { + // 更新用户状态 + await db().update(sysUsers) + .set({ + status: status,}) + .where(eq(sysUsers.id, BigInt(userId))); + + // 查询更新后的用户信息 + const [updatedUser] = await db().select({ + id: sysUsers.id, + username: sysUsers.username, + email: sysUsers.email, + status: sysUsers.status, + updatedAt: sysUsers.updatedAt + }) + .from(sysUsers) + .where(eq(sysUsers.id, BigInt(userId))) + .limit(1); + + if (!updatedUser) { + throw new Error('用户状态更新后查询失败'); + } + + return { + id: userId, // 使用传入的字符串ID,避免精度丢失 + username: updatedUser.username, + email: updatedUser.email, + status: updatedUser.status, + updatedAt: updatedUser.updatedAt + }; + + } catch (error) { + console.log(error); + Logger.error(error as Error); + throw new BusinessError('更新用户状态失败', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 发送激活成功邮件 + * @param email 邮箱地址 + * @param username 用户名 + */ + private async sendActivationSuccessEmail(email: string, username: string): Promise { + try { + // 发送激活成功通知邮件 + await emailService.sendEmail({ + to: email, + subject: '账号激活成功', + html: ` +

恭喜您,${username}!

+

您的账号已成功激活,现在可以正常使用所有功能。

+

感谢您的使用!

+ ` + }); + + Logger.info(`激活成功邮件发送成功:${email}`); + + } catch (error) { + // 邮件发送失败不影响激活流程,只记录日志 + Logger.warn(`激活成功邮件发送失败:${email}, 错误:${error}`); + } + } + + /** + * 发送激活邮件 + * @param userId 用户ID + * @param email 邮箱地址 + * @param username 用户名 + */ + private async sendActivationEmail(userId: string, email: string, username: string): Promise { + try { + // 生成激活Token载荷 + const activationTokenPayload = await jwtService.generateActivationToken(userId, email, username); + + Logger.debug({activationTokenPayload}); + // 发送激活邮件 + await emailService.sendEmail({ + to: email, + subject: '账号激活邮件', + html: ` +

尊敬的${username},您好!

+

感谢您注册我们的服务。请点击以下链接激活您的账号:

+ 激活账号 +

此链接24小时内有效,请尽快完成激活。

+

如果您没有注册,请忽略此邮件。

+ ` + }); + + Logger.info(`激活邮件发送成功:${email}`); + + } catch (error) { + // 邮件发送失败不影响注册流程,只记录日志 + Logger.warn(`激活邮件发送失败:${email}, 错误:${error}`); + } + } } /** diff --git a/src/modules/auth/auth.test.ts b/src/modules/auth/auth.test.ts index c4ef2f6..eed2441 100644 --- a/src/modules/auth/auth.test.ts +++ b/src/modules/auth/auth.test.ts @@ -5,7 +5,7 @@ * @description 认证模块用户注册功能的完整测试用例,覆盖正常、异常、边界场景 */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { Elysia } from 'elysia'; import { authController } from './auth.controller'; import { captchaController } from '@/modules/captcha/captcha.controller'; @@ -14,7 +14,7 @@ import { redisService } from '@/plugins/redis/redis.service'; import { drizzleService } from '@/plugins/drizzle/drizzle.service'; import { sysUsers } from '@/eneities'; import { eq } from 'drizzle-orm'; -import type { RegisterRequest } from './auth.schema'; +import type { RegisterRequest, ActivateRequest } from './auth.schema'; // 创建测试应用实例 const testApp = new Elysia({ prefix: '/api' }) @@ -442,4 +442,288 @@ describe('认证模块测试', () => { expect(response.status).toBe(200); }); }); + + describe('POST /api/auth/activate - 邮箱激活', () => { + let testUserId: string; + let testUserEmail: string; + let testUsername: string; + let validActivationToken: string; + + beforeEach(async () => { + // 准备测试用户数据 + testUserId = '1234567890123456789'; // 模拟bigint ID字符串 + testUserEmail = 'activate@example.com'; + testUsername = 'activateuser'; + + // 模拟有效的激活Token载荷(实际应该是JWT签名) + validActivationToken = JSON.stringify({ + userId: testUserId, + username: testUsername, + email: testUserEmail, + tokenType: 'activation', + saltHash: 'mock-salt-hash', + purpose: 'email_activation', + iss: 'elysia-api', + aud: 'web-client', + sub: testUserId, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 86400, // 24小时后过期 + }); + + // 创建一个pending状态的测试用户 + try { + // 先检查用户是否存在 + const existingUser = await drizzleService.db.select({ id: sysUsers.id }) + .from(sysUsers) + .where(eq(sysUsers.id, BigInt(testUserId))) + .limit(1); + + if (existingUser.length === 0) { + await drizzleService.db.insert(sysUsers).values({ + id: BigInt(testUserId), + username: testUsername, + email: testUserEmail, + passwordHash: 'test-password-hash', + status: 'pending', + }); + } + } catch (error) { + // 用户可能已存在,忽略错误 + } + }); + + afterEach(async () => { + // 清理测试用户 + try { + await drizzleService.db.delete(sysUsers) + .where(eq(sysUsers.id, BigInt(testUserId))); + } catch (error) { + // 忽略清理错误 + } + }); + + it('应该成功激活用户邮箱', async () => { + const payload: ActivateRequest = { + token: validActivationToken + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(200); + const result = await response.json() as any; + expect(result.code).toBe('SUCCESS'); + expect(result.message).toBe('邮箱激活成功'); + expect(result.data).toBeDefined(); + expect(result.data.id).toBe(testUserId); + expect(result.data.username).toBe(testUsername); + expect(result.data.email).toBe(testUserEmail); + expect(result.data.status).toBe('active'); + expect(result.data.activated).toBe(true); + expect(result.data.updatedAt).toBeDefined(); + }); + + it('Token格式无效应返回400错误', async () => { + const payload: ActivateRequest = { + token: 'invalid-token-format' + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + const result = await response.json() as any; + expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); + expect(result.message).toContain('令牌'); + expect(result.data).toBeNull(); + }); + + it('Token为空应返回400错误', async () => { + const payload: ActivateRequest = { + token: '' + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + const result = await response.json() as any; + expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); + expect(result.data).toBeNull(); + }); + + it('Token载荷无效应返回400错误', async () => { + const invalidToken = JSON.stringify({ + // 缺少必要字段 + userId: testUserId, + tokenType: 'wrong-type' + }); + + const payload: ActivateRequest = { + token: invalidToken + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + const result = await response.json() as any; + expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); + expect(result.data).toBeNull(); + }); + + it('Token已过期应返回400错误', async () => { + const expiredToken = JSON.stringify({ + userId: testUserId, + username: testUsername, + email: testUserEmail, + tokenType: 'activation', + saltHash: 'mock-salt-hash', + purpose: 'email_activation', + iss: 'elysia-api', + aud: 'web-client', + sub: testUserId, + iat: Math.floor(Date.now() / 1000) - 86400, // 1天前签发 + exp: Math.floor(Date.now() / 1000) - 3600, // 1小时前过期 + }); + + const payload: ActivateRequest = { + token: expiredToken + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + const result = await response.json() as any; + expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); + expect(result.message).toContain('过期'); + expect(result.data).toBeNull(); + }); + + it('用户不存在应返回404错误', async () => { + const nonExistentUserToken = JSON.stringify({ + userId: '9999999999999999999', // 不存在的用户ID + username: 'nonexistent', + email: 'nonexistent@example.com', + tokenType: 'activation', + saltHash: 'mock-salt-hash', + purpose: 'email_activation', + iss: 'elysia-api', + aud: 'web-client', + sub: '9999999999999999999', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 86400, + }); + + const payload: ActivateRequest = { + token: nonExistentUserToken + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(404); + const result = await response.json() as any; + expect(result.code).toBe('USER_NOT_FOUND'); + expect(result.message).toBe('用户不存在'); + expect(result.data).toBeNull(); + }); + + it('账号已激活应返回409错误', async () => { + // 先激活用户 + await drizzleService.db.update(sysUsers) + .set({ status: 'active' }) + .where(eq(sysUsers.id, BigInt(testUserId))); + + const payload: ActivateRequest = { + token: validActivationToken + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(409); + const result = await response.json() as any; + expect(result.code).toBe('ALREADY_ACTIVATED'); + expect(result.message).toBe('账号已经激活'); + expect(result.data).toBeNull(); + + // 恢复为pending状态,便于其他测试 + await drizzleService.db.update(sysUsers) + .set({ status: 'pending' }) + .where(eq(sysUsers.id, BigInt(testUserId))); + }); + + it('缺少Token参数应返回400错误', async () => { + const payload = {}; // 缺少token字段 + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + }); + + it('Token长度过短应返回400错误', async () => { + const payload: ActivateRequest = { + token: 'short' // 长度小于10 + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + }); + + it('Token长度过长应返回400错误', async () => { + const payload: ActivateRequest = { + token: 'a'.repeat(1001) // 长度超过1000 + }; + + const response = await testApp + .handle(new Request('http://localhost/api/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })); + + expect(response.status).toBe(400); + }); + }); }); \ No newline at end of file diff --git a/src/plugins/drizzle/drizzle.service.ts b/src/plugins/drizzle/drizzle.service.ts index 08f0cd9..56f7cb2 100644 --- a/src/plugins/drizzle/drizzle.service.ts +++ b/src/plugins/drizzle/drizzle.service.ts @@ -42,6 +42,12 @@ export class DrizzleService { queueLimit: Number(process.env.DB_QUEUE_LIMIT) || 0, /** 等待连接 */ waitForConnections: true, + // 启用此选项后,MySQL驱动程序将支持大数字(big numbers),这对于存储和处理 bigint 类型的数据尤为重要。 + // 如果不启用此选项,MySQL驱动程序可能无法正确处理超过 JavaScript 数字精度范围的大数值,导致数据精度丢失。 + supportBigNumbers: true, + // 启用此选项后,MySQL驱动程序将在接收 bigint 或其他大数值时,将其作为字符串返回,而不是作为JavaScript数字。 + // 这种处理方式可以避免JavaScript本身的数值精度限制问题,确保大数值在应用程序中保持精确。 + bigNumberStrings: true, }; /** @@ -95,13 +101,13 @@ export class DrizzleService { */ private validateConfig(): void { const requiredFields = ['host', 'port', 'user', 'password', 'database']; - + for (const field of requiredFields) { if (!dbConfig[field as keyof typeof dbConfig]) { throw new Error(`数据库配置缺少必需字段: ${field}`); } } - + if (dbConfig.port < 1 || dbConfig.port > 65535) { throw new Error(`数据库端口号无效: ${dbConfig.port}`); } @@ -113,7 +119,7 @@ export class DrizzleService { private updateConnectionStatus(status: ConnectionStatus, error?: string): void { this._connectionInfo.status = status; this._connectionInfo.error = error; - + if (status === 'connected') { this._connectionInfo.connectedAt = new Date(); this._connectionInfo.error = undefined; @@ -126,9 +132,9 @@ export class DrizzleService { private async createConnection(): Promise { try { this.validateConfig(); - + this.updateConnectionStatus('connecting'); - + /** MySQL连接池配置 */ const connection = mysql.createPool({ host: dbConfig.host, @@ -152,7 +158,7 @@ export class DrizzleService { database: dbConfig.database, connectionLimit: this._poolConfig.connectionLimit, }); - + return connection; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -175,6 +181,7 @@ export class DrizzleService { try { this._connectionPool = await this.createConnection(); + console.log(process.env.NODE_ENV, process.env.NODE_ENV === 'development') /** Drizzle数据库实例 */ this._db = drizzle(this._connectionPool, { schema, @@ -197,7 +204,7 @@ export class DrizzleService { schema: Object.keys(schema).length > 0 ? Object.keys(schema) : ['无schema'], loggerEnabled: process.env.NODE_ENV === 'development', }); - + return this._db; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -215,11 +222,11 @@ export class DrizzleService { if (!this._connectionPool) { return false; } - + const connection = await this._connectionPool.getConnection(); await connection.ping(); connection.release(); - + return true; } catch (error) { Logger.error(error instanceof Error ? error : new Error('数据库连接检查失败')); @@ -252,10 +259,10 @@ export class DrizzleService { */ public async reconnect(): Promise { Logger.info('正在重新连接数据库...'); - + // 先关闭现有连接 await this.close(); - + // 重新初始化连接 return await this.initialize(); } @@ -289,7 +296,7 @@ export class DrizzleService { poolStats?: ReturnType; }> { const isConnected = await this.checkConnection(); - + return { status: isConnected ? 'healthy' : 'unhealthy', connectionInfo: this.connectionInfo, diff --git a/src/plugins/jwt/jwt.service.ts b/src/plugins/jwt/jwt.service.ts index a9d0f31..0486b22 100644 --- a/src/plugins/jwt/jwt.service.ts +++ b/src/plugins/jwt/jwt.service.ts @@ -3,22 +3,30 @@ * @author hotok * @date 2025-06-29 * @lastEditor hotok - * @lastEditTime 2025-06-29 - * @description 提供类型安全的JWT生成、验证和管理功能 + * @lastEditTime 2025-07-06 + * @description 提供类型安全的JWT生成、验证和管理功能,支持不同类型的token */ -import { jwtConfig } from '@/config/jwt.config'; +import crypto from 'crypto'; +import { jwtConfig, tokenConfig, getTokenConfig } from '@/config/jwt.config'; import { Logger } from '@/plugins/logger/logger.service'; +import { TOKEN_TYPES } from '@/type/jwt.type'; import type { JwtUserType, JwtPayloadType, JwtSignOptionsType, + TokenType, + BaseJwtPayload, + ActivationTokenPayload, + AccessTokenPayload, + RefreshTokenPayload, + PasswordResetTokenPayload, } from '@/type/jwt.type'; import type { UserInfoType } from '@/modules/example/example.schema'; /** * JWT服务类 - * @description 提供JWT Token的生成、验证、刷新等功能 + * @description 提供JWT Token的生成、验证、刷新等功能,支持不同类型的token */ export class JwtService { /** @@ -32,7 +40,7 @@ export class JwtService { try { // 从完整用户信息提取JWT载荷所需的字段 const jwtUser: JwtUserType = { - userId: userInfo.id, + userId: String(userInfo.id), username: userInfo.username, email: userInfo.email, nickname: userInfo.nickname, @@ -43,7 +51,7 @@ export class JwtService { // 构建JWT载荷 const payload: Omit = { ...jwtUser, - sub: userInfo.id.toString(), + sub: String(userInfo.id), iss: options?.issuer || 'elysia-api', aud: options?.audience || 'web-client', }; @@ -144,7 +152,6 @@ export class JwtService { * @param payload JWT载荷 * @param thresholdMinutes 阈值分钟数(默认30分钟) * @returns boolean 是否即将过期 - * @modification hotok 2025-06-29 添加Token过期检查功能 */ isTokenExpiringSoon(payload: JwtPayloadType, thresholdMinutes: number = 30): boolean { if (!payload.exp) return false; @@ -159,7 +166,6 @@ export class JwtService { * 获取Token剩余有效时间 * @param payload JWT载荷 * @returns number 剩余秒数,-1表示已过期 - * @modification hotok 2025-06-29 添加Token时间计算功能 */ getTokenRemainingTime(payload: JwtPayloadType): number { if (!payload.exp) return -1; @@ -169,6 +175,278 @@ export class JwtService { return remaining > 0 ? remaining : -1; } + + /** + * 生成盐值哈希 + * @param tokenType Token类型 + * @param userId 用户ID(字符串形式) + * @param email 用户邮箱 + * @returns 盐值哈希 + */ + private generateSaltHash(tokenType: TokenType, userId: string, email: string): string { + const config = getTokenConfig(tokenType); + const data = `${config.salt}:${userId}:${email}:${Date.now()}`; + return crypto.createHash('sha256').update(data).digest('hex'); + } + + /** + * 验证盐值哈希 + * @param saltHash 盐值哈希 + * @param tokenType Token类型 + * @param userId 用户ID(字符串形式) + * @param email 用户邮箱 + * @returns 是否有效 + */ + private verifySaltHash(saltHash: string, tokenType: TokenType, userId: string, email: string): boolean { + try { + // 简单验证,实际应用中可以实现更复杂的验证逻辑 + return Boolean(saltHash) && saltHash.length === 64; // SHA256哈希长度 + } catch (error) { + Logger.warn(`盐值哈希验证失败: ${error}`); + return false; + } + } + + /** + * 生成邮箱激活Token + * @param userId 用户ID(字符串形式) + * @param email 用户邮箱 + * @param username 用户名 + * @returns Promise 激活Token + */ + async generateActivationToken(userId: string, email: string, username: string): Promise { + try { + const config = getTokenConfig(TOKEN_TYPES.ACTIVATION); + const saltHash = this.generateSaltHash(TOKEN_TYPES.ACTIVATION, userId, email); + + // 注意:这里返回的是载荷对象,实际的token生成需要在controller中使用jwt.sign + const payload: Omit = { + userId, + username, + email, + tokenType: TOKEN_TYPES.ACTIVATION, + saltHash, + purpose: 'email_activation', + iss: jwtConfig.issuer, + aud: jwtConfig.audience, + sub: userId, + }; + + Logger.info(`邮箱激活Token载荷生成成功,用户ID: ${userId}, 邮箱: ${email}`); + + // 返回JSON字符串,controller中需要使用jwt.sign进行实际签名 + return JSON.stringify(payload); + + } catch (error) { + Logger.error(new Error(`邮箱激活Token生成失败: ${error}`)); + throw new Error('激活Token生成失败'); + } + } + + /** + * 生成访问Token + * @param userInfo 用户信息 + * @param role 用户角色 + * @returns Promise 访问Token载荷JSON + */ + async generateAccessToken(userInfo: { + id: string; + username: string; + email: string; + nickname?: string; + status: string; + }, role?: string): Promise { + try { + const config = getTokenConfig(TOKEN_TYPES.ACCESS); + const saltHash = this.generateSaltHash(TOKEN_TYPES.ACCESS, userInfo.id, userInfo.email); + + const payload: Omit = { + userId: userInfo.id, + username: userInfo.username, + email: userInfo.email, + nickname: userInfo.nickname, + status: userInfo.status, + role, + tokenType: TOKEN_TYPES.ACCESS, + saltHash, + iss: jwtConfig.issuer, + aud: jwtConfig.audience, + sub: userInfo.id, + }; + + Logger.info(`访问Token载荷生成成功,用户ID: ${userInfo.id}, 用户名: ${userInfo.username}`); + return JSON.stringify(payload); + + } catch (error) { + Logger.error(new Error(`访问Token生成失败: ${error}`)); + throw new Error('访问Token生成失败'); + } + } + + /** + * 生成刷新Token + * @param userId 用户ID(字符串形式) + * @param username 用户名 + * @param email 用户邮箱 + * @param accessTokenId 关联的访问token ID(可选) + * @returns Promise 刷新Token载荷JSON + */ + async generateRefreshToken(userId: string, username: string, email: string, accessTokenId?: string): Promise { + try { + const config = getTokenConfig(TOKEN_TYPES.REFRESH); + const saltHash = this.generateSaltHash(TOKEN_TYPES.REFRESH, userId, email); + + const payload: Omit = { + userId, + username, + email, + tokenType: TOKEN_TYPES.REFRESH, + saltHash, + accessTokenId, + iss: jwtConfig.issuer, + aud: jwtConfig.audience, + sub: userId, + }; + + Logger.info(`刷新Token载荷生成成功,用户ID: ${userId}`); + return JSON.stringify(payload); + + } catch (error) { + Logger.error(new Error(`刷新Token生成失败: ${error}`)); + throw new Error('刷新Token生成失败'); + } + } + + /** + * 生成密码重置Token + * @param userId 用户ID(字符串形式) + * @param email 用户邮箱 + * @param username 用户名 + * @returns Promise 密码重置Token载荷JSON + */ + async generatePasswordResetToken(userId: string, email: string, username: string): Promise { + try { + const config = getTokenConfig(TOKEN_TYPES.PASSWORD_RESET); + const saltHash = this.generateSaltHash(TOKEN_TYPES.PASSWORD_RESET, userId, email); + + const payload: Omit = { + userId, + username, + email, + tokenType: TOKEN_TYPES.PASSWORD_RESET, + saltHash, + purpose: 'password_reset', + iss: jwtConfig.issuer, + aud: jwtConfig.audience, + sub: userId, + }; + + Logger.info(`密码重置Token载荷生成成功,用户ID: ${userId}, 邮箱: ${email}`); + return JSON.stringify(payload); + + } catch (error) { + Logger.error(new Error(`密码重置Token生成失败: ${error}`)); + throw new Error('密码重置Token生成失败'); + } + } + + /** + * 验证激活Token载荷 + * @param payload JWT载荷 + * @returns 是否有效的激活Token + */ + verifyActivationTokenPayload(payload: any): payload is ActivationTokenPayload { + try { + // 检查基础字段 + if (!payload || typeof payload !== 'object') { + return false; + } + + // 检查token类型 + if (payload.tokenType !== TOKEN_TYPES.ACTIVATION) { + Logger.warn(`Token类型不匹配,期望: ${TOKEN_TYPES.ACTIVATION}, 实际: ${payload.tokenType}`); + return false; + } + + // 检查必需字段 + const requiredFields = ['userId', 'username', 'email', 'saltHash', 'purpose']; + for (const field of requiredFields) { + if (!payload[field]) { + Logger.warn(`激活Token载荷缺少字段: ${field}`); + return false; + } + } + + // 检查purpose + if (payload.purpose !== 'email_activation') { + Logger.warn(`激活Token用途不正确: ${payload.purpose}`); + return false; + } + + // 验证盐值哈希 + if (!this.verifySaltHash(payload.saltHash, TOKEN_TYPES.ACTIVATION, payload.userId, payload.email)) { + Logger.warn(`激活Token盐值哈希验证失败`); + return false; + } + + Logger.info(`激活Token载荷验证成功,用户ID: ${payload.userId}`); + return true; + + } catch (error) { + Logger.error(new Error(`激活Token载荷验证失败: ${error}`)); + return false; + } + } + + /** + * 验证访问Token载荷 + * @param payload JWT载荷 + * @returns 是否有效的访问Token + */ + verifyAccessTokenPayload(payload: any): payload is AccessTokenPayload { + try { + if (!payload || typeof payload !== 'object') { + return false; + } + + if (payload.tokenType !== TOKEN_TYPES.ACCESS) { + return false; + } + + const requiredFields = ['userId', 'username', 'email', 'saltHash', 'status']; + for (const field of requiredFields) { + if (!payload[field]) { + return false; + } + } + + if (!this.verifySaltHash(payload.saltHash, TOKEN_TYPES.ACCESS, payload.userId, payload.email)) { + return false; + } + + return true; + + } catch (error) { + Logger.error(new Error(`访问Token载荷验证失败: ${error}`)); + return false; + } + } + + /** + * 获取Token配置(用于Controller) + * @param tokenType Token类型 + * @returns Token配置 + */ + getTokenConfig(tokenType: TokenType) { + return getTokenConfig(tokenType); + } + + /** + * 获取JWT基础配置(用于Controller) + */ + getJwtConfig() { + return jwtConfig; + } } /** diff --git a/src/plugins/logger/logger.service.ts b/src/plugins/logger/logger.service.ts index 6117c91..14b033f 100644 --- a/src/plugins/logger/logger.service.ts +++ b/src/plugins/logger/logger.service.ts @@ -191,7 +191,9 @@ const formatMessage = (message: string | object): string => { if (typeof message === 'string') { return message; } - return JSON.stringify(message, null, 2); + + return JSON.stringify(message, (_, v) => + typeof v === 'bigint' ? v.toString() : v, 2); }; /** diff --git a/src/type/jwt.type.ts b/src/type/jwt.type.ts index 9571a97..d691728 100644 --- a/src/type/jwt.type.ts +++ b/src/type/jwt.type.ts @@ -3,17 +3,32 @@ * @author hotok * @date 2025-06-29 * @lastEditor hotok - * @lastEditTime 2025-06-29 + * @lastEditTime 2025-07-06 * @description JWT Token载荷和用户信息的TypeScript类型定义 */ +/** + * Token类型枚举 + */ +export const TOKEN_TYPES = { + ACCESS: 'access', + REFRESH: 'refresh', + ACTIVATION: 'activation', + PASSWORD_RESET: 'password_reset', +} as const; + +/** + * Token类型定义 + */ +export type TokenType = typeof TOKEN_TYPES[keyof typeof TOKEN_TYPES]; + /** * JWT Token中的用户信息类型 * @description 存储在JWT Token中的用户基本信息,不包含敏感数据 */ export interface JwtUserType { - /** 用户ID */ - userId: number; + /** 用户ID(bigint类型以字符串形式存储防止精度丢失) */ + userId: string; /** 用户名 */ username: string; /** 用户邮箱 */ @@ -47,6 +62,79 @@ export interface JwtPayloadType extends JwtUserType { nbf?: number; } +/** + * JWT载荷基础类型(包含token类型和盐值) + * @description 所有特定类型token的基础载荷结构 + */ +export interface BaseJwtPayload { + /** 用户ID(bigint类型以字符串形式存储防止精度丢失) */ + userId: string; + /** 用户名 */ + username: string; + /** 邮箱 */ + email: string; + /** Token类型 */ + tokenType: TokenType; + /** 盐值哈希 */ + saltHash: string; + /** 签发者 */ + iss: string; + /** 受众 */ + aud: string; + /** 主题 */ + sub: string; + /** 签发时间 */ + iat: number; + /** 过期时间 */ + exp: number; +} + +/** + * 激活Token载荷类型 + * @description 邮箱激活token的载荷结构 + */ +export interface ActivationTokenPayload extends BaseJwtPayload { + tokenType: 'activation'; + /** 邮箱(用于激活验证) */ + email: string; + /** 用途说明 */ + purpose: 'email_activation'; +} + +/** + * 访问Token载荷类型 + * @description 访问token的载荷结构 + */ +export interface AccessTokenPayload extends BaseJwtPayload { + tokenType: 'access'; + /** 昵称 */ + nickname?: string; + /** 用户状态 */ + status: string; + /** 角色 */ + role?: string; +} + +/** + * 刷新Token载荷类型 + * @description 刷新token的载荷结构 + */ +export interface RefreshTokenPayload extends BaseJwtPayload { + tokenType: 'refresh'; + /** 原始访问token的ID(用于关联) */ + accessTokenId?: string; +} + +/** + * 密码重置Token载荷类型 + * @description 密码重置token的载荷结构 + */ +export interface PasswordResetTokenPayload extends BaseJwtPayload { + tokenType: 'password_reset'; + /** 用途说明 */ + purpose: 'password_reset'; +} + /** * JWT认证上下文类型 * @description 在认证中间件中使用的用户上下文类型 diff --git a/src/validators/global.response.ts b/src/validators/global.response.ts index 4297023..9f1d802 100644 --- a/src/validators/global.response.ts +++ b/src/validators/global.response.ts @@ -220,8 +220,34 @@ export const globalResponseWrapperSchema = (dataSchema: any) => t.Literal('UNAUTHORIZED'), t.Literal('FORBIDDEN'), t.Literal('NOT_FOUND'), + t.Literal('METHOD_NOT_ALLOWED'), + t.Literal('CONFLICT'), + t.Literal('RATE_LIMIT_EXCEEDED'), t.Literal('BUSINESS_ERROR'), + t.Literal('USER_NOT_FOUND'), + t.Literal('USER_ALREADY_EXISTS'), + t.Literal('INVALID_CREDENTIALS'), + t.Literal('TOKEN_EXPIRED'), + t.Literal('TOKEN_INVALID'), + t.Literal('INSUFFICIENT_PERMISSIONS'), + t.Literal('USERNAME_EXISTS'), + t.Literal('EMAIL_EXISTS'), + t.Literal('PASSWORD_MISMATCH'), + t.Literal('CAPTCHA_ERROR'), + t.Literal('EMAIL_SEND_FAILED'), + t.Literal('INVALID_ACTIVATION_TOKEN'), + t.Literal('ALREADY_ACTIVATED'), + t.Literal('INVALID_PASSWORD'), + t.Literal('ACCOUNT_NOT_ACTIVATED'), + t.Literal('ACCOUNT_LOCKED'), + t.Literal('TOO_MANY_FAILED_ATTEMPTS'), + t.Literal('INVALID_RESET_TOKEN'), + t.Literal('NOT_IMPLEMENTED'), t.Literal('INTERNAL_ERROR'), + t.Literal('DATABASE_ERROR'), + t.Literal('REDIS_ERROR'), + t.Literal('EXTERNAL_API_ERROR'), + t.Literal('SERVICE_UNAVAILABLE'), ], { description: '响应状态码', }),