feat(dict): 完成字典模块创建字典项接口的Schema、响应、Service、Controller及相关文档,实现参数校验、唯一性校验、层级处理、数据库写入和统一响应。修复drizzle-orm类型兼容问题,调整Schema与Service参数处理逻辑,支持雪花ID生成。

This commit is contained in:
HeXiaoLong:Suanier 2025-07-07 18:32:31 +08:00
parent 863b376020
commit f9f75c9d2d
99 changed files with 18798 additions and 15672 deletions

View File

@ -1,5 +1,5 @@
--- ---
description: "全局规则" description: "生成产品需求文档 (PRD)"
globs: ["**/*"] globs: ["**/*"]
alwaysApply: true alwaysApply: true
--- ---

View File

@ -1,5 +1,5 @@
--- ---
description: "全局规则" description: "从PRD生成任务清单"
globs: ["**/*"] globs: ["**/*"]
alwaysApply: true alwaysApply: true
--- ---

View File

@ -1,3 +1,9 @@
---
description: "ElysiaAPI开发流程"
globs: ["**/*"]
alwaysApply: true
---
# ElysiaAPI开发流程 # ElysiaAPI开发流程
## 0. 概览 ## 0. 概览

View File

@ -1,5 +1,5 @@
--- ---
description: "全局规则" description: "任务执行流程"
globs: ["**/*"] globs: ["**/*"]
alwaysApply: true alwaysApply: true
--- ---
@ -44,3 +44,41 @@ alwaysApply: true
8. 完成一个子任务后提交git 8. 完成一个子任务后提交git
9. 将已完成的任务归档到tasks的归档目录archive并修改文件名文件名前添加时间 9. 将已完成的任务归档到tasks的归档目录archive并修改文件名文件名前添加时间
10. 将需求prd也归档 10. 将需求prd也归档
重要的事情说三遍
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**
**每一个子任务执行完暂停,等待我确认,不要再连续执行接下来的任务**

75
.env Normal file
View File

@ -0,0 +1,75 @@
# ==============================================
# 项目环境变量配置文件
# ==============================================
# ==============================================
# 应用配置
# ==============================================
NODE_ENV=development
# ==============================================
# MySQL数据库配置
# ==============================================
# 基本连接配置
DB_HOST=uair.cc
DB_PORT=3306
DB_USER=nie
DB_PASSWORD=nie
DB_NAME=nie
# 连接池配置
DB_CONNECTION_LIMIT=10
DB_QUEUE_LIMIT=0
DB_ACQUIRE_TIMEOUT=60000
DB_TIMEOUT=60000
# ==============================================
# Redis配置
# ==============================================
REDIS_CONNECT_NAME=cursor-init-redis
REDIS_HOST=uair.cc
REDIS_PORT=6379
REDIS_USERNAME=default
REDIS_PASSWORD=nie
REDIS_DATABASE=0
# ==============================================
# JWT认证配置
# ==============================================
JWT_SECRET=your_jwt_secret_change_this_in_production
# ==============================================
# 日志配置
# ==============================================
LOG_LEVEL=debug
LOG_MAX_FILES=30d
LOG_MAX_SIZE=70k
LOG_DIRECTORY=logs
LOG_CONSOLE=true
# ====================
# 邮件服务配置
# ====================
# SMTP服务器配置
SMTP_USER=togy.gc@qq.com
SMTP_PASS=qyafspjzahydddaa
SMTP_FROM_EMAIL=togy.gc@qq.com # 必须与SMTP_USER一致
SMTP_FROM_NAME=星撰玉衡
# SMTP连接超时配置毫秒
SMTP_TIMEOUT=60000
SMTP_GREETING_TIMEOUT=30000
SMTP_SOCKET_TIMEOUT=60000
# 邮件基础配置
EMAIL_FROM=星撰玉衡
EMAIL_REPLY_TO=togy.gc@qq.com
# 邮件发送选项配置
EMAIL_RETRY_ATTEMPTS=3
EMAIL_RETRY_DELAY=5000
EMAIL_ENABLE_QUEUE=false
EMAIL_MAX_RECIPIENTS=50
EMAIL_RATE_LIMIT=100

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.env
bun.lockb bun.lockb
/logs /logs

View File

@ -119,6 +119,7 @@ src/plugins/
``` ```
### 1.4 类型定义 (type/) ### 1.4 类型定义 (type/)
``` ```
src/type/ src/type/
├── config.type.ts # 配置相关类型 ├── config.type.ts # 配置相关类型
@ -131,6 +132,7 @@ src/type/
``` ```
### 1.5 工具函数 (utils/) ### 1.5 工具函数 (utils/)
``` ```
src/utils/ src/utils/
├── deviceInfo.ts # 设备信息工具 ├── deviceInfo.ts # 设备信息工具
@ -148,11 +150,13 @@ src/utils/
``` ```
### 1.6 常量定义 (constants/) ### 1.6 常量定义 (constants/)
``` ```
src/constants/ src/constants/
├── swaggerTags.ts # Swagger标签定义所有模块的tag应该集中在此定义 ├── swaggerTags.ts # Swagger标签定义所有模块的tag应该集中在此定义
└── 其他常量 └── 其他常量
``` ```
### 1.7 数据库实体 ### 1.7 数据库实体
``` ```
@ -163,6 +167,7 @@ src/eneities/
``` ```
### 1.8 文件命名约定 ### 1.8 文件命名约定
- 模块名使用 **单数形式**`auth`、`user`、`product`、`order` - 模块名使用 **单数形式**`auth`、`user`、`product`、`order`
- 文件名格式:`[module].[type].ts` - 文件名格式:`[module].[type].ts`
- 导出名格式:`[module][类型名]` - 导出名格式:`[module][类型名]`
@ -182,11 +187,12 @@ src/eneities/
- 注意已经存在的工具函数,比如分页有现成的简化工具,且能够统一请求格式 - 注意已经存在的工具函数,比如分页有现成的简化工具,且能够统一请求格式
### 2.2 特别注意点 ### 2.2 特别注意点
**必须遵循的命名模式:** **必须遵循的命名模式:**
- Request类型`[动作][模块]Request` → `RegisterRequest` - Request类型`[动作][模块]Request` → `RegisterRequest`
- Schema名`[动作][模块]Schema` → `RegisterSchema` - Schema名`[动作][模块]Schema` → `RegisterSchema`
### 2.3 代码示例 ### 2.3 代码示例
```ts ```ts
@ -206,38 +212,37 @@ import { createQuerySchema } from '@/utils/pagination';
* 用户列表查询参数Schema * 用户列表查询参数Schema
* @description 用户列表查询的请求参数验证规则 * @description 用户列表查询的请求参数验证规则
*/ */
export const UserListQuerySchema = createQuerySchema(t.Object({ export const UserListQuerySchema = createQuerySchema(
// 用户特有参数 t.Object({
keyword: t.Optional(t.String({ // 用户特有参数
minLength: 1, keyword: t.Optional(
maxLength: 100, t.String({
description: '搜索关键词,支持用户名、邮箱模糊搜索', minLength: 1,
examples: ['admin', 'test@example.com'] maxLength: 100,
})), description: '搜索关键词,支持用户名、邮箱模糊搜索',
status: t.Optional(t.Union([ examples: ['admin', 'test@example.com'],
t.Literal('active'), }),
t.Literal('inactive'), ),
t.Literal('pending') status: t.Optional(
], { t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('pending')], {
description: '用户状态筛选', description: '用户状态筛选',
examples: ['active', 'inactive', 'pending'] examples: ['active', 'inactive', 'pending'],
})), }),
gender: t.Optional(t.Union([ ),
t.Literal(0), gender: t.Optional(
t.Literal(1), t.Union([t.Literal(0), t.Literal(1), t.Literal(2), t.Literal('0'), t.Literal('1'), t.Literal('2')], {
t.Literal(2), description: '性别筛选0-未知1-男2-女',
t.Literal('0'), examples: [0, 1, 2],
t.Literal('1'), }),
t.Literal('2'), ),
], { isRoot: t.Optional(
description: '性别筛选0-未知1-男2-女', t.Boolean({
examples: [0, 1, 2] description: '是否超级管理员筛选',
})), examples: [true, false],
isRoot: t.Optional(t.Boolean({ }),
description: '是否超级管理员筛选', ),
examples: [true, false] }),
})) );
}));
/** 用户列表查询参数类型 */ /** 用户列表查询参数类型 */
export type UserListQueryRequest = Static<typeof UserListQuerySchema>; export type UserListQueryRequest = Static<typeof UserListQuerySchema>;
@ -251,9 +256,10 @@ export type UserListQueryRequest = Static<typeof UserListQuerySchema>;
- 错误相应只提供示例和描述 - 错误相应只提供示例和描述
- 导出响应成功类型 - 导出响应成功类型
### 3.2 特别注意点 ### 3.2 特别注意点
**必须遵循的命名模式:** **必须遵循的命名模式:**
- Response格式定义`[动作][模块]Response` → `RegisterResponse` - Response格式定义`[动作][模块]Response` → `RegisterResponse`
- Response成功类型`[动作][模块]SuccessType ` → `RegisterSuccessType ` - Response成功类型`[动作][模块]SuccessType ` → `RegisterSuccessType `
@ -273,8 +279,6 @@ import { t, type Static } from 'elysia';
import { responseWrapperSchema } from '@/utils/responseFormate'; import { responseWrapperSchema } from '@/utils/responseFormate';
import { createPaginationResponseSchema } from '@/utils/pagination'; import { createPaginationResponseSchema } from '@/utils/pagination';
/** /**
* 用户列表项Schema * 用户列表项Schema
* @description 用户列表中单个用户的数据结构 * @description 用户列表中单个用户的数据结构
@ -283,21 +287,21 @@ export const UserListItemSchema = t.Object({
/** 用户ID */ /** 用户ID */
id: t.String({ id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失', description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3'] examples: ['1', '2', '3'],
}), }),
/** 用户名 */ /** 用户名 */
username: t.String({ username: t.String({
description: '用户名', description: '用户名',
examples: ['admin', 'testuser'] examples: ['admin', 'testuser'],
}), }),
/** 更多字段... */ /** 更多字段... */
/** 更新时间 */ /** 更新时间 */
updatedAt: t.String({ updatedAt: t.String({
description: '更新时间', description: '更新时间',
examples: ['2024-12-19T10:30:00Z'] examples: ['2024-12-19T10:30:00Z'],
}) }),
}); });
/** /**
@ -306,28 +310,34 @@ export const UserListItemSchema = t.Object({
*/ */
export const GetUserListResponsesSchema = { export const GetUserListResponsesSchema = {
200: responseWrapperSchema(createPaginationResponseSchema(UserListItemSchema)), 200: responseWrapperSchema(createPaginationResponseSchema(UserListItemSchema)),
401: responseWrapperSchema(t.Object({ 401: responseWrapperSchema(
error: t.String({ t.Object({
description: '认证失败', error: t.String({
examples: ['未提供有效的认证令牌', '令牌已过期'] description: '认证失败',
}) examples: ['未提供有效的认证令牌', '令牌已过期'],
})), }),
400: responseWrapperSchema(t.Object({ }),
error: t.String({ ),
description: '参数错误', 400: responseWrapperSchema(
examples: ['分页参数无效', '搜索关键词格式错误'] t.Object({
}) error: t.String({
})), description: '参数错误',
500: responseWrapperSchema(t.Object({ examples: ['分页参数无效', '搜索关键词格式错误'],
error: t.String({ }),
description: '服务器错误', }),
examples: ['内部服务器错误'] ),
}) 500: responseWrapperSchema(
})) t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误'],
}),
}),
),
}; };
/** 获取用户列表成功响应数据类型 */ /** 获取用户列表成功响应数据类型 */
export type GetUserListSuccessType = Static<typeof GetUserListResponsesSchema[200]>; export type GetUserListSuccessType = Static<(typeof GetUserListResponsesSchema)[200]>;
``` ```
## 4. Service业务逻辑层 ## 4. Service业务逻辑层
@ -398,7 +408,7 @@ export class UserService {
status: sysUsers.status, status: sysUsers.status,
lastLoginAt: sysUsers.lastLoginAt, lastLoginAt: sysUsers.lastLoginAt,
createdAt: sysUsers.createdAt, createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt updatedAt: sysUsers.updatedAt,
}) })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId))) .where(eq(sysUsers.id, BigInt(userId)))
@ -406,34 +416,33 @@ export class UserService {
if (!user || user.length === 0) { if (!user || user.length === 0) {
Logger.warn(`用户不存在:${userId}`); Logger.warn(`用户不存在:${userId}`);
throw new BusinessError( throw new BusinessError(`用户不存在:${userId}`, 404);
`用户不存在:${userId}`,
404
);
} }
const userData = user[0]!; const userData = user[0]!;
Logger.info(`获取用户信息成功:${userId} - ${userData.username}`); Logger.info(`获取用户信息成功:${userId} - ${userData.username}`);
return successResponse({ return successResponse(
id: userId, // 使用传入的字符串ID避免精度丢失 {
username: userData.username, id: userId, // 使用传入的字符串ID避免精度丢失
email: userData.email, username: userData.username,
nickname: userData.nickname, email: userData.email,
avatar: userData.avatar, nickname: userData.nickname,
phone: userData.mobile, avatar: userData.avatar,
status: userData.status, phone: userData.mobile,
lastLoginAt: userData.lastLoginAt || null, status: userData.status,
createdAt: userData.createdAt, lastLoginAt: userData.lastLoginAt || null,
updatedAt: userData.updatedAt createdAt: userData.createdAt,
}, '获取用户信息成功'); updatedAt: userData.updatedAt,
},
'获取用户信息成功',
);
} }
} }
// 导出单例实例 // 导出单例实例
export const userService = new UserService(); export const userService = new UserService();
``` ```
## 5. Controllers接口名称接入 ## 5. Controllers接口名称接入
@ -463,21 +472,18 @@ export const authController = new Elysia()
* @route POST /api/auth/register * @route POST /api/auth/register
* @description 用户注册,包含验证码验证、用户名邮箱唯一性检查等 * @description 用户注册,包含验证码验证、用户名邮箱唯一性检查等
*/ */
.post( .post('/register', ({ body }) => authService.register(body), {
'/register', body: RegisterSchema,
({ body }) => authService.register(body), detail: {
{ summary: '用户注册',
body: RegisterSchema, description: '用户注册接口,需要提供用户名、邮箱、密码和验证码',
detail: { tags: [tags.auth],
summary: '用户注册', operationId: 'registerUser',
description: '用户注册接口,需要提供用户名、邮箱、密码和验证码', },
tags: [tags.auth], response: RegisterResponsesSchema,
operationId: 'registerUser', });
},
response: RegisterResponsesSchema,
}
);
``` ```
## 6. 错误处理 ## 6. 错误处理
### 6.1 错误处理规范 ### 6.1 错误处理规范
@ -494,10 +500,11 @@ import { BusinessError } from '@/utils/responseFormate';
// 抛出业务错误 // 抛出业务错误
throw new BusinessError('消息说明...', 409); throw new BusinessError('消息说明...', 409);
``` ```
## 7. 测试用例文档 ## 7. 测试用例文档
1. 分模块 1. 分模块
2. 分接口 2. 分接口
3. 测试名称 3. 测试名称
4. 场景 4. 场景
5. 方法 5. 方法

View File

@ -1,11 +1,12 @@
# 创建redis连接测试 # 创建redis连接测试
_Exported on 2025/6/28 at GMT+8 22:07:57 from Cursor (1.1.6)_ _Exported on 2025/6/28 at GMT+8 22:07:57 from Cursor (1.1.6)_
--- ---
**User** **User**
不要修改我的redis.ts文件 给我写一个redis连接测试 不要修改我的redis.ts文件 给我写一个redis连接测试
--- ---
@ -35,7 +36,7 @@ describe('Redis连接测试', () => {
beforeAll(async () => { beforeAll(async () => {
redis = new Redis(); redis = new Redis();
// 等待连接建立 // 等待连接建立
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
}); });
afterAll(async () => { afterAll(async () => {
@ -83,28 +84,28 @@ describe('Redis连接测试', () => {
it('应该能够设置和获取字符串值', async () => { it('应该能够设置和获取字符串值', async () => {
// 设置值 // 设置值
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 获取值 // 获取值
const result = await redis.redisClient.get(testKey); const result = await redis.redisClient.get(testKey);
expect(result).toBe(testValue); expect(result).toBe(testValue);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(testKey); await redis.redisClient.del(testKey);
}); });
it('应该能够设置带过期时间的值', async () => { it('应该能够设置带过期时间的值', async () => {
const expiryTime = 2; // 2秒过期 const expiryTime = 2; // 2秒过期
// 设置带过期时间的值 // 设置带过期时间的值
await redis.redisClient.setEx(testKey, expiryTime, testValue); await redis.redisClient.setEx(testKey, expiryTime, testValue);
// 立即获取应该有值 // 立即获取应该有值
const result1 = await redis.redisClient.get(testKey); const result1 = await redis.redisClient.get(testKey);
expect(result1).toBe(testValue); expect(result1).toBe(testValue);
// 等待过期 // 等待过期
await new Promise(resolve => setTimeout(resolve, 2100)); await new Promise((resolve) => setTimeout(resolve, 2100));
// 过期后应该为null // 过期后应该为null
const result2 = await redis.redisClient.get(testKey); const result2 = await redis.redisClient.get(testKey);
expect(result2).toBeNull(); expect(result2).toBeNull();
@ -113,14 +114,14 @@ describe('Redis连接测试', () => {
it('应该能够检查键是否存在', async () => { it('应该能够检查键是否存在', async () => {
// 设置测试键 // 设置测试键
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 检查存在 // 检查存在
const exists1 = await redis.redisClient.exists(testKey); const exists1 = await redis.redisClient.exists(testKey);
expect(exists1).toBe(1); expect(exists1).toBe(1);
// 删除键 // 删除键
await redis.redisClient.del(testKey); await redis.redisClient.del(testKey);
// 检查不存在 // 检查不存在
const exists2 = await redis.redisClient.exists(testKey); const exists2 = await redis.redisClient.exists(testKey);
expect(exists2).toBe(0); expect(exists2).toBe(0);
@ -129,11 +130,11 @@ describe('Redis连接测试', () => {
it('应该能够删除键', async () => { it('应该能够删除键', async () => {
// 设置测试键 // 设置测试键
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 删除键 // 删除键
const deleteCount = await redis.redisClient.del(testKey); const deleteCount = await redis.redisClient.del(testKey);
expect(deleteCount).toBe(1); expect(deleteCount).toBe(1);
// 验证键已被删除 // 验证键已被删除
const result = await redis.redisClient.get(testKey); const result = await redis.redisClient.get(testKey);
expect(result).toBeNull(); expect(result).toBeNull();
@ -150,11 +151,11 @@ describe('Redis连接测试', () => {
it('应该能够设置和获取Hash字段', async () => { it('应该能够设置和获取Hash字段', async () => {
// 设置Hash字段 // 设置Hash字段
await redis.redisClient.hSet(hashKey, field1, value1); await redis.redisClient.hSet(hashKey, field1, value1);
// 获取Hash字段 // 获取Hash字段
const result = await redis.redisClient.hGet(hashKey, field1); const result = await redis.redisClient.hGet(hashKey, field1);
expect(result).toBe(value1); expect(result).toBe(value1);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
@ -165,14 +166,14 @@ describe('Redis连接测试', () => {
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 获取所有Hash字段 // 获取所有Hash字段
const result = await redis.redisClient.hGetAll(hashKey); const result = await redis.redisClient.hGetAll(hashKey);
expect(result).toEqual({ expect(result).toEqual({
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
@ -186,15 +187,15 @@ describe('Redis连接测试', () => {
it('应该能够推入和弹出列表元素', async () => { it('应该能够推入和弹出列表元素', async () => {
// 推入元素 // 推入元素
await redis.redisClient.lPush(listKey, value1, value2); await redis.redisClient.lPush(listKey, value1, value2);
// 获取列表长度 // 获取列表长度
const length = await redis.redisClient.lLen(listKey); const length = await redis.redisClient.lLen(listKey);
expect(length).toBe(2); expect(length).toBe(2);
// 弹出元素 // 弹出元素
const poppedValue = await redis.redisClient.lPop(listKey); const poppedValue = await redis.redisClient.lPop(listKey);
expect(poppedValue).toBe(value2); expect(poppedValue).toBe(value2);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(listKey); await redis.redisClient.del(listKey);
}); });
@ -204,11 +205,11 @@ describe('Redis连接测试', () => {
it('健康检查在连接断开时应返回false', async () => { it('健康检查在连接断开时应返回false', async () => {
// 暂时断开连接 // 暂时断开连接
await redis.disconnectRedis(); await redis.disconnectRedis();
// 健康检查应该返回false // 健康检查应该返回false
const isHealthy = await redis.checkRedisHealth(); const isHealthy = await redis.checkRedisHealth();
expect(isHealthy).toBe(false); expect(isHealthy).toBe(false);
// 重新连接 // 重新连接
await redis.connectRedis(); await redis.connectRedis();
}); });
@ -216,10 +217,10 @@ describe('Redis连接测试', () => {
it('应该能够重新连接Redis', async () => { it('应该能够重新连接Redis', async () => {
// 断开连接 // 断开连接
await redis.disconnectRedis(); await redis.disconnectRedis();
// 重新连接 // 重新连接
await redis.connectRedis(); await redis.connectRedis();
// 验证连接正常 // 验证连接正常
const isHealthy = await redis.checkRedisHealth(); const isHealthy = await redis.checkRedisHealth();
expect(isHealthy).toBe(true); expect(isHealthy).toBe(true);
@ -230,17 +231,17 @@ describe('Redis连接测试', () => {
it('应该能够快速执行大量set操作', async () => { it('应该能够快速执行大量set操作', async () => {
const startTime = Date.now(); const startTime = Date.now();
const operations = []; const operations = [];
// 执行100次set操作 // 执行100次set操作
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
operations.push(redis.redisClient.set(`perf_test_${i}`, `value_${i}`)); operations.push(redis.redisClient.set(`perf_test_${i}`, `value_${i}`));
} }
await Promise.all(operations); await Promise.all(operations);
const endTime = Date.now(); const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(1000); // 应该在1秒内完成 expect(endTime - startTime).toBeLessThan(1000); // 应该在1秒内完成
// 清理测试数据 // 清理测试数据
const deleteOperations = []; const deleteOperations = [];
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
@ -293,14 +294,14 @@ import { redisConfig } from '@/config/redis.config';
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 获取所有Hash字段 // 获取所有Hash字段
const result = await redis.redisClient.hGetAll(hashKey); const result = await redis.redisClient.hGetAll(hashKey);
expect(result).toEqual({ expect(result).toEqual({
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
@ -311,14 +312,14 @@ import { redisConfig } from '@/config/redis.config';
// 设置多个Hash字段 // 设置多个Hash字段
await redis.redisClient.hSet(hashKey, field1, value1); await redis.redisClient.hSet(hashKey, field1, value1);
await redis.redisClient.hSet(hashKey, field2, value2); await redis.redisClient.hSet(hashKey, field2, value2);
// 获取所有Hash字段 // 获取所有Hash字段
const result = await redis.redisClient.hGetAll(hashKey); const result = await redis.redisClient.hGetAll(hashKey);
expect(result).toEqual({ expect(result).toEqual({
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
@ -328,15 +329,15 @@ import { redisConfig } from '@/config/redis.config';
it('应该能够推入和弹出列表元素', async () => { it('应该能够推入和弹出列表元素', async () => {
// 推入元素 // 推入元素
await redis.redisClient.lPush(listKey, value1, value2); await redis.redisClient.lPush(listKey, value1, value2);
// 获取列表长度 // 获取列表长度
const length = await redis.redisClient.lLen(listKey); const length = await redis.redisClient.lLen(listKey);
expect(length).toBe(2); expect(length).toBe(2);
// 弹出元素 // 弹出元素
const poppedValue = await redis.redisClient.lPop(listKey); const poppedValue = await redis.redisClient.lPop(listKey);
expect(poppedValue).toBe(value2); expect(poppedValue).toBe(value2);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(listKey); await redis.redisClient.del(listKey);
}); });
@ -347,15 +348,15 @@ import { redisConfig } from '@/config/redis.config';
// 推入元素 // 推入元素
await redis.redisClient.lPush(listKey, value1); await redis.redisClient.lPush(listKey, value1);
await redis.redisClient.lPush(listKey, value2); await redis.redisClient.lPush(listKey, value2);
// 获取列表长度 // 获取列表长度
const length = await redis.redisClient.lLen(listKey); const length = await redis.redisClient.lLen(listKey);
expect(length).toBe(2); expect(length).toBe(2);
// 弹出元素 // 弹出元素
const poppedValue = await redis.redisClient.lPop(listKey); const poppedValue = await redis.redisClient.lPop(listKey);
expect(poppedValue).toBe(value2); expect(poppedValue).toBe(value2);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(listKey); await redis.redisClient.del(listKey);
}); });
@ -450,28 +451,34 @@ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
## 📋 测试覆盖范围 ## 📋 测试覆盖范围
### 🔗 Redis连接管理 ### 🔗 Redis连接管理
- ✅ Redis服务器连接测试 - ✅ Redis服务器连接测试
- ✅ 连接状态检查 - ✅ 连接状态检查
- ✅ Ping命令测试 - ✅ Ping命令测试
### 🔧 Redis基本操作 ### 🔧 Redis基本操作
- ✅ 字符串的设置和获取 - ✅ 字符串的设置和获取
- ✅ 带过期时间的键值对 - ✅ 带过期时间的键值对
- ✅ 键的存在性检查 - ✅ 键的存在性检查
- ✅ 键的删除操作 - ✅ 键的删除操作
### 📝 Redis Hash操作 ### 📝 Redis Hash操作
- ✅ Hash字段的设置和获取 - ✅ Hash字段的设置和获取
- ✅ 多个Hash字段操作 - ✅ 多个Hash字段操作
### 📜 Redis列表操作 ### 📜 Redis列表操作
- ✅ 列表元素的推入和弹出 - ✅ 列表元素的推入和弹出
### ⚠️ 错误处理测试 ### ⚠️ 错误处理测试
- ✅ 连接断开时的健康检查 - ✅ 连接断开时的健康检查
- ✅ 重新连接功能 - ✅ 重新连接功能
### ⚡ 性能测试 ### ⚡ 性能测试
- ✅ 大量并发操作性能测试 - ✅ 大量并发操作性能测试
## 🚀 如何运行测试 ## 🚀 如何运行测试
@ -604,7 +611,7 @@ class HealthService {
async getHealthStatus(ctx: Context): Promise<HealthStatus> { async getHealthStatus(ctx: Context): Promise<HealthStatus> {
const startTime = Date.now(); const startTime = Date.now();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
try { try {
// 并行检查所有依赖 // 并行检查所有依赖
const [mysqlStatus, redisStatus] = await Promise.allSettled([ const [mysqlStatus, redisStatus] = await Promise.allSettled([
@ -614,8 +621,12 @@ class HealthService {
/** 系统整体状态 */ /** 系统整体状态 */
const overallStatus = this.determineOverallStatus([ const overallStatus = this.determineOverallStatus([
mysqlStatus.status === 'fulfilled' ? mysqlStatus.value : { status: 'unhealthy', error: 'Connection failed' }, mysqlStatus.status === 'fulfilled'
redisStatus.status === 'fulfilled' ? redisStatus.value : { status: 'unhealthy', error: 'Connection failed' }, ? mysqlStatus.value
: { status: 'unhealthy', error: 'Connection failed' },
redisStatus.status === 'fulfilled'
? redisStatus.value
: { status: 'unhealthy', error: 'Connection failed' },
]); ]);
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
@ -631,8 +642,14 @@ class HealthService {
version: process.env.npm_package_version || '1.0.0', version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development', environment: process.env.NODE_ENV || 'development',
components: { components: {
mysql: mysqlStatus.status === 'fulfilled' ? mysqlStatus.value : { status: 'unhealthy', error: 'Connection failed' }, mysql:
redis: redisStatus.status === 'fulfilled' ? redisStatus.value : { status: 'unhealthy', error: 'Connection failed' }, mysqlStatus.status === 'fulfilled'
? mysqlStatus.value
: { status: 'unhealthy', error: 'Connection failed' },
redis:
redisStatus.status === 'fulfilled'
? redisStatus.value
: { status: 'unhealthy', error: 'Connection failed' },
}, },
}, },
}; };
@ -663,14 +680,14 @@ class HealthService {
async getDetailedHealthStatus(ctx: Context): Promise<DetailedHealthStatus> { async getDetailedHealthStatus(ctx: Context): Promise<DetailedHealthStatus> {
const startTime = Date.now(); const startTime = Date.now();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
try { try {
// 获取基本健康状态 // 获取基本健康状态
const basicHealth = await this.getHealthStatus(ctx); const basicHealth = await this.getHealthStatus(ctx);
// 获取系统资源信息 // 获取系统资源信息
const systemInfo = this.getSystemInfo(); const systemInfo = this.getSystemInfo();
return { return {
...basicHealth, ...basicHealth,
data: { data: {
@ -711,7 +728,7 @@ class HealthService {
const startTime = Date.now(); const startTime = Date.now();
await mysql.execute('SELECT 1'); await mysql.execute('SELECT 1');
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
return { return {
status: 'healthy', status: 'healthy',
responseTime, responseTime,
@ -744,7 +761,7 @@ class HealthService {
const startTime = Date.now(); const startTime = Date.now();
const isHealthy = await this.redis.checkRedisHealth(); const isHealthy = await this.redis.checkRedisHealth();
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
if (isHealthy) { if (isHealthy) {
const redisStatus = this.redis.getRedisStatus(); const redisStatus = this.redis.getRedisStatus();
return { return {
@ -782,9 +799,9 @@ class HealthService {
* @returns 整体状态 * @returns 整体状态
*/ */
private determineOverallStatus(components: ComponentStatus[]): 'healthy' | 'unhealthy' | 'degraded' { private determineOverallStatus(components: ComponentStatus[]): 'healthy' | 'unhealthy' | 'degraded' {
const healthyCount = components.filter(c => c.status === 'healthy').length; const healthyCount = components.filter((c) => c.status === 'healthy').length;
const totalCount = components.length; const totalCount = components.length;
if (healthyCount === totalCount) { if (healthyCount === totalCount) {
return 'healthy'; return 'healthy';
} else if (healthyCount === 0) { } else if (healthyCount === 0) {
@ -1287,7 +1304,7 @@ import { app } from '@/app';
describe('健康检查接口测试', () => { describe('健康检查接口测试', () => {
beforeAll(async () => { beforeAll(async () => {
// 等待应用启动 // 等待应用启动
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
}); });
describe('GET /api/health', () => { describe('GET /api/health', () => {
@ -1298,14 +1315,14 @@ describe('健康检查接口测试', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 检查基本数据结构 // 检查基本数据结构
expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/);
expect(body.data.timestamp).toBeTypeOf('string'); expect(body.data.timestamp).toBeTypeOf('string');
@ -1314,7 +1331,7 @@ describe('健康检查接口测试', () => {
expect(body.data.version).toBeTypeOf('string'); expect(body.data.version).toBeTypeOf('string');
expect(body.data.environment).toBeTypeOf('string'); expect(body.data.environment).toBeTypeOf('string');
expect(body.data.components).toBeTypeOf('object'); expect(body.data.components).toBeTypeOf('object');
// 检查组件状态 // 检查组件状态
if (body.data.components.mysql) { if (body.data.components.mysql) {
expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/);
@ -1330,9 +1347,9 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 验证ISO时间戳格式 // 验证ISO时间戳格式
const timestamp = new Date(body.data.timestamp); const timestamp = new Date(body.data.timestamp);
expect(timestamp.toISOString()).toBe(body.data.timestamp); expect(timestamp.toISOString()).toBe(body.data.timestamp);
@ -1346,9 +1363,9 @@ describe('健康检查接口测试', () => {
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 响应时间应该在合理范围内 // 响应时间应该在合理范围内
expect(body.data.responseTime).toBeGreaterThan(0); expect(body.data.responseTime).toBeGreaterThan(0);
expect(body.data.responseTime).toBeLessThan(endTime - startTime + 100); // 允许一定误差 expect(body.data.responseTime).toBeLessThan(endTime - startTime + 100); // 允许一定误差
@ -1360,9 +1377,9 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(body.data.environment).toMatch(/^(development|production|test)$/); expect(body.data.environment).toMatch(/^(development|production|test)$/);
expect(body.data.uptime).toBeGreaterThan(0); expect(body.data.uptime).toBeGreaterThan(0);
}); });
@ -1376,21 +1393,21 @@ describe('健康检查接口测试', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 检查基本健康检查数据 // 检查基本健康检查数据
expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/);
expect(body.data.timestamp).toBeTypeOf('string'); expect(body.data.timestamp).toBeTypeOf('string');
expect(body.data.uptime).toBeTypeOf('number'); expect(body.data.uptime).toBeTypeOf('number');
expect(body.data.responseTime).toBeTypeOf('number'); expect(body.data.responseTime).toBeTypeOf('number');
expect(body.data.components).toBeTypeOf('object'); expect(body.data.components).toBeTypeOf('object');
// 检查详细信息 // 检查详细信息
if (body.data.system) { if (body.data.system) {
expect(body.data.system.platform).toBeTypeOf('string'); expect(body.data.system.platform).toBeTypeOf('string');
@ -1400,16 +1417,16 @@ describe('健康检查接口测试', () => {
expect(body.data.system.pid).toBeTypeOf('number'); expect(body.data.system.pid).toBeTypeOf('number');
expect(body.data.system.cwd).toBeTypeOf('string'); expect(body.data.system.cwd).toBeTypeOf('string');
} }
if (body.data.performance) { if (body.data.performance) {
expect(body.data.performance.cpuUsage).toBeTypeOf('object'); expect(body.data.performance.cpuUsage).toBeTypeOf('object');
expect(body.data.performance.memoryUsage).toBeTypeOf('object'); expect(body.data.performance.memoryUsage).toBeTypeOf('object');
expect(body.data.performance.uptime).toBeTypeOf('number'); expect(body.data.performance.uptime).toBeTypeOf('number');
// 检查CPU使用情况 // 检查CPU使用情况
expect(body.data.performance.cpuUsage.user).toBeTypeOf('number'); expect(body.data.performance.cpuUsage.user).toBeTypeOf('number');
expect(body.data.performance.cpuUsage.system).toBeTypeOf('number'); expect(body.data.performance.cpuUsage.system).toBeTypeOf('number');
// 检查内存使用情况 // 检查内存使用情况
expect(body.data.performance.memoryUsage.rss).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.rss).toBeTypeOf('number');
expect(body.data.performance.memoryUsage.heapTotal).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.heapTotal).toBeTypeOf('number');
@ -1425,9 +1442,9 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.system) { if (body.data.system) {
expect(body.data.system.runtime).toBe('Bun'); expect(body.data.system.runtime).toBe('Bun');
expect(body.data.system.pid).toBe(process.pid); expect(body.data.system.pid).toBe(process.pid);
@ -1444,18 +1461,18 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.components.mysql) { if (body.data.components.mysql) {
expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/);
if (body.data.components.mysql.details) { if (body.data.components.mysql.details) {
expect(body.data.components.mysql.details.connection).toMatch(/^(active|failed)$/); expect(body.data.components.mysql.details.connection).toMatch(/^(active|failed)$/);
expect(body.data.components.mysql.details.host).toBeTypeOf('string'); expect(body.data.components.mysql.details.host).toBeTypeOf('string');
expect(body.data.components.mysql.details.port).toBeTypeOf('string'); expect(body.data.components.mysql.details.port).toBeTypeOf('string');
} }
if (body.data.components.mysql.responseTime) { if (body.data.components.mysql.responseTime) {
expect(body.data.components.mysql.responseTime).toBeGreaterThan(0); expect(body.data.components.mysql.responseTime).toBeGreaterThan(0);
} }
@ -1468,16 +1485,16 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.components.redis) { if (body.data.components.redis) {
expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/);
if (body.data.components.redis.details) { if (body.data.components.redis.details) {
expect(body.data.components.redis.details.connection).toMatch(/^(active|failed)$/); expect(body.data.components.redis.details.connection).toMatch(/^(active|failed)$/);
} }
if (body.data.components.redis.responseTime) { if (body.data.components.redis.responseTime) {
expect(body.data.components.redis.responseTime).toBeGreaterThan(0); expect(body.data.components.redis.responseTime).toBeGreaterThan(0);
} }
@ -1492,28 +1509,28 @@ describe('健康检查接口测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 即使有组件异常,也应该返回结构化的响应 // 即使有组件异常,也应该返回结构化的响应
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 如果有组件异常整体状态可能是degraded或unhealthy // 如果有组件异常整体状态可能是degraded或unhealthy
if (body.data.status === 'unhealthy' || body.data.status === 'degraded') { if (body.data.status === 'unhealthy' || body.data.status === 'degraded') {
// 应该有组件错误信息 // 应该有组件错误信息
const components = body.data.components; const components = body.data.components;
let hasUnhealthyComponent = false; let hasUnhealthyComponent = false;
Object.values(components).forEach((component: any) => { Object.values(components).forEach((component: any) => {
if (component && component.status === 'unhealthy') { if (component && component.status === 'unhealthy') {
hasUnhealthyComponent = true; hasUnhealthyComponent = true;
expect(component.error).toBeTypeOf('string'); expect(component.error).toBeTypeOf('string');
} }
}); });
if (!hasUnhealthyComponent && body.data.error) { if (!hasUnhealthyComponent && body.data.error) {
expect(body.data.error).toBeTypeOf('string'); expect(body.data.error).toBeTypeOf('string');
} }
@ -1524,32 +1541,32 @@ describe('健康检查接口测试', () => {
describe('健康检查性能测试', () => { describe('健康检查性能测试', () => {
it('健康检查应该快速响应', async () => { it('健康检查应该快速响应', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(2000); // 应该在2秒内完成 expect(responseTime).toBeLessThan(2000); // 应该在2秒内完成
}); });
it('详细健康检查应该在合理时间内完成', async () => { it('详细健康检查应该在合理时间内完成', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health/detailed', { new Request('http://localhost/api/health/detailed', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(3000); // 详细检查可能稍慢但应该在3秒内完成 expect(responseTime).toBeLessThan(3000); // 详细检查可能稍慢但应该在3秒内完成
}); });
@ -1557,20 +1574,20 @@ describe('健康检查接口测试', () => {
it('并发健康检查应该正常处理', async () => { it('并发健康检查应该正常处理', async () => {
const promises = []; const promises = [];
const concurrentRequests = 5; const concurrentRequests = 5;
for (let i = 0; i < concurrentRequests; i++) { for (let i = 0; i < concurrentRequests; i++) {
promises.push( promises.push(
app.fetch( app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
) ),
); );
} }
const responses = await Promise.all(promises); const responses = await Promise.all(promises);
responses.forEach(res => { responses.forEach((res) => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
}); });
@ -1628,11 +1645,13 @@ import { app } from '@/app';
### ✅ 已实现的功能 ### ✅ 已实现的功能
#### 1. **健康检查控制器** (`src/controllers/health.controller.ts`) #### 1. **健康检查控制器** (`src/controllers/health.controller.ts`)
- 📍 `/api/health` - 基本健康检查接口 - 📍 `/api/health` - 基本健康检查接口
- 📍 `/api/health/detailed` - 详细健康检查接口 - 📍 `/api/health/detailed` - 详细健康检查接口
- 📖 集成了完整的 Swagger 文档 - 📖 集成了完整的 Swagger 文档
#### 2. **健康检查服务** (`src/services/health.service.ts`) #### 2. **健康检查服务** (`src/services/health.service.ts`)
- 🔄 **并行检查**:同时检查 MySQL 和 Redis 连接状态 - 🔄 **并行检查**:同时检查 MySQL 和 Redis 连接状态
- 📊 **三种状态**healthy、unhealthy、degraded - 📊 **三种状态**healthy、unhealthy、degraded
- ⏱️ **响应时间**:监控每个组件的响应时间 - ⏱️ **响应时间**:监控每个组件的响应时间
@ -1641,18 +1660,22 @@ import { app } from '@/app';
- ⚠️ **错误处理**:完善的异常捕获和错误响应 - ⚠️ **错误处理**:完善的异常捕获和错误响应
#### 3. **类型定义** (`src/type/health.type.ts`) #### 3. **类型定义** (`src/type/health.type.ts`)
- 完整的 TypeScript 类型定义 - 完整的 TypeScript 类型定义
- 组件状态、系统信息、性能指标等类型 - 组件状态、系统信息、性能指标等类型
#### 4. **响应验证器** (`src/validators/health.response.ts`) #### 4. **响应验证器** (`src/validators/health.response.ts`)
- Elysia TypeBox 严格验证规则 - Elysia TypeBox 严格验证规则
- 支持基本和详细健康检查响应验证 - 支持基本和详细健康检查响应验证
#### 5. **应用集成** (`src/app.ts`) #### 5. **应用集成** (`src/app.ts`)
- 将健康检查控制器集成到主应用 - 将健康检查控制器集成到主应用
- 替换了原有的简单健康检查接口 - 替换了原有的简单健康检查接口
#### 6. **全面测试** (`src/tests/health.test.ts`) #### 6. **全面测试** (`src/tests/health.test.ts`)
- 🧪 基本健康检查接口测试 - 🧪 基本健康检查接口测试
- 🔍 详细健康检查接口测试 - 🔍 详细健康检查接口测试
- 🔗 依赖服务状态测试 - 🔗 依赖服务状态测试
@ -1662,20 +1685,21 @@ import { app } from '@/app';
### 🌟 接口特性 ### 🌟 接口特性
| 特性 | 描述 | | 特性 | 描述 |
|------|------| | ------------ | ----------------------------------- |
| **依赖监控** | MySQL + Redis 连接状态实时检查 | | **依赖监控** | MySQL + Redis 连接状态实时检查 |
| **状态分级** | healthy/unhealthy/degraded 三级状态 | | **状态分级** | healthy/unhealthy/degraded 三级状态 |
| **性能监控** | CPU、内存、响应时间统计 | | **性能监控** | CPU、内存、响应时间统计 |
| **错误友好** | 详细错误信息和降级策略 | | **错误友好** | 详细错误信息和降级策略 |
| **文档完善** | 自动生成 Swagger API 文档 | | **文档完善** | 自动生成 Swagger API 文档 |
| **测试全面** | 涵盖正常、异常、性能、并发场景 | | **测试全面** | 涵盖正常、异常、性能、并发场景 |
### 📋 任务清单更新 ### 📋 任务清单更新
任务清单已更新: 任务清单已更新:
- ✅ 5.0 健康检查接口 - ✅ 5.0 健康检查接口
- ✅ 5.1 实现 /health 路由,返回服务与依赖健康状态 - ✅ 5.1 实现 /health 路由,返回服务与依赖健康状态
你可以通过以下命令测试健康检查功能: 你可以通过以下命令测试健康检查功能:
@ -1867,11 +1891,13 @@ import { healthResponse } from '@/validators/health.response';
我已经修复了主要的导入问题,包括: 我已经修复了主要的导入问题,包括:
**已修复的问题** **已修复的问题**
- MySQL导入`mysql` 改为 `pool`
- MySQL导入`mysql` 改为 `pool`
- Logger参数修复了错误日志记录的参数数量 - Logger参数修复了错误日志记录的参数数量
- 类型导入:使用 `import type` 和内联类型定义 - 类型导入:使用 `import type` 和内联类型定义
🔧 **当前状态** 🔧 **当前状态**
- 健康检查功能已完全实现 - 健康检查功能已完全实现
- 任务5.0已标记为完成 - 任务5.0已标记为完成
- 还有一些TypeScript路径别名相关的警告 - 还有一些TypeScript路径别名相关的警告
@ -1896,6 +1922,7 @@ ERROR undefined is not an object (evaluating 'this.checkMysqlHealth')
我看到了问题!测试虽然通过了,但有很多错误日志显示 `undefined is not an object (evaluating 'this.checkMysqlHealth')`。这是一个典型的 JavaScript `this` 上下文丢失的问题。 我看到了问题!测试虽然通过了,但有很多错误日志显示 `undefined is not an object (evaluating 'this.checkMysqlHealth')`。这是一个典型的 JavaScript `this` 上下文丢失的问题。
当我们在控制器中直接传递服务方法时: 当我们在控制器中直接传递服务方法时:
```typescript ```typescript
.get('/health', healthService.getHealthStatus, { .get('/health', healthService.getHealthStatus, {
``` ```
@ -2191,11 +2218,7 @@ export const HealthCheckResponseSchema = t.Object({
code: t.Number(), code: t.Number(),
message: t.String(), message: t.String(),
data: t.Object({ data: t.Object({
status: t.Union([ status: t.Union([t.Literal('healthy'), t.Literal('unhealthy'), t.Literal('degraded')], {
t.Literal('healthy'),
t.Literal('unhealthy'),
t.Literal('degraded'),
], {
description: '系统健康状态healthy-健康unhealthy-不健康degraded-降级', description: '系统健康状态healthy-健康unhealthy-不健康degraded-降级',
}), }),
timestamp: t.String({ timestamp: t.String({
@ -2219,18 +2242,22 @@ export const HealthCheckResponseSchema = t.Object({
examples: ['development', 'production', 'test'], examples: ['development', 'production', 'test'],
}), }),
components: t.Object({ components: t.Object({
mysql: t.Optional(t.Object({ mysql: t.Optional(
status: t.String(), t.Object({
responseTime: t.Optional(t.Number()), status: t.String(),
error: t.Optional(t.String()), responseTime: t.Optional(t.Number()),
details: t.Optional(t.Any()), error: t.Optional(t.String()),
})), details: t.Optional(t.Any()),
redis: t.Optional(t.Object({ }),
status: t.String(), ),
responseTime: t.Optional(t.Number()), redis: t.Optional(
error: t.Optional(t.String()), t.Object({
details: t.Optional(t.Any()), status: t.String(),
})), responseTime: t.Optional(t.Number()),
error: t.Optional(t.String()),
details: t.Optional(t.Any()),
}),
),
}), }),
}), }),
}); });
@ -2856,9 +2883,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const contentType = res.headers.get('content-type'); const contentType = res.headers.get('content-type');
expect(contentType).toContain('text/html'); expect(contentType).toContain('text/html');
}); });
@ -2869,9 +2896,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const html = await res.text(); const html = await res.text();
// 检查基本配置 // 检查基本配置
expect(html).toContain('Cursor Init API服务'); expect(html).toContain('Cursor Init API服务');
expect(html).toContain('swagger-ui'); expect(html).toContain('swagger-ui');
@ -2885,20 +2912,20 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const contentType = res.headers.get('content-type'); const contentType = res.headers.get('content-type');
expect(contentType).toContain('application/json'); expect(contentType).toContain('application/json');
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证OpenAPI文档结构 // 验证OpenAPI文档结构
expect(openApiDoc).toHaveProperty('openapi'); expect(openApiDoc).toHaveProperty('openapi');
expect(openApiDoc).toHaveProperty('info'); expect(openApiDoc).toHaveProperty('info');
expect(openApiDoc).toHaveProperty('paths'); expect(openApiDoc).toHaveProperty('paths');
expect(openApiDoc).toHaveProperty('components'); expect(openApiDoc).toHaveProperty('components');
// 验证基本信息 // 验证基本信息
expect(openApiDoc.info.title).toBe('Cursor Init API服务'); expect(openApiDoc.info.title).toBe('Cursor Init API服务');
expect(openApiDoc.info.version).toBe('1.0.0'); expect(openApiDoc.info.version).toBe('1.0.0');
@ -2911,9 +2938,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证安全配置 // 验证安全配置
expect(openApiDoc.components).toHaveProperty('securitySchemes'); expect(openApiDoc.components).toHaveProperty('securitySchemes');
expect(openApiDoc.components.securitySchemes).toHaveProperty('bearerAuth'); expect(openApiDoc.components.securitySchemes).toHaveProperty('bearerAuth');
@ -2928,13 +2955,13 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证全局组件 // 验证全局组件
expect(openApiDoc.components).toHaveProperty('schemas'); expect(openApiDoc.components).toHaveProperty('schemas');
expect(openApiDoc.components).toHaveProperty('responses'); expect(openApiDoc.components).toHaveProperty('responses');
// 验证响应组件 // 验证响应组件
const responses = openApiDoc.components.responses; const responses = openApiDoc.components.responses;
expect(responses).toHaveProperty('Success'); expect(responses).toHaveProperty('Success');
@ -2945,7 +2972,7 @@ describe('Swagger API文档测试', () => {
expect(responses).toHaveProperty('ValidationError'); expect(responses).toHaveProperty('ValidationError');
expect(responses).toHaveProperty('InternalError'); expect(responses).toHaveProperty('InternalError');
expect(responses).toHaveProperty('ServiceUnavailable'); expect(responses).toHaveProperty('ServiceUnavailable');
// 验证Schema组件 // 验证Schema组件
const schemas = openApiDoc.components.schemas; const schemas = openApiDoc.components.schemas;
expect(schemas).toHaveProperty('BaseResponse'); expect(schemas).toHaveProperty('BaseResponse');
@ -2960,13 +2987,13 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证健康检查接口 // 验证健康检查接口
expect(openApiDoc.paths).toHaveProperty('/api/health'); expect(openApiDoc.paths).toHaveProperty('/api/health');
expect(openApiDoc.paths).toHaveProperty('/api/health/detailed'); expect(openApiDoc.paths).toHaveProperty('/api/health/detailed');
const healthPath = openApiDoc.paths['/api/health']; const healthPath = openApiDoc.paths['/api/health'];
expect(healthPath).toHaveProperty('get'); expect(healthPath).toHaveProperty('get');
expect(healthPath.get).toHaveProperty('tags'); expect(healthPath.get).toHaveProperty('tags');
@ -2981,12 +3008,12 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证认证接口 // 验证认证接口
expect(openApiDoc.paths).toHaveProperty('/api/auth/login'); expect(openApiDoc.paths).toHaveProperty('/api/auth/login');
const loginPath = openApiDoc.paths['/api/auth/login']; const loginPath = openApiDoc.paths['/api/auth/login'];
expect(loginPath).toHaveProperty('post'); expect(loginPath).toHaveProperty('post');
expect(loginPath.post).toHaveProperty('tags'); expect(loginPath.post).toHaveProperty('tags');
@ -3000,17 +3027,17 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证标签 // 验证标签
expect(openApiDoc).toHaveProperty('tags'); expect(openApiDoc).toHaveProperty('tags');
expect(Array.isArray(openApiDoc.tags)).toBe(true); expect(Array.isArray(openApiDoc.tags)).toBe(true);
const tagNames = openApiDoc.tags.map((tag: any) => tag.name); const tagNames = openApiDoc.tags.map((tag: any) => tag.name);
expect(tagNames).toContain('认证管理'); expect(tagNames).toContain('认证管理');
expect(tagNames).toContain('健康检查'); expect(tagNames).toContain('健康检查');
// 验证标签描述 // 验证标签描述
const healthTag = openApiDoc.tags.find((tag: any) => tag.name === '健康检查'); const healthTag = openApiDoc.tags.find((tag: any) => tag.name === '健康检查');
expect(healthTag).toHaveProperty('description'); expect(healthTag).toHaveProperty('description');
@ -3025,9 +3052,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证错误码描述在文档中 // 验证错误码描述在文档中
expect(openApiDoc.info.description).toContain('错误码说明'); expect(openApiDoc.info.description).toContain('错误码说明');
expect(openApiDoc.info.description).toContain('| 错误码 | 说明 | 示例场景 |'); expect(openApiDoc.info.description).toContain('| 错误码 | 说明 | 示例场景 |');
@ -3042,9 +3069,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证认证说明 // 验证认证说明
expect(openApiDoc.info.description).toContain('认证说明'); expect(openApiDoc.info.description).toContain('认证说明');
expect(openApiDoc.info.description).toContain('Authorization: Bearer'); expect(openApiDoc.info.description).toContain('Authorization: Bearer');
@ -3058,9 +3085,9 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证响应格式说明 // 验证响应格式说明
expect(openApiDoc.info.description).toContain('响应格式'); expect(openApiDoc.info.description).toContain('响应格式');
expect(openApiDoc.info.description).toContain('"code": 0'); expect(openApiDoc.info.description).toContain('"code": 0');
@ -3074,16 +3101,16 @@ describe('Swagger API文档测试', () => {
method: 'GET', method: 'GET',
}), }),
); );
const openApiDoc = await res.json(); const openApiDoc = await res.json();
// 验证示例响应 // 验证示例响应
const successResponse = openApiDoc.components.responses.Success; const successResponse = openApiDoc.components.responses.Success;
expect(successResponse.content['application/json']).toHaveProperty('examples'); expect(successResponse.content['application/json']).toHaveProperty('examples');
const errorResponse = openApiDoc.components.responses.BadRequest; const errorResponse = openApiDoc.components.responses.BadRequest;
expect(errorResponse.content['application/json']).toHaveProperty('examples'); expect(errorResponse.content['application/json']).toHaveProperty('examples');
const unauthorizedResponse = openApiDoc.components.responses.Unauthorized; const unauthorizedResponse = openApiDoc.components.responses.Unauthorized;
expect(unauthorizedResponse.content['application/json']).toHaveProperty('examples'); expect(unauthorizedResponse.content['application/json']).toHaveProperty('examples');
expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenExpired'); expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenExpired');
@ -3095,32 +3122,32 @@ describe('Swagger API文档测试', () => {
describe('Swagger文档性能测试', () => { describe('Swagger文档性能测试', () => {
it('文档页面应该快速加载', async () => { it('文档页面应该快速加载', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/docs', { new Request('http://localhost/docs', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(1000); // 应该在1秒内完成 expect(responseTime).toBeLessThan(1000); // 应该在1秒内完成
}); });
it('JSON文档应该快速响应', async () => { it('JSON文档应该快速响应', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/docs/json', { new Request('http://localhost/docs/json', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(500); // JSON文档应该更快 expect(responseTime).toBeLessThan(500); // JSON文档应该更快
}); });
@ -3251,4 +3278,3 @@ feat: 完成健康检查接口和Swagger文档完善
- src/tests/redis.test.ts - Redis连接测试 - src/tests/redis.test.ts - Redis连接测试
- src/tests/swagger.test.ts - Swagger文档功能测试 - src/tests/swagger.test.ts - Swagger文档功能测试
``` ```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,157 +1,156 @@
# API Schema 类型使用指南 # API Schema 类型使用指南
## 1. Schema 转 TypeScript 类型 ## 1. Schema 转 TypeScript 类型
`.schema.ts` 文件中定义并导出类型: `.schema.ts` 文件中定义并导出类型:
```typescript ```typescript
// example.schema.ts // example.schema.ts
import { t, type Static } from 'elysia'; import { t, type Static } from 'elysia';
// Schema 定义 // Schema 定义
export const GetUserByUsernameSchema = t.Object({ export const GetUserByUsernameSchema = t.Object({
username: t.String({ username: t.String({
minLength: 2, minLength: 2,
maxLength: 50, maxLength: 50,
description: '用户名', description: '用户名',
}), }),
}); });
// 从 Schema 推断类型 // 从 Schema 推断类型
export type GetUserByUsernameParams = Static<typeof GetUserByUsernameSchema>; export type GetUserByUsernameParams = Static<typeof GetUserByUsernameSchema>;
``` ```
## 2. 在 Service 中使用类型 ## 2. 在 Service 中使用类型
```typescript ```typescript
// example.service.ts // example.service.ts
import type { GetUserByUsernameParams, UserInfo } from './example.schema'; import type { GetUserByUsernameParams, UserInfo } from './example.schema';
export class ExampleService { export class ExampleService {
// 使用类型注解参数 // 使用类型注解参数
async getUserByUsername(params: GetUserByUsernameParams): Promise<UserInfo> { async getUserByUsername(params: GetUserByUsernameParams): Promise<UserInfo> {
const { username } = params; // TypeScript 会自动推断类型 const { username } = params; // TypeScript 会自动推断类型
// 业务逻辑... // 业务逻辑...
return userResult; return userResult;
} }
// 或者直接使用解构参数 // 或者直接使用解构参数
async getUserByUsername2({ username }: GetUserByUsernameParams): Promise<UserInfo> { async getUserByUsername2({ username }: GetUserByUsernameParams): Promise<UserInfo> {
// 业务逻辑... // 业务逻辑...
return userResult; return userResult;
} }
} }
``` ```
## 3. 在 Controller 中使用类型 ## 3. 在 Controller 中使用类型
```typescript ```typescript
// example.controller.ts // example.controller.ts
import type { GetUserByUsernameParams, UserInfo } from './example.schema'; import type { GetUserByUsernameParams, UserInfo } from './example.schema';
import { GetUserByUsernameSchema } from './example.schema'; import { GetUserByUsernameSchema } from './example.schema';
export const controller = new Elysia() export const controller = new Elysia().get(
.get( '/user/:username',
'/user/:username', async ({ params }) => {
async ({ params }) => { // params 自动推断为 GetUserByUsernameParams 类型
// params 自动推断为 GetUserByUsernameParams 类型 const userInfo: UserInfo = await service.getUserByUsername(params);
const userInfo: UserInfo = await service.getUserByUsername(params); return successResponse(userInfo);
return successResponse(userInfo); },
}, {
{ // 使用 Schema 进行运行时验证
// 使用 Schema 进行运行时验证 params: GetUserByUsernameSchema,
params: GetUserByUsernameSchema, },
} );
); ```
```
## 4. 在测试中使用类型
## 4. 在测试中使用类型
```typescript
```typescript // example.test.ts
// example.test.ts import type { GetUserByUsernameParams, UserInfo } from './example.schema';
import type { GetUserByUsernameParams, UserInfo } from './example.schema';
describe('用户查询测试', () => {
describe('用户查询测试', () => { it('应该正确处理参数类型', () => {
it('应该正确处理参数类型', () => { // 类型安全的测试数据
// 类型安全的测试数据 const validParams: GetUserByUsernameParams = {
const validParams: GetUserByUsernameParams = { username: 'testuser',
username: 'testuser' };
};
const invalidParams = {
const invalidParams = { username: 'a', // TypeScript 会提示这可能不符合验证规则
username: 'a' // TypeScript 会提示这可能不符合验证规则 };
}; });
}); });
}); ```
```
## 5. 工具函数中使用类型
## 5. 工具函数中使用类型
```typescript
```typescript // utils/validators.ts
// utils/validators.ts import type { GetUserByUsernameParams } from '../modules/example/example.schema';
import type { GetUserByUsernameParams } from '../modules/example/example.schema';
// 类型安全的验证函数
// 类型安全的验证函数 export function validateUsername(params: GetUserByUsernameParams): boolean {
export function validateUsername(params: GetUserByUsernameParams): boolean { return params.username.length >= 2 && params.username.length <= 50;
return params.username.length >= 2 && params.username.length <= 50; }
}
// 类型安全的格式化函数
// 类型安全的格式化函数 export function formatUserQuery(params: GetUserByUsernameParams): string {
export function formatUserQuery(params: GetUserByUsernameParams): string { return `查询用户: ${params.username}`;
return `查询用户: ${params.username}`; }
} ```
```
## 6. 响应类型使用示例
## 6. 响应类型使用示例
```typescript
```typescript // example.response.ts
// example.response.ts import { t, type Static } from 'elysia';
import { t, type Static } from 'elysia'; import { UserInfoSchema } from './example.schema';
import { UserInfoSchema } from './example.schema';
export const GetUserSuccessResponse = t.Object({
export const GetUserSuccessResponse = t.Object({ code: t.Literal(0),
code: t.Literal(0), message: t.String(),
message: t.String(), data: UserInfoSchema,
data: UserInfoSchema, });
});
// 导出响应类型
// 导出响应类型 export type GetUserSuccessResponseType = Static<typeof GetUserSuccessResponse>;
export type GetUserSuccessResponseType = Static<typeof GetUserSuccessResponse>; ```
```
## 7. 完整的类型流转示例
## 7. 完整的类型流转示例
```typescript
```typescript // 完整的类型安全流程
// 完整的类型安全流程 import type { GetUserByUsernameParams, UserInfo } from './example.schema';
import type {
GetUserByUsernameParams, // Service 层
UserInfo class UserService {
} from './example.schema'; async getUser(params: GetUserByUsernameParams): Promise<UserInfo> {
// params.username 有完整的类型提示
// Service 层 // 返回值必须符合 UserInfo 类型
class UserService { }
async getUser(params: GetUserByUsernameParams): Promise<UserInfo> { }
// params.username 有完整的类型提示
// 返回值必须符合 UserInfo 类型 // Controller 层
} const controller = new Elysia().get(
} '/user/:username',
async ({ params }) => {
// Controller 层 // params 自动推断类型
const controller = new Elysia() const user = await userService.getUser(params);
.get('/user/:username', async ({ params }) => { // user 自动推断为 UserInfo 类型
// params 自动推断类型 return { code: 0, message: '成功', data: user };
const user = await userService.getUser(params); },
// user 自动推断为 UserInfo 类型 {
return { code: 0, message: '成功', data: user }; params: GetUserByUsernameSchema, // 运行时验证
}, { },
params: GetUserByUsernameSchema, // 运行时验证 );
}); ```
```
## 💡 最佳实践
## 💡 最佳实践
1. **总是导出类型**:每个 Schema 都应该导出对应的 TypeScript 类型
1. **总是导出类型**:每个 Schema 都应该导出对应的 TypeScript 类型 2. **类型注解**:在 Service 方法中明确使用类型注解
2. **类型注解**:在 Service 方法中明确使用类型注解 3. **一致命名**:类型名应该与 Schema 名保持一致的命名规范
3. **一致命名**:类型名应该与 Schema 名保持一致的命名规范 4. **分离关注点**Schema 用于运行时验证Type 用于编译时类型检查
4. **分离关注点**Schema 用于运行时验证Type 用于编译时类型检查

View File

@ -17,22 +17,25 @@
### 1. 短期操作(推荐不开启自动续期) ### 1. 短期操作(推荐不开启自动续期)
**适用场景** **适用场景**
- 用户登录 - 用户登录
- Token刷新 - Token刷新
- 数据查询 - 数据查询
- 简单的数据更新 - 简单的数据更新
**配置建议** **配置建议**
```typescript ```typescript
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: 'user:login:username', key: 'user:login:username',
ttl: 15, // 15秒过期 ttl: 15, // 15秒过期
timeout: 8000, // 8秒超时 timeout: 8000, // 8秒超时
autoRenew: false // 不开启自动续期 autoRenew: false, // 不开启自动续期
}); });
``` ```
**优点** **优点**
- 简单可靠,不会出现死锁 - 简单可靠,不会出现死锁
- 性能开销小 - 性能开销小
- 适合快速操作 - 适合快速操作
@ -40,23 +43,26 @@ const lock = await DistributedLockService.acquire({
### 2. 长期操作(需要开启自动续期) ### 2. 长期操作(需要开启自动续期)
**适用场景** **适用场景**
- 用户注册(包含邮件发送) - 用户注册(包含邮件发送)
- 密码重置(包含邮件发送) - 密码重置(包含邮件发送)
- 文件上传 - 文件上传
- 复杂的数据处理 - 复杂的数据处理
**配置建议** **配置建议**
```typescript ```typescript
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: 'user:register:username:email', key: 'user:register:username:email',
ttl: 60, // 60秒过期 ttl: 60, // 60秒过期
timeout: 15000, // 15秒超时 timeout: 15000, // 15秒超时
autoRenew: true, // 开启自动续期 autoRenew: true, // 开启自动续期
renewInterval: 20000 // 20秒续期一次 renewInterval: 20000, // 20秒续期一次
}); });
``` ```
**注意事项** **注意事项**
- 必须确保在操作完成后手动释放锁 - 必须确保在操作完成后手动释放锁
- 进程退出时会自动清理锁 - 进程退出时会自动清理锁
- 续期失败时会记录警告日志 - 续期失败时会记录警告日志
@ -64,26 +70,29 @@ const lock = await DistributedLockService.acquire({
## 锁键名设计规范 ## 锁键名设计规范
### 1. 命名规则 ### 1. 命名规则
``` ```
{业务模块}:{操作类型}:{关键标识} {业务模块}:{操作类型}:{关键标识}
``` ```
### 2. 示例 ### 2. 示例
```typescript ```typescript
// 用户注册锁 // 用户注册锁
'user:register:username:email' 'user:register:username:email';
// 用户登录锁 // 用户登录锁
'user:login:username' 'user:login:username';
// 密码重置锁 // 密码重置锁
'password:reset:email' 'password:reset:email';
// Token刷新锁 // Token刷新锁
'token:refresh:token_value' 'token:refresh:token_value';
``` ```
### 3. 注意事项 ### 3. 注意事项
- 键名要具有唯一性 - 键名要具有唯一性
- 避免使用过长的键名 - 避免使用过长的键名
- 使用有意义的标识符 - 使用有意义的标识符
@ -93,64 +102,71 @@ const lock = await DistributedLockService.acquire({
### 1. 锁的粒度控制 ### 1. 锁的粒度控制
**好的做法** **好的做法**
```typescript ```typescript
// 针对特定用户加锁 // 针对特定用户加锁
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: `user:login:${username}`, key: `user:login:${username}`,
ttl: 15, ttl: 15,
autoRenew: false autoRenew: false,
}); });
``` ```
**避免的做法** **避免的做法**
```typescript ```typescript
// 锁的粒度太粗,影响其他用户 // 锁的粒度太粗,影响其他用户
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: 'user:login', // 所有用户登录都被阻塞 key: 'user:login', // 所有用户登录都被阻塞
ttl: 15, ttl: 15,
autoRenew: false autoRenew: false,
}); });
``` ```
### 2. 超时时间设置 ### 2. 超时时间设置
**原则** **原则**
- 超时时间应该大于预期的操作时间 - 超时时间应该大于预期的操作时间
- 但不要设置过长,避免长时间阻塞 - 但不要设置过长,避免长时间阻塞
**建议** **建议**
```typescript ```typescript
// 快速操作 // 快速操作
timeout: 5000 // 5秒 timeout: 5000; // 5秒
// 中等操作 // 中等操作
timeout: 10000 // 10秒 timeout: 10000; // 10秒
// 慢速操作 // 慢速操作
timeout: 30000 // 30秒 timeout: 30000; // 30秒
``` ```
### 3. TTL设置 ### 3. TTL设置
**原则** **原则**
- TTL应该大于操作时间 - TTL应该大于操作时间
- 对于自动续期的锁TTL可以设置得相对较短 - 对于自动续期的锁TTL可以设置得相对较短
**建议** **建议**
```typescript ```typescript
// 快速操作 // 快速操作
ttl: 10 // 10秒 ttl: 10; // 10秒
// 中等操作 // 中等操作
ttl: 30 // 30秒 ttl: 30; // 30秒
// 慢速操作 // 慢速操作
ttl: 60 // 60秒 ttl: 60; // 60秒
``` ```
### 4. 错误处理 ### 4. 错误处理
**必须使用 try-finally** **必须使用 try-finally**
```typescript ```typescript
const lock = await DistributedLockService.acquire(config); const lock = await DistributedLockService.acquire(config);
@ -166,12 +182,14 @@ try {
### 5. 监控和日志 ### 5. 监控和日志
**监控指标** **监控指标**
- 锁获取成功率 - 锁获取成功率
- 锁等待时间 - 锁等待时间
- 锁释放情况 - 锁释放情况
- 死锁检测 - 死锁检测
**日志记录** **日志记录**
```typescript ```typescript
Logger.info(`获取分布式锁成功: ${lockKey}`); Logger.info(`获取分布式锁成功: ${lockKey}`);
Logger.warn(`锁续期失败: ${lockKey}`); Logger.warn(`锁续期失败: ${lockKey}`);
@ -183,11 +201,13 @@ Logger.error(`获取锁超时: ${lockKey}`);
### 1. 死锁问题 ### 1. 死锁问题
**原因** **原因**
- 进程崩溃但锁未释放 - 进程崩溃但锁未释放
- 网络中断导致无法续期 - 网络中断导致无法续期
- 业务逻辑异常导致锁未释放 - 业务逻辑异常导致锁未释放
**解决方案** **解决方案**
- 设置合理的TTL - 设置合理的TTL
- 使用try-finally确保锁释放 - 使用try-finally确保锁释放
- 进程退出时自动清理锁 - 进程退出时自动清理锁
@ -196,11 +216,13 @@ Logger.error(`获取锁超时: ${lockKey}`);
### 2. 性能问题 ### 2. 性能问题
**原因** **原因**
- 锁的粒度太粗 - 锁的粒度太粗
- 锁的持有时间过长 - 锁的持有时间过长
- 频繁的锁竞争 - 频繁的锁竞争
**解决方案** **解决方案**
- 细化锁的粒度 - 细化锁的粒度
- 优化业务逻辑,减少锁持有时间 - 优化业务逻辑,减少锁持有时间
- 使用读写锁分离 - 使用读写锁分离
@ -209,11 +231,13 @@ Logger.error(`获取锁超时: ${lockKey}`);
### 3. 一致性问题 ### 3. 一致性问题
**原因** **原因**
- 锁释放时机不当 - 锁释放时机不当
- 业务逻辑异常 - 业务逻辑异常
- 并发控制不当 - 并发控制不当
**解决方案** **解决方案**
- 确保锁的原子性操作 - 确保锁的原子性操作
- 使用事务保证数据一致性 - 使用事务保证数据一致性
- 添加业务层面的幂等性检查 - 添加业务层面的幂等性检查
@ -238,9 +262,9 @@ async function complexOperation() {
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: 'complex:operation', key: 'complex:operation',
ttl: 60, ttl: 60,
autoRenew: true autoRenew: true,
}); });
try { try {
// 复杂业务逻辑 // 复杂业务逻辑
await step1(); await step1();
@ -263,4 +287,4 @@ async function complexOperation() {
5. **监控和日志**:及时发现问题 5. **监控和日志**:及时发现问题
6. **定期清理**:防止死锁积累 6. **定期清理**:防止死锁积累
通过合理使用分布式锁,可以有效保证系统的数据一致性和业务正确性。 通过合理使用分布式锁,可以有效保证系统的数据一致性和业务正确性。

View File

@ -119,6 +119,7 @@ src/plugins/
``` ```
### 1.4 类型定义 (type/) ### 1.4 类型定义 (type/)
``` ```
src/type/ src/type/
├── config.type.ts # 配置相关类型 ├── config.type.ts # 配置相关类型
@ -131,6 +132,7 @@ src/type/
``` ```
### 1.5 工具函数 (utils/) ### 1.5 工具函数 (utils/)
``` ```
src/utils/ src/utils/
├── deviceInfo.ts # 设备信息工具 ├── deviceInfo.ts # 设备信息工具
@ -148,11 +150,13 @@ src/utils/
``` ```
### 1.6 常量定义 (constants/) ### 1.6 常量定义 (constants/)
``` ```
src/constants/ src/constants/
├── swaggerTags.ts # Swagger标签定义所有模块的tag应该集中在此定义 ├── swaggerTags.ts # Swagger标签定义所有模块的tag应该集中在此定义
└── 其他常量 └── 其他常量
``` ```
### 1.7 数据库实体 ### 1.7 数据库实体
``` ```
@ -163,6 +167,7 @@ src/eneities/
``` ```
### 1.8 文件命名约定 ### 1.8 文件命名约定
- 模块名使用 **单数形式**`auth`、`user`、`product`、`order` - 模块名使用 **单数形式**`auth`、`user`、`product`、`order`
- 文件名格式:`[module].[type].ts` - 文件名格式:`[module].[type].ts`
- 导出名格式:`[module][类型名]` - 导出名格式:`[module][类型名]`
@ -182,11 +187,12 @@ src/eneities/
- 注意已经存在的工具函数,比如分页有现成的简化工具,且能够统一请求格式 - 注意已经存在的工具函数,比如分页有现成的简化工具,且能够统一请求格式
### 2.2 特别注意点 ### 2.2 特别注意点
**必须遵循的命名模式:** **必须遵循的命名模式:**
- Request类型`[动作][模块]Request` → `RegisterRequest` - Request类型`[动作][模块]Request` → `RegisterRequest`
- Schema名`[动作][模块]Schema` → `RegisterSchema` - Schema名`[动作][模块]Schema` → `RegisterSchema`
### 2.3 代码示例 ### 2.3 代码示例
```ts ```ts
@ -206,38 +212,37 @@ import { createQuerySchema } from '@/utils/pagination';
* 用户列表查询参数Schema * 用户列表查询参数Schema
* @description 用户列表查询的请求参数验证规则 * @description 用户列表查询的请求参数验证规则
*/ */
export const UserListQuerySchema = createQuerySchema(t.Object({ export const UserListQuerySchema = createQuerySchema(
// 用户特有参数 t.Object({
keyword: t.Optional(t.String({ // 用户特有参数
minLength: 1, keyword: t.Optional(
maxLength: 100, t.String({
description: '搜索关键词,支持用户名、邮箱模糊搜索', minLength: 1,
examples: ['admin', 'test@example.com'] maxLength: 100,
})), description: '搜索关键词,支持用户名、邮箱模糊搜索',
status: t.Optional(t.Union([ examples: ['admin', 'test@example.com'],
t.Literal('active'), }),
t.Literal('inactive'), ),
t.Literal('pending') status: t.Optional(
], { t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('pending')], {
description: '用户状态筛选', description: '用户状态筛选',
examples: ['active', 'inactive', 'pending'] examples: ['active', 'inactive', 'pending'],
})), }),
gender: t.Optional(t.Union([ ),
t.Literal(0), gender: t.Optional(
t.Literal(1), t.Union([t.Literal(0), t.Literal(1), t.Literal(2), t.Literal('0'), t.Literal('1'), t.Literal('2')], {
t.Literal(2), description: '性别筛选0-未知1-男2-女',
t.Literal('0'), examples: [0, 1, 2],
t.Literal('1'), }),
t.Literal('2'), ),
], { isRoot: t.Optional(
description: '性别筛选0-未知1-男2-女', t.Boolean({
examples: [0, 1, 2] description: '是否超级管理员筛选',
})), examples: [true, false],
isRoot: t.Optional(t.Boolean({ }),
description: '是否超级管理员筛选', ),
examples: [true, false] }),
})) );
}));
/** 用户列表查询参数类型 */ /** 用户列表查询参数类型 */
export type UserListQueryRequest = Static<typeof UserListQuerySchema>; export type UserListQueryRequest = Static<typeof UserListQuerySchema>;
@ -251,9 +256,10 @@ export type UserListQueryRequest = Static<typeof UserListQuerySchema>;
- 错误相应只提供示例和描述 - 错误相应只提供示例和描述
- 导出响应成功类型 - 导出响应成功类型
### 3.2 特别注意点 ### 3.2 特别注意点
**必须遵循的命名模式:** **必须遵循的命名模式:**
- Response格式定义`[动作][模块]Response` → `RegisterResponse` - Response格式定义`[动作][模块]Response` → `RegisterResponse`
- Response成功类型`[动作][模块]SuccessType ` → `RegisterSuccessType ` - Response成功类型`[动作][模块]SuccessType ` → `RegisterSuccessType `
@ -273,8 +279,6 @@ import { t, type Static } from 'elysia';
import { responseWrapperSchema } from '@/utils/responseFormate'; import { responseWrapperSchema } from '@/utils/responseFormate';
import { createPaginationResponseSchema } from '@/utils/pagination'; import { createPaginationResponseSchema } from '@/utils/pagination';
/** /**
* 用户列表项Schema * 用户列表项Schema
* @description 用户列表中单个用户的数据结构 * @description 用户列表中单个用户的数据结构
@ -283,21 +287,21 @@ export const UserListItemSchema = t.Object({
/** 用户ID */ /** 用户ID */
id: t.String({ id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失', description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3'] examples: ['1', '2', '3'],
}), }),
/** 用户名 */ /** 用户名 */
username: t.String({ username: t.String({
description: '用户名', description: '用户名',
examples: ['admin', 'testuser'] examples: ['admin', 'testuser'],
}), }),
/** 更多字段... */ /** 更多字段... */
/** 更新时间 */ /** 更新时间 */
updatedAt: t.String({ updatedAt: t.String({
description: '更新时间', description: '更新时间',
examples: ['2024-12-19T10:30:00Z'] examples: ['2024-12-19T10:30:00Z'],
}) }),
}); });
/** /**
@ -306,28 +310,34 @@ export const UserListItemSchema = t.Object({
*/ */
export const GetUserListResponsesSchema = { export const GetUserListResponsesSchema = {
200: responseWrapperSchema(createPaginationResponseSchema(UserListItemSchema)), 200: responseWrapperSchema(createPaginationResponseSchema(UserListItemSchema)),
401: responseWrapperSchema(t.Object({ 401: responseWrapperSchema(
error: t.String({ t.Object({
description: '认证失败', error: t.String({
examples: ['未提供有效的认证令牌', '令牌已过期'] description: '认证失败',
}) examples: ['未提供有效的认证令牌', '令牌已过期'],
})), }),
400: responseWrapperSchema(t.Object({ }),
error: t.String({ ),
description: '参数错误', 400: responseWrapperSchema(
examples: ['分页参数无效', '搜索关键词格式错误'] t.Object({
}) error: t.String({
})), description: '参数错误',
500: responseWrapperSchema(t.Object({ examples: ['分页参数无效', '搜索关键词格式错误'],
error: t.String({ }),
description: '服务器错误', }),
examples: ['内部服务器错误'] ),
}) 500: responseWrapperSchema(
})) t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误'],
}),
}),
),
}; };
/** 获取用户列表成功响应数据类型 */ /** 获取用户列表成功响应数据类型 */
export type GetUserListSuccessType = Static<typeof GetUserListResponsesSchema[200]>; export type GetUserListSuccessType = Static<(typeof GetUserListResponsesSchema)[200]>;
``` ```
## 4. Service业务逻辑层 ## 4. Service业务逻辑层
@ -398,7 +408,7 @@ export class UserService {
status: sysUsers.status, status: sysUsers.status,
lastLoginAt: sysUsers.lastLoginAt, lastLoginAt: sysUsers.lastLoginAt,
createdAt: sysUsers.createdAt, createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt updatedAt: sysUsers.updatedAt,
}) })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId))) .where(eq(sysUsers.id, BigInt(userId)))
@ -406,34 +416,33 @@ export class UserService {
if (!user || user.length === 0) { if (!user || user.length === 0) {
Logger.warn(`用户不存在:${userId}`); Logger.warn(`用户不存在:${userId}`);
throw new BusinessError( throw new BusinessError(`用户不存在:${userId}`, 404);
`用户不存在:${userId}`,
404
);
} }
const userData = user[0]!; const userData = user[0]!;
Logger.info(`获取用户信息成功:${userId} - ${userData.username}`); Logger.info(`获取用户信息成功:${userId} - ${userData.username}`);
return successResponse({ return successResponse(
id: userId, // 使用传入的字符串ID避免精度丢失 {
username: userData.username, id: userId, // 使用传入的字符串ID避免精度丢失
email: userData.email, username: userData.username,
nickname: userData.nickname, email: userData.email,
avatar: userData.avatar, nickname: userData.nickname,
phone: userData.mobile, avatar: userData.avatar,
status: userData.status, phone: userData.mobile,
lastLoginAt: userData.lastLoginAt || null, status: userData.status,
createdAt: userData.createdAt, lastLoginAt: userData.lastLoginAt || null,
updatedAt: userData.updatedAt createdAt: userData.createdAt,
}, '获取用户信息成功'); updatedAt: userData.updatedAt,
},
'获取用户信息成功',
);
} }
} }
// 导出单例实例 // 导出单例实例
export const userService = new UserService(); export const userService = new UserService();
``` ```
## 5. Controllers接口名称接入 ## 5. Controllers接口名称接入
@ -463,21 +472,18 @@ export const authController = new Elysia()
* @route POST /api/auth/register * @route POST /api/auth/register
* @description 用户注册,包含验证码验证、用户名邮箱唯一性检查等 * @description 用户注册,包含验证码验证、用户名邮箱唯一性检查等
*/ */
.post( .post('/register', ({ body }) => authService.register(body), {
'/register', body: RegisterSchema,
({ body }) => authService.register(body), detail: {
{ summary: '用户注册',
body: RegisterSchema, description: '用户注册接口,需要提供用户名、邮箱、密码和验证码',
detail: { tags: [tags.auth],
summary: '用户注册', operationId: 'registerUser',
description: '用户注册接口,需要提供用户名、邮箱、密码和验证码', },
tags: [tags.auth], response: RegisterResponsesSchema,
operationId: 'registerUser', });
},
response: RegisterResponsesSchema,
}
);
``` ```
## 6. 错误处理 ## 6. 错误处理
### 6.1 错误处理规范 ### 6.1 错误处理规范
@ -494,10 +500,11 @@ import { BusinessError } from '@/utils/responseFormate';
// 抛出业务错误 // 抛出业务错误
throw new BusinessError('消息说明...', 409); throw new BusinessError('消息说明...', 409);
``` ```
## 7. 测试用例文档 ## 7. 测试用例文档
1. 分模块 1. 分模块
2. 分接口 2. 分接口
3. 测试名称 3. 测试名称
4. 场景 4. 场景
5. 方法 5. 方法

View File

@ -1,44 +1,47 @@
--- ---
description: description:
globs: globs:
alwaysApply: true alwaysApply: true
--- ---
## Git 提交规范Commit Message Rules ## Git 提交规范Commit Message Rules
1. **提交格式** 1. **提交格式**
```
<type>(<scope>): <subject> ```
``` <type>(<scope>): <subject>
- `type`:提交类型(必填) ```
- `scope`:影响范围(可选)
- `subject`:简要描述(必填) - `type`:提交类型(必填)
- `scope`:影响范围(可选)
- `subject`:简要描述(必填)
2. **type 类型** 2. **type 类型**
- feat新功能 - feat新功能
- fix修复 bug - fix修复 bug
- docs文档变更 - docs文档变更
- style代码格式不影响功能例如空格、分号等 - style代码格式不影响功能例如空格、分号等
- refactor代码重构既不是新增功能也不是修复 bug - refactor代码重构既不是新增功能也不是修复 bug
- perf性能优化 - perf性能优化
- test增加或修改测试 - test增加或修改测试
- chore构建过程或辅助工具的变动 - chore构建过程或辅助工具的变动
- revert回滚某个提交 - revert回滚某个提交
3. **scope 范围** 3. **scope 范围**
- 用于说明 commit 影响的模块或文件(如 user、api、core 等),可省略。 - 用于说明 commit 影响的模块或文件(如 user、api、core 等),可省略。
4. **subject 描述** 4. **subject 描述**
- 简明扼要,建议不超过 50 字符 - 简明扼要,建议不超过 50 字符
- 以动词开头,首字母小写 - 以动词开头,首字母小写
- 结尾不加句号 - 结尾不加句号
5. **正文body** 5. **正文body**
- 可选,详细描述本次提交的内容、动机、对比信息等 - 可选,详细描述本次提交的内容、动机、对比信息等
- 建议每行不超过 72 字符 - 建议每行不超过 72 字符
6. **Footer** 6. **Footer**
- 可选,用于关联 issue 或进行破坏性变更说明 - 可选,用于关联 issue 或进行破坏性变更说明
- 例如:`BREAKING CHANGE: xxx` 或 `Closes #123` - 例如:`BREAKING CHANGE: xxx` 或 `Closes #123`
### 示例 ### 示例
@ -52,4 +55,4 @@ docs(readme): 更新安装说明
refactor(core): 优化数据处理逻辑 refactor(core): 优化数据处理逻辑
chore: 升级依赖包 chore: 升级依赖包
``` ```

View File

@ -1,214 +1,204 @@
# JWT 用户类型使用指南 # JWT 用户类型使用指南
## 概述 ## 概述
我们定义了完整的JWT类型系统提供类型安全的JWT操作。 我们定义了完整的JWT类型系统提供类型安全的JWT操作。
## 类型定义 ## 类型定义
### 1. JwtUserType - JWT中的用户信息 ### 1. JwtUserType - JWT中的用户信息
```typescript
interface JwtUserType { ```typescript
userId: number; interface JwtUserType {
username: string; userId: number;
email: string; username: string;
nickname?: string; email: string;
status: number; nickname?: string;
role?: string; status: number;
} role?: string;
``` }
```
### 2. JwtPayloadType - 完整的JWT载荷
```typescript ### 2. JwtPayloadType - 完整的JWT载荷
interface JwtPayloadType extends JwtUserType {
iat: number; // 发行时间 ```typescript
exp: number; // 过期时间 interface JwtPayloadType extends JwtUserType {
sub?: string; // 主题 iat: number; // 发行时间
iss?: string; // 发行者 exp: number; // 过期时间
aud?: string; // 受众 sub?: string; // 主题
jti?: string; // JWT ID iss?: string; // 发行者
nbf?: number; // 生效时间 aud?: string; // 受众
} jti?: string; // JWT ID
``` nbf?: number; // 生效时间
}
## 使用示例 ```
### 1. 在认证Controller中生成JWT Token ## 使用示例
```typescript ### 1. 在认证Controller中生成JWT Token
// auth.controller.ts
import { createJwtPayload } from '@/utils/jwt.helper'; ```typescript
import type { UserInfoType } from '@/modules/example/example.schema'; // auth.controller.ts
import { createJwtPayload } from '@/utils/jwt.helper';
export const authController = new Elysia() import type { UserInfoType } from '@/modules/example/example.schema';
.use(jwtPlugin)
.post('/login', async ({ body, jwt }) => { export const authController = new Elysia().use(jwtPlugin).post('/login', async ({ body, jwt }) => {
// 用户登录验证逻辑... // 用户登录验证逻辑...
const userInfo: UserInfoType = await getUserFromDatabase(body.username); const userInfo: UserInfoType = await getUserFromDatabase(body.username);
// 创建JWT载荷 // 创建JWT载荷
const payload = createJwtPayload(userInfo, { const payload = createJwtPayload(userInfo, {
role: 'user', // 可选的角色信息 role: 'user', // 可选的角色信息
issuer: 'my-api', issuer: 'my-api',
audience: 'web-app', audience: 'web-app',
}); });
// 生成Token // 生成Token
const token = await jwt.sign(payload); const token = await jwt.sign(payload);
return { return {
code: 0, code: 0,
message: '登录成功', message: '登录成功',
data: { data: {
token, token,
user: payload, // 返回用户信息(不含敏感数据) user: payload, // 返回用户信息(不含敏感数据)
}, },
}; };
}); });
``` ```
### 2. 在需要认证的Controller中使用用户信息 ### 2. 在需要认证的Controller中使用用户信息
```typescript ```typescript
// user.controller.ts // user.controller.ts
import { formatUserForLog, isValidJwtUser } from '@/utils/jwt.helper'; import { formatUserForLog, isValidJwtUser } from '@/utils/jwt.helper';
import type { JwtUserType } from '@/type/jwt.type'; import type { JwtUserType } from '@/type/jwt.type';
export const userController = new Elysia() export const userController = new Elysia().use(jwtAuthPlugin).get('/profile', async ({ user, payload }) => {
.use(jwtAuthPlugin) // user 自动推断为 JwtUserType 类型
.get('/profile', async ({ user, payload }) => { // payload 自动推断为 JwtPayloadType 类型
// user 自动推断为 JwtUserType 类型
// payload 自动推断为 JwtPayloadType 类型 // 验证用户有效性
if (!isValidJwtUser(payload)) {
// 验证用户有效性 Logger.warn(`无效用户尝试访问: ${formatUserForLog(user)}`);
if (!isValidJwtUser(payload)) { return { code: 401, message: '用户状态异常', data: null };
Logger.warn(`无效用户尝试访问: ${formatUserForLog(user)}`); }
return { code: 401, message: '用户状态异常', data: null };
} // 使用类型安全的用户信息
Logger.info(`用户查看个人资料: ${formatUserForLog(user)}`);
// 使用类型安全的用户信息
Logger.info(`用户查看个人资料: ${formatUserForLog(user)}`); // 获取完整的用户信息(从数据库)
const fullUserInfo = await getUserById(user.userId);
// 获取完整的用户信息(从数据库)
const fullUserInfo = await getUserById(user.userId); return {
code: 0,
return { message: '获取成功',
code: 0, data: fullUserInfo,
message: '获取成功', };
data: fullUserInfo, });
}; ```
});
``` ### 3. 在Service中使用JWT用户类型
### 3. 在Service中使用JWT用户类型 ```typescript
// user.service.ts
```typescript import type { JwtUserType } from '@/type/jwt.type';
// user.service.ts
import type { JwtUserType } from '@/type/jwt.type'; export class UserService {
// 使用JWT用户类型作为参数
export class UserService { async updateUserProfile(currentUser: JwtUserType, updateData: any) {
// 使用JWT用户类型作为参数 // 检查权限
async updateUserProfile(currentUser: JwtUserType, updateData: any) { if (currentUser.status !== 1) {
// 检查权限 throw new Error('用户状态异常,无法操作');
if (currentUser.status !== 1) { }
throw new Error('用户状态异常,无法操作');
} // 更新用户信息
const updatedUser = await db.update(users).set(updateData).where(eq(users.id, currentUser.userId));
// 更新用户信息
const updatedUser = await db.update(users) Logger.info(`用户资料更新: ${currentUser.username} (ID: ${currentUser.userId})`);
.set(updateData) return updatedUser;
.where(eq(users.id, currentUser.userId)); }
Logger.info(`用户资料更新: ${currentUser.username} (ID: ${currentUser.userId})`); // 根据JWT用户信息获取权限
return updatedUser; async getUserPermissions(jwtUser: JwtUserType): Promise<string[]> {
} const permissions = await db.select().from(userPermissions).where(eq(userPermissions.userId, jwtUser.userId));
// 根据JWT用户信息获取权限 return permissions.map((p) => p.permission);
async getUserPermissions(jwtUser: JwtUserType): Promise<string[]> { }
const permissions = await db.select() }
.from(userPermissions) ```
.where(eq(userPermissions.userId, jwtUser.userId));
### 4. Token状态检查
return permissions.map(p => p.permission);
} ```typescript
} // middleware/token-check.ts
``` import { isTokenExpiringSoon, getTokenRemainingTime, formatRemainingTime } from '@/utils/jwt.helper';
### 4. Token状态检查 export const tokenStatusMiddleware = (app: Elysia) =>
app.derive(({ payload, user }) => {
```typescript if (!payload) return {};
// middleware/token-check.ts
import { // 检查Token是否即将过期
isTokenExpiringSoon, const expiringSoon = isTokenExpiringSoon(payload, 30); // 30分钟阈值
getTokenRemainingTime, const remainingTime = getTokenRemainingTime(payload);
formatRemainingTime
} from '@/utils/jwt.helper'; if (expiringSoon) {
Logger.warn(`用户Token即将过期: ${user.username}, 剩余时间: ${formatRemainingTime(remainingTime)}`);
export const tokenStatusMiddleware = (app: Elysia) => }
app.derive(({ payload, user }) => {
if (!payload) return {}; return {
tokenInfo: {
// 检查Token是否即将过期 expiringSoon,
const expiringSoon = isTokenExpiringSoon(payload, 30); // 30分钟阈值 remainingTime,
const remainingTime = getTokenRemainingTime(payload); formattedTime: formatRemainingTime(remainingTime),
},
if (expiringSoon) { };
Logger.warn(`用户Token即将过期: ${user.username}, 剩余时间: ${formatRemainingTime(remainingTime)}`); });
} ```
return { ### 5. 角色权限检查
tokenInfo: {
expiringSoon, ```typescript
remainingTime, // middleware/role-check.ts
formattedTime: formatRemainingTime(remainingTime), import type { JwtUserType } from '@/type/jwt.type';
},
}; export function requireRole(requiredRole: string) {
}); return (app: Elysia) =>
``` app.onBeforeHandle(({ user, set }) => {
const jwtUser = user as JwtUserType;
### 5. 角色权限检查
if (!jwtUser.role || jwtUser.role !== requiredRole) {
```typescript Logger.warn(`权限不足: 用户${jwtUser.username}尝试访问需要${requiredRole}角色的资源`);
// middleware/role-check.ts set.status = 403;
import type { JwtUserType } from '@/type/jwt.type'; return {
code: 403,
export function requireRole(requiredRole: string) { message: '权限不足',
return (app: Elysia) => data: null,
app.onBeforeHandle(({ user, set }) => { };
const jwtUser = user as JwtUserType; }
});
if (!jwtUser.role || jwtUser.role !== requiredRole) { }
Logger.warn(`权限不足: 用户${jwtUser.username}尝试访问需要${requiredRole}角色的资源`);
set.status = 403; // 使用示例
return { export const adminController = new Elysia()
code: 403, .use(jwtAuthPlugin)
message: '权限不足', .use(requireRole('admin'))
data: null, .get('/admin-only', () => {
}; return { message: '只有管理员能看到这个内容' };
} });
}); ```
}
## 🎯 类型安全的好处
// 使用示例
export const adminController = new Elysia() 1. **编译时检查**: TypeScript 会在编译时检查所有JWT相关操作
.use(jwtAuthPlugin) 2. **智能提示**: IDE 提供完整的属性提示和自动补全
.use(requireRole('admin')) 3. **重构安全**: 修改用户类型时,所有相关代码都会得到类型检查
.get('/admin-only', () => { 4. **文档作用**: 类型定义本身就是最好的文档
return { message: '只有管理员能看到这个内容' };
}); ## 📝 最佳实践
```
1. **总是使用类型注解**: 在Service方法中明确使用 `JwtUserType` 类型
## 🎯 类型安全的好处 2. **验证用户状态**: 使用 `isValidJwtUser()` 检查用户有效性
3. **记录操作日志**: 使用 `formatUserForLog()` 格式化用户信息
1. **编译时检查**: TypeScript 会在编译时检查所有JWT相关操作 4. **检查Token状态**: 在关键操作前检查Token是否即将过期
2. **智能提示**: IDE 提供完整的属性提示和自动补全 5. **权限分离**: 使用角色字段实现细粒度权限控制
3. **重构安全**: 修改用户类型时,所有相关代码都会得到类型检查
4. **文档作用**: 类型定义本身就是最好的文档
## 📝 最佳实践
1. **总是使用类型注解**: 在Service方法中明确使用 `JwtUserType` 类型
2. **验证用户状态**: 使用 `isValidJwtUser()` 检查用户有效性
3. **记录操作日志**: 使用 `formatUserForLog()` 格式化用户信息
4. **检查Token状态**: 在关键操作前检查Token是否即将过期
5. **权限分离**: 使用角色字段实现细粒度权限控制

View File

@ -9,14 +9,14 @@
import { dbConfig } from '@/config'; import { dbConfig } from '@/config';
/** /**
* Drizzle Kit配置对象 * Drizzle Kit配置对象
* 使用前需要安装: bun add drizzle-kit -D * 使用前需要安装: bun add drizzle-kit -D
*/ */
export default { export default {
/** 数据库类型 */ /** 数据库类型 */
dialect: 'mysql', dialect: 'mysql',
/** 数据库连接配置 */ /** 数据库连接配置 */
dbCredentials: { dbCredentials: {
host: dbConfig.host, host: dbConfig.host,
@ -25,21 +25,21 @@ export default {
password: dbConfig.password, password: dbConfig.password,
database: dbConfig.database, database: dbConfig.database,
}, },
/** Schema文件路径 */ /** Schema文件路径 */
schema: './src/entities/schema.js', schema: './src/entities/schema.js',
/** 迁移文件输出目录 */ /** 迁移文件输出目录 */
out: './drizzle', out: './drizzle',
/** 详细日志 */ /** 详细日志 */
verbose: true, verbose: true,
/** 严格模式 */ /** 严格模式 */
strict: true, strict: true,
introspect: { introspect: {
// 启用驼峰命名 // 启用驼峰命名
casing: 'camel', casing: 'camel',
}, },
} as const; } as const;

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
{ {
"version": "7", "version": "7",
"dialect": "mysql", "dialect": "mysql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "5", "version": "5",
"when": 1751620262784, "when": 1751620262784,
"tag": "0000_nostalgic_eternity", "tag": "0000_nostalgic_eternity",
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@ -1,3 +1,2 @@
import { relations } from "drizzle-orm/relations"; import { relations } from 'drizzle-orm/relations';
import { } from "./schema"; import {} from './schema';

View File

@ -1,316 +1,359 @@
import { mysqlTable, mysqlSchema, AnyMySqlColumn, index, primaryKey, unique, bigint, varchar, text, int, json, datetime, tinyint, date } from "drizzle-orm/mysql-core" import {
import { sql } from "drizzle-orm" mysqlTable,
mysqlSchema,
AnyMySqlColumn,
index,
primaryKey,
unique,
bigint,
varchar,
int,
json,
timestamp,
text,
datetime,
tinyint,
date,
} from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
export const sysDictItems = mysqlTable("sys_dict_items", { export const sysDict = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_dict',
typeId: bigint("type_id", { mode: "number" }).notNull(), {
itemKey: varchar("item_key", { length: 50 }).notNull(), id: bigint({ mode: 'number' }).autoincrement().notNull(),
itemValue: varchar("item_value", { length: 200 }).notNull(), code: varchar({ length: 50 }).notNull(),
label: varchar({ length: 100 }).notNull(), name: varchar({ length: 100 }).notNull(),
labelEn: varchar("label_en", { length: 200 }), value: varchar({ length: 200 }),
description: text(), description: varchar({ length: 500 }),
pid: bigint({ mode: "number" }), icon: varchar({ length: 100 }),
path: varchar({ length: 500 }), pid: bigint({ mode: 'number' }),
level: int().default(1).notNull(), level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(), sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), status: varchar({ length: 20 }).default('active').notNull(),
cssClass: varchar("css_class", { length: 50 }), isSystem: tinyint('is_system').default(0).notNull(),
color: varchar({ length: 50 }), color: varchar({ length: 20 }),
extra: json(), extra: json(),
createdBy: bigint("created_by", { mode: "number" }), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().onUpdateNow().notNull(),
updatedBy: bigint("updated_by", { mode: "number" }), },
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), (table) => [
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_level').on(table.level),
}, index('idx_pid').on(table.pid),
(table) => [ index('idx_sort').on(table.sortOrder),
index("idx_deleted_at").on(table.deletedAt), index('idx_status').on(table.status),
index("idx_key").on(table.itemKey), primaryKey({ columns: [table.id], name: 'sys_dict_id' }),
index("idx_pid").on(table.pid), unique('uk_code').on(table.code),
index("idx_sort").on(table.typeId, table.sortOrder), ],
index("idx_status").on(table.status), );
index("idx_type_id").on(table.typeId),
primaryKey({ columns: [table.id], name: "sys_dict_items_id"}),
unique("uk_type_key").on(table.typeId, table.itemKey, table.deletedAt),
]);
export const sysDictTypes = mysqlTable("sys_dict_types", { export const sysOperationLogs = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_operation_logs',
code: varchar({ length: 50 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
description: text(), userId: bigint('user_id', { mode: 'number' }),
pid: bigint({ mode: "number" }), username: varchar({ length: 100 }),
path: varchar({ length: 500 }), module: varchar({ length: 50 }).notNull(),
level: int().default(1).notNull(), action: varchar({ length: 50 }).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), target: varchar({ length: 200 }),
isSystem: tinyint("is_system").default(0).notNull(), targetId: bigint('target_id', { mode: 'number' }),
sortOrder: int("sort_order").default(0).notNull(), requestData: text('request_data'),
createdBy: bigint("created_by", { mode: "number" }), responseData: text('response_data'),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), status: varchar({ length: 20 }).notNull(),
updatedBy: bigint("updated_by", { mode: "number" }), ip: varchar({ length: 45 }),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), userAgent: varchar('user_agent', { length: 200 }),
deletedAt: datetime("deleted_at", { mode: 'string'}), duration: bigint({ mode: 'number' }),
}, errorMsg: text('error_msg'),
(table) => [ createdAt: datetime('created_at', { mode: 'string' })
index("idx_deleted_at").on(table.deletedAt), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_is_system").on(table.isSystem), .notNull(),
index("idx_name").on(table.name), },
index("idx_path").on(table.path), (table) => [
index("idx_pid").on(table.pid), index('idx_created_at').on(table.createdAt),
index("idx_sort").on(table.pid, table.sortOrder), index('idx_ip').on(table.ip),
index("idx_status").on(table.status), index('idx_module_action').on(table.module, table.action),
primaryKey({ columns: [table.id], name: "sys_dict_types_id"}), index('idx_status').on(table.status),
unique("uk_code").on(table.code, table.deletedAt), index('idx_target').on(table.targetId),
]); index('idx_user_id').on(table.userId),
primaryKey({ columns: [table.id], name: 'sys_operation_logs_id' }),
],
);
export const sysOperationLogs = mysqlTable("sys_operation_logs", { export const sysOrganizations = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_organizations',
userId: bigint("user_id", { mode: "number" }), {
username: varchar({ length: 100 }), id: bigint({ mode: 'number' }).notNull(),
module: varchar({ length: 50 }).notNull(), code: varchar({ length: 100 }).notNull(),
action: varchar({ length: 50 }).notNull(), name: varchar({ length: 200 }).notNull(),
target: varchar({ length: 200 }), fullName: varchar('full_name', { length: 200 }),
targetId: bigint("target_id", { mode: "number" }), description: text(),
requestData: text("request_data"), pid: bigint({ mode: 'number' }),
responseData: text("response_data"), path: varchar({ length: 500 }),
status: varchar({ length: 20 }).notNull(), level: int().default(1).notNull(),
ip: varchar({ length: 45 }), type: varchar({ length: 20 }),
userAgent: varchar("user_agent", { length: 200 }), status: varchar({ length: 20 }).default('active').notNull(),
duration: bigint({ mode: "number" }), sortOrder: int('sort_order').default(0).notNull(),
errorMsg: text("error_msg"), leaderId: bigint('leader_id', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), address: varchar({ length: 200 }),
}, phone: varchar({ length: 50 }),
(table) => [ extra: json(),
index("idx_created_at").on(table.createdAt), createdBy: bigint('created_by', { mode: 'number' }),
index("idx_ip").on(table.ip), createdAt: datetime('created_at', { mode: 'string' })
index("idx_module_action").on(table.module, table.action), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_status").on(table.status), .notNull(),
index("idx_target").on(table.targetId), updatedBy: bigint('updated_by', { mode: 'number' }),
index("idx_user_id").on(table.userId), updatedAt: datetime('updated_at', { mode: 'string' })
primaryKey({ columns: [table.id], name: "sys_operation_logs_id"}), .default(sql`(CURRENT_TIMESTAMP)`)
]); .notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
version: int().default(1).notNull(),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_leader_id').on(table.leaderId),
index('idx_name').on(table.name),
index('idx_path').on(table.path),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.pid, table.sortOrder),
index('idx_status').on(table.status),
index('idx_type').on(table.type),
primaryKey({ columns: [table.id], name: 'sys_organizations_id' }),
unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysOrganizations = mysqlTable("sys_organizations", { export const sysPermissions = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_permissions',
code: varchar({ length: 100 }).notNull(), {
name: varchar({ length: 200 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
fullName: varchar("full_name", { length: 200 }), code: varchar({ length: 100 }).notNull(),
description: text(), name: varchar({ length: 100 }).notNull(),
pid: bigint({ mode: "number" }), type: varchar({ length: 20 }).notNull(),
path: varchar({ length: 500 }), resource: varchar({ length: 50 }),
level: int().default(1).notNull(), action: varchar({ length: 50 }),
type: varchar({ length: 20 }), description: text(),
status: varchar({ length: 20 }).default('active').notNull(), pid: bigint({ mode: 'number' }),
sortOrder: int("sort_order").default(0).notNull(), path: varchar({ length: 500 }),
leaderId: bigint("leader_id", { mode: "number" }), level: int().default(1).notNull(),
address: varchar({ length: 200 }), sortOrder: int('sort_order').default(0).notNull(),
phone: varchar({ length: 50 }), status: varchar({ length: 20 }).default('active').notNull(),
extra: json(), meta: json(),
createdBy: bigint("created_by", { mode: "number" }), createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdAt: datetime('created_at', { mode: 'string' })
updatedBy: bigint("updated_by", { mode: "number" }), .default(sql`(CURRENT_TIMESTAMP)`)
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), .notNull(),
deletedAt: datetime("deleted_at", { mode: 'string'}), updatedBy: bigint('updated_by', { mode: 'number' }),
version: int().default(1).notNull(), updatedAt: datetime('updated_at', { mode: 'string' })
}, .default(sql`(CURRENT_TIMESTAMP)`)
(table) => [ .notNull(),
index("idx_deleted_at").on(table.deletedAt), deletedAt: datetime('deleted_at', { mode: 'string' }),
index("idx_leader_id").on(table.leaderId), },
index("idx_name").on(table.name), (table) => [
index("idx_path").on(table.path), index('idx_deleted_at').on(table.deletedAt),
index("idx_pid").on(table.pid), index('idx_pid').on(table.pid),
index("idx_sort").on(table.pid, table.sortOrder), index('idx_resource_action').on(table.resource, table.action),
index("idx_status").on(table.status), index('idx_sort').on(table.pid, table.sortOrder),
index("idx_type").on(table.type), index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: "sys_organizations_id"}), index('idx_type').on(table.type),
unique("uk_code").on(table.code, table.deletedAt), primaryKey({ columns: [table.id], name: 'sys_permissions_id' }),
]); unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysPermissions = mysqlTable("sys_permissions", { export const sysRolePermissions = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_role_permissions',
code: varchar({ length: 100 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
type: varchar({ length: 20 }).notNull(), roleId: bigint('role_id', { mode: 'number' }).notNull(),
resource: varchar({ length: 50 }), permissionId: bigint('permission_id', { mode: 'number' }).notNull(),
action: varchar({ length: 50 }), isHalf: tinyint('is_half').default(0).notNull(),
description: text(), createdBy: bigint('created_by', { mode: 'number' }),
pid: bigint({ mode: "number" }), createdAt: datetime('created_at', { mode: 'string' })
path: varchar({ length: 500 }), .default(sql`(CURRENT_TIMESTAMP)`)
level: int().default(1).notNull(), .notNull(),
sortOrder: int("sort_order").default(0).notNull(), },
status: varchar({ length: 20 }).default('active').notNull(), (table) => [
meta: json(), index('idx_is_half').on(table.isHalf),
createdBy: bigint("created_by", { mode: "number" }), index('idx_permission_id').on(table.permissionId),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_role_id').on(table.roleId),
updatedBy: bigint("updated_by", { mode: "number" }), primaryKey({ columns: [table.id], name: 'sys_role_permissions_id' }),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), unique('uk_role_permission').on(table.roleId, table.permissionId),
deletedAt: datetime("deleted_at", { mode: 'string'}), ],
}, );
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_pid").on(table.pid),
index("idx_resource_action").on(table.resource, table.action),
index("idx_sort").on(table.pid, table.sortOrder),
index("idx_status").on(table.status),
index("idx_type").on(table.type),
primaryKey({ columns: [table.id], name: "sys_permissions_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysRolePermissions = mysqlTable("sys_role_permissions", { export const sysRoles = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_roles',
roleId: bigint("role_id", { mode: "number" }).notNull(), {
permissionId: bigint("permission_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
isHalf: tinyint("is_half").default(0).notNull(), code: varchar({ length: 50 }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), name: varchar({ length: 100 }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), description: text(),
}, pid: bigint({ mode: 'number' }),
(table) => [ path: varchar({ length: 500 }),
index("idx_is_half").on(table.isHalf), level: int().default(1).notNull(),
index("idx_permission_id").on(table.permissionId), sortOrder: int('sort_order').default(0).notNull(),
index("idx_role_id").on(table.roleId), status: varchar({ length: 20 }).default('active').notNull(),
primaryKey({ columns: [table.id], name: "sys_role_permissions_id"}), isSystem: tinyint('is_system').default(0).notNull(),
unique("uk_role_permission").on(table.roleId, table.permissionId), permissionsSnapshot: json('permissions_snapshot'),
]); extra: json(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
updatedBy: bigint('updated_by', { mode: 'number' }),
updatedAt: datetime('updated_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
version: int().default(1).notNull(),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_is_system').on(table.isSystem),
index('idx_name').on(table.name),
index('idx_path').on(table.path),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.pid, table.sortOrder),
index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: 'sys_roles_id' }),
unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysRoles = mysqlTable("sys_roles", { export const sysTags = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_tags',
code: varchar({ length: 50 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
description: text(), name: varchar({ length: 50 }).notNull(),
pid: bigint({ mode: "number" }), type: varchar({ length: 50 }).default('user'),
path: varchar({ length: 500 }), color: varchar({ length: 50 }),
level: int().default(1).notNull(), description: text(),
sortOrder: int("sort_order").default(0).notNull(), usageCount: int('usage_count').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), createdBy: bigint('created_by', { mode: 'number' }),
isSystem: tinyint("is_system").default(0).notNull(), createdAt: datetime('created_at', { mode: 'string' })
permissionsSnapshot: json("permissions_snapshot"), .default(sql`(CURRENT_TIMESTAMP)`)
extra: json(), .notNull(),
createdBy: bigint("created_by", { mode: "number" }), deletedAt: datetime('deleted_at', { mode: 'string' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), },
updatedBy: bigint("updated_by", { mode: "number" }), (table) => [
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_deleted_at').on(table.deletedAt),
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_name').on(table.name),
version: int().default(1).notNull(), index('idx_type').on(table.type),
}, index('idx_usage_count').on(table.usageCount),
(table) => [ primaryKey({ columns: [table.id], name: 'sys_tags_id' }),
index("idx_deleted_at").on(table.deletedAt), unique('uk_name_type').on(table.name, table.type, table.deletedAt),
index("idx_is_system").on(table.isSystem), ],
index("idx_name").on(table.name), );
index("idx_path").on(table.path),
index("idx_pid").on(table.pid),
index("idx_sort").on(table.pid, table.sortOrder),
index("idx_status").on(table.status),
primaryKey({ columns: [table.id], name: "sys_roles_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysTags = mysqlTable("sys_tags", { export const sysUserOrganizations = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_organizations',
name: varchar({ length: 50 }).notNull(), {
type: varchar({ length: 50 }).default('user'), id: bigint({ mode: 'number' }).notNull(),
color: varchar({ length: 50 }), userId: bigint('user_id', { mode: 'number' }).notNull(),
description: text(), organizationId: bigint('organization_id', { mode: 'number' }).notNull(),
usageCount: int("usage_count").default(0).notNull(), isPrimary: tinyint('is_primary').default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }), position: varchar({ length: 100 }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), joinedAt: datetime('joined_at', { mode: 'string' })
deletedAt: datetime("deleted_at", { mode: 'string'}), .default(sql`(CURRENT_TIMESTAMP)`)
}, .notNull(),
(table) => [ createdBy: bigint('created_by', { mode: 'number' }),
index("idx_deleted_at").on(table.deletedAt), createdAt: datetime('created_at', { mode: 'string' })
index("idx_name").on(table.name), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_type").on(table.type), .notNull(),
index("idx_usage_count").on(table.usageCount), },
primaryKey({ columns: [table.id], name: "sys_tags_id"}), (table) => [
unique("uk_name_type").on(table.name, table.type, table.deletedAt), index('idx_is_primary').on(table.isPrimary),
]); index('idx_joined_at').on(table.joinedAt),
index('idx_organization_id').on(table.organizationId),
index('idx_user_id').on(table.userId),
primaryKey({ columns: [table.id], name: 'sys_user_organizations_id' }),
unique('uk_user_org').on(table.userId, table.organizationId),
],
);
export const sysUserOrganizations = mysqlTable("sys_user_organizations", { export const sysUserRoles = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_roles',
userId: bigint("user_id", { mode: "number" }).notNull(), {
organizationId: bigint("organization_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
isPrimary: tinyint("is_primary").default(0).notNull(), userId: bigint('user_id', { mode: 'number' }).notNull(),
position: varchar({ length: 100 }), roleId: bigint('role_id', { mode: 'number' }).notNull(),
joinedAt: datetime("joined_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), expiredAt: datetime('expired_at', { mode: 'string' }),
createdBy: bigint("created_by", { mode: "number" }), createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdAt: datetime('created_at', { mode: 'string' })
}, .default(sql`(CURRENT_TIMESTAMP)`)
(table) => [ .notNull(),
index("idx_is_primary").on(table.isPrimary), },
index("idx_joined_at").on(table.joinedAt), (table) => [
index("idx_organization_id").on(table.organizationId), index('idx_created_at').on(table.createdAt),
index("idx_user_id").on(table.userId), index('idx_expired_at').on(table.expiredAt),
primaryKey({ columns: [table.id], name: "sys_user_organizations_id"}), index('idx_role_id').on(table.roleId),
unique("uk_user_org").on(table.userId, table.organizationId), index('idx_user_id').on(table.userId),
]); primaryKey({ columns: [table.id], name: 'sys_user_roles_id' }),
unique('uk_user_role').on(table.userId, table.roleId),
],
);
export const sysUserRoles = mysqlTable("sys_user_roles", { export const sysUserTags = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_tags',
userId: bigint("user_id", { mode: "number" }).notNull(), {
roleId: bigint("role_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
expiredAt: datetime("expired_at", { mode: 'string'}), userId: bigint('user_id', { mode: 'number' }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), tagId: bigint('tag_id', { mode: 'number' }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdBy: bigint('created_by', { mode: 'number' }),
}, createdAt: datetime('created_at', { mode: 'string' })
(table) => [ .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_created_at").on(table.createdAt), .notNull(),
index("idx_expired_at").on(table.expiredAt), },
index("idx_role_id").on(table.roleId), (table) => [
index("idx_user_id").on(table.userId), index('idx_created_at').on(table.createdAt),
primaryKey({ columns: [table.id], name: "sys_user_roles_id"}), index('idx_tag_id').on(table.tagId),
unique("uk_user_role").on(table.userId, table.roleId), index('idx_user_id').on(table.userId),
]); primaryKey({ columns: [table.id], name: 'sys_user_tags_id' }),
unique('uk_user_tag').on(table.userId, table.tagId),
],
);
export const sysUserTags = mysqlTable("sys_user_tags", { export const sysUsers = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_users',
userId: bigint("user_id", { mode: "number" }).notNull(), {
tagId: bigint("tag_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), username: varchar({ length: 50 }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), email: varchar({ length: 100 }).notNull(),
}, mobile: varchar({ length: 20 }),
(table) => [ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
index("idx_created_at").on(table.createdAt), avatar: varchar({ length: 255 }),
index("idx_tag_id").on(table.tagId), nickname: varchar({ length: 100 }),
index("idx_user_id").on(table.userId), status: varchar({ length: 20 }).default('active').notNull(),
primaryKey({ columns: [table.id], name: "sys_user_tags_id"}), gender: tinyint().default(0),
unique("uk_user_tag").on(table.userId, table.tagId), // you can use { mode: 'date' }, if you want to have Date as type for this column
]); birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
export const sysUsers = mysqlTable("sys_users", { loginCount: int('login_count').default(0).notNull(),
id: bigint({ mode: "number" }).notNull(), lastLoginAt: datetime('last_login_at', { mode: 'string' }),
username: varchar({ length: 50 }).notNull(), lastLoginIp: varchar('last_login_ip', { length: 45 }),
email: varchar({ length: 100 }).notNull(), failedAttempts: int('failed_attempts').default(0).notNull(),
mobile: varchar({ length: 20 }), lockedUntil: datetime('locked_until', { mode: 'string' }),
passwordHash: varchar("password_hash", { length: 255 }).notNull(), isRoot: tinyint('is_root').default(0).notNull(),
avatar: varchar({ length: 255 }), extra: json(),
nickname: varchar({ length: 100 }), createdBy: bigint('created_by', { mode: 'number' }),
status: varchar({ length: 20 }).default('active').notNull(), createdAt: datetime('created_at', { mode: 'string' })
gender: tinyint().default(0), .default(sql`(CURRENT_TIMESTAMP)`)
// you can use { mode: 'date' }, if you want to have Date as type for this column .notNull(),
birthday: date({ mode: 'string' }), updatedBy: bigint('updated_by', { mode: 'number' }),
bio: varchar({ length: 500 }), updatedAt: datetime('updated_at', { mode: 'string' })
loginCount: int("login_count").default(0).notNull(), .default(sql`(CURRENT_TIMESTAMP)`)
lastLoginAt: datetime("last_login_at", { mode: 'string'}), .notNull(),
lastLoginIp: varchar("last_login_ip", { length: 45 }), deletedAt: datetime('deleted_at', { mode: 'string' }),
failedAttempts: int("failed_attempts").default(0).notNull(), version: int().default(1).notNull(),
lockedUntil: datetime("locked_until", { mode: 'string'}), },
isRoot: tinyint("is_root").default(0).notNull(), (table) => [
extra: json(), index('idx_created_at').on(table.createdAt),
createdBy: bigint("created_by", { mode: "number" }), index('idx_deleted_at').on(table.deletedAt),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_is_root').on(table.isRoot),
updatedBy: bigint("updated_by", { mode: "number" }), index('idx_last_login').on(table.lastLoginAt),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_mobile').on(table.mobile),
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_status').on(table.status),
version: int().default(1).notNull(), primaryKey({ columns: [table.id], name: 'sys_users_id' }),
}, unique('uk_email').on(table.email, table.deletedAt),
(table) => [ unique('uk_username').on(table.username, table.deletedAt),
index("idx_created_at").on(table.createdAt), ],
index("idx_deleted_at").on(table.deletedAt), );
index("idx_is_root").on(table.isRoot),
index("idx_last_login").on(table.lastLoginAt),
index("idx_mobile").on(table.mobile),
index("idx_status").on(table.status),
primaryKey({ columns: [table.id], name: "sys_users_id"}),
unique("uk_email").on(table.email, table.deletedAt),
unique("uk_username").on(table.username, table.deletedAt),
]);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,249 +1,278 @@
# M2 - 基础用户系统 - 详细设计 (V2) # M2 - 基础用户系统 - 详细设计 (V2)
--- ---
## 1. 引言 ## 1. 引言
### 1.1. 文档目的 ### 1.1. 文档目的
本文档是 "M2 - 基础用户系统" 阶段的 **V2 修订版**旨在提供一份详尽、准确、可直接用于开发的技术设计方案。V2 版根据反馈进行了全面重构重点增强了数据库设计的合理性、API定义的完整性并补充了关键的 `root` 用户机制和标签功能。
本文档是 "M2 - 基础用户系统" 阶段的 **V2 修订版**旨在提供一份详尽、准确、可直接用于开发的技术设计方案。V2 版根据反馈进行了全面重构重点增强了数据库设计的合理性、API定义的完整性并补充了关键的 `root` 用户机制和标签功能。
### 1.2. 模块范围
本设计覆盖功能点包括: ### 1.2. 模块范围
- 用户认证(注册、登录)及 `root` 超级管理员机制。
- 用户、角色、权限、组织、字典的CRUD管理支持层级结构。 本设计覆盖功能点包括:
- 用户标签系统。
- 基于角色的访问控制RBAC的实现。 - 用户认证(注册、登录)及 `root` 超级管理员机制。
- 用户、角色、权限、组织、字典的CRUD管理支持层级结构。
--- - 用户标签系统。
- 基于角色的访问控制RBAC的实现。
## 2. 数据库设计 (Revised)
---
### 2.1. E-R 图 (Entity-Relationship Diagram)
*已更新,包含标签和重构后的字典表* ## 2. 数据库设计 (Revised)
```mermaid
erDiagram ### 2.1. E-R 图 (Entity-Relationship Diagram)
users {
bigint id PK _已更新包含标签和重构后的字典表_
varchar(50) username UK
varchar(255) email UK ```mermaid
varchar(255) password_hash erDiagram
varchar(20) status users {
datetime created_at bigint id PK
datetime updated_at varchar(50) username UK
} varchar(255) email UK
varchar(255) password_hash
roles { varchar(20) status
bigint id PK datetime created_at
varchar(50) name UK datetime updated_at
text description }
bigint parent_id FK "nullable, self-ref"
datetime created_at roles {
datetime updated_at bigint id PK
} varchar(50) name UK
text description
permissions { bigint parent_id FK "nullable, self-ref"
bigint id PK datetime created_at
varchar(50) action UK datetime updated_at
varchar(50) resource UK }
text description
} permissions {
bigint id PK
organizations { varchar(50) action UK
bigint id PK varchar(50) resource UK
varchar(100) name text description
text description }
bigint parent_id FK "nullable, self-ref"
datetime created_at organizations {
datetime updated_at bigint id PK
} varchar(100) name
text description
dictionary_types { bigint parent_id FK "nullable, self-ref"
bigint id PK datetime created_at
varchar(50) type_name UK datetime updated_at
text description }
}
dictionary_types {
dictionary_items { bigint id PK
bigint id PK varchar(50) type_name UK
bigint type_id FK text description
varchar(50) `key` }
varchar(255) value
int sort_order dictionary_items {
} bigint id PK
bigint type_id FK
tags { varchar(50) `key`
bigint id PK varchar(255) value
varchar(50) name UK int sort_order
} }
users ||--o{ user_roles : "has" tags {
roles ||--o{ user_roles : "assigned to" bigint id PK
roles ||--o{ role_permissions : "has" varchar(50) name UK
permissions ||--o{ role_permissions : "granted to" }
users ||--o{ user_organizations : "belongs to"
organizations ||--o{ user_organizations : "has" users ||--o{ user_roles : "has"
users ||--o{ user_tags : "has" roles ||--o{ user_roles : "assigned to"
tags ||--o{ user_tags : "applied to" roles ||--o{ role_permissions : "has"
dictionary_types ||--o{ dictionary_items : "has" permissions ||--o{ role_permissions : "granted to"
users ||--o{ user_organizations : "belongs to"
roles }o..o| roles : "is child of" organizations ||--o{ user_organizations : "has"
organizations }o..o| organizations : "is child of" users ||--o{ user_tags : "has"
tags ||--o{ user_tags : "applied to"
user_roles { bigint user_id PK,FK; bigint role_id PK,FK } dictionary_types ||--o{ dictionary_items : "has"
role_permissions { bigint role_id PK,FK; bigint permission_id PK,FK }
user_organizations { bigint user_id PK,FK; bigint organization_id PK,FK } roles }o..o| roles : "is child of"
user_tags { bigint user_id PK,FK; bigint tag_id PK,FK } organizations }o..o| organizations : "is child of"
```
user_roles { bigint user_id PK,FK; bigint role_id PK,FK }
### 2.2. 表结构定义 role_permissions { bigint role_id PK,FK; bigint permission_id PK,FK }
user_organizations { bigint user_id PK,FK; bigint organization_id PK,FK }
#### `roles` / `organizations` user_tags { bigint user_id PK,FK; bigint tag_id PK,FK }
- **`parent_id`**: `BIGINT NULL`, `FOREIGN KEY (parent_id) REFERENCES roles(id) ON DELETE SET NULL`。此字段用于实现树形层级结构。值为`NULL`表示为顶级角色/组织。 ```
#### `dictionary_types` - 字典类型表 ### 2.2. 表结构定义
| 字段名 | 类型 | 约束 | 描述 |
|---------------|---------------|---------------------|----------------------| #### `roles` / `organizations`
| `id` | `BIGINT` | `PK`, `AI` | 类型唯一ID |
| `type_name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL`| 字典类型名(如: `user_status` | - **`parent_id`**: `BIGINT NULL`, `FOREIGN KEY (parent_id) REFERENCES roles(id) ON DELETE SET NULL`。此字段用于实现树形层级结构。值为`NULL`表示为顶级角色/组织。
| `description` | `TEXT` | | 类型描述 |
#### `dictionary_types` - 字典类型表
#### `dictionary_items` - 字典条目表
| 字段名 | 类型 | 约束 | 描述 | | 字段名 | 类型 | 约束 | 描述 |
|---------------|----------------|----------------------|----------------------| | ------------- | ------------- | -------------------- | ------------------------------- |
| `id` | `BIGINT` | `PK`, `AI` | 条目唯一ID | | `id` | `BIGINT` | `PK`, `AI` | 类型唯一ID |
| `type_id` | `BIGINT` | `NOT NULL`, `FK` | 关联到`dictionary_types.id` | | `type_name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL` | 字典类型名(如: `user_status` |
| `key` | `VARCHAR(50)` | `NOT NULL` | 键 (程序中使用) | | `description` | `TEXT` | | 类型描述 |
| `value` | `VARCHAR(255)` | `NOT NULL` | 值 (UI中显示) |
| `sort_order` | `INT` | `DEFAULT 0` | 排序字段 | #### `dictionary_items` - 字典条目表
*复合唯一键: `(type_id, key)`*
| 字段名 | 类型 | 约束 | 描述 |
#### `tags` - 标签表 | ------------ | -------------- | ---------------- | --------------------------- |
| 字段名 | 类型 | 约束 | 描述 | | `id` | `BIGINT` | `PK`, `AI` | 条目唯一ID |
|--------|---------------|---------------------|------------| | `type_id` | `BIGINT` | `NOT NULL`, `FK` | 关联到`dictionary_types.id` |
| `id` | `BIGINT` | `PK`, `AI` | 标签唯一ID | | `key` | `VARCHAR(50)` | `NOT NULL` | 键 (程序中使用) |
| `name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL`| 标签名 | | `value` | `VARCHAR(255)` | `NOT NULL` | 值 (UI中显示) |
| `sort_order` | `INT` | `DEFAULT 0` | 排序字段 |
#### `user_tags` - 用户标签关联表
| 字段名 | 类型 | 约束 | 描述 | _复合唯一键: `(type_id, key)`_
|-----------|----------|----------|------------|
| `user_id` | `BIGINT` | `PK`, `FK` | 关联用户ID | #### `tags` - 标签表
| `tag_id` | `BIGINT` | `PK`, `FK` | 关联标签ID |
| 字段名 | 类型 | 约束 | 描述 |
--- | ------ | ------------- | -------------------- | ---------- |
| `id` | `BIGINT` | `PK`, `AI` | 标签唯一ID |
## 3. Root 用户与系统初始化 | `name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL` | 标签名 |
- **`root` 用户**: #### `user_tags` - 用户标签关联表
- `root` 是一个特殊的超级管理员账户,拥有系统中所有权限。
- 此账户 **不可通过 API 创建或删除**。它应在系统首次部署时,通过数据库 **Seeding (种子填充) 脚本** 创建。 | 字段名 | 类型 | 约束 | 描述 |
- `root` 用户的角色是固定的(如 `super_admin`),此角色同样通过 Seeding 创建,并关联所有已定义的权限。 | --------- | -------- | ---------- | ---------- |
- 任何权限校验逻辑都必须对 `root` 用户或 `super_admin` 角色开绿灯。任何API都不能降低 `root` 的权限或删除该用户。 | `user_id` | `BIGINT` | `PK`, `FK` | 关联用户ID |
| `tag_id` | `BIGINT` | `PK`, `FK` | 关联标签ID |
---
---
## 4. API 接口设计 (Detailed)
## 3. Root 用户与系统初始化
**Base URL**: `/api/v1`
**通用错误响应结构**: - **`root` 用户**:
```json - `root` 是一个特殊的超级管理员账户,拥有系统中所有权限。
{ - 此账户 **不可通过 API 创建或删除**。它应在系统首次部署时,通过数据库 **Seeding (种子填充) 脚本** 创建。
"code": 40001, // 详细业务错误码 - `root` 用户的角色是固定的(如 `super_admin`),此角色同样通过 Seeding 创建,并关联所有已定义的权限。
"message": "Validation failed: username must be at least 3 characters.", - 任何权限校验逻辑都必须对 `root` 用户或 `super_admin` 角色开绿灯。任何API都不能降低 `root` 的权限或删除该用户。
"data": null
} ---
```
## 4. API 接口设计 (Detailed)
### 3.1. Auth - 认证接口 (`/auth`)
**Base URL**: `/api/v1`
#### `POST /register` - 用户注册 **通用错误响应结构**:
- **权限**: Public
- **请求体 (`application/json`)**: ```json
| 名称 | 类型 | 必填 | 校验规则 | 描述 | {
|------------|--------|------|----------------------------------------------------|----------------| "code": 40001, // 详细业务错误码
| `username` | string | 是 | min:3, max:50, 字母/数字/下划线组合 | 用户名 | "message": "Validation failed: username must be at least 3 characters.",
| `email` | string | 是 | valid email format | 邮箱地址 | "data": null
| `password` | string | 是 | min:8, max:100, 必须包含大小写字母和数字 | 密码 | }
- **成功响应 (201 Created)**: ```
```json
{ "code": 0, "message": "User registered successfully.", "data": { "id": 1, "username": "newuser", "email": "..." } } ### 3.1. Auth - 认证接口 (`/auth`)
```
- **异常响应**: #### `POST /register` - 用户注册
| HTTP 码 | 业务码 | 原因 |
|---------|----------|----------------------------| - **权限**: Public
| 400 | 40001 | 请求参数不符合校验规则 | - **请求体 (`application/json`)**:
| 409 | 40901 | 用户名或邮箱已被占用 | | 名称 | 类型 | 必填 | 校验规则 | 描述 |
|------------|--------|------|----------------------------------------------------|----------------|
#### `POST /login` - 用户登录 | `username` | string | 是 | min:3, max:50, 字母/数字/下划线组合 | 用户名 |
- **权限**: Public | `email` | string | 是 | valid email format | 邮箱地址 |
- **请求体 (`application/json`)**: | `password` | string | 是 | min:8, max:100, 必须包含大小写字母和数字 | 密码 |
| 名称 | 类型 | 必填 | 校验规则 | 描述 | - **成功响应 (201 Created)**:
|------------|--------|------|------------------|------------------| ```json
| `username` | string | 是 | - | 用户名或邮箱 | {
| `password` | string | 是 | - | 密码 | "code": 0,
- **成功响应 (200 OK)**: "message": "User registered successfully.",
```json "data": { "id": 1, "username": "newuser", "email": "..." }
{ "code": 0, "message": "Login successful.", "data": { "token": "ey..." } } }
``` ```
- **异常响应**: - **异常响应**:
| HTTP 码 | 业务码 | 原因 | | HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------| |---------|----------|----------------------------|
| 401 | 40101 | 用户名或密码错误 | | 400 | 40001 | 请求参数不符合校验规则 |
| 401 | 40102 | 账户被禁用或未激活 | | 409 | 40901 | 用户名或邮箱已被占用 |
--- #### `POST /login` - 用户登录
### 3.2. Users - 用户接口 (`/users`)
*除特殊说明外,均需认证* - **权限**: Public
- **请求体 (`application/json`)**:
#### `GET /me` - 获取当前用户信息 | 名称 | 类型 | 必填 | 校验规则 | 描述 |
- **权限**: Authenticated |------------|--------|------|------------------|------------------|
- **成功响应 (200 OK)**: `data` 包含当前用户详细信息、关联的角色和权限列表。 | `username` | string | 是 | - | 用户名或邮箱 |
| `password` | string | 是 | - | 密码 |
#### `GET /` - 获取用户列表 - **成功响应 (200 OK)**:
- **权限**: `read:users` ```json
- **查询参数**: { "code": 0, "message": "Login successful.", "data": { "token": "ey..." } }
| 名称 | 类型 | 必填 | 描述 | ```
|-----------|--------|------|------------------------------------| - **异常响应**:
| `page` | number | 否 | 页码, default 1 | | HTTP 码 | 业务码 | 原因 |
| `pageSize`| number | 否 | 每页数量, default 10 | |---------|----------|--------------------------|
| `keyword` | string | 否 | 按用户名或邮箱模糊搜索 | | 401 | 40101 | 用户名或密码错误 |
- **成功响应 (200 OK)**: `data` 包含 `items` (用户列表) 和 `pagination` (分页信息)。 | 401 | 40102 | 账户被禁用或未激活 |
#### `PUT /{userId}/status` - 更新用户状态 ---
- **权限**: `update:user_status`
- **请求体**: `{ "status": "active" }` (status 必须是字典中 `user_status` 类型的值) ### 3.2. Users - 用户接口 (`/users`)
- **成功响应 (200 OK)**: 返回更新后的用户信息。
- **异常响应**: _除特殊说明外均需认证_
| HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------| #### `GET /me` - 获取当前用户信息
| 403 | 40301 | 试图修改 `root` 用户状态 |
| 404 | 40401 | 指定用户不存在 | - **权限**: Authenticated
- **成功响应 (200 OK)**: `data` 包含当前用户详细信息、关联的角色和权限列表。
#### `POST /{userId}/tags` - 为用户打标签
- **权限**: `update:user_tags` #### `GET /` - 获取用户列表
- **请求体**: `{ "tagIds": [1, 2, 3] }`
- **成功响应 (204 No Content)**. - **权限**: `read:users`
- **查询参数**:
--- | 名称 | 类型 | 必填 | 描述 |
### 3.3. Roles - 角色接口 (`/roles`) |-----------|--------|------|------------------------------------|
*所有接口均需 `manage:roles` 权限* | `page` | number | 否 | 页码, default 1 |
| `pageSize`| number | 否 | 每页数量, default 10 |
#### `GET /` - 获取角色列表 | `keyword` | string | 否 | 按用户名或邮箱模糊搜索 |
- **描述**: 以树形结构返回所有角色。 - **成功响应 (200 OK)**: `data` 包含 `items` (用户列表) 和 `pagination` (分页信息)。
- **成功响应 (200 OK)**: `data` 是一个包含 `children` 属性的树形数组。
#### `PUT /{userId}/status` - 更新用户状态
#### `POST /` - 创建新角色
- **请求体**: `{ "name": "editor", "description": "...", "parentId": 1, "permissionIds": [101, 102] }` - **权限**: `update:user_status`
- **成功响应 (201 Created)**: `data` 包含新创建的角色信息。 - **请求体**: `{ "status": "active" }` (status 必须是字典中 `user_status` 类型的值)
- **异常响应 (409 Conflict)**: 角色名已存在。 - **成功响应 (200 OK)**: 返回更新后的用户信息。
- **异常响应**:
--- | HTTP 码 | 业务码 | 原因 |
*其他模块Organizations, Dictionaries, Tags的API将遵循类似的详细设计模式提供完整的增删改查接口明确定义权限、请求体、校验规则和所有可能的成功/异常响应。* |---------|----------|--------------------------|
| 403 | 40301 | 试图修改 `root` 用户状态 |
# 补充说明 | 404 | 40401 | 指定用户不存在 |
1. 你忽略了我在main.md中提到的root用户所以缺少一类集中管理的接口和机制请认真阅读 #### `POST /{userId}/tags` - 为用户打标签
2. 接口设计太简陋了,太粗略了,需要把异常情况考虑进去,
3. 数据库设计完全无法满足需求没有pid如何实现层级结构 - **权限**: `update:user_tags`
4. 字典确实,无法完成拓展任务 - **请求体**: `{ "tagIds": [1, 2, 3] }`
5. 标签功能呢 - **成功响应 (204 No Content)**.
6. 接口参数范围,校验
---
### 3.3. Roles - 角色接口 (`/roles`)
_所有接口均需 `manage:roles` 权限_
#### `GET /` - 获取角色列表
- **描述**: 以树形结构返回所有角色。
- **成功响应 (200 OK)**: `data` 是一个包含 `children` 属性的树形数组。
#### `POST /` - 创建新角色
- **请求体**: `{ "name": "editor", "description": "...", "parentId": 1, "permissionIds": [101, 102] }`
- **成功响应 (201 Created)**: `data` 包含新创建的角色信息。
- **异常响应 (409 Conflict)**: 角色名已存在。
---
_其他模块Organizations, Dictionaries, Tags的API将遵循类似的详细设计模式提供完整的增删改查接口明确定义权限、请求体、校验规则和所有可能的成功/异常响应。_
# 补充说明
1. 你忽略了我在main.md中提到的root用户所以缺少一类集中管理的接口和机制请认真阅读
2. 接口设计太简陋了,太粗略了,需要把异常情况考虑进去,
3. 数据库设计完全无法满足需求没有pid如何实现层级结构
4. 字典确实,无法完成拓展任务
5. 标签功能呢
6. 接口参数范围,校验

View File

@ -1,65 +1,65 @@
# 个人网站策划 # 个人网站策划
## 前言 ## 前言
我想做一个个人博客网站需要有一下功能 我想做一个个人博客网站需要有一下功能
1. 基本用户系统 1. 基本用户系统
- 字典,维护一些常用字典:地区省份、菜单类别、组织架构类别等,为以后其他功能开发提供可拓展的属性 - 字典,维护一些常用字典:地区省份、菜单类别、组织架构类别等,为以后其他功能开发提供可拓展的属性
- 角色,角色是树形结构,可以有标签、排序等功能 - 角色,角色是树形结构,可以有标签、排序等功能
- 组织架构/用户组,是树形结构,用户可以在多个组织 - 组织架构/用户组,是树形结构,用户可以在多个组织
- 用户标签,字典中维护的一些用户属性 - 用户标签,字典中维护的一些用户属性
- 权限,包括页面权限,接口权限和数据权限等,下级角色只能从上级角色已有的权限中分配 - 权限,包括页面权限,接口权限和数据权限等,下级角色只能从上级角色已有的权限中分配
- 用户用户基础信息用户拓展信息用户创建注册通过邮箱注册可以限制错误登录次数也可以重置可以禁用用户注销用户找回密码修改个人信息最高管理员为root账户他能做所有事情 - 用户用户基础信息用户拓展信息用户创建注册通过邮箱注册可以限制错误登录次数也可以重置可以禁用用户注销用户找回密码修改个人信息最高管理员为root账户他能做所有事情
2. 笔记/博客系统 2. 笔记/博客系统
- 笔记结构是树形的,可以增加标签 - 笔记结构是树形的,可以增加标签
- 笔记可以发布为博客,即设置为公开内容,博客是单独的内容,修改笔记不影响博客能容,但是有绑定关系,可以更新和重新发布 - 笔记可以发布为博客,即设置为公开内容,博客是单独的内容,修改笔记不影响博客能容,但是有绑定关系,可以更新和重新发布
- 博客可以点赞、评论、收藏,也是层级结构,可以增加标签分类 - 博客可以点赞、评论、收藏,也是层级结构,可以增加标签分类
- 内容有markdown、富文本、思维导图、画板结构通过关系型数据库存储内容可以通过其他数据库存储如mongodb、es等增强搜索性能可以嵌入文件等 - 内容有markdown、富文本、思维导图、画板结构通过关系型数据库存储内容可以通过其他数据库存储如mongodb、es等增强搜索性能可以嵌入文件等
- 对代码和编程友好 - 对代码和编程友好
- 可以接入aimcp等将笔记作为知识库增强实用性 - 可以接入aimcp等将笔记作为知识库增强实用性
3. 个人空间 3. 个人空间
- 生活日志,分享照片、视频等 - 生活日志,分享照片、视频等
- 可以评论、收藏、分享、点赞 - 可以评论、收藏、分享、点赞
4. 阅读 4. 阅读
- 可以上传自己下载的书,支持多种格式 - 可以上传自己下载的书,支持多种格式
- ai解读 - ai解读
- 评论、批注、分享 - 评论、批注、分享
5. 工具 5. 工具
- 密码记录器(同步开发浏览器插件) - 密码记录器(同步开发浏览器插件)
- 书签记录同步器(同步开发浏览器插件) - 书签记录同步器(同步开发浏览器插件)
- bilibili收藏同步 - bilibili收藏同步
- qq音乐、网易云音乐同步 - qq音乐、网易云音乐同步
- 文件管理nas、支持在线音视频播放、媒体文件预览 - 文件管理nas、支持在线音视频播放、媒体文件预览
- 文件对传WEBRTC、P2P、阿里云OSS - 文件对传WEBRTC、P2P、阿里云OSS
- 热点新闻捕获 - 热点新闻捕获
- 网页离线存储做成pdf - 网页离线存储做成pdf

View File

@ -1,455 +1,512 @@
# 个人网站综合平台产品需求文档 (PRD) # 个人网站综合平台产品需求文档 (PRD)
## 引言/概述 ## 引言/概述
### 项目背景 ### 项目背景
构建一个集成化的个人网站平台,涵盖内容创作、知识管理、生活记录、实用工具等多个维度,旨在打造一个功能完整、用户体验优秀的个人数字空间。
构建一个集成化的个人网站平台,涵盖内容创作、知识管理、生活记录、实用工具等多个维度,旨在打造一个功能完整、用户体验优秀的个人数字空间。
### 核心价值
- **知识管理中心**:统一管理个人笔记、博客、阅读内容 ### 核心价值
- **生活记录平台**:记录和分享个人生活点滴
- **实用工具集**:集成常用的个人效率工具 - **知识管理中心**:统一管理个人笔记、博客、阅读内容
- **智能化体验**通过AI增强内容创作和知识检索 - **生活记录平台**:记录和分享个人生活点滴
- **实用工具集**:集成常用的个人效率工具
## 目标 - **智能化体验**通过AI增强内容创作和知识检索
### 主要目标 ## 目标
1. **建立完整的个人数字资产管理体系**
2. **提供便捷的内容创作和发布平台** ### 主要目标
3. **构建实用的个人效率工具集**
4. **实现智能化的知识管理和检索** 1. **建立完整的个人数字资产管理体系**
2. **提供便捷的内容创作和发布平台**
### 可衡量目标 3. **构建实用的个人效率工具集**
- 支持多种内容格式Markdown、富文本、思维导图、画板 4. **实现智能化的知识管理和检索**
- 实现秒级内容搜索响应
- 支持10+种实用工具集成 ### 可衡量目标
- 提供完整的权限管理体系
- 支持多种内容格式Markdown、富文本、思维导图、画板
## 用户故事 (User Stories) - 实现秒级内容搜索响应
- 支持10+种实用工具集成
- **作为一名创作者**, 我希望能够在一个地方写笔记、画思维导图,并且能将它们无缝地整理成树状结构,方便我构建自己的知识体系。 - 提供完整的权限管理体系
- **作为一名博主**, 我希望可以一键将我精心撰写的笔记发布成一篇公开的博客,并且发布后对笔记的修改不会影响已发布的博客,除非我选择更新它。
- **作为一名普通用户**, 我希望能用我的邮箱快速注册一个账号,并且可以管理我的个人信息、重置密码,确保我的账户安全。 ## 用户故事 (User Stories)
- **作为一名开发者**, 我希望有一个工具能帮我安全地记录各种网站的密码,并通过浏览器插件自动填充,还能同步我的书签,提升我的工作效率。
- **作为一名生活记录者**, 我希望能方便地上传照片和视频,以时间线的形式记录我的生活点滴,并能设置只对特定的人开放。 - **作为一名创作者**, 我希望能够在一个地方写笔记、画思维导图,并且能将它们无缝地整理成树状结构,方便我构建自己的知识体系。
- **作为一名博主**, 我希望可以一键将我精心撰写的笔记发布成一篇公开的博客,并且发布后对笔记的修改不会影响已发布的博客,除非我选择更新它。
## 功能模块详述 - **作为一名普通用户**, 我希望能用我的邮箱快速注册一个账号,并且可以管理我的个人信息、重置密码,确保我的账户安全。
- **作为一名开发者**, 我希望有一个工具能帮我安全地记录各种网站的密码,并通过浏览器插件自动填充,还能同步我的书签,提升我的工作效率。
### 1. 基础用户系统模块 - **作为一名生活记录者**, 我希望能方便地上传照片和视频,以时间线的形式记录我的生活点滴,并能设置只对特定的人开放。
#### 1.1 字典管理子系统 ## 功能模块详述
**功能描述**:维护系统中使用的各类数据字典
- **地区字典**:省份、城市、区县三级联动 ### 1. 基础用户系统模块
- **分类字典**:菜单类别、标签类别、文件类型等
- **组织字典**:部门类型、角色类型、权限类型 #### 1.1 字典管理子系统
- **状态字典**:用户状态、内容状态、审核状态等
- **扩展字典**:支持自定义字典类型 **功能描述**:维护系统中使用的各类数据字典
**验收标准**:
- 管理员可以增删改查字典类型和字典项。 - **地区字典**:省份、城市、区县三级联动
- 系统关键状态(如用户禁用、启用)应与字典数据关联。 - **分类字典**:菜单类别、标签类别、文件类型等
- 前端下拉框、选项等数据应能通过API动态获取字典。 - **组织字典**:部门类型、角色类型、权限类型
- **状态字典**:用户状态、内容状态、审核状态等
#### 1.2 角色权限子系统 - **扩展字典**:支持自定义字典类型
**功能描述**基于RBAC模型的权限管理体系 **验收标准**:
- **角色管理** - 管理员可以增删改查字典类型和字典项。
- 树形结构的角色层级 - 系统关键状态(如用户禁用、启用)应与字典数据关联。
- 角色继承机制 - 前端下拉框、选项等数据应能通过API动态获取字典。
- 角色标签和属性
- 角色排序和分组 #### 1.2 角色权限子系统
- **权限管理**
- 页面访问权限 **功能描述**基于RBAC模型的权限管理体系
- 接口调用权限
- 数据访问权限(行级、字段级) - **角色管理**
- 操作权限(增删改查) - 树形结构的角色层级
- **权限分配** - 角色继承机制
- 下级角色只能分配上级已有权限 - 角色标签和属性
- 批量权限分配 - 角色排序和分组
- 权限模板机制 - **权限管理**
**验收标准**: - 页面访问权限
- Root管理员拥有所有权限且不可被修改。 - 接口调用权限
- 角色创建时,其可分配的权限不能超过创建者拥有的权限。 - 数据访问权限(行级、字段级)
- 用户访问未授权的页面或API时应返回403 Forbidden状态。 - 操作权限(增删改查)
- 角色的树形结构应能正确地在前端展示和操作。 - **权限分配**
- 下级角色只能分配上级已有权限
#### 1.3 组织架构子系统 - 批量权限分配
**功能描述**:管理用户组织关系 - 权限模板机制
- **组织结构** **验收标准**:
- 树形组织架构 - Root管理员拥有所有权限且不可被修改。
- 支持多级嵌套 - 角色创建时,其可分配的权限不能超过创建者拥有的权限。
- 组织属性和标签 - 用户访问未授权的页面或API时应返回403 Forbidden状态。
- **用户归属** - 角色的树形结构应能正确地在前端展示和操作。
- 用户可属于多个组织
- 主组织和辅助组织 #### 1.3 组织架构子系统
- 组织内角色分配
**验收标准**: **功能描述**:管理用户组织关系
- 用户可以被分配到多个组织节点下。
- 组织架构支持拖拽调整层级和顺序。 - **组织结构**
- 管理员可以根据组织架构筛选和管理用户。 - 树形组织架构
- 支持多级嵌套
#### 1.4 用户管理子系统 - 组织属性和标签
**功能描述**:完整的用户生命周期管理 - **用户归属**
- **用户注册** - 用户可属于多个组织
- 邮箱注册验证 - 主组织和辅助组织
- 手机号注册(可选) - 组织内角色分配
- 社交账号登录集成 **验收标准**:
- **用户认证** - 用户可以被分配到多个组织节点下。
- 密码登录 - 组织架构支持拖拽调整层级和顺序。
- 登录错误次数限制 - 管理员可以根据组织架构筛选和管理用户。
- 账户锁定和解锁机制
- 双因子认证(可选) #### 1.4 用户管理子系统
- **用户信息**
- 基础信息:用户名、邮箱、手机、头像 **功能描述**:完整的用户生命周期管理
- 扩展信息:个人简介、兴趣标签、社交链接
- 隐私设置:信息可见性控制 - **用户注册**
- **账户管理** - 邮箱注册验证
- 密码修改和重置 - 手机号注册(可选)
- 找回密码(邮箱/手机) - 社交账号登录集成
- 账户禁用和注销 - **用户认证**
- Root超级管理员 - 密码登录
**验收标准**: - 登录错误次数限制
- 新用户注册后,系统需发送验证邮件,点击链接后方可激活账户。 - 账户锁定和解锁机制
- 用户连续5次输错密码后账户应被锁定30分钟。 - 双因子认证(可选)
- 用户可以随时修改自己的个人信息(除用户名外)。 - **用户信息**
- 注销功能需要用户二次确认,并告知数据将被永久删除。 - 基础信息:用户名、邮箱、手机、头像
- 扩展信息:个人简介、兴趣标签、社交链接
### 2. 内容管理系统模块 - 隐私设置:信息可见性控制
- **账户管理**
#### 2.1 笔记管理子系统 - 密码修改和重置
**功能描述**:个人知识管理和笔记系统 - 找回密码(邮箱/手机)
- **笔记结构** - 账户禁用和注销
- 树形文件夹结构 - Root超级管理员
- 无限层级嵌套 **验收标准**:
- 文件夹和笔记混合组织 - 新用户注册后,系统需发送验证邮件,点击链接后方可激活账户。
- **内容格式** - 用户连续5次输错密码后账户应被锁定30分钟。
- Markdown编辑器 - 用户可以随时修改自己的个人信息(除用户名外)。
- 富文本编辑器 - 注销功能需要用户二次确认,并告知数据将被永久删除。
- 思维导图工具
- 在线画板工具 ### 2. 内容管理系统模块
- 代码片段高亮
- **笔记功能** #### 2.1 笔记管理子系统
- 实时保存
- 版本历史 **功能描述**:个人知识管理和笔记系统
- 标签分类
- 全文搜索 - **笔记结构**
- 笔记链接和引用 - 树形文件夹结构
- 附件管理(图片、文档、音视频) - 无限层级嵌套
- **协作功能** - 文件夹和笔记混合组织
- 笔记分享(链接分享、权限控制) - **内容格式**
- 协作编辑(可选) - Markdown编辑器
- 评论和批注 - 富文本编辑器
**验收标准**: - 思维导图工具
- 笔记内容在编辑时应每隔30秒自动保存一次。 - 在线画板工具
- 支持至少三级以上的树形目录结构。 - 代码片段高亮
- 全文搜索功能应能在1秒内返回标题或内容包含关键词的结果。 - **笔记功能**
- 分享的笔记链接可以设置有效期和密码。 - 实时保存
- 版本历史
#### 2.2 博客发布子系统 - 标签分类
**功能描述**:将笔记转化为公开博客 - 全文搜索
- **发布机制** - 笔记链接和引用
- 笔记一键发布为博客 - 附件管理(图片、文档、音视频)
- 博客独立存储(修改笔记不影响已发布博客) - **协作功能**
- 绑定关系维护 - 笔记分享(链接分享、权限控制)
- 重新发布和更新机制 - 协作编辑(可选)
- **博客管理** - 评论和批注
- 发布状态管理(草稿、已发布、下线) **验收标准**:
- SEO优化标题、描述、关键词 - 笔记内容在编辑时应每隔30秒自动保存一次。
- 分类和标签 - 支持至少三级以上的树形目录结构。
- 发布时间控制 - 全文搜索功能应能在1秒内返回标题或内容包含关键词的结果。
- **互动功能** - 分享的笔记链接可以设置有效期和密码。
- 点赞和收藏
- 评论系统(层级评论) #### 2.2 博客发布子系统
- 阅读量统计
- 分享功能 **功能描述**:将笔记转化为公开博客
- **展示功能**
- 博客列表和详情页 - **发布机制**
- 分类和标签筛选 - 笔记一键发布为博客
- 搜索功能 - 博客独立存储(修改笔记不影响已发布博客)
- RSS订阅 - 绑定关系维护
**验收标准**: - 重新发布和更新机制
- 从笔记发布博客后,二者内容各自独立。 - **博客管理**
- 博客可以关联多个分类和标签。 - 发布状态管理(草稿、已发布、下线)
- 未发布的博客(草稿)在公开列表不可见。 - SEO优化标题、描述、关键词
- 评论支持层级回复,并有新评论通知。 - 分类和标签
- 发布时间控制
#### 2.3 AI集成子系统 - **互动功能**
**功能描述**AI增强的内容创作和知识管理 - 点赞和收藏
- **内容创作辅助** - 评论系统(层级评论)
- AI写作建议 - 阅读量统计
- 文本润色和优化 - 分享功能
- 自动摘要生成 - **展示功能**
- 关键词提取 - 博客列表和详情页
- **知识库功能** - 分类和标签筛选
- 笔记内容向量化 - 搜索功能
- 智能检索和推荐 - RSS订阅
- 相关内容关联 **验收标准**:
- 问答系统 - 从笔记发布博客后,二者内容各自独立。
- **MCP集成** - 博客可以关联多个分类和标签。
- 多模态内容处理 - 未发布的博客(草稿)在公开列表不可见。
- 代码理解和生成 - 评论支持层级回复,并有新评论通知。
- 图像识别和描述
#### 2.3 AI集成子系统
### 3. 个人空间模块
**功能描述**AI增强的内容创作和知识管理
#### 3.1 生活日志子系统
**功能描述**:记录和分享个人生活 - **内容创作辅助**
- **内容类型** - AI写作建议
- 文字日志 - 文本润色和优化
- 图片分享(支持多图) - 自动摘要生成
- 视频分享 - 关键词提取
- 位置签到 - **知识库功能**
- 心情记录 - 笔记内容向量化
- **组织方式** - 智能检索和推荐
- 时间轴展示 - 相关内容关联
- 分类管理 - 问答系统
- 标签系统 - **MCP集成**
- 重要程度标记 - 多模态内容处理
- **隐私控制** - 代码理解和生成
- 公开/私密/好友可见 - 图像识别和描述
- 精细化权限控制
- 访客记录 ### 3. 个人空间模块
#### 3.2 社交互动子系统 #### 3.1 生活日志子系统
**功能描述**:与访客的互动功能
- **互动功能** **功能描述**:记录和分享个人生活
- 点赞系统
- 评论功能 - **内容类型**
- 收藏功能 - 文字日志
- 分享功能 - 图片分享(支持多图)
- **通知系统** - 视频分享
- 实时通知 - 位置签到
- 消息聚合 - 心情记录
- 邮件通知(可选) - **组织方式**
- 时间轴展示
### 4. 阅读管理模块 - 分类管理
- 标签系统
#### 4.1 电子书管理子系统 - 重要程度标记
**功能描述**:个人电子书库管理 - **隐私控制**
- **格式支持** - 公开/私密/好友可见
- PDF、EPUB、MOBI、TXT等 - 精细化权限控制
- 在线阅读器 - 访客记录
- 进度同步
#### 3.2 社交互动子系统
#### 4.2 阅读社区子系统
**功能描述**:阅读交流和分享 **功能描述**:与访客的互动功能
- **分享功能**
- 读书笔记分享 - **互动功能**
- 书评发布 - 点赞系统
- 阅读进度分享 - 评论功能
- 收藏功能
### 5. 实用工具模块 - 分享功能
- **通知系统**
#### 5.1 密码管理子系统 - 实时通知
**功能描述**:个人密码安全管理 - 消息聚合
- **密码存储** - 邮件通知(可选)
- 加密存储
- 分类管理 ### 4. 阅读管理模块
- 标签系统
- **浏览器插件** #### 4.1 电子书管理子系统
- 自动填充
- 密码生成 **功能描述**:个人电子书库管理
- 同步功能
- **格式支持**
#### 5.2 书签同步子系统 - PDF、EPUB、MOBI、TXT等
**功能描述**:跨设备书签管理 - 在线阅读器
- **同步功能** - 进度同步
- 浏览器书签导入/导出
- 实时同步 #### 4.2 阅读社区子系统
- 冲突解决
**功能描述**:阅读交流和分享
#### 5.3 第三方平台同步子系统
**功能描述**:整合各平台数据 - **分享功能**
- **Bilibili收藏同步** - 读书笔记分享
- 收藏视频同步 - 书评发布
- 分类管理 - 阅读进度分享
- 观看进度
### 5. 实用工具模块
#### 5.4 文件管理子系统
**功能描述**:个人云存储和媒体中心 #### 5.1 密码管理子系统
- **存储功能**
- NAS集成 **功能描述**:个人密码安全管理
- 文件上传下载
- 文件夹管理 - **密码存储**
- 权限控制 - 加密存储
- **媒体功能** - 分类管理
- 在线音视频播放 - 标签系统
- 图片预览 - **浏览器插件**
- 文档在线查看 - 自动填充
- **传输功能** - 密码生成
- WebRTC点对点传输 - 同步功能
- 阿里云OSS集成
- 断点续传 #### 5.2 书签同步子系统
#### 5.5 信息聚合子系统 **功能描述**:跨设备书签管理
**功能描述**:热点信息和内容聚合
- **新闻聚合** - **同步功能**
- 多源新闻抓取 - 浏览器书签导入/导出
- 分类展示 - 实时同步
- 关键词过滤 - 冲突解决
## 高阶数据模型概览 (High-Level Data Model) #### 5.3 第三方平台同步子系统
此为概念模型,用于指导数据库设计,具体字段和关系将在详细设计阶段确定。 **功能描述**:整合各平台数据
- **核心实体**: - **Bilibili收藏同步**
- `User`: 存储用户信息 (id, username, email, password_hash) - 收藏视频同步
- `Role`: 角色定义 (id, name, description) - 分类管理
- `Permission`: 权限定义 (id, action, resource) - 观看进度
- `Organization`: 组织架构 (id, name, parent_id)
- `Dictionary`: 数据字典 (id, type, key, value) #### 5.4 文件管理子系统
- `Note`: 笔记内容 (id, user_id, title, content, content_type, parent_id)
- `Post`: 博客文章 (id, user_id, source_note_id, title, content, status) **功能描述**:个人云存储和媒体中心
- `Tag`: 标签 (id, name)
- `Category`: 分类 (id, name) - **存储功能**
- `Comment`: 评论 (id, post_id, user_id, content, parent_id) - NAS集成
- `File`: 文件管理 (id, user_id, file_name, path, type) - 文件上传下载
- `Bookmark`: 书签 (id, user_id, title, url) - 文件夹管理
- `Password`: 密码记录 (id, user_id, entry_name, username, encrypted_password) - 权限控制
- **媒体功能**
- **关系**: - 在线音视频播放
- `User` (多) <-> (多) `Role` (通过 `UserRole` 中间表) - 图片预览
- `Role` (多) <-> (多) `Permission` (通过 `RolePermission` 中间表) - 文档在线查看
- `User` (多) <-> (多) `Organization` (通过 `UserOrganization` 中间表) - **传输功能**
- `Note` (一) -> (多) `Post` - WebRTC点对点传输
- `Post` (多) <-> (多) `Tag` (通过 `PostTag` 中间表) - 阿里云OSS集成
- `Post` (多) <-> (多) `Category` (通过 `PostCategory` 中间表) - 断点续传
- `Post` (一) -> (多) `Comment`
- `User` (一) -> (多) `Note`, `Post`, `File`, `Bookmark`, `Password` #### 5.5 信息聚合子系统
## 关键用户交互流程 (Key User Flows) **功能描述**:热点信息和内容聚合
### 流程一:新用户注册与激活 - **新闻聚合**
1. **访问首页**: 用户访问网站,点击"注册"按钮。 - 多源新闻抓取
2. **填写信息**: 跳转至注册页面,要求输入用户名、邮箱和密码(密码需二次确认)。 - 分类展示
3. **前端校验**: 实时校验用户名和邮箱是否已被占用,密码是否符合复杂度要求。 - 关键词过滤
4. **提交注册**: 用户点击"注册",前端校验通过后,将数据发送至后端。
5. **后端处理**: 后端创建用户记录(状态为`inactive`生成验证Token并向用户邮箱发送一封包含激活链接含Token的邮件。 ## 高阶数据模型概览 (High-Level Data Model)
6. **用户激活**: 用户打开邮箱,点击激活链接。
7. **激活验证**: 后端验证Token有效性将用户状态更新为`active`,并引导用户至登录页面,提示"激活成功"。 此为概念模型,用于指导数据库设计,具体字段和关系将在详细设计阶段确定。
### 流程二:从笔记到博客的发布 - **核心实体**:
1. **创建笔记**: 用户在笔记系统中创建并编辑一篇笔记内容可以是Markdown、富文本等。 - `User`: 存储用户信息 (id, username, email, password_hash)
2. **发起发布**: 在笔记编辑页面,用户点击"发布为博客"按钮。 - `Role`: 角色定义 (id, name, description)
3. **配置博客信息**: 弹窗或新页面要求用户填写博客的URL slug、选择分类、添加标签、设置封面图等。 - `Permission`: 权限定义 (id, action, resource)
4. **确认发布**: 用户点击"确认发布"。 - `Organization`: 组织架构 (id, name, parent_id)
5. **后端处理**: - `Dictionary`: 数据字典 (id, type, key, value)
- 后端在`Posts`表中创建一条新记录。 - `Note`: 笔记内容 (id, user_id, title, content, content_type, parent_id)
- 复制当前版本的笔记内容到该`Post`记录中。 - `Post`: 博客文章 (id, user_id, source_note_id, title, content, status)
- 记录该`Post`与源`Note`的关联关系 (`source_note_id`)。 - `Tag`: 标签 (id, name)
- 将博客状态设置为`published`。 - `Category`: 分类 (id, name)
6. **发布成功**: 系统提示"发布成功",并提供查看博客文章的链接。 - `Comment`: 评论 (id, post_id, user_id, content, parent_id)
7. **后续修改**: 用户后续在原笔记上的任何修改,都不会影响已发布的这篇博客文章。用户可在博客管理界面选择"从笔记更新内容"来同步最新修改。 - `File`: 文件管理 (id, user_id, file_name, path, type)
- `Bookmark`: 书签 (id, user_id, title, url)
## 技术架构需求 - `Password`: 密码记录 (id, user_id, entry_name, username, encrypted_password)
### 后端技术栈 - **关系**:
- **框架**Elysia + Bun.js - `User` (多) <-> (多) `Role` (通过 `UserRole` 中间表)
- **数据库**MySQL (主) + Redis (缓存) + Elasticsearch (搜索) - `Role` (多) <-> (多) `Permission` (通过 `RolePermission` 中间表)
- **对象存储**支持本地存储、阿里云OSS、自建NAS - `User` (多) <-> (多) `Organization` (通过 `UserOrganization` 中间表)
- **消息队列**Redis/RabbitMQ处理异步任务 - `Note` (一) -> (多) `Post`
- `Post` (多) <-> (多) `Tag` (通过 `PostTag` 中间表)
### 前端技术栈 - `Post` (多) <-> (多) `Category` (通过 `PostCategory` 中间表)
- **Web端**Vue.js/React + TypeScript - `Post` (一) -> (多) `Comment`
- **移动端**PWA或React Native - `User` (一) -> (多) `Note`, `Post`, `File`, `Bookmark`, `Password`
- **浏览器插件**Manifest V3
## 关键用户交互流程 (Key User Flows)
### 基础设施
- **部署**Docker + Docker Compose ### 流程一:新用户注册与激活
- **监控**:日志收集、性能监控、错误追踪
- **安全**HTTPS、数据加密、访问控制 1. **访问首页**: 用户访问网站,点击"注册"按钮。
2. **填写信息**: 跳转至注册页面,要求输入用户名、邮箱和密码(密码需二次确认)。
### API设计原则 3. **前端校验**: 实时校验用户名和邮箱是否已被占用,密码是否符合复杂度要求。
- **RESTful风格**: 使用标准的HTTP方法 (GET, POST, PUT, DELETE, PATCH)。 4. **提交注册**: 用户点击"注册",前端校验通过后,将数据发送至后端。
- **版本控制**: API URL中应包含版本号`/api/v1/...` 5. **后端处理**: 后端创建用户记录(状态为`inactive`生成验证Token并向用户邮箱发送一封包含激活链接含Token的邮件。
- **统一响应格式**: 所有响应遵循 `{ "code": 0, "message": "success", "data": {} }` 结构。 6. **用户激活**: 用户打开邮箱,点击激活链接。
- **分页**: 对列表数据提供统一的分页参数,如 `page``pageSize` 7. **激活验证**: 后端验证Token有效性将用户状态更新为`active`,并引导用户至登录页面,提示"激活成功"。
- **排序与筛选**: 支持通过URL参数对结果进行排序和筛选。
- **认证**: 受保护的API需通过`Authorization`头传递JWT。 ### 流程二:从笔记到博客的发布
## 非功能性需求 1. **创建笔记**: 用户在笔记系统中创建并编辑一篇笔记内容可以是Markdown、富文本等。
2. **发起发布**: 在笔记编辑页面,用户点击"发布为博客"按钮。
### 性能要求 3. **配置博客信息**: 弹窗或新页面要求用户填写博客的URL slug、选择分类、添加标签、设置封面图等。
- **响应时间**:页面加载<2s接口响应<500ms 4. **确认发布**: 用户点击"确认发布"。
- **并发支持**支持1000+并发用户 5. **后端处理**:
- **可用性**99.9%系统可用性 - 后端在`Posts`表中创建一条新记录。
- 复制当前版本的笔记内容到该`Post`记录中。
### 安全要求 - 记录该`Post`与源`Note`的关联关系 (`source_note_id`)。
- **数据加密**:敏感数据加密存储和传输 - 将博客状态设置为`published`。
- **访问控制**:完善的认证和授权机制 6. **发布成功**: 系统提示"发布成功",并提供查看博客文章的链接。
- **审计日志**:关键操作日志记录 7. **后续修改**: 用户后续在原笔记上的任何修改,都不会影响已发布的这篇博客文章。用户可在博客管理界面选择"从笔记更新内容"来同步最新修改。
### 扩展性要求 ## 技术架构需求
- **模块化设计**:支持功能模块独立部署
- **API设计**RESTful API支持版本控制 ### 后端技术栈
- **数据库**:支持读写分离和分库分表
- **框架**Elysia + Bun.js
## 开发优先级 - **数据库**MySQL (主) + Redis (缓存) + Elasticsearch (搜索)
- **对象存储**支持本地存储、阿里云OSS、自建NAS
### 第一期核心基础MVP - **消息队列**Redis/RabbitMQ处理异步任务
1. 基础用户系统(用户管理、角色权限)
2. 笔记系统核心功能 ### 前端技术栈
3. 基础博客发布
4. 系统基础架构 - **Web端**Vue.js/React + TypeScript
- **移动端**PWA或React Native
### 第二期:内容增强 - **浏览器插件**Manifest V3
1. 完整的博客系统
2. 个人空间功能 ### 基础设施
3. 基础工具(密码管理、书签)
4. 搜索优化 - **部署**Docker + Docker Compose
- **监控**:日志收集、性能监控、错误追踪
### 第三期:高级功能 - **安全**HTTPS、数据加密、访问控制
1. 阅读管理系统
2. 文件管理系统 ### API设计原则
3. AI集成功能
4. 第三方平台同步 - **RESTful风格**: 使用标准的HTTP方法 (GET, POST, PUT, DELETE, PATCH)。
- **版本控制**: API URL中应包含版本号`/api/v1/...`
### 第四期:生态完善 - **统一响应格式**: 所有响应遵循 `{ "code": 0, "message": "success", "data": {} }` 结构。
1. 移动端应用 - **分页**: 对列表数据提供统一的分页参数,如 `page``pageSize`
2. 浏览器插件 - **排序与筛选**: 支持通过URL参数对结果进行排序和筛选。
3. 高级分析功能 - **认证**: 受保护的API需通过`Authorization`头传递JWT。
4. 性能优化
## 非功能性需求
## 风险与挑战
### 性能要求
### 技术风险
- **数据迁移**:大量历史数据的迁移和同步 - **响应时间**:页面加载<2s接口响应<500ms
- **性能优化**:大文件上传和处理 - **并发支持**支持1000+并发用户
- **第三方集成**API变更和限制 - **可用性**99.9%系统可用性
### 产品风险 ### 安全要求
- **功能复杂度**:避免功能过于复杂影响用户体验
- **数据安全**:个人敏感数据的安全保护 - **数据加密**:敏感数据加密存储和传输
- **兼容性**:跨浏览器和设备兼容 - **访问控制**:完善的认证和授权机制
- **审计日志**:关键操作日志记录
## 成功指标
### 扩展性要求
### 用户指标
- 日活跃用户数 - **模块化设计**:支持功能模块独立部署
- 用户留存率 - **API设计**RESTful API支持版本控制
- 功能使用率 - **数据库**:支持读写分离和分库分表
### 技术指标 ## 开发优先级
- 系统响应时间
- 错误率控制 ### 第一期核心基础MVP
- 数据备份完整性
1. 基础用户系统(用户管理、角色权限)
### 业务指标 2. 笔记系统核心功能
- 内容创作量 3. 基础博客发布
- 用户满意度 4. 系统基础架构
- 功能完成度
### 第二期:内容增强
## 后续规划
1. 完整的博客系统
### 短期目标6个月内 2. 个人空间功能
- 完成第一期功能开发 3. 基础工具(密码管理、书签)
- 系统稳定性优化 4. 搜索优化
- 基础功能测试
### 第三期:高级功能
### 中期目标1年内
- 完成主要功能模块 1. 阅读管理系统
- 移动端支持 2. 文件管理系统
- 性能优化 3. AI集成功能
4. 第三方平台同步
### 长期目标1年以上
- AI功能深度集成 ### 第四期:生态完善
- 开放API生态
- 多语言支持 1. 移动端应用
2. 浏览器插件
3. 高级分析功能
4. 性能优化
## 风险与挑战
### 技术风险
- **数据迁移**:大量历史数据的迁移和同步
- **性能优化**:大文件上传和处理
- **第三方集成**API变更和限制
### 产品风险
- **功能复杂度**:避免功能过于复杂影响用户体验
- **数据安全**:个人敏感数据的安全保护
- **兼容性**:跨浏览器和设备兼容
## 成功指标
### 用户指标
- 日活跃用户数
- 用户留存率
- 功能使用率
### 技术指标
- 系统响应时间
- 错误率控制
- 数据备份完整性
### 业务指标
- 内容创作量
- 用户满意度
- 功能完成度
## 后续规划
### 短期目标6个月内
- 完成第一期功能开发
- 系统稳定性优化
- 基础功能测试
### 中期目标1年内
- 完成主要功能模块
- 移动端支持
- 性能优化
### 长期目标1年以上
- AI功能深度集成
- 开放API生态
- 多语言支持

View File

@ -1,128 +1,128 @@
# 星撰个人综合平台 - 项目工程设计文档 # 星撰个人综合平台 - 项目工程设计文档
--- ---
## 0. 引言 ## 0. 引言
本文档旨在为"星撰"个人综合平台项目提供全面的工程设计与规划。基于已确定的产品需求文档PRD本文档从软件工程和工程控制论的视角出发深入分析项目的技术指标、可行性、技术选型、未来展望及各项核心系统属性为项目的顺利开发、部署和长期演进提供一份结构化的工程蓝图。 本文档旨在为"星撰"个人综合平台项目提供全面的工程设计与规划。基于已确定的产品需求文档PRD本文档从软件工程和工程控制论的视角出发深入分析项目的技术指标、可行性、技术选型、未来展望及各项核心系统属性为项目的顺利开发、部署和长期演进提供一份结构化的工程蓝图。
**项目代号**: 星撰 (StarWriter) **项目代号**: 星撰 (StarWriter)
**核心愿景**: 构建一个高度集成、数据私有、体验优秀的个人数字化中枢,统一管理知识、生活与工具。 **核心愿景**: 构建一个高度集成、数据私有、体验优秀的个人数字化中枢,统一管理知识、生活与工具。
--- ---
## 1. 技术指标 (Technical Specifications) ## 1. 技术指标 (Technical Specifications)
为确保项目质量,我们设定以下可量化的关键技术指标: 为确保项目质量,我们设定以下可量化的关键技术指标:
| 类别 | 指标项 | 目标值 | 备注 | | 类别 | 指标项 | 目标值 | 备注 |
|--------------|------------------------------|------------------------------------------------|----------------------------------------------| | ------------ | -------------------------- | ------------------------------------------ | ---------------------------------- |
| **性能** | API 平均响应时间 | < 200ms (95th percentile) | 针对核心数据读写操作 | | **性能** | API 平均响应时间 | < 200ms (95th percentile) | 针对核心数据读写操作 |
| | 复杂查询/搜索响应时间 | < 800ms | 如全文搜索聚合分析 | | | 复杂查询/搜索响应时间 | < 800ms | 如全文搜索聚合分析 |
| | 页面首次内容绘制 (FCP) | < 1.5s | 核心页面的加载性能 | | | 页面首次内容绘制 (FCP) | < 1.5s | 核心页面的加载性能 |
| **并发** | MVP 阶段并发用户数 | 100+ | 系统可稳定支持的并发会话数 | | **并发** | MVP 阶段并发用户数 | 100+ | 系统可稳定支持的并发会话数 |
| | 长期目标并发用户数 | 1000+ | | | | 长期目标并发用户数 | 1000+ | |
| **可用性** | 系统年可用性 (Uptime) | 99.9% | 相当于每年停机时间不超过 8.76 小时 | | **可用性** | 系统年可用性 (Uptime) | 99.9% | 相当于每年停机时间不超过 8.76 小时 |
| **稳定性** | 核心 API 成功率 | > 99.95% | | | **稳定性** | 核心 API 成功率 | > 99.95% | |
| | 数据备份恢复点目标 (RPO) | 24 小时 | 每日自动备份,最多丢失 24 小时数据 | | | 数据备份恢复点目标 (RPO) | 24 小时 | 每日自动备份,最多丢失 24 小时数据 |
| | 数据备份恢复时间目标 (RTO) | < 2 小时 | 从灾难发生到恢复服务所需时间 | | | 数据备份恢复时间目标 (RTO) | < 2 小时 | 从灾难发生到恢复服务所需时间 |
| **安全性** | 安全漏洞响应 | 遵循 OWASP Top 10 防御原则0 高危漏洞 | 使用自动化工具扫描并定期审查 | | **安全性** | 安全漏洞响应 | 遵循 OWASP Top 10 防御原则0 高危漏洞 | 使用自动化工具扫描并定期审查 |
| | 敏感数据处理 | 密码、密钥等使用非对称加密或强哈希算法存储 | | | | 敏感数据处理 | 密码、密钥等使用非对称加密或强哈希算法存储 | |
| **代码质量** | 核心模块单元测试覆盖率 | > 90% | 确保业务逻辑的稳定可靠 | | **代码质量** | 核心模块单元测试覆盖率 | > 90% | 确保业务逻辑的稳定可靠 |
| | CI/CD 流水线单次执行时间 | < 10 分钟 | 从代码提交到构建部署完成 | | | CI/CD 流水线单次执行时间 | < 10 分钟 | 从代码提交到构建部署完成 |
--- ---
## 2. 可行性分析 (Feasibility Analysis) ## 2. 可行性分析 (Feasibility Analysis)
- **技术可行性**: - **技术可行性**:
- **技术栈成熟度**: 项目选用的 Elysia.js, Bun, Vue/React, MySQL, Redis 等技术均为成熟或高速发展的开源技术,拥有活跃的社区和丰富的文档,技术风险可控。 - **技术栈成熟度**: 项目选用的 Elysia.js, Bun, Vue/React, MySQL, Redis 等技术均为成熟或高速发展的开源技术,拥有活跃的社区和丰富的文档,技术风险可控。
- **实现复杂度**: 项目功能虽多但采用模块化、分阶段MVP的开发策略可将复杂系统分解为多个可管理的小模块降低了单次开发的复杂度。 - **实现复杂度**: 项目功能虽多但采用模块化、分阶段MVP的开发策略可将复杂系统分解为多个可管理的小模块降低了单次开发的复杂度。
- **团队技能**: 核心开发人员具备所需的全栈技术能力,能够驾驭该技术栈。 - **团队技能**: 核心开发人员具备所需的全栈技术能力,能够驾驭该技术栈。
- **经济可行性**: - **经济可行性**:
- **初期成本**: 主要成本为服务器托管费用。初期可利用云服务商的免费套餐或低成本VPS成本极低。所有核心软件均为开源无授权费用。 - **初期成本**: 主要成本为服务器托管费用。初期可利用云服务商的免费套餐或低成本VPS成本极低。所有核心软件均为开源无授权费用。
- **长期成本**: 随着用户量和数据量的增长,服务器和存储成本会线性增加,但成本模型清晰可控。 - **长期成本**: 随着用户量和数据量的增长,服务器和存储成本会线性增加,但成本模型清晰可控。
- **开发成本**: 作为个人项目,主要为时间成本,无直接的人力资源开销。 - **开发成本**: 作为个人项目,主要为时间成本,无直接的人力资源开销。
- **操作与维护可行性**: - **操作与维护可行性**:
- **部署**: 采用 Docker 和 Docker Compose 进行容器化部署,实现了环境的标准化和一键部署,极大降低了操作和维护的复杂性。 - **部署**: 采用 Docker 和 Docker Compose 进行容器化部署,实现了环境的标准化和一键部署,极大降低了操作和维护的复杂性。
- **监控**: 规划了完善的日志和监控体系,能够主动发现和定位问题,提升了系统的可维护性。 - **监控**: 规划了完善的日志和监控体系,能够主动发现和定位问题,提升了系统的可维护性。
- **开发流程**: 明确的 PRD 和工程设计文档,结合 CI/CD 自动化流程,保障了开发过程的规范性和高效性。 - **开发流程**: 明确的 PRD 和工程设计文档,结合 CI/CD 自动化流程,保障了开发过程的规范性和高效性。
--- ---
## 3. 技术选型与论证 (Technology Stack Selection & Justification) ## 3. 技术选型与论证 (Technology Stack Selection & Justification)
| 领域 | 技术选型 | 选型论证 | | 领域 | 技术选型 | 选型论证 |
|----------------|-----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | -------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **后端运行时** | **Bun.js** | 极高的执行效率,内置打包器、测试器、包管理器,提供了现代、一体化的开发体验,与 Elysia.js 完美契合。 | | **后端运行时** | **Bun.js** | 极高的执行效率,内置打包器、测试器、包管理器,提供了现代、一体化的开发体验,与 Elysia.js 完美契合。 |
| **后端框架** | **Elysia.js** | 基于 Bun 设计性能卓越。提供端到端的类型安全Type-Safe开发体验极佳插件生态丰富非常适合构建高性能、类型健壮的 API 服务。 | | **后端框架** | **Elysia.js** | 基于 Bun 设计性能卓越。提供端到端的类型安全Type-Safe开发体验极佳插件生态丰富非常适合构建高性能、类型健壮的 API 服务。 |
| **前端框架** | **Vue.js / React** | 两者均为业界领先的声明式 UI 框架拥有强大的生态系统和组件库。选择其一可快速构建复杂、交互丰富的单页应用SPA。最终选择可根据个人偏好决定。 | | **前端框架** | **Vue.js / React** | 两者均为业界领先的声明式 UI 框架拥有强大的生态系统和组件库。选择其一可快速构建复杂、交互丰富的单页应用SPA。最终选择可根据个人偏好决定。 |
| **数据库** | **MySQL (主) + Redis (缓存) + Elasticsearch (搜索)** | **MySQL**: 成熟可靠的关系型数据库,提供事务支持和数据一致性,适合存储结构化的核心业务数据。**Redis**: 高性能内存数据库,用于缓存、会话管理、消息队列等场景,能极大提升系统响应速度。**Elasticsearch**: 强大的全文搜索引擎,为笔记、博客等内容提供毫秒级的复杂搜索能力,是内容驱动型平台的关键组件。 | | **数据库** | **MySQL (主) + Redis (缓存) + Elasticsearch (搜索)** | **MySQL**: 成熟可靠的关系型数据库,提供事务支持和数据一致性,适合存储结构化的核心业务数据。**Redis**: 高性能内存数据库,用于缓存、会话管理、消息队列等场景,能极大提升系统响应速度。**Elasticsearch**: 强大的全文搜索引擎,为笔记、博客等内容提供毫秒级的复杂搜索能力,是内容驱动型平台的关键组件。 |
| **部署方案** | **Docker + Docker Compose** | 实现开发、测试、生产环境的一致性,简化部署流程,便于服务的水平扩展和管理。 | | **部署方案** | **Docker + Docker Compose** | 实现开发、测试、生产环境的一致性,简化部署流程,便于服务的水平扩展和管理。 |
--- ---
## 4. 前沿技术展望 (Future Technology Outlook) ## 4. 前沿技术展望 (Future Technology Outlook)
本项目在设计上保持开放,为未来集成前沿技术预留了接口: 本项目在设计上保持开放,为未来集成前沿技术预留了接口:
- **深度 AI 集成 (LLM/RAG)**: 当前规划的 AI 功能是基础。未来可引入基于私有数据笔记、文档的检索增强生成RAG技术将平台打造成一个真正个性化的智能问答和创作助手。 - **深度 AI 集成 (LLM/RAG)**: 当前规划的 AI 功能是基础。未来可引入基于私有数据笔记、文档的检索增强生成RAG技术将平台打造成一个真正个性化的智能问答和创作助手。
- **去中心化身份与存储 (DID/IPFS)**: 探索使用去中心化身份标识DID进行用户认证增强用户的自主权。核心的、不可变的笔记或博客可选择性地发布到 IPFS/Arweave 等去中心化存储网络,实现数据的永久保存和抗审查。 - **去中心化身份与存储 (DID/IPFS)**: 探索使用去中心化身份标识DID进行用户认证增强用户的自主权。核心的、不可变的笔记或博客可选择性地发布到 IPFS/Arweave 等去中心化存储网络,实现数据的永久保存和抗审查。
- **边缘计算 (Edge Computing)**: 对于静态资源和一些低延迟要求的 API可以利用边缘计算节点进行分发和计算为全球用户提供更快的访问速度。 - **边缘计算 (Edge Computing)**: 对于静态资源和一些低延迟要求的 API可以利用边缘计算节点进行分发和计算为全球用户提供更快的访问速度。
- **WebAssembly (WASM)**: 对于前端的性能敏感模块,如富文本编辑器、思维导图、在线画板的核心引擎,未来可采用 Rust/C++ 编写并通过 WASM 在浏览器中运行,以获得接近原生的性能。 - **WebAssembly (WASM)**: 对于前端的性能敏感模块,如富文本编辑器、思维导图、在线画板的核心引擎,未来可采用 Rust/C++ 编写并通过 WASM 在浏览器中运行,以获得接近原生的性能。
--- ---
## 5. 实用性分析 (Utility Analysis) ## 5. 实用性分析 (Utility Analysis)
本项目的核心实用价值在于 **"终极整合"** 与 **"数据主权"**。 本项目的核心实用价值在于 **"终极整合"** 与 **"数据主权"**。
- **解决的核心痛点**: 现代人的数字信息散落在各个孤立的平台笔记在A应用书签在B浏览器密码在C工具博客在D平台。"星撰"旨在通过一个统一的平台整合这些功能,消除切换成本,构建个人数据的内在联系。 - **解决的核心痛点**: 现代人的数字信息散落在各个孤立的平台笔记在A应用书签在B浏览器密码在C工具博客在D平台。"星撰"旨在通过一个统一的平台整合这些功能,消除切换成本,构建个人数据的内在联系。
- **为用户创造的价值**: - **为用户创造的价值**:
1. **数据所有权**: 用户数据完全存储在自己的服务器上,彻底摆脱对商业平台的依赖和隐私泄露的担忧。 1. **数据所有权**: 用户数据完全存储在自己的服务器上,彻底摆脱对商业平台的依赖和隐私泄露的担忧。
2. **高度可定制**: 作为一个自建项目,可以根据个人需求无限扩展和定制功能,不受商业产品的限制。 2. **高度可定制**: 作为一个自建项目,可以根据个人需求无限扩展和定制功能,不受商业产品的限制。
3. **效率提升**: 将多种高频工具集成,减少了跨应用的操作,提升了日常工作和学习的效率。 3. **效率提升**: 将多种高频工具集成,减少了跨应用的操作,提升了日常工作和学习的效率。
--- ---
## 6. 商用性分析 (Commercial Viability Analysis) ## 6. 商用性分析 (Commercial Viability Analysis)
尽管项目初衷为个人使用,但其架构和功能具备一定的商用潜力。 尽管项目初衷为个人使用,但其架构和功能具备一定的商用潜力。
- **潜在市场**: 面向注重数据隐私、有极客精神、需要高度定制化解决方案的个人开发者、知识工作者、内容创作者。 - **潜在市场**: 面向注重数据隐私、有极客精神、需要高度定制化解决方案的个人开发者、知识工作者、内容创作者。
- **商业模式探索**: - **商业模式探索**:
1. **SaaS 订阅服务**: 提供托管服务用户无需自行部署。可通过免费增值模式Freemium吸引用户高级功能如无限AI调用、团队协作、更大存储空间收费。 1. **SaaS 订阅服务**: 提供托管服务用户无需自行部署。可通过免费增值模式Freemium吸引用户高级功能如无限AI调用、团队协作、更大存储空间收费。
2. **开源+付费支持/高级版**: 项目核心开源,吸引社区贡献。提供付费的企业级支持、高级插件或一键部署的商业版。 2. **开源+付费支持/高级版**: 项目核心开源,吸引社区贡献。提供付费的企业级支持、高级插件或一键部署的商业版。
3. **模板/插件市场**: 建立一个生态,允许开发者创建和销售主题模板、功能插件。 3. **模板/插件市场**: 建立一个生态,允许开发者创建和销售主题模板、功能插件。
- **核心竞争力**: 与 Notion, Obsidian 等产品的竞争中,其核心优势是 **开源、可自托管、高度集成**。这对于目标市场中的特定群体具有强大吸引力。 - **核心竞争力**: 与 Notion, Obsidian 等产品的竞争中,其核心优势是 **开源、可自托管、高度集成**。这对于目标市场中的特定群体具有强大吸引力。
--- ---
## 7. 稳定性与可靠性设计 (Stability & Reliability Design) ## 7. 稳定性与可靠性设计 (Stability & Reliability Design)
- **冗余与备份**: 数据库采用主从复制Read Replicas实现读写分离和高可用。实施每日全量备份和增量备份策略并定期进行恢复演练。 - **冗余与备份**: 数据库采用主从复制Read Replicas实现读写分离和高可用。实施每日全量备份和增量备份策略并定期进行恢复演练。
- - **无状态服务**: 后端 API 设计为无状态,所有会话信息存储在 Redis 中。这使得服务可以轻松地进行水平扩展和故障切换。 - - **无状态服务**: 后端 API 设计为无状态,所有会话信息存储在 Redis 中。这使得服务可以轻松地进行水平扩展和故障切换。
- **优雅降级**: 当 Elasticsearch 或 Redis 等非核心依赖出现故障时,系统应能自动降级服务(如暂时关闭搜索功能、使用数据库替代缓存),保证核心功能(如读写笔记)不受影响。 - **优雅降级**: 当 Elasticsearch 或 Redis 等非核心依赖出现故障时,系统应能自动降级服务(如暂时关闭搜索功能、使用数据库替代缓存),保证核心功能(如读写笔记)不受影响。
- **健康检查**: 对外提供健康检查 API (`/health`),供负载均衡和监控系统调用,实现故障节点的自动摘除。 - **健康检查**: 对外提供健康检查 API (`/health`),供负载均衡和监控系统调用,实现故障节点的自动摘除。
- **配置中心**: 所有配置(数据库连接、密钥等)通过环境变量或配置中心管理,实现配置与代码分离,增强安全性与灵活性。 - **配置中心**: 所有配置(数据库连接、密钥等)通过环境变量或配置中心管理,实现配置与代码分离,增强安全性与灵活性。
--- ---
## 8. 可拓展性设计 (Scalability & Extensibility Design) ## 8. 可拓展性设计 (Scalability & Extensibility Design)
- **水平扩展 (Scale Out)**: 无状态的后端服务设计,使其可以轻易地在负载均衡器后部署多个实例,以应对增长的流量。 - **水平扩展 (Scale Out)**: 无状态的后端服务设计,使其可以轻易地在负载均衡器后部署多个实例,以应对增长的流量。
- **垂直扩展 (Scale Up)**: 系统也可以通过增加单个服务器的 CPU 和内存来提升处理能力。 - **垂直扩展 (Scale Up)**: 系统也可以通过增加单个服务器的 CPU 和内存来提升处理能力。
- **数据库扩展**: 初期采用读写分离。未来若数据量巨大可根据业务模块进行垂直拆分如用户库、内容库或对超大表进行水平分片Sharding - **数据库扩展**: 初期采用读写分离。未来若数据量巨大可根据业务模块进行垂直拆分如用户库、内容库或对超大表进行水平分片Sharding
- **功能扩展 (Extensibility)**: - **功能扩展 (Extensibility)**:
- **模块化架构**: `src` 目录下的 `controllers`, `services`, `plugins` 结构清晰,新增功能模块不会影响现有逻辑。 - **模块化架构**: `src` 目录下的 `controllers`, `services`, `plugins` 结构清晰,新增功能模块不会影响现有逻辑。
- **插件化设计**: 核心功能稳定后,可设计一套正式的插件机制,允许社区或自己以插件形式开发新功能(如新的编辑器、新的同步工具),实现真正的"热插拔"式扩展。 - **插件化设计**: 核心功能稳定后,可设计一套正式的插件机制,允许社区或自己以插件形式开发新功能(如新的编辑器、新的同步工具),实现真正的"热插拔"式扩展。
- **事件驱动**: 通过引入消息队列,采用事件驱动模式解耦核心业务和附加业务(如发送邮件、生成通知、索引文档),提高系统的响应能力和可扩展性。 - **事件驱动**: 通过引入消息队列,采用事件驱动模式解耦核心业务和附加业务(如发送邮件、生成通知、索引文档),提高系统的响应能力和可扩展性。
--- ---
## 9. 结论 ## 9. 结论
"星撰"项目不仅在产品功能上满足了现代数字生活的需求,其工程设计也遵循了高标准。项目技术上可行,经济上低成本,且在稳定性、可扩展性方面进行了深思熟虑的规划。本文档将作为指导,确保项目沿着一条稳健、高效、可持续的道路前进。 "星撰"项目不仅在产品功能上满足了现代数字生活的需求,其工程设计也遵循了高标准。项目技术上可行,经济上低成本,且在稳定性、可扩展性方面进行了深思熟虑的规划。本文档将作为指导,确保项目沿着一条稳健、高效、可持续的道路前进。

View File

@ -1,121 +1,137 @@
# 星撰个人综合平台 - 详细开发计划 (按功能模块划分) # 星撰个人综合平台 - 详细开发计划 (按功能模块划分)
--- ---
## 1. 引言 ## 1. 引言
### 1.1. 文档目的 ### 1.1. 文档目的
本文档基于《产品需求文档 (PRD)》和《概要设计》,旨在将项目需求拆分为具体、可执行的开发任务,并将其组织成一个以核心功能模块为导向、分阶段的开发计划。本文档是项目执行阶段的核心路线图。
本文档基于《产品需求文档 (PRD)》和《概要设计》,旨在将项目需求拆分为具体、可执行的开发任务,并将其组织成一个以核心功能模块为导向、分阶段的开发计划。本文档是项目执行阶段的核心路线图。
### 1.2. 计划结构
本计划遵循迭代式增量开发模型,将整个项目划分为多个主要阶段。**此版本已根据 `main.md` 的功能列表进行重组**,每个阶段聚焦于一个完整的功能模块。 ### 1.2. 计划结构
--- 本计划遵循迭代式增量开发模型,将整个项目划分为多个主要阶段。**此版本已根据 `main.md` 的功能列表进行重组**,每个阶段聚焦于一个完整的功能模块。
## 阶段一: 项目初始化与基础设施 ---
**目标**: 搭建项目骨架,配置开发环境和规范,建立 CI/CD 基础流程。这是后续所有开发工作的基础。
- [ ] **1.1. 项目脚手架**: 初始化 Elysia + Bun + TypeScript 项目,配置 `tsconfig.json` 和路径别名。 ## 阶段一: 项目初始化与基础设施
- [ ] **1.2. 代码规范**: 配置 ESLint 和 Prettier确保代码风格和质量一致。
- [ ] **1.3. 配置文件管理**: 建立 `src/config` 目录管理数据库、JWT、日志等配置支持环境变量。 **目标**: 搭建项目骨架,配置开发环境和规范,建立 CI/CD 基础流程。这是后续所有开发工作的基础。
- [ ] **1.4. 数据库连接**: 编写并测试 MySQL 和 Redis 的连接模块。
- [ ] **1.5. 核心插件**: 实现全局错误处理、结构化日志 (`pino`) 和 Swagger API 文档插件。 - [ ] **1.1. 项目脚手架**: 初始化 Elysia + Bun + TypeScript 项目,配置 `tsconfig.json` 和路径别名。
- [ ] **1.6. 容器化**: 编写 `Dockerfile``docker-compose.yml`,实现开发环境一键启动。 - [ ] **1.2. 代码规范**: 配置 ESLint 和 Prettier确保代码风格和质量一致。
- [ ] **1.7. CI/CD 基础**: 搭建基础的 GitHub Actions 工作流,实现提交代码时自动运行 lint 和测试。 - [ ] **1.3. 配置文件管理**: 建立 `src/config` 目录管理数据库、JWT、日志等配置支持环境变量。
- [ ] **1.4. 数据库连接**: 编写并测试 MySQL 和 Redis 的连接模块。
--- - [ ] **1.5. 核心插件**: 实现全局错误处理、结构化日志 (`pino`) 和 Swagger API 文档插件。
- [ ] **1.6. 容器化**: 编写 `Dockerfile``docker-compose.yml`,实现开发环境一键启动。
## 阶段二: 基础用户系统 - [ ] **1.7. CI/CD 基础**: 搭建基础的 GitHub Actions 工作流,实现提交代码时自动运行 lint 和测试。
**目标**: 实现一个完整的、支持RBAC的用户中心包含用户、角色、权限、组织和字典等核心功能。
- [ ] **2.1. 数据库设计**: 设计并创建 `users`, `roles`, `permissions`, `organizations`, `dictionaries` 及相关联的表。 ---
- [ ] **2.2. 用户认证 API**:
- [ ] `POST /api/v1/auth/register`: 实现用户注册(含数据校验、密码哈希)。 ## 阶段二: 基础用户系统
- [ ] `POST /api/v1/auth/login`: 实现用户登录,成功后返回 JWT。
- [ ] `GET /api/v1/users/me`: 创建受保护路由,获取当前登录用户信息。 **目标**: 实现一个完整的、支持RBAC的用户中心包含用户、角色、权限、组织和字典等核心功能。
- [ ] **2.3. JWT与权限中间件**:
- [ ] 实现 JWT 校验中间件。 - [ ] **2.1. 数据库设计**: 设计并创建 `users`, `roles`, `permissions`, `organizations`, `dictionaries` 及相关联的表。
- [ ] 实现一个基础的 RBAC 权限校验中间件。 - [ ] **2.2. 用户认证 API**:
- [ ] **2.4. 字典管理 CRUD API**: 实现对数据字典类型和条目的增删改查。 - [ ] `POST /api/v1/auth/register`: 实现用户注册(含数据校验、密码哈希)。
- [ ] **2.5. 角色管理 CRUD API**: 实现对角色的增删改查及权限分配。 - [ ] `POST /api/v1/auth/login`: 实现用户登录,成功后返回 JWT。
- [ ] **2.6. 组织架构 CRUD API**: 实现树形组织架构的增删改查。 - [ ] `GET /api/v1/users/me`: 创建受保护路由,获取当前登录用户信息。
- [ ] **2.7. 用户管理 CRUD API**: 实现后台对用户的增删改查和角色分配。 - [ ] **2.3. JWT与权限中间件**:
- [ ] 实现 JWT 校验中间件。
--- - [ ] 实现一个基础的 RBAC 权限校验中间件。
- [ ] **2.4. 字典管理 CRUD API**: 实现对数据字典类型和条目的增删改查。
## 阶段三: 笔记与博客系统 - [ ] **2.5. 角色管理 CRUD API**: 实现对角色的增删改查及权限分配。
**目标**: 构建平台的内容核心,支持从私有笔记到公开博客的完整流程,并集成搜索和社交功能。 - [ ] **2.6. 组织架构 CRUD API**: 实现树形组织架构的增删改查。
- [ ] **3.1. 数据库设计**: 设计 `notes`, `posts`, `tags`, `categories`, `comments` 及相关关联表。 - [ ] **2.7. 用户管理 CRUD API**: 实现后台对用户的增删改查和角色分配。
- [ ] **3.2. 笔记核心 API**:
- [ ] 实现笔记的 CRUD (创建/读取/更新/删除)。 ---
- [ ] `GET /api/v1/notes`: 获取用户的笔记树形结构列表。
- [ ] 实现笔记的标签管理功能。 ## 阶段三: 笔记与博客系统
- [ ] **3.3. 博客发布流程 API**:
- [ ] `POST /api/v1/notes/:id/publish`: 从笔记一键发布为博客。 **目标**: 构建平台的内容核心,支持从私有笔记到公开博客的完整流程,并集成搜索和社交功能。
- [ ] 实现博客的 CRUD包括分类管理。
- [ ] **3.4. 公开访问 API**: - [ ] **3.1. 数据库设计**: 设计 `notes`, `posts`, `tags`, `categories`, `comments` 及相关关联表。
- [ ] `GET /api/v1/public/posts`: (公开) 获取博客列表(分页、分类、标签筛选)。 - [ ] **3.2. 笔记核心 API**:
- [ ] `GET /api/v1/public/posts/:slug`: (公开) 获取单篇博客内容。 - [ ] 实现笔记的 CRUD (创建/读取/更新/删除)。
- [ ] **3.5. 互动功能 API**: - [ ] `GET /api/v1/notes`: 获取用户的笔记树形结构列表。
- [ ] 实现博客的点赞、收藏功能。 - [ ] 实现笔记的标签管理功能。
- [ ] 实现层级评论的发表和查看功能。 - [ ] **3.3. 博客发布流程 API**:
- [ ] **3.6. 搜索引擎集成**: - [ ] `POST /api/v1/notes/:id/publish`: 从笔记一键发布为博客。
- [ ] 配置 Elasticsearch 服务。 - [ ] 实现博客的 CRUD包括分类管理。
- [ ] 编写服务将 `posts` 数据索引到 ES。 - [ ] **3.4. 公开访问 API**:
- [ ] `GET /api/v1/public/search`: 实现基于 ES 的博客全文搜索 API。 - [ ] `GET /api/v1/public/posts`: (公开) 获取博客列表(分页、分类、标签筛选)。
- [ ] `GET /api/v1/public/posts/:slug`: (公开) 获取单篇博客内容。
--- - [ ] **3.5. 互动功能 API**:
- [ ] 实现博客的点赞、收藏功能。
## 阶段四: 个人空间 - [ ] 实现层级评论的发表和查看功能。
**目标**: 开发一个用于记录和分享个人生活点滴的模块。 - [ ] **3.6. 搜索引擎集成**:
- [ ] **4.1. 数据库设计**: 设计 `life_logs` 表,支持文本、图片/视频链接、位置、心情等。 - [ ] 配置 Elasticsearch 服务。
- [ ] **4.2. 生活日志 CRUD API**: 实现生活日志的增删改查。 - [ ] 编写服务将 `posts` 数据索引到 ES。
- [ ] **4.3. 时间线 API**: `GET /api/v1/space/timeline` 按时间线获取日志列表。 - [ ] `GET /api/v1/public/search`: 实现基于 ES 的博客全文搜索 API。
- [ ] **4.4. 隐私控制**: 为日志增加隐私设置(公开/私密/好友可见)。
---
---
## 阶段四: 个人空间
## 阶段五: 阅读系统
**目标**: 构建个人电子书库,支持在线阅读、批注和智能解读。 **目标**: 开发一个用于记录和分享个人生活点滴的模块。
- [ ] **5.1. 数据库设计**: 设计 `ebooks`, `annotations` (批注) 表。
- [ ] **5.2. 电子书 API**: 实现电子书文件的上传、元数据解析和列表管理。 - [ ] **4.1. 数据库设计**: 设计 `life_logs` 表,支持文本、图片/视频链接、位置、心情等。
- [ ] **5.3. 阅读功能 API**: 实现阅读时添加/查看批注、记录和同步阅读进度的功能。 - [ ] **4.2. 生活日志 CRUD API**: 实现生活日志的增删改查。
- [ ] **5.4. (选做) AI 解读 API**: `POST /api/v1/ebooks/:id/interpret` 对书籍内容进行摘要或问答。 - [ ] **4.3. 时间线 API**: `GET /api/v1/space/timeline` 按时间线获取日志列表。
- [ ] **4.4. 隐私控制**: 为日志增加隐私设置(公开/私密/好友可见)。
---
---
## 阶段六: 综合工具集
**目标**: 集成一系列实用工具,提升平台的附加价值和用户粘性。 ## 阶段五: 阅读系统
- [ ] **6.1. 密码与书签管理**:
- [ ] 数据库设计 `passwords`, `bookmarks` 表,关键字段加密。 **目标**: 构建个人电子书库,支持在线阅读、批注和智能解读。
- [ ] 实现密码管理器的安全 CRUD API。
- [ ] 实现书签管理器的 CRUD API。 - [ ] **5.1. 数据库设计**: 设计 `ebooks`, `annotations` (批注) 表。
- [ ] **6.2. 文件管理系统**: - [ ] **5.2. 电子书 API**: 实现电子书文件的上传、元数据解析和列表管理。
- [ ] 数据库设计 `files` 表,存储文件元数据。 - [ ] **5.3. 阅读功能 API**: 实现阅读时添加/查看批注、记录和同步阅读进度的功能。
- [ ] 封装一个支持本地/OSS的对象存储服务。 - [ ] **5.4. (选做) AI 解读 API**: `POST /api/v1/ebooks/:id/interpret` 对书籍内容进行摘要或问答。
- [ ] 实现文件的上传、下载、删除、重命名和目录管理 API。
- [ ] **6.3. 第三方平台同步**: ---
- [ ] 设计同步任务管理模块和第三方平台适配器。
- [ ] 实现 Bilibili 收藏夹同步适配器。 ## 阶段六: 综合工具集
- [ ] 实现 QQ音乐/网易云音乐 歌单同步适配器。
- [ ] **6.4. 信息聚合工具**: **目标**: 集成一系列实用工具,提升平台的附加价值和用户粘性。
- [ ] 实现热点新闻捕获和聚合的后台任务。
- [ ] `POST /api/v1/tools/web-to-pdf`: 实现网页离线存储为 PDF 的 API。 - [ ] **6.1. 密码与书签管理**:
- [ ] 数据库设计 `passwords`, `bookmarks` 表,关键字段加密。
--- - [ ] 实现密码管理器的安全 CRUD API。
- [ ] 实现书签管理器的 CRUD API。
## 阶段七: 高级功能与生态完善 - [ ] **6.2. 文件管理系统**:
**目标**: 引入AI、实时通知等高级功能并通过跨平台应用完善生态。最后对系统进行加固和优化确保长期稳定。 - [ ] 数据库设计 `files` 表,存储文件元数据。
- [ ] **7.1. AI 功能深度集成**: - [ ] 封装一个支持本地/OSS的对象存储服务。
- [ ] `POST /api/v1/ai/summary`: 对给定文本生成摘要。 - [ ] 实现文件的上传、下载、删除、重命名和目录管理 API。
- [ ] `POST /api/v1/ai/polish`: 对给定文本进行润色。 - [ ] **6.3. 第三方平台同步**:
- [ ] (高级) 探索将笔记向量化,实现基于个人知识库的智能问答。 - [ ] 设计同步任务管理模块和第三方平台适配器。
- [ ] **7.2. 高级功能与优化**: - [ ] 实现 Bilibili 收藏夹同步适配器。
- [ ] 使用 WebSocket 实现评论、点赞等事件的实时通知。 - [ ] 实现 QQ音乐/网易云音乐 歌单同步适配器。
- [ ] 开发基础的网站访问统计和内容分析功能。 - [ ] **6.4. 信息聚合工具**:
- [ ] 设计并实现基于 OAuth 2.0 的开放 API 体系。 - [ ] 实现热点新闻捕获和聚合的后台任务。
- [ ] **7.3. 跨平台生态**: - [ ] `POST /api/v1/tools/web-to-pdf`: 实现网页离线存储为 PDF 的 API。
- [ ] (前端) 将 Web 应用打包为 PWA。
- [ ] (前端) 开发用于快速保存书签和填充密码的浏览器插件。 ---
- [ ] **7.4. 系统加固与维护**:
- [ ] 引入 CDN 加速静态资源,分析并优化慢查询 SQL。 ## 阶段七: 高级功能与生态完善
- [ ] 定期进行依赖项安全扫描和代码审计。
- [ ] 完善所有功能的开发者文档和用户手册。 **目标**: 引入AI、实时通知等高级功能并通过跨平台应用完善生态。最后对系统进行加固和优化确保长期稳定。
- [ ] **7.1. AI 功能深度集成**:
- [ ] `POST /api/v1/ai/summary`: 对给定文本生成摘要。
- [ ] `POST /api/v1/ai/polish`: 对给定文本进行润色。
- [ ] (高级) 探索将笔记向量化,实现基于个人知识库的智能问答。
- [ ] **7.2. 高级功能与优化**:
- [ ] 使用 WebSocket 实现评论、点赞等事件的实时通知。
- [ ] 开发基础的网站访问统计和内容分析功能。
- [ ] 设计并实现基于 OAuth 2.0 的开放 API 体系。
- [ ] **7.3. 跨平台生态**:
- [ ] (前端) 将 Web 应用打包为 PWA。
- [ ] (前端) 开发用于快速保存书签和填充密码的浏览器插件。
- [ ] **7.4. 系统加固与维护**:
- [ ] 引入 CDN 加速静态资源,分析并优化慢查询 SQL。
- [ ] 定期进行依赖项安全扫描和代码审计。
- [ ] 完善所有功能的开发者文档和用户手册。

View File

@ -1,217 +1,241 @@
# 星撰个人综合平台 - 软件开发概要设计 # 星撰个人综合平台 - 软件开发概要设计
--- ---
## 1. 引言 ## 1. 引言
### 1.1. 文档目的 ### 1.1. 文档目的
本概要设计文档旨在为"星撰"个人综合平台项目定义一个完整的软件开发生命周期SDLC框架。它将作为项目从规划、设计、实现到部署和维护的最高指导性文件确保所有开发活动遵循标准化的软件工程最佳实践。
本概要设计文档旨在为"星撰"个人综合平台项目定义一个完整的软件开发生命周期SDLC框架。它将作为项目从规划、设计、实现到部署和维护的最高指导性文件确保所有开发活动遵循标准化的软件工程最佳实践。
### 1.2. 项目概述
"星撰"是一个高度集成的个人数字化中枢,其核心功能、用户故事及非功能性需求已在《个人网站综合平台产品需求文档 (PRD)》中详细定义。本项目旨在将该PRD转化为一个稳定、可扩展、高质量的软件产品。 ### 1.2. 项目概述
### 1.3. 范围 "星撰"是一个高度集成的个人数字化中枢,其核心功能、用户故事及非功能性需求已在《个人网站综合平台产品需求文档 (PRD)》中详细定义。本项目旨在将该PRD转化为一个稳定、可扩展、高质量的软件产品。
本文档覆盖范围包括:
- 项目开发模型的选定。 ### 1.3. 范围
- 系统的高层体系结构设计。
- 各阶段(设计、开发、测试、部署)的规划和规范。 本文档覆盖范围包括:
- 项目管理、风险控制和质量保证策略。
- 项目开发模型的选定。
--- - 系统的高层体系结构设计。
- 各阶段(设计、开发、测试、部署)的规划和规范。
## 2. 项目规划与管理 - 项目管理、风险控制和质量保证策略。
### 2.1. 开发模型 ---
本项目采用 **迭代式增量开发模型 (Iterative and Incremental Development)**
- **迭代式**: 将开发过程划分为多个短周期的迭代,每个迭代都包含需求分析、设计、编码、测试的完整流程。 ## 2. 项目规划与管理
- **增量式**: 每个迭代都会产出一个可测试、可交付的软件增量。产品功能将分阶段见PRD中的优先级规划逐步构建和完善。
### 2.1. 开发模型
**优势**: 此模型能够快速交付核心功能MVP及时获取反馈灵活应对需求变化并有效控制项目风险。
本项目采用 **迭代式增量开发模型 (Iterative and Incremental Development)**
### 2.2. 项目里程碑
项目将按照PRD中的优先级划分为四个主要里程碑阶段 - **迭代式**: 将开发过程划分为多个短周期的迭代,每个迭代都包含需求分析、设计、编码、测试的完整流程。
- **增量式**: 每个迭代都会产出一个可测试、可交付的软件增量。产品功能将分阶段见PRD中的优先级规划逐步构建和完善。
| 里程碑 | 名称 | 核心交付物 | 目标 |
|--------|--------------|------------------------------------------------------|------------------------------------| **优势**: 此模型能够快速交付核心功能MVP及时获取反馈灵活应对需求变化并有效控制项目风险。
| M1 | **核心基础 (MVP)** | 基础用户系统、笔记核心功能、基础博客发布、CI/CD流程建立 | 验证核心架构,跑通开发部署全流程 |
| M2 | **内容增强** | 完整博客系统、个人空间、基础工具、搜索引擎集成 | 完善内容生态,提升用户体验 | ### 2.2. 项目里程碑
| M3 | **高级功能** | 阅读管理、文件管理、AI功能集成、第三方平台同步 | 扩展平台能力,引入智能化 |
| M4 | **生态完善** | 移动端应用、浏览器插件、高级分析功能、性能深度优化 | 构建跨平台生态,实现长期稳定运营 | 项目将按照PRD中的优先级划分为四个主要里程碑阶段
### 2.3. 风险管理 | 里程碑 | 名称 | 核心交付物 | 目标 |
| 风险类别 | 风险描述 | 可能性 | 影响 | 应对策略 | | ------ | ------------------ | ------------------------------------------------------- | -------------------------------- |
|--------------|----------------------------------------|--------|------|------------------------------------------------------------------------| | M1 | **核心基础 (MVP)** | 基础用户系统、笔记核心功能、基础博客发布、CI/CD流程建立 | 验证核心架构,跑通开发部署全流程 |
| **技术风险** | 第三方API变更如B站、QQ音乐 | 中 | 中 | 封装Adapter层隔离变化设计优雅降级机制。 | | M2 | **内容增强** | 完整博客系统、个人空间、基础工具、搜索引擎集成 | 完善内容生态,提升用户体验 |
| | 新技术引入Bun/Elysia的未知问题 | 低 | 中 | 保持对社区的关注,构建完善的测试体系以快速发现问题。 | | M3 | **高级功能** | 阅读管理、文件管理、AI功能集成、第三方平台同步 | 扩展平台能力,引入智能化 |
| **进度风险** | 个人项目时间投入不稳定 | 高 | 高 | 严格遵循任务清单,将大任务分解为小任务,利用碎片时间完成。 | | M4 | **生态完善** | 移动端应用、浏览器插件、高级分析功能、性能深度优化 | 构建跨平台生态,实现长期稳定运营 |
| **安全风险** | 个人敏感数据(密码、文件)泄露 | 中 | 高 | 强制加密存储;定期进行安全扫描;代码审查时关注安全漏洞。 |
### 2.3. 风险管理
---
| 风险类别 | 风险描述 | 可能性 | 影响 | 应对策略 |
## 3. 系统设计 | ------------ | ---------------------------------- | ------ | ---- | ---------------------------------------------------------- |
| **技术风险** | 第三方API变更如B站、QQ音乐 | 中 | 中 | 封装Adapter层隔离变化设计优雅降级机制。 |
### 3.1. 系统架构 | | 新技术引入Bun/Elysia的未知问题 | 低 | 中 | 保持对社区的关注,构建完善的测试体系以快速发现问题。 |
系统采用分层、模块化的架构,实现前后端分离。 | **进度风险** | 个人项目时间投入不稳定 | 高 | 高 | 严格遵循任务清单,将大任务分解为小任务,利用碎片时间完成。 |
| **安全风险** | 个人敏感数据(密码、文件)泄露 | 中 | 高 | 强制加密存储;定期进行安全扫描;代码审查时关注安全漏洞。 |
```mermaid
graph TD ---
subgraph "客户端 (Clients)"
WebApp[Web 应用 (Vue/React)] ## 3. 系统设计
BrowserExt[浏览器插件]
MobileApp[移动应用 (PWA/Native)] ### 3.1. 系统架构
end
系统采用分层、模块化的架构,实现前后端分离。
subgraph "服务端 (Backend - Elysia.js on Bun)"
API_Gateway[API 网关 / 路由层] ```mermaid
graph TD
subgraph "核心服务层 (Core Services)" subgraph "客户端 (Clients)"
AuthService[认证与授权服务] WebApp[Web 应用 (Vue/React)]
UserService[用户管理服务] BrowserExt[浏览器插件]
ContentService[内容管理服务 (笔记/博客)] MobileApp[移动应用 (PWA/Native)]
FileService[文件管理服务] end
end
subgraph "服务端 (Backend - Elysia.js on Bun)"
subgraph "工具服务层 (Utility Services)" API_Gateway[API 网关 / 路由层]
ToolService[密码/书签/同步等工具服务]
end subgraph "核心服务层 (Core Services)"
AuthService[认证与授权服务]
subgraph "插件与中间件 (Plugins & Middlewares)" UserService[用户管理服务]
Logger[日志插件] ContentService[内容管理服务 (笔记/博客)]
ErrorHandler[错误处理] FileService[文件管理服务]
JWTMiddleware[JWT认证] end
Swagger[Swagger文档]
end subgraph "工具服务层 (Utility Services)"
end ToolService[密码/书签/同步等工具服务]
end
subgraph "数据与存储层 (Data & Storage)"
MySQL[(MySQL - 关系型数据)] subgraph "插件与中间件 (Plugins & Middlewares)"
Redis[(Redis - 缓存/会话)] Logger[日志插件]
Elasticsearch[(Elasticsearch - 全文搜索)] ErrorHandler[错误处理]
ObjectStorage[对象存储 (OSS/NAS)] JWTMiddleware[JWT认证]
end Swagger[Swagger文档]
end
subgraph "第三方服务 (3rd Party Services)" end
EmailService[邮件服务]
OAuth[OAuth 2.0 提供商] subgraph "数据与存储层 (Data & Storage)"
AI_API[AI/LLM API] MySQL[(MySQL - 关系型数据)]
end Redis[(Redis - 缓存/会话)]
Elasticsearch[(Elasticsearch - 全文搜索)]
%% 连接关系 ObjectStorage[对象存储 (OSS/NAS)]
WebApp --> API_Gateway end
BrowserExt --> API_Gateway
MobileApp --> API_Gateway subgraph "第三方服务 (3rd Party Services)"
EmailService[邮件服务]
API_Gateway --> AuthService OAuth[OAuth 2.0 提供商]
API_Gateway --> UserService AI_API[AI/LLM API]
API_Gateway --> ContentService end
API_Gateway --> FileService
API_Gateway --> ToolService %% 连接关系
WebApp --> API_Gateway
AuthService <--> MySQL BrowserExt --> API_Gateway
UserService <--> MySQL MobileApp --> API_Gateway
ContentService <--> MySQL
ContentService <--> Elasticsearch API_Gateway --> AuthService
FileService <--> ObjectStorage API_Gateway --> UserService
ToolService <--> MySQL API_Gateway --> ContentService
API_Gateway --> FileService
AuthService --> JWTMiddleware API_Gateway --> ToolService
API_Gateway -- use --> Logger
API_Gateway -- use --> ErrorHandler AuthService <--> MySQL
API_Gateway -- use --> JWTMiddleware UserService <--> MySQL
API_Gateway -- use --> Swagger ContentService <--> MySQL
ContentService <--> Elasticsearch
subgraph "通用依赖" FileService <--> ObjectStorage
AuthService <--> Redis ToolService <--> MySQL
UserService <--> Redis
ContentService <--> Redis AuthService --> JWTMiddleware
end API_Gateway -- use --> Logger
API_Gateway -- use --> ErrorHandler
AuthService --> EmailService API_Gateway -- use --> JWTMiddleware
AuthService --> OAuth API_Gateway -- use --> Swagger
ContentService --> AI_API
``` subgraph "通用依赖"
* **客户端层**: 负责用户交互和数据展示。 AuthService <--> Redis
* **服务端层**: 核心业务逻辑处理中心。通过API网关统一入口内部按业务垂直划分为多个服务模块。 UserService <--> Redis
* **数据与存储层**: 持久化存储数据,提供缓存和搜索能力。 ContentService <--> Redis
* **第三方服务**: 集成外部服务以完成特定功能。 end
### 3.2. 模块划分 AuthService --> EmailService
系统功能将按以下模块进行开发,每个模块对应`src/controllers`, `src/services`下的独立子目录。 AuthService --> OAuth
1. **`core`**: 核心模块,包括用户、角色、权限、组织、字典等。 ContentService --> AI_API
2. **`content`**: 内容模块,包括笔记、博客、评论、分类、标签等。 ```
3. **`space`**: 个人空间模块,包括生活日志、社交互动。
4. **`reader`**: 阅读模块,包括电子书管理、批注。 - **客户端层**: 负责用户交互和数据展示。
5. **`tools`**: 工具模块,包括密码、书签、文件、同步等。 - **服务端层**: 核心业务逻辑处理中心。通过API网关统一入口内部按业务垂直划分为多个服务模块。
6. **`system`**: 系统模块,包括健康检查、监控、配置管理。 - **数据与存储层**: 持久化存储数据,提供缓存和搜索能力。
- **第三方服务**: 集成外部服务以完成特定功能。
### 3.3. 详细设计规划
本概要设计完成后,将对每个模块进行详细设计,产出物包括: ### 3.2. 模块划分
- **数据库设计文档**: E-R图、表结构定义字段、类型、约束、索引、数据字典。
- **API接口设计文档**: 使用Swagger/OpenAPI规范定义每个端点的URL、HTTP方法、请求参数、响应体结构及错误码。 系统功能将按以下模块进行开发,每个模块对应`src/controllers`, `src/services`下的独立子目录。
- **UI/UX设计稿**: 针对核心页面和流程,制作线框图或高保真原型。
1. **`core`**: 核心模块,包括用户、角色、权限、组织、字典等。
--- 2. **`content`**: 内容模块,包括笔记、博客、评论、分类、标签等。
3. **`space`**: 个人空间模块,包括生活日志、社交互动。
## 4. 实现与编码 4. **`reader`**: 阅读模块,包括电子书管理、批注。
5. **`tools`**: 工具模块,包括密码、书签、文件、同步等。
### 4.1. 开发环境与工具 6. **`system`**: 系统模块,包括健康检查、监控、配置管理。
- **IDE**: Visual Studio Code
- **语言**: TypeScript ### 3.3. 详细设计规划
- **运行时**: Bun.js
- **包管理器**: Bun 本概要设计完成后,将对每个模块进行详细设计,产出物包括:
- **版本控制**: Git / GitHub
- **代码检查与格式化**: ESLint / Prettier - **数据库设计文档**: E-R图、表结构定义字段、类型、约束、索引、数据字典。
- **API接口设计文档**: 使用Swagger/OpenAPI规范定义每个端点的URL、HTTP方法、请求参数、响应体结构及错误码。
### 4.2. 编码规范 - **UI/UX设计稿**: 针对核心页面和流程,制作线框图或高保真原型。
- 严格遵守项目已定义的注释、命名、代码风格规范。
- 所有业务逻辑必须在 `services` 层实现,`controllers` 层只负责路由和数据校验。 ---
- 禁止在代码中硬编码配置项(如密钥、端口),所有配置通过环境变量或配置文件注入。
## 4. 实现与编码
### 4.3. 版本控制策略
采用 **Gitflow工作流** 的简化版: ### 4.1. 开发环境与工具
- `main`: 主分支,存放稳定、可发布的代码。每个里程碑完成后合并。
- `develop`: 开发分支,集成了所有已完成的功能。 - **IDE**: Visual Studio Code
- `feat/feature-name`: 功能分支,每个新功能或任务都在此分支开发,完成后合并到 `develop` - **语言**: TypeScript
- `fix/bug-name`: Bug修复分支。 - **运行时**: Bun.js
- **包管理器**: Bun
--- - **版本控制**: Git / GitHub
- **代码检查与格式化**: ESLint / Prettier
## 5. 测试策略
### 4.2. 编码规范
### 5.1. 测试层次
- **单元测试**: 使用 `Vitest``services``utils` 中的核心函数进行测试,目标覆盖率 > 90%。 - 严格遵守项目已定义的注释、命名、代码风格规范。
- **接口测试 (集成测试)**: 使用 `supertest` 或Elysia的内置测试工具对每个API端点进行测试验证其与数据库、缓存的交互是否正确。 - 所有业务逻辑必须在 `services` 层实现,`controllers` 层只负责路由和数据校验。
- **端到端测试 (系统测试)**: 使用 `Playwright``Cypress` 对关键用户流程(如注册登录、发布博客)进行自动化测试。 - 禁止在代码中硬编码配置项(如密钥、端口),所有配置通过环境变量或配置文件注入。
- **性能测试**: 使用 `autocannon` 对核心API进行压力测试确保其满足技术指标。
### 4.3. 版本控制策略
### 5.2. 测试执行
- 所有代码提交前,必须在本地通过相关单元测试和接口测试。 采用 **Gitflow工作流** 的简化版:
- CI/CD流水线将在每次向 `develop``main` 分支合并代码时,自动运行全量测试用例。测试不通过则禁止合并。
- `main`: 主分支,存放稳定、可发布的代码。每个里程碑完成后合并。
--- - `develop`: 开发分支,集成了所有已完成的功能。
- `feat/feature-name`: 功能分支,每个新功能或任务都在此分支开发,完成后合并到 `develop`
## 6. 部署与运维 - `fix/bug-name`: Bug修复分支。
### 6.1. 部署流程 (CI/CD) ---
使用 **GitHub Actions** 自动化构建、测试和部署流程。
1. **Push to `feat/*`**: 触发ESLint检查和单元测试。 ## 5. 测试策略
2. **Merge to `develop`**: 触发全量测试构建Docker镜像并推送到镜像仓库然后部署到 **Staging (预发布)** 环境。
3. **Merge to `main`**: 触发全量测试构建生产Docker镜像并部署到 **Production (生产)** 环境。 ### 5.1. 测试层次
### 6.2. 基础设施 - **单元测试**: 使用 `Vitest``services``utils` 中的核心函数进行测试,目标覆盖率 > 90%。
- **运行环境**: Docker容器。 - **接口测试 (集成测试)**: 使用 `supertest` 或Elysia的内置测试工具对每个API端点进行测试验证其与数据库、缓存的交互是否正确。
- **容器编排**: Docker Compose (初期) / Kubernetes (未来扩展)。 - **端到端测试 (系统测试)**: 使用 `Playwright``Cypress` 对关键用户流程(如注册登录、发布博客)进行自动化测试。
- **数据库/缓存**: 使用云服务商提供的托管服务或在Docker中自建。 - **性能测试**: 使用 `autocannon` 对核心API进行压力测试确保其满足技术指标。
### 6.3. 运维监控 ### 5.2. 测试执行
- **日志**: 使用 `pino` 记录结构化日志并聚合到统一的日志管理平台如ELK Stack或Loki
- **监控与告警**: 使用 `Prometheus` 收集应用指标,`Grafana` 进行可视化展示并针对关键指标如API错误率、延迟设置告警。 - 所有代码提交前,必须在本地通过相关单元测试和接口测试。
- CI/CD流水线将在每次向 `develop``main` 分支合并代码时,自动运行全量测试用例。测试不通过则禁止合并。
---
---
## 7. 维护与演进
## 6. 部署与运维
- **问题跟踪**: 使用GitHub Issues跟踪Bug和功能请求。
- **文档同步**: 所有代码变更若涉及架构、API或数据库设计必须同步更新相关设计文档。 ### 6.1. 部署流程 (CI/CD)
- **定期审查**: 每个里程碑结束后,进行项目复盘,评估开发流程、代码质量和系统架构,并进行优化。
- **依赖更新**: 定期(如每季度)审查并更新项目依赖,以修复潜在的安全漏洞和利用新特性。 使用 **GitHub Actions** 自动化构建、测试和部署流程。
1. **Push to `feat/*`**: 触发ESLint检查和单元测试。
2. **Merge to `develop`**: 触发全量测试构建Docker镜像并推送到镜像仓库然后部署到 **Staging (预发布)** 环境。
3. **Merge to `main`**: 触发全量测试构建生产Docker镜像并部署到 **Production (生产)** 环境。
### 6.2. 基础设施
- **运行环境**: Docker容器。
- **容器编排**: Docker Compose (初期) / Kubernetes (未来扩展)。
- **数据库/缓存**: 使用云服务商提供的托管服务或在Docker中自建。
### 6.3. 运维监控
- **日志**: 使用 `pino` 记录结构化日志并聚合到统一的日志管理平台如ELK Stack或Loki
- **监控与告警**: 使用 `Prometheus` 收集应用指标,`Grafana` 进行可视化展示并针对关键指标如API错误率、延迟设置告警。
---
## 7. 维护与演进
- **问题跟踪**: 使用GitHub Issues跟踪Bug和功能请求。
- **文档同步**: 所有代码变更若涉及架构、API或数据库设计必须同步更新相关设计文档。
- **定期审查**: 每个里程碑结束后,进行项目复盘,评估开发流程、代码质量和系统架构,并进行优化。
- **依赖更新**: 定期(如每季度)审查并更新项目依赖,以修复潜在的安全漏洞和利用新特性。

View File

@ -1,7 +1,9 @@
# QQ邮箱配置指南 # QQ邮箱配置指南
## 🚨 问题原因 ## 🚨 问题原因
错误 `550 The "From" header is missing or invalid` 是因为QQ邮箱要求 错误 `550 The "From" header is missing or invalid` 是因为QQ邮箱要求
1. **From字段的邮箱地址必须与SMTP认证用户名完全一致** 1. **From字段的邮箱地址必须与SMTP认证用户名完全一致**
2. **From字段格式必须符合RFC5322标准** 2. **From字段格式必须符合RFC5322标准**
@ -41,6 +43,7 @@ EMAIL_REPLY_TO=your_qq_email@qq.com
### 第三步:替换为你的真实信息 ### 第三步:替换为你的真实信息
**示例配置:** **示例配置:**
```env ```env
SMTP_HOST=smtp.qq.com SMTP_HOST=smtp.qq.com
SMTP_PORT=587 SMTP_PORT=587
@ -73,16 +76,21 @@ bun run src/tests/demo/emailDemo.ts your_receive_email@example.com
## 🔧 常见问题 ## 🔧 常见问题
### Q: 为什么要用授权码而不是QQ密码 ### Q: 为什么要用授权码而不是QQ密码
A: QQ邮箱的安全策略第三方应用必须使用授权码 A: QQ邮箱的安全策略第三方应用必须使用授权码
### Q: 授权码在哪里生成? ### Q: 授权码在哪里生成?
A: QQ邮箱设置 → 账户 → 开启IMAP/SMTP服务时生成 A: QQ邮箱设置 → 账户 → 开启IMAP/SMTP服务时生成
### Q: 为什么From地址必须与SMTP_USER一致 ### Q: 为什么From地址必须与SMTP_USER一致
A: QQ邮箱的反欺诈机制防止伪造发件人 A: QQ邮箱的反欺诈机制防止伪造发件人
### Q: 可以使用其他邮箱服务吗? ### Q: 可以使用其他邮箱服务吗?
A: 可以修改SMTP_HOST即可 A: 可以修改SMTP_HOST即可
- 163邮箱`smtp.163.com` - 163邮箱`smtp.163.com`
- Gmail`smtp.gmail.com` - Gmail`smtp.gmail.com`
- 企业邮箱:根据提供商配置 - 企业邮箱:根据提供商配置
@ -101,4 +109,4 @@ SMTP_PASS=替换为你的16位授权码
SMTP_FROM_EMAIL=替换为你的QQ邮箱@qq.com SMTP_FROM_EMAIL=替换为你的QQ邮箱@qq.com
SMTP_FROM_NAME=星撰系统 SMTP_FROM_NAME=星撰系统
EMAIL_REPLY_TO=替换为你的QQ邮箱@qq.com EMAIL_REPLY_TO=替换为你的QQ邮箱@qq.com
``` ```

View File

@ -3,15 +3,11 @@
* 运行方式: bun run quick-email-test.ts your@email.com * 运行方式: bun run quick-email-test.ts your@email.com
*/ */
import { import { initializeEmailService, sendEmail, closeEmailService } from './src/plugins/email/email.service';
initializeEmailService,
sendEmail,
closeEmailService
} from './src/plugins/email/email.service';
async function quickTest() { async function quickTest() {
const testEmail = process.argv[2]; const testEmail = process.argv[2];
if (!testEmail) { if (!testEmail) {
console.log('❌ 请提供邮箱地址'); console.log('❌ 请提供邮箱地址');
console.log('💡 使用方法: bun run quick-email-test.ts your@email.com'); console.log('💡 使用方法: bun run quick-email-test.ts your@email.com');
@ -36,7 +32,7 @@ async function quickTest() {
<h2>🎉 </h2> <h2>🎉 </h2>
<p></p> <p></p>
<p><small>发送时间: ${new Date().toLocaleString('zh-CN')}</small></p> <p><small>发送时间: ${new Date().toLocaleString('zh-CN')}</small></p>
` `,
}); });
if (result.success) { if (result.success) {
@ -47,7 +43,6 @@ async function quickTest() {
console.log('❌ 邮件发送失败'); console.log('❌ 邮件发送失败');
console.log(`💥 错误: ${result.error}`); console.log(`💥 错误: ${result.error}`);
} }
} catch (error) { } catch (error) {
console.log('💥 执行失败:', error); console.log('💥 执行失败:', error);
} finally { } finally {
@ -56,4 +51,4 @@ async function quickTest() {
} }
} }
quickTest(); quickTest();

View File

@ -18,20 +18,6 @@ import { plugins } from '@/plugins/index';
// 格式化路由 // 格式化路由
import { formatRoute } from '@/utils/formatRoute'; import { formatRoute } from '@/utils/formatRoute';
/**
*
* @description
*/
class AuthenticationError extends Error {
constructor(message: string, code = 500) {
super(message);
this.name = 'AuthenticationError';
if (code === 401) {
this.name = 'Unauthorized';
}
}
}
/** /**
* Elysia应用实例 * Elysia应用实例
* @description * @description

View File

@ -127,4 +127,4 @@ export const getEmailConfig = () => ({
email: emailConfig, email: emailConfig,
templates: emailTemplates, templates: emailTemplates,
options: emailOptions, options: emailOptions,
}); });

View File

@ -7,7 +7,6 @@
* @description JWT密钥和过期时间token配置 * @description JWT密钥和过期时间token配置
*/ */
/** /**
* JWT基础配置 * JWT基础配置
* @property {string} secret - JWT签名密钥 * @property {string} secret - JWT签名密钥
@ -21,4 +20,4 @@ export const jwtConfig = {
issuer: process.env.JWT_ISSUER || 'elysia-api', issuer: process.env.JWT_ISSUER || 'elysia-api',
/** JWT受众 */ /** JWT受众 */
audience: process.env.JWT_AUDIENCE || 'web-client', audience: process.env.JWT_AUDIENCE || 'web-client',
}; };

View File

@ -1,45 +1,45 @@
/** /**
* @file Redis数据库配置 * @file Redis数据库配置
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description Redis连接配置 * @description Redis连接配置
*/ */
/** /**
* Redis数据库连接配置 * Redis数据库连接配置
* @property {string} connectName - Redis连接名称 * @property {string} connectName - Redis连接名称
* @property {string} host - Redis服务器主机地址 * @property {string} host - Redis服务器主机地址
* @property {number} port - Redis服务器端口号 * @property {number} port - Redis服务器端口号
* @property {string} username - Redis用户名 * @property {string} username - Redis用户名
* @property {string} password - Redis密码 * @property {string} password - Redis密码
* @property {number} database - Redis数据库索引 * @property {number} database - Redis数据库索引
* @property {string} url - Redis连接URL * @property {string} url - Redis连接URL
*/ */
export const redisConfig = { export const redisConfig = {
/** Redis连接名称 */ /** Redis连接名称 */
connectName: process.env.REDIS_CONNECT_NAME || 'cursor-init-redis', connectName: process.env.REDIS_CONNECT_NAME || 'cursor-init-redis',
/** Redis服务器主机地址 */ /** Redis服务器主机地址 */
host: process.env.REDIS_HOST || 'uair.cc', host: process.env.REDIS_HOST || 'uair.cc',
/** Redis服务器端口号 */ /** Redis服务器端口号 */
port: Number(process.env.REDIS_PORT) || 6379, port: Number(process.env.REDIS_PORT) || 6379,
/** Redis用户名 */ /** Redis用户名 */
username: process.env.REDIS_USERNAME || 'default', username: process.env.REDIS_USERNAME || 'default',
/** Redis密码 */ /** Redis密码 */
password: process.env.REDIS_PASSWORD || 'nie', password: process.env.REDIS_PASSWORD || 'nie',
/** Redis数据库索引 */ /** Redis数据库索引 */
database: Number(process.env.REDIS_DATABASE) || 0, database: Number(process.env.REDIS_DATABASE) || 0,
}; };
/** /**
* Redis连接URL * Redis连接URL
* @returns Redis连接URL字符串 * @returns Redis连接URL字符串
*/ */
export const getRedisUrl = (): string => { export const getRedisUrl = (): string => {
const { username, password, host, port, database } = redisConfig; const { username, password, host, port, database } = redisConfig;
if (username && password) { if (username && password) {
return `redis://${username}:${password}@${host}:${port}/${database}`; return `redis://${username}:${password}@${host}:${port}/${database}`;
} }
return `redis://${host}:${port}/${database}`; return `redis://${host}:${port}/${database}`;
}; };

View File

@ -28,9 +28,11 @@ export const tags = {
permission: 'Permission', permission: 'Permission',
/** 验证码相关接口 */ /** 验证码相关接口 */
captcha: 'Captcha', captcha: 'Captcha',
/** 字典管理接口 */
dict: 'Dict',
} as const; } as const;
/** /**
* *
*/ */
export type ApiTag = typeof tags[keyof typeof tags]; export type ApiTag = (typeof tags)[keyof typeof tags];

View File

@ -14,14 +14,16 @@
import { customType } from 'drizzle-orm/mysql-core'; import { customType } from 'drizzle-orm/mysql-core';
// 写入读取是将bigint转化为string // 写入读取是将bigint转化为string
export const bigintString = customType({ export const bigintString = customType<{ data: string; driverData: bigint }>({
dataType() { dataType() {
return 'bigint'; return 'bigint';
}, },
fromDriver(value) { // 数据库 -> JS fromDriver(value) {
return value?.toString(); // 处理 null 值 // 数据库 -> JS
return value?.toString();
}, },
toDriver(value) { // JS -> 数据库 toDriver(value) {
// JS -> 数据库
return BigInt(value as string); // 确保写入时为数字类型 return BigInt(value as string); // 确保写入时为数字类型
} },
}); });

View File

@ -1,317 +1,357 @@
import { mysqlTable, mysqlSchema, AnyMySqlColumn, index, primaryKey, unique, varchar, text, int, json, datetime, tinyint, date } from "drizzle-orm/mysql-core" import {
import { sql } from "drizzle-orm" mysqlTable,
import { bigintString as bigint } from "./customType" index,
primaryKey,
unique,
varchar,
int,
json,
timestamp,
text,
datetime,
tinyint,
date,
} from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
import { bigintString as bigint } from './customType';
export const sysDictItems = mysqlTable("sys_dict_items", { export const sysDict = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_dict',
typeId: bigint("type_id", { mode: "number" }).notNull(), {
itemKey: varchar("item_key", { length: 50 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
itemValue: varchar("item_value", { length: 200 }).notNull(), code: varchar({ length: 50 }).notNull(),
label: varchar({ length: 100 }).notNull(), name: varchar({ length: 100 }).notNull(),
labelEn: varchar("label_en", { length: 200 }), value: varchar({ length: 200 }),
description: text(), description: varchar({ length: 500 }),
pid: bigint({ mode: "number" }), icon: varchar({ length: 100 }),
path: varchar({ length: 500 }), pid: bigint({ mode: 'number' }),
level: int().default(1).notNull(), level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(), sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), status: varchar({ length: 20 }).default('active').notNull(),
cssClass: varchar("css_class", { length: 50 }), isSystem: tinyint('is_system').default(0).notNull(),
color: varchar({ length: 50 }), color: varchar({ length: 20 }),
extra: json(), extra: json(),
createdBy: bigint("created_by", { mode: "number" }), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().onUpdateNow().notNull(),
updatedBy: bigint("updated_by", { mode: "number" }), },
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), (table) => [
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_level').on(table.level),
}, index('idx_pid').on(table.pid),
(table) => [ index('idx_sort').on(table.sortOrder),
index("idx_deleted_at").on(table.deletedAt), index('idx_status').on(table.status),
index("idx_key").on(table.itemKey), primaryKey({ columns: [table.id], name: 'sys_dict_id' }),
index("idx_pid").on(table.pid), unique('uk_code').on(table.code),
index("idx_sort").on(table.typeId, table.sortOrder), ],
index("idx_status").on(table.status), );
index("idx_type_id").on(table.typeId),
primaryKey({ columns: [table.id], name: "sys_dict_items_id"}),
unique("uk_type_key").on(table.typeId, table.itemKey, table.deletedAt),
]);
export const sysDictTypes = mysqlTable("sys_dict_types", { export const sysOperationLogs = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_operation_logs',
code: varchar({ length: 50 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
description: text(), userId: bigint('user_id', { mode: 'number' }),
pid: bigint({ mode: "number" }), username: varchar({ length: 100 }),
path: varchar({ length: 500 }), module: varchar({ length: 50 }).notNull(),
level: int().default(1).notNull(), action: varchar({ length: 50 }).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), target: varchar({ length: 200 }),
isSystem: tinyint("is_system").default(0).notNull(), targetId: bigint('target_id', { mode: 'number' }),
sortOrder: int("sort_order").default(0).notNull(), requestData: text('request_data'),
createdBy: bigint("created_by", { mode: "number" }), responseData: text('response_data'),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), status: varchar({ length: 20 }).notNull(),
updatedBy: bigint("updated_by", { mode: "number" }), ip: varchar({ length: 45 }),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), userAgent: varchar('user_agent', { length: 200 }),
deletedAt: datetime("deleted_at", { mode: 'string'}), duration: bigint({ mode: 'number' }),
}, errorMsg: text('error_msg'),
(table) => [ createdAt: datetime('created_at', { mode: 'string' })
index("idx_deleted_at").on(table.deletedAt), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_is_system").on(table.isSystem), .notNull(),
index("idx_name").on(table.name), },
index("idx_path").on(table.path), (table) => [
index("idx_pid").on(table.pid), index('idx_created_at').on(table.createdAt),
index("idx_sort").on(table.pid, table.sortOrder), index('idx_ip').on(table.ip),
index("idx_status").on(table.status), index('idx_module_action').on(table.module, table.action),
primaryKey({ columns: [table.id], name: "sys_dict_types_id"}), index('idx_status').on(table.status),
unique("uk_code").on(table.code, table.deletedAt), index('idx_target').on(table.targetId),
]); index('idx_user_id').on(table.userId),
primaryKey({ columns: [table.id], name: 'sys_operation_logs_id' }),
],
);
export const sysOperationLogs = mysqlTable("sys_operation_logs", { export const sysOrganizations = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_organizations',
userId: bigint("user_id", { mode: "number" }), {
username: varchar({ length: 100 }), id: bigint({ mode: 'number' }).notNull(),
module: varchar({ length: 50 }).notNull(), code: varchar({ length: 100 }).notNull(),
action: varchar({ length: 50 }).notNull(), name: varchar({ length: 200 }).notNull(),
target: varchar({ length: 200 }), fullName: varchar('full_name', { length: 200 }),
targetId: bigint("target_id", { mode: "number" }), description: text(),
requestData: text("request_data"), pid: bigint({ mode: 'number' }),
responseData: text("response_data"), path: varchar({ length: 500 }),
status: varchar({ length: 20 }).notNull(), level: int().default(1).notNull(),
ip: varchar({ length: 45 }), type: varchar({ length: 20 }),
userAgent: varchar("user_agent", { length: 200 }), status: varchar({ length: 20 }).default('active').notNull(),
duration: bigint({ mode: "number" }), sortOrder: int('sort_order').default(0).notNull(),
errorMsg: text("error_msg"), leaderId: bigint('leader_id', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), address: varchar({ length: 200 }),
}, phone: varchar({ length: 50 }),
(table) => [ extra: json(),
index("idx_created_at").on(table.createdAt), createdBy: bigint('created_by', { mode: 'number' }),
index("idx_ip").on(table.ip), createdAt: datetime('created_at', { mode: 'string' })
index("idx_module_action").on(table.module, table.action), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_status").on(table.status), .notNull(),
index("idx_target").on(table.targetId), updatedBy: bigint('updated_by', { mode: 'number' }),
index("idx_user_id").on(table.userId), updatedAt: datetime('updated_at', { mode: 'string' })
primaryKey({ columns: [table.id], name: "sys_operation_logs_id"}), .default(sql`(CURRENT_TIMESTAMP)`)
]); .notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
version: int().default(1).notNull(),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_leader_id').on(table.leaderId),
index('idx_name').on(table.name),
index('idx_path').on(table.path),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.pid, table.sortOrder),
index('idx_status').on(table.status),
index('idx_type').on(table.type),
primaryKey({ columns: [table.id], name: 'sys_organizations_id' }),
unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysOrganizations = mysqlTable("sys_organizations", { export const sysPermissions = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_permissions',
code: varchar({ length: 100 }).notNull(), {
name: varchar({ length: 200 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
fullName: varchar("full_name", { length: 200 }), code: varchar({ length: 100 }).notNull(),
description: text(), name: varchar({ length: 100 }).notNull(),
pid: bigint({ mode: "number" }), type: varchar({ length: 20 }).notNull(),
path: varchar({ length: 500 }), resource: varchar({ length: 50 }),
level: int().default(1).notNull(), action: varchar({ length: 50 }),
type: varchar({ length: 20 }), description: text(),
status: varchar({ length: 20 }).default('active').notNull(), pid: bigint({ mode: 'number' }),
sortOrder: int("sort_order").default(0).notNull(), path: varchar({ length: 500 }),
leaderId: bigint("leader_id", { mode: "number" }), level: int().default(1).notNull(),
address: varchar({ length: 200 }), sortOrder: int('sort_order').default(0).notNull(),
phone: varchar({ length: 50 }), status: varchar({ length: 20 }).default('active').notNull(),
extra: json(), meta: json(),
createdBy: bigint("created_by", { mode: "number" }), createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdAt: datetime('created_at', { mode: 'string' })
updatedBy: bigint("updated_by", { mode: "number" }), .default(sql`(CURRENT_TIMESTAMP)`)
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), .notNull(),
deletedAt: datetime("deleted_at", { mode: 'string'}), updatedBy: bigint('updated_by', { mode: 'number' }),
version: int().default(1).notNull(), updatedAt: datetime('updated_at', { mode: 'string' })
}, .default(sql`(CURRENT_TIMESTAMP)`)
(table) => [ .notNull(),
index("idx_deleted_at").on(table.deletedAt), deletedAt: datetime('deleted_at', { mode: 'string' }),
index("idx_leader_id").on(table.leaderId), },
index("idx_name").on(table.name), (table) => [
index("idx_path").on(table.path), index('idx_deleted_at').on(table.deletedAt),
index("idx_pid").on(table.pid), index('idx_pid').on(table.pid),
index("idx_sort").on(table.pid, table.sortOrder), index('idx_resource_action').on(table.resource, table.action),
index("idx_status").on(table.status), index('idx_sort').on(table.pid, table.sortOrder),
index("idx_type").on(table.type), index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: "sys_organizations_id"}), index('idx_type').on(table.type),
unique("uk_code").on(table.code, table.deletedAt), primaryKey({ columns: [table.id], name: 'sys_permissions_id' }),
]); unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysPermissions = mysqlTable("sys_permissions", { export const sysRolePermissions = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_role_permissions',
code: varchar({ length: 100 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
type: varchar({ length: 20 }).notNull(), roleId: bigint('role_id', { mode: 'number' }).notNull(),
resource: varchar({ length: 50 }), permissionId: bigint('permission_id', { mode: 'number' }).notNull(),
action: varchar({ length: 50 }), isHalf: tinyint('is_half').default(0).notNull(),
description: text(), createdBy: bigint('created_by', { mode: 'number' }),
pid: bigint({ mode: "number" }), createdAt: datetime('created_at', { mode: 'string' })
path: varchar({ length: 500 }), .default(sql`(CURRENT_TIMESTAMP)`)
level: int().default(1).notNull(), .notNull(),
sortOrder: int("sort_order").default(0).notNull(), },
status: varchar({ length: 20 }).default('active').notNull(), (table) => [
meta: json(), index('idx_is_half').on(table.isHalf),
createdBy: bigint("created_by", { mode: "number" }), index('idx_permission_id').on(table.permissionId),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_role_id').on(table.roleId),
updatedBy: bigint("updated_by", { mode: "number" }), primaryKey({ columns: [table.id], name: 'sys_role_permissions_id' }),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), unique('uk_role_permission').on(table.roleId, table.permissionId),
deletedAt: datetime("deleted_at", { mode: 'string'}), ],
}, );
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_pid").on(table.pid),
index("idx_resource_action").on(table.resource, table.action),
index("idx_sort").on(table.pid, table.sortOrder),
index("idx_status").on(table.status),
index("idx_type").on(table.type),
primaryKey({ columns: [table.id], name: "sys_permissions_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysRolePermissions = mysqlTable("sys_role_permissions", { export const sysRoles = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_roles',
roleId: bigint("role_id", { mode: "number" }).notNull(), {
permissionId: bigint("permission_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
isHalf: tinyint("is_half").default(0).notNull(), code: varchar({ length: 50 }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), name: varchar({ length: 100 }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), description: text(),
}, pid: bigint({ mode: 'number' }),
(table) => [ path: varchar({ length: 500 }),
index("idx_is_half").on(table.isHalf), level: int().default(1).notNull(),
index("idx_permission_id").on(table.permissionId), sortOrder: int('sort_order').default(0).notNull(),
index("idx_role_id").on(table.roleId), status: varchar({ length: 20 }).default('active').notNull(),
primaryKey({ columns: [table.id], name: "sys_role_permissions_id"}), isSystem: tinyint('is_system').default(0).notNull(),
unique("uk_role_permission").on(table.roleId, table.permissionId), permissionsSnapshot: json('permissions_snapshot'),
]); extra: json(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
updatedBy: bigint('updated_by', { mode: 'number' }),
updatedAt: datetime('updated_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
version: int().default(1).notNull(),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_is_system').on(table.isSystem),
index('idx_name').on(table.name),
index('idx_path').on(table.path),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.pid, table.sortOrder),
index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: 'sys_roles_id' }),
unique('uk_code').on(table.code, table.deletedAt),
],
);
export const sysRoles = mysqlTable("sys_roles", { export const sysTags = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_tags',
code: varchar({ length: 50 }).notNull(), {
name: varchar({ length: 100 }).notNull(), id: bigint({ mode: 'number' }).notNull(),
description: text(), name: varchar({ length: 50 }).notNull(),
pid: bigint({ mode: "number" }), type: varchar({ length: 50 }).default('user'),
path: varchar({ length: 500 }), color: varchar({ length: 50 }),
level: int().default(1).notNull(), description: text(),
sortOrder: int("sort_order").default(0).notNull(), usageCount: int('usage_count').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(), createdBy: bigint('created_by', { mode: 'number' }),
isSystem: tinyint("is_system").default(0).notNull(), createdAt: datetime('created_at', { mode: 'string' })
permissionsSnapshot: json("permissions_snapshot"), .default(sql`(CURRENT_TIMESTAMP)`)
extra: json(), .notNull(),
createdBy: bigint("created_by", { mode: "number" }), deletedAt: datetime('deleted_at', { mode: 'string' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), },
updatedBy: bigint("updated_by", { mode: "number" }), (table) => [
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_deleted_at').on(table.deletedAt),
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_name').on(table.name),
version: int().default(1).notNull(), index('idx_type').on(table.type),
}, index('idx_usage_count').on(table.usageCount),
(table) => [ primaryKey({ columns: [table.id], name: 'sys_tags_id' }),
index("idx_deleted_at").on(table.deletedAt), unique('uk_name_type').on(table.name, table.type, table.deletedAt),
index("idx_is_system").on(table.isSystem), ],
index("idx_name").on(table.name), );
index("idx_path").on(table.path),
index("idx_pid").on(table.pid),
index("idx_sort").on(table.pid, table.sortOrder),
index("idx_status").on(table.status),
primaryKey({ columns: [table.id], name: "sys_roles_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysTags = mysqlTable("sys_tags", { export const sysUserOrganizations = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_organizations',
name: varchar({ length: 50 }).notNull(), {
type: varchar({ length: 50 }).default('user'), id: bigint({ mode: 'number' }).notNull(),
color: varchar({ length: 50 }), userId: bigint('user_id', { mode: 'number' }).notNull(),
description: text(), organizationId: bigint('organization_id', { mode: 'number' }).notNull(),
usageCount: int("usage_count").default(0).notNull(), isPrimary: tinyint('is_primary').default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }), position: varchar({ length: 100 }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), joinedAt: datetime('joined_at', { mode: 'string' })
deletedAt: datetime("deleted_at", { mode: 'string'}), .default(sql`(CURRENT_TIMESTAMP)`)
}, .notNull(),
(table) => [ createdBy: bigint('created_by', { mode: 'number' }),
index("idx_deleted_at").on(table.deletedAt), createdAt: datetime('created_at', { mode: 'string' })
index("idx_name").on(table.name), .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_type").on(table.type), .notNull(),
index("idx_usage_count").on(table.usageCount), },
primaryKey({ columns: [table.id], name: "sys_tags_id"}), (table) => [
unique("uk_name_type").on(table.name, table.type, table.deletedAt), index('idx_is_primary').on(table.isPrimary),
]); index('idx_joined_at').on(table.joinedAt),
index('idx_organization_id').on(table.organizationId),
index('idx_user_id').on(table.userId),
primaryKey({ columns: [table.id], name: 'sys_user_organizations_id' }),
unique('uk_user_org').on(table.userId, table.organizationId),
],
);
export const sysUserOrganizations = mysqlTable("sys_user_organizations", { export const sysUserRoles = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_roles',
userId: bigint("user_id", { mode: "number" }).notNull(), {
organizationId: bigint("organization_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
isPrimary: tinyint("is_primary").default(0).notNull(), userId: bigint('user_id', { mode: 'number' }).notNull(),
position: varchar({ length: 100 }), roleId: bigint('role_id', { mode: 'number' }).notNull(),
joinedAt: datetime("joined_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), expiredAt: datetime('expired_at', { mode: 'string' }),
createdBy: bigint("created_by", { mode: "number" }), createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdAt: datetime('created_at', { mode: 'string' })
}, .default(sql`(CURRENT_TIMESTAMP)`)
(table) => [ .notNull(),
index("idx_is_primary").on(table.isPrimary), },
index("idx_joined_at").on(table.joinedAt), (table) => [
index("idx_organization_id").on(table.organizationId), index('idx_created_at').on(table.createdAt),
index("idx_user_id").on(table.userId), index('idx_expired_at').on(table.expiredAt),
primaryKey({ columns: [table.id], name: "sys_user_organizations_id"}), index('idx_role_id').on(table.roleId),
unique("uk_user_org").on(table.userId, table.organizationId), index('idx_user_id').on(table.userId),
]); primaryKey({ columns: [table.id], name: 'sys_user_roles_id' }),
unique('uk_user_role').on(table.userId, table.roleId),
],
);
export const sysUserRoles = mysqlTable("sys_user_roles", { export const sysUserTags = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_user_tags',
userId: bigint("user_id", { mode: "number" }).notNull(), {
roleId: bigint("role_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
expiredAt: datetime("expired_at", { mode: 'string'}), userId: bigint('user_id', { mode: 'number' }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), tagId: bigint('tag_id', { mode: 'number' }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), createdBy: bigint('created_by', { mode: 'number' }),
}, createdAt: datetime('created_at', { mode: 'string' })
(table) => [ .default(sql`(CURRENT_TIMESTAMP)`)
index("idx_created_at").on(table.createdAt), .notNull(),
index("idx_expired_at").on(table.expiredAt), },
index("idx_role_id").on(table.roleId), (table) => [
index("idx_user_id").on(table.userId), index('idx_created_at').on(table.createdAt),
primaryKey({ columns: [table.id], name: "sys_user_roles_id"}), index('idx_tag_id').on(table.tagId),
unique("uk_user_role").on(table.userId, table.roleId), index('idx_user_id').on(table.userId),
]); primaryKey({ columns: [table.id], name: 'sys_user_tags_id' }),
unique('uk_user_tag').on(table.userId, table.tagId),
],
);
export const sysUserTags = mysqlTable("sys_user_tags", { export const sysUsers = mysqlTable(
id: bigint({ mode: "number" }).notNull(), 'sys_users',
userId: bigint("user_id", { mode: "number" }).notNull(), {
tagId: bigint("tag_id", { mode: "number" }).notNull(), id: bigint({ mode: 'number' }).notNull(),
createdBy: bigint("created_by", { mode: "number" }), username: varchar({ length: 50 }).notNull(),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), email: varchar({ length: 100 }).notNull(),
}, mobile: varchar({ length: 20 }),
(table) => [ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
index("idx_created_at").on(table.createdAt), avatar: varchar({ length: 255 }),
index("idx_tag_id").on(table.tagId), nickname: varchar({ length: 100 }),
index("idx_user_id").on(table.userId), status: varchar({ length: 20 }).default('active').notNull(),
primaryKey({ columns: [table.id], name: "sys_user_tags_id"}), gender: tinyint().default(0),
unique("uk_user_tag").on(table.userId, table.tagId), // you can use { mode: 'date' }, if you want to have Date as type for this column
]); birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
export const sysUsers = mysqlTable("sys_users", { loginCount: int('login_count').default(0).notNull(),
id: bigint({ mode: "number" }).notNull(), lastLoginAt: datetime('last_login_at', { mode: 'string' }),
username: varchar({ length: 50 }).notNull(), lastLoginIp: varchar('last_login_ip', { length: 45 }),
email: varchar({ length: 100 }).notNull(), failedAttempts: int('failed_attempts').default(0).notNull(),
mobile: varchar({ length: 20 }), lockedUntil: datetime('locked_until', { mode: 'string' }),
passwordHash: varchar("password_hash", { length: 255 }).notNull(), isRoot: tinyint('is_root').default(0).notNull(),
avatar: varchar({ length: 255 }), extra: json(),
nickname: varchar({ length: 100 }), createdBy: bigint('created_by', { mode: 'number' }),
status: varchar({ length: 20 }).default('active').notNull(), createdAt: datetime('created_at', { mode: 'string' })
gender: tinyint().default(0), .default(sql`(CURRENT_TIMESTAMP)`)
// you can use { mode: 'date' }, if you want to have Date as type for this column .notNull(),
birthday: date({ mode: 'string' }), updatedBy: bigint('updated_by', { mode: 'number' }),
bio: varchar({ length: 500 }), updatedAt: datetime('updated_at', { mode: 'string' })
loginCount: int("login_count").default(0).notNull(), .default(sql`(CURRENT_TIMESTAMP)`)
lastLoginAt: datetime("last_login_at", { mode: 'string'}), .notNull(),
lastLoginIp: varchar("last_login_ip", { length: 45 }), deletedAt: datetime('deleted_at', { mode: 'string' }),
failedAttempts: int("failed_attempts").default(0).notNull(), version: int().default(1).notNull(),
lockedUntil: datetime("locked_until", { mode: 'string'}), },
isRoot: tinyint("is_root").default(0).notNull(), (table) => [
extra: json(), index('idx_created_at').on(table.createdAt),
createdBy: bigint("created_by", { mode: "number" }), index('idx_deleted_at').on(table.deletedAt),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_is_root').on(table.isRoot),
updatedBy: bigint("updated_by", { mode: "number" }), index('idx_last_login').on(table.lastLoginAt),
updatedAt: datetime("updated_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(), index('idx_mobile').on(table.mobile),
deletedAt: datetime("deleted_at", { mode: 'string'}), index('idx_status').on(table.status),
version: int().default(1).notNull(), primaryKey({ columns: [table.id], name: 'sys_users_id' }),
}, unique('uk_email').on(table.email, table.deletedAt),
(table) => [ unique('uk_username').on(table.username, table.deletedAt),
index("idx_created_at").on(table.createdAt), ],
index("idx_deleted_at").on(table.deletedAt), );
index("idx_is_root").on(table.isRoot),
index("idx_last_login").on(table.lastLoginAt),
index("idx_mobile").on(table.mobile),
index("idx_status").on(table.status),
primaryKey({ columns: [table.id], name: "sys_users_id"}),
unique("uk_email").on(table.email, table.deletedAt),
unique("uk_username").on(table.username, table.deletedAt),
]);

View File

@ -1,6 +1,6 @@
/** /**
* @file Controller层实现 * @file Controller层实现
* @author AI Assistant * @author AI Assistant
* @date 2024-12-19 * @date 2024-12-19
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
@ -8,8 +8,22 @@
*/ */
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { RegisterSchema, ActivateSchema, LoginSchema, RefreshSchema, ResetPasswordRequestSchema, ResetPasswordConfirmSchema } from './auth.schema'; import {
import { RegisterResponsesSchema, ActivateResponsesSchema, LoginResponsesSchema, RefreshResponsesSchema, ResetPasswordRequestResponsesSchema, ResetPasswordConfirmResponsesSchema } from './auth.response'; RegisterSchema,
ActivateSchema,
LoginSchema,
RefreshSchema,
ResetPasswordRequestSchema,
ResetPasswordConfirmSchema,
} from './auth.schema';
import {
RegisterResponsesSchema,
ActivateResponsesSchema,
LoginResponsesSchema,
RefreshResponsesSchema,
ResetPasswordRequestResponsesSchema,
ResetPasswordConfirmResponsesSchema,
} from './auth.response';
import { authService } from './auth.service'; import { authService } from './auth.service';
import { tags } from '@/constants/swaggerTags'; import { tags } from '@/constants/swaggerTags';
@ -25,21 +39,17 @@ export const authController = new Elysia()
* @param body RegisterRequest * @param body RegisterRequest
* @returns RegisterSuccessResponse | RegisterErrorResponse * @returns RegisterSuccessResponse | RegisterErrorResponse
*/ */
.post( .post('/register', ({ body }) => authService.register(body), {
'/register', body: RegisterSchema,
({ body, set }) => authService.register(body), detail: {
{ summary: '用户注册',
body: RegisterSchema, description: '用户注册接口,需要提供用户名、邮箱、密码和验证码',
detail: { tags: [tags.auth],
summary: '用户注册', operationId: 'registerUser',
description: '用户注册接口,需要提供用户名、邮箱、密码和验证码', },
tags: [tags.auth], response: RegisterResponsesSchema,
operationId: 'registerUser', })
},
response: RegisterResponsesSchema,
}
)
/** /**
* *
* @route POST /api/auth/activate * @route POST /api/auth/activate
@ -47,21 +57,17 @@ export const authController = new Elysia()
* @param body ActivateRequest * @param body ActivateRequest
* @returns ActivateSuccessResponse | ActivateErrorResponse * @returns ActivateSuccessResponse | ActivateErrorResponse
*/ */
.post( .post('/activate', ({ body }) => authService.activate(body), {
'/activate', body: ActivateSchema,
({ body, set }) => authService.activate(body), detail: {
{ summary: '邮箱激活',
body: ActivateSchema, description: '通过激活Token激活用户邮箱激活成功后用户状态将变为active',
detail: { tags: [tags.auth],
summary: '邮箱激活', operationId: 'activateUser',
description: '通过激活Token激活用户邮箱激活成功后用户状态将变为active', },
tags: [tags.auth], response: ActivateResponsesSchema,
operationId: 'activateUser', })
},
response: ActivateResponsesSchema,
}
)
/** /**
* *
* @route POST /api/auth/login * @route POST /api/auth/login
@ -69,21 +75,17 @@ export const authController = new Elysia()
* @param body LoginRequest * @param body LoginRequest
* @returns LoginSuccessResponse | LoginErrorResponse * @returns LoginSuccessResponse | LoginErrorResponse
*/ */
.post( .post('/login', ({ body }) => authService.login(body), {
'/login', body: LoginSchema,
({ body, set }) => authService.login(body), detail: {
{ summary: '用户登录',
body: LoginSchema, description: '用户登录接口支持用户名或邮箱登录登录成功返回JWT访问令牌和刷新令牌',
detail: { tags: [tags.auth],
summary: '用户登录', operationId: 'loginUser',
description: '用户登录接口支持用户名或邮箱登录登录成功返回JWT访问令牌和刷新令牌', },
tags: [tags.auth], response: LoginResponsesSchema,
operationId: 'loginUser', })
},
response: LoginResponsesSchema,
}
)
/** /**
* Token刷新接口 * Token刷新接口
* @route POST /api/auth/refresh * @route POST /api/auth/refresh
@ -91,21 +93,17 @@ export const authController = new Elysia()
* @param body RefreshRequest * @param body RefreshRequest
* @returns RefreshSuccessResponse | RefreshErrorResponse * @returns RefreshSuccessResponse | RefreshErrorResponse
*/ */
.post( .post('/refresh', ({ body }) => authService.refresh(body), {
'/refresh', body: RefreshSchema,
({ body, set }) => authService.refresh(body), detail: {
{ summary: 'Token刷新',
body: RefreshSchema, description: '使用刷新令牌获取新的访问令牌和刷新令牌,延长用户会话时间',
detail: { tags: [tags.auth],
summary: 'Token刷新', operationId: 'refreshToken',
description: '使用刷新令牌获取新的访问令牌和刷新令牌,延长用户会话时间', },
tags: [tags.auth], response: RefreshResponsesSchema,
operationId: 'refreshToken', })
},
response: RefreshResponsesSchema,
}
)
/** /**
* *
* @route POST /api/auth/password/reset-request * @route POST /api/auth/password/reset-request
@ -113,21 +111,17 @@ export const authController = new Elysia()
* @param body ResetPasswordRequestRequest * @param body ResetPasswordRequestRequest
* @returns ResetPasswordRequestSuccessResponse | ResetPasswordRequestErrorResponse * @returns ResetPasswordRequestSuccessResponse | ResetPasswordRequestErrorResponse
*/ */
.post( .post('/password/reset-request', ({ body }) => authService.resetPasswordRequest(body), {
'/password/reset-request', body: ResetPasswordRequestSchema,
({ body, set }) => authService.resetPasswordRequest(body), detail: {
{ summary: '找回密码',
body: ResetPasswordRequestSchema, description: '用户忘记密码时发送重置邮件到注册邮箱,邮件包含重置链接',
detail: { tags: [tags.auth],
summary: '找回密码', operationId: 'resetPasswordRequest',
description: '用户忘记密码时发送重置邮件到注册邮箱,邮件包含重置链接', },
tags: [tags.auth], response: ResetPasswordRequestResponsesSchema,
operationId: 'resetPasswordRequest', })
},
response: ResetPasswordRequestResponsesSchema,
}
)
/** /**
* *
* @route POST /api/auth/password/reset-confirm * @route POST /api/auth/password/reset-confirm
@ -135,17 +129,13 @@ export const authController = new Elysia()
* @param body ResetPasswordConfirmRequest * @param body ResetPasswordConfirmRequest
* @returns ResetPasswordConfirmSuccessResponse | ResetPasswordConfirmErrorResponse * @returns ResetPasswordConfirmSuccessResponse | ResetPasswordConfirmErrorResponse
*/ */
.post( .post('/password/reset-confirm', ({ body }) => authService.resetPasswordConfirm(body), {
'/password/reset-confirm', body: ResetPasswordConfirmSchema,
({ body, set }) => authService.resetPasswordConfirm(body), detail: {
{ summary: '重置密码',
body: ResetPasswordConfirmSchema, description: '用户通过重置令牌设置新密码,需要提供令牌、新密码和确认密码',
detail: { tags: [tags.auth],
summary: '重置密码', operationId: 'resetPasswordConfirm',
description: '用户通过重置令牌设置新密码,需要提供令牌、新密码和确认密码', },
tags: [tags.auth], response: ResetPasswordConfirmResponsesSchema,
operationId: 'resetPasswordConfirm', });
},
response: ResetPasswordConfirmResponsesSchema,
}
);

View File

@ -17,36 +17,38 @@ import { responseWrapperSchema } from '@/utils/responseFormate';
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const RegisterResponsesSchema = { export const RegisterResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 用户ID */ t.Object({
id: t.String({ /** 用户ID */
description: '用户IDbigint类型以字符串形式返回防止精度丢失', id: t.String({
examples: ['1', '2', '3'] description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3'],
}),
/** 用户名 */
username: t.String({
description: '用户名',
examples: ['admin', 'testuser'],
}),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com'],
}),
/** 账号状态 */
status: t.String({
description: '账号状态',
examples: ['pending', 'active'],
}),
/** 创建时间 */
createdAt: t.String({
description: '创建时间',
examples: ['2024-12-19T10:30:00Z'],
}),
}), }),
/** 用户名 */ ),
username: t.String({
description: '用户名',
examples: ['admin', 'testuser']
}),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com']
}),
/** 账号状态 */
status: t.String({
description: '账号状态',
examples: ['pending', 'active']
}),
/** 创建时间 */
createdAt: t.String({
description: '创建时间',
examples: ['2024-12-19T10:30:00Z']
})
})),
}; };
/** 用户注册成功响应数据类型 */ /** 用户注册成功响应数据类型 */
export type RegisterResponsesType = Static<typeof RegisterResponsesSchema[200]>; export type RegisterResponsesType = Static<(typeof RegisterResponsesSchema)[200]>;
// ========== 邮箱激活相关响应格式 ========== // ========== 邮箱激活相关响应格式 ==========
@ -55,42 +57,44 @@ export type RegisterResponsesType = Static<typeof RegisterResponsesSchema[200]>;
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const ActivateResponsesSchema = { export const ActivateResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 用户ID */ t.Object({
id: t.String({ /** 用户ID */
description: '用户IDbigint类型以字符串形式返回防止精度丢失', id: t.String({
examples: ['1', '2', '3'] description: '用户IDbigint类型以字符串形式返回防止精度丢失',
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],
}),
}), }),
/** 用户名 */ ),
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]
})
})),
}; };
/** 邮箱激活成功响应数据类型 */ /** 邮箱激活成功响应数据类型 */
export type ActivateSuccessType = Static<typeof ActivateResponsesSchema[200]>; export type ActivateSuccessType = Static<(typeof ActivateResponsesSchema)[200]>;
// ========== 用户登录相关响应格式 ========== // ========== 用户登录相关响应格式 ==========
@ -99,68 +103,70 @@ export type ActivateSuccessType = Static<typeof ActivateResponsesSchema[200]>;
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const LoginResponsesSchema = { export const LoginResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 用户基本信息 */ t.Object({
user: t.Object({ /** 用户基本信息 */
/** 用户ID */ user: t.Object({
id: t.String({ /** 用户ID */
description: '用户IDbigint类型以字符串形式返回防止精度丢失', id: t.String({
examples: ['1', '2', '3'] description: '用户IDbigint类型以字符串形式返回防止精度丢失',
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'],
}),
/** 最后登录时间 */
lastLoginAt: t.Union([t.String(), t.Null()], {
description: '最后登录时间',
examples: ['2024-12-19T10:30:00Z', null],
}),
}), }),
/** 用户名 */ /** 认证令牌信息 */
username: t.String({ tokens: t.Object({
description: '用户名', /** 访问令牌 */
examples: ['admin', 'testuser'] accessToken: t.String({
description: 'JWT访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}),
/** 刷新令牌 */
refreshToken: t.String({
description: 'JWT刷新令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}),
/** 令牌类型 */
tokenType: t.String({
description: '令牌类型',
examples: ['Bearer'],
}),
/** 过期时间(秒) */
expiresIn: t.String({
description: '访问令牌过期时间(秒)',
examples: [7200, 86400],
}),
/** 刷新令牌过期时间(秒) */
refreshExpiresIn: t.String({
description: '刷新令牌过期时间(秒)',
examples: [2592000],
}),
}), }),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com']
}),
/** 账号状态 */
status: t.String({
description: '账号状态',
examples: ['active']
}),
/** 最后登录时间 */
lastLoginAt: t.Union([t.String(), t.Null()], {
description: '最后登录时间',
examples: ['2024-12-19T10:30:00Z', null]
})
}), }),
/** 认证令牌信息 */ ),
tokens: t.Object({
/** 访问令牌 */
accessToken: t.String({
description: 'JWT访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...']
}),
/** 刷新令牌 */
refreshToken: t.String({
description: 'JWT刷新令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...']
}),
/** 令牌类型 */
tokenType: t.String({
description: '令牌类型',
examples: ['Bearer']
}),
/** 过期时间(秒) */
expiresIn: t.String({
description: '访问令牌过期时间(秒)',
examples: [7200, 86400]
}),
/** 刷新令牌过期时间(秒) */
refreshExpiresIn: t.String({
description: '刷新令牌过期时间(秒)',
examples: [2592000]
})
})
})),
}; };
/** 用户登录成功响应数据类型 */ /** 用户登录成功响应数据类型 */
export type LoginSuccessType = Static<typeof LoginResponsesSchema[200]>; export type LoginSuccessType = Static<(typeof LoginResponsesSchema)[200]>;
// ========== Token刷新相关响应格式 ========== // ========== Token刷新相关响应格式 ==========
@ -169,45 +175,47 @@ export type LoginSuccessType = Static<typeof LoginResponsesSchema[200]>;
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const RefreshResponsesSchema = { export const RefreshResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 认证令牌信息 */ t.Object({
tokens: t.Object({ /** 认证令牌信息 */
/** 访问令牌 */ tokens: t.Object({
accessToken: t.String({ /** 访问令牌 */
description: 'JWT访问令牌', accessToken: t.String({
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] description: 'JWT访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}),
/** 刷新令牌 */
refreshToken: t.String({
description: 'JWT刷新令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}),
/** 令牌类型 */
tokenType: t.String({
description: '令牌类型',
examples: ['Bearer'],
}),
/** 过期时间(秒) */
expiresIn: t.String({
description: '访问令牌过期时间(秒)',
examples: [7200, 86400],
}),
/** 刷新令牌过期时间(秒) */
refreshExpiresIn: t.String({
description: '刷新令牌过期时间(秒)',
examples: [2592000],
}),
}), }),
/** 刷新令牌 */ /** 刷新时间 */
refreshToken: t.String({ refreshedAt: t.String({
description: 'JWT刷新令牌', description: '令牌刷新时间',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] examples: ['2024-12-19T10:30:00Z'],
}), }),
/** 令牌类型 */
tokenType: t.String({
description: '令牌类型',
examples: ['Bearer']
}),
/** 过期时间(秒) */
expiresIn: t.String({
description: '访问令牌过期时间(秒)',
examples: [7200, 86400]
}),
/** 刷新令牌过期时间(秒) */
refreshExpiresIn: t.String({
description: '刷新令牌过期时间(秒)',
examples: [2592000]
})
}), }),
/** 刷新时间 */ ),
refreshedAt: t.String({
description: '令牌刷新时间',
examples: ['2024-12-19T10:30:00Z']
})
})),
}; };
/** Token刷新成功响应数据类型 */ /** Token刷新成功响应数据类型 */
export type RefreshSuccessType = Static<typeof RefreshResponsesSchema[200]>; export type RefreshSuccessType = Static<(typeof RefreshResponsesSchema)[200]>;
// ========== 找回密码相关响应格式 ========== // ========== 找回密码相关响应格式 ==========
@ -216,37 +224,39 @@ export type RefreshSuccessType = Static<typeof RefreshResponsesSchema[200]>;
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const ResetPasswordRequestResponsesSchema = { export const ResetPasswordRequestResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 邮箱地址 */ t.Object({
email: t.String({ /** 邮箱地址 */
description: '发送重置邮件的邮箱地址', email: t.String({
examples: ['user@example.com', 'admin@company.com'] description: '发送重置邮件的邮箱地址',
examples: ['user@example.com', 'admin@company.com'],
}),
/** 发送状态 */
sent: t.Boolean({
description: '邮件发送状态',
examples: [true],
}),
/** 发送时间 */
sentAt: t.String({
description: '邮件发送时间',
examples: ['2024-12-19T10:30:00Z'],
}),
/** 重置链接有效期(分钟) */
expiresIn: t.Number({
description: '重置链接有效期(分钟)',
examples: [30, 60],
}),
/** 提示信息 */
message: t.String({
description: '操作提示信息',
examples: ['重置邮件已发送,请查收邮箱'],
}),
}), }),
/** 发送状态 */ ),
sent: t.Boolean({
description: '邮件发送状态',
examples: [true]
}),
/** 发送时间 */
sentAt: t.String({
description: '邮件发送时间',
examples: ['2024-12-19T10:30:00Z']
}),
/** 重置链接有效期(分钟) */
expiresIn: t.Number({
description: '重置链接有效期(分钟)',
examples: [30, 60]
}),
/** 提示信息 */
message: t.String({
description: '操作提示信息',
examples: ['重置邮件已发送,请查收邮箱']
})
})),
}; };
/** 找回密码成功响应数据类型 */ /** 找回密码成功响应数据类型 */
export type ResetPasswordRequestSuccessType = Static<typeof ResetPasswordRequestResponsesSchema[200]>; export type ResetPasswordRequestSuccessType = Static<(typeof ResetPasswordRequestResponsesSchema)[200]>;
// ========== 重置密码相关响应格式 ========== // ========== 重置密码相关响应格式 ==========
@ -255,39 +265,41 @@ export type ResetPasswordRequestSuccessType = Static<typeof ResetPasswordRequest
* @description Controller中定义所有可能的响应格式 * @description Controller中定义所有可能的响应格式
*/ */
export const ResetPasswordConfirmResponsesSchema = { export const ResetPasswordConfirmResponsesSchema = {
200: responseWrapperSchema(t.Object({ 200: responseWrapperSchema(
/** 用户ID */ t.Object({
id: t.String({ /** 用户ID */
description: '用户IDbigint类型以字符串形式返回防止精度丢失', id: t.String({
examples: ['1', '2', '3'] description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3'],
}),
/** 用户名 */
username: t.String({
description: '用户名',
examples: ['admin', 'testuser'],
}),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com'],
}),
/** 密码更新时间 */
updatedAt: t.String({
description: '密码更新时间',
examples: ['2024-12-19T10:30:00Z'],
}),
/** 重置成功标识 */
reset: t.Boolean({
description: '密码重置是否成功',
examples: [true],
}),
/** 提示信息 */
message: t.String({
description: '操作提示信息',
examples: ['密码重置成功,请使用新密码登录'],
}),
}), }),
/** 用户名 */ ),
username: t.String({
description: '用户名',
examples: ['admin', 'testuser']
}),
/** 邮箱地址 */
email: t.String({
description: '邮箱地址',
examples: ['user@example.com']
}),
/** 密码更新时间 */
updatedAt: t.String({
description: '密码更新时间',
examples: ['2024-12-19T10:30:00Z']
}),
/** 重置成功标识 */
reset: t.Boolean({
description: '密码重置是否成功',
examples: [true]
}),
/** 提示信息 */
message: t.String({
description: '操作提示信息',
examples: ['密码重置成功,请使用新密码登录']
})
})),
}; };
/** 重置密码成功响应数据类型 */ /** 重置密码成功响应数据类型 */
export type ResetPasswordConfirmSuccessType = Static<typeof ResetPasswordConfirmResponsesSchema[200]>; export type ResetPasswordConfirmSuccessType = Static<(typeof ResetPasswordConfirmResponsesSchema)[200]>;

View File

@ -19,34 +19,34 @@ export const RegisterSchema = t.Object({
minLength: 2, minLength: 2,
maxLength: 50, maxLength: 50,
description: '用户名2-50字符', description: '用户名2-50字符',
examples: ['root', 'testuser'] examples: ['root', 'testuser'],
}), }),
/** 邮箱地址对应sys_users.email */ /** 邮箱地址对应sys_users.email */
email: t.String({ email: t.String({
format: 'email', format: 'email',
maxLength: 100, maxLength: 100,
description: '邮箱地址', description: '邮箱地址',
examples: ['x71291@outlook.com'] examples: ['x71291@outlook.com'],
}), }),
/** 密码6-50字符 */ /** 密码6-50字符 */
password: t.String({ password: t.String({
minLength: 6, minLength: 6,
maxLength: 50, maxLength: 50,
description: '密码6-50字符', description: '密码6-50字符',
examples: ['password123'] examples: ['password123'],
}), }),
/** 图形验证码 */ /** 图形验证码 */
captcha: t.String({ captcha: t.String({
minLength: 4, minLength: 4,
maxLength: 6, maxLength: 6,
description: '图形验证码', description: '图形验证码',
examples: ['a1b2'] examples: ['a1b2'],
}), }),
/** 验证码会话ID */ /** 验证码会话ID */
captchaId: t.String({ captchaId: t.String({
description: '验证码会话ID', description: '验证码会话ID',
examples: ['cap'] examples: ['cap'],
}) }),
}); });
/** /**
@ -59,8 +59,8 @@ export const ActivateSchema = t.Object({
minLength: 10, minLength: 10,
maxLength: 1000, maxLength: 1000,
description: '邮箱激活令牌JWT格式24小时有效', description: '邮箱激活令牌JWT格式24小时有效',
examples: ['eyJhbGciOiJIUzI1NiI'] examples: ['eyJhbGciOiJIUzI1NiI'],
}) }),
}); });
/** /**
@ -73,33 +73,39 @@ export const LoginSchema = t.Object({
minLength: 2, minLength: 2,
maxLength: 100, maxLength: 100,
description: '用户名/邮箱地址100字符', description: '用户名/邮箱地址100字符',
examples: ['root', 'testuser', 'x71291@outlook.com'] examples: ['root', 'testuser', 'x71291@outlook.com'],
}), }),
/** 图形验证码(可选) */ /** 图形验证码(可选) */
captcha: t.Optional(t.String({ captcha: t.Optional(
minLength: 4, t.String({
maxLength: 6, minLength: 4,
description: '图形验证码,登录失败次数过多时需要', maxLength: 6,
examples: ['a1b2'] description: '图形验证码,登录失败次数过多时需要',
})), examples: ['a1b2'],
}),
),
/** 密码6-50字符 */ /** 密码6-50字符 */
password: t.String({ password: t.String({
minLength: 6, minLength: 6,
maxLength: 50, maxLength: 50,
description: '密码6-50字符', description: '密码6-50字符',
examples: ['password123'] examples: ['password123'],
}), }),
/** 验证码会话ID可选 */ /** 验证码会话ID可选 */
captchaId: t.Optional(t.String({ captchaId: t.Optional(
description: '验证码会话ID与captcha配对使用', t.String({
examples: ['cap'] description: '验证码会话ID与captcha配对使用',
})), examples: ['cap'],
}),
),
/** 是否记住登录状态 */ /** 是否记住登录状态 */
rememberMe: t.Optional(t.Boolean({ rememberMe: t.Optional(
description: '是否记住登录状态影响token过期时间', t.Boolean({
examples: [true, false], description: '是否记住登录状态影响token过期时间',
default: false examples: [true, false],
})) default: false,
}),
),
}); });
/** /**
@ -112,8 +118,8 @@ export const RefreshSchema = t.Object({
minLength: 10, minLength: 10,
maxLength: 1000, maxLength: 1000,
description: '刷新令牌JWT格式用于获取新的访问令牌', description: '刷新令牌JWT格式用于获取新的访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}) }),
}); });
/** /**
@ -126,20 +132,20 @@ export const ResetPasswordRequestSchema = t.Object({
format: 'email', format: 'email',
maxLength: 100, maxLength: 100,
description: '注册时使用的邮箱地址', description: '注册时使用的邮箱地址',
examples: ['user@example.com', 'admin@company.com'] examples: ['user@example.com', 'admin@company.com'],
}), }),
/** 图形验证码 */ /** 图形验证码 */
captcha: t.String({ captcha: t.String({
minLength: 4, minLength: 4,
maxLength: 6, maxLength: 6,
description: '图形验证码', description: '图形验证码',
examples: ['a1b2', '1234'] examples: ['a1b2', '1234'],
}), }),
/** 验证码会话ID */ /** 验证码会话ID */
captchaId: t.String({ captchaId: t.String({
description: '验证码会话ID', description: '验证码会话ID',
examples: ['cap_123', 'captcha_session'] examples: ['cap_123', 'captcha_session'],
}) }),
}); });
/** /**
@ -152,22 +158,22 @@ export const ResetPasswordConfirmSchema = t.Object({
minLength: 10, minLength: 10,
maxLength: 1000, maxLength: 1000,
description: '重置密码令牌JWT格式30分钟有效', description: '重置密码令牌JWT格式30分钟有效',
examples: ['eyJhbGciOiJIUzI1NiI'] examples: ['eyJhbGciOiJIUzI1NiI'],
}), }),
/** 新密码6-50字符 */ /** 新密码6-50字符 */
newPassword: t.String({ newPassword: t.String({
minLength: 6, minLength: 6,
maxLength: 50, maxLength: 50,
description: '新密码6-50字符', description: '新密码6-50字符',
examples: ['newpassword123'] examples: ['newpassword123'],
}), }),
/** 确认新密码,必须与新密码一致 */ /** 确认新密码,必须与新密码一致 */
confirmPassword: t.String({ confirmPassword: t.String({
minLength: 6, minLength: 6,
maxLength: 50, maxLength: 50,
description: '确认新密码,必须与新密码一致', description: '确认新密码,必须与新密码一致',
examples: ['newpassword123'] examples: ['newpassword123'],
}) }),
}); });
/** 用户注册请求类型 */ /** 用户注册请求类型 */
@ -186,4 +192,4 @@ export type RefreshRequest = Static<typeof RefreshSchema>;
export type ResetPasswordRequestRequest = Static<typeof ResetPasswordRequestSchema>; export type ResetPasswordRequestRequest = Static<typeof ResetPasswordRequestSchema>;
/** 重置密码请求类型 */ /** 重置密码请求类型 */
export type ResetPasswordConfirmRequest = Static<typeof ResetPasswordConfirmSchema>; export type ResetPasswordConfirmRequest = Static<typeof ResetPasswordConfirmSchema>;

View File

@ -5,7 +5,7 @@
* @lastEditor AI Assistant * @lastEditor AI Assistant
* @lastEditTime 2025-01-07 * @lastEditTime 2025-01-07
* @description * @description
* *
* 使 * 使
* 1. token使TTL * 1. token使TTL
* 2. 使TTL * 2. 使TTL
@ -15,16 +15,30 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { db } from '@/plugins/drizzle/drizzle.service'; import { db } from '@/plugins/drizzle/drizzle.service';
import { sysUsers } from '@/eneities'; import { sysUsers } from '@/eneities/index';
import { captchaService } from '@/modules/captcha/captcha.service'; import { captchaService } from '@/modules/captcha/captcha.service';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import { nextId } from '@/utils/snowflake'; import { nextId } from '@/utils/snowflake';
import { jwtService } from '@/plugins/jwt/jwt.service'; import { jwtService } from '@/plugins/jwt/jwt.service';
import { emailService } from '@/plugins/email/email.service'; import { emailService } from '@/plugins/email/email.service';
import { DistributedLockService, LOCK_KEYS } from '@/utils/distributedLock'; import { DistributedLockService, LOCK_KEYS } from '@/utils/distributedLock';
import type { RegisterRequest, ActivateRequest, LoginRequest, RefreshRequest, ResetPasswordRequestRequest, ResetPasswordConfirmRequest } from './auth.schema'; import type {
RegisterRequest,
ActivateRequest,
LoginRequest,
RefreshRequest,
ResetPasswordRequestRequest,
ResetPasswordConfirmRequest,
} from './auth.schema';
import { successResponse, errorResponse, BusinessError } from '@/utils/responseFormate'; import { successResponse, errorResponse, BusinessError } from '@/utils/responseFormate';
import type { ActivateSuccessType, LoginSuccessType, RegisterResponsesType, RefreshSuccessType, ResetPasswordRequestSuccessType, ResetPasswordConfirmSuccessType } from './auth.response'; import type {
ActivateSuccessType,
LoginSuccessType,
RegisterResponsesType,
RefreshSuccessType,
ResetPasswordRequestSuccessType,
ResetPasswordConfirmSuccessType,
} from './auth.response';
import { TOKEN_TYPES } from '@/type/jwt.type'; import { TOKEN_TYPES } from '@/type/jwt.type';
/** /**
@ -48,10 +62,10 @@ export class AuthService {
// 获取分布式锁,防止并发注册(长期操作,开启自动续期) // 获取分布式锁,防止并发注册(长期操作,开启自动续期)
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.USER_REGISTER}:${username}:${email}`, key: `${LOCK_KEYS.USER_REGISTER}:${username}:${email}`,
ttl: 60, // 注册可能需要较长时间(邮件发送等) ttl: 60, // 注册可能需要较长时间(邮件发送等)
timeout: 15000, timeout: 15000,
autoRenew: true, autoRenew: true,
renewInterval: 20000 // 20秒续期一次 renewInterval: 20000, // 20秒续期一次
}); });
try { try {
@ -71,7 +85,7 @@ export class AuthService {
const newUser = await this.createUser({ const newUser = await this.createUser({
username, username,
email, email,
passwordHash passwordHash,
}); });
// 6. 发送激活邮件 // 6. 发送激活邮件
@ -79,14 +93,16 @@ export class AuthService {
Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`); Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`);
return successResponse({ return successResponse(
id: newUser.id, {
username: newUser.username, id: newUser.id,
email: newUser.email, username: newUser.username,
status: newUser.status, email: newUser.email,
createdAt: newUser.createdAt status: newUser.status,
}, '用户注册成功,请查收激活邮件'); createdAt: newUser.createdAt,
},
'用户注册成功,请查收激活邮件',
);
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -101,14 +117,11 @@ export class AuthService {
private async validateCaptcha(captcha: string, captchaId: string): Promise<void> { private async validateCaptcha(captcha: string, captchaId: string): Promise<void> {
const result = await captchaService.verifyCaptcha({ const result = await captchaService.verifyCaptcha({
captchaId, captchaId,
captchaCode: captcha captchaCode: captcha,
}); });
if (!result.data?.valid) { if (!result.data?.valid) {
throw new BusinessError( throw new BusinessError(result.data?.message || '验证码验证失败', 400);
result.data?.message || '验证码验证失败',
400
);
} }
} }
@ -117,7 +130,8 @@ export class AuthService {
* @param username * @param username
*/ */
private async checkUsernameExists(username: string): Promise<void> { private async checkUsernameExists(username: string): Promise<void> {
const existingUser = await db().select({ id: sysUsers.id }) const existingUser = await db()
.select({ id: sysUsers.id })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.username, username)) .where(eq(sysUsers.username, username))
.limit(1); .limit(1);
@ -132,7 +146,8 @@ export class AuthService {
* @param email * @param email
*/ */
private async checkEmailExists(email: string): Promise<void> { private async checkEmailExists(email: string): Promise<void> {
const existingUser = await db().select({ id: sysUsers.id }) const existingUser = await db()
.select({ id: sysUsers.id })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.email, email)) .where(eq(sysUsers.email, email))
.limit(1); .limit(1);
@ -156,11 +171,7 @@ export class AuthService {
* @param userData * @param userData
* @returns Promise<CreatedUser> * @returns Promise<CreatedUser>
*/ */
private async createUser(userData: { private async createUser(userData: { username: string; email: string; passwordHash: string }): Promise<{
username: string;
email: string;
passwordHash: string;
}): Promise<{
id: string; id: string;
username: string; username: string;
email: string; email: string;
@ -177,17 +188,18 @@ export class AuthService {
username, username,
email, email,
passwordHash, passwordHash,
status: 'pending' // 新注册用户状态为待激活 status: 'pending', // 新注册用户状态为待激活
}); });
// 查询刚创建的用户信息 // 查询刚创建的用户信息
const [newUser] = await db().select({ const [newUser] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
status: sysUsers.status, email: sysUsers.email,
createdAt: sysUsers.createdAt status: sysUsers.status,
}) createdAt: sysUsers.createdAt,
})
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, userId)) .where(eq(sysUsers.id, userId))
.limit(1); .limit(1);
@ -203,7 +215,7 @@ export class AuthService {
username: newUser!.username, username: newUser!.username,
email: newUser!.email, email: newUser!.email,
status: newUser!.status, status: newUser!.status,
createdAt: newUser!.createdAt createdAt: newUser!.createdAt,
}; };
} }
@ -213,7 +225,7 @@ export class AuthService {
* @returns Promise<ActivateSuccessResponse> * @returns Promise<ActivateSuccessResponse>
*/ */
public async activate(request: ActivateRequest): Promise<ActivateSuccessType> { public async activate(request: ActivateRequest): Promise<ActivateSuccessType> {
Logger.info(`邮箱激活请求开始处理`); Logger.info('邮箱激活请求开始处理');
const { token } = request; const { token } = request;
@ -228,7 +240,7 @@ export class AuthService {
key: `${LOCK_KEYS.EMAIL_ACTIVATE}:${tokenPayload.userId}`, key: `${LOCK_KEYS.EMAIL_ACTIVATE}:${tokenPayload.userId}`,
ttl: 30, ttl: 30,
timeout: 10000, timeout: 10000,
autoRenew: true autoRenew: true,
}); });
try { try {
@ -248,15 +260,17 @@ export class AuthService {
Logger.info(`邮箱激活成功:${user.id} - ${user.username}`); Logger.info(`邮箱激活成功:${user.id} - ${user.username}`);
return successResponse({ return successResponse(
id: updatedUser.id, {
username: updatedUser.username, id: updatedUser.id,
email: updatedUser.email, username: updatedUser.username,
status: updatedUser.status, email: updatedUser.email,
updatedAt: updatedUser.updatedAt, status: updatedUser.status,
activated: true updatedAt: updatedUser.updatedAt,
}, '邮箱激活成功'); activated: true,
},
'邮箱激活成功',
);
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -276,14 +290,15 @@ export class AuthService {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}> { }> {
const [user] = await db().select({ const [user] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
status: sysUsers.status, email: sysUsers.email,
createdAt: sysUsers.createdAt, status: sysUsers.status,
updatedAt: sysUsers.updatedAt createdAt: sysUsers.createdAt,
}) updatedAt: sysUsers.updatedAt,
})
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, userId)) .where(eq(sysUsers.id, userId))
.limit(1); .limit(1);
@ -298,7 +313,7 @@ export class AuthService {
email: user.email, email: user.email,
status: user.status, status: user.status,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt updatedAt: user.updatedAt,
}; };
} }
@ -308,7 +323,10 @@ export class AuthService {
* @param status * @param status
* @returns Promise<UpdatedUser> * @returns Promise<UpdatedUser>
*/ */
private async updateUserStatus(userId: string, status: string): Promise<{ private async updateUserStatus(
userId: string,
status: string,
): Promise<{
id: string; id: string;
username: string; username: string;
email: string; email: string;
@ -316,20 +334,22 @@ export class AuthService {
updatedAt: string; updatedAt: string;
}> { }> {
// 更新用户状态 // 更新用户状态
await db().update(sysUsers) await db()
.update(sysUsers)
.set({ .set({
status: status, status: status,
}) })
.where(eq(sysUsers.id, BigInt(userId))); .where(eq(sysUsers.id, BigInt(userId)));
// 查询更新后的用户信息 // 查询更新后的用户信息
const [updatedUser] = await db().select({ const [updatedUser] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
status: sysUsers.status, email: sysUsers.email,
updatedAt: sysUsers.updatedAt status: sysUsers.status,
}) updatedAt: sysUsers.updatedAt,
})
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId))) .where(eq(sysUsers.id, BigInt(userId)))
.limit(1); .limit(1);
@ -343,7 +363,7 @@ export class AuthService {
username: updatedUser!.username, username: updatedUser!.username,
email: updatedUser!.email, email: updatedUser!.email,
status: updatedUser!.status, status: updatedUser!.status,
updatedAt: updatedUser!.updatedAt updatedAt: updatedUser!.updatedAt,
}; };
} }
@ -361,9 +381,9 @@ export class AuthService {
// 获取分布式锁,防止并发登录(短期操作,不开启自动续期) // 获取分布式锁,防止并发登录(短期操作,不开启自动续期)
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.USER_LOGIN}:${identifier}`, key: `${LOCK_KEYS.USER_LOGIN}:${identifier}`,
ttl: 15, // 登录操作通常很快 ttl: 15, // 登录操作通常很快
timeout: 8000, timeout: 8000,
autoRenew: false // 短期操作不需要续期 autoRenew: false, // 短期操作不需要续期
}); });
try { try {
@ -382,12 +402,15 @@ export class AuthService {
this.checkAccountStatus(user); this.checkAccountStatus(user);
// 5. 生成JWT令牌 // 5. 生成JWT令牌
const tokens = jwtService.generateTokens({ const tokens = jwtService.generateTokens(
id: user.id, {
username: user.username, id: user.id,
email: user.email, username: user.username,
status: user.status email: user.email,
}, rememberMe); status: user.status,
},
rememberMe,
);
// 6. 更新最后登录时间 // 6. 更新最后登录时间
await this.updateLastLoginTime(user.id); await this.updateLastLoginTime(user.id);
@ -397,17 +420,19 @@ export class AuthService {
Logger.info(`用户登录成功:${user.id} - ${user.username}`); Logger.info(`用户登录成功:${user.id} - ${user.username}`);
return successResponse({ return successResponse(
user: { {
id: user.id, user: {
username: user.username, id: user.id,
email: user.email, username: user.username,
status: user.status, email: user.email,
lastLoginAt: user.lastLoginAt status: user.status,
lastLoginAt: user.lastLoginAt,
},
tokens,
}, },
tokens '登录成功',
}, '登录成功'); );
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -431,18 +456,17 @@ export class AuthService {
const isEmail = identifier.includes('@'); const isEmail = identifier.includes('@');
// 构建查询条件 // 构建查询条件
const whereCondition = isEmail const whereCondition = isEmail ? eq(sysUsers.email, identifier) : eq(sysUsers.username, identifier);
? eq(sysUsers.email, identifier)
: eq(sysUsers.username, identifier);
const [user] = await db().select({ const [user] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
status: sysUsers.status, email: sysUsers.email,
passwordHash: sysUsers.passwordHash, status: sysUsers.status,
lastLoginAt: sysUsers.lastLoginAt passwordHash: sysUsers.passwordHash,
}) lastLoginAt: sysUsers.lastLoginAt,
})
.from(sysUsers) .from(sysUsers)
.where(whereCondition) .where(whereCondition)
.limit(1); .limit(1);
@ -457,7 +481,7 @@ export class AuthService {
email: user.email, email: user.email,
status: user.status, status: user.status,
passwordHash: user.passwordHash, passwordHash: user.passwordHash,
lastLoginAt: user.lastLoginAt lastLoginAt: user.lastLoginAt,
}; };
} }
@ -496,10 +520,11 @@ export class AuthService {
* @param userId ID * @param userId ID
*/ */
private async updateLastLoginTime(userId: string): Promise<void> { private async updateLastLoginTime(userId: string): Promise<void> {
await db().update(sysUsers) await db()
.update(sysUsers)
.set({ .set({
lastLoginAt: sql`NOW()`, // 使用 MySQL 的 NOW() 函数 lastLoginAt: sql`NOW()`, // 使用 MySQL 的 NOW() 函数
loginCount: sql`${sysUsers.loginCount} + 1` loginCount: sql`${sysUsers.loginCount} + 1`,
}) })
.where(eq(sysUsers.id, BigInt(userId))); .where(eq(sysUsers.id, BigInt(userId)));
} }
@ -529,10 +554,9 @@ export class AuthService {
<h2>${username}</h2> <h2>${username}</h2>
<p>使</p> <p>使</p>
<p>使</p> <p>使</p>
` `,
}); });
// Logger.info(`激活成功邮件发送成功:${email}`); // Logger.info(`激活成功邮件发送成功:${email}`);
} catch (error) { } catch (error) {
// 邮件发送失败不影响激活流程,只记录日志 // 邮件发送失败不影响激活流程,只记录日志
Logger.warn(`激活成功邮件发送失败:${email}, 错误:${error}`); Logger.warn(`激活成功邮件发送失败:${email}, 错误:${error}`);
@ -561,7 +585,7 @@ export class AuthService {
<a href="http://localhost:3000/activate?token=${encodeURIComponent(activationTokenPayload)}"></a> <a href="http://localhost:3000/activate?token=${encodeURIComponent(activationTokenPayload)}"></a>
<p>24</p> <p>24</p>
<p></p> <p></p>
` `,
}); });
Logger.info(`激活邮件发送成功:${email}`); Logger.info(`激活邮件发送成功:${email}`);
} catch (error) { } catch (error) {
@ -576,16 +600,16 @@ export class AuthService {
* @returns Promise<RefreshSuccessResponse> * @returns Promise<RefreshSuccessResponse>
*/ */
public async refresh(request: RefreshRequest): Promise<RefreshSuccessType> { public async refresh(request: RefreshRequest): Promise<RefreshSuccessType> {
Logger.info(`Token刷新请求开始处理`); Logger.info('Token刷新请求开始处理');
const { refreshToken } = request; const { refreshToken } = request;
// 获取分布式锁,防止并发刷新(短期操作,不开启自动续期) // 获取分布式锁,防止并发刷新(短期操作,不开启自动续期)
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.TOKEN_REFRESH}:${refreshToken}`, key: `${LOCK_KEYS.TOKEN_REFRESH}:${refreshToken}`,
ttl: 10, // Token刷新操作很快 ttl: 10, // Token刷新操作很快
timeout: 5000, timeout: 5000,
autoRenew: false // 短期操作不需要续期 autoRenew: false, // 短期操作不需要续期
}); });
try { try {
@ -609,16 +633,18 @@ export class AuthService {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
status: user.status status: user.status,
}); });
// 5. 记录刷新日志 // 5. 记录刷新日志
await this.recordRefreshLog(user.id); await this.recordRefreshLog(user.id);
return successResponse({ return successResponse(
tokens {
}, 'Token刷新成功'); tokens,
},
'Token刷新成功',
);
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -651,7 +677,7 @@ export class AuthService {
key: `${LOCK_KEYS.PASSWORD_RESET}:${email}`, key: `${LOCK_KEYS.PASSWORD_RESET}:${email}`,
ttl: 30, ttl: 30,
timeout: 10000, timeout: 10000,
autoRenew: true autoRenew: true,
}); });
try { try {
@ -672,14 +698,16 @@ export class AuthService {
Logger.info(`找回密码邮件发送成功:${user.id} - ${user.email}`); Logger.info(`找回密码邮件发送成功:${user.id} - ${user.email}`);
return successResponse({ return successResponse(
email: user.email, {
sent: true, email: user.email,
sentAt: new Date().toISOString(), sent: true,
expiresIn: 30, // 30分钟有效期 sentAt: new Date().toISOString(),
message: '重置邮件已发送,请查收邮箱' expiresIn: 30, // 30分钟有效期
}, '重置邮件已发送,请查收邮箱'); message: '重置邮件已发送,请查收邮箱',
},
'重置邮件已发送,请查收邮箱',
);
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -698,12 +726,13 @@ export class AuthService {
email: string; email: string;
status: string; status: string;
}> { }> {
const [user] = await db().select({ const [user] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
status: sysUsers.status email: sysUsers.email,
}) status: sysUsers.status,
})
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.email, email)) .where(eq(sysUsers.email, email))
.limit(1); .limit(1);
@ -716,7 +745,7 @@ export class AuthService {
id: user.id!.toString(), id: user.id!.toString(),
username: user.username, username: user.username,
email: user.email, email: user.email,
status: user.status status: user.status,
}; };
} }
@ -754,7 +783,7 @@ export class AuthService {
<p></p> <p></p>
<p><br></p> <p><br></p>
</div> </div>
` `,
}; };
await emailService.sendEmail(emailContent); await emailService.sendEmail(emailContent);
@ -787,7 +816,7 @@ export class AuthService {
key: `${LOCK_KEYS.PASSWORD_RESET}:${tokenPayload.userId}`, key: `${LOCK_KEYS.PASSWORD_RESET}:${tokenPayload.userId}`,
ttl: 30, ttl: 30,
timeout: 10000, timeout: 10000,
autoRenew: true autoRenew: true,
}); });
try { try {
@ -808,15 +837,17 @@ export class AuthService {
Logger.info(`密码重置成功:${user.id} - ${user.username}`); Logger.info(`密码重置成功:${user.id} - ${user.username}`);
return successResponse({ return successResponse(
id: updatedUser.id, {
username: updatedUser.username, id: updatedUser.id,
email: updatedUser.email, username: updatedUser.username,
updatedAt: updatedUser.updatedAt, email: updatedUser.email,
reset: true, updatedAt: updatedUser.updatedAt,
message: '密码重置成功,请使用新密码登录' reset: true,
}, '密码重置成功,请使用新密码登录'); message: '密码重置成功,请使用新密码登录',
},
'密码重置成功,请使用新密码登录',
);
} finally { } finally {
// 释放锁 // 释放锁
await lock.release(); await lock.release();
@ -829,25 +860,30 @@ export class AuthService {
* @param newPasswordHash * @param newPasswordHash
* @returns Promise<更新后的用户信息> * @returns Promise<更新后的用户信息>
*/ */
private async updateUserPassword(userId: string, newPasswordHash: string): Promise<{ private async updateUserPassword(
userId: string,
newPasswordHash: string,
): Promise<{
id: string; id: string;
username: string; username: string;
email: string; email: string;
updatedAt: string; updatedAt: string;
}> { }> {
await db().update(sysUsers) await db()
.update(sysUsers)
.set({ .set({
passwordHash: newPasswordHash, passwordHash: newPasswordHash,
}) })
.where(eq(sysUsers.id, BigInt(userId))); .where(eq(sysUsers.id, BigInt(userId)));
// 查询更新后的用户信息 // 查询更新后的用户信息
const [updatedUser] = await db().select({ const [updatedUser] = await db()
id: sysUsers.id, .select({
username: sysUsers.username, id: sysUsers.id,
email: sysUsers.email, username: sysUsers.username,
updatedAt: sysUsers.updatedAt email: sysUsers.email,
}) updatedAt: sysUsers.updatedAt,
})
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId))) .where(eq(sysUsers.id, BigInt(userId)))
.limit(1); .limit(1);
@ -860,7 +896,7 @@ export class AuthService {
id: updatedUser.id!.toString(), id: updatedUser.id!.toString(),
username: updatedUser.username, username: updatedUser.username,
email: updatedUser.email, email: updatedUser.email,
updatedAt: updatedUser.updatedAt updatedAt: updatedUser.updatedAt,
}; };
} }
@ -896,7 +932,7 @@ export class AuthService {
<p></p> <p></p>
<p><br></p> <p><br></p>
</div> </div>
` `,
}; };
await emailService.sendEmail(emailContent); await emailService.sendEmail(emailContent);
@ -905,4 +941,4 @@ export class AuthService {
} }
// 导出单例实例 // 导出单例实例
export const authService = new AuthService(); export const authService = new AuthService();

View File

@ -18,36 +18,40 @@
#### 1.1 正常流程测试 #### 1.1 正常流程测试
**测试用例**: 成功注册新用户 **测试用例**: 成功注册新用户
- **请求参数**: - **请求参数**:
```json ```json
{ {
"username": "testuser", "username": "testuser",
"email": "test@example.com", "email": "test@example.com",
"password": "password123", "password": "password123",
"captcha": "a1b2", "captcha": "a1b2",
"captchaId": "test_captcha_id" "captchaId": "test_captcha_id"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 用户信息正确创建 - 用户信息正确创建
- 密码已加密存储 - 密码已加密存储
- 激活邮件已发送 - 激活邮件已发送
- 用户状态为pending - 用户状态为pending
#### 1.2 异常流程测试 #### 1.2 异常流程测试
**测试用例**: 用户名已存在 **测试用例**: 用户名已存在
- **请求参数**: 使用已存在的用户名 - **请求参数**: 使用已存在的用户名
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "用户名已存在" - **错误信息**: "用户名已存在"
**测试用例**: 邮箱已被注册 **测试用例**: 邮箱已被注册
- **请求参数**: 使用已注册的邮箱 - **请求参数**: 使用已注册的邮箱
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "邮箱已被注册" - **错误信息**: "邮箱已被注册"
**测试用例**: 验证码错误 **测试用例**: 验证码错误
- **请求参数**: 错误的验证码 - **请求参数**: 错误的验证码
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "验证码验证失败" - **错误信息**: "验证码验证失败"
@ -57,20 +61,22 @@
#### 2.1 正常流程测试 #### 2.1 正常流程测试
**测试用例**: 成功激活用户邮箱 **测试用例**: 成功激活用户邮箱
- **请求参数**: - **请求参数**:
```json ```json
{ {
"token": "valid_activation_token" "token": "valid_activation_token"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 用户状态更新为active - 用户状态更新为active
- 激活时间正确记录 - 激活时间正确记录
#### 2.2 异常流程测试 #### 2.2 异常流程测试
**测试用例**: 无效的激活令牌 **测试用例**: 无效的激活令牌
- **请求参数**: 无效或过期的令牌 - **请求参数**: 无效或过期的令牌
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "激活令牌无效或已过期" - **错误信息**: "激活令牌无效或已过期"
@ -80,42 +86,47 @@
#### 3.1 正常流程测试 #### 3.1 正常流程测试
**测试用例**: 用户名登录成功 **测试用例**: 用户名登录成功
- **请求参数**: - **请求参数**:
```json ```json
{ {
"identifier": "testuser", "identifier": "testuser",
"password": "password123" "password": "password123"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 返回访问令牌和刷新令牌 - 返回访问令牌和刷新令牌
- 最后登录时间更新 - 最后登录时间更新
- 登录日志记录 - 登录日志记录
**测试用例**: 邮箱登录成功 **测试用例**: 邮箱登录成功
- **请求参数**: - **请求参数**:
```json ```json
{ {
"identifier": "test@example.com", "identifier": "test@example.com",
"password": "password123" "password": "password123"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
#### 3.2 异常流程测试 #### 3.2 异常流程测试
**测试用例**: 用户名不存在 **测试用例**: 用户名不存在
- **请求参数**: 不存在的用户名 - **请求参数**: 不存在的用户名
- **预期响应**: 404 Not Found - **预期响应**: 404 Not Found
- **错误信息**: "用户不存在" - **错误信息**: "用户不存在"
**测试用例**: 密码错误 **测试用例**: 密码错误
- **请求参数**: 错误的密码 - **请求参数**: 错误的密码
- **预期响应**: 401 Unauthorized - **预期响应**: 401 Unauthorized
- **错误信息**: "用户名或密码错误" - **错误信息**: "用户名或密码错误"
**测试用例**: 账号未激活 **测试用例**: 账号未激活
- **请求参数**: 未激活用户的凭据 - **请求参数**: 未激活用户的凭据
- **预期响应**: 403 Forbidden - **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱" - **错误信息**: "账号未激活,请先激活邮箱"
@ -125,20 +136,22 @@
#### 4.1 正常流程测试 #### 4.1 正常流程测试
**测试用例**: 成功刷新令牌 **测试用例**: 成功刷新令牌
- **请求参数**: - **请求参数**:
```json ```json
{ {
"refreshToken": "valid_refresh_token" "refreshToken": "valid_refresh_token"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 返回新的访问令牌和刷新令牌 - 返回新的访问令牌和刷新令牌
- 刷新日志记录 - 刷新日志记录
#### 4.2 异常流程测试 #### 4.2 异常流程测试
**测试用例**: 无效的刷新令牌 **测试用例**: 无效的刷新令牌
- **请求参数**: 无效或过期的刷新令牌 - **请求参数**: 无效或过期的刷新令牌
- **预期响应**: 401 Unauthorized - **预期响应**: 401 Unauthorized
- **错误信息**: "刷新令牌无效或已过期" - **错误信息**: "刷新令牌无效或已过期"
@ -148,33 +161,37 @@
#### 5.1 正常流程测试 #### 5.1 正常流程测试
**测试用例**: 成功发送重置邮件 **测试用例**: 成功发送重置邮件
- **请求参数**: - **请求参数**:
```json ```json
{ {
"email": "test@example.com", "email": "test@example.com",
"captcha": "a1b2", "captcha": "a1b2",
"captchaId": "test_captcha_id" "captchaId": "test_captcha_id"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 重置邮件已发送 - 重置邮件已发送
- 重置令牌已生成 - 重置令牌已生成
- 返回发送状态和时间 - 返回发送状态和时间
#### 5.2 异常流程测试 #### 5.2 异常流程测试
**测试用例**: 邮箱未注册 **测试用例**: 邮箱未注册
- **请求参数**: 未注册的邮箱地址 - **请求参数**: 未注册的邮箱地址
- **预期响应**: 404 Not Found - **预期响应**: 404 Not Found
- **错误信息**: "该邮箱未注册" - **错误信息**: "该邮箱未注册"
**测试用例**: 验证码错误 **测试用例**: 验证码错误
- **请求参数**: 错误的验证码 - **请求参数**: 错误的验证码
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "验证码验证失败" - **错误信息**: "验证码验证失败"
**测试用例**: 账号未激活 **测试用例**: 账号未激活
- **请求参数**: 未激活用户的邮箱 - **请求参数**: 未激活用户的邮箱
- **预期响应**: 403 Forbidden - **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱" - **错误信息**: "账号未激活,请先激活邮箱"
@ -184,39 +201,44 @@
#### 6.1 正常流程测试 #### 6.1 正常流程测试
**测试用例**: 成功重置密码 **测试用例**: 成功重置密码
- **请求参数**: - **请求参数**:
```json ```json
{ {
"token": "valid_reset_token", "token": "valid_reset_token",
"newPassword": "newpassword123", "newPassword": "newpassword123",
"confirmPassword": "newpassword123" "confirmPassword": "newpassword123"
} }
``` ```
- **预期响应**: 200 OK - **预期响应**: 200 OK
- **验证点**: - **验证点**:
- 密码已更新 - 密码已更新
- 重置令牌已失效 - 重置令牌已失效
- 成功邮件已发送 - 成功邮件已发送
- 返回用户基本信息 - 返回用户基本信息
#### 6.2 异常流程测试 #### 6.2 异常流程测试
**测试用例**: 重置令牌无效 **测试用例**: 重置令牌无效
- **请求参数**: 无效或过期的重置令牌 - **请求参数**: 无效或过期的重置令牌
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "重置令牌无效或已过期" - **错误信息**: "重置令牌无效或已过期"
**测试用例**: 密码不一致 **测试用例**: 密码不一致
- **请求参数**: 新密码和确认密码不一致 - **请求参数**: 新密码和确认密码不一致
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "两次输入的密码不一致" - **错误信息**: "两次输入的密码不一致"
**测试用例**: 密码长度不足 **测试用例**: 密码长度不足
- **请求参数**: 新密码少于6字符 - **请求参数**: 新密码少于6字符
- **预期响应**: 400 Bad Request - **预期响应**: 400 Bad Request
- **错误信息**: "密码长度不符合要求" - **错误信息**: "密码长度不符合要求"
**测试用例**: 账号未激活 **测试用例**: 账号未激活
- **请求参数**: 未激活用户的重置令牌 - **请求参数**: 未激活用户的重置令牌
- **预期响应**: 403 Forbidden - **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱" - **错误信息**: "账号未激活,请先激活邮箱"
@ -228,15 +250,18 @@
### 1. 输入验证边界 ### 1. 输入验证边界
**测试用例**: 用户名长度边界 **测试用例**: 用户名长度边界
- 最小长度: 2字符 - 最小长度: 2字符
- 最大长度: 50字符 - 最大长度: 50字符
- 超出范围应返回400错误 - 超出范围应返回400错误
**测试用例**: 邮箱格式验证 **测试用例**: 邮箱格式验证
- 有效邮箱格式应通过验证 - 有效邮箱格式应通过验证
- 无效邮箱格式应返回400错误 - 无效邮箱格式应返回400错误
**测试用例**: 密码强度要求 **测试用例**: 密码强度要求
- 最小长度: 6字符 - 最小长度: 6字符
- 最大长度: 50字符 - 最大长度: 50字符
- 超出范围应返回400错误 - 超出范围应返回400错误
@ -244,20 +269,24 @@
### 2. 并发测试 ### 2. 并发测试
**测试用例**: 并发注册 **测试用例**: 并发注册
- 同时使用相同用户名注册 - 同时使用相同用户名注册
- 应只有一个成功,其他失败 - 应只有一个成功,其他失败
**测试用例**: 并发登录 **测试用例**: 并发登录
- 同一用户同时登录 - 同一用户同时登录
- 应都能成功,但刷新令牌会失效 - 应都能成功,但刷新令牌会失效
### 3. 性能测试 ### 3. 性能测试
**测试用例**: 大量用户注册 **测试用例**: 大量用户注册
- 测试系统在高并发下的表现 - 测试系统在高并发下的表现
- 验证数据库连接池和缓存性能 - 验证数据库连接池和缓存性能
**测试用例**: 邮件发送性能 **测试用例**: 邮件发送性能
- 测试邮件服务的并发处理能力 - 测试邮件服务的并发处理能力
- 验证邮件队列机制 - 验证邮件队列机制
@ -266,30 +295,36 @@
### 1. 密码安全 ### 1. 密码安全
**测试用例**: 密码加密存储 **测试用例**: 密码加密存储
- 验证密码是否使用bcrypt加密 - 验证密码是否使用bcrypt加密
- 确认原始密码不在数据库中 - 确认原始密码不在数据库中
**测试用例**: 密码强度验证 **测试用例**: 密码强度验证
- 测试弱密码的拒绝机制 - 测试弱密码的拒绝机制
- 验证密码复杂度要求 - 验证密码复杂度要求
### 2. 令牌安全 ### 2. 令牌安全
**测试用例**: JWT令牌验证 **测试用例**: JWT令牌验证
- 验证令牌签名和过期时间 - 验证令牌签名和过期时间
- 测试令牌篡改检测 - 测试令牌篡改检测
**测试用例**: 令牌刷新安全 **测试用例**: 令牌刷新安全
- 验证刷新令牌的一次性使用 - 验证刷新令牌的一次性使用
- 测试令牌泄露防护 - 测试令牌泄露防护
### 3. 输入安全 ### 3. 输入安全
**测试用例**: SQL注入防护 **测试用例**: SQL注入防护
- 测试特殊字符输入 - 测试特殊字符输入
- 验证参数化查询 - 验证参数化查询
**测试用例**: XSS防护 **测试用例**: XSS防护
- 测试恶意脚本输入 - 测试恶意脚本输入
- 验证输出转义 - 验证输出转义
@ -359,4 +394,4 @@ bun test src/modules/auth/auth.test.ts --reporter=verbose
2. **环境变量**: 测试环境应使用专门的配置 2. **环境变量**: 测试环境应使用专门的配置
3. **异步操作**: 邮件发送等异步操作需要适当的等待时间 3. **异步操作**: 邮件发送等异步操作需要适当的等待时间
4. **资源清理**: 测试完成后应清理所有测试数据 4. **资源清理**: 测试完成后应清理所有测试数据
5. **错误处理**: 测试应覆盖各种错误情况 5. **错误处理**: 测试应覆盖各种错误情况

View File

@ -40,11 +40,9 @@ describe('认证模块测试', () => {
// 清理测试数据和关闭连接 // 清理测试数据和关闭连接
try { try {
// 删除测试用户 // 删除测试用户
await drizzleService.db.delete(sysUsers) await drizzleService.db.delete(sysUsers).where(eq(sysUsers.username, 'testuser'));
.where(eq(sysUsers.username, 'testuser')); await drizzleService.db.delete(sysUsers).where(eq(sysUsers.email, 'test@example.com'));
await drizzleService.db.delete(sysUsers)
.where(eq(sysUsers.email, 'test@example.com'));
await redisService.close(); await redisService.close();
await drizzleService.close(); await drizzleService.close();
} catch (error) { } catch (error) {
@ -55,23 +53,24 @@ describe('认证模块测试', () => {
beforeEach(async () => { beforeEach(async () => {
// 每个测试前生成新的验证码 // 每个测试前生成新的验证码
try { try {
const captchaResponse = await testApp const captchaResponse = await testApp.handle(
.handle(new Request('http://localhost/api/captcha/generate', { new Request('http://localhost/api/captcha/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
type: 'image', type: 'image',
length: 4, length: 4,
expireTime: 300 expireTime: 300,
}), }),
})); }),
);
if (captchaResponse.status === 200) { if (captchaResponse.status === 200) {
const captchaResult = await captchaResponse.json() as any; const captchaResult = (await captchaResponse.json()) as any;
captchaId = captchaResult.data.id; captchaId = captchaResult.data.id;
// 模拟已知验证码在实际测试中可能需要直接从Redis获取 // 模拟已知验证码在实际测试中可能需要直接从Redis获取
validCaptchaCode = 'TEST'; validCaptchaCode = 'TEST';
// 直接在Redis中设置已知的验证码用于测试 // 直接在Redis中设置已知的验证码用于测试
await redisService.setex( await redisService.setex(
`captcha:${captchaId}`, `captcha:${captchaId}`,
@ -82,8 +81,8 @@ describe('认证模块测试', () => {
type: 'image', type: 'image',
image: 'test-image-data', image: 'test-image-data',
expireTime: Date.now() + 300000, expireTime: Date.now() + 300000,
createdAt: Date.now() createdAt: Date.now(),
}) }),
); );
} }
} catch (error) { } catch (error) {
@ -100,18 +99,19 @@ describe('认证模块测试', () => {
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.message).toBe('用户注册成功'); expect(result.message).toBe('用户注册成功');
expect(result.data).toBeDefined(); expect(result.data).toBeDefined();
@ -129,34 +129,36 @@ describe('认证模块测试', () => {
email: 'existing@example.com', email: 'existing@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
await testApp await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(firstPayload), body: JSON.stringify(firstPayload),
})); }),
);
// 重新生成验证码 // 重新生成验证码
try { try {
const captchaResponse = await testApp const captchaResponse = await testApp.handle(
.handle(new Request('http://localhost/api/captcha/generate', { new Request('http://localhost/api/captcha/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
type: 'image', type: 'image',
length: 4, length: 4,
expireTime: 300 expireTime: 300,
}), }),
})); }),
);
if (captchaResponse.status === 200) { if (captchaResponse.status === 200) {
const captchaResult = await captchaResponse.json() as any; const captchaResult = (await captchaResponse.json()) as any;
captchaId = captchaResult.data.id; captchaId = captchaResult.data.id;
validCaptchaCode = 'TEST'; validCaptchaCode = 'TEST';
// 直接在Redis中设置已知的验证码用于测试 // 直接在Redis中设置已知的验证码用于测试
await redisService.setex( await redisService.setex(
`captcha:${captchaId}`, `captcha:${captchaId}`,
@ -167,8 +169,8 @@ describe('认证模块测试', () => {
type: 'image', type: 'image',
image: 'test-image-data', image: 'test-image-data',
expireTime: Date.now() + 300000, expireTime: Date.now() + 300000,
createdAt: Date.now() createdAt: Date.now(),
}) }),
); );
} }
} catch (error) { } catch (error) {
@ -182,18 +184,19 @@ describe('认证模块测试', () => {
email: 'different@example.com', email: 'different@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(duplicatePayload), body: JSON.stringify(duplicatePayload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('USERNAME_EXISTS'); expect(result.code).toBe('USERNAME_EXISTS');
expect(result.message).toBe('用户名已存在'); expect(result.message).toBe('用户名已存在');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -205,18 +208,19 @@ describe('认证模块测试', () => {
email: 'test@example.com', // 使用之前注册的邮箱 email: 'test@example.com', // 使用之前注册的邮箱
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(duplicateEmailPayload), body: JSON.stringify(duplicateEmailPayload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('EMAIL_EXISTS'); expect(result.code).toBe('EMAIL_EXISTS');
expect(result.message).toBe('邮箱已被注册'); expect(result.message).toBe('邮箱已被注册');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -228,18 +232,19 @@ describe('认证模块测试', () => {
email: 'newuser2@example.com', email: 'newuser2@example.com',
password: 'password123', password: 'password123',
captcha: 'WRONG', captcha: 'WRONG',
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('CAPTCHA_ERROR'); expect(result.code).toBe('CAPTCHA_ERROR');
expect(result.message).toContain('验证码'); expect(result.message).toContain('验证码');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -251,18 +256,19 @@ describe('认证模块测试', () => {
email: 'newuser3@example.com', email: 'newuser3@example.com',
password: 'password123', password: 'password123',
captcha: 'TEST', captcha: 'TEST',
captchaId: 'nonexistent-captcha-id' captchaId: 'nonexistent-captcha-id',
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('CAPTCHA_ERROR'); expect(result.code).toBe('CAPTCHA_ERROR');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
}); });
@ -275,15 +281,16 @@ describe('认证模块测试', () => {
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -294,15 +301,16 @@ describe('认证模块测试', () => {
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -313,15 +321,16 @@ describe('认证模块测试', () => {
email: 'invalid-email', // 无效邮箱格式 email: 'invalid-email', // 无效邮箱格式
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -332,15 +341,16 @@ describe('认证模块测试', () => {
email: 'test@example.com', email: 'test@example.com',
password: '12345', // 5个字符小于最小长度6 password: '12345', // 5个字符小于最小长度6
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -351,15 +361,16 @@ describe('认证模块测试', () => {
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'password123',
captcha: '123', // 3个字符小于最小长度4 captcha: '123', // 3个字符小于最小长度4
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -370,15 +381,16 @@ describe('认证模块测试', () => {
// 缺少 email // 缺少 email
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -391,15 +403,16 @@ describe('认证模块测试', () => {
email: 'min2@example.com', email: 'min2@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
@ -410,15 +423,16 @@ describe('认证模块测试', () => {
email: 'max2@example.com', email: 'max2@example.com',
password: 'password123', password: 'password123',
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
@ -429,15 +443,16 @@ describe('认证模块测试', () => {
email: 'minpass2@example.com', email: 'minpass2@example.com',
password: '123456', // 6个字符正好等于最小长度 password: '123456', // 6个字符正好等于最小长度
captcha: validCaptchaCode, captcha: validCaptchaCode,
captchaId: captchaId captchaId: captchaId,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/register', { new Request('http://localhost/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
@ -454,7 +469,7 @@ describe('认证模块测试', () => {
testUserId = '1234567890123456789'; // 模拟bigint ID字符串 testUserId = '1234567890123456789'; // 模拟bigint ID字符串
testUserEmail = 'activate@example.com'; testUserEmail = 'activate@example.com';
testUsername = 'activateuser'; testUsername = 'activateuser';
// 模拟有效的激活Token载荷实际应该是JWT签名 // 模拟有效的激活Token载荷实际应该是JWT签名
validActivationToken = JSON.stringify({ validActivationToken = JSON.stringify({
userId: testUserId, userId: testUserId,
@ -469,15 +484,16 @@ describe('认证模块测试', () => {
iat: Math.floor(Date.now() / 1000), iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400, // 24小时后过期 exp: Math.floor(Date.now() / 1000) + 86400, // 24小时后过期
}); });
// 创建一个pending状态的测试用户 // 创建一个pending状态的测试用户
try { try {
// 先检查用户是否存在 // 先检查用户是否存在
const existingUser = await drizzleService.db.select({ id: sysUsers.id }) const existingUser = await drizzleService.db
.select({ id: sysUsers.id })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(testUserId))) .where(eq(sysUsers.id, BigInt(testUserId)))
.limit(1); .limit(1);
if (existingUser.length === 0) { if (existingUser.length === 0) {
await drizzleService.db.insert(sysUsers).values({ await drizzleService.db.insert(sysUsers).values({
id: BigInt(testUserId), id: BigInt(testUserId),
@ -495,8 +511,7 @@ describe('认证模块测试', () => {
afterEach(async () => { afterEach(async () => {
// 清理测试用户 // 清理测试用户
try { try {
await drizzleService.db.delete(sysUsers) await drizzleService.db.delete(sysUsers).where(eq(sysUsers.id, BigInt(testUserId)));
.where(eq(sysUsers.id, BigInt(testUserId)));
} catch (error) { } catch (error) {
// 忽略清理错误 // 忽略清理错误
} }
@ -504,18 +519,19 @@ describe('认证模块测试', () => {
it('应该成功激活用户邮箱', async () => { it('应该成功激活用户邮箱', async () => {
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: validActivationToken token: validActivationToken,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.message).toBe('邮箱激活成功'); expect(result.message).toBe('邮箱激活成功');
expect(result.data).toBeDefined(); expect(result.data).toBeDefined();
@ -529,18 +545,19 @@ describe('认证模块测试', () => {
it('Token格式无效应返回400错误', async () => { it('Token格式无效应返回400错误', async () => {
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: 'invalid-token-format' token: 'invalid-token-format',
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); expect(result.code).toBe('INVALID_ACTIVATION_TOKEN');
expect(result.message).toContain('令牌'); expect(result.message).toContain('令牌');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -548,18 +565,19 @@ describe('认证模块测试', () => {
it('Token为空应返回400错误', async () => { it('Token为空应返回400错误', async () => {
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: '' token: '',
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); expect(result.code).toBe('INVALID_ACTIVATION_TOKEN');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
}); });
@ -568,22 +586,23 @@ describe('认证模块测试', () => {
const invalidToken = JSON.stringify({ const invalidToken = JSON.stringify({
// 缺少必要字段 // 缺少必要字段
userId: testUserId, userId: testUserId,
tokenType: 'wrong-type' tokenType: 'wrong-type',
}); });
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: invalidToken token: invalidToken,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); expect(result.code).toBe('INVALID_ACTIVATION_TOKEN');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
}); });
@ -604,18 +623,19 @@ describe('认证模块测试', () => {
}); });
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: expiredToken token: expiredToken,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('INVALID_ACTIVATION_TOKEN'); expect(result.code).toBe('INVALID_ACTIVATION_TOKEN');
expect(result.message).toContain('过期'); expect(result.message).toContain('过期');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -637,18 +657,19 @@ describe('认证模块测试', () => {
}); });
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: nonExistentUserToken token: nonExistentUserToken,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(404); expect(response.status).toBe(404);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('USER_NOT_FOUND'); expect(result.code).toBe('USER_NOT_FOUND');
expect(result.message).toBe('用户不存在'); expect(result.message).toBe('用户不存在');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
@ -656,29 +677,32 @@ describe('认证模块测试', () => {
it('账号已激活应返回409错误', async () => { it('账号已激活应返回409错误', async () => {
// 先激活用户 // 先激活用户
await drizzleService.db.update(sysUsers) await drizzleService.db
.update(sysUsers)
.set({ status: 'active' }) .set({ status: 'active' })
.where(eq(sysUsers.id, BigInt(testUserId))); .where(eq(sysUsers.id, BigInt(testUserId)));
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: validActivationToken token: validActivationToken,
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(409); expect(response.status).toBe(409);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('ALREADY_ACTIVATED'); expect(result.code).toBe('ALREADY_ACTIVATED');
expect(result.message).toBe('账号已经激活'); expect(result.message).toBe('账号已经激活');
expect(result.data).toBeNull(); expect(result.data).toBeNull();
// 恢复为pending状态便于其他测试 // 恢复为pending状态便于其他测试
await drizzleService.db.update(sysUsers) await drizzleService.db
.update(sysUsers)
.set({ status: 'pending' }) .set({ status: 'pending' })
.where(eq(sysUsers.id, BigInt(testUserId))); .where(eq(sysUsers.id, BigInt(testUserId)));
}); });
@ -686,44 +710,47 @@ describe('认证模块测试', () => {
it('缺少Token参数应返回400错误', async () => { it('缺少Token参数应返回400错误', async () => {
const payload = {}; // 缺少token字段 const payload = {}; // 缺少token字段
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
it('Token长度过短应返回400错误', async () => { it('Token长度过短应返回400错误', async () => {
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: 'short' // 长度小于10 token: 'short', // 长度小于10
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
it('Token长度过长应返回400错误', async () => { it('Token长度过长应返回400错误', async () => {
const payload: ActivateRequest = { const payload: ActivateRequest = {
token: 'a'.repeat(1001) // 长度超过1000 token: 'a'.repeat(1001), // 长度超过1000
}; };
const response = await testApp const response = await testApp.handle(
.handle(new Request('http://localhost/api/auth/activate', { new Request('http://localhost/api/auth/activate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
}); });
}); });

View File

@ -16,51 +16,39 @@ export const captchaController = new Elysia()
* *
* @route POST /api/captcha/generate * @route POST /api/captcha/generate
*/ */
.post( .post('/generate', ({ body }) => captchaService.generateCaptcha(body), {
'/generate', body: GenerateCaptchaSchema,
({ body }) => captchaService.generateCaptcha(body), detail: {
{ summary: '生成验证码',
body: GenerateCaptchaSchema, description: '生成图形验证码,支持自定义尺寸和过期时间',
detail: { tags: [tags.captcha],
summary: '生成验证码', },
description: '生成图形验证码,支持自定义尺寸和过期时间', response: { 200: responseWrapperSchema(t.Any()) },
tags: [tags.captcha], })
},
response: {200: responseWrapperSchema(t.Any())},
}
)
/** /**
* *
* @route POST /api/captcha/verify * @route POST /api/captcha/verify
*/ */
.post( .post('/verify', ({ body }) => captchaService.verifyCaptcha(body), {
'/verify', body: VerifyCaptchaSchema,
({ body }) => captchaService.verifyCaptcha(body), detail: {
{ summary: '验证验证码',
body: VerifyCaptchaSchema, description: '验证用户输入的验证码是否正确',
detail: { tags: [tags.captcha],
summary: '验证验证码', },
description: '验证用户输入的验证码是否正确', response: { 200: responseWrapperSchema(t.Any()) },
tags: [tags.captcha], })
},
response: {200: responseWrapperSchema(t.Any())},
}
)
/** /**
* *
* @route POST /api/captcha/cleanup * @route POST /api/captcha/cleanup
*/ */
.post( .post('/cleanup', () => captchaService.cleanupExpiredCaptchas(), {
'/cleanup', detail: {
() => captchaService.cleanupExpiredCaptchas(), summary: '清理过期验证码',
{ description: '清理Redis中已过期的验证码数据',
detail: { tags: [tags.captcha],
summary: '清理过期验证码', },
description: '清理Redis中已过期的验证码数据', response: { 200: responseWrapperSchema(t.Any()) },
tags: [tags.captcha], });
},
response: {200: responseWrapperSchema(t.Any())},
}
);

View File

@ -11,64 +11,72 @@ import { t, type Static } from 'elysia';
* Schema * Schema
*/ */
export const GenerateCaptchaSchema = t.Object({ export const GenerateCaptchaSchema = t.Object({
type: t.Optional(t.Union([ type: t.Optional(
t.Literal('image'), t.Union([t.Literal('image'), t.Literal('sms'), t.Literal('email')], {
t.Literal('sms'), description: '验证码类型',
t.Literal('email') examples: ['image', 'sms', 'email'],
], { default: 'image',
description: '验证码类型', }),
examples: ['image', 'sms', 'email'], ),
default: 'image' width: t.Optional(
})), t.Number({
width: t.Optional(t.Number({ minimum: 100,
minimum: 100, maximum: 400,
maximum: 400, description: '验证码图片宽度',
description: '验证码图片宽度', examples: [200],
examples: [200], default: 200,
default: 200 }),
})), ),
height: t.Optional(t.Number({ height: t.Optional(
minimum: 40, t.Number({
maximum: 100, minimum: 40,
description: '验证码图片高度', maximum: 100,
examples: [60], description: '验证码图片高度',
default: 60 examples: [60],
})), default: 60,
length: t.Optional(t.Number({ }),
minimum: 4, ),
maximum: 8, length: t.Optional(
description: '验证码长度', t.Number({
examples: [4], minimum: 4,
default: 4 maximum: 8,
})), description: '验证码长度',
expireTime: t.Optional(t.Number({ examples: [4],
minimum: 60, default: 4,
maximum: 1800, }),
description: '验证码过期时间(秒)', ),
examples: [300], expireTime: t.Optional(
default: 300 t.Number({
})) minimum: 60,
maximum: 1800,
description: '验证码过期时间(秒)',
examples: [300],
default: 300,
}),
),
}); });
/** /**
* Schema * Schema
*/ */
export const VerifyCaptchaSchema = t.Object({ export const VerifyCaptchaSchema = t.Object({
captchaId: t.String({ captchaId: t.String({
minLength: 1, minLength: 1,
description: '验证码ID', description: '验证码ID',
examples: ['captcha_1234567890'] examples: ['captcha_1234567890'],
}), }),
captchaCode: t.String({ captchaCode: t.String({
minLength: 4, minLength: 4,
maxLength: 8, maxLength: 8,
description: '用户输入的验证码', description: '用户输入的验证码',
examples: ['1234'] examples: ['1234'],
}), }),
scene: t.Optional(t.String({ scene: t.Optional(
description: '验证场景', t.String({
examples: ['login', 'register', 'reset_password'] description: '验证场景',
})) examples: ['login', 'register', 'reset_password'],
}),
),
}); });
/** /**
@ -81,7 +89,7 @@ export const CaptchaDataSchema = t.Object({
image: t.Optional(t.String({ description: 'Base64图片数据' })), image: t.Optional(t.String({ description: 'Base64图片数据' })),
expireTime: t.Number({ description: '过期时间戳' }), expireTime: t.Number({ description: '过期时间戳' }),
scene: t.Optional(t.String({ description: '验证场景' })), scene: t.Optional(t.String({ description: '验证场景' })),
createdAt: t.Number({ description: '创建时间戳' }) createdAt: t.Number({ description: '创建时间戳' }),
}); });
/** /**
@ -91,11 +99,11 @@ export const CaptchaGenerateResponseSchema = t.Object({
id: t.String({ description: '验证码ID' }), id: t.String({ description: '验证码ID' }),
image: t.String({ description: 'Base64编码的验证码图片' }), image: t.String({ description: 'Base64编码的验证码图片' }),
expireTime: t.Number({ description: '过期时间戳' }), expireTime: t.Number({ description: '过期时间戳' }),
type: t.String({ description: '验证码类型' }) type: t.String({ description: '验证码类型' }),
}); });
// 导出TypeScript类型 // 导出TypeScript类型
export type GenerateCaptchaRequest = Static<typeof GenerateCaptchaSchema>; export type GenerateCaptchaRequest = Static<typeof GenerateCaptchaSchema>;
export type VerifyCaptchaRequest = Static<typeof VerifyCaptchaSchema>; export type VerifyCaptchaRequest = Static<typeof VerifyCaptchaSchema>;
export type CaptchaData = Static<typeof CaptchaDataSchema>; export type CaptchaData = Static<typeof CaptchaDataSchema>;
export type CaptchaGenerateResponse = Static<typeof CaptchaGenerateResponseSchema>; export type CaptchaGenerateResponse = Static<typeof CaptchaGenerateResponseSchema>;

View File

@ -11,7 +11,7 @@ import type {
GenerateCaptchaRequest, GenerateCaptchaRequest,
VerifyCaptchaRequest, VerifyCaptchaRequest,
CaptchaData, CaptchaData,
CaptchaGenerateResponse CaptchaGenerateResponse,
} from './captcha.schema'; } from './captcha.schema';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import { redisService } from '@/plugins/redis/redis.service'; import { redisService } from '@/plugins/redis/redis.service';
@ -24,13 +24,7 @@ export class CaptchaService {
* @returns Promise<GenerateCaptchaSuccessResponse> * @returns Promise<GenerateCaptchaSuccessResponse>
*/ */
async generateCaptcha(body: GenerateCaptchaRequest) { async generateCaptcha(body: GenerateCaptchaRequest) {
const { const { type = 'image', width = 200, height = 60, length = 4, expireTime = 300 } = body;
type = 'image',
width = 200,
height = 60,
length = 4,
expireTime = 300
} = body;
// 生成验证码ID // 生成验证码ID
const captchaId = `captcha_${randomBytes(16).toString('hex')}`; const captchaId = `captcha_${randomBytes(16).toString('hex')}`;
@ -39,7 +33,7 @@ export class CaptchaService {
const code = this.generateRandomCode(length); const code = this.generateRandomCode(length);
// 计算过期时间 // 计算过期时间
const expireTimestamp = Date.now() + (expireTime * 1000); const expireTimestamp = Date.now() + expireTime * 1000;
let imageData: string | undefined; let imageData: string | undefined;
@ -55,7 +49,7 @@ export class CaptchaService {
type, type,
image: imageData, image: imageData,
expireTime: expireTimestamp, expireTime: expireTimestamp,
createdAt: Date.now() createdAt: Date.now(),
}; };
// 存储到Redis // 存储到Redis
@ -69,7 +63,7 @@ export class CaptchaService {
id: captchaId, id: captchaId,
image: imageData || '', image: imageData || '',
expireTime: expireTimestamp, expireTime: expireTimestamp,
type type,
}; };
return successResponse(responseData); return successResponse(responseData);
@ -106,9 +100,7 @@ export class CaptchaService {
// 验证成功后删除验证码,防止重复使用 // 验证成功后删除验证码,防止重复使用
await redisService.del(redisKey); await redisService.del(redisKey);
Logger.info(`验证码验证成功:${captchaId}`); Logger.info(`验证码验证成功:${captchaId}`);
return successResponse( return successResponse({ valid: true }, '验证码验证成功');
{ valid: true }, '验证码验证成功'
);
} else { } else {
throw new BusinessError('验证码错误', 400); throw new BusinessError('验证码错误', 400);
} }
@ -214,11 +206,9 @@ export class CaptchaService {
} }
Logger.info(`清理过期验证码完成,共清理 ${cleanedCount}`); Logger.info(`清理过期验证码完成,共清理 ${cleanedCount}`);
return successResponse( return successResponse({ cleanedCount }, '清理完成');
{ cleanedCount }, '清理完成'
);
} }
} }
// 导出单例实例 // 导出单例实例
export const captchaService = new CaptchaService(); export const captchaService = new CaptchaService();

View File

@ -45,18 +45,19 @@ describe('Captcha API', () => {
width: 200, width: 200,
height: 60, height: 60,
length: 4, length: 4,
expireTime: 300 expireTime: 300,
}; };
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/generate', { new Request('http://localhost/api/captcha/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.data.id).toBeDefined(); expect(result.data.id).toBeDefined();
expect(result.data.image).toBeDefined(); expect(result.data.image).toBeDefined();
@ -71,15 +72,16 @@ describe('Captcha API', () => {
it('应该使用默认参数生成验证码', async () => { it('应该使用默认参数生成验证码', async () => {
const payload = {}; const payload = {};
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/generate', { new Request('http://localhost/api/captcha/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.data.type).toBe('image'); expect(result.data.type).toBe('image');
expect(result.data.image).toMatch(/^data:image\/png;base64,/); expect(result.data.image).toMatch(/^data:image\/png;base64,/);
@ -90,15 +92,16 @@ describe('Captcha API', () => {
width: 50, // 小于最小值100 width: 50, // 小于最小值100
height: 20, // 小于最小值40 height: 20, // 小于最小值40
length: 2, // 小于最小值4 length: 2, // 小于最小值4
expireTime: 30 // 小于最小值60 expireTime: 30, // 小于最小值60
}; };
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/generate', { new Request('http://localhost/api/captcha/generate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -109,18 +112,19 @@ describe('Captcha API', () => {
const payload: VerifyCaptchaRequest = { const payload: VerifyCaptchaRequest = {
captchaId: 'nonexistent_captcha_id', captchaId: 'nonexistent_captcha_id',
captchaCode: '1234', captchaCode: '1234',
scene: 'login' scene: 'login',
}; };
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/verify', { new Request('http://localhost/api/captcha/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.data.valid).toBe(false); expect(result.data.valid).toBe(false);
expect(result.data.message).toContain('验证码不存在或已过期'); expect(result.data.message).toContain('验证码不存在或已过期');
@ -132,12 +136,13 @@ describe('Captcha API', () => {
captchaCode: '123', // 长度小于4 captchaCode: '123', // 长度小于4
}; };
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/verify', { new Request('http://localhost/api/captcha/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
})); }),
);
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });
@ -145,14 +150,15 @@ describe('Captcha API', () => {
describe('POST /api/captcha/cleanup', () => { describe('POST /api/captcha/cleanup', () => {
it('应该成功清理过期验证码', async () => { it('应该成功清理过期验证码', async () => {
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/captcha/cleanup', { new Request('http://localhost/api/captcha/cleanup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as any; const result = (await response.json()) as any;
expect(result.code).toBe('SUCCESS'); expect(result.code).toBe('SUCCESS');
expect(result.data.cleanedCount).toBeGreaterThanOrEqual(0); expect(result.data.cleanedCount).toBeGreaterThanOrEqual(0);
}); });
@ -161,7 +167,7 @@ describe('Captcha API', () => {
describe('验证码服务功能测试', () => { describe('验证码服务功能测试', () => {
it('应该生成指定长度的随机验证码', async () => { it('应该生成指定长度的随机验证码', async () => {
const { captchaService } = await import('./captcha.service'); const { captchaService } = await import('./captcha.service');
// 测试不同长度的验证码 // 测试不同长度的验证码
const lengths = [4, 6, 8]; const lengths = [4, 6, 8];
for (const length of lengths) { for (const length of lengths) {
@ -173,7 +179,7 @@ describe('Captcha API', () => {
it('应该生成Base64格式的图片数据', async () => { it('应该生成Base64格式的图片数据', async () => {
const { captchaService } = await import('./captcha.service'); const { captchaService } = await import('./captcha.service');
const imageData = await (captchaService as any).generateImageCaptcha('TEST', 200, 60); const imageData = await (captchaService as any).generateImageCaptcha('TEST', 200, 60);
expect(imageData).toMatch(/^data:image\/png;base64,/); expect(imageData).toMatch(/^data:image\/png;base64,/);
expect(imageData.length).toBeGreaterThan(100); // 确保有实际的图片数据 expect(imageData.length).toBeGreaterThan(100); // 确保有实际的图片数据
@ -187,4 +193,4 @@ describe('Captcha API', () => {
expect('ABC'.toLowerCase()).toBe('abc'.toLowerCase()); expect('ABC'.toLowerCase()).toBe('abc'.toLowerCase());
}); });
}); });
}); });

View File

@ -0,0 +1,35 @@
/**
* @file Controller层实现
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
* @description
*/
import { Elysia } from 'elysia';
import { dictService } from './dict.service';
import { CreateDictSchema } from './dict.schema';
import { CreateDictResponsesSchema } from './dict.response';
import { tags } from '@/constants/swaggerTags';
/**
*
* @description HTTP请求
*/
export const dictController = new Elysia()
/**
*
* @route POST /api/dict
* @description
*/
.post('/', ({ body }) => dictService.createDict(body), {
body: CreateDictSchema,
detail: {
summary: '创建字典项',
description: '创建字典项接口,需要提供字典代码、名称等基本信息',
tags: [tags.dict],
operationId: 'createDict',
},
response: CreateDictResponsesSchema,
});

View File

@ -0,0 +1,338 @@
# 字典模块业务逻辑文档
## 模块概述
字典模块提供系统字典的管理功能支持树形结构的字典数据CRUD操作。字典数据用于存储系统配置、状态选项、分类信息等静态数据。
## 数据库设计
### 表结构sys_dict
```sql
CREATE TABLE `sys_dict` (
`id` BIGINT NOT NULL COMMENT '主键ID',
`code` VARCHAR(50) NOT NULL COMMENT '字典代码,唯一标识',
`name` VARCHAR(100) NOT NULL COMMENT '字典名称',
`value` VARCHAR(200) NULL COMMENT '字典值(叶子节点才有值)',
`description` VARCHAR(500) NULL COMMENT '字典描述',
`icon` VARCHAR(100) NULL COMMENT '图标CSS类名或图标路径',
`pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID0表示顶级',
`level` INT NOT NULL DEFAULT 1 COMMENT '层级深度1为顶级',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active-启用inactive-禁用',
`is_system` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否系统字典',
`color` VARCHAR(20) NULL COMMENT '颜色标识',
`extra` JSON NULL COMMENT '扩展字段',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_pid` (`pid`),
KEY `idx_level` (`level`),
KEY `idx_status` (`status`),
KEY `idx_sort` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统字典表';
```
## 接口设计
### 1. 创建字典项接口 (POST /api/dict)
#### 业务逻辑
1. **参数验证**
- 验证必填字段code、name
- 验证code唯一性全局唯一
- 验证name在同级下的唯一性
- 验证pid的有效性如果指定了父级
- 验证level深度不超过10层
2. **业务规则**
- 默认pid为0顶级字典
- 默认level为1顶级
- 默认status为'active'
- 默认is_system为false
- 默认sort_order为0
- 如果有父级level = 父级level + 1
- 如果指定了父级需要验证父级是否存在且状态为active
3. **数据处理**
- code转换为小写并去除两端空格
- name去除两端空格
- description去除两端空格
- 自动计算level如果指定了pid
- 自动生成sort_order同级最大sort_order + 1
4. **错误处理**
- code已存在409 Conflict
- name在同级下已存在409 Conflict
- 父级不存在404 Not Found
- 父级状态非active400 Bad Request
- level超过10层400 Bad Request
#### 性能考虑
1. **数据库索引**
- code字段有唯一索引查询快速
- pid字段有索引支持父子关系查询
- level字段有索引支持层级查询
2. **并发控制**
- 使用分布式锁防止code重复创建
- 使用数据库事务确保数据一致性
#### 安全考虑
1. **输入验证**
- 防止SQL注入使用参数化查询
- 防止XSS对输入进行转义
- 限制字段长度:防止过长的输入
2. **权限控制**
- 需要管理员权限
- 系统字典只能由超级管理员创建
#### 缓存策略
1. **缓存失效**
- 创建字典项后,清除相关缓存
- 清除父级字典的缓存
- 清除完整字典树缓存
## 2. 获取字典项内容接口 (GET /api/dict/:id)
#### 业务逻辑
1. **参数验证**
- 验证id格式bigint
- 验证id是否存在
2. **查询逻辑**
- 根据id查询字典项
- 只返回状态为active的字典项
- 包含完整的字典项信息
3. **错误处理**
- 字典项不存在404 Not Found
- 字典项状态非active404 Not Found
#### 缓存策略
1. **缓存读取**
- 优先从Redis缓存读取
- 缓存未命中时从数据库查询
- 查询结果写入缓存
## 3. 获取完整字典树接口 (GET /api/dict/tree)
#### 业务逻辑
1. **查询参数**
- status过滤状态可选
- is_system是否系统字典可选
2. **查询逻辑**
- 查询所有符合条件的字典项
- 按level和sort_order排序
- 构建树形结构
3. **树形构建**
- 使用递归算法构建树
- 只包含状态为active的字典项
- 按sort_order排序
#### 性能考虑
1. **缓存策略**
- 完整字典树缓存到Redis
- 缓存时间1小时
- 支持按条件过滤的缓存
2. **查询优化**
- 使用单次查询获取所有数据
- 在应用层构建树形结构
## 4. 获取指定字典树接口 (GET /api/dict/tree/:code)
#### 业务逻辑
1. **参数验证**
- 验证code格式
- 验证code是否存在
2. **查询逻辑**
- 根据code查找字典项
- 获取该字典项的所有子级
- 构建子树结构
3. **错误处理**
- 字典项不存在404 Not Found
- 字典项状态非active404 Not Found
#### 缓存策略
1. **缓存键设计**
- 使用code作为缓存键的一部分
- 支持条件过滤的缓存
## 5. 更新字典项内容接口 (PUT /api/dict/:id)
#### 业务逻辑
1. **参数验证**
- 验证id格式和存在性
- 验证更新字段的有效性
- 验证code唯一性如果更新code
- 验证name在同级下的唯一性如果更新name
2. **业务规则**
- 系统字典只能由超级管理员更新
- 不能更新已删除的字典项
- 更新code时需要检查是否被其他字典项引用
3. **级联更新**
- 如果更新level需要更新所有子级的level
- 如果更新status可以选择是否级联更新子级
#### 缓存策略
1. **缓存失效**
- 更新后清除相关缓存
- 清除父级和子级的缓存
## 6. 字典项排序接口 (PUT /api/dict/sort)
#### 业务逻辑
1. **参数验证**
- 验证排序数据的完整性
- 验证字典项的存在性
2. **排序逻辑**
- 支持同级内的排序
- 支持跨级移动(改变父级)
- 自动重新计算level和sort_order
3. **事务处理**
- 使用数据库事务确保排序的一致性
- 支持批量排序操作
## 7. 删除字典项接口 (DELETE /api/dict/:id)
#### 业务逻辑
1. **软删除**
- 不物理删除数据
- 将status设置为'inactive'
- 保留数据完整性
2. **依赖检查**
- 检查是否有其他字典项引用
- 检查是否有业务数据引用
- 如果有依赖,不允许删除
3. **级联处理**
- 可以选择是否级联删除子级
- 默认不级联删除
#### 缓存策略
1. **缓存失效**
- 删除后清除相关缓存
- 清除父级和子级的缓存
## 缓存机制
### Redis缓存设计
1. **缓存键命名**
- 完整字典树:`dict:tree:all`
- 指定字典树:`dict:tree:{code}`
- 单个字典项:`dict:item:{id}`
- 字典项列表:`dict:list:{conditions}`
2. **缓存时间**
- 字典树1小时
- 单个字典项30分钟
- 字典项列表15分钟
3. **缓存失效策略**
- 写入操作后主动失效
- 定时刷新机制
- 内存不足时LRU淘汰
### 缓存一致性
1. **写入策略**
- 先更新数据库,再失效缓存
- 使用分布式锁防止并发问题
2. **读取策略**
- 先读缓存,缓存未命中读数据库
- 数据库查询结果写入缓存
## 错误码定义
| 错误码 | 说明 | HTTP状态码 |
| -------- | ---------------------- | ---------- |
| DICT_001 | 字典代码已存在 | 409 |
| DICT_002 | 字典名称在同级下已存在 | 409 |
| DICT_003 | 父级字典不存在 | 404 |
| DICT_004 | 父级字典状态非active | 400 |
| DICT_005 | 字典层级超过限制 | 400 |
| DICT_006 | 字典项不存在 | 404 |
| DICT_007 | 字典项状态非active | 404 |
| DICT_008 | 系统字典权限不足 | 403 |
| DICT_009 | 字典项被引用,无法删除 | 400 |
| DICT_010 | 排序数据无效 | 400 |
## 性能优化建议
1. **数据库优化**
- 合理使用索引
- 避免N+1查询问题
- 使用批量操作
2. **缓存优化**
- 合理设置缓存时间
- 使用缓存预热
- 实现缓存穿透保护
3. **应用层优化**
- 使用连接池
- 实现请求限流
- 优化树形构建算法
## 测试策略
1. **单元测试**
- 测试所有业务逻辑方法
- 测试边界条件和异常情况
- 测试缓存机制
2. **集成测试**
- 测试完整的API接口
- 测试数据库操作
- 测试Redis缓存
3. **性能测试**
- 测试大量数据的处理能力
- 测试并发访问性能
- 测试缓存命中率
## 部署注意事项
1. **环境配置**
- 确保Redis服务可用
- 配置数据库连接池
- 设置合适的日志级别
2. **监控告警**
- 监控API响应时间
- 监控缓存命中率
- 监控数据库连接数
3. **备份策略**
- 定期备份字典数据
- 备份Redis缓存数据
- 制定数据恢复方案

View File

@ -0,0 +1,130 @@
/**
* @file
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
* @description
*/
import { t, type Static } from 'elysia';
import { responseWrapperSchema } from '@/utils/responseFormate';
/**
*
*/
export const CreateDictSuccessSchema = t.Object({
id: t.String({
description: '字典项IDbigint类型以字符串返回防止精度丢失',
examples: ['1', '2', '100'],
}),
code: t.String({
description: '字典代码',
examples: ['user_status'],
}),
name: t.String({
description: '字典名称',
examples: ['用户状态'],
}),
value: t.Optional(
t.String({
description: '字典值',
examples: ['active'],
}),
),
description: t.Optional(
t.String({
description: '字典描述',
examples: ['用户状态字典'],
}),
),
icon: t.Optional(
t.String({
description: '图标',
examples: ['icon-user'],
}),
),
pid: t.String({
description: '父级IDbigint类型以字符串返回',
examples: ['0', '1'],
}),
level: t.Number({
description: '层级深度',
examples: [1, 2],
}),
sortOrder: t.Number({
description: '排序号',
examples: [0, 1, 10],
}),
status: t.String({
description: '状态',
examples: ['active', 'inactive'],
}),
isSystem: t.Boolean({
description: '是否系统字典',
examples: [true, false],
}),
color: t.Optional(
t.String({
description: '颜色标识',
examples: ['#1890ff'],
}),
),
extra: t.Optional(
t.Record(t.String(), t.Any(), {
description: '扩展字段',
examples: [{ key1: 'value1' }],
}),
),
createdAt: t.String({
description: '创建时间',
examples: ['2024-12-19T10:30:00Z'],
}),
updatedAt: t.String({
description: '更新时间',
examples: ['2024-12-19T10:30:00Z'],
}),
});
/**
*
* @description Controller中定义所有可能的响应格式
*/
export const CreateDictResponsesSchema = {
200: responseWrapperSchema(CreateDictSuccessSchema),
409: responseWrapperSchema(
t.Object({
error: t.String({
description: '唯一性冲突',
examples: ['字典代码已存在', '字典名称在同级下已存在'],
}),
}),
),
400: responseWrapperSchema(
t.Object({
error: t.String({
description: '参数错误',
examples: ['参数校验失败', '父级字典状态非active', '字典层级超过限制'],
}),
}),
),
404: responseWrapperSchema(
t.Object({
error: t.String({
description: '资源不存在',
examples: ['父级字典不存在'],
}),
}),
),
500: responseWrapperSchema(
t.Object({
error: t.String({
description: '服务器错误',
examples: ['内部服务器错误'],
}),
}),
),
};
/** 创建字典项成功响应数据类型 */
export type CreateDictSuccessType = Static<(typeof CreateDictResponsesSchema)[200]>;

View File

@ -0,0 +1,371 @@
/**
* @file Schema定义
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
* @description Schema
*/
import { t, type Static } from 'elysia';
/**
* Schema
* @description
*/
export const CreateDictSchema = t.Object({
/** 字典代码,唯一标识 */
code: t.Transform(t.String({
minLength: 1,
maxLength: 50,
description: '字典代码,全局唯一标识,自动转换为小写并去除两端空格',
examples: ['user_status', 'order_type', 'system_config'],
}))
.Decode((value: string) => value.trim().toLowerCase())
.Encode((value: string) => value),
/** 字典名称 */
name: t.Transform(t.String({
minLength: 1,
maxLength: 100,
description: '字典名称,同级下唯一,自动去除两端空格',
examples: ['用户状态', '订单类型', '系统配置'],
}))
.Decode((value: string) => value.trim().toLowerCase())
.Encode((value: string) => value),
/** 字典值(叶子节点才有值) */
value: t.Optional(
t
.String({
maxLength: 200,
description: '字典值,叶子节点才有值,自动去除两端空格',
examples: ['active', 'inactive', 'pending'],
})
),
/** 字典描述 */
description: t.Optional(
t
.String({
maxLength: 500,
description: '字典描述,自动去除两端空格',
examples: ['用户状态字典,包含激活、禁用、待审核等状态'],
})
),
/** 图标CSS类名或图标路径 */
icon: t.Optional(
t
.String({
maxLength: 100,
description: '图标CSS类名或图标路径自动去除两端空格',
examples: ['icon-user', 'icon-order', '/icons/config.png'],
})
),
/** 父级ID0表示顶级 */
pid: t.Optional(
t.Union(
[
t.Literal('0'),
t.String({
pattern: '^[1-9]\\d*$',
description: '父级字典IDBigint字符串形式',
}),
],
{
description: '父级ID0表示顶级字典',
examples: ['0', '1', '2'],
},
),
),
/** 排序号 */
sortOrder: t.Optional(
t.Number({
minimum: 0,
maximum: 999999,
description: '排序号,同级内排序使用',
examples: [0, 1, 10, 100],
}),
),
/** 状态active-启用inactive-禁用 */
status: t.Optional(
t
.Union([t.Literal('active'), t.Literal('inactive')], {
description: '字典状态默认active',
examples: ['active', 'inactive'],
default: 'active',
})
),
/** 是否系统字典 */
isSystem: t.Optional(
t
.Boolean({
description: '是否系统字典系统字典只能由超级管理员创建默认false',
examples: [true, false],
default: false,
})
),
/** 颜色标识 */
color: t.Optional(
t
.String({
maxLength: 20,
description: '颜色标识,用于前端显示,自动去除两端空格',
examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'],
})
),
/** 扩展字段 */
extra: t.Optional(
t.Record(t.String(), t.Any(), {
description: '扩展字段JSON格式',
examples: [{ key1: 'value1', key2: 'value2' }],
}),
),
});
/** 创建字典项请求参数类型 */
export type CreateDictRequest = Static<typeof CreateDictSchema>;
/**
* Schema
* @description ID获取字典项的请求参数验证规则
*/
export const GetDictByIdSchema = t.Object({
/** 字典项ID */
id: t.String({
pattern: '^[1-9]\\d*$',
description: '字典项ID必须是正整数',
examples: ['1', '2', '100'],
}),
});
/** 获取字典项请求参数类型 */
export type GetDictByIdRequest = Static<typeof GetDictByIdSchema>;
/**
* Schema
* @description
*/
export const GetDictTreeQuerySchema = t.Object({
/** 状态过滤 */
status: t.Optional(
t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('all')], {
description: '状态过滤条件',
examples: ['active', 'inactive', 'all'],
}),
),
/** 是否系统字典过滤 */
isSystem: t.Optional(
t.Union([t.Literal('true'), t.Literal('false'), t.Literal('all')], {
description: '是否系统字典过滤条件',
examples: ['true', 'false', 'all'],
}),
),
});
/** 获取完整字典树查询参数类型 */
export type GetDictTreeQueryRequest = Static<typeof GetDictTreeQuerySchema>;
/**
* Schema
* @description code获取指定字典树的请求参数验证规则
*/
export const GetDictTreeByCodeSchema = t.Object({
/** 字典代码 */
code: t.String({
minLength: 1,
maxLength: 50,
description: '字典代码,用于查找指定的字典树',
examples: ['user_status', 'order_type', 'system_config'],
}),
/** 状态过滤 */
status: t.Optional(
t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('all')], {
description: '状态过滤条件',
examples: ['active', 'inactive', 'all'],
}),
),
/** 是否系统字典过滤 */
isSystem: t.Optional(
t.Union([t.Literal('true'), t.Literal('false'), t.Literal('all')], {
description: '是否系统字典过滤条件',
examples: ['true', 'false', 'all'],
}),
),
});
/** 获取指定字典树请求参数类型 */
export type GetDictTreeByCodeRequest = Static<typeof GetDictTreeByCodeSchema>;
/**
* Schema
* @description
*/
export const UpdateDictSchema = t.Object({
/** 字典代码,唯一标识 */
code: t.Optional(
t.String({
minLength: 1,
maxLength: 50,
description: '字典代码,全局唯一标识,自动转换为小写',
examples: ['user_status', 'order_type', 'system_config'],
}),
),
/** 字典名称 */
name: t.Optional(
t.String({
minLength: 1,
maxLength: 100,
description: '字典名称,同级下唯一',
examples: ['用户状态', '订单类型', '系统配置'],
}),
),
/** 字典值(叶子节点才有值) */
value: t.Optional(
t.String({
maxLength: 200,
description: '字典值,叶子节点才有值',
examples: ['active', 'inactive', 'pending'],
}),
),
/** 字典描述 */
description: t.Optional(
t.String({
maxLength: 500,
description: '字典描述信息',
examples: ['用户状态字典,包含激活、禁用、待审核等状态'],
}),
),
/** 图标CSS类名或图标路径 */
icon: t.Optional(
t.String({
maxLength: 100,
description: '图标CSS类名或图标路径',
examples: ['icon-user', 'icon-order', '/icons/config.png'],
}),
),
/** 父级ID0表示顶级 */
pid: t.Optional(
t.Union(
[
t.Literal(0),
t.String({
pattern: '^[1-9]\\d*$',
description: '父级字典ID必须是正整数',
}),
],
{
description: '父级ID0表示顶级字典',
examples: [0, '1', '2'],
},
),
),
/** 排序号 */
sortOrder: t.Optional(
t.Number({
minimum: 0,
maximum: 999999,
description: '排序号,同级内排序使用',
examples: [0, 1, 10, 100],
}),
),
/** 状态active-启用inactive-禁用 */
status: t.Optional(
t.Union([t.Literal('active'), t.Literal('inactive')], {
description: '字典状态',
examples: ['active', 'inactive'],
}),
),
/** 是否系统字典 */
isSystem: t.Optional(
t.Boolean({
description: '是否系统字典,系统字典只能由超级管理员创建',
examples: [true, false],
}),
),
/** 颜色标识 */
color: t.Optional(
t.String({
maxLength: 20,
description: '颜色标识,用于前端显示',
examples: ['#1890ff', '#52c41a', '#faad14', '#f5222d'],
}),
),
/** 扩展字段 */
extra: t.Optional(
t.Record(t.String(), t.Any(), {
description: '扩展字段JSON格式',
examples: [{ key1: 'value1', key2: 'value2' }],
}),
),
});
/** 更新字典项请求参数类型 */
export type UpdateDictRequest = Static<typeof UpdateDictSchema>;
/**
* Schema
* @description
*/
export const SortDictSchema = t.Object({
/** 排序项列表 */
items: t.Array(
t.Object({
/** 字典项ID */
id: t.String({
pattern: '^[1-9]\\d*$',
description: '字典项ID',
examples: ['1', '2', '100'],
}),
/** 新的父级ID */
pid: t.Union(
[
t.Literal(0),
t.String({
pattern: '^[1-9]\\d*$',
description: '父级字典ID',
}),
],
{
description: '新的父级ID0表示顶级',
examples: [0, '1', '2'],
},
),
/** 新的排序号 */
sortOrder: t.Number({
minimum: 0,
maximum: 999999,
description: '新的排序号',
examples: [0, 1, 10, 100],
}),
}),
{
minItems: 1,
maxItems: 100,
description: '排序项列表至少包含一个项目最多100个',
},
),
});
/** 字典项排序请求参数类型 */
export type SortDictRequest = Static<typeof SortDictSchema>;
/**
* Schema
* @description
*/
export const DeleteDictSchema = t.Object({
/** 字典项ID */
id: t.String({
pattern: '^[1-9]\\d*$',
description: '字典项ID必须是正整数',
examples: ['1', '2', '100'],
}),
/** 是否级联删除子级 */
cascade: t.Optional(
t.Boolean({
description: '是否级联删除子级字典项',
examples: [true, false],
}),
),
});
/** 删除字典项请求参数类型 */
export type DeleteDictRequest = Static<typeof DeleteDictSchema>;

View File

@ -0,0 +1,127 @@
/**
* @file Service层实现
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
* @description
*/
import { Logger } from '@/plugins/logger/logger.service';
import { db } from '@/plugins/drizzle/drizzle.service';
import { sysDict } from '@/eneities';
import { eq, and, max } from 'drizzle-orm';
import { successResponse, BusinessError } from '@/utils/responseFormate';
import { nextId } from '@/utils/snowflake';
import type { CreateDictRequest } from './dict.schema';
import type { CreateDictSuccessType } from './dict.response';
/**
*
* @description
*/
export class DictService {
/**
*
* @param body
* @returns Promise<CreateDictSuccessType>
* @throws BusinessError
*/
public async createDict(body: CreateDictRequest): Promise<CreateDictSuccessType> {
// 1. code唯一性校验
const existCode = await db().select({id: sysDict.id}).from(sysDict).where(eq(sysDict.code, body.code)).limit(1);
if (existCode.length > 0) {
throw new BusinessError(`字典代码已存在: ${body.code}`, 409);
}
// 2. name同级唯一性校验
const pid = body.pid || '0';
const existName = await db().select({id: sysDict.id}).from(sysDict).where(and(eq(sysDict.name, body.name), eq(sysDict.pid, pid))).limit(1);
if (existName.length > 0) {
throw new BusinessError(`字典名称已存在: ${body.name}`, 409);
}
// 3. 父级校验与层级处理
let level = 1;
if (pid !== '0') {
const parent = await db().select().from(sysDict).where(eq(sysDict.id, pid)).limit(1);
if (parent.length === 0) {
throw new BusinessError(`父级字典不存在: ${pid}`, 404);
}
if (parent[0]!.status !== 'active') {
throw new BusinessError(`父级字典状态非active: ${pid}`, 400);
}
level = parent[0]!.level + 1;
if (level > 10) {
throw new BusinessError(`字典层级超过限制: ${level}`, 400);
}
}
// 4. sortOrder处理同级最大+1
let sortOrder = 0;
if (body.sortOrder !== undefined) {
sortOrder = body.sortOrder;
} else {
const maxSort = await db()
.select({ maxSort: max(sysDict.sortOrder) })
.from(sysDict)
.where(eq(sysDict.pid, pid));
sortOrder = (maxSort[0]?.maxSort ?? 0) + 1;
}
// 5. 数据写入
const dictId = nextId();
await db()
.insert(sysDict)
.values([
{
id: dictId.toString(),
code: body.code,
name: body.name,
value: body.value ?? null,
description: body.description ?? null,
icon: body.icon ?? null,
pid: BigInt(pid),
level,
sortOrder,
status: body.status,
isSystem: body.isSystem ? 1 : 0,
color: body.color ?? null,
extra: body.extra ?? {},
},
] as any);
// 6. 查询刚插入的数据
const insertedArr = await db().select().from(sysDict).where(eq(sysDict.id, dictId.toString())).limit(1);
if (!insertedArr || insertedArr.length === 0) {
throw new BusinessError('创建字典项失败', 500);
}
const inserted = insertedArr[0]!;
// 7. 返回统一响应
return successResponse(
{
id: String(inserted.id),
code: inserted.code,
name: inserted.name,
value: inserted.value,
description: inserted.description,
icon: inserted.icon,
pid: String(inserted.pid),
level: inserted.level,
sortOrder: inserted.sortOrder,
status: inserted.status,
isSystem: Boolean(inserted.isSystem),
color: inserted.color,
extra: inserted.extra,
createdAt: inserted.createdAt,
updatedAt: inserted.updatedAt,
},
'创建字典项成功',
);
}
}
// 导出单例实例
export const dictService = new DictService();

View File

@ -28,4 +28,4 @@ export const healthController = new Elysia()
summary: '获取详细健康状态', summary: '获取详细健康状态',
description: '获取系统详细健康状态,包括性能指标、资源使用情况等', description: '获取系统详细健康状态,包括性能指标、资源使用情况等',
}, },
}); });

View File

@ -1,300 +1,310 @@
/** /**
* @file * @file
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description Redis等依赖检查 * @description Redis等依赖检查
*/ */
import type { Context } from 'elysia'; import type { Context } from 'elysia';
import { Redis } from '@/utils/redis'; import { Redis } from '@/utils/redis';
import { pool } from '@/utils/mysql'; import { pool } from '@/utils/mysql';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
// 临时内联类型定义 // 临时内联类型定义
interface ComponentStatus { interface ComponentStatus {
status: 'healthy' | 'unhealthy' | 'degraded'; status: 'healthy' | 'unhealthy' | 'degraded';
responseTime?: number; responseTime?: number;
error?: string; error?: string;
details?: Record<string, any>; details?: Record<string, any>;
} }
interface HealthStatus { interface HealthStatus {
code: number; code: number;
message: string; message: string;
data: { data: {
status: 'healthy' | 'unhealthy' | 'degraded'; status: 'healthy' | 'unhealthy' | 'degraded';
timestamp: string; timestamp: string;
uptime: number; uptime: number;
responseTime: number; responseTime: number;
version: string; version: string;
environment: string; environment: string;
error?: string; error?: string;
components: { components: {
mysql?: ComponentStatus; mysql?: ComponentStatus;
redis?: ComponentStatus; redis?: ComponentStatus;
[key: string]: ComponentStatus | undefined; [key: string]: ComponentStatus | undefined;
}; };
}; };
} }
interface DetailedHealthStatus extends HealthStatus { interface DetailedHealthStatus extends HealthStatus {
data: HealthStatus['data'] & { data: HealthStatus['data'] & {
system?: { system?: {
platform: string; platform: string;
arch: string; arch: string;
nodeVersion: string; nodeVersion: string;
runtime: string; runtime: string;
pid: number; pid: number;
cwd: string; cwd: string;
}; };
performance?: { performance?: {
cpuUsage: { cpuUsage: {
user: number; user: number;
system: number; system: number;
}; };
memoryUsage: { memoryUsage: {
rss: number; rss: number;
heapTotal: number; heapTotal: number;
heapUsed: number; heapUsed: number;
external: number; external: number;
arrayBuffers: number; arrayBuffers: number;
}; };
uptime: number; uptime: number;
}; };
}; };
} }
/** /**
* *
* *
*/ */
class HealthService { class HealthService {
/** /**
* Redis实例 * Redis实例
*/ */
private redis: Redis; private redis: Redis;
constructor() { constructor() {
this.redis = new Redis(); this.redis = new Redis();
} }
/** /**
* *
* @param ctx Elysia上下文 * @param ctx Elysia上下文
* @returns * @returns
*/ */
async getHealthStatus(ctx: Context): Promise<HealthStatus> { async getHealthStatus(ctx: Context): Promise<HealthStatus> {
const startTime = Date.now(); const startTime = Date.now();
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
try { try {
// 并行检查所有依赖 // 并行检查所有依赖
const [mysqlStatus, redisStatus] = await Promise.allSettled([ const [mysqlStatus, redisStatus] = await Promise.allSettled([
this.checkMysqlHealth(), this.checkMysqlHealth(),
this.checkRedisHealth(), this.checkRedisHealth(),
]); ]);
/** 系统整体状态 */ /** 系统整体状态 */
const overallStatus = this.determineOverallStatus([ const overallStatus = this.determineOverallStatus([
mysqlStatus.status === 'fulfilled' ? mysqlStatus.value : { status: 'unhealthy', error: 'Connection failed' }, mysqlStatus.status === 'fulfilled'
redisStatus.status === 'fulfilled' ? redisStatus.value : { status: 'unhealthy', error: 'Connection failed' }, ? mysqlStatus.value
]); : { status: 'unhealthy', error: 'Connection failed' },
redisStatus.status === 'fulfilled'
const responseTime = Date.now() - startTime; ? redisStatus.value
: { status: 'unhealthy', error: 'Connection failed' },
return { ]);
code: overallStatus === 'healthy' ? 0 : 1,
message: overallStatus === 'healthy' ? '所有服务运行正常' : '部分服务异常', const responseTime = Date.now() - startTime;
data: {
status: overallStatus, return {
timestamp, code: overallStatus === 'healthy' ? 0 : 1,
uptime: process.uptime(), message: overallStatus === 'healthy' ? '所有服务运行正常' : '部分服务异常',
responseTime, data: {
version: process.env.npm_package_version || '1.0.0', status: overallStatus,
environment: process.env.NODE_ENV || 'development', timestamp,
components: { uptime: process.uptime(),
mysql: mysqlStatus.status === 'fulfilled' ? mysqlStatus.value : { status: 'unhealthy', error: 'Connection failed' }, responseTime,
redis: redisStatus.status === 'fulfilled' ? redisStatus.value : { status: 'unhealthy', error: 'Connection failed' }, version: process.env.npm_package_version || '1.0.0',
}, environment: process.env.NODE_ENV || 'development',
}, components: {
}; mysql:
} catch (error) { mysqlStatus.status === 'fulfilled'
Logger.error(error as Error); ? mysqlStatus.value
return { : { status: 'unhealthy', error: 'Connection failed' },
code: 1, redis:
message: '健康检查异常', redisStatus.status === 'fulfilled'
data: { ? redisStatus.value
status: 'unhealthy', : { status: 'unhealthy', error: 'Connection failed' },
timestamp, },
uptime: process.uptime(), },
responseTime: Date.now() - startTime, };
version: process.env.npm_package_version || '1.0.0', } catch (error) {
environment: process.env.NODE_ENV || 'development', Logger.error(error as Error);
error: 'Health check failed', return {
components: {}, code: 1,
}, message: '健康检查异常',
}; data: {
} status: 'unhealthy',
} timestamp,
uptime: process.uptime(),
/** responseTime: Date.now() - startTime,
* version: process.env.npm_package_version || '1.0.0',
* @param ctx Elysia上下文 environment: process.env.NODE_ENV || 'development',
* @returns error: 'Health check failed',
*/ components: {},
async getDetailedHealthStatus(ctx: Context): Promise<DetailedHealthStatus> { },
const startTime = Date.now(); };
const timestamp = new Date().toISOString(); }
}
try {
// 获取基本健康状态 /**
const basicHealth = await this.getHealthStatus(ctx); *
* @param ctx Elysia上下文
// 获取系统资源信息 * @returns
const systemInfo = this.getSystemInfo(); */
async getDetailedHealthStatus(ctx: Context): Promise<DetailedHealthStatus> {
return { const startTime = Date.now();
...basicHealth, const timestamp = new Date().toISOString();
data: {
...basicHealth.data, try {
system: systemInfo, // 获取基本健康状态
performance: { const basicHealth = await this.getHealthStatus(ctx);
cpuUsage: process.cpuUsage(),
memoryUsage: process.memoryUsage(), // 获取系统资源信息
uptime: process.uptime(), const systemInfo = this.getSystemInfo();
},
}, return {
}; ...basicHealth,
} catch (error) { data: {
Logger.error(error as Error); ...basicHealth.data,
return { system: systemInfo,
code: 1, performance: {
message: '详细健康检查异常', cpuUsage: process.cpuUsage(),
data: { memoryUsage: process.memoryUsage(),
status: 'unhealthy', uptime: process.uptime(),
timestamp, },
uptime: process.uptime(), },
responseTime: Date.now() - startTime, };
version: process.env.npm_package_version || '1.0.0', } catch (error) {
environment: process.env.NODE_ENV || 'development', Logger.error(error as Error);
error: 'Detailed health check failed', return {
components: {}, code: 1,
}, message: '详细健康检查异常',
}; data: {
} status: 'unhealthy',
} timestamp,
uptime: process.uptime(),
/** responseTime: Date.now() - startTime,
* MySQL健康状态 version: process.env.npm_package_version || '1.0.0',
* @returns MySQL组件状态 environment: process.env.NODE_ENV || 'development',
*/ error: 'Detailed health check failed',
private async checkMysqlHealth(): Promise<ComponentStatus> { components: {},
try { },
const startTime = Date.now(); };
await pool.execute('SELECT 1'); }
const responseTime = Date.now() - startTime; }
return { /**
status: 'healthy', * MySQL健康状态
responseTime, * @returns MySQL组件状态
details: { */
connection: 'active', private async checkMysqlHealth(): Promise<ComponentStatus> {
host: process.env.DB_HOST || 'localhost', try {
port: process.env.DB_PORT || '3306', const startTime = Date.now();
}, await pool.execute('SELECT 1');
}; const responseTime = Date.now() - startTime;
} catch (error) {
Logger.error(error as Error); return {
return { status: 'healthy',
status: 'unhealthy', responseTime,
error: (error as Error).message, details: {
details: { connection: 'active',
connection: 'failed', host: process.env.DB_HOST || 'localhost',
host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || '3306',
port: process.env.DB_PORT || '3306', },
}, };
}; } catch (error) {
} Logger.error(error as Error);
} return {
status: 'unhealthy',
/** error: (error as Error).message,
* Redis健康状态 details: {
* @returns Redis组件状态 connection: 'failed',
*/ host: process.env.DB_HOST || 'localhost',
private async checkRedisHealth(): Promise<ComponentStatus> { port: process.env.DB_PORT || '3306',
try { },
const startTime = Date.now(); };
const isHealthy = await this.redis.checkRedisHealth(); }
const responseTime = Date.now() - startTime; }
if (isHealthy) { /**
const redisStatus = this.redis.getRedisStatus(); * Redis健康状态
return { * @returns Redis组件状态
status: 'healthy', */
responseTime, private async checkRedisHealth(): Promise<ComponentStatus> {
details: { try {
connection: 'active', const startTime = Date.now();
...redisStatus.config, const isHealthy = await this.redis.checkRedisHealth();
}, const responseTime = Date.now() - startTime;
};
} else { if (isHealthy) {
return { const redisStatus = this.redis.getRedisStatus();
status: 'unhealthy', return {
error: 'Redis ping failed', status: 'healthy',
details: { responseTime,
connection: 'failed', details: {
}, connection: 'active',
}; ...redisStatus.config,
} },
} catch (error) { };
Logger.error(error as Error); } else {
return { return {
status: 'unhealthy', status: 'unhealthy',
error: (error as Error).message, error: 'Redis ping failed',
details: { details: {
connection: 'failed', connection: 'failed',
}, },
}; };
} }
} } catch (error) {
Logger.error(error as Error);
/** return {
* status: 'unhealthy',
* @param components error: (error as Error).message,
* @returns details: {
*/ connection: 'failed',
private determineOverallStatus(components: ComponentStatus[]): 'healthy' | 'unhealthy' | 'degraded' { },
const healthyCount = components.filter(c => c.status === 'healthy').length; };
const totalCount = components.length; }
}
if (healthyCount === totalCount) {
return 'healthy'; /**
} else if (healthyCount === 0) { *
return 'unhealthy'; * @param components
} else { * @returns
return 'degraded'; */
} private determineOverallStatus(components: ComponentStatus[]): 'healthy' | 'unhealthy' | 'degraded' {
} const healthyCount = components.filter((c) => c.status === 'healthy').length;
const totalCount = components.length;
/**
* if (healthyCount === totalCount) {
* @returns return 'healthy';
*/ } else if (healthyCount === 0) {
private getSystemInfo() { return 'unhealthy';
return { } else {
platform: process.platform, return 'degraded';
arch: process.arch, }
nodeVersion: process.version, }
runtime: 'Bun',
pid: process.pid, /**
cwd: process.cwd(), *
}; * @returns
} */
} private getSystemInfo() {
return {
/** platform: process.platform,
* arch: process.arch,
*/ nodeVersion: process.version,
export const healthService = new HealthService(); runtime: 'Bun',
pid: process.pid,
cwd: process.cwd(),
};
}
}
/**
*
*/
export const healthService = new HealthService();

View File

@ -1,38 +1,41 @@
/** /**
* @file API * @file API
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-29 * @lastEditTime 2025-06-29
* @description API 使 group * @description API 使 group
*/ */
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { healthController } from './health/health.controller'; import { healthController } from './health/health.controller';
import { userController } from './user/user.controller'; import { userController } from './user/user.controller';
import { testController } from './test/test.controller'; import { testController } from './test/test.controller';
import { captchaController } from './captcha/captcha.controller'; import { captchaController } from './captcha/captcha.controller';
import { authController } from './auth/auth.controller'; import { authController } from './auth/auth.controller';
import { dictController } from './dict/dict.controller';
/**
* - API /**
* @description API 便 * - API
*/ * @description API 便
export const controllers = new Elysia({ */
prefix: '/api', export const controllers = new Elysia({
name: 'controller', prefix: '/api',
}) name: 'controller',
// 版本信息 })
.get('/version', () => ({ // 版本信息
version: '1.0.0', .get('/version', () => ({
})) version: '1.0.0',
// 用户系统接口 }))
.group('/user', (app) => app.use(userController)) // 用户系统接口
// 验证性接口 .group('/user', (app) => app.use(userController))
.group('/test', (app) => app.use(testController)) // 验证性接口
// 健康检查接口 .group('/test', (app) => app.use(testController))
.group('/health', (app) => app.use(healthController)) // 健康检查接口
// 认证接口 .group('/health', (app) => app.use(healthController))
.group('/auth', (app) => app.use(authController)) // 认证接口
// 验证码接口 .group('/auth', (app) => app.use(authController))
.group('/captcha', (app) => app.use(captchaController)); // 验证码接口
.group('/captcha', (app) => app.use(captchaController))
// 字典接口
.group('/dict', (app) => app.use(dictController));

View File

@ -1,5 +1,5 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
export const testController = new Elysia({ name: 'testController' }) export const testController = new Elysia({ name: 'testController' })
.get('/', () => ({ message: '验证性接口' })) .get('/', () => ({ message: '验证性接口' }))
.get('/hello', () => ({ message: 'hello' })); .get('/hello', () => ({ message: 'hello' }));

View File

@ -0,0 +1,267 @@
# 创建用户接口业务逻辑文档
## 接口基本信息
- **接口名称**: 创建用户接口
- **请求方法**: POST
- **接口路径**: `/users`
- **权限要求**: 需要 `user:create` 权限
- **优先级**: P0
## 需求描述
管理员创建新用户,可直接设置角色、组织等信息。支持批量创建用户,可预设用户角色和组织,支持直接激活状态。
## 设计原因
- 管理员需要批量创建用户
- 可预设用户角色和组织
- 支持直接激活状态
- 提供完整的用户信息设置
## 接口参数
```typescript
{
username: string; // 用户名必填3-50字符字母开头仅包含字母数字下划线
email: string; // 邮箱地址,必填,需符合邮箱格式
password: string; // 密码必填8-100字符必须包含大小写字母和数字
mobile?: string; // 手机号可选11位数字
nickname?: string; // 昵称可选1-50字符
status?: string; // 用户状态可选默认active可选值active/inactive/locked
gender?: number; // 性别可选0-未知1-男2-女
birthday?: string; // 生日可选ISO 8601格式
bio?: string; // 个人简介可选最大500字符
roleIds?: number[]; // 角色ID数组可选
organizationIds?: Array<{
id: number;
isPrimary: boolean; // 是否主要组织
position?: string; // 职位,可选
}>; // 组织信息,可选
tagIds?: number[]; // 标签ID数组可选
sendNotification?: boolean; // 是否发送通知邮件可选默认false
}
```
## 业务逻辑流程
### 1. 权限验证
- 检查当前用户是否具有 `user:create` 权限
- 如果没有权限返回403错误
### 2. 参数验证
- 验证必填字段username、email
- 验证字段格式:
- username: 3-50字符字母开头仅包含字母数字下划线
- email: 符合邮箱格式
- mobile: 11位数字如果提供
- 验证唯一性:
- username 在系统中唯一
- email 在系统中唯一
- mobile 在系统中唯一(如果提供)
- password从配置文件获取使用默认密码
### 3. 数据预处理
- username: 转换为小写并去除两端空格
- email: 转换为小写并去除两端空格
- mobile: 去除空格和特殊字符
- nickname: 去除两端空格
- bio: 去除两端空格
### 4. 密码处理
- 使用 bcrypt 加密密码成本因子为12从配置文件获取
- 生成密码哈希值
### 5. 事务处理
使用数据库事务确保数据一致性:
#### 5.1 创建用户记录
```sql
INSERT INTO sys_users (
username, email, password_hash, mobile, nickname,
status, gender, birthday, bio, created_by, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
```
#### 5.2 分配角色如果提供roleIds
```sql
INSERT INTO sys_user_roles (user_id, role_id, created_by) VALUES (?, ?, ?)
```
#### 5.3 分配组织如果提供organizationIds
```sql
INSERT INTO sys_user_organizations (
user_id, organization_id, is_primary, position, created_by
) VALUES (?, ?, ?, ?, ?)
```
#### 5.4 分配标签如果提供tagIds
```sql
INSERT INTO sys_user_tags (user_id, tag_id, created_by) VALUES (?, ?, ?)
```
### 6. 后续处理
- 如果 sendNotification 为 true发送欢迎邮件异步处理
- 记录操作日志到 sys_operation_logs 表
- 清除相关缓存(用户列表缓存等)
## 响应格式
### 成功响应 (201 Created)
```json
{
"code": 0,
"message": "创建成功",
"data": {
"id": "1234567890",
"username": "new_user",
"email": "new@example.com",
"mobile": "13800138000",
"nickname": "新用户",
"status": "active",
"gender": 1,
"birthday": "1990-01-01",
"bio": "个人简介",
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
}
```
### 错误响应
#### 400 Bad Request - 参数错误
```json
{
"code": 400,
"message": "用户名格式不正确",
"data": null
}
```
#### 403 Forbidden - 权限不足
```json
{
"code": 403,
"message": "权限不足",
"data": null
}
```
#### 409 Conflict - 资源冲突
```json
{
"code": 409,
"message": "用户名已被占用",
"data": null
}
```
## 安全考虑
### 1. 权限控制
- 只有具有 `user:create` 权限的用户才能创建用户
- 记录创建者信息,便于审计
### 2. 数据验证
- 严格的参数格式验证
- 唯一性检查防止重复数据
- 密码强度要求
### 3. 事务安全
- 使用数据库事务确保数据一致性
- 如果任何步骤失败,回滚所有操作
### 4. 分布式锁
- 在检查用户名和邮箱唯一性时使用分布式锁
- 防止并发创建导致的重复数据
## 性能考虑
### 1. 数据库优化
- 在 username、email、mobile 字段上建立唯一索引
- 使用批量插入减少数据库交互次数
### 2. 缓存策略
- 创建成功后清除用户列表缓存
- 清除相关角色和组织缓存
### 3. 异步处理
- 邮件发送使用异步队列处理
- 日志记录使用异步处理
## 错误处理
### 1. 业务错误
- 参数验证失败返回400错误
- 权限不足返回403错误
- 资源冲突返回409错误
### 2. 系统错误
- 数据库连接失败返回500错误
- 邮件服务失败:记录错误但不影响用户创建
- 缓存服务失败:记录错误但不影响主流程
## 测试用例
### 1. 正常创建
- 提供完整的用户信息
- 验证返回的用户数据正确
- 验证数据库中数据正确保存
### 2. 权限测试
- 无权限用户尝试创建应返回403错误
- 有权限用户创建:应成功
### 3. 参数验证
- 无效的用户名格式应返回400错误
- 无效的邮箱格式应返回400错误
- 弱密码应返回400错误
### 4. 唯一性测试
- 重复用户名应返回409错误
- 重复邮箱应返回409错误
- 重复手机号应返回409错误
### 5. 事务测试
- 创建过程中数据库异常:应回滚所有操作
- 部分数据插入失败:应回滚所有操作
## 注意事项
1. **分布式锁使用**:在检查唯一性时必须使用分布式锁
2. **事务边界**:整个创建过程必须在同一个事务中
3. **密码安全**密码必须使用bcrypt加密成本因子为12
4. **日志记录**:必须记录操作日志,包含操作者和操作内容
5. **缓存清理**:创建成功后必须清理相关缓存
6. **异步处理**:邮件发送等非关键操作使用异步处理
7. **错误处理**:必须处理所有可能的错误情况
8. **数据一致性**:确保用户、角色、组织、标签数据的一致性

View File

@ -10,47 +10,41 @@
import { t, type Static } from 'elysia'; import { t, type Static } from 'elysia';
import { createPaginationResponseSchema, createQuerySchema } from '@/utils/pagination'; import { createPaginationResponseSchema, createQuerySchema } from '@/utils/pagination';
/** /**
* Schema * Schema
* @description * @description
*/ */
export const UserListQuerySchema = createQuerySchema(t.Object({ export const UserListQuerySchema = createQuerySchema(
// 用户特有参数 t.Object({
keyword: t.Optional(t.String({ // 用户特有参数
minLength: 1, keyword: t.Optional(
maxLength: 100, t.String({
description: '搜索关键词,支持用户名、邮箱模糊搜索', minLength: 1,
examples: ['admin', 'test@example.com'] maxLength: 100,
})), description: '搜索关键词,支持用户名、邮箱模糊搜索',
status: t.Optional(t.Union([ examples: ['admin', 'test@example.com'],
t.Literal('active'), }),
t.Literal('inactive'), ),
t.Literal('pending') status: t.Optional(
], { t.Union([t.Literal('active'), t.Literal('inactive'), t.Literal('pending')], {
description: '用户状态筛选', description: '用户状态筛选',
examples: ['active', 'inactive', 'pending'] examples: ['active', 'inactive', 'pending'],
})), }),
gender: t.Optional(t.Union([ ),
t.Literal(0), gender: t.Optional(
t.Literal(1), t.Union([t.Literal(0), t.Literal(1), t.Literal(2), t.Literal('0'), t.Literal('1'), t.Literal('2')], {
t.Literal(2), description: '性别筛选0-未知1-男2-女',
t.Literal('0'), examples: [0, 1, 2],
t.Literal('1'), }),
t.Literal('2'), ),
], { isRoot: t.Optional(
description: '性别筛选0-未知1-男2-女', t.Boolean({
examples: [0, 1, 2] description: '是否超级管理员筛选',
})), examples: [true, false],
isRoot: t.Optional(t.Boolean({ }),
description: '是否超级管理员筛选', ),
examples: [true, false] }),
})) );
}));
/** 用户列表查询参数类型 */ /** 用户列表查询参数类型 */
export type UserListQueryRequest = Static<typeof UserListQuerySchema>; export type UserListQueryRequest = Static<typeof UserListQuerySchema>;

View File

@ -44,7 +44,7 @@ export class UserService {
status: sysUsers.status, status: sysUsers.status,
lastLoginAt: sysUsers.lastLoginAt, lastLoginAt: sysUsers.lastLoginAt,
createdAt: sysUsers.createdAt, createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt updatedAt: sysUsers.updatedAt,
}) })
.from(sysUsers) .from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId))) .where(eq(sysUsers.id, BigInt(userId)))
@ -52,28 +52,28 @@ export class UserService {
if (!user || user.length === 0) { if (!user || user.length === 0) {
Logger.warn(`用户不存在:${userId}`); Logger.warn(`用户不存在:${userId}`);
throw new BusinessError( throw new BusinessError(`用户不存在:${userId}`, 404);
`用户不存在:${userId}`,
404
);
} }
const userData = user[0]!; const userData = user[0]!;
Logger.info(`获取用户信息成功:${userId} - ${userData.username}`); Logger.info(`获取用户信息成功:${userId} - ${userData.username}`);
return successResponse({ return successResponse(
id: userId, // 使用传入的字符串ID避免精度丢失 {
username: userData.username, id: userId, // 使用传入的字符串ID避免精度丢失
email: userData.email, username: userData.username,
nickname: userData.nickname, email: userData.email,
avatar: userData.avatar, nickname: userData.nickname,
phone: userData.mobile, avatar: userData.avatar,
status: userData.status, phone: userData.mobile,
lastLoginAt: userData.lastLoginAt || null, status: userData.status,
createdAt: userData.createdAt, lastLoginAt: userData.lastLoginAt || null,
updatedAt: userData.updatedAt createdAt: userData.createdAt,
}, '获取用户信息成功'); updatedAt: userData.updatedAt,
},
'获取用户信息成功',
);
} }
/** /**
@ -94,7 +94,7 @@ export class UserService {
// 关键词搜索(用户名、邮箱模糊搜索) // 关键词搜索(用户名、邮箱模糊搜索)
if (keyword) { if (keyword) {
conditions.push( conditions.push(
sql`(${sysUsers.username} LIKE ${`%${keyword}%`} OR ${sysUsers.email} LIKE ${`%${keyword}%`})` sql`(${sysUsers.username} LIKE ${`%${keyword}%`} OR ${sysUsers.email} LIKE ${`%${keyword}%`})`,
); );
} }
@ -117,10 +117,14 @@ export class UserService {
conditions.push(sql`${sysUsers.deletedAt} IS NULL`); conditions.push(sql`${sysUsers.deletedAt} IS NULL`);
// 构建排序 // 构建排序
const orderBy = sortBy === 'username' ? sysUsers.username : const orderBy =
sortBy === 'email' ? sysUsers.email : sortBy === 'username'
sortBy === 'updatedAt' ? sysUsers.updatedAt : ? sysUsers.username
sysUsers.createdAt; : sortBy === 'email'
? sysUsers.email
: sortBy === 'updatedAt'
? sysUsers.updatedAt
: sysUsers.createdAt;
const orderDirection = sortOrder === 'asc' ? asc : desc; const orderDirection = sortOrder === 'asc' ? asc : desc;
@ -151,7 +155,7 @@ export class UserService {
failedAttempts: sysUsers.failedAttempts, failedAttempts: sysUsers.failedAttempts,
isRoot: sysUsers.isRoot, isRoot: sysUsers.isRoot,
createdAt: sysUsers.createdAt, createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt updatedAt: sysUsers.updatedAt,
}) })
.from(sysUsers) .from(sysUsers)
.where(and(...conditions)) .where(and(...conditions))
@ -160,7 +164,7 @@ export class UserService {
.offset((page - 1) * pageSize); .offset((page - 1) * pageSize);
// 转换数据格式 // 转换数据格式
const userList: UserListItem[] = users.map(user => ({ const userList: UserListItem[] = users.map((user) => ({
id: user.id!.toString(), // 确保ID以字符串形式返回 id: user.id!.toString(), // 确保ID以字符串形式返回
username: user.username, username: user.username,
email: user.email, email: user.email,
@ -177,7 +181,7 @@ export class UserService {
failedAttempts: user.failedAttempts, failedAttempts: user.failedAttempts,
isRoot: user.isRoot === 1, isRoot: user.isRoot === 1,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt updatedAt: user.updatedAt,
})); }));
// 计算分页信息 // 计算分页信息
@ -185,12 +189,15 @@ export class UserService {
Logger.info(`获取用户列表成功:总数${total},当前页${page},每页${pageSize}`); Logger.info(`获取用户列表成功:总数${total},当前页${page},每页${pageSize}`);
return successResponse({ return successResponse(
...pagination, {
data: userList ...pagination,
}, '获取用户列表成功'); data: userList,
},
'获取用户列表成功',
);
} }
} }
// 导出单例实例 // 导出单例实例
export const userService = new UserService(); export const userService = new UserService();

View File

@ -18,122 +18,127 @@
#### 1.1 正常流程测试 #### 1.1 正常流程测试
**测试用例**: 成功获取当前用户信息 **测试用例**: 成功获取当前用户信息
- **前置条件**: 用户已登录有有效的JWT Token - **前置条件**: 用户已登录有有效的JWT Token
- **测试步骤**: - **测试步骤**:
1. 发送GET请求到 `/api/users/me` 1. 发送GET请求到 `/api/users/me`
2. 在Authorization header中携带有效的JWT Token 2. 在Authorization header中携带有效的JWT Token
- **预期结果**: - **预期结果**:
- 状态码: 200 - 状态码: 200
- 响应格式: - 响应格式:
```json ```json
{ {
"code": 200, "code": 200,
"message": "获取用户信息成功", "message": "获取用户信息成功",
"data": { "data": {
"id": "1", "id": "1",
"username": "testuser", "username": "testuser",
"email": "test@example.com", "email": "test@example.com",
"nickname": "测试用户", "nickname": "测试用户",
"avatar": "https://example.com/avatar.jpg", "avatar": "https://example.com/avatar.jpg",
"phone": "13800138000", "phone": "13800138000",
"status": "active", "status": "active",
"lastLoginAt": "2024-12-19T10:30:00Z", "lastLoginAt": "2024-12-19T10:30:00Z",
"createdAt": "2024-12-19T10:30:00Z", "createdAt": "2024-12-19T10:30:00Z",
"updatedAt": "2024-12-19T10:30:00Z" "updatedAt": "2024-12-19T10:30:00Z"
}, },
"type": "SUCCESS", "type": "SUCCESS",
"timestamp": "2024-12-19T10:30:00Z" "timestamp": "2024-12-19T10:30:00Z"
} }
``` ```
#### 1.2 认证失败测试 #### 1.2 认证失败测试
**测试用例**: 未提供JWT Token **测试用例**: 未提供JWT Token
- **前置条件**: 无 - **前置条件**: 无
- **测试步骤**: - **测试步骤**:
1. 发送GET请求到 `/api/users/me` 1. 发送GET请求到 `/api/users/me`
2. 不提供Authorization header 2. 不提供Authorization header
- **预期结果**: - **预期结果**:
- 状态码: 401 - 状态码: 401
- 响应格式: - 响应格式:
```json ```json
{ {
"code": 401, "code": 401,
"message": "未提供有效的认证令牌", "message": "未提供有效的认证令牌",
"data": null, "data": null,
"type": "AUTH_ERROR", "type": "AUTH_ERROR",
"timestamp": "2024-12-19T10:30:00Z" "timestamp": "2024-12-19T10:30:00Z"
} }
``` ```
**测试用例**: JWT Token无效 **测试用例**: JWT Token无效
- **前置条件**: 无 - **前置条件**: 无
- **测试步骤**: - **测试步骤**:
1. 发送GET请求到 `/api/users/me` 1. 发送GET请求到 `/api/users/me`
2. 在Authorization header中携带无效的JWT Token 2. 在Authorization header中携带无效的JWT Token
- **预期结果**: - **预期结果**:
- 状态码: 401 - 状态码: 401
- 响应格式: - 响应格式:
```json ```json
{ {
"code": 401, "code": 401,
"message": "令牌已过期", "message": "令牌已过期",
"data": null, "data": null,
"type": "AUTH_ERROR", "type": "AUTH_ERROR",
"timestamp": "2024-12-19T10:30:00Z" "timestamp": "2024-12-19T10:30:00Z"
} }
``` ```
#### 1.3 用户不存在测试 #### 1.3 用户不存在测试
**测试用例**: 用户已被删除 **测试用例**: 用户已被删除
- **前置条件**: 用户已登录,但数据库中该用户已被删除 - **前置条件**: 用户已登录,但数据库中该用户已被删除
- **测试步骤**: - **测试步骤**:
1. 发送GET请求到 `/api/users/me` 1. 发送GET请求到 `/api/users/me`
2. 在Authorization header中携带有效的JWT Token 2. 在Authorization header中携带有效的JWT Token
- **预期结果**: - **预期结果**:
- 状态码: 404 - 状态码: 404
- 响应格式: - 响应格式:
```json ```json
{ {
"code": 404, "code": 404,
"message": "用户不存在或已被删除", "message": "用户不存在或已被删除",
"data": null, "data": null,
"type": "NOT_FOUND", "type": "NOT_FOUND",
"timestamp": "2024-12-19T10:30:00Z" "timestamp": "2024-12-19T10:30:00Z"
} }
``` ```
#### 1.4 边界情况测试 #### 1.4 边界情况测试
**测试用例**: 用户信息字段为空 **测试用例**: 用户信息字段为空
- **前置条件**: 用户已登录,但用户信息中某些字段为空 - **前置条件**: 用户已登录,但用户信息中某些字段为空
- **测试步骤**: - **测试步骤**:
1. 发送GET请求到 `/api/users/me` 1. 发送GET请求到 `/api/users/me`
2. 在Authorization header中携带有效的JWT Token 2. 在Authorization header中携带有效的JWT Token
- **预期结果**: - **预期结果**:
- 状态码: 200 - 状态码: 200
- 响应中的空字段应该为null: - 响应中的空字段应该为null:
```json ```json
{ {
"code": 200, "code": 200,
"message": "获取用户信息成功", "message": "获取用户信息成功",
"data": { "data": {
"id": "1", "id": "1",
"username": "testuser", "username": "testuser",
"email": "test@example.com", "email": "test@example.com",
"nickname": null, "nickname": null,
"avatar": null, "avatar": null,
"phone": null, "phone": null,
"status": "active", "status": "active",
"lastLoginAt": null, "lastLoginAt": null,
"createdAt": "2024-12-19T10:30:00Z", "createdAt": "2024-12-19T10:30:00Z",
"updatedAt": "2024-12-19T10:30:00Z" "updatedAt": "2024-12-19T10:30:00Z"
}, },
"type": "SUCCESS", "type": "SUCCESS",
"timestamp": "2024-12-19T10:30:00Z" "timestamp": "2024-12-19T10:30:00Z"
} }
``` ```
## 测试数据准备 ## 测试数据准备
@ -142,11 +147,11 @@
```sql ```sql
-- 插入测试用户 -- 插入测试用户
INSERT INTO sys_users ( INSERT INTO sys_users (
id, username, email, password_hash, salt, id, username, email, password_hash, salt,
nickname, avatar, phone, status, nickname, avatar, phone, status,
last_login_at, created_at, updated_at last_login_at, created_at, updated_at
) VALUES ( ) VALUES (
1, 'testuser', 'test@example.com', 1, 'testuser', 'test@example.com',
'hashed_password', 'salt_value', 'hashed_password', 'salt_value',
'测试用户', 'https://example.com/avatar.jpg', '13800138000', '测试用户', 'https://example.com/avatar.jpg', '13800138000',
'active', '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z' 'active', '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z'
@ -154,13 +159,13 @@ INSERT INTO sys_users (
-- 插入空字段测试用户 -- 插入空字段测试用户
INSERT INTO sys_users ( INSERT INTO sys_users (
id, username, email, password_hash, salt, id, username, email, password_hash, salt,
nickname, avatar, phone, status, nickname, avatar, phone, status,
last_login_at, created_at, updated_at last_login_at, created_at, updated_at
) VALUES ( ) VALUES (
2, 'emptyuser', 'empty@example.com', 2, 'emptyuser', 'empty@example.com',
'hashed_password', 'salt_value', 'hashed_password', 'salt_value',
NULL, NULL, NULL, 'active', NULL, NULL, NULL, 'active',
NULL, '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z' NULL, '2024-12-19T10:30:00Z', '2024-12-19T10:30:00Z'
); );
``` ```
@ -169,11 +174,9 @@ INSERT INTO sys_users (
```typescript ```typescript
// 生成测试用的JWT Token // 生成测试用的JWT Token
const testToken = jwt.sign( const testToken = jwt.sign({ userId: '1', username: 'testuser' }, process.env.JWT_SECRET || 'test-secret', {
{ userId: '1', username: 'testuser' }, expiresIn: '1h',
process.env.JWT_SECRET || 'test-secret', });
{ expiresIn: '1h' }
);
``` ```
## 性能测试 ## 性能测试
@ -242,13 +245,14 @@ describe('User API', () => {
describe('GET /api/users/me', () => { describe('GET /api/users/me', () => {
it('应该成功获取当前用户信息', async () => { it('应该成功获取当前用户信息', async () => {
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/users/me', { new Request('http://localhost/api/users/me', {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${testToken}` Authorization: `Bearer ${testToken}`,
} },
})); }),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json(); const result = await response.json();
@ -257,10 +261,11 @@ describe('User API', () => {
}); });
it('应该处理认证失败', async () => { it('应该处理认证失败', async () => {
const response = await app const response = await app.handle(
.handle(new Request('http://localhost/api/users/me', { new Request('http://localhost/api/users/me', {
method: 'GET' method: 'GET',
})); }),
);
expect(response.status).toBe(401); expect(response.status).toBe(401);
}); });
@ -272,13 +277,13 @@ describe('User API', () => {
### 测试结果记录 ### 测试结果记录
| 测试用例 | 状态 | 执行时间 | 备注 | | 测试用例 | 状态 | 执行时间 | 备注 |
|---------|------|----------|------| | ---------------- | ---- | -------- | ---- |
| 正常获取用户信息 | ✅ | 50ms | 通过 | | 正常获取用户信息 | ✅ | 50ms | 通过 |
| 未提供Token | ✅ | 30ms | 通过 | | 未提供Token | ✅ | 30ms | 通过 |
| Token无效 | ✅ | 35ms | 通过 | | Token无效 | ✅ | 35ms | 通过 |
| 用户不存在 | ✅ | 40ms | 通过 | | 用户不存在 | ✅ | 40ms | 通过 |
| 空字段处理 | ✅ | 45ms | 通过 | | 空字段处理 | ✅ | 45ms | 通过 |
### 问题记录 ### 问题记录
@ -288,4 +293,4 @@ describe('User API', () => {
## 总结 ## 总结
用户模块的获取当前用户信息接口测试覆盖了正常流程、异常处理、边界情况等各个方面,确保接口的稳定性和安全性。所有测试用例均通过,可以投入生产使用。 用户模块的获取当前用户信息接口测试覆盖了正常流程、异常处理、边界情况等各个方面,确保接口的稳定性和安全性。所有测试用例均通过,可以投入生产使用。

View File

@ -1,121 +1,122 @@
# Drizzle ORM 插件 # Drizzle ORM 插件
这是一个集成到 Elysia 框架的 Drizzle ORM 插件,提供类型安全的数据库操作。 这是一个集成到 Elysia 框架的 Drizzle ORM 插件,提供类型安全的数据库操作。
## 安装依赖 ## 安装依赖
```bash ```bash
# 安装 Drizzle ORM 核心包 # 安装 Drizzle ORM 核心包
bun add drizzle-orm bun add drizzle-orm
# 安装 Drizzle Kit (开发工具) # 安装 Drizzle Kit (开发工具)
bun add drizzle-kit -D bun add drizzle-kit -D
``` ```
## 文件结构 ## 文件结构
``` ```
src/plugins/drizzle/ src/plugins/drizzle/
├── drizzle.plugins.ts # 主插件文件 ├── drizzle.plugins.ts # 主插件文件
├── drizzle.config.ts # Drizzle Kit 配置 ├── drizzle.config.ts # Drizzle Kit 配置
├── schema/ # 数据库表结构定义 ├── schema/ # 数据库表结构定义
│ ├── index.ts # Schema 总入口 │ ├── index.ts # Schema 总入口
│ └── users.ts # 用户表示例 │ └── users.ts # 用户表示例
└── README.md # 使用说明 └── README.md # 使用说明
``` ```
## 使用方法 ## 使用方法
### 1. 在路由中使用数据库 ### 1. 在路由中使用数据库
```typescript ```typescript
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { users } from '@/plugins/drizzle/schema'; import { users } from '@/plugins/drizzle/schema';
const app = new Elysia() const app = new Elysia()
.get('/users', async ({ db }) => { .get('/users', async ({ db }) => {
// 查询所有用户 // 查询所有用户
const allUsers = await db.select().from(users); const allUsers = await db.select().from(users);
return allUsers; return allUsers;
}) })
.get('/users/:id', async ({ db, params }) => { .get('/users/:id', async ({ db, params }) => {
// 根据ID查询用户 // 根据ID查询用户
const user = await db.select() const user = await db
.from(users) .select()
.where(eq(users.id, parseInt(params.id))); .from(users)
return user[0]; .where(eq(users.id, parseInt(params.id)));
}) return user[0];
.post('/users', async ({ db, body }) => { })
// 创建新用户 .post('/users', async ({ db, body }) => {
const newUser = await db.insert(users).values(body); // 创建新用户
return newUser; const newUser = await db.insert(users).values(body);
}); return newUser;
``` });
```
### 2. 定义新的表结构
### 2. 定义新的表结构
`schema/` 目录下创建新的表文件:
`schema/` 目录下创建新的表文件:
```typescript
// schema/posts.ts ```typescript
import { mysqlTable, int, varchar, text, timestamp } from 'drizzle-orm/mysql-core'; // schema/posts.ts
import { users } from './users'; import { mysqlTable, int, varchar, text, timestamp } from 'drizzle-orm/mysql-core';
import { users } from './users';
export const posts = mysqlTable('posts', {
id: int('id').primaryKey().autoincrement(), export const posts = mysqlTable('posts', {
title: varchar('title', { length: 255 }).notNull(), id: int('id').primaryKey().autoincrement(),
content: text('content'), title: varchar('title', { length: 255 }).notNull(),
authorId: int('author_id').references(() => users.id), content: text('content'),
createdAt: timestamp('created_at').defaultNow().notNull(), authorId: int('author_id').references(() => users.id),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}); updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
export type Post = typeof posts.$inferSelect;
export type InsertPost = typeof posts.$inferInsert; export type Post = typeof posts.$inferSelect;
``` export type InsertPost = typeof posts.$inferInsert;
```
然后在 `schema/index.ts` 中导出:
然后在 `schema/index.ts` 中导出:
```typescript
export * from './posts'; ```typescript
``` export * from './posts';
```
### 3. 生成和运行数据库迁移
### 3. 生成和运行数据库迁移
```bash
# 生成迁移文件 ```bash
bun drizzle-kit generate # 生成迁移文件
bun drizzle-kit generate
# 推送迁移到数据库
bun drizzle-kit push # 推送迁移到数据库
bun drizzle-kit push
# 查看数据库状态
bun drizzle-kit studio # 查看数据库状态
``` bun drizzle-kit studio
```
## 配置说明
## 配置说明
- **数据库连接**: 自动从 `@/config` 读取数据库配置
- **连接池**: 默认最大连接数为 10 - **数据库连接**: 自动从 `@/config` 读取数据库配置
- **日志**: 启用 SQL 查询日志 - **连接池**: 默认最大连接数为 10
- **迁移**: 迁移文件输出到 `./drizzle` 目录 - **日志**: 启用 SQL 查询日志
- **迁移**: 迁移文件输出到 `./drizzle` 目录
## 类型支持
## 类型支持
插件提供完整的 TypeScript 类型支持:
插件提供完整的 TypeScript 类型支持:
- `DrizzleDB`: 数据库实例类型
- `User`, `InsertUser`: 用户表相关类型 - `DrizzleDB`: 数据库实例类型
- `DrizzleContext`: Elysia 上下文扩展类型 - `User`, `InsertUser`: 用户表相关类型
- `DrizzleContext`: Elysia 上下文扩展类型
## 注意事项
## 注意事项
1. 确保数据库配置正确且数据库服务已启动
2. 生产环境建议使用环境变量管理数据库凭据 1. 确保数据库配置正确且数据库服务已启动
3. 定期备份数据库,特别是在运行迁移之前 2. 生产环境建议使用环境变量管理数据库凭据
4. 使用 Drizzle Studio 可视化管理数据库 3. 定期备份数据库,特别是在运行迁移之前
4. 使用 Drizzle Studio 可视化管理数据库
## 相关链接
## 相关链接
- [Drizzle ORM 官方文档](https://orm.drizzle.team/)
- [Drizzle Kit 文档](https://orm.drizzle.team/kit-docs/overview) - [Drizzle ORM 官方文档](https://orm.drizzle.team/)
- [Drizzle Kit 文档](https://orm.drizzle.team/kit-docs/overview)

View File

@ -1,24 +1,24 @@
/** /**
* @file Drizzle ORM * @file Drizzle ORM
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-29 * @lastEditTime 2025-06-29
* @description Drizzle ORM到Elysia * @description Drizzle ORM到Elysia
*/ */
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import * as schema from '../../eneities'; import * as schema from '../../eneities';
import { drizzleService } from './drizzle.service'; import { drizzleService } from './drizzle.service';
/** /**
* Drizzle ORM * Drizzle ORM
* *
*/ */
export const drizzlePlugin = new Elysia({ name: 'drizzle' }).onStart(async () => { export const drizzlePlugin = new Elysia({ name: 'drizzle' }).onStart(async () => {
await drizzleService.initialize(); await drizzleService.initialize();
}); });
/** 导出数据库类型,供其他模块使用 */ /** 导出数据库类型,供其他模块使用 */
export type DB = typeof schema; export type DB = typeof schema;
export type DrizzleDB = Awaited<ReturnType<typeof drizzleService.initialize>>; export type DrizzleDB = Awaited<ReturnType<typeof drizzleService.initialize>>;

View File

@ -1,459 +1,462 @@
/** /**
* @file Drizzle ORM服务类 * @file Drizzle ORM服务类
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-29 * @lastEditTime 2025-06-29
* @description Drizzle ORM数据库连接服务类 * @description Drizzle ORM数据库连接服务类
*/ */
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { dbConfig } from '@/config'; import { dbConfig } from '@/config';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import * as schema from '../../eneities'; import * as schema from '../../eneities';
import type { DrizzleDB, ConnectionStatus, DatabaseConnectionInfo } from '@/type/drizzle.type'; import type { DrizzleDB, ConnectionStatus, DatabaseConnectionInfo } from '@/type/drizzle.type';
/** /**
* Drizzle数据库服务类 * Drizzle数据库服务类
* 使 * 使
*/ */
export class DrizzleService { export class DrizzleService {
/** 单例实例 */ /** 单例实例 */
private static instance: DrizzleService | null = null; private static instance: DrizzleService | null = null;
/** 数据库实例 */ /** 数据库实例 */
private _db: DrizzleDB | null = null; private _db: DrizzleDB | null = null;
/** 连接池实例 */ /** 连接池实例 */
private _connectionPool: mysql.Pool | null = null; private _connectionPool: mysql.Pool | null = null;
/** 连接状态信息 */ /** 连接状态信息 */
private _connectionInfo: DatabaseConnectionInfo; private _connectionInfo: DatabaseConnectionInfo;
/** 初始化标志 */ /** 初始化标志 */
private _isInitialized = false; private _isInitialized = false;
/** 连接池配置 */ /** 连接池配置 */
private readonly _poolConfig = { private readonly _poolConfig = {
/** 最大连接数 */ /** 最大连接数 */
connectionLimit: Number(process.env.DB_CONNECTION_LIMIT) || 10, connectionLimit: Number(process.env.DB_CONNECTION_LIMIT) || 10,
/** 队列限制 */ /** 队列限制 */
queueLimit: Number(process.env.DB_QUEUE_LIMIT) || 0, queueLimit: Number(process.env.DB_QUEUE_LIMIT) || 0,
/** 等待连接 */ /** 等待连接 */
waitForConnections: true, waitForConnections: true,
// 启用此选项后MySQL驱动程序将支持大数字big numbers这对于存储和处理 bigint 类型的数据尤为重要。 // 启用此选项后MySQL驱动程序将支持大数字big numbers这对于存储和处理 bigint 类型的数据尤为重要。
// 如果不启用此选项MySQL驱动程序可能无法正确处理超过 JavaScript 数字精度范围的大数值,导致数据精度丢失。 // 如果不启用此选项MySQL驱动程序可能无法正确处理超过 JavaScript 数字精度范围的大数值,导致数据精度丢失。
supportBigNumbers: true, supportBigNumbers: true,
// 启用此选项后MySQL驱动程序将在接收 bigint 或其他大数值时将其作为字符串返回而不是作为JavaScript数字。 // 启用此选项后MySQL驱动程序将在接收 bigint 或其他大数值时将其作为字符串返回而不是作为JavaScript数字。
// 这种处理方式可以避免JavaScript本身的数值精度限制问题确保大数值在应用程序中保持精确。 // 这种处理方式可以避免JavaScript本身的数值精度限制问题确保大数值在应用程序中保持精确。
bigNumberStrings: true, bigNumberStrings: true,
}; };
/** /**
* *
*/ */
private constructor() { private constructor() {
this._connectionInfo = { this._connectionInfo = {
status: 'disconnected', status: 'disconnected',
host: dbConfig.host, host: dbConfig.host,
port: dbConfig.port, port: dbConfig.port,
database: dbConfig.database, database: dbConfig.database,
}; };
} }
/** /**
* *
*/ */
public static getInstance(): DrizzleService { public static getInstance(): DrizzleService {
if (!DrizzleService.instance) { if (!DrizzleService.instance) {
DrizzleService.instance = new DrizzleService(); DrizzleService.instance = new DrizzleService();
} }
return DrizzleService.instance; return DrizzleService.instance;
} }
/** /**
* *
*/ */
public get db(): DrizzleDB { public get db(): DrizzleDB {
if (!this._db) { if (!this._db) {
throw new Error('数据库未初始化,请先调用 initialize() 方法'); throw new Error('数据库未初始化,请先调用 initialize() 方法');
} }
return this._db; return this._db;
} }
/** /**
* *
*/ */
public get connectionInfo(): DatabaseConnectionInfo { public get connectionInfo(): DatabaseConnectionInfo {
return { ...this._connectionInfo }; return { ...this._connectionInfo };
} }
/** /**
* *
*/ */
public get isInitialized(): boolean { public get isInitialized(): boolean {
return this._isInitialized; return this._isInitialized;
} }
/** /**
* *
*/ */
private validateConfig(): void { private validateConfig(): void {
const requiredFields = ['host', 'port', 'user', 'password', 'database']; const requiredFields = ['host', 'port', 'user', 'password', 'database'];
for (const field of requiredFields) { for (const field of requiredFields) {
if (!dbConfig[field as keyof typeof dbConfig]) { if (!dbConfig[field as keyof typeof dbConfig]) {
throw new Error(`数据库配置缺少必需字段: ${field}`); throw new Error(`数据库配置缺少必需字段: ${field}`);
} }
} }
if (dbConfig.port < 1 || dbConfig.port > 65535) { if (dbConfig.port < 1 || dbConfig.port > 65535) {
throw new Error(`数据库端口号无效: ${dbConfig.port}`); throw new Error(`数据库端口号无效: ${dbConfig.port}`);
} }
} }
/** /**
* *
*/ */
private updateConnectionStatus(status: ConnectionStatus, error?: string): void { private updateConnectionStatus(status: ConnectionStatus, error?: string): void {
this._connectionInfo.status = status; this._connectionInfo.status = status;
this._connectionInfo.error = error; this._connectionInfo.error = error;
if (status === 'connected') { if (status === 'connected') {
this._connectionInfo.connectedAt = new Date(); this._connectionInfo.connectedAt = new Date();
this._connectionInfo.error = undefined; this._connectionInfo.error = undefined;
} }
} }
/** /**
* MySQL连接池 * MySQL连接池
*/ */
private async createConnection(): Promise<mysql.Pool> { private async createConnection(): Promise<mysql.Pool> {
try { try {
this.validateConfig(); this.validateConfig();
this.updateConnectionStatus('connecting'); this.updateConnectionStatus('connecting');
/** MySQL连接池配置 */ /** MySQL连接池配置 */
const connection = mysql.createPool({ const connection = mysql.createPool({
host: dbConfig.host, host: dbConfig.host,
port: dbConfig.port, port: dbConfig.port,
user: dbConfig.user, user: dbConfig.user,
password: dbConfig.password, password: dbConfig.password,
database: dbConfig.database, database: dbConfig.database,
...this._poolConfig, ...this._poolConfig,
}); });
// 测试连接 // 测试连接
const testConnection = await connection.getConnection(); const testConnection = await connection.getConnection();
await testConnection.ping(); await testConnection.ping();
testConnection.release(); testConnection.release();
this.updateConnectionStatus('connected'); this.updateConnectionStatus('connected');
Logger.info({ Logger.info({
message: 'MySQL连接池创建成功', message: 'MySQL连接池创建成功',
host: dbConfig.host, host: dbConfig.host,
port: dbConfig.port, port: dbConfig.port,
database: dbConfig.database, database: dbConfig.database,
connectionLimit: this._poolConfig.connectionLimit, connectionLimit: this._poolConfig.connectionLimit,
}); });
return connection; return connection;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
this.updateConnectionStatus('error', errorMessage); this.updateConnectionStatus('error', errorMessage);
Logger.error(new Error(`MySQL连接池创建失败: ${errorMessage}`)); Logger.error(new Error(`MySQL连接池创建失败: ${errorMessage}`));
throw new Error(`MySQL连接池创建失败: ${errorMessage}`); throw new Error(`MySQL连接池创建失败: ${errorMessage}`);
} }
} }
/** /**
* *
*/ */
public async initialize(): Promise<DrizzleDB> { public async initialize(): Promise<DrizzleDB> {
// 防止重复初始化 // 防止重复初始化
if (this._isInitialized && this._db) { if (this._isInitialized && this._db) {
Logger.info('Drizzle ORM 已初始化,返回现有实例'); Logger.info('Drizzle ORM 已初始化,返回现有实例');
return this._db; return this._db;
} }
try { try {
this._connectionPool = await this.createConnection(); this._connectionPool = await this.createConnection();
console.log(process.env.NODE_ENV, process.env.NODE_ENV === 'development') console.log(process.env.NODE_ENV, process.env.NODE_ENV === 'development');
/** Drizzle数据库实例 */ /** Drizzle数据库实例 */
this._db = drizzle(this._connectionPool, { this._db = drizzle(this._connectionPool, {
schema, schema,
mode: 'default', mode: 'default',
logger: process.env.NODE_ENV === 'development' ? { logger:
logQuery: (query, params) => { process.env.NODE_ENV === 'development'
Logger.debug({ ? {
type: 'SQL_QUERY', logQuery: (query, params) => {
query: query.replace(/\s+/g, ' ').trim(), Logger.debug({
params: params, type: 'SQL_QUERY',
}); query: query.replace(/\s+/g, ' ').trim(),
}, params: params,
} : false, });
}); },
}
this._isInitialized = true; : false,
Logger.info({ });
message: 'Drizzle ORM 初始化成功',
schema: Object.keys(schema).length > 0 ? Object.keys(schema) : ['无schema'], this._isInitialized = true;
loggerEnabled: process.env.NODE_ENV === 'development', Logger.info({
}); message: 'Drizzle ORM 初始化成功',
schema: Object.keys(schema).length > 0 ? Object.keys(schema) : ['无schema'],
return this._db; loggerEnabled: process.env.NODE_ENV === 'development',
} catch (error) { });
const errorMessage = error instanceof Error ? error.message : String(error);
this.updateConnectionStatus('error', errorMessage); return this._db;
Logger.error(new Error(`Drizzle ORM 初始化失败: ${errorMessage}`)); } catch (error) {
throw new Error(`Drizzle ORM 初始化失败: ${errorMessage}`); const errorMessage = error instanceof Error ? error.message : String(error);
} this.updateConnectionStatus('error', errorMessage);
} Logger.error(new Error(`Drizzle ORM 初始化失败: ${errorMessage}`));
throw new Error(`Drizzle ORM 初始化失败: ${errorMessage}`);
/** }
* }
*/
public async checkConnection(): Promise<boolean> { /**
try { *
if (!this._connectionPool) { */
return false; public async checkConnection(): Promise<boolean> {
} try {
if (!this._connectionPool) {
const connection = await this._connectionPool.getConnection(); return false;
await connection.ping(); }
connection.release();
const connection = await this._connectionPool.getConnection();
return true; await connection.ping();
} catch (error) { connection.release();
Logger.error(error instanceof Error ? error : new Error('数据库连接检查失败'));
return false; return true;
} } catch (error) {
} Logger.error(error instanceof Error ? error : new Error('数据库连接检查失败'));
return false;
/** }
* }
*/
public async close(): Promise<void> { /**
try { *
if (this._connectionPool) { */
await this._connectionPool.end(); public async close(): Promise<void> {
this._connectionPool = null; try {
this._db = null; if (this._connectionPool) {
this.updateConnectionStatus('disconnected'); await this._connectionPool.end();
this._isInitialized = false; this._connectionPool = null;
Logger.info('数据库连接已关闭'); this._db = null;
} this.updateConnectionStatus('disconnected');
} catch (error) { this._isInitialized = false;
const errorMessage = error instanceof Error ? error.message : String(error); Logger.info('数据库连接已关闭');
Logger.error(new Error(`关闭数据库连接时出错: ${errorMessage}`)); }
throw new Error(`关闭数据库连接失败: ${errorMessage}`); } catch (error) {
} const errorMessage = error instanceof Error ? error.message : String(error);
} Logger.error(new Error(`关闭数据库连接时出错: ${errorMessage}`));
throw new Error(`关闭数据库连接失败: ${errorMessage}`);
/** }
* }
*/
public async reconnect(): Promise<DrizzleDB> { /**
Logger.info('正在重新连接数据库...'); *
*/
// 先关闭现有连接 public async reconnect(): Promise<DrizzleDB> {
await this.close(); Logger.info('正在重新连接数据库...');
// 重新初始化连接 // 先关闭现有连接
return await this.initialize(); await this.close();
}
// 重新初始化连接
/** return await this.initialize();
* }
*/
public getPoolStats(): { /**
connectionLimit: number; *
queueLimit: number; */
waitForConnections: boolean; public getPoolStats(): {
} | null { connectionLimit: number;
if (!this._connectionPool) { queueLimit: number;
return null; waitForConnections: boolean;
} } | null {
if (!this._connectionPool) {
return { return null;
connectionLimit: this._poolConfig.connectionLimit, }
queueLimit: this._poolConfig.queueLimit,
waitForConnections: this._poolConfig.waitForConnections, return {
}; connectionLimit: this._poolConfig.connectionLimit,
} queueLimit: this._poolConfig.queueLimit,
waitForConnections: this._poolConfig.waitForConnections,
/** };
* }
*/
public async healthCheck(): Promise<{ /**
status: 'healthy' | 'unhealthy'; *
connectionInfo: DatabaseConnectionInfo; */
isConnected: boolean; public async healthCheck(): Promise<{
poolStats?: ReturnType<DrizzleService['getPoolStats']>; status: 'healthy' | 'unhealthy';
}> { connectionInfo: DatabaseConnectionInfo;
const isConnected = await this.checkConnection(); isConnected: boolean;
poolStats?: ReturnType<DrizzleService['getPoolStats']>;
return { }> {
status: isConnected ? 'healthy' : 'unhealthy', const isConnected = await this.checkConnection();
connectionInfo: this.connectionInfo,
isConnected, return {
poolStats: this.getPoolStats(), status: isConnected ? 'healthy' : 'unhealthy',
}; connectionInfo: this.connectionInfo,
} isConnected,
} poolStats: this.getPoolStats(),
};
/** }
* ============================================== }
* - 使API
* ============================================== /**
*/ * ==============================================
* - 使API
/** * ==============================================
* Drizzle服务单例实例 */
*
* @description DrizzleService的单例实例使 /**
* @example * Drizzle服务单例实例
* ```typescript *
* import { drizzleService } from '@/plugins/drizzle/drizzle.service'; * @description DrizzleService的单例实例使
* * @example
* // 初始化数据库 * ```typescript
* await drizzleService.initialize(); * import { drizzleService } from '@/plugins/drizzle/drizzle.service';
* *
* // 获取数据库实例 * // 初始化数据库
* const database = drizzleService.db; * await drizzleService.initialize();
* *
* // 检查连接状态 * // 获取数据库实例
* const isConnected = await drizzleService.checkConnection(); * const database = drizzleService.db;
* ``` *
*/ * // 检查连接状态
export const drizzleService = DrizzleService.getInstance(); * const isConnected = await drizzleService.checkConnection();
* ```
/** */
* ============================================== export const drizzleService = DrizzleService.getInstance();
* - API
* ============================================== /**
*/ * ==============================================
* - API
/** * ==============================================
* Drizzle数据库连接 */
*
* @description drizzleService.initialize() /**
* @returns {Promise<DrizzleDB>} Drizzle数据库实例 * Drizzle数据库连接
* *
* @example * @description drizzleService.initialize()
* ```typescript * @returns {Promise<DrizzleDB>} Drizzle数据库实例
* import { createDrizzleDB } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* const database = await createDrizzleDB(); * ```typescript
* ``` * import { createDrizzleDB } from '@/plugins/drizzle/drizzle.service';
* *
* @deprecated 使 drizzleService.initialize() * const database = await createDrizzleDB();
*/ * ```
export const createDrizzleDB = () => drizzleService.initialize(); *
* @deprecated 使 drizzleService.initialize()
/** */
* export const createDrizzleDB = () => drizzleService.initialize();
*
* @description drizzleService.connectionInfo /**
* @returns {DatabaseConnectionInfo} *
* *
* @example * @description drizzleService.connectionInfo
* ```typescript * @returns {DatabaseConnectionInfo}
* import { getConnectionInfo } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* const info = getConnectionInfo(); * ```typescript
* console.log(`数据库状态: ${info.status}`); * import { getConnectionInfo } from '@/plugins/drizzle/drizzle.service';
* ``` *
* * const info = getConnectionInfo();
* @deprecated 使 drizzleService.connectionInfo * console.log(`数据库状态: ${info.status}`);
*/ * ```
export const getConnectionInfo = () => drizzleService.connectionInfo; *
* @deprecated 使 drizzleService.connectionInfo
/** */
* export const getConnectionInfo = () => drizzleService.connectionInfo;
*
* @description drizzleService.checkConnection() /**
* @returns {Promise<boolean>} *
* *
* @example * @description drizzleService.checkConnection()
* ```typescript * @returns {Promise<boolean>}
* import { checkConnection } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* const isConnected = await checkConnection(); * ```typescript
* if (!isConnected) { * import { checkConnection } from '@/plugins/drizzle/drizzle.service';
* console.log('数据库连接异常'); *
* } * const isConnected = await checkConnection();
* ``` * if (!isConnected) {
* * console.log('数据库连接异常');
* @deprecated 使 drizzleService.checkConnection() * }
*/ * ```
export const checkConnection = () => drizzleService.checkConnection(); *
* @deprecated 使 drizzleService.checkConnection()
/** */
* export const checkConnection = () => drizzleService.checkConnection();
*
* @description drizzleService.close() /**
* @returns {Promise<void>} Promise *
* *
* @example * @description drizzleService.close()
* ```typescript * @returns {Promise<void>} Promise
* import { closeDrizzleDB } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* // 应用关闭时清理资源 * ```typescript
* process.on('SIGTERM', async () => { * import { closeDrizzleDB } from '@/plugins/drizzle/drizzle.service';
* await closeDrizzleDB(); *
* process.exit(0); * // 应用关闭时清理资源
* }); * process.on('SIGTERM', async () => {
* ``` * await closeDrizzleDB();
* * process.exit(0);
* @deprecated 使 drizzleService.close() * });
*/ * ```
export const closeDrizzleDB = () => drizzleService.close(); *
* @deprecated 使 drizzleService.close()
/** */
* export const closeDrizzleDB = () => drizzleService.close();
*
* @description drizzleService.reconnect() /**
* @returns {Promise<DrizzleDB>} *
* *
* @example * @description drizzleService.reconnect()
* ```typescript * @returns {Promise<DrizzleDB>}
* import { reconnectDB } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* try { * ```typescript
* const database = await reconnectDB(); * import { reconnectDB } from '@/plugins/drizzle/drizzle.service';
* console.log('数据库重连成功'); *
* } catch (error) { * try {
* console.error('数据库重连失败:', error); * const database = await reconnectDB();
* } * console.log('数据库重连成功');
* ``` * } catch (error) {
* * console.error('数据库重连失败:', error);
* @deprecated 使 drizzleService.reconnect() * }
*/ * ```
export const reconnectDB = () => drizzleService.reconnect(); *
* @deprecated 使 drizzleService.reconnect()
/** */
* export const reconnectDB = () => drizzleService.reconnect();
*
* @description drizzleService.db /**
* @returns {DrizzleDB} Drizzle数据库实例 *
* @throws {Error} *
* * @description drizzleService.db
* @example * @returns {DrizzleDB} Drizzle数据库实例
* ```typescript * @throws {Error}
* import { db } from '@/plugins/drizzle/drizzle.service'; *
* * @example
* // 确保先初始化 * ```typescript
* await createDrizzleDB(); * import { db } from '@/plugins/drizzle/drizzle.service';
* *
* // 获取数据库实例 * // 确保先初始化
* const database = db(); * await createDrizzleDB();
* const users = await database.select().from(usersTable); *
* ``` * // 获取数据库实例
* * const database = db();
* @deprecateds 使 drizzleService.db * const users = await database.select().from(usersTable);
*/ * ```
export const db = () => drizzleService.db; *
* @deprecateds 使 drizzleService.db
*/
export const db = () => drizzleService.db;

View File

@ -43,9 +43,8 @@ EMAIL_RETRY_DELAY=5000
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { emailPlugin } from '@/plugins/email/email.plugins'; import { emailPlugin } from '@/plugins/email/email.plugins';
const app = new Elysia() const app = new Elysia().use(emailPlugin);
.use(emailPlugin) // ... 其他插件
// ... 其他插件
``` ```
## 使用方法 ## 使用方法
@ -156,6 +155,7 @@ app.get('/email/health', async ({ emailService }) => {
发送自定义邮件。 发送自定义邮件。
**参数:** **参数:**
- `options.to`: 收件人邮箱(字符串或数组) - `options.to`: 收件人邮箱(字符串或数组)
- `options.subject`: 邮件主题 - `options.subject`: 邮件主题
- `options.html`: HTML 内容 - `options.html`: HTML 内容
@ -170,6 +170,7 @@ app.get('/email/health', async ({ emailService }) => {
发送模板邮件。 发送模板邮件。
**参数:** **参数:**
- `options.to`: 收件人邮箱 - `options.to`: 收件人邮箱
- `options.template`: 模板类型 - `options.template`: 模板类型
- `options.params`: 模板参数 - `options.params`: 模板参数
@ -214,4 +215,4 @@ app.get('/email/health', async ({ emailService }) => {
2. **端口配置**: 根据邮件提供商配置正确的端口和 SSL 设置 2. **端口配置**: 根据邮件提供商配置正确的端口和 SSL 设置
3. **发送限制**: 注意邮件提供商的发送频率限制 3. **发送限制**: 注意邮件提供商的发送频率限制
4. **模板自定义**: 可以通过修改服务类来自定义邮件模板 4. **模板自定义**: 可以通过修改服务类来自定义邮件模板
5. **安全性**: 避免在邮件内容中包含敏感信息 5. **安全性**: 避免在邮件内容中包含敏感信息

View File

@ -129,11 +129,7 @@ export const emailPlugin = new Elysia({ name: 'email' })
/** /**
* *
*/ */
async sendWelcomeEmail(options: { async sendWelcomeEmail(options: { to: string; username: string; nickname?: string }): Promise<EmailSendResult> {
to: string;
username: string;
nickname?: string;
}): Promise<EmailSendResult> {
return await emailService.sendTemplateEmail({ return await emailService.sendTemplateEmail({
to: options.to, to: options.to,
template: 'welcome', template: 'welcome',
@ -221,11 +217,7 @@ export type EmailServiceDecorator = {
resetCode: string; resetCode: string;
expireTime: string; expireTime: string;
}): Promise<EmailSendResult>; }): Promise<EmailSendResult>;
sendWelcomeEmail(options: { sendWelcomeEmail(options: { to: string; username: string; nickname?: string }): Promise<EmailSendResult>;
to: string;
username: string;
nickname?: string;
}): Promise<EmailSendResult>;
sendPasswordChangedEmail(options: { sendPasswordChangedEmail(options: {
to: string; to: string;
username: string; username: string;
@ -239,4 +231,4 @@ export type EmailServiceDecorator = {
subject?: string; subject?: string;
}): Promise<EmailSendResult>; }): Promise<EmailSendResult>;
}; };
}; };

View File

@ -107,7 +107,7 @@ export class EmailService {
private updateStatus( private updateStatus(
status: EmailServiceStatus['status'], status: EmailServiceStatus['status'],
transporterStatus: EmailServiceStatus['transporterStatus'], transporterStatus: EmailServiceStatus['transporterStatus'],
error?: string error?: string,
): void { ): void {
this._status = { this._status = {
status, status,
@ -189,11 +189,9 @@ export class EmailService {
if (!fromAddress) { if (!fromAddress) {
throw new Error('发件人邮箱地址不能为空请检查SMTP_USER或SMTP_FROM_EMAIL环境变量'); throw new Error('发件人邮箱地址不能为空请检查SMTP_USER或SMTP_FROM_EMAIL环境变量');
} }
const fromName = emailConfig.fromName || '星撰系统'; const fromName = emailConfig.fromName || '星撰系统';
const formattedFrom = fromAddress.includes('<') const formattedFrom = fromAddress.includes('<') ? fromAddress : `"${fromName}" <${fromAddress}>`;
? fromAddress
: `"${fromName}" <${fromAddress}>`;
const mailOptions = { const mailOptions = {
from: formattedFrom, from: formattedFrom,
@ -263,7 +261,7 @@ export class EmailService {
} }
// 等待重试延迟 // 等待重试延迟
await new Promise(resolve => setTimeout(resolve, emailOptions.retryDelay)); await new Promise((resolve) => setTimeout(resolve, emailOptions.retryDelay));
} }
} }
@ -326,7 +324,7 @@ export class EmailService {
*/ */
private renderTemplate( private renderTemplate(
templateType: EmailTemplateType, templateType: EmailTemplateType,
params: EmailTemplateParams params: EmailTemplateParams,
): { subject: string; html: string; text: string } { ): { subject: string; html: string; text: string } {
const template = emailTemplates[templateType]; const template = emailTemplates[templateType];
const defaultParams = { const defaultParams = {
@ -617,4 +615,4 @@ export const checkEmailServiceHealth = () => emailService.healthCheck();
export const closeEmailService = () => emailService.close(); export const closeEmailService = () => emailService.close();
// 导出服务实例 // 导出服务实例
export { emailService }; export { emailService };

View File

@ -83,6 +83,14 @@ export const errorHandlerPlugin = (app: Elysia) =>
errors: error.message, errors: error.message,
}; };
} }
case 409: {
set.status = code;
return {
code: error.code,
message: '数据已存在,请勿重复操作',
errors: error.message,
};
}
default: { default: {
// 处理 ElysiaCustomStatusResponse status抛出的异常 // 处理 ElysiaCustomStatusResponse status抛出的异常
if (error?.constructor?.name === 'ElysiaCustomStatusResponse') { if (error?.constructor?.name === 'ElysiaCustomStatusResponse') {
@ -94,7 +102,13 @@ export const errorHandlerPlugin = (app: Elysia) =>
}; };
} }
console.log('error ==================== \n', error, `\n ==================== error \n`, code, 'code =============='); console.log(
'error ==================== \n',
error,
'\n ==================== error \n',
code,
'code ==============',
);
set.status = 500; set.status = 500;
Logger.error(error as Error); Logger.error(error as Error);
return { return {

View File

@ -1,31 +1,31 @@
/** /**
* @file Plugins * @file Plugins
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-29 * @lastEditTime 2025-06-29
* @description 使 group * @description 使 group
*/ */
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { loggerPlugin } from '@/plugins/logger/logger.plugins'; import { loggerPlugin } from '@/plugins/logger/logger.plugins';
import { errorHandlerPlugin } from '@/plugins/errorHandle/errorHandler.plugins'; import { errorHandlerPlugin } from '@/plugins/errorHandle/errorHandler.plugins';
import { swaggerPlugin } from '@/plugins/swagger/swagger.plugins'; import { swaggerPlugin } from '@/plugins/swagger/swagger.plugins';
import { drizzlePlugin } from '@/plugins/drizzle/drizzle.plugins'; import { drizzlePlugin } from '@/plugins/drizzle/drizzle.plugins';
import { redisPlugin } from '@/plugins/redis/redis.plugins'; import { redisPlugin } from '@/plugins/redis/redis.plugins';
import { emailPlugin } from '@/plugins/email/email.plugins'; import { emailPlugin } from '@/plugins/email/email.plugins';
export const plugins = (app: Elysia) => export const plugins = (app: Elysia) =>
app app
// 日志插件 // 日志插件
.use(loggerPlugin) .use(loggerPlugin)
// 错误处理插件 // 错误处理插件
.use(errorHandlerPlugin) .use(errorHandlerPlugin)
// 数据库插件 // 数据库插件
.use(drizzlePlugin) .use(drizzlePlugin)
// Redis插件 // Redis插件
.use(redisPlugin) .use(redisPlugin)
// 邮件服务插件 // 邮件服务插件
.use(emailPlugin) .use(emailPlugin)
// API 文档插件 // API 文档插件
.use(swaggerPlugin); .use(swaggerPlugin);

View File

@ -29,7 +29,7 @@ export const jwtAuthPlugin = (app: Elysia) =>
if (payload.type !== TOKEN_TYPES.ACCESS && ENV === 'production') { if (payload.type !== TOKEN_TYPES.ACCESS && ENV === 'production') {
return status(401, 'Token无效'); return status(401, 'Token无效');
} }
// 提取用户信息 // 提取用户信息
const user: JwtUserType = { const user: JwtUserType = {
userId: payload.userId, userId: payload.userId,

View File

@ -1,102 +1,105 @@
/** /**
* @file JWT服务类 - * @file JWT服务类 -
* @author AI Assistant * @author AI Assistant
* @date 2025-01-07 * @date 2025-01-07
* @description 使jsonwebtoken库提供JWT功能 * @description 使jsonwebtoken库提供JWT功能
*/ */
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { jwtConfig } from '@/config'; import { jwtConfig } from '@/config';
import { TOKEN_TYPES, type JwtPayloadType } from '@/type/jwt.type'; import { TOKEN_TYPES, type JwtPayloadType } from '@/type/jwt.type';
/** /**
* JWT服务类 - * JWT服务类 -
*/ */
export class JwtService { export class JwtService {
/** /**
* Token * Token
*/ */
generateActivationToken(userId: string, email: string, username: string){ generateActivationToken(userId: string, email: string, username: string) {
return jwt.sign( return jwt.sign(
{ {
userId, userId,
email, email,
username, username,
type: TOKEN_TYPES.ACTIVATION, type: TOKEN_TYPES.ACTIVATION,
}, },
jwtConfig.secret, jwtConfig.secret,
{ expiresIn: '1D' } { expiresIn: '1D' },
); );
} }
/** /**
* Token对 * Token对
*/ */
generateTokens(userInfo: { generateTokens(
id: string; userInfo: {
username: string; id: string;
email: string; username: string;
nickname?: string; email: string;
status: string; nickname?: string;
}, rememberMe = false) { status: string;
const userPayload = { },
userId: userInfo.id, rememberMe = false,
username: userInfo.username, ) {
email: userInfo.email, const userPayload = {
nickname: userInfo.nickname, userId: userInfo.id,
status: userInfo.status, username: userInfo.username,
}; email: userInfo.email,
const accessToken = jwt.sign( nickname: userInfo.nickname,
{ status: userInfo.status,
...userPayload, };
type: TOKEN_TYPES.ACCESS, const accessToken = jwt.sign(
}, {
jwtConfig.secret, ...userPayload,
{ expiresIn: '20M' } type: TOKEN_TYPES.ACCESS,
) },
jwtConfig.secret,
const refreshToken = jwt.sign( { expiresIn: '20M' },
{ );
...userPayload,
type: TOKEN_TYPES.REFRESH, const refreshToken = jwt.sign(
}, {
jwtConfig.secret, ...userPayload,
{ expiresIn: '14D' } type: TOKEN_TYPES.REFRESH,
) },
jwtConfig.secret,
return { { expiresIn: '14D' },
accessToken, );
refreshToken,
tokenType: 'Bearer', return {
expiresIn: '20M', accessToken,
refreshExpiresIn: '14D', refreshToken,
}; tokenType: 'Bearer',
} expiresIn: '20M',
refreshExpiresIn: '14D',
/** };
* Token }
*/
verifyToken(token: string) { /**
try { * Token
return jwt.verify(token, jwtConfig.secret) as JwtPayloadType */
} catch { verifyToken(token: string) {
return { error: true } as JwtPayloadType; try {
} return jwt.verify(token, jwtConfig.secret) as JwtPayloadType;
} } catch {
return { error: true } as JwtPayloadType;
/** }
* Token }
*/
generateResetToken(userId: string) { /**
return jwt.sign( * Token
{ */
userId, generateResetToken(userId: string) {
type: TOKEN_TYPES.PASSWORD_RESET, return jwt.sign(
}, {
jwtConfig.secret, userId,
{ expiresIn: '30M' } type: TOKEN_TYPES.PASSWORD_RESET,
); },
} jwtConfig.secret,
} { expiresIn: '30M' },
);
export const jwtService = new JwtService(); }
}
export const jwtService = new JwtService();

View File

@ -26,7 +26,7 @@ export const loggerPlugin = (app: Elysia) =>
.state('requestStart', null as [number, number] | null) .state('requestStart', null as [number, number] | null)
.state('color', null as string | null) .state('color', null as string | null)
/** 请求拦截器 - 记录请求信息 */ /** 请求拦截器 - 记录请求信息 */
.onRequest(({ store, request, server, path,}) => { .onRequest(({ store, request, server, path }) => {
/** 记录请求开始时间 */ /** 记录请求开始时间 */
store.requestStart = process.hrtime(); store.requestStart = process.hrtime();
/** 获取客户端IP信息 */ /** 获取客户端IP信息 */

View File

@ -1,260 +1,259 @@
/** /**
* @file Winston日志器工具类 * @file Winston日志器工具类
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description winston的高性能日志记录器 * @description winston的高性能日志记录器
*/ */
import winston, { log } from 'winston'; import winston, { log } from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file'; import DailyRotateFile from 'winston-daily-rotate-file';
import { loggerConfig } from '@/config/logger.config'; import { loggerConfig } from '@/config/logger.config';
import chalk from 'chalk'; import chalk from 'chalk';
import { centerText } from '@/utils/text'; import { centerText } from '@/utils/text';
import { formatFileSize } from '@/utils/formatFileSize'; import { formatFileSize } from '@/utils/formatFileSize';
/** /**
* *
*/ */
const colorMethods = { const colorMethods = {
error: (msg: string) => chalk.bgRed.white(msg), error: (msg: string) => chalk.bgRed.white(msg),
warn: (msg: string) => chalk.bgYellow.black(msg), warn: (msg: string) => chalk.bgYellow.black(msg),
info: (msg: string) => chalk.bgGreen(msg), info: (msg: string) => chalk.bgGreen(msg),
http: (msg: string) => chalk.bgCyan(msg), http: (msg: string) => chalk.bgCyan(msg),
verbose: (msg: string) => chalk.bgGray(msg), verbose: (msg: string) => chalk.bgGray(msg),
debug: (msg: string) => chalk.bgMagenta(msg), debug: (msg: string) => chalk.bgMagenta(msg),
silly: (msg: string) => chalk.bgGray(msg), silly: (msg: string) => chalk.bgGray(msg),
}; };
const colorMethodsForStart = { const colorMethodsForStart = {
error: (msg: string) => chalk.red(msg), error: (msg: string) => chalk.red(msg),
warn: (msg: string) => chalk.yellow(msg), warn: (msg: string) => chalk.yellow(msg),
info: (msg: string) => chalk.green(msg), info: (msg: string) => chalk.green(msg),
http: (msg: string) => chalk.cyan(msg), http: (msg: string) => chalk.cyan(msg),
verbose: (msg: string) => chalk.gray(msg), verbose: (msg: string) => chalk.gray(msg),
debug: (msg: string) => chalk.magenta(msg), debug: (msg: string) => chalk.magenta(msg),
silly: (msg: string) => chalk.gray(msg), silly: (msg: string) => chalk.gray(msg),
}; };
/** /**
* *
* @param stack * @param stack
* @returns * @returns
*/ */
const formatStack = (stack: string): string => { const formatStack = (stack: string): string => {
return ( return (
chalk.red('•••') + chalk.red('•••') +
'\n' + '\n' +
stack stack
.split('\n') .split('\n')
.map((line, index) => { .map((line, index) => {
if (index === 0) return line; // 第一行是错误消息,不处理 if (index === 0) return line; // 第一行是错误消息,不处理
if (line.trim() === '') return line; // 空行不处理 if (line.trim() === '') return line; // 空行不处理
// 为每行第一个字符添加红色背景 // 为每行第一个字符添加红色背景
const firstChar = line.charAt(0); const firstChar = line.charAt(0);
const restOfLine = line.slice(1); const restOfLine = line.slice(1);
return chalk.bgRed(' ') + firstChar + restOfLine; return chalk.bgRed(' ') + firstChar + restOfLine;
}) })
.join('\n') .join('\n')
); );
}; };
/** /**
* JSON信息 * JSON信息
* @param str JSON字符串 * @param str JSON字符串
* @param level * @param level
* @returns JSON字符串 * @returns JSON字符串
*/ */
const formatJSON = (str: string, level: string): string => { const formatJSON = (str: string, level: string): string => {
if (typeof str !== 'string') { if (typeof str !== 'string') {
console.log('str', str); console.log('str', str);
return JSON.stringify(str, null, 2); return JSON.stringify(str, null, 2);
} }
if (!str?.includes('\n')) { if (!str?.includes('\n')) {
return str; return str;
} }
const color = colorMethodsForStart[level as keyof typeof colorMethods]; const color = colorMethodsForStart[level as keyof typeof colorMethods];
return ( return (
'\n' + '\n' +
color('|') + color('|') +
str str
.split('\n') .split('\n')
.map((line, index) => { .map((line, index) => {
if (index === 0) return line; // 第一行是错误消息,不处理 if (index === 0) return line; // 第一行是错误消息,不处理
if (line.trim() === '') return line; // 空行不处理 if (line.trim() === '') return line; // 空行不处理
// 为每行第一个字符添加红色背景 // 为每行第一个字符添加红色背景
const firstChar = line.charAt(0); const firstChar = line.charAt(0);
const restOfLine = line.slice(1); const restOfLine = line.slice(1);
return color('|') + firstChar + restOfLine; return color('|') + firstChar + restOfLine;
}) })
.join('\n') .join('\n')
); );
}; };
/** /**
* JSON信息 * JSON信息
* @param str JSON字符串 * @param str JSON字符串
* @param level * @param level
* @returns JSON字符串 * @returns JSON字符串
*/ */
const formatHTTP = (obj: any): string => { const formatHTTP = (obj: any): string => {
if (obj.type === 'request') { if (obj.type === 'request') {
return obj.color + `|< ${obj.method} ${obj.path} ${obj.ip} ${obj.browser}`; return obj.color + `|< ${obj.method} ${obj.path} ${obj.ip} ${obj.browser}`;
} else if (obj.type === 'response') { } else if (obj.type === 'response') {
return ( return (
obj.color + obj.color +
`| > ${obj.method} ${obj.path} ${obj.statusCode} ${chalk.bold(obj.requestTime)} ${chalk.bgGreen(obj.responseSize)}` `| > ${obj.method} ${obj.path} ${obj.statusCode} ${chalk.bold(obj.requestTime)} ${chalk.bgGreen(obj.responseSize)}`
); );
} }
}; };
/** /**
* *
*/ */
const consoleTransport = new winston.transports.Console({ const consoleTransport = new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ format: 'YY/MM/DD HH:mm:ss - SSS' }), winston.format.timestamp({ format: 'YY/MM/DD HH:mm:ss - SSS' }),
winston.format.printf(({ timestamp, message, level, stack }) => { winston.format.printf(({ timestamp, message, level, stack }) => {
// 使用居中对齐格式化日志级别 // 使用居中对齐格式化日志级别
const levelText = centerText(level.toUpperCase(), 7); const levelText = centerText(level.toUpperCase(), 7);
const levelFormatted = colorMethods[level as keyof typeof colorMethods](levelText); const levelFormatted = colorMethods[level as keyof typeof colorMethods](levelText);
if (level === 'error' && stack && typeof stack === 'string') { if (level === 'error' && stack && typeof stack === 'string') {
const formattedStack = formatStack(stack); const formattedStack = formatStack(stack);
return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${message}\n${formattedStack}`; return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${message}\n${formattedStack}`;
} else if (level === 'error') { } else if (level === 'error') {
return `[${chalk.red.bold(timestamp)}] ${levelFormatted} 未定义的异常 ${message}`; return `[${chalk.red.bold(timestamp)}] ${levelFormatted} 未定义的异常 ${message}`;
} else if (level === 'http') { } else if (level === 'http') {
return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${formatHTTP(message)}`; return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${formatHTTP(message)}`;
} else if (level === 'debug' && (message as string).includes('"type": "SQL_QUERY"')) { } else if (level === 'debug' && (message as string).includes('"type": "SQL_QUERY"')) {
const sqlLevel = colorMethods[level as keyof typeof colorMethods](centerText('=SQL='.toUpperCase(), 7)); const sqlLevel = colorMethods[level as keyof typeof colorMethods](centerText('=SQL='.toUpperCase(), 7));
console.log(message); console.log(message);
return `[${chalk.red.bold(timestamp)}] ${sqlLevel} ${formatJSON(message as string, level)}`; return `[${chalk.red.bold(timestamp)}] ${sqlLevel} ${formatJSON(message as string, level)}`;
} }
return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${formatJSON(message as string, level)}`; return `[${chalk.red.bold(timestamp)}] ${levelFormatted} ${formatJSON(message as string, level)}`;
}), }),
), ),
}); });
/** /**
* *
*/ */
const appFileTransport = new DailyRotateFile({ const appFileTransport = new DailyRotateFile({
filename: `${loggerConfig.directory}/app-%DATE%.log`, filename: `${loggerConfig.directory}/app-%DATE%.log`,
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
maxSize: loggerConfig.maxSize, maxSize: loggerConfig.maxSize,
maxFiles: loggerConfig.maxFiles, maxFiles: loggerConfig.maxFiles,
format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.json()), format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.json()),
}); });
/** /**
* *
*/ */
const errorFileTransport = new DailyRotateFile({ const errorFileTransport = new DailyRotateFile({
filename: `${loggerConfig.directory}/error-%DATE%.log`, filename: `${loggerConfig.directory}/error-%DATE%.log`,
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
maxSize: loggerConfig.maxSize, maxSize: loggerConfig.maxSize,
maxFiles: loggerConfig.maxFiles, maxFiles: loggerConfig.maxFiles,
level: 'error', level: 'error',
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), // 确保堆栈信息被记录 winston.format.errors({ stack: true }), // 确保堆栈信息被记录
winston.format.json(), winston.format.json(),
), ),
}); });
/** /**
* Winston日志器实例 * Winston日志器实例
*/ */
const logger = winston.createLogger({ const logger = winston.createLogger({
/** 日志级别 */ /** 日志级别 */
level: loggerConfig.level, level: loggerConfig.level,
/** 传输器配置 */ /** 传输器配置 */
transports: [ transports: [
// 应用主日志文件 // 应用主日志文件
appFileTransport, appFileTransport,
// 错误专用日志文件 // 错误专用日志文件
errorFileTransport, errorFileTransport,
// 控制台日志(如果启用) // 控制台日志(如果启用)
...(loggerConfig.console ? [consoleTransport] : []), ...(loggerConfig.console ? [consoleTransport] : []),
], ],
}); });
/** /**
* *
* @param message * @param message
* @returns * @returns
*/ */
const formatMessage = (message: string | object): string => { const formatMessage = (message: string | object): string => {
if (typeof message === 'string') { if (typeof message === 'string') {
return message; return message;
} }
return JSON.stringify(message, (_, v) => return JSON.stringify(message, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2);
typeof v === 'bigint' ? v.toString() : v, 2); };
};
/**
/** *
* */
*/ export class Logger {
export class Logger { static debug(message: string | object): void {
static debug(message: string | object): void { logger.debug(formatMessage(message));
logger.debug(formatMessage(message)); }
} static info(message: string | object): void {
static info(message: string | object): void { logger.info(formatMessage(message));
logger.info(formatMessage(message)); }
} static warn(message: string | object): void {
static warn(message: string | object): void { logger.warn(formatMessage(message));
logger.warn(formatMessage(message)); }
} static error(error: Error): void {
static error(error: Error): void { logger.error({
logger.error({ message: error.message,
message: error.message, stack: error.stack,
stack: error.stack, name: error.name,
name: error.name, cause: error.cause,
cause: error.cause, });
}); }
} static http(message: string | object): void {
static http(message: string | object): void { logger.http(message);
logger.http(message); }
} static verbose(message: string | object): void {
static verbose(message: string | object): void { logger.verbose(formatMessage(message));
logger.verbose(formatMessage(message)); }
} }
} // 获取响应体大小
// 获取响应体大小 export function getResponseSize(response: unknown) {
export function getResponseSize(response: unknown) { let responseSize = 0;
let responseSize = 0;
if (response instanceof Response) {
if (response instanceof Response) { // 对于 Response 对象,可以通过 headers 获取 content-length
// 对于 Response 对象,可以通过 headers 获取 content-length const contentLength = response.headers.get('content-length');
const contentLength = response.headers.get('content-length'); if (contentLength) {
if (contentLength) { responseSize = parseInt(contentLength, 10);
responseSize = parseInt(contentLength, 10); } else if (response.body) {
} else if (response.body) { // 如果没有 content-length可以尝试读取 body 大小
// 如果没有 content-length可以尝试读取 body 大小 // 注意:这可能会消耗 stream需要谨慎使用
// 注意:这可能会消耗 stream需要谨慎使用 responseSize = new Blob([response.body]).size;
responseSize = new Blob([response.body]).size; }
} } else if (typeof response === 'string') {
} else if (typeof response === 'string') { // 对于字符串响应,计算字节大小
// 对于字符串响应,计算字节大小 responseSize = new TextEncoder().encode(response).length;
responseSize = new TextEncoder().encode(response).length; } else if (response && typeof response === 'object') {
} else if (response && typeof response === 'object') { // 对于对象响应,先序列化再计算大小
// 对于对象响应,先序列化再计算大小 responseSize = new TextEncoder().encode(JSON.stringify(response)).length;
responseSize = new TextEncoder().encode(JSON.stringify(response)).length; } else if (response instanceof File || response instanceof Blob) {
} else if (response instanceof File || response instanceof Blob) { // 对于文件响应,可以直接访问 size 属性
// 对于文件响应,可以直接访问 size 属性 responseSize = response.size;
responseSize = response.size; }
}
return formatFileSize(responseSize);
return formatFileSize(responseSize); }
}
// 导出默认实例
// 导出默认实例 export default Logger;
export default Logger;

View File

@ -17,4 +17,3 @@ import { redisService } from './redis.service';
export const redisPlugin = new Elysia({ name: 'redis' }).onStart(async () => { export const redisPlugin = new Elysia({ name: 'redis' }).onStart(async () => {
await redisService.initialize(); await redisService.initialize();
}); });

View File

@ -10,11 +10,7 @@
import { createClient } from 'redis'; import { createClient } from 'redis';
import { redisConfig, getRedisUrl } from '@/config/redis.config'; import { redisConfig, getRedisUrl } from '@/config/redis.config';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
import type { import type { RedisConnectionStatus, RedisConnectionInfo, RedisHealthCheckResult } from '@/type/redis.type';
RedisConnectionStatus,
RedisConnectionInfo,
RedisHealthCheckResult
} from '@/type/redis.type';
/** /**
* Redis服务类 * Redis服务类
@ -101,7 +97,7 @@ export class RedisService {
this._connectionInfo.status = status; this._connectionInfo.status = status;
this._connectionInfo.error = error; this._connectionInfo.error = error;
this._connectionInfo.isConnected = status === 'connected'; this._connectionInfo.isConnected = status === 'connected';
if (status === 'connected') { if (status === 'connected') {
this._connectionInfo.connectedAt = new Date(); this._connectionInfo.connectedAt = new Date();
this._connectionInfo.error = undefined; this._connectionInfo.error = undefined;
@ -121,7 +117,7 @@ export class RedisService {
try { try {
this.validateConfig(); this.validateConfig();
this.updateConnectionStatus('connecting'); this.updateConnectionStatus('connecting');
// 创建Redis客户端 // 创建Redis客户端
this._client = createClient({ this._client = createClient({
name: redisConfig.connectName, name: redisConfig.connectName,
@ -139,7 +135,7 @@ export class RedisService {
this._isInitialized = true; this._isInitialized = true;
this.updateConnectionStatus('connected'); this.updateConnectionStatus('connected');
Logger.info({ Logger.info({
message: 'Redis 初始化成功', message: 'Redis 初始化成功',
host: redisConfig.host, host: redisConfig.host,
@ -147,7 +143,7 @@ export class RedisService {
database: redisConfig.database, database: redisConfig.database,
connectName: redisConfig.connectName, connectName: redisConfig.connectName,
}); });
return this._client; return this._client;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@ -165,7 +161,7 @@ export class RedisService {
if (!this._client || !this._connectionInfo.isConnected) { if (!this._client || !this._connectionInfo.isConnected) {
return false; return false;
} }
await this._client.ping(); await this._client.ping();
return true; return true;
} catch (error) { } catch (error) {
@ -179,7 +175,7 @@ export class RedisService {
*/ */
public async healthCheck(): Promise<RedisHealthCheckResult> { public async healthCheck(): Promise<RedisHealthCheckResult> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
if (!this._client) { if (!this._client) {
return { return {
@ -202,7 +198,7 @@ export class RedisService {
} catch (error) { } catch (error) {
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return { return {
status: 'unhealthy', status: 'unhealthy',
responseTime, responseTime,
@ -236,10 +232,10 @@ export class RedisService {
*/ */
public async reconnect(): Promise<any> { public async reconnect(): Promise<any> {
Logger.info('正在重新连接Redis...'); Logger.info('正在重新连接Redis...');
// 先关闭现有连接 // 先关闭现有连接
await this.close(); await this.close();
// 重新初始化连接 // 重新初始化连接
return await this.initialize(); return await this.initialize();
} }
@ -335,18 +331,18 @@ export class RedisService {
/** /**
* Redis服务单例实例 * Redis服务单例实例
* *
* @description RedisService的单例实例使 * @description RedisService的单例实例使
* @example * @example
* ```typescript * ```typescript
* import { redisService } from '@/plugins/redis/redis.service'; * import { redisService } from '@/plugins/redis/redis.service';
* *
* // 初始化Redis * // 初始化Redis
* await redisService.initialize(); * await redisService.initialize();
* *
* // 获取客户端实例 * // 获取客户端实例
* const client = redisService.client; * const client = redisService.client;
* *
* // 检查连接状态 * // 检查连接状态
* const isConnected = await redisService.checkConnection(); * const isConnected = await redisService.checkConnection();
* ``` * ```
@ -361,90 +357,90 @@ export const redisService = RedisService.getInstance();
/** /**
* Redis连接 * Redis连接
* *
* @description redisService.initialize() * @description redisService.initialize()
* @returns {Promise<any>} Redis客户端实例 * @returns {Promise<any>} Redis客户端实例
* *
* @example * @example
* ```typescript * ```typescript
* import { createRedisClient } from '@/plugins/redis/redis.service'; * import { createRedisClient } from '@/plugins/redis/redis.service';
* *
* const client = await createRedisClient(); * const client = await createRedisClient();
* ``` * ```
* *
* @deprecated 使 redisService.initialize() * @deprecated 使 redisService.initialize()
*/ */
export const createRedisClient = () => redisService.initialize(); export const createRedisClient = () => redisService.initialize();
/** /**
* Redis连接状态信息 * Redis连接状态信息
* *
* @description redisService.connectionInfo * @description redisService.connectionInfo
* @returns {RedisConnectionInfo} Redis连接状态信息 * @returns {RedisConnectionInfo} Redis连接状态信息
* *
* @example * @example
* ```typescript * ```typescript
* import { getRedisConnectionInfo } from '@/plugins/redis/redis.service'; * import { getRedisConnectionInfo } from '@/plugins/redis/redis.service';
* *
* const info = getRedisConnectionInfo(); * const info = getRedisConnectionInfo();
* console.log(`Redis状态: ${info.status}`); * console.log(`Redis状态: ${info.status}`);
* ``` * ```
* *
* @deprecated 使 redisService.connectionInfo * @deprecated 使 redisService.connectionInfo
*/ */
export const getRedisConnectionInfo = () => redisService.connectionInfo; export const getRedisConnectionInfo = () => redisService.connectionInfo;
/** /**
* Redis连接状态 * Redis连接状态
* *
* @description redisService.checkConnection() * @description redisService.checkConnection()
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
* *
* @example * @example
* ```typescript * ```typescript
* import { checkRedisConnection } from '@/plugins/redis/redis.service'; * import { checkRedisConnection } from '@/plugins/redis/redis.service';
* *
* const isConnected = await checkRedisConnection(); * const isConnected = await checkRedisConnection();
* if (!isConnected) { * if (!isConnected) {
* console.log('Redis连接异常'); * console.log('Redis连接异常');
* } * }
* ``` * ```
* *
* @deprecated 使 redisService.checkConnection() * @deprecated 使 redisService.checkConnection()
*/ */
export const checkRedisConnection = () => redisService.checkConnection(); export const checkRedisConnection = () => redisService.checkConnection();
/** /**
* Redis连接 * Redis连接
* *
* @description redisService.close() * @description redisService.close()
* @returns {Promise<void>} Promise * @returns {Promise<void>} Promise
* *
* @example * @example
* ```typescript * ```typescript
* import { closeRedisConnection } from '@/plugins/redis/redis.service'; * import { closeRedisConnection } from '@/plugins/redis/redis.service';
* *
* // 应用关闭时清理资源 * // 应用关闭时清理资源
* process.on('SIGTERM', async () => { * process.on('SIGTERM', async () => {
* await closeRedisConnection(); * await closeRedisConnection();
* process.exit(0); * process.exit(0);
* }); * });
* ``` * ```
* *
* @deprecated 使 redisService.close() * @deprecated 使 redisService.close()
*/ */
export const closeRedisConnection = () => redisService.close(); export const closeRedisConnection = () => redisService.close();
/** /**
* Redis * Redis
* *
* @description redisService.reconnect() * @description redisService.reconnect()
* @returns {Promise<any>} * @returns {Promise<any>}
* *
* @example * @example
* ```typescript * ```typescript
* import { reconnectRedis } from '@/plugins/redis/redis.service'; * import { reconnectRedis } from '@/plugins/redis/redis.service';
* *
* try { * try {
* const client = await reconnectRedis(); * const client = await reconnectRedis();
* console.log('Redis重连成功'); * console.log('Redis重连成功');
@ -452,30 +448,30 @@ export const closeRedisConnection = () => redisService.close();
* console.error('Redis重连失败:', error); * console.error('Redis重连失败:', error);
* } * }
* ``` * ```
* *
* @deprecated 使 redisService.reconnect() * @deprecated 使 redisService.reconnect()
*/ */
export const reconnectRedis = () => redisService.reconnect(); export const reconnectRedis = () => redisService.reconnect();
/** /**
* Redis客户端实例 * Redis客户端实例
* *
* @description redisService.client * @description redisService.client
* @returns {any} Redis客户端实例 * @returns {any} Redis客户端实例
* @throws {Error} Redis未初始化则抛出错误 * @throws {Error} Redis未初始化则抛出错误
* *
* @example * @example
* ```typescript * ```typescript
* import { redis } from '@/plugins/redis/redis.service'; * import { redis } from '@/plugins/redis/redis.service';
* *
* // 确保先初始化 * // 确保先初始化
* await createRedisClient(); * await createRedisClient();
* *
* // 获取客户端实例 * // 获取客户端实例
* const client = redis(); * const client = redis();
* const result = await client.get('key'); * const result = await client.get('key');
* ``` * ```
* *
* @deprecated 使 redisService.client * @deprecated 使 redisService.client
*/ */
export const redis = () => redisService.client; export const redis = () => redisService.client;

View File

@ -1,16 +1,16 @@
/** /**
* @file Demo * @file Demo
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @description demo * @description demo
*/ */
import { import {
initializeEmailService, initializeEmailService,
sendEmail, sendEmail,
sendTemplateEmail, sendTemplateEmail,
checkEmailServiceHealth, checkEmailServiceHealth,
closeEmailService closeEmailService,
} from '@/plugins/email/email.service'; } from '@/plugins/email/email.service';
import { validateEmailConfig } from '@/config/email.config'; import { validateEmailConfig } from '@/config/email.config';
import type { EmailSendOptions, EmailTemplateSendOptions } from '@/type/email.type'; import type { EmailSendOptions, EmailTemplateSendOptions } from '@/type/email.type';
@ -25,7 +25,7 @@ class EmailDemo {
async init(): Promise<boolean> { async init(): Promise<boolean> {
try { try {
console.log('🚀 正在初始化邮件服务...'); console.log('🚀 正在初始化邮件服务...');
// 检查配置 // 检查配置
const isConfigValid = validateEmailConfig(); const isConfigValid = validateEmailConfig();
if (!isConfigValid) { if (!isConfigValid) {
@ -36,7 +36,7 @@ class EmailDemo {
// 初始化服务 // 初始化服务
await initializeEmailService(); await initializeEmailService();
this.initialized = true; this.initialized = true;
console.log('✅ 邮件服务初始化成功'); console.log('✅ 邮件服务初始化成功');
return true; return true;
} catch (error) { } catch (error) {
@ -65,7 +65,7 @@ class EmailDemo {
*/ */
async sendSimpleEmail(to: string): Promise<void> { async sendSimpleEmail(to: string): Promise<void> {
console.log(`\n📧 发送简单测试邮件到: ${to}`); console.log(`\n📧 发送简单测试邮件到: ${to}`);
const emailOptions: EmailSendOptions = { const emailOptions: EmailSendOptions = {
to: to, to: to,
subject: '邮件服务测试 - 简单邮件', subject: '邮件服务测试 - 简单邮件',
@ -89,7 +89,7 @@ class EmailDemo {
try { try {
const result = await sendEmail(emailOptions); const result = await sendEmail(emailOptions);
if (result.success) { if (result.success) {
console.log('✅ 邮件发送成功!'); console.log('✅ 邮件发送成功!');
console.log(`📮 消息ID: ${result.messageId}`); console.log(`📮 消息ID: ${result.messageId}`);
@ -111,7 +111,7 @@ class EmailDemo {
*/ */
async sendTemplateEmail(to: string): Promise<void> { async sendTemplateEmail(to: string): Promise<void> {
console.log(`\n📧 发送模板邮件到: ${to}`); console.log(`\n📧 发送模板邮件到: ${to}`);
const templateOptions: EmailTemplateSendOptions = { const templateOptions: EmailTemplateSendOptions = {
to: to, to: to,
template: 'welcome', template: 'welcome',
@ -124,7 +124,7 @@ class EmailDemo {
try { try {
const result = await sendTemplateEmail(templateOptions); const result = await sendTemplateEmail(templateOptions);
if (result.success) { if (result.success) {
console.log('✅ 模板邮件发送成功!'); console.log('✅ 模板邮件发送成功!');
console.log(`📮 消息ID: ${result.messageId}`); console.log(`📮 消息ID: ${result.messageId}`);
@ -156,7 +156,7 @@ async function main() {
console.log('='.repeat(50)); console.log('='.repeat(50));
const demo = new EmailDemo(); const demo = new EmailDemo();
try { try {
// 初始化 // 初始化
const initSuccess = await demo.init(); const initSuccess = await demo.init();
@ -177,7 +177,7 @@ async function main() {
// 询问收件人邮箱 // 询问收件人邮箱
const testEmail = process.argv[2] || 'test@example.com'; const testEmail = process.argv[2] || 'test@example.com';
console.log(`\n🎯 测试邮箱: ${testEmail}`); console.log(`\n🎯 测试邮箱: ${testEmail}`);
if (testEmail === 'test@example.com') { if (testEmail === 'test@example.com') {
console.log('💡 提示: 可以通过参数指定邮箱地址'); console.log('💡 提示: 可以通过参数指定邮箱地址');
console.log(' 示例: bun run src/tests/demo/emailDemo.ts your@email.com'); console.log(' 示例: bun run src/tests/demo/emailDemo.ts your@email.com');
@ -185,18 +185,17 @@ async function main() {
// 发送测试邮件 // 发送测试邮件
await demo.sendSimpleEmail(testEmail); await demo.sendSimpleEmail(testEmail);
// 等待一下再发送模板邮件 // 等待一下再发送模板邮件
console.log('\n⏳ 等待2秒后发送模板邮件...'); console.log('\n⏳ 等待2秒后发送模板邮件...');
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
await demo.sendTemplateEmail(testEmail); await demo.sendTemplateEmail(testEmail);
console.log('\n' + '='.repeat(50)); console.log('\n' + '='.repeat(50));
console.log('✨ Demo执行完成请检查您的邮箱收件箱'); console.log('✨ Demo执行完成请检查您的邮箱收件箱');
console.log('📬 如果没收到邮件,请检查垃圾邮件文件夹'); console.log('📬 如果没收到邮件,请检查垃圾邮件文件夹');
console.log('='.repeat(50)); console.log('='.repeat(50));
} catch (error) { } catch (error) {
console.log('💥 Demo执行过程中出现错误:', error); console.log('💥 Demo执行过程中出现错误:', error);
} finally { } finally {
@ -210,4 +209,4 @@ if (import.meta.main) {
main().catch(console.error); main().catch(console.error);
} }
export { EmailDemo }; export { EmailDemo };

View File

@ -9,20 +9,20 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setTimeout } from 'node:timers'; import { setTimeout } from 'node:timers';
import { import {
emailService, emailService,
initializeEmailService, initializeEmailService,
sendEmail, sendEmail,
sendTemplateEmail, sendTemplateEmail,
getEmailServiceStatus, getEmailServiceStatus,
checkEmailServiceHealth, checkEmailServiceHealth,
closeEmailService closeEmailService,
} from '@/plugins/email/email.service'; } from '@/plugins/email/email.service';
import { validateEmailConfig } from '@/config/email.config'; import { validateEmailConfig } from '@/config/email.config';
import type { EmailSendOptions, EmailTemplateSendOptions } from '@/type/email.type'; import type { EmailSendOptions, EmailTemplateSendOptions } from '@/type/email.type';
// 简单的延时函数 // 简单的延时函数
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 测试用的邮箱地址(请根据实际情况修改) // 测试用的邮箱地址(请根据实际情况修改)
const TEST_EMAIL = 'x71291@outlook.com'; const TEST_EMAIL = 'x71291@outlook.com';
@ -35,7 +35,7 @@ describe('邮件服务测试', () => {
beforeAll(async () => { beforeAll(async () => {
// 检查邮件配置是否有效 // 检查邮件配置是否有效
isEmailConfigValid = validateEmailConfig(); isEmailConfigValid = validateEmailConfig();
if (isEmailConfigValid) { if (isEmailConfigValid) {
try { try {
// 初始化邮件服务 // 初始化邮件服务
@ -81,7 +81,7 @@ describe('邮件服务测试', () => {
describe('邮件服务健康检查', () => { describe('邮件服务健康检查', () => {
it('应该执行健康检查并返回结果', async () => { it('应该执行健康检查并返回结果', async () => {
const healthResult = await checkEmailServiceHealth(); const healthResult = await checkEmailServiceHealth();
expect(healthResult).toHaveProperty('status'); expect(healthResult).toHaveProperty('status');
expect(healthResult).toHaveProperty('responseTime'); expect(healthResult).toHaveProperty('responseTime');
expect(healthResult).toHaveProperty('serviceStatus'); expect(healthResult).toHaveProperty('serviceStatus');
@ -95,41 +95,49 @@ describe('邮件服务测试', () => {
// 只在邮件配置有效时运行实际发送测试 // 只在邮件配置有效时运行实际发送测试
const runSendTest = isEmailConfigValid ? it : it.skip; const runSendTest = isEmailConfigValid ? it : it.skip;
runSendTest('应该能够发送简单文本邮件', async () => { runSendTest(
const emailOptions: EmailSendOptions = { '应该能够发送简单文本邮件',
to: TEST_EMAIL, async () => {
subject: '测试邮件 - 简单文本', const emailOptions: EmailSendOptions = {
text: '这是一封测试邮件的纯文本内容。', to: TEST_EMAIL,
html: '<p>这是一封测试邮件的<strong>HTML内容</strong>。</p>', subject: '测试邮件 - 简单文本',
}; text: '这是一封测试邮件的纯文本内容。',
html: '<p>这是一封测试邮件的<strong>HTML内容</strong>。</p>',
};
const result = await sendEmail(emailOptions); const result = await sendEmail(emailOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
expect(result.sentAt).toBeInstanceOf(Date);
if (result.success) {
expect(result).toHaveProperty('messageId');
expect(result.accepted).toContain(TEST_EMAIL);
console.log('邮件发送成功:', result.messageId);
} else {
console.warn('邮件发送失败:', result.error);
}
}, 10000); // 设置较长的超时时间
runSendTest('应该能够发送带抄送的邮件', async () => { expect(result).toHaveProperty('success');
const emailOptions: EmailSendOptions = { expect(result).toHaveProperty('sentAt');
to: TEST_EMAIL, expect(result.sentAt).toBeInstanceOf(Date);
cc: TEST_EMAIL, // 测试环境下抄送给同一个邮箱
subject: '测试邮件 - 带抄送',
html: '<h2>测试抄送功能</h2><p>这封邮件测试抄送功能。</p>',
};
const result = await sendEmail(emailOptions); if (result.success) {
expect(result).toHaveProperty('success'); expect(result).toHaveProperty('messageId');
expect(result).toHaveProperty('sentAt'); expect(result.accepted).toContain(TEST_EMAIL);
}, 10000); console.log('邮件发送成功:', result.messageId);
} else {
console.warn('邮件发送失败:', result.error);
}
},
10000,
); // 设置较长的超时时间
runSendTest(
'应该能够发送带抄送的邮件',
async () => {
const emailOptions: EmailSendOptions = {
to: TEST_EMAIL,
cc: TEST_EMAIL, // 测试环境下抄送给同一个邮箱
subject: '测试邮件 - 带抄送',
html: '<h2>测试抄送功能</h2><p>这封邮件测试抄送功能。</p>',
};
const result = await sendEmail(emailOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
},
10000,
);
it('应该处理邮件发送错误', async () => { it('应该处理邮件发送错误', async () => {
const invalidEmailOptions: EmailSendOptions = { const invalidEmailOptions: EmailSendOptions = {
@ -139,11 +147,11 @@ describe('邮件服务测试', () => {
}; };
const result = await sendEmail(invalidEmailOptions); const result = await sendEmail(invalidEmailOptions);
// 无论SMTP是否配置无效邮箱都应该返回失败 // 无论SMTP是否配置无效邮箱都应该返回失败
expect(result).toHaveProperty('success'); expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt'); expect(result).toHaveProperty('sentAt');
if (!result.success) { if (!result.success) {
expect(result).toHaveProperty('error'); expect(result).toHaveProperty('error');
expect(result.rejected).toContain('invalid-email'); expect(result.rejected).toContain('invalid-email');
@ -154,118 +162,138 @@ describe('邮件服务测试', () => {
describe('模板邮件功能', () => { describe('模板邮件功能', () => {
const runTemplateTest = isEmailConfigValid ? it : it.skip; const runTemplateTest = isEmailConfigValid ? it : it.skip;
runTemplateTest('应该能够发送用户激活邮件', async () => { runTemplateTest(
const templateOptions: EmailTemplateSendOptions = { '应该能够发送用户激活邮件',
to: TEST_EMAIL, async () => {
template: 'activation', const templateOptions: EmailTemplateSendOptions = {
params: { to: TEST_EMAIL,
username: TEST_USERNAME, template: 'activation',
nickname: TEST_NICKNAME, params: {
email: TEST_EMAIL, username: TEST_USERNAME,
activationUrl: 'https://example.com/activate?token=test123', nickname: TEST_NICKNAME,
activationCode: '123456', email: TEST_EMAIL,
expireTime: '24小时', activationUrl: 'https://example.com/activate?token=test123',
}, activationCode: '123456',
}; expireTime: '24小时',
},
};
const result = await sendTemplateEmail(templateOptions); const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
if (result.success) {
expect(result).toHaveProperty('messageId');
console.log('激活邮件发送成功:', result.messageId);
}
}, 10000);
runTemplateTest('应该能够发送密码重置邮件', async () => { expect(result).toHaveProperty('success');
const templateOptions: EmailTemplateSendOptions = { expect(result).toHaveProperty('sentAt');
to: TEST_EMAIL,
template: 'passwordReset',
params: {
username: TEST_USERNAME,
nickname: TEST_NICKNAME,
email: TEST_EMAIL,
resetUrl: 'https://example.com/reset?token=test123',
resetCode: '654321',
expireTime: '30分钟',
},
};
const result = await sendTemplateEmail(templateOptions); if (result.success) {
expect(result).toHaveProperty('messageId');
expect(result).toHaveProperty('success'); console.log('激活邮件发送成功:', result.messageId);
expect(result).toHaveProperty('sentAt'); }
},
if (result.success) { 10000,
console.log('密码重置邮件发送成功:', result.messageId); );
}
}, 10000);
runTemplateTest('应该能够发送欢迎邮件', async () => { runTemplateTest(
const templateOptions: EmailTemplateSendOptions = { '应该能够发送密码重置邮件',
to: TEST_EMAIL, async () => {
template: 'welcome', const templateOptions: EmailTemplateSendOptions = {
params: { to: TEST_EMAIL,
username: TEST_USERNAME, template: 'passwordReset',
nickname: TEST_NICKNAME, params: {
email: TEST_EMAIL, username: TEST_USERNAME,
}, nickname: TEST_NICKNAME,
}; email: TEST_EMAIL,
resetUrl: 'https://example.com/reset?token=test123',
resetCode: '654321',
expireTime: '30分钟',
},
};
const result = await sendTemplateEmail(templateOptions); const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
if (result.success) {
console.log('欢迎邮件发送成功:', result.messageId);
}
}, 10000);
runTemplateTest('应该能够发送密码修改通知邮件', async () => { expect(result).toHaveProperty('success');
const templateOptions: EmailTemplateSendOptions = { expect(result).toHaveProperty('sentAt');
to: TEST_EMAIL,
template: 'passwordChanged',
params: {
username: TEST_USERNAME,
nickname: TEST_NICKNAME,
email: TEST_EMAIL,
},
};
const result = await sendTemplateEmail(templateOptions); if (result.success) {
console.log('密码重置邮件发送成功:', result.messageId);
expect(result).toHaveProperty('success'); }
expect(result).toHaveProperty('sentAt'); },
10000,
if (result.success) { );
console.log('密码修改通知邮件发送成功:', result.messageId);
}
}, 10000);
runTemplateTest('应该能够发送系统通知邮件', async () => { runTemplateTest(
const templateOptions: EmailTemplateSendOptions = { '应该能够发送欢迎邮件',
to: TEST_EMAIL, async () => {
template: 'notification', const templateOptions: EmailTemplateSendOptions = {
params: { to: TEST_EMAIL,
username: TEST_USERNAME, template: 'welcome',
nickname: TEST_NICKNAME, params: {
message: '这是一条重要的系统通知,请及时查看您的账户状态。', username: TEST_USERNAME,
}, nickname: TEST_NICKNAME,
subject: '重要系统通知', email: TEST_EMAIL,
}; },
};
const result = await sendTemplateEmail(templateOptions); const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success'); expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt'); expect(result).toHaveProperty('sentAt');
if (result.success) { if (result.success) {
console.log('系统通知邮件发送成功:', result.messageId); console.log('欢迎邮件发送成功:', result.messageId);
} }
}, 10000); },
10000,
);
runTemplateTest(
'应该能够发送密码修改通知邮件',
async () => {
const templateOptions: EmailTemplateSendOptions = {
to: TEST_EMAIL,
template: 'passwordChanged',
params: {
username: TEST_USERNAME,
nickname: TEST_NICKNAME,
email: TEST_EMAIL,
},
};
const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
if (result.success) {
console.log('密码修改通知邮件发送成功:', result.messageId);
}
},
10000,
);
runTemplateTest(
'应该能够发送系统通知邮件',
async () => {
const templateOptions: EmailTemplateSendOptions = {
to: TEST_EMAIL,
template: 'notification',
params: {
username: TEST_USERNAME,
nickname: TEST_NICKNAME,
message: '这是一条重要的系统通知,请及时查看您的账户状态。',
},
subject: '重要系统通知',
};
const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt');
if (result.success) {
console.log('系统通知邮件发送成功:', result.messageId);
}
},
10000,
);
it('应该处理无效模板类型', async () => { it('应该处理无效模板类型', async () => {
const invalidTemplateOptions = { const invalidTemplateOptions = {
@ -277,7 +305,7 @@ describe('邮件服务测试', () => {
}; };
const result = await sendTemplateEmail(invalidTemplateOptions); const result = await sendTemplateEmail(invalidTemplateOptions);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result).toHaveProperty('error'); expect(result).toHaveProperty('error');
expect(result.error).toContain('未找到邮件模板'); expect(result.error).toContain('未找到邮件模板');
@ -294,11 +322,11 @@ describe('邮件服务测试', () => {
}; };
const result = await sendEmail(emailOptions); const result = await sendEmail(emailOptions);
// 如果SMTP未配置或连接失败应该有适当的错误处理 // 如果SMTP未配置或连接失败应该有适当的错误处理
expect(result).toHaveProperty('success'); expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt'); expect(result).toHaveProperty('sentAt');
if (!result.success) { if (!result.success) {
expect(result).toHaveProperty('error'); expect(result).toHaveProperty('error');
expect(typeof result.error).toBe('string'); expect(typeof result.error).toBe('string');
@ -309,29 +337,33 @@ describe('邮件服务测试', () => {
describe('邮件服务性能测试', () => { describe('邮件服务性能测试', () => {
const runPerformanceTest = isEmailConfigValid ? it : it.skip; const runPerformanceTest = isEmailConfigValid ? it : it.skip;
runPerformanceTest('应该能够并发发送多封邮件', async () => { runPerformanceTest(
const emailPromises = []; '应该能够并发发送多封邮件',
const emailCount = 3; // 限制并发数量以避免SMTP限制 async () => {
const emailPromises = [];
const emailCount = 3; // 限制并发数量以避免SMTP限制
for (let i = 0; i < emailCount; i++) { for (let i = 0; i < emailCount; i++) {
const emailOptions: EmailSendOptions = { const emailOptions: EmailSendOptions = {
to: TEST_EMAIL, to: TEST_EMAIL,
subject: `并发测试邮件 ${i + 1}`, subject: `并发测试邮件 ${i + 1}`,
text: `这是第 ${i + 1} 封并发测试邮件`, text: `这是第 ${i + 1} 封并发测试邮件`,
}; };
emailPromises.push(sendEmail(emailOptions));
}
const results = await Promise.all(emailPromises); emailPromises.push(sendEmail(emailOptions));
}
expect(results).toHaveLength(emailCount);
results.forEach((result, index) => { const results = await Promise.all(emailPromises);
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('sentAt'); expect(results).toHaveLength(emailCount);
console.log(`邮件 ${index + 1} 发送结果:`, result.success ? '成功' : '失败'); results.forEach((result, index) => {
}); expect(result).toHaveProperty('success');
}, 30000); // 更长的超时时间用于并发测试 expect(result).toHaveProperty('sentAt');
console.log(`邮件 ${index + 1} 发送结果:`, result.success ? '成功' : '失败');
});
},
30000,
); // 更长的超时时间用于并发测试
}); });
describe('邮件模板渲染测试', () => { describe('邮件模板渲染测试', () => {
@ -351,11 +383,11 @@ describe('邮件服务测试', () => {
// 我们可以通过发送结果来验证模板是否正确处理 // 我们可以通过发送结果来验证模板是否正确处理
const result = await sendTemplateEmail(templateOptions); const result = await sendTemplateEmail(templateOptions);
expect(result).toHaveProperty('success'); expect(result).toHaveProperty('success');
// 模板渲染错误会导致发送失败 // 模板渲染错误会导致发送失败
if (!result.success && result.error) { if (!result.success && result.error) {
expect(result.error).not.toContain('模板渲染失败'); expect(result.error).not.toContain('模板渲染失败');
} }
}); });
}); });
}); });

View File

@ -1,305 +1,305 @@
/** /**
* @file * @file
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description * @description
*/ */
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setTimeout } from 'node:timers'; import { setTimeout } from 'node:timers';
import { app } from '@/app'; import { app } from '@/app';
describe('健康检查接口测试', () => { describe('健康检查接口测试', () => {
beforeAll(async () => { beforeAll(async () => {
// 等待应用启动 // 等待应用启动
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
}); });
describe('GET /api/health', () => { describe('GET /api/health', () => {
it('应该返回基本健康状态', async () => { it('应该返回基本健康状态', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 检查基本数据结构 // 检查基本数据结构
expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/);
expect(body.data.timestamp).toBeTypeOf('string'); expect(body.data.timestamp).toBeTypeOf('string');
expect(body.data.uptime).toBeTypeOf('number'); expect(body.data.uptime).toBeTypeOf('number');
expect(body.data.responseTime).toBeTypeOf('number'); expect(body.data.responseTime).toBeTypeOf('number');
expect(body.data.version).toBeTypeOf('string'); expect(body.data.version).toBeTypeOf('string');
expect(body.data.environment).toBeTypeOf('string'); expect(body.data.environment).toBeTypeOf('string');
expect(body.data.components).toBeTypeOf('object'); expect(body.data.components).toBeTypeOf('object');
// 检查组件状态 // 检查组件状态
if (body.data.components.mysql) { if (body.data.components.mysql) {
expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/);
} }
if (body.data.components.redis) { if (body.data.components.redis) {
expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/);
} }
}); });
it('应该包含正确的时间戳格式', async () => { it('应该包含正确的时间戳格式', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 验证ISO时间戳格式 // 验证ISO时间戳格式
const timestamp = new Date(body.data.timestamp); const timestamp = new Date(body.data.timestamp);
expect(timestamp.toISOString()).toBe(body.data.timestamp); expect(timestamp.toISOString()).toBe(body.data.timestamp);
}); });
it('应该返回合理的响应时间', async () => { it('应该返回合理的响应时间', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 响应时间应该在合理范围内 // 响应时间应该在合理范围内
expect(body.data.responseTime).toBeGreaterThan(0); expect(body.data.responseTime).toBeGreaterThan(0);
expect(body.data.responseTime).toBeLessThan(endTime - startTime + 100); // 允许一定误差 expect(body.data.responseTime).toBeLessThan(endTime - startTime + 100); // 允许一定误差
}); });
it('应该返回正确的环境信息', async () => { it('应该返回正确的环境信息', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(body.data.environment).toMatch(/^(development|production|test)$/); expect(body.data.environment).toMatch(/^(development|production|test)$/);
expect(body.data.uptime).toBeGreaterThan(0); expect(body.data.uptime).toBeGreaterThan(0);
}); });
}); });
describe('GET /api/health/detailed', () => { describe('GET /api/health/detailed', () => {
it('应该返回详细健康状态', async () => { it('应该返回详细健康状态', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health/detailed', { new Request('http://localhost/api/health/detailed', {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 检查基本健康检查数据 // 检查基本健康检查数据
expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.status).toMatch(/^(healthy|unhealthy|degraded)$/);
expect(body.data.timestamp).toBeTypeOf('string'); expect(body.data.timestamp).toBeTypeOf('string');
expect(body.data.uptime).toBeTypeOf('number'); expect(body.data.uptime).toBeTypeOf('number');
expect(body.data.responseTime).toBeTypeOf('number'); expect(body.data.responseTime).toBeTypeOf('number');
expect(body.data.components).toBeTypeOf('object'); expect(body.data.components).toBeTypeOf('object');
// 检查详细信息 // 检查详细信息
if (body.data.system) { if (body.data.system) {
expect(body.data.system.platform).toBeTypeOf('string'); expect(body.data.system.platform).toBeTypeOf('string');
expect(body.data.system.arch).toBeTypeOf('string'); expect(body.data.system.arch).toBeTypeOf('string');
expect(body.data.system.nodeVersion).toBeTypeOf('string'); expect(body.data.system.nodeVersion).toBeTypeOf('string');
expect(body.data.system.runtime).toBeTypeOf('string'); expect(body.data.system.runtime).toBeTypeOf('string');
expect(body.data.system.pid).toBeTypeOf('number'); expect(body.data.system.pid).toBeTypeOf('number');
expect(body.data.system.cwd).toBeTypeOf('string'); expect(body.data.system.cwd).toBeTypeOf('string');
} }
if (body.data.performance) { if (body.data.performance) {
expect(body.data.performance.cpuUsage).toBeTypeOf('object'); expect(body.data.performance.cpuUsage).toBeTypeOf('object');
expect(body.data.performance.memoryUsage).toBeTypeOf('object'); expect(body.data.performance.memoryUsage).toBeTypeOf('object');
expect(body.data.performance.uptime).toBeTypeOf('number'); expect(body.data.performance.uptime).toBeTypeOf('number');
// 检查CPU使用情况 // 检查CPU使用情况
expect(body.data.performance.cpuUsage.user).toBeTypeOf('number'); expect(body.data.performance.cpuUsage.user).toBeTypeOf('number');
expect(body.data.performance.cpuUsage.system).toBeTypeOf('number'); expect(body.data.performance.cpuUsage.system).toBeTypeOf('number');
// 检查内存使用情况 // 检查内存使用情况
expect(body.data.performance.memoryUsage.rss).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.rss).toBeTypeOf('number');
expect(body.data.performance.memoryUsage.heapTotal).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.heapTotal).toBeTypeOf('number');
expect(body.data.performance.memoryUsage.heapUsed).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.heapUsed).toBeTypeOf('number');
expect(body.data.performance.memoryUsage.external).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.external).toBeTypeOf('number');
expect(body.data.performance.memoryUsage.arrayBuffers).toBeTypeOf('number'); expect(body.data.performance.memoryUsage.arrayBuffers).toBeTypeOf('number');
} }
}); });
it('详细健康检查应该包含系统信息', async () => { it('详细健康检查应该包含系统信息', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health/detailed', { new Request('http://localhost/api/health/detailed', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.system) { if (body.data.system) {
expect(body.data.system.runtime).toBe('Bun'); expect(body.data.system.runtime).toBe('Bun');
expect(body.data.system.pid).toBe(process.pid); expect(body.data.system.pid).toBe(process.pid);
expect(body.data.system.platform).toBe(process.platform); expect(body.data.system.platform).toBe(process.platform);
expect(body.data.system.arch).toBe(process.arch); expect(body.data.system.arch).toBe(process.arch);
} }
}); });
}); });
describe('健康检查依赖服务测试', () => { describe('健康检查依赖服务测试', () => {
it('MySQL组件状态应该包含连接信息', async () => { it('MySQL组件状态应该包含连接信息', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.components.mysql) { if (body.data.components.mysql) {
expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.mysql.status).toMatch(/^(healthy|unhealthy|degraded)$/);
if (body.data.components.mysql.details) { if (body.data.components.mysql.details) {
expect(body.data.components.mysql.details.connection).toMatch(/^(active|failed)$/); expect(body.data.components.mysql.details.connection).toMatch(/^(active|failed)$/);
expect(body.data.components.mysql.details.host).toBeTypeOf('string'); expect(body.data.components.mysql.details.host).toBeTypeOf('string');
expect(body.data.components.mysql.details.port).toBeTypeOf('string'); expect(body.data.components.mysql.details.port).toBeTypeOf('string');
} }
if (body.data.components.mysql.responseTime) { if (body.data.components.mysql.responseTime) {
expect(body.data.components.mysql.responseTime).toBeGreaterThan(0); expect(body.data.components.mysql.responseTime).toBeGreaterThan(0);
} }
} }
}); });
it('Redis组件状态应该包含连接信息', async () => { it('Redis组件状态应该包含连接信息', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
if (body.data.components.redis) { if (body.data.components.redis) {
expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/); expect(body.data.components.redis.status).toMatch(/^(healthy|unhealthy|degraded)$/);
if (body.data.components.redis.details) { if (body.data.components.redis.details) {
expect(body.data.components.redis.details.connection).toMatch(/^(active|failed)$/); expect(body.data.components.redis.details.connection).toMatch(/^(active|failed)$/);
} }
if (body.data.components.redis.responseTime) { if (body.data.components.redis.responseTime) {
expect(body.data.components.redis.responseTime).toBeGreaterThan(0); expect(body.data.components.redis.responseTime).toBeGreaterThan(0);
} }
} }
}); });
}); });
describe('健康检查错误处理', () => { describe('健康检查错误处理', () => {
it('健康检查应该处理组件异常', async () => { it('健康检查应该处理组件异常', async () => {
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const body = (await res.json()) as any; const body = (await res.json()) as any;
// 即使有组件异常,也应该返回结构化的响应 // 即使有组件异常,也应该返回结构化的响应
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.code).toBeTypeOf('number'); expect(body.code).toBeTypeOf('number');
expect(body.message).toBeTypeOf('string'); expect(body.message).toBeTypeOf('string');
expect(body.data).toBeTypeOf('object'); expect(body.data).toBeTypeOf('object');
// 如果有组件异常整体状态可能是degraded或unhealthy // 如果有组件异常整体状态可能是degraded或unhealthy
if (body.data.status === 'unhealthy' || body.data.status === 'degraded') { if (body.data.status === 'unhealthy' || body.data.status === 'degraded') {
// 应该有组件错误信息 // 应该有组件错误信息
const components = body.data.components; const components = body.data.components;
let hasUnhealthyComponent = false; let hasUnhealthyComponent = false;
Object.values(components).forEach((component: any) => { Object.values(components).forEach((component: any) => {
if (component && component.status === 'unhealthy') { if (component && component.status === 'unhealthy') {
hasUnhealthyComponent = true; hasUnhealthyComponent = true;
expect(component.error).toBeTypeOf('string'); expect(component.error).toBeTypeOf('string');
} }
}); });
if (!hasUnhealthyComponent && body.data.error) { if (!hasUnhealthyComponent && body.data.error) {
expect(body.data.error).toBeTypeOf('string'); expect(body.data.error).toBeTypeOf('string');
} }
} }
}); });
}); });
describe('健康检查性能测试', () => { describe('健康检查性能测试', () => {
it('健康检查应该快速响应', async () => { it('健康检查应该快速响应', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(2000); // 应该在2秒内完成 expect(responseTime).toBeLessThan(2000); // 应该在2秒内完成
}); });
it('详细健康检查应该在合理时间内完成', async () => { it('详细健康检查应该在合理时间内完成', async () => {
const startTime = Date.now(); const startTime = Date.now();
const res = await app.fetch( const res = await app.fetch(
new Request('http://localhost/api/health/detailed', { new Request('http://localhost/api/health/detailed', {
method: 'GET', method: 'GET',
}), }),
); );
const endTime = Date.now(); const endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(responseTime).toBeLessThan(3000); // 详细检查可能稍慢但应该在3秒内完成 expect(responseTime).toBeLessThan(3000); // 详细检查可能稍慢但应该在3秒内完成
}); });
it('并发健康检查应该正常处理', async () => { it('并发健康检查应该正常处理', async () => {
const promises = []; const promises = [];
const concurrentRequests = 5; const concurrentRequests = 5;
for (let i = 0; i < concurrentRequests; i++) { for (let i = 0; i < concurrentRequests; i++) {
promises.push( promises.push(
app.fetch( app.fetch(
new Request('http://localhost/api/health', { new Request('http://localhost/api/health', {
method: 'GET', method: 'GET',
}), }),
), ),
); );
} }
const responses = await Promise.all(promises); const responses = await Promise.all(promises);
responses.forEach(res => { responses.forEach((res) => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
}); });
}); });
}); });

View File

@ -1,238 +1,238 @@
/** /**
* @file Redis连接测试 * @file Redis连接测试
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description Redis连接 * @description Redis连接
*/ */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setTimeout } from 'node:timers'; import { setTimeout } from 'node:timers';
import { Redis } from '@/utils/redis'; import { Redis } from '@/utils/redis';
import { redisConfig } from '@/config/redis.config'; import { redisConfig } from '@/config/redis.config';
// 简单的延时函数 // 简单的延时函数
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('Redis连接测试', () => { describe('Redis连接测试', () => {
let redis: Redis; let redis: Redis;
beforeAll(async () => { beforeAll(async () => {
redis = new Redis(); redis = new Redis();
// 等待连接建立 // 等待连接建立
await delay(1000); await delay(1000);
}); });
afterAll(async () => { afterAll(async () => {
if (redis) { if (redis) {
await redis.disconnectRedis(); await redis.disconnectRedis();
} }
}); });
beforeEach(async () => { beforeEach(async () => {
// 确保每个测试前Redis连接正常 // 确保每个测试前Redis连接正常
if (!redis.redisClient.isOpen) { if (!redis.redisClient.isOpen) {
await redis.connectRedis(); await redis.connectRedis();
} }
}); });
describe('Redis连接管理', () => { describe('Redis连接管理', () => {
it('应该成功连接到Redis服务器', async () => { it('应该成功连接到Redis服务器', async () => {
const isHealthy = await redis.checkRedisHealth(); const isHealthy = await redis.checkRedisHealth();
expect(isHealthy).toBe(true); expect(isHealthy).toBe(true);
}); });
it('应该正确返回Redis连接状态', () => { it('应该正确返回Redis连接状态', () => {
const status = redis.getRedisStatus(); const status = redis.getRedisStatus();
expect(status).toEqual({ expect(status).toEqual({
isConnected: expect.any(Boolean), isConnected: expect.any(Boolean),
config: { config: {
host: redisConfig.host, host: redisConfig.host,
port: redisConfig.port, port: redisConfig.port,
database: redisConfig.database, database: redisConfig.database,
connectName: redisConfig.connectName, connectName: redisConfig.connectName,
}, },
}); });
}); });
it('应该能够执行ping命令', async () => { it('应该能够执行ping命令', async () => {
const result = await redis.redisClient.ping(); const result = await redis.redisClient.ping();
expect(result).toBe('PONG'); expect(result).toBe('PONG');
}); });
}); });
describe('Redis基本操作', () => { describe('Redis基本操作', () => {
const testKey = 'test_key'; const testKey = 'test_key';
const testValue = 'test_value'; const testValue = 'test_value';
it('应该能够设置和获取字符串值', async () => { it('应该能够设置和获取字符串值', async () => {
// 设置值 // 设置值
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 获取值 // 获取值
const result = await redis.redisClient.get(testKey); const result = await redis.redisClient.get(testKey);
expect(result).toBe(testValue); expect(result).toBe(testValue);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(testKey); await redis.redisClient.del(testKey);
}); });
it('应该能够设置带过期时间的值', async () => { it('应该能够设置带过期时间的值', async () => {
const expiryTime = 2; // 2秒过期 const expiryTime = 2; // 2秒过期
// 设置带过期时间的值 // 设置带过期时间的值
await redis.redisClient.setEx(testKey, expiryTime, testValue); await redis.redisClient.setEx(testKey, expiryTime, testValue);
// 立即获取应该有值 // 立即获取应该有值
const result1 = await redis.redisClient.get(testKey); const result1 = await redis.redisClient.get(testKey);
expect(result1).toBe(testValue); expect(result1).toBe(testValue);
// 等待过期 // 等待过期
await delay(2100); await delay(2100);
// 过期后应该为null // 过期后应该为null
const result2 = await redis.redisClient.get(testKey); const result2 = await redis.redisClient.get(testKey);
expect(result2).toBeNull(); expect(result2).toBeNull();
}); });
it('应该能够检查键是否存在', async () => { it('应该能够检查键是否存在', async () => {
// 设置测试键 // 设置测试键
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 检查存在 // 检查存在
const exists1 = await redis.redisClient.exists(testKey); const exists1 = await redis.redisClient.exists(testKey);
expect(exists1).toBe(1); expect(exists1).toBe(1);
// 删除键 // 删除键
await redis.redisClient.del(testKey); await redis.redisClient.del(testKey);
// 检查不存在 // 检查不存在
const exists2 = await redis.redisClient.exists(testKey); const exists2 = await redis.redisClient.exists(testKey);
expect(exists2).toBe(0); expect(exists2).toBe(0);
}); });
it('应该能够删除键', async () => { it('应该能够删除键', async () => {
// 设置测试键 // 设置测试键
await redis.redisClient.set(testKey, testValue); await redis.redisClient.set(testKey, testValue);
// 删除键 // 删除键
const deleteCount = await redis.redisClient.del(testKey); const deleteCount = await redis.redisClient.del(testKey);
expect(deleteCount).toBe(1); expect(deleteCount).toBe(1);
// 验证键已被删除 // 验证键已被删除
const result = await redis.redisClient.get(testKey); const result = await redis.redisClient.get(testKey);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe('Redis Hash操作', () => { describe('Redis Hash操作', () => {
const hashKey = 'test_hash'; const hashKey = 'test_hash';
const field1 = 'field1'; const field1 = 'field1';
const value1 = 'value1'; const value1 = 'value1';
const field2 = 'field2'; const field2 = 'field2';
const value2 = 'value2'; const value2 = 'value2';
it('应该能够设置和获取Hash字段', async () => { it('应该能够设置和获取Hash字段', async () => {
// 设置Hash字段 // 设置Hash字段
await redis.redisClient.hSet(hashKey, field1, value1); await redis.redisClient.hSet(hashKey, field1, value1);
// 获取Hash字段 // 获取Hash字段
const result = await redis.redisClient.hGet(hashKey, field1); const result = await redis.redisClient.hGet(hashKey, field1);
expect(result).toBe(value1); expect(result).toBe(value1);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
it('应该能够设置和获取多个Hash字段', async () => { it('应该能够设置和获取多个Hash字段', async () => {
// 设置多个Hash字段 // 设置多个Hash字段
await redis.redisClient.hSet(hashKey, field1, value1); await redis.redisClient.hSet(hashKey, field1, value1);
await redis.redisClient.hSet(hashKey, field2, value2); await redis.redisClient.hSet(hashKey, field2, value2);
// 获取所有Hash字段 // 获取所有Hash字段
const result = await redis.redisClient.hGetAll(hashKey); const result = await redis.redisClient.hGetAll(hashKey);
expect(result).toEqual({ expect(result).toEqual({
[field1]: value1, [field1]: value1,
[field2]: value2, [field2]: value2,
}); });
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(hashKey); await redis.redisClient.del(hashKey);
}); });
}); });
describe('Redis列表操作', () => { describe('Redis列表操作', () => {
const listKey = 'test_list'; const listKey = 'test_list';
const value1 = 'item1'; const value1 = 'item1';
const value2 = 'item2'; const value2 = 'item2';
it('应该能够推入和弹出列表元素', async () => { it('应该能够推入和弹出列表元素', async () => {
// 推入元素 // 推入元素
await redis.redisClient.lPush(listKey, value1); await redis.redisClient.lPush(listKey, value1);
await redis.redisClient.lPush(listKey, value2); await redis.redisClient.lPush(listKey, value2);
// 获取列表长度 // 获取列表长度
const length = await redis.redisClient.lLen(listKey); const length = await redis.redisClient.lLen(listKey);
expect(length).toBe(2); expect(length).toBe(2);
// 弹出元素 // 弹出元素
const poppedValue = await redis.redisClient.lPop(listKey); const poppedValue = await redis.redisClient.lPop(listKey);
expect(poppedValue).toBe(value2); expect(poppedValue).toBe(value2);
// 清理测试数据 // 清理测试数据
await redis.redisClient.del(listKey); await redis.redisClient.del(listKey);
}); });
}); });
describe('Redis连接错误处理', () => { describe('Redis连接错误处理', () => {
it('健康检查在连接断开时应返回false', async () => { it('健康检查在连接断开时应返回false', async () => {
// 暂时断开连接 // 暂时断开连接
await redis.disconnectRedis(); await redis.disconnectRedis();
// 健康检查应该返回false // 健康检查应该返回false
const isHealthy = await redis.checkRedisHealth(); const isHealthy = await redis.checkRedisHealth();
expect(isHealthy).toBe(false); expect(isHealthy).toBe(false);
// 重新连接 // 重新连接
await redis.connectRedis(); await redis.connectRedis();
}); });
it('应该能够重新连接Redis', async () => { it('应该能够重新连接Redis', async () => {
// 断开连接 // 断开连接
await redis.disconnectRedis(); await redis.disconnectRedis();
// 重新连接 // 重新连接
await redis.connectRedis(); await redis.connectRedis();
// 验证连接正常 // 验证连接正常
const isHealthy = await redis.checkRedisHealth(); const isHealthy = await redis.checkRedisHealth();
expect(isHealthy).toBe(true); expect(isHealthy).toBe(true);
}); });
}); });
describe('Redis性能测试', () => { describe('Redis性能测试', () => {
it('应该能够快速执行大量set操作', async () => { it('应该能够快速执行大量set操作', async () => {
const startTime = Date.now(); const startTime = Date.now();
const operations = []; const operations = [];
// 执行100次set操作 // 执行100次set操作
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
operations.push(redis.redisClient.set(`perf_test_${i}`, `value_${i}`)); operations.push(redis.redisClient.set(`perf_test_${i}`, `value_${i}`));
} }
await Promise.all(operations); await Promise.all(operations);
const endTime = Date.now(); const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(1000); // 应该在1秒内完成 expect(endTime - startTime).toBeLessThan(1000); // 应该在1秒内完成
console.log(endTime - startTime); console.log(endTime - startTime);
// 清理测试数据 // 清理测试数据
const deleteOperations = []; const deleteOperations = [];
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
deleteOperations.push(redis.redisClient.del(`perf_test_${i}`)); deleteOperations.push(redis.redisClient.del(`perf_test_${i}`));
} }
await Promise.all(deleteOperations); await Promise.all(deleteOperations);
}); });
}); });
}); });

View File

@ -1,291 +1,290 @@
/** /**
* @file Swagger文档功能测试 * @file Swagger文档功能测试
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description Swagger API文档功能访 * @description Swagger API文档功能访
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { setTimeout } from 'node:timers'; import { app } from '@/app';
import { app } from '@/app';
describe('Swagger API文档测试', () => {
describe('Swagger API文档测试', () => { describe('GET /docs', () => {
describe('GET /docs', () => { it('应该可以访问Swagger文档页面', async () => {
it('应该可以访问Swagger文档页面', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs', {
new Request('http://localhost/docs', { method: 'GET',
method: 'GET', }),
}), );
);
expect(res.status).toBe(200);
expect(res.status).toBe(200);
const contentType = res.headers.get('content-type');
const contentType = res.headers.get('content-type'); expect(contentType).toContain('text/html');
expect(contentType).toContain('text/html'); });
});
it('Swagger文档应该包含基本配置信息', async () => {
it('Swagger文档应该包含基本配置信息', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs', {
new Request('http://localhost/docs', { method: 'GET',
method: 'GET', }),
}), );
);
const html = await res.text();
const html = await res.text();
// 检查基本配置
// 检查基本配置 expect(html).toContain('Cursor Init API服务');
expect(html).toContain('Cursor Init API服务'); expect(html).toContain('swagger-ui');
expect(html).toContain('swagger-ui'); });
}); });
});
describe('GET /docs/json', () => {
describe('GET /docs/json', () => { it('应该返回OpenAPI JSON文档', async () => {
it('应该返回OpenAPI JSON文档', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
expect(res.status).toBe(200);
expect(res.status).toBe(200);
const contentType = res.headers.get('content-type');
const contentType = res.headers.get('content-type'); expect(contentType).toContain('application/json');
expect(contentType).toContain('application/json');
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证OpenAPI文档结构
// 验证OpenAPI文档结构 expect(openApiDoc).toHaveProperty('openapi');
expect(openApiDoc).toHaveProperty('openapi'); expect(openApiDoc).toHaveProperty('info');
expect(openApiDoc).toHaveProperty('info'); expect(openApiDoc).toHaveProperty('paths');
expect(openApiDoc).toHaveProperty('paths'); expect(openApiDoc).toHaveProperty('components');
expect(openApiDoc).toHaveProperty('components');
// 验证基本信息
// 验证基本信息 expect(openApiDoc.info.title).toBe('Cursor Init API服务');
expect(openApiDoc.info.title).toBe('Cursor Init API服务'); expect(openApiDoc.info.version).toBe('1.0.0');
expect(openApiDoc.info.version).toBe('1.0.0'); expect(openApiDoc.info.description).toContain('Cursor Init API服务');
expect(openApiDoc.info.description).toContain('Cursor Init API服务'); });
});
it('OpenAPI文档应该包含安全配置', async () => {
it('OpenAPI文档应该包含安全配置', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证安全配置
// 验证安全配置 expect(openApiDoc.components).toHaveProperty('securitySchemes');
expect(openApiDoc.components).toHaveProperty('securitySchemes'); expect(openApiDoc.components.securitySchemes).toHaveProperty('bearerAuth');
expect(openApiDoc.components.securitySchemes).toHaveProperty('bearerAuth'); expect(openApiDoc.components.securitySchemes.bearerAuth.type).toBe('http');
expect(openApiDoc.components.securitySchemes.bearerAuth.type).toBe('http'); expect(openApiDoc.components.securitySchemes.bearerAuth.scheme).toBe('bearer');
expect(openApiDoc.components.securitySchemes.bearerAuth.scheme).toBe('bearer'); expect(openApiDoc.components.securitySchemes.bearerAuth.bearerFormat).toBe('JWT');
expect(openApiDoc.components.securitySchemes.bearerAuth.bearerFormat).toBe('JWT'); });
});
it('OpenAPI文档应该包含全局组件定义', async () => {
it('OpenAPI文档应该包含全局组件定义', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证全局组件
// 验证全局组件 expect(openApiDoc.components).toHaveProperty('schemas');
expect(openApiDoc.components).toHaveProperty('schemas'); expect(openApiDoc.components).toHaveProperty('responses');
expect(openApiDoc.components).toHaveProperty('responses');
// 验证响应组件
// 验证响应组件 const responses = openApiDoc.components.responses;
const responses = openApiDoc.components.responses; expect(responses).toHaveProperty('Success');
expect(responses).toHaveProperty('Success'); expect(responses).toHaveProperty('BadRequest');
expect(responses).toHaveProperty('BadRequest'); expect(responses).toHaveProperty('Unauthorized');
expect(responses).toHaveProperty('Unauthorized'); expect(responses).toHaveProperty('Forbidden');
expect(responses).toHaveProperty('Forbidden'); expect(responses).toHaveProperty('NotFound');
expect(responses).toHaveProperty('NotFound'); expect(responses).toHaveProperty('ValidationError');
expect(responses).toHaveProperty('ValidationError'); expect(responses).toHaveProperty('InternalError');
expect(responses).toHaveProperty('InternalError'); expect(responses).toHaveProperty('ServiceUnavailable');
expect(responses).toHaveProperty('ServiceUnavailable');
// 验证Schema组件
// 验证Schema组件 const schemas = openApiDoc.components.schemas;
const schemas = openApiDoc.components.schemas; expect(schemas).toHaveProperty('BaseResponse');
expect(schemas).toHaveProperty('BaseResponse'); expect(schemas).toHaveProperty('SuccessResponse');
expect(schemas).toHaveProperty('SuccessResponse'); expect(schemas).toHaveProperty('ErrorResponse');
expect(schemas).toHaveProperty('ErrorResponse'); expect(schemas).toHaveProperty('PaginationResponse');
expect(schemas).toHaveProperty('PaginationResponse'); });
});
it('OpenAPI文档应该包含健康检查接口', async () => {
it('OpenAPI文档应该包含健康检查接口', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证健康检查接口
// 验证健康检查接口 expect(openApiDoc.paths).toHaveProperty('/api/health');
expect(openApiDoc.paths).toHaveProperty('/api/health'); expect(openApiDoc.paths).toHaveProperty('/api/health/detailed');
expect(openApiDoc.paths).toHaveProperty('/api/health/detailed');
const healthPath = openApiDoc.paths['/api/health'];
const healthPath = openApiDoc.paths['/api/health']; expect(healthPath).toHaveProperty('get');
expect(healthPath).toHaveProperty('get'); expect(healthPath.get).toHaveProperty('tags');
expect(healthPath.get).toHaveProperty('tags'); expect(healthPath.get.tags).toContain('健康检查');
expect(healthPath.get.tags).toContain('健康检查'); expect(healthPath.get).toHaveProperty('summary');
expect(healthPath.get).toHaveProperty('summary'); expect(healthPath.get).toHaveProperty('description');
expect(healthPath.get).toHaveProperty('description'); });
});
it('OpenAPI文档应该包含认证接口', async () => {
it('OpenAPI文档应该包含认证接口', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证认证接口
// 验证认证接口 expect(openApiDoc.paths).toHaveProperty('/api/auth/login');
expect(openApiDoc.paths).toHaveProperty('/api/auth/login');
const loginPath = openApiDoc.paths['/api/auth/login'];
const loginPath = openApiDoc.paths['/api/auth/login']; expect(loginPath).toHaveProperty('post');
expect(loginPath).toHaveProperty('post'); expect(loginPath.post).toHaveProperty('tags');
expect(loginPath.post).toHaveProperty('tags'); expect(loginPath.post).toHaveProperty('requestBody');
expect(loginPath.post).toHaveProperty('requestBody'); expect(loginPath.post).toHaveProperty('responses');
expect(loginPath.post).toHaveProperty('responses'); });
});
it('OpenAPI文档应该包含标签分类', async () => {
it('OpenAPI文档应该包含标签分类', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证标签
// 验证标签 expect(openApiDoc).toHaveProperty('tags');
expect(openApiDoc).toHaveProperty('tags'); expect(Array.isArray(openApiDoc.tags)).toBe(true);
expect(Array.isArray(openApiDoc.tags)).toBe(true);
const tagNames = openApiDoc.tags.map((tag: any) => tag.name);
const tagNames = openApiDoc.tags.map((tag: any) => tag.name); expect(tagNames).toContain('认证管理');
expect(tagNames).toContain('认证管理'); expect(tagNames).toContain('健康检查');
expect(tagNames).toContain('健康检查');
// 验证标签描述
// 验证标签描述 const healthTag = openApiDoc.tags.find((tag: any) => tag.name === '健康检查');
const healthTag = openApiDoc.tags.find((tag: any) => tag.name === '健康检查'); expect(healthTag).toHaveProperty('description');
expect(healthTag).toHaveProperty('description'); expect(healthTag.description).toContain('系统健康状态');
expect(healthTag.description).toContain('系统健康状态'); });
}); });
});
describe('Swagger文档内容验证', () => {
describe('Swagger文档内容验证', () => { it('应该包含错误码说明', async () => {
it('应该包含错误码说明', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证错误码描述在文档中
// 验证错误码描述在文档中 expect(openApiDoc.info.description).toContain('错误码说明');
expect(openApiDoc.info.description).toContain('错误码说明'); expect(openApiDoc.info.description).toContain('| 错误码 | 说明 | 示例场景 |');
expect(openApiDoc.info.description).toContain('| 错误码 | 说明 | 示例场景 |'); expect(openApiDoc.info.description).toContain('| 0 | 操作成功 |');
expect(openApiDoc.info.description).toContain('| 0 | 操作成功 |'); expect(openApiDoc.info.description).toContain('| 400 | 业务逻辑错误 |');
expect(openApiDoc.info.description).toContain('| 400 | 业务逻辑错误 |'); expect(openApiDoc.info.description).toContain('| 401 | 身份认证失败 |');
expect(openApiDoc.info.description).toContain('| 401 | 身份认证失败 |'); });
});
it('应该包含认证说明', async () => {
it('应该包含认证说明', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证认证说明
// 验证认证说明 expect(openApiDoc.info.description).toContain('认证说明');
expect(openApiDoc.info.description).toContain('认证说明'); expect(openApiDoc.info.description).toContain('Authorization: Bearer');
expect(openApiDoc.info.description).toContain('Authorization: Bearer'); expect(openApiDoc.components.securitySchemes.bearerAuth.description).toContain('JWT认证说明');
expect(openApiDoc.components.securitySchemes.bearerAuth.description).toContain('JWT认证说明'); expect(openApiDoc.components.securitySchemes.bearerAuth.description).toContain('Token有效期为24小时');
expect(openApiDoc.components.securitySchemes.bearerAuth.description).toContain('Token有效期为24小时'); });
});
it('应该包含响应格式说明', async () => {
it('应该包含响应格式说明', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证响应格式说明
// 验证响应格式说明 expect(openApiDoc.info.description).toContain('响应格式');
expect(openApiDoc.info.description).toContain('响应格式'); expect(openApiDoc.info.description).toContain('"code": 0');
expect(openApiDoc.info.description).toContain('"code": 0'); expect(openApiDoc.info.description).toContain('"message": "操作成功"');
expect(openApiDoc.info.description).toContain('"message": "操作成功"'); expect(openApiDoc.info.description).toContain('"data"');
expect(openApiDoc.info.description).toContain('"data"'); });
});
it('应该包含示例响应', async () => {
it('应该包含示例响应', async () => { const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const openApiDoc = await res.json();
const openApiDoc = await res.json();
// 验证示例响应
// 验证示例响应 const successResponse = openApiDoc.components.responses.Success;
const successResponse = openApiDoc.components.responses.Success; expect(successResponse.content['application/json']).toHaveProperty('examples');
expect(successResponse.content['application/json']).toHaveProperty('examples');
const errorResponse = openApiDoc.components.responses.BadRequest;
const errorResponse = openApiDoc.components.responses.BadRequest; expect(errorResponse.content['application/json']).toHaveProperty('examples');
expect(errorResponse.content['application/json']).toHaveProperty('examples');
const unauthorizedResponse = openApiDoc.components.responses.Unauthorized;
const unauthorizedResponse = openApiDoc.components.responses.Unauthorized; expect(unauthorizedResponse.content['application/json']).toHaveProperty('examples');
expect(unauthorizedResponse.content['application/json']).toHaveProperty('examples'); expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenExpired');
expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenExpired'); expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenInvalid');
expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('tokenInvalid'); expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('notLoggedIn');
expect(unauthorizedResponse.content['application/json'].examples).toHaveProperty('notLoggedIn'); });
}); });
});
describe('Swagger文档性能测试', () => {
describe('Swagger文档性能测试', () => { it('文档页面应该快速加载', async () => {
it('文档页面应该快速加载', async () => { const startTime = Date.now();
const startTime = Date.now();
const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs', {
new Request('http://localhost/docs', { method: 'GET',
method: 'GET', }),
}), );
);
const endTime = Date.now();
const endTime = Date.now(); const responseTime = endTime - startTime;
const responseTime = endTime - startTime;
expect(res.status).toBe(200);
expect(res.status).toBe(200); expect(responseTime).toBeLessThan(1000); // 应该在1秒内完成
expect(responseTime).toBeLessThan(1000); // 应该在1秒内完成 });
});
it('JSON文档应该快速响应', async () => {
it('JSON文档应该快速响应', async () => { const startTime = Date.now();
const startTime = Date.now();
const res = await app.fetch(
const res = await app.fetch( new Request('http://localhost/docs/json', {
new Request('http://localhost/docs/json', { method: 'GET',
method: 'GET', }),
}), );
);
const endTime = Date.now();
const endTime = Date.now(); const responseTime = endTime - startTime;
const responseTime = endTime - startTime;
expect(res.status).toBe(200);
expect(res.status).toBe(200); expect(responseTime).toBeLessThan(500); // JSON文档应该更快
expect(responseTime).toBeLessThan(500); // JSON文档应该更快 });
}); });
}); });
});

View File

@ -49,4 +49,4 @@ export interface DatabaseConnectionInfo {
connectedAt?: Date; connectedAt?: Date;
/** 错误信息 */ /** 错误信息 */
error?: string; error?: string;
} }

View File

@ -253,4 +253,4 @@ export interface EmailHealthCheckResult {
serviceStatus: EmailServiceStatus; serviceStatus: EmailServiceStatus;
/** 错误信息 */ /** 错误信息 */
error?: string; error?: string;
} }

View File

@ -1,164 +1,164 @@
/** /**
* @file JWT类型定义 * @file JWT类型定义
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-07-06 * @lastEditTime 2025-07-06
* @description JWT Token载荷和用户信息的TypeScript类型定义 * @description JWT Token载荷和用户信息的TypeScript类型定义
*/ */
/** /**
* Token类型枚举 * Token类型枚举
*/ */
export const TOKEN_TYPES = { export const TOKEN_TYPES = {
ACCESS: 'access', ACCESS: 'access',
REFRESH: 'refresh', REFRESH: 'refresh',
ACTIVATION: 'activation', ACTIVATION: 'activation',
PASSWORD_RESET: 'password_reset', PASSWORD_RESET: 'password_reset',
} as const; } as const;
/** /**
* Token类型定义 * Token类型定义
*/ */
export type TokenType = typeof TOKEN_TYPES[keyof typeof TOKEN_TYPES]; export type TokenType = (typeof TOKEN_TYPES)[keyof typeof TOKEN_TYPES];
/** /**
* JWT Token中的用户信息类型 * JWT Token中的用户信息类型
* @description JWT Token中的用户基本信息 * @description JWT Token中的用户基本信息
*/ */
export interface JwtUserType { export interface JwtUserType {
/** 用户IDbigint类型以字符串形式存储防止精度丢失 */ /** 用户IDbigint类型以字符串形式存储防止精度丢失 */
userId: string; userId: string;
/** 用户名 */ /** 用户名 */
username: string; username: string;
/** 用户邮箱 */ /** 用户邮箱 */
email: string; email: string;
/** 用户昵称 */ /** 用户昵称 */
nickname?: string; nickname?: string;
/** 用户状态0-禁用1-启用 */ /** 用户状态0-禁用1-启用 */
status: number; status: number;
/** 用户角色(可选,用于权限控制) */ /** 用户角色(可选,用于权限控制) */
role?: string; role?: string;
} }
/** /**
* JWT载荷类型 * JWT载荷类型
* @description JWT Token的完整载荷JWT标准字段 * @description JWT Token的完整载荷JWT标准字段
*/ */
export interface JwtPayloadType extends JwtUserType { export interface JwtPayloadType extends JwtUserType {
/** Token发行时间秒级时间戳 */ /** Token发行时间秒级时间戳 */
iat: number; iat: number;
/** Token过期时间秒级时间戳 */ /** Token过期时间秒级时间戳 */
exp: number; exp: number;
/** Token主题通常是用户ID */ /** Token主题通常是用户ID */
sub?: string; sub?: string;
/** Token发行者 */ /** Token发行者 */
iss?: string; iss?: string;
/** Token受众 */ /** Token受众 */
aud?: string; aud?: string;
/** JWT ID */ /** JWT ID */
jti?: string; jti?: string;
/** Token生效时间秒级时间戳 */ /** Token生效时间秒级时间戳 */
nbf?: number; nbf?: number;
error?: boolean; error?: boolean;
type?: TokenType; type?: TokenType;
} }
/** /**
* JWT载荷基础类型token类型和盐值 * JWT载荷基础类型token类型和盐值
* @description token的基础载荷结构 * @description token的基础载荷结构
*/ */
export interface BaseJwtPayload { export interface BaseJwtPayload {
/** 用户IDbigint类型以字符串形式存储防止精度丢失 */ /** 用户IDbigint类型以字符串形式存储防止精度丢失 */
userId: string; userId: string;
/** 用户名 */ /** 用户名 */
username: string; username: string;
/** 邮箱 */ /** 邮箱 */
email: string; email: string;
/** Token类型 */ /** Token类型 */
tokenType: TokenType; tokenType: TokenType;
/** 盐值哈希 */ /** 盐值哈希 */
saltHash: string; saltHash: string;
/** 签发者 */ /** 签发者 */
iss: string; iss: string;
/** 受众 */ /** 受众 */
aud: string; aud: string;
/** 主题 */ /** 主题 */
sub: string; sub: string;
/** 签发时间 */ /** 签发时间 */
iat: number; iat: number;
/** 过期时间 */ /** 过期时间 */
exp: number; exp: number;
} }
/** /**
* Token载荷类型 * Token载荷类型
* @description token的载荷结构 * @description token的载荷结构
*/ */
export interface ActivationTokenPayload extends BaseJwtPayload { export interface ActivationTokenPayload extends BaseJwtPayload {
tokenType: 'activation'; tokenType: 'activation';
/** 邮箱(用于激活验证) */ /** 邮箱(用于激活验证) */
email: string; email: string;
/** 用途说明 */ /** 用途说明 */
purpose: 'email_activation'; purpose: 'email_activation';
} }
/** /**
* 访Token载荷类型 * 访Token载荷类型
* @description 访token的载荷结构 * @description 访token的载荷结构
*/ */
export interface AccessTokenPayload extends BaseJwtPayload { export interface AccessTokenPayload extends BaseJwtPayload {
tokenType: 'access'; tokenType: 'access';
/** 昵称 */ /** 昵称 */
nickname?: string; nickname?: string;
/** 用户状态 */ /** 用户状态 */
status: string; status: string;
/** 角色 */ /** 角色 */
role?: string; role?: string;
} }
/** /**
* Token载荷类型 * Token载荷类型
* @description token的载荷结构 * @description token的载荷结构
*/ */
export interface RefreshTokenPayload extends BaseJwtPayload { export interface RefreshTokenPayload extends BaseJwtPayload {
tokenType: 'refresh'; tokenType: 'refresh';
/** 原始访问token的ID用于关联 */ /** 原始访问token的ID用于关联 */
accessTokenId?: string; accessTokenId?: string;
} }
/** /**
* Token载荷类型 * Token载荷类型
* @description token的载荷结构 * @description token的载荷结构
*/ */
export interface PasswordResetTokenPayload extends BaseJwtPayload { export interface PasswordResetTokenPayload extends BaseJwtPayload {
tokenType: 'password_reset'; tokenType: 'password_reset';
/** 用途说明 */ /** 用途说明 */
purpose: 'password_reset'; purpose: 'password_reset';
} }
/** /**
* JWT认证上下文类型 * JWT认证上下文类型
* @description 使 * @description 使
*/ */
export interface JwtContextType { export interface JwtContextType {
/** 当前认证用户信息 */ /** 当前认证用户信息 */
user: JwtUserType; user: JwtUserType;
/** 原始JWT载荷 */ /** 原始JWT载荷 */
payload?: JwtPayloadType; payload?: JwtPayloadType;
} }
/** /**
* JWT生成参数类型 * JWT生成参数类型
* @description JWT Token时的参数类型 * @description JWT Token时的参数类型
*/ */
export interface JwtSignOptionsType { export interface JwtSignOptionsType {
/** 用户信息 */ /** 用户信息 */
user: JwtUserType; user: JwtUserType;
/** 自定义过期时间(可选) */ /** 自定义过期时间(可选) */
expiresIn?: string; expiresIn?: string;
/** 自定义发行者(可选) */ /** 自定义发行者(可选) */
issuer?: string; issuer?: string;
/** 自定义受众(可选) */ /** 自定义受众(可选) */
audience?: string; audience?: string;
} }

View File

@ -1,4 +1,3 @@
/** /**
* Logger类的类型定义 * Logger类的类型定义
*/ */
@ -15,4 +14,4 @@ export interface LoggerInstance {
http(message: string | object): void; http(message: string | object): void;
/** 详细级别日志 */ /** 详细级别日志 */
verbose(message: string | object): void; verbose(message: string | object): void;
} }

View File

@ -61,4 +61,4 @@ export interface RedisHealthCheckResult {
connectionInfo: RedisConnectionInfo; connectionInfo: RedisConnectionInfo;
/** 错误信息 */ /** 错误信息 */
error?: string; error?: string;
} }

View File

@ -52,13 +52,13 @@ export interface DistributedLock {
export class DistributedLockService { export class DistributedLockService {
/** 锁前缀 */ /** 锁前缀 */
private static readonly LOCK_PREFIX = 'distributed_lock:'; private static readonly LOCK_PREFIX = 'distributed_lock:';
/** 默认TTL */ /** 默认TTL */
private static readonly DEFAULT_TTL = 30; private static readonly DEFAULT_TTL = 30;
/** 默认超时时间(毫秒) */ /** 默认超时时间(毫秒) */
private static readonly DEFAULT_TIMEOUT = 5000; private static readonly DEFAULT_TIMEOUT = 5000;
/** 默认续期间隔(毫秒) */ /** 默认续期间隔(毫秒) */
private static readonly DEFAULT_RENEW_INTERVAL = 10000; private static readonly DEFAULT_RENEW_INTERVAL = 10000;
@ -85,15 +85,15 @@ export class DistributedLockService {
while (Date.now() - startTime < timeout) { while (Date.now() - startTime < timeout) {
// 使用 SET key value NX EX seconds 原子操作 // 使用 SET key value NX EX seconds 原子操作
const result = await redisService.client.set(lockKey, lockValue, { const result = await redisService.client.set(lockKey, lockValue, {
NX: true, // 只有当 key 不存在时才设置 NX: true, // 只有当 key 不存在时才设置
EX: ttl // 设置过期时间(秒) EX: ttl, // 设置过期时间(秒)
}); });
if (result === 'OK') { if (result === 'OK') {
acquired = true; acquired = true;
break; break;
} }
// 等待一段时间后重试 // 等待一段时间后重试
await this.sleep(100); await this.sleep(100);
} }
@ -110,7 +110,7 @@ export class DistributedLockService {
value: lockValue, value: lockValue,
acquired: true, acquired: true,
acquiredAt: Date.now(), acquiredAt: Date.now(),
// 释放锁 // 释放锁
release: async (): Promise<boolean> => { release: async (): Promise<boolean> => {
// 清理定时器和事件监听器 // 清理定时器和事件监听器
@ -124,7 +124,7 @@ export class DistributedLockService {
process.removeListener('SIGTERM', processExitHandler); process.removeListener('SIGTERM', processExitHandler);
processExitHandler = null; processExitHandler = null;
} }
const released = await this.releaseLock(lockKey, lockValue); const released = await this.releaseLock(lockKey, lockValue);
if (released) { if (released) {
lock.acquired = false; lock.acquired = false;
@ -132,11 +132,11 @@ export class DistributedLockService {
} }
return released; return released;
}, },
// 续期锁 // 续期锁
renew: async (): Promise<boolean> => { renew: async (): Promise<boolean> => {
return await this.renewLock(lockKey, lockValue, ttl); return await this.renewLock(lockKey, lockValue, ttl);
} },
}; };
// 启动自动续期(仅在需要时) // 启动自动续期(仅在需要时)
@ -165,14 +165,13 @@ export class DistributedLockService {
Logger.warn(`进程退出,强制释放分布式锁: ${lockKey}`); Logger.warn(`进程退出,强制释放分布式锁: ${lockKey}`);
await this.forceRelease(config.key); await this.forceRelease(config.key);
}; };
process.on('exit', processExitHandler); process.on('exit', processExitHandler);
process.on('SIGINT', processExitHandler); process.on('SIGINT', processExitHandler);
process.on('SIGTERM', processExitHandler); process.on('SIGTERM', processExitHandler);
} }
return lock; return lock;
} catch (error) { } catch (error) {
// 清理已创建的定时器和事件监听器 // 清理已创建的定时器和事件监听器
if (renewTimer) { if (renewTimer) {
@ -183,7 +182,7 @@ export class DistributedLockService {
process.removeListener('SIGINT', processExitHandler); process.removeListener('SIGINT', processExitHandler);
process.removeListener('SIGTERM', processExitHandler); process.removeListener('SIGTERM', processExitHandler);
} }
Logger.error(new Error(`获取分布式锁失败: ${lockKey}, error: ${error}`)); Logger.error(new Error(`获取分布式锁失败: ${lockKey}, error: ${error}`));
throw error; throw error;
} }
@ -205,12 +204,12 @@ export class DistributedLockService {
return 0 return 0
end end
`; `;
const result = await redisService.client.eval(luaScript, { const result = await redisService.client.eval(luaScript, {
keys: [lockKey], keys: [lockKey],
arguments: [lockValue] arguments: [lockValue],
}); });
return result === 1; return result === 1;
} catch (error) { } catch (error) {
Logger.error(new Error(`释放锁失败: ${lockKey}, error: ${error}`)); Logger.error(new Error(`释放锁失败: ${lockKey}, error: ${error}`));
@ -235,12 +234,12 @@ export class DistributedLockService {
return 0 return 0
end end
`; `;
const result = await redisService.client.eval(luaScript, { const result = await redisService.client.eval(luaScript, {
keys: [lockKey], keys: [lockKey],
arguments: [lockValue, ttl.toString()] arguments: [lockValue, ttl.toString()],
}); });
return result === 1; return result === 1;
} catch (error) { } catch (error) {
Logger.error(new Error(`续期锁失败: ${lockKey}, error: ${error}`)); Logger.error(new Error(`续期锁失败: ${lockKey}, error: ${error}`));
@ -291,7 +290,7 @@ export class DistributedLockService {
* @returns Promise<void> * @returns Promise<void>
*/ */
private static sleep(ms: number): Promise<void> { private static sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
} }
@ -312,7 +311,7 @@ export const LOCK_KEYS = {
// 验证码生成锁 // 验证码生成锁
CAPTCHA_GENERATE: 'captcha:generate', CAPTCHA_GENERATE: 'captcha:generate',
// 邮件发送锁 // 邮件发送锁
EMAIL_SEND: 'email:send' EMAIL_SEND: 'email:send',
} as const; } as const;
/** /**
@ -324,22 +323,22 @@ export const LOCK_KEYS = {
export function withDistributedLock(lockKey: string, ttl: number = 30, timeout: number = 5000) { export function withDistributedLock(lockKey: string, ttl: number = 30, timeout: number = 5000) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) { return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value; const method = descriptor.value;
descriptor.value = async function (...args: any[]) { descriptor.value = async function (...args: any[]) {
const lock = await DistributedLockService.acquire({ const lock = await DistributedLockService.acquire({
key: lockKey, key: lockKey,
ttl, ttl,
timeout, timeout,
autoRenew: true autoRenew: true,
}); });
try { try {
return await method.apply(this, args); return await method.apply(this, args);
} finally { } finally {
await lock.release(); await lock.release();
} }
}; };
return descriptor; return descriptor;
}; };
} }

View File

@ -1,5 +1,5 @@
import chalk from 'chalk'; import chalk from 'chalk';
export const formatRoute = (router: any) => { export const formatRoute = (router: any) => {
return router.map((route: any) => chalk.green.bold(route.method.padEnd(6, ' ')) + ' ' + route.path).join('\n'); return router.map((route: any) => chalk.green.bold(route.method.padEnd(6, ' ')) + ' ' + route.path).join('\n');
}; };

View File

@ -1,138 +1,138 @@
/** /**
* @file JWT辅助工具 * @file JWT辅助工具
* @author hotok * @author hotok
* @date 2025-06-29 * @date 2025-06-29
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-29 * @lastEditTime 2025-06-29
* @description JWT相关的辅助函数Controller中的JWT操作 * @description JWT相关的辅助函数Controller中的JWT操作
*/ */
import type { JwtUserType, JwtPayloadType } from '@/type/jwt.type'; import type { JwtUserType, JwtPayloadType } from '@/type/jwt.type';
import type { UserInfoType } from '@/modules/example/example.schema'; import type { UserInfoType } from '@/modules/example/example.schema';
/** /**
* JWT用户信息 * JWT用户信息
* @param userInfo * @param userInfo
* @param role * @param role
* @returns JwtUserType JWT中的用户信息 * @returns JwtUserType JWT中的用户信息
* @modification hotok 2025-06-29 JWT用户信息转换函数 * @modification hotok 2025-06-29 JWT用户信息转换函数
*/ */
export function createJwtUser(userInfo: UserInfoType, role?: string): JwtUserType { export function createJwtUser(userInfo: UserInfoType, role?: string): JwtUserType {
return { return {
userId: userInfo.id, userId: userInfo.id,
username: userInfo.username, username: userInfo.username,
email: userInfo.email, email: userInfo.email,
nickname: userInfo.nickname, nickname: userInfo.nickname,
status: userInfo.status, status: userInfo.status,
role: role, role: role,
}; };
} }
/** /**
* JWT载荷 * JWT载荷
* @param userInfo * @param userInfo
* @param options * @param options
* @returns JWT载荷对象iatexp等自动生成字段 * @returns JWT载荷对象iatexp等自动生成字段
* @modification hotok 2025-06-29 JWT载荷生成函数 * @modification hotok 2025-06-29 JWT载荷生成函数
*/ */
export function createJwtPayload( export function createJwtPayload(
userInfo: UserInfoType, userInfo: UserInfoType,
options?: { options?: {
role?: string; role?: string;
issuer?: string; issuer?: string;
audience?: string; audience?: string;
subject?: string; subject?: string;
}, },
): Omit<JwtPayloadType, 'iat' | 'exp'> { ): Omit<JwtPayloadType, 'iat' | 'exp'> {
const jwtUser = createJwtUser(userInfo, options?.role); const jwtUser = createJwtUser(userInfo, options?.role);
return { return {
...jwtUser, ...jwtUser,
sub: options?.subject || userInfo.id.toString(), sub: options?.subject || userInfo.id.toString(),
iss: options?.issuer || 'elysia-api', iss: options?.issuer || 'elysia-api',
aud: options?.audience || 'web-client', aud: options?.audience || 'web-client',
}; };
} }
/** /**
* JWT载荷中的用户是否有效 * JWT载荷中的用户是否有效
* @param payload JWT载荷 * @param payload JWT载荷
* @returns boolean * @returns boolean
* @modification hotok 2025-06-29 * @modification hotok 2025-06-29
*/ */
export function isValidJwtUser(payload: JwtPayloadType): boolean { export function isValidJwtUser(payload: JwtPayloadType): boolean {
// 检查必需字段 // 检查必需字段
if (!payload.userId || !payload.username || !payload.email) { if (!payload.userId || !payload.username || !payload.email) {
return false; return false;
} }
// 检查用户状态1为启用 // 检查用户状态1为启用
if (payload.status !== 1) { if (payload.status !== 1) {
return false; return false;
} }
return true; return true;
} }
/** /**
* JWT是否即将过期 * JWT是否即将过期
* @param payload JWT载荷 * @param payload JWT载荷
* @param thresholdMinutes 30 * @param thresholdMinutes 30
* @returns boolean * @returns boolean
* @modification hotok 2025-06-29 * @modification hotok 2025-06-29
*/ */
export function isTokenExpiringSoon(payload: JwtPayloadType, thresholdMinutes: number = 30): boolean { export function isTokenExpiringSoon(payload: JwtPayloadType, thresholdMinutes: number = 30): boolean {
if (!payload.exp) return false; if (!payload.exp) return false;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const threshold = thresholdMinutes * 60; const threshold = thresholdMinutes * 60;
return (payload.exp - now) <= threshold; return payload.exp - now <= threshold;
} }
/** /**
* Token剩余有效时间 * Token剩余有效时间
* @param payload JWT载荷 * @param payload JWT载荷
* @returns number -1 * @returns number -1
* @modification hotok 2025-06-29 * @modification hotok 2025-06-29
*/ */
export function getTokenRemainingTime(payload: JwtPayloadType): number { export function getTokenRemainingTime(payload: JwtPayloadType): number {
if (!payload.exp) return -1; if (!payload.exp) return -1;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const remaining = payload.exp - now; const remaining = payload.exp - now;
return remaining > 0 ? remaining : -1; return remaining > 0 ? remaining : -1;
} }
/** /**
* Token剩余时间为可读字符串 * Token剩余时间为可读字符串
* @param seconds * @param seconds
* @returns string * @returns string
* @modification hotok 2025-06-29 * @modification hotok 2025-06-29
*/ */
export function formatRemainingTime(seconds: number): string { export function formatRemainingTime(seconds: number): string {
if (seconds <= 0) return '已过期'; if (seconds <= 0) return '已过期';
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
if (hours > 0) { if (hours > 0) {
return `${hours}小时${minutes}分钟`; return `${hours}小时${minutes}分钟`;
} else if (minutes > 0) { } else if (minutes > 0) {
return `${minutes}分钟${remainingSeconds}`; return `${minutes}分钟${remainingSeconds}`;
} else { } else {
return `${remainingSeconds}`; return `${remainingSeconds}`;
} }
} }
/** /**
* JWT用户信息的简化版本 * JWT用户信息的简化版本
* @param user JWT用户信息 * @param user JWT用户信息
* @returns string * @returns string
* @modification hotok 2025-06-29 * @modification hotok 2025-06-29
*/ */
export function formatUserForLog(user: JwtUserType): string { export function formatUserForLog(user: JwtUserType): string {
return `用户ID:${user.userId} 用户名:${user.username} 状态:${user.status === 1 ? '启用' : '禁用'}`; return `用户ID:${user.userId} 用户名:${user.username} 状态:${user.status === 1 ? '启用' : '禁用'}`;
} }

View File

@ -7,42 +7,47 @@
* @description Schema和工具函数 * @description Schema和工具函数
*/ */
import { t, type Static, type TSchema } from 'elysia'; import { t, type Static } from 'elysia';
/** /**
* Schema * Schema
*/ */
export const BasePaginationSchema = t.Object({ export const BasePaginationSchema = t.Object({
/** 页码从1开始 */ /** 页码从1开始 */
page: t.Optional(t.Number({ page: t.Optional(
minimum: 1, t.Number({
description: '页码从1开始', minimum: 1,
examples: [1, 2, 3], description: '页码从1开始',
default: 1 examples: [1, 2, 3],
})), default: 1,
}),
),
/** 每页大小最大100 */ /** 每页大小最大100 */
pageSize: t.Optional(t.Number({ pageSize: t.Optional(
minimum: 1, t.Number({
maximum: 100, minimum: 1,
description: '每页大小最大100', maximum: 100,
examples: [10, 20, 50], description: '每页大小最大100',
default: 20 examples: [10, 20, 50],
})), default: 20,
}),
),
/** 排序字段 */ /** 排序字段 */
sortBy: t.Optional(t.String({ sortBy: t.Optional(
description: '排序字段', t.String({
examples: ['createdAt', 'updatedAt', 'username', 'email'], description: '排序字段',
default: 'createdAt' examples: ['createdAt', 'updatedAt', 'username', 'email'],
})), default: 'createdAt',
}),
),
/** 排序方向 */ /** 排序方向 */
sortOrder: t.Optional(t.Union([ sortOrder: t.Optional(
t.Literal('asc'), t.Union([t.Literal('asc'), t.Literal('desc')], {
t.Literal('desc') description: '排序方向',
], { examples: ['asc', 'desc'],
description: '排序方向', default: 'desc',
examples: ['asc', 'desc'], }),
default: 'desc' ),
}))
}); });
/** /**
@ -54,9 +59,9 @@ export const BasePaginationSchema = t.Object({
export const createQuerySchema = (customSchema: any) => { export const createQuerySchema = (customSchema: any) => {
return t.Object({ return t.Object({
...BasePaginationSchema.properties, ...BasePaginationSchema.properties,
...customSchema.properties ...customSchema.properties,
}); });
} };
/** /**
* *
@ -72,20 +77,20 @@ export const createPaginationResponseSchema = <T>(dataSchema: T) => {
/** 总记录数 */ /** 总记录数 */
total: t.Number({ total: t.Number({
description: '总记录数', description: '总记录数',
examples: [100, 250, 1000] examples: [100, 250, 1000],
}), }),
/** 当前页码 */ /** 当前页码 */
page: t.Number({ page: t.Number({
description: '当前页码', description: '当前页码',
examples: [1, 2, 3] examples: [1, 2, 3],
}), }),
/** 每页大小 */ /** 每页大小 */
pageSize: t.Number({ pageSize: t.Number({
description: '每页大小', description: '每页大小',
examples: [10, 20, 50] examples: [10, 20, 50],
}), }),
/** 数据列表 */ /** 数据列表 */
data: t.Array(dataSchema) data: t.Array(dataSchema),
}); });
}; };
@ -110,7 +115,7 @@ export const calculatePagination = (total: number, page: number, pageSize: numbe
return { return {
total, total,
page, page,
pageSize pageSize,
}; };
}; };
@ -124,6 +129,6 @@ export const normalizePaginationParams = (params: Partial<BasePaginationRequest>
page: Math.max(1, params.page || 1), page: Math.max(1, params.page || 1),
pageSize: Math.min(100, Math.max(1, params.pageSize || 20)), pageSize: Math.min(100, Math.max(1, params.pageSize || 20)),
sortBy: params.sortBy || 'createdAt', sortBy: params.sortBy || 'createdAt',
sortOrder: params.sortOrder || 'desc' sortOrder: params.sortOrder || 'desc',
}; };
}; };

View File

@ -1,127 +1,127 @@
/** /**
* @file Redis数据库连接工具 * @file Redis数据库连接工具
* @author hotok * @author hotok
* @date 2025-06-28 * @date 2025-06-28
* @lastEditor hotok * @lastEditor hotok
* @lastEditTime 2025-06-28 * @lastEditTime 2025-06-28
* @description Redis连接实例 * @description Redis连接实例
*/ */
import { createClient, type RedisClientType } from 'redis'; import { createClient, type RedisClientType } from 'redis';
import { redisConfig, getRedisUrl } from '@/config/redis.config'; import { redisConfig, getRedisUrl } from '@/config/redis.config';
import { Logger } from '@/plugins/logger/logger.service'; import { Logger } from '@/plugins/logger/logger.service';
/** /**
* Redis客户端实例 * Redis客户端实例
*/ */
class Redis { class Redis {
public redisClient: RedisClientType; public redisClient: RedisClientType;
/** /**
* Redis连接状态 * Redis连接状态
*/ */
private isConnected = false; private isConnected = false;
constructor() { constructor() {
this.redisClient = createClient({ this.redisClient = createClient({
name: redisConfig.connectName, name: redisConfig.connectName,
username: redisConfig.username, username: redisConfig.username,
password: redisConfig.password, password: redisConfig.password,
database: redisConfig.database, database: redisConfig.database,
url: getRedisUrl(), url: getRedisUrl(),
}); });
// 错误处理 // 错误处理
this.redisClient.on('error', (error) => { this.redisClient.on('error', (error) => {
Logger.error(error as Error); Logger.error(error as Error);
this.isConnected = false; this.isConnected = false;
}); });
this.redisClient.on('connect', () => { this.redisClient.on('connect', () => {
Logger.info('Redis客户端连接建立'); Logger.info('Redis客户端连接建立');
this.isConnected = true; this.isConnected = true;
}); });
this.redisClient.on('ready', () => { this.redisClient.on('ready', () => {
Logger.info('Redis客户端准备就绪'); Logger.info('Redis客户端准备就绪');
}); });
this.redisClient.on('end', () => { this.redisClient.on('end', () => {
Logger.info('Redis客户端连接结束'); Logger.info('Redis客户端连接结束');
this.isConnected = false; this.isConnected = false;
}); });
this.redisClient.on('reconnecting', () => { this.redisClient.on('reconnecting', () => {
Logger.warn('Redis客户端正在重连'); Logger.warn('Redis客户端正在重连');
}); });
// 初始化连接 // 初始化连接
this.connectRedis().catch((error) => { this.connectRedis().catch((error) => {
Logger.error(error as Error); Logger.error(error as Error);
}); });
} }
/** /**
* Redis * Redis
* @returns Promise<void> * @returns Promise<void>
*/ */
async connectRedis() { async connectRedis() {
try { try {
if (!this.isConnected) { if (!this.isConnected) {
await this.redisClient.connect(); await this.redisClient.connect();
this.isConnected = true; this.isConnected = true;
} }
} catch (error) { } catch (error) {
Logger.error(error as Error); Logger.error(error as Error);
throw error; throw error;
} }
} }
/** /**
* Redis连接 * Redis连接
* @returns Promise<void> * @returns Promise<void>
*/ */
async disconnectRedis() { async disconnectRedis() {
try { try {
if (this.isConnected) { if (this.isConnected) {
await this.redisClient.close(); await this.redisClient.close();
this.isConnected = false; this.isConnected = false;
} }
} catch (error) { } catch (error) {
Logger.error(error as Error); Logger.error(error as Error);
throw error; throw error;
} }
} }
/** /**
* Redis健康检查 * Redis健康检查
* @returns Promise<boolean> * @returns Promise<boolean>
*/ */
async checkRedisHealth() { async checkRedisHealth() {
try { try {
await this.redisClient.ping(); await this.redisClient.ping();
return true; return true;
} catch (error) { } catch (error) {
Logger.error(error as Error); Logger.error(error as Error);
return false; return false;
} }
} }
/** /**
* Redis连接状态 * Redis连接状态
* @returns * @returns
*/ */
getRedisStatus() { getRedisStatus() {
return { return {
isConnected: this.isConnected, isConnected: this.isConnected,
config: { config: {
host: redisConfig.host, host: redisConfig.host,
port: redisConfig.port, port: redisConfig.port,
database: redisConfig.database, database: redisConfig.database,
connectName: redisConfig.connectName, connectName: redisConfig.connectName,
}, },
}; };
} }
} }
/** /**
* Redis类使 * Redis类使
*/ */
export { Redis }; export { Redis };

View File

@ -7,7 +7,7 @@
* @description * @description
*/ */
import Logger from "@/plugins/logger/logger.service"; import Logger from '@/plugins/logger/logger.service';
/** /**
* *
@ -21,8 +21,8 @@ export const successResponse = (data: any, message: string = 'success') => {
message, message,
data, data,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} };
} };
export const errorResponse = (code: number, message: string, type: string, data: any = null) => { export const errorResponse = (code: number, message: string, type: string, data: any = null) => {
const response = { const response = {
@ -31,10 +31,10 @@ export const errorResponse = (code: number, message: string, type: string, data:
data, data,
type, type,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} };
Logger.warn(response); Logger.warn(response);
return response return response;
} };
export class BusinessError extends Error { export class BusinessError extends Error {
public readonly code: number; public readonly code: number;
@ -64,4 +64,4 @@ export const responseWrapperSchema = (dataSchema: any) =>
examples: ['操作成功', '操作失败', '创建成功'], examples: ['操作成功', '操作失败', '创建成功'],
}), }),
data: dataSchema, data: dataSchema,
}); });

View File

@ -7,314 +7,314 @@
* @description ID生成器的单元测试ID生成 * @description ID生成器的单元测试ID生成
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { Snowflake, getSnowflake, nextId, parseId, createSnowflake, type SnowflakeConfig } from './snowflake'; import { Snowflake, getSnowflake, nextId, parseId, createSnowflake, type SnowflakeConfig } from './snowflake';
describe('Snowflake ID Generator', () => { describe('Snowflake ID Generator', () => {
let snowflake: Snowflake; let snowflake: Snowflake;
beforeEach(() => { beforeEach(() => {
// 创建新的雪花ID实例 // 创建新的雪花ID实例
snowflake = new Snowflake({ workerId: 1, datacenterId: 1 }); snowflake = new Snowflake({ workerId: 1, datacenterId: 1 });
});
describe('Constructor', () => {
it('应该成功创建雪花ID生成器实例', () => {
const config: SnowflakeConfig = {
workerId: 1,
datacenterId: 1,
};
const instance = new Snowflake(config);
expect(instance).toBeInstanceOf(Snowflake);
}); });
it('应该使用默认配置创建实例', () => { describe('Constructor', () => {
const config: SnowflakeConfig = { it('应该成功创建雪花ID生成器实例', () => {
workerId: 1, const config: SnowflakeConfig = {
datacenterId: 1, workerId: 1,
}; datacenterId: 1,
const instance = new Snowflake(config); };
const instanceConfig = instance.getConfig(); const instance = new Snowflake(config);
expect(instance).toBeInstanceOf(Snowflake);
expect(instanceConfig.workerId).toBe(1); });
expect(instanceConfig.datacenterId).toBe(1);
expect(instanceConfig.sequence).toBe(0); it('应该使用默认配置创建实例', () => {
expect(instanceConfig.epoch).toBe(1577836800000); // 2020-01-01 00:00:00 UTC const config: SnowflakeConfig = {
workerId: 1,
datacenterId: 1,
};
const instance = new Snowflake(config);
const instanceConfig = instance.getConfig();
expect(instanceConfig.workerId).toBe(1);
expect(instanceConfig.datacenterId).toBe(1);
expect(instanceConfig.sequence).toBe(0);
expect(instanceConfig.epoch).toBe(1577836800000); // 2020-01-01 00:00:00 UTC
});
it('应该使用自定义配置创建实例', () => {
const customEpoch = 1609459200000; // 2021-01-01 00:00:00 UTC
const config: SnowflakeConfig = {
workerId: 5,
datacenterId: 3,
sequence: 100,
epoch: customEpoch,
};
const instance = new Snowflake(config);
const instanceConfig = instance.getConfig();
expect(instanceConfig.workerId).toBe(5);
expect(instanceConfig.datacenterId).toBe(3);
expect(instanceConfig.sequence).toBe(100);
expect(instanceConfig.epoch).toBe(customEpoch);
});
it('应该验证workerId范围', () => {
expect(() => {
new Snowflake({ workerId: -1, datacenterId: 1 });
}).toThrow('Worker ID must be between 0 and 31');
expect(() => {
new Snowflake({ workerId: 32, datacenterId: 1 });
}).toThrow('Worker ID must be between 0 and 31');
});
it('应该验证datacenterId范围', () => {
expect(() => {
new Snowflake({ workerId: 1, datacenterId: -1 });
}).toThrow('Datacenter ID must be between 0 and 31');
expect(() => {
new Snowflake({ workerId: 1, datacenterId: 32 });
}).toThrow('Datacenter ID must be between 0 and 31');
});
}); });
it('应该使用自定义配置创建实例', () => { describe('ID Generation', () => {
const customEpoch = 1609459200000; // 2021-01-01 00:00:00 UTC it('应该生成唯一的ID', () => {
const config: SnowflakeConfig = { const id1 = snowflake.nextId();
workerId: 5, const id2 = snowflake.nextId();
datacenterId: 3,
sequence: 100, expect(id1).not.toBe(id2);
epoch: customEpoch, expect(typeof id1).toBe('bigint');
}; expect(typeof id2).toBe('bigint');
const instance = new Snowflake(config); });
const instanceConfig = instance.getConfig();
it('应该生成递增的ID', () => {
expect(instanceConfig.workerId).toBe(5); const ids: bigint[] = [];
expect(instanceConfig.datacenterId).toBe(3); for (let i = 0; i < 10; i++) {
expect(instanceConfig.sequence).toBe(100); ids.push(snowflake.nextId());
expect(instanceConfig.epoch).toBe(customEpoch); }
for (let i = 1; i < ids.length; i++) {
expect(ids[i]).toBeGreaterThan(ids[i - 1]);
}
});
it('应该在同一毫秒内递增序列号', () => {
// 模拟同一毫秒
const originalDateNow = Date.now;
Date.now = () => 1609459200000;
const id1 = snowflake.nextId();
const id2 = snowflake.nextId();
const parsed1 = Snowflake.parseId(id1);
const parsed2 = Snowflake.parseId(id2);
expect(parsed1.timestamp).toBe(parsed2.timestamp);
expect(parsed2.sequence).toBe(parsed1.sequence + 1);
// 恢复原始函数
Date.now = originalDateNow;
});
it('应该在不同毫秒间重置序列号', () => {
let callCount = 0;
const originalDateNow = Date.now;
Date.now = () => {
callCount++;
return 1609459200000 + callCount; // 每次调用递增1毫秒
};
const id1 = snowflake.nextId();
const id2 = snowflake.nextId();
const parsed1 = Snowflake.parseId(id1);
const parsed2 = Snowflake.parseId(id2);
expect(parsed1.timestamp).toBeLessThan(parsed2.timestamp);
expect(parsed2.sequence).toBe(0); // 序列号应该重置
// 恢复原始函数
Date.now = originalDateNow;
});
}); });
it('应该验证workerId范围', () => { describe('ID Parsing', () => {
expect(() => { it('应该正确解析生成的ID', () => {
new Snowflake({ workerId: -1, datacenterId: 1 }); const id = snowflake.nextId();
}).toThrow('Worker ID must be between 0 and 31'); const parsed = Snowflake.parseId(id);
expect(() => { expect(parsed.workerId).toBe(1);
new Snowflake({ workerId: 32, datacenterId: 1 }); expect(parsed.datacenterId).toBe(1);
}).toThrow('Worker ID must be between 0 and 31'); expect(parsed.sequence).toBeGreaterThanOrEqual(0);
expect(parsed.timestamp).toBeGreaterThan(0);
expect(parsed.createdAt).toBeInstanceOf(Date);
});
it('应该解析自定义配置生成的ID', () => {
const customSnowflake = new Snowflake({
workerId: 10,
datacenterId: 20,
epoch: 1609459200000,
});
const id = customSnowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(parsed.workerId).toBe(10);
expect(parsed.datacenterId).toBe(20);
});
it('应该正确计算创建时间', () => {
const id = snowflake.nextId();
const parsed = Snowflake.parseId(id);
const now = new Date();
// 创建时间应该在合理范围内前后1秒
const timeDiff = Math.abs(parsed.createdAt.getTime() - now.getTime());
expect(timeDiff).toBeLessThan(1000);
});
}); });
it('应该验证datacenterId范围', () => { describe('Singleton Pattern', () => {
expect(() => { it('应该返回相同的单例实例', () => {
new Snowflake({ workerId: 1, datacenterId: -1 }); const instance1 = getSnowflake();
}).toThrow('Datacenter ID must be between 0 and 31'); const instance2 = getSnowflake();
expect(() => { expect(instance1).toBe(instance2);
new Snowflake({ workerId: 1, datacenterId: 32 }); });
}).toThrow('Datacenter ID must be between 0 and 31');
});
});
describe('ID Generation', () => { it('应该使用默认配置创建单例', () => {
it('应该生成唯一的ID', () => { const instance = getSnowflake();
const id1 = snowflake.nextId(); const config = instance.getConfig();
const id2 = snowflake.nextId();
expect(config.workerId).toBe(1);
expect(id1).not.toBe(id2); expect(config.datacenterId).toBe(1);
expect(typeof id1).toBe('bigint'); });
expect(typeof id2).toBe('bigint');
it('应该使用自定义配置创建单例', () => {
const customInstance = getSnowflake({ workerId: 5, datacenterId: 5 });
const config = customInstance.getConfig();
expect(config.workerId).toBe(5);
expect(config.datacenterId).toBe(5);
});
}); });
it('应该生成递增的ID', () => { describe('Utility Functions', () => {
const ids: bigint[] = []; it('nextId函数应该生成ID', () => {
for (let i = 0; i < 10; i++) { const id = nextId();
ids.push(snowflake.nextId()); expect(typeof id).toBe('bigint');
} expect(id).toBeGreaterThan(0n);
});
for (let i = 1; i < ids.length; i++) {
expect(ids[i]).toBeGreaterThan(ids[i - 1]); it('parseId函数应该解析ID', () => {
} const id = nextId();
const parsed = parseId(id);
expect(parsed).toHaveProperty('timestamp');
expect(parsed).toHaveProperty('datacenterId');
expect(parsed).toHaveProperty('workerId');
expect(parsed).toHaveProperty('sequence');
expect(parsed).toHaveProperty('createdAt');
});
it('createSnowflake函数应该创建新实例', () => {
const instance1 = createSnowflake({ workerId: 1, datacenterId: 1 });
const instance2 = createSnowflake({ workerId: 2, datacenterId: 2 });
expect(instance1).not.toBe(instance2);
const config1 = instance1.getConfig();
const config2 = instance2.getConfig();
expect(config1.workerId).toBe(1);
expect(config2.workerId).toBe(2);
});
}); });
it('应该在同一毫秒内递增序列号', () => { describe('Performance Tests', () => {
// 模拟同一毫秒 it('应该能够快速生成大量ID', () => {
const originalDateNow = Date.now; const startTime = Date.now();
Date.now = () => 1609459200000; const ids: bigint[] = [];
const id1 = snowflake.nextId(); // 生成1000个ID
const id2 = snowflake.nextId(); for (let i = 0; i < 1000; i++) {
ids.push(snowflake.nextId());
const parsed1 = Snowflake.parseId(id1); }
const parsed2 = Snowflake.parseId(id2);
const endTime = Date.now();
expect(parsed1.timestamp).toBe(parsed2.timestamp); const duration = endTime - startTime;
expect(parsed2.sequence).toBe(parsed1.sequence + 1);
expect(ids.length).toBe(1000);
// 恢复原始函数 expect(duration).toBeLessThan(100); // 应该在100ms内完成
Date.now = originalDateNow;
// 验证所有ID都是唯一的
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(1000);
});
it('应该能够处理序列号溢出', () => {
// 模拟快速生成ID触发序列号溢出
const originalDateNow = Date.now;
Date.now = () => 1609459200000;
const ids: bigint[] = [];
for (let i = 0; i < 5000; i++) {
ids.push(snowflake.nextId());
}
// 验证所有ID都是唯一的
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(5000);
// 恢复原始函数
Date.now = originalDateNow;
});
}); });
it('应该在不同毫秒间重置序列号', () => { describe('Edge Cases', () => {
let callCount = 0; it('应该处理最大配置值', () => {
const originalDateNow = Date.now; const maxSnowflake = new Snowflake({
Date.now = () => { workerId: 31,
callCount++; datacenterId: 31,
return 1609459200000 + callCount; // 每次调用递增1毫秒 });
};
const id1 = snowflake.nextId();
const id2 = snowflake.nextId();
const parsed1 = Snowflake.parseId(id1);
const parsed2 = Snowflake.parseId(id2);
expect(parsed1.timestamp).toBeLessThan(parsed2.timestamp);
expect(parsed2.sequence).toBe(0); // 序列号应该重置
// 恢复原始函数
Date.now = originalDateNow;
});
});
describe('ID Parsing', () => { const id = maxSnowflake.nextId();
it('应该正确解析生成的ID', () => { const parsed = Snowflake.parseId(id);
const id = snowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(parsed.workerId).toBe(1);
expect(parsed.datacenterId).toBe(1);
expect(parsed.sequence).toBeGreaterThanOrEqual(0);
expect(parsed.timestamp).toBeGreaterThan(0);
expect(parsed.createdAt).toBeInstanceOf(Date);
});
it('应该解析自定义配置生成的ID', () => { expect(parsed.workerId).toBe(31);
const customSnowflake = new Snowflake({ expect(parsed.datacenterId).toBe(31);
workerId: 10, });
datacenterId: 20,
epoch: 1609459200000,
});
const id = customSnowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(parsed.workerId).toBe(10);
expect(parsed.datacenterId).toBe(20);
});
it('应该正确计算创建时间', () => { it('应该处理最小配置值', () => {
const id = snowflake.nextId(); const minSnowflake = new Snowflake({
const parsed = Snowflake.parseId(id); workerId: 0,
const now = new Date(); datacenterId: 0,
});
// 创建时间应该在合理范围内前后1秒
const timeDiff = Math.abs(parsed.createdAt.getTime() - now.getTime());
expect(timeDiff).toBeLessThan(1000);
});
});
describe('Singleton Pattern', () => { const id = minSnowflake.nextId();
it('应该返回相同的单例实例', () => { const parsed = Snowflake.parseId(id);
const instance1 = getSnowflake();
const instance2 = getSnowflake();
expect(instance1).toBe(instance2);
});
it('应该使用默认配置创建单例', () => { expect(parsed.workerId).toBe(0);
const instance = getSnowflake(); expect(parsed.datacenterId).toBe(0);
const config = instance.getConfig(); });
expect(config.workerId).toBe(1);
expect(config.datacenterId).toBe(1);
});
it('应该使用自定义配置创建单例', () => { it('应该处理自定义epoch', () => {
const customInstance = getSnowflake({ workerId: 5, datacenterId: 5 }); const customEpoch = Date.now();
const config = customInstance.getConfig(); const customSnowflake = new Snowflake({
workerId: 1,
expect(config.workerId).toBe(5); datacenterId: 1,
expect(config.datacenterId).toBe(5); epoch: customEpoch,
}); });
});
describe('Utility Functions', () => { const id = customSnowflake.nextId();
it('nextId函数应该生成ID', () => { const parsed = Snowflake.parseId(id);
const id = nextId();
expect(typeof id).toBe('bigint');
expect(id).toBeGreaterThan(0n);
});
it('parseId函数应该解析ID', () => { // 由于parseId使用默认epoch时间戳会有差异
const id = nextId(); expect(parsed.createdAt.getTime()).toBeLessThan(Date.now());
const parsed = parseId(id); });
expect(parsed).toHaveProperty('timestamp');
expect(parsed).toHaveProperty('datacenterId');
expect(parsed).toHaveProperty('workerId');
expect(parsed).toHaveProperty('sequence');
expect(parsed).toHaveProperty('createdAt');
}); });
});
it('createSnowflake函数应该创建新实例', () => {
const instance1 = createSnowflake({ workerId: 1, datacenterId: 1 });
const instance2 = createSnowflake({ workerId: 2, datacenterId: 2 });
expect(instance1).not.toBe(instance2);
const config1 = instance1.getConfig();
const config2 = instance2.getConfig();
expect(config1.workerId).toBe(1);
expect(config2.workerId).toBe(2);
});
});
describe('Performance Tests', () => {
it('应该能够快速生成大量ID', () => {
const startTime = Date.now();
const ids: bigint[] = [];
// 生成1000个ID
for (let i = 0; i < 1000; i++) {
ids.push(snowflake.nextId());
}
const endTime = Date.now();
const duration = endTime - startTime;
expect(ids.length).toBe(1000);
expect(duration).toBeLessThan(100); // 应该在100ms内完成
// 验证所有ID都是唯一的
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(1000);
});
it('应该能够处理序列号溢出', () => {
// 模拟快速生成ID触发序列号溢出
const originalDateNow = Date.now;
Date.now = () => 1609459200000;
const ids: bigint[] = [];
for (let i = 0; i < 5000; i++) {
ids.push(snowflake.nextId());
}
// 验证所有ID都是唯一的
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(5000);
// 恢复原始函数
Date.now = originalDateNow;
});
});
describe('Edge Cases', () => {
it('应该处理最大配置值', () => {
const maxSnowflake = new Snowflake({
workerId: 31,
datacenterId: 31,
});
const id = maxSnowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(parsed.workerId).toBe(31);
expect(parsed.datacenterId).toBe(31);
});
it('应该处理最小配置值', () => {
const minSnowflake = new Snowflake({
workerId: 0,
datacenterId: 0,
});
const id = minSnowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(parsed.workerId).toBe(0);
expect(parsed.datacenterId).toBe(0);
});
it('应该处理自定义epoch', () => {
const customEpoch = Date.now();
const customSnowflake = new Snowflake({
workerId: 1,
datacenterId: 1,
epoch: customEpoch,
});
const id = customSnowflake.nextId();
const parsed = Snowflake.parseId(id);
// 由于parseId使用默认epoch时间戳会有差异
expect(parsed.createdAt.getTime()).toBeLessThan(Date.now());
});
});
});

View File

@ -15,14 +15,14 @@
* @property {number} epoch - () * @property {number} epoch - ()
*/ */
export interface SnowflakeConfig { export interface SnowflakeConfig {
/** 工作机器ID范围0-31 */ /** 工作机器ID范围0-31 */
workerId: number; workerId: number;
/** 数据中心ID范围0-31 */ /** 数据中心ID范围0-31 */
datacenterId: number; datacenterId: number;
/** 序列号起始值默认0 */ /** 序列号起始值默认0 */
sequence?: number; sequence?: number;
/** 起始时间戳默认2020-01-01 00:00:00 UTC */ /** 起始时间戳默认2020-01-01 00:00:00 UTC */
epoch?: number; epoch?: number;
} }
/** /**
@ -30,169 +30,173 @@ export interface SnowflakeConfig {
* 64ID(41) + ID(5) + ID(5) + (12) * 64ID(41) + ID(5) + ID(5) + (12)
*/ */
export class Snowflake { export class Snowflake {
/** 工作机器ID位数 */ /** 工作机器ID位数 */
private static readonly WORKER_ID_BITS = 5; private static readonly WORKER_ID_BITS = 5;
/** 数据中心ID位数 */ /** 数据中心ID位数 */
private static readonly DATACENTER_ID_BITS = 5; private static readonly DATACENTER_ID_BITS = 5;
/** 序列号位数 */ /** 序列号位数 */
private static readonly SEQUENCE_BITS = 12; private static readonly SEQUENCE_BITS = 12;
/** 最大工作机器ID */ /** 最大工作机器ID */
private static readonly MAX_WORKER_ID = (1 << Snowflake.WORKER_ID_BITS) - 1; private static readonly MAX_WORKER_ID = (1 << Snowflake.WORKER_ID_BITS) - 1;
/** 最大数据中心ID */ /** 最大数据中心ID */
private static readonly MAX_DATACENTER_ID = (1 << Snowflake.DATACENTER_ID_BITS) - 1; private static readonly MAX_DATACENTER_ID = (1 << Snowflake.DATACENTER_ID_BITS) - 1;
/** 最大序列号 */ /** 最大序列号 */
private static readonly MAX_SEQUENCE = (1 << Snowflake.SEQUENCE_BITS) - 1; private static readonly MAX_SEQUENCE = (1 << Snowflake.SEQUENCE_BITS) - 1;
/** 工作机器ID左移位数 */ /** 工作机器ID左移位数 */
private static readonly WORKER_ID_SHIFT = Snowflake.SEQUENCE_BITS; private static readonly WORKER_ID_SHIFT = Snowflake.SEQUENCE_BITS;
/** 数据中心ID左移位数 */ /** 数据中心ID左移位数 */
private static readonly DATACENTER_ID_SHIFT = Snowflake.SEQUENCE_BITS + Snowflake.WORKER_ID_BITS; private static readonly DATACENTER_ID_SHIFT = Snowflake.SEQUENCE_BITS + Snowflake.WORKER_ID_BITS;
/** 时间戳左移位数 */ /** 时间戳左移位数 */
private static readonly TIMESTAMP_LEFT_SHIFT = Snowflake.SEQUENCE_BITS + Snowflake.WORKER_ID_BITS + Snowflake.DATACENTER_ID_BITS; private static readonly TIMESTAMP_LEFT_SHIFT =
Snowflake.SEQUENCE_BITS + Snowflake.WORKER_ID_BITS + Snowflake.DATACENTER_ID_BITS;
/** 工作机器ID */ /** 工作机器ID */
private readonly workerId: number; private readonly workerId: number;
/** 数据中心ID */ /** 数据中心ID */
private readonly datacenterId: number; private readonly datacenterId: number;
/** 起始时间戳 */ /** 起始时间戳 */
private readonly epoch: number; private readonly epoch: number;
/** 当前序列号 */ /** 当前序列号 */
private sequence: number; private sequence: number;
/** 上次生成ID的时间戳 */ /** 上次生成ID的时间戳 */
private lastTimestamp: number; private lastTimestamp: number;
/** /**
* *
* @param config ID配置 * @param config ID配置
* @throws {Error} workerId或datacenterId超出范围时抛出错误 * @throws {Error} workerId或datacenterId超出范围时抛出错误
*/ */
constructor(config: SnowflakeConfig) { constructor(config: SnowflakeConfig) {
// 验证workerId范围 // 验证workerId范围
if (config.workerId < 0 || config.workerId > Snowflake.MAX_WORKER_ID) { if (config.workerId < 0 || config.workerId > Snowflake.MAX_WORKER_ID) {
throw new Error(`Worker ID must be between 0 and ${Snowflake.MAX_WORKER_ID}`); throw new Error(`Worker ID must be between 0 and ${Snowflake.MAX_WORKER_ID}`);
}
// 验证datacenterId范围
if (config.datacenterId < 0 || config.datacenterId > Snowflake.MAX_DATACENTER_ID) {
throw new Error(`Datacenter ID must be between 0 and ${Snowflake.MAX_DATACENTER_ID}`);
}
this.workerId = config.workerId;
this.datacenterId = config.datacenterId;
this.sequence = config.sequence || 0;
this.epoch = config.epoch || 1577836800000; // 2020-01-01 00:00:00 UTC
this.lastTimestamp = -1;
} }
// 验证datacenterId范围 /**
if (config.datacenterId < 0 || config.datacenterId > Snowflake.MAX_DATACENTER_ID) { * ID
throw new Error(`Datacenter ID must be between 0 and ${Snowflake.MAX_DATACENTER_ID}`); * @returns {bigint} 64ID
* @throws {Error}
*/
public nextId(): bigint {
let timestamp = this.getCurrentTimestamp();
// 检查时钟回拨
if (timestamp < this.lastTimestamp) {
const timeDiff = this.lastTimestamp - timestamp;
throw new Error(`Clock moved backwards. Refusing to generate id for ${timeDiff} milliseconds`);
}
// 如果是同一毫秒内,递增序列号
if (timestamp === this.lastTimestamp) {
this.sequence = (this.sequence + 1) & Snowflake.MAX_SEQUENCE;
// 如果序列号溢出,等待下一毫秒
if (this.sequence === 0) {
timestamp = this.waitNextMillis(this.lastTimestamp);
}
} else {
// 不同毫秒,重置序列号
this.sequence = 0;
}
this.lastTimestamp = timestamp;
// 生成雪花ID
const id =
(BigInt(timestamp - this.epoch) << BigInt(Snowflake.TIMESTAMP_LEFT_SHIFT)) |
(BigInt(this.datacenterId) << BigInt(Snowflake.DATACENTER_ID_SHIFT)) |
(BigInt(this.workerId) << BigInt(Snowflake.WORKER_ID_SHIFT)) |
BigInt(this.sequence);
return id;
} }
this.workerId = config.workerId; /**
this.datacenterId = config.datacenterId; * ID
this.sequence = config.sequence || 0; * @param id ID
this.epoch = config.epoch || 1577836800000; // 2020-01-01 00:00:00 UTC * @returns {object}
this.lastTimestamp = -1; */
} public static parseId(id: bigint): {
timestamp: number;
datacenterId: number;
workerId: number;
sequence: number;
createdAt: Date;
} {
const timestamp = Number((id >> BigInt(Snowflake.TIMESTAMP_LEFT_SHIFT)) & BigInt((1 << 41) - 1));
const datacenterId = Number(
(id >> BigInt(Snowflake.DATACENTER_ID_SHIFT)) & BigInt(Snowflake.MAX_DATACENTER_ID),
);
const workerId = Number((id >> BigInt(Snowflake.WORKER_ID_SHIFT)) & BigInt(Snowflake.MAX_WORKER_ID));
const sequence = Number(id & BigInt(Snowflake.MAX_SEQUENCE));
/** // 计算创建时间使用默认epoch
* ID const epoch = 1577836800000; // 2020-01-01 00:00:00 UTC
* @returns {bigint} 64ID const createdAt = new Date(epoch + timestamp);
* @throws {Error}
*/
public nextId(): bigint {
let timestamp = this.getCurrentTimestamp();
// 检查时钟回拨 return {
if (timestamp < this.lastTimestamp) { timestamp,
const timeDiff = this.lastTimestamp - timestamp; datacenterId,
throw new Error(`Clock moved backwards. Refusing to generate id for ${timeDiff} milliseconds`); workerId,
sequence,
createdAt,
};
} }
// 如果是同一毫秒内,递增序列号 /**
if (timestamp === this.lastTimestamp) { *
this.sequence = (this.sequence + 1) & Snowflake.MAX_SEQUENCE; * @returns {number}
*/
// 如果序列号溢出,等待下一毫秒 private getCurrentTimestamp(): number {
if (this.sequence === 0) { return Date.now();
timestamp = this.waitNextMillis(this.lastTimestamp);
}
} else {
// 不同毫秒,重置序列号
this.sequence = 0;
} }
this.lastTimestamp = timestamp; /**
*
// 生成雪花ID * @param lastTimestamp
const id = ((BigInt(timestamp - this.epoch) << BigInt(Snowflake.TIMESTAMP_LEFT_SHIFT)) | * @returns {number}
(BigInt(this.datacenterId) << BigInt(Snowflake.DATACENTER_ID_SHIFT)) | */
(BigInt(this.workerId) << BigInt(Snowflake.WORKER_ID_SHIFT)) | private waitNextMillis(lastTimestamp: number): number {
BigInt(this.sequence)); let timestamp = this.getCurrentTimestamp();
while (timestamp <= lastTimestamp) {
return id; timestamp = this.getCurrentTimestamp();
} }
return timestamp;
/**
* ID
* @param id ID
* @returns {object}
*/
public static parseId(id: bigint): {
timestamp: number;
datacenterId: number;
workerId: number;
sequence: number;
createdAt: Date;
} {
const timestamp = Number((id >> BigInt(Snowflake.TIMESTAMP_LEFT_SHIFT)) & BigInt((1 << 41) - 1));
const datacenterId = Number((id >> BigInt(Snowflake.DATACENTER_ID_SHIFT)) & BigInt(Snowflake.MAX_DATACENTER_ID));
const workerId = Number((id >> BigInt(Snowflake.WORKER_ID_SHIFT)) & BigInt(Snowflake.MAX_WORKER_ID));
const sequence = Number(id & BigInt(Snowflake.MAX_SEQUENCE));
// 计算创建时间使用默认epoch
const epoch = 1577836800000; // 2020-01-01 00:00:00 UTC
const createdAt = new Date(epoch + timestamp);
return {
timestamp,
datacenterId,
workerId,
sequence,
createdAt,
};
}
/**
*
* @returns {number}
*/
private getCurrentTimestamp(): number {
return Date.now();
}
/**
*
* @param lastTimestamp
* @returns {number}
*/
private waitNextMillis(lastTimestamp: number): number {
let timestamp = this.getCurrentTimestamp();
while (timestamp <= lastTimestamp) {
timestamp = this.getCurrentTimestamp();
} }
return timestamp;
}
/** /**
* *
* @returns {object} * @returns {object}
*/ */
public getConfig(): { public getConfig(): {
workerId: number; workerId: number;
datacenterId: number; datacenterId: number;
sequence: number; sequence: number;
epoch: number; epoch: number;
lastTimestamp: number; lastTimestamp: number;
} { } {
return { return {
workerId: this.workerId, workerId: this.workerId,
datacenterId: this.datacenterId, datacenterId: this.datacenterId,
sequence: this.sequence, sequence: this.sequence,
epoch: this.epoch, epoch: this.epoch,
lastTimestamp: this.lastTimestamp, lastTimestamp: this.lastTimestamp,
}; };
} }
} }
/** /**
@ -207,15 +211,15 @@ let snowflakeInstance: Snowflake | null = null;
* @returns {Snowflake} ID生成器实例 * @returns {Snowflake} ID生成器实例
*/ */
export function getSnowflake(config?: Partial<SnowflakeConfig>): Snowflake { export function getSnowflake(config?: Partial<SnowflakeConfig>): Snowflake {
if (!snowflakeInstance) { if (!snowflakeInstance) {
const defaultConfig: SnowflakeConfig = { const defaultConfig: SnowflakeConfig = {
workerId: 1, workerId: 1,
datacenterId: 1, datacenterId: 1,
...config, ...config,
}; };
snowflakeInstance = new Snowflake(defaultConfig); snowflakeInstance = new Snowflake(defaultConfig);
} }
return snowflakeInstance; return snowflakeInstance;
} }
/** /**
@ -223,7 +227,7 @@ export function getSnowflake(config?: Partial<SnowflakeConfig>): Snowflake {
* @returns {bigint} 64ID * @returns {bigint} 64ID
*/ */
export function nextId(): bigint { export function nextId(): bigint {
return getSnowflake().nextId(); return getSnowflake().nextId();
} }
/** /**
@ -232,7 +236,7 @@ export function nextId(): bigint {
* @returns {object} * @returns {object}
*/ */
export function parseId(id: bigint) { export function parseId(id: bigint) {
return Snowflake.parseId(id); return Snowflake.parseId(id);
} }
/** /**
@ -241,5 +245,5 @@ export function parseId(id: bigint) {
* @returns {Snowflake} ID生成器实例 * @returns {Snowflake} ID生成器实例
*/ */
export function createSnowflake(config: SnowflakeConfig): Snowflake { export function createSnowflake(config: SnowflakeConfig): Snowflake {
return new Snowflake(config); return new Snowflake(config);
} }

View File

@ -1,380 +0,0 @@
# M2 - 基础用户系统 - 开发任务计划
## 相关文件 (Relevant Files)
### 认证模块 (Auth)
- `src/modules/auth/auth.schema.ts` - 认证模块Schema定义
- `src/modules/auth/auth.response.ts` - 认证模块响应格式定义
- `src/modules/auth/auth.service.ts` - 认证模块Service层实现
- `src/modules/auth/auth.controller.ts` - 认证模块Controller层实现
- `src/modules/auth/auth.test.md` - 认证模块测试用例文档
### 用户管理模块 (User)
- `src/modules/user/user.schema.ts` - 用户模块Schema定义已存在
- `src/modules/user/user.response.ts` - 用户模块响应格式定义(已存在)
- `src/modules/user/user.service.ts` - 用户模块Service层实现已存在
- `src/modules/user/user.controller.ts` - 用户模块Controller层实现需更新
- `src/modules/user/user.test.md` - 用户模块测试用例文档
### 角色权限模块 (Role)
- `src/modules/role/role.schema.ts` - 角色模块Schema定义
- `src/modules/role/role.response.ts` - 角色模块响应格式定义
- `src/modules/role/role.service.ts` - 角色模块Service层实现
- `src/modules/role/role.controller.ts` - 角色模块Controller层实现
- `src/modules/role/role.test.md` - 角色模块测试用例文档
### 权限管理模块 (Permission)
- `src/modules/permission/permission.schema.ts` - 权限模块Schema定义
- `src/modules/permission/permission.response.ts` - 权限模块响应格式定义
- `src/modules/permission/permission.service.ts` - 权限模块Service层实现
- `src/modules/permission/permission.controller.ts` - 权限模块Controller层实现
- `src/modules/permission/permission.test.md` - 权限模块测试用例文档
### 组织架构模块 (Organization)
- `src/modules/organization/organization.schema.ts` - 组织模块Schema定义
- `src/modules/organization/organization.response.ts` - 组织模块响应格式定义
- `src/modules/organization/organization.service.ts` - 组织模块Service层实现
- `src/modules/organization/organization.controller.ts` - 组织模块Controller层实现
- `src/modules/organization/organization.test.md` - 组织模块测试用例文档
### 系统基础模块 (System)
- `src/modules/system/dict/dict.schema.ts` - 字典模块Schema定义
- `src/modules/system/dict/dict.response.ts` - 字典模块响应格式定义
- `src/modules/system/dict/dict.service.ts` - 字典模块Service层实现
- `src/modules/system/dict/dict.controller.ts` - 字典模块Controller层实现
- `src/modules/system/dict/dict.test.md` - 字典模块测试用例文档
- `src/modules/system/tag/tag.schema.ts` - 标签模块Schema定义
- `src/modules/system/tag/tag.response.ts` - 标签模块响应格式定义
- `src/modules/system/tag/tag.service.ts` - 标签模块Service层实现
- `src/modules/system/tag/tag.controller.ts` - 标签模块Controller层实现
- `src/modules/system/tag/tag.test.md` - 标签模块测试用例文档
- `src/modules/system/log/log.schema.ts` - 日志模块Schema定义
- `src/modules/system/log/log.response.ts` - 日志模块响应格式定义
- `src/modules/system/log/log.service.ts` - 日志模块Service层实现
- `src/modules/system/log/log.controller.ts` - 日志模块Controller层实现
- `src/modules/system/log/log.test.md` - 日志模块测试用例文档
### 备注 (Notes)
- 验证码功能已有captcha模块可直接集成
- 遵循Elysia开发规范每个接口都要有完整的5个文件
- 按照PRD优先级P0 > P1 > P2 顺序开发
## 任务 (Tasks)
### 🔐 认证模块 (Auth Module) - P0优先级
- [x] 1.0 POST /auth/register - 用户注册接口
- [x] 1.1 创建auth.schema.ts - 定义用户注册Schema
- [x] 1.2 创建auth.response.ts - 定义注册响应格式
- [x] 1.3 创建auth.service.ts - 实现注册业务逻辑
- [x] 1.4 创建auth.controller.ts - 实现注册路由
- [x] 1.5 创建auth.test.md - 编写注册测试用例文档
- [x] 2.0 POST /auth/activate - 邮箱激活接口
- [x] 2.1 扩展auth.schema.ts - 定义激活Schema
- [x] 2.2 扩展auth.response.ts - 定义激活响应格式
- [x] 2.3 扩展auth.service.ts - 实现激活业务逻辑
- [x] 2.4 扩展auth.controller.ts - 实现激活路由
- [x] 2.5 扩展auth.test.md - 编写激活测试用例文档
- [x] 3.0 POST /auth/login - 用户登录接口
- [x] 3.1 扩展auth.schema.ts - 定义登录Schema
- [x] 3.2 扩展auth.response.ts - 定义登录响应格式
- [x] 3.3 扩展auth.service.ts - 实现登录业务逻辑
- [x] 3.4 扩展auth.controller.ts - 实现登录路由
- [x] 3.5 扩展auth.test.md - 编写登录测试用例文档
- [x] 4.0 POST /auth/refresh - Token刷新接口
- [x] 4.1 扩展auth.schema.ts - 定义刷新Schema
- [x] 4.2 扩展auth.response.ts - 定义刷新响应格式
- [x] 4.3 扩展auth.service.ts - 实现刷新业务逻辑
- [x] 4.4 扩展auth.controller.ts - 实现刷新路由
- [x] 4.5 扩展auth.test.md - 编写刷新测试用例文档
- ~~[ ] 5.0 POST /auth/logout - 退出登录接口~~
- ~~[ ] 5.1 扩展auth.schema.ts - 定义退出Schema~~
- ~~[ ] 5.2 扩展auth.response.ts - 定义退出响应格式~~
- ~~[ ] 5.3 扩展auth.service.ts - 实现退出业务逻辑~~
- ~~[ ] 5.4 扩展auth.controller.ts - 实现退出路由~~
- ~~[ ] 5.5 扩展auth.test.md - 编写退出测试用例文档~~
- [x] 6.0 POST /auth/password/reset-request - 找回密码接口
- [x] 6.1 扩展auth.schema.ts - 定义找回密码Schema
- [x] 6.2 扩展auth.response.ts - 定义找回密码响应格式
- [x] 6.3 扩展auth.service.ts - 实现找回密码业务逻辑
- [x] 6.4 扩展auth.controller.ts - 实现找回密码路由
- [x] 6.5 扩展auth.test.md - 编写找回密码测试用例文档
- [x] 7.0 POST /auth/password/reset-confirm - 重置密码接口
- [x] 7.1 扩展auth.schema.ts - 定义重置密码Schema
- [x] 7.2 扩展auth.response.ts - 定义重置密码响应格式
- [x] 7.3 扩展auth.service.ts - 实现重置密码业务逻辑
- [x] 7.4 扩展auth.controller.ts - 实现重置密码路由
- [x] 7.5 扩展auth.test.md - 编写重置密码测试用例文档
- [x] 8.0 GET /auth/captcha - 图形验证码接口
- [x] 8.1 扩展auth.schema.ts - 定义验证码Schema
- [x] 8.2 扩展auth.response.ts - 定义验证码响应格式
- [x] 8.3 扩展auth.service.ts - 集成验证码服务
- [x] 8.4 扩展auth.controller.ts - 实现验证码路由
- [x] 8.5 扩展auth.test.md - 编写验证码测试用例文档
- [x] 9.0 检查认证模块 (Auth Module)那些接口需要加分布式锁,并加锁
### 👤 用户管理模块 (User Module) - P0优先级
- [x] 9.0 GET /users/me - 获取当前用户信息接口
- [x] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [x] 9.1 扩展user.schema.ts - 定义当前用户Schema
- [x] 9.2 扩展user.response.ts - 定义当前用户响应格式
- [x] 9.3 扩展user.service.ts - 实现当前用户业务逻辑
- [x] 9.4 更新user.controller.ts - 实现当前用户路由
- [x] 9.5 创建user.test.md - 编写当前用户测试用例文档
- [x] 10.0 GET /users - 用户列表查询接口
- [x] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [x] 10.1 扩展user.schema.ts - 定义用户列表Schema
- [x] 10.2 扩展user.response.ts - 定义用户列表响应格式
- [x] 10.3 扩展user.service.ts - 实现用户列表业务逻辑
- [x] 10.4 扩展user.controller.ts - 实现用户列表路由
- [x] 10.5 扩展user.test.md - 编写用户列表测试用例文档
- [ ] 11.0 POST /users - 创建用户接口
- [ ] 0.0 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 11.1 扩展user.schema.ts - 定义创建用户Schema
- [ ] 11.2 扩展user.response.ts - 定义创建用户响应格式
- [ ] 11.3 扩展user.service.ts - 实现创建用户业务逻辑
- [ ] 11.4 扩展user.controller.ts - 实现创建用户路由
- [ ] 11.5 扩展user.test.md - 编写创建用户测试用例文档
- [ ] 12.0 PUT /users/{id} - 更新用户信息接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 12.1 扩展user.schema.ts - 定义更新用户Schema
- [ ] 12.2 扩展user.response.ts - 定义更新用户响应格式
- [ ] 12.3 扩展user.service.ts - 实现更新用户业务逻辑
- [ ] 12.4 扩展user.controller.ts - 实现更新用户路由
- [ ] 12.5 扩展user.test.md - 编写更新用户测试用例文档
- [ ] 13.0 DELETE /users/{id} - 删除用户接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 13.1 扩展user.schema.ts - 定义删除用户Schema
- [ ] 13.2 扩展user.response.ts - 定义删除用户响应格式
- [ ] 13.3 扩展user.service.ts - 实现删除用户业务逻辑
- [ ] 13.4 扩展user.controller.ts - 实现删除用户路由
- [ ] 13.5 扩展user.test.md - 编写删除用户测试用例文档
- [ ] 14.0 PUT /users/me/password - 修改密码接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 14.1 扩展user.schema.ts - 定义修改密码Schema
- [ ] 14.2 扩展user.response.ts - 定义修改密码响应格式
- [ ] 14.3 扩展user.service.ts - 实现修改密码业务逻辑
- [ ] 14.4 扩展user.controller.ts - 实现修改密码路由
- [ ] 14.5 扩展user.test.md - 编写修改密码测试用例文档
- [ ] 15.0 GET /users/{id} - 用户详情接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 15.1 扩展user.schema.ts - 定义用户详情Schema
- [ ] 15.2 扩展user.response.ts - 定义用户详情响应格式
- [ ] 15.3 扩展user.service.ts - 实现用户详情业务逻辑
- [ ] 15.4 扩展user.controller.ts - 实现用户详情路由
- [ ] 15.5 扩展user.test.md - 编写用户详情测试用例文档
- [ ] 16.0 POST /users/batch - 批量操作接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 16.1 扩展user.schema.ts - 定义批量操作Schema
- [ ] 16.2 扩展user.response.ts - 定义批量操作响应格式
- [ ] 16.3 扩展user.service.ts - 实现批量操作业务逻辑
- [ ] 16.4 扩展user.controller.ts - 实现批量操作路由
- [ ] 16.5 扩展user.test.md - 编写批量操作测试用例文档
### 🎭 角色权限模块 (Role Module) - P0优先级
- [ ] 17.0 GET /roles - 角色列表接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 17.1 创建role.schema.ts - 定义角色Schema
- [ ] 17.2 创建role.response.ts - 定义角色响应格式
- [ ] 17.3 创建role.service.ts - 实现角色业务逻辑
- [ ] 17.4 创建role.controller.ts - 实现角色路由
- [ ] 17.5 创建role.test.md - 编写角色测试用例文档
- [ ] 18.0 POST /roles - 创建角色接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 18.1 扩展role.schema.ts - 定义创建角色Schema
- [ ] 18.2 扩展role.response.ts - 定义创建角色响应格式
- [ ] 18.3 扩展role.service.ts - 实现创建角色业务逻辑
- [ ] 18.4 扩展role.controller.ts - 实现创建角色路由
- [ ] 18.5 扩展role.test.md - 编写创建角色测试用例文档
- [ ] 19.0 PUT /roles/{id} - 更新角色接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 19.1 扩展role.schema.ts - 定义更新角色Schema
- [ ] 19.2 扩展role.response.ts - 定义更新角色响应格式
- [ ] 19.3 扩展role.service.ts - 实现更新角色业务逻辑
- [ ] 19.4 扩展role.controller.ts - 实现更新角色路由
- [ ] 19.5 扩展role.test.md - 编写更新角色测试用例文档
- [ ] 20.0 DELETE /roles/{id} - 删除角色接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 20.1 扩展role.schema.ts - 定义删除角色Schema
- [ ] 20.2 扩展role.response.ts - 定义删除角色响应格式
- [ ] 20.3 扩展role.service.ts - 实现删除角色业务逻辑
- [ ] 20.4 扩展role.controller.ts - 实现删除角色路由
- [ ] 20.5 扩展role.test.md - 编写删除角色测试用例文档
- [ ] 21.0 GET /permissions - 权限列表接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 21.1 创建permission.schema.ts - 定义权限Schema
- [ ] 21.2 创建permission.response.ts - 定义权限响应格式
- [ ] 21.3 创建permission.service.ts - 实现权限业务逻辑
- [ ] 21.4 创建permission.controller.ts - 实现权限路由
- [ ] 21.5 创建permission.test.md - 编写权限测试用例文档
- [ ] 22.0 POST /roles/{id}/permissions - 权限分配接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 22.1 扩展role.schema.ts - 定义权限分配Schema
- [ ] 22.2 扩展role.response.ts - 定义权限分配响应格式
- [ ] 22.3 扩展role.service.ts - 实现权限分配业务逻辑
- [ ] 22.4 扩展role.controller.ts - 实现权限分配路由
- [ ] 22.5 扩展role.test.md - 编写权限分配测试用例文档
- [ ] 23.0 POST /users/{id}/roles - 用户角色分配接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 23.1 扩展user.schema.ts - 定义用户角色分配Schema
- [ ] 23.2 扩展user.response.ts - 定义用户角色分配响应格式
- [ ] 23.3 扩展user.service.ts - 实现用户角色分配业务逻辑
- [ ] 23.4 扩展user.controller.ts - 实现用户角色分配路由
- [ ] 23.5 扩展user.test.md - 编写用户角色分配测试用例文档
### 🏢 组织架构模块 (Organization Module) - P1优先级
- [ ] 24.0 GET /organizations - 组织列表接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 24.1 创建organization.schema.ts - 定义组织Schema
- [ ] 24.2 创建organization.response.ts - 定义组织响应格式
- [ ] 24.3 创建organization.service.ts - 实现组织业务逻辑
- [ ] 24.4 创建organization.controller.ts - 实现组织路由
- [ ] 24.5 创建organization.test.md - 编写组织测试用例文档
- [ ] 25.0 POST /organizations - 创建组织接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 25.1 扩展organization.schema.ts - 定义创建组织Schema
- [ ] 25.2 扩展organization.response.ts - 定义创建组织响应格式
- [ ] 25.3 扩展organization.service.ts - 实现创建组织业务逻辑
- [ ] 25.4 扩展organization.controller.ts - 实现创建组织路由
- [ ] 25.5 扩展organization.test.md - 编写创建组织测试用例文档
- [ ] 26.0 PUT /organizations/{id} - 更新组织接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 26.1 扩展organization.schema.ts - 定义更新组织Schema
- [ ] 26.2 扩展organization.response.ts - 定义更新组织响应格式
- [ ] 26.3 扩展organization.service.ts - 实现更新组织业务逻辑
- [ ] 26.4 扩展organization.controller.ts - 实现更新组织路由
- [ ] 26.5 扩展organization.test.md - 编写更新组织测试用例文档
- [ ] 27.0 DELETE /organizations/{id} - 删除组织接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 27.1 扩展organization.schema.ts - 定义删除组织Schema
- [ ] 27.2 扩展organization.response.ts - 定义删除组织响应格式
- [ ] 27.3 扩展organization.service.ts - 实现删除组织业务逻辑
- [ ] 27.4 扩展organization.controller.ts - 实现删除组织路由
- [ ] 27.5 扩展organization.test.md - 编写删除组织测试用例文档
- [ ] 28.0 POST /users/{id}/organizations - 用户组织关系接口
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 28.1 扩展user.schema.ts - 定义用户组织关系Schema
- [ ] 28.2 扩展user.response.ts - 定义用户组织关系响应格式
- [ ] 28.3 扩展user.service.ts - 实现用户组织关系业务逻辑
- [ ] 28.4 扩展user.controller.ts - 实现用户组织关系路由
- [ ] 28.5 扩展user.test.md - 编写用户组织关系测试用例文档
### 🗂️ 系统基础模块 (System Module) - P1优先级
- [ ] 29.0 字典类型管理 - CRUD /dict-types
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 29.1 创建dict.schema.ts - 定义字典类型Schema
- [ ] 29.2 创建dict.response.ts - 定义字典类型响应格式
- [ ] 29.3 创建dict.service.ts - 实现字典类型业务逻辑
- [ ] 29.4 创建dict.controller.ts - 实现字典类型路由
- [ ] 29.5 创建dict.test.md - 编写字典类型测试用例文档
- [ ] 30.0 字典项管理 - CRUD /dict-items
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 30.1 扩展dict.schema.ts - 定义字典项Schema
- [ ] 30.2 扩展dict.response.ts - 定义字典项响应格式
- [ ] 30.3 扩展dict.service.ts - 实现字典项业务逻辑
- [ ] 30.4 扩展dict.controller.ts - 实现字典项路由
- [ ] 30.5 扩展dict.test.md - 编写字典项测试用例文档
- [ ] 31.0 标签管理 - CRUD /tags
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 31.1 创建tag.schema.ts - 定义标签Schema
- [ ] 31.2 创建tag.response.ts - 定义标签响应格式
- [ ] 31.3 创建tag.service.ts - 实现标签业务逻辑
- [ ] 31.4 创建tag.controller.ts - 实现标签路由
- [ ] 31.5 创建tag.test.md - 编写标签测试用例文档
- [ ] 32.0 操作日志 - GET /logs/operations
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 32.1 创建log.schema.ts - 定义操作日志Schema
- [ ] 32.2 创建log.response.ts - 定义操作日志响应格式
- [ ] 32.3 创建log.service.ts - 实现操作日志业务逻辑
- [ ] 32.4 创建log.controller.ts - 实现操作日志路由
- [ ] 32.5 创建log.test.md - 编写操作日志测试用例文档
- [ ] 33.0 登录日志 - GET /logs/logins
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 33.1 扩展log.schema.ts - 定义登录日志Schema
- [ ] 33.2 扩展log.response.ts - 定义登录日志响应格式
- [ ] 33.3 扩展log.service.ts - 实现登录日志业务逻辑
- [ ] 33.4 扩展log.controller.ts - 实现登录日志路由
- [ ] 33.5 扩展log.test.md - 编写登录日志测试用例文档
### 🔧 基础设施完善
- [ ] 34.0 JWT认证中间件
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 34.1 创建JWT认证插件
- [ ] 34.2 实现Token黑名单管理
- [ ] 34.3 实现RefreshToken机制
- [ ] 34.4 集成权限验证中间件
- [ ] 34.5 编写认证中间件测试
- [ ] 35.0 路由模块集成
- [ ] Before 整理输入此接口的逻辑必须等待用户确认后进行需要输入go才能进行下一步
- [ ] 35.1 更新src/modules/index.ts - 集成所有模块
- [ ] 35.2 更新src/app.ts - 注册所有路由
- [ ] 35.3 更新Swagger标签定义
- [ ] 35.4 完善API文档
- [ ] 35.5 集成测试验证
## 开发优先级说明
### 第一阶段P0基础认证和用户管理
- **认证模块**用户注册、激活、登录、刷新、退出任务1-5
- **用户管理模块**当前用户、用户列表、用户CRUD、密码管理任务9-14
- **完成目标**:具备基本的用户认证和管理功能
### 第二阶段P0角色权限系统
- **角色管理**角色CRUD、权限分配任务17-20
- **权限管理**权限列表、权限分配、用户角色分配任务21-23
- **完成目标**具备完整的RBAC权限控制体系
### 第三阶段P1扩展功能
- **密码管理**找回密码、重置密码任务6-7
- **验证码系统**图形验证码任务8
- **用户扩展**用户详情、批量操作任务15-16
- **组织架构**组织管理、用户组织关系任务24-28
### 第四阶段P1-P2系统完善
- **系统基础**字典、标签、日志管理任务29-33
- **基础设施**JWT中间件、路由集成任务34-35
- **完成目标**:系统功能完整,可投入生产使用
## 备注说明
1. **已完成部分**用户模块的Schema、Response、Service已基本完成可直接使用
2. **验证码集成**现有captcha模块可直接集成到认证流程中
3. **开发规范**严格按照Elysia开发规范每个接口都要有完整的5个子任务
4. **测试要求**:每个模块都要有完整的测试用例文档,确保功能正确性
5. **优先级管理**按照P0 > P1 > P2的顺序开发确保核心功能优先完成

View File

@ -1,3 +1,3 @@
1. 协助ai完成接口 1. 协助ai完成接口
2. 协助ai完成测试用例 2. 协助ai完成测试用例
3. 优化mdc关于drizzle和redis的使用 3. 优化mdc关于drizzle和redis的使用

View File

@ -3,9 +3,11 @@
## 1. 项目概述 ## 1. 项目概述
### 1.1 项目背景 ### 1.1 项目背景
基于星撰个人综合平台的M2阶段需求开发完整的基础用户系统。该系统将为后续的博客系统、个人空间、阅读系统等功能模块提供统一的用户管理和权限控制基础。 基于星撰个人综合平台的M2阶段需求开发完整的基础用户系统。该系统将为后续的博客系统、个人空间、阅读系统等功能模块提供统一的用户管理和权限控制基础。
### 1.2 项目目标 ### 1.2 项目目标
- 构建安全可靠的用户认证和授权体系 - 构建安全可靠的用户认证和授权体系
- 实现灵活的角色和权限管理 - 实现灵活的角色和权限管理
- 提供完整的用户生命周期管理 - 提供完整的用户生命周期管理
@ -13,7 +15,9 @@
- 为后续功能模块提供统一的用户服务 - 为后续功能模块提供统一的用户服务
### 1.3 项目范围 ### 1.3 项目范围
本次开发包含以下核心模块: 本次开发包含以下核心模块:
- **认证模块**:注册、登录、激活、密码管理 - **认证模块**:注册、登录、激活、密码管理
- **用户管理模块**用户CRUD、信息管理、状态管理 - **用户管理模块**用户CRUD、信息管理、状态管理
- **角色权限模块**:角色管理、权限分配、权限验证 - **角色权限模块**:角色管理、权限分配、权限验证
@ -25,61 +29,67 @@
### 2.1 核心功能清单 ### 2.1 核心功能清单
#### 2.1.1 认证模块 (Auth) #### 2.1.1 认证模块 (Auth)
| 功能 | 优先级 | 状态 | 接口 |
|------|--------|------|------| | 功能 | 优先级 | 状态 | 接口 |
| 用户注册 | P0 | 待开发 | POST /auth/register | | ---------- | ------ | ------ | --------------------------------- |
| 邮箱激活 | P0 | 待开发 | POST /auth/activate | | 用户注册 | P0 | 待开发 | POST /auth/register |
| 用户登录 | P0 | 待开发 | POST /auth/login | | 邮箱激活 | P0 | 待开发 | POST /auth/activate |
| Token刷新 | P0 | 待开发 | POST /auth/refresh | | 用户登录 | P0 | 待开发 | POST /auth/login |
| 退出登录 | P0 | 待开发 | POST /auth/logout | | Token刷新 | P0 | 待开发 | POST /auth/refresh |
| 找回密码 | P1 | 待开发 | POST /auth/password/reset-request | | 退出登录 | P0 | 待开发 | POST /auth/logout |
| 重置密码 | P1 | 待开发 | POST /auth/password/reset-confirm | | 找回密码 | P1 | 待开发 | POST /auth/password/reset-request |
| 图形验证码 | P1 | 待开发 | GET /auth/captcha | | 重置密码 | P1 | 待开发 | POST /auth/password/reset-confirm |
| 图形验证码 | P1 | 待开发 | GET /auth/captcha |
#### 2.1.2 用户管理模块 (User) #### 2.1.2 用户管理模块 (User)
| 功能 | 优先级 | 状态 | 接口 |
|------|--------|------|------| | 功能 | 优先级 | 状态 | 接口 |
| 获取当前用户信息 | P0 | 待开发 | GET /users/me | | ---------------- | ------ | ------ | ---------------------- |
| 用户列表查询 | P0 | 待开发 | GET /users | | 获取当前用户信息 | P0 | 待开发 | GET /users/me |
| 创建用户 | P0 | 待开发 | POST /users | | 用户列表查询 | P0 | 待开发 | GET /users |
| 更新用户信息 | P0 | 待开发 | PUT /users/{id} | | 创建用户 | P0 | 待开发 | POST /users |
| 删除用户 | P1 | 待开发 | DELETE /users/{id} | | 更新用户信息 | P0 | 待开发 | PUT /users/{id} |
| 修改密码 | P0 | 待开发 | PUT /users/me/password | | 删除用户 | P1 | 待开发 | DELETE /users/{id} |
| 用户详情 | P1 | 待开发 | GET /users/{id} | | 修改密码 | P0 | 待开发 | PUT /users/me/password |
| 批量操作 | P2 | 待开发 | POST /users/batch | | 用户详情 | P1 | 待开发 | GET /users/{id} |
| 批量操作 | P2 | 待开发 | POST /users/batch |
#### 2.1.3 角色权限模块 (Role & Permission) #### 2.1.3 角色权限模块 (Role & Permission)
| 功能 | 优先级 | 状态 | 接口 |
|------|--------|------|------| | 功能 | 优先级 | 状态 | 接口 |
| 角色列表 | P0 | 待开发 | GET /roles | | ------------ | ------ | ------ | ---------------------------- |
| 创建角色 | P0 | 待开发 | POST /roles | | 角色列表 | P0 | 待开发 | GET /roles |
| 更新角色 | P0 | 待开发 | PUT /roles/{id} | | 创建角色 | P0 | 待开发 | POST /roles |
| 删除角色 | P1 | 待开发 | DELETE /roles/{id} | | 更新角色 | P0 | 待开发 | PUT /roles/{id} |
| 权限列表 | P0 | 待开发 | GET /permissions | | 删除角色 | P1 | 待开发 | DELETE /roles/{id} |
| 权限分配 | P0 | 待开发 | POST /roles/{id}/permissions | | 权限列表 | P0 | 待开发 | GET /permissions |
| 用户角色分配 | P0 | 待开发 | POST /users/{id}/roles | | 权限分配 | P0 | 待开发 | POST /roles/{id}/permissions |
| 用户角色分配 | P0 | 待开发 | POST /users/{id}/roles |
#### 2.1.4 组织架构模块 (Organization) #### 2.1.4 组织架构模块 (Organization)
| 功能 | 优先级 | 状态 | 接口 |
|------|--------|------|------| | 功能 | 优先级 | 状态 | 接口 |
| 组织列表 | P1 | 待开发 | GET /organizations | | ------------ | ------ | ------ | ------------------------------ |
| 创建组织 | P1 | 待开发 | POST /organizations | | 组织列表 | P1 | 待开发 | GET /organizations |
| 更新组织 | P1 | 待开发 | PUT /organizations/{id} | | 创建组织 | P1 | 待开发 | POST /organizations |
| 删除组织 | P2 | 待开发 | DELETE /organizations/{id} | | 更新组织 | P1 | 待开发 | PUT /organizations/{id} |
| 用户组织关系 | P1 | 待开发 | POST /users/{id}/organizations | | 删除组织 | P2 | 待开发 | DELETE /organizations/{id} |
| 用户组织关系 | P1 | 待开发 | POST /users/{id}/organizations |
#### 2.1.5 系统基础模块 (System) #### 2.1.5 系统基础模块 (System)
| 功能 | 优先级 | 状态 | 接口 |
|------|--------|------|------| | 功能 | 优先级 | 状态 | 接口 |
| 字典类型管理 | P1 | 待开发 | CRUD /dict-types | | ------------ | ------ | ------ | -------------------- |
| 字典项管理 | P1 | 待开发 | CRUD /dict-items | | 字典类型管理 | P1 | 待开发 | CRUD /dict-types |
| 标签管理 | P1 | 待开发 | CRUD /tags | | 字典项管理 | P1 | 待开发 | CRUD /dict-items |
| 操作日志 | P2 | 待开发 | GET /logs/operations | | 标签管理 | P1 | 待开发 | CRUD /tags |
| 登录日志 | P2 | 待开发 | GET /logs/logins | | 操作日志 | P2 | 待开发 | GET /logs/operations |
| 登录日志 | P2 | 待开发 | GET /logs/logins |
### 2.2 技术架构 ### 2.2 技术架构
#### 2.2.1 后端架构 #### 2.2.1 后端架构
- **框架**Elysia (Bun) - **框架**Elysia (Bun)
- **数据库**MySQL + Drizzle ORM - **数据库**MySQL + Drizzle ORM
- **缓存**Redis - **缓存**Redis
@ -89,11 +99,13 @@
- **API文档**Swagger - **API文档**Swagger
#### 2.2.2 数据存储策略 #### 2.2.2 数据存储策略
- **主数据**MySQL存储 - **主数据**MySQL存储
- **缓存数据**Redis存储用户会话、验证码、权限缓存 - **缓存数据**Redis存储用户会话、验证码、权限缓存
- **文件存储**:本地存储 + 后续可扩展OSS - **文件存储**:本地存储 + 后续可扩展OSS
#### 2.2.3 安全策略 #### 2.2.3 安全策略
- **密码加密**bcrypt (cost=12) - **密码加密**bcrypt (cost=12)
- **JWT策略**AccessToken(2小时) + RefreshToken(7天) - **JWT策略**AccessToken(2小时) + RefreshToken(7天)
- **权限控制**RBAC模型 - **权限控制**RBAC模型
@ -105,9 +117,11 @@
### 3.1 阶段划分 ### 3.1 阶段划分
#### 第一阶段:基础认证系统 (1-2周) #### 第一阶段:基础认证系统 (1-2周)
**目标**:完成用户注册、登录、基础用户管理功能 **目标**:完成用户注册、登录、基础用户管理功能
**核心任务** **核心任务**
- 数据库表结构完善 - 数据库表结构完善
- 用户注册/登录接口 - 用户注册/登录接口
- JWT认证中间件 - JWT认证中间件
@ -115,14 +129,17 @@
- 单元测试编写 - 单元测试编写
**交付物** **交付物**
- 可工作的用户注册登录系统 - 可工作的用户注册登录系统
- 完整的API文档 - 完整的API文档
- 基础测试用例 - 基础测试用例
#### 第二阶段:角色权限系统 (1-2周) #### 第二阶段:角色权限系统 (1-2周)
**目标**完成RBAC权限控制体系 **目标**完成RBAC权限控制体系
**核心任务** **核心任务**
- 角色管理功能 - 角色管理功能
- 权限管理功能 - 权限管理功能
- 权限验证中间件 - 权限验证中间件
@ -130,14 +147,17 @@
- 权限缓存机制 - 权限缓存机制
**交付物** **交付物**
- 完整的RBAC系统 - 完整的RBAC系统
- 权限控制中间件 - 权限控制中间件
- 管理员操作界面接口 - 管理员操作界面接口
#### 第三阶段:系统完善 (1周) #### 第三阶段:系统完善 (1周)
**目标**:完善系统功能,优化性能 **目标**:完善系统功能,优化性能
**核心任务** **核心任务**
- 组织架构管理 - 组织架构管理
- 字典标签管理 - 字典标签管理
- 操作日志记录 - 操作日志记录
@ -145,34 +165,38 @@
- 安全加固 - 安全加固
**交付物** **交付物**
- 完整的用户系统 - 完整的用户系统
- 性能测试报告 - 性能测试报告
- 部署文档 - 部署文档
### 3.2 里程碑计划 ### 3.2 里程碑计划
| 里程碑 | 时间节点 | 主要交付物 | | 里程碑 | 时间节点 | 主要交付物 |
|--------|----------|------------| | ------ | -------- | ------------------ |
| M2.1 | 第1周末 | 基础认证功能完成 | | M2.1 | 第1周末 | 基础认证功能完成 |
| M2.2 | 第2周末 | 用户管理功能完成 | | M2.2 | 第2周末 | 用户管理功能完成 |
| M2.3 | 第3周末 | 角色权限功能完成 | | M2.3 | 第3周末 | 角色权限功能完成 |
| M2.4 | 第4周末 | 系统完善发布v1.0 | | M2.4 | 第4周末 | 系统完善发布v1.0 |
## 4. 质量保障 ## 4. 质量保障
### 4.1 测试策略 ### 4.1 测试策略
- **单元测试**:覆盖率 > 80% - **单元测试**:覆盖率 > 80%
- **集成测试**:关键业务流程测试 - **集成测试**:关键业务流程测试
- **性能测试**并发用户1000+ - **性能测试**并发用户1000+
- **安全测试**OWASP Top 10检查 - **安全测试**OWASP Top 10检查
### 4.2 代码质量 ### 4.2 代码质量
- **代码规范**ESLint + Prettier - **代码规范**ESLint + Prettier
- **类型安全**TypeScript严格模式 - **类型安全**TypeScript严格模式
- **代码审查**Pull Request必须审查 - **代码审查**Pull Request必须审查
- **文档完善**:接口文档、部署文档 - **文档完善**:接口文档、部署文档
### 4.3 监控告警 ### 4.3 监控告警
- **API监控**:响应时间、错误率 - **API监控**:响应时间、错误率
- **数据库监控**:连接数、慢查询 - **数据库监控**:连接数、慢查询
- **缓存监控**Redis状态监控 - **缓存监控**Redis状态监控
@ -181,34 +205,39 @@
## 5. 风险评估 ## 5. 风险评估
### 5.1 技术风险 ### 5.1 技术风险
| 风险 | 概率 | 影响 | 应对策略 |
|------|------|------|----------| | 风险 | 概率 | 影响 | 应对策略 |
| 数据库性能瓶颈 | 中 | 高 | 索引优化、查询优化 | | -------------- | ---- | ---- | ---------------------------- |
| JWT安全性问题 | 低 | 高 | RefreshToken轮转、黑名单机制 | | 数据库性能瓶颈 | 中 | 高 | 索引优化、查询优化 |
| 并发数据一致性 | 中 | 中 | 乐观锁、Redis锁 | | JWT安全性问题 | 低 | 高 | RefreshToken轮转、黑名单机制 |
| 并发数据一致性 | 中 | 中 | 乐观锁、Redis锁 |
### 5.2 进度风险 ### 5.2 进度风险
| 风险 | 概率 | 影响 | 应对策略 |
|------|------|------|----------| | 风险 | 概率 | 影响 | 应对策略 |
| 需求变更 | 中 | 中 | 敏捷开发、版本控制 | | ------------ | ---- | ---- | ------------------------ |
| 技术难点 | 低 | 中 | 技术预研、备选方案 | | 需求变更 | 中 | 中 | 敏捷开发、版本控制 |
| 测试时间不足 | 中 | 高 | 并行开发测试、自动化测试 | | 技术难点 | 低 | 中 | 技术预研、备选方案 |
| 测试时间不足 | 中 | 高 | 并行开发测试、自动化测试 |
## 6. 成功标准 ## 6. 成功标准
### 6.1 功能指标 ### 6.1 功能指标
- ✅ 用户注册成功率 > 95% - ✅ 用户注册成功率 > 95%
- ✅ 登录响应时间 < 200ms - ✅ 登录响应时间 < 200ms
- ✅ 权限验证准确率 = 100% - ✅ 权限验证准确率 = 100%
- ✅ 密码安全强度符合规范 - ✅ 密码安全强度符合规范
### 6.2 技术指标 ### 6.2 技术指标
- ✅ API可用性 > 99.9% - ✅ API可用性 > 99.9%
- ✅ 单元测试覆盖率 > 80% - ✅ 单元测试覆盖率 > 80%
- ✅ 代码质量评分 > 8.0 - ✅ 代码质量评分 > 8.0
- ✅ 安全漏洞 = 0 - ✅ 安全漏洞 = 0
### 6.3 用户体验指标 ### 6.3 用户体验指标
- ✅ 注册流程步骤 ≤ 3步 - ✅ 注册流程步骤 ≤ 3步
- ✅ 忘记密码找回成功率 > 90% - ✅ 忘记密码找回成功率 > 90%
- ✅ 用户反馈满意度 > 85% - ✅ 用户反馈满意度 > 85%
@ -216,19 +245,22 @@
## 7. 后续规划 ## 7. 后续规划
### 7.1 功能扩展 ### 7.1 功能扩展
- OAuth第三方登录微信、GitHub等 - OAuth第三方登录微信、GitHub等
- 两步验证2FA - 两步验证2FA
- 单点登录SSO - 单点登录SSO
- 用户行为分析 - 用户行为分析
### 7.2 性能优化 ### 7.2 性能优化
- 分库分表策略 - 分库分表策略
- 缓存策略优化 - 缓存策略优化
- CDN接入 - CDN接入
- 数据库读写分离 - 数据库读写分离
### 7.3 运维支持 ### 7.3 运维支持
- 容器化部署 - 容器化部署
- 监控体系完善 - 监控体系完善
- 自动化运维 - 自动化运维
- 灾备方案 - 灾备方案

View File

@ -0,0 +1,390 @@
# M2 - 基础用户系统 - 开发任务计划
## 相关文件 (Relevant Files)
### 认证模块 (Auth)
- `src/modules/auth/auth.schema.ts` - 认证模块Schema定义
- `src/modules/auth/auth.response.ts` - 认证模块响应格式定义
- `src/modules/auth/auth.service.ts` - 认证模块Service层实现
- `src/modules/auth/auth.controller.ts` - 认证模块Controller层实现
- `src/modules/auth/auth.test.md` - 认证模块测试用例文档
### 用户管理模块 (User)
- `src/modules/user/user.schema.ts` - 用户模块Schema定义已存在
- `src/modules/user/user.response.ts` - 用户模块响应格式定义(已存在)
- `src/modules/user/user.service.ts` - 用户模块Service层实现已存在
- `src/modules/user/user.controller.ts` - 用户模块Controller层实现需更新
- `src/modules/user/user.test.md` - 用户模块测试用例文档
### 角色权限模块 (Role)
- `src/modules/role/role.schema.ts` - 角色模块Schema定义
- `src/modules/role/role.response.ts` - 角色模块响应格式定义
- `src/modules/role/role.service.ts` - 角色模块Service层实现
- `src/modules/role/role.controller.ts` - 角色模块Controller层实现
- `src/modules/role/role.test.md` - 角色模块测试用例文档
### 权限管理模块 (Permission)
- `src/modules/permission/permission.schema.ts` - 权限模块Schema定义
- `src/modules/permission/permission.response.ts` - 权限模块响应格式定义
- `src/modules/permission/permission.service.ts` - 权限模块Service层实现
- `src/modules/permission/permission.controller.ts` - 权限模块Controller层实现
- `src/modules/permission/permission.test.md` - 权限模块测试用例文档
### 组织架构模块 (Organization)
- `src/modules/organization/organization.schema.ts` - 组织模块Schema定义
- `src/modules/organization/organization.response.ts` - 组织模块响应格式定义
- `src/modules/organization/organization.service.ts` - 组织模块Service层实现
- `src/modules/organization/organization.controller.ts` - 组织模块Controller层实现
- `src/modules/organization/organization.test.md` - 组织模块测试用例文档
### 系统基础模块 (System)
- `src/modules/system/dict/dict.schema.ts` - 字典模块Schema定义
- `src/modules/system/dict/dict.response.ts` - 字典模块响应格式定义
- `src/modules/system/dict/dict.service.ts` - 字典模块Service层实现
- `src/modules/system/dict/dict.controller.ts` - 字典模块Controller层实现
- `src/modules/system/dict/dict.test.md` - 字典模块测试用例文档
- `src/modules/system/tag/tag.schema.ts` - 标签模块Schema定义
- `src/modules/system/tag/tag.response.ts` - 标签模块响应格式定义
- `src/modules/system/tag/tag.service.ts` - 标签模块Service层实现
- `src/modules/system/tag/tag.controller.ts` - 标签模块Controller层实现
- `src/modules/system/tag/tag.test.md` - 标签模块测试用例文档
- `src/modules/system/log/log.schema.ts` - 日志模块Schema定义
- `src/modules/system/log/log.response.ts` - 日志模块响应格式定义
- `src/modules/system/log/log.service.ts` - 日志模块Service层实现
- `src/modules/system/log/log.controller.ts` - 日志模块Controller层实现
- `src/modules/system/log/log.test.md` - 日志模块测试用例文档
### 备注 (Notes)
- 验证码功能已有captcha模块可直接集成
- 遵循Elysia开发规范每个接口都要有完整的5个文件
- 按照PRD优先级P0 > P1 > P2 顺序开发
## 任务 (Tasks)
### 🔐 认证模块 (Auth Module) - P0优先级
- [x] 1.0 POST /auth/register - 用户注册接口
- [x] 1.1 创建auth.schema.ts - 定义用户注册Schema
- [x] 1.2 创建auth.response.ts - 定义注册响应格式
- [x] 1.3 创建auth.service.ts - 实现注册业务逻辑
- [x] 1.4 创建auth.controller.ts - 实现注册路由
- [x] 1.5 创建auth.test.md - 编写注册测试用例文档
- [x] 2.0 POST /auth/activate - 邮箱激活接口
- [x] 2.1 扩展auth.schema.ts - 定义激活Schema
- [x] 2.2 扩展auth.response.ts - 定义激活响应格式
- [x] 2.3 扩展auth.service.ts - 实现激活业务逻辑
- [x] 2.4 扩展auth.controller.ts - 实现激活路由
- [x] 2.5 扩展auth.test.md - 编写激活测试用例文档
- [x] 3.0 POST /auth/login - 用户登录接口
- [x] 3.1 扩展auth.schema.ts - 定义登录Schema
- [x] 3.2 扩展auth.response.ts - 定义登录响应格式
- [x] 3.3 扩展auth.service.ts - 实现登录业务逻辑
- [x] 3.4 扩展auth.controller.ts - 实现登录路由
- [x] 3.5 扩展auth.test.md - 编写登录测试用例文档
- [x] 4.0 POST /auth/refresh - Token刷新接口
- [x] 4.1 扩展auth.schema.ts - 定义刷新Schema
- [x] 4.2 扩展auth.response.ts - 定义刷新响应格式
- [x] 4.3 扩展auth.service.ts - 实现刷新业务逻辑
- [x] 4.4 扩展auth.controller.ts - 实现刷新路由
- [x] 4.5 扩展auth.test.md - 编写刷新测试用例文档
- ~~[ ] 5.0 POST /auth/logout - 退出登录接口~~
- ~~[ ] 5.1 扩展auth.schema.ts - 定义退出Schema~~
- ~~[ ] 5.2 扩展auth.response.ts - 定义退出响应格式~~
- ~~[ ] 5.3 扩展auth.service.ts - 实现退出业务逻辑~~
- ~~[ ] 5.4 扩展auth.controller.ts - 实现退出路由~~
- ~~[ ] 5.5 扩展auth.test.md - 编写退出测试用例文档~~
- [x] 6.0 POST /auth/password/reset-request - 找回密码接口
- [x] 6.1 扩展auth.schema.ts - 定义找回密码Schema
- [x] 6.2 扩展auth.response.ts - 定义找回密码响应格式
- [x] 6.3 扩展auth.service.ts - 实现找回密码业务逻辑
- [x] 6.4 扩展auth.controller.ts - 实现找回密码路由
- [x] 6.5 扩展auth.test.md - 编写找回密码测试用例文档
- [x] 7.0 POST /auth/password/reset-confirm - 重置密码接口
- [x] 7.1 扩展auth.schema.ts - 定义重置密码Schema
- [x] 7.2 扩展auth.response.ts - 定义重置密码响应格式
- [x] 7.3 扩展auth.service.ts - 实现重置密码业务逻辑
- [x] 7.4 扩展auth.controller.ts - 实现重置密码路由
- [x] 7.5 扩展auth.test.md - 编写重置密码测试用例文档
- [x] 8.0 GET /auth/captcha - 图形验证码接口
- [x] 8.1 扩展auth.schema.ts - 定义验证码Schema
- [x] 8.2 扩展auth.response.ts - 定义验证码响应格式
- [x] 8.3 扩展auth.service.ts - 集成验证码服务
- [x] 8.4 扩展auth.controller.ts - 实现验证码路由
- [x] 8.5 扩展auth.test.md - 编写验证码测试用例文档
- [x] 9.0 检查认证模块 (Auth Module)那些接口需要加分布式锁,并加锁
### 👤 用户管理模块 (User Module) - P0优先级
- [x] 9.0 GET /users/me - 获取当前用户信息接口
- [x] Before 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [x] 9.1 扩展user.schema.ts - 定义当前用户Schema
- [x] 9.2 扩展user.response.ts - 定义当前用户响应格式
- [x] 9.3 扩展user.service.ts - 实现当前用户业务逻辑
- [x] 9.4 更新user.controller.ts - 实现当前用户路由
- [x] 9.5 创建user.test.md - 编写当前用户测试用例文档
- [x] 10.0 GET /users - 用户列表查询接口
- [x] 10.1 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [x] 10.2 扩展user.schema.ts - 定义用户列表Schema
- [x] 10.3 扩展user.response.ts - 定义用户列表响应格式
- [x] 10.4 扩展user.service.ts - 实现用户列表业务逻辑
- [x] 10.5 扩展user.controller.ts - 实现用户列表路由
- [x] 10.6 扩展user.test.md - 编写用户列表测试用例文档
- [ ] 11.0 POST /users - 创建用户接口
- [x] 11.1 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 11.2 扩展user.schema.ts - 定义创建用户Schema
- [ ] 11.3 扩展user.response.ts - 定义创建用户响应格式
- [ ] 11.4 扩展user.service.ts - 根据当前单个接口doc.md实现创建用户业务逻辑
- [ ] 11.5 扩展user.controller.ts - 实现创建用户路由
- [ ] 11.6 扩展user.test.md - 编写创建用户测试用例文档
- [ ] 12.0 PUT /users/{id} - 更新用户信息接口
- [ ] 12.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 12.1 扩展user.schema.ts - 定义更新用户Schema
- [ ] 12.2 扩展user.response.ts - 定义更新用户响应格式
- [ ] 12.3 扩展user.service.ts - 根据当前单个接口doc.md实现更新用户业务逻辑
- [ ] 12.4 扩展user.controller.ts - 实现更新用户路由
- [ ] 12.5 扩展user.test.md - 编写更新用户测试用例文档
- [ ] 13.0 DELETE /users/{id} - 删除用户接口
- [ ] 13.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 13.1 扩展user.schema.ts - 定义删除用户Schema
- [ ] 13.2 扩展user.response.ts - 定义删除用户响应格式
- [ ] 13.3 扩展user.service.ts - 根据当前单个接口doc.md实现删除用户业务逻辑
- [ ] 13.4 扩展user.controller.ts - 实现删除用户路由
- [ ] 13.5 扩展user.test.md - 编写删除用户测试用例文档
- [ ] 14.0 PUT /users/me/password - 修改密码接口
- [ ] 14.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 14.1 扩展user.schema.ts - 定义修改密码Schema
- [ ] 14.2 扩展user.response.ts - 定义修改密码响应格式
- [ ] 14.3 扩展user.service.ts - 根据当前单个接口doc.md实现修改密码业务逻辑
- [ ] 14.4 扩展user.controller.ts - 实现修改密码路由
- [ ] 14.5 扩展user.test.md - 编写修改密码测试用例文档
- [ ] 15.0 GET /users/{id} - 用户详情接口
- [ ] 15.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 15.1 扩展user.schema.ts - 定义用户详情Schema
- [ ] 15.2 扩展user.response.ts - 定义用户详情响应格式
- [ ] 15.3 扩展user.service.ts - 根据当前单个接口doc.md实现用户详情业务逻辑
- [ ] 15.4 扩展user.controller.ts - 实现用户详情路由
- [ ] 15.5 扩展user.test.md - 编写用户详情测试用例文档
- [ ] 16.0 POST /users/batch - 批量操作接口
- [ ] 16.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 16.1 扩展user.schema.ts - 定义批量操作Schema
- [ ] 16.2 扩展user.response.ts - 定义批量操作响应格式
- [ ] 16.3 扩展user.service.ts - 根据当前单个接口doc.md实现批量操作业务逻辑
- [ ] 16.4 扩展user.controller.ts - 实现批量操作路由
- [ ] 16.5 扩展user.test.md - 编写批量操作测试用例文档
### 🎭 角色权限模块 (Role Module) - P0优先级
- [ ] 17.0 GET /roles - 角色列表接口
- [ ] 17.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 17.1 创建role.schema.ts - 定义角色Schema
- [ ] 17.2 创建role.response.ts - 定义角色响应格式
- [ ] 17.3 创建role.service.ts - 根据当前单个接口doc.md实现角色业务逻辑
- [ ] 17.4 创建role.controller.ts - 实现角色路由
- [ ] 17.5 创建role.test.md - 编写角色测试用例文档
- [ ] 18.0 POST /roles - 创建角色接口
- [ ] 18.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 18.1 扩展role.schema.ts - 定义创建角色Schema
- [ ] 18.2 扩展role.response.ts - 定义创建角色响应格式
- [ ] 18.3 扩展role.service.ts - 根据当前单个接口doc.md实现创建角色业务逻辑
- [ ] 18.4 扩展role.controller.ts - 实现创建角色路由
- [ ] 18.5 扩展role.test.md - 编写创建角色测试用例文档
- [ ] 19.0 PUT /roles/{id} - 更新角色接口
- [ ] 19.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 19.1 扩展role.schema.ts - 定义更新角色Schema
- [ ] 19.2 扩展role.response.ts - 定义更新角色响应格式
- [ ] 19.3 扩展role.service.ts - 根据当前单个接口doc.md实现更新角色业务逻辑
- [ ] 19.4 扩展role.controller.ts - 实现更新角色路由
- [ ] 19.5 扩展role.test.md - 编写更新角色测试用例文档
- [ ] 20.0 DELETE /roles/{id} - 删除角色接口
- [ ] 20.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 20.1 扩展role.schema.ts - 定义删除角色Schema
- [ ] 20.2 扩展role.response.ts - 定义删除角色响应格式
- [ ] 20.3 扩展role.service.ts - 根据当前单个接口doc.md实现删除角色业务逻辑
- [ ] 20.4 扩展role.controller.ts - 实现删除角色路由
- [ ] 20.5 扩展role.test.md - 编写删除角色测试用例文档
- [ ] 21.0 GET /permissions - 权限列表接口
- [ ] 21.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 21.1 创建permission.schema.ts - 定义权限Schema
- [ ] 21.2 创建permission.response.ts - 定义权限响应格式
- [ ] 21.3 创建permission.service.ts - 根据当前单个接口doc.md实现权限业务逻辑
- [ ] 21.4 创建permission.controller.ts - 实现权限路由
- [ ] 21.5 创建permission.test.md - 编写权限测试用例文档
- [ ] 22.0 POST /roles/{id}/permissions - 权限分配接口
- [ ] 22.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 22.1 扩展role.schema.ts - 定义权限分配Schema
- [ ] 22.2 扩展role.response.ts - 定义权限分配响应格式
- [ ] 22.3 扩展role.service.ts - 根据当前单个接口doc.md实现权限分配业务逻辑
- [ ] 22.4 扩展role.controller.ts - 实现权限分配路由
- [ ] 22.5 扩展role.test.md - 编写权限分配测试用例文档
- [ ] 23.0 POST /users/{id}/roles - 用户角色分配接口
- [ ] 23.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 23.1 扩展user.schema.ts - 定义用户角色分配Schema
- [ ] 23.2 扩展user.response.ts - 定义用户角色分配响应格式
- [ ] 23.3 扩展user.service.ts - 根据当前单个接口doc.md实现用户角色分配业务逻辑
- [ ] 23.4 扩展user.controller.ts - 实现用户角色分配路由
- [ ] 23.5 扩展user.test.md - 编写用户角色分配测试用例文档
### 🏢 组织架构模块 (Organization Module) - P1优先级
- [ ] 24.0 GET /organizations - 组织列表接口
- [ ] 24.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 24.1 创建organization.schema.ts - 定义组织Schema
- [ ] 24.2 创建organization.response.ts - 定义组织响应格式
- [ ] 24.3 创建organization.service.ts - 根据当前单个接口doc.md实现组织业务逻辑
- [ ] 24.4 创建organization.controller.ts - 实现组织路由
- [ ] 24.5 创建organization.test.md - 编写组织测试用例文档
- [ ] 25.0 POST /organizations - 创建组织接口
- [ ] 25.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 25.1 扩展organization.schema.ts - 定义创建组织Schema
- [ ] 25.2 扩展organization.response.ts - 定义创建组织响应格式
- [ ] 25.3 扩展organization.service.ts - 根据当前单个接口doc.md实现创建组织业务逻辑
- [ ] 25.4 扩展organization.controller.ts - 实现创建组织路由
- [ ] 25.5 扩展organization.test.md - 编写创建组织测试用例文档
- [ ] 26.0 PUT /organizations/{id} - 更新组织接口
- [ ] 26.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 26.1 扩展organization.schema.ts - 定义更新组织Schema
- [ ] 26.2 扩展organization.response.ts - 定义更新组织响应格式
- [ ] 26.3 扩展organization.service.ts - 根据当前单个接口doc.md实现更新组织业务逻辑
- [ ] 26.4 扩展organization.controller.ts - 实现更新组织路由
- [ ] 26.5 扩展organization.test.md - 编写更新组织测试用例文档
- [ ] 27.0 DELETE /organizations/{id} - 删除组织接口
- [ ] 27.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 27.1 扩展organization.schema.ts - 定义删除组织Schema
- [ ] 27.2 扩展organization.response.ts - 定义删除组织响应格式
- [ ] 27.3 扩展organization.service.ts - 根据当前单个接口doc.md实现删除组织业务逻辑
- [ ] 27.4 扩展organization.controller.ts - 实现删除组织路由
- [ ] 27.5 扩展organization.test.md - 编写删除组织测试用例文档
- [ ] 28.0 POST /users/{id}/organizations - 用户组织关系接口
- [ ] 28.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 28.1 扩展user.schema.ts - 定义用户组织关系Schema
- [ ] 28.2 扩展user.response.ts - 定义用户组织关系响应格式
- [ ] 28.3 扩展user.service.ts - 根据当前单个接口doc.md实现用户组织关系业务逻辑
- [ ] 28.4 扩展user.controller.ts - 实现用户组织关系路由
- [ ] 28.5 扩展user.test.md - 编写用户组织关系测试用例文档
### 🗂️ 系统基础模块 (System Module) - P1优先级
- [ ] 29.0 字典类型管理 - CRUD /dict-types
- [ ] 29.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 29.1 创建dict.schema.ts - 定义字典类型Schema
- [ ] 29.2 创建dict.response.ts - 定义字典类型响应格式
- [ ] 29.3 创建dict.service.ts - 根据当前单个接口doc.md实现字典类型业务逻辑
- [ ] 29.4 创建dict.controller.ts - 实现字典类型路由
- [ ] 29.5 创建dict.test.md - 编写字典类型测试用例文档
- [ ] 30.0 字典项管理 - CRUD /dict-items
- [ ] 30.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 30.1 扩展dict.schema.ts - 定义字典项Schema
- [ ] 30.2 扩展dict.response.ts - 定义字典项响应格式
- [ ] 30.3 扩展dict.service.ts - 根据当前单个接口doc.md实现字典项业务逻辑
- [ ] 30.4 扩展dict.controller.ts - 实现字典项路由
- [ ] 30.5 扩展dict.test.md - 编写字典项测试用例文档
- [ ] 31.0 标签管理 - CRUD /tags
- [ ] 31.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 31.1 创建tag.schema.ts - 定义标签Schema
- [ ] 31.2 创建tag.response.ts - 定义标签响应格式
- [ ] 31.3 创建tag.service.ts - 根据当前单个接口doc.md实现标签业务逻辑
- [ ] 31.4 创建tag.controller.ts - 实现标签路由
- [ ] 31.5 创建tag.test.md - 编写标签测试用例文档
- [ ] 32.0 操作日志 - GET /logs/operations
- [ ] 32.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 32.1 创建log.schema.ts - 定义操作日志Schema
- [ ] 32.2 创建log.response.ts - 定义操作日志响应格式
- [ ] 32.3 创建log.service.ts - 根据当前单个接口doc.md实现操作日志业务逻辑
- [ ] 32.4 创建log.controller.ts - 实现操作日志路由
- [ ] 32.5 创建log.test.md - 编写操作日志测试用例文档
- [ ] 33.0 登录日志 - GET /logs/logins
- [ ] 33.0 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 33.1 扩展log.schema.ts - 定义登录日志Schema
- [ ] 33.2 扩展log.response.ts - 定义登录日志响应格式
- [ ] 33.3 扩展log.service.ts - 根据当前单个接口doc.md实现登录日志业务逻辑
- [ ] 33.4 扩展log.controller.ts - 实现登录日志路由
- [ ] 33.5 扩展log.test.md - 编写登录日志测试用例文档
### 🔧 基础设施完善
- [ ] 34.0 JWT认证中间件
- [ ] Before 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 34.1 创建JWT认证插件
- [ ] 34.2 实现Token黑名单管理
- [ ] 34.3 实现RefreshToken机制
- [ ] 34.4 集成权限验证中间件
- [ ] 34.5 编写认证中间件测试
- [ ] 35.0 路由模块集成
- [ ] Before 整理当前接口业务逻辑写入当前单个接口doc.md后续步骤按照此逻辑进行
- [ ] 35.1 更新src/modules/index.ts - 集成所有模块
- [ ] 35.2 更新src/app.ts - 注册所有路由
- [ ] 35.3 更新Swagger标签定义
- [ ] 35.4 完善API文档
- [ ] 35.5 集成测试验证
## 开发优先级说明
### 第一阶段P0基础认证和用户管理
- **认证模块**用户注册、激活、登录、刷新、退出任务1-5
- **用户管理模块**当前用户、用户列表、用户CRUD、密码管理任务9-14
- **完成目标**:具备基本的用户认证和管理功能
### 第二阶段P0角色权限系统
- **角色管理**角色CRUD、权限分配任务17-20
- **权限管理**权限列表、权限分配、用户角色分配任务21-23
- **完成目标**具备完整的RBAC权限控制体系
### 第三阶段P1扩展功能
- **密码管理**找回密码、重置密码任务6-7
- **验证码系统**图形验证码任务8
- **用户扩展**用户详情、批量操作任务15-16
- **组织架构**组织管理、用户组织关系任务24-28
### 第四阶段P1-P2系统完善
- **系统基础**字典、标签、日志管理任务29-33
- **基础设施**JWT中间件、路由集成任务34-35
- **完成目标**:系统功能完整,可投入生产使用
## 备注说明
1. **已完成部分**用户模块的Schema、Response、Service已基本完成可直接使用
2. **验证码集成**现有captcha模块可直接集成到认证流程中
3. **开发规范**严格按照Elysia开发规范每个接口都要有完整的5个子任务
4. **测试要求**:每个模块都要有完整的测试用例文档,确保功能正确性
5. **优先级管理**按照P0 > P1 > P2的顺序开发确保核心功能优先完成

View File

@ -0,0 +1,196 @@
# 字典模块开发计划
## 重新设计的字典表结构
**表名**: `sys_dict`
**字段设计**:
```sql
CREATE TABLE `sys_dict` (
`id` BIGINT NOT NULL COMMENT '主键ID',
`code` VARCHAR(50) NOT NULL COMMENT '字典代码,唯一标识',
`name` VARCHAR(100) NOT NULL COMMENT '字典名称',
`value` VARCHAR(200) NULL COMMENT '字典值(叶子节点才有值)',
`description` VARCHAR(500) NULL COMMENT '字典描述',
`icon` VARCHAR(100) NULL COMMENT '图标CSS类名或图标路径',
`pid` BIGINT NULL DEFAULT 0 COMMENT '父级ID0表示顶级',
`level` INT NOT NULL DEFAULT 1 COMMENT '层级深度1为顶级',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态active-启用inactive-禁用',
`is_system` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否系统字典',
`color` VARCHAR(20) NULL COMMENT '颜色标识',
`extra` JSON NULL COMMENT '扩展字段',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_pid` (`pid`),
KEY `idx_level` (`level`),
KEY `idx_status` (`status`),
KEY `idx_sort` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统字典表';
```
## 字典管理模块接口清单
1. 创建字典项
- 默认pid为0
- 字典项不允许同名
- 等级深度不能超过10
- 默认为系统字典
2. 获取字典项内容
- 根据code或id查找字典项
3. 获取完整字典树
- 获取完整的字典树
- 可根据status、is_system等条件过滤
4. 获取指定字典树
- 通过code或id或pid查找字典树
- 可根据status、is_system等条件过滤
5. 更新字典项内容
- 通过id修改字典内容可选参数修改不是传递全部参数至少包括一个参数
6. 字典项排序
- 拖动排序
7. 删除字典项
- 软删除,防止依赖找不到
## 缓存机制
- 提供方法将整个字典写入redis缓存后续接口查询先从缓存找
## 相关文件 (Relevant Files)
- `src/eneities/sysDict.ts` - 字典表实体定义
- `src/modules/dict/dict.docs.md` - 字典模块业务逻辑文档
- `src/modules/dict/dict.schema.ts` - 字典模块Schema定义
- `src/modules/dict/dict.response.ts` - 字典模块响应格式定义
- `src/modules/dict/dict.service.ts` - 字典模块业务逻辑
- `src/modules/dict/dict.controller.ts` - 字典模块路由控制器
- `src/modules/dict/dict.test.md` - 字典模块测试用例文档
- `src/modules/dict/dict.test.ts` - 字典模块单元测试
- `src/constants/swaggerTags.ts` - 更新Swagger标签定义
### 备注 (Notes)
- 单元测试通常应放置在与它们测试的代码文件相同的目录中
- 字典模块需要支持树形结构的CRUD操作
- 需要实现Redis缓存机制提高查询性能
- 需要处理软删除逻辑,避免数据依赖问题
## 开发计划
### 阶段1数据库实体和基础架构
- [x] 1.0 创建字典表实体
- [x] 1.1 ~~创建 `src/eneities/sysDict.ts` 文件,定义字典表实体结构~~
- [x] 1.2 更新 `src/eneities/index.ts` 导出字典实体
- [x] 1.3 创建数据库迁移文件,执行字典表创建
### 阶段2字典模块核心接口开发
- [ ] 2.0 创建字典项接口 (POST /api/dict)
- [ ] 2.1 生成接口业务逻辑文档,写入 `dict.docs.md`
- [ ] 2.2 创建 `dict.schema.ts` - 定义创建字典项Schema
- [ ] 2.3 创建 `dict.response.ts` - 定义创建字典项响应格式
- [ ] 2.4 创建 `dict.service.ts` - 实现创建字典项业务逻辑
- [ ] 2.5 创建 `dict.controller.ts` - 实现创建字典项路由
- [ ] 2.6 创建 `dict.test.md` - 编写创建字典项测试用例
- [ ] 3.0 获取字典项内容接口 (GET /api/dict/:id)
- [ ] 3.1 更新 `dict.docs.md` - 添加获取字典项业务逻辑
- [ ] 3.2 扩展 `dict.schema.ts` - 定义获取字典项Schema
- [ ] 3.3 扩展 `dict.response.ts` - 定义获取字典项响应格式
- [ ] 3.4 扩展 `dict.service.ts` - 实现获取字典项业务逻辑
- [ ] 3.5 扩展 `dict.controller.ts` - 实现获取字典项路由
- [ ] 3.6 更新 `dict.test.md` - 添加获取字典项测试用例
- [ ] 4.0 获取完整字典树接口 (GET /api/dict/tree)
- [ ] 4.1 更新 `dict.docs.md` - 添加获取完整字典树业务逻辑
- [ ] 4.2 扩展 `dict.schema.ts` - 定义获取字典树查询Schema
- [ ] 4.3 扩展 `dict.response.ts` - 定义字典树响应格式
- [ ] 4.4 扩展 `dict.service.ts` - 实现获取完整字典树业务逻辑
- [ ] 4.5 扩展 `dict.controller.ts` - 实现获取完整字典树路由
- [ ] 4.6 更新 `dict.test.md` - 添加获取完整字典树测试用例
- [ ] 5.0 获取指定字典树接口 (GET /api/dict/tree/:code)
- [ ] 5.1 更新 `dict.docs.md` - 添加获取指定字典树业务逻辑
- [ ] 5.2 扩展 `dict.schema.ts` - 定义获取指定字典树Schema
- [ ] 5.3 扩展 `dict.response.ts` - 定义指定字典树响应格式
- [ ] 5.4 扩展 `dict.service.ts` - 实现获取指定字典树业务逻辑
- [ ] 5.5 扩展 `dict.controller.ts` - 实现获取指定字典树路由
- [ ] 5.6 更新 `dict.test.md` - 添加获取指定字典树测试用例
### 阶段3字典管理接口开发
- [ ] 6.0 更新字典项内容接口 (PUT /api/dict/:id)
- [ ] 6.1 更新 `dict.docs.md` - 添加更新字典项业务逻辑
- [ ] 6.2 扩展 `dict.schema.ts` - 定义更新字典项Schema
- [ ] 6.3 扩展 `dict.response.ts` - 定义更新字典项响应格式
- [ ] 6.4 扩展 `dict.service.ts` - 实现更新字典项业务逻辑
- [ ] 6.5 扩展 `dict.controller.ts` - 实现更新字典项路由
- [ ] 6.6 更新 `dict.test.md` - 添加更新字典项测试用例
- [ ] 7.0 字典项排序接口 (PUT /api/dict/sort)
- [ ] 7.1 更新 `dict.docs.md` - 添加字典项排序业务逻辑
- [ ] 7.2 扩展 `dict.schema.ts` - 定义字典项排序Schema
- [ ] 7.3 扩展 `dict.response.ts` - 定义字典项排序响应格式
- [ ] 7.4 扩展 `dict.service.ts` - 实现字典项排序业务逻辑
- [ ] 7.5 扩展 `dict.controller.ts` - 实现字典项排序路由
- [ ] 7.6 更新 `dict.test.md` - 添加字典项排序测试用例
- [ ] 8.0 删除字典项接口 (DELETE /api/dict/:id)
- [ ] 8.1 更新 `dict.docs.md` - 添加删除字典项业务逻辑(软删除)
- [ ] 8.2 扩展 `dict.schema.ts` - 定义删除字典项Schema
- [ ] 8.3 扩展 `dict.response.ts` - 定义删除字典项响应格式
- [ ] 8.4 扩展 `dict.service.ts` - 实现删除字典项业务逻辑
- [ ] 8.5 扩展 `dict.controller.ts` - 实现删除字典项路由
- [ ] 8.6 更新 `dict.test.md` - 添加删除字典项测试用例
### 阶段4缓存机制和优化
- [ ] 9.0 Redis缓存机制实现
- [ ] 9.1 更新 `dict.docs.md` - 添加缓存机制说明
- [ ] 9.2 扩展 `dict.service.ts` - 实现Redis缓存写入方法
- [ ] 9.3 扩展 `dict.service.ts` - 实现Redis缓存读取方法
- [ ] 9.4 扩展 `dict.service.ts` - 实现缓存失效机制
- [ ] 9.5 更新所有查询接口,优先从缓存读取
- [ ] 9.6 更新 `dict.test.md` - 添加缓存机制测试用例
### 阶段5集成和测试
- [ ] 10.0 模块集成和配置
- [ ] 10.1 更新 `src/modules/index.ts` - 导出字典模块
- [ ] 10.2 更新 `src/constants/swaggerTags.ts` - 添加字典模块标签
- [ ] 10.3 更新主应用文件,注册字典模块路由
- [ ] 10.4 创建 `dict.test.ts` - 实现单元测试
- [ ] 10.5 进行端到端测试验证
### 阶段6文档和部署
- [ ] 11.0 文档完善和部署准备
- [ ] 11.1 完善API文档确保Swagger正确显示
- [ ] 11.2 创建字典模块使用说明文档
- [ ] 11.3 进行性能测试和优化
- [ ] 11.4 准备部署脚本和配置
- [ ] 11.5 最终测试和验证
## 注意事项
- **每个接口的子任务执行完成后需要停下来等待用户确认进行下一步输入才能进入下一步有可能需要对AI生成的内容作出修改**
- 所有接口都需要遵循项目的编码规范和错误处理机制
- 字典模块需要支持树形结构的递归查询和操作
- 缓存机制需要考虑数据一致性和失效策略
- 软删除机制需要确保不影响现有业务逻辑

View File

@ -1,9 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": [ "lib": ["ESNext"],
"ESNext"
],
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
@ -31,22 +29,12 @@
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"paths": { "paths": {
"@/*": [ "@/*": ["src/*"],
"src/*" "@package.json": ["package.json"]
],
"@package.json": [
"package.json"
]
}, },
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": [ "include": ["src", "types/**/*.d.ts", "drizzle.config.ts"],
"src", "exclude": ["node_modules", "dist"]
"types/**/*.d.ts" }
, "drizzle.config.ts" ],
"exclude": [
"node_modules",
"dist"
]
}