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: ["**/*"]
alwaysApply: true
---

View File

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

View File

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

View File

@ -1,5 +1,5 @@
---
description: "全局规则"
description: "任务执行流程"
globs: ["**/*"]
alwaysApply: true
---
@ -44,3 +44,41 @@ alwaysApply: true
8. 完成一个子任务后提交git
9. 将已完成的任务归档到tasks的归档目录archive并修改文件名文件名前添加时间
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/
dist/
.env
bun.lockb
/logs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,3 +1,2 @@
import { relations } from "drizzle-orm/relations";
import { } from "./schema";
import { relations } from 'drizzle-orm/relations';
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 { sql } from "drizzle-orm"
import {
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", {
id: bigint({ mode: "number" }).notNull(),
typeId: bigint("type_id", { mode: "number" }).notNull(),
itemKey: varchar("item_key", { length: 50 }).notNull(),
itemValue: varchar("item_value", { length: 200 }).notNull(),
label: varchar({ length: 100 }).notNull(),
labelEn: varchar("label_en", { length: 200 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
cssClass: varchar("css_class", { length: 50 }),
color: varchar({ length: 50 }),
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'}),
},
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_key").on(table.itemKey),
index("idx_pid").on(table.pid),
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 sysDict = mysqlTable(
'sys_dict',
{
id: bigint({ mode: 'number' }).autoincrement().notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
value: varchar({ length: 200 }),
description: varchar({ length: 500 }),
icon: varchar({ length: 100 }),
pid: bigint({ mode: 'number' }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint('is_system').default(0).notNull(),
color: varchar({ length: 20 }),
extra: json(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().onUpdateNow().notNull(),
},
(table) => [
index('idx_level').on(table.level),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.sortOrder),
index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: 'sys_dict_id' }),
unique('uk_code').on(table.code),
],
);
export const sysDictTypes = mysqlTable("sys_dict_types", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint("is_system").default(0).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
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'}),
},
(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_dict_types_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysOperationLogs = mysqlTable(
'sys_operation_logs',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }),
username: varchar({ length: 100 }),
module: varchar({ length: 50 }).notNull(),
action: varchar({ length: 50 }).notNull(),
target: varchar({ length: 200 }),
targetId: bigint('target_id', { mode: 'number' }),
requestData: text('request_data'),
responseData: text('response_data'),
status: varchar({ length: 20 }).notNull(),
ip: varchar({ length: 45 }),
userAgent: varchar('user_agent', { length: 200 }),
duration: bigint({ mode: 'number' }),
errorMsg: text('error_msg'),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_ip').on(table.ip),
index('idx_module_action').on(table.module, table.action),
index('idx_status').on(table.status),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }),
username: varchar({ length: 100 }),
module: varchar({ length: 50 }).notNull(),
action: varchar({ length: 50 }).notNull(),
target: varchar({ length: 200 }),
targetId: bigint("target_id", { mode: "number" }),
requestData: text("request_data"),
responseData: text("response_data"),
status: varchar({ length: 20 }).notNull(),
ip: varchar({ length: 45 }),
userAgent: varchar("user_agent", { length: 200 }),
duration: bigint({ mode: "number" }),
errorMsg: text("error_msg"),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_ip").on(table.ip),
index("idx_module_action").on(table.module, table.action),
index("idx_status").on(table.status),
index("idx_target").on(table.targetId),
index("idx_user_id").on(table.userId),
primaryKey({ columns: [table.id], name: "sys_operation_logs_id"}),
]);
export const sysOrganizations = mysqlTable(
'sys_organizations',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 200 }).notNull(),
fullName: varchar('full_name', { length: 200 }),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
type: varchar({ length: 20 }),
status: varchar({ length: 20 }).default('active').notNull(),
sortOrder: int('sort_order').default(0).notNull(),
leaderId: bigint('leader_id', { mode: 'number' }),
address: varchar({ length: 200 }),
phone: varchar({ length: 50 }),
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_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", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 200 }).notNull(),
fullName: varchar("full_name", { length: 200 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
type: varchar({ length: 20 }),
status: varchar({ length: 20 }).default('active').notNull(),
sortOrder: int("sort_order").default(0).notNull(),
leaderId: bigint("leader_id", { mode: "number" }),
address: varchar({ length: 200 }),
phone: varchar({ length: 50 }),
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_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 sysPermissions = mysqlTable(
'sys_permissions',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 100 }).notNull(),
type: varchar({ length: 20 }).notNull(),
resource: varchar({ length: 50 }),
action: varchar({ length: 50 }),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
meta: 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' }),
},
(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 sysPermissions = mysqlTable("sys_permissions", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 100 }).notNull(),
type: varchar({ length: 20 }).notNull(),
resource: varchar({ length: 50 }),
action: varchar({ length: 50 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
meta: 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'}),
},
(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',
{
id: bigint({ mode: 'number' }).notNull(),
roleId: bigint('role_id', { mode: 'number' }).notNull(),
permissionId: bigint('permission_id', { mode: 'number' }).notNull(),
isHalf: tinyint('is_half').default(0).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_is_half').on(table.isHalf),
index('idx_permission_id').on(table.permissionId),
index('idx_role_id').on(table.roleId),
primaryKey({ columns: [table.id], name: 'sys_role_permissions_id' }),
unique('uk_role_permission').on(table.roleId, table.permissionId),
],
);
export const sysRolePermissions = mysqlTable("sys_role_permissions", {
id: bigint({ mode: "number" }).notNull(),
roleId: bigint("role_id", { mode: "number" }).notNull(),
permissionId: bigint("permission_id", { mode: "number" }).notNull(),
isHalf: tinyint("is_half").default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_is_half").on(table.isHalf),
index("idx_permission_id").on(table.permissionId),
index("idx_role_id").on(table.roleId),
primaryKey({ columns: [table.id], name: "sys_role_permissions_id"}),
unique("uk_role_permission").on(table.roleId, table.permissionId),
]);
export const sysRoles = mysqlTable(
'sys_roles',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint('is_system').default(0).notNull(),
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", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint("is_system").default(0).notNull(),
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 sysTags = mysqlTable(
'sys_tags',
{
id: bigint({ mode: 'number' }).notNull(),
name: varchar({ length: 50 }).notNull(),
type: varchar({ length: 50 }).default('user'),
color: varchar({ length: 50 }),
description: text(),
usageCount: int('usage_count').default(0).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_name').on(table.name),
index('idx_type').on(table.type),
index('idx_usage_count').on(table.usageCount),
primaryKey({ columns: [table.id], name: 'sys_tags_id' }),
unique('uk_name_type').on(table.name, table.type, table.deletedAt),
],
);
export const sysTags = mysqlTable("sys_tags", {
id: bigint({ mode: "number" }).notNull(),
name: varchar({ length: 50 }).notNull(),
type: varchar({ length: 50 }).default('user'),
color: varchar({ length: 50 }),
description: text(),
usageCount: int("usage_count").default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
deletedAt: datetime("deleted_at", { mode: 'string'}),
},
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_name").on(table.name),
index("idx_type").on(table.type),
index("idx_usage_count").on(table.usageCount),
primaryKey({ columns: [table.id], name: "sys_tags_id"}),
unique("uk_name_type").on(table.name, table.type, table.deletedAt),
]);
export const sysUserOrganizations = mysqlTable(
'sys_user_organizations',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
organizationId: bigint('organization_id', { mode: 'number' }).notNull(),
isPrimary: tinyint('is_primary').default(0).notNull(),
position: varchar({ length: 100 }),
joinedAt: datetime('joined_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
organizationId: bigint("organization_id", { mode: "number" }).notNull(),
isPrimary: tinyint("is_primary").default(0).notNull(),
position: varchar({ length: 100 }),
joinedAt: datetime("joined_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
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 sysUserRoles = mysqlTable(
'sys_user_roles',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
roleId: bigint('role_id', { mode: 'number' }).notNull(),
expiredAt: datetime('expired_at', { mode: 'string' }),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_expired_at').on(table.expiredAt),
index('idx_role_id').on(table.roleId),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
roleId: bigint("role_id", { mode: "number" }).notNull(),
expiredAt: datetime("expired_at", { mode: 'string'}),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_expired_at").on(table.expiredAt),
index("idx_role_id").on(table.roleId),
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 sysUserTags = mysqlTable(
'sys_user_tags',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
tagId: bigint('tag_id', { mode: 'number' }).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_tag_id').on(table.tagId),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
tagId: bigint("tag_id", { mode: "number" }).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_tag_id").on(table.tagId),
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 sysUsers = mysqlTable("sys_users", {
id: bigint({ mode: "number" }).notNull(),
username: varchar({ length: 50 }).notNull(),
email: varchar({ length: 100 }).notNull(),
mobile: varchar({ length: 20 }),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
avatar: varchar({ length: 255 }),
nickname: varchar({ length: 100 }),
status: varchar({ length: 20 }).default('active').notNull(),
gender: tinyint().default(0),
// you can use { mode: 'date' }, if you want to have Date as type for this column
birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
loginCount: int("login_count").default(0).notNull(),
lastLoginAt: datetime("last_login_at", { mode: 'string'}),
lastLoginIp: varchar("last_login_ip", { length: 45 }),
failedAttempts: int("failed_attempts").default(0).notNull(),
lockedUntil: datetime("locked_until", { mode: 'string'}),
isRoot: tinyint("is_root").default(0).notNull(),
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_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),
]);
export const sysUsers = mysqlTable(
'sys_users',
{
id: bigint({ mode: 'number' }).notNull(),
username: varchar({ length: 50 }).notNull(),
email: varchar({ length: 100 }).notNull(),
mobile: varchar({ length: 20 }),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
avatar: varchar({ length: 255 }),
nickname: varchar({ length: 100 }),
status: varchar({ length: 20 }).default('active').notNull(),
gender: tinyint().default(0),
// you can use { mode: 'date' }, if you want to have Date as type for this column
birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
loginCount: int('login_count').default(0).notNull(),
lastLoginAt: datetime('last_login_at', { mode: 'string' }),
lastLoginIp: varchar('last_login_ip', { length: 45 }),
failedAttempts: int('failed_attempts').default(0).notNull(),
lockedUntil: datetime('locked_until', { mode: 'string' }),
isRoot: tinyint('is_root').default(0).notNull(),
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_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)
---
## 1. 引言
### 1.1. 文档目的
本文档是 "M2 - 基础用户系统" 阶段的 **V2 修订版**旨在提供一份详尽、准确、可直接用于开发的技术设计方案。V2 版根据反馈进行了全面重构重点增强了数据库设计的合理性、API定义的完整性并补充了关键的 `root` 用户机制和标签功能。
### 1.2. 模块范围
本设计覆盖功能点包括:
- 用户认证(注册、登录)及 `root` 超级管理员机制。
- 用户、角色、权限、组织、字典的CRUD管理支持层级结构。
- 用户标签系统。
- 基于角色的访问控制RBAC的实现。
---
## 2. 数据库设计 (Revised)
### 2.1. E-R 图 (Entity-Relationship Diagram)
*已更新,包含标签和重构后的字典表*
```mermaid
erDiagram
users {
bigint id PK
varchar(50) username UK
varchar(255) email UK
varchar(255) password_hash
varchar(20) status
datetime created_at
datetime updated_at
}
roles {
bigint id PK
varchar(50) name UK
text description
bigint parent_id FK "nullable, self-ref"
datetime created_at
datetime updated_at
}
permissions {
bigint id PK
varchar(50) action UK
varchar(50) resource UK
text description
}
organizations {
bigint id PK
varchar(100) name
text description
bigint parent_id FK "nullable, self-ref"
datetime created_at
datetime updated_at
}
dictionary_types {
bigint id PK
varchar(50) type_name UK
text description
}
dictionary_items {
bigint id PK
bigint type_id FK
varchar(50) `key`
varchar(255) value
int sort_order
}
tags {
bigint id PK
varchar(50) name UK
}
users ||--o{ user_roles : "has"
roles ||--o{ user_roles : "assigned to"
roles ||--o{ role_permissions : "has"
permissions ||--o{ role_permissions : "granted to"
users ||--o{ user_organizations : "belongs to"
organizations ||--o{ user_organizations : "has"
users ||--o{ user_tags : "has"
tags ||--o{ user_tags : "applied to"
dictionary_types ||--o{ dictionary_items : "has"
roles }o..o| roles : "is child of"
organizations }o..o| organizations : "is child of"
user_roles { bigint user_id PK,FK; bigint role_id PK,FK }
role_permissions { bigint role_id PK,FK; bigint permission_id PK,FK }
user_organizations { bigint user_id PK,FK; bigint organization_id PK,FK }
user_tags { bigint user_id PK,FK; bigint tag_id PK,FK }
```
### 2.2. 表结构定义
#### `roles` / `organizations`
- **`parent_id`**: `BIGINT NULL`, `FOREIGN KEY (parent_id) REFERENCES roles(id) ON DELETE SET NULL`。此字段用于实现树形层级结构。值为`NULL`表示为顶级角色/组织。
#### `dictionary_types` - 字典类型表
| 字段名 | 类型 | 约束 | 描述 |
|---------------|---------------|---------------------|----------------------|
| `id` | `BIGINT` | `PK`, `AI` | 类型唯一ID |
| `type_name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL`| 字典类型名(如: `user_status` |
| `description` | `TEXT` | | 类型描述 |
#### `dictionary_items` - 字典条目表
| 字段名 | 类型 | 约束 | 描述 |
|---------------|----------------|----------------------|----------------------|
| `id` | `BIGINT` | `PK`, `AI` | 条目唯一ID |
| `type_id` | `BIGINT` | `NOT NULL`, `FK` | 关联到`dictionary_types.id` |
| `key` | `VARCHAR(50)` | `NOT NULL` | 键 (程序中使用) |
| `value` | `VARCHAR(255)` | `NOT NULL` | 值 (UI中显示) |
| `sort_order` | `INT` | `DEFAULT 0` | 排序字段 |
*复合唯一键: `(type_id, key)`*
#### `tags` - 标签表
| 字段名 | 类型 | 约束 | 描述 |
|--------|---------------|---------------------|------------|
| `id` | `BIGINT` | `PK`, `AI` | 标签唯一ID |
| `name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL`| 标签名 |
#### `user_tags` - 用户标签关联表
| 字段名 | 类型 | 约束 | 描述 |
|-----------|----------|----------|------------|
| `user_id` | `BIGINT` | `PK`, `FK` | 关联用户ID |
| `tag_id` | `BIGINT` | `PK`, `FK` | 关联标签ID |
---
## 3. Root 用户与系统初始化
- **`root` 用户**:
- `root` 是一个特殊的超级管理员账户,拥有系统中所有权限。
- 此账户 **不可通过 API 创建或删除**。它应在系统首次部署时,通过数据库 **Seeding (种子填充) 脚本** 创建。
- `root` 用户的角色是固定的(如 `super_admin`),此角色同样通过 Seeding 创建,并关联所有已定义的权限。
- 任何权限校验逻辑都必须对 `root` 用户或 `super_admin` 角色开绿灯。任何API都不能降低 `root` 的权限或删除该用户。
---
## 4. API 接口设计 (Detailed)
**Base URL**: `/api/v1`
**通用错误响应结构**:
```json
{
"code": 40001, // 详细业务错误码
"message": "Validation failed: username must be at least 3 characters.",
"data": null
}
```
### 3.1. Auth - 认证接口 (`/auth`)
#### `POST /register` - 用户注册
- **权限**: Public
- **请求体 (`application/json`)**:
| 名称 | 类型 | 必填 | 校验规则 | 描述 |
|------------|--------|------|----------------------------------------------------|----------------|
| `username` | string | 是 | min:3, max:50, 字母/数字/下划线组合 | 用户名 |
| `email` | string | 是 | valid email format | 邮箱地址 |
| `password` | string | 是 | min:8, max:100, 必须包含大小写字母和数字 | 密码 |
- **成功响应 (201 Created)**:
```json
{ "code": 0, "message": "User registered successfully.", "data": { "id": 1, "username": "newuser", "email": "..." } }
```
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|----------------------------|
| 400 | 40001 | 请求参数不符合校验规则 |
| 409 | 40901 | 用户名或邮箱已被占用 |
#### `POST /login` - 用户登录
- **权限**: Public
- **请求体 (`application/json`)**:
| 名称 | 类型 | 必填 | 校验规则 | 描述 |
|------------|--------|------|------------------|------------------|
| `username` | string | 是 | - | 用户名或邮箱 |
| `password` | string | 是 | - | 密码 |
- **成功响应 (200 OK)**:
```json
{ "code": 0, "message": "Login successful.", "data": { "token": "ey..." } }
```
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------|
| 401 | 40101 | 用户名或密码错误 |
| 401 | 40102 | 账户被禁用或未激活 |
---
### 3.2. Users - 用户接口 (`/users`)
*除特殊说明外,均需认证*
#### `GET /me` - 获取当前用户信息
- **权限**: Authenticated
- **成功响应 (200 OK)**: `data` 包含当前用户详细信息、关联的角色和权限列表。
#### `GET /` - 获取用户列表
- **权限**: `read:users`
- **查询参数**:
| 名称 | 类型 | 必填 | 描述 |
|-----------|--------|------|------------------------------------|
| `page` | number | 否 | 页码, default 1 |
| `pageSize`| number | 否 | 每页数量, default 10 |
| `keyword` | string | 否 | 按用户名或邮箱模糊搜索 |
- **成功响应 (200 OK)**: `data` 包含 `items` (用户列表) 和 `pagination` (分页信息)。
#### `PUT /{userId}/status` - 更新用户状态
- **权限**: `update:user_status`
- **请求体**: `{ "status": "active" }` (status 必须是字典中 `user_status` 类型的值)
- **成功响应 (200 OK)**: 返回更新后的用户信息。
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------|
| 403 | 40301 | 试图修改 `root` 用户状态 |
| 404 | 40401 | 指定用户不存在 |
#### `POST /{userId}/tags` - 为用户打标签
- **权限**: `update:user_tags`
- **请求体**: `{ "tagIds": [1, 2, 3] }`
- **成功响应 (204 No Content)**.
---
### 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. 接口参数范围,校验
# M2 - 基础用户系统 - 详细设计 (V2)
---
## 1. 引言
### 1.1. 文档目的
本文档是 "M2 - 基础用户系统" 阶段的 **V2 修订版**旨在提供一份详尽、准确、可直接用于开发的技术设计方案。V2 版根据反馈进行了全面重构重点增强了数据库设计的合理性、API定义的完整性并补充了关键的 `root` 用户机制和标签功能。
### 1.2. 模块范围
本设计覆盖功能点包括:
- 用户认证(注册、登录)及 `root` 超级管理员机制。
- 用户、角色、权限、组织、字典的CRUD管理支持层级结构。
- 用户标签系统。
- 基于角色的访问控制RBAC的实现。
---
## 2. 数据库设计 (Revised)
### 2.1. E-R 图 (Entity-Relationship Diagram)
_已更新包含标签和重构后的字典表_
```mermaid
erDiagram
users {
bigint id PK
varchar(50) username UK
varchar(255) email UK
varchar(255) password_hash
varchar(20) status
datetime created_at
datetime updated_at
}
roles {
bigint id PK
varchar(50) name UK
text description
bigint parent_id FK "nullable, self-ref"
datetime created_at
datetime updated_at
}
permissions {
bigint id PK
varchar(50) action UK
varchar(50) resource UK
text description
}
organizations {
bigint id PK
varchar(100) name
text description
bigint parent_id FK "nullable, self-ref"
datetime created_at
datetime updated_at
}
dictionary_types {
bigint id PK
varchar(50) type_name UK
text description
}
dictionary_items {
bigint id PK
bigint type_id FK
varchar(50) `key`
varchar(255) value
int sort_order
}
tags {
bigint id PK
varchar(50) name UK
}
users ||--o{ user_roles : "has"
roles ||--o{ user_roles : "assigned to"
roles ||--o{ role_permissions : "has"
permissions ||--o{ role_permissions : "granted to"
users ||--o{ user_organizations : "belongs to"
organizations ||--o{ user_organizations : "has"
users ||--o{ user_tags : "has"
tags ||--o{ user_tags : "applied to"
dictionary_types ||--o{ dictionary_items : "has"
roles }o..o| roles : "is child of"
organizations }o..o| organizations : "is child of"
user_roles { bigint user_id PK,FK; bigint role_id PK,FK }
role_permissions { bigint role_id PK,FK; bigint permission_id PK,FK }
user_organizations { bigint user_id PK,FK; bigint organization_id PK,FK }
user_tags { bigint user_id PK,FK; bigint tag_id PK,FK }
```
### 2.2. 表结构定义
#### `roles` / `organizations`
- **`parent_id`**: `BIGINT NULL`, `FOREIGN KEY (parent_id) REFERENCES roles(id) ON DELETE SET NULL`。此字段用于实现树形层级结构。值为`NULL`表示为顶级角色/组织。
#### `dictionary_types` - 字典类型表
| 字段名 | 类型 | 约束 | 描述 |
| ------------- | ------------- | -------------------- | ------------------------------- |
| `id` | `BIGINT` | `PK`, `AI` | 类型唯一ID |
| `type_name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL` | 字典类型名(如: `user_status` |
| `description` | `TEXT` | | 类型描述 |
#### `dictionary_items` - 字典条目表
| 字段名 | 类型 | 约束 | 描述 |
| ------------ | -------------- | ---------------- | --------------------------- |
| `id` | `BIGINT` | `PK`, `AI` | 条目唯一ID |
| `type_id` | `BIGINT` | `NOT NULL`, `FK` | 关联到`dictionary_types.id` |
| `key` | `VARCHAR(50)` | `NOT NULL` | 键 (程序中使用) |
| `value` | `VARCHAR(255)` | `NOT NULL` | 值 (UI中显示) |
| `sort_order` | `INT` | `DEFAULT 0` | 排序字段 |
_复合唯一键: `(type_id, key)`_
#### `tags` - 标签表
| 字段名 | 类型 | 约束 | 描述 |
| ------ | ------------- | -------------------- | ---------- |
| `id` | `BIGINT` | `PK`, `AI` | 标签唯一ID |
| `name` | `VARCHAR(50)` | `UNIQUE`, `NOT NULL` | 标签名 |
#### `user_tags` - 用户标签关联表
| 字段名 | 类型 | 约束 | 描述 |
| --------- | -------- | ---------- | ---------- |
| `user_id` | `BIGINT` | `PK`, `FK` | 关联用户ID |
| `tag_id` | `BIGINT` | `PK`, `FK` | 关联标签ID |
---
## 3. Root 用户与系统初始化
- **`root` 用户**:
- `root` 是一个特殊的超级管理员账户,拥有系统中所有权限。
- 此账户 **不可通过 API 创建或删除**。它应在系统首次部署时,通过数据库 **Seeding (种子填充) 脚本** 创建。
- `root` 用户的角色是固定的(如 `super_admin`),此角色同样通过 Seeding 创建,并关联所有已定义的权限。
- 任何权限校验逻辑都必须对 `root` 用户或 `super_admin` 角色开绿灯。任何API都不能降低 `root` 的权限或删除该用户。
---
## 4. API 接口设计 (Detailed)
**Base URL**: `/api/v1`
**通用错误响应结构**:
```json
{
"code": 40001, // 详细业务错误码
"message": "Validation failed: username must be at least 3 characters.",
"data": null
}
```
### 3.1. Auth - 认证接口 (`/auth`)
#### `POST /register` - 用户注册
- **权限**: Public
- **请求体 (`application/json`)**:
| 名称 | 类型 | 必填 | 校验规则 | 描述 |
|------------|--------|------|----------------------------------------------------|----------------|
| `username` | string | 是 | min:3, max:50, 字母/数字/下划线组合 | 用户名 |
| `email` | string | 是 | valid email format | 邮箱地址 |
| `password` | string | 是 | min:8, max:100, 必须包含大小写字母和数字 | 密码 |
- **成功响应 (201 Created)**:
```json
{
"code": 0,
"message": "User registered successfully.",
"data": { "id": 1, "username": "newuser", "email": "..." }
}
```
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|----------------------------|
| 400 | 40001 | 请求参数不符合校验规则 |
| 409 | 40901 | 用户名或邮箱已被占用 |
#### `POST /login` - 用户登录
- **权限**: Public
- **请求体 (`application/json`)**:
| 名称 | 类型 | 必填 | 校验规则 | 描述 |
|------------|--------|------|------------------|------------------|
| `username` | string | 是 | - | 用户名或邮箱 |
| `password` | string | 是 | - | 密码 |
- **成功响应 (200 OK)**:
```json
{ "code": 0, "message": "Login successful.", "data": { "token": "ey..." } }
```
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------|
| 401 | 40101 | 用户名或密码错误 |
| 401 | 40102 | 账户被禁用或未激活 |
---
### 3.2. Users - 用户接口 (`/users`)
_除特殊说明外均需认证_
#### `GET /me` - 获取当前用户信息
- **权限**: Authenticated
- **成功响应 (200 OK)**: `data` 包含当前用户详细信息、关联的角色和权限列表。
#### `GET /` - 获取用户列表
- **权限**: `read:users`
- **查询参数**:
| 名称 | 类型 | 必填 | 描述 |
|-----------|--------|------|------------------------------------|
| `page` | number | 否 | 页码, default 1 |
| `pageSize`| number | 否 | 每页数量, default 10 |
| `keyword` | string | 否 | 按用户名或邮箱模糊搜索 |
- **成功响应 (200 OK)**: `data` 包含 `items` (用户列表) 和 `pagination` (分页信息)。
#### `PUT /{userId}/status` - 更新用户状态
- **权限**: `update:user_status`
- **请求体**: `{ "status": "active" }` (status 必须是字典中 `user_status` 类型的值)
- **成功响应 (200 OK)**: 返回更新后的用户信息。
- **异常响应**:
| HTTP 码 | 业务码 | 原因 |
|---------|----------|--------------------------|
| 403 | 40301 | 试图修改 `root` 用户状态 |
| 404 | 40401 | 指定用户不存在 |
#### `POST /{userId}/tags` - 为用户打标签
- **权限**: `update:user_tags`
- **请求体**: `{ "tagIds": [1, 2, 3] }`
- **成功响应 (204 No Content)**.
---
### 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. 基本用户系统
- 字典,维护一些常用字典:地区省份、菜单类别、组织架构类别等,为以后其他功能开发提供可拓展的属性
- 角色,角色是树形结构,可以有标签、排序等功能
- 组织架构/用户组,是树形结构,用户可以在多个组织
- 用户标签,字典中维护的一些用户属性
- 权限,包括页面权限,接口权限和数据权限等,下级角色只能从上级角色已有的权限中分配
- 用户用户基础信息用户拓展信息用户创建注册通过邮箱注册可以限制错误登录次数也可以重置可以禁用用户注销用户找回密码修改个人信息最高管理员为root账户他能做所有事情
2. 笔记/博客系统
- 笔记结构是树形的,可以增加标签
- 笔记可以发布为博客,即设置为公开内容,博客是单独的内容,修改笔记不影响博客能容,但是有绑定关系,可以更新和重新发布
- 博客可以点赞、评论、收藏,也是层级结构,可以增加标签分类
- 内容有markdown、富文本、思维导图、画板结构通过关系型数据库存储内容可以通过其他数据库存储如mongodb、es等增强搜索性能可以嵌入文件等
- 对代码和编程友好
- 可以接入aimcp等将笔记作为知识库增强实用性
3. 个人空间
- 生活日志,分享照片、视频等
- 可以评论、收藏、分享、点赞
4. 阅读
- 可以上传自己下载的书,支持多种格式
- ai解读
- 评论、批注、分享
5. 工具
- 密码记录器(同步开发浏览器插件)
- 书签记录同步器(同步开发浏览器插件)
- bilibili收藏同步
- qq音乐、网易云音乐同步
- 文件管理nas、支持在线音视频播放、媒体文件预览
- 文件对传WEBRTC、P2P、阿里云OSS
- 热点新闻捕获
- 网页离线存储做成pdf
# 个人网站策划
## 前言
我想做一个个人博客网站需要有一下功能
1. 基本用户系统
- 字典,维护一些常用字典:地区省份、菜单类别、组织架构类别等,为以后其他功能开发提供可拓展的属性
- 角色,角色是树形结构,可以有标签、排序等功能
- 组织架构/用户组,是树形结构,用户可以在多个组织
- 用户标签,字典中维护的一些用户属性
- 权限,包括页面权限,接口权限和数据权限等,下级角色只能从上级角色已有的权限中分配
- 用户用户基础信息用户拓展信息用户创建注册通过邮箱注册可以限制错误登录次数也可以重置可以禁用用户注销用户找回密码修改个人信息最高管理员为root账户他能做所有事情
2. 笔记/博客系统
- 笔记结构是树形的,可以增加标签
- 笔记可以发布为博客,即设置为公开内容,博客是单独的内容,修改笔记不影响博客能容,但是有绑定关系,可以更新和重新发布
- 博客可以点赞、评论、收藏,也是层级结构,可以增加标签分类
- 内容有markdown、富文本、思维导图、画板结构通过关系型数据库存储内容可以通过其他数据库存储如mongodb、es等增强搜索性能可以嵌入文件等
- 对代码和编程友好
- 可以接入aimcp等将笔记作为知识库增强实用性
3. 个人空间
- 生活日志,分享照片、视频等
- 可以评论、收藏、分享、点赞
4. 阅读
- 可以上传自己下载的书,支持多种格式
- ai解读
- 评论、批注、分享
5. 工具
- 密码记录器(同步开发浏览器插件)
- 书签记录同步器(同步开发浏览器插件)
- bilibili收藏同步
- qq音乐、网易云音乐同步
- 文件管理nas、支持在线音视频播放、媒体文件预览
- 文件对传WEBRTC、P2P、阿里云OSS
- 热点新闻捕获
- 网页离线存储做成pdf

View File

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

View File

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

View File

@ -1,121 +1,137 @@
# 星撰个人综合平台 - 详细开发计划 (按功能模块划分)
---
## 1. 引言
### 1.1. 文档目的
本文档基于《产品需求文档 (PRD)》和《概要设计》,旨在将项目需求拆分为具体、可执行的开发任务,并将其组织成一个以核心功能模块为导向、分阶段的开发计划。本文档是项目执行阶段的核心路线图。
### 1.2. 计划结构
本计划遵循迭代式增量开发模型,将整个项目划分为多个主要阶段。**此版本已根据 `main.md` 的功能列表进行重组**,每个阶段聚焦于一个完整的功能模块。
---
## 阶段一: 项目初始化与基础设施
**目标**: 搭建项目骨架,配置开发环境和规范,建立 CI/CD 基础流程。这是后续所有开发工作的基础。
- [ ] **1.1. 项目脚手架**: 初始化 Elysia + Bun + TypeScript 项目,配置 `tsconfig.json` 和路径别名。
- [ ] **1.2. 代码规范**: 配置 ESLint 和 Prettier确保代码风格和质量一致。
- [ ] **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`: 创建受保护路由,获取当前登录用户信息。
- [ ] **2.3. JWT与权限中间件**:
- [ ] 实现 JWT 校验中间件。
- [ ] 实现一个基础的 RBAC 权限校验中间件。
- [ ] **2.4. 字典管理 CRUD API**: 实现对数据字典类型和条目的增删改查。
- [ ] **2.5. 角色管理 CRUD API**: 实现对角色的增删改查及权限分配。
- [ ] **2.6. 组织架构 CRUD API**: 实现树形组织架构的增删改查。
- [ ] **2.7. 用户管理 CRUD API**: 实现后台对用户的增删改查和角色分配。
---
## 阶段三: 笔记与博客系统
**目标**: 构建平台的内容核心,支持从私有笔记到公开博客的完整流程,并集成搜索和社交功能。
- [ ] **3.1. 数据库设计**: 设计 `notes`, `posts`, `tags`, `categories`, `comments` 及相关关联表。
- [ ] **3.2. 笔记核心 API**:
- [ ] 实现笔记的 CRUD (创建/读取/更新/删除)。
- [ ] `GET /api/v1/notes`: 获取用户的笔记树形结构列表。
- [ ] 实现笔记的标签管理功能。
- [ ] **3.3. 博客发布流程 API**:
- [ ] `POST /api/v1/notes/:id/publish`: 从笔记一键发布为博客。
- [ ] 实现博客的 CRUD包括分类管理。
- [ ] **3.4. 公开访问 API**:
- [ ] `GET /api/v1/public/posts`: (公开) 获取博客列表(分页、分类、标签筛选)。
- [ ] `GET /api/v1/public/posts/:slug`: (公开) 获取单篇博客内容。
- [ ] **3.5. 互动功能 API**:
- [ ] 实现博客的点赞、收藏功能。
- [ ] 实现层级评论的发表和查看功能。
- [ ] **3.6. 搜索引擎集成**:
- [ ] 配置 Elasticsearch 服务。
- [ ] 编写服务将 `posts` 数据索引到 ES。
- [ ] `GET /api/v1/public/search`: 实现基于 ES 的博客全文搜索 API。
---
## 阶段四: 个人空间
**目标**: 开发一个用于记录和分享个人生活点滴的模块。
- [ ] **4.1. 数据库设计**: 设计 `life_logs` 表,支持文本、图片/视频链接、位置、心情等。
- [ ] **4.2. 生活日志 CRUD API**: 实现生活日志的增删改查。
- [ ] **4.3. 时间线 API**: `GET /api/v1/space/timeline` 按时间线获取日志列表。
- [ ] **4.4. 隐私控制**: 为日志增加隐私设置(公开/私密/好友可见)。
---
## 阶段五: 阅读系统
**目标**: 构建个人电子书库,支持在线阅读、批注和智能解读。
- [ ] **5.1. 数据库设计**: 设计 `ebooks`, `annotations` (批注) 表。
- [ ] **5.2. 电子书 API**: 实现电子书文件的上传、元数据解析和列表管理。
- [ ] **5.3. 阅读功能 API**: 实现阅读时添加/查看批注、记录和同步阅读进度的功能。
- [ ] **5.4. (选做) AI 解读 API**: `POST /api/v1/ebooks/:id/interpret` 对书籍内容进行摘要或问答。
---
## 阶段六: 综合工具集
**目标**: 集成一系列实用工具,提升平台的附加价值和用户粘性。
- [ ] **6.1. 密码与书签管理**:
- [ ] 数据库设计 `passwords`, `bookmarks` 表,关键字段加密。
- [ ] 实现密码管理器的安全 CRUD API。
- [ ] 实现书签管理器的 CRUD API。
- [ ] **6.2. 文件管理系统**:
- [ ] 数据库设计 `files` 表,存储文件元数据。
- [ ] 封装一个支持本地/OSS的对象存储服务。
- [ ] 实现文件的上传、下载、删除、重命名和目录管理 API。
- [ ] **6.3. 第三方平台同步**:
- [ ] 设计同步任务管理模块和第三方平台适配器。
- [ ] 实现 Bilibili 收藏夹同步适配器。
- [ ] 实现 QQ音乐/网易云音乐 歌单同步适配器。
- [ ] **6.4. 信息聚合工具**:
- [ ] 实现热点新闻捕获和聚合的后台任务。
- [ ] `POST /api/v1/tools/web-to-pdf`: 实现网页离线存储为 PDF 的 API。
---
## 阶段七: 高级功能与生态完善
**目标**: 引入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。
- [ ] 定期进行依赖项安全扫描和代码审计。
- [ ] 完善所有功能的开发者文档和用户手册。
# 星撰个人综合平台 - 详细开发计划 (按功能模块划分)
---
## 1. 引言
### 1.1. 文档目的
本文档基于《产品需求文档 (PRD)》和《概要设计》,旨在将项目需求拆分为具体、可执行的开发任务,并将其组织成一个以核心功能模块为导向、分阶段的开发计划。本文档是项目执行阶段的核心路线图。
### 1.2. 计划结构
本计划遵循迭代式增量开发模型,将整个项目划分为多个主要阶段。**此版本已根据 `main.md` 的功能列表进行重组**,每个阶段聚焦于一个完整的功能模块。
---
## 阶段一: 项目初始化与基础设施
**目标**: 搭建项目骨架,配置开发环境和规范,建立 CI/CD 基础流程。这是后续所有开发工作的基础。
- [ ] **1.1. 项目脚手架**: 初始化 Elysia + Bun + TypeScript 项目,配置 `tsconfig.json` 和路径别名。
- [ ] **1.2. 代码规范**: 配置 ESLint 和 Prettier确保代码风格和质量一致。
- [ ] **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`: 创建受保护路由,获取当前登录用户信息。
- [ ] **2.3. JWT与权限中间件**:
- [ ] 实现 JWT 校验中间件。
- [ ] 实现一个基础的 RBAC 权限校验中间件。
- [ ] **2.4. 字典管理 CRUD API**: 实现对数据字典类型和条目的增删改查。
- [ ] **2.5. 角色管理 CRUD API**: 实现对角色的增删改查及权限分配。
- [ ] **2.6. 组织架构 CRUD API**: 实现树形组织架构的增删改查。
- [ ] **2.7. 用户管理 CRUD API**: 实现后台对用户的增删改查和角色分配。
---
## 阶段三: 笔记与博客系统
**目标**: 构建平台的内容核心,支持从私有笔记到公开博客的完整流程,并集成搜索和社交功能。
- [ ] **3.1. 数据库设计**: 设计 `notes`, `posts`, `tags`, `categories`, `comments` 及相关关联表。
- [ ] **3.2. 笔记核心 API**:
- [ ] 实现笔记的 CRUD (创建/读取/更新/删除)。
- [ ] `GET /api/v1/notes`: 获取用户的笔记树形结构列表。
- [ ] 实现笔记的标签管理功能。
- [ ] **3.3. 博客发布流程 API**:
- [ ] `POST /api/v1/notes/:id/publish`: 从笔记一键发布为博客。
- [ ] 实现博客的 CRUD包括分类管理。
- [ ] **3.4. 公开访问 API**:
- [ ] `GET /api/v1/public/posts`: (公开) 获取博客列表(分页、分类、标签筛选)。
- [ ] `GET /api/v1/public/posts/:slug`: (公开) 获取单篇博客内容。
- [ ] **3.5. 互动功能 API**:
- [ ] 实现博客的点赞、收藏功能。
- [ ] 实现层级评论的发表和查看功能。
- [ ] **3.6. 搜索引擎集成**:
- [ ] 配置 Elasticsearch 服务。
- [ ] 编写服务将 `posts` 数据索引到 ES。
- [ ] `GET /api/v1/public/search`: 实现基于 ES 的博客全文搜索 API。
---
## 阶段四: 个人空间
**目标**: 开发一个用于记录和分享个人生活点滴的模块。
- [ ] **4.1. 数据库设计**: 设计 `life_logs` 表,支持文本、图片/视频链接、位置、心情等。
- [ ] **4.2. 生活日志 CRUD API**: 实现生活日志的增删改查。
- [ ] **4.3. 时间线 API**: `GET /api/v1/space/timeline` 按时间线获取日志列表。
- [ ] **4.4. 隐私控制**: 为日志增加隐私设置(公开/私密/好友可见)。
---
## 阶段五: 阅读系统
**目标**: 构建个人电子书库,支持在线阅读、批注和智能解读。
- [ ] **5.1. 数据库设计**: 设计 `ebooks`, `annotations` (批注) 表。
- [ ] **5.2. 电子书 API**: 实现电子书文件的上传、元数据解析和列表管理。
- [ ] **5.3. 阅读功能 API**: 实现阅读时添加/查看批注、记录和同步阅读进度的功能。
- [ ] **5.4. (选做) AI 解读 API**: `POST /api/v1/ebooks/:id/interpret` 对书籍内容进行摘要或问答。
---
## 阶段六: 综合工具集
**目标**: 集成一系列实用工具,提升平台的附加价值和用户粘性。
- [ ] **6.1. 密码与书签管理**:
- [ ] 数据库设计 `passwords`, `bookmarks` 表,关键字段加密。
- [ ] 实现密码管理器的安全 CRUD API。
- [ ] 实现书签管理器的 CRUD API。
- [ ] **6.2. 文件管理系统**:
- [ ] 数据库设计 `files` 表,存储文件元数据。
- [ ] 封装一个支持本地/OSS的对象存储服务。
- [ ] 实现文件的上传、下载、删除、重命名和目录管理 API。
- [ ] **6.3. 第三方平台同步**:
- [ ] 设计同步任务管理模块和第三方平台适配器。
- [ ] 实现 Bilibili 收藏夹同步适配器。
- [ ] 实现 QQ音乐/网易云音乐 歌单同步适配器。
- [ ] **6.4. 信息聚合工具**:
- [ ] 实现热点新闻捕获和聚合的后台任务。
- [ ] `POST /api/v1/tools/web-to-pdf`: 实现网页离线存储为 PDF 的 API。
---
## 阶段七: 高级功能与生态完善
**目标**: 引入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. 文档目的
本概要设计文档旨在为"星撰"个人综合平台项目定义一个完整的软件开发生命周期SDLC框架。它将作为项目从规划、设计、实现到部署和维护的最高指导性文件确保所有开发活动遵循标准化的软件工程最佳实践。
### 1.2. 项目概述
"星撰"是一个高度集成的个人数字化中枢,其核心功能、用户故事及非功能性需求已在《个人网站综合平台产品需求文档 (PRD)》中详细定义。本项目旨在将该PRD转化为一个稳定、可扩展、高质量的软件产品。
### 1.3. 范围
本文档覆盖范围包括:
- 项目开发模型的选定。
- 系统的高层体系结构设计。
- 各阶段(设计、开发、测试、部署)的规划和规范。
- 项目管理、风险控制和质量保证策略。
---
## 2. 项目规划与管理
### 2.1. 开发模型
本项目采用 **迭代式增量开发模型 (Iterative and Incremental Development)**
- **迭代式**: 将开发过程划分为多个短周期的迭代,每个迭代都包含需求分析、设计、编码、测试的完整流程。
- **增量式**: 每个迭代都会产出一个可测试、可交付的软件增量。产品功能将分阶段见PRD中的优先级规划逐步构建和完善。
**优势**: 此模型能够快速交付核心功能MVP及时获取反馈灵活应对需求变化并有效控制项目风险。
### 2.2. 项目里程碑
项目将按照PRD中的优先级划分为四个主要里程碑阶段
| 里程碑 | 名称 | 核心交付物 | 目标 |
|--------|--------------|------------------------------------------------------|------------------------------------|
| M1 | **核心基础 (MVP)** | 基础用户系统、笔记核心功能、基础博客发布、CI/CD流程建立 | 验证核心架构,跑通开发部署全流程 |
| M2 | **内容增强** | 完整博客系统、个人空间、基础工具、搜索引擎集成 | 完善内容生态,提升用户体验 |
| M3 | **高级功能** | 阅读管理、文件管理、AI功能集成、第三方平台同步 | 扩展平台能力,引入智能化 |
| M4 | **生态完善** | 移动端应用、浏览器插件、高级分析功能、性能深度优化 | 构建跨平台生态,实现长期稳定运营 |
### 2.3. 风险管理
| 风险类别 | 风险描述 | 可能性 | 影响 | 应对策略 |
|--------------|----------------------------------------|--------|------|------------------------------------------------------------------------|
| **技术风险** | 第三方API变更如B站、QQ音乐 | 中 | 中 | 封装Adapter层隔离变化设计优雅降级机制。 |
| | 新技术引入Bun/Elysia的未知问题 | 低 | 中 | 保持对社区的关注,构建完善的测试体系以快速发现问题。 |
| **进度风险** | 个人项目时间投入不稳定 | 高 | 高 | 严格遵循任务清单,将大任务分解为小任务,利用碎片时间完成。 |
| **安全风险** | 个人敏感数据(密码、文件)泄露 | 中 | 高 | 强制加密存储;定期进行安全扫描;代码审查时关注安全漏洞。 |
---
## 3. 系统设计
### 3.1. 系统架构
系统采用分层、模块化的架构,实现前后端分离。
```mermaid
graph TD
subgraph "客户端 (Clients)"
WebApp[Web 应用 (Vue/React)]
BrowserExt[浏览器插件]
MobileApp[移动应用 (PWA/Native)]
end
subgraph "服务端 (Backend - Elysia.js on Bun)"
API_Gateway[API 网关 / 路由层]
subgraph "核心服务层 (Core Services)"
AuthService[认证与授权服务]
UserService[用户管理服务]
ContentService[内容管理服务 (笔记/博客)]
FileService[文件管理服务]
end
subgraph "工具服务层 (Utility Services)"
ToolService[密码/书签/同步等工具服务]
end
subgraph "插件与中间件 (Plugins & Middlewares)"
Logger[日志插件]
ErrorHandler[错误处理]
JWTMiddleware[JWT认证]
Swagger[Swagger文档]
end
end
subgraph "数据与存储层 (Data & Storage)"
MySQL[(MySQL - 关系型数据)]
Redis[(Redis - 缓存/会话)]
Elasticsearch[(Elasticsearch - 全文搜索)]
ObjectStorage[对象存储 (OSS/NAS)]
end
subgraph "第三方服务 (3rd Party Services)"
EmailService[邮件服务]
OAuth[OAuth 2.0 提供商]
AI_API[AI/LLM API]
end
%% 连接关系
WebApp --> API_Gateway
BrowserExt --> API_Gateway
MobileApp --> API_Gateway
API_Gateway --> AuthService
API_Gateway --> UserService
API_Gateway --> ContentService
API_Gateway --> FileService
API_Gateway --> ToolService
AuthService <--> MySQL
UserService <--> MySQL
ContentService <--> MySQL
ContentService <--> Elasticsearch
FileService <--> ObjectStorage
ToolService <--> MySQL
AuthService --> JWTMiddleware
API_Gateway -- use --> Logger
API_Gateway -- use --> ErrorHandler
API_Gateway -- use --> JWTMiddleware
API_Gateway -- use --> Swagger
subgraph "通用依赖"
AuthService <--> Redis
UserService <--> Redis
ContentService <--> Redis
end
AuthService --> EmailService
AuthService --> OAuth
ContentService --> AI_API
```
* **客户端层**: 负责用户交互和数据展示。
* **服务端层**: 核心业务逻辑处理中心。通过API网关统一入口内部按业务垂直划分为多个服务模块。
* **数据与存储层**: 持久化存储数据,提供缓存和搜索能力。
* **第三方服务**: 集成外部服务以完成特定功能。
### 3.2. 模块划分
系统功能将按以下模块进行开发,每个模块对应`src/controllers`, `src/services`下的独立子目录。
1. **`core`**: 核心模块,包括用户、角色、权限、组织、字典等。
2. **`content`**: 内容模块,包括笔记、博客、评论、分类、标签等。
3. **`space`**: 个人空间模块,包括生活日志、社交互动。
4. **`reader`**: 阅读模块,包括电子书管理、批注。
5. **`tools`**: 工具模块,包括密码、书签、文件、同步等。
6. **`system`**: 系统模块,包括健康检查、监控、配置管理。
### 3.3. 详细设计规划
本概要设计完成后,将对每个模块进行详细设计,产出物包括:
- **数据库设计文档**: E-R图、表结构定义字段、类型、约束、索引、数据字典。
- **API接口设计文档**: 使用Swagger/OpenAPI规范定义每个端点的URL、HTTP方法、请求参数、响应体结构及错误码。
- **UI/UX设计稿**: 针对核心页面和流程,制作线框图或高保真原型。
---
## 4. 实现与编码
### 4.1. 开发环境与工具
- **IDE**: Visual Studio Code
- **语言**: TypeScript
- **运行时**: Bun.js
- **包管理器**: Bun
- **版本控制**: Git / GitHub
- **代码检查与格式化**: ESLint / Prettier
### 4.2. 编码规范
- 严格遵守项目已定义的注释、命名、代码风格规范。
- 所有业务逻辑必须在 `services` 层实现,`controllers` 层只负责路由和数据校验。
- 禁止在代码中硬编码配置项(如密钥、端口),所有配置通过环境变量或配置文件注入。
### 4.3. 版本控制策略
采用 **Gitflow工作流** 的简化版:
- `main`: 主分支,存放稳定、可发布的代码。每个里程碑完成后合并。
- `develop`: 开发分支,集成了所有已完成的功能。
- `feat/feature-name`: 功能分支,每个新功能或任务都在此分支开发,完成后合并到 `develop`
- `fix/bug-name`: Bug修复分支。
---
## 5. 测试策略
### 5.1. 测试层次
- **单元测试**: 使用 `Vitest``services``utils` 中的核心函数进行测试,目标覆盖率 > 90%。
- **接口测试 (集成测试)**: 使用 `supertest` 或Elysia的内置测试工具对每个API端点进行测试验证其与数据库、缓存的交互是否正确。
- **端到端测试 (系统测试)**: 使用 `Playwright``Cypress` 对关键用户流程(如注册登录、发布博客)进行自动化测试。
- **性能测试**: 使用 `autocannon` 对核心API进行压力测试确保其满足技术指标。
### 5.2. 测试执行
- 所有代码提交前,必须在本地通过相关单元测试和接口测试。
- CI/CD流水线将在每次向 `develop``main` 分支合并代码时,自动运行全量测试用例。测试不通过则禁止合并。
---
## 6. 部署与运维
### 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或数据库设计必须同步更新相关设计文档。
- **定期审查**: 每个里程碑结束后,进行项目复盘,评估开发流程、代码质量和系统架构,并进行优化。
- **依赖更新**: 定期(如每季度)审查并更新项目依赖,以修复潜在的安全漏洞和利用新特性。
# 星撰个人综合平台 - 软件开发概要设计
---
## 1. 引言
### 1.1. 文档目的
本概要设计文档旨在为"星撰"个人综合平台项目定义一个完整的软件开发生命周期SDLC框架。它将作为项目从规划、设计、实现到部署和维护的最高指导性文件确保所有开发活动遵循标准化的软件工程最佳实践。
### 1.2. 项目概述
"星撰"是一个高度集成的个人数字化中枢,其核心功能、用户故事及非功能性需求已在《个人网站综合平台产品需求文档 (PRD)》中详细定义。本项目旨在将该PRD转化为一个稳定、可扩展、高质量的软件产品。
### 1.3. 范围
本文档覆盖范围包括:
- 项目开发模型的选定。
- 系统的高层体系结构设计。
- 各阶段(设计、开发、测试、部署)的规划和规范。
- 项目管理、风险控制和质量保证策略。
---
## 2. 项目规划与管理
### 2.1. 开发模型
本项目采用 **迭代式增量开发模型 (Iterative and Incremental Development)**
- **迭代式**: 将开发过程划分为多个短周期的迭代,每个迭代都包含需求分析、设计、编码、测试的完整流程。
- **增量式**: 每个迭代都会产出一个可测试、可交付的软件增量。产品功能将分阶段见PRD中的优先级规划逐步构建和完善。
**优势**: 此模型能够快速交付核心功能MVP及时获取反馈灵活应对需求变化并有效控制项目风险。
### 2.2. 项目里程碑
项目将按照PRD中的优先级划分为四个主要里程碑阶段
| 里程碑 | 名称 | 核心交付物 | 目标 |
| ------ | ------------------ | ------------------------------------------------------- | -------------------------------- |
| M1 | **核心基础 (MVP)** | 基础用户系统、笔记核心功能、基础博客发布、CI/CD流程建立 | 验证核心架构,跑通开发部署全流程 |
| M2 | **内容增强** | 完整博客系统、个人空间、基础工具、搜索引擎集成 | 完善内容生态,提升用户体验 |
| M3 | **高级功能** | 阅读管理、文件管理、AI功能集成、第三方平台同步 | 扩展平台能力,引入智能化 |
| M4 | **生态完善** | 移动端应用、浏览器插件、高级分析功能、性能深度优化 | 构建跨平台生态,实现长期稳定运营 |
### 2.3. 风险管理
| 风险类别 | 风险描述 | 可能性 | 影响 | 应对策略 |
| ------------ | ---------------------------------- | ------ | ---- | ---------------------------------------------------------- |
| **技术风险** | 第三方API变更如B站、QQ音乐 | 中 | 中 | 封装Adapter层隔离变化设计优雅降级机制。 |
| | 新技术引入Bun/Elysia的未知问题 | 低 | 中 | 保持对社区的关注,构建完善的测试体系以快速发现问题。 |
| **进度风险** | 个人项目时间投入不稳定 | 高 | 高 | 严格遵循任务清单,将大任务分解为小任务,利用碎片时间完成。 |
| **安全风险** | 个人敏感数据(密码、文件)泄露 | 中 | 高 | 强制加密存储;定期进行安全扫描;代码审查时关注安全漏洞。 |
---
## 3. 系统设计
### 3.1. 系统架构
系统采用分层、模块化的架构,实现前后端分离。
```mermaid
graph TD
subgraph "客户端 (Clients)"
WebApp[Web 应用 (Vue/React)]
BrowserExt[浏览器插件]
MobileApp[移动应用 (PWA/Native)]
end
subgraph "服务端 (Backend - Elysia.js on Bun)"
API_Gateway[API 网关 / 路由层]
subgraph "核心服务层 (Core Services)"
AuthService[认证与授权服务]
UserService[用户管理服务]
ContentService[内容管理服务 (笔记/博客)]
FileService[文件管理服务]
end
subgraph "工具服务层 (Utility Services)"
ToolService[密码/书签/同步等工具服务]
end
subgraph "插件与中间件 (Plugins & Middlewares)"
Logger[日志插件]
ErrorHandler[错误处理]
JWTMiddleware[JWT认证]
Swagger[Swagger文档]
end
end
subgraph "数据与存储层 (Data & Storage)"
MySQL[(MySQL - 关系型数据)]
Redis[(Redis - 缓存/会话)]
Elasticsearch[(Elasticsearch - 全文搜索)]
ObjectStorage[对象存储 (OSS/NAS)]
end
subgraph "第三方服务 (3rd Party Services)"
EmailService[邮件服务]
OAuth[OAuth 2.0 提供商]
AI_API[AI/LLM API]
end
%% 连接关系
WebApp --> API_Gateway
BrowserExt --> API_Gateway
MobileApp --> API_Gateway
API_Gateway --> AuthService
API_Gateway --> UserService
API_Gateway --> ContentService
API_Gateway --> FileService
API_Gateway --> ToolService
AuthService <--> MySQL
UserService <--> MySQL
ContentService <--> MySQL
ContentService <--> Elasticsearch
FileService <--> ObjectStorage
ToolService <--> MySQL
AuthService --> JWTMiddleware
API_Gateway -- use --> Logger
API_Gateway -- use --> ErrorHandler
API_Gateway -- use --> JWTMiddleware
API_Gateway -- use --> Swagger
subgraph "通用依赖"
AuthService <--> Redis
UserService <--> Redis
ContentService <--> Redis
end
AuthService --> EmailService
AuthService --> OAuth
ContentService --> AI_API
```
- **客户端层**: 负责用户交互和数据展示。
- **服务端层**: 核心业务逻辑处理中心。通过API网关统一入口内部按业务垂直划分为多个服务模块。
- **数据与存储层**: 持久化存储数据,提供缓存和搜索能力。
- **第三方服务**: 集成外部服务以完成特定功能。
### 3.2. 模块划分
系统功能将按以下模块进行开发,每个模块对应`src/controllers`, `src/services`下的独立子目录。
1. **`core`**: 核心模块,包括用户、角色、权限、组织、字典等。
2. **`content`**: 内容模块,包括笔记、博客、评论、分类、标签等。
3. **`space`**: 个人空间模块,包括生活日志、社交互动。
4. **`reader`**: 阅读模块,包括电子书管理、批注。
5. **`tools`**: 工具模块,包括密码、书签、文件、同步等。
6. **`system`**: 系统模块,包括健康检查、监控、配置管理。
### 3.3. 详细设计规划
本概要设计完成后,将对每个模块进行详细设计,产出物包括:
- **数据库设计文档**: E-R图、表结构定义字段、类型、约束、索引、数据字典。
- **API接口设计文档**: 使用Swagger/OpenAPI规范定义每个端点的URL、HTTP方法、请求参数、响应体结构及错误码。
- **UI/UX设计稿**: 针对核心页面和流程,制作线框图或高保真原型。
---
## 4. 实现与编码
### 4.1. 开发环境与工具
- **IDE**: Visual Studio Code
- **语言**: TypeScript
- **运行时**: Bun.js
- **包管理器**: Bun
- **版本控制**: Git / GitHub
- **代码检查与格式化**: ESLint / Prettier
### 4.2. 编码规范
- 严格遵守项目已定义的注释、命名、代码风格规范。
- 所有业务逻辑必须在 `services` 层实现,`controllers` 层只负责路由和数据校验。
- 禁止在代码中硬编码配置项(如密钥、端口),所有配置通过环境变量或配置文件注入。
### 4.3. 版本控制策略
采用 **Gitflow工作流** 的简化版:
- `main`: 主分支,存放稳定、可发布的代码。每个里程碑完成后合并。
- `develop`: 开发分支,集成了所有已完成的功能。
- `feat/feature-name`: 功能分支,每个新功能或任务都在此分支开发,完成后合并到 `develop`
- `fix/bug-name`: Bug修复分支。
---
## 5. 测试策略
### 5.1. 测试层次
- **单元测试**: 使用 `Vitest``services``utils` 中的核心函数进行测试,目标覆盖率 > 90%。
- **接口测试 (集成测试)**: 使用 `supertest` 或Elysia的内置测试工具对每个API端点进行测试验证其与数据库、缓存的交互是否正确。
- **端到端测试 (系统测试)**: 使用 `Playwright``Cypress` 对关键用户流程(如注册登录、发布博客)进行自动化测试。
- **性能测试**: 使用 `autocannon` 对核心API进行压力测试确保其满足技术指标。
### 5.2. 测试执行
- 所有代码提交前,必须在本地通过相关单元测试和接口测试。
- CI/CD流水线将在每次向 `develop``main` 分支合并代码时,自动运行全量测试用例。测试不通过则禁止合并。
---
## 6. 部署与运维
### 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邮箱配置指南
## 🚨 问题原因
错误 `550 The "From" header is missing or invalid` 是因为QQ邮箱要求
1. **From字段的邮箱地址必须与SMTP认证用户名完全一致**
2. **From字段格式必须符合RFC5322标准**
@ -41,6 +43,7 @@ EMAIL_REPLY_TO=your_qq_email@qq.com
### 第三步:替换为你的真实信息
**示例配置:**
```env
SMTP_HOST=smtp.qq.com
SMTP_PORT=587
@ -73,16 +76,21 @@ bun run src/tests/demo/emailDemo.ts your_receive_email@example.com
## 🔧 常见问题
### Q: 为什么要用授权码而不是QQ密码
A: QQ邮箱的安全策略第三方应用必须使用授权码
### Q: 授权码在哪里生成?
A: QQ邮箱设置 → 账户 → 开启IMAP/SMTP服务时生成
### Q: 为什么From地址必须与SMTP_USER一致
A: QQ邮箱的反欺诈机制防止伪造发件人
### Q: 可以使用其他邮箱服务吗?
A: 可以修改SMTP_HOST即可
- 163邮箱`smtp.163.com`
- Gmail`smtp.gmail.com`
- 企业邮箱:根据提供商配置
@ -101,4 +109,4 @@ SMTP_PASS=替换为你的16位授权码
SMTP_FROM_EMAIL=替换为你的QQ邮箱@qq.com
SMTP_FROM_NAME=星撰系统
EMAIL_REPLY_TO=替换为你的QQ邮箱@qq.com
```
```

View File

@ -3,15 +3,11 @@
* 运行方式: bun run quick-email-test.ts your@email.com
*/
import {
initializeEmailService,
sendEmail,
closeEmailService
} from './src/plugins/email/email.service';
import { initializeEmailService, sendEmail, closeEmailService } from './src/plugins/email/email.service';
async function quickTest() {
const testEmail = process.argv[2];
if (!testEmail) {
console.log('❌ 请提供邮箱地址');
console.log('💡 使用方法: bun run quick-email-test.ts your@email.com');
@ -36,7 +32,7 @@ async function quickTest() {
<h2>🎉 </h2>
<p></p>
<p><small>发送时间: ${new Date().toLocaleString('zh-CN')}</small></p>
`
`,
});
if (result.success) {
@ -47,7 +43,6 @@ async function quickTest() {
console.log('❌ 邮件发送失败');
console.log(`💥 错误: ${result.error}`);
}
} catch (error) {
console.log('💥 执行失败:', error);
} 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';
/**
*
* @description
*/
class AuthenticationError extends Error {
constructor(message: string, code = 500) {
super(message);
this.name = 'AuthenticationError';
if (code === 401) {
this.name = 'Unauthorized';
}
}
}
/**
* Elysia应用实例
* @description

View File

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

View File

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

View File

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

View File

@ -28,9 +28,11 @@ export const tags = {
permission: 'Permission',
/** 验证码相关接口 */
captcha: 'Captcha',
/** 字典管理接口 */
dict: 'Dict',
} 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';
// 写入读取是将bigint转化为string
export const bigintString = customType({
export const bigintString = customType<{ data: string; driverData: bigint }>({
dataType() {
return 'bigint';
},
fromDriver(value) { // 数据库 -> JS
return value?.toString(); // 处理 null 值
fromDriver(value) {
// 数据库 -> JS
return value?.toString();
},
toDriver(value) { // JS -> 数据库
toDriver(value) {
// JS -> 数据库
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 { sql } from "drizzle-orm"
import { bigintString as bigint } from "./customType"
import {
mysqlTable,
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", {
id: bigint({ mode: "number" }).notNull(),
typeId: bigint("type_id", { mode: "number" }).notNull(),
itemKey: varchar("item_key", { length: 50 }).notNull(),
itemValue: varchar("item_value", { length: 200 }).notNull(),
label: varchar({ length: 100 }).notNull(),
labelEn: varchar("label_en", { length: 200 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
cssClass: varchar("css_class", { length: 50 }),
color: varchar({ length: 50 }),
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'}),
},
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_key").on(table.itemKey),
index("idx_pid").on(table.pid),
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 sysDict = mysqlTable(
'sys_dict',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
value: varchar({ length: 200 }),
description: varchar({ length: 500 }),
icon: varchar({ length: 100 }),
pid: bigint({ mode: 'number' }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint('is_system').default(0).notNull(),
color: varchar({ length: 20 }),
extra: json(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().onUpdateNow().notNull(),
},
(table) => [
index('idx_level').on(table.level),
index('idx_pid').on(table.pid),
index('idx_sort').on(table.sortOrder),
index('idx_status').on(table.status),
primaryKey({ columns: [table.id], name: 'sys_dict_id' }),
unique('uk_code').on(table.code),
],
);
export const sysDictTypes = mysqlTable("sys_dict_types", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint("is_system").default(0).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
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'}),
},
(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_dict_types_id"}),
unique("uk_code").on(table.code, table.deletedAt),
]);
export const sysOperationLogs = mysqlTable(
'sys_operation_logs',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }),
username: varchar({ length: 100 }),
module: varchar({ length: 50 }).notNull(),
action: varchar({ length: 50 }).notNull(),
target: varchar({ length: 200 }),
targetId: bigint('target_id', { mode: 'number' }),
requestData: text('request_data'),
responseData: text('response_data'),
status: varchar({ length: 20 }).notNull(),
ip: varchar({ length: 45 }),
userAgent: varchar('user_agent', { length: 200 }),
duration: bigint({ mode: 'number' }),
errorMsg: text('error_msg'),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_ip').on(table.ip),
index('idx_module_action').on(table.module, table.action),
index('idx_status').on(table.status),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }),
username: varchar({ length: 100 }),
module: varchar({ length: 50 }).notNull(),
action: varchar({ length: 50 }).notNull(),
target: varchar({ length: 200 }),
targetId: bigint("target_id", { mode: "number" }),
requestData: text("request_data"),
responseData: text("response_data"),
status: varchar({ length: 20 }).notNull(),
ip: varchar({ length: 45 }),
userAgent: varchar("user_agent", { length: 200 }),
duration: bigint({ mode: "number" }),
errorMsg: text("error_msg"),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_ip").on(table.ip),
index("idx_module_action").on(table.module, table.action),
index("idx_status").on(table.status),
index("idx_target").on(table.targetId),
index("idx_user_id").on(table.userId),
primaryKey({ columns: [table.id], name: "sys_operation_logs_id"}),
]);
export const sysOrganizations = mysqlTable(
'sys_organizations',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 200 }).notNull(),
fullName: varchar('full_name', { length: 200 }),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
type: varchar({ length: 20 }),
status: varchar({ length: 20 }).default('active').notNull(),
sortOrder: int('sort_order').default(0).notNull(),
leaderId: bigint('leader_id', { mode: 'number' }),
address: varchar({ length: 200 }),
phone: varchar({ length: 50 }),
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_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", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 200 }).notNull(),
fullName: varchar("full_name", { length: 200 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
type: varchar({ length: 20 }),
status: varchar({ length: 20 }).default('active').notNull(),
sortOrder: int("sort_order").default(0).notNull(),
leaderId: bigint("leader_id", { mode: "number" }),
address: varchar({ length: 200 }),
phone: varchar({ length: 50 }),
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_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 sysPermissions = mysqlTable(
'sys_permissions',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 100 }).notNull(),
type: varchar({ length: 20 }).notNull(),
resource: varchar({ length: 50 }),
action: varchar({ length: 50 }),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
meta: 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' }),
},
(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 sysPermissions = mysqlTable("sys_permissions", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 100 }).notNull(),
name: varchar({ length: 100 }).notNull(),
type: varchar({ length: 20 }).notNull(),
resource: varchar({ length: 50 }),
action: varchar({ length: 50 }),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
meta: 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'}),
},
(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',
{
id: bigint({ mode: 'number' }).notNull(),
roleId: bigint('role_id', { mode: 'number' }).notNull(),
permissionId: bigint('permission_id', { mode: 'number' }).notNull(),
isHalf: tinyint('is_half').default(0).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_is_half').on(table.isHalf),
index('idx_permission_id').on(table.permissionId),
index('idx_role_id').on(table.roleId),
primaryKey({ columns: [table.id], name: 'sys_role_permissions_id' }),
unique('uk_role_permission').on(table.roleId, table.permissionId),
],
);
export const sysRolePermissions = mysqlTable("sys_role_permissions", {
id: bigint({ mode: "number" }).notNull(),
roleId: bigint("role_id", { mode: "number" }).notNull(),
permissionId: bigint("permission_id", { mode: "number" }).notNull(),
isHalf: tinyint("is_half").default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_is_half").on(table.isHalf),
index("idx_permission_id").on(table.permissionId),
index("idx_role_id").on(table.roleId),
primaryKey({ columns: [table.id], name: "sys_role_permissions_id"}),
unique("uk_role_permission").on(table.roleId, table.permissionId),
]);
export const sysRoles = mysqlTable(
'sys_roles',
{
id: bigint({ mode: 'number' }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: 'number' }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int('sort_order').default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint('is_system').default(0).notNull(),
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", {
id: bigint({ mode: "number" }).notNull(),
code: varchar({ length: 50 }).notNull(),
name: varchar({ length: 100 }).notNull(),
description: text(),
pid: bigint({ mode: "number" }),
path: varchar({ length: 500 }),
level: int().default(1).notNull(),
sortOrder: int("sort_order").default(0).notNull(),
status: varchar({ length: 20 }).default('active').notNull(),
isSystem: tinyint("is_system").default(0).notNull(),
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 sysTags = mysqlTable(
'sys_tags',
{
id: bigint({ mode: 'number' }).notNull(),
name: varchar({ length: 50 }).notNull(),
type: varchar({ length: 50 }).default('user'),
color: varchar({ length: 50 }),
description: text(),
usageCount: int('usage_count').default(0).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
deletedAt: datetime('deleted_at', { mode: 'string' }),
},
(table) => [
index('idx_deleted_at').on(table.deletedAt),
index('idx_name').on(table.name),
index('idx_type').on(table.type),
index('idx_usage_count').on(table.usageCount),
primaryKey({ columns: [table.id], name: 'sys_tags_id' }),
unique('uk_name_type').on(table.name, table.type, table.deletedAt),
],
);
export const sysTags = mysqlTable("sys_tags", {
id: bigint({ mode: "number" }).notNull(),
name: varchar({ length: 50 }).notNull(),
type: varchar({ length: 50 }).default('user'),
color: varchar({ length: 50 }),
description: text(),
usageCount: int("usage_count").default(0).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
deletedAt: datetime("deleted_at", { mode: 'string'}),
},
(table) => [
index("idx_deleted_at").on(table.deletedAt),
index("idx_name").on(table.name),
index("idx_type").on(table.type),
index("idx_usage_count").on(table.usageCount),
primaryKey({ columns: [table.id], name: "sys_tags_id"}),
unique("uk_name_type").on(table.name, table.type, table.deletedAt),
]);
export const sysUserOrganizations = mysqlTable(
'sys_user_organizations',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
organizationId: bigint('organization_id', { mode: 'number' }).notNull(),
isPrimary: tinyint('is_primary').default(0).notNull(),
position: varchar({ length: 100 }),
joinedAt: datetime('joined_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
organizationId: bigint("organization_id", { mode: "number" }).notNull(),
isPrimary: tinyint("is_primary").default(0).notNull(),
position: varchar({ length: 100 }),
joinedAt: datetime("joined_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
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 sysUserRoles = mysqlTable(
'sys_user_roles',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
roleId: bigint('role_id', { mode: 'number' }).notNull(),
expiredAt: datetime('expired_at', { mode: 'string' }),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_expired_at').on(table.expiredAt),
index('idx_role_id').on(table.roleId),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
roleId: bigint("role_id", { mode: "number" }).notNull(),
expiredAt: datetime("expired_at", { mode: 'string'}),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_expired_at").on(table.expiredAt),
index("idx_role_id").on(table.roleId),
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 sysUserTags = mysqlTable(
'sys_user_tags',
{
id: bigint({ mode: 'number' }).notNull(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
tagId: bigint('tag_id', { mode: 'number' }).notNull(),
createdBy: bigint('created_by', { mode: 'number' }),
createdAt: datetime('created_at', { mode: 'string' })
.default(sql`(CURRENT_TIMESTAMP)`)
.notNull(),
},
(table) => [
index('idx_created_at').on(table.createdAt),
index('idx_tag_id').on(table.tagId),
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", {
id: bigint({ mode: "number" }).notNull(),
userId: bigint("user_id", { mode: "number" }).notNull(),
tagId: bigint("tag_id", { mode: "number" }).notNull(),
createdBy: bigint("created_by", { mode: "number" }),
createdAt: datetime("created_at", { mode: 'string'}).default(sql`(CURRENT_TIMESTAMP)`).notNull(),
},
(table) => [
index("idx_created_at").on(table.createdAt),
index("idx_tag_id").on(table.tagId),
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 sysUsers = mysqlTable("sys_users", {
id: bigint({ mode: "number" }).notNull(),
username: varchar({ length: 50 }).notNull(),
email: varchar({ length: 100 }).notNull(),
mobile: varchar({ length: 20 }),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
avatar: varchar({ length: 255 }),
nickname: varchar({ length: 100 }),
status: varchar({ length: 20 }).default('active').notNull(),
gender: tinyint().default(0),
// you can use { mode: 'date' }, if you want to have Date as type for this column
birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
loginCount: int("login_count").default(0).notNull(),
lastLoginAt: datetime("last_login_at", { mode: 'string'}),
lastLoginIp: varchar("last_login_ip", { length: 45 }),
failedAttempts: int("failed_attempts").default(0).notNull(),
lockedUntil: datetime("locked_until", { mode: 'string'}),
isRoot: tinyint("is_root").default(0).notNull(),
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_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),
]);
export const sysUsers = mysqlTable(
'sys_users',
{
id: bigint({ mode: 'number' }).notNull(),
username: varchar({ length: 50 }).notNull(),
email: varchar({ length: 100 }).notNull(),
mobile: varchar({ length: 20 }),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
avatar: varchar({ length: 255 }),
nickname: varchar({ length: 100 }),
status: varchar({ length: 20 }).default('active').notNull(),
gender: tinyint().default(0),
// you can use { mode: 'date' }, if you want to have Date as type for this column
birthday: date({ mode: 'string' }),
bio: varchar({ length: 500 }),
loginCount: int('login_count').default(0).notNull(),
lastLoginAt: datetime('last_login_at', { mode: 'string' }),
lastLoginIp: varchar('last_login_ip', { length: 45 }),
failedAttempts: int('failed_attempts').default(0).notNull(),
lockedUntil: datetime('locked_until', { mode: 'string' }),
isRoot: tinyint('is_root').default(0).notNull(),
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_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层实现
* @author AI Assistant
* @author AI Assistant
* @date 2024-12-19
* @lastEditor AI Assistant
* @lastEditTime 2025-01-07
@ -8,8 +8,22 @@
*/
import { Elysia } from 'elysia';
import { RegisterSchema, ActivateSchema, LoginSchema, RefreshSchema, ResetPasswordRequestSchema, ResetPasswordConfirmSchema } from './auth.schema';
import { RegisterResponsesSchema, ActivateResponsesSchema, LoginResponsesSchema, RefreshResponsesSchema, ResetPasswordRequestResponsesSchema, ResetPasswordConfirmResponsesSchema } from './auth.response';
import {
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 { tags } from '@/constants/swaggerTags';
@ -25,21 +39,17 @@ export const authController = new Elysia()
* @param body RegisterRequest
* @returns RegisterSuccessResponse | RegisterErrorResponse
*/
.post(
'/register',
({ body, set }) => authService.register(body),
{
body: RegisterSchema,
detail: {
summary: '用户注册',
description: '用户注册接口,需要提供用户名、邮箱、密码和验证码',
tags: [tags.auth],
operationId: 'registerUser',
},
response: RegisterResponsesSchema,
}
)
.post('/register', ({ body }) => authService.register(body), {
body: RegisterSchema,
detail: {
summary: '用户注册',
description: '用户注册接口,需要提供用户名、邮箱、密码和验证码',
tags: [tags.auth],
operationId: 'registerUser',
},
response: RegisterResponsesSchema,
})
/**
*
* @route POST /api/auth/activate
@ -47,21 +57,17 @@ export const authController = new Elysia()
* @param body ActivateRequest
* @returns ActivateSuccessResponse | ActivateErrorResponse
*/
.post(
'/activate',
({ body, set }) => authService.activate(body),
{
body: ActivateSchema,
detail: {
summary: '邮箱激活',
description: '通过激活Token激活用户邮箱激活成功后用户状态将变为active',
tags: [tags.auth],
operationId: 'activateUser',
},
response: ActivateResponsesSchema,
}
)
.post('/activate', ({ body }) => authService.activate(body), {
body: ActivateSchema,
detail: {
summary: '邮箱激活',
description: '通过激活Token激活用户邮箱激活成功后用户状态将变为active',
tags: [tags.auth],
operationId: 'activateUser',
},
response: ActivateResponsesSchema,
})
/**
*
* @route POST /api/auth/login
@ -69,21 +75,17 @@ export const authController = new Elysia()
* @param body LoginRequest
* @returns LoginSuccessResponse | LoginErrorResponse
*/
.post(
'/login',
({ body, set }) => authService.login(body),
{
body: LoginSchema,
detail: {
summary: '用户登录',
description: '用户登录接口支持用户名或邮箱登录登录成功返回JWT访问令牌和刷新令牌',
tags: [tags.auth],
operationId: 'loginUser',
},
response: LoginResponsesSchema,
}
)
.post('/login', ({ body }) => authService.login(body), {
body: LoginSchema,
detail: {
summary: '用户登录',
description: '用户登录接口支持用户名或邮箱登录登录成功返回JWT访问令牌和刷新令牌',
tags: [tags.auth],
operationId: 'loginUser',
},
response: LoginResponsesSchema,
})
/**
* Token刷新接口
* @route POST /api/auth/refresh
@ -91,21 +93,17 @@ export const authController = new Elysia()
* @param body RefreshRequest
* @returns RefreshSuccessResponse | RefreshErrorResponse
*/
.post(
'/refresh',
({ body, set }) => authService.refresh(body),
{
body: RefreshSchema,
detail: {
summary: 'Token刷新',
description: '使用刷新令牌获取新的访问令牌和刷新令牌,延长用户会话时间',
tags: [tags.auth],
operationId: 'refreshToken',
},
response: RefreshResponsesSchema,
}
)
.post('/refresh', ({ body }) => authService.refresh(body), {
body: RefreshSchema,
detail: {
summary: 'Token刷新',
description: '使用刷新令牌获取新的访问令牌和刷新令牌,延长用户会话时间',
tags: [tags.auth],
operationId: 'refreshToken',
},
response: RefreshResponsesSchema,
})
/**
*
* @route POST /api/auth/password/reset-request
@ -113,21 +111,17 @@ export const authController = new Elysia()
* @param body ResetPasswordRequestRequest
* @returns ResetPasswordRequestSuccessResponse | ResetPasswordRequestErrorResponse
*/
.post(
'/password/reset-request',
({ body, set }) => authService.resetPasswordRequest(body),
{
body: ResetPasswordRequestSchema,
detail: {
summary: '找回密码',
description: '用户忘记密码时发送重置邮件到注册邮箱,邮件包含重置链接',
tags: [tags.auth],
operationId: 'resetPasswordRequest',
},
response: ResetPasswordRequestResponsesSchema,
}
)
.post('/password/reset-request', ({ body }) => authService.resetPasswordRequest(body), {
body: ResetPasswordRequestSchema,
detail: {
summary: '找回密码',
description: '用户忘记密码时发送重置邮件到注册邮箱,邮件包含重置链接',
tags: [tags.auth],
operationId: 'resetPasswordRequest',
},
response: ResetPasswordRequestResponsesSchema,
})
/**
*
* @route POST /api/auth/password/reset-confirm
@ -135,17 +129,13 @@ export const authController = new Elysia()
* @param body ResetPasswordConfirmRequest
* @returns ResetPasswordConfirmSuccessResponse | ResetPasswordConfirmErrorResponse
*/
.post(
'/password/reset-confirm',
({ body, set }) => authService.resetPasswordConfirm(body),
{
body: ResetPasswordConfirmSchema,
detail: {
summary: '重置密码',
description: '用户通过重置令牌设置新密码,需要提供令牌、新密码和确认密码',
tags: [tags.auth],
operationId: 'resetPasswordConfirm',
},
response: ResetPasswordConfirmResponsesSchema,
}
);
.post('/password/reset-confirm', ({ body }) => authService.resetPasswordConfirm(body), {
body: ResetPasswordConfirmSchema,
detail: {
summary: '重置密码',
description: '用户通过重置令牌设置新密码,需要提供令牌、新密码和确认密码',
tags: [tags.auth],
operationId: 'resetPasswordConfirm',
},
response: ResetPasswordConfirmResponsesSchema,
});

View File

@ -17,36 +17,38 @@ import { responseWrapperSchema } from '@/utils/responseFormate';
* @description Controller中定义所有可能的响应格式
*/
export const RegisterResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 用户ID */
id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3']
200: responseWrapperSchema(
t.Object({
/** 用户ID */
id: t.String({
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中定义所有可能的响应格式
*/
export const ActivateResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 用户ID */
id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3']
200: responseWrapperSchema(
t.Object({
/** 用户ID */
id: t.String({
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中定义所有可能的响应格式
*/
export const LoginResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 用户基本信息 */
user: t.Object({
/** 用户ID */
id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3']
200: responseWrapperSchema(
t.Object({
/** 用户基本信息 */
user: t.Object({
/** 用户ID */
id: t.String({
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({
description: '用户名',
examples: ['admin', 'testuser']
/** 认证令牌信息 */
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],
}),
}),
/** 邮箱地址 */
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刷新相关响应格式 ==========
@ -169,45 +175,47 @@ export type LoginSuccessType = Static<typeof LoginResponsesSchema[200]>;
* @description Controller中定义所有可能的响应格式
*/
export const RefreshResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 认证令牌信息 */
tokens: t.Object({
/** 访问令牌 */
accessToken: t.String({
description: 'JWT访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...']
200: responseWrapperSchema(
t.Object({
/** 认证令牌信息 */
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],
}),
}),
/** 刷新令牌 */
refreshToken: t.String({
description: 'JWT刷新令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...']
/** 刷新时间 */
refreshedAt: t.String({
description: '令牌刷新时间',
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刷新成功响应数据类型 */
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中定义所有可能的响应格式
*/
export const ResetPasswordRequestResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 邮箱地址 */
email: t.String({
description: '发送重置邮件的邮箱地址',
examples: ['user@example.com', 'admin@company.com']
200: responseWrapperSchema(
t.Object({
/** 邮箱地址 */
email: t.String({
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中定义所有可能的响应格式
*/
export const ResetPasswordConfirmResponsesSchema = {
200: responseWrapperSchema(t.Object({
/** 用户ID */
id: t.String({
description: '用户IDbigint类型以字符串形式返回防止精度丢失',
examples: ['1', '2', '3']
200: responseWrapperSchema(
t.Object({
/** 用户ID */
id: t.String({
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,
maxLength: 50,
description: '用户名2-50字符',
examples: ['root', 'testuser']
examples: ['root', 'testuser'],
}),
/** 邮箱地址对应sys_users.email */
email: t.String({
format: 'email',
maxLength: 100,
description: '邮箱地址',
examples: ['x71291@outlook.com']
examples: ['x71291@outlook.com'],
}),
/** 密码6-50字符 */
password: t.String({
minLength: 6,
maxLength: 50,
description: '密码6-50字符',
examples: ['password123']
examples: ['password123'],
}),
/** 图形验证码 */
captcha: t.String({
minLength: 4,
maxLength: 6,
description: '图形验证码',
examples: ['a1b2']
examples: ['a1b2'],
}),
/** 验证码会话ID */
captchaId: t.String({
description: '验证码会话ID',
examples: ['cap']
})
examples: ['cap'],
}),
});
/**
@ -59,8 +59,8 @@ export const ActivateSchema = t.Object({
minLength: 10,
maxLength: 1000,
description: '邮箱激活令牌JWT格式24小时有效',
examples: ['eyJhbGciOiJIUzI1NiI']
})
examples: ['eyJhbGciOiJIUzI1NiI'],
}),
});
/**
@ -73,33 +73,39 @@ export const LoginSchema = t.Object({
minLength: 2,
maxLength: 100,
description: '用户名/邮箱地址100字符',
examples: ['root', 'testuser', 'x71291@outlook.com']
examples: ['root', 'testuser', 'x71291@outlook.com'],
}),
/** 图形验证码(可选) */
captcha: t.Optional(t.String({
minLength: 4,
maxLength: 6,
description: '图形验证码,登录失败次数过多时需要',
examples: ['a1b2']
})),
captcha: t.Optional(
t.String({
minLength: 4,
maxLength: 6,
description: '图形验证码,登录失败次数过多时需要',
examples: ['a1b2'],
}),
),
/** 密码6-50字符 */
password: t.String({
minLength: 6,
maxLength: 50,
description: '密码6-50字符',
examples: ['password123']
examples: ['password123'],
}),
/** 验证码会话ID可选 */
captchaId: t.Optional(t.String({
description: '验证码会话ID与captcha配对使用',
examples: ['cap']
})),
captchaId: t.Optional(
t.String({
description: '验证码会话ID与captcha配对使用',
examples: ['cap'],
}),
),
/** 是否记住登录状态 */
rememberMe: t.Optional(t.Boolean({
description: '是否记住登录状态影响token过期时间',
examples: [true, false],
default: false
}))
rememberMe: t.Optional(
t.Boolean({
description: '是否记住登录状态影响token过期时间',
examples: [true, false],
default: false,
}),
),
});
/**
@ -112,8 +118,8 @@ export const RefreshSchema = t.Object({
minLength: 10,
maxLength: 1000,
description: '刷新令牌JWT格式用于获取新的访问令牌',
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...']
})
examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'],
}),
});
/**
@ -126,20 +132,20 @@ export const ResetPasswordRequestSchema = t.Object({
format: 'email',
maxLength: 100,
description: '注册时使用的邮箱地址',
examples: ['user@example.com', 'admin@company.com']
examples: ['user@example.com', 'admin@company.com'],
}),
/** 图形验证码 */
captcha: t.String({
minLength: 4,
maxLength: 6,
description: '图形验证码',
examples: ['a1b2', '1234']
examples: ['a1b2', '1234'],
}),
/** 验证码会话ID */
captchaId: t.String({
description: '验证码会话ID',
examples: ['cap_123', 'captcha_session']
})
examples: ['cap_123', 'captcha_session'],
}),
});
/**
@ -152,22 +158,22 @@ export const ResetPasswordConfirmSchema = t.Object({
minLength: 10,
maxLength: 1000,
description: '重置密码令牌JWT格式30分钟有效',
examples: ['eyJhbGciOiJIUzI1NiI']
examples: ['eyJhbGciOiJIUzI1NiI'],
}),
/** 新密码6-50字符 */
newPassword: t.String({
minLength: 6,
maxLength: 50,
description: '新密码6-50字符',
examples: ['newpassword123']
examples: ['newpassword123'],
}),
/** 确认新密码,必须与新密码一致 */
confirmPassword: t.String({
minLength: 6,
maxLength: 50,
description: '确认新密码,必须与新密码一致',
examples: ['newpassword123']
})
examples: ['newpassword123'],
}),
});
/** 用户注册请求类型 */
@ -186,4 +192,4 @@ export type RefreshRequest = Static<typeof RefreshSchema>;
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
* @lastEditTime 2025-01-07
* @description
*
*
* 使
* 1. token使TTL
* 2. 使TTL
@ -15,16 +15,30 @@
import bcrypt from 'bcrypt';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/plugins/drizzle/drizzle.service';
import { sysUsers } from '@/eneities';
import { sysUsers } from '@/eneities/index';
import { captchaService } from '@/modules/captcha/captcha.service';
import { Logger } from '@/plugins/logger/logger.service';
import { nextId } from '@/utils/snowflake';
import { jwtService } from '@/plugins/jwt/jwt.service';
import { emailService } from '@/plugins/email/email.service';
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 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';
/**
@ -48,10 +62,10 @@ export class AuthService {
// 获取分布式锁,防止并发注册(长期操作,开启自动续期)
const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.USER_REGISTER}:${username}:${email}`,
ttl: 60, // 注册可能需要较长时间(邮件发送等)
ttl: 60, // 注册可能需要较长时间(邮件发送等)
timeout: 15000,
autoRenew: true,
renewInterval: 20000 // 20秒续期一次
renewInterval: 20000, // 20秒续期一次
});
try {
@ -71,7 +85,7 @@ export class AuthService {
const newUser = await this.createUser({
username,
email,
passwordHash
passwordHash,
});
// 6. 发送激活邮件
@ -79,14 +93,16 @@ export class AuthService {
Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`);
return successResponse({
id: newUser.id,
username: newUser.username,
email: newUser.email,
status: newUser.status,
createdAt: newUser.createdAt
}, '用户注册成功,请查收激活邮件');
return successResponse(
{
id: newUser.id,
username: newUser.username,
email: newUser.email,
status: newUser.status,
createdAt: newUser.createdAt,
},
'用户注册成功,请查收激活邮件',
);
} finally {
// 释放锁
await lock.release();
@ -101,14 +117,11 @@ export class AuthService {
private async validateCaptcha(captcha: string, captchaId: string): Promise<void> {
const result = await captchaService.verifyCaptcha({
captchaId,
captchaCode: captcha
captchaCode: captcha,
});
if (!result.data?.valid) {
throw new BusinessError(
result.data?.message || '验证码验证失败',
400
);
throw new BusinessError(result.data?.message || '验证码验证失败', 400);
}
}
@ -117,7 +130,8 @@ export class AuthService {
* @param username
*/
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)
.where(eq(sysUsers.username, username))
.limit(1);
@ -132,7 +146,8 @@ export class AuthService {
* @param email
*/
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)
.where(eq(sysUsers.email, email))
.limit(1);
@ -156,11 +171,7 @@ export class AuthService {
* @param userData
* @returns Promise<CreatedUser>
*/
private async createUser(userData: {
username: string;
email: string;
passwordHash: string;
}): Promise<{
private async createUser(userData: { username: string; email: string; passwordHash: string }): Promise<{
id: string;
username: string;
email: string;
@ -177,17 +188,18 @@ export class AuthService {
username,
email,
passwordHash,
status: 'pending' // 新注册用户状态为待激活
status: 'pending', // 新注册用户状态为待激活
});
// 查询刚创建的用户信息
const [newUser] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
createdAt: sysUsers.createdAt
})
const [newUser] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
createdAt: sysUsers.createdAt,
})
.from(sysUsers)
.where(eq(sysUsers.id, userId))
.limit(1);
@ -203,7 +215,7 @@ export class AuthService {
username: newUser!.username,
email: newUser!.email,
status: newUser!.status,
createdAt: newUser!.createdAt
createdAt: newUser!.createdAt,
};
}
@ -213,7 +225,7 @@ export class AuthService {
* @returns Promise<ActivateSuccessResponse>
*/
public async activate(request: ActivateRequest): Promise<ActivateSuccessType> {
Logger.info(`邮箱激活请求开始处理`);
Logger.info('邮箱激活请求开始处理');
const { token } = request;
@ -228,7 +240,7 @@ export class AuthService {
key: `${LOCK_KEYS.EMAIL_ACTIVATE}:${tokenPayload.userId}`,
ttl: 30,
timeout: 10000,
autoRenew: true
autoRenew: true,
});
try {
@ -248,15 +260,17 @@ export class AuthService {
Logger.info(`邮箱激活成功:${user.id} - ${user.username}`);
return successResponse({
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
status: updatedUser.status,
updatedAt: updatedUser.updatedAt,
activated: true
}, '邮箱激活成功');
return successResponse(
{
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
status: updatedUser.status,
updatedAt: updatedUser.updatedAt,
activated: true,
},
'邮箱激活成功',
);
} finally {
// 释放锁
await lock.release();
@ -276,14 +290,15 @@ export class AuthService {
createdAt: string;
updatedAt: string;
}> {
const [user] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt
})
const [user] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
createdAt: sysUsers.createdAt,
updatedAt: sysUsers.updatedAt,
})
.from(sysUsers)
.where(eq(sysUsers.id, userId))
.limit(1);
@ -298,7 +313,7 @@ export class AuthService {
email: user.email,
status: user.status,
createdAt: user.createdAt,
updatedAt: user.updatedAt
updatedAt: user.updatedAt,
};
}
@ -308,7 +323,10 @@ export class AuthService {
* @param status
* @returns Promise<UpdatedUser>
*/
private async updateUserStatus(userId: string, status: string): Promise<{
private async updateUserStatus(
userId: string,
status: string,
): Promise<{
id: string;
username: string;
email: string;
@ -316,20 +334,22 @@ export class AuthService {
updatedAt: string;
}> {
// 更新用户状态
await db().update(sysUsers)
await db()
.update(sysUsers)
.set({
status: status,
})
.where(eq(sysUsers.id, BigInt(userId)));
// 查询更新后的用户信息
const [updatedUser] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
updatedAt: sysUsers.updatedAt
})
const [updatedUser] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
updatedAt: sysUsers.updatedAt,
})
.from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId)))
.limit(1);
@ -343,7 +363,7 @@ export class AuthService {
username: updatedUser!.username,
email: updatedUser!.email,
status: updatedUser!.status,
updatedAt: updatedUser!.updatedAt
updatedAt: updatedUser!.updatedAt,
};
}
@ -361,9 +381,9 @@ export class AuthService {
// 获取分布式锁,防止并发登录(短期操作,不开启自动续期)
const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.USER_LOGIN}:${identifier}`,
ttl: 15, // 登录操作通常很快
ttl: 15, // 登录操作通常很快
timeout: 8000,
autoRenew: false // 短期操作不需要续期
autoRenew: false, // 短期操作不需要续期
});
try {
@ -382,12 +402,15 @@ export class AuthService {
this.checkAccountStatus(user);
// 5. 生成JWT令牌
const tokens = jwtService.generateTokens({
id: user.id,
username: user.username,
email: user.email,
status: user.status
}, rememberMe);
const tokens = jwtService.generateTokens(
{
id: user.id,
username: user.username,
email: user.email,
status: user.status,
},
rememberMe,
);
// 6. 更新最后登录时间
await this.updateLastLoginTime(user.id);
@ -397,17 +420,19 @@ export class AuthService {
Logger.info(`用户登录成功:${user.id} - ${user.username}`);
return successResponse({
user: {
id: user.id,
username: user.username,
email: user.email,
status: user.status,
lastLoginAt: user.lastLoginAt
return successResponse(
{
user: {
id: user.id,
username: user.username,
email: user.email,
status: user.status,
lastLoginAt: user.lastLoginAt,
},
tokens,
},
tokens
}, '登录成功');
'登录成功',
);
} finally {
// 释放锁
await lock.release();
@ -431,18 +456,17 @@ export class AuthService {
const isEmail = identifier.includes('@');
// 构建查询条件
const whereCondition = isEmail
? eq(sysUsers.email, identifier)
: eq(sysUsers.username, identifier);
const whereCondition = isEmail ? eq(sysUsers.email, identifier) : eq(sysUsers.username, identifier);
const [user] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
passwordHash: sysUsers.passwordHash,
lastLoginAt: sysUsers.lastLoginAt
})
const [user] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
passwordHash: sysUsers.passwordHash,
lastLoginAt: sysUsers.lastLoginAt,
})
.from(sysUsers)
.where(whereCondition)
.limit(1);
@ -457,7 +481,7 @@ export class AuthService {
email: user.email,
status: user.status,
passwordHash: user.passwordHash,
lastLoginAt: user.lastLoginAt
lastLoginAt: user.lastLoginAt,
};
}
@ -496,10 +520,11 @@ export class AuthService {
* @param userId ID
*/
private async updateLastLoginTime(userId: string): Promise<void> {
await db().update(sysUsers)
await db()
.update(sysUsers)
.set({
lastLoginAt: sql`NOW()`, // 使用 MySQL 的 NOW() 函数
loginCount: sql`${sysUsers.loginCount} + 1`
loginCount: sql`${sysUsers.loginCount} + 1`,
})
.where(eq(sysUsers.id, BigInt(userId)));
}
@ -529,10 +554,9 @@ export class AuthService {
<h2>${username}</h2>
<p>使</p>
<p>使</p>
`
`,
});
// Logger.info(`激活成功邮件发送成功:${email}`);
} catch (error) {
// 邮件发送失败不影响激活流程,只记录日志
Logger.warn(`激活成功邮件发送失败:${email}, 错误:${error}`);
@ -561,7 +585,7 @@ export class AuthService {
<a href="http://localhost:3000/activate?token=${encodeURIComponent(activationTokenPayload)}"></a>
<p>24</p>
<p></p>
`
`,
});
Logger.info(`激活邮件发送成功:${email}`);
} catch (error) {
@ -576,16 +600,16 @@ export class AuthService {
* @returns Promise<RefreshSuccessResponse>
*/
public async refresh(request: RefreshRequest): Promise<RefreshSuccessType> {
Logger.info(`Token刷新请求开始处理`);
Logger.info('Token刷新请求开始处理');
const { refreshToken } = request;
// 获取分布式锁,防止并发刷新(短期操作,不开启自动续期)
const lock = await DistributedLockService.acquire({
key: `${LOCK_KEYS.TOKEN_REFRESH}:${refreshToken}`,
ttl: 10, // Token刷新操作很快
ttl: 10, // Token刷新操作很快
timeout: 5000,
autoRenew: false // 短期操作不需要续期
autoRenew: false, // 短期操作不需要续期
});
try {
@ -609,16 +633,18 @@ export class AuthService {
id: user.id,
username: user.username,
email: user.email,
status: user.status
status: user.status,
});
// 5. 记录刷新日志
await this.recordRefreshLog(user.id);
return successResponse({
tokens
}, 'Token刷新成功');
return successResponse(
{
tokens,
},
'Token刷新成功',
);
} finally {
// 释放锁
await lock.release();
@ -651,7 +677,7 @@ export class AuthService {
key: `${LOCK_KEYS.PASSWORD_RESET}:${email}`,
ttl: 30,
timeout: 10000,
autoRenew: true
autoRenew: true,
});
try {
@ -672,14 +698,16 @@ export class AuthService {
Logger.info(`找回密码邮件发送成功:${user.id} - ${user.email}`);
return successResponse({
email: user.email,
sent: true,
sentAt: new Date().toISOString(),
expiresIn: 30, // 30分钟有效期
message: '重置邮件已发送,请查收邮箱'
}, '重置邮件已发送,请查收邮箱');
return successResponse(
{
email: user.email,
sent: true,
sentAt: new Date().toISOString(),
expiresIn: 30, // 30分钟有效期
message: '重置邮件已发送,请查收邮箱',
},
'重置邮件已发送,请查收邮箱',
);
} finally {
// 释放锁
await lock.release();
@ -698,12 +726,13 @@ export class AuthService {
email: string;
status: string;
}> {
const [user] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status
})
const [user] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
status: sysUsers.status,
})
.from(sysUsers)
.where(eq(sysUsers.email, email))
.limit(1);
@ -716,7 +745,7 @@ export class AuthService {
id: user.id!.toString(),
username: user.username,
email: user.email,
status: user.status
status: user.status,
};
}
@ -754,7 +783,7 @@ export class AuthService {
<p></p>
<p><br></p>
</div>
`
`,
};
await emailService.sendEmail(emailContent);
@ -787,7 +816,7 @@ export class AuthService {
key: `${LOCK_KEYS.PASSWORD_RESET}:${tokenPayload.userId}`,
ttl: 30,
timeout: 10000,
autoRenew: true
autoRenew: true,
});
try {
@ -808,15 +837,17 @@ export class AuthService {
Logger.info(`密码重置成功:${user.id} - ${user.username}`);
return successResponse({
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
updatedAt: updatedUser.updatedAt,
reset: true,
message: '密码重置成功,请使用新密码登录'
}, '密码重置成功,请使用新密码登录');
return successResponse(
{
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
updatedAt: updatedUser.updatedAt,
reset: true,
message: '密码重置成功,请使用新密码登录',
},
'密码重置成功,请使用新密码登录',
);
} finally {
// 释放锁
await lock.release();
@ -829,25 +860,30 @@ export class AuthService {
* @param newPasswordHash
* @returns Promise<更新后的用户信息>
*/
private async updateUserPassword(userId: string, newPasswordHash: string): Promise<{
private async updateUserPassword(
userId: string,
newPasswordHash: string,
): Promise<{
id: string;
username: string;
email: string;
updatedAt: string;
}> {
await db().update(sysUsers)
await db()
.update(sysUsers)
.set({
passwordHash: newPasswordHash,
})
.where(eq(sysUsers.id, BigInt(userId)));
// 查询更新后的用户信息
const [updatedUser] = await db().select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
updatedAt: sysUsers.updatedAt
})
const [updatedUser] = await db()
.select({
id: sysUsers.id,
username: sysUsers.username,
email: sysUsers.email,
updatedAt: sysUsers.updatedAt,
})
.from(sysUsers)
.where(eq(sysUsers.id, BigInt(userId)))
.limit(1);
@ -860,7 +896,7 @@ export class AuthService {
id: updatedUser.id!.toString(),
username: updatedUser.username,
email: updatedUser.email,
updatedAt: updatedUser.updatedAt
updatedAt: updatedUser.updatedAt,
};
}
@ -896,7 +932,7 @@ export class AuthService {
<p></p>
<p><br></p>
</div>
`
`,
};
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 正常流程测试
**测试用例**: 成功注册新用户
- **请求参数**:
```json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"captcha": "a1b2",
"captchaId": "test_captcha_id"
}
```
```json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"captcha": "a1b2",
"captchaId": "test_captcha_id"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 用户信息正确创建
- 密码已加密存储
- 激活邮件已发送
- 用户状态为pending
- 用户信息正确创建
- 密码已加密存储
- 激活邮件已发送
- 用户状态为pending
#### 1.2 异常流程测试
**测试用例**: 用户名已存在
- **请求参数**: 使用已存在的用户名
- **预期响应**: 400 Bad Request
- **错误信息**: "用户名已存在"
**测试用例**: 邮箱已被注册
- **请求参数**: 使用已注册的邮箱
- **预期响应**: 400 Bad Request
- **错误信息**: "邮箱已被注册"
**测试用例**: 验证码错误
- **请求参数**: 错误的验证码
- **预期响应**: 400 Bad Request
- **错误信息**: "验证码验证失败"
@ -57,20 +61,22 @@
#### 2.1 正常流程测试
**测试用例**: 成功激活用户邮箱
- **请求参数**:
```json
{
"token": "valid_activation_token"
}
```
```json
{
"token": "valid_activation_token"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 用户状态更新为active
- 激活时间正确记录
- 用户状态更新为active
- 激活时间正确记录
#### 2.2 异常流程测试
**测试用例**: 无效的激活令牌
- **请求参数**: 无效或过期的令牌
- **预期响应**: 400 Bad Request
- **错误信息**: "激活令牌无效或已过期"
@ -80,42 +86,47 @@
#### 3.1 正常流程测试
**测试用例**: 用户名登录成功
- **请求参数**:
```json
{
"identifier": "testuser",
"password": "password123"
}
```
```json
{
"identifier": "testuser",
"password": "password123"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 返回访问令牌和刷新令牌
- 最后登录时间更新
- 登录日志记录
- 返回访问令牌和刷新令牌
- 最后登录时间更新
- 登录日志记录
**测试用例**: 邮箱登录成功
- **请求参数**:
```json
{
"identifier": "test@example.com",
"password": "password123"
}
```
```json
{
"identifier": "test@example.com",
"password": "password123"
}
```
- **预期响应**: 200 OK
#### 3.2 异常流程测试
**测试用例**: 用户名不存在
- **请求参数**: 不存在的用户名
- **预期响应**: 404 Not Found
- **错误信息**: "用户不存在"
**测试用例**: 密码错误
- **请求参数**: 错误的密码
- **预期响应**: 401 Unauthorized
- **错误信息**: "用户名或密码错误"
**测试用例**: 账号未激活
- **请求参数**: 未激活用户的凭据
- **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱"
@ -125,20 +136,22 @@
#### 4.1 正常流程测试
**测试用例**: 成功刷新令牌
- **请求参数**:
```json
{
"refreshToken": "valid_refresh_token"
}
```
```json
{
"refreshToken": "valid_refresh_token"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 返回新的访问令牌和刷新令牌
- 刷新日志记录
- 返回新的访问令牌和刷新令牌
- 刷新日志记录
#### 4.2 异常流程测试
**测试用例**: 无效的刷新令牌
- **请求参数**: 无效或过期的刷新令牌
- **预期响应**: 401 Unauthorized
- **错误信息**: "刷新令牌无效或已过期"
@ -148,33 +161,37 @@
#### 5.1 正常流程测试
**测试用例**: 成功发送重置邮件
- **请求参数**:
```json
{
"email": "test@example.com",
"captcha": "a1b2",
"captchaId": "test_captcha_id"
}
```
```json
{
"email": "test@example.com",
"captcha": "a1b2",
"captchaId": "test_captcha_id"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 重置邮件已发送
- 重置令牌已生成
- 返回发送状态和时间
- 重置邮件已发送
- 重置令牌已生成
- 返回发送状态和时间
#### 5.2 异常流程测试
**测试用例**: 邮箱未注册
- **请求参数**: 未注册的邮箱地址
- **预期响应**: 404 Not Found
- **错误信息**: "该邮箱未注册"
**测试用例**: 验证码错误
- **请求参数**: 错误的验证码
- **预期响应**: 400 Bad Request
- **错误信息**: "验证码验证失败"
**测试用例**: 账号未激活
- **请求参数**: 未激活用户的邮箱
- **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱"
@ -184,39 +201,44 @@
#### 6.1 正常流程测试
**测试用例**: 成功重置密码
- **请求参数**:
```json
{
"token": "valid_reset_token",
"newPassword": "newpassword123",
"confirmPassword": "newpassword123"
}
```
```json
{
"token": "valid_reset_token",
"newPassword": "newpassword123",
"confirmPassword": "newpassword123"
}
```
- **预期响应**: 200 OK
- **验证点**:
- 密码已更新
- 重置令牌已失效
- 成功邮件已发送
- 返回用户基本信息
- 密码已更新
- 重置令牌已失效
- 成功邮件已发送
- 返回用户基本信息
#### 6.2 异常流程测试
**测试用例**: 重置令牌无效
- **请求参数**: 无效或过期的重置令牌
- **预期响应**: 400 Bad Request
- **错误信息**: "重置令牌无效或已过期"
**测试用例**: 密码不一致
- **请求参数**: 新密码和确认密码不一致
- **预期响应**: 400 Bad Request
- **错误信息**: "两次输入的密码不一致"
**测试用例**: 密码长度不足
- **请求参数**: 新密码少于6字符
- **预期响应**: 400 Bad Request
- **错误信息**: "密码长度不符合要求"
**测试用例**: 账号未激活
- **请求参数**: 未激活用户的重置令牌
- **预期响应**: 403 Forbidden
- **错误信息**: "账号未激活,请先激活邮箱"
@ -228,15 +250,18 @@
### 1. 输入验证边界
**测试用例**: 用户名长度边界
- 最小长度: 2字符
- 最大长度: 50字符
- 超出范围应返回400错误
**测试用例**: 邮箱格式验证
- 有效邮箱格式应通过验证
- 无效邮箱格式应返回400错误
**测试用例**: 密码强度要求
- 最小长度: 6字符
- 最大长度: 50字符
- 超出范围应返回400错误
@ -244,20 +269,24 @@
### 2. 并发测试
**测试用例**: 并发注册
- 同时使用相同用户名注册
- 应只有一个成功,其他失败
**测试用例**: 并发登录
- 同一用户同时登录
- 应都能成功,但刷新令牌会失效
### 3. 性能测试
**测试用例**: 大量用户注册
- 测试系统在高并发下的表现
- 验证数据库连接池和缓存性能
**测试用例**: 邮件发送性能
- 测试邮件服务的并发处理能力
- 验证邮件队列机制
@ -266,30 +295,36 @@
### 1. 密码安全
**测试用例**: 密码加密存储
- 验证密码是否使用bcrypt加密
- 确认原始密码不在数据库中
**测试用例**: 密码强度验证
- 测试弱密码的拒绝机制
- 验证密码复杂度要求
### 2. 令牌安全
**测试用例**: JWT令牌验证
- 验证令牌签名和过期时间
- 测试令牌篡改检测
**测试用例**: 令牌刷新安全
- 验证刷新令牌的一次性使用
- 测试令牌泄露防护
### 3. 输入安全
**测试用例**: SQL注入防护
- 测试特殊字符输入
- 验证参数化查询
**测试用例**: XSS防护
- 测试恶意脚本输入
- 验证输出转义
@ -359,4 +394,4 @@ bun test src/modules/auth/auth.test.ts --reporter=verbose
2. **环境变量**: 测试环境应使用专门的配置
3. **异步操作**: 邮件发送等异步操作需要适当的等待时间
4. **资源清理**: 测试完成后应清理所有测试数据
5. **错误处理**: 测试应覆盖各种错误情况
5. **错误处理**: 测试应覆盖各种错误情况

View File

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

View File

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

View File

@ -11,64 +11,72 @@ import { t, type Static } from 'elysia';
* Schema
*/
export const GenerateCaptchaSchema = t.Object({
type: t.Optional(t.Union([
t.Literal('image'),
t.Literal('sms'),
t.Literal('email')
], {
description: '验证码类型',
examples: ['image', 'sms', 'email'],
default: 'image'
})),
width: t.Optional(t.Number({
minimum: 100,
maximum: 400,
description: '验证码图片宽度',
examples: [200],
default: 200
})),
height: t.Optional(t.Number({
minimum: 40,
maximum: 100,
description: '验证码图片高度',
examples: [60],
default: 60
})),
length: t.Optional(t.Number({
minimum: 4,
maximum: 8,
description: '验证码长度',
examples: [4],
default: 4
})),
expireTime: t.Optional(t.Number({
minimum: 60,
maximum: 1800,
description: '验证码过期时间(秒)',
examples: [300],
default: 300
}))
type: t.Optional(
t.Union([t.Literal('image'), t.Literal('sms'), t.Literal('email')], {
description: '验证码类型',
examples: ['image', 'sms', 'email'],
default: 'image',
}),
),
width: t.Optional(
t.Number({
minimum: 100,
maximum: 400,
description: '验证码图片宽度',
examples: [200],
default: 200,
}),
),
height: t.Optional(
t.Number({
minimum: 40,
maximum: 100,
description: '验证码图片高度',
examples: [60],
default: 60,
}),
),
length: t.Optional(
t.Number({
minimum: 4,
maximum: 8,
description: '验证码长度',
examples: [4],
default: 4,
}),
),
expireTime: t.Optional(
t.Number({
minimum: 60,
maximum: 1800,
description: '验证码过期时间(秒)',
examples: [300],
default: 300,
}),
),
});
/**
* Schema
*/
export const VerifyCaptchaSchema = t.Object({
captchaId: t.String({
captchaId: t.String({
minLength: 1,
description: '验证码ID',
examples: ['captcha_1234567890']
examples: ['captcha_1234567890'],
}),
captchaCode: t.String({
captchaCode: t.String({
minLength: 4,
maxLength: 8,
description: '用户输入的验证码',
examples: ['1234']
examples: ['1234'],
}),
scene: t.Optional(t.String({
description: '验证场景',
examples: ['login', 'register', 'reset_password']
}))
scene: t.Optional(
t.String({
description: '验证场景',
examples: ['login', 'register', 'reset_password'],
}),
),
});
/**
@ -81,7 +89,7 @@ export const CaptchaDataSchema = t.Object({
image: t.Optional(t.String({ description: 'Base64图片数据' })),
expireTime: t.Number({ 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' }),
image: t.String({ description: 'Base64编码的验证码图片' }),
expireTime: t.Number({ description: '过期时间戳' }),
type: t.String({ description: '验证码类型' })
type: t.String({ description: '验证码类型' }),
});
// 导出TypeScript类型
export type GenerateCaptchaRequest = Static<typeof GenerateCaptchaSchema>;
export type VerifyCaptchaRequest = Static<typeof VerifyCaptchaSchema>;
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,
VerifyCaptchaRequest,
CaptchaData,
CaptchaGenerateResponse
CaptchaGenerateResponse,
} from './captcha.schema';
import { Logger } from '@/plugins/logger/logger.service';
import { redisService } from '@/plugins/redis/redis.service';
@ -24,13 +24,7 @@ export class CaptchaService {
* @returns Promise<GenerateCaptchaSuccessResponse>
*/
async generateCaptcha(body: GenerateCaptchaRequest) {
const {
type = 'image',
width = 200,
height = 60,
length = 4,
expireTime = 300
} = body;
const { type = 'image', width = 200, height = 60, length = 4, expireTime = 300 } = body;
// 生成验证码ID
const captchaId = `captcha_${randomBytes(16).toString('hex')}`;
@ -39,7 +33,7 @@ export class CaptchaService {
const code = this.generateRandomCode(length);
// 计算过期时间
const expireTimestamp = Date.now() + (expireTime * 1000);
const expireTimestamp = Date.now() + expireTime * 1000;
let imageData: string | undefined;
@ -55,7 +49,7 @@ export class CaptchaService {
type,
image: imageData,
expireTime: expireTimestamp,
createdAt: Date.now()
createdAt: Date.now(),
};
// 存储到Redis
@ -69,7 +63,7 @@ export class CaptchaService {
id: captchaId,
image: imageData || '',
expireTime: expireTimestamp,
type
type,
};
return successResponse(responseData);
@ -106,9 +100,7 @@ export class CaptchaService {
// 验证成功后删除验证码,防止重复使用
await redisService.del(redisKey);
Logger.info(`验证码验证成功:${captchaId}`);
return successResponse(
{ valid: true }, '验证码验证成功'
);
return successResponse({ valid: true }, '验证码验证成功');
} else {
throw new BusinessError('验证码错误', 400);
}
@ -214,11 +206,9 @@ export class CaptchaService {
}
Logger.info(`清理过期验证码完成,共清理 ${cleanedCount}`);
return successResponse(
{ cleanedCount }, '清理完成'
);
return successResponse({ cleanedCount }, '清理完成');
}
}
// 导出单例实例
export const captchaService = new CaptchaService();
export const captchaService = new CaptchaService();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,6 +83,14 @@ export const errorHandlerPlugin = (app: Elysia) =>
errors: error.message,
};
}
case 409: {
set.status = code;
return {
code: error.code,
message: '数据已存在,请勿重复操作',
errors: error.message,
};
}
default: {
// 处理 ElysiaCustomStatusResponse status抛出的异常
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;
Logger.error(error as Error);
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@
* @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,
data,
timestamp: new Date().toISOString(),
}
}
};
};
export const errorResponse = (code: number, message: string, type: string, data: any = null) => {
const response = {
@ -31,10 +31,10 @@ export const errorResponse = (code: number, message: string, type: string, data:
data,
type,
timestamp: new Date().toISOString(),
}
};
Logger.warn(response);
return response
}
return response;
};
export class BusinessError extends Error {
public readonly code: number;
@ -64,4 +64,4 @@ export const responseWrapperSchema = (dataSchema: any) =>
examples: ['操作成功', '操作失败', '创建成功'],
}),
data: dataSchema,
});
});

View File

@ -7,314 +7,314 @@
* @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';
describe('Snowflake ID Generator', () => {
let snowflake: Snowflake;
let snowflake: Snowflake;
beforeEach(() => {
// 创建新的雪花ID实例
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);
beforeEach(() => {
// 创建新的雪花ID实例
snowflake = new Snowflake({ workerId: 1, datacenterId: 1 });
});
it('应该使用默认配置创建实例', () => {
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
describe('Constructor', () => {
it('应该成功创建雪花ID生成器实例', () => {
const config: SnowflakeConfig = {
workerId: 1,
datacenterId: 1,
};
const instance = new Snowflake(config);
expect(instance).toBeInstanceOf(Snowflake);
});
it('应该使用默认配置创建实例', () => {
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('应该使用自定义配置创建实例', () => {
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);
describe('ID Generation', () => {
it('应该生成唯一的ID', () => {
const id1 = snowflake.nextId();
const id2 = snowflake.nextId();
expect(id1).not.toBe(id2);
expect(typeof id1).toBe('bigint');
expect(typeof id2).toBe('bigint');
});
it('应该生成递增的ID', () => {
const ids: bigint[] = [];
for (let i = 0; i < 10; i++) {
ids.push(snowflake.nextId());
}
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范围', () => {
expect(() => {
new Snowflake({ workerId: -1, datacenterId: 1 });
}).toThrow('Worker ID must be between 0 and 31');
describe('ID Parsing', () => {
it('应该正确解析生成的ID', () => {
const id = snowflake.nextId();
const parsed = Snowflake.parseId(id);
expect(() => {
new Snowflake({ workerId: 32, datacenterId: 1 });
}).toThrow('Worker ID must be between 0 and 31');
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', () => {
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范围', () => {
expect(() => {
new Snowflake({ workerId: 1, datacenterId: -1 });
}).toThrow('Datacenter ID must be between 0 and 31');
describe('Singleton Pattern', () => {
it('应该返回相同的单例实例', () => {
const instance1 = getSnowflake();
const instance2 = getSnowflake();
expect(() => {
new Snowflake({ workerId: 1, datacenterId: 32 });
}).toThrow('Datacenter ID must be between 0 and 31');
});
});
expect(instance1).toBe(instance2);
});
describe('ID Generation', () => {
it('应该生成唯一的ID', () => {
const id1 = snowflake.nextId();
const id2 = snowflake.nextId();
expect(id1).not.toBe(id2);
expect(typeof id1).toBe('bigint');
expect(typeof id2).toBe('bigint');
it('应该使用默认配置创建单例', () => {
const instance = getSnowflake();
const config = instance.getConfig();
expect(config.workerId).toBe(1);
expect(config.datacenterId).toBe(1);
});
it('应该使用自定义配置创建单例', () => {
const customInstance = getSnowflake({ workerId: 5, datacenterId: 5 });
const config = customInstance.getConfig();
expect(config.workerId).toBe(5);
expect(config.datacenterId).toBe(5);
});
});
it('应该生成递增的ID', () => {
const ids: bigint[] = [];
for (let i = 0; i < 10; i++) {
ids.push(snowflake.nextId());
}
for (let i = 1; i < ids.length; i++) {
expect(ids[i]).toBeGreaterThan(ids[i - 1]);
}
describe('Utility Functions', () => {
it('nextId函数应该生成ID', () => {
const id = nextId();
expect(typeof id).toBe('bigint');
expect(id).toBeGreaterThan(0n);
});
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('应该在同一毫秒内递增序列号', () => {
// 模拟同一毫秒
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;
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;
});
});
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;
});
});
describe('Edge Cases', () => {
it('应该处理最大配置值', () => {
const maxSnowflake = new Snowflake({
workerId: 31,
datacenterId: 31,
});
describe('ID Parsing', () => {
it('应该正确解析生成的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);
});
const id = maxSnowflake.nextId();
const parsed = Snowflake.parseId(id);
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);
});
expect(parsed.workerId).toBe(31);
expect(parsed.datacenterId).toBe(31);
});
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('应该处理最小配置值', () => {
const minSnowflake = new Snowflake({
workerId: 0,
datacenterId: 0,
});
describe('Singleton Pattern', () => {
it('应该返回相同的单例实例', () => {
const instance1 = getSnowflake();
const instance2 = getSnowflake();
expect(instance1).toBe(instance2);
});
const id = minSnowflake.nextId();
const parsed = Snowflake.parseId(id);
it('应该使用默认配置创建单例', () => {
const instance = getSnowflake();
const config = instance.getConfig();
expect(config.workerId).toBe(1);
expect(config.datacenterId).toBe(1);
});
expect(parsed.workerId).toBe(0);
expect(parsed.datacenterId).toBe(0);
});
it('应该使用自定义配置创建单例', () => {
const customInstance = getSnowflake({ workerId: 5, datacenterId: 5 });
const config = customInstance.getConfig();
expect(config.workerId).toBe(5);
expect(config.datacenterId).toBe(5);
});
});
it('应该处理自定义epoch', () => {
const customEpoch = Date.now();
const customSnowflake = new Snowflake({
workerId: 1,
datacenterId: 1,
epoch: customEpoch,
});
describe('Utility Functions', () => {
it('nextId函数应该生成ID', () => {
const id = nextId();
expect(typeof id).toBe('bigint');
expect(id).toBeGreaterThan(0n);
});
const id = customSnowflake.nextId();
const parsed = Snowflake.parseId(id);
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');
// 由于parseId使用默认epoch时间戳会有差异
expect(parsed.createdAt.getTime()).toBeLessThan(Date.now());
});
});
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 - ()
*/
export interface SnowflakeConfig {
/** 工作机器ID范围0-31 */
workerId: number;
/** 数据中心ID范围0-31 */
datacenterId: number;
/** 序列号起始值默认0 */
sequence?: number;
/** 起始时间戳默认2020-01-01 00:00:00 UTC */
epoch?: number;
/** 工作机器ID范围0-31 */
workerId: number;
/** 数据中心ID范围0-31 */
datacenterId: number;
/** 序列号起始值默认0 */
sequence?: number;
/** 起始时间戳默认2020-01-01 00:00:00 UTC */
epoch?: number;
}
/**
@ -30,169 +30,173 @@ export interface SnowflakeConfig {
* 64ID(41) + ID(5) + ID(5) + (12)
*/
export class Snowflake {
/** 工作机器ID位数 */
private static readonly WORKER_ID_BITS = 5;
/** 数据中心ID位数 */
private static readonly DATACENTER_ID_BITS = 5;
/** 序列号位数 */
private static readonly SEQUENCE_BITS = 12;
/** 工作机器ID位数 */
private static readonly WORKER_ID_BITS = 5;
/** 数据中心ID位数 */
private static readonly DATACENTER_ID_BITS = 5;
/** 序列号位数 */
private static readonly SEQUENCE_BITS = 12;
/** 最大工作机器ID */
private static readonly MAX_WORKER_ID = (1 << Snowflake.WORKER_ID_BITS) - 1;
/** 最大数据中心ID */
private static readonly MAX_DATACENTER_ID = (1 << Snowflake.DATACENTER_ID_BITS) - 1;
/** 最大序列号 */
private static readonly MAX_SEQUENCE = (1 << Snowflake.SEQUENCE_BITS) - 1;
/** 最大工作机器ID */
private static readonly MAX_WORKER_ID = (1 << Snowflake.WORKER_ID_BITS) - 1;
/** 最大数据中心ID */
private static readonly MAX_DATACENTER_ID = (1 << Snowflake.DATACENTER_ID_BITS) - 1;
/** 最大序列号 */
private static readonly MAX_SEQUENCE = (1 << Snowflake.SEQUENCE_BITS) - 1;
/** 工作机器ID左移位数 */
private static readonly WORKER_ID_SHIFT = Snowflake.SEQUENCE_BITS;
/** 数据中心ID左移位数 */
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;
/** 工作机器ID左移位数 */
private static readonly WORKER_ID_SHIFT = Snowflake.SEQUENCE_BITS;
/** 数据中心ID左移位数 */
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;
/** 工作机器ID */
private readonly workerId: number;
/** 数据中心ID */
private readonly datacenterId: number;
/** 起始时间戳 */
private readonly epoch: number;
/** 当前序列号 */
private sequence: number;
/** 上次生成ID的时间戳 */
private lastTimestamp: number;
/** 工作机器ID */
private readonly workerId: number;
/** 数据中心ID */
private readonly datacenterId: number;
/** 起始时间戳 */
private readonly epoch: number;
/** 当前序列号 */
private sequence: number;
/** 上次生成ID的时间戳 */
private lastTimestamp: number;
/**
*
* @param config ID配置
* @throws {Error} workerId或datacenterId超出范围时抛出错误
*/
constructor(config: SnowflakeConfig) {
// 验证workerId范围
if (config.workerId < 0 || config.workerId > Snowflake.MAX_WORKER_ID) {
throw new Error(`Worker ID must be between 0 and ${Snowflake.MAX_WORKER_ID}`);
/**
*
* @param config ID配置
* @throws {Error} workerId或datacenterId超出范围时抛出错误
*/
constructor(config: SnowflakeConfig) {
// 验证workerId范围
if (config.workerId < 0 || config.workerId > 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) {
throw new Error(`Datacenter ID must be between 0 and ${Snowflake.MAX_DATACENTER_ID}`);
/**
* 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;
this.sequence = config.sequence || 0;
this.epoch = config.epoch || 1577836800000; // 2020-01-01 00:00:00 UTC
this.lastTimestamp = -1;
}
/**
* 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));
/**
* ID
* @returns {bigint} 64ID
* @throws {Error}
*/
public nextId(): bigint {
let timestamp = this.getCurrentTimestamp();
// 计算创建时间使用默认epoch
const epoch = 1577836800000; // 2020-01-01 00:00:00 UTC
const createdAt = new Date(epoch + timestamp);
// 检查时钟回拨
if (timestamp < this.lastTimestamp) {
const timeDiff = this.lastTimestamp - timestamp;
throw new Error(`Clock moved backwards. Refusing to generate id for ${timeDiff} milliseconds`);
return {
timestamp,
datacenterId,
workerId,
sequence,
createdAt,
};
}
// 如果是同一毫秒内,递增序列号
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;
/**
*
* @returns {number}
*/
private getCurrentTimestamp(): number {
return Date.now();
}
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;
}
/**
* 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();
/**
*
* @param lastTimestamp
* @returns {number}
*/
private waitNextMillis(lastTimestamp: number): number {
let timestamp = this.getCurrentTimestamp();
while (timestamp <= lastTimestamp) {
timestamp = this.getCurrentTimestamp();
}
return timestamp;
}
return timestamp;
}
/**
*
* @returns {object}
*/
public getConfig(): {
workerId: number;
datacenterId: number;
sequence: number;
epoch: number;
lastTimestamp: number;
} {
return {
workerId: this.workerId,
datacenterId: this.datacenterId,
sequence: this.sequence,
epoch: this.epoch,
lastTimestamp: this.lastTimestamp,
};
}
/**
*
* @returns {object}
*/
public getConfig(): {
workerId: number;
datacenterId: number;
sequence: number;
epoch: number;
lastTimestamp: number;
} {
return {
workerId: this.workerId,
datacenterId: this.datacenterId,
sequence: this.sequence,
epoch: this.epoch,
lastTimestamp: this.lastTimestamp,
};
}
}
/**
@ -207,15 +211,15 @@ let snowflakeInstance: Snowflake | null = null;
* @returns {Snowflake} ID生成器实例
*/
export function getSnowflake(config?: Partial<SnowflakeConfig>): Snowflake {
if (!snowflakeInstance) {
const defaultConfig: SnowflakeConfig = {
workerId: 1,
datacenterId: 1,
...config,
};
snowflakeInstance = new Snowflake(defaultConfig);
}
return snowflakeInstance;
if (!snowflakeInstance) {
const defaultConfig: SnowflakeConfig = {
workerId: 1,
datacenterId: 1,
...config,
};
snowflakeInstance = new Snowflake(defaultConfig);
}
return snowflakeInstance;
}
/**
@ -223,7 +227,7 @@ export function getSnowflake(config?: Partial<SnowflakeConfig>): Snowflake {
* @returns {bigint} 64ID
*/
export function nextId(): bigint {
return getSnowflake().nextId();
return getSnowflake().nextId();
}
/**
@ -232,7 +236,7 @@ export function nextId(): bigint {
* @returns {object}
*/
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生成器实例
*/
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完成接口
2. 协助ai完成测试用例
3. 优化mdc关于drizzle和redis的使用
3. 优化mdc关于drizzle和redis的使用

View File

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