From 661d742750178bf793114915ee36d3bb3e1360e5 Mon Sep 17 00:00:00 2001 From: Acbox Date: Sat, 10 Jan 2026 21:55:39 +0800 Subject: [PATCH] feat: full api server --- .env.example | 49 +- .gitignore | 1 + package.json | 3 - packages/agent/package.json | 4 + packages/api/AGENT_API.md | 383 ++++++++++++++ packages/api/API_CHANGES.md | 251 +++++++++ packages/api/AUTH_README.md | 277 ++++++++++ packages/api/README.md | 193 ++++++- packages/api/SETTINGS_API.md | 281 ++++++++++ packages/api/USER_MANAGEMENT.md | 480 ++++++++++++++++++ packages/api/examples/agent-stream-client.ts | 164 ++++++ packages/api/package.json | 2 + packages/api/src/index.ts | 4 +- packages/api/src/middlewares/auth.ts | 113 +++++ packages/api/src/middlewares/index.ts | 1 + packages/api/src/modules/agent/index.ts | 122 ++++- packages/api/src/modules/agent/model.ts | 13 + packages/api/src/modules/agent/service.ts | 63 +++ packages/api/src/modules/auth/index.ts | 135 +++++ packages/api/src/modules/auth/model.ts | 13 + packages/api/src/modules/auth/service.ts | 86 ++++ packages/api/src/modules/index.ts | 4 +- packages/api/src/modules/memory/index.ts | 49 +- .../api/src/modules/memory/message/index.ts | 68 ++- .../api/src/modules/memory/message/model.ts | 10 +- .../api/src/modules/memory/message/service.ts | 8 +- packages/api/src/modules/memory/model.ts | 10 +- packages/api/src/modules/settings/index.ts | 101 ++-- packages/api/src/modules/settings/model.ts | 26 +- packages/api/src/modules/settings/service.ts | 57 +-- packages/api/src/modules/user/index.ts | 171 +++++++ packages/api/src/modules/user/model.ts | 63 +++ packages/api/src/modules/user/service.ts | 213 ++++++++ packages/db/README.md | 13 + packages/db/USERS_SCHEMA.md | 120 +++++ packages/db/src/schema.ts | 3 +- packages/db/src/settings.ts | 5 +- packages/db/src/user-helpers.ts | 112 ++++ packages/db/src/users.ts | 40 ++ pnpm-lock.yaml | 33 ++ 40 files changed, 3587 insertions(+), 157 deletions(-) create mode 100644 packages/api/AGENT_API.md create mode 100644 packages/api/API_CHANGES.md create mode 100644 packages/api/AUTH_README.md create mode 100644 packages/api/SETTINGS_API.md create mode 100644 packages/api/USER_MANAGEMENT.md create mode 100644 packages/api/examples/agent-stream-client.ts create mode 100644 packages/api/src/middlewares/auth.ts create mode 100644 packages/api/src/modules/agent/model.ts create mode 100644 packages/api/src/modules/agent/service.ts create mode 100644 packages/api/src/modules/auth/index.ts create mode 100644 packages/api/src/modules/auth/model.ts create mode 100644 packages/api/src/modules/auth/service.ts create mode 100644 packages/api/src/modules/user/index.ts create mode 100644 packages/api/src/modules/user/model.ts create mode 100644 packages/api/src/modules/user/service.ts create mode 100644 packages/db/USERS_SCHEMA.md create mode 100644 packages/db/src/user-helpers.ts create mode 100644 packages/db/src/users.ts diff --git a/.env.example b/.env.example index f5d94b9f..cab25b3c 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,52 @@ -# Server +# ================================== +# Database Configuration +# ================================== + +# PostgreSQL database connection URL +# Format: postgresql://username:password@host:port/database +# Example: postgresql://postgres:password@localhost:5432/memohome +DATABASE_URL=postgresql://username:password@localhost:5432/database_name + + +# ================================== +# API Server Configuration +# ================================== API_SERVER_PORT=7002 WEB_PORT=7003 API_BASE_URL=http://localhost:7002 WEB_URL=http://localhost:7003 -# Database (PostgreSQL) -DATABASE_URL= + +# ================================== +# Authentication Configuration +# ================================== + +# Root user credentials (super admin) +# This user is not stored in the database and has full admin privileges +# Use a strong username and password in production +ROOT_USER=admin +ROOT_USER_PASSWORD=change_this_secure_password + +# JWT secret key for signing tokens +# IMPORTANT: Use a long, random string in production (at least 32 characters) +# You can generate one with: openssl rand -base64 32 +JWT_SECRET=your-jwt-secret-key-change-in-production-use-long-random-string + +# JWT token expiration time +# Format: number + unit (s=seconds, m=minutes, h=hours, d=days) +# Examples: 30m, 2h, 7d, 30d +# Default: 7d (7 days) +JWT_EXPIRES_IN=7d + + +# ================================== +# Optional: Development Configuration +# ================================== + +# Node environment +# Options: development, production, test +NODE_ENV=development + +# Enable debug logging (optional) +# DEBUG=true diff --git a/.gitignore b/.gitignore index d4ab8547..c9100f76 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ Thumbs.db *.temp .cache/ +.pnpm-store diff --git a/package.json b/package.json index 0acef6ec..a9d87473 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,6 @@ "web:dev": "pnpm --filter @memohome/web dev", "web:build": "pnpm --filter @memohome/web build", "web:start": "pnpm --filter @memohome/web start", - "device-api:dev": "pnpm --filter @memohome/device-api dev", - "device-api:build": "pnpm --filter @memohome/device-api build", - "device-api:start": "pnpm --filter @memohome/device-api start", "db:push": "pnpm --filter @memohome/db push", "db:migrate": "pnpm --filter @memohome/db migrate", "db:generate": "pnpm --filter @memohome/db generate", diff --git a/packages/agent/package.json b/packages/agent/package.json index d1b900ed..979f3ee4 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -2,6 +2,10 @@ "name": "@memohome/agent", "version": "1.0.0", "description": "Agent package for the phonetutor monorepo", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "test": "vitest", "start": "bun run client/index.ts" diff --git a/packages/api/AGENT_API.md b/packages/api/AGENT_API.md new file mode 100644 index 00000000..cedfd24a --- /dev/null +++ b/packages/api/AGENT_API.md @@ -0,0 +1,383 @@ +# Agent API 文档 + +## 概述 + +Agent API 提供了一个智能对话代理接口,支持流式响应、记忆管理和工具调用。 + +## 端点 + +### POST `/agent/stream` 🔒 需要认证 + +与 AI Agent 进行流式对话。 + +#### 权限要求 + +- 需要有效的 Bearer token +- 自动使用当前登录用户的身份 + +#### 请求 + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Body:** +```json +{ + "message": "你好,请介绍一下你自己", + "maxContextLoadTime": 60, + "language": "Chinese" +} +``` + +**参数说明:** + +| 字段 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| message | string | 是 | - | 用户消息内容 | +| maxContextLoadTime | number | 否 | 从设置读取或60 | 加载上下文的时间范围(分钟,1-1440) | +| language | string | 否 | 从设置读取或"Same as user input" | Agent 回复的首选语言 | + +**注意**: `maxContextLoadTime` 和 `language` 如果在请求中未指定,将从用户设置(Settings)中读取。如果设置中也没有,则使用默认值。 + +#### 响应 + +返回 Server-Sent Events (SSE) 流式响应。 + +**Content-Type:** `text/event-stream` + +**事件格式:** + +每个事件以 `data: ` 开头,后跟 JSON 格式的数据: + +``` +data: {"type":"text-delta","text":"你"} + +data: {"type":"text-delta","text":"好"} + +data: {"type":"tool-call","toolName":"search_memory","args":{...}} + +data: [DONE] +``` + +**事件类型:** + +1. **text-delta** - 文本增量 +```json +{ + "type": "text-delta", + "text": "文本片段" +} +``` + +2. **tool-call** - 工具调用 +```json +{ + "type": "tool-call", + "toolName": "search_memory", + "args": { + "query": "搜索内容" + } +} +``` + +3. **[DONE]** - 流结束标记 + +4. **error** - 错误事件 +```json +{ + "type": "error", + "error": "错误消息" +} +``` + +#### 错误响应 + +**400 Bad Request** - 模型配置未找到 +```json +{ + "success": false, + "error": "Model configuration not found. Please configure your models in settings." +} +``` + +**401 Unauthorized** - 未认证 +```json +{ + "success": false, + "error": "No bearer token provided" +} +``` + +**500 Internal Server Error** - 服务器错误 +```json +{ + "success": false, + "error": "Failed to process request" +} +``` + +--- + +## 使用示例 + +### JavaScript/TypeScript (EventSource) + +```typescript +async function streamAgentChat(message: string, token: string) { + const response = await fetch('http://localhost:7002/agent/stream', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + maxContextLoadTime: 60, + language: 'Chinese', + }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error) + } + + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + if (!reader) throw new Error('No reader available') + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value) + const lines = chunk.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + + if (data === '[DONE]') { + console.log('\n✅ Stream completed') + return + } + + try { + const event = JSON.parse(data) + + if (event.type === 'text-delta' && event.text) { + process.stdout.write(event.text) + } else if (event.type === 'tool-call') { + console.log(`\n[Tool: ${event.toolName}]`) + } else if (event.type === 'error') { + console.error('\n❌ Error:', event.error) + } + } catch (e) { + // Skip invalid JSON + } + } + } + } +} + +// 使用示例 +const token = 'your_jwt_token_here' +await streamAgentChat('你好,请介绍一下你自己', token) +``` + +### curl 示例 + +```bash +# 1. 登录获取 token +TOKEN=$(curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"your_password"}' \ + | jq -r '.data.token') + +# 2. 流式对话 +curl -N -X POST http://localhost:7002/agent/stream \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "你好,请介绍一下你自己", + "maxContextLoadTime": 60, + "language": "Chinese" + }' +``` + +### Python 示例 + +```python +import requests +import json + +def stream_agent_chat(message: str, token: str): + url = 'http://localhost:7002/agent/stream' + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json', + } + data = { + 'message': message, + 'maxContextLoadTime': 60, + 'language': 'Chinese', + } + + with requests.post(url, headers=headers, json=data, stream=True) as response: + response.raise_for_status() + + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data = line_str[6:] + + if data == '[DONE]': + print('\n✅ Stream completed') + break + + try: + event = json.loads(data) + + if event.get('type') == 'text-delta' and event.get('text'): + print(event['text'], end='', flush=True) + elif event.get('type') == 'tool-call': + print(f"\n[Tool: {event['toolName']}]") + elif event.get('type') == 'error': + print(f"\n❌ Error: {event['error']}") + except json.JSONDecodeError: + pass + +# 使用示例 +token = 'your_jwt_token_here' +stream_agent_chat('你好,请介绍一下你自己', token) +``` + +--- + +## 工作原理 + +### 1. 认证验证 + +- 验证 JWT token +- 提取用户信息 + +### 2. 模型配置获取 + +从用户设置中获取: +- Chat Model(对话模型) +- Embedding Model(嵌入模型) +- Summary Model(摘要模型) + +### 3. Agent 创建 + +- 创建 Memory 实例(用于记忆管理) +- 创建 Agent 实例,配置回调函数: + - `onReadMemory`: 从数据库加载历史对话 + - `onSearchMemory`: 搜索相关记忆 + - `onFinish`: 保存对话到记忆 + +### 4. 流式响应 + +- Agent 处理用户消息 +- 实时流式返回生成的文本 +- 报告工具调用事件 +- 完成后自动保存到记忆 + +### 5. 记忆管理 + +- 自动加载近期对话历史作为上下文 +- 使用向量搜索查找相关记忆 +- 对话结束后自动保存 + +--- + +## 特性 + +### ✅ 流式响应 + +- 实时返回生成的文本 +- 无需等待完整响应 +- 更好的用户体验 + +### ✅ 记忆管理 + +- 自动保存对话历史 +- 智能加载相关上下文 +- 向量搜索相关记忆 + +### ✅ 工具调用 + +- 支持搜索记忆工具 +- 可扩展其他工具 +- 实时报告工具使用 + +### ✅ 个性化配置 + +- 每个用户使用自己的模型配置 +- 可自定义语言偏好 +- 可配置上下文加载时间 + +--- + +## 前置条件 + +### 1. 配置模型和 Agent 设置 + +使用 Settings API 配置默认模型和 Agent 参数: + +```bash +curl -X PUT http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "defaultChatModel": "chat-model-uuid", + "defaultEmbeddingModel": "embedding-model-uuid", + "defaultSummaryModel": "summary-model-uuid", + "maxContextLoadTime": 60, + "language": "Chinese" + }' +``` + +详见 [Settings API 文档](./SETTINGS_API.md) + +### 2. 创建模型配置 + +使用 Model API 创建模型: + +```bash +curl -X POST http://localhost:7002/model \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "modelId": "gpt-4", + "baseUrl": "https://api.openai.com/v1", + "apiKey": "your-api-key", + "clientType": "openai", + "name": "GPT-4" + }' +``` + +--- + +## 限制和注意事项 + +1. **模型配置必需**: 用户必须先配置好模型才能使用 Agent +2. **流式连接**: 需要支持 Server-Sent Events 的客户端 +3. **超时处理**: 长时间无响应可能导致连接超时 +4. **并发限制**: 建议每个用户同时只维护一个对话流 + +--- + +## 相关文档 + +- [认证系统](./AUTH_README.md) +- [用户管理](./USER_MANAGEMENT.md) +- [Settings API](./API_CHANGES.md) +- [Model API](./README.md) +- [Agent 包文档](../agent/README.md) + diff --git a/packages/api/API_CHANGES.md b/packages/api/API_CHANGES.md new file mode 100644 index 00000000..d9c8b27e --- /dev/null +++ b/packages/api/API_CHANGES.md @@ -0,0 +1,251 @@ +# API 变更说明 + +## 🔒 Settings 和 Memory 模块现已使用认证 + +### 概述 + +Settings 和 Memory 模块现在使用 JWT 认证中间件,自动从 token 中获取当前用户信息,**不再需要手动传入 userId**。 + +--- + +## Settings 模块变更 + +### ❌ 旧 API(已废弃) + +```bash +# 获取用户设置 +GET /settings/:userId + +# 创建用户设置 +POST /settings +{ + "userId": "user123", + "defaultChatModel": "uuid-here" +} + +# 更新用户设置 +PUT /settings/:userId +{ + "defaultChatModel": "uuid-here" +} +``` + +### ✅ 新 API(需要认证) + +```bash +# 获取当前用户的设置 +GET /settings +Authorization: Bearer + +# 更新或创建当前用户的设置 +PUT /settings +Authorization: Bearer +{ + "defaultChatModel": "uuid-here", + "defaultEmbeddingModel": "uuid-here", + "defaultSummaryModel": "uuid-here", + "maxContextLoadTime": 60, + "language": "Chinese" +} +``` + +### 使用示例 + +```bash +# 1. 登录获取 token +TOKEN=$(curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"user","password":"pass"}' \ + | jq -r '.data.token') + +# 2. 获取我的设置 +curl http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" + +# 3. 更新我的设置 +curl -X PUT http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "defaultChatModel": "123e4567-e89b-12d3-a456-426614174000", + "maxContextLoadTime": 60, + "language": "Chinese" + }' +``` + +--- + +## Memory 模块变更 + +### ❌ 旧 API(已废弃) + +```bash +# 添加记忆 +POST /memory +{ + "messages": [...], + "timestamp": "2024-01-10T10:00:00Z", + "user": "user123" +} + +# 搜索记忆 +GET /memory/search?query=hello&userId=user123 + +# 获取消息历史 +GET /memory/message?page=1&limit=10&userId=user123 + +# 按日期过滤消息 +GET /memory/message/filter?from=2024-01-01&to=2024-01-31&userId=user123 +``` + +### ✅ 新 API(需要认证) + +```bash +# 添加记忆(自动使用当前用户) +POST /memory +Authorization: Bearer +{ + "messages": [...], + "timestamp": "2024-01-10T10:00:00Z" +} + +# 搜索当前用户的记忆 +GET /memory/search?query=hello +Authorization: Bearer + +# 获取当前用户的消息历史 +GET /memory/message?page=1&limit=10 +Authorization: Bearer + +# 按日期过滤当前用户的消息 +GET /memory/message/filter?from=2024-01-01&to=2024-01-31 +Authorization: Bearer +``` + +### 使用示例 + +```bash +# 1. 登录获取 token +TOKEN=$(curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"user","password":"pass"}' \ + | jq -r '.data.token') + +# 2. 添加记忆 +curl -X POST http://localhost:7002/memory \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"} + ], + "timestamp": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'" + }' + +# 3. 搜索记忆 +curl "http://localhost:7002/memory/search?query=hello" \ + -H "Authorization: Bearer $TOKEN" + +# 4. 获取消息历史(分页) +curl "http://localhost:7002/memory/message?page=1&limit=10" \ + -H "Authorization: Bearer $TOKEN" + +# 5. 按日期范围过滤 +curl "http://localhost:7002/memory/message/filter?from=2024-01-01&to=2024-01-31" \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +## 安全优势 + +### ✅ 更安全 + +- 用户只能访问自己的数据 +- 无法通过修改 userId 参数访问其他用户的数据 +- 所有操作都需要有效的认证 token + +### ✅ 更简洁 + +- API 调用更简单,无需手动传递 userId +- 减少了参数验证和错误处理 +- 代码更清晰易维护 + +### ✅ 一致性 + +- 与其他需要认证的模块保持一致 +- 统一的认证流程和错误处理 +- 符合 RESTful 最佳实践 + +--- + +## 迁移指南 + +### 1. 更新客户端代码 + +#### 之前: +```javascript +// 需要手动传入 userId +const settings = await fetch(`/settings/${userId}`) +const memories = await fetch(`/memory/search?query=hello&userId=${userId}`) +``` + +#### 现在: +```javascript +// 自动使用 token 中的用户信息 +const settings = await fetch('/settings', { + headers: { 'Authorization': `Bearer ${token}` } +}) + +const memories = await fetch('/memory/search?query=hello', { + headers: { 'Authorization': `Bearer ${token}` } +}) +``` + +### 2. 错误处理 + +新增错误响应: + +```json +// 401 Unauthorized - 未提供 token 或 token 无效 +{ + "success": false, + "error": "No bearer token provided" +} + +// 401 Unauthorized - Token 过期 +{ + "success": false, + "error": "Invalid or expired token" +} +``` + +--- + +## API 端点总结 + +### Settings 模块 `/settings` 🔒 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| GET | `/` | 获取当前用户设置 | ✅ 必需 | +| PUT | `/` | 更新当前用户设置 | ✅ 必需 | + +### Memory 模块 `/memory` 🔒 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| POST | `/` | 添加记忆 | ✅ 必需 | +| GET | `/search` | 搜索记忆 | ✅ 必需 | +| GET | `/message` | 获取消息列表(分页) | ✅ 必需 | +| GET | `/message/filter` | 按日期范围过滤消息 | ✅ 必需 | + +--- + +## 相关文档 + +- [认证系统文档](./AUTH_README.md) +- [用户管理文档](./USER_MANAGEMENT.md) +- [项目设置指南](../../SETUP.md) + diff --git a/packages/api/AUTH_README.md b/packages/api/AUTH_README.md new file mode 100644 index 00000000..95e48959 --- /dev/null +++ b/packages/api/AUTH_README.md @@ -0,0 +1,277 @@ +# 认证系统使用文档 + +## 概述 + +本项目使用 JWT (JSON Web Token) 进行用户认证,集成了 Elysia 官方插件: +- [@elysiajs/jwt](https://elysiajs.com/plugins/jwt.html) - JWT token 生成和验证 +- [@elysiajs/bearer](https://elysiajs.com/plugins/bearer.html) - Bearer token 提取 + +## 环境变量配置 + +在项目根目录的 `.env` 文件中配置以下变量: + +```bash +# Root 超级用户配置 +ROOT_USER=admin +ROOT_USER_PASSWORD=your_secure_password + +# JWT 配置 +JWT_SECRET=your-jwt-secret-key-change-in-production +JWT_EXPIRES_IN=7d # Token 有效期,可选 + +# API 服务器端口 +API_SERVER_PORT=7002 +``` + +## API 端点 + +### 1. 登录 POST `/auth/login` + +用户登录并获取 JWT token。 + +**请求体:** +```json +{ + "username": "admin", + "password": "your_password" +} +``` + +**成功响应:** +```json +{ + "success": true, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "root", + "username": "admin", + "role": "admin", + "displayName": "Root User", + "email": null + } + } +} +``` + +**失败响应:** +```json +{ + "success": false, + "error": "Invalid username or password" +} +``` + +### 2. 验证 Token GET `/auth/verify` + +验证 JWT token 是否有效。 + +**请求头:** +``` +Authorization: Bearer +``` + +**成功响应:** +```json +{ + "success": true, + "data": { + "userId": "root", + "username": "admin", + "role": "admin" + } +} +``` + +**失败响应:** +```json +{ + "success": false, + "error": "Invalid or expired token" +} +``` + +### 3. 获取当前用户信息 GET `/auth/me` + +获取当前登录用户的信息。 + +**请求头:** +``` +Authorization: Bearer +``` + +**成功响应:** +```json +{ + "success": true, + "data": { + "userId": "root", + "username": "admin", + "role": "admin" + } +} +``` + +## 使用示例 + +### JavaScript/TypeScript 客户端 + +```typescript +// 1. 登录 +const loginResponse = await fetch('http://localhost:7002/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: 'admin', + password: 'your_password', + }), +}) + +const loginData = await loginResponse.json() +const token = loginData.data.token + +// 2. 使用 token 访问受保护的资源 +const meResponse = await fetch('http://localhost:7002/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + }, +}) + +const userData = await meResponse.json() +console.log(userData.data) // 用户信息 +``` + +### curl 示例 + +```bash +# 1. 登录 +curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"your_password"}' + +# 2. 使用返回的 token 访问受保护资源 +TOKEN="" +curl http://localhost:7002/auth/me \ + -H "Authorization: Bearer $TOKEN" +``` + +## 在其他模块中使用认证 + +### 使用认证中间件 + +项目提供了三个认证中间件: + +#### 1. `authMiddleware` - 强制认证 + +要求请求必须包含有效的 Bearer token,否则返回 401。 + +```typescript +import Elysia from 'elysia' +import { authMiddleware } from './middlewares' + +export const protectedModule = new Elysia({ prefix: '/protected' }) + .use(authMiddleware) + .get('/data', ({ user }) => { + // user 包含: { userId, username, role } + return { + success: true, + message: `Hello ${user.username}!`, + } + }) +``` + +#### 2. `optionalAuthMiddleware` - 可选认证 + +如果有 token 则验证,没有 token 也允许访问(user 为 null)。 + +```typescript +import Elysia from 'elysia' +import { optionalAuthMiddleware } from './middlewares' + +export const publicModule = new Elysia({ prefix: '/public' }) + .use(optionalAuthMiddleware) + .get('/data', ({ user }) => { + if (user) { + return { message: `Welcome back, ${user.username}!` } + } + return { message: 'Welcome, guest!' } + }) +``` + +#### 3. `adminMiddleware` - 管理员权限 + +要求用户必须是 admin 角色,否则返回 403。 + +```typescript +import Elysia from 'elysia' +import { adminMiddleware } from './middlewares' + +export const adminModule = new Elysia({ prefix: '/admin' }) + .use(adminMiddleware) + .get('/users', ({ user }) => { + // 只有 admin 才能访问 + return { success: true, data: [] } + }) +``` + +## Root 用户说明 + +Root 用户是通过环境变量配置的超级管理员: + +- **优先级最高**:登录时优先检查是否为 Root 用户 +- **不存储在数据库**:直接通过环境变量验证 +- **始终是 admin 角色**:拥有最高权限 +- **用于系统初始化**:可用于创建其他管理员用户 + +## 数据库用户 + +除了 Root 用户外,系统支持在数据库中创建普通用户: + +```typescript +import { createUser } from '@memohome/db/src/user-helpers' + +// 创建新用户 +const newUser = await createUser({ + username: 'john_doe', + email: 'john@example.com', + passwordHash: await Bun.password.hash('password123'), + role: 'member', + displayName: 'John Doe', +}) +``` + +用户密码使用 `Bun.password.hash` 加密存储,登录时使用 `Bun.password.verify` 验证。 + +## 安全建议 + +1. **生产环境配置**: + - 使用强密码作为 `ROOT_USER_PASSWORD` + - 设置随机的 `JWT_SECRET`(至少 32 字符) + - 不要将 `.env` 文件提交到代码仓库 + +2. **Token 管理**: + - Token 默认有效期为 7 天 + - 前端应安全存储 token(如 httpOnly cookie 或安全的 localStorage) + - Token 过期后需要重新登录 + +3. **密码策略**: + - 要求用户使用强密码 + - 考虑实现密码复杂度验证 + - 考虑添加密码重置功能 + +## 技术栈 + +- **Elysia**:Web 框架 +- **@elysiajs/jwt**:JWT token 生成和验证插件 +- **@elysiajs/bearer**:Bearer token 提取插件 +- **Bun.password**:密码加密和验证(基于 bcrypt) +- **Drizzle ORM**:数据库操作 + +## 相关文档 + +- [Elysia JWT Plugin](https://elysiajs.com/plugins/jwt.html) +- [Elysia Bearer Plugin](https://elysiajs.com/plugins/bearer.html) +- [用户表设计文档](../db/USERS_SCHEMA.md) + diff --git a/packages/api/README.md b/packages/api/README.md index 25e0d20e..0e2afbef 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1 +1,192 @@ -# @memohome/api \ No newline at end of file +# @memohome/api + +API 服务器,基于 Elysia 构建。 + +## API 模块 + +### 认证模块 (`/auth`) + +用户认证和授权管理。 + +- `POST /auth/login` - 用户登录 +- `GET /auth/verify` - 验证 token +- `GET /auth/me` - 获取当前用户信息 + +详细文档:[AUTH_README.md](./AUTH_README.md) + +### 用户管理模块 (`/user`) 🔒 仅管理员 + +完整的用户 CRUD 操作接口。 + +- `GET /user` - 获取所有用户 +- `GET /user/:id` - 获取单个用户 +- `POST /user` - 创建用户 +- `PUT /user/:id` - 更新用户信息 +- `DELETE /user/:id` - 删除用户 +- `PATCH /user/:id/password` - 更新用户密码 + +详细文档:[USER_MANAGEMENT.md](./USER_MANAGEMENT.md) + +### 模型管理模块 (`/model`) + +AI 模型配置管理。 + +- `GET /model` - 获取所有模型 +- `GET /model/:id` - 获取单个模型 +- `POST /model` - 创建模型配置 +- `PUT /model/:id` - 更新模型配置 +- `DELETE /model/:id` - 删除模型配置 +- `GET /model/chat/default` - 获取默认聊天模型 +- `GET /model/summary/default` - 获取默认摘要模型 +- `GET /model/embedding/default` - 获取默认嵌入模型 + +### 设置模块 (`/settings`) 🔒 需要认证 + +用户偏好设置管理(自动使用当前登录用户)。 + +- `GET /settings` - 获取当前用户设置 +- `PUT /settings` - 更新当前用户设置(支持模型配置、Agent 参数等) + +详细文档:[SETTINGS_API.md](./SETTINGS_API.md) + +### 记忆模块 (`/memory`) 🔒 需要认证 + +用户记忆和对话历史管理(自动使用当前登录用户)。 + +- `POST /memory` - 添加记忆 +- `GET /memory/search` - 搜索记忆 +- `GET /memory/message` - 获取消息历史(分页) +- `GET /memory/message/filter` - 按日期范围过滤消息 + +详细文档:[API_CHANGES.md](./API_CHANGES.md) + +### Agent 模块 (`/agent`) 🔒 需要认证 + +AI Agent 智能对话接口,支持流式响应和记忆管理。 + +- `POST /agent/stream` - 流式对话(Server-Sent Events) + +详细文档:[AGENT_API.md](./AGENT_API.md) + +## 快速开始 + +### 安装依赖 + +```bash +pnpm install +``` + +### 配置环境变量 + +复制并编辑环境变量文件: + +```bash +cp ../../.env.example ../../.env +``` + +必需配置: +- `DATABASE_URL` - PostgreSQL 连接字符串 +- `ROOT_USER` - Root 超级管理员用户名 +- `ROOT_USER_PASSWORD` - Root 超级管理员密码 +- `JWT_SECRET` - JWT 签名密钥 + +### 启动开发服务器 + +```bash +pnpm run dev +``` + +服务器将在 `http://localhost:7002` 启动。 + +### 构建生产版本 + +```bash +pnpm run build +pnpm run start +``` + +## 认证和权限 + +### Bearer Token 认证 + +所有受保护的 API 端点需要在请求头中携带 JWT token: + +``` +Authorization: Bearer +``` + +### 权限级别 + +- **公开接口**:无需认证 +- **用户接口**:需要有效的 JWT token +- **管理员接口** 🔒:需要管理员角色的 JWT token + +## 中间件 + +### `authMiddleware` + +强制认证中间件,要求请求必须包含有效的 Bearer token。 + +```typescript +import { authMiddleware } from './middlewares' + +const protectedModule = new Elysia() + .use(authMiddleware) + .get('/protected', ({ user }) => { + return { message: `Hello ${user.username}!` } + }) +``` + +### `adminMiddleware` 🔒 + +管理员权限中间件,要求用户角色为 `admin`。 + +```typescript +import { adminMiddleware } from './middlewares' + +const adminModule = new Elysia() + .use(adminMiddleware) + .get('/admin-only', ({ user }) => { + return { message: 'Admin access granted' } + }) +``` + +### `optionalAuthMiddleware` + +可选认证中间件,如果有 token 则验证,没有则 `user` 为 `null`。 + +```typescript +import { optionalAuthMiddleware } from './middlewares' + +const publicModule = new Elysia() + .use(optionalAuthMiddleware) + .get('/public', ({ user }) => { + if (user) { + return { message: `Welcome back, ${user.username}!` } + } + return { message: 'Welcome, guest!' } + }) +``` + +## 运行测试 + +```bash +pnpm test +``` + +## 技术栈 + +- **Elysia** - 高性能 Web 框架 +- **@elysiajs/jwt** - JWT 认证插件 +- **@elysiajs/bearer** - Bearer token 提取插件 +- **@elysiajs/cors** - CORS 支持 +- **Drizzle ORM** - 数据库 ORM +- **Zod** - 数据验证 +- **Bun** - JavaScript 运行时 + +## 相关文档 + +- [认证系统](./AUTH_README.md) +- [用户管理](./USER_MANAGEMENT.md) +- [项目设置指南](../../SETUP.md) +- [数据库 Schema](../db/USERS_SCHEMA.md) \ No newline at end of file diff --git a/packages/api/SETTINGS_API.md b/packages/api/SETTINGS_API.md new file mode 100644 index 00000000..64d6bfba --- /dev/null +++ b/packages/api/SETTINGS_API.md @@ -0,0 +1,281 @@ +# Settings API 文档 + +## 概述 + +Settings API 用于管理用户的个性化设置,包括默认模型配置和 Agent 行为设置。 + +## 端点 + +### GET `/settings` 🔒 需要认证 + +获取当前用户的设置。 + +#### 请求 + +**Headers:** +``` +Authorization: Bearer +``` + +#### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "userId": "user-id", + "defaultChatModel": "123e4567-e89b-12d3-a456-426614174000", + "defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001", + "defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002", + "maxContextLoadTime": 60, + "language": "Chinese" + } +} +``` + +#### 错误响应 (404 Not Found) + +```json +{ + "success": false, + "error": "Settings not found" +} +``` + +--- + +### PUT `/settings` 🔒 需要认证 + +更新或创建当前用户的设置(Upsert 操作)。 + +#### 请求 + +**Headers:** +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Body:** +```json +{ + "defaultChatModel": "123e4567-e89b-12d3-a456-426614174000", + "defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001", + "defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002", + "maxContextLoadTime": 60, + "language": "Chinese" +} +``` + +**参数说明:** + +| 字段 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| defaultChatModel | string (uuid) | 否 | null | 默认聊天模型 ID | +| defaultEmbeddingModel | string (uuid) | 否 | null | 默认嵌入模型 ID | +| defaultSummaryModel | string (uuid) | 否 | null | 默认摘要模型 ID | +| maxContextLoadTime | number | 否 | 60 | Agent 加载上下文的时间范围(分钟,1-1440) | +| language | string | 否 | "Same as user input" | Agent 回复的首选语言 | + +#### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "userId": "user-id", + "defaultChatModel": "123e4567-e89b-12d3-a456-426614174000", + "defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001", + "defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002", + "maxContextLoadTime": 60, + "language": "Chinese" + } +} +``` + +--- + +## 使用示例 + +### 完整工作流 + +```bash +# 1. 登录获取 token +TOKEN=$(curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"your_password"}' \ + | jq -r '.data.token') + +# 2. 获取当前设置 +curl http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" + +# 3. 更新设置 +curl -X PUT http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "defaultChatModel": "123e4567-e89b-12d3-a456-426614174000", + "defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001", + "defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002", + "maxContextLoadTime": 120, + "language": "English" + }' + +# 4. 只更新部分字段 +curl -X PUT http://localhost:7002/settings \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "maxContextLoadTime": 30, + "language": "Chinese" + }' +``` + +### JavaScript/TypeScript 示例 + +```typescript +// 获取设置 +async function getSettings(token: string) { + const response = await fetch('http://localhost:7002/settings', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + + const data = await response.json() + return data +} + +// 更新设置 +async function updateSettings(token: string, settings: { + defaultChatModel?: string + defaultEmbeddingModel?: string + defaultSummaryModel?: string + maxContextLoadTime?: number + language?: string +}) { + const response = await fetch('http://localhost:7002/settings', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(settings), + }) + + const data = await response.json() + return data +} + +// 使用示例 +const token = 'your_jwt_token' + +// 获取当前设置 +const currentSettings = await getSettings(token) +console.log(currentSettings) + +// 更新 Agent 设置 +await updateSettings(token, { + maxContextLoadTime: 90, + language: 'Chinese', +}) +``` + +--- + +## Agent 设置说明 + +### maxContextLoadTime + +控制 Agent 加载历史对话上下文的时间范围。 + +- **类型**: 整数(分钟) +- **范围**: 1-1440(1分钟到24小时) +- **默认值**: 60(1小时) +- **说明**: + - 值越大,Agent 能访问更久远的对话历史 + - 但也会增加 token 使用量和响应时间 + - 建议根据实际需求调整 + +**示例:** +- `30` - 加载最近30分钟的对话 +- `60` - 加载最近1小时的对话(默认) +- `1440` - 加载最近24小时的对话 + +### language + +设置 Agent 回复的首选语言。 + +- **类型**: 字符串 +- **默认值**: "Same as user input"(与用户输入相同) +- **说明**: + - 可以设置为任何语言名称 + - Agent 会尽量使用指定语言回复 + - 特殊值 "Same as user input" 表示跟随用户输入语言 + +**常用值:** +- `"Same as user input"` - 自动匹配用户语言(默认) +- `"Chinese"` - 中文 +- `"English"` - 英文 +- `"Japanese"` - 日文 +- `"Spanish"` - 西班牙语 + +--- + +## Agent 使用优先级 + +当使用 Agent API 时,配置的优先级为: + +1. **请求参数** - `/agent/stream` 请求中直接指定的参数 +2. **用户设置** - Settings 中保存的默认值 +3. **系统默认** - 内置的默认值 + +**示例:** + +```bash +# 情况1: 使用请求参数(最高优先级) +curl -X POST http://localhost:7002/agent/stream \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "message": "Hello", + "maxContextLoadTime": 30, + "language": "English" + }' +# 使用: maxContextLoadTime=30, language="English" + +# 情况2: 使用用户设置(如果请求中未指定) +# 假设用户设置: maxContextLoadTime=60, language="Chinese" +curl -X POST http://localhost:7002/agent/stream \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"message": "Hello"}' +# 使用: maxContextLoadTime=60, language="Chinese" + +# 情况3: 使用系统默认(如果都未设置) +# 使用: maxContextLoadTime=60, language="Same as user input" +``` + +--- + +## 数据库 Schema + +```sql +CREATE TABLE settings ( + user_id TEXT PRIMARY KEY, + default_chat_model UUID REFERENCES model(id), + default_embedding_model UUID REFERENCES model(id), + default_summary_model UUID REFERENCES model(id), + max_context_load_time INTEGER DEFAULT 60, + language TEXT DEFAULT 'Same as user input' +); +``` + +--- + +## 相关文档 + +- [Agent API](./AGENT_API.md) +- [Model API](./README.md) +- [认证系统](./AUTH_README.md) +- [API 变更说明](./API_CHANGES.md) + diff --git a/packages/api/USER_MANAGEMENT.md b/packages/api/USER_MANAGEMENT.md new file mode 100644 index 00000000..887a6758 --- /dev/null +++ b/packages/api/USER_MANAGEMENT.md @@ -0,0 +1,480 @@ +# 用户管理 API 文档 + +## 概述 + +用户管理模块提供了完整的用户 CRUD 操作接口,**仅限管理员访问**。所有端点都需要携带有效的管理员 JWT token。 + +## 权限要求 + +所有用户管理接口都受 `adminMiddleware` 保护: +- 必须提供有效的 Bearer token +- 用户角色必须是 `admin` +- 否则返回 `403 Forbidden` 错误 + +## API 端点 + +### 基础 URL + +``` +http://localhost:7002/user +``` + +### 请求头 + +所有请求都需要包含认证 token: + +``` +Authorization: Bearer +``` + +--- + +## 1. 获取所有用户 GET `/user` + +获取系统中所有用户的列表。 + +### 请求示例 + +```bash +curl http://localhost:7002/user \ + -H "Authorization: Bearer " +``` + +### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "john_doe", + "email": "john@example.com", + "role": "member", + "displayName": "John Doe", + "avatarUrl": "https://example.com/avatar.jpg", + "isActive": "true", + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-10T10:00:00Z", + "lastLoginAt": "2024-01-10T12:00:00Z" + } + ] +} +``` + +--- + +## 2. 获取单个用户 GET `/user/:id` + +根据用户 ID 获取用户详细信息。 + +### 路径参数 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| id | UUID | 是 | 用户的唯一标识符 | + +### 请求示例 + +```bash +curl http://localhost:7002/user/123e4567-e89b-12d3-a456-426614174000 \ + -H "Authorization: Bearer " +``` + +### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "john_doe", + "email": "john@example.com", + "role": "member", + "displayName": "John Doe", + "avatarUrl": "https://example.com/avatar.jpg", + "isActive": "true", + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-10T10:00:00Z", + "lastLoginAt": "2024-01-10T12:00:00Z" + } +} +``` + +### 错误响应 (404 Not Found) + +```json +{ + "success": false, + "error": "User not found" +} +``` + +--- + +## 3. 创建用户 POST `/user` + +创建新用户账户。 + +### 请求体 + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| username | string | 是 | 用户名(3-50字符,唯一) | +| email | string | 否 | 邮箱地址(必须是有效格式,唯一) | +| password | string | 是 | 密码(至少6个字符) | +| role | string | 否 | 用户角色:`admin` 或 `member`(默认 `member`) | +| displayName | string | 否 | 显示名称 | +| avatarUrl | string | 否 | 头像 URL(必须是有效的 URL) | + +### 请求示例 + +```bash +curl -X POST http://localhost:7002/user \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "jane_smith", + "email": "jane@example.com", + "password": "secure_password123", + "role": "member", + "displayName": "Jane Smith" + }' +``` + +### 成功响应 (201 Created) + +```json +{ + "success": true, + "data": { + "id": "223e4567-e89b-12d3-a456-426614174001", + "username": "jane_smith", + "email": "jane@example.com", + "role": "member", + "displayName": "Jane Smith", + "avatarUrl": null, + "isActive": "true", + "createdAt": "2024-01-10T15:00:00Z" + } +} +``` + +**注意**: 创建用户时会自动创建一个默认的 settings 条目,包含: +- `maxContextLoadTime`: 60(分钟) +- `language`: "Same as user input" +- 所有模型配置为 `null`(需要用户后续配置) + +### 错误响应 (409 Conflict) + +```json +{ + "success": false, + "error": "Username already exists" +} +``` + +或 + +```json +{ + "success": false, + "error": "Email already exists" +} +``` + +--- + +## 4. 更新用户 PUT `/user/:id` + +更新用户信息(不包括密码)。 + +### 路径参数 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| id | UUID | 是 | 用户的唯一标识符 | + +### 请求体(所有字段都是可选的) + +| 字段 | 类型 | 说明 | +|------|------|------| +| email | string | 邮箱地址 | +| role | string | 用户角色:`admin` 或 `member` | +| displayName | string | 显示名称 | +| avatarUrl | string | 头像 URL | +| isActive | string | 账户状态:`true` 或 `false` | + +### 请求示例 + +```bash +curl -X PUT http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "displayName": "Jane Smith (Updated)", + "role": "admin", + "isActive": "true" + }' +``` + +### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "id": "223e4567-e89b-12d3-a456-426614174001", + "username": "jane_smith", + "email": "jane@example.com", + "role": "admin", + "displayName": "Jane Smith (Updated)", + "avatarUrl": null, + "isActive": "true", + "createdAt": "2024-01-10T15:00:00Z", + "updatedAt": "2024-01-10T16:00:00Z", + "lastLoginAt": null + } +} +``` + +### 错误响应 (404 Not Found) + +```json +{ + "success": false, + "error": "User not found" +} +``` + +--- + +## 5. 删除用户 DELETE `/user/:id` + +删除用户账户。 + +### 路径参数 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| id | UUID | 是 | 用户的唯一标识符 | + +### 请求示例 + +```bash +curl -X DELETE http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001 \ + -H "Authorization: Bearer " +``` + +### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "id": "223e4567-e89b-12d3-a456-426614174001", + "username": "jane_smith" + } +} +``` + +### 错误响应 (404 Not Found) + +```json +{ + "success": false, + "error": "User not found" +} +``` + +--- + +## 6. 更新用户密码 PATCH `/user/:id/password` + +重置或更新用户密码。 + +### 路径参数 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| id | UUID | 是 | 用户的唯一标识符 | + +### 请求体 + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| password | string | 是 | 新密码(至少6个字符) | + +### 请求示例 + +```bash +curl -X PATCH http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001/password \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "password": "new_secure_password456" + }' +``` + +### 成功响应 (200 OK) + +```json +{ + "success": true, + "data": { + "id": "223e4567-e89b-12d3-a456-426614174001", + "username": "jane_smith" + }, + "message": "Password updated successfully" +} +``` + +### 错误响应 (404 Not Found) + +```json +{ + "success": false, + "error": "User not found" +} +``` + +--- + +## 错误响应 + +### 401 Unauthorized + +未提供 token 或 token 无效: + +```json +{ + "success": false, + "error": "No bearer token provided" +} +``` + +### 403 Forbidden + +非管理员用户尝试访问: + +```json +{ + "success": false, + "error": "Forbidden: Admin access required" +} +``` + +### 400 Bad Request + +请求数据验证失败: + +```json +{ + "success": false, + "error": "Username must be at least 3 characters" +} +``` + +--- + +## 使用示例 + +### 完整工作流 + +```bash +# 1. 管理员登录获取 token +TOKEN=$(curl -X POST http://localhost:7002/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"your_admin_password"}' \ + | jq -r '.data.token') + +# 2. 创建新用户 +curl -X POST http://localhost:7002/user \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "new_user", + "email": "newuser@example.com", + "password": "password123", + "role": "member", + "displayName": "New User" + }' + +# 3. 获取所有用户 +curl http://localhost:7002/user \ + -H "Authorization: Bearer $TOKEN" + +# 4. 获取特定用户 +USER_ID="123e4567-e89b-12d3-a456-426614174000" +curl http://localhost:7002/user/$USER_ID \ + -H "Authorization: Bearer $TOKEN" + +# 5. 更新用户信息 +curl -X PUT http://localhost:7002/user/$USER_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "displayName": "Updated Name", + "role": "admin" + }' + +# 6. 重置用户密码 +curl -X PATCH http://localhost:7002/user/$USER_ID/password \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "password": "new_password123" + }' + +# 7. 删除用户 +curl -X DELETE http://localhost:7002/user/$USER_ID \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +## 自动化行为 + +### 创建用户时的自动操作 + +当创建新用户时,系统会自动执行以下操作: + +1. **密码加密**: 使用 bcrypt 算法加密密码 +2. **创建 Settings**: 自动为用户创建默认设置条目 + - `maxContextLoadTime`: 60 分钟 + - `language`: "Same as user input" + - 模型配置: 全部为 null(需要用户后续配置) + +这确保每个用户都有完整的配置,可以立即使用系统功能。 + +--- + +## 安全注意事项 + +1. **管理员权限**: + - 所有接口仅限管理员访问 + - 确保 Root 用户的密码强度足够 + - 定期审计管理员账户 + +2. **密码安全**: + - 密码使用 bcrypt 加密存储 + - 最少 6 个字符(建议更高要求) + - 重置密码后建议通知用户 + +3. **数据验证**: + - 所有输入都经过严格验证 + - 邮箱和用户名必须唯一 + - URL 格式必须有效 + +4. **用户状态**: + - 使用 `isActive` 字段禁用用户而非直接删除 + - 保留用户数据用于审计 + +--- + +## 相关文档 + +- [认证系统文档](./AUTH_README.md) +- [用户表设计](../db/USERS_SCHEMA.md) +- [项目设置指南](../../SETUP.md) + diff --git a/packages/api/examples/agent-stream-client.ts b/packages/api/examples/agent-stream-client.ts new file mode 100644 index 00000000..2eda32fa --- /dev/null +++ b/packages/api/examples/agent-stream-client.ts @@ -0,0 +1,164 @@ +/** + * Agent Stream Client Example + * + * This example demonstrates how to use the /agent/stream API endpoint + * to have a streaming conversation with the AI agent. + * + * Usage: + * bun run agent-stream-client.ts + */ + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:7002' + +interface LoginResponse { + success: boolean + data?: { + token: string + user: { + id: string + username: string + role: string + } + } + error?: string +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }) + + const data: LoginResponse = await response.json() + + if (!data.success || !data.data?.token) { + throw new Error(data.error || 'Login failed') + } + + return data.data.token +} + +async function streamAgentChat(message: string, token: string) { + console.log(`\n📤 User: ${message}`) + console.log('🤖 Agent: ', { end: '' }) + + const response = await fetch(`${API_BASE_URL}/agent/stream`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + maxContextLoadTime: 60, + language: 'Same as user input', + }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Stream request failed') + } + + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + if (!reader) { + throw new Error('No reader available') + } + + let hasOutput = false + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const lines = chunk.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim() + + if (data === '[DONE]') { + console.log('\n\n✅ Stream completed') + return + } + + if (!data) continue + + try { + const event = JSON.parse(data) + + if (event.type === 'text-delta' && event.text) { + process.stdout.write(event.text) + hasOutput = true + } else if (event.type === 'tool-call') { + console.log(`\n[🔧 Tool: ${event.toolName}]`) + hasOutput = true + } else if (event.type === 'error') { + console.error('\n❌ Error:', event.error) + throw new Error(event.error) + } + } catch (e) { + if (e instanceof SyntaxError) { + // Skip invalid JSON + continue + } + throw e + } + } + } + } + } finally { + reader.releaseLock() + } + + if (!hasOutput) { + console.log('(No response)') + } +} + +async function main() { + try { + // Get credentials from environment or use defaults + const username = process.env.USERNAME || 'admin' + const password = process.env.PASSWORD || 'admin' + + console.log('🔐 Logging in...') + const token = await login(username, password) + console.log('✅ Login successful') + + // Example conversations + const messages = [ + '你好,请介绍一下你自己', + '你能帮我做什么?', + '记住:我的名字是张三', + '我的名字是什么?', + ] + + for (const message of messages) { + await streamAgentChat(message, token) + // Wait a bit between messages + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + console.log('\n🎉 All conversations completed!') + } catch (error) { + console.error('\n❌ Error:', error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +// Handle Ctrl+C gracefully +process.on('SIGINT', () => { + console.log('\n\n👋 Goodbye!') + process.exit(0) +}) + +main() + diff --git a/packages/api/package.json b/packages/api/package.json index 5b0e7c61..f2f27fae 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,9 +11,11 @@ "./client": "./src/client.ts" }, "dependencies": { + "@elysiajs/bearer": "^1.1.4", "@elysiajs/cors": "^1.4.1", "@elysiajs/cron": "^1.4.1", "@elysiajs/eden": "^1.4.6", + "@elysiajs/jwt": "^1.2.0", "@memohome/agent": "workspace:*", "@memohome/db": "workspace:*", "@memohome/memory": "workspace:*", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4e54860e..b33417b8 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,16 +1,18 @@ import { Elysia } from 'elysia' import { corsMiddleware } from './middlewares' -import { agentModule, modelModule, settingsModule } from './modules' +import { agentModule, authModule, modelModule, settingsModule, userModule } from './modules' import { memoryModule } from './modules/memory' const port = process.env.API_SERVER_PORT || 7002 export const app = new Elysia() .use(corsMiddleware) + .use(authModule) .use(agentModule) .use(memoryModule) .use(modelModule) .use(settingsModule) + .use(userModule) .listen(port) console.log( diff --git a/packages/api/src/middlewares/auth.ts b/packages/api/src/middlewares/auth.ts new file mode 100644 index 00000000..459bc144 --- /dev/null +++ b/packages/api/src/middlewares/auth.ts @@ -0,0 +1,113 @@ +import { Elysia } from 'elysia' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' + +/** + * 认证中间件 + * 验证 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(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, + }, + } + }) + +/** + * 可选认证中间件 + * 如果有 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(bearer()) + .derive(async ({ bearer, jwt }) => { + if (!bearer) { + return { user: null } + } + + const payload = await jwt.verify(bearer) + + if (!payload) { + return { user: null } + } + + return { + user: { + userId: payload.userId as string, + username: payload.username as string, + role: payload.role as string, + }, + } + }) + +/** + * 管理员权限中间件 + * 验证 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(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') + } + + const user = { + userId: payload.userId as string, + username: payload.username as string, + role: payload.role as string, + } + + // 检查是否为管理员 + if (user.role !== 'admin') { + set.status = 403 + throw new Error('Forbidden: Admin access required') + } + + return { user } + }) + diff --git a/packages/api/src/middlewares/index.ts b/packages/api/src/middlewares/index.ts index e50f281e..ba777fa0 100644 --- a/packages/api/src/middlewares/index.ts +++ b/packages/api/src/middlewares/index.ts @@ -1 +1,2 @@ +export * from './auth' export * from './cors' \ 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 1c7acaea..ec6e77b8 100644 --- a/packages/api/src/modules/agent/index.ts +++ b/packages/api/src/modules/agent/index.ts @@ -1,5 +1,125 @@ import Elysia from 'elysia' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' +import { AgentStreamModel } from './model' +import { createAgentStream } from './service' +import { getChatModel, getEmbeddingModel, getSummaryModel } from '../model/service' +import { getSettings } from '../settings/service' +import { ChatModel, EmbeddingModel } from '@memohome/shared' export const agentModule = new Elysia({ prefix: '/agent', -}) \ No newline at end of file +}) + .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, + }, + } + }) + // Stream agent conversation + .post('/stream', async ({ user, body, set }) => { + try { + // Get user's model configurations and settings + const [chatModel, embeddingModel, summaryModel, userSettings] = await Promise.all([ + getChatModel(user.userId), + getEmbeddingModel(user.userId), + getSummaryModel(user.userId), + getSettings(user.userId), + ]) + + if (!chatModel || !embeddingModel || !summaryModel) { + set.status = 400 + return { + success: false, + error: 'Model configuration not found. Please configure your models in settings.', + } + } + + // Use body params if provided, otherwise use settings, otherwise use defaults + const maxContextLoadTime = body.maxContextLoadTime + ?? userSettings?.maxContextLoadTime + ?? 60 + const language = body.language + ?? userSettings?.language + ?? 'Same as user input' + + // Create agent + const agent = await createAgentStream({ + userId: user.userId, + chatModel: chatModel.model as ChatModel, + embeddingModel: embeddingModel.model as EmbeddingModel, + summaryModel: summaryModel.model as ChatModel, + maxContextLoadTime, + language, + }) + + // Set headers for Server-Sent Events + set.headers['Content-Type'] = 'text/event-stream' + set.headers['Cache-Control'] = 'no-cache' + set.headers['Connection'] = 'keep-alive' + + // Create a stream + const stream = new ReadableStream({ + async start(controller) { + try { + const encoder = new TextEncoder() + + // Send events as they come + for await (const event of agent.ask(body.message)) { + const data = JSON.stringify(event) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + + // Send done event + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorData = JSON.stringify({ + type: 'error', + error: errorMessage + }) + controller.enqueue(new TextEncoder().encode(`data: ${errorData}\n\n`)) + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }) + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to process request', + } + } + }, AgentStreamModel) \ No newline at end of file diff --git a/packages/api/src/modules/agent/model.ts b/packages/api/src/modules/agent/model.ts new file mode 100644 index 00000000..1b73885f --- /dev/null +++ b/packages/api/src/modules/agent/model.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const AgentStreamModel = { + body: z.object({ + message: z.string().min(1, 'Message is required'), + // Optional overrides - if not provided, will use settings + maxContextLoadTime: z.number().int().min(1).max(1440).optional(), + language: z.string().optional(), + }), +} + +export type AgentStreamInput = z.infer + diff --git a/packages/api/src/modules/agent/service.ts b/packages/api/src/modules/agent/service.ts new file mode 100644 index 00000000..9f65e572 --- /dev/null +++ b/packages/api/src/modules/agent/service.ts @@ -0,0 +1,63 @@ +import { createAgent } from '@memohome/agent' +import { createMemory, filterByTimestamp, MemoryUnit } from '@memohome/memory' +import { ChatModel, EmbeddingModel } from '@memohome/shared' + +// Type for messages passed to onFinish callback +type MessageType = Record + +export interface CreateAgentStreamParams { + userId: string + chatModel: ChatModel + embeddingModel: EmbeddingModel + summaryModel: ChatModel + maxContextLoadTime: number + language?: string + onFinish?: (messages: MessageType[]) => Promise +} + +export async function createAgentStream(params: CreateAgentStreamParams) { + const { + userId, + chatModel, + embeddingModel, + summaryModel, + maxContextLoadTime, + language, + onFinish, + } = params + + // Create memory instance + const memoryInstance = createMemory({ + summaryModel, + embeddingModel, + }) + + // Create agent + const agent = createAgent({ + model: chatModel, + maxContextLoadTime, + language: language || 'Same as user input', + onReadMemory: async (from: Date, to: Date) => { + return await filterByTimestamp(from, to, userId) + }, + onSearchMemory: async (query: string) => { + const results = await memoryInstance.searchMemory(query, userId) + return results + }, + onFinish: async (messages: MessageType[]) => { + // Save conversation to memory + const memoryUnit: MemoryUnit = { + messages: messages as unknown as MemoryUnit['messages'], + timestamp: new Date(), + user: userId, + } + await memoryInstance.addMemory(memoryUnit) + + // Call custom onFinish handler if provided + await onFinish?.(messages) + }, + }) + + return agent +} + diff --git a/packages/api/src/modules/auth/index.ts b/packages/api/src/modules/auth/index.ts new file mode 100644 index 00000000..f549642a --- /dev/null +++ b/packages/api/src/modules/auth/index.ts @@ -0,0 +1,135 @@ +import Elysia from 'elysia' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' +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()) + // Login endpoint + .post('/login', async ({ body, jwt, set }) => { + try { + const user = await validateUser(body.username, body.password) + + if (!user) { + set.status = 401 + return { + success: false, + error: 'Invalid username or password', + } + } + + // 使用 JWT 插件生成 token + const token = await jwt.sign({ + userId: user.id, + username: user.username, + role: user.role, + }) + + return { + success: true, + data: { + token, + user: { + id: user.id, + username: user.username, + role: user.role, + displayName: user.displayName, + email: user.email, + }, + }, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to login', + } + } + }, LoginModel) + // Verify token endpoint + .get('/verify', async ({ bearer, jwt, set }) => { + try { + if (!bearer) { + set.status = 401 + return { + success: false, + error: 'No bearer token provided', + } + } + + // 使用 JWT 插件验证 token + const payload = await jwt.verify(bearer) + + if (!payload) { + set.status = 401 + return { + success: false, + error: 'Invalid or expired token', + } + } + + return { + success: true, + data: { + userId: payload.userId, + username: payload.username, + role: payload.role, + }, + } + } catch { + set.status = 401 + return { + success: false, + error: 'Invalid or expired token', + } + } + }) + // Get current user info + .get('/me', async ({ bearer, jwt, set }) => { + try { + if (!bearer) { + set.status = 401 + return { + success: false, + error: 'No bearer token provided', + } + } + + // 使用 JWT 插件验证 token + const payload = await jwt.verify(bearer) + + if (!payload) { + set.status = 401 + return { + success: false, + error: 'Invalid or expired token', + } + } + + return { + success: true, + data: { + userId: payload.userId, + username: payload.username, + role: payload.role, + }, + } + } catch { + set.status = 401 + return { + success: false, + error: 'Invalid or expired token', + } + } + }) + diff --git a/packages/api/src/modules/auth/model.ts b/packages/api/src/modules/auth/model.ts new file mode 100644 index 00000000..1f06e579 --- /dev/null +++ b/packages/api/src/modules/auth/model.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +const LoginSchema = z.object({ + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), +}) + +export type LoginInput = z.infer + +export const LoginModel = { + body: LoginSchema, +} + diff --git a/packages/api/src/modules/auth/service.ts b/packages/api/src/modules/auth/service.ts new file mode 100644 index 00000000..9bdc9cee --- /dev/null +++ b/packages/api/src/modules/auth/service.ts @@ -0,0 +1,86 @@ +import { db } from '@memohome/db' +import { users, settings } from '@memohome/db/schema' +import { eq } from 'drizzle-orm' + +/** + * 验证用户凭据 + * 优先检查是否为 ROOT 用户,否则查询数据库 + */ +export const validateUser = async (username: string, password: string) => { + // 检查是否为 ROOT 用户 + const rootUser = process.env.ROOT_USER + const rootPassword = process.env.ROOT_USER_PASSWORD + + if (rootUser && rootPassword && username === rootUser) { + if (password === rootPassword) { + // 检查 root 用户的 settings 是否存在,不存在则创建 + const [existingSettings] = await db + .select() + .from(settings) + .where(eq(settings.userId, 'root')) + + if (!existingSettings) { + // 为 root 用户创建默认 settings + await db + .insert(settings) + .values({ + userId: 'root', + defaultChatModel: null, + defaultEmbeddingModel: null, + defaultSummaryModel: null, + maxContextLoadTime: 60, + language: 'Same as user input', + }) + .onConflictDoNothing() // 避免并发创建导致的冲突 + } + + // 返回 ROOT 用户信息 + return { + id: 'root', + username: rootUser, + role: 'admin' as const, + displayName: 'Root User', + } + } + return null + } + + // 查询数据库中的用户 + const [user] = await db + .select() + .from(users) + .where(eq(users.username, username)) + + if (!user) { + return null + } + + // 验证密码 (这里使用简单的 Bun.password.verify) + const isValid = await Bun.password.verify(password, user.passwordHash) + + if (!isValid) { + return null + } + + // 检查账户是否激活 + if (user.isActive !== 'true') { + return null + } + + // 更新最后登录时间 + await db + .update(users) + .set({ + lastLoginAt: new Date(), + }) + .where(eq(users.id, user.id)) + + return { + id: user.id, + username: user.username, + role: user.role, + displayName: user.displayName || user.username, + email: user.email, + } +} + diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index f788d966..0290106d 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -1,3 +1,5 @@ export * from './agent' +export * from './auth' export * from './model' -export * from './settings' \ No newline at end of file +export * from './settings' +export * from './user' \ No newline at end of file diff --git a/packages/api/src/modules/memory/index.ts b/packages/api/src/modules/memory/index.ts index cf14d4a9..2fbabf22 100644 --- a/packages/api/src/modules/memory/index.ts +++ b/packages/api/src/modules/memory/index.ts @@ -1,4 +1,6 @@ import Elysia from 'elysia' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' import { messageModule } from './message' import { AddMemoryModel, SearchMemoryModel } from './model' import { addMemory, searchMemory } from './service' @@ -7,31 +9,66 @@ 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(messageModule) - // Add memory - .post('/', async ({ body }) => { + // Add memory for current user + .post('/', async ({ user, body, set }) => { try { - const result = await addMemory(body as unknown as MemoryUnit) + const memoryUnit: MemoryUnit = { + ...body, + user: user.userId, + } + const result = await addMemory(memoryUnit) return { success: true, data: result, } } catch (error) { + set.status = 500 return { success: false, error: error instanceof Error ? error.message : 'Failed to add memory', } } }, AddMemoryModel) - // Search memory - .get('/search', async ({ query }) => { + // Search memory for current user + .get('/search', async ({ user, query, set }) => { try { - const results = await searchMemory(query.query, query.userId) + const results = await searchMemory(query.query, user.userId) return { success: true, data: results, } } catch (error) { + set.status = 500 return { success: false, error: error instanceof Error ? error.message : 'Failed to search memory', diff --git a/packages/api/src/modules/memory/message/index.ts b/packages/api/src/modules/memory/message/index.ts index b93b9612..73843f5d 100644 --- a/packages/api/src/modules/memory/message/index.ts +++ b/packages/api/src/modules/memory/message/index.ts @@ -1,22 +1,70 @@ import Elysia from 'elysia' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' import { GetMemoryMessageFilterModel, GetMemoryMessageModel } from './model' import { getMemoryMessages, getMemoryMessagesFilter } from './service' export const messageModule = new Elysia({ prefix: '/message', }) - .get('/', async ({ query }) => { - const units = await getMemoryMessages(query) - return { - success: true, - units, + .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') } - }, GetMemoryMessageModel) - .get('/filter', async ({ query }) => { - const units = await getMemoryMessagesFilter(query) return { - units, - success: true, + user: { + userId: payload.userId as string, + username: payload.username as string, + role: payload.role as string, + }, + } + }) + // Get messages for current user (paginated) + .get('/', async ({ user, query, set }) => { + try { + const units = await getMemoryMessages(user.userId, query) + return { + success: true, + data: units, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch messages', + } + } + }, GetMemoryMessageModel) + // Get messages by date range for current user + .get('/filter', async ({ user, query, set }) => { + try { + const units = await getMemoryMessagesFilter(user.userId, query) + return { + success: true, + data: units, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to filter messages', + } } }, GetMemoryMessageFilterModel) \ No newline at end of file diff --git a/packages/api/src/modules/memory/message/model.ts b/packages/api/src/modules/memory/message/model.ts index b8ccfe01..f8c6c263 100644 --- a/packages/api/src/modules/memory/message/model.ts +++ b/packages/api/src/modules/memory/message/model.ts @@ -2,16 +2,14 @@ import { z } from 'zod' export const GetMemoryMessageModel = { query: z.object({ - limit: z.string().transform(Number).default(10), - page: z.string().transform(Number).default(1), - userId: z.string(), + limit: z.coerce.number().default(10), + page: z.coerce.number().default(1), }), } export const GetMemoryMessageFilterModel = { query: z.object({ - from: z.date(), - to: z.date(), - userId: z.string(), + from: z.coerce.date(), + to: z.coerce.date(), }), } \ No newline at end of file diff --git a/packages/api/src/modules/memory/message/service.ts b/packages/api/src/modules/memory/message/service.ts index 94774959..9c0b6c22 100644 --- a/packages/api/src/modules/memory/message/service.ts +++ b/packages/api/src/modules/memory/message/service.ts @@ -3,13 +3,13 @@ import { history } from '@memohome/db/schema' import { eq, desc, and, gte, lte, asc } from 'drizzle-orm' export const getMemoryMessages = async ( + userId: string, query: { limit: number page: number - userId: string } ) => { - const { limit, page, userId } = query + const { limit, page } = query const results = await db .select() .from(history) @@ -22,13 +22,13 @@ export const getMemoryMessages = async ( } export const getMemoryMessagesFilter = async ( + userId: string, query: { from: Date to: Date - userId: string } ) => { - const { from, to, userId } = query + const { from, to } = query const results = await db .select() .from(history) diff --git a/packages/api/src/modules/memory/model.ts b/packages/api/src/modules/memory/model.ts index 90c4d27c..93035b68 100644 --- a/packages/api/src/modules/memory/model.ts +++ b/packages/api/src/modules/memory/model.ts @@ -1,20 +1,18 @@ import { z } from 'zod' -// MemoryUnit schema -const MemoryUnitSchema = z.object({ - messages: z.array(z.object()), +// MemoryUnit schema (without user field, will be added from auth) +const MemoryUnitBodySchema = z.object({ + messages: z.array(z.any()), timestamp: z.coerce.date(), - user: z.string(), }) export const AddMemoryModel = { - body: MemoryUnitSchema, + body: MemoryUnitBodySchema, } export const SearchMemoryModel = { query: z.object({ query: z.string().min(1, 'Search query is required'), - userId: z.string().min(1, 'User ID is required'), }), } diff --git a/packages/api/src/modules/settings/index.ts b/packages/api/src/modules/settings/index.ts index 3f7309e4..4bc0147f 100644 --- a/packages/api/src/modules/settings/index.ts +++ b/packages/api/src/modules/settings/index.ts @@ -1,25 +1,47 @@ import Elysia from 'elysia' -import { - GetSettingsModel, - CreateSettingsModel, - UpdateSettingsModel, -} from './model' -import { - getSettings, - createSettings, - updateSettings, - upsertSettings, -} from './service' +import { bearer } from '@elysiajs/bearer' +import { jwt } from '@elysiajs/jwt' +import { UpdateSettingsModel } from './model' +import { getSettings, upsertSettings } from './service' export const settingsModule = new Elysia({ prefix: '/settings', }) - // Get user settings - .get('/:userId', async ({ params }) => { + .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, + }, + } + }) + // Get current user's settings + .get('/', async ({ user, set }) => { try { - const { userId } = params - const userSettings = await getSettings(userId) + const userSettings = await getSettings(user.userId) if (!userSettings) { + set.status = 404 return { success: false, error: 'Settings not found', @@ -30,62 +52,27 @@ export const settingsModule = new Elysia({ data: userSettings, } } catch (error) { + set.status = 500 return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch settings', } } - }, GetSettingsModel) - // Create new settings - .post('/', async ({ body }) => { + }) + // Update or create current user's settings + .put('/', async ({ user, body, set }) => { try { - const newSettings = await createSettings(body) + const result = await upsertSettings(user.userId, body) return { success: true, - data: newSettings, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to create settings', - } - } - }, CreateSettingsModel) - // Update settings - .put('/:userId', async ({ params, body }) => { - try { - const { userId } = params - const updatedSettings = await updateSettings(userId, body) - if (!updatedSettings) { - return { - success: false, - error: 'Settings not found', - } - } - return { - success: true, - data: updatedSettings, + data: result, } } catch (error) { + set.status = 500 return { success: false, error: error instanceof Error ? error.message : 'Failed to update settings', } } }, UpdateSettingsModel) - // Upsert settings (create or update) - .patch('/', async ({ body }) => { - try { - const result = await upsertSettings(body) - return { - success: true, - data: result, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to upsert settings', - } - } - }, CreateSettingsModel) diff --git a/packages/api/src/modules/settings/model.ts b/packages/api/src/modules/settings/model.ts index 52476d66..6adaadc3 100644 --- a/packages/api/src/modules/settings/model.ts +++ b/packages/api/src/modules/settings/model.ts @@ -1,32 +1,16 @@ import { z } from 'zod' -const SettingsSchema = z.object({ - userId: z.string().min(1, 'User ID is required'), +const SettingsBodySchema = z.object({ defaultChatModel: z.string().uuid().nullable().optional(), defaultEmbeddingModel: z.string().uuid().nullable().optional(), defaultSummaryModel: z.string().uuid().nullable().optional(), + maxContextLoadTime: z.number().int().min(1).max(1440).optional(), // 1 minute to 24 hours + language: z.string().optional(), }) -export type SettingsInput = z.infer - -export const GetSettingsModel = { - params: z.object({ - userId: z.string(), - }), -} - -export const CreateSettingsModel = { - body: SettingsSchema, -} +export type SettingsInput = z.infer export const UpdateSettingsModel = { - params: z.object({ - userId: z.string(), - }), - body: z.object({ - defaultChatModel: z.string().uuid().nullable().optional(), - defaultEmbeddingModel: z.string().uuid().nullable().optional(), - defaultSummaryModel: z.string().uuid().nullable().optional(), - }), + body: SettingsBodySchema, } diff --git a/packages/api/src/modules/settings/service.ts b/packages/api/src/modules/settings/service.ts index d6adceb0..dc8935f2 100644 --- a/packages/api/src/modules/settings/service.ts +++ b/packages/api/src/modules/settings/service.ts @@ -11,51 +11,38 @@ export const getSettings = async (userId: string) => { return result } -export const createSettings = async (data: SettingsInput) => { - const [newSettings] = await db - .insert(settings) - .values({ - userId: data.userId, - defaultChatModel: data.defaultChatModel || null, - defaultEmbeddingModel: data.defaultEmbeddingModel || null, - defaultSummaryModel: data.defaultSummaryModel || null, - }) - .returning() - return newSettings -} +export const upsertSettings = async (userId: string, data: SettingsInput) => { + const updateData: Record = {} + + if (data.defaultChatModel !== undefined) { + updateData.defaultChatModel = data.defaultChatModel + } + if (data.defaultEmbeddingModel !== undefined) { + updateData.defaultEmbeddingModel = data.defaultEmbeddingModel + } + if (data.defaultSummaryModel !== undefined) { + updateData.defaultSummaryModel = data.defaultSummaryModel + } + if (data.maxContextLoadTime !== undefined) { + updateData.maxContextLoadTime = data.maxContextLoadTime + } + if (data.language !== undefined) { + updateData.language = data.language + } -export const updateSettings = async ( - userId: string, - data: Partial> -) => { - const [updatedSettings] = await db - .update(settings) - .set({ - defaultChatModel: data.defaultChatModel, - defaultEmbeddingModel: data.defaultEmbeddingModel, - defaultSummaryModel: data.defaultSummaryModel, - }) - .where(eq(settings.userId, userId)) - .returning() - return updatedSettings -} - -export const upsertSettings = async (data: SettingsInput) => { const [result] = await db .insert(settings) .values({ - userId: data.userId, + userId: userId, defaultChatModel: data.defaultChatModel || null, defaultEmbeddingModel: data.defaultEmbeddingModel || null, defaultSummaryModel: data.defaultSummaryModel || null, + maxContextLoadTime: data.maxContextLoadTime || 60, + language: data.language || 'Same as user input', }) .onConflictDoUpdate({ target: settings.userId, - set: { - defaultChatModel: data.defaultChatModel, - defaultEmbeddingModel: data.defaultEmbeddingModel, - defaultSummaryModel: data.defaultSummaryModel, - }, + set: updateData, }) .returning() return result diff --git a/packages/api/src/modules/user/index.ts b/packages/api/src/modules/user/index.ts new file mode 100644 index 00000000..7892793b --- /dev/null +++ b/packages/api/src/modules/user/index.ts @@ -0,0 +1,171 @@ +import Elysia from 'elysia' +import { adminMiddleware } from '../../middlewares' +import { + GetUserByIdModel, + CreateUserModel, + UpdateUserModel, + DeleteUserModel, + UpdatePasswordModel, +} from './model' +import { + getUsers, + getUserById, + createUser, + updateUser, + deleteUser, + updateUserPassword, +} from './service' + +export const userModule = new Elysia({ + prefix: '/user', +}) + // 使用管理员中间件保护所有路由 + .use(adminMiddleware) + // Get all users + .get('/', async () => { + try { + const userList = await getUsers() + return { + success: true, + data: userList, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch users', + } + } + }) + // Get user by ID + .get('/:id', async ({ params, set }) => { + try { + const { id } = params + const user = await getUserById(id) + + if (!user) { + set.status = 404 + return { + success: false, + error: 'User not found', + } + } + + return { + success: true, + data: user, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch user', + } + } + }, GetUserByIdModel) + // Create new user + .post('/', async ({ body, set }) => { + try { + const newUser = await createUser(body) + set.status = 201 + return { + success: true, + data: newUser, + } + } catch (error) { + if (error instanceof Error && ( + error.message.includes('already exists') + )) { + set.status = 409 + } else { + set.status = 500 + } + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create user', + } + } + }, CreateUserModel) + // Update user + .put('/:id', async ({ params, body, set }) => { + try { + const { id } = params + const updatedUser = await updateUser(id, body) + + if (!updatedUser) { + set.status = 404 + return { + success: false, + error: 'User not found', + } + } + + return { + success: true, + data: updatedUser, + } + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + set.status = 409 + } else { + set.status = 500 + } + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update user', + } + } + }, UpdateUserModel) + // Delete user + .delete('/:id', async ({ params, set }) => { + try { + const { id } = params + const deletedUser = await deleteUser(id) + + if (!deletedUser) { + set.status = 404 + return { + success: false, + error: 'User not found', + } + } + + return { + success: true, + data: deletedUser, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete user', + } + } + }, DeleteUserModel) + // Update user password + .patch('/:id/password', async ({ params, body, set }) => { + try { + const { id } = params + const updatedUser = await updateUserPassword(id, body.password) + + if (!updatedUser) { + set.status = 404 + return { + success: false, + error: 'User not found', + } + } + + return { + success: true, + data: updatedUser, + message: 'Password updated successfully', + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update password', + } + } + }, UpdatePasswordModel) + diff --git a/packages/api/src/modules/user/model.ts b/packages/api/src/modules/user/model.ts new file mode 100644 index 00000000..f900ed5a --- /dev/null +++ b/packages/api/src/modules/user/model.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' + +// 用户角色枚举 +const UserRoleSchema = z.enum(['admin', 'member']) + +// 创建用户的 Schema +const CreateUserSchema = z.object({ + username: z.string().min(3, 'Username must be at least 3 characters').max(50), + email: z.string().email('Invalid email format').optional(), + password: z.string().min(6, 'Password must be at least 6 characters'), + role: UserRoleSchema.default('member'), + displayName: z.string().optional(), + avatarUrl: z.string().url('Invalid URL format').optional(), +}) + +// 更新用户的 Schema +const UpdateUserSchema = z.object({ + email: z.string().email('Invalid email format').optional(), + role: UserRoleSchema.optional(), + displayName: z.string().optional(), + avatarUrl: z.string().url('Invalid URL format').optional(), + isActive: z.enum(['true', 'false']).optional(), +}) + +// 更新密码的 Schema +const UpdatePasswordSchema = z.object({ + password: z.string().min(6, 'Password must be at least 6 characters'), +}) + +export type CreateUserInput = z.infer +export type UpdateUserInput = z.infer +export type UpdatePasswordInput = z.infer + +export const GetUserByIdModel = { + params: z.object({ + id: z.string().uuid('Invalid user ID format'), + }), +} + +export const CreateUserModel = { + body: CreateUserSchema, +} + +export const UpdateUserModel = { + params: z.object({ + id: z.string().uuid('Invalid user ID format'), + }), + body: UpdateUserSchema, +} + +export const DeleteUserModel = { + params: z.object({ + id: z.string().uuid('Invalid user ID format'), + }), +} + +export const UpdatePasswordModel = { + params: z.object({ + id: z.string().uuid('Invalid user ID format'), + }), + body: UpdatePasswordSchema, +} + diff --git a/packages/api/src/modules/user/service.ts b/packages/api/src/modules/user/service.ts new file mode 100644 index 00000000..c2bf0166 --- /dev/null +++ b/packages/api/src/modules/user/service.ts @@ -0,0 +1,213 @@ +import { db } from '@memohome/db' +import { users, settings } from '@memohome/db/schema' +import { eq } from 'drizzle-orm' +import type { CreateUserInput, UpdateUserInput } from './model' + +/** + * 获取所有用户列表 + */ +export const getUsers = async () => { + const userList = await db + .select({ + id: users.id, + username: users.username, + email: users.email, + role: users.role, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isActive: users.isActive, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + lastLoginAt: users.lastLoginAt, + }) + .from(users) + .orderBy(users.createdAt) + + return userList +} + +/** + * 根据 ID 获取用户 + */ +export const getUserById = async (id: string) => { + const [user] = await db + .select({ + id: users.id, + username: users.username, + email: users.email, + role: users.role, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isActive: users.isActive, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + lastLoginAt: users.lastLoginAt, + }) + .from(users) + .where(eq(users.id, id)) + + return user +} + +/** + * 创建新用户 + */ +export const createUser = async (data: CreateUserInput) => { + // 检查用户名是否已存在 + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.username, data.username)) + + if (existingUser) { + throw new Error('Username already exists') + } + + // 检查邮箱是否已存在(如果提供了邮箱) + if (data.email) { + const [existingEmail] = await db + .select() + .from(users) + .where(eq(users.email, data.email)) + + if (existingEmail) { + throw new Error('Email already exists') + } + } + + // 加密密码 + const passwordHash = await Bun.password.hash(data.password) + + // 创建用户 + const [newUser] = await db + .insert(users) + .values({ + username: data.username, + email: data.email || null, + passwordHash, + role: data.role || 'member', + displayName: data.displayName || null, + avatarUrl: data.avatarUrl || null, + }) + .returning({ + id: users.id, + username: users.username, + email: users.email, + role: users.role, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isActive: users.isActive, + createdAt: users.createdAt, + }) + + // 自动创建用户的 settings 条目(使用默认值) + await db + .insert(settings) + .values({ + userId: newUser.id, + defaultChatModel: null, + defaultEmbeddingModel: null, + defaultSummaryModel: null, + maxContextLoadTime: 60, + language: 'Same as user input', + }) + + return newUser +} + +/** + * 更新用户信息 + */ +export const updateUser = async (id: string, data: UpdateUserInput) => { + // 检查用户是否存在 + const existingUser = await getUserById(id) + if (!existingUser) { + return null + } + + // 如果更新邮箱,检查邮箱是否已被其他用户使用 + if (data.email) { + const [emailUser] = await db + .select() + .from(users) + .where(eq(users.email, data.email)) + + if (emailUser && emailUser.id !== id) { + throw new Error('Email already exists') + } + } + + // 更新用户 + const [updatedUser] = await db + .update(users) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(users.id, id)) + .returning({ + id: users.id, + username: users.username, + email: users.email, + role: users.role, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isActive: users.isActive, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + lastLoginAt: users.lastLoginAt, + }) + + return updatedUser +} + +/** + * 删除用户 + */ +export const deleteUser = async (id: string) => { + // 检查用户是否存在 + const existingUser = await getUserById(id) + if (!existingUser) { + return null + } + + const [deletedUser] = await db + .delete(users) + .where(eq(users.id, id)) + .returning({ + id: users.id, + username: users.username, + }) + + return deletedUser +} + +/** + * 更新用户密码 + */ +export const updateUserPassword = async (id: string, password: string) => { + // 检查用户是否存在 + const existingUser = await getUserById(id) + if (!existingUser) { + return null + } + + // 加密新密码 + const passwordHash = await Bun.password.hash(password) + + // 更新密码 + const [updatedUser] = await db + .update(users) + .set({ + passwordHash, + updatedAt: new Date(), + }) + .where(eq(users.id, id)) + .returning({ + id: users.id, + username: users.username, + }) + + return updatedUser +} + diff --git a/packages/db/README.md b/packages/db/README.md index f66995d3..3d312356 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -1,5 +1,18 @@ # @memohome/db +Database package for memohome project using Drizzle ORM and PostgreSQL. + +## Database Schema + +### Tables + +- **model**: AI model configurations +- **history**: Chat history records +- **settings**: User settings and preferences +- **users**: User accounts with authentication + +See [USERS_SCHEMA.md](./USERS_SCHEMA.md) for detailed user table documentation. + ## Quick Start ### Initialize Database diff --git a/packages/db/USERS_SCHEMA.md b/packages/db/USERS_SCHEMA.md new file mode 100644 index 00000000..689f63ae --- /dev/null +++ b/packages/db/USERS_SCHEMA.md @@ -0,0 +1,120 @@ +# 用户表设计 + +## 数据库表结构 + +### users 表(用户表) + +存储用户的基本信息和登录凭证。 + +| 字段名 | 类型 | 说明 | 约束 | +|--------|------|------|------| +| id | uuid | 用户唯一标识 | 主键,自动生成 | +| username | text | 用户名 | 非空,唯一 | +| email | text | 邮箱地址 | 唯一(可选)| +| password_hash | text | 密码哈希值 | 非空 | +| role | user_role | 用户角色 | 枚举:admin/member,默认 member | +| display_name | text | 显示名称 | 可选 | +| avatar_url | text | 头像地址 | 可选 | +| is_active | text | 账户状态 | 默认 true | +| created_at | timestamp | 创建时间 | 自动生成 | +| updated_at | timestamp | 更新时间 | 自动生成 | +| last_login_at | timestamp | 最后登录时间 | 可选 | + +## 角色说明 + +系统定义了两种角色: +- **admin**: 管理员,拥有完整权限 +- **member**: 普通成员,拥有基础权限 + +## 使用方法 + +### 1. 生成迁移文件 + +```bash +cd packages/db +pnpm run generate +``` + +### 2. 执行数据库迁移 + +```bash +pnpm run push +``` + +### 3. 在代码中使用 + +```typescript +import { users, userRoleEnum } from '@memohome/db/schema' +import { db } from '@memohome/db' + +// 创建用户 +const password = 'password123' +const passwordHash = await Bun.password.hash(password) // 使用 Bun 内置的密码加密 + +const newUser = await db.insert(users).values({ + username: 'john_doe', + email: 'john@example.com', + passwordHash: passwordHash, + role: 'member', + displayName: 'John Doe', +}).returning() + +// 或者使用辅助函数 +import { createUser } from '@memohome/db/src/user-helpers' + +const user = await createUser({ + username: 'john_doe', + email: 'john@example.com', + passwordHash: await Bun.password.hash('password123'), + role: 'member', + displayName: 'John Doe', +}) +``` + +## Root 用户 + +系统支持通过环境变量配置超级用户(Root User): + +```bash +ROOT_USER=admin +ROOT_USER_PASSWORD=your_secure_password +``` + +Root 用户特点: +- 不存储在数据库中 +- 通过环境变量直接验证 +- 始终拥有 admin 角色 +- 用于系统初始化和紧急管理 + +## 安全建议 + +1. **密码存储**: + - 永远不要存储明文密码 + - 使用 Bun.password.hash 进行密码加密 + - Bun 内置的密码加密基于 bcrypt 算法,安全可靠 + +2. **身份验证**: + - 使用 JWT 进行用户身份验证 + - 设置合理的过期时间(默认 7 天) + - 在请求头中携带 `Authorization: Bearer ` + +3. **权限控制**: + - 在业务层实现基于角色的访问控制(RBAC) + - 验证用户权限后再执行敏感操作 + - 记录重要操作的审计日志 + +4. **环境变量**: + - 设置强密码作为 ROOT_USER_PASSWORD + - 在生产环境中设置自定义的 JWT_SECRET + - 不要将敏感信息提交到代码仓库 + +## 扩展建议 + +如果需要更复杂的功能,可以考虑: + +1. 添加用户注册功能 +2. 添加密码重置功能 +3. 添加邮箱验证功能 +4. 实现刷新令牌机制 +5. 添加用户会话管理 + diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6e8f6de0..9ab6ed47 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,3 +1,4 @@ export * from './history' export * from './model' -export * from './settings' \ No newline at end of file +export * from './settings' +export * from './users' \ No newline at end of file diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 07a622e1..d04b9a84 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -1,4 +1,4 @@ -import { pgTable, text, uuid } from 'drizzle-orm/pg-core' +import { pgTable, text, uuid, integer } from 'drizzle-orm/pg-core' import { model } from './model' export const settings = pgTable('settings', { @@ -6,4 +6,7 @@ export const settings = pgTable('settings', { defaultChatModel: uuid('default_chat_model').references(() => model.id), defaultEmbeddingModel: uuid('default_embedding_model').references(() => model.id), defaultSummaryModel: uuid('default_summary_model').references(() => model.id), + // Agent settings + maxContextLoadTime: integer('max_context_load_time').default(60), // minutes + language: text('language').default('Same as user input'), }) \ No newline at end of file diff --git a/packages/db/src/user-helpers.ts b/packages/db/src/user-helpers.ts new file mode 100644 index 00000000..b638f27a --- /dev/null +++ b/packages/db/src/user-helpers.ts @@ -0,0 +1,112 @@ +import { eq } from 'drizzle-orm' +import { db } from './index' +import { users } from './users' + +/** + * 用户操作辅助函数 + * 这些函数提供了常用的用户、用户组操作 + */ + +// ============= 用户操作 ============= + +/** + * 根据用户名查找用户 + */ +export async function findUserByUsername(username: string) { + const result = await db.select().from(users).where(eq(users.username, username)) + return result[0] || null +} + +/** + * 根据邮箱查找用户 + */ +export async function findUserByEmail(email: string) { + const result = await db.select().from(users).where(eq(users.email, email)) + return result[0] || null +} + +/** + * 根据ID查找用户 + */ +export async function findUserById(id: string) { + const result = await db.select().from(users).where(eq(users.id, id)) + return result[0] || null +} + +/** + * 创建新用户 + */ +export async function createUser(data: { + username: string + email?: string + passwordHash: string + role?: 'admin' | 'member' + displayName?: string + avatarUrl?: string +}) { + const result = await db.insert(users).values({ + username: data.username, + email: data.email, + passwordHash: data.passwordHash, + role: data.role || 'member', + displayName: data.displayName, + avatarUrl: data.avatarUrl, + }).returning() + + return result[0] +} + +/** + * 更新用户信息 + */ +export async function updateUser(id: string, data: { + displayName?: string + avatarUrl?: string + email?: string + role?: 'admin' | 'member' + isActive?: string +}) { + const result = await db + .update(users) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(users.id, id)) + .returning() + + return result[0] +} + +/** + * 更新用户密码 + */ +export async function updateUserPassword(id: string, passwordHash: string) { + await db + .update(users) + .set({ + passwordHash, + updatedAt: new Date(), + }) + .where(eq(users.id, id)) +} + +/** + * 更新最后登录时间 + */ +export async function updateLastLogin(id: string) { + await db + .update(users) + .set({ + lastLoginAt: new Date(), + }) + .where(eq(users.id, id)) +} + +/** + * 删除用户 + */ +export async function deleteUser(id: string) { + await db.delete(users).where(eq(users.id, id)) +} + diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts new file mode 100644 index 00000000..ad2e8686 --- /dev/null +++ b/packages/db/src/users.ts @@ -0,0 +1,40 @@ +import { pgTable, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core' + +// 定义用户角色枚举 +export const userRoleEnum = pgEnum('user_role', ['admin', 'member']) + +// 用户表 +export const users = pgTable('users', { + // 主键ID + id: uuid('id').primaryKey().defaultRandom(), + + // 用户名(唯一) + username: text('username').notNull().unique(), + + // 邮箱(可选,唯一) + email: text('email').unique(), + + // 密码哈希值(使用 bcrypt 或其他加密方式) + passwordHash: text('password_hash').notNull(), + + // 用户角色 + role: userRoleEnum('role').notNull().default('member'), + + // 显示名称 + displayName: text('display_name'), + + // 头像 URL + avatarUrl: text('avatar_url'), + + // 账户状态(是否激活) + isActive: text('is_active').notNull().default('true'), + + // 创建时间 + createdAt: timestamp('created_at').notNull().defaultNow(), + + // 更新时间 + updatedAt: timestamp('updated_at').notNull().defaultNow(), + + // 最后登录时间 + lastLoginAt: timestamp('last_login_at'), +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94b7456e..34e43ec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: packages/api: dependencies: + '@elysiajs/bearer': + specifier: ^1.1.4 + version: 1.4.2(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/cors': specifier: ^1.4.1 version: 1.4.1(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)) @@ -99,6 +102,9 @@ importers: '@elysiajs/eden': specifier: ^1.4.6 version: 1.4.6(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/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)) '@memohome/agent': specifier: workspace:* version: link:../agent @@ -260,6 +266,9 @@ importers: packages/web: dependencies: + '@memohome/api': + specifier: workspace:* + version: link:../api '@memohome/shared': specifier: workspace:* version: link:../shared @@ -557,6 +566,11 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@elysiajs/bearer@1.4.2': + resolution: {integrity: sha512-MK2aCFqnFMqMNSa1e/A6+Ow5uNl5LpKd8K4lCB2LIsyDrI6juxOUHAgqq+esgdSoh3urD1UIMqFC//TsqCQViA==} + peerDependencies: + elysia: '>= 1.4.3' + '@elysiajs/cors@1.4.1': resolution: {integrity: sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ==} peerDependencies: @@ -572,6 +586,11 @@ packages: peerDependencies: elysia: '>=1.4.19' + '@elysiajs/jwt@1.4.0': + resolution: {integrity: sha512-Z0PvZhQxdDeKZ8HslXzDoXXD83NKExNPmoiAPki3nI2Xvh5wtUrBH+zWOD17yP14IbRo8fxGj3L25MRCAPsgPA==} + 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' @@ -2924,6 +2943,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -4648,6 +4670,10 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@elysiajs/bearer@1.4.2(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) + '@elysiajs/cors@1.4.1(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) @@ -4661,6 +4687,11 @@ snapshots: 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) + '@elysiajs/jwt@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))': + 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) + jose: 6.1.3 + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -6960,6 +6991,8 @@ snapshots: jju@1.4.0: {} + jose@6.1.3: {} + js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1