diff --git a/FIXES_SUMMARY.md b/FIXES_SUMMARY.md new file mode 100644 index 00000000..613b30bf --- /dev/null +++ b/FIXES_SUMMARY.md @@ -0,0 +1,190 @@ +# 数据库和API修复总结 + +本次修复解决了数据库表设计和后端API的6个主要问题。 + +## ✅ 已完成的修复 + +### 1. 修复 isActive 数据类型 ✓ + +**问题**: `users.isActive` 字段使用 `text` 类型而不是 `boolean` + +**修复**: +- 文件: `packages/db/src/users.ts` +- 将 `isActive: text('is_active').notNull().default('true')` 改为 `isActive: boolean('is_active').notNull().default(true)` + +### 2. 添加外键约束 ✓ + +**问题**: 缺少重要的外键约束 + +**修复**: +- 文件: `packages/db/src/settings.ts` + - `userId` 字段从 `text` 改为 `uuid`,并添加外键引用 `users.id` +- 文件: `packages/db/src/history.ts` + - `user` 字段从 `text` 改为 `uuid`,并添加外键引用 `users.id` + +### 3. 重构 JWT 中间件消除重复代码 ✓ + +**问题**: JWT 配置在多个模块中重复定义 + +**修复**: +- 文件: `packages/api/src/middlewares/auth.ts` + - 创建共享的 `jwtPlugin` 包含 JWT 和 Bearer token 配置 + - 所有中间件复用这个插件,消除重复代码 +- 更新的模块: + - `packages/api/src/modules/auth/index.ts` + - `packages/api/src/modules/memory/index.ts` + - `packages/api/src/modules/settings/index.ts` + - `packages/api/src/modules/agent/index.ts` + +### 4. 实现统一错误处理中间件 ✓ + +**问题**: 缺少统一的错误处理机制 + +**修复**: +- 文件: `packages/api/src/middlewares/error.ts` (新建) + - 创建统一的错误处理中间件 + - 定义标准的错误响应格式 `ErrorResponse` + - 定义标准的成功响应格式 `SuccessResponse` + - 自动根据错误类型设置合适的 HTTP 状态码 + - 支持的错误类型: + - `VALIDATION` (400) + - `NOT_FOUND` (404) + - `PARSE` (400) + - `UNAUTHORIZED` (401) + - `FORBIDDEN` (403) + - `CONFLICT` (409) + - `INTERNAL_SERVER_ERROR` (500) +- 文件: `packages/api/src/index.ts` + - 在主应用中启用错误处理中间件 + +### 5. 为 model 模块添加权限控制 ✓ + +**问题**: model 模块的创建、更新、删除操作没有权限检查 + +**修复**: +- 文件: `packages/api/src/modules/model/index.ts` + - 读取操作 (GET) 使用 `optionalAuthMiddleware`(公开或可选认证) + - 写入操作 (POST, PUT, DELETE) 使用 `adminMiddleware`(仅管理员) + - 使用 `guard` 分离不同权限级别的路由 + +### 6. 添加分页功能到列表接口 ✓ + +**问题**: 列表接口缺少分页、排序功能 + +**修复**: +- 文件: `packages/api/src/utils/pagination.ts` (新建) + - 创建通用的分页工具函数 + - `parsePaginationParams()` - 解析分页参数 + - `createPaginatedResult()` - 创建分页结果 + - `calculateOffset()` - 计算偏移量 + - 标准分页响应格式: + ```typescript + { + items: T[], + pagination: { + page: number, + limit: number, + total: number, + totalPages: number, + hasNext: boolean, + hasPrev: boolean + } + } + ``` + +- 文件: `packages/api/src/modules/user/service.ts` + - 更新 `getUsers()` 支持分页和排序 + - 支持参数: `page`, `limit`, `sortBy`, `sortOrder` + +- 文件: `packages/api/src/modules/user/index.ts` + - GET `/user` 接口支持分页查询参数 + +- 文件: `packages/api/src/modules/model/service.ts` + - 更新 `getModels()` 支持分页 + - 支持参数: `page`, `limit`, `sortOrder` + +- 文件: `packages/api/src/modules/model/index.ts` + - GET `/model` 接口支持分页查询参数 + +## 📋 API 使用示例 + +### 分页查询用户 +```bash +GET /user?page=1&limit=10&sortBy=createdAt&sortOrder=desc +``` + +响应: +```json +{ + "success": true, + "items": [...], + "pagination": { + "page": 1, + "limit": 10, + "total": 50, + "totalPages": 5, + "hasNext": true, + "hasPrev": false + } +} +``` + +### 分页查询模型 +```bash +GET /model?page=1&limit=10&sortOrder=desc +``` + +### 错误响应格式 +```json +{ + "success": false, + "error": "Error message", + "code": "ERROR_CODE", + "details": { ... } +} +``` + +## 🔄 数据库迁移 + +修改了数据库 schema 后,需要运行迁移: + +```bash +cd packages/db +pnpm run generate # 生成迁移文件 +pnpm run push # 执行迁移 +``` + +## ⚠️ 注意事项 + +1. **数据库迁移**: 修改了 `users.isActive`, `settings.userId`, `history.user` 字段,需要迁移现有数据 +2. **API 响应格式变化**: 列表接口现在返回分页格式,前端需要适配 +3. **权限控制**: model 的写入操作现在需要管理员权限 + +## 📚 相关文件 + +### 数据库 Schema +- `packages/db/src/users.ts` +- `packages/db/src/settings.ts` +- `packages/db/src/history.ts` + +### 中间件 +- `packages/api/src/middlewares/auth.ts` +- `packages/api/src/middlewares/error.ts` +- `packages/api/src/middlewares/index.ts` + +### API 模块 +- `packages/api/src/modules/user/index.ts` +- `packages/api/src/modules/user/service.ts` +- `packages/api/src/modules/model/index.ts` +- `packages/api/src/modules/model/service.ts` +- `packages/api/src/modules/auth/index.ts` +- `packages/api/src/modules/memory/index.ts` +- `packages/api/src/modules/settings/index.ts` +- `packages/api/src/modules/agent/index.ts` + +### 工具函数 +- `packages/api/src/utils/pagination.ts` + +### 主应用 +- `packages/api/src/index.ts` + diff --git a/packages/api/package.json b/packages/api/package.json index f2f27fae..7ef6ea33 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,6 +16,7 @@ "@elysiajs/cron": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/jwt": "^1.2.0", + "@elysiajs/openapi": "^1.4.13", "@memohome/agent": "workspace:*", "@memohome/db": "workspace:*", "@memohome/memory": "workspace:*", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b33417b8..8c57dfcf 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,11 +1,14 @@ import { Elysia } from 'elysia' -import { corsMiddleware } from './middlewares' +import { corsMiddleware, errorMiddleware } from './middlewares' import { agentModule, authModule, modelModule, settingsModule, userModule } from './modules' import { memoryModule } from './modules/memory' +import openapi from '@elysiajs/openapi' const port = process.env.API_SERVER_PORT || 7002 export const app = new Elysia() + .use(errorMiddleware) + .use(openapi()) .use(corsMiddleware) .use(authModule) .use(agentModule) diff --git a/packages/api/src/middlewares/auth.ts b/packages/api/src/middlewares/auth.ts index 459bc144..914edd2f 100644 --- a/packages/api/src/middlewares/auth.ts +++ b/packages/api/src/middlewares/auth.ts @@ -2,20 +2,40 @@ import { Elysia } from 'elysia' import { bearer } from '@elysiajs/bearer' import { jwt } from '@elysiajs/jwt' +/** + * JWT 配置常量 + */ +const JWT_CONFIG = { + name: 'jwt', + secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', + exp: process.env.JWT_EXPIRES_IN || '7d', +} + +/** + * 用户信息类型 + */ +export type AuthUser = { + userId: string + username: string + role: string +} + +/** + * 共享的基础认证插件 + * 提供 JWT 和 Bearer token 功能 + */ +export const jwtPlugin = new Elysia({ name: 'jwt-plugin' }) + .use(jwt(JWT_CONFIG)) + .use(bearer()) + /** * 认证中间件 * 验证 Bearer token 并将用户信息注入到 context 中 */ export const authMiddleware = new Elysia({ name: 'auth' }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) + .use(jwt(JWT_CONFIG)) .use(bearer()) - .derive(async ({ bearer, jwt, set }) => { + .derive({ as: 'scoped' }, async ({ bearer, jwt, set }) => { if (!bearer) { set.status = 401 throw new Error('No bearer token provided') @@ -33,7 +53,7 @@ export const authMiddleware = new Elysia({ name: 'auth' }) userId: payload.userId as string, username: payload.username as string, role: payload.role as string, - }, + } as AuthUser, } }) @@ -42,23 +62,17 @@ export const authMiddleware = new Elysia({ name: 'auth' }) * 如果有 token 则验证,没有 token 则继续(user 为 null) */ export const optionalAuthMiddleware = new Elysia({ name: 'optional-auth' }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) + .use(jwt(JWT_CONFIG)) .use(bearer()) - .derive(async ({ bearer, jwt }) => { + .derive({ as: 'scoped' }, async ({ bearer, jwt }) => { if (!bearer) { - return { user: null } + return { user: null as AuthUser | null } } const payload = await jwt.verify(bearer) if (!payload) { - return { user: null } + return { user: null as AuthUser | null } } return { @@ -66,7 +80,7 @@ export const optionalAuthMiddleware = new Elysia({ name: 'optional-auth' }) userId: payload.userId as string, username: payload.username as string, role: payload.role as string, - }, + } as AuthUser | null, } }) @@ -75,15 +89,9 @@ export const optionalAuthMiddleware = new Elysia({ name: 'optional-auth' }) * 验证 token 并检查用户是否为管理员 */ export const adminMiddleware = new Elysia({ name: 'admin' }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) + .use(jwt(JWT_CONFIG)) .use(bearer()) - .derive(async ({ bearer, jwt, set }) => { + .derive({ as: 'scoped' }, async ({ bearer, jwt, set }) => { if (!bearer) { set.status = 401 throw new Error('No bearer token provided') @@ -96,7 +104,7 @@ export const adminMiddleware = new Elysia({ name: 'admin' }) throw new Error('Invalid or expired token') } - const user = { + const user: AuthUser = { userId: payload.userId as string, username: payload.username as string, role: payload.role as string, diff --git a/packages/api/src/middlewares/error.ts b/packages/api/src/middlewares/error.ts new file mode 100644 index 00000000..a83fb144 --- /dev/null +++ b/packages/api/src/middlewares/error.ts @@ -0,0 +1,133 @@ +import { Elysia } from 'elysia' + +/** + * 统一错误响应格式 + */ +export interface ErrorResponse { + success: false + error: string + code?: string + details?: unknown +} + +/** + * 统一成功响应格式 + */ +export interface SuccessResponse { + success: true + data: T + message?: string +} + +/** + * 统一错误处理中间件 + * 捕获所有未处理的错误并返回统一格式 + */ +export const errorMiddleware = new Elysia({ name: 'error' }) + .onError(({ code, error, set }) => { + console.error('[Error]', code, error) + + // 根据不同的错误类型设置不同的状态码和响应 + switch (code) { + case 'VALIDATION': + set.status = 400 + return { + success: false, + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details: error.message, + } satisfies ErrorResponse + + case 'NOT_FOUND': + set.status = 404 + return { + success: false, + error: 'Resource not found', + code: 'NOT_FOUND', + } satisfies ErrorResponse + + case 'PARSE': + set.status = 400 + return { + success: false, + error: 'Invalid request format', + code: 'PARSE_ERROR', + details: error.message, + } satisfies ErrorResponse + + case 'INTERNAL_SERVER_ERROR': + set.status = 500 + return { + success: false, + error: 'Internal server error', + code: 'INTERNAL_SERVER_ERROR', + } satisfies ErrorResponse + + case 'UNKNOWN': + default: + // 处理自定义错误 + if (error instanceof Error) { + const message = error.message + + // 401 未授权错误 + if ( + message.includes('No bearer token') || + message.includes('Invalid or expired token') + ) { + set.status = 401 + return { + success: false, + error: message, + code: 'UNAUTHORIZED', + } satisfies ErrorResponse + } + + // 403 权限不足错误 + if (message.includes('Forbidden') || message.includes('Admin access required')) { + set.status = 403 + return { + success: false, + error: message, + code: 'FORBIDDEN', + } satisfies ErrorResponse + } + + // 409 冲突错误(如用户已存在) + if (message.includes('already exists')) { + set.status = 409 + return { + success: false, + error: message, + code: 'CONFLICT', + } satisfies ErrorResponse + } + + // 404 未找到错误 + if (message.includes('not found')) { + set.status = 404 + return { + success: false, + error: message, + code: 'NOT_FOUND', + } satisfies ErrorResponse + } + + // 默认 500 服务器错误 + set.status = 500 + return { + success: false, + error: message, + code: 'ERROR', + } satisfies ErrorResponse + } + + // 未知错误 + set.status = 500 + return { + success: false, + error: 'An unexpected error occurred', + code: 'UNKNOWN_ERROR', + } satisfies ErrorResponse + } + }) + diff --git a/packages/api/src/middlewares/index.ts b/packages/api/src/middlewares/index.ts index ba777fa0..2fa00005 100644 --- a/packages/api/src/middlewares/index.ts +++ b/packages/api/src/middlewares/index.ts @@ -1,2 +1,3 @@ export * from './auth' -export * from './cors' \ No newline at end of file +export * from './cors' +export * from './error' \ No newline at end of file diff --git a/packages/api/src/modules/agent/index.ts b/packages/api/src/modules/agent/index.ts index ec6e77b8..74e657a5 100644 --- a/packages/api/src/modules/agent/index.ts +++ b/packages/api/src/modules/agent/index.ts @@ -1,6 +1,5 @@ import Elysia from 'elysia' -import { bearer } from '@elysiajs/bearer' -import { jwt } from '@elysiajs/jwt' +import { authMiddleware } from '../../middlewares/auth' import { AgentStreamModel } from './model' import { createAgentStream } from './service' import { getChatModel, getEmbeddingModel, getSummaryModel } from '../model/service' @@ -10,35 +9,7 @@ import { ChatModel, EmbeddingModel } from '@memohome/shared' export const agentModule = new Elysia({ prefix: '/agent', }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) - .use(bearer()) - .derive(async ({ bearer, jwt, set }) => { - if (!bearer) { - set.status = 401 - throw new Error('No bearer token provided') - } - - const payload = await jwt.verify(bearer) - - if (!payload) { - set.status = 401 - throw new Error('Invalid or expired token') - } - - return { - user: { - userId: payload.userId as string, - username: payload.username as string, - role: payload.role as string, - }, - } - }) + .use(authMiddleware) // Stream agent conversation .post('/stream', async ({ user, body, set }) => { try { diff --git a/packages/api/src/modules/auth/index.ts b/packages/api/src/modules/auth/index.ts index f549642a..9c2c0f2a 100644 --- a/packages/api/src/modules/auth/index.ts +++ b/packages/api/src/modules/auth/index.ts @@ -1,20 +1,12 @@ import Elysia from 'elysia' -import { bearer } from '@elysiajs/bearer' -import { jwt } from '@elysiajs/jwt' +import { jwtPlugin } from '../../middlewares/auth' import { LoginModel } from './model' import { validateUser } from './service' export const authModule = new Elysia({ prefix: '/auth', }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) - .use(bearer()) + .use(jwtPlugin) // Login endpoint .post('/login', async ({ body, jwt, set }) => { try { diff --git a/packages/api/src/modules/auth/service.ts b/packages/api/src/modules/auth/service.ts index 9bdc9cee..ee2c98dd 100644 --- a/packages/api/src/modules/auth/service.ts +++ b/packages/api/src/modules/auth/service.ts @@ -63,7 +63,7 @@ export const validateUser = async (username: string, password: string) => { } // 检查账户是否激活 - if (user.isActive !== 'true') { + if (!user.isActive) { return null } diff --git a/packages/api/src/modules/memory/index.ts b/packages/api/src/modules/memory/index.ts index 2fbabf22..73777b46 100644 --- a/packages/api/src/modules/memory/index.ts +++ b/packages/api/src/modules/memory/index.ts @@ -1,6 +1,5 @@ import Elysia from 'elysia' -import { bearer } from '@elysiajs/bearer' -import { jwt } from '@elysiajs/jwt' +import { authMiddleware } from '../../middlewares/auth' import { messageModule } from './message' import { AddMemoryModel, SearchMemoryModel } from './model' import { addMemory, searchMemory } from './service' @@ -9,35 +8,7 @@ import { MemoryUnit } from '@memohome/memory' export const memoryModule = new Elysia({ prefix: '/memory', }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) - .use(bearer()) - .derive(async ({ bearer, jwt, set }) => { - if (!bearer) { - set.status = 401 - throw new Error('No bearer token provided') - } - - const payload = await jwt.verify(bearer) - - if (!payload) { - set.status = 401 - throw new Error('Invalid or expired token') - } - - return { - user: { - userId: payload.userId as string, - username: payload.username as string, - role: payload.role as string, - }, - } - }) + .use(authMiddleware) .use(messageModule) // Add memory for current user .post('/', async ({ user, body, set }) => { diff --git a/packages/api/src/modules/memory/message/index.ts b/packages/api/src/modules/memory/message/index.ts index 73843f5d..98c0cb0f 100644 --- a/packages/api/src/modules/memory/message/index.ts +++ b/packages/api/src/modules/memory/message/index.ts @@ -1,20 +1,12 @@ import Elysia from 'elysia' -import { bearer } from '@elysiajs/bearer' -import { jwt } from '@elysiajs/jwt' +import { authMiddleware } from '../../../middlewares/auth' import { GetMemoryMessageFilterModel, GetMemoryMessageModel } from './model' import { getMemoryMessages, getMemoryMessagesFilter } from './service' export const messageModule = new Elysia({ prefix: '/message', }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) - .use(bearer()) + .use(authMiddleware) .derive(async ({ bearer, jwt, set }) => { if (!bearer) { set.status = 401 diff --git a/packages/api/src/modules/model/index.ts b/packages/api/src/modules/model/index.ts index 80df45b8..43bb6e04 100644 --- a/packages/api/src/modules/model/index.ts +++ b/packages/api/src/modules/model/index.ts @@ -1,4 +1,5 @@ import Elysia from 'elysia' +import { adminMiddleware, optionalAuthMiddleware } from '../../middlewares/auth' import { CreateModelModel, UpdateModelModel, @@ -21,13 +22,24 @@ import { Model } from '@memohome/shared' export const modelModule = new Elysia({ prefix: '/model', }) + // 公开的读取接口 + .use(optionalAuthMiddleware) // Get all models - .get('/', async () => { + .get('/', async ({ query }) => { try { - const models = await getModels() + const page = parseInt(query.page as string) || 1 + const limit = parseInt(query.limit as string) || 10 + const sortOrder = (query.sortOrder as string) || 'desc' + + const result = await getModels({ + page, + limit, + sortOrder: sortOrder as 'asc' | 'desc', + }) + return { success: true, - data: models, + ...result, } } catch (error) { return { @@ -58,65 +70,6 @@ export const modelModule = new Elysia({ } } }, GetModelByIdModel) - // Create new model - .post('/', async ({ body }) => { - try { - const newModel = await createModel(body as Model) - return { - success: true, - data: newModel, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to create model', - } - } - }, CreateModelModel) - // Update model - .put('/:id', async ({ params, body }) => { - try { - const { id } = params - const updatedModel = await updateModel(id, body as Model) - if (!updatedModel) { - return { - success: false, - error: 'Model not found', - } - } - return { - success: true, - data: updatedModel, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to update model', - } - } - }, UpdateModelModel) - // Delete model - .delete('/:id', async ({ params }) => { - try { - const { id } = params - const deletedModel = await deleteModel(id) - if (!deletedModel) { - return { - success: false, - error: 'Model not found', - } - } - return { - success: true, - data: deletedModel, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to delete model', - } - } - }, DeleteModelModel) // Get default chat model .get('/chat/default', async ({ query }) => { try { @@ -183,3 +136,73 @@ export const modelModule = new Elysia({ } } }, GetDefaultModelModel) + // 管理员权限的写入接口 + .guard( + { + beforeHandle: () => { + // This will be overridden by adminMiddleware + }, + }, + (app) => + app + .use(adminMiddleware) + // Create new model + .post('/', async ({ body }) => { + try { + const newModel = await createModel(body as Model) + return { + success: true, + data: newModel, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create model', + } + } + }, CreateModelModel) + // Update model + .put('/:id', async ({ params, body }) => { + try { + const { id } = params + const updatedModel = await updateModel(id, body as Model) + if (!updatedModel) { + return { + success: false, + error: 'Model not found', + } + } + return { + success: true, + data: updatedModel, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update model', + } + } + }, UpdateModelModel) + // Delete model + .delete('/:id', async ({ params }) => { + try { + const { id } = params + const deletedModel = await deleteModel(id) + if (!deletedModel) { + return { + success: false, + error: 'Model not found', + } + } + return { + success: true, + data: deletedModel, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete model', + } + } + }, DeleteModelModel) + ) diff --git a/packages/api/src/modules/model/service.ts b/packages/api/src/modules/model/service.ts index 64eb5166..962b7451 100644 --- a/packages/api/src/modules/model/service.ts +++ b/packages/api/src/modules/model/service.ts @@ -1,12 +1,43 @@ import { db } from '@memohome/db' import { model } from '@memohome/db/schema' import { Model } from '@memohome/shared' -import { eq } from 'drizzle-orm' +import { eq, sql, desc, asc } from 'drizzle-orm' import { getSettings } from '@/modules/settings/service' +import { calculateOffset, createPaginatedResult, type PaginatedResult } from '../../utils/pagination' -export const getModels = async () => { - const models = await db.select().from(model) - return models +/** + * 模型列表返回类型 + */ +type ModelListItem = { + id: string + model: Model +} + +export const getModels = async (params?: { + page?: number + limit?: number + sortOrder?: 'asc' | 'desc' +}): Promise> => { + const page = params?.page || 1 + const limit = params?.limit || 10 + const sortOrder = params?.sortOrder || 'desc' + const offset = calculateOffset(page, limit) + + // 获取总数 + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(model) + + // 获取分页数据(按 id 排序,因为 model 表没有 createdAt) + const orderFn = sortOrder === 'desc' ? desc : asc + const models = await db + .select() + .from(model) + .orderBy(orderFn(model.id)) + .limit(limit) + .offset(offset) + + return createPaginatedResult(models, Number(count), page, limit) } export const getModelById = async (id: string) => { diff --git a/packages/api/src/modules/settings/index.ts b/packages/api/src/modules/settings/index.ts index 4bc0147f..e1e27b06 100644 --- a/packages/api/src/modules/settings/index.ts +++ b/packages/api/src/modules/settings/index.ts @@ -1,41 +1,12 @@ import Elysia from 'elysia' -import { bearer } from '@elysiajs/bearer' -import { jwt } from '@elysiajs/jwt' +import { authMiddleware } from '../../middlewares/auth' import { UpdateSettingsModel } from './model' import { getSettings, upsertSettings } from './service' export const settingsModule = new Elysia({ prefix: '/settings', }) - .use( - jwt({ - name: 'jwt', - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - exp: process.env.JWT_EXPIRES_IN || '7d', - }) - ) - .use(bearer()) - .derive(async ({ bearer, jwt, set }) => { - if (!bearer) { - set.status = 401 - throw new Error('No bearer token provided') - } - - const payload = await jwt.verify(bearer) - - if (!payload) { - set.status = 401 - throw new Error('Invalid or expired token') - } - - return { - user: { - userId: payload.userId as string, - username: payload.username as string, - role: payload.role as string, - }, - } - }) + .use(authMiddleware) // Get current user's settings .get('/', async ({ user, set }) => { try { diff --git a/packages/api/src/modules/user/index.ts b/packages/api/src/modules/user/index.ts index 7892793b..805d2633 100644 --- a/packages/api/src/modules/user/index.ts +++ b/packages/api/src/modules/user/index.ts @@ -22,12 +22,23 @@ export const userModule = new Elysia({ // 使用管理员中间件保护所有路由 .use(adminMiddleware) // Get all users - .get('/', async () => { + .get('/', async ({ query }) => { try { - const userList = await getUsers() + const page = parseInt(query.page as string) || 1 + const limit = parseInt(query.limit as string) || 10 + const sortBy = query.sortBy as string || 'createdAt' + const sortOrder = (query.sortOrder as string) || 'desc' + + const result = await getUsers({ + page, + limit, + sortBy, + sortOrder: sortOrder as 'asc' | 'desc', + }) + return { success: true, - data: userList, + ...result, } } catch (error) { return { diff --git a/packages/api/src/modules/user/model.ts b/packages/api/src/modules/user/model.ts index f900ed5a..00e18d53 100644 --- a/packages/api/src/modules/user/model.ts +++ b/packages/api/src/modules/user/model.ts @@ -19,7 +19,7 @@ const UpdateUserSchema = z.object({ role: UserRoleSchema.optional(), displayName: z.string().optional(), avatarUrl: z.string().url('Invalid URL format').optional(), - isActive: z.enum(['true', 'false']).optional(), + isActive: z.boolean().optional(), }) // 更新密码的 Schema diff --git a/packages/api/src/modules/user/service.ts b/packages/api/src/modules/user/service.ts index c2bf0166..9b15c9f3 100644 --- a/packages/api/src/modules/user/service.ts +++ b/packages/api/src/modules/user/service.ts @@ -1,12 +1,55 @@ import { db } from '@memohome/db' import { users, settings } from '@memohome/db/schema' -import { eq } from 'drizzle-orm' +import { eq, sql, desc, asc } from 'drizzle-orm' import type { CreateUserInput, UpdateUserInput } from './model' +import { calculateOffset, createPaginatedResult, type PaginatedResult } from '../../utils/pagination' /** - * 获取所有用户列表 + * 用户列表返回类型 */ -export const getUsers = async () => { +type UserListItem = { + id: string + username: string + email: string | null + role: 'admin' | 'member' + displayName: string | null + avatarUrl: string | null + isActive: boolean + createdAt: Date + updatedAt: Date + lastLoginAt: Date | null +} + +/** + * 获取所有用户列表(支持分页) + */ +export const getUsers = async (params?: { + page?: number + limit?: number + sortBy?: string + sortOrder?: 'asc' | 'desc' +}): Promise> => { + const page = params?.page || 1 + const limit = params?.limit || 10 + const sortBy = params?.sortBy || 'createdAt' + const sortOrder = params?.sortOrder || 'desc' + const offset = calculateOffset(page, limit) + + // 获取总数 + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(users) + + // 动态排序 + const orderColumn = sortBy === 'username' ? users.username : + sortBy === 'email' ? users.email : + sortBy === 'role' ? users.role : + sortBy === 'updatedAt' ? users.updatedAt : + users.createdAt + + const orderFn = sortOrder === 'desc' ? desc : asc + + // 获取分页数据 const userList = await db .select({ id: users.id, @@ -21,9 +64,11 @@ export const getUsers = async () => { lastLoginAt: users.lastLoginAt, }) .from(users) - .orderBy(users.createdAt) + .orderBy(orderFn(orderColumn)) + .limit(limit) + .offset(offset) - return userList + return createPaginatedResult(userList, Number(count), page, limit) } /** diff --git a/packages/api/src/utils/pagination.ts b/packages/api/src/utils/pagination.ts new file mode 100644 index 00000000..5511246d --- /dev/null +++ b/packages/api/src/utils/pagination.ts @@ -0,0 +1,73 @@ +/** + * 分页参数接口 + */ +export interface PaginationParams { + page?: number + limit?: number + sortBy?: string + sortOrder?: 'asc' | 'desc' +} + +/** + * 分页结果接口 + */ +export interface PaginatedResult { + items: T[] + pagination: { + page: number + limit: number + total: number + totalPages: number + hasNext: boolean + hasPrev: boolean + } +} + +/** + * 解析分页参数 + */ +export function parsePaginationParams(query: Record): Required { + const page = Math.max(1, parseInt(query.page as string) || 1) + const limit = Math.min(100, Math.max(1, parseInt(query.limit as string) || 10)) + const sortBy = query.sortBy as string || 'createdAt' + const sortOrder = (query.sortOrder as string)?.toLowerCase() === 'desc' ? 'desc' : 'asc' + + return { + page, + limit, + sortBy, + sortOrder, + } +} + +/** + * 创建分页结果 + */ +export function createPaginatedResult( + items: T[], + total: number, + page: number, + limit: number +): PaginatedResult { + const totalPages = Math.ceil(total / limit) + + return { + items, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + } +} + +/** + * 计算偏移量 + */ +export function calculateOffset(page: number, limit: number): number { + return (page - 1) * limit +} + diff --git a/packages/db/src/history.ts b/packages/db/src/history.ts index 95a2c79b..03f899ce 100644 --- a/packages/db/src/history.ts +++ b/packages/db/src/history.ts @@ -1,4 +1,5 @@ -import { pgTable, timestamp, uuid, jsonb, text } from 'drizzle-orm/pg-core' +import { pgTable, timestamp, uuid, jsonb } from 'drizzle-orm/pg-core' +import { users } from './users' export const history = pgTable( 'history', @@ -6,6 +7,6 @@ export const history = pgTable( id: uuid('id').primaryKey().defaultRandom(), messages: jsonb('messages').notNull(), timestamp: timestamp('timestamp').notNull(), - user: text('user').notNull(), + user: uuid('user').notNull().references(() => users.id), } ) \ No newline at end of file diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index d04b9a84..421e5b4f 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -1,8 +1,9 @@ import { pgTable, text, uuid, integer } from 'drizzle-orm/pg-core' import { model } from './model' +import { users } from './users' export const settings = pgTable('settings', { - userId: text('user_id').primaryKey(), + userId: uuid('user_id').primaryKey().references(() => users.id), defaultChatModel: uuid('default_chat_model').references(() => model.id), defaultEmbeddingModel: uuid('default_embedding_model').references(() => model.id), defaultSummaryModel: uuid('default_summary_model').references(() => model.id), diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index ad2e8686..a117f177 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -1,4 +1,4 @@ -import { pgTable, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core' +import { pgTable, pgEnum, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core' // 定义用户角色枚举 export const userRoleEnum = pgEnum('user_role', ['admin', 'member']) @@ -27,7 +27,7 @@ export const users = pgTable('users', { avatarUrl: text('avatar_url'), // 账户状态(是否激活) - isActive: text('is_active').notNull().default('true'), + isActive: boolean('is_active').notNull().default(true), // 创建时间 createdAt: timestamp('created_at').notNull().defaultNow(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34e43ec9..2e09be31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@elysiajs/jwt': specifier: ^1.2.0 version: 1.4.0(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) + '@elysiajs/openapi': + specifier: ^1.4.13 + version: 1.4.13(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) '@memohome/agent': specifier: workspace:* version: link:../agent @@ -266,9 +269,6 @@ importers: packages/web: dependencies: - '@memohome/api': - specifier: workspace:* - version: link:../api '@memohome/shared': specifier: workspace:* version: link:../shared @@ -591,6 +591,11 @@ packages: peerDependencies: elysia: '>= 1.4.0' + '@elysiajs/openapi@1.4.13': + resolution: {integrity: sha512-5BNI7yuFo8zjacTWA8a/wYE0tNZ4ecD7PAkeUDEU7lj6Iep0t2i5Rml6q4/+roM5oZFdtvvzoX4DH8zGKFEo0w==} + peerDependencies: + elysia: '>= 1.4.0' + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -4692,6 +4697,10 @@ snapshots: elysia: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3) jose: 6.1.3 + '@elysiajs/openapi@1.4.13(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))': + dependencies: + elysia: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3) + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20