From f64c5beee694b3200ef1c9968ed7b825b553e476 Mon Sep 17 00:00:00 2001 From: yejunbo <692979649@qq.com> Date: Thu, 9 Oct 2025 13:01:23 +0800 Subject: [PATCH] feat(smf-h): AI Chat Service with auth, persistence, redis cache, list/clear endpoints, docs --- smf-h/README.md | 93 +++ smf-h/config/config.example.yaml | 42 ++ smf-h/config/config.yaml | 35 + smf-h/docs/SECURITY_CONFIG.md | 83 +++ smf-h/docs/api.md | 176 +++++ smf-h/docs/code_overview.md | 300 ++++++++ smf-h/docs/swagger/docs.go | 604 +++++++++++++++ smf-h/docs/swagger/swagger.json | 579 +++++++++++++++ smf-h/docs/swagger/swagger.yaml | 384 ++++++++++ smf-h/go.mod | 65 ++ smf-h/go.sum | 269 +++++++ smf-h/internal/auth/auth.go | 63 ++ smf-h/internal/auth/middleware.go | 69 ++ smf-h/internal/config/config.go | 177 +++++ smf-h/internal/db/mysql.go | 50 ++ smf-h/internal/db/tx.go | 4 + smf-h/internal/memory/conversation_memory.go | 162 +++++ smf-h/internal/models/conversation.go | 14 + smf-h/internal/models/message.go | 17 + smf-h/internal/models/user.go | 20 + smf-h/internal/redisstore/redis.go | 111 +++ smf-h/main.go | 726 +++++++++++++++++++ 22 files changed, 4043 insertions(+) create mode 100644 smf-h/README.md create mode 100644 smf-h/config/config.example.yaml create mode 100644 smf-h/config/config.yaml create mode 100644 smf-h/docs/SECURITY_CONFIG.md create mode 100644 smf-h/docs/api.md create mode 100644 smf-h/docs/code_overview.md create mode 100644 smf-h/docs/swagger/docs.go create mode 100644 smf-h/docs/swagger/swagger.json create mode 100644 smf-h/docs/swagger/swagger.yaml create mode 100644 smf-h/go.mod create mode 100644 smf-h/go.sum create mode 100644 smf-h/internal/auth/auth.go create mode 100644 smf-h/internal/auth/middleware.go create mode 100644 smf-h/internal/config/config.go create mode 100644 smf-h/internal/db/mysql.go create mode 100644 smf-h/internal/db/tx.go create mode 100644 smf-h/internal/memory/conversation_memory.go create mode 100644 smf-h/internal/models/conversation.go create mode 100644 smf-h/internal/models/message.go create mode 100644 smf-h/internal/models/user.go create mode 100644 smf-h/internal/redisstore/redis.go create mode 100644 smf-h/main.go diff --git a/smf-h/README.md b/smf-h/README.md new file mode 100644 index 0000000..30c95b7 --- /dev/null +++ b/smf-h/README.md @@ -0,0 +1,93 @@ +# AI Chat Service + +一个包含用户认证、模型权限控制、会话持久化、历史缓存与 Swagger 文档的简化 AI 多轮对话示例服务。 + +## 功能特性 +- 用户注册 / 登录(JWT,环境变量可覆盖密钥) +- 模型访问权限控制(用户等级 / 角色 + 模型最小等级) +- 会话与消息:MySQL 持久化 + 内存缓存 + 最近历史 Redis 缓存 +- 统一错误码与结构化响应(支持多类错误 swagger 展示) +- Swagger 文档(动态生成) +- 环境变量安全覆写配置,真实 `config.yaml` 不提交 +- Redis 可选:失败自动降级不影响主流程 + +## 快速启动 (Windows PowerShell) +```powershell +# 可选:克隆后进入目录 +# git clone && cd awesomeProject + +# 1. 准备示例配置(真实敏感值用环境变量覆盖) +Copy-Item config\config.example.yaml config\config.yaml + +# 2. 设置必要环境变量(示例,可按需调整) +$env:MYSQL_PASSWORD = "your_mysql_password" +$env:AUTH_JWT_SECRET = "your_jwt_secret" +# 如果需要真实模型调用 +# $env:ARK_API_KEY = "your_model_api_key" + +# 3. 启动服务 +go run main.go + +# 4. 打开 Swagger +# http://localhost:8080/swagger/index.html +``` + +## 错误码对照(节选) +| code | 含义 | +|------|------| +| 0 | 成功 | +| 40001 | 参数错误 | +| 40002 | 模型不存在或未配置 | +| 40101 | 未认证 | +| 40301 | 模型权限不足 | +| 40302 | 会话归属错误 | +| 50000 | 内部错误 | +| 50001 | 模型返回为空 | + +完整说明参见:`docs/api.md` + +## 目录说明 +``` +internal/ 内部逻辑模块 + auth/ JWT、密码哈希、中间件 + config/ 配置加载与环境变量覆写 + db/ 数据库初始化 + memory/ 内存会话管理 + models/ GORM 实体 + redisstore/ Redis 可选缓存封装 +config/ 配置文件目录(示例 + 本地实际) +docs/ 架构/接口/安全/代码说明文档 +main.go 程序入口与路由 +``` + +## 常见环境变量 +``` +MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DB / MYSQL_CHARSET +AUTH_JWT_SECRET / AUTH_ACCESS_TTL +CHAT_DEFAULT_MODEL / CHAT_MAX_HISTORY / CHAT_MEMORY_LIMIT_MSGS +REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_DB +``` +详细列表:`docs/SECURITY_CONFIG.md` + +## Redis 最近历史缓存 +- Key: `chat:conv:recent:` +- 写入:发送消息成功后 +- 读取:历史接口优先命中;失败走 DB 回源 +- 降级:Redis 初始化失败时自动忽略缓存 + +## 后续可扩展 +- SSE 流式输出 +- 速率限制 (Redis Incr) +- 会话列表接口 +- 模型 Provider 抽象层 + +## PR / 评审建议 +- 不要提交 `config/config.yaml` +- 通过 README 步骤可本地快速运行 +- 安全策略见 `docs/SECURITY_CONFIG.md` + +## License +按上游仓库策略或后续补充。 + +--- +**END** diff --git a/smf-h/config/config.example.yaml b/smf-h/config/config.example.yaml new file mode 100644 index 0000000..21efd8c --- /dev/null +++ b/smf-h/config/config.example.yaml @@ -0,0 +1,42 @@ +# 示例配置文件。提交 PR 时请使用本文件,真实敏感信息放在本地 config.yaml(已被 .gitignore 忽略)。 +server: + port: 8080 + mode: release +mysql: + host: 127.0.0.1 + port: 3306 + user: root + password: CHANGE_ME + database: ai_chat + charset: utf8mb4 +chat: + default_model: doubao-seed-1-6-250615 + max_history: 12 + memory_limit_msgs: 200 + request_timeout: 15s +auth: + jwt_secret: CHANGE_ME_JWT_SECRET + access_ttl: 24h +models: + - id: doubao-seed-1-6-250615 + min_level: 1 + - id: deepseek-v3-1-terminus + min_level: 3 + - id: kimi-k2-250905 + min_level: 5 +redis: + host: 127.0.0.1 + port: 6379 + password: "" # 本地无密码 + db: 0 + pool_size: 20 + dial_timeout: 2s + read_timeout: 1s + write_timeout: 1s + +# 使用环境变量覆盖敏感值 (示例): +# setx MYSQL_PASSWORD "real_password" +# setx AUTH_JWT_SECRET "real_jwt_secret" +# setx REDIS_PASSWORD "redis_pwd" +# 或 PowerShell 当前会话: +# $env:MYSQL_PASSWORD="real_password" diff --git a/smf-h/config/config.yaml b/smf-h/config/config.yaml new file mode 100644 index 0000000..62d2cae --- /dev/null +++ b/smf-h/config/config.yaml @@ -0,0 +1,35 @@ +server: + port: 8080 + mode: debug +mysql: + host: 127.0.0.1 + port: 3306 + user: root + password: 2823128231 + database: ai_chat + charset: utf8mb4 +chat: + default_model: doubao-seed-1-6-250615 + max_history: 12 + memory_limit_msgs: 200 + request_timeout: 15s +auth: + jwt_secret: change_me_dev_secret + access_ttl: 24h +models: + - id: doubao-seed-1-6-250615 + min_level: 1 + - id: deepseek-v3-1-terminus + min_level: 3 + - id: kimi-k2-250905 + min_level: 5 + +redis: + host: 127.0.0.1 + port: 6379 + # password: 2823128231 + db: 0 + pool_size: 20 + dial_timeout: 2s + read_timeout: 1s + write_timeout: 1s diff --git a/smf-h/docs/SECURITY_CONFIG.md b/smf-h/docs/SECURITY_CONFIG.md new file mode 100644 index 0000000..e6af484 --- /dev/null +++ b/smf-h/docs/SECURITY_CONFIG.md @@ -0,0 +1,83 @@ +# 配置与密钥安全指南 + +本指南说明如何在提交代码(PR)时避免泄露敏感信息,并保证不同环境(本地/测试/生产)配置隔离。 + +## 1. 不要提交的内容 +- 真实 `config/config.yaml` (已加入 .gitignore) +- 数据库密码、JWT 密钥、第三方 API Key (如 ARK_API_KEY) +- 生产 Redis / MySQL 地址 +- 私有证书、密钥对、令牌文件 + +## 2. 提交什么 +| 文件 | 作用 | 是否包含敏感信息 | +|------|------|------------------| +| `config/config.example.yaml` | 示例/模板 | 否(占位符) | +| `internal/config/config.go` | 解析 + 环境变量覆盖 | 否 | +| `.gitignore` | 忽略真实配置 | 否 | + +## 3. 本地真实配置放哪里 +- 开发: `config/config.yaml` (不会被 git 追踪) +- 生产: 推荐使用环境变量 + 仅最小必要的 fallback config 文件 + +## 4. 环境变量覆盖支持 (已实现) +支持的变量 (非空即覆盖): +``` +SERVER_PORT, SERVER_MODE +MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, MYSQL_CHARSET +AUTH_JWT_SECRET, AUTH_ACCESS_TTL +CHAT_DEFAULT_MODEL, CHAT_MAX_HISTORY, CHAT_MEMORY_LIMIT_MSGS, CHAT_REQUEST_TIMEOUT +REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB, REDIS_POOL_SIZE, +REDIS_DIAL_TIMEOUT, REDIS_READ_TIMEOUT, REDIS_WRITE_TIMEOUT +``` +示例 (PowerShell 临时会话): +```powershell +$env:MYSQL_PASSWORD = "SuperSecret!" +$env:AUTH_JWT_SECRET = "ProdJwtSecret_ChangeMe" +$env:REDIS_PASSWORD = "RedisPwd123" +``` + +## 5. PR 前自检清单 +| 检查项 | 通过条件 | +|--------|----------| +| config.yaml 是否未被提交 | `git status` 无该文件 | +| 未把真实密码硬编码进代码 | 搜索 `password:` / `jwt_secret:` 无真实值 | +| 关键密钥均可用 env 注入 | 启动服务时通过日志/调试确认被覆盖 | + +## 6. CI/CD 建议 +- 使用平台 Secret 管理 (GitHub Actions Secrets / GitLab CI Variables) +- 部署启动脚本导出变量后再 `go run / build`: +```bash +export MYSQL_PASSWORD=$SECRET_MYSQL_PASSWORD +export AUTH_JWT_SECRET=$SECRET_JWT +./app +``` +- 避免将密钥写入镜像层:用运行时注入 (Kubernetes Secret / Docker --env-file) + +## 7. 生产加固建议 +| 领域 | 建议 | +|------|------| +| JWT | 定期轮换密钥,考虑多个版本(旧+新)并行过渡 | +| 数据库 | 最小权限账号,禁止 *.* 全权限;开启 TLS (如需) | +| Redis | 绑定内网地址,启用 ACL 或至少强密码,禁止公网暴露 | +| 日志 | 不打印 token / 密码 / 完整 SQL (参数可脱敏) | +| 代码 | 通过 secret 扫描工具 (trufflehog / gitleaks) 预防泄露 | + +## 8. 临时调试不泄露技巧 +- 打印配置时跳过敏感字段 (`****`)。 +- 将敏感值长度或 hash 输出用于核对是否加载到了正确的 Secret。 + +## 9. 应急处理 +| 场景 | 动作 | +|------|------| +| 不小心提交密钥 | 立即在平台侧作废该密钥;强制推送清理历史(必要时);生成新 Key | +| 密钥疑似泄露 | 强制失效 + 全局通知 + 轮换;审计访问日志 | +| 配置被恶意篡改 | 使用只读挂载或配置签名校验 | + +## 10. 后续可增强 +- 引入 `.env` + dotenv 解析(本项目目前依赖系统 env) +- 加入启动时敏感配置缺失校验并统一错误提示 +- 敏感值自动检测 pre-commit 钩子 +- 支持 Vault / Secrets Manager 远程加载 + +--- +**END** diff --git a/smf-h/docs/api.md b/smf-h/docs/api.md new file mode 100644 index 0000000..9c926f0 --- /dev/null +++ b/smf-h/docs/api.md @@ -0,0 +1,176 @@ +# API 文档 (Swagger 注释版同步) + +> 本文件为手写说明,实际以 /swagger/index.html 展示的生成文档为准。生成工具: swag (github.com/swaggo/swag) + +## 认证 +所有受保护接口需要 Header: `Authorization: Bearer ` + +## 错误码 +| code | 含义 | +|------|------| +| 0 | 成功 | +| 40001 | 参数错误 | +| 40002 | 模型不存在或未配置 | +| 40101 | 未认证/Token 无效 | +| 40301 | 模型权限不足(等级不够) | +| 40302 | 会话不属于当前用户 | +| 50000 | 服务内部错误 | +| 50001 | 模型返回为空 | + +### 错误结构映射 +所有错误均统一 JSON 结构 `{ "code": , "msg": }`,但在 Swagger 中以不同结构体名区分业务语义: + +| 结构体 | 典型 code | 说明 | 示例 `msg` | +|--------|-----------|------|-----------| +| BadRequestError | 40001 | 通用参数校验失败 | `参数错误: question 必填` | +| ModelNotFoundError | 40002 | 模型 ID 不存在或未在策略中配置 | `模型不存在或未配置` | +| UnauthorizedError | 40101 | 缺少或非法的 Bearer Token | `未提供或非法的 Authorization 头` | +| ForbiddenModelError | 40301 | 用户等级不满足模型最小等级 | `无权限使用模型(doubao...),需要等级≥3` | +| ForbiddenConversationError | 40302 | 访问他人会话 | `会话不属于当前用户` | +| InternalModelCallError | 50000 | 调用模型/数据库等内部错误 | `模型调用失败` | +| InternalEmptyReturnError | 50001 | 模型响应结构没有有效内容 | `模型返回为空` | + +> Swagger 会列出同一 HTTP 状态码下的多种错误结构(例如多个 400/403/500),便于前端生成精确的错误分支。 + +## 接口列表 +### 健康检查 +GET /health + +### 注册 +POST /api/auth/register +Body: +``` +{ + "username": "u1", + "email": "u1@test.com", + "password": "Abcd1234!" +} +``` +返回: +``` +{ "code":0, "data": {"token":"...", "user_id":1} } +``` + +### 登录 +POST /api/auth/login +Body 支持 username 或 email: +``` +{ "username":"u1", "password":"Abcd1234!" } +``` + +### 发送消息 +POST /api/chat/send (需登录) +``` +{ + "conversation_id": "", // 为空即新建 + "question": "你好", + "model": "doubao-seed-1-6-250615" +} +``` +成功: +``` +{ + "code":0, + "data":{ + "conversation_id":"uuid", + "answer":"...", + "used_history":2 + } +} +``` +错误示例: +- 模型不存在: `{ "code":40002, "msg":"模型不存在或未配置" }` +- 等级不足: `{ "code":40301, "msg":"无权限使用模型(doubao...),需要等级≥3" }` +- 会话不属于用户: `{ "code":40302, "msg":"会话不属于当前用户" }` + +### 获取历史 +GET /api/chat/history?conversation_id=xxx (需登录) +返回: +``` +{ + "code":0, + "data": { + "conversation_id":"xxx", + "messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}], + "count":2 + } +} +``` + +### 清空 / 删除会话 +POST /api/chat/clear (需登录) +``` +{ + "conversation_id": "xxx", + "full": false // 可选,true=连同会话元数据彻底删除 +} +``` +响应示例(已精简,仅返回会话 ID): +``` +{ + "code":0, + "data": { "conversation_id":"xxx" } +} + + +Redis 缓存键:`chat:conv:recent:` 会被同时删除。 +``` + +### 会话列表 +GET /api/chat/list (需登录) +``` +返回: +{ + "code":0, + "data": { + "conversations": [ + {"id":"c1","last_active_at":"2025-10-08T10:11:12Z"}, + {"id":"c2","last_active_at":"2025-10-07T09:01:00Z"} + ], + "count": 2 + } +} +``` +当前未分页,可后续加 `?limit=&offset=`。 + +### 调试查看缓存 (仅 DEBUG 用) +GET /api/chat/debug/cache?conversation_id=xxx (需登录 & 环境变量 `DEBUG_CACHE=1` 才启用) +``` +返回: +{ + "code":0, + "data":{ + "conversation_id":"xxx", + "cached_recent":"[ {..}, ... ]", + "length": 456 // JSON 原始字符串长度 + } +} +``` +若未开启返回 403。 + +## 计划中 (Planned) +- POST /api/chat/send/stream SSE 流式输出 +- 分页的会话列表 (limit / offset) +- 会话摘要 (summary) 与多级上下文压缩 +- Token 精确统计(替换粗略 roughTokenEstimate) + +## 会话上下文与缓存 +当前 /send 在内部会: +1. 若内存中该会话为空 -> 先 warm:尝试 Redis 最近消息缓存 -> 不命中则 DB 拉取全部(随后内存 FIFO 剪裁)。 +2. 取最近 `2 * max_history` 条消息作为上下文(扩大窗口以提高回答连续性)。 +3. 写入用户消息、调用模型、写入助手消息。 +4. 将最近 `max_history*2` 消息写入 Redis (`chat:conv:recent:` TTL 10m)。 + +清空 `/clear`: +- full=false 删除 messages + 内存数组 + 缓存 recent +- full=true 还会删除 conversations 记录,并从内存管理器移除整个会话 + +## Swagger 使用 +安装 swag CLI 本地生成: +``` +swag init --parseDependency --parseInternal -g main.go -o docs/swagger +``` +访问: http://localhost:8080/swagger/index.html + +## Changelog +- v0.4 新增:模型权限、会话/消息持久化、错误码 40002/40302,细化错误结构(BadRequestError / ModelNotFoundError / Forbidden* / Internal*)并统一 writeErr 输出 diff --git a/smf-h/docs/code_overview.md b/smf-h/docs/code_overview.md new file mode 100644 index 0000000..adbd40d --- /dev/null +++ b/smf-h/docs/code_overview.md @@ -0,0 +1,300 @@ +# 代码说明文档 (Code Overview) + +> 面向开发者的代码级说明,聚焦目录结构、关键流程、扩展点与最佳实践。与 `architecture.md`(宏观架构)和 `api.md`(接口)配套阅读。 + +--- +## 1. 项目概览 +本项目是一个简化版 AI 问答 / Chat 系统服务端,提供: +- 用户注册 / 登录(JWT) +- 模型访问权限控制(按用户等级 / 角色) +- 会话与消息持久化(数据库 + 内存增量缓存) +- 多轮对话上下文裁剪(最近 N 条 / Token 粗估) +- 统一错误码与结构化响应 +- Swagger 文档生成 + +运行方式(需 Go 1.24+,未设置 ARK_API_KEY 时进入 mock 回复模式): +```powershell +# (可选) 设置模型 API Key +$env:ARK_API_KEY = "" +# 启动 +go run main.go +# 访问 Swagger +http://localhost:8080/swagger/index.html +``` + +--- +## 2. 目录结构说明 +``` +. +├── main.go # 入口:路由、依赖初始化、Handler +├── go.mod / go.sum # 依赖管理 +├── config/ # 配置文件 (config.yaml) +├── internal/ +│ ├── auth/ # 认证与权限:JWT、密码、middleware +│ ├── config/ # 配置加载解析 (YAML -> struct) +│ ├── db/ # GORM 初始化与全局句柄 +│ ├── memory/ # 内存会话管理器 (MemoryManager) + 上下文窗口截断 +│ └── models/ # 数据表模型 (User / Conversation / Message) +└── docs/ + ├── api.md # 手写接口说明 + ├── architecture.md # 架构设计文档 + ├── code_overview.md # (本文件) + └── swagger/ # swag 自动生成的 swagger 规范文件 +``` + +### internal 分层意图 +| 包 | 职责 | 依赖方向 | +|----|------|----------| +| auth | JWT 解析/生成、密码哈希、鉴权中间件 | 仅依赖标准库 & 第三方库 | +| config | 加载 YAML -> Root 配置结构 | 无业务依赖 | +| db | 初始化全局 `db.Global` (GORM) | 依赖配置 | +| memory | 纯内存会话上下文存储 | 无数据库依赖 | +| models | 定义 GORM 实体 | 依赖 GORM | +| main.go | 组装应用:路由、处理流程 | 依赖以上所有 | + +--- +## 3. 配置系统 (config) +配置文件示例(`config/config.yaml`): +```yaml +server: + port: 8080 + mode: release +mysql: + user: root + password: 123456 + host: 127.0.0.1 + port: 3306 + database: chatdb + charset: utf8mb4 +chat: + default_model: doubao-seed-1-6-250615 + max_history: 6 + memory_limit_msgs: 50 +auth: + jwt_secret: "dev-secret" + access_ttl: 86400000000000 # 24h 纳秒值 +models: + - id: doubao-seed-1-6-250615 + min_level: 1 + - id: doubao-pro-x + min_level: 3 +``` +加载逻辑:`config.Load(path)` -> 反序列化 -> `main.go` 存入全局 `appConfig`。 + +--- +## 4. 数据模型 (models) +| 表 | 关键字段 | 说明 | +|----|----------|------| +| users | username/email/password/level/role | 角色扩展未来用于管理端 | +| conversations | id(user provided uuid)/user_id/last_active_at | 会话元数据,用于归属与排序 | +| messages | conversation_id/user_id/role/content/token_count | 原始对话消息记录 | + +注意: +- `conversation_id` 为外部可见 ID(UUID)。 +- 历史消息内存缓存只保留最近若干条;数据库持久化全量,用于历史回放与重启后的回灌(warm)。 + +--- +## 5. 认证与权限 (auth) +流程: +1. 注册:bcrypt 加密密码 -> 写入用户 -> 生成 JWT。 +2. 登录:按用户名或邮箱匹配 -> 校验密码 -> 发放 JWT。 +3. 中间件:从 Header `Authorization: Bearer ` 解析,放入 `Context`(userID/level/role)。 +4. 模型访问:在 `handleChatSend` 中根据 `modelLevelMap[modelID]` 与用户 level 校验;管理员角色可跳过(`HasAdminRole`)。 + +JWT 声明包含:用户 ID、用户名、过期时间;HS256 签名,密钥来自配置 `auth.jwt_secret`。 + +--- +## 6. 内存会话 (memory) +核心结构: +- `MemoryManager`:`map[conversationID]*ConversationMemory` +- `ConversationMemory`:`[]Message`(环或截断策略) + +策略: +- 每次追加消息时估算 token(`roughTokenEstimate` 简单近似)。 +- 获取模型输入时使用 `LastN(maxHistoryToUse * 2)` (扩大窗口,提升连续性)。 +- /send 首次使用某会话且内存为空:先 warm(Redis recent -> DB 全量),然后再 append 新消息,确保重启后首轮仍有上下文。 +- 内存超出 `memory_limit_msgs` 时 FIFO 丢最旧。 +- `/clear` full=true 时会彻底从内存管理器移除该会话。 + +--- +## 7. Handler 关键流程 +### 7.1 发送消息 `/api/chat/send` +``` +[Bind JSON] + -> (可生成 conversation_id & DB 创建会话) + -> 会话归属校验 (conversation.user_id == token.user_id) + -> 模型存在 & 权限等级校验 + -> Warm (若内存空:Redis recent 缓存,否则 DB 回源) + -> 内存追加用户消息 + DB 保存 + -> 截取最近 2*N 条历史 => 调用模型 SDK + -> 校验返回结构 (choices / content) + -> 追加助手消息 (内存 + DB) + -> 返回 answer / used_history +``` + +### 7.2 获取历史 `/api/chat/history` +``` +[Query conversation_id] + -> 归属校验 + -> DB 全量读取 messages 按 id 升序 + -> 内存若为空则灌入(用于后续继续对话) + -> 返回 messages 列表 +``` + +### 7.3 清空/删除会话 `/api/chat/clear` +``` +[Bind conversation_id, full] + -> 归属校验 + -> DB 删除该会话所有 messages + -> full=true 额外删除 conversations 记录 + -> 内存 Clear(); full=true 时 mgr.Delete() + -> 删除 Redis recent/summary 缓存 + -> 返回 {deleted, full} + +### 7.4 会话列表 `/api/chat/list` +``` +GET -> user_id 过滤 conversations 按 last_active_at desc +返回 [{id,last_active_at}] +``` + + + +### 7.4 注册 / 登录 +注册:查重 -> hash 密码 -> 写库 -> 生成 token。 +登录:按 username/email 选路 -> 查库 -> 校验密码 -> 生成 token。 + +--- + +``` +const ( + CodeOK = 0 + CodeBadParam = 40001 + CodeModelNotFound = 40002 + CodeUnauthorized = 40101 + CodeForbiddenModel = 40301 + CodeForbiddenConversation = 40302 + CodeInternalError = 50000 + CodeModelEmpty = 50001 +) +``` +Swagger 通过不同结构体名(`BadRequestError` 等)让前端区分分支。 + +--- +## 9. 模型调用 +使用字节火山方舟 SDK: +- Client 初始化:`arkruntime.NewClientWithApiKey(apiKey, WithBaseUrl(...))` +- 调用:`client.CreateChatCompletion(ctx, model.CreateChatCompletionRequest{Model: req.Model, Messages: modelMsgs})` +- 将内存消息转换为 SDK 消息:`convertToModelMessages`。 + +Mock 模式:未设置 ARK_API_KEY 时跳过真实请求,直接返回 `[mock:model] 你说: ...`,用于离线开发。 + +扩展其他模型:可封装接口 `ModelProvider`,当前代码为内联调用,可后续抽象: +``` +type ModelProvider interface { Chat(messages []ChatMessage) (answer string, err error) } +``` + +--- +## 10. Swagger 文档 +- 注释写在 `main.go` handler 前。 +- 生成命令: +```powershell +swag init --parseDependency --parseInternal -g main.go -o docs/swagger +``` +- 访问:`/swagger/index.html` +- 注意:多个相同状态码 @Failure 会分别生成;某些 UI 可能只默认展示首个,需要说明。 + +--- +## 11. 扩展指南 +### 11.1 新增模型策略 +1. 在 `config.yaml` 增加: +```yaml +models: + - id: new-model-x + min_level: 2 +``` +2. 重启服务 -> `modelLevelMap` 自动加载。 + +### 11.2 会话列表接口现已实现 +- 查询 `conversations` where user_id = ? order by last_active_at desc;当前无分页。 + +### 11.3 添加流式输出 (SSE) +- 新路由:`/api/chat/send/stream` +- Header:`Content-Type: text/event-stream` +- 逐步写入 `data: ` 并 `flush` +- 仍然需保存完整最终消息到 DB / 内存。 + +### 11.4 抽象模型层 +创建 `internal/llm/`: +``` +type ChatMessage struct { Role, Content string } +interface Provider { Complete(ctx context.Context, msgs []ChatMessage) (string, error) } +``` +主 handler 仅依赖接口 -> 支持多实现切换。 + +### 11.5 引入缓存/限流 +- 限流:中间件令牌桶(如 golang.org/x/time/rate)。 +- 模型调用缓存:key=(model,hash(latestN)),短时相同问题加速。 + +### 11.6 用户角色拓展 +- 增加 `role=admin` 权限:可列出所有用户会话 / 强制清除。 +- 编写基于 `role` 的路由组中间件。 + +--- +## 12. 测试建议 +| 测试类型 | 重点 | +|----------|------| +| 单元 | memory 管理 append / trim;密码哈希校验;权限判断逻辑 | +| 集成 | 注册->登录->发消息->历史->清空 全链路 | +| 异常 | 无 token / 等级不足 / 非本人会话 / 模型不存在 | +| 回归 | 错误码与文档一致性 | + +可以引入 `httptest`: +```go +w := httptest.NewRecorder() +req, _ := http.NewRequest("POST", "/api/chat/send", bytes.NewReader(body)) +req.Header.Set("Authorization", "Bearer ") +router.ServeHTTP(w, req) +``` + +--- +## 13. 运维与部署 +| 关注点 | 建议 | +|--------|------| +| 配置 | 区分 dev / prod,多套 yaml 或注入 ENV 覆盖关键项 (DB, JWT Secret, API Key) | +| 日志 | 目前使用默认 Gin;生产建议接入结构化日志 (zap/logrus) | +| 迁移 | 目前 AutoMigrate;复杂场景使用 goose / atlas | +| 安全 | 强制 HTTPS、JWT Secret 定期轮换、限制密码尝试次数 | +| 监控 | 增加 /metrics (Prometheus) & 慢查询日志 | + +--- +## 14. 常见问题 (FAQ) +| 问题 | 原因 | 解决 | +|------|------|------| +| Swagger `/swagger/doc.json` 500 | 缺少空白导入或重复 `docs.go` | 保留 `_ ".../docs/swagger"` 且清理重复生成 | +| 模型权限未生效 | 配置未加载/模型ID拼写 | 检查 `models` 配置与日志 | +| 历史上下文丢失 | 进程重启内存清空 | 首次 /send 会 warm:Redis -> DB;或手动调用 /history | +| 清空后 Redis 仍有缓存 | 旧逻辑未删缓存 | 已在 /clear 中调用 DeleteConversationAll | +| 会话删除后仍在列表 | 使用 full=false 仅清消息 | 需要彻底删除传 full=true | +| Token 过期过快 | access_ttl 配置单位为纳秒 | 确认 YAML 数值是否过小 | + +--- +## 15. 后续可演进方向 +- 模型多实例 + 超时/重试封装 +- 分布式会话(Redis 替内存) +- 统一审计日志 & 操作追踪 ID +- 细化用户等级成长机制(积分/调用次数) +- OpenAPI 代码生成前端 SDK + +--- +## 16. 快速清单 (Checklist) +| 事项 | 位置 | 说明 | +|------|------|------| +| 添加新错误码 | `main.go` 常量区 | 同步 `api.md` & `code_overview.md` | +| 新增 Handler | `main.go` | 增加 Swagger 注释 + 使用 `writeErr` + 视需要更新缓存逻辑 | +| 清空/删除会话 | `/api/chat/clear` | 选择 full=true 彻底删除(含缓存) | +| 新增模型策略 | `config.yaml` | 重启即可生效 | +| 数据库字段变更 | `models/` | 执行 AutoMigrate (开发) / 正式使用迁移工具 | +| Token 问题调试 | `internal/auth` | 检查 TTL / Secret | + +--- +**END** diff --git a/smf-h/docs/swagger/docs.go b/smf-h/docs/swagger/docs.go new file mode 100644 index 0000000..53759de --- /dev/null +++ b/smf-h/docs/swagger/docs.go @@ -0,0 +1,604 @@ +// Package swagger Code generated by swaggo/swag. DO NOT EDIT +package swagger + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "用户登录", + "parameters": [ + { + "description": "登录参数 (username 或 email 任选其一)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.loginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.registerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "清空会话", + "parameters": [ + { + "description": "会话ID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ChatClearResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "获取历史", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ChatHistoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/send": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "发送用户问题并返回模型回答,支持新建或继续会话", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "发送消息", + "parameters": [ + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.ChatRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/main.ChatSendResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.ModelNotFoundError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalEmptyReturnError" + } + } + } + } + } + }, + "definitions": { + "main.AuthData": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user_id": { + "type": "integer", + "example": 1 + } + } + }, + "main.AuthResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.AuthData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.BadRequestError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40001 + }, + "msg": { + "type": "string", + "example": "参数错误: question 必填" + } + } + }, + "main.ChatClearData": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + } + } + }, + "main.ChatClearResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatClearData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ChatHistoryData": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "count": { + "type": "integer", + "example": 4 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/memory.Message" + } + } + } + }, + "main.ChatHistoryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatHistoryData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ChatRequest": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string", + "example": "" + }, + "model": { + "type": "string", + "example": "doubao-seed-1-6-250615" + }, + "question": { + "type": "string", + "example": "你好" + } + } + }, + "main.ChatSendData": { + "type": "object", + "properties": { + "answer": { + "type": "string", + "example": "你好,我是AI助手" + }, + "conversation_id": { + "type": "string", + "example": "c3a2f1c4-uuid" + }, + "used_history": { + "type": "integer", + "example": 2 + } + } + }, + "main.ChatSendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatSendData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ForbiddenConversationError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40302 + }, + "msg": { + "type": "string", + "example": "会话不属于当前用户" + } + } + }, + "main.ForbiddenModelError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40301 + }, + "msg": { + "type": "string", + "example": "无权限使用模型(doubao-seed-1-6-250615),需要等级≥3" + } + } + }, + "main.InternalEmptyReturnError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 50001 + }, + "msg": { + "type": "string", + "example": "模型返回为空" + } + } + }, + "main.InternalModelCallError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 50000 + }, + "msg": { + "type": "string", + "example": "模型调用失败" + } + } + }, + "main.ModelNotFoundError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40002 + }, + "msg": { + "type": "string", + "example": "模型不存在或未配置" + } + } + }, + "main.UnauthorizedError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40101 + }, + "msg": { + "type": "string", + "example": "未提供或非法的 Authorization 头" + } + } + }, + "main.loginReq": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "user1@test.com" + }, + "password": { + "type": "string", + "example": "Abcd1234!" + }, + "username": { + "type": "string", + "example": "user1" + } + } + }, + "main.registerReq": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "user1@test.com" + }, + "password": { + "type": "string", + "example": "Abcd1234!" + }, + "username": { + "type": "string", + "example": "user1" + } + } + }, + "memory.Message": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "role": { + "type": "string" + }, + "token_count": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.4", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "AI Chat API", + Description: "简化版 AI 问答系统接口,包含认证/聊天/会话与模型权限示例。", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/smf-h/docs/swagger/swagger.json b/smf-h/docs/swagger/swagger.json new file mode 100644 index 0000000..0cd5dfb --- /dev/null +++ b/smf-h/docs/swagger/swagger.json @@ -0,0 +1,579 @@ +{ + "swagger": "2.0", + "info": { + "description": "简化版 AI 问答系统接口,包含认证/聊天/会话与模型权限示例。", + "title": "AI Chat API", + "contact": {}, + "version": "0.4" + }, + "basePath": "/", + "paths": { + "/api/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "用户登录", + "parameters": [ + { + "description": "登录参数 (username 或 email 任选其一)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.loginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.registerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/clear": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "清空会话", + "parameters": [ + { + "description": "会话ID", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ChatClearResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "获取历史", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ChatHistoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.BadRequestError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalModelCallError" + } + } + } + } + }, + "/api/chat/send": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "发送用户问题并返回模型回答,支持新建或继续会话", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chat" + ], + "summary": "发送消息", + "parameters": [ + { + "description": "请求体", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.ChatRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/main.ChatSendResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/main.ModelNotFoundError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/main.UnauthorizedError" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/main.ForbiddenConversationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/main.InternalEmptyReturnError" + } + } + } + } + } + }, + "definitions": { + "main.AuthData": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user_id": { + "type": "integer", + "example": 1 + } + } + }, + "main.AuthResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.AuthData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.BadRequestError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40001 + }, + "msg": { + "type": "string", + "example": "参数错误: question 必填" + } + } + }, + "main.ChatClearData": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + } + } + }, + "main.ChatClearResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatClearData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ChatHistoryData": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "count": { + "type": "integer", + "example": 4 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/memory.Message" + } + } + } + }, + "main.ChatHistoryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatHistoryData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ChatRequest": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string", + "example": "" + }, + "model": { + "type": "string", + "example": "doubao-seed-1-6-250615" + }, + "question": { + "type": "string", + "example": "你好" + } + } + }, + "main.ChatSendData": { + "type": "object", + "properties": { + "answer": { + "type": "string", + "example": "你好,我是AI助手" + }, + "conversation_id": { + "type": "string", + "example": "c3a2f1c4-uuid" + }, + "used_history": { + "type": "integer", + "example": 2 + } + } + }, + "main.ChatSendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "data": { + "$ref": "#/definitions/main.ChatSendData" + }, + "msg": { + "type": "string", + "example": "ok" + } + } + }, + "main.ForbiddenConversationError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40302 + }, + "msg": { + "type": "string", + "example": "会话不属于当前用户" + } + } + }, + "main.ForbiddenModelError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40301 + }, + "msg": { + "type": "string", + "example": "无权限使用模型(doubao-seed-1-6-250615),需要等级≥3" + } + } + }, + "main.InternalEmptyReturnError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 50001 + }, + "msg": { + "type": "string", + "example": "模型返回为空" + } + } + }, + "main.InternalModelCallError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 50000 + }, + "msg": { + "type": "string", + "example": "模型调用失败" + } + } + }, + "main.ModelNotFoundError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40002 + }, + "msg": { + "type": "string", + "example": "模型不存在或未配置" + } + } + }, + "main.UnauthorizedError": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 40101 + }, + "msg": { + "type": "string", + "example": "未提供或非法的 Authorization 头" + } + } + }, + "main.loginReq": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "user1@test.com" + }, + "password": { + "type": "string", + "example": "Abcd1234!" + }, + "username": { + "type": "string", + "example": "user1" + } + } + }, + "main.registerReq": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "user1@test.com" + }, + "password": { + "type": "string", + "example": "Abcd1234!" + }, + "username": { + "type": "string", + "example": "user1" + } + } + }, + "memory.Message": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "role": { + "type": "string" + }, + "token_count": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/smf-h/docs/swagger/swagger.yaml b/smf-h/docs/swagger/swagger.yaml new file mode 100644 index 0000000..98f8c7e --- /dev/null +++ b/smf-h/docs/swagger/swagger.yaml @@ -0,0 +1,384 @@ +basePath: / +definitions: + main.AuthData: + properties: + token: + example: eyJhbGciOiJIUzI1NiIs... + type: string + user_id: + example: 1 + type: integer + type: object + main.AuthResponse: + properties: + code: + example: 0 + type: integer + data: + $ref: '#/definitions/main.AuthData' + msg: + example: ok + type: string + type: object + main.BadRequestError: + properties: + code: + example: 40001 + type: integer + msg: + example: '参数错误: question 必填' + type: string + type: object + main.ChatClearData: + properties: + conversation_id: + type: string + type: object + main.ChatClearResponse: + properties: + code: + example: 0 + type: integer + data: + $ref: '#/definitions/main.ChatClearData' + msg: + example: ok + type: string + type: object + main.ChatHistoryData: + properties: + conversation_id: + type: string + count: + example: 4 + type: integer + messages: + items: + $ref: '#/definitions/memory.Message' + type: array + type: object + main.ChatHistoryResponse: + properties: + code: + example: 0 + type: integer + data: + $ref: '#/definitions/main.ChatHistoryData' + msg: + example: ok + type: string + type: object + main.ChatRequest: + properties: + conversation_id: + example: "" + type: string + model: + example: doubao-seed-1-6-250615 + type: string + question: + example: 你好 + type: string + type: object + main.ChatSendData: + properties: + answer: + example: 你好,我是AI助手 + type: string + conversation_id: + example: c3a2f1c4-uuid + type: string + used_history: + example: 2 + type: integer + type: object + main.ChatSendResponse: + properties: + code: + example: 0 + type: integer + data: + $ref: '#/definitions/main.ChatSendData' + msg: + example: ok + type: string + type: object + main.ForbiddenConversationError: + properties: + code: + example: 40302 + type: integer + msg: + example: 会话不属于当前用户 + type: string + type: object + main.ForbiddenModelError: + properties: + code: + example: 40301 + type: integer + msg: + example: 无权限使用模型(doubao-seed-1-6-250615),需要等级≥3 + type: string + type: object + main.InternalEmptyReturnError: + properties: + code: + example: 50001 + type: integer + msg: + example: 模型返回为空 + type: string + type: object + main.InternalModelCallError: + properties: + code: + example: 50000 + type: integer + msg: + example: 模型调用失败 + type: string + type: object + main.ModelNotFoundError: + properties: + code: + example: 40002 + type: integer + msg: + example: 模型不存在或未配置 + type: string + type: object + main.UnauthorizedError: + properties: + code: + example: 40101 + type: integer + msg: + example: 未提供或非法的 Authorization 头 + type: string + type: object + main.loginReq: + properties: + email: + example: user1@test.com + type: string + password: + example: Abcd1234! + type: string + username: + example: user1 + type: string + required: + - password + type: object + main.registerReq: + properties: + email: + example: user1@test.com + type: string + password: + example: Abcd1234! + type: string + username: + example: user1 + type: string + required: + - password + - username + type: object + memory.Message: + properties: + content: + type: string + role: + type: string + token_count: + type: integer + type: object +info: + contact: {} + description: 简化版 AI 问答系统接口,包含认证/聊天/会话与模型权限示例。 + title: AI Chat API + version: "0.4" +paths: + /api/auth/login: + post: + consumes: + - application/json + parameters: + - description: 登录参数 (username 或 email 任选其一) + in: body + name: body + required: true + schema: + $ref: '#/definitions/main.loginReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.AuthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/main.BadRequestError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/main.InternalModelCallError' + summary: 用户登录 + tags: + - Auth + /api/auth/register: + post: + consumes: + - application/json + parameters: + - description: 注册参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/main.registerReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.AuthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/main.BadRequestError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/main.InternalModelCallError' + summary: 用户注册 + tags: + - Auth + /api/chat/clear: + post: + consumes: + - application/json + parameters: + - description: 会话ID + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.ChatClearResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/main.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/main.UnauthorizedError' + "403": + description: Forbidden + schema: + $ref: '#/definitions/main.ForbiddenConversationError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/main.InternalModelCallError' + security: + - BearerAuth: [] + summary: 清空会话 + tags: + - Chat + /api/chat/history: + get: + parameters: + - description: 会话ID + in: query + name: conversation_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.ChatHistoryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/main.BadRequestError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/main.UnauthorizedError' + "403": + description: Forbidden + schema: + $ref: '#/definitions/main.ForbiddenConversationError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/main.InternalModelCallError' + security: + - BearerAuth: [] + summary: 获取历史 + tags: + - Chat + /api/chat/send: + post: + consumes: + - application/json + description: 发送用户问题并返回模型回答,支持新建或继续会话 + parameters: + - description: 请求体 + in: body + name: body + required: true + schema: + $ref: '#/definitions/main.ChatRequest' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/main.ChatSendResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/main.ModelNotFoundError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/main.UnauthorizedError' + "403": + description: Forbidden + schema: + $ref: '#/definitions/main.ForbiddenConversationError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/main.InternalEmptyReturnError' + security: + - BearerAuth: [] + summary: 发送消息 + tags: + - Chat +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/smf-h/go.mod b/smf-h/go.mod new file mode 100644 index 0000000..e7f4a2f --- /dev/null +++ b/smf-h/go.mod @@ -0,0 +1,65 @@ +module awesomeProject + +go 1.24.0 + +toolchain go1.24.7 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.3.0 + github.com/redis/go-redis/v9 v9.5.3 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.3 + github.com/volcengine/volcengine-go-sdk v1.1.37 + golang.org/x/crypto v0.25.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/volcengine/volc-sdk-golang v1.0.23 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/smf-h/go.sum b/smf-h/go.sum new file mode 100644 index 0000000..aa3717f --- /dev/null +++ b/smf-h/go.sum @@ -0,0 +1,269 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8= +github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU= +github.com/volcengine/volcengine-go-sdk v1.1.37 h1:5TvqawYmqO3zIx9dJmzq7fYHypacDoVmUL8Y0NQ4Kxw= +github.com/volcengine/volcengine-go-sdk v1.1.37/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/smf-h/internal/auth/auth.go b/smf-h/internal/auth/auth.go new file mode 100644 index 0000000..dddf54b --- /dev/null +++ b/smf-h/internal/auth/auth.go @@ -0,0 +1,63 @@ +package auth +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = []byte("change_me_dev_secret") + +// SetJWTSecret 由外部在启动时设置配置中的密钥 +func SetJWTSecret(s string) { + if s != "" { + jwtSecret = []byte(s) + } +} + +// Claims 自定义 JWT 声明 +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +// GenerateToken 生成访问 token +func GenerateToken(uid uint, username string, ttl time.Duration) (string, error) { + claims := Claims{ + UserID: uid, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// ParseToken 解析并验证 token +func ParseToken(tokenStr string) (*Claims, error) { + t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + if err != nil { + return nil, err + } + if claims, ok := t.Claims.(*Claims); ok && t.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} + +// HashPassword 加密密码 +func HashPassword(pw string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + return string(b), err +} + +// CheckPassword 校验密码 +func CheckPassword(hash, pw string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil +} diff --git a/smf-h/internal/auth/middleware.go b/smf-h/internal/auth/middleware.go new file mode 100644 index 0000000..c0fa078 --- /dev/null +++ b/smf-h/internal/auth/middleware.go @@ -0,0 +1,69 @@ +package auth + +import ( + "net/http" + "strings" + + "awesomeProject/internal/db" + "awesomeProject/internal/models" + + "github.com/gin-gonic/gin" +) + +// Context keys +const ( + CtxUserIDKey = "user_id" + CtxUsernameKey = "username" + CtxUserLevel = "user_level" + CtxUserRole = "user_role" +) + +// AuthRequired JWT 鉴权中间件,失败统一返回 40101 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + authz := c.GetHeader("Authorization") + if authz == "" || !strings.HasPrefix(strings.ToLower(authz), "bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "msg": "未提供或非法的 Authorization 头"}) + return + } + tokenStr := strings.TrimSpace(authz[7:]) + claims, err := ParseToken(tokenStr) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "msg": "token 无效或已过期"}) + return + } + // 读取用户(需要等级/角色) + var user models.User + if err := db.Global.First(&user, claims.UserID).Error; err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "msg": "用户不存在"}) + return + } + c.Set(CtxUserIDKey, user.ID) + c.Set(CtxUsernameKey, user.Username) + c.Set(CtxUserLevel, user.Level) + c.Set(CtxUserRole, user.Role) + c.Next() + } +} + +// HasAdminRole 判断是否管理员 +func HasAdminRole(c *gin.Context) bool { + v, ok := c.Get(CtxUserRole) + if !ok { + return false + } + role, _ := v.(string) + return role == "admin" +} + +// GetUserLevel 获取用户等级,失败返回 0 +func GetUserLevel(c *gin.Context) int { + v, ok := c.Get(CtxUserLevel) + if !ok { + return 0 + } + if lv, ok2 := v.(int); ok2 { + return lv + } + return 0 +} diff --git a/smf-h/internal/config/config.go b/smf-h/internal/config/config.go new file mode 100644 index 0000000..ae941ce --- /dev/null +++ b/smf-h/internal/config/config.go @@ -0,0 +1,177 @@ +package config + +import ( + "io/ioutil" + "os" + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Root 顶级配置结构 +type Root struct { + Server ServerConfig `yaml:"server"` + MySQL MySQLConfig `yaml:"mysql"` + Chat ChatConfig `yaml:"chat"` + Auth AuthConfig `yaml:"auth"` + Models []ModelPolicy `yaml:"models"` + Redis RedisConfig `yaml:"redis"` +} + +type ServerConfig struct { + Port int `yaml:"port"` + Mode string `yaml:"mode"` // debug / release +} + +type MySQLConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + Database string `yaml:"database"` + Charset string `yaml:"charset"` +} + +type ChatConfig struct { + DefaultModel string `yaml:"default_model"` + MaxHistory int `yaml:"max_history"` + MemoryLimitMsgs int `yaml:"memory_limit_msgs"` + RequestTimeout time.Duration `yaml:"request_timeout"` // e.g. 10s +} + +type AuthConfig struct { + JWTSecret string `yaml:"jwt_secret"` + AccessTTL time.Duration `yaml:"access_ttl"` // 例如 24h / 12h +} + +// RedisConfig Redis 连接配置 +type RedisConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Password string `yaml:"password"` + DB int `yaml:"db"` + PoolSize int `yaml:"pool_size"` + DialTimeout time.Duration `yaml:"dial_timeout"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` +} + +// ModelPolicy 模型权限策略:id + min_level +type ModelPolicy struct { + ID string `yaml:"id" json:"id"` + MinLevel int `yaml:"min_level" json:"min_level"` +} + +// Load 读取 YAML 文件并解析 +func Load(path string) (Root, error) { + var cfg Root + b, err := ioutil.ReadFile(path) + if err != nil { + return cfg, err + } + if err := yaml.Unmarshal(b, &cfg); err != nil { + return cfg, err + } + ApplyEnvOverrides(&cfg) + return cfg, nil +} + +// ApplyEnvOverrides 允许使用环境变量安全覆写敏感或动态配置(如部署 / CI 注入) +// 仅当对应环境变量非空时才覆盖。 +func ApplyEnvOverrides(cfg *Root) { + if v := os.Getenv("SERVER_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + cfg.Server.Port = p + } + } + if v := os.Getenv("SERVER_MODE"); v != "" { + cfg.Server.Mode = v + } + + if v := os.Getenv("MYSQL_HOST"); v != "" { + cfg.MySQL.Host = v + } + if v := os.Getenv("MYSQL_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + cfg.MySQL.Port = p + } + } + if v := os.Getenv("MYSQL_USER"); v != "" { + cfg.MySQL.User = v + } + if v := os.Getenv("MYSQL_PASSWORD"); v != "" { + cfg.MySQL.Password = v + } + if v := os.Getenv("MYSQL_DB"); v != "" { + cfg.MySQL.Database = v + } + if v := os.Getenv("MYSQL_CHARSET"); v != "" { + cfg.MySQL.Charset = v + } + + if v := os.Getenv("AUTH_JWT_SECRET"); v != "" { + cfg.Auth.JWTSecret = v + } + if v := os.Getenv("AUTH_ACCESS_TTL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Auth.AccessTTL = d + } + } + + if v := os.Getenv("CHAT_DEFAULT_MODEL"); v != "" { + cfg.Chat.DefaultModel = v + } + if v := os.Getenv("CHAT_MAX_HISTORY"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Chat.MaxHistory = n + } + } + if v := os.Getenv("CHAT_MEMORY_LIMIT_MSGS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Chat.MemoryLimitMsgs = n + } + } + if v := os.Getenv("CHAT_REQUEST_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Chat.RequestTimeout = d + } + } + + if v := os.Getenv("REDIS_HOST"); v != "" { + cfg.Redis.Host = v + } + if v := os.Getenv("REDIS_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + cfg.Redis.Port = p + } + } + if v := os.Getenv("REDIS_PASSWORD"); v != "" { + cfg.Redis.Password = v + } + if v := os.Getenv("REDIS_DB"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Redis.DB = n + } + } + if v := os.Getenv("REDIS_POOL_SIZE"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Redis.PoolSize = n + } + } + if v := os.Getenv("REDIS_DIAL_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Redis.DialTimeout = d + } + } + if v := os.Getenv("REDIS_READ_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Redis.ReadTimeout = d + } + } + if v := os.Getenv("REDIS_WRITE_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Redis.WriteTimeout = d + } + } +} diff --git a/smf-h/internal/db/mysql.go b/smf-h/internal/db/mysql.go new file mode 100644 index 0000000..14fc450 --- /dev/null +++ b/smf-h/internal/db/mysql.go @@ -0,0 +1,50 @@ +package db + +import ( + "fmt" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var Global *gorm.DB + +// Config 供简单演示使用,可扩展从环境变量或配置文件加载 +type Config struct { + User string + Password string + Host string + Port int + Database string + Charset string +} + +// Init 初始化全局 MySQL 连接 +func Init(cfg Config) error { + if cfg.Charset == "" { + cfg.Charset = "utf8mb4" + } + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database, cfg.Charset, + ) + gormCfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)} + db, err := gorm.Open(mysql.Open(dsn), gormCfg) + if err != nil { + return err + } + // 设置连接池 + sqlDB, err := db.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(50) + sqlDB.SetConnMaxLifetime(time.Hour) + + fmt.Printf("[DB] connected host=%s port=%d db=%s user=%s charset=%s\n", cfg.Host, cfg.Port, cfg.Database, cfg.User, cfg.Charset) + + Global = db + return nil +} diff --git a/smf-h/internal/db/tx.go b/smf-h/internal/db/tx.go new file mode 100644 index 0000000..37761db --- /dev/null +++ b/smf-h/internal/db/tx.go @@ -0,0 +1,4 @@ +package db + +// 事务管理暂不启用:按需时再添加。 +// 保留空文件以避免编译错误(之前为空触发 expected 'package')。 diff --git a/smf-h/internal/memory/conversation_memory.go b/smf-h/internal/memory/conversation_memory.go new file mode 100644 index 0000000..363ecc1 --- /dev/null +++ b/smf-h/internal/memory/conversation_memory.go @@ -0,0 +1,162 @@ +package memory + +import ( + "errors" + "sync" +) + +// Message 表示一条对话消息 +// Role 典型值: user / assistant / system +// Content 为文本内容 +// TokenCount 可选,用于后续做上下文裁剪策略 +// 这里简化不做实际 token 计算,可在外部调用模型前估算 + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + TokenCount int `json:"token_count,omitempty"` +} + +// ConversationMemory 用于管理单个会话的消息列表 +// 内部线程安全,可被多个 goroutine 并发使用 + +type ConversationMemory struct { + mu sync.RWMutex + maxMsgs int // 限制最多保留的消息条数 (简单策略) + msgs []Message +} + +// NewConversationMemory 创建一个新的会话记忆对象 +// maxMsgs: 为 0 或负数时表示不限制条数(但生产环境建议限制以防内存膨胀) +func NewConversationMemory(maxMsgs int) *ConversationMemory { + return &ConversationMemory{maxMsgs: maxMsgs, msgs: make([]Message, 0)} +} + +// Append 追加一条消息 +// 如果超出 maxMsgs,会丢弃最早的消息(先进先出) +func (c *ConversationMemory) Append(m Message) { + c.mu.Lock() + defer c.mu.Unlock() + c.msgs = append(c.msgs, m) + if c.maxMsgs > 0 && len(c.msgs) > c.maxMsgs { + // 丢弃最前面多出的部分,只保留最后 maxMsgs 条 + over := len(c.msgs) - c.maxMsgs + c.msgs = c.msgs[over:] + } +} + +// GetAll 返回当前所有消息的副本,防止外部修改内部切片 +func (c *ConversationMemory) GetAll() []Message { + c.mu.RLock() + defer c.mu.RUnlock() + res := make([]Message, len(c.msgs)) + copy(res, c.msgs) + return res +} + +// LastN 返回最近的 N 条消息(不足 N 则返回全部) +func (c *ConversationMemory) LastN(n int) []Message { + c.mu.RLock() + defer c.mu.RUnlock() + if n <= 0 || n >= len(c.msgs) { + res := make([]Message, len(c.msgs)) + copy(res, c.msgs) + return res + } + res := make([]Message, n) + copy(res, c.msgs[len(c.msgs)-n:]) + return res +} + +// TruncateByTokens 按“估算的 token 数”从头开始裁剪,直到总 token 不超过 limit +// 如果单条消息 token 已超过 limit 则返回错误 +// 这里假设 Message.TokenCount 已在外部填充 +func (c *ConversationMemory) TruncateByTokens(limit int) error { + if limit <= 0 { + return errors.New("limit must be positive") + } + c.mu.Lock() + defer c.mu.Unlock() + var total int + for _, m := range c.msgs { + if m.TokenCount > limit { + return errors.New("single message exceeds token limit") + } + total += m.TokenCount + } + // 若总量本就不超,直接返回 + if total <= limit { + return nil + } + // 从最前面开始移除,直到满足条件 + idx := 0 + for idx < len(c.msgs) && total > limit { + removed := c.msgs[idx].TokenCount + total -= removed + idx++ + } + c.msgs = c.msgs[idx:] + return nil +} + +// Clear 清空会话 +func (c *ConversationMemory) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.msgs = c.msgs[:0] +} + +// ------- 多会话管理器 ------- + +// MemoryManager 管理多个会话ID -> ConversationMemory +// 适合用在内存版快速原型。生产可换成 Redis / 数据库 + 缓存。 +type MemoryManager struct { + mu sync.RWMutex + sessions map[string]*ConversationMemory + defaultN int // 每个会话默认最多消息条数限制 +} + +// NewMemoryManager 创建管理器 +func NewMemoryManager(defaultMaxMsgs int) *MemoryManager { + return &MemoryManager{ + sessions: make(map[string]*ConversationMemory), + defaultN: defaultMaxMsgs, + } +} + +// Get 会返回指定会话ID的记忆对象,不存在则新建 +func (m *MemoryManager) Get(conversationID string) *ConversationMemory { + m.mu.RLock() + cm, ok := m.sessions[conversationID] + m.mu.RUnlock() + if ok { + return cm + } + m.mu.Lock() + defer m.mu.Unlock() + // 双检查 + if cm, ok = m.sessions[conversationID]; ok { + return cm + } + cm = NewConversationMemory(m.defaultN) + m.sessions[conversationID] = cm + return cm +} + +// Delete 删除一个会话 +func (m *MemoryManager) Delete(conversationID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, conversationID) +} + +// ListIDs 列出当前所有会话ID (用于调试) +func (m *MemoryManager) ListIDs() []string { + m.mu.RLock() + defer m.mu.RUnlock() + ids := make([]string, 0, len(m.sessions)) + for id := range m.sessions { + ids = append(ids, id) + } + return ids +} diff --git a/smf-h/internal/models/conversation.go b/smf-h/internal/models/conversation.go new file mode 100644 index 0000000..3374b0c --- /dev/null +++ b/smf-h/internal/models/conversation.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// Conversation 仅存储会话元数据和归属用户,消息仍在内存 +type Conversation struct { + ID string `gorm:"type:varchar(64);primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + LastActiveAt time.Time `gorm:"index" json:"last_active_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Conversation) TableName() string { return "conversations" } diff --git a/smf-h/internal/models/message.go b/smf-h/internal/models/message.go new file mode 100644 index 0000000..7a11344 --- /dev/null +++ b/smf-h/internal/models/message.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// Message 持久化一条会话消息 +type Message struct { + ID uint `gorm:"primaryKey" json:"id"` + ConversationID string `gorm:"type:varchar(64);index;not null;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;" json:"conversation_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Role string `gorm:"type:varchar(16);index;not null" json:"role"` // user / assistant / system + Content string `gorm:"type:mediumtext;not null" json:"content"` + TokenCount int `gorm:"type:int;default:0" json:"token_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Message) TableName() string { return "messages" } diff --git a/smf-h/internal/models/user.go b/smf-h/internal/models/user.go new file mode 100644 index 0000000..bb2e7e8 --- /dev/null +++ b/smf-h/internal/models/user.go @@ -0,0 +1,20 @@ +package models + +import "time" + +// User 基础用户实体 +// 使用显式 varchar 类型,避免 MySQL 在 utf8mb4 下创建索引时报错 (BLOB/TEXT 需要前缀长度) +// password 存放 bcrypt hash (~60 字符) 预留 255 +// 允许 Username 或 Email 其一为空,但至少应用层约束必须提供一个(注册时已校验) +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"type:varchar(64);uniqueIndex;comment:用户名" json:"username"` + Email string `gorm:"type:varchar(128);uniqueIndex;comment:邮箱" json:"email"` + Password string `gorm:"type:varchar(255);not null;comment:密码hash" json:"-"` + Level int `gorm:"type:int;default:1;comment:用户等级(数值越大权限越高)" json:"level"` + Role string `gorm:"type:varchar(32);default:user;comment:角色: user/admin" json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { return "users" } diff --git a/smf-h/internal/redisstore/redis.go b/smf-h/internal/redisstore/redis.go new file mode 100644 index 0000000..0d70d82 --- /dev/null +++ b/smf-h/internal/redisstore/redis.go @@ -0,0 +1,111 @@ +package redisstore + +import ( + "context" + "fmt" + "time" + + "awesomeProject/internal/config" + + "github.com/redis/go-redis/v9" +) + +// Client 全局 Redis 客户端 (可为空表示未启用) +var Client *redis.Client + +// available 标记是否初始化成功 +var available bool + +// IsAvailable Redis 是否可用 +func IsAvailable() bool { return available } + +// Init 初始化 Redis 连接 +func Init(cfg config.RedisConfig) error { + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + if cfg.PoolSize == 0 { + cfg.PoolSize = 20 + } + if cfg.DialTimeout == 0 { + cfg.DialTimeout = 2 * time.Second + } + if cfg.ReadTimeout == 0 { + cfg.ReadTimeout = 1 * time.Second + } + if cfg.WriteTimeout == 0 { + cfg.WriteTimeout = 1 * time.Second + } + + // 先创建临时 client,测试通过后再赋值全局 + tmp := redis.NewClient(&redis.Options{ + Addr: addr, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + DialTimeout: cfg.DialTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + }) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := tmp.Ping(ctx).Err(); err != nil { + available = false + return err + } + Client = tmp + available = true + return nil +} + +// Suggested cache key patterns +const ( + KeyUserTokenPrefix = "chat:user:token:" // userID -> last issued token (optional for invalidate) + KeyConversationSummary = "chat:conv:summary:" // convID -> brief summary json + KeyConversationLock = "chat:conv:lock:" // convID -> distributed lock + KeyRateLimitPrefix = "chat:ratelimit:" // userID -> counts + KeyModelUsageDailyPrefix = "chat:model:usage:daily:" // modelID:YYYYMMDD -> int + KeyConversationRecent = "chat:conv:recent:" // convID -> recent messages json +) + +// CacheRecentMessages 缓存最近会话消息(JSON),ttl 可为 0 表示不设置过期 +func CacheRecentMessages(ctx context.Context, conversationID string, json string, ttl time.Duration) { + if !available || Client == nil { + return + } + key := KeyConversationRecent + conversationID + if ttl > 0 { + _ = Client.Set(ctx, key, json, ttl).Err() + } else { + _ = Client.Set(ctx, key, json, 0).Err() + } +} + +// GetRecentMessages 读取最近会话缓存 +func GetRecentMessages(ctx context.Context, conversationID string) (string, error) { + if !available || Client == nil { + return "", nil + } + key := KeyConversationRecent + conversationID + val, err := Client.Get(ctx, key).Result() + if err == redis.Nil { + return "", nil + } + return val, err +} + +// DeleteRecentMessages 删除会话的最近消息缓存 +func DeleteRecentMessages(ctx context.Context, conversationID string) { + if !available || Client == nil || conversationID == "" { + return + } + key := KeyConversationRecent + conversationID + _ = Client.Del(ctx, key).Err() +} + +// DeleteConversationAll 删除一个会话相关的所有缓存键(目前 summary + recent) +func DeleteConversationAll(ctx context.Context, conversationID string) { + if !available || Client == nil || conversationID == "" { + return + } + keys := []string{KeyConversationRecent + conversationID, KeyConversationSummary + conversationID} + _ = Client.Del(ctx, keys...).Err() +} diff --git a/smf-h/main.go b/smf-h/main.go new file mode 100644 index 0000000..b53f207 --- /dev/null +++ b/smf-h/main.go @@ -0,0 +1,726 @@ +package main + +// @title AI Chat API +// @version 0.4 +// @description 简化版 AI 问答系统接口,包含认证/聊天/会话与模型权限示例。 +// @BasePath / +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "github.com/volcengine/volcengine-go-sdk/service/arkruntime" + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" + "github.com/volcengine/volcengine-go-sdk/volcengine" + + _ "awesomeProject/docs/swagger" // swagger 文档注册 (确保 /swagger/doc.json 可用) + "awesomeProject/internal/auth" + "awesomeProject/internal/config" + "awesomeProject/internal/db" + "awesomeProject/internal/memory" + "awesomeProject/internal/models" + "awesomeProject/internal/redisstore" +) + +// 全局状态 +var ( + mgr *memory.MemoryManager + defaultModelID string + maxHistoryToUse int + client *arkruntime.Client + appConfig config.Root + modelLevelMap map[string]int // modelID -> min_level +) + +// ChatRequest 入参 +type ChatRequest struct { + ConversationID string `json:"conversation_id" example:""` + Question string `json:"question" example:"你好"` + Model string `json:"model" example:"doubao-seed-1-6-250615"` +} + +// ChatResponse 出参 +type ChatResponse struct { + ConversationID string `json:"conversation_id"` + Answer string `json:"answer"` + UsedHistory int `json:"used_history"` + Messages []memory.Message `json:"messages,omitempty"` +} + +// APIResponse 通用响应包装 +type APIResponse struct { + Code int `json:"code" example:"0"` + Msg string `json:"msg" example:"ok"` + Data interface{} `json:"data,omitempty"` +} + +// ---- 更具体的响应包装(用于 swagger 展示结构) ---- +type AuthData struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` + UserID uint `json:"user_id" example:"1"` +} +type AuthResponse struct { + Code int `json:"code" example:"0"` + Msg string `json:"msg" example:"ok"` + Data AuthData `json:"data"` +} + +type ChatSendData struct { + ConversationID string `json:"conversation_id" example:"c3a2f1c4-uuid"` + Answer string `json:"answer" example:"你好,我是AI助手"` + UsedHistory int `json:"used_history" example:"2"` +} +type ChatSendResponse struct { + Code int `json:"code" example:"0"` + Msg string `json:"msg" example:"ok"` + Data ChatSendData `json:"data"` +} + +type ChatHistoryData struct { + ConversationID string `json:"conversation_id"` + Messages []memory.Message `json:"messages"` + Count int `json:"count" example:"4"` +} +type ChatHistoryResponse struct { + Code int `json:"code" example:"0"` + Msg string `json:"msg" example:"ok"` + Data ChatHistoryData `json:"data"` +} + +type ChatClearData struct { + ConversationID string `json:"conversation_id"` +} +type ChatClearResponse struct { + Code int `json:"code" example:"0"` + Msg string `json:"msg" example:"ok"` + Data ChatClearData `json:"data"` +} + +// ---------------- 错误结构 与 常量 ---------------- +type APIError struct { + Code int `json:"code" example:"40001"` + Msg string `json:"msg" example:"参数错误"` +} + +type BadRequestError struct { + Code int `json:"code" example:"40001"` + Msg string `json:"msg" example:"参数错误: question 必填"` +} +type ModelNotFoundError struct { + Code int `json:"code" example:"40002"` + Msg string `json:"msg" example:"模型不存在或未配置"` +} +type UnauthorizedError struct { + Code int `json:"code" example:"40101"` + Msg string `json:"msg" example:"未提供或非法的 Authorization 头"` +} +type ForbiddenModelError struct { + Code int `json:"code" example:"40301"` + Msg string `json:"msg" example:"无权限使用模型(doubao-seed-1-6-250615),需要等级≥3"` +} +type ForbiddenConversationError struct { + Code int `json:"code" example:"40302"` + Msg string `json:"msg" example:"会话不属于当前用户"` +} +type InternalModelCallError struct { + Code int `json:"code" example:"50000"` + Msg string `json:"msg" example:"模型调用失败"` +} +type InternalEmptyReturnError struct { + Code int `json:"code" example:"50001"` + Msg string `json:"msg" example:"模型返回为空"` +} + +const ( + CodeOK = 0 + CodeBadParam = 40001 + CodeModelNotFound = 40002 + CodeUnauthorized = 40101 + CodeForbiddenModel = 40301 + CodeForbiddenConversation = 40302 + CodeInternalError = 50000 + CodeModelEmpty = 50001 +) + +func writeErr(c *gin.Context, httpStatus, code int, msg string) { + c.JSON(httpStatus, APIError{Code: code, Msg: msg}) +} + +func main() { + // 1. 加载配置(允许没有文件时使用硬编码默认配置,方便本地快速运行不提交真实配置) + cfg, err := config.Load("config/config.yaml") + if err != nil { + fmt.Println("[WARN] 未找到或解析配置文件,将使用内置开发默认配置:", err) + cfg = config.Root{ + Server: config.ServerConfig{Port: 8080, Mode: "debug"}, + MySQL: config.MySQLConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "", Database: "ai_chat", Charset: "utf8mb4"}, + Chat: config.ChatConfig{DefaultModel: "doubao-seed-1-6-250615", MaxHistory: 12, MemoryLimitMsgs: 200, RequestTimeout: 15 * time.Second}, + Auth: config.AuthConfig{JWTSecret: "dev_local_jwt_secret", AccessTTL: 24 * time.Hour}, + Models: []config.ModelPolicy{{ID: "doubao-seed-1-6-250615", MinLevel: 1}, {ID: "deepseek-v3-1-terminus", MinLevel: 3}}, + Redis: config.RedisConfig{Host: "127.0.0.1", Port: 6379, DB: 0, PoolSize: 20, DialTimeout: 2 * time.Second, ReadTimeout: 1 * time.Second, WriteTimeout: 1 * time.Second}, + } + // 仍允许环境变量覆盖 + config.ApplyEnvOverrides(&cfg) + } + appConfig = cfg + + // 2. 初始化内存与参数 + defaultModelID = cfg.Chat.DefaultModel + maxHistoryToUse = cfg.Chat.MaxHistory + mgr = memory.NewMemoryManager(cfg.Chat.MemoryLimitMsgs) + + // 构建模型权限 map + modelLevelMap = make(map[string]int) + for _, mp := range cfg.Models { + if mp.ID != "" { + modelLevelMap[mp.ID] = mp.MinLevel + } + } + // 确保默认模型至少进入策略,未配置则给予 min_level=1 + if _, ok := modelLevelMap[defaultModelID]; !ok && defaultModelID != "" { + modelLevelMap[defaultModelID] = 1 + } + + // 3. AI 客户端(API KEY 依旧使用环境变量) + apiKey := os.Getenv("ARK_API_KEY") + if apiKey == "" { + fmt.Println("[WARN] 未设置 ARK_API_KEY,将使用 mock 模型回答 (本地开发模式)。设置 ARK_API_KEY 可启用真实模型调用。") + } else { + client = arkruntime.NewClientWithApiKey(apiKey, arkruntime.WithBaseUrl("https://ark.cn-beijing.volces.com/api/v3")) + } + + // 3.1 设置 JWT 密钥 + auth.SetJWTSecret(cfg.Auth.JWTSecret) + + // 4. 初始化 MySQL + if err := db.Init(db.Config{ + User: cfg.MySQL.User, + Password: cfg.MySQL.Password, + Host: cfg.MySQL.Host, + Port: cfg.MySQL.Port, + Database: cfg.MySQL.Database, + Charset: cfg.MySQL.Charset, + }); err != nil { + panic("MySQL 连接失败: " + err.Error()) + } + + // 4.1 初始化 Redis(可选,失败记录日志并降级为未启用) + if err := redisstore.Init(cfg.Redis); err != nil { + fmt.Println("[WARN] Redis 未启用,原因:", err.Error()) + } + + // 5. Gin 模式 + if cfg.Server.Mode != "" { + gin.SetMode(cfg.Server.Mode) + } + r := gin.Default() + + // 4.2 AutoMigrate 用户表 (放在路由前) + if err := db.Global.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}); err != nil { + panic("自动迁移失败: " + err.Error()) + } + + // Swagger 文档路由(可选:生产环境可关闭) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + // 路由 + // 健康检查 + // @Summary 健康检查 + // @Tags System + // @Produce json + // @Success 200 {object} map[string]string + // @Router /health [get] + r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + // Auth 路由 + r.POST("/api/auth/register", handleRegister) + r.POST("/api/auth/login", handleLogin) + // 需要鉴权的聊天路由 + chatGroup := r.Group("/api/chat", auth.AuthRequired()) + { + chatGroup.POST("/send", handleChatSend) + chatGroup.GET("/history", handleChatHistory) + chatGroup.POST("/clear", handleChatClear) + chatGroup.GET("/list", handleChatList) + // 调试缓存接口(需在环境变量 DEBUG_CACHE=1 下调用) + chatGroup.GET("/debug/cache", handleChatDebugCache) + } + + addr := fmt.Sprintf(":%d", cfg.Server.Port) + r.Run(addr) +} + +// 发送聊天请求 +// handleChatSend 发送聊天 +// @Summary 发送消息 +// @Description 发送用户问题并返回模型回答,支持新建或继续会话 +// @Tags Chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body ChatRequest true "请求体" +// @Success 200 {object} ChatSendResponse "成功" +// @Failure 400 {object} BadRequestError +// @Failure 400 {object} ModelNotFoundError +// @Failure 401 {object} UnauthorizedError +// @Failure 403 {object} ForbiddenModelError +// @Failure 403 {object} ForbiddenConversationError +// @Failure 500 {object} InternalModelCallError +// @Failure 500 {object} InternalEmptyReturnError +// @Router /api/chat/send [post] +func handleChatSend(c *gin.Context) { + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Question) == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "参数错误: question 必填") + return + } + if req.ConversationID == "" { + req.ConversationID = uuid.NewString() + // 创建会话记录并归属当前用户 + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + conv := models.Conversation{ID: req.ConversationID, UserID: uid, LastActiveAt: time.Now()} + _ = db.Global.Create(&conv).Error + } + if req.Model == "" { + req.Model = defaultModelID + } + + // 会话归属验证(如果会话存在则检查 user_id) + if req.ConversationID != "" { + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + var conv models.Conversation + if err := db.Global.First(&conv, "id = ?", req.ConversationID).Error; err == nil { + if conv.UserID != uid { + writeErr(c, http.StatusForbidden, CodeForbiddenConversation, "会话不属于当前用户") + return + } + // 更新活跃时间 + _ = db.Global.Model(&conv).Update("last_active_at", time.Now()).Error + } + } + + // 内存预热:如果该会话内存为空,尝试先用 Redis 最近窗口填充,失败再回源 DB + warmConversationMemory(c.Request.Context(), req.ConversationID) + + // 模型存在性校验:未在策略表里直接判定为不存在 + if _, ok := modelLevelMap[req.Model]; !ok { + writeErr(c, http.StatusBadRequest, CodeModelNotFound, "模型不存在或未配置") + return + } + + // 权限校验:获取用户等级(中间件已注入) + userLevel := auth.GetUserLevel(c) + minLv := modelLevelMap[req.Model] + if userLevel < minLv && !auth.HasAdminRole(c) { + writeErr(c, http.StatusForbidden, CodeForbiddenModel, fmt.Sprintf("无权限使用模型(%s),需要等级≥%d", req.Model, minLv)) + return + } + + cm := mgr.Get(req.ConversationID) + userTokenCount := roughTokenEstimate(req.Question) + cm.Append(memory.Message{Role: "user", Content: req.Question, TokenCount: userTokenCount}) + // 持久化用户消息 + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + _ = db.Global.Create(&models.Message{ConversationID: req.ConversationID, UserID: uid, Role: "user", Content: req.Question, TokenCount: userTokenCount}).Error + + // 准备历史:之前只取 maxHistoryToUse,容易导致上下文太短; + // 为了让模型看到更多往返,这里扩大到 *2(与缓存策略一致)。 + contextWindow := maxHistoryToUse * 2 + if contextWindow <= 0 { + contextWindow = maxHistoryToUse + } + history := cm.LastN(contextWindow) + modelMsgs := convertToModelMessages(history) + + var answer string + if client == nil { // mock 模式 + answer = fmt.Sprintf("[mock:%s] 你说: %s", req.Model, req.Question) + } else { + resp, err := client.CreateChatCompletion(c.Request.Context(), model.CreateChatCompletionRequest{ + Model: req.Model, + Messages: modelMsgs, + }) + if err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "模型调用失败") + return + } + // 根据 SDK 结构:如果 Message 不是指针则无需判空;只校验 Choices 与 Content 指针 + if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == nil || resp.Choices[0].Message.Content.StringValue == nil { + writeErr(c, http.StatusInternalServerError, CodeModelEmpty, "模型返回为空") + return + } + answer = *resp.Choices[0].Message.Content.StringValue + } + ansToken := roughTokenEstimate(answer) + cm.Append(memory.Message{Role: "assistant", Content: answer, TokenCount: ansToken}) + // 持久化助手回复 + _ = db.Global.Create(&models.Message{ConversationID: req.ConversationID, UserID: uid, Role: "assistant", Content: answer, TokenCount: ansToken}).Error + + // 写入最近历史缓存(用户+助手最新若干条): 以数据库最终消息为准,从内存截取最近 maxHistoryToUse*2 作为展示缓存 (冗余一些) + { + recent := cm.LastN(maxHistoryToUse * 2) + if b, err := json.Marshal(recent); err == nil { + redisstore.CacheRecentMessages(c.Request.Context(), req.ConversationID, string(b), 10*time.Minute) + if os.Getenv("DEBUG_CACHE") == "1" { + fmt.Printf("[DEBUG_CACHE] set recent cache conv=%s size=%d\n", req.ConversationID, len(recent)) + } + } + } + + c.JSON(http.StatusOK, ChatSendResponse{Code: 0, Msg: "ok", Data: ChatSendData{ + ConversationID: req.ConversationID, + Answer: answer, + UsedHistory: len(history), + }}) +} + +// warmConversationMemory 如果指定会话在内存中为空,则尝试:Redis -> DB 回填最近历史,避免服务重启后第一轮 /send 丢上下文。 +func warmConversationMemory(ctx context.Context, conversationID string) { + if conversationID == "" { + return + } + cm := mgr.Get(conversationID) + if len(cm.GetAll()) > 0 { // 已有内存 + return + } + // 1. Redis 最近历史 + if cached, err := redisstore.GetRecentMessages(ctx, conversationID); err == nil && cached != "" { + var recent []memory.Message + if jsonErr := json.Unmarshal([]byte(cached), &recent); jsonErr == nil { + for _, m := range recent { + cm.Append(m) + } + return + } + } + // 2. 数据库全量(或可限制最大读取条数:这里简单读取全部,然后内存自身会保留最近 maxMsgs 条) + var dbMsgs []models.Message + if err := db.Global.Where("conversation_id = ?", conversationID).Order("id asc").Find(&dbMsgs).Error; err == nil { + for _, m := range dbMsgs { + cm.Append(memory.Message{Role: m.Role, Content: m.Content, TokenCount: m.TokenCount}) + } + } +} + +// 获取历史 +// handleChatHistory 获取会话历史 +// @Summary 获取历史 +// @Tags Chat +// @Produce json +// @Security BearerAuth +// @Param conversation_id query string true "会话ID" +// @Success 200 {object} ChatHistoryResponse +// @Failure 400 {object} BadRequestError +// @Failure 401 {object} UnauthorizedError +// @Failure 403 {object} ForbiddenConversationError +// @Failure 500 {object} InternalModelCallError +// @Router /api/chat/history [get] +func handleChatHistory(c *gin.Context) { + conversationID := c.Query("conversation_id") + if conversationID == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "conversation_id 必填") + return + } + // 所属校验 + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + var conv models.Conversation + if err := db.Global.First(&conv, "id = ?", conversationID).Error; err == nil { + if conv.UserID != uid { + writeErr(c, http.StatusForbidden, CodeForbiddenConversation, "会话不属于当前用户") + return + } + } + // 尝试从缓存读取最近历史(不一定是全量,用于快速返回) + var dbMsgs []models.Message + var fromCache bool + if cached, err := redisstore.GetRecentMessages(c.Request.Context(), conversationID); err == nil && cached != "" { + var recent []memory.Message + if jsonErr := json.Unmarshal([]byte(cached), &recent); jsonErr == nil { + // 将 recent 转换为 dbMsgs 兼容后续逻辑 + for _, m := range recent { + dbMsgs = append(dbMsgs, models.Message{ConversationID: conversationID, Role: m.Role, Content: m.Content, TokenCount: m.TokenCount}) + } + fromCache = true + } + } + if !fromCache { // 缓存未命中 -> 读数据库全量 + if err := db.Global.Where("conversation_id = ?", conversationID).Order("id asc").Find(&dbMsgs).Error; err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "历史读取失败") + return + } + // 回填缓存(截取最近 maxHistoryToUse*2) + if len(dbMsgs) > 0 { + limit := maxHistoryToUse * 2 + start := 0 + if len(dbMsgs) > limit { + start = len(dbMsgs) - limit + } + recent := make([]memory.Message, 0, len(dbMsgs[start:])) + for _, m := range dbMsgs[start:] { + recent = append(recent, memory.Message{Role: m.Role, Content: m.Content, TokenCount: m.TokenCount}) + } + if b, err := json.Marshal(recent); err == nil { + redisstore.CacheRecentMessages(c.Request.Context(), conversationID, string(b), 10*time.Minute) + } + } + } + // 同步到内存(若需要后续继续对话) + cm := mgr.Get(conversationID) + if len(cm.GetAll()) == 0 { // 仅在内存为空时灌入,避免重复 + for _, m := range dbMsgs { + cm.Append(memory.Message{Role: m.Role, Content: m.Content, TokenCount: m.TokenCount}) + } + } + // 组装返回结构 + returnMsgs := make([]memory.Message, 0, len(dbMsgs)) + for _, m := range dbMsgs { + returnMsgs = append(returnMsgs, memory.Message{Role: m.Role, Content: m.Content, TokenCount: m.TokenCount}) + } + c.JSON(http.StatusOK, ChatHistoryResponse{Code: 0, Msg: "ok", Data: ChatHistoryData{ConversationID: conversationID, Messages: returnMsgs, Count: len(returnMsgs)}}) +} + +// 清空会话 +// handleChatClear 清空会话 +// @Summary 清空会话 +// @Tags Chat +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body map[string]string true "会话ID" +// @Success 200 {object} ChatClearResponse +// @Failure 400 {object} BadRequestError +// @Failure 401 {object} UnauthorizedError +// @Failure 403 {object} ForbiddenConversationError +// @Failure 500 {object} InternalModelCallError +// @Router /api/chat/clear [post] +func handleChatClear(c *gin.Context) { + type clearReq struct { + ConversationID string `json:"conversation_id"` + Full bool `json:"full"` // 为 true 时连同会话记录一起删除 + } + var r clearReq + if err := c.ShouldBindJSON(&r); err != nil || r.ConversationID == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "conversation_id 必填") + return + } + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + var conv models.Conversation + if err := db.Global.First(&conv, "id = ?", r.ConversationID).Error; err == nil { + if conv.UserID != uid { + writeErr(c, http.StatusForbidden, CodeForbiddenConversation, "会话不属于当前用户") + return + } + } + // 删除数据库消息 + if err := db.Global.Where("conversation_id = ?", r.ConversationID).Delete(&models.Message{}).Error; err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "清空失败") + return + } + if r.Full { // 连同会话元数据删除 + _ = db.Global.Delete(&models.Conversation{}, "id = ?", r.ConversationID).Error + } + // 清内存 + mgr.Get(r.ConversationID).Clear() + if r.Full { + mgr.Delete(r.ConversationID) + } + // 删除 Redis 缓存(recent + summary) + redisstore.DeleteConversationAll(c.Request.Context(), r.ConversationID) + if os.Getenv("DEBUG_CACHE") == "1" { + fmt.Printf("[DEBUG_CACHE] delete cache conv=%s full=%v redis_available=%v\n", r.ConversationID, r.Full, redisstore.IsAvailable()) + } + c.JSON(http.StatusOK, ChatClearResponse{Code: 0, Msg: "ok", Data: ChatClearData{ConversationID: r.ConversationID}}) +} + +// 调试查看缓存(只在 DEBUG_CACHE=1 时启用) +func handleChatDebugCache(c *gin.Context) { + if os.Getenv("DEBUG_CACHE") != "1" { + c.JSON(http.StatusForbidden, APIError{Code: CodeForbiddenModel, Msg: "not enabled"}) + return + } + convID := c.Query("conversation_id") + if convID == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "conversation_id 必填") + return + } + cached, _ := redisstore.GetRecentMessages(c.Request.Context(), convID) + c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "ok", Data: gin.H{"conversation_id": convID, "cached_recent": cached, "length": len(cached)}}) +} + +// handleChatList 返回当前用户的会话 ID 列表 +// @Summary 会话列表 +// @Tags Chat +// @Produce json +// @Security BearerAuth +// @Success 200 {object} APIResponse +// @Failure 401 {object} UnauthorizedError +// @Router /api/chat/list [get] +func handleChatList(c *gin.Context) { + uidVal, _ := c.Get(auth.CtxUserIDKey) + uid, _ := uidVal.(uint) + type convRow struct { + ID string `json:"id"` + LastActiveAt time.Time `json:"last_active_at"` + } + var rows []convRow + if err := db.Global.Model(&models.Conversation{}). + Select("id, last_active_at"). + Where("user_id = ?", uid). + Order("last_active_at desc"). + Find(&rows).Error; err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "查询失败") + return + } + c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "ok", Data: gin.H{"conversations": rows, "count": len(rows)}}) +} + +// 工具: 将内存消息转为模型消息 +func convertToModelMessages(msgs []memory.Message) []*model.ChatCompletionMessage { + res := make([]*model.ChatCompletionMessage, 0, len(msgs)) + for _, m := range msgs { + role := model.ChatMessageRoleUser + if m.Role == "assistant" { + role = model.ChatMessageRoleAssistant + } else if m.Role == "system" { + role = model.ChatMessageRoleSystem + } + res = append(res, &model.ChatCompletionMessage{Role: role, Content: &model.ChatCompletionMessageContent{StringValue: volcengine.String(m.Content)}}) + } + return res +} + +// 粗略 token 估算 +func roughTokenEstimate(s string) int { + if s == "" { + return 0 + } + return int(float64(len(strings.Fields(s))) * 1.3) +} + +// -------------------- 用户注册与登录 -------------------- + +type registerReq struct { + Username string `json:"username" binding:"required" example:"user1"` + Email string `json:"email" example:"user1@test.com"` + Password string `json:"password" binding:"required" example:"Abcd1234!"` +} + +type loginReq struct { + Username string `json:"username" example:"user1"` + Email string `json:"email" example:"user1@test.com"` + Password string `json:"password" binding:"required" example:"Abcd1234!"` +} + +// handleRegister 简单注册:用户名唯一,密码 bcrypt +// handleRegister 用户注册 +// @Summary 用户注册 +// @Tags Auth +// @Accept json +// @Produce json +// @Param body body registerReq true "注册参数" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} BadRequestError +// @Failure 500 {object} InternalModelCallError +// @Router /api/auth/register [post] +func handleRegister(c *gin.Context) { + var req registerReq + if err := c.ShouldBindJSON(&req); err != nil { + writeErr(c, http.StatusBadRequest, CodeBadParam, "参数错误") + return + } + if req.Username == "" && req.Email == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "username 或 email 必填其一") + return + } + // 检查是否已存在 + var existing models.User + if err := db.Global.Where("username = ? OR email = ?", req.Username, req.Email).First(&existing).Error; err == nil { + writeErr(c, http.StatusBadRequest, CodeBadParam, "用户已存在") + return + } + // hash 密码 + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "密码处理失败") + return + } + user := models.User{Username: req.Username, Email: req.Email, Password: hash} + if err := db.Global.Create(&user).Error; err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "创建失败") + return + } + ttl := appConfig.Auth.AccessTTL + if ttl == 0 { + ttl = 24 * 3600 * 1e9 + } + token, err := auth.GenerateToken(user.ID, user.Username, ttl) + if err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "token 生成失败") + return + } + c.JSON(http.StatusOK, AuthResponse{Code: 0, Msg: "ok", Data: AuthData{Token: token, UserID: user.ID}}) +} + +// handleLogin 支持用用户名或邮箱登录 +// handleLogin 用户登录 +// @Summary 用户登录 +// @Tags Auth +// @Accept json +// @Produce json +// @Param body body loginReq true "登录参数 (username 或 email 任选其一)" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} BadRequestError +// @Failure 500 {object} InternalModelCallError +// @Router /api/auth/login [post] +func handleLogin(c *gin.Context) { + var req loginReq + if err := c.ShouldBindJSON(&req); err != nil { + writeErr(c, http.StatusBadRequest, CodeBadParam, "参数错误") + return + } + if req.Username == "" && req.Email == "" { + writeErr(c, http.StatusBadRequest, CodeBadParam, "username 或 email 必填其一") + return + } + var user models.User + q := db.Global + if req.Username != "" { + q = q.Where("username = ?", req.Username) + } else { + q = q.Where("email = ?", req.Email) + } + if err := q.First(&user).Error; err != nil { + writeErr(c, http.StatusBadRequest, CodeBadParam, "用户不存在") + return + } + if !auth.CheckPassword(user.Password, req.Password) { + writeErr(c, http.StatusBadRequest, CodeBadParam, "密码错误") + return + } + ttl := appConfig.Auth.AccessTTL + if ttl == 0 { + ttl = 24 * 3600 * 1e9 + } + token, err := auth.GenerateToken(user.ID, user.Username, ttl) + if err != nil { + writeErr(c, http.StatusInternalServerError, CodeInternalError, "token 生成失败") + return + } + c.JSON(http.StatusOK, AuthResponse{Code: 0, Msg: "ok", Data: AuthData{Token: token, UserID: user.ID}}) +}