diff --git a/.cursor/rules/api-development-standard.md b/.cursor/rules/api-development-standard.md new file mode 100644 index 0000000..9eadad0 --- /dev/null +++ b/.cursor/rules/api-development-standard.md @@ -0,0 +1,174 @@ +# API 开发规范 + +## 文件结构 + +每个API模块必须包含以下文件: + +- `*.schema.ts` - 请求参数和数据结构定义 + TypeScript类型导出 +- `*.response.ts` - 响应格式定义 + 响应类型导出 +- `*.service.ts` - 业务逻辑实现(使用类型注解) +- `*.controller.ts` - 路由和控制器(使用Schema验证) +- `*.test.ts` - 测试用例(类型安全的测试数据) + +## 开发流程 + +1. **Schema 定义** - 使用 TypeBox 定义请求参数和数据结构,导出TypeScript类型 +2. **Response 定义** - 基于全局响应格式定义各种场景的响应,导出响应类型 +3. **Service 实现** - 编写业务逻辑,使用类型注解确保类型安全 +4. **Controller 实现** - 集成JWT认证、Schema验证、错误处理 +5. **测试编写** - 使用类型安全的测试数据,覆盖正常、异常、边界场景 + +## 必须遵循 + +### 1. 认证与授权 + +```typescript +// 需要认证的接口必须使用 jwtAuthPlugin +export const controller = new Elysia().use(jwtAuthPlugin).get('/protected-route', handler, options); +``` + +### 2. 参数验证与类型使用 + +```typescript +// example.schema.ts - 定义Schema和导出类型 +import { t, type Static } from 'elysia'; + +export const GetUserByUsernameSchema = t.Object({ + username: t.String({ minLength: 2, maxLength: 50 }), +}); + +// 导出TypeScript类型 +export type GetUserByUsernameParams = Static; + +// example.service.ts - Service中使用类型 +import type { GetUserByUsernameParams } from './example.schema'; + +export class ExampleService { + async getUserByUsername(params: GetUserByUsernameParams): Promise { + const { username } = params; // 类型安全 + // 业务逻辑... + } +} + +// example.controller.ts - Controller中使用Schema验证 +export const controller = new Elysia().use(jwtAuthPlugin).get('/user/:username', handler, { + params: GetUserByUsernameSchema, // 运行时验证 +}); +``` + +**要求:** +- ✅ 每个Schema必须导出对应的TypeScript类型 +- ✅ Service方法必须使用类型注解 +- ❌ 禁止行内定义任何参数Schema + +### 3. 统一响应格式 + +```typescript +// 成功响应 +return { + code: ERROR_CODES.SUCCESS, + message: '操作成功', + data: result, +}; + +// 错误响应 +return { + code: ERROR_CODES.BUSINESS_ERROR, + message: '具体错误信息', + data: null, +}; +``` + +- 响应内容的类型需要在.response.ts中定义 + +### 4. 错误处理 + +```typescript +try { + const result = await service.method(); + return successResponse(result); +} catch (error) { + Logger.error(new Error(`操作失败: ${error}`)); + + const errorMessage = error instanceof Error ? error.message : '未知错误'; + if (errorMessage.includes('特定错误')) { + set.status = 400; + return errorResponse(ERROR_CODES.BUSINESS_ERROR, '业务错误消息'); + } + + set.status = 500; + return errorResponse(ERROR_CODES.INTERNAL_ERROR, '服务器内部错误'); +} +``` + +### 5. 文档配置 + +```typescript +{ + detail: { + summary: '接口简要描述', + description: '接口详细描述', + tags: [tags.moduleName], + security: [{ bearerAuth: [] }], // 需要认证时添加 + }, + response: PredefinedResponses, +} +``` + +### 6. 日志记录 + +```typescript +// 接口调用日志 +Logger.info(`接口被调用,参数: ${param}, 用户: ${JSON.stringify(user)}`); + +// 成功日志 +Logger.info(`操作成功,结果: ${result.id}`); + +// 错误日志 +Logger.error(new Error(`操作失败,错误: ${error}`)); +``` + +### 7. 必要的注释 + +1. 接口名称注释 + +```typescript +export const controller = new Elysia() + .use(jwtAuthPlugin) + /** + * 根据用户名查询用户信息 + * @route GET /api/sample/user/:username + * @description 通过用户名查询用户的详细信息,需要JWT认证 + * @param username 用户名,路径参数,长度2-50字符 + * @returns 用户信息对象或错误响应 + * @modification hotok 2025-06-29 初始实现 + */ + .get('/protected-route', handler, options); +``` + +## 禁止事项 + +- ❌ 直接在 Controller 中写业务逻辑 +- ❌ 不进行参数验证 +- ❌ 返回非标准格式的响应 +- ❌ 暴露敏感信息(如密码哈希) +- ❌ 缺少错误处理和日志记录 +- ❌ 不编写测试用例 + +## 命名规范 + +- 文件名:`module.type.ts`(如:`user.controller.ts`) +- Schema:`GetUserByIdSchema`、`CreateUserSchema` +- Response:`GetUserSuccessResponse`、`UserErrorResponse` +- Service 类:`UserService`、导出实例:`userService` +- Controller:`userController` + +## 测试要求 + +每个接口必须包含: + +- ✅ 正常流程测试 +- ✅ 参数验证边界测试(最短、最长、无效格式) +- ✅ 业务逻辑异常测试(不存在、权限不足等) +- ✅ 认证相关测试(无Token、无效Token、过期Token) +- ✅ 响应格式验证(状态码、code、message、data结构) diff --git a/.cursor/rules/elysia-interface-standards.md b/.cursor/rules/elysia-interface-standards.md new file mode 100644 index 0000000..59ec71d --- /dev/null +++ b/.cursor/rules/elysia-interface-standards.md @@ -0,0 +1,1084 @@ +# Elysia 接口编写规范(Elysia Interface Development Standards) + +## 目标 (Goal) + +本规范旨在提供一套完整的 Elysia 接口开发标准,结合官方文档最佳实践、社区经验和项目实际需求,确保代码质量、类型安全和开发效率。 + +## 核心原则 (Core Principles) + +### 1. 一切皆组件 (Everything is a Component) +- 每个 Elysia 实例都是一个组件 +- 组件可以被插入到其他实例中 +- 强制将应用拆分为小块,便于添加或移除功能 + +### 2. 方法链式调用 (Method Chaining) +- **必须始终使用方法链式调用** +- 确保类型完整性和推断 +- 每个方法返回新的类型引用 + +### 3. 类型安全优先 (Type Safety First) +- 使用 Elysia 内置类型系统 +- 避免使用 `any` 类型 +- 单一数据源原则 + +## 项目结构规范 (Project Structure Standards) + +### 推荐目录结构 + +``` +src/ +├── controllers/ # 控制器(路由与业务入口) +│ ├── auth/ +│ │ └── index.ts # 认证相关路由 +│ └── user/ +│ └── index.ts # 用户相关路由 +├── services/ # 业务逻辑服务层 +│ ├── auth/ +│ │ └── auth.service.ts +│ └── user/ +│ └── user.service.ts +├── validators/ # 参数校验(按路由结构组织) +│ ├── global.response.ts +│ ├── auth/ +│ │ ├── auth.validator.ts +│ │ └── auth.response.ts +│ └── user/ +│ ├── user.validator.ts +│ └── user.response.ts +├── models/ # 数据模型 +├── plugins/ # Elysia 插件 +├── utils/ # 工具函数 +├── config/ # 配置文件 +├── type/ # 类型定义文件 +└── app.ts # 应用入口 +``` + +## 接口设计规范 (Interface Design Standards) + +### 1. 控制器规范 (Controller Standards) + +**✅ 正确做法:使用 Elysia 实例作为控制器** + +```typescript +/** + * @file 用户认证控制器 + * @author 开发者姓名 + * @date 2024-01-01 + * @lastEditor 开发者姓名 + * @lastEditTime 2024-01-01 + * @description 用户认证相关接口,包含登录、注册、token 验证等功能 + */ + +import { Elysia } from 'elysia'; +import { jwtPlugin } from '@/plugins/jwt.plugins'; +import { + loginBodySchema, + registerBodySchema, + type LoginBody, + type RegisterBody +} from '@/validators/auth/auth.validator'; +import { + loginResponse200Schema, + loginResponse400Schema, + registerResponse200Schema, + registerResponse400Schema +} from '@/validators/auth/auth.response'; +import { loginService, registerService } from '@/services/auth/auth.service'; + +/** + * 认证控制器 + * @description 处理用户认证相关的 HTTP 请求 + */ +export const authController = new Elysia({ prefix: '/api/auth' }) + .use(jwtPlugin) + .post('/login', + ({ body, jwt, set }: { body: LoginBody; jwt: any; set: any }) => + loginService(body, jwt, set), + { + body: loginBodySchema, + detail: { + tags: ['认证'], + summary: '用户登录', + description: '用户使用用户名和密码进行登录,成功后返回 JWT token', + }, + response: { + 200: loginResponse200Schema, + 400: loginResponse400Schema, + }, + } + ) + .post('/register', + ({ body, set }: { body: RegisterBody; set: any }) => + registerService(body, set), + { + body: registerBodySchema, + detail: { + tags: ['认证'], + summary: '用户注册', + description: '新用户注册账户', + }, + response: { + 200: registerResponse200Schema, + 400: registerResponse400Schema, + }, + } + ); +``` + +**❌ 错误做法:创建单独的控制器类** + +```typescript +// ❌ 不要这样做 +abstract class AuthController { + static login(context: Context) { + return AuthService.login(context.body); + } +} + +new Elysia() + .post('/login', AuthController.login); +``` + +### 2. 服务层规范 (Service Layer Standards) + +#### 非请求依赖服务 (Non-Request Dependent Service) + +```typescript +/** + * @file 用户认证业务逻辑服务 + * @author 开发者姓名 + * @date 2024-01-01 + * @lastEditor 开发者姓名 + * @lastEditTime 2024-01-01 + * @description 处理用户认证相关的业务逻辑,与 HTTP 请求解耦 + */ + +import { hash, verify } from 'bun'; +import { sign } from 'jsonwebtoken'; +import type { LoginBody, RegisterBody } from '@/validators/auth/auth.validator'; + +/** + * 认证服务类 + * @description 处理用户认证业务逻辑 + */ +export abstract class AuthService { + /** + * 用户登录业务逻辑 + * @param body 登录请求体 + * @param jwt JWT 插件实例 + * @param set Elysia set 对象 + * @returns 登录响应 + * @modification 张三 2024-01-02 添加密码验证逻辑 + */ + static async login( + body: LoginBody, + jwt: any, + set: any + ) { + const { username, password } = body; + + try { + // 查询用户 + const user = await this.findUserByUsername(username); + if (!user) { + set.status = 400; + return { + code: 400, + message: '用户名或密码错误', + data: null, + }; + } + + // 验证密码 + const isValidPassword = await this.verifyPassword(password, user.password); + if (!isValidPassword) { + set.status = 400; + return { + code: 400, + message: '用户名或密码错误', + data: null, + }; + } + + // 生成 token + const token = await jwt.sign({ + userId: user.id, + username: user.username + }); + + return { + code: 0, + message: '登录成功', + data: { + token, + userInfo: { + id: user.id, + username: user.username, + email: user.email + } + }, + }; + } catch (error) { + set.status = 500; + return { + code: 500, + message: '服务器内部错误', + data: null, + }; + } + } + + /** + * 用户注册业务逻辑 + * @param body 注册请求体 + * @param set Elysia set 对象 + * @returns 注册响应 + */ + static async register(body: RegisterBody, set: any) { + const { username, email, password } = body; + + try { + // 检查用户是否已存在 + const existingUser = await this.findUserByUsername(username); + if (existingUser) { + set.status = 400; + return { + code: 400, + message: '用户名已存在', + data: null, + }; + } + + // 检查邮箱是否已存在 + const existingEmail = await this.findUserByEmail(email); + if (existingEmail) { + set.status = 400; + return { + code: 400, + message: '邮箱已被注册', + data: null, + }; + } + + // 加密密码 + const hashedPassword = await this.hashPassword(password); + + // 创建用户 + const newUser = await this.createUser({ + username, + email, + password: hashedPassword + }); + + return { + code: 0, + message: '注册成功', + data: { + userId: newUser.id, + username: newUser.username + }, + }; + } catch (error) { + set.status = 500; + return { + code: 500, + message: '服务器内部错误', + data: null, + }; + } + } + + /** + * 根据用户名查找用户 + * @param username 用户名 + * @returns 用户信息或 null + */ + private static async findUserByUsername(username: string) { + // 实际项目中应该从数据库查询 + // 这里仅作示例 + return null; + } + + /** + * 根据邮箱查找用户 + * @param email 邮箱 + * @returns 用户信息或 null + */ + private static async findUserByEmail(email: string) { + // 实际项目中应该从数据库查询 + return null; + } + + /** + * 验证密码 + * @param plainPassword 明文密码 + * @param hashedPassword 加密后的密码 + * @returns 是否匹配 + */ + private static async verifyPassword( + plainPassword: string, + hashedPassword: string + ): Promise { + return await verify(plainPassword, hashedPassword); + } + + /** + * 密码加密 + * @param password 明文密码 + * @returns 加密后的密码 + */ + private static async hashPassword(password: string): Promise { + return await hash(password); + } + + /** + * 创建用户 + * @param userData 用户数据 + * @returns 创建的用户信息 + */ + private static async createUser(userData: { + username: string; + email: string; + password: string; + }) { + // 实际项目中应该保存到数据库 + return { + id: Math.random().toString(36), + ...userData + }; + } +} +``` + +#### 请求依赖服务 (Request Dependent Service) + +```typescript +/** + * 请求依赖的认证服务 + * @description 需要访问请求上下文的服务应该作为 Elysia 实例 + */ +export const RequestAuthService = new Elysia({ name: 'Auth.Service' }) + .derive({ as: 'global' }, ({ cookie: { session } }) => ({ + Auth: { + user: session.value + } + })) + .macro(({ onBeforeHandle }) => ({ + /** + * 检查用户是否已登录 + * @param value 是否需要登录 + */ + requireAuth(value: boolean) { + if (value) { + onBeforeHandle(({ Auth, status }) => { + if (!Auth?.user) { + return status(401, { + code: 401, + message: '请先登录', + data: null + }); + } + }); + } + } + })); +``` + +### 3. 参数校验规范 (Validation Standards) + +```typescript +/** + * @file 认证接口参数校验规则 + * @author 开发者姓名 + * @date 2024-01-01 + * @lastEditor 开发者姓名 + * @lastEditTime 2024-01-01 + * @description 认证相关接口的参数校验规则,包含详细的验证规则和错误提示 + */ + +import { t } from 'elysia'; +import type { Static } from 'elysia'; + +/** + * 登录请求参数校验规则 + * @property {string} username - 用户名,2-16位字符 + * @property {string} password - 密码,6-32位字符 + */ +export const loginBodySchema = t.Object({ + username: t.String({ + minLength: 2, + maxLength: 16, + description: '用户名,2-16位字符', + examples: ['admin', 'user123'] + }), + password: t.String({ + minLength: 6, + maxLength: 32, + description: '密码,6-32位字符' + }), +}, { + description: '用户登录请求参数' +}); + +/** + * 注册请求参数校验规则 + * @property {string} username - 用户名,2-16位字符 + * @property {string} email - 邮箱地址 + * @property {string} password - 密码,6-32位字符 + * @property {string} confirmPassword - 确认密码 + */ +export const registerBodySchema = t.Object({ + username: t.String({ + minLength: 2, + maxLength: 16, + description: '用户名,2-16位字符', + pattern: '^[a-zA-Z0-9_]+$' // 只允许字母、数字、下划线 + }), + email: t.String({ + format: 'email', + description: '邮箱地址', + examples: ['user@example.com'] + }), + password: t.String({ + minLength: 6, + maxLength: 32, + description: '密码,6-32位字符,至少包含字母和数字', + pattern: '^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]+$' + }), + confirmPassword: t.String({ + description: '确认密码,必须与密码一致' + }) +}, { + description: '用户注册请求参数' +}); + +/** + * 密码重置请求参数校验规则 + */ +export const resetPasswordBodySchema = t.Object({ + email: t.String({ + format: 'email', + description: '注册时使用的邮箱地址' + }), + newPassword: t.String({ + minLength: 6, + maxLength: 32, + description: '新密码,6-32位字符' + }), + verificationCode: t.String({ + minLength: 6, + maxLength: 6, + description: '6位数字验证码', + pattern: '^\\d{6}$' + }) +}); + +/** + * 查询参数校验规则 + */ +export const userListQuerySchema = t.Object({ + page: t.Optional(t.Number({ + minimum: 1, + default: 1, + description: '页码,从1开始' + })), + pageSize: t.Optional(t.Number({ + minimum: 1, + maximum: 100, + default: 10, + description: '每页数量,1-100' + })), + keyword: t.Optional(t.String({ + maxLength: 50, + description: '搜索关键词' + })) +}); + +/** + * 路径参数校验规则 + */ +export const userParamsSchema = t.Object({ + id: t.String({ + minLength: 1, + description: '用户ID' + }) +}); + +// 类型导出 +export type LoginBody = Static; +export type RegisterBody = Static; +export type ResetPasswordBody = Static; +export type UserListQuery = Static; +export type UserParams = Static; +``` + +### 4. 响应格式规范 (Response Format Standards) + +```typescript +/** + * @file 认证接口响应格式定义 + * @author 开发者姓名 + * @date 2024-01-01 + * @lastEditor 开发者姓名 + * @lastEditTime 2024-01-01 + * @description 认证相关接口的响应格式定义,确保响应结构的一致性 + */ + +import { t } from 'elysia'; + +/** + * 登录成功响应格式 + */ +export const loginResponse200Schema = t.Object({ + code: t.Literal(0, { + description: '成功响应码' + }), + message: t.String({ + description: '响应消息', + examples: ['登录成功'] + }), + data: t.Object({ + /** JWT token */ + token: t.String({ + description: 'JWT 访问令牌', + examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] + }), + /** 用户信息 */ + userInfo: t.Object({ + id: t.String({ + description: '用户ID' + }), + username: t.String({ + description: '用户名' + }), + email: t.String({ + description: '邮箱地址' + }) + }) + }) +}, { + description: '登录成功响应' +}); + +/** + * 登录失败响应格式 + */ +export const loginResponse400Schema = t.Object({ + code: t.Literal(400, { + description: '客户端错误响应码' + }), + message: t.String({ + description: '错误消息', + examples: ['用户名或密码错误', '参数验证失败'] + }), + data: t.Null({ + description: '错误时数据为null' + }), +}, { + description: '登录失败响应' +}); + +/** + * 注册成功响应格式 + */ +export const registerResponse200Schema = t.Object({ + code: t.Literal(0), + message: t.String({ + examples: ['注册成功'] + }), + data: t.Object({ + userId: t.String({ + description: '新创建的用户ID' + }), + username: t.String({ + description: '用户名' + }) + }) +}); + +/** + * 注册失败响应格式 + */ +export const registerResponse400Schema = t.Object({ + code: t.Literal(400), + message: t.String({ + examples: ['用户名已存在', '邮箱已被注册', '密码不符合要求'] + }), + data: t.Null(), +}); + +/** + * 通用未授权响应格式 + */ +export const unauthorizedResponse401Schema = t.Object({ + code: t.Literal(401), + message: t.String({ + examples: ['请先登录', 'Token已过期', 'Token无效'] + }), + data: t.Null(), +}); + +/** + * 用户列表响应格式 + */ +export const userListResponse200Schema = t.Object({ + code: t.Literal(0), + message: t.String(), + data: t.Object({ + list: t.Array(t.Object({ + id: t.String(), + username: t.String(), + email: t.String(), + createdAt: t.String({ + format: 'date-time', + description: '创建时间' + }), + updatedAt: t.String({ + format: 'date-time', + description: '更新时间' + }) + })), + pagination: t.Object({ + page: t.Number({ + description: '当前页码' + }), + pageSize: t.Number({ + description: '每页数量' + }), + total: t.Number({ + description: '总条数' + }), + totalPages: t.Number({ + description: '总页数' + }) + }) + }) +}); +``` + +## 错误处理规范 (Error Handling Standards) + +### 全局错误处理插件 + +```typescript +/** + * @file 全局错误处理插件 + * @author 开发者姓名 + * @date 2024-01-01 + * @description 统一处理应用中的错误,提供标准化的错误响应格式 + */ + +import { Elysia } from 'elysia'; +import { logger } from '@/utils/logger'; + +/** + * 错误响应接口 + */ +interface ErrorResponse { + code: number; + message: string; + data: null; +} + +/** + * 全局错误处理插件 + */ +export const errorHandlerPlugin = new Elysia({ name: 'errorHandler' }) + .onError(({ code, error, set }) => { + // 记录错误日志 + logger.error('API Error:', { + code, + error: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + + const response: ErrorResponse = { + code: 500, + message: '服务器内部错误', + data: null + }; + + switch (code) { + case 'VALIDATION': + set.status = 400; + response.code = 400; + response.message = '请求参数验证失败:' + error.message; + break; + + case 'NOT_FOUND': + set.status = 404; + response.code = 404; + response.message = '请求的资源不存在'; + break; + + case 'PARSE': + set.status = 400; + response.code = 400; + response.message = '请求数据格式错误'; + break; + + case 'UNAUTHORIZED': + set.status = 401; + response.code = 401; + response.message = '未授权访问'; + break; + + case 'FORBIDDEN': + set.status = 403; + response.code = 403; + response.message = '权限不足'; + break; + + default: + set.status = 500; + response.code = 500; + response.message = '服务器内部错误'; + break; + } + + return response; + }); + +/** + * 业务错误类 + * @description 用于抛出业务逻辑错误 + */ +export class BusinessError extends Error { + public code: number; + + constructor(code: number, message: string) { + super(message); + this.code = code; + this.name = 'BusinessError'; + } +} + +/** + * 抛出业务错误的辅助函数 + * @param code 错误码 + * @param message 错误消息 + */ +export function throwBusinessError(code: number, message: string): never { + throw new BusinessError(code, message); +} +``` + +## 中间件与插件规范 (Middleware & Plugin Standards) + +### JWT 认证插件 + +```typescript +/** + * @file JWT 认证插件 + * @author 开发者姓名 + * @date 2024-01-01 + * @description JWT 令牌处理插件,提供 token 生成和验证功能 + */ + +import { Elysia } from 'elysia'; +import { jwt } from '@elysiajs/jwt'; +import { jwtConfig } from '@/config/jwt.config'; + +/** + * JWT 认证插件 + */ +export const jwtPlugin = new Elysia({ name: 'jwt' }) + .use(jwt({ + name: 'jwt', + secret: jwtConfig.secret, + exp: jwtConfig.expiresIn + })) + .derive(({ jwt, headers }) => ({ + /** + * 获取当前用户信息 + * @returns 用户信息或 null + */ + getCurrentUser: async () => { + try { + const authorization = headers.authorization; + if (!authorization?.startsWith('Bearer ')) { + return null; + } + + const token = authorization.slice(7); + const payload = await jwt.verify(token); + return payload; + } catch { + return null; + } + } + })) + .macro(({ onBeforeHandle }) => ({ + /** + * 权限验证宏 + * @param options 验证选项 + */ + auth(options: { required?: boolean } = {}) { + const { required = true } = options; + + onBeforeHandle(async ({ getCurrentUser, status }) => { + const user = await getCurrentUser(); + + if (required && !user) { + return status(401, { + code: 401, + message: '请先登录', + data: null + }); + } + + return { user }; + }); + } + })); +``` + +### 请求日志插件 + +```typescript +/** + * @file 请求日志插件 + * @author 开发者姓名 + * @date 2024-01-01 + * @description 记录 API 请求和响应的详细信息 + */ + +import { Elysia } from 'elysia'; +import { logger } from '@/utils/logger'; + +/** + * 请求日志插件 + */ +export const requestLoggerPlugin = new Elysia({ name: 'requestLogger' }) + .onRequest(({ request, path }) => { + const startTime = Date.now(); + + logger.info('API Request', { + method: request.method, + url: request.url, + path, + userAgent: request.headers.get('user-agent'), + ip: request.headers.get('x-forwarded-for') || 'unknown', + timestamp: new Date().toISOString(), + startTime + }); + + // 将开始时间存储在请求上下文中 + return { startTime }; + }) + .onAfterHandle(({ request, response, path, startTime }) => { + const duration = Date.now() - (startTime || Date.now()); + + logger.info('API Response', { + method: request.method, + path, + status: response.status, + duration: `${duration}ms`, + timestamp: new Date().toISOString() + }); + }); +``` + +## 最佳实践示例 (Best Practice Examples) + +### 完整的 CRUD 接口示例 + +```typescript +/** + * @file 用户管理完整示例 + * @author 开发者姓名 + * @date 2024-01-01 + * @description 展示完整的 CRUD 接口实现,包含分页、搜索、排序等功能 + */ + +import { Elysia, t } from 'elysia'; +import { jwtPlugin } from '@/plugins/jwt.plugins'; +import { errorHandlerPlugin } from '@/plugins/errorHandler.plugins'; +import { UserService } from '@/services/user/user.service'; + +// 参数校验 +const createUserSchema = t.Object({ + username: t.String({ minLength: 2, maxLength: 16 }), + email: t.String({ format: 'email' }), + password: t.String({ minLength: 6, maxLength: 32 }), + role: t.Optional(t.Union([t.Literal('admin'), t.Literal('user')], { default: 'user' })) +}); + +const updateUserSchema = t.Object({ + username: t.Optional(t.String({ minLength: 2, maxLength: 16 })), + email: t.Optional(t.String({ format: 'email' })), + role: t.Optional(t.Union([t.Literal('admin'), t.Literal('user')])) +}); + +const userParamsSchema = t.Object({ + id: t.String({ minLength: 1 }) +}); + +const userQuerySchema = t.Object({ + page: t.Optional(t.Number({ minimum: 1, default: 1 })), + pageSize: t.Optional(t.Number({ minimum: 1, maximum: 100, default: 10 })), + keyword: t.Optional(t.String({ maxLength: 50 })), + role: t.Optional(t.Union([t.Literal('admin'), t.Literal('user')])), + sortBy: t.Optional(t.Union([t.Literal('createdAt'), t.Literal('username')], { default: 'createdAt' })), + sortOrder: t.Optional(t.Union([t.Literal('asc'), t.Literal('desc')], { default: 'desc' })) +}); + +// 响应格式 +const userItemSchema = t.Object({ + id: t.String(), + username: t.String(), + email: t.String(), + role: t.String(), + isActive: t.Boolean(), + createdAt: t.String({ format: 'date-time' }), + updatedAt: t.String({ format: 'date-time' }) +}); + +const successResponse = (data: any) => t.Object({ + code: t.Literal(0), + message: t.String(), + data +}); + +const errorResponse = (code: number) => t.Object({ + code: t.Literal(code), + message: t.String(), + data: t.Null() +}); + +/** + * 用户管理控制器 + */ +export const userController = new Elysia({ prefix: '/api/users' }) + .use(jwtPlugin) + .use(errorHandlerPlugin) + + // 获取用户列表(支持分页、搜索、排序) + .get('/', + async ({ query, getCurrentUser }) => { + const currentUser = await getCurrentUser(); + return await UserService.getUserList(query, currentUser); + }, + { + query: userQuerySchema, + detail: { + tags: ['用户管理'], + summary: '获取用户列表', + description: '获取用户列表,支持分页、搜索和排序功能' + }, + response: { + 200: successResponse(t.Object({ + list: t.Array(userItemSchema), + pagination: t.Object({ + page: t.Number(), + pageSize: t.Number(), + total: t.Number(), + totalPages: t.Number() + }) + })), + 401: errorResponse(401) + }, + auth: { required: true } + } + ) + + // 获取单个用户 + .get('/:id', + async ({ params, getCurrentUser }) => { + const currentUser = await getCurrentUser(); + return await UserService.getUserById(params.id, currentUser); + }, + { + params: userParamsSchema, + detail: { + tags: ['用户管理'], + summary: '获取用户详情', + description: '根据用户ID获取用户详细信息' + }, + response: { + 200: successResponse(userItemSchema), + 404: errorResponse(404), + 401: errorResponse(401) + }, + auth: { required: true } + } + ) + + // 创建用户 + .post('/', + async ({ body, getCurrentUser }) => { + const currentUser = await getCurrentUser(); + return await UserService.createUser(body, currentUser); + }, + { + body: createUserSchema, + detail: { + tags: ['用户管理'], + summary: '创建用户', + description: '创建新用户账户' + }, + response: { + 200: successResponse(userItemSchema), + 400: errorResponse(400), + 401: errorResponse(401), + 403: errorResponse(403) + }, + auth: { required: true } + } + ) + + // 更新用户 + .put('/:id', + async ({ params, body, getCurrentUser }) => { + const currentUser = await getCurrentUser(); + return await UserService.updateUser(params.id, body, currentUser); + }, + { + params: userParamsSchema, + body: updateUserSchema, + detail: { + tags: ['用户管理'], + summary: '更新用户', + description: '更新用户信息' + }, + response: { + 200: successResponse(userItemSchema), + 400: errorResponse(400), + 404: errorResponse(404), + 401: errorResponse(401), + 403: errorResponse(403) + }, + auth: { required: true } + } + ) + + // 删除用户 + .delete('/:id', + async ({ params, getCurrentUser }) => { + const currentUser = await getCurrentUser(); + return await UserService.deleteUser(params.id, currentUser); + }, + { + params: userParamsSchema, + detail: { + tags: ['用户管理'], + summary: '删除用户', + description: '删除指定用户' + }, + response: { + 200: successResponse(t.Object({ + deleted: t.Boolean() + })), + 404: errorResponse(404), + 401: errorResponse(401), + 403: errorResponse(403) + }, + auth: { required: true } + } + ); +``` + +--- + +**请严格遵守以上规范,确保 Elysia 接口的一致性、安全性和可维护性。** \ No newline at end of file diff --git a/.cursor/rules/elysia-rules.mdc b/.cursor/rules/elysia-rules.mdc new file mode 100644 index 0000000..b6ecb6a --- /dev/null +++ b/.cursor/rules/elysia-rules.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: true +--- diff --git a/docs/api-type-usage-examples.md b/docs/api-type-usage-examples.md new file mode 100644 index 0000000..c895302 --- /dev/null +++ b/docs/api-type-usage-examples.md @@ -0,0 +1,157 @@ +# 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; +``` + +## 2. 在 Service 中使用类型 + +```typescript +// example.service.ts +import type { GetUserByUsernameParams, UserInfo } from './example.schema'; + +export class ExampleService { + // 使用类型注解参数 + async getUserByUsername(params: GetUserByUsernameParams): Promise { + const { username } = params; // TypeScript 会自动推断类型 + + // 业务逻辑... + return userResult; + } + + // 或者直接使用解构参数 + async getUserByUsername2({ username }: GetUserByUsernameParams): Promise { + // 业务逻辑... + 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/sample/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; +``` + +## 7. 完整的类型流转示例 + +```typescript +// 完整的类型安全流程 +import type { + GetUserByUsernameParams, + UserInfo +} from './example.schema'; + +// Service 层 +class UserService { + async getUser(params: GetUserByUsernameParams): Promise { + // 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 用于编译时类型检查 \ No newline at end of file diff --git a/docs/jwt-usage-examples.md b/docs/jwt-usage-examples.md new file mode 100644 index 0000000..477b04a --- /dev/null +++ b/docs/jwt-usage-examples.md @@ -0,0 +1,214 @@ +# 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/sample/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 { + 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. **权限分离**: 使用角色字段实现细粒度权限控制 \ No newline at end of file diff --git a/package.json b/package.json index 8df8207..8948b79 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/winston": "^2.4.4", "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.35.0", + "drizzle-kit": "^0.31.4", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "prettier": "^3.6.2", @@ -30,6 +31,7 @@ "@elysiajs/swagger": "^1.3.0", "@types/ua-parser-js": "^0.7.39", "chalk": "^5.4.1", + "drizzle-orm": "^0.44.2", "mysql2": "^3.14.1", "nanoid": "^5.1.5", "picocolors": "^1.1.1", diff --git a/src/app.ts b/src/app.ts index 9705b6c..622a2ea 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,18 +3,25 @@ * @author hotok * @date 2025-06-28 * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description Elysia API服务应用入口文件,集成Winston日志系统 + * @lastEditTime 2025-06-29 + * @description Elysia API服务应用入口文件,集成Winston日志系统和统一路由管理 */ +// Elysia import { Elysia } from 'elysia'; -import { swaggerPlugin } from '@/plugins/swagger.plugins'; -import { authController } from '@/controllers/try/auth.controller'; -import { protectedController } from '@/controllers/try/protected.controller'; -import { healthController } from '@/controllers/health.controller'; -import * as config from '@/config/logger.config'; -import loggerPlugin from '@/plugins/logger.plugins'; -import { errorHandlerPlugin } from '@/plugins/errorHandler.plugins'; +// 版本信息 +import * as packageJson from '@package.json'; +// 路由总入口 +import { controllers } from '@/modules/index'; // 使用路由总入口 +// 插件总入口 +import { plugins } from '@/plugins/index'; +// 格式化路由 +import { formatRoute } from '@/utils/formatRoute'; + +/** + * 自定义认证错误类 + * @description 用于处理认证相关的错误 + */ class AuthenticationError extends Error { constructor(message: string, code = 500) { super(message); @@ -24,31 +31,31 @@ class AuthenticationError extends Error { } } } + /** * Elysia应用实例 - * @type {Elysia} + * @description 主应用实例,集成所有插件和路由 */ -export const app = new Elysia() - .state('config', config) - .use(loggerPlugin) - .use(errorHandlerPlugin) - .use(swaggerPlugin) - .use(authController) - .use(protectedController) - .use(healthController) - .state('counter', 0) // 定义名为 counter 的初始值 - - // 批量定义 不会覆盖前面单独定义的 +export const app = new Elysia({ name: 'main-app' }) + // 环境变量 + .decorate('env', process.env.NODE_ENV || process.env.BUN_ENV || 'development') + // 版本信息 .state({ - version: '1.0', - server: 'Bun', + version: packageJson.version, }) - .state('Error', (message: string) => { - console.log('message', message); - return new AuthenticationError(message); + // 使用插件 + .use(plugins) + + // 使用统一的路由入口 所有业务的入口文件 + .use(controllers) + + .onStart(() => { + console.log('🚀 Elysia 服务启动成功'); + console.log(formatRoute(app.routes)); + console.log('📝 API 文档地址: http://localhost:3000/docs'); + console.log('🏥 健康检查: http://localhost:3000/api/health'); + console.log('📡 API 版本信息: http://localhost:3000/api/version'); }) - .state('db', '一个方法') - .decorate('closeDB', () => console.log('关闭方法')); // 添加关闭方法 - - -// app.closeDB() 可以以在路由中调用 + .onStop(() => { + console.log('👋 Elysia 服务正在关闭...'); + }); diff --git a/src/controllers/try/auth.controller.ts b/src/controllers/try/auth.controller.ts deleted file mode 100644 index 18d55fd..0000000 --- a/src/controllers/try/auth.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @file 用户认证控制器(试运行) - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 提供基础登录接口,集成JWT签发与参数校验,业务逻辑分离到service - */ - -import { Elysia } from 'elysia'; -import { jwtPlugin } from '@/plugins/jwt.plugins'; -import { loginBodySchema, type LoginBody } from '@/validators/try/auth.validator'; -import { loginResponse200Schema, loginResponse400Schema } from '@/validators/try/auth.response'; -import { loginService } from '@/services/try/auth.service'; - -/** - * 登录接口 - * @param app Elysia实例 - * @returns Elysia实例(带登录路由) - */ -export const authController = new Elysia() - .use(jwtPlugin) - .post('/api/login', ({ body, jwt, set }: { body: LoginBody; jwt: any; set: any }) => loginService(body, jwt, set), { - body: loginBodySchema, - detail: { - tags: ['认证'], - summary: '用户登录', - description: '基础登录接口,用户名/密码校验通过后返回JWT', - }, - response: { - 200: loginResponse200Schema, - 400: loginResponse400Schema, - }, - }); diff --git a/src/controllers/try/protected.controller.ts b/src/controllers/try/protected.controller.ts deleted file mode 100644 index 6996682..0000000 --- a/src/controllers/try/protected.controller.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file 受保护接口控制器(试运行) - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 提供需要JWT认证的受保护接口,业务逻辑分离到service - */ - -import { Elysia } from 'elysia'; -import { jwtAuthPlugin } from '@/plugins/jwt.plugins'; -import { protectedResponse200Schema, protectedResponse401Schema } from '@/validators/try/protected.response'; -import { protectedService } from '@/services/try/protected.service'; - -/** - * 受保护接口 - * @param app Elysia实例 - * @returns Elysia实例(带受保护路由) - */ -export const protectedController = new Elysia() - .use(jwtAuthPlugin) - .get('/api/protected', ({ user }: any) => protectedService(user), { - detail: { - summary: '受保护接口', - tags: ['认证'], - description: '需要JWT认证的受保护接口,返回当前用户信息', - security: [{ bearerAuth: [] }], - }, - response: { - 200: protectedResponse200Schema, - 401: protectedResponse401Schema, - }, - }); diff --git a/src/controllers/health.controller.ts b/src/modules/health/health.controller.ts similarity index 64% rename from src/controllers/health.controller.ts rename to src/modules/health/health.controller.ts index 9ac0cd9..f192d17 100644 --- a/src/controllers/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -8,27 +8,24 @@ */ import { Elysia } from 'elysia'; -import { healthService } from '@/services/health.service'; -import { healthResponse } from '@/validators/health.response'; +import { healthService } from '@/modules/health/health.service'; /** * 健康检查控制器 * 提供系统健康状态检查接口 */ -export const healthController = new Elysia({ prefix: '/api' }) - .get('/health', async (ctx) => await healthService.getHealthStatus(ctx), { +export const healthController = new Elysia() + .get('/', async (ctx) => await healthService.getHealthStatus(ctx), { detail: { tags: ['健康检查'], summary: '获取系统健康状态', description: '检查系统及各依赖服务的健康状态,包括数据库、Redis等', }, - response: healthResponse, }) - .get('/health/detailed', async (ctx) => await healthService.getDetailedHealthStatus(ctx), { + .get('/detailed', async (ctx) => await healthService.getDetailedHealthStatus(ctx), { detail: { tags: ['健康检查'], summary: '获取详细健康状态', description: '获取系统详细健康状态,包括性能指标、资源使用情况等', }, - response: healthResponse, }); \ No newline at end of file diff --git a/src/services/health.service.ts b/src/modules/health/health.service.ts similarity index 95% rename from src/services/health.service.ts rename to src/modules/health/health.service.ts index 4e43fd3..53e1600 100644 --- a/src/services/health.service.ts +++ b/src/modules/health/health.service.ts @@ -10,7 +10,7 @@ import type { Context } from 'elysia'; import { Redis } from '@/utils/redis'; import { pool } from '@/utils/mysql'; -import { Logger } from '@/utils/logger'; +import { Logger } from '@/plugins/logger/logger.service'; // 临时内联类型定义 interface ComponentStatus { diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 0000000..6ef6b67 --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,35 @@ +/** + * @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 { sampleController } from './sample/example.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('/sample', (app) => app.use(sampleController)); diff --git a/src/modules/sample/example.controller.ts b/src/modules/sample/example.controller.ts new file mode 100644 index 0000000..48655da --- /dev/null +++ b/src/modules/sample/example.controller.ts @@ -0,0 +1,53 @@ +/** + * @file 样例控制器 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 样例接口控制器,演示完整的接口开发流程 + */ + +import { Elysia } from 'elysia'; +import { jwtAuthPlugin } from '@/plugins/jwt/jwt.plugins'; +import { GetUserByUsernameSchema } from './example.schema'; +import { GetUserByUsernameResponses } from './example.response'; +import { tags } from '@/modules/tags'; +import { exampleService } from './example.service'; + +/** + * 样例控制器 + * @description 提供样例接口的路由定义和请求处理逻辑 + * @modification hotok 2025-06-29 实现根据用户名查询用户接口 + */ +export const sampleController = new Elysia() + // 使用JWT认证插件 + .use(jwtAuthPlugin) + /** + * 根据用户名查询用户信息 + * @route GET /api/sample/user/:username + * @description 通过用户名查询用户的详细信息,需要JWT认证 + * @param username 用户名,路径参数,长度2-50字符 + * @returns 用户信息对象或错误响应 + * @modification hotok 2025-06-29 初始实现 + */ + .get( + '/user/:username', + ({ params, user }) => { + return exampleService.findUserByUsername({ params, user }); + }, + { + // 路径参数验证 + params: GetUserByUsernameSchema, + + // API文档配置 + detail: { + summary: '根据用户名查询用户信息', + description: + '通过用户名查询用户的详细信息,需要JWT身份认证。返回用户的基本信息,不包含敏感数据如密码。', + tags: [tags.user, tags.sample], + security: [{ bearerAuth: [] }], + }, + // 响应格式定义 + response: GetUserByUsernameResponses, + }, + ); diff --git a/src/modules/sample/example.response.ts b/src/modules/sample/example.response.ts new file mode 100644 index 0000000..606630c --- /dev/null +++ b/src/modules/sample/example.response.ts @@ -0,0 +1,82 @@ +/** + * @file 样例接口响应Schema定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 样例接口的响应结构定义,基于全局响应格式扩展 + */ + +import { t } from 'elysia'; +import { UserInfoSchema } from './example.schema'; + +/** + * 根据用户名查询用户成功响应 + */ +export const GetUserByUsernameSuccessResponse = t.Object({ + code: t.Literal(0, { + description: '成功响应码', + }), + message: t.String({ + description: '成功消息', + examples: ['查询用户成功'], + }), + data: UserInfoSchema, +}); + +/** + * 根据用户名查询用户失败响应 + */ +export const GetUserByUsernameErrorResponse = t.Object({ + code: t.Number({ + description: '错误响应码', + examples: [400, 401, 404], + }), + message: t.String({ + description: '错误消息', + examples: ['用户不存在', '参数验证失败', '身份认证失败'], + }), + data: t.Null({ + description: '错误时数据字段为null', + }), +}); + +/** + * 根据用户名查询用户接口的所有可能响应 + */ +export const GetUserByUsernameResponses = { + /** 200 查询成功 */ + 200: GetUserByUsernameSuccessResponse, + /** 400 业务错误 */ + 400: t.Object({ + code: t.Literal(400), + message: t.String({ + examples: ['用户不存在', '用户状态异常'], + }), + data: t.Null(), + }), + /** 401 认证失败 */ + 401: t.Object({ + code: t.Literal(401), + message: t.String({ + examples: ['身份认证失败,请重新登录', 'Token已过期'], + }), + data: t.Null(), + }), + /** 422 参数验证失败 */ + 422: t.Object({ + code: t.Literal(422), + message: t.String({ + examples: ['用户名长度必须在2-50字符之间', '用户名不能为空'], + }), + data: t.Null(), + }), + /** 500 服务器内部错误 */ + 500: t.Object({ + code: t.Literal(500), + message: t.String({ + examples: ['服务器内部错误,请稍后重试', '数据库查询失败'], + }), + data: t.Null(), + }), +}; \ No newline at end of file diff --git a/src/modules/sample/example.schema.ts b/src/modules/sample/example.schema.ts new file mode 100644 index 0000000..be29457 --- /dev/null +++ b/src/modules/sample/example.schema.ts @@ -0,0 +1,79 @@ +/** + * @file 样例接口Schema定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 样例接口的请求参数和响应数据的TypeBox schema定义 + */ + +import { t, type Static } from 'elysia'; + +/** + * 根据用户名查询用户的请求参数Schema + */ +export const GetUserByUsernameSchema = t.Object({ + /** 用户名,必填,长度2-50字符 */ + username: t.String({ + minLength: 2, + maxLength: 50, + description: '用户名,用于查询用户信息', + examples: ['admin', 'testuser', 'zhangsan'], + }), +}); + +/** + * 用户信息返回数据Schema + */ +export const UserInfoSchema = t.Object({ + /** 用户ID */ + id: t.Number({ + description: '用户唯一标识ID', + examples: [1, 2, 100], + }), + /** 用户名 */ + username: t.String({ + description: '用户名', + examples: ['admin', 'testuser'], + }), + /** 邮箱 */ + email: t.String({ + description: '用户邮箱', + examples: ['admin@example.com', 'user@test.com'], + }), + /** 用户昵称 */ + nickname: t.Optional(t.String({ + description: '用户昵称', + examples: ['管理员', '测试用户'], + })), + /** 用户头像URL */ + avatar: t.Optional(t.String({ + description: '用户头像URL', + examples: ['https://example.com/avatar.jpg'], + })), + /** 用户状态:0-禁用,1-启用 */ + status: t.Number({ + description: '用户状态,0-禁用,1-启用', + examples: [0, 1], + }), + /** 创建时间 */ + createdAt: t.String({ + description: '用户创建时间', + examples: ['2024-06-29T10:30:00.000Z'], + }), + /** 更新时间 */ + updatedAt: t.String({ + description: '用户最后更新时间', + examples: ['2024-06-29T10:30:00.000Z'], + }), +}); + +/** + * 根据用户名查询用户的请求参数类型 + */ +export type GetUserByUsernameType = Static; + +/** + * 用户信息数据类型 + */ +export type UserInfoType = Static; \ No newline at end of file diff --git a/src/modules/sample/example.service.ts b/src/modules/sample/example.service.ts new file mode 100644 index 0000000..a547fe7 --- /dev/null +++ b/src/modules/sample/example.service.ts @@ -0,0 +1,86 @@ +/** + * @file 样例业务逻辑服务 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 样例接口的业务逻辑实现 + * + * 设计思路: + * 1. 接收用户名参数,对参数进行基础校验 + * 2. 使用Drizzle ORM查询数据库中的用户信息 + * 3. 处理查询结果:用户存在则返回用户信息,不存在则抛出业务异常 + * 4. 对敏感信息进行过滤,不返回密码哈希等敏感字段 + * 5. 统一异常处理,确保返回标准的错误响应格式 + * 6. 记录操作日志,便于系统监控和问题排查 + * + * 安全考虑: + * - 严格过滤敏感信息,不向客户端返回密码哈希 + * - 对查询参数进行SQL注入防护(Drizzle ORM自带防护) + * - 记录查询日志,便于安全审计 + */ + +import { eq } from 'drizzle-orm'; +import { db } from '@/plugins/drizzle/drizzle.service'; +import { users } from '@/plugins/drizzle/schema/users'; +import { ERROR_CODES } from '@/validators/global.response'; +import { type GetUserByUsernameType } from './example.schema'; +import type { JwtUserType } from '@/type/jwt.type'; + +/** + * 样例服务类 + * @description 提供用户相关的业务逻辑处理 + */ +export class ExampleService { + async findUserByUsername({ params, user }: { params: GetUserByUsernameType; user: JwtUserType }) { + const { username } = params; + user; + // 使用Drizzle ORM查询用户信息 + const userList = await db + .select({ + id: users.id, + username: users.username, + email: users.email, + nickname: users.nickname, + avatar: users.avatar, + status: users.status, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + + // 检查查询结果 + if (!userList || userList.length === 0) { + return { + code: 400 as const, + message: '用户不存在', + data: null, + }; + } + + const userInfo = userList[0]; + + // 返回成功响应 + return { + code: ERROR_CODES.SUCCESS, + message: '查询用户成功', + data: { + id: userInfo.id, + username: userInfo.username, + email: userInfo.email, + nickname: userInfo.nickname || undefined, + avatar: userInfo.avatar || undefined, + status: userInfo.status, + createdAt: userInfo.createdAt.toISOString(), + updatedAt: userInfo.updatedAt.toISOString(), + }, + }; + } +} + +/** + * 导出样例服务实例 + */ +export const exampleService = new ExampleService(); diff --git a/src/modules/sample/example.test.ts b/src/modules/sample/example.test.ts new file mode 100644 index 0000000..e8ef24f --- /dev/null +++ b/src/modules/sample/example.test.ts @@ -0,0 +1,188 @@ +/** + * @file 样例接口测试用例 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 样例接口的完整测试用例,覆盖正常、异常、边界场景 + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { Elysia } from 'elysia'; +import { sampleController } from './example.controller'; +import { jwtPlugin } from '@/plugins/jwt/jwt.plugins'; + +// 创建测试应用实例 +const app = new Elysia() + .use(jwtPlugin) + .use(sampleController); + +// 测试用的JWT Token(需要根据实际情况生成) +let testToken = ''; + +describe('样例接口测试', () => { + beforeAll(async () => { + // 在实际测试中,这里应该通过登录接口获取有效token + // 这里为了演示,假设我们有一个有效的token + // 创建临时的JWT实例来生成测试token + const tempApp = new Elysia().use(jwtPlugin); + const context = { jwt: tempApp.derive().jwt }; + + testToken = await context.jwt.sign({ + userId: 1, + username: 'admin', + iat: Math.floor(Date.now() / 1000), + }); + }); + + describe('GET /api/sample/user/:username', () => { + it('应该成功查询存在的用户', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/admin', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${testToken}`, + }, + }), + ); + + const body = (await res.json()) as any; + console.log('成功查询响应:', body); + + expect(res.status).toBe(200); + expect(body.code).toBe(0); + expect(body.message).toBe('查询用户成功'); + expect(body.data).toBeDefined(); + expect(typeof body.data.id).toBe('number'); + expect(typeof body.data.username).toBe('string'); + expect(typeof body.data.email).toBe('string'); + }); + + it('用户名过短应返回422', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/a', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${testToken}`, + }, + }), + ); + + const body = (await res.json()) as any; + console.log('用户名过短响应:', body); + + expect(res.status).toBe(422); + expect(body.code).toBe(422); + expect(body.message).toMatch(/用户名/); + }); + + it('用户名过长应返回422', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/' + 'a'.repeat(51), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${testToken}`, + }, + }), + ); + + const body = (await res.json()) as any; + console.log('用户名过长响应:', body); + + expect(res.status).toBe(422); + expect(body.code).toBe(422); + expect(body.message).toMatch(/用户名/); + }); + + it('用户不存在应返回400', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/nonexistentuser12345', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${testToken}`, + }, + }), + ); + + const body = (await res.json()) as any; + console.log('用户不存在响应:', body); + + expect(res.status).toBe(400); + expect(body.code).toBe(400); + expect(body.message).toBe('用户不存在'); + expect(body.data).toBeNull(); + }); + + it('缺少Authorization头应返回401', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/admin', { + method: 'GET', + }), + ); + + const body = (await res.json()) as any; + console.log('缺少Authorization响应:', body); + + expect(res.status).toBe(401); + expect(body.code).toBe(401); + expect(body.message).toMatch(/Token|认证|授权/); + }); + + it('无效Token应返回401', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/admin', { + method: 'GET', + headers: { + Authorization: 'Bearer invalid_token_here', + }, + }), + ); + + const body = (await res.json()) as any; + console.log('无效Token响应:', body); + + expect(res.status).toBe(401); + expect(body.code).toBe(401); + expect(body.message).toMatch(/Token|认证|授权/); + }); + + it('错误的Authorization格式应返回401', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/user/admin', { + method: 'GET', + headers: { + Authorization: 'InvalidFormat token', + }, + }), + ); + + const body = (await res.json()) as any; + console.log('错误Authorization格式响应:', body); + + expect(res.status).toBe(401); + expect(body.code).toBe(401); + expect(body.message).toMatch(/Token|认证|授权/); + }); + }); + + describe('GET /api/sample/health', () => { + it('应该返回模块健康状态', async () => { + const res = await app.fetch( + new Request('http://localhost/sample/health', { + method: 'GET', + }), + ); + + const body = (await res.json()) as any; + console.log('健康检查响应:', body); + + expect(res.status).toBe(200); + expect(body.code).toBe(0); + expect(body.message).toBe('样例模块运行正常'); + expect(body.data).toBeDefined(); + expect(body.data.module).toBe('sample'); + expect(body.data.status).toBe('healthy'); + expect(typeof body.data.timestamp).toBe('string'); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/tags.ts b/src/modules/tags.ts new file mode 100644 index 0000000..f58d3b4 --- /dev/null +++ b/src/modules/tags.ts @@ -0,0 +1,36 @@ +/** + * @file API文档标签定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 统一管理Swagger文档中的标签,便于接口分类和文档组织 + */ + +/** + * API文档标签枚举 + * @description 用于Swagger文档的接口分类 + */ +export const tags = { + /** 用户相关接口 */ + user: 'User', + /** 认证相关接口 */ + auth: 'Auth', + /** 健康检查接口 */ + health: 'Health', + /** 测试接口 */ + test: 'Test', + /** 样例接口 */ + sample: 'Sample', + /** 文件上传接口 */ + upload: 'Upload', + /** 系统管理接口 */ + system: 'System', + /** 权限管理接口 */ + permission: 'Permission', +} as const; + +/** + * 标签类型定义 + */ +export type ApiTag = typeof tags[keyof typeof tags]; \ No newline at end of file diff --git a/src/modules/test/test.controller.ts b/src/modules/test/test.controller.ts new file mode 100644 index 0000000..00419ff --- /dev/null +++ b/src/modules/test/test.controller.ts @@ -0,0 +1,5 @@ +import { Elysia } from 'elysia'; + +export const testController = new Elysia({ name: 'testController' }) + .get('/', () => ({ message: '验证性接口' })) + .get('/hello', () => ({ message: 'hello' })); diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..b50728c --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -0,0 +1,3 @@ +import { Elysia } from 'elysia'; + +export const userController = new Elysia({ name: 'userController' }).get('/', () => ({ message: '用户系统' })); diff --git a/src/plugins/drizzle/README.md b/src/plugins/drizzle/README.md new file mode 100644 index 0000000..ae5c99a --- /dev/null +++ b/src/plugins/drizzle/README.md @@ -0,0 +1,121 @@ +# 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) \ No newline at end of file diff --git a/src/plugins/drizzle/drizzle.config.ts b/src/plugins/drizzle/drizzle.config.ts new file mode 100644 index 0000000..0e290e5 --- /dev/null +++ b/src/plugins/drizzle/drizzle.config.ts @@ -0,0 +1,40 @@ +/** + * @file Drizzle配置文件 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description Drizzle Kit配置,用于数据库迁移和代码生成 + */ + +import { dbConfig } from '@/config'; + +/** + * Drizzle Kit配置对象 + * 使用前需要安装: bun add drizzle-kit -D + */ +export default { + /** 数据库类型 */ + dialect: 'mysql', + + /** 数据库连接配置 */ + dbCredentials: { + host: dbConfig.host, + port: dbConfig.port, + user: dbConfig.user, + password: dbConfig.password, + database: dbConfig.database, + }, + + /** Schema文件路径 */ + schema: './src/plugins/drizzle/schema/*', + + /** 迁移文件输出目录 */ + out: './drizzle', + + /** 详细日志 */ + verbose: true, + + /** 严格模式 */ + strict: true, +} as const; \ No newline at end of file diff --git a/src/plugins/drizzle/drizzle.plugins.ts b/src/plugins/drizzle/drizzle.plugins.ts new file mode 100644 index 0000000..d739b94 --- /dev/null +++ b/src/plugins/drizzle/drizzle.plugins.ts @@ -0,0 +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 './schema'; +import { createDrizzleDB } from './drizzle.service'; + +/** + * Drizzle ORM 插件 + * 提供类型安全的数据库操作接口 + */ +export const drizzlePlugin = new Elysia({ name: 'drizzle' }).onStart(async () => { + await createDrizzleDB(); +}); + +/** 导出数据库类型,供其他模块使用 */ +export type DB = typeof schema; +export type DrizzleDB = Awaited>; diff --git a/src/plugins/drizzle/drizzle.service.ts b/src/plugins/drizzle/drizzle.service.ts new file mode 100644 index 0000000..a20eee1 --- /dev/null +++ b/src/plugins/drizzle/drizzle.service.ts @@ -0,0 +1,61 @@ +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 './schema'; +import type { DrizzleDB } from '@/types/drizzle.type'; + +export let db: DrizzleDB; +/** + * 创建MySQL连接池 + */ +const createConnection = async () => { + try { + /** MySQL连接池配置 */ + const connection = mysql.createPool({ + host: dbConfig.host, + port: dbConfig.port, + user: dbConfig.user, + password: dbConfig.password, + database: dbConfig.database, + /** 连接池配置 */ + connectionLimit: 10, + queueLimit: 0, + }); + + Logger.info('MySQL连接池创建成功'); + return connection; + } catch (error) { + Logger.error(error as Error); + throw new Error('MySQL连接池创建失败'); + } +}; + +/** + * 创建Drizzle数据库实例 + */ +export const createDrizzleDB = async () => { + try { + const connection = await createConnection(); + + /** Drizzle数据库实例 */ + db = drizzle(connection, { + schema, + mode: 'default', + logger: { + logQuery: (query, params) => { + Logger.debug( + `SQL: ${query} - Params: ${JSON.stringify(params)}`, + ); + }, + }, + }); + + Logger.info('Drizzle ORM 初始化成功'); + return db; + } catch (error) { + Logger.error(error as Error); + throw new Error('Drizzle ORM 初始化失败'); + } +}; \ No newline at end of file diff --git a/src/plugins/drizzle/schema/index.ts b/src/plugins/drizzle/schema/index.ts new file mode 100644 index 0000000..724a049 --- /dev/null +++ b/src/plugins/drizzle/schema/index.ts @@ -0,0 +1,15 @@ +/** + * @file 数据库Schema总入口 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 导出所有数据库表的schema定义 + */ + +// 导出用户表schema +export * from './users'; + +// 其他表schema示例 +// export * from './posts'; +// export * from './comments'; \ No newline at end of file diff --git a/src/plugins/drizzle/schema/users.ts b/src/plugins/drizzle/schema/users.ts new file mode 100644 index 0000000..2254ef0 --- /dev/null +++ b/src/plugins/drizzle/schema/users.ts @@ -0,0 +1,48 @@ +/** + * @file 用户表Schema定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 用户表的Drizzle ORM schema定义 + */ + +import { mysqlTable, int, varchar, timestamp, text, tinyint } from 'drizzle-orm/mysql-core'; + +/** + * 用户表 + */ +export const users = mysqlTable('users', { + /** 用户ID,主键,自增 */ + id: int('id').primaryKey().autoincrement(), + + /** 用户名,唯一索引 */ + username: varchar('username', { length: 50 }).notNull().unique(), + + /** 邮箱,唯一索引 */ + email: varchar('email', { length: 100 }).notNull().unique(), + + /** 密码哈希 */ + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + + /** 用户昵称 */ + nickname: varchar('nickname', { length: 50 }), + + /** 用户头像URL */ + avatar: text('avatar'), + + /** 用户状态:0-禁用,1-启用 */ + status: tinyint('status').default(1).notNull(), + + /** 创建时间 */ + createdAt: timestamp('created_at').defaultNow().notNull(), + + /** 更新时间 */ + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(), +}); + +/** 用户表类型 */ +export type User = typeof users.$inferSelect; + +/** 插入用户类型 */ +export type InsertUser = typeof users.$inferInsert; \ No newline at end of file diff --git a/src/plugins/errorHandler.plugins.ts b/src/plugins/errorHandle/errorHandler.plugins.ts similarity index 90% rename from src/plugins/errorHandler.plugins.ts rename to src/plugins/errorHandle/errorHandler.plugins.ts index f3c69d5..8793d0f 100644 --- a/src/plugins/errorHandler.plugins.ts +++ b/src/plugins/errorHandle/errorHandler.plugins.ts @@ -8,19 +8,17 @@ */ import { Elysia } from 'elysia'; -import { ENV } from '@/config'; -const isDevelopment = ENV === 'development'; /** * 全局错误处理插件 */ export const errorHandlerPlugin = (app: Elysia) => - app.onError(({ error, set, code, log }) => { + app.onError(({ error, set, code, log, env }) => { switch (code) { case 'VALIDATION': { set.status = 400; let errors = null as any; - if (isDevelopment) { + if (env === 'development') { errors = error.all.map((err) => ({ field: err.path?.slice(1) || 'root', message: err.message, @@ -66,7 +64,7 @@ export const errorHandlerPlugin = (app: Elysia) => set.status = error.code; return { code: error.code, - message: error.response.message || '服务器内部错误', + message: error.response.message || error.response || '服务器内部错误', errors: error, }; } diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..612435c --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,25 @@ +/** + * @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'; + +export const plugins = (app: Elysia) => + app + // 日志插件 + .use(loggerPlugin) + // 错误处理插件 + .use(errorHandlerPlugin) + // 数据库插件 + .use(drizzlePlugin) + // API 文档插件 + .use(swaggerPlugin); diff --git a/src/plugins/jwt.plugins.ts b/src/plugins/jwt/jwt.plugins.ts similarity index 52% rename from src/plugins/jwt.plugins.ts rename to src/plugins/jwt/jwt.plugins.ts index 95569a4..83996fc 100644 --- a/src/plugins/jwt.plugins.ts +++ b/src/plugins/jwt/jwt.plugins.ts @@ -3,12 +3,13 @@ * @author hotok * @date 2025-06-28 * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 封装Elysia JWT插件,统一配置密钥 + * @lastEditTime 2025-06-29 + * @description 封装Elysia JWT插件,统一配置密钥,提供类型安全的JWT认证 */ import { Elysia } from 'elysia'; import { jwt } from '@elysiajs/jwt'; import { jwtConfig } from '@/config/jwt.config'; +import type { JwtUserType, JwtPayloadType } from '@/type/jwt.type'; export const jwtPlugin = jwt({ name: 'jwt', @@ -26,11 +27,22 @@ export const jwtAuthPlugin = (app: Elysia) => } const token = authHeader.replace('Bearer ', ''); try { - const user = await jwt.verify(token); - if (!user) return status(401, 'Token无效'); - return { user }; + const payload = await jwt.verify(token) as JwtPayloadType | false; + if (!payload) return status(401, 'Token无效'); + + // 提取用户信息 + const user: JwtUserType = { + userId: payload.userId, + username: payload.username, + email: payload.email, + nickname: payload.nickname, + status: payload.status, + role: payload.role, + }; + + return { user } as const; } catch { - return {}; + return status(401, 'Token无效'); } }) .onBeforeHandle(({ user, status }) => { diff --git a/src/plugins/jwt/jwt.service.ts b/src/plugins/jwt/jwt.service.ts new file mode 100644 index 0000000..864ba2d --- /dev/null +++ b/src/plugins/jwt/jwt.service.ts @@ -0,0 +1,177 @@ +/** + * @file JWT服务类 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 提供类型安全的JWT生成、验证和管理功能 + */ + +import { jwtConfig } from '@/config/jwt.config'; +import { Logger } from '@/plugins/logger/logger.service'; +import type { + JwtUserType, + JwtPayloadType, + JwtSignOptionsType, +} from '@/type/jwt.type'; +import type { UserInfoType } from '@/modules/sample/example.schema'; + +/** + * JWT服务类 + * @description 提供JWT Token的生成、验证、刷新等功能 + */ +export class JwtService { + /** + * 生成JWT Token + * @param userInfo 完整的用户信息 + * @param options 可选的JWT配置 + * @returns Promise JWT Token字符串 + * @modification hotok 2025-06-29 初始实现JWT生成功能 + */ + async generateToken(userInfo: UserInfoType, options?: Partial): Promise { + try { + // 从完整用户信息提取JWT载荷所需的字段 + const jwtUser: JwtUserType = { + userId: userInfo.id, + username: userInfo.username, + email: userInfo.email, + nickname: userInfo.nickname, + status: userInfo.status, + role: options?.user?.role, // 如果有传入角色信息 + }; + + // 构建JWT载荷 + const payload: Omit = { + ...jwtUser, + sub: userInfo.id.toString(), + iss: options?.issuer || 'elysia-api', + aud: options?.audience || 'web-client', + }; + + // 注意:实际的token生成需要使用Elysia的jwt实例 + // 这里提供接口定义,具体实现需要在controller中使用jwt.sign + const token = 'generated-token-placeholder'; + + Logger.info(`JWT Token生成成功,用户ID: ${userInfo.id}, 用户名: ${userInfo.username}`); + return token; + + } catch (error) { + Logger.error(new Error(`JWT Token生成失败: ${error}`)); + throw new Error('Token生成失败'); + } + } + + /** + * 验证JWT Token + * @param token JWT Token字符串 + * @returns Promise 验证成功返回载荷,失败返回null + * @modification hotok 2025-06-29 初始实现JWT验证功能 + */ + async verifyToken(token: string): Promise { + try { + // 注意:实际的token验证需要使用Elysia的jwt实例 + // 这里提供接口定义,具体实现需要在controller中使用jwt.verify + const payload = null as any as JwtPayloadType; + + if (!payload || !payload.userId) { + Logger.warn(`JWT Token验证失败:载荷无效`); + return null; + } + + // 检查Token是否过期 + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) { + Logger.warn(`JWT Token已过期,用户ID: ${payload.userId}`); + return null; + } + + Logger.info(`JWT Token验证成功,用户ID: ${payload.userId}`); + return payload; + + } catch (error) { + Logger.warn(`JWT Token验证失败: ${error}`); + return null; + } + } + + /** + * 刷新JWT Token + * @param oldToken 旧的JWT Token + * @param userInfo 最新的用户信息 + * @returns Promise 新的JWT Token,失败返回null + * @modification hotok 2025-06-29 初始实现JWT刷新功能 + */ + async refreshToken(oldToken: string, userInfo: UserInfoType): Promise { + try { + // 验证旧Token + const oldPayload = await this.verifyToken(oldToken); + if (!oldPayload) { + Logger.warn('Token刷新失败:旧Token无效'); + return null; + } + + // 生成新Token + const newToken = await this.generateToken(userInfo); + + Logger.info(`JWT Token刷新成功,用户ID: ${userInfo.id}`); + return newToken; + + } catch (error) { + Logger.error(new Error(`JWT Token刷新失败: ${error}`)); + return null; + } + } + + /** + * 提取JWT载荷中的用户信息 + * @param payload JWT载荷 + * @returns JwtUserType 用户信息 + * @modification hotok 2025-06-29 添加用户信息提取功能 + */ + extractUserFromPayload(payload: JwtPayloadType): JwtUserType { + return { + userId: payload.userId, + username: payload.username, + email: payload.email, + nickname: payload.nickname, + status: payload.status, + role: payload.role, + }; + } + + /** + * 检查用户Token是否即将过期 + * @param payload JWT载荷 + * @param thresholdMinutes 阈值分钟数(默认30分钟) + * @returns boolean 是否即将过期 + * @modification hotok 2025-06-29 添加Token过期检查功能 + */ + isTokenExpiringSoon(payload: JwtPayloadType, thresholdMinutes: number = 30): boolean { + if (!payload.exp) return false; + + 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 添加Token时间计算功能 + */ + 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; + } +} + +/** + * 导出JWT服务实例 + */ +export const jwtService = new JwtService(); \ No newline at end of file diff --git a/src/plugins/logger.plugins.ts b/src/plugins/logger.plugins.ts deleted file mode 100644 index ab8a377..0000000 --- a/src/plugins/logger.plugins.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @file HTTP请求日志插件 - * @author hotok - * @date 2024-01-15 - * @lastEditor hotok - * @lastEditTime 2024-01-15 - * @description 记录HTTP请求和响应的详细信息,包括请求时间、响应时间、客户端信息等 - */ - -import { Elysia } from 'elysia'; -import Logger from '@/utils/logger'; -import { browserInfo } from '@/utils/deviceInfo'; -import { formatFileSize } from '@/utils/formatFileSize'; -import { getRandomBackgroundColor } from '@/utils/randomChalk'; - -/** - * HTTP请求日志插件 - * @description 为Elysia应用添加请求和响应日志功能 - * @param app Elysia应用实例 - * @returns 配置了日志功能的Elysia应用 - * @example - * const app = new Elysia().use(loggerPlugin) - */ -const loggerPlugin = (app: Elysia) => - app - /** 注册日志实例到应用状态 */ - .decorate('log', Logger) - /** 注册请求开始时间到应用状态,用于计算响应时间 */ - .state('requestStart', null as [number, number] | null) - .state('color', null as string | null) - /** 请求拦截器 - 记录请求信息 */ - .onRequest(({ store: { requestStart, color }, request, server, path, log }) => { - /** 记录请求开始时间 */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - requestStart = process.hrtime(); - /** 获取客户端IP信息 */ - const clientIP = server?.requestIP(request); - color = getRandomBackgroundColor()(' '); - /** 记录请求日志 */ - log.http({ - type: 'request', - method: request.method, - path, - color: color, - ip: `${clientIP?.family}: ${clientIP?.address}:${clientIP?.port}`, - browser: browserInfo(request.headers.get('user-agent')), - }); - }) - /** 响应拦截器 - 记录响应信息 */ - .onAfterResponse(({ log, store: { requestStart, color }, request, set, response, path }) => { - if (requestStart) { - /** 计算请求处理时间 */ - const [seconds, nanoseconds] = process.hrtime(requestStart); - const duration = seconds * 1000 + nanoseconds / 1000000; - - /** 记录响应日志 */ - log.http({ - type: 'response', - method: request.method, - path, - color: color, - statusCode: set.status || 200, - requestTime: `${duration.toFixed(2)}ms`, - responseSize: getResponseSize(response), - }); - } - }); - -function getResponseSize(response: Response) { - 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 loggerPlugin; diff --git a/src/plugins/logger/logger.plugins.ts b/src/plugins/logger/logger.plugins.ts new file mode 100644 index 0000000..ba97079 --- /dev/null +++ b/src/plugins/logger/logger.plugins.ts @@ -0,0 +1,61 @@ +/** + * @file HTTP请求日志插件 + * @author hotok + * @date 2024-01-15 + * @lastEditor hotok + * @lastEditTime 2024-01-15 + * @description 记录HTTP请求和响应的详细信息,包括请求时间、响应时间、客户端信息等 + */ + +import { Elysia } from 'elysia'; +import { browserInfo } from '@/utils/deviceInfo'; +import { getRandomBackgroundColor } from '@/utils/randomChalk'; +import Logger, { getResponseSize } from '@/plugins/logger/logger.service'; + +/** + * HTTP请求日志插件 + * @description 为Elysia应用添加请求和响应日志功能 + * @param app Elysia应用实例 + * @returns 配置了日志功能的Elysia应用 + * @example + * const app = new Elysia().use(loggerPlugin) + */ +export const loggerPlugin = (app: Elysia) => + app + /** 注册请求开始时间到应用状态,用于计算响应时间 */ + .state('requestStart', null as [number, number] | null) + .state('color', null as string | null) + /** 请求拦截器 - 记录请求信息 */ + .onRequest(({ store, request, server, path,}) => { + /** 记录请求开始时间 */ + store.requestStart = process.hrtime(); + /** 获取客户端IP信息 */ + const clientIP = server?.requestIP(request); + store.color = getRandomBackgroundColor()(' '); + /** 记录请求日志 */ + Logger.http({ + type: 'request', + method: request.method, + path, + color: store.color, + ip: `${clientIP?.family}: ${clientIP?.address}:${clientIP?.port}`, + browser: browserInfo(request.headers.get('user-agent')), + }); + }) + /** 响应拦截器 - 记录响应信息 */ + .onAfterResponse(({ store, request, set, response, path }) => { + /** 计算请求处理时间 */ + const [seconds, nanoseconds] = process.hrtime(store.requestStart); + const duration = seconds * 1000 + nanoseconds / 1000000; + + /** 记录响应日志 */ + Logger.http({ + type: 'response', + method: request.method, + path, + color: store.color, + statusCode: set.status || 200, + requestTime: `${duration.toFixed(2)}ms`, + responseSize: getResponseSize(response), + }); + }); diff --git a/src/utils/logger.ts b/src/plugins/logger/logger.service.ts similarity index 82% rename from src/utils/logger.ts rename to src/plugins/logger/logger.service.ts index 74409d2..6117c91 100644 --- a/src/utils/logger.ts +++ b/src/plugins/logger/logger.service.ts @@ -1,255 +1,254 @@ -/** - * @file Winston日志器工具类 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 基于winston的高性能日志记录器,支持分环境输出、按日期轮转、彩色美化 - */ - -import winston from 'winston'; -import DailyRotateFile from 'winston-daily-rotate-file'; -import { loggerConfig } from '@/config/logger.config'; -import chalk from 'chalk'; -import { centerText } from '@/utils/text'; - -/** - * 日志等级颜色格式化方法 - */ -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' }), - 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.gray(timestamp)}] ${levelFormatted} ${message}\n${formattedStack}`; - } else if (level === 'error') { - return `[${chalk.gray(timestamp)}] ${levelFormatted} 未定义的异常 ${message}`; - } else if (level === 'http') { - return `[${chalk.gray(timestamp)}] ${levelFormatted} ${formatHTTP(message)}`; - } - - return `[${chalk.gray(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, null, 2); -}; - -/** - * 简单日志器类 - */ -export class Logger { - /** - * 调试日志 - * @param message 日志消息,支持字符串或对象 - */ - static debug(message: string | object): void { - logger.debug(formatMessage(message)); - } - - /** - * 信息日志 - * @param message 日志消息,支持字符串或对象 - */ - static info(message: string | object): void { - logger.info(formatMessage(message)); - } - - /** - * 警告日志 - * @param message 日志消息,支持字符串或对象 - */ - static warn(message: string | object): void { - logger.warn(formatMessage(message)); - } - - /** - * 错误日志 - 只接受 Error 对象,自动记录堆栈信息 - * @param error Error 对象,包含错误信息和堆栈 - */ - static error(error: Error): void { - logger.error({ - message: error.message, - stack: error.stack, - name: error.name, - cause: error.cause, - }); - } - - /** - * HTTP日志 - * @param message 日志消息,支持字符串或对象 - */ - static http(message: string | object): void { - logger.http(message); - } - - /** - * 详细日志 - * @param message 日志消息,支持字符串或对象 - */ - static verbose(message: string | object): void { - logger.verbose(formatMessage(message)); - } -} - -// 导出默认实例 -export default Logger; +/** + * @file Winston日志器工具类 + * @author hotok + * @date 2025-06-28 + * @lastEditor hotok + * @lastEditTime 2025-06-28 + * @description 基于winston的高性能日志记录器,支持分环境输出、按日期轮转、彩色美化 + */ + +import winston 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' }), + 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.gray(timestamp)}] ${levelFormatted} ${message}\n${formattedStack}`; + } else if (level === 'error') { + return `[${chalk.gray(timestamp)}] ${levelFormatted} 未定义的异常 ${message}`; + } else if (level === 'http') { + return `[${chalk.gray(timestamp)}] ${levelFormatted} ${formatHTTP(message)}`; + } + + return `[${chalk.gray(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, null, 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; diff --git a/src/plugins/swagger.plugins.ts b/src/plugins/swagger/swagger.plugins.ts similarity index 100% rename from src/plugins/swagger.plugins.ts rename to src/plugins/swagger/swagger.plugins.ts diff --git a/src/server.ts b/src/server.ts index 5984528..8d8a2e3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,4 +10,3 @@ import { app } from './app'; app.listen(3000); -console.log('🚀 服务已启动:http://localhost:3000'); diff --git a/src/services/try/auth.service.ts b/src/services/try/auth.service.ts deleted file mode 100644 index 00f1b4c..0000000 --- a/src/services/try/auth.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file 用户认证业务逻辑 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 登录业务逻辑,供controller调用 - */ - -/** - * 登录业务逻辑 - * @param body 登录请求体 - * @param jwt JWT插件实例 - * @param set Elysia set对象 - * @returns 登录响应对象 - */ -export const loginService = async (body: { username: string; password: string }, jwt: any, set: any) => { - const { username, password } = body; - if (username !== 'admin' || password !== '123456') { - set.status = 400; - return { - code: 400, - message: '用户名或密码错误', - data: null, - }; - } - const token = await jwt.sign({ username }); - return { - code: 0, - message: '登录成功', - data: { token }, - }; -}; diff --git a/src/services/try/protected.service.ts b/src/services/try/protected.service.ts deleted file mode 100644 index d803d58..0000000 --- a/src/services/try/protected.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @file 受保护接口业务逻辑 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 受保护接口业务逻辑,供controller调用 - */ - -/** - * 受保护接口业务逻辑 - * @param store Elysia store对象 - * @returns 受保护接口响应对象 - */ -export const protectedService = (user: any) => { - /** - * @type {any} user - JWT解码后的用户信息 - * @description 由jwtAuthPlugin中间件注入 - */ - console.log('user', user); - return { - code: 0, - message: '受保护资源访问成功', - data: { username: user?.username ?? '' }, - }; -}; diff --git a/src/type/health.type.ts b/src/type/health.type.ts deleted file mode 100644 index e5af296..0000000 --- a/src/type/health.type.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @file 健康检查类型定义 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 健康检查相关的TypeScript类型定义 - */ - -/// - -/** - * 组件健康状态 - */ -export type ComponentHealthStatus = 'healthy' | 'unhealthy' | 'degraded'; - -/** - * 系统整体健康状态 - */ -export type SystemHealthStatus = 'healthy' | 'unhealthy' | 'degraded'; - -/** - * 组件状态信息 - */ -export interface ComponentStatus { - /** 组件状态 */ - status: ComponentHealthStatus; - /** 响应时间(毫秒) */ - responseTime?: number; - /** 错误信息 */ - error?: string; - /** 详细信息 */ - details?: Record; -} - -/** - * 系统信息 - */ -export interface SystemInfo { - /** 操作系统平台 */ - platform: string; - /** 系统架构 */ - arch: string; - /** Node.js版本 */ - nodeVersion: string; - /** 运行时 */ - runtime: string; - /** 进程ID */ - pid: number; - /** 当前工作目录 */ - cwd: string; -} - -/** - * 性能指标 - */ -export interface PerformanceMetrics { - /** CPU使用情况 */ - cpuUsage: { - user: number; - system: number; - }; - /** 内存使用情况 */ - memoryUsage: { - rss: number; - heapTotal: number; - heapUsed: number; - external: number; - arrayBuffers: number; - }; - /** 运行时间(秒) */ - uptime: number; -} - -/** - * 基本健康状态响应 - */ -export interface HealthStatus { - /** 响应码 */ - code: number; - /** 响应消息 */ - message: string; - /** 健康状态数据 */ - data: { - /** 系统整体状态 */ - status: SystemHealthStatus; - /** 时间戳 */ - timestamp: string; - /** 系统运行时间(秒) */ - uptime: number; - /** 响应时间(毫秒) */ - responseTime: number; - /** 版本号 */ - version: string; - /** 环境 */ - environment: string; - /** 错误信息(仅在异常时) */ - error?: string; - /** 各组件状态 */ - components: { - /** MySQL数据库状态 */ - mysql?: ComponentStatus; - /** Redis缓存状态 */ - redis?: ComponentStatus; - /** 其他组件状态 */ - [key: string]: ComponentStatus | undefined; - }; - }; -} - -/** - * 详细健康状态响应 - */ -export interface DetailedHealthStatus extends HealthStatus { - /** 详细健康状态数据 */ - data: HealthStatus['data'] & { - /** 系统信息 */ - system?: SystemInfo; - /** 性能指标 */ - performance?: PerformanceMetrics; - }; -} \ No newline at end of file diff --git a/src/type/jwt.type.ts b/src/type/jwt.type.ts new file mode 100644 index 0000000..9571a97 --- /dev/null +++ b/src/type/jwt.type.ts @@ -0,0 +1,74 @@ +/** + * @file JWT类型定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description JWT Token载荷和用户信息的TypeScript类型定义 + */ + +/** + * JWT Token中的用户信息类型 + * @description 存储在JWT Token中的用户基本信息,不包含敏感数据 + */ +export interface JwtUserType { + /** 用户ID */ + userId: number; + /** 用户名 */ + 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; +} + +/** + * 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; +} \ No newline at end of file diff --git a/src/type/logger.type.ts b/src/type/logger.type.ts deleted file mode 100644 index bbbf83c..0000000 --- a/src/type/logger.type.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 日志配置接口 - * @interface LogConfig - */ -export interface LogConfigType { - /** 日志文件目录 */ - directory: string; - /** 是否输出到控制台 */ - console: boolean; - /** 单个日志文件最大大小 */ - maxSize: string; - /** 最大保留文件数 */ - maxFiles: string; - /** 日志等级 */ - level: string; -} diff --git a/src/utils/formatRoute.ts b/src/utils/formatRoute.ts new file mode 100644 index 0000000..6eda223 --- /dev/null +++ b/src/utils/formatRoute.ts @@ -0,0 +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'); +}; diff --git a/src/utils/jwt.helper.ts b/src/utils/jwt.helper.ts new file mode 100644 index 0000000..19f4771 --- /dev/null +++ b/src/utils/jwt.helper.ts @@ -0,0 +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/sample/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载荷对象(不包含iat、exp等自动生成字段) + * @modification hotok 2025-06-29 创建JWT载荷生成函数 + */ +export function createJwtPayload( + userInfo: UserInfoType, + options?: { + role?: string; + issuer?: string; + audience?: string; + subject?: string; + }, +): Omit { + 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 ? '启用' : '禁用'}`; +} \ No newline at end of file diff --git a/src/utils/redis.ts b/src/utils/redis.ts index 77be806..aa42b8e 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -9,7 +9,7 @@ import { createClient, type RedisClientType } from 'redis'; import { redisConfig, getRedisUrl } from '@/config/redis.config'; -import { Logger } from '@/utils/logger'; +import { Logger } from '@/plugins/logger/logger.service'; /** * Redis客户端实例 diff --git a/src/validators/health.response.ts b/src/validators/health.response.ts deleted file mode 100644 index 3e12cdc..0000000 --- a/src/validators/health.response.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * @file 健康检查响应验证器 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 健康检查接口响应数据结构验证 - */ - -import { t } from 'elysia'; - -/** - * 组件状态验证器 - */ -const componentStatusSchema = t.Object({ - /** 组件状态 */ - status: t.Union([t.Literal('healthy'), t.Literal('unhealthy'), t.Literal('degraded')]), - /** 响应时间(毫秒) */ - responseTime: t.Optional(t.Number()), - /** 错误信息 */ - error: t.Optional(t.String()), - /** 详细信息 */ - details: t.Optional(t.Record(t.String(), t.Any())), -}); - -/** - * 系统信息验证器 - */ -const systemInfoSchema = t.Object({ - /** 操作系统平台 */ - platform: t.String(), - /** 系统架构 */ - arch: t.String(), - /** Node.js版本 */ - nodeVersion: t.String(), - /** 运行时 */ - runtime: t.String(), - /** 进程ID */ - pid: t.Number(), - /** 当前工作目录 */ - cwd: t.String(), -}); - -/** - * 性能指标验证器 - */ -const performanceMetricsSchema = t.Object({ - /** CPU使用情况 */ - cpuUsage: t.Object({ - user: t.Number(), - system: t.Number(), - }), - /** 内存使用情况 */ - memoryUsage: t.Object({ - rss: t.Number(), - heapTotal: t.Number(), - heapUsed: t.Number(), - external: t.Number(), - arrayBuffers: t.Number(), - }), - /** 运行时间(秒) */ - uptime: t.Number(), -}); - -/** - * 基本健康状态响应验证器 - */ -const basicHealthDataSchema = t.Object({ - /** 系统整体状态 */ - status: t.Union([t.Literal('healthy'), t.Literal('unhealthy'), t.Literal('degraded')]), - /** 时间戳 */ - timestamp: t.String(), - /** 系统运行时间(秒) */ - uptime: t.Number(), - /** 响应时间(毫秒) */ - responseTime: t.Number(), - /** 版本号 */ - version: t.String(), - /** 环境 */ - environment: t.String(), - /** 错误信息(仅在异常时) */ - error: t.Optional(t.String()), - /** 各组件状态 */ - components: t.Object({ - /** MySQL数据库状态 */ - mysql: t.Optional(componentStatusSchema), - /** Redis缓存状态 */ - redis: t.Optional(componentStatusSchema), - }), -}); - -/** - * 详细健康状态数据验证器 - */ -const detailedHealthDataSchema = t.Intersect([ - basicHealthDataSchema, - t.Object({ - /** 系统信息 */ - system: t.Optional(systemInfoSchema), - /** 性能指标 */ - performance: t.Optional(performanceMetricsSchema), - }), -]); - -/** - * 健康检查响应验证器 - */ -export const healthResponse = { - 200: t.Object({ - /** 响应码 */ - code: t.Number(), - /** 响应消息 */ - message: t.String(), - /** 健康状态数据 */ - data: basicHealthDataSchema, - }), - 500: t.Object({ - /** 响应码 */ - code: t.Number(), - /** 响应消息 */ - message: t.String(), - /** 错误数据 */ - data: t.Object({ - /** 系统整体状态 */ - status: t.Literal('unhealthy'), - /** 时间戳 */ - timestamp: t.String(), - /** 系统运行时间(秒) */ - uptime: t.Number(), - /** 响应时间(毫秒) */ - responseTime: t.Number(), - /** 版本号 */ - version: t.String(), - /** 环境 */ - environment: t.String(), - /** 错误信息 */ - error: t.String(), - /** 各组件状态 */ - components: t.Object({}), - }), - }), -}; - -/** - * 详细健康检查响应验证器 - */ -export const detailedHealthResponse = { - 200: t.Object({ - /** 响应码 */ - code: t.Number(), - /** 响应消息 */ - message: t.String(), - /** 详细健康状态数据 */ - data: detailedHealthDataSchema, - }), - 500: t.Object({ - /** 响应码 */ - code: t.Number(), - /** 响应消息 */ - message: t.String(), - /** 错误数据 */ - data: t.Object({ - /** 系统整体状态 */ - status: t.Literal('unhealthy'), - /** 时间戳 */ - timestamp: t.String(), - /** 系统运行时间(秒) */ - uptime: t.Number(), - /** 响应时间(毫秒) */ - responseTime: t.Number(), - /** 版本号 */ - version: t.String(), - /** 环境 */ - environment: t.String(), - /** 错误信息 */ - error: t.String(), - /** 各组件状态 */ - components: t.Object({}), - }), - }), -}; \ No newline at end of file diff --git a/src/validators/try/auth.response.ts b/src/validators/try/auth.response.ts deleted file mode 100644 index c83965b..0000000 --- a/src/validators/try/auth.response.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @file 登录接口响应schema - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 登录接口响应结构定义 - */ - -import { t } from 'elysia'; - -/** 登录成功响应schema */ -export const loginResponse200Schema = t.Object({ - code: t.Literal(0), - message: t.String(), - data: t.Object({ token: t.String() }), -}); - -/** 登录失败响应schema */ -export const loginResponse400Schema = t.Object({ - code: t.Literal(400), - message: t.String(), - data: t.Null(), -}); diff --git a/src/validators/try/auth.validator.ts b/src/validators/try/auth.validator.ts deleted file mode 100644 index 553cd43..0000000 --- a/src/validators/try/auth.validator.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @file 用户认证参数校验规则 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 登录接口参数校验规则,含中文错误提示 - */ - -import { t } from 'elysia'; -import type { Static } from 'elysia'; - -/** - * 登录请求参数校验规则 - * @property {string} username - 用户名,最少3位 - * @property {string} password - 密码,最少6位 - */ -export const loginBodySchema = t.Object({ - username: t.String({ minLength: 2, maxLength: 16 }), - password: t.String({ minLength: 6, maxLength: 32 }), -}); - -export type LoginBody = Static; diff --git a/src/validators/try/protected.response.ts b/src/validators/try/protected.response.ts deleted file mode 100644 index 79ca673..0000000 --- a/src/validators/try/protected.response.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @file 受保护接口响应校验规则 - * @author hotok - * @date 2025-06-28 - * @lastEditor hotok - * @lastEditTime 2025-06-28 - * @description 受保护接口的响应类型定义,含成功和未授权两种情况 - */ - -import { t } from 'elysia'; -import type { Static } from 'elysia'; - -/** - * 受保护接口 200 响应 schema - */ -export const protectedResponse200Schema = t.Object({ - code: t.Literal(0), - message: t.String(), - data: t.Object({ - username: t.String(), - // 可根据实际业务扩展字段 - }), -}); - -/** - * 受保护接口 401 响应 schema - */ -export const protectedResponse401Schema = t.Object({ - code: t.Literal(401), - message: t.String(), - data: t.Null(), -}); - -/** - * 受保护接口 200 响应类型 - */ -export type ProtectedResponse200 = Static; - -/** - * 受保护接口 401 响应类型 - */ -export type ProtectedResponse401 = Static; diff --git a/tasks/20250629-计划.md b/tasks/20250629-计划.md new file mode 100644 index 0000000..ada7f10 --- /dev/null +++ b/tasks/20250629-计划.md @@ -0,0 +1,3 @@ +1. 协助ai完成接口 +2. 协助ai完成测试用例 +3. 优化mdc关于drizzle和redis的使用 \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f518ca7..a5276bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,31 +1,29 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": [ + "ESNext" + ], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - // Bundler mode "moduleResolution": "Node", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, - // Paths "esModuleInterop": true, "forceConsistentCasingInFileNames": true, @@ -33,9 +31,22 @@ "outDir": "dist", "rootDir": "src", "paths": { - "@/*": ["src/*"] - } + "@/*": [ + "src/*" + ], + "@package.json": [ + "package.json" + ] + }, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src", + "types/**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/types/config.type.ts b/types/config.type.ts new file mode 100644 index 0000000..72e4a1a --- /dev/null +++ b/types/config.type.ts @@ -0,0 +1,75 @@ +/** + * 数据库配置类型 + */ +export interface DbConfig { + /** 数据库主机地址 */ + host: string; + /** 数据库端口号 */ + port: number; + /** 数据库用户名 */ + user: string; + /** 数据库密码 */ + password: string; + /** 数据库名称 */ + database: string; +} + +/** + * JWT配置类型 + */ +export interface JwtConfig { + /** JWT签名密钥 */ + secret: string; + /** Token有效期 */ + exp: string; +} + +/** + * Redis配置类型 + */ +export interface RedisConfig { + /** Redis连接名称 */ + connectName: string; + /** Redis服务器主机地址 */ + host: string; + /** Redis服务器端口号 */ + port: number; + /** Redis用户名 */ + username: string; + /** Redis密码 */ + password: string; + /** Redis数据库索引 */ + database: number; +} +/** + * 日志配置接口 + * @interface LogConfig + */ +export interface LogConfigType { + /** 日志文件目录 */ + directory: string; + /** 是否输出到控制台 */ + console: boolean; + /** 单个日志文件最大大小 */ + maxSize: string; + /** 最大保留文件数 */ + maxFiles: string; + /** 日志等级 */ + level: string; +} + +/** + * 全局配置类型,包含所有配置项 + */ +export interface GlobalConfig { + /** 数据库配置 */ + db: DbConfig; + /** JWT配置 */ + jwt: JwtConfig; + /** Redis配置 */ + redis: RedisConfig; + /** 日志配置 */ + logger: LogConfigType; + /** 当前环境 */ + env: string; +} diff --git a/types/drizzle.type.ts b/types/drizzle.type.ts new file mode 100644 index 0000000..c9d3ae6 --- /dev/null +++ b/types/drizzle.type.ts @@ -0,0 +1,52 @@ +/** + * @file Drizzle ORM类型定义 + * @author hotok + * @date 2025-06-29 + * @lastEditor hotok + * @lastEditTime 2025-06-29 + * @description 定义Drizzle ORM相关的类型,包括数据库实例和表类型 + */ + +import type { MySql2Database } from 'drizzle-orm/mysql2'; +import type * as schema from '../src/plugins/drizzle/schema'; + +/** + * Drizzle数据库实例类型 + */ +export type DrizzleDB = MySql2Database; + +/** + * 数据库表Schema类型 + */ +export type DatabaseSchema = typeof schema; + +/** + * 扩展Elysia Context,添加数据库实例 + */ +export interface DrizzleContext { + /** Drizzle数据库实例 */ + db: DrizzleDB; +} + +/** + * 数据库连接状态 + */ +export type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; + +/** + * 数据库连接信息 + */ +export interface DatabaseConnectionInfo { + /** 连接状态 */ + status: ConnectionStatus; + /** 连接主机 */ + host: string; + /** 连接端口 */ + port: number; + /** 数据库名称 */ + database: string; + /** 连接时间 */ + connectedAt?: Date; + /** 错误信息 */ + error?: string; +} \ No newline at end of file diff --git a/types/logger.type.ts b/types/logger.type.ts new file mode 100644 index 0000000..3f9a5ff --- /dev/null +++ b/types/logger.type.ts @@ -0,0 +1,18 @@ + +/** + * Logger类的类型定义 + */ +export interface LoggerInstance { + /** 调试级别日志 */ + debug(message: string | object): void; + /** 信息级别日志 */ + info(message: string | object): void; + /** 警告级别日志 */ + warn(message: string | object): void; + /** 错误级别日志 */ + error(error: Error): void; + /** HTTP级别日志 */ + http(message: string | object): void; + /** 详细级别日志 */ + verbose(message: string | object): void; +} \ No newline at end of file