feat: improve api

This commit is contained in:
Acbox
2026-01-10 22:18:50 +08:00
parent 661d742750
commit fee657ddd2
22 changed files with 655 additions and 228 deletions
+190
View File
@@ -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`
+1
View File
@@ -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:*",
+4 -1
View File
@@ -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)
+37 -29
View File
@@ -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,
+133
View File
@@ -0,0 +1,133 @@
import { Elysia } from 'elysia'
/**
* 统一错误响应格式
*/
export interface ErrorResponse {
success: false
error: string
code?: string
details?: unknown
}
/**
* 统一成功响应格式
*/
export interface SuccessResponse<T = unknown> {
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
}
})
+1
View File
@@ -1,2 +1,3 @@
export * from './auth'
export * from './cors'
export * from './error'
+2 -31
View File
@@ -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 {
+2 -10
View File
@@ -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 {
+1 -1
View File
@@ -63,7 +63,7 @@ export const validateUser = async (username: string, password: string) => {
}
// 检查账户是否激活
if (user.isActive !== 'true') {
if (!user.isActive) {
return null
}
+2 -31
View File
@@ -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 }) => {
@@ -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
+85 -62
View File
@@ -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)
)
+35 -4
View File
@@ -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<PaginatedResult<ModelListItem>> => {
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<number>`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) => {
+2 -31
View File
@@ -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 {
+14 -3
View File
@@ -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 {
+1 -1
View File
@@ -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
+50 -5
View File
@@ -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<PaginatedResult<UserListItem>> => {
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<number>`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)
}
/**
+73
View File
@@ -0,0 +1,73 @@
/**
* 分页参数接口
*/
export interface PaginationParams {
page?: number
limit?: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
/**
* 分页结果接口
*/
export interface PaginatedResult<T> {
items: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
/**
* 解析分页参数
*/
export function parsePaginationParams(query: Record<string, any>): Required<PaginationParams> {
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<T>(
items: T[],
total: number,
page: number,
limit: number
): PaginatedResult<T> {
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
}
+3 -2
View File
@@ -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),
}
)
+2 -1
View File
@@ -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),
+2 -2
View File
@@ -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(),
+12 -3
View File
@@ -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