Compare commits

164 Commits

Author SHA1 Message Date
921b56cfb9 fix(agent): keep response policy in managed prompt 2026-07-16 17:16:51 +08:00
1b5fcc4d52 fix(agent): enforce complete homework answers 2026-07-16 17:03:04 +08:00
6c02662d3b feat(agent): persist production generation settings 2026-07-16 16:07:11 +08:00
245b6b3eca fix(rag): recall complete course homework lists 2026-07-16 15:48:40 +08:00
cbbdb41b80 fix(admin): restore knowledge preview scrolling 2026-07-16 14:19:33 +08:00
c3f24d0ccd feat(admin): add knowledge content preview 2026-07-16 10:39:46 +08:00
56c9f2f359 fix(rag): retrieve complete course assignments 2026-07-15 22:09:52 +08:00
ba1840233c refactor(chat): simplify thinking status text 2026-07-13 20:18:42 +08:00
e4a391aa08 fix(agent): serialize retrieval trace responses 2026-07-13 19:37:12 +08:00
128d444a76 fix(admin): correct login page copyright 2026-07-13 17:32:39 +08:00
653317ca7d feat(admin): redesign branded login page 2026-07-13 17:28:27 +08:00
971440ebff fix: 修复添加学员启用开关布局 2026-07-13 16:05:28 +08:00
bbd19cf9ed fix: 修复会话上下文记忆链路 2026-07-13 15:43:29 +08:00
dda2e040ca feat: explain agent generation parameters 2026-07-13 15:17:09 +08:00
a8bb8b068c fix: show agent generation parameters directly 2026-07-13 14:47:56 +08:00
44ab895149 feat: add prompt history management 2026-07-13 14:06:45 +08:00
cb3b91b101 fix: move storage stats to dashboard 2026-07-13 13:32:41 +08:00
750a57a390 fix: invalidate agent cache on knowledge changes 2026-07-13 13:18:52 +08:00
00d53c2569 perf: paginate admin audit lists 2026-07-13 13:09:15 +08:00
0864f9305b fix: prevent agent debug from blocking chat 2026-07-13 12:27:07 +08:00
2453816364 fix: stabilize agent knowledge dropdown 2026-07-13 12:09:43 +08:00
120fe185f9 fix: complete admin knowledge audit improvements 2026-07-13 11:40:34 +08:00
82e0c02bac docs: 补充知识库交互修复验证结果 2026-07-12 15:56:02 +08:00
b5047ca7e4 fix: 恢复知识库开关并修复弹层组件 2026-07-12 15:51:45 +08:00
390f0a0cbf docs: 记录知识库开关与弹层交互修复 2026-07-12 15:47:34 +08:00
64a5052351 docs: 记录知识库管理闭环修复结果 2026-07-12 15:35:25 +08:00
e337897b7a fix: 修复知识库唯一约束迁移兼容性 2026-07-12 15:31:17 +08:00
8266d845c0 fix: 补齐知识库元数据管理与生命周期校验 2026-07-12 15:30:23 +08:00
c3639fe30d docs: 补充知识库管理操作闭环设计 2026-07-12 15:18:55 +08:00
16fa9d06c2 chore: 完成知识库迁移与检索验证收口 2026-07-11 19:30:08 +08:00
872233adc6 feat: 完善知识库版本管理与检索审计后台 2026-07-11 19:22:18 +08:00
6cadca8c8e feat: 切换Agent自主知识工具检索链路 2026-07-11 19:14:17 +08:00
d2d43de6d3 feat: 实现知识同步审核发布生命周期 2026-07-11 19:04:16 +08:00
73fdfed0e5 feat: 建立知识版本与检索追踪数据模型 2026-07-11 18:59:29 +08:00
0199dd8242 docs: 完成知识库与Agent重构设计 2026-07-11 18:55:10 +08:00
9bce095583 docs: 完成用户有效期控件验收记录 2026-07-11 17:05:55 +08:00
70be6ab657 fix: 统一用户有效期日期选择控件 2026-07-11 17:05:31 +08:00
85208972b7 docs: 记录用户有效期日期控件统一方案 2026-07-11 17:03:30 +08:00
3393d1a809 fix: admin-web vite.config 补充 API proxy 配置 2026-07-11 17:01:10 +08:00
a81f16f298 docs: 归档登录页设计验收记录 2026-07-11 16:44:58 +08:00
da795b10c3 docs: 完成用户端聊天页设计验收 2026-07-11 16:44:13 +08:00
10c829e62d fix: 修正用户端热更新连接配置 2026-07-11 16:44:05 +08:00
603fb70715 feat: 重构移动端用户聊天页面 2026-07-11 16:40:40 +08:00
4f75e11a8d docs: 记录用户端聊天页重构方案 2026-07-11 16:26:40 +08:00
d0d25d9e17 fix: expand ai request log text fields 2026-07-10 18:46:41 +08:00
b3cb4a1ca9 fix: add chat session summary migration 2026-07-10 18:34:08 +08:00
5844741f24 fix: Vite HMR WebSocket 通过 nginx 代理,修复域名访问时的 ws 连接失败 2026-07-10 18:01:08 +08:00
d9a8f42522 docs: 完成登录页设计验收记录 2026-07-10 17:21:37 +08:00
dc4b1bfce9 fix: 恢复登录手机号和短信倒计时 2026-07-10 17:13:21 +08:00
fc8a593393 fix: 修复登录表单移动端横向溢出 2026-07-10 17:11:22 +08:00
2e274dea51 feat: 重构用户端手机号验证码登录页 2026-07-10 17:08:31 +08:00
808ba74fc5 feat: 支持按需触发短信图形验证 2026-07-10 17:02:50 +08:00
1ab834733e docs: 记录用户端登录页正式改造方案 2026-07-10 17:01:56 +08:00
851c2a532a Revert "feat: 重构学员端登录页面——专业移动端H5登录体验"
This reverts commit b82a5a464f.
2026-07-10 14:35:53 +08:00
b82a5a464f feat: 重构学员端登录页面——专业移动端H5登录体验
LoginPanel.vue 完全重写:
- 品牌标题区:绿色圆角图标+主标题+副标题+装饰盾牌
- 表单卡片:白色圆角卡片承载手机号+短信验证码
- 图形验证码按需弹出:点击发送验证码时弹出模态弹窗
- 用户协议:自定义checkbox+协议弹窗(Tab切换)
- 倒计时持久化:localStorage按手机号存储,刷新不重置
- Toast提示:发送成功/失败/登录错误
- 辅助入口:收不到验证码/登录遇到问题弹窗
- 底部安全说明:盾牌图标+信任提示
- 输入过滤:只允许数字,16px字号防iOS缩放
- 响应式:最大420px居中,safe-area适配

styles.css:移除旧登录样式,保留全局基础
2026-07-10 14:34:11 +08:00
5ead280df0 fix: Vite dev server 配置 API 代理,解决 405 Method Not Allowed 2026-07-10 14:23:57 +08:00
83f67936c8 feat: 学员端短信验证码发送增加60秒倒计时 2026-07-10 13:51:44 +08:00
085deef9d3 docs: 完成第二批上线优化实施记录 2026-07-10 12:19:20 +08:00
75c54466d4 test: 增加生产环境冒烟检查 2026-07-10 12:18:35 +08:00
367127504f fix: 兼容最小权限数据库备份校验 2026-07-10 12:17:18 +08:00
6d164a77fe refactor: 拆分后台系统配置定义 2026-07-10 12:16:01 +08:00
0bc767933e feat: 完善数据库备份恢复流程 2026-07-10 12:15:25 +08:00
d7d9514348 feat: 增加结构化日志和服务就绪检查 2026-07-10 12:14:19 +08:00
ebaf3defa2 docs: 记录第二批上线优化和非向量检索决策 2026-07-10 12:12:42 +08:00
b759409580 docs: 完成生产上线风险修复记录 2026-07-10 12:02:31 +08:00
076fcb9f02 refactor: 拆分后台登录页面职责 2026-07-10 11:55:48 +08:00
7f835ec699 test: 补齐上线关键链路自动验证 2026-07-10 11:54:49 +08:00
fb39c9c543 feat: 增加生产环境容器编排 2026-07-10 11:50:45 +08:00
9fb3dad5bd feat: 加密并隐藏系统敏感配置 2026-07-10 11:49:21 +08:00
aedecb3aab feat: 增加登录限流和跨进程退出机制 2026-07-10 11:47:28 +08:00
5aca872129 fix: 修复聊天排队和每日额度重置 2026-07-10 11:45:37 +08:00
5819830e33 docs: 记录生产上线风险修复计划 2026-07-10 11:44:59 +08:00
f07335dab4 chore: 本地调试去掉 frpc sidecar 2026-07-09 19:00:26 +08:00
ef24868105 fix: 移除后台登录默认凭据 2026-07-09 16:45:09 +08:00
55333445f4 docs: 补充生产管理员初始化配置说明 2026-07-09 16:39:54 +08:00
53febbbf91 docs: 记录后台登录上线安全要求 2026-07-09 16:38:35 +08:00
7587da6d18 Add single domain gateway routing 2026-07-09 16:03:29 +08:00
664d1acbce Document single domain gateway routing 2026-07-09 15:56:54 +08:00
e661e53d0b ui: 学员端登录页删除logo 2026-07-09 15:16:48 +08:00
5bd737b064 Remove legacy hy_qa_asset_backend directory 2026-07-09 15:12:47 +08:00
769bd2b7b4 Replace dashboard date range picker 2026-07-09 15:11:24 +08:00
0b0ff6b9dc Document dashboard date control update 2026-07-09 15:09:13 +08:00
34e740bbda Replace record audit date picker controls 2026-07-09 15:06:26 +08:00
d9c7f9a309 Document record audit date control update 2026-07-09 15:01:20 +08:00
0ea75dea1d feat: 看板优化——中文标题+Token消耗+时间段筛选+品牌名统一
- 学员端:LoginPanel/App.vue 描述文案优化,去掉生硬表述
- 后台品牌统一:index.html/App.vue 标题改为'大本营千问千答'
- 看板tab:Dashboard -> 数据看板
- 看板新增:输入Token、输出Token、总Token 统计
- 看板支持按时间段筛选:后端API增加start/end参数,前端加日期选择器
- stats-grid布局从5列调整为4列,响应式改为2列
2026-07-09 14:58:45 +08:00
facb3a6a56 Rename product to Dabenying QA assistant 2026-07-09 12:28:55 +08:00
6c53566a4e Update assistant naming scope documentation 2026-07-09 12:25:38 +08:00
532d51370e Document Dabenying assistant naming update 2026-07-09 12:24:40 +08:00
cde7da572b Store login verification codes in Redis 2026-07-09 12:16:21 +08:00
c56a4b49e1 Show dialog for user SMS login errors 2026-07-09 12:12:15 +08:00
34d6dd01c7 Add graphic captcha before user SMS login 2026-07-09 12:09:10 +08:00
0e2706a4f4 Document user login graphic captcha requirement 2026-07-09 11:58:38 +08:00
2dcb43b717 feat: 添加 frpc sidecar 容器(内网穿透,随项目自动启动)
- docker-compose.dev.yml: 新增 frpc 服务,通过 .env 环境变量配置
- deploy/frpc.Dockerfile: 基于 Alpine 构建,下载 frp 0.68.1 二进制
- deploy/frpc-entrypoint.sh: 由环境变量生成 frpc.toml 配置文件
- .env.example: 环境变量说明(占位值,不含真实凭证)
- .gitignore: 忽略 .env 文件,防止敏感信息提交
2026-07-09 11:53:04 +08:00
c86b517063 Add Aliyun SMS settings and refresh admin login 2026-07-09 11:02:26 +08:00
d78d0ea75f Document Aliyun SMS and admin login redesign 2026-07-09 10:52:47 +08:00
e1a130177c Add AI request audit detail drawer 2026-07-09 10:44:52 +08:00
42569a1057 Document AI request detail audit interaction 2026-07-09 10:41:16 +08:00
f39e51a4d4 Show retrieved knowledge chunks in audit logs 2026-07-09 10:29:58 +08:00
d6b7ec1e52 Document AI retrieval chunk audit requirement 2026-07-09 10:27:03 +08:00
1c73e8dee6 ui: 知识库导入区域增加间距样式 2026-07-09 10:19:21 +08:00
67048a14e8 feat: 简化知识库导入——只输入NodeToken,自动获取标题和SpaceID
- admin_knowledge.py: /knowledge/import 改为单条导入,自动获取 title+space_id
- feishu_service.py: 新增 get_node_info() 公开方法
- App.vue: 简化 UI,只保留 NodeToken 输入框和导入按钮
- api.ts: 更新类型定义
2026-07-09 10:13:09 +08:00
76da6dd65a feat: 递归导入只需输入NodeToken,自动获取SpaceID
- feishu_service.py: import_nodes_from_parent 去掉 space_id 参数,
  通过 get_node_info 自动获取 space_id
- admin_knowledge.py: import API 去掉 spaceId 参数
- App.vue: 前端去掉 SpaceID 输入框,只需输入 NodeToken
2026-07-08 18:54:09 +08:00
3598e34819 feat: 知识库递归导入(从指定父节点,不需要空间权限)
- feishu_service.py: import_space_nodes 改为 import_nodes_from_parent
  从指定父节点递归获取子节点,不需要空间级别权限
- admin_knowledge.py: import API 增加 parent_node_token 参数
- api.ts/App.vue: 前端增加父节点 NodeToken 输入框
2026-07-08 18:50:46 +08:00
f4196612d4 ui: 刷新飞书缓存按钮移到知识库管理页面 2026-07-08 18:01:24 +08:00
5dfff52b04 feat: 后台一键刷新飞书缓存
- feishu_service.py: FeishuKnowledgeService 新增 clear_cache() 方法
- admin_settings.py: 新增 POST /admin/feishu/cache/clear API
- api.ts: 前端封装 clearFeishuCache API
- App.vue: Agent管理页面新增'刷新飞书缓存'按钮

管理员更新飞书文档后,点击按钮即可清空服务端内存缓存,
客户端下次提问时会重新读取最新内容。
2026-07-08 17:57:26 +08:00
61db0aa9b8 feat: 对话摘要+段落按需读取+think标签解析修复
- chat.py: ChatSession 新增 summary/summary_up_to_message_id 字段
- rag_service.py: Prompt 支持历史摘要+最近5轮原文拼接
- model_service.py: 新增 summarize() 方法,复用已启用模型生成摘要
- chat_service.py: 集成对话历史传递,8轮后触发摘要生成
- feishu_service.py: 段落级按需读取,按标题匹配+关键词打分+top5文档

摘要策略:
- 保留最近5轮原文保证追问连续性
- 更早历史由模型压缩成摘要(<=300字)
- 摘要失败不阻断主流程

飞书读取优化:
- 每个知识库最多读5个相关文档
- 每文档提取top10相关段落
- 按原文顺序拼接,不截断

Fix: _extract_openai_answer 重命名为 _extract_gemini_answer
避免同名函数覆盖导致MiniMax模型被错误解析为Gemini响应
2026-07-08 17:49:52 +08:00
efd03ff9df Add deployment tuning and chat load test 2026-07-08 17:45:18 +08:00
6464c61396 Document deployment tuning phase 2026-07-08 17:39:35 +08:00
c68af6f806 Cache Feishu retrieval data in Redis 2026-07-08 17:39:04 +08:00
70466a6a31 Document Redis Feishu cache scope 2026-07-08 17:36:44 +08:00
23d5f42972 Add Redis-backed chat queue 2026-07-08 17:36:20 +08:00
db1d83631b Document Redis concurrency phase 2026-07-08 17:28:02 +08:00
812ae80536 Add async Feishu retrieval for chat RAG 2026-07-08 17:26:10 +08:00
7a6068590e Document async Feishu retrieval plan 2026-07-08 17:21:56 +08:00
72bd2e1e02 Use async model streaming for chat 2026-07-08 17:20:29 +08:00
1aab53cb37 Document async model streaming plan 2026-07-08 17:16:38 +08:00
211cb6207e Document async optimization split 2026-07-08 17:15:15 +08:00
465a481e7a Stream chat completions from model providers 2026-07-08 17:13:53 +08:00
d476c54580 Document streaming optimization phase 2026-07-08 17:07:12 +08:00
6f3bbd3803 Implement chat queue fallback 2026-07-08 16:57:40 +08:00
10b6de0622 Document concurrency optimization plan 2026-07-08 16:48:51 +08:00
925ef0238d Support Feishu file node retrieval 2026-07-08 12:29:21 +08:00
7811e3ed1f Document Feishu file node retrieval issue 2026-07-08 12:26:22 +08:00
13185ab42b Align user chat with agent model flow 2026-07-08 12:18:38 +08:00
35011d2679 Document user chat agent alignment 2026-07-08 12:15:07 +08:00
fb0bbc346b Fix Feishu wiki child node retrieval 2026-07-08 12:08:23 +08:00
7be5dde1db Support Feishu credentials in system settings 2026-07-08 12:03:37 +08:00
aed6d8ffbf Document Feishu credentials in settings 2026-07-08 12:00:41 +08:00
9c76be70fe Document Feishu credential configuration 2026-07-08 11:59:04 +08:00
eda2c5af76 Clarify chat retrieval failures 2026-07-08 11:55:22 +08:00
ac72b6a94a Implement scoped real knowledge retrieval 2026-07-08 11:46:09 +08:00
ca50fa437c Document scoped real knowledge retrieval 2026-07-08 11:42:59 +08:00
6ecdc8011d Show all system settings explicitly 2026-07-08 11:35:52 +08:00
4b3ea7baa4 Document explicit system settings page 2026-07-08 11:34:15 +08:00
cd01e45fbd Implement admin Excel import settings and chat preview 2026-07-07 19:05:52 +08:00
d18d9fe9d8 Document admin import settings and preview changes 2026-07-07 18:53:19 +08:00
29f25dd767 Implement student roster login gating 2026-07-07 18:45:07 +08:00
4f11d8632c Document student roster login requirement 2026-07-07 18:40:21 +08:00
84da551cbe Fix model delete confirmation dialog styles 2026-07-07 18:35:19 +08:00
03b1810a90 Implement open knowledge access scope 2026-07-07 18:32:01 +08:00
8d5d36c5eb Document open knowledge access scope 2026-07-07 18:26:06 +08:00
7be9847d65 Complete admin operations management 2026-07-07 13:55:10 +08:00
da262f4596 Separate agent tuning from model setup 2026-07-07 13:31:20 +08:00
dada4aafcd Add quick LLM provider setup 2026-07-07 13:12:27 +08:00
bcdf91c85d Optimize admin web bundle 2026-07-07 13:03:00 +08:00
55b7c5b0ea Complete admin model management 2026-07-07 12:58:26 +08:00
08c301c136 Complete admin chat records 2026-07-07 12:17:23 +08:00
08dd371480 Add V2 phase six verification assets 2026-07-06 18:14:36 +08:00
62eeb3186c Add V2 admin console 2026-07-06 18:13:08 +08:00
301661e7b4 Add V2 external service providers 2026-07-06 18:03:29 +08:00
86cbed2437 Complete V2 user H5 flow 2026-07-06 18:00:21 +08:00
207340973c Add V2 RAG answer skeleton 2026-07-06 17:55:22 +08:00
96742de07b Add V2 local dev compose 2026-07-06 17:45:24 +08:00
e9f3db9ab2 Add Vue user H5 client 2026-07-06 17:34:37 +08:00
17cba7cf61 Scaffold V2 backend foundation 2026-07-06 17:25:06 +08:00
44af745828 Record Vue 3 client decision 2026-07-06 17:14:06 +08:00
b7dfa13872 Plan V2 phase one architecture 2026-07-06 17:09:33 +08:00
dbd59a84df Document V2 plan in Chinese 2026-07-06 17:02:16 +08:00
4eac7351a9 Start AI knowledge base v2 workspace 2026-07-06 16:55:03 +08:00
708e0a424e Hide H5 auth and document phase one scope 2026-07-05 19:55:22 +08:00
fa2d82333c Add WeChat auth entry for H5 2026-07-05 19:48:32 +08:00
a4b59add07 Use backend callable QA for H5 answers 2026-07-05 18:53:46 +08:00
cd73ed3001 Enhance H5 QA session interactions 2026-07-05 18:47:59 +08:00
34aeb22760 Add mobile H5 QA experience 2026-07-05 18:32:28 +08:00
328 changed files with 37246 additions and 6546 deletions

View File

@@ -0,0 +1,38 @@
# ============================================
# 飞书知识库配置
# ============================================
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_SEARCH_URL=
# ============================================
# 管理后台首个管理员初始化
# 生产环境必须替换为安全账号密码,不能使用 admin123456
# ============================================
APP_ENV=production
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
MYSQL_PASSWORD=replace-with-strong-database-password
REDIS_PASSWORD=replace-with-strong-redis-password
DATABASE_URL=mysql+pymysql://ai_kb:URL编码后的数据库密码@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL=redis://:URL编码后的Redis密码@redis:6379/0
JWT_SECRET_KEY=replace-with-at-least-32-random-characters
# 使用命令生成python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CONFIG_ENCRYPTION_KEY=replace-with-generated-fernet-key
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
BOOTSTRAP_ADMIN_NAME=系统管理员
# ============================================
# 内网穿透frpc sidecar
# 启动 docker compose 前,拷贝本文件为 .env 并填写
# 或直接在 shell 中 export 这些变量
# ============================================
FRP_SERVER_ADDR=your-server-ip
FRP_SERVER_PORT=7000
FRP_AUTH_TOKEN=your-auth-token
FRP_PROXY_NAME=qa-web
# 默认穿透统一入口 gateway:80由 gateway 按路径转发到用户端、后台和 API
FRP_LOCAL_IP=gateway
FRP_LOCAL_PORT=80
# 自定义域名逗号分隔qa.huiyushuyuan.cn
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn

1
ai_knowledge_base_v2/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

View File

@@ -0,0 +1,51 @@
# AI 知识库系统 V2 工作区
这个目录用于承载新需求版本:**AI 企业知识库问答系统**。
它和旧的 `hy_qa_asset_backend/docs` 目录刻意分开。旧项目的目标是“答疑资产后台系统”,核心是把大本营答疑内容清洗、审核、沉淀成可调用问答;新需求的目标是“企业飞书知识库 AI 问答系统”,核心是登录、权限、知识库、聊天会话、实时飞书检索和 RAG 问答。
## 目录说明
- `source_documents/`:保存原始需求资料,包括 PRD、SRS、API、数据库、RAG、后台原型、实施计划、测试、验收、部署、字段字典、权限矩阵。
- `extracted/`:从原始 Word/Excel 中抽取出的 Markdown主要用于开发检索和上下文阅读。
- `development_records/`:保存新项目开发过程记录、范围决策、架构决策、实施计划和阶段性说明。
- `reports/`:保存评估报告、方案报告和后续技术调研结论。
## 当前目标
按照已上传的 AI 知识库系统需求包,重新规划和开发一版新系统:
- 用户端手机号验证码登录、学员名单准入、AI 聊天、多会话、历史聊天、流式回答、停止生成、Markdown 渲染、开放知识库过滤。
- 管理后台管理员登录、用户管理、学员手动添加、学员批量导入、管理员管理、知识库管理、Prompt 管理、模型管理、聊天记录、数据统计、系统配置、操作日志。
- AI/RAG一期以飞书知识库作为唯一知识来源每次提问实时检索按知识库开放状态过滤已开放知识库默认所有用户可见组装 Prompt调用大模型通过 SSE 流式输出;记录 AI 请求日志。
- 一期不做文件上传、图片理解、OCR、联网搜索、向量数据库同步、支付系统、课程系统、自动授权、多租户、多 Prompt、重新生成、编辑消息。
## 工作原则
- V2 先作为独立工作区推进,避免和旧项目文档混在一起。
- 上传的原始文档是 V2 的需求依据。
- 给用户、团队、交接和评审看的文档统一使用中文。
- `extracted/` 里的机器抽取文档可以保留原始语言或工程化表达,优先服务开发检索。
- 旧代码只在领域边界匹配时复用,不把旧的“答疑资产审核模型”硬套到新的“开放知识库问答模型”上。
## 本地开发启动
阶段二已经提供本地开发编排:
```bash
cd ai_knowledge_base_v2
docker compose -f docker-compose.dev.yml up --build
```
默认访问地址:
- 统一入口:`http://127.0.0.1:8080/`
- 用户端 H5`http://127.0.0.1:8080/`
- 管理后台:`http://127.0.0.1:8080/login`
- 后端 API`http://127.0.0.1:8080/api/health`
- 用户端直连调试:`http://127.0.0.1:5173/`
- 管理后台直连调试:`http://127.0.0.1:5174/login/`
- 后端直连调试:`http://127.0.0.1:8100/api/health`
- MySQL`127.0.0.1:3307`
开发阶段 mock 短信验证码为 `123456`

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
*.tsbuildinfo

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5174
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -0,0 +1,33 @@
# 大本营答疑助手管理后台
这是大本营答疑助手的管理后台,使用 Vue 3、Vite、TypeScript 和 Element Plus。
## 本地启动
```bash
npm install
npm run dev -- --port 5174
```
默认访问地址:
```text
http://127.0.0.1:8080/login
```
默认后端地址:
```text
/api
```
## 管理员初始化
开发阶段后端会在首次管理员登录时自动创建默认管理员:
```text
账号admin
密码admin123456
```
管理后台登录页不会预填账号密码。生产环境必须通过环境变量配置安全的首个管理员账号密码,不能使用开发默认密码。

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大本营千问千答</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "ai-kb-admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"element-plus": "^2.11.0",
"vite": "^7.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { reactive } from "vue";
defineProps<{ loading: boolean }>();
const emit = defineEmits<{
submit: [credentials: { username: string; password: string }];
}>();
const form = reactive({ username: "", password: "" });
function submit() {
emit("submit", { username: form.username.trim(), password: form.password });
}
</script>
<template>
<main class="login-page">
<section class="login-hero" aria-label="大本营千问千答后台登录">
<section class="login-hero-main" aria-label="产品介绍">
<header class="login-brand">
<span class="login-brand-mark" aria-hidden="true">
<svg viewBox="0 0 64 64" role="presentation">
<circle cx="32" cy="32" r="28" />
<path d="M32 9c4 7 8 10 15 11-1 7 1 12 7 17-6 4-9 9-9 16-7-1-12 1-17 6-4-6-9-9-16-9 1-7-1-12-6-17 6-4 9-9 9-16 7 1 12-1 17-6 4 6 9 9 16 9Z" />
<path d="M32 19c2.5 5 5.5 7.5 11 8-4 3.5-5.5 7.5-4 13-4.7-2.5-8.7-2.5-13 0 1.5-5.5 0-9.5-4-13 5.5-.5 8.5-3 10-8Z" />
<circle cx="32" cy="32" r="3" />
</svg>
</span>
<span class="login-brand-copy">
<strong>大本营千问千答</strong>
<small>智能知识管理与问答系统</small>
</span>
</header>
<div class="login-orbit" aria-hidden="true"><i></i></div>
<div class="login-dot-matrix" aria-hidden="true"></div>
<div class="login-brand-content">
<h1>
把大本营答疑知识库和学员<br />
运营放在<span>同一个控制台</span>
</h1>
<p>
统一维护学员名单开放知识库模型接入Agent 调试问答记录和系统运行参数让日常运营更清晰也让排查与交接更从容
</p>
<div class="login-highlights" aria-label="核心能力">
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="5" y="3.5" width="13" height="17" rx="2" /><path d="M8.5 7.5h5M8.5 11.5h6M8.5 15.5h3.5M18 7h2v11a2 2 0 0 1-2 2" /></svg>
</span>
<strong>大本营知识库</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="4" y="7" width="16" height="12" rx="4" /><path d="M9 12h.01M15 12h.01M8 16h8M12 7V4M10.5 3h3" /></svg>
</span>
<strong>答疑 Agent 调试</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M5 5.5h14v11H9l-4 3v-14Z" /><path d="M8.5 9.5h7M8.5 13h4" /></svg>
</span>
<strong>问答记录审计</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 3.5 15 5l3.3-.2.2 3.3 1.7 2.9-2 2.7-.5 3.3-3.3.5-2.4 2.3-2.4-2.3-3.3-.5-.5-3.3-2-2.7 1.7-2.9.2-3.3L9 5l3-1.5Z" /><circle cx="12" cy="11.7" r="3" /></svg>
</span>
<strong>系统运行管理</strong>
</div>
</div>
</div>
<svg class="login-wave" viewBox="0 0 920 250" preserveAspectRatio="none" aria-hidden="true">
<path d="M-20 45C105 25 110 150 250 151S410 85 530 118s185 97 410 18" />
<path d="M-20 62C95 40 127 169 264 167s160-64 277-28 180 82 399 18" />
<path d="M-20 80C90 57 140 188 278 184s159-60 273-23 176 69 389 17" />
<path d="M-20 105C88 78 142 209 295 202s168-54 278-16 165 56 367 13" />
<path d="M-20 133C95 102 157 224 315 220s164-46 282-11 158 43 343 12" />
</svg>
<svg class="login-lotus" viewBox="0 0 360 300" aria-hidden="true">
<g>
<path d="M180 258C126 219 119 159 180 72c61 87 54 147 0 186Z" />
<path d="M177 260C112 244 78 196 99 99c75 58 98 108 78 161Z" />
<path d="M183 260c65-16 99-64 78-161-75 58-98 108-78 161Z" />
<path d="M162 264C92 266 47 232 34 143c78 30 119 70 128 121Z" />
<path d="M198 264c70 2 115-32 128-121-78 30-119 70-128 121Z" />
<path d="M119 275c-45 5-80-8-111-41 56 3 97 15 111 41ZM241 275c45 5 80-8 111-41-56 3-97 15-111 41Z" />
</g>
</svg>
</section>
<section class="login-card" aria-labelledby="login-title">
<div class="login-card-inner">
<div class="login-card-head">
<span class="login-card-eyebrow">ADMIN CONSOLE</span>
<h2 id="login-title">欢迎登录</h2>
<p>登录大本营答疑助手后台管理系统</p>
</div>
<el-form label-position="top" autocomplete="off" @submit.prevent="submit">
<el-form-item label="管理员账号">
<el-input v-model="form.username" size="large" placeholder="请输入管理员账号" autocomplete="off">
<template #prefix>
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5" /><path d="M5.5 19c.6-4 2.8-6 6.5-6s5.9 2 6.5 6" /></svg>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
size="large"
type="password"
placeholder="请输入密码"
autocomplete="new-password"
show-password
>
<template #prefix>
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2.5" /></svg>
</template>
</el-input>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="full-btn login-submit">
进入答疑后台
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
</el-button>
</el-form>
<p class="login-security-note"><span aria-hidden="true"></span>仅限已授权的后台管理员访问</p>
</div>
</section>
</section>
<p class="login-copyright">© 2026 深圳市慧愈文化科技有限公司</p>
</main>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{ page: number; pageSize: number; total: number }>();
const emit = defineEmits<{ change: [page: number, pageSize: number] }>();
function changePage(page: number, pageSize: number) { emit("change", page, pageSize); }
function changeSize(pageSize: number) { emit("change", 1, pageSize); }
</script>
<template>
<div class="admin-pagination">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="changePage($event, pageSize)"
@size-change="changeSize"
/>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number | null;
disabled?: boolean;
}>();
defineEmits<{
"update:temperature": [value: number | null];
"update:topP": [value: number | null];
"update:topK": [value: number | null];
"update:presencePenalty": [value: number | null];
"update:frequencyPenalty": [value: number | null];
"update:maxToken": [value: number | null];
}>();
</script>
<template>
<div class="agent-config-grid">
<el-form-item>
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 02。数值越小回答越稳定、一致数值越大表达越多样但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 01。模型只从累计概率达到该数值的候选词中选择。数值越小越专注越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 11000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 40968192数值越大允许回答越长也会增加耗时和费用最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
</el-form-item>
</div>
</template>

View File

@@ -0,0 +1,473 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { AgentDebugResult, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{ previewKnowledgeId?: number | null }>();
const emit = defineEmits<{ consumedPreview: [] }>();
const loading = ref(false);
const promptSaving = ref(false);
const runtimeSaving = ref(false);
const historyLoading = ref(false);
const agentDebugging = ref(false);
const activeConfigTab = ref("prompt");
const prompt = ref<PromptDetail | null>(null);
const promptContent = ref("");
const savedPromptContent = ref("");
const models = ref<ModelItem[]>([]);
const knowledge = ref<KnowledgeItem[]>([]);
const history = ref<PromptHistoryItem[]>([]);
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
const historyDetailOpen = ref(false);
const selectedHistory = ref<PromptDetail | null>(null);
const historyDetailLoading = ref(false);
const agentDebugTrace = ref<Record<string, any>[]>([]);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]);
const agentForm = reactive({
modelId: undefined as number | undefined,
knowledgeIds: [] as number[],
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
question: "",
});
const runtimeConfig = ref<AgentRuntimeConfig | null>(null);
const runtimeForm = reactive({
temperature: null as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
});
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
const selectedModelName = computed(() => {
const model = models.value.find((item) => item.id === agentForm.modelId);
return model?.displayName || model?.modelName || "未选择模型";
});
const selectedKnowledgeSummary = computed(() => {
if (!agentForm.knowledgeIds.length) return "未选择知识库";
if (agentForm.knowledgeIds.length === 1) {
return knowledge.value.find((item) => item.id === agentForm.knowledgeIds[0])?.name || "1 个知识库";
}
return `${agentForm.knowledgeIds.length} 个知识库`;
});
onMounted(load);
watch(() => props.previewKnowledgeId, (knowledgeId) => {
if (knowledgeId && knowledge.value.length) applyPreviewKnowledge(knowledgeId);
});
async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
]);
applyPrompt(promptResult);
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id;
applyRuntimeConfig(formalConfig);
applyDebugModelDefaults(agentForm.modelId);
agentForm.knowledgeIds = knowledgeRows
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
.map((item) => item.id);
if (props.previewKnowledgeId) applyPreviewKnowledge(props.previewKnowledgeId);
await loadHistory();
} catch (error) {
ElMessage.error(errorMessage(error, "Agent 配置加载失败"));
} finally {
loading.value = false;
}
}
function applyRuntimeConfig(value: AgentRuntimeConfig) {
runtimeConfig.value = value;
Object.assign(runtimeForm, {
temperature: value.temperature,
topP: value.topP,
topK: value.topK,
presencePenalty: value.presencePenalty,
frequencyPenalty: value.frequencyPenalty,
maxToken: value.maxToken,
});
}
function applyDebugModelDefaults(modelId?: number) {
const model = models.value.find((item) => item.id === modelId);
if (!model) return;
Object.assign(agentForm, {
temperature: model.temperature ?? null,
topP: model.topP ?? null,
topK: model.topK ?? null,
presencePenalty: model.presencePenalty ?? null,
frequencyPenalty: model.frequencyPenalty ?? null,
maxToken: model.maxToken ?? (model.enabled === 1 ? runtimeConfig.value?.maxToken : null) ?? 8192,
});
}
async function saveRuntimeConfig() {
if (!runtimeConfig.value?.modelId) return ElMessage.error("请先在模型管理中启用一个正式模型");
if (!runtimeForm.maxToken) return ElMessage.warning("最大输出长度不能为空");
runtimeSaving.value = true;
try {
const saved = await api.saveAgentRuntimeConfig({
temperature: runtimeForm.temperature,
topP: runtimeForm.topP,
topK: runtimeForm.topK,
presencePenalty: runtimeForm.presencePenalty,
frequencyPenalty: runtimeForm.frequencyPenalty,
maxToken: runtimeForm.maxToken,
});
applyRuntimeConfig(saved);
const model = models.value.find((item) => item.id === saved.modelId);
if (model) Object.assign(model, runtimeForm);
if (agentForm.modelId === saved.modelId) applyDebugModelDefaults(saved.modelId ?? undefined);
ElMessage.success("正式运行参数已保存,将从用户端下一条消息开始生效");
} catch (error) {
ElMessage.error(errorMessage(error, "正式运行参数保存失败"));
} finally {
runtimeSaving.value = false;
}
}
function applyPreviewKnowledge(knowledgeId: number) {
if (knowledge.value.some((item) => item.id === knowledgeId)) {
agentForm.knowledgeIds = [knowledgeId];
activeConfigTab.value = "debug";
}
emit("consumedPreview");
}
function applyPrompt(value: PromptDetail) {
prompt.value = value;
promptContent.value = value.promptContent;
savedPromptContent.value = value.promptContent;
}
async function savePrompt() {
if (!promptContent.value.trim()) return ElMessage.warning("主提示词不能为空");
promptSaving.value = true;
try {
applyPrompt(await api.savePrompt(promptContent.value.trim()));
ElMessage.success("主提示词已保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "主提示词保存失败"));
} finally {
promptSaving.value = false;
}
}
async function resetPrompt() {
try {
await ElMessageBox.confirm(
"恢复默认提示词会新增一个版本,现有历史不会被删除。确认继续?",
"恢复默认提示词",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.resetPrompt());
ElMessage.success("已恢复默认提示词并保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "恢复默认提示词失败"));
} finally {
promptSaving.value = false;
}
}
async function loadHistory(page = historyPager.page, pageSize = historyPager.pageSize) {
historyLoading.value = true;
try {
const result = await api.promptHistory({ page, pageSize });
history.value = result.items;
Object.assign(historyPager, { page: result.page, pageSize: result.pageSize, total: result.total });
} catch (error) {
ElMessage.error(errorMessage(error, "提示词历史加载失败"));
} finally {
historyLoading.value = false;
}
}
async function openHistory(item: PromptHistoryItem) {
historyDetailOpen.value = true;
historyDetailLoading.value = true;
selectedHistory.value = null;
try {
selectedHistory.value = await api.promptHistoryDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "提示词版本加载失败"));
historyDetailOpen.value = false;
} finally {
historyDetailLoading.value = false;
}
}
async function restoreHistory(id: number) {
try {
await ElMessageBox.confirm(
`确认把版本 #${id} 恢复为当前主提示词?系统会新增一个恢复版本,不会覆盖历史。`,
"恢复历史版本",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.restorePrompt(id));
historyDetailOpen.value = false;
activeConfigTab.value = "prompt";
ElMessage.success(`版本 #${id} 已恢复为当前主提示词`);
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "历史版本恢复失败"));
} finally {
promptSaving.value = false;
}
}
async function restoreSelectedHistory() {
if (selectedHistory.value?.id) await restoreHistory(selectedHistory.value.id);
}
async function debugAgent() {
if (!agentForm.modelId) return ElMessage.error("请选择调试模型");
if (!agentForm.question.trim()) return ElMessage.error("请输入调试问题");
if (!promptContent.value.trim()) return ElMessage.error("主提示词不能为空");
const question = agentForm.question.trim();
agentPreviewMessages.value.push({ role: "user", content: question });
agentForm.question = "";
agentDebugging.value = true;
try {
const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds,
question,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
});
if (!result.ok) {
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
return ElMessage.error(result.message);
}
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message);
} catch (error) {
const message = errorMessage(error, "Agent 调试失败");
agentPreviewMessages.value.push({ role: "assistant", content: message });
ElMessage.error(message);
} finally {
agentDebugging.value = false;
}
}
function clearAgentPreview() {
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
agentDebugTrace.value = [];
}
function agentMessageParts(content: string) {
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
if (!matched) return { reasoning: "", answer: content };
return {
reasoning: matched[1].trim(),
answer: content.replace(matched[0], "").trim() || "模型未返回正式回答。",
};
}
function changeTypeLabel(value?: string) {
return ({ save: "手动保存", reset: "恢复默认", restore: "历史恢复", default: "系统默认" } as Record<string, string>)[value || ""] || "保存";
}
function changeTypeTag(value?: string) {
if (value === "reset") return "warning";
if (value === "restore") return "success";
return "info";
}
function formatDate(value?: string | null) {
if (!value) return "尚未保存";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
</script>
<template>
<div class="page-head agent-page-head">
<div>
<h2>Agent 管理</h2>
<p>维护主提示词用户端正式生成参数和历史版本并验证真实检索与回答效果</p>
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
</div>
</div>
<section v-loading="loading" class="agent-workbench agent-workbench-v2">
<div class="agent-config-panel agent-control-panel">
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
<el-tab-pane label="主提示词" name="prompt">
<div class="agent-section-intro">
<div><h3>当前主提示词</h3><p>保存恢复默认或恢复历史版本时都会新增版本历史记录不会被覆盖</p></div>
<el-tag v-if="promptDirty" type="warning">有未保存修改</el-tag>
</div>
<el-input v-model="promptContent" class="agent-prompt-editor" type="textarea" :rows="18" resize="vertical" placeholder="请输入 Agent 主提示词" />
<div class="agent-editor-meta"><span>{{ promptContent.length }} 字符</span><span>调试会立即使用编辑框中的内容</span></div>
<div class="actions agent-primary-actions">
<el-button type="primary" :loading="promptSaving" :disabled="!promptDirty" @click="savePrompt">保存为新版本</el-button>
<el-button :loading="promptSaving" @click="resetPrompt">恢复默认提示词</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="正式运行配置" name="runtime">
<div class="agent-section-intro">
<div><h3>用户端正式运行参数</h3><p>保存后从用户端下一条消息开始生效当前正式模型{{ runtimeConfig?.modelName || '未启用模型' }}</p></div>
<el-button type="primary" :loading="runtimeSaving" :disabled="!runtimeConfig?.modelId" @click="saveRuntimeConfig">保存正式参数</el-button>
</div>
<el-alert v-if="!runtimeConfig?.modelId" title="当前没有已启用模型,请先到模型管理中启用一个正式模型。" type="warning" :closable="false" show-icon />
<el-alert v-else title="最大输出长度默认提升为 8192 Token设置过低会导致长回答在完成前被模型截断。实际可用上限仍受模型供应商限制。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="agent-runtime-form">
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>正式生成参数</h4><span>用户端正式对话使用</span></div>
<AgentGenerationParameters
v-model:temperature="runtimeForm.temperature"
v-model:top-p="runtimeForm.topP"
v-model:top-k="runtimeForm.topK"
v-model:presence-penalty="runtimeForm.presencePenalty"
v-model:frequency-penalty="runtimeForm.frequencyPenalty"
v-model:max-token="runtimeForm.maxToken"
:disabled="!runtimeConfig?.modelId"
/>
</section>
</el-form>
</el-tab-pane>
<el-tab-pane label="调试配置" name="debug">
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览不会修改模型和知识库的正式状态</p></div></div>
<el-form label-position="top">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型" @change="applyDebugModelDefaults">
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
</el-select>
</el-form-item>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>高级生成参数</h4><span>仅影响本次后台调试</span></div>
<AgentGenerationParameters
v-model:temperature="agentForm.temperature"
v-model:top-p="agentForm.topP"
v-model:top-k="agentForm.topK"
v-model:presence-penalty="agentForm.presencePenalty"
v-model:frequency-penalty="agentForm.frequencyPenalty"
v-model:max-token="agentForm.maxToken"
/>
</section>
</el-form>
</el-tab-pane>
<el-tab-pane label="历史版本" name="history">
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本恢复操作会作为一个新版本记录</p></div></div>
<div v-loading="historyLoading" class="prompt-history-list">
<el-empty v-if="history.length === 0" description="还没有已保存的提示词版本" :image-size="64" />
<article v-for="item in history" :key="item.id" class="prompt-history-item" :class="{ current: item.isCurrent }">
<div class="prompt-history-head">
<strong>版本 #{{ item.id }}</strong>
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
</div>
<p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot">
<span>{{ item.charCount }} 字符<span v-if="item.sourcePromptId"> · 来源 #{{ item.sourcePromptId }}</span></span>
<div><el-button link type="primary" @click="openHistory(item)">查看完整内容</el-button><el-button v-if="!item.isCurrent" link type="primary" @click="restoreHistory(item.id)">恢复此版本</el-button></div>
</div>
</article>
</div>
<AdminPagination :page="historyPager.page" :page-size="historyPager.pageSize" :total="historyPager.total" @change="loadHistory" />
</el-tab-pane>
</el-tabs>
</div>
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }}</small></div>
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link @click="clearAgentPreview">清空</el-button></div>
</div>
<div class="agent-preview-chat">
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
<div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'">
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking"><summary>思考过程</summary><pre>{{ agentMessageParts(message.content).reasoning }}</pre></details>
<pre>{{ agentMessageParts(message.content).answer }}</pre>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking"><div class="agent-preview-avatar">AI</div><div class="agent-preview-bubble"><strong>Agent</strong><pre>正在思考中...</pre></div></article>
</div>
<div class="agent-preview-composer">
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" placeholder="向 Agent 发送测试问题Enter 发送Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
</div>
</aside>
<aside class="agent-trace-panel agent-trace-wide">
<div class="agent-preview-head"><div><h3>检索追踪</h3><small>按调用顺序查看请求响应耗时与错误</small></div><span>本轮 {{ agentDebugTrace.length }} Tool 调用</span></div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
<div v-loading="historyDetailLoading" class="prompt-history-detail">
<template v-if="selectedHistory">
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
<pre>{{ selectedHistory.promptContent }}</pre>
</template>
</div>
<template #footer><el-button @click="historyDetailOpen = false">关闭</el-button><el-button v-if="selectedHistory?.id !== prompt?.id" type="primary" :loading="promptSaving" @click="restoreSelectedHistory">恢复为当前主提示词</el-button></template>
</el-dialog>
</template>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../services/api";
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versions: KnowledgeVersion[];
versionId: number | null;
}>();
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
const loading = ref(false);
const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null);
const activeSectionId = ref<number | null>(null);
let requestSequence = 0;
const activeSection = computed(() => (
content.value?.sections.find((section) => section.id === activeSectionId.value)
?? content.value?.sections[0]
?? null
));
const characterCount = computed(() => (
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
));
watch(() => props.versionId, loadVersion, { immediate: true });
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
content.value = null;
activeSectionId.value = null;
error.value = "";
if (!versionId) return;
loading.value = true;
try {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = result.sections[0]?.id ?? null;
} catch (loadError) {
if (sequence !== requestSequence) return;
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
function selectVersion(value: number) {
emit("update:versionId", value);
}
function versionStatus(version: KnowledgeVersion) {
const labels: Record<string, string> = {
published: "正式版本",
pending_review: "待审核",
approved: "待发布",
rejected: "已拒绝",
superseded: "历史版本",
};
return labels[version.status] ?? version.status;
}
</script>
<template>
<section class="content-preview" v-loading="loading">
<header class="content-preview-toolbar">
<div>
<h4>知识库内容预览</h4>
<p>按同步后的章节结构查看实际进入检索链路的内容</p>
</div>
<el-select
:model-value="versionId"
class="content-version-select"
placeholder="选择预览版本"
:disabled="versions.length === 0"
@update:model-value="selectVersion"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="`V${version.versionNo} · ${versionStatus(version)}`"
:value="version.id"
/>
</el-select>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
</el-alert>
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
<template v-else-if="content && activeSection">
<div class="content-preview-summary">
<span>V{{ content.version.versionNo }}</span>
<span>{{ content.sections.length }} 个章节</span>
<span>{{ content.cards.length }} 张知识卡</span>
<span> {{ characterCount.toLocaleString() }} </span>
</div>
<div class="content-preview-layout">
<aside class="content-outline" aria-label="章节目录">
<strong>章节目录</strong>
<button
v-for="section in content.sections"
:key="section.id"
type="button"
:class="{ active: section.id === activeSection.id }"
:title="section.title"
@click="activeSectionId = section.id"
>
<span>{{ section.sectionKey }}</span>
<em>{{ section.title }}</em>
</button>
</aside>
<article class="content-article">
<div class="content-article-head">
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre>{{ activeSection.content }}</pre>
</article>
</div>
<el-collapse class="content-card-panel">
<el-collapse-item :title="`查看知识卡(${content.cards.length}`" name="cards">
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
<el-table v-else :data="content.cards" max-height="360">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
<el-table-column prop="riskLevel" label="风险" width="100" />
<el-table-column prop="reviewStatus" label="审核" width="110" />
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</section>
</template>
<style scoped>
.content-preview { min-height: 360px; }
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
.content-version-select { width: 220px; flex: 0 0 220px; }
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
.content-outline button:hover { background: #eaf3ef; }
.content-outline button.active { background: #dcefe7; color: #176746; }
.content-outline button span { font-size: 11px; color: #7c8d87; }
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.content-article { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 24px 28px; }
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
.content-article-head small { color: #26815f; }
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
.content-card-panel { margin-top: 16px; }
@media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
.content-version-select { width: 100%; flex-basis: auto; }
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
.content-outline > strong { display: none; }
.content-outline button { min-width: 180px; width: 180px; }
.content-article { height: auto; max-height: 58vh; padding: 20px; }
.content-article-head { flex-direction: column; gap: 8px; }
}
</style>

View File

@@ -0,0 +1,539 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type {
KnowledgeDetail,
KnowledgeItem,
KnowledgeSyncJob,
KnowledgeVersion,
MigrationSummary,
} from "../types/api";
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
const rows = ref<KnowledgeItem[]>([]);
const migration = ref<MigrationSummary | null>(null);
const loading = ref(false);
const operatingId = ref<number | null>(null);
const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null);
const versions = ref<KnowledgeVersion[]>([]);
const jobs = ref<KnowledgeSyncJob[]>([]);
const previewVersionId = ref<number | null>(null);
const activeTab = ref("overview");
const selectedRows = ref<KnowledgeItem[]>([]);
const editOpen = ref(false);
const editSaving = ref(false);
const batchOpen = ref(false);
const batchSaving = ref(false);
const resolvingNode = ref(false);
const nodeResolved = ref(false);
const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const form = reactive({
name: "",
feishuSpaceId: "",
feishuNodeId: "",
knowledgeType: "general",
reviewMode: "manual",
status: 0,
remark: "",
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
onMounted(load);
async function load(page = pager.page, pageSize = pager.pageSize) {
loading.value = true;
try {
const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
page, pageSize,
});
rows.value = result.items;
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
if (!migration.value) migration.value = await api.migrationSummary();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
} finally {
loading.value = false;
}
}
function openEdit() {
if (!detail.value) return;
Object.assign(editForm, {
id: detail.value.knowledge.id,
name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType,
reviewMode: detail.value.knowledge.reviewMode,
});
editOpen.value = true;
}
async function saveMetadata() {
if (!editForm.name.trim()) {
ElMessage.warning("请输入后台知识库名称");
return;
}
editSaving.value = true;
try {
await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
});
ElMessage.success("知识库基本信息已更新");
editOpen.value = false;
await Promise.all([load(), refreshDetail(editForm.id)]);
} catch (error) {
ElMessage.error(errorMessage(error, "基本信息保存失败"));
} finally {
editSaving.value = false;
}
}
function openBatchEdit() {
if (!selectedRows.value.length) {
ElMessage.warning("请先勾选需要修改的知识库");
return;
}
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
batchOpen.value = true;
}
async function saveBatchMetadata() {
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
ElMessage.warning("请至少选择一个修改项");
return;
}
if (!await confirmAction(
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
"确认批量修改",
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
)) return;
batchSaving.value = true;
try {
const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined,
reviewMode: batchForm.reviewMode || undefined,
});
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false;
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "批量修改失败"));
} finally {
batchSaving.value = false;
}
}
async function handleMore(command: string, item: KnowledgeItem) {
try {
if (command === "detail") return await openDetail(item);
if (command === "preview") return emit("preview", item.id);
if (command === "versions") {
await openDetail(item);
if (detailOpen.value) activeTab.value = "versions";
return;
}
if (command === "archive") return await archive(item);
if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item);
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
}
}
async function restore(item: KnowledgeItem) {
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "active");
ElMessage.success("知识库已恢复,当前仍为关闭状态");
await load();
}
async function createKnowledge() {
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
return;
}
try {
await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
}
}
async function resolveNode() {
const nodeId = form.feishuNodeId.trim();
if (!nodeId) return ElMessage.warning("请输入 Node ID");
resolvingNode.value = true;
try {
const info = await api.resolveKnowledgeNode(nodeId);
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
ElMessage.success("已获取飞书知识库信息");
} catch (error) {
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
} finally { resolvingNode.value = false; }
}
async function openDetail(item: KnowledgeItem) {
detailOpen.value = true;
detail.value = null;
activeTab.value = "overview";
previewVersionId.value = null;
try {
const refreshedDetail = await refreshDetail(item.id);
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
} catch (error) {
detailOpen.value = false;
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
}
}
async function refreshDetail(id: number) {
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
]);
detail.value = nextDetail;
versions.value = nextVersions;
jobs.value = nextJobs;
return nextDetail;
}
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
operatingId.value = item.id;
try {
const result = await api.syncKnowledge(item.id, mode);
const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本"
: result.status === "published"
? "同步、质检和自动发布已完成"
: "同步处理完成,已生成待审核版本";
ElMessage.success(message);
await load();
if (detailOpen.value) await refreshDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "知识库同步失败"));
} finally {
operatingId.value = null;
}
}
async function runMigration() {
if (!await confirmAction(
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
"开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return;
loading.value = true;
try {
const result = await api.runKnowledgeMigration();
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识迁移失败"));
} finally {
loading.value = false;
}
}
async function confirmManifest() {
if (!detail.value?.pendingVersion) return;
try {
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
ElMessage.success("知识库说明卡已确认");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "说明卡确认失败"));
}
}
async function review(action: "approve" | "reject") {
if (!detail.value?.pendingVersion) return;
let reason = "";
if (action === "reject") {
try {
const result = await ElMessageBox.prompt(
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
"拒绝版本",
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
);
reason = result.value;
} catch {
return;
}
}
try {
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本审核失败"));
}
}
async function publish(keepOpen: boolean) {
if (!detail.value?.pendingVersion) return;
if (!await confirmAction(
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`,
"发布知识版本",
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
ElMessage.success("正式版本已发布");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本发布失败"));
}
}
async function rollback(version: KnowledgeVersion) {
if (!detail.value) return;
if (!await confirmAction(
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
"回滚知识版本",
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
ElMessage.success("知识版本已回滚");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本回滚失败"));
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1;
if (!await confirmAction(
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
next ? "开放知识库" : "关闭知识库",
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
)) return false;
operatingId.value = item.id;
try {
await api.setKnowledgeOpen(item.id, next);
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
if (detailOpen.value && detail.value?.knowledge.id === item.id) await refreshDetail(item.id);
return true;
} catch (error) {
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
return false;
} finally {
operatingId.value = null;
}
}
async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "archived");
ElMessage.success("知识库已归档");
await load();
}
async function remove(item: KnowledgeItem) {
if (!await confirmAction(
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
"物理删除知识库",
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
)) return;
await api.deleteKnowledge(item.id);
ElMessage.success("知识库已删除");
await load();
}
async function showVersion(version: KnowledgeVersion) {
previewVersionId.value = version.id;
activeTab.value = "content";
}
function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
if (["pending_review", "processing", "pending"].includes(status)) return "warning";
return "info";
}
function typeLabel(value: string) {
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function confirmAction(
message: string,
title: string,
options: Parameters<typeof ElMessageBox.confirm>[2],
) {
try {
await ElMessageBox.confirm(message, title, options);
return true;
} catch {
return false;
}
}
</script>
<template>
<div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步审核和发布相互独立</p></div>
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
</div>
<section class="migration-overview">
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
<div><span>待审核</span><strong>{{ migration?.counts.pending_review ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section>
<section class="knowledge-work-panel">
<h3>通过 Node ID 快速新增</h3>
<div class="node-resolve-row">
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
</div>
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
<div v-if="nodeResolved" class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
<el-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
</el-select>
<el-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button>
</div>
</section>
<div class="knowledge-filters">
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div>
<div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" />
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail">
<div class="knowledge-detail-head">
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
</div>
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="overview">
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
</el-tab-pane>
<el-tab-pane label="内容预览" name="content" lazy>
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
</el-tab-pane>
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
</el-tabs>
</template>
</el-drawer>
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-form label-position="top">
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
</el-dialog>
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form">
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog>
</template>
<style scoped>
.knowledge-batch-bar,
.knowledge-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.knowledge-batch-bar {
min-height: 48px;
padding: 8px 0;
color: #66756f;
}
.knowledge-section-head h4 {
margin: 0;
}
.knowledge-status-cell {
display: flex;
align-items: center;
gap: 10px;
}
.knowledge-detail-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-edit-form {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<div class="table-row-actions"><slot /></div>
</template>

View File

@@ -0,0 +1,277 @@
export type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password";
export interface SystemSettingDefinition {
key: string;
label: string;
type: SystemSettingType;
defaultValue: SystemSettingValue;
min?: number;
max?: number;
placeholder?: string;
description: string;
}
export interface SystemSettingSection {
title: string;
description: string;
settings: SystemSettingDefinition[];
}
export const systemSettingSections: SystemSettingSection[] = [
{
title: "用户与登录",
description: "控制学员准入、默认资料、验证码和登录态有效时间。",
settings: [
{
key: "daily_chat_limit",
label: "默认每日聊天额度",
type: "number",
defaultValue: 100,
min: 0,
max: 100000,
description: "新建或导入学员未单独填写额度时使用。",
},
{
key: "access_token_expire_minutes",
label: "登录 Token 有效期(分钟)",
type: "number",
defaultValue: 43200,
min: 5,
max: 43200,
description: "用户端和管理端登录后的 Token 有效期。",
},
{
key: "sms_code_expire_minutes",
label: "验证码有效期(分钟)",
type: "number",
defaultValue: 5,
min: 1,
max: 60,
description: "短信验证码生成后的可使用时间。",
},
{
key: "mock_sms_enabled",
label: "使用 mock 短信",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,生产环境应关闭并接入真实短信。",
},
{
key: "mock_sms_code",
label: "mock 短信验证码",
type: "text",
defaultValue: "123456",
placeholder: "例如 123456",
description: "mock 短信开启时使用的固定验证码。",
},
{
key: "aliyun_sms_access_key_id",
label: "阿里云 AccessKey ID",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey ID",
description: "关闭 mock 短信后用于调用阿里云短信服务。",
},
{
key: "aliyun_sms_access_key_secret",
label: "阿里云 AccessKey Secret",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey Secret",
description: "敏感配置,建议使用最小权限 RAM 用户。",
},
{
key: "aliyun_sms_sign_name",
label: "阿里云短信签名",
type: "text",
defaultValue: "",
placeholder: "例如 某某课堂",
description: "需与阿里云短信控制台审核通过的签名一致。",
},
{
key: "aliyun_sms_template_code",
label: "阿里云模板 Code",
type: "text",
defaultValue: "",
placeholder: "例如 SMS_123456789",
description: "验证码短信模板 Code。",
},
{
key: "aliyun_sms_template_param_key",
label: "验证码变量名",
type: "text",
defaultValue: "code",
placeholder: "默认 code",
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
},
{
key: "aliyun_sms_endpoint",
label: "阿里云短信 Endpoint",
type: "text",
defaultValue: "dysmsapi.aliyuncs.com",
placeholder: "dysmsapi.aliyuncs.com",
description: "一般保持默认值即可。",
},
{
key: "default_user_name_prefix",
label: "默认用户名前缀",
type: "text",
defaultValue: "用户",
placeholder: "例如 用户",
description: "系统需要生成默认名称时使用的前缀。",
},
],
},
{
title: "AI 问答",
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
settings: [
{
key: "ai_timeout_seconds",
label: "AI 请求超时(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 300,
description: "真实模型请求超过该时间应判定为超时。",
},
{
key: "mock_model_enabled",
label: "使用 mock 大模型",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,真实模型验收前应关闭。",
},
{
key: "chat_context_message_count",
label: "上下文消息数",
type: "number",
defaultValue: 10,
min: 0,
max: 100,
description: "当前会话带入模型的最近历史消息条数;更早内容会滚动摘要,设为 0 将关闭会话记忆。修改后下一次提问立即生效。",
},
{
key: "show_reference_sources",
label: "用户端展示引用来源",
type: "switch",
defaultValue: true,
description: "后台始终记录引用;此项控制用户端是否展示。",
},
{
key: "chat_max_active_requests",
label: "问答最大并发数",
type: "number",
defaultValue: 2,
min: 1,
max: 1000,
description: "同一后端进程内同时进入飞书和模型生成链路的最大请求数。",
},
{
key: "chat_max_queue_size",
label: "问答最大排队数",
type: "number",
defaultValue: 20,
min: 0,
max: 10000,
description: "超过最大并发后允许等待的请求数量,超过后直接提示稍后再试。",
},
{
key: "chat_queue_timeout_seconds",
label: "问答排队超时(秒)",
type: "number",
defaultValue: 60,
min: 1,
max: 3600,
description: "请求在排队中超过该时间后自动结束并提示用户稍后再试。",
},
{
key: "chat_active_lease_seconds",
label: "问答执行租约(秒)",
type: "number",
defaultValue: 900,
min: 60,
max: 86400,
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
},
],
},
{
title: "外部服务",
description: "控制飞书搜索接口、飞书直读超时和重试策略。",
settings: [
{
key: "feishu_search_url",
label: "飞书检索服务地址",
type: "text",
defaultValue: "",
placeholder: "例如 https://example.com/search",
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
},
{
key: "feishu_app_id",
label: "飞书应用 AppID",
type: "text",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppID",
description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
},
{
key: "feishu_app_secret",
label: "飞书应用 AppSecret",
type: "password",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppSecret",
description: "用于后端获取飞书 tenant_access_token保存后立即生效。",
},
{
key: "feishu_timeout_seconds",
label: "飞书请求超时(秒)",
type: "number",
defaultValue: 20,
min: 1,
max: 120,
description: "飞书相关接口调用超过该时间应判定为超时。",
},
{
key: "feishu_retry_count",
label: "飞书检索重试次数",
type: "number",
defaultValue: 2,
min: 0,
max: 10,
description: "飞书检索失败时的最大重试次数。",
},
],
},
{
title: "系统开关",
description: "控制系统级展示和运行策略,不包含密钥类配置。",
settings: [
{
key: "show_operation_log_detail",
label: "展示操作日志详情",
type: "switch",
defaultValue: true,
description: "开启后后台审计页可展示更完整的操作上下文。",
},
{
key: "chat_export_enabled",
label: "允许导出聊天记录",
type: "switch",
defaultValue: true,
description: "关闭后后台不应提供聊天记录导出能力。",
},
{
key: "maintenance_mode",
label: "维护模式",
type: "switch",
defaultValue: false,
description: "预留给后续停机维护提示和访问控制使用。",
},
],
},
];
export const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);

View File

@@ -0,0 +1,101 @@
import {
ElAlert,
ElButton,
ElCollapse,
ElCollapseItem,
ElDatePicker,
ElDivider,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElPagination,
ElProgress,
ElSelect,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
vLoading,
} from "element-plus";
import "element-plus/theme-chalk/base.css";
import "element-plus/theme-chalk/el-alert.css";
import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-collapse.css";
import "element-plus/theme-chalk/el-date-picker.css";
import "element-plus/theme-chalk/el-divider.css";
import "element-plus/theme-chalk/el-dialog.css";
import "element-plus/theme-chalk/el-drawer.css";
import "element-plus/theme-chalk/el-dropdown.css";
import "element-plus/theme-chalk/el-empty.css";
import "element-plus/theme-chalk/el-form.css";
import "element-plus/theme-chalk/el-input.css";
import "element-plus/theme-chalk/el-input-number.css";
import "element-plus/theme-chalk/el-loading.css";
import "element-plus/theme-chalk/el-message.css";
import "element-plus/theme-chalk/el-message-box.css";
import "element-plus/theme-chalk/el-option.css";
import "element-plus/theme-chalk/el-overlay.css";
import "element-plus/theme-chalk/el-pagination.css";
import "element-plus/theme-chalk/el-progress.css";
import "element-plus/theme-chalk/el-popper.css";
import "element-plus/theme-chalk/el-scrollbar.css";
import "element-plus/theme-chalk/el-select.css";
import "element-plus/theme-chalk/el-switch.css";
import "element-plus/theme-chalk/el-table.css";
import "element-plus/theme-chalk/el-tabs.css";
import "element-plus/theme-chalk/el-tag.css";
import "element-plus/theme-chalk/el-tooltip.css";
import { createApp } from "vue";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue";
import "./styles.css";
const app = createApp(App);
app.config.globalProperties.$ELEMENT = { locale: zhCn };
[
ElAlert,
ElButton,
ElCollapse,
ElCollapseItem,
ElDatePicker,
ElDivider,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElPagination,
ElProgress,
ElSelect,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
].forEach((component) => {
app.use(component);
});
app.directive("loading", vLoading);
app.mount("#app");

View File

@@ -0,0 +1,223 @@
import type {
AdminProfile,
AdminUser,
AgentDebugResult,
AgentGenerationConfig,
AgentRuntimeConfig,
AiLogRecord,
ApiResponse,
ChatDetail,
ChatRecord,
ChatRecordQuery,
DashboardStats,
KnowledgeItem,
KnowledgeDetail,
KnowledgeSyncJob,
KnowledgeVersion,
KnowledgeVersionContent,
MigrationSummary,
KnowledgeBatchUpdateResult,
RetrievalLogItem,
AttentionItem,
ModelItem,
SystemConfigItem,
UserImportResult,
PageResult,
PromptDetail,
PromptHistoryItem,
} from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
const TOKEN_KEY = "ai-kb-admin-token";
export function getToken() {
return window.localStorage.getItem(TOKEN_KEY);
}
export function saveToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
const body = await readApiResponse<T>(response);
if (!response.ok || body.code !== 0) {
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
}
return body.data;
}
async function readApiResponse<T>(response: Response): Promise<ApiResponse<T> & { detail?: string }> {
const text = await response.text();
if (!text.trim()) {
throw new Error(response.ok ? "接口返回为空" : `服务暂时不可用(${response.status}`);
}
try {
return JSON.parse(text) as ApiResponse<T> & { detail?: string };
} catch {
throw new Error(response.ok ? "接口返回格式不正确" : `服务暂时不可用(${response.status}`);
}
}
async function download(path: string, filename: string) {
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { headers });
if (!response.ok) throw new Error("导出失败");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
window.URL.revokeObjectURL(url);
}
async function upload<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
const body = await readApiResponse<T>(response);
if (!response.ok || body.code !== 0) {
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
}
return body.data;
}
function queryString(query: object) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
});
const text = params.toString();
return text ? `?${text}` : "";
}
export const api = {
login: (username: string, password: string) =>
request<{ token: string; admin: AdminProfile }>("/admin/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
profile: () => request<AdminProfile>("/admin/profile"),
dashboard: (start?: string, end?: string) => {
const params = new URLSearchParams();
if (start) params.set("start", start);
if (end) params.set("end", end);
const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
},
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
downloadStudentTemplate: () => download("/admin/user/import/template", "学员导入模板.xlsx"),
importUsersExcel: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return upload<UserImportResult>("/admin/user/import/excel", formData);
},
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
importKnowledgeFromSpace: (body: { node_token: string }) =>
request<{ imported: number; skipped: number; id: number; name: string }>(
"/admin/knowledge/import",
{ method: "POST", body: JSON.stringify(body) },
),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
syncKnowledge: (id: number, mode: "review" | "replace") =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
confirmKnowledgeManifest: (id: number, versionId: number) =>
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
setKnowledgeOpen: (id: number, open: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
request<KnowledgeItem>(`/admin/knowledge/${id}/lifecycle`, { method: "PUT", body: JSON.stringify({ status }) }),
migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"),
runKnowledgeMigration: (knowledgeId?: number) =>
request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }),
prompt: () => request<PromptDetail>("/admin/prompt"),
savePrompt: (promptContent: string) =>
request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
resetPrompt: () => request<PromptDetail>("/admin/prompt/reset", { method: "POST", body: "{}" }),
promptHistory: (query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
debugAgent: (payload: Record<string, unknown>) =>
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
updateModel: (id: number, payload: Record<string, unknown>) =>
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
deleteModel: (modelId: number) =>
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
testModel: (modelId: number) =>
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
method: "POST",
body: "{}",
}),
configs: () => request<SystemConfigItem[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) =>
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
updateAttention: (id: number, status: string, note?: string) =>
request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }),
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
clearFeishuCache: () =>
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,324 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface AdminProfile {
id: number;
username: string;
name: string;
status: number;
}
export interface DashboardStats {
userCount: number;
sessionCount: number;
messageCount: number;
aiRequestCount: number;
knowledgeCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
}
export interface PromptDetail {
id: number | null;
promptContent: string;
changeType: "default" | "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName?: string | null;
updatedAt?: string | null;
charCount: number;
}
export interface PromptHistoryItem {
id: number;
preview: string;
charCount: number;
changeType: "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName: string;
updatedAt: string;
isCurrent: boolean;
}
export interface AdminUser {
id: number;
phone: string;
name: string;
nickname?: string | null;
status: number;
dailyChatLimit: number;
dailyChatUsed: number;
expiredAt?: string | null;
lastLoginAt?: string | null;
createdAt?: string | null;
}
export interface UserImportFailure {
row: number;
phone: string;
reason: string;
}
export interface UserImportResult {
created: number;
updated: number;
failed: number;
failures: UserImportFailure[];
}
export interface SystemConfigItem {
id: number;
configKey: string;
configValue: string;
description?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeItem {
id: number;
name: string;
feishuSpaceId: string;
feishuNodeId: string;
status: number;
remark?: string | null;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general";
reviewMode: "manual" | "automatic";
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
sourceError?: string | null;
manifestConfirmed: boolean;
currentVersionId?: number | null;
pendingVersionId?: number | null;
lastSyncedAt?: string | null;
lastPublishedAt?: string | null;
migrationStatus: string;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ModelItem {
id: number;
provider: string;
displayName?: string | null;
apiType: string;
modelName: string;
baseUrl?: string | null;
apiUrl: string;
apiKeyMasked: string;
authType: string;
apiVersion?: string | null;
temperature?: number | null;
topP?: number | null;
topK?: number | null;
presencePenalty?: number | null;
frequencyPenalty?: number | null;
maxToken?: number | null;
contextWindow?: number | null;
streamEnabled: number;
responseFormat?: string | null;
extraParams?: string | null;
remark?: string | null;
timeoutSecond: number;
enabled: number;
}
export interface AgentGenerationConfig {
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number;
}
export interface AgentRuntimeConfig extends AgentGenerationConfig {
modelId: number | null;
modelName: string | null;
}
export interface AgentDebugResult {
ok: boolean;
message: string;
answer: string;
prompt: string;
modelName?: string;
retrieveCount?: number;
knowledgeIds?: string;
retrievalTrace?: Record<string, unknown>[];
retrievalLogId?: number;
}
export interface KnowledgeVersion {
id: number;
knowledgeId: number;
snapshotId: number;
versionNo: number;
status: string;
qualityStatus: string;
changeSummary?: Record<string, unknown> | null;
qualityReport?: Record<string, unknown> | null;
processingRuleVersion: string;
generationModel?: string | null;
createdAt: string;
publishedAt?: string | null;
}
export interface KnowledgeManifest {
id: number;
versionId: number;
purpose: string;
applicableQuestions: string;
inapplicableQuestions: string;
coreTopics: string;
entities?: string | null;
boundaries?: string | null;
changeLevel: string;
confirmed: boolean;
}
export interface KnowledgeDetail {
knowledge: KnowledgeItem;
currentVersion?: KnowledgeVersion | null;
pendingVersion?: KnowledgeVersion | null;
manifest?: KnowledgeManifest | null;
}
export interface KnowledgeVersionContent {
version: KnowledgeVersion;
manifest?: KnowledgeManifest | null;
sections: Array<{ id: number; sectionKey: string; title: string; content: string; sourceStart: number; sourceEnd: number; contentHash: string }>;
cards: Array<Record<string, unknown>>;
}
export interface KnowledgeSyncJob {
id: number;
knowledgeId: number;
mode: string;
status: string;
stage: string;
progress: number;
sourceError?: string | null;
resultSummary?: Record<string, unknown> | null;
createdAt: string;
finishedAt?: string | null;
}
export interface MigrationSummary {
total: number;
counts: Record<string, number>;
}
export interface KnowledgeBatchUpdateResult {
total: number;
success: number;
failed: number;
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
}
export interface RetrievalLogItem {
id: number;
sessionId?: number | null;
question: string;
knowledgeCalled: boolean;
selectedKnowledgeIds?: string | null;
queryTerms?: string[] | null;
status: string;
toolCostMs?: number | null;
totalCostMs?: number | null;
attentionCreated: boolean;
createdAt: string;
}
export interface AttentionItem {
id: number;
sessionId: number;
messageId: number;
userId: number;
triggerMessage: string;
problemSummary: string;
triggerReason: string;
priority: string;
status: string;
handlerNote?: string | null;
createdAt: string;
}
export interface ChatRecord {
id: number;
userId: number;
userPhone: string;
userName: string;
title: string;
messageCount: number;
lastMessageAt?: string | null;
updatedAt: string;
}
export interface ChatMessageRecord {
id: number;
sessionId: number;
userId: number;
role: "user" | "assistant";
content: string;
messageStatus: string;
tokenInput?: number | null;
tokenOutput?: number | null;
responseTimeMs?: number | null;
modelId?: number | null;
createdAt: string;
}
export interface AiLogRecord {
id: number;
sessionId?: number | null;
messageId?: number | null;
userId?: number | null;
modelName?: string | null;
knowledgeIds?: string | null;
retrieveCount: number;
retrievedChunks: RetrievedKnowledgeChunk[];
inputToken?: number | null;
outputToken?: number | null;
totalToken?: number | null;
costMs?: number | null;
status: string;
errorMessage?: string | null;
prompt?: string | null;
createdAt: string;
}
export interface RetrievedKnowledgeChunk {
index: number;
knowledgeId?: number | null;
knowledgeName: string;
title: string;
content: string;
sourceUrl?: string | null;
}
export interface ChatDetail {
session: ChatRecord;
messages: ChatMessageRecord[];
aiLogs: AiLogRecord[];
}
export interface ChatRecordQuery {
keyword?: string;
userId?: number | null;
status?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,40 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
base: "/login/",
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
if (id.includes("element-plus/es/components/table")) return "el-table";
if (
id.includes("/vue/") ||
id.includes("@vue/") ||
id.includes("@vueuse") ||
id.includes("async-validator") ||
id.includes("@popperjs") ||
id.includes("@floating-ui") ||
id.includes("dayjs")
) {
return "vendor";
}
return undefined;
},
},
},
},
server: {
allowedHosts: ["qa.huiyushuyuan.cn"],
host: "0.0.0.0",
port: 5174,
proxy: {
"/api": {
target: "http://backend:8100",
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@@ -0,0 +1,47 @@
APP_NAME=AI Knowledge Base V2 Backend
APP_ENV=local
DEBUG=true
API_PREFIX=/api
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL=
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
AUTO_CREATE_TABLES=false
JWT_SECRET_KEY=change-me-in-production
CONFIG_ENCRYPTION_KEY=
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=43200
MOCK_SMS_ENABLED=true
MOCK_SMS_CODE=123456
SMS_CODE_EXPIRE_MINUTES=5
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
MOCK_RAG_ENABLED=false
MOCK_MODEL_ENABLED=true
FEISHU_MOCK_ENABLED=false
FEISHU_SEARCH_URL=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_TIMEOUT_SECONDS=20
FEISHU_RETRY_COUNT=2
DEFAULT_DAILY_CHAT_LIMIT=100
DEFAULT_USER_NAME_PREFIX=用户
CHAT_MAX_ACTIVE_REQUESTS=2
CHAT_MAX_QUEUE_SIZE=20
CHAT_QUEUE_TIMEOUT_SECONDS=60
CHAT_ACTIVE_LEASE_SECONDS=900
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8100
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"]

View File

@@ -0,0 +1,52 @@
# 大本营答疑助手后端
这是大本营答疑助手的后端服务目录当前处于阶段二第一批建设后端基础工程、核心数据模型、mock 登录能力。
## 技术栈
- FastAPI
- SQLAlchemy
- Alembic
- MySQL 8.x
- Pydantic Settings
- PyJWT
## 当前已包含
- 应用入口和健康检查
- 统一 JSON 响应结构
- 配置读取和 `.env` 示例
- SQLAlchemy 数据库连接
- Alembic 迁移骨架
- 核心 `sys_*` 数据模型
- mock 短信验证码
- 用户手机号验证码登录
- 用户 Token 鉴权
- 当前用户信息接口
## 本地启动
方式一:使用 V2 统一编排。
```bash
cd ai_knowledge_base_v2
docker compose -f docker-compose.dev.yml up --build
```
方式二:本地 Python 直接启动。
```bash
cd ai_knowledge_base_v2/apps/backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --host 127.0.0.1 --port 8100
```
默认短信验证码为 `.env` 中的 `MOCK_SMS_CODE`,默认值是 `123456`
## 下一步
- 补管理员登录接口。
- 接入 Redis 替换内存短信验证码和 Token 黑名单。

View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_user",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("nickname", sa.String(length=50), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("daily_chat_limit", sa.Integer(), nullable=False),
sa.Column("daily_chat_used", sa.Integer(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_phone", "sys_user", ["phone"], unique=True)
op.create_table(
"sys_role",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_role_code", "sys_role", ["code"], unique=True)
op.create_table(
"sys_admin",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("password", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("role_id", sa.BigInteger(), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["role_id"], ["sys_role.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_admin_username", "sys_admin", ["username"], unique=True)
op.create_table(
"sys_knowledge",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("feishu_space_id", sa.String(length=100), nullable=False),
sa.Column("feishu_node_id", sa.String(length=100), nullable=False),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_user_kb",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_kb_user_id", "sys_user_kb", ["user_id"])
op.create_index("ix_sys_user_kb_knowledge_id", "sys_user_kb", ["knowledge_id"])
op.create_table(
"sys_model",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_url", sa.String(length=255), nullable=False),
sa.Column("api_key", sa.Text(), nullable=False),
sa.Column("temperature", sa.Numeric(4, 2), nullable=True),
sa.Column("max_token", sa.Integer(), nullable=True),
sa.Column("timeout_second", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_prompt",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("prompt_content", sa.Text(), nullable=False),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_system_config",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("config_key", sa.String(length=100), nullable=False),
sa.Column("config_value", sa.Text(), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_system_config_config_key", "sys_system_config", ["config_key"], unique=True)
op.create_table(
"sys_chat_session",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(length=100), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=False),
sa.Column("last_message_at", sa.DateTime(), nullable=True),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_session_user_id", "sys_chat_session", ["user_id"])
op.create_table(
"sys_chat_message",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("message_status", sa.String(length=20), nullable=False),
sa.Column("token_input", sa.Integer(), nullable=True),
sa.Column("token_output", sa.Integer(), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("model_id", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["model_id"], ["sys_model.id"]),
sa.ForeignKeyConstraint(["session_id"], ["sys_chat_session.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_message_session_id", "sys_chat_message", ["session_id"])
op.create_index("ix_sys_chat_message_user_id", "sys_chat_message", ["user_id"])
op.create_table(
"sys_ai_request_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=True),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),
sa.Column("prompt", sa.Text(), nullable=True),
sa.Column("knowledge_ids", sa.String(length=500), nullable=True),
sa.Column("retrieve_count", sa.Integer(), nullable=False),
sa.Column("input_token", sa.Integer(), nullable=True),
sa.Column("output_token", sa.Integer(), nullable=True),
sa.Column("total_token", sa.Integer(), nullable=True),
sa.Column("cost_ms", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_ai_request_log_session_id", "sys_ai_request_log", ["session_id"])
op.create_index("ix_sys_ai_request_log_message_id", "sys_ai_request_log", ["message_id"])
op.create_index("ix_sys_ai_request_log_user_id", "sys_ai_request_log", ["user_id"])
op.create_table(
"sys_operation_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("admin_id", sa.BigInteger(), nullable=True),
sa.Column("module", sa.String(length=50), nullable=False),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("target_id", sa.BigInteger(), nullable=True),
sa.Column("before_json", sa.Text(), nullable=True),
sa.Column("after_json", sa.Text(), nullable=True),
sa.Column("ip", sa.String(length=50), nullable=True),
sa.Column("result", sa.String(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_operation_log_admin_id", "sys_operation_log", ["admin_id"])
def downgrade() -> None:
op.drop_index("ix_sys_operation_log_admin_id", table_name="sys_operation_log")
op.drop_table("sys_operation_log")
op.drop_index("ix_sys_ai_request_log_user_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_message_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_session_id", table_name="sys_ai_request_log")
op.drop_table("sys_ai_request_log")
op.drop_index("ix_sys_chat_message_user_id", table_name="sys_chat_message")
op.drop_index("ix_sys_chat_message_session_id", table_name="sys_chat_message")
op.drop_table("sys_chat_message")
op.drop_index("ix_sys_chat_session_user_id", table_name="sys_chat_session")
op.drop_table("sys_chat_session")
op.drop_index("ix_sys_system_config_config_key", table_name="sys_system_config")
op.drop_table("sys_system_config")
op.drop_table("sys_prompt")
op.drop_table("sys_model")
op.drop_index("ix_sys_user_kb_knowledge_id", table_name="sys_user_kb")
op.drop_index("ix_sys_user_kb_user_id", table_name="sys_user_kb")
op.drop_table("sys_user_kb")
op.drop_table("sys_knowledge")
op.drop_index("ix_sys_admin_username", table_name="sys_admin")
op.drop_table("sys_admin")
op.drop_index("ix_sys_role_code", table_name="sys_role")
op.drop_table("sys_role")
op.drop_index("ix_sys_user_phone", table_name="sys_user")
op.drop_table("sys_user")

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0002_expand_model_config"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True))
op.add_column(
"sys_model",
sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"),
)
op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True))
op.add_column(
"sys_model",
sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"),
)
op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True))
op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True))
op.add_column(
"sys_model",
sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True))
op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True))
op.alter_column("sys_model", "api_type", server_default=None)
op.alter_column("sys_model", "auth_type", server_default=None)
op.alter_column("sys_model", "stream_enabled", server_default=None)
def downgrade() -> None:
op.drop_column("sys_model", "remark")
op.drop_column("sys_model", "extra_params")
op.drop_column("sys_model", "response_format")
op.drop_column("sys_model", "stream_enabled")
op.drop_column("sys_model", "context_window")
op.drop_column("sys_model", "frequency_penalty")
op.drop_column("sys_model", "presence_penalty")
op.drop_column("sys_model", "top_k")
op.drop_column("sys_model", "top_p")
op.drop_column("sys_model", "api_version")
op.drop_column("sys_model", "auth_type")
op.drop_column("sys_model", "base_url")
op.drop_column("sys_model", "api_type")
op.drop_column("sys_model", "display_name")

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0003_ai_log_retrieved_chunks"
down_revision = "0002_expand_model_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_ai_request_log", sa.Column("retrieved_chunks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_ai_request_log", "retrieved_chunks")

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0004_daily_chat_reset_date"
down_revision = "0003_ai_log_retrieved_chunks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_user", sa.Column("daily_chat_reset_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_user", "daily_chat_reset_date")

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0005_chat_session_summary"
down_revision = "0004_daily_chat_reset_date"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_chat_session", sa.Column("summary", sa.Text(), nullable=True))
op.add_column("sys_chat_session", sa.Column("summary_up_to_message_id", sa.BigInteger(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_chat_session", "summary_up_to_message_id")
op.drop_column("sys_chat_session", "summary")

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = "0006_expand_ai_request_log_text"
down_revision = "0005_chat_session_summary"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"sys_ai_request_log",
"prompt",
existing_type=sa.Text(),
type_=mysql.MEDIUMTEXT(),
existing_nullable=True,
)
op.alter_column(
"sys_ai_request_log",
"retrieved_chunks",
existing_type=sa.Text(),
type_=mysql.MEDIUMTEXT(),
existing_nullable=True,
)
def downgrade() -> None:
op.alter_column(
"sys_ai_request_log",
"retrieved_chunks",
existing_type=mysql.MEDIUMTEXT(),
type_=sa.Text(),
existing_nullable=True,
)
op.alter_column(
"sys_ai_request_log",
"prompt",
existing_type=mysql.MEDIUMTEXT(),
type_=sa.Text(),
existing_nullable=True,
)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = "0007_knowledge_agent_rebuild"
down_revision = "0006_expand_ai_request_log_text"
branch_labels = None
depends_on = None
def upgrade() -> None:
for column in [
sa.Column("source_title", sa.String(255), nullable=True),
sa.Column("knowledge_type", sa.String(30), nullable=False, server_default="general"),
sa.Column("review_mode", sa.String(30), nullable=False, server_default="manual"),
sa.Column("lifecycle_status", sa.String(30), nullable=False, server_default="active"),
sa.Column("source_status", sa.String(30), nullable=False, server_default="normal"),
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("manifest_confirmed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("current_version_id", sa.BigInteger(), nullable=True),
sa.Column("pending_version_id", sa.BigInteger(), nullable=True),
sa.Column("last_synced_at", sa.DateTime(), nullable=True),
sa.Column("last_published_at", sa.DateTime(), nullable=True),
sa.Column("migration_status", sa.String(30), nullable=False, server_default="pending"),
]:
op.add_column("sys_knowledge", column)
op.create_table(
"sys_knowledge_source_snapshot",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("sync_job_id", sa.BigInteger(), nullable=True),
sa.Column("source_title", sa.String(255), nullable=False),
sa.Column("source_content", mysql.LONGTEXT(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("source_identifier", sa.String(255), nullable=False),
sa.Column("fetched_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_source_snapshot", "knowledge_id", "sync_job_id", "content_hash")
op.create_table(
"sys_knowledge_version",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("snapshot_id", sa.BigInteger(), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("quality_status", sa.String(30), nullable=False, server_default="pending"),
sa.Column("change_summary", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("quality_report", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("processing_rule_version", sa.String(50), nullable=False),
sa.Column("generation_model", sa.String(100), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("published_by", sa.BigInteger(), nullable=True),
sa.Column("published_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["snapshot_id"], ["sys_knowledge_source_snapshot.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),
)
_indexes("sys_knowledge_version", "knowledge_id", "status")
op.create_table(
"sys_knowledge_manifest",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("purpose", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("core_topics", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("boundaries", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("change_level", sa.String(20), nullable=False, server_default="major"),
sa.Column("confirmed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("confirmed_by", sa.BigInteger(), nullable=True),
sa.Column("confirmed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_manifest", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_section",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_key", sa.String(64), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", mysql.LONGTEXT(), nullable=False),
sa.Column("source_start", sa.Integer(), nullable=False),
sa.Column("source_end", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_section", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_chunk",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("normalized_text", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("source_start", sa.Integer(), nullable=False),
sa.Column("source_end", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_chunk", "knowledge_id", "version_id", "section_id")
op.create_table(
"sys_knowledge_card",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("summary", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("core_conclusion", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("risk_level", sa.String(20), nullable=False, server_default="normal"),
sa.Column("source_range", sa.String(100), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("generation_model", sa.String(100), nullable=True),
sa.Column("generation_rule_version", sa.String(50), nullable=False),
sa.Column("review_status", sa.String(20), nullable=False, server_default="pending"),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_card", "knowledge_id", "version_id", "section_id")
_create_job_and_log_tables()
def _create_job_and_log_tables() -> None:
op.create_table(
"sys_knowledge_sync_job",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("mode", sa.String(30), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("stage", sa.String(50), nullable=False),
sa.Column("progress", sa.Integer(), nullable=False, server_default="0"),
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("result_summary", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("requested_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_sync_job", "knowledge_id", "status")
op.create_table(
"sys_knowledge_review_record",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("card_id", sa.BigInteger(), nullable=True),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("reason", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("reviewed_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["card_id"], ["sys_knowledge_card.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_review_record", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_publish_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("from_version_id", sa.BigInteger(), nullable=True),
sa.Column("to_version_id", sa.BigInteger(), nullable=True),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("keep_open", sa.Integer(), nullable=False, server_default="0"),
sa.Column("detail_json", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("operated_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_publish_log", "knowledge_id")
op.create_table(
"sys_knowledge_retrieval_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=True),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("question", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("catalog_snapshot", mysql.LONGTEXT(), nullable=False),
sa.Column("catalog_hash", sa.String(64), nullable=False),
sa.Column("safety_rule_version", sa.String(50), nullable=False),
sa.Column("knowledge_called", sa.Integer(), nullable=False, server_default="0"),
sa.Column("selected_knowledge_ids", sa.String(500), nullable=True),
sa.Column("query_terms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("tool_trace", mysql.LONGTEXT(), nullable=True),
sa.Column("final_section_ids", sa.String(500), nullable=True),
sa.Column("discarded_conflicts", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("final_answer", mysql.LONGTEXT(), nullable=True),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("tool_cost_ms", sa.Integer(), nullable=True),
sa.Column("total_cost_ms", sa.Integer(), nullable=True),
sa.Column("error_message", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("attention_created", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_retrieval_log", "session_id", "message_id", "user_id")
op.create_table(
"sys_knowledge_retrieval_candidate",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("lexical_score", sa.String(30), nullable=False),
sa.Column("rerank_score", sa.String(30), nullable=True),
sa.Column("selected", sa.Integer(), nullable=False, server_default="0"),
sa.Column("discard_reason", sa.String(255), nullable=True),
sa.ForeignKeyConstraint(["retrieval_log_id"], ["sys_knowledge_retrieval_log.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_retrieval_candidate", "retrieval_log_id", "knowledge_id", "version_id")
op.create_table(
"sys_human_attention_record",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("trigger_message", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("problem_summary", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("trigger_reason", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("priority", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("handler_id", sa.BigInteger(), nullable=True),
sa.Column("handler_note", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_human_attention_record", "session_id", "message_id", "user_id", "priority", "status")
op.create_table(
"sys_human_attention_history",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("attention_id", sa.BigInteger(), nullable=False),
sa.Column("from_status", sa.String(20), nullable=True),
sa.Column("to_status", sa.String(20), nullable=False),
sa.Column("note", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("operated_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["attention_id"], ["sys_human_attention_record.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_human_attention_history", "attention_id")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(f"ix_{table}_{column}", table, [column])
def downgrade() -> None:
for table in [
"sys_human_attention_history",
"sys_human_attention_record",
"sys_knowledge_retrieval_candidate",
"sys_knowledge_retrieval_log",
"sys_knowledge_publish_log",
"sys_knowledge_review_record",
"sys_knowledge_sync_job",
"sys_knowledge_card",
"sys_knowledge_chunk",
"sys_knowledge_section",
"sys_knowledge_manifest",
"sys_knowledge_version",
"sys_knowledge_source_snapshot",
]:
op.drop_table(table)
for column in [
"migration_status", "last_published_at", "last_synced_at", "pending_version_id",
"current_version_id", "manifest_confirmed", "source_error", "source_status",
"lifecycle_status", "review_mode", "knowledge_type", "source_title",
]:
op.drop_column("sys_knowledge", column)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0008_unique_kb_node"
down_revision = "0007_knowledge_agent_rebuild"
branch_labels = None
depends_on = None
def upgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
if "uq_knowledge_feishu_node" not in constraints:
op.create_unique_constraint(
"uq_knowledge_feishu_node",
"sys_knowledge",
["feishu_space_id", "feishu_node_id"],
)
def downgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
if "uq_knowledge_feishu_node" in constraints:
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")

View File

@@ -0,0 +1,59 @@
"""log retention, storage snapshots and audit indexes
Revision ID: 0009_logs_storage
Revises: 0008_unique_kb_node
"""
from alembic import op
import sqlalchemy as sa
revision = "0009_logs_storage"
down_revision = "0008_unique_kb_node"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_log_retention_policy" not in tables:
op.create_table(
"sys_log_retention_policy",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("retention_days", sa.Integer(), nullable=True),
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_run_at", sa.DateTime(), nullable=True),
sa.Column("last_result", sa.Text(), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if "sys_storage_snapshot" not in tables:
op.create_table(
"sys_storage_snapshot",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("scope", sa.String(50), nullable=False),
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
sa.Column("detail_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
op.create_index(name, table, columns)
def downgrade() -> None:
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
op.drop_table("sys_storage_snapshot")
op.drop_table("sys_log_retention_policy")

View File

@@ -0,0 +1,29 @@
"""add prompt history metadata and ordering index
Revision ID: 0010_prompt_history_management
Revises: 0009_logs_storage
"""
from alembic import op
import sqlalchemy as sa
revision = "0010_prompt_history_management"
down_revision = "0009_logs_storage"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_prompt",
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
)
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
def downgrade() -> None:
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
op.drop_column("sys_prompt", "source_prompt_id")
op.drop_column("sys_prompt", "change_type")

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
KnowledgeRetrievalCandidate,
KnowledgeRetrievalLog,
)
from app.schemas.knowledge import AttentionUpdateRequest
from app.services.admin_service import OperationLogService
from app.models.logs import LogRetentionPolicy
from app.services.maintenance_service import MaintenanceService
from app.api.pagination import page_result
router = APIRouter()
@router.get("/retrieval-log/list")
def retrieval_logs(
sessionId: int | None = Query(default=None),
knowledgeId: int | None = Query(default=None),
knowledgeCalled: bool | None = Query(default=None),
statusValue: str = Query(default="", alias="status"),
attentionCreated: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
if sessionId is not None:
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
if knowledgeId is not None:
query = query.where(KnowledgeRetrievalLog.selected_knowledge_ids.like(f"%{knowledgeId}%"))
if knowledgeCalled is not None:
query = query.where(KnowledgeRetrievalLog.knowledge_called == (1 if knowledgeCalled else 0))
if statusValue:
query = query.where(KnowledgeRetrievalLog.status == statusValue)
if attentionCreated is not None:
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_retrieval_dict(item, detail=False) for item in rows], total=total, page=page, page_size=pageSize))
@router.post("/retrieval-log/cleanup/estimate")
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
return api_success({"before": before, "estimatedCount": count})
@router.post("/retrieval-log/cleanup")
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
before = _cleanup_before(payload)
total = MaintenanceService.delete_retrieval_logs(db, before)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
db.commit()
return api_success({"deleted": total, "before": before})
@router.get("/retrieval-log/retention")
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
@router.put("/retrieval-log/retention")
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
days = payload.get("retentionDays")
if days is not None and (not isinstance(days, int) or days < 1):
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
db.add(policy)
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
db.commit()
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
def _cleanup_before(payload: dict) -> datetime:
raw = str(payload.get("before") or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@router.get("/retrieval-log/{log_id}")
def retrieval_detail(
log_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
log = db.get(KnowledgeRetrievalLog, log_id)
if log is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="检索日志不存在")
candidates = db.scalars(
select(KnowledgeRetrievalCandidate)
.where(KnowledgeRetrievalCandidate.retrieval_log_id == log.id)
.order_by(KnowledgeRetrievalCandidate.selected.desc(), KnowledgeRetrievalCandidate.id)
).all()
data = _retrieval_dict(log, detail=True)
data["candidates"] = [
{
"id": item.id,
"knowledgeId": item.knowledge_id,
"versionId": item.version_id,
"chunkId": item.chunk_id,
"sectionId": item.section_id,
"lexicalScore": item.lexical_score,
"rerankScore": item.rerank_score,
"selected": item.selected == 1,
"discardReason": item.discard_reason,
}
for item in candidates
]
return api_success(data)
@router.get("/attention/list")
def attention_list(
priority: str = Query(default=""),
statusValue: str = Query(default="", alias="status"),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(HumanAttentionRecord)
if priority:
query = query.where(HumanAttentionRecord.priority == priority)
if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}")
def update_attention(
attention_id: int,
payload: AttentionUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
item = db.get(HumanAttentionRecord, attention_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
previous = item.status
item.status = payload.status
item.handler_id = current_admin.id
item.handler_note = payload.note
db.add_all(
[
item,
HumanAttentionHistory(
attention_id=item.id,
from_status=previous,
to_status=payload.status,
note=payload.note,
operated_by=current_admin.id,
),
]
)
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="update", target_id=item.id)
db.commit()
return api_success(_attention_dict(item))
@router.delete("/attention/{attention_id}")
def delete_attention(
attention_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
item = db.get(HumanAttentionRecord, attention_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
histories = db.scalars(
select(HumanAttentionHistory).where(HumanAttentionHistory.attention_id == item.id)
).all()
for history in histories:
db.delete(history)
db.delete(item)
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="delete", target_id=attention_id)
db.commit()
return api_success()
def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
data = {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"question": item.question,
"knowledgeCalled": item.knowledge_called == 1,
"selectedKnowledgeIds": item.selected_knowledge_ids,
"queryTerms": _json(item.query_terms),
"finalSectionIds": item.final_section_ids,
"status": item.status,
"toolCostMs": item.tool_cost_ms,
"totalCostMs": item.total_cost_ms,
"errorMessage": item.error_message,
"attentionCreated": item.attention_created == 1,
"createdAt": item.created_at,
}
if detail:
data.update(
{
"catalogSnapshot": _json(item.catalog_snapshot),
"catalogHash": item.catalog_hash,
"safetyRuleVersion": item.safety_rule_version,
"toolTrace": _json(item.tool_trace),
"discardedConflicts": _json(item.discarded_conflicts),
"finalAnswer": item.final_answer,
}
)
return data
def _attention_dict(item: HumanAttentionRecord) -> dict:
return {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
"userId": item.user_id,
"triggerMessage": item.trigger_message,
"problemSummary": item.problem_summary,
"triggerReason": item.trigger_reason,
"priority": item.priority,
"status": item.status,
"handlerId": item.handler_id,
"handlerNote": item.handler_note,
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
def _json(raw: str | None):
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
from app.services.admin_service import AdminAuthService
from app.services.auth_service import AuthService
from app.services.security_state_service import client_ip
from app.core.dependencies import get_current_token_payload
router = APIRouter()
@router.post("/login")
def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(get_db)) -> dict:
result = AdminAuthService.login(db, payload.username, payload.password, client_ip(request))
return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json"))
@router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump())
@router.post("/logout")
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
AuthService.logout(token_payload)
return api_success()

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
router = APIRouter()
@router.get("/dashboard")
def dashboard(
start: str = Query(default="", description="开始日期 YYYY-MM-DD"),
end: str = Query(default="", description="结束日期 YYYY-MM-DD"),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
if end:
end_dt = datetime.strptime(end, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
else:
end_dt = None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/storage")
def storage_stats(
refresh: bool = Query(default=False),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
if latest is not None and not refresh:
return api_success(_snapshot_dict(latest, db))
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
errors: list[str] = []
try:
if db.bind and db.bind.dialect.name == "mysql":
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
else:
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
except Exception as exc:
errors.append(f"database: {exc}")
try:
redis = get_sync_redis_client()
if redis is not None:
info = redis.info("memory")
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
except Exception as exc:
errors.append(f"redis: {exc}")
try:
root = Path(".uploads")
if root.exists():
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
else:
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
except Exception as exc:
errors.append(f"files: {exc}")
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
db.add(snapshot); db.commit(); db.refresh(snapshot)
return api_success(_snapshot_dict(snapshot, db))
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
return with_growth

View File

@@ -0,0 +1,258 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.knowledge import (
Knowledge,
KnowledgeCard,
KnowledgeChunk,
KnowledgeManifest,
KnowledgePublishLog,
KnowledgeReviewRecord,
KnowledgeSection,
KnowledgeSourceSnapshot,
KnowledgeSyncJob,
KnowledgeVersion,
UserKnowledgePermission,
)
from app.schemas.admin import KnowledgeSaveRequest
from app.services.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService
from app.api.pagination import page_result
router = APIRouter()
@router.get("/knowledge/list")
def list_knowledge(
keyword: str = Query(default=""),
knowledgeType: str = Query(default=""),
openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(Knowledge)
if keyword:
query = query.where((Knowledge.name.like(f"%{keyword}%")) | (Knowledge.source_title.like(f"%{keyword}%")))
if knowledgeType:
query = query.where(Knowledge.knowledge_type == knowledgeType)
if openStatus is not None:
query = query.where(Knowledge.status == openStatus)
if sourceStatus:
query = query.where(Knowledge.source_status == sourceStatus)
if pending is not None:
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
@router.get("/knowledge/options")
def knowledge_options(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
items = db.scalars(select(Knowledge).where(Knowledge.lifecycle_status == "active").order_by(Knowledge.id.desc())).all()
return api_success([_knowledge_dict(item) for item in items])
@router.post("/knowledge/resolve-node")
def resolve_knowledge_node(
payload: dict,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
node_id = str(payload.get("nodeId") or "").strip()
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
raise HTTPException(status_code=400, detail="Node ID 格式错误")
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
if existing is not None:
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name}")
try:
info = FeishuKnowledgeService.get_node_info(node_id, db)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
if not space_id:
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID请检查节点是否移动或当前应用权限")
if not title:
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
@router.post("/knowledge/import")
def import_knowledge_from_space(
payload: dict,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
node_token = payload.get("node_token", "").strip()
if not node_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
info = FeishuKnowledgeService.get_node_info(node_token, db)
space_id = info.get("space_id", "")
if not space_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无法获取节点 {node_token} 的 SpaceID",
)
# 检查是否已存在
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_token))
if existing is not None:
return api_success({"imported": 0, "skipped": 1, "id": existing.id, "name": existing.name})
knowledge = Knowledge(
name=info.get("title", node_token),
feishu_space_id=space_id,
feishu_node_id=node_token,
status=0,
knowledge_type="general",
review_mode="manual",
remark=f"obj_type={info.get('obj_type', '')}",
)
db.add(knowledge)
db.flush()
OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
db.refresh(knowledge)
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
@router.post("/knowledge")
def create_knowledge(
payload: KnowledgeSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
knowledge = Knowledge(
name=payload.name,
feishu_space_id=payload.feishuSpaceId,
feishu_node_id=payload.feishuNodeId,
status=0,
remark=payload.remark,
knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode,
)
db.add(knowledge)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
db.refresh(knowledge)
return api_success(_knowledge_dict(knowledge))
@router.put("/knowledge/{knowledge_id}")
def update_knowledge(
knowledge_id: int,
payload: KnowledgeSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _get_knowledge(db, knowledge_id)
knowledge.name = payload.name
knowledge.remark = payload.remark
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit()
db.refresh(knowledge)
return api_success(_knowledge_dict(knowledge))
@router.delete("/knowledge/{knowledge_id}")
def delete_knowledge(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _get_knowledge(db, knowledge_id)
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="物理删除前必须先关闭并归档知识库",
)
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
).all()
for permission in permissions:
db.delete(permission)
for model in (KnowledgeReviewRecord, KnowledgeCard, KnowledgeChunk, KnowledgeSection, KnowledgeManifest, KnowledgePublishLog, KnowledgeSyncJob):
for row in db.scalars(select(model).where(model.knowledge_id == knowledge.id)).all():
db.delete(row)
db.flush()
for row in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == knowledge.id)).all():
db.delete(row)
db.flush()
for row in db.scalars(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)).all():
db.delete(row)
db.delete(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
db.commit()
return api_success()
def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
knowledge = db.get(Knowledge, knowledge_id)
if knowledge is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
return knowledge
def _require_super_admin(admin: Admin) -> None:
if admin.role is not None and admin.role.code != "super_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库")
def _knowledge_dict(knowledge: Knowledge) -> dict:
return {
"id": knowledge.id,
"name": knowledge.name,
"feishuSpaceId": knowledge.feishu_space_id,
"feishuNodeId": knowledge.feishu_node_id,
"status": knowledge.status,
"remark": knowledge.remark,
"sourceTitle": knowledge.source_title,
"knowledgeType": knowledge.knowledge_type,
"reviewMode": knowledge.review_mode,
"lifecycleStatus": knowledge.lifecycle_status,
"sourceStatus": knowledge.source_status,
"sourceError": knowledge.source_error,
"manifestConfirmed": knowledge.manifest_confirmed == 1,
"currentVersionId": knowledge.current_version_id,
"pendingVersionId": knowledge.pending_version_id,
"lastSyncedAt": knowledge.last_synced_at,
"lastPublishedAt": knowledge.last_published_at,
"migrationStatus": knowledge.migration_status,
"createdAt": knowledge.created_at,
"updatedAt": knowledge.updated_at,
}

View File

@@ -0,0 +1,505 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.knowledge import (
Knowledge,
KnowledgeCard,
KnowledgeChunk,
KnowledgeManifest,
KnowledgeSection,
KnowledgeSyncJob,
KnowledgeVersion,
)
from app.schemas.knowledge import (
KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest,
KnowledgeReviewRequest,
KnowledgeStatusRequest,
KnowledgeSyncRequest,
)
from app.services.admin_service import OperationLogService
from app.services.knowledge_pipeline_service import KnowledgePipelineService
router = APIRouter()
@router.patch("/knowledge/batch/metadata")
def batch_update_metadata(
payload: KnowledgeBatchMetadataUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
if payload.knowledgeType is None and payload.reviewMode is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段")
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = {
item.id: item
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
}
results: list[dict] = []
for knowledge_id in knowledge_ids:
item = rows.get(knowledge_id)
if item is None:
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
continue
if payload.knowledgeType is not None:
item.knowledge_type = payload.knowledgeType
if payload.reviewMode is not None:
item.review_mode = payload.reviewMode
db.add(item)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="knowledge",
action="batch_update_metadata",
target_id=item.id,
)
results.append({"knowledgeId": knowledge_id, "ok": True})
db.commit()
success = sum(1 for item in results if item["ok"])
return api_success(
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
)
@router.patch("/knowledge/{knowledge_id}/metadata")
def update_metadata(
knowledge_id: int,
payload: KnowledgeMetadataUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
knowledge.name = payload.name.strip()
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
db.add(knowledge)
OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
)
db.commit()
db.refresh(knowledge)
return api_success(_knowledge_detail(knowledge))
@router.get("/knowledge/{knowledge_id}/detail")
def detail(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
manifest = None
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
if manifest_version_id:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
return api_success(
{
"knowledge": _knowledge_detail(knowledge),
"currentVersion": _version_dict(current),
"pendingVersion": _version_dict(pending),
"manifest": _manifest_dict(manifest),
}
)
@router.get("/knowledge/{knowledge_id}/versions")
def versions(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
_knowledge(db, knowledge_id)
rows = db.scalars(
select(KnowledgeVersion)
.where(KnowledgeVersion.knowledge_id == knowledge_id)
.order_by(KnowledgeVersion.version_no.desc())
).all()
return api_success([_version_dict(item) for item in rows])
@router.get("/knowledge/version/{version_id}/content")
def version_content(
version_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
version = _version(db, version_id)
sections = db.scalars(
select(KnowledgeSection).where(KnowledgeSection.version_id == version.id).order_by(KnowledgeSection.sort_order)
).all()
cards = db.scalars(
select(KnowledgeCard).where(KnowledgeCard.version_id == version.id).order_by(KnowledgeCard.id)
).all()
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
return api_success(
{
"version": _version_dict(version),
"manifest": _manifest_dict(manifest),
"sections": [
{
"id": item.id,
"sectionKey": item.section_key,
"title": item.title,
"content": item.content,
"sourceStart": item.source_start,
"sourceEnd": item.source_end,
"contentHash": item.content_hash,
}
for item in sections
],
"cards": [
{
"id": item.id,
"sectionId": item.section_id,
"title": item.title,
"summary": item.summary,
"coreConclusion": item.core_conclusion,
"applicableQuestions": item.applicable_questions,
"inapplicableQuestions": item.inapplicable_questions,
"keywords": _json_value(item.keywords),
"synonyms": _json_value(item.synonyms),
"riskLevel": item.risk_level,
"sourceRange": item.source_range,
"reviewStatus": item.review_status,
}
for item in cards
],
}
)
@router.get("/knowledge/{knowledge_id}/sync-jobs")
def sync_jobs(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
rows = db.scalars(
select(KnowledgeSyncJob)
.where(KnowledgeSyncJob.knowledge_id == knowledge_id)
.order_by(KnowledgeSyncJob.id.desc())
.limit(50)
).all()
return api_success([_job_dict(item) for item in rows])
@router.post("/knowledge/{knowledge_id}/sync")
async def synchronize(
knowledge_id: int,
payload: KnowledgeSyncRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
if knowledge.lifecycle_status == "archived":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode=payload.mode
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
db.commit()
return api_success(_job_dict(job))
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
def confirm_manifest(
knowledge_id: int,
version_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
try:
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
def review(
knowledge_id: int,
version_id: int,
payload: KnowledgeReviewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id or version.status != "pending_review":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
if version.quality_status == "blocked" and payload.action == "approve":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
def publish(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
try:
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
def rollback(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.current_version_id == version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
if payload.keepOpen and knowledge.lifecycle_status != "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
try:
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.put("/knowledge/{knowledge_id}/open-status")
def open_status(
knowledge_id: int,
payload: KnowledgeStatusRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
if payload.open:
if not knowledge.current_version_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
if knowledge.manifest_confirmed != 1:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
if knowledge.source_status != "normal":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
if knowledge.lifecycle_status != "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能开放")
knowledge.status = 1 if payload.open else 0
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.put("/knowledge/{knowledge_id}/lifecycle")
def lifecycle(
knowledge_id: int,
payload: KnowledgeLifecycleRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
if knowledge.lifecycle_status == payload.status:
return api_success(_knowledge_detail(knowledge))
knowledge.lifecycle_status = payload.status
if payload.status == "archived":
knowledge.status = 0
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.get("/knowledge-migration/summary")
def migration_summary(
db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)
) -> dict:
_require_super_admin(current_admin)
rows = db.execute(
select(Knowledge.migration_status, func.count(Knowledge.id)).group_by(Knowledge.migration_status)
).all()
counts = {migration_status: count for migration_status, count in rows}
return api_success({"total": sum(counts.values()), "counts": counts})
@router.post("/knowledge-migration/run")
async def run_migration(
knowledgeId: int | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
if knowledgeId is not None:
query = query.where(Knowledge.id == knowledgeId)
rows = db.scalars(query.order_by(Knowledge.id)).all()
results = []
for knowledge in rows:
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode="review"
)
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
except Exception as exc:
results.append({"knowledgeId": knowledge.id, "ok": False, "error": str(exc)})
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge_migration", action="run", target_id=knowledgeId)
db.commit()
return api_success({"total": len(results), "success": sum(1 for item in results if item["ok"]), "results": results})
def _require_super_admin(admin: Admin) -> None:
if admin.role is not None and admin.role.code != "super_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以执行此操作")
def _knowledge(db: Session, knowledge_id: int) -> Knowledge:
item = db.get(Knowledge, knowledge_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
return item
def _version(db: Session, version_id: int) -> KnowledgeVersion:
item = db.get(KnowledgeVersion, version_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识版本不存在")
return item
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
knowledge = _knowledge(db, knowledge_id)
version = _version(db, version_id)
if version.knowledge_id != knowledge.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
return knowledge, version
def _knowledge_detail(item: Knowledge) -> dict:
return {
"id": item.id,
"name": item.name,
"status": item.status,
"remark": item.remark,
"sourceTitle": item.source_title,
"feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id,
"knowledgeType": item.knowledge_type,
"reviewMode": item.review_mode,
"open": item.status == 1,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
"sourceError": item.source_error,
"manifestConfirmed": item.manifest_confirmed == 1,
"currentVersionId": item.current_version_id,
"pendingVersionId": item.pending_version_id,
"lastSyncedAt": item.last_synced_at,
"lastPublishedAt": item.last_published_at,
"migrationStatus": item.migration_status,
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
def _version_dict(item: KnowledgeVersion | None) -> dict | None:
if item is None:
return None
return {
"id": item.id,
"knowledgeId": item.knowledge_id,
"snapshotId": item.snapshot_id,
"versionNo": item.version_no,
"status": item.status,
"qualityStatus": item.quality_status,
"changeSummary": _json_value(item.change_summary),
"qualityReport": _json_value(item.quality_report),
"processingRuleVersion": item.processing_rule_version,
"generationModel": item.generation_model,
"createdAt": item.created_at,
"publishedAt": item.published_at,
}
def _manifest_dict(item: KnowledgeManifest | None) -> dict | None:
if item is None:
return None
return {
"id": item.id,
"versionId": item.version_id,
"purpose": item.purpose,
"applicableQuestions": item.applicable_questions,
"inapplicableQuestions": item.inapplicable_questions,
"coreTopics": item.core_topics,
"entities": item.entities,
"boundaries": item.boundaries,
"changeLevel": item.change_level,
"confirmed": item.confirmed == 1,
}
def _job_dict(item: KnowledgeSyncJob) -> dict:
return {
"id": item.id,
"knowledgeId": item.knowledge_id,
"mode": item.mode,
"status": item.status,
"stage": item.stage,
"progress": item.progress,
"sourceError": item.source_error,
"resultSummary": _json_value(item.result_summary),
"createdAt": item.created_at,
"startedAt": item.started_at,
"finishedAt": item.finished_at,
}
def _json_value(raw: str | None):
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
import csv
import json
from datetime import datetime
from io import StringIO
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, load_only
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
from app.api.pagination import page_result
router = APIRouter()
@router.get("/chat/list")
def chat_list(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
@router.get("/chat/export")
def export_chats(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
rows = db.execute(
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
.order_by(ChatSession.updated_at.desc())
.limit(1000)
).all()
output = StringIO()
writer = csv.writer(output)
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user in rows:
writer.writerow(
[
session.id,
session.user_id,
user.phone if user else "",
user.name if user else "",
session.title,
session.message_count,
session.last_message_at,
session.updated_at,
]
)
content = "\ufeff" + output.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="chat_records.csv"'},
)
@router.get("/chat/{session_id}")
def chat_detail(
session_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
session_row = db.execute(
select(ChatSession, User)
.join(User, User.id == ChatSession.user_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user = session_row
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).all()
ai_logs = db.scalars(
select(AiRequestLog)
.where(AiRequestLog.session_id == session_id)
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all()
return api_success(
{
"session": _chat_row_dict(session, user),
"messages": [_message_dict(item) for item in messages],
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
}
)
@router.get("/log/list")
def operation_logs(
module: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(OperationLog)
if module:
query = query.where(OperationLog.module == module)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
logs = db.scalars(
query.options(load_only(OperationLog.id, OperationLog.admin_id, OperationLog.module, OperationLog.action, OperationLog.target_id, OperationLog.result, OperationLog.created_at))
.order_by(OperationLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
return api_success(page_result(
[
{
"id": item.id,
"adminId": item.admin_id,
"module": item.module,
"action": item.action,
"targetId": item.target_id,
"result": item.result,
"createdAt": item.created_at,
}
for item in logs
], total=total, page=page, page_size=pageSize
))
@router.get("/ai-log/list")
def ai_logs(
sessionId: int | None = Query(default=None),
userId: int | None = Query(default=None),
status: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(AiRequestLog)
if sessionId is not None:
query = query.where(AiRequestLog.session_id == sessionId)
if userId is not None:
query = query.where(AiRequestLog.user_id == userId)
if status:
query = query.where(AiRequestLog.status == status)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
logs = db.scalars(
query.options(load_only(
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
@router.get("/ai-log/{log_id}")
def ai_log_detail(
log_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
log = db.get(AiRequestLog, log_id)
if log is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI 请求日志不存在")
return api_success(_ai_log_dict(log, include_prompt=True))
def _chat_query(
*,
keyword: str,
user_id: int | None,
status: str,
date_from: datetime | None,
date_to: datetime | None,
):
query = (
select(ChatSession, User)
.join(User, User.id == ChatSession.user_id, isouter=True)
.where(ChatSession.is_deleted == 0)
)
if user_id is not None:
query = query.where(ChatSession.user_id == user_id)
if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
if date_to is not None:
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
if status:
query = query.where(
exists()
.where(ChatMessage.session_id == ChatSession.id)
.where(ChatMessage.message_status == status)
)
if keyword:
like = f"%{keyword}%"
query = query.where(
or_(
ChatSession.title.like(like),
User.phone.like(like),
User.name.like(like),
exists()
.where(ChatMessage.session_id == ChatSession.id)
.where(ChatMessage.content.like(like)),
)
)
return query
def _chat_row_dict(session: ChatSession, user: User | None) -> dict:
return {
"id": session.id,
"userId": session.user_id,
"userPhone": user.phone if user else "",
"userName": user.name if user else "",
"title": session.title,
"messageCount": session.message_count,
"lastMessageAt": session.last_message_at,
"updatedAt": session.updated_at,
}
def _message_dict(message: ChatMessage) -> dict:
return {
"id": message.id,
"sessionId": message.session_id,
"userId": message.user_id,
"role": message.role,
"content": message.content,
"messageStatus": message.message_status,
"tokenInput": message.token_input,
"tokenOutput": message.token_output,
"responseTimeMs": message.response_time_ms,
"modelId": message.model_id,
"createdAt": message.created_at,
}
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
data = {
"id": log.id,
"sessionId": log.session_id,
"messageId": log.message_id,
"userId": log.user_id,
"modelName": log.model_name,
"knowledgeIds": log.knowledge_ids,
"retrieveCount": log.retrieve_count,
"retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks) if include_chunks else [],
"inputToken": log.input_token,
"outputToken": log.output_token,
"totalToken": log.total_token,
"costMs": log.cost_ms,
"status": log.status,
"errorMessage": log.error_message,
"createdAt": log.created_at,
}
if include_prompt:
data["prompt"] = log.prompt
return data
def _parse_retrieved_chunks(raw: str | None) -> list[dict[str, Any]]:
if not raw:
return []
try:
chunks = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(chunks, list):
return []
normalized: list[dict[str, Any]] = []
for index, item in enumerate(chunks, start=1):
if not isinstance(item, dict):
continue
normalized.append(
{
"index": item.get("index") or index,
"knowledgeId": item.get("knowledgeId"),
"knowledgeName": item.get("knowledgeName") or "",
"title": item.get("title") or "",
"content": item.get("content") or "",
"sourceUrl": item.get("sourceUrl") or None,
}
)
return normalized

View File

@@ -0,0 +1,522 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.knowledge import Knowledge
from app.schemas.admin import (
AgentDebugRequest,
AgentRuntimeConfigSaveRequest,
EnableModelRequest,
ModelSaveRequest,
PromptSaveRequest,
SystemConfigSaveRequest,
)
from app.services.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import ModelClientService
from app.services.rag_service import PromptService, RagResult
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
router = APIRouter()
DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识片段回答,不能编造。"
@router.get("/prompt")
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
row = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.limit(1)
).first()
return api_success(_prompt_detail(row[0], row[1]) if row else _default_prompt_detail())
@router.put("/prompt")
def save_prompt(
payload: PromptSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
prompt = Prompt(prompt_content=payload.promptContent, change_type="save", updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
db.commit()
db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.post("/prompt/reset")
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = Prompt(prompt_content=DEFAULT_PROMPT, change_type="reset", updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
db.commit()
db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.get("/prompt/history")
def prompt_history(
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
total = db.scalar(select(func.count(Prompt.id))) or 0
rows = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [
_prompt_summary(prompt, admin, is_current=index == 0 and page == 1)
for index, (prompt, admin) in enumerate(rows)
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/prompt/history/{prompt_id}")
def prompt_history_detail(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
row = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.where(Prompt.id == prompt_id)
).first()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
return api_success(_prompt_detail(row[0], row[1]))
@router.post("/prompt/history/{prompt_id}/restore")
def restore_prompt(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
source = db.get(Prompt, prompt_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
restored = Prompt(
prompt_content=source.prompt_content,
change_type="restore",
source_prompt_id=source.id,
updated_by=current_admin.id,
)
db.add(restored)
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="prompt",
action="restore",
target_id=restored.id,
)
db.commit()
db.refresh(restored)
return api_success(_prompt_detail(restored, current_admin))
@router.post("/agent/debug")
async def debug_agent(
payload: AgentDebugRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, payload.modelId)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
rag_result = await KnowledgeAgentService.build_result(
db,
question=payload.question,
version_overrides=payload.knowledgeVersions,
preview_knowledge_ids=payload.knowledgeIds,
)
debug_messages = [
{"role": "system", "content": payload.promptContent.strip()},
*(rag_result.messages or []),
]
rag_result = RagResult(
question=rag_result.question,
knowledge_scopes=rag_result.knowledge_scopes,
chunks=rag_result.chunks,
prompt=PromptService.render_messages(debug_messages),
allow_general_knowledge=rag_result.allow_general_knowledge,
retrieval_log_id=rag_result.retrieval_log_id,
tool_trace=rag_result.tool_trace,
messages=debug_messages,
)
result = await ModelClientService.debug_model_async(
model,
rag_result,
{
"temperature": payload.temperature,
"top_p": payload.topP,
"top_k": payload.topK,
"presence_penalty": payload.presencePenalty,
"frequency_penalty": payload.frequencyPenalty,
"max_token": payload.maxToken,
},
)
result["retrievalTrace"] = rag_result.tool_trace or []
result["retrievalLogId"] = rag_result.retrieval_log_id
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="debug", target_id=model.id)
db.commit()
return api_success(result)
@router.get("/agent/runtime-config")
def get_agent_runtime_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
return api_success(_agent_runtime_config_dict(model))
@router.put("/agent/runtime-config")
def save_agent_runtime_config(
payload: AgentRuntimeConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
if model is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="当前没有已启用模型,请先在模型管理中启用一个模型",
)
model.temperature = payload.temperature
model.top_p = payload.topP
model.top_k = payload.topK
model.presence_penalty = payload.presencePenalty
model.frequency_penalty = payload.frequencyPenalty
model.max_token = payload.maxToken
db.add(model)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="agent",
action="save_runtime_config",
target_id=model.id,
)
db.commit()
db.refresh(model)
return api_success(_agent_runtime_config_dict(model))
@router.get("/model/list")
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
return api_success([_model_dict(model) for model in models])
@router.post("/model")
def create_model(
payload: ModelSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
if not payload.apiKey:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空")
model = ModelConfig(
provider=payload.provider,
display_name=payload.displayName,
api_type=payload.apiType,
model_name=payload.modelName,
base_url=payload.baseUrl,
api_url=payload.apiUrl,
api_key=SecretService.encrypt(payload.apiKey),
auth_type=payload.authType,
api_version=payload.apiVersion,
temperature=payload.temperature,
top_p=payload.topP,
top_k=payload.topK,
presence_penalty=payload.presencePenalty,
frequency_penalty=payload.frequencyPenalty,
max_token=payload.maxToken,
context_window=payload.contextWindow,
stream_enabled=payload.streamEnabled,
response_format=payload.responseFormat,
extra_params=payload.extraParams,
remark=payload.remark,
timeout_second=payload.timeoutSecond,
enabled=0,
)
db.add(model)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="create", target_id=model.id)
db.commit()
db.refresh(model)
return api_success(_model_dict(model))
@router.put("/model/{model_id}")
def update_model(
model_id: int,
payload: ModelSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
model.provider = payload.provider
model.display_name = payload.displayName
model.api_type = payload.apiType
model.model_name = payload.modelName
model.base_url = payload.baseUrl
model.api_url = payload.apiUrl
if payload.apiKey and payload.apiKey != MASKED_SECRET:
model.api_key = SecretService.encrypt(payload.apiKey)
model.auth_type = payload.authType
model.api_version = payload.apiVersion
model.temperature = payload.temperature
model.top_p = payload.topP
model.top_k = payload.topK
model.presence_penalty = payload.presencePenalty
model.frequency_penalty = payload.frequencyPenalty
model.max_token = payload.maxToken
model.context_window = payload.contextWindow
model.stream_enabled = payload.streamEnabled
model.response_format = payload.responseFormat
model.extra_params = payload.extraParams
model.remark = payload.remark
model.timeout_second = payload.timeoutSecond
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
db.commit()
db.refresh(model)
return api_success(_model_dict(model))
@router.post("/model/enable")
def enable_model(
payload: EnableModelRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
target = db.get(ModelConfig, payload.modelId)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
for model in db.scalars(select(ModelConfig)).all():
model.enabled = 1 if model.id == target.id else 0
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="enable", target_id=target.id)
db.commit()
return api_success()
@router.delete("/model/{model_id}")
def delete_model(
model_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
db.delete(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="delete", target_id=model.id)
db.commit()
return api_success()
@router.post("/model/{model_id}/test")
def test_model(
model_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
result = ModelClientService.test_model(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="test", target_id=model.id)
db.commit()
return api_success(result)
@router.get("/config")
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
return api_success([_config_dict(config) for config in configs])
@router.put("/config")
def save_config(
payload: SystemConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
if config is None:
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
if payload.configKey in SENSITIVE_CONFIG_KEYS:
if payload.configValue == MASKED_SECRET:
return api_success(_config_dict(config))
config.config_value = SecretService.encrypt(payload.configValue)
else:
config.config_value = payload.configValue
config.description = payload.description
config.updated_by = current_admin.id
db.add(config)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="config", action="save", target_id=config.id)
db.commit()
db.refresh(config)
return api_success(_config_dict(config))
def _model_dict(model: ModelConfig) -> dict:
return {
"id": model.id,
"provider": model.provider,
"displayName": model.display_name,
"apiType": model.api_type,
"modelName": model.model_name,
"baseUrl": model.base_url,
"apiUrl": model.api_url,
"apiKeyMasked": SecretService.masked(model.api_key),
"authType": model.auth_type,
"apiVersion": model.api_version,
"temperature": float(model.temperature) if model.temperature is not None else None,
"topP": float(model.top_p) if model.top_p is not None else None,
"topK": model.top_k,
"presencePenalty": float(model.presence_penalty) if model.presence_penalty is not None else None,
"frequencyPenalty": float(model.frequency_penalty) if model.frequency_penalty is not None else None,
"maxToken": model.max_token,
"contextWindow": model.context_window,
"streamEnabled": model.stream_enabled,
"responseFormat": model.response_format,
"extraParams": model.extra_params,
"remark": model.remark,
"timeoutSecond": model.timeout_second,
"enabled": model.enabled,
}
def _enabled_model(db: Session) -> ModelConfig | None:
return db.scalar(
select(ModelConfig)
.where(ModelConfig.enabled == 1)
.order_by(ModelConfig.id.desc())
.limit(1)
)
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
return {
"modelId": model.id if model is not None else None,
"modelName": (model.display_name or model.model_name) if model is not None else None,
"temperature": float(model.temperature) if model is not None and model.temperature is not None else None,
"topP": float(model.top_p) if model is not None and model.top_p is not None else None,
"topK": model.top_k if model is not None else None,
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
}
def _default_prompt_detail() -> dict:
return {
"id": None,
"promptContent": DEFAULT_PROMPT,
"changeType": "default",
"sourcePromptId": None,
"updatedByName": "系统默认",
"updatedAt": None,
"charCount": len(DEFAULT_PROMPT),
}
def _prompt_summary(prompt: Prompt, admin: Admin | None, *, is_current: bool) -> dict:
compact = " ".join(prompt.prompt_content.split())
return {
"id": prompt.id,
"preview": compact[:140],
"charCount": len(prompt.prompt_content),
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else "未知管理员",
"updatedAt": prompt.updated_at,
"isCurrent": is_current,
}
def _prompt_detail(prompt: Prompt, admin: Admin | None = None) -> dict:
return {
"id": prompt.id,
"promptContent": prompt.prompt_content,
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else None,
"updatedAt": prompt.updated_at,
"charCount": len(prompt.prompt_content),
}
def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]:
query = select(Knowledge).where(Knowledge.status == 1)
if knowledge_ids:
query = query.where(Knowledge.id.in_(knowledge_ids))
rows = db.scalars(query.order_by(Knowledge.id.asc())).all()
return [
KnowledgeScope(
id=item.id,
name=item.name,
feishu_space_id=item.feishu_space_id,
feishu_node_id=item.feishu_node_id,
)
for item in rows
]
def _build_agent_debug_prompt(prompt_content: str, question: str, chunks: list) -> str:
context = "\n\n".join(
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
for index, chunk in enumerate(chunks, start=1)
)
if not context:
context = "未检索到相关知识片段。"
return f"{prompt_content.strip()}\n\n{context}\n\n用户问题:{question.strip()}"
@router.post("/feishu/cache/clear")
def clear_feishu_cache(current_admin: Admin = Depends(get_current_admin)) -> dict:
count = FeishuKnowledgeService.clear_cache()
return api_success({"cleared": count, "message": f"已清空 {count} 条飞书缓存"})
def _config_dict(config: SystemConfig) -> dict:
value = config.config_value
if config.config_key in SENSITIVE_CONFIG_KEYS:
value = SecretService.masked(value)
return {
"id": config.id,
"configKey": config.config_key,
"configValue": value,
"description": config.description,
"updatedAt": config.updated_at,
}

View File

@@ -0,0 +1,386 @@
from __future__ import annotations
import re
from datetime import date, datetime
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
from app.api.pagination import page_result
router = APIRouter()
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
@router.get("/user/list")
def list_users(
keyword: str = Query(default=""),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = select(User).where(User.is_deleted == 0).order_by(User.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.where((User.phone.like(like)) | (User.name.like(like)))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
@router.post("/user")
def create_user(
payload: AdminUserCreateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
phone = _normalize_phone(payload.phone)
_validate_phone(phone)
existing = db.scalar(select(User).where(User.phone == phone))
if existing is not None and not existing.is_deleted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号已存在")
if existing is not None:
user = existing
user.is_deleted = 0
user.daily_chat_used = 0
else:
user = User(phone=phone, daily_chat_used=0)
_apply_user_payload(user, payload, _default_daily_chat_limit(db))
db.add(user)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
@router.post("/user/import")
def import_users(
payload: AdminUserImportRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
if not payload.students:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据")
return api_success(_import_user_items(list(enumerate(payload.students, start=1)), db, current_admin))
@router.get("/user/import/template")
def download_user_import_template(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(STUDENT_TEMPLATE_HEADERS)
sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"])
sheet.append(["13800000002", "李四", "", "", "启用", ""])
widths = [18, 14, 14, 16, 12, 22]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'},
)
@router.post("/user/import/excel")
def import_users_excel(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
filename = (file.filename or "").lower()
if not filename.endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件")
content = file.file.read()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空")
try:
workbook = load_workbook(BytesIO(content), data_only=True)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败") from exc
sheet = workbook.active
header_map = _excel_header_map(next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ()))
for required in ("手机号", "姓名"):
if required not in header_map:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"模板缺少必填列:{required}")
students: list[tuple[int, AdminUserImportItem]] = []
parse_failures: list[dict] = []
for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not any(_cell_text(cell) for cell in row):
continue
try:
item = AdminUserImportItem(
phone=_cell_text(_excel_value(row, header_map, "手机号")),
name=_cell_text(_excel_value(row, header_map, "姓名")),
nickname=_cell_text(_excel_value(row, header_map, "昵称")) or None,
dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")),
status=_parse_status(_excel_value(row, header_map, "状态")),
expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")),
)
students.append((row_number, item))
except (ValueError, ValidationError) as exc:
parse_failures.append(
{
"row": row_number,
"phone": _cell_text(_excel_value(row, header_map, "手机号")),
"reason": _row_error_message(exc),
}
)
if not students and not parse_failures:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可导入的学员数据")
result = _import_user_items(students, db, current_admin, parse_failures)
return api_success(result)
def _import_user_items(
students: list[tuple[int, AdminUserImportItem]],
db: Session,
current_admin: Admin,
initial_failures: list[dict] | None = None,
) -> dict:
created = 0
updated = 0
failed: list[dict] = list(initial_failures or [])
seen: set[str] = set()
default_daily_limit = _default_daily_chat_limit(db)
for row_number, item in students:
phone = _normalize_phone(item.phone)
name = item.name.strip()
try:
_validate_phone(phone)
if not name:
raise ValueError("姓名不能为空")
if phone in seen:
raise ValueError("手机号在本次导入中重复")
seen.add(phone)
user = db.scalar(select(User).where(User.phone == phone))
if user is None:
user = User(phone=phone, daily_chat_used=0)
created += 1
else:
user.is_deleted = 0
updated += 1
_apply_user_payload(user, item, default_daily_limit)
db.add(user)
except ValueError as exc:
failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)})
if created or updated:
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="user",
action="import",
target_id=None,
result="SUCCESS",
)
db.commit()
return {"created": created, "updated": updated, "failed": len(failed), "failures": failed}
@router.get("/user/{user_id}")
def user_detail(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
return api_success(_user_dict(user))
@router.put("/user/{user_id}")
def update_user(
user_id: int,
payload: AdminUserUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
if payload.status is not None:
user.status = payload.status
if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None)
db.add(user)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
@router.delete("/user/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
user.is_deleted = 1
db.add(user)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="delete", target_id=user.id)
db.commit()
return api_success()
def _get_user(db: Session, user_id: int) -> User:
user = db.get(User, user_id)
if user is None or user.is_deleted:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return user
def _user_dict(user: User) -> dict:
return {
"id": user.id,
"phone": user.phone,
"name": user.name,
"nickname": user.nickname,
"status": user.status,
"dailyChatLimit": user.daily_chat_limit,
"dailyChatUsed": user.daily_chat_used,
"expiredAt": user.expired_at,
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
}
def _apply_user_payload(
user: User,
payload: AdminUserCreateRequest | AdminUserImportItem,
default_daily_limit: int,
) -> None:
user.name = payload.name.strip()
user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == "daily_chat_limit"))
if config is not None and config.config_value.strip():
try:
return max(0, min(100000, int(float(config.config_value.strip()))))
except ValueError:
pass
try:
from app.core.config import get_settings
return get_settings().default_daily_chat_limit
except Exception:
return 100
def _excel_header_map(headers: tuple) -> dict[str, int]:
return {_cell_text(header): index for index, header in enumerate(headers) if _cell_text(header)}
def _excel_value(row: tuple, header_map: dict[str, int], header: str) -> object | None:
index = header_map.get(header)
if index is None or index >= len(row):
return None
return row[index]
def _cell_text(value: object | None) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _parse_optional_int(value: object | None) -> int | None:
text = _cell_text(value)
if not text:
return None
try:
parsed = int(float(text))
except ValueError as exc:
raise ValueError("每日聊天额度必须是数字") from exc
if parsed < 0 or parsed > 100000:
raise ValueError("每日聊天额度必须在 0 到 100000 之间")
return parsed
def _parse_status(value: object | None) -> int:
text = _cell_text(value).lower()
if not text:
return 1
if text in {"1", "启用", "正常", "", "true", "enabled"}:
return 1
if text in {"0", "禁用", "停用", "", "false", "disabled"}:
return 0
raise ValueError("状态只能填写启用/禁用或 1/0")
def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "":
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
def _row_error_message(exc: ValueError | ValidationError) -> str:
if isinstance(exc, ValidationError):
return "字段格式不正确"
return str(exc)
def _normalize_phone(phone: str) -> str:
return re.sub(r"\D", "", phone or "")
def _validate_phone(phone: str) -> None:
if not re.fullmatch(r"1[3-9]\d{9}", phone):
raise ValueError("手机号格式不正确")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_token_payload
from app.core.responses import api_success
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
from app.services.auth_service import AuthService
from app.services.captcha_service import CaptchaService
from app.services.security_state_service import client_ip
router = APIRouter()
@router.get("/captcha")
def captcha() -> dict:
return api_success(CaptchaResponse.model_validate(CaptchaService.create()).model_dump())
@router.post("/sms/send")
def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(get_db)) -> dict:
AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode, client_ip(request))
return api_success(message="发送成功")
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
result = AuthService.login_with_sms(db, payload.phone, payload.code)
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
@router.post("/logout")
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
AuthService.logout(token_payload)
return api_success()

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.chat import (
ChatCompletionRequest,
ChatMessageRead,
ChatSessionRead,
CreateSessionResponse,
StopChatRequest,
UpdateSessionTitleRequest,
)
from app.services.chat_service import ChatService
from app.services.chat_queue_runtime import (
cancel_chat_waiter,
release_chat_slot,
request_chat_slot,
wait_for_chat_slot,
)
from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/session")
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
session = ChatService.create_session(db, current_user)
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
@router.get("/session/list")
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
sessions = ChatService.list_sessions(db, current_user)
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
@router.get("/history")
def history(
sessionId: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
messages = ChatService.get_history(db, current_user, sessionId)
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
@router.put("/session/title")
def update_title(
payload: UpdateSessionTitleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
return api_success(ChatSessionRead.model_validate(session).model_dump())
@router.delete("/session/{session_id}")
def delete_session(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
ChatService.delete_session(db, current_user, session_id)
return api_success()
@router.post("/completions")
def completions(
payload: ChatCompletionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream")
@router.post("/stop")
def stop(
payload: StopChatRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
ChatService.stop_generation(db, current_user, payload.sessionId)
return api_success()
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
config = load_chat_queue_config(db)
queue_lease = await request_chat_slot(config)
queue_request = queue_lease.request
acquired = queue_request.status == "acquired"
should_cancel_waiter = queue_request.status == "queued"
if queue_request.status == "rejected":
yield _sse_event(
"error",
message="当前请求过多,请稍后再试。",
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
yield _sse_done()
return
if queue_request.status == "queued" and queue_request.token is not None:
yield _sse_event(
"queued",
message=f"当前请求较多,正在排队中,前方约 {max(queue_request.position - 1, 0)} 个请求。",
position=queue_request.position,
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
queue_lease = await wait_for_chat_slot(queue_lease, config)
queue_request = queue_lease.request
acquired = queue_request.status == "acquired"
should_cancel_waiter = queue_request.status == "queued"
if queue_request.status == "rejected":
yield _sse_event(
"error",
message="当前请求过多,请稍后再试。",
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
yield _sse_done()
return
if queue_request.status == "timeout":
yield _sse_event("error", message="排队等待超时,请稍后再试。")
yield _sse_done()
return
if queue_request.status == "canceled":
yield _sse_event("error", message="排队请求已取消,请稍后再试。")
yield _sse_done()
return
try:
yield _sse_event(
"generating",
message="已进入生成队列,正在生成回答。",
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
)
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
yield _sse_event("content", content=chunk)
except HTTPException as exc:
yield _sse_event("error", message=str(exc.detail))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Chat stream failed")
yield _sse_event("error", message="AI 回复生成失败,请稍后再试。")
finally:
if acquired:
await release_chat_slot(queue_lease)
elif should_cancel_waiter:
await cancel_chat_waiter(queue_lease)
yield _sse_done()
def _sse_event(event_type: str, **payload: object) -> str:
return f"data: {json.dumps({'type': event_type, **payload}, ensure_ascii=False)}\n\n"
def _sse_done() -> str:
return "data: [DONE]\n\n"

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.core.config import get_settings
from app.core.database import SessionLocal
from app.core.responses import api_success
from app.services.redis_client import get_sync_redis_client
router = APIRouter()
@router.get("/health")
def health() -> dict:
return api_success({"status": "ok"})
@router.get("/ready")
def ready():
checks = {"database": _database_ready(), "redis": _redis_ready()}
redis_required = get_settings().readiness_requires_redis
ready_state = checks["database"] and (checks["redis"] or not redis_required)
payload = api_success({"status": "ready" if ready_state else "not_ready", "checks": checks})
return payload if ready_state else JSONResponse(status_code=503, content=payload)
def _database_ready() -> bool:
try:
with SessionLocal() as db:
db.execute(text("SELECT 1"))
return True
except Exception:
return False
def _redis_ready() -> bool:
client = get_sync_redis_client()
if client is None:
return False
try:
return bool(client.ping())
except Exception:
return False

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from typing import Any
def page_result(items: list[Any], *, total: int, page: int, page_size: int) -> dict[str, Any]:
return {"items": items, "total": total, "page": page, "pageSize": page_size}

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api import (
admin_auth,
admin_agent_records,
admin_dashboard,
admin_knowledge,
admin_knowledge_lifecycle,
admin_records,
admin_settings,
admin_users,
auth,
chat,
health,
user,
)
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.user import UserProfile
router = APIRouter()
@router.get("/profile")
def profile(current_user: User = Depends(get_current_user)) -> dict:
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "AI Knowledge Base V2 Backend"
app_env: str = "local"
debug: bool = True
log_level: str = "INFO"
api_prefix: str = "/api"
cors_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
)
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
redis_url: str = ""
readiness_requires_redis: bool = True
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 30
db_pool_recycle: int = 1800
auto_create_tables: bool = False
jwt_secret_key: str = "change-me-in-production"
config_encryption_key: str = ""
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 30
mock_sms_enabled: bool = True
mock_sms_code: str = "123456"
sms_code_expire_minutes: int = 5
aliyun_sms_access_key_id: str = ""
aliyun_sms_access_key_secret: str = ""
aliyun_sms_sign_name: str = ""
aliyun_sms_template_code: str = ""
aliyun_sms_template_param_key: str = "code"
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
mock_rag_enabled: bool = False
mock_model_enabled: bool = True
feishu_mock_enabled: bool = False
feishu_search_url: str = ""
feishu_app_id: str = ""
feishu_app_secret: str = ""
feishu_timeout_seconds: int = 20
feishu_retry_count: int = 2
default_daily_chat_limit: int = 100
default_user_name_prefix: str = "用户"
chat_max_active_requests: int = 2
chat_max_queue_size: int = 20
chat_queue_timeout_seconds: int = 60
chat_active_lease_seconds: int = 900
bootstrap_admin_username: str = ""
bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员"
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.models import Base
from app.services.knowledge_catalog_cache_service import register_knowledge_cache_invalidation_events
settings = get_settings()
engine_options = {"pool_pre_ping": True, "future": True}
if not settings.database_url.startswith("sqlite"):
engine_options.update(
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
pool_recycle=settings.db_pool_recycle,
)
engine = create_engine(settings.database_url, **engine_options)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
register_knowledge_cache_invalidation_events()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_tables() -> None:
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.admin import Admin
from app.models.user import User
from app.services.auth_service import AuthService
bearer_scheme = HTTPBearer(auto_error=True)
def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
payload = decode_access_token(credentials.credentials)
if AuthService.is_token_revoked(payload):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已退出")
return payload
def get_current_user(
payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db),
) -> User:
if payload.get("type") != "user":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
user_id = payload.get("sub")
user = db.get(User, int(user_id)) if user_id else None
if user is None or user.is_deleted:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
if user.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
now = datetime.now(UTC).replace(tzinfo=None)
if user.effective_at and user.effective_at > now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
if user.expired_at and user.expired_at < now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
return user
def get_current_admin(
payload: dict = Depends(get_current_token_payload),
db: Session = Depends(get_db),
) -> Admin:
if payload.get("type") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是管理员登录态")
admin_id = payload.get("sub")
admin = db.get(Admin, int(admin_id)) if admin_id else None
if admin is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员不存在")
if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
return admin

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.responses import api_error
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=api_error(_code_from_status(exc.status_code), str(exc.detail)))
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content=api_error(50000, "系统内部错误,请稍后重试"))
def _code_from_status(status_code: int) -> int:
if status_code in {401, 403}:
return 20000 if status_code == 401 else 40000
if status_code == 400:
return 10000
return 50000

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from contextvars import ContextVar
from datetime import UTC, datetime
import json
import logging
from logging.config import dictConfig
from time import perf_counter
from uuid import uuid4
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": getattr(record, "request_id", None) or request_id_context.get(),
}
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def configure_logging(level: str = "INFO") -> None:
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}},
"loggers": {
"app": {"handlers": ["console"], "level": level, "propagate": False},
"app.access": {"handlers": ["console"], "level": level, "propagate": False},
},
}
)
class RequestObservabilityMiddleware:
def __init__(self, app) -> None:
self.app = app
self.logger = logging.getLogger("app.access")
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {key.lower(): value for key, value in scope.get("headers", [])}
incoming_id = headers.get(b"x-request-id", b"").decode("ascii", errors="ignore").strip()
request_id = incoming_id[:64] if incoming_id else uuid4().hex
token = request_id_context.set(request_id)
started_at = perf_counter()
status_code = 500
response_finished = False
async def send_with_request_id(message) -> None:
nonlocal status_code, response_finished
if message["type"] == "http.response.start":
status_code = int(message["status"])
message.setdefault("headers", []).append((b"x-request-id", request_id.encode("ascii")))
elif message["type"] == "http.response.body" and not message.get("more_body", False):
response_finished = True
await send(message)
try:
await self.app(scope, receive, send_with_request_id)
except Exception:
self._log(scope, request_id, 500, started_at, logging.ERROR, "request_failed", exc_info=True)
raise
finally:
if response_finished:
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
request_id_context.reset(token)
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
client = scope.get("client")
self.logger.log(
level,
message,
extra={
"request_id": request_id,
"method": scope.get("method"),
"path": scope.get("path"),
"status_code": status_code,
"duration_ms": round((perf_counter() - started_at) * 1000, 2),
"client_ip": client[0] if client else "unknown",
},
**kwargs,
)

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
return {"code": 0, "message": message, "data": data}
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
return {"code": code, "message": message, "data": data}

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import os
from uuid import uuid4
import jwt
from fastapi import HTTPException, status
from app.core.config import get_settings
def create_access_token(subject: str, token_type: str = "user") -> tuple[str, datetime]:
settings = get_settings()
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": subject,
"type": token_type,
"jti": uuid4().hex,
"exp": expires_at,
"iat": datetime.now(UTC),
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return token, expires_at
def decode_access_token(token: str) -> dict:
settings = get_settings()
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc
def hash_password(password: str) -> str:
salt = os.urandom(16).hex()
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return f"pbkdf2_sha256${salt}${digest.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algorithm, salt, expected = password_hash.split("$", 2)
except ValueError:
return False
if algorithm != "pbkdf2_sha256":
return False
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return hmac.compare_digest(digest.hex(), expected)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService
import asyncio
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
SecretService.validate_production_key()
if settings.auto_create_tables:
create_tables()
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
try:
yield
finally:
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
register_exception_handlers(app)
app.add_middleware(RequestObservabilityMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
@app.get("/")
def root() -> dict[str, str]:
return {"name": settings.app_name, "status": "ok"}

View File

@@ -0,0 +1,54 @@
from app.models.admin import Admin, Role
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
Knowledge,
KnowledgeCard,
KnowledgeChunk,
KnowledgeManifest,
KnowledgePublishLog,
KnowledgeRetrievalCandidate,
KnowledgeRetrievalLog,
KnowledgeReviewRecord,
KnowledgeSection,
KnowledgeSourceSnapshot,
KnowledgeSyncJob,
KnowledgeVersion,
UserKnowledgePermission,
)
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
from app.models.user import User
__all__ = [
"Admin",
"AiRequestLog",
"Base",
"ChatMessage",
"ChatSession",
"Knowledge",
"KnowledgeCard",
"KnowledgeChunk",
"KnowledgeManifest",
"KnowledgePublishLog",
"KnowledgeRetrievalCandidate",
"KnowledgeRetrievalLog",
"KnowledgeReviewRecord",
"KnowledgeSection",
"KnowledgeSourceSnapshot",
"KnowledgeSyncJob",
"KnowledgeVersion",
"HumanAttentionHistory",
"HumanAttentionRecord",
"ModelConfig",
"OperationLog",
"LogRetentionPolicy",
"StorageSnapshot",
"Prompt",
"Role",
"SystemConfig",
"User",
"UserKnowledgePermission",
]

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Role(Base, TimestampMixin):
__tablename__ = "sys_role"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
class Admin(Base, TimestampMixin):
__tablename__ = "sys_admin"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
password: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
status: Mapped[int] = mapped_column(default=1, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
role: Mapped[Role | None] = relationship("Role", back_populates="admins")

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Index, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Prompt(Base):
__tablename__ = "sys_prompt"
__table_args__ = (Index("ix_sys_prompt_updated_at_id", "updated_at", "id"),)
id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
)
prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
change_type: Mapped[str] = mapped_column(String(20), default="save", nullable=False)
source_prompt_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class ModelConfig(Base):
__tablename__ = "sys_model"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
api_type: Mapped[str] = mapped_column(String(50), default="openai_compatible", nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
api_url: Mapped[str] = mapped_column(String(255), nullable=False)
api_key: Mapped[str] = mapped_column(Text, nullable=False)
auth_type: Mapped[str] = mapped_column(String(30), default="bearer", nullable=False)
api_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
top_p: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
top_k: Mapped[int | None] = mapped_column(Integer, nullable=True)
presence_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
frequency_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
max_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
context_window: Mapped[int | None] = mapped_column(Integer, nullable=True)
stream_enabled: Mapped[int] = mapped_column(default=1, nullable=False)
response_format: Mapped[str | None] = mapped_column(String(50), nullable=True)
extra_params: Mapped[str | None] = mapped_column(Text, nullable=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
enabled: Mapped[int] = mapped_column(default=0, nullable=False)
class SystemConfig(Base):
__tablename__ = "sys_system_config"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
config_value: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class SoftDeleteMixin:
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ChatSession(Base, TimestampMixin):
__tablename__ = "sys_chat_session"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(100), nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="chat_sessions")
messages: Mapped[list["ChatMessage"]] = relationship("ChatMessage", back_populates="session")
class ChatMessage(Base):
__tablename__ = "sys_chat_message"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
message_status: Mapped[str] = mapped_column(String(20), default="FINISHED", nullable=False)
token_input: Mapped[int | None] = mapped_column(Integer, nullable=True)
token_output: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
model_id: Mapped[int | None] = mapped_column(ForeignKey("sys_model.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages")

View File

@@ -0,0 +1,276 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class Knowledge(Base, TimestampMixin):
__tablename__ = "sys_knowledge"
__table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_space_id: Mapped[str] = mapped_column(String(100), nullable=False)
feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[int] = mapped_column(default=1, nullable=False)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
review_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
manifest_confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
current_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
pending_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
migration_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
user_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
"UserKnowledgePermission", back_populates="knowledge"
)
class UserKnowledgePermission(Base, TimestampMixin):
__tablename__ = "sys_user_kb"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
user: Mapped["User"] = relationship("User", back_populates="knowledge_permissions")
knowledge: Mapped[Knowledge] = relationship("Knowledge", back_populates="user_permissions")
class KnowledgeSourceSnapshot(Base):
__tablename__ = "sys_knowledge_source_snapshot"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
sync_job_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
source_title: Mapped[str] = mapped_column(String(255), nullable=False)
source_content: Mapped[str] = mapped_column(Text, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
source_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
class KnowledgeVersion(Base):
__tablename__ = "sys_knowledge_version"
__table_args__ = (UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
snapshot_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_source_snapshot.id"), nullable=False)
version_no: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
quality_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
change_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
quality_report: Mapped[str | None] = mapped_column(Text, nullable=True)
processing_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
published_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class KnowledgeManifest(Base):
__tablename__ = "sys_knowledge_manifest"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
purpose: Mapped[str] = mapped_column(Text, nullable=False)
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
core_topics: Mapped[str] = mapped_column(Text, nullable=False)
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
boundaries: Mapped[str | None] = mapped_column(Text, nullable=True)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
change_level: Mapped[str] = mapped_column(String(20), default="major", nullable=False)
confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
confirmed_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class KnowledgeSection(Base):
__tablename__ = "sys_knowledge_section"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_key: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
class KnowledgeChunk(Base):
__tablename__ = "sys_knowledge_chunk"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
normalized_text: Mapped[str] = mapped_column(Text, nullable=False)
keywords: Mapped[str | None] = mapped_column(Text, nullable=True)
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
class KnowledgeCard(Base):
__tablename__ = "sys_knowledge_card"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
core_conclusion: Mapped[str] = mapped_column(Text, nullable=False)
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
keywords: Mapped[str] = mapped_column(Text, nullable=False)
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
risk_level: Mapped[str] = mapped_column(String(20), default="normal", nullable=False)
source_range: Mapped[str] = mapped_column(String(100), nullable=False)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
generation_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
review_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
class KnowledgeSyncJob(Base):
__tablename__ = "sys_knowledge_sync_job"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
mode: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
stage: Mapped[str] = mapped_column(String(50), nullable=False)
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
result_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class KnowledgeReviewRecord(Base):
__tablename__ = "sys_knowledge_review_record"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
card_id: Mapped[int | None] = mapped_column(ForeignKey("sys_knowledge_card.id"), nullable=True)
action: Mapped[str] = mapped_column(String(30), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
reviewed_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class KnowledgePublishLog(Base):
__tablename__ = "sys_knowledge_publish_log"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
from_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
to_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
action: Mapped[str] = mapped_column(String(30), nullable=False)
keep_open: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class KnowledgeRetrievalLog(Base):
__tablename__ = "sys_knowledge_retrieval_log"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
question: Mapped[str] = mapped_column(Text, nullable=False)
catalog_snapshot: Mapped[str] = mapped_column(Text, nullable=False)
catalog_hash: Mapped[str] = mapped_column(String(64), nullable=False)
safety_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
knowledge_called: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
selected_knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
query_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
tool_trace: Mapped[str | None] = mapped_column(Text, nullable=True)
final_section_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
discarded_conflicts: Mapped[str | None] = mapped_column(Text, nullable=True)
final_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
tool_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
attention_created: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class KnowledgeRetrievalCandidate(Base):
__tablename__ = "sys_knowledge_retrieval_candidate"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
retrieval_log_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_retrieval_log.id"), index=True, nullable=False)
knowledge_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
version_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
chunk_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
section_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
lexical_score: Mapped[str] = mapped_column(String(30), nullable=False)
rerank_score: Mapped[str | None] = mapped_column(String(30), nullable=True)
selected: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
discard_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
class HumanAttentionRecord(Base):
__tablename__ = "sys_human_attention_record"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
message_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
trigger_message: Mapped[str] = mapped_column(Text, nullable=False)
problem_summary: Mapped[str] = mapped_column(Text, nullable=False)
trigger_reason: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
status: Mapped[str] = mapped_column(String(20), index=True, default="pending", nullable=False)
handler_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
handler_note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class HumanAttentionHistory(Base):
__tablename__ = "sys_human_attention_history"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
attention_id: Mapped[int] = mapped_column(ForeignKey("sys_human_attention_record.id"), index=True, nullable=False)
from_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
to_status: Mapped[str] = mapped_column(String(20), nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class AiRequestLog(Base):
__tablename__ = "sys_ai_request_log"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
retrieved_chunks: Mapped[str | None] = mapped_column(Text, nullable=True)
input_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class OperationLog(Base):
__tablename__ = "sys_operation_log"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
module: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
before_json: Mapped[str | None] = mapped_column(Text, nullable=True)
after_json: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class LogRetentionPolicy(Base):
__tablename__ = "sys_log_retention_policy"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_result: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
class StorageSnapshot(Base):
__tablename__ = "sys_storage_snapshot"
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
scope: Mapped[str] = mapped_column(String(50), nullable=False)
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
detail_json: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import BigInteger, Date, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "sys_user"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[int] = mapped_column(default=1, nullable=False)
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
daily_chat_reset_date: Mapped[date | None] = mapped_column(Date, nullable=True)
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
knowledge_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
"UserKnowledgePermission", back_populates="user"
)
chat_sessions: Mapped[list["ChatSession"]] = relationship("ChatSession", back_populates="user")

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.common import ORMModel
class AdminLoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=50)
password: str = Field(min_length=1, max_length=100)
class AdminLoginResponse(BaseModel):
token: str
expiredAt: datetime
admin: dict
class AdminRead(ORMModel):
id: int
username: str
name: str
status: int
class DashboardStats(BaseModel):
userCount: int
sessionCount: int
messageCount: int
aiRequestCount: int
knowledgeCount: int
inputToken: int
outputToken: int
totalToken: int
class AdminUserUpdateRequest(BaseModel):
status: int | None = Field(default=None, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class AdminUserCreateRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
name: str = Field(min_length=1, max_length=50)
nickname: str | None = Field(default=None, max_length=50)
status: int = Field(default=1, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class AdminUserImportItem(BaseModel):
phone: str = Field(default="", max_length=20)
name: str = Field(default="", max_length=50)
nickname: str | None = Field(default=None, max_length=50)
status: int = Field(default=1, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class AdminUserImportRequest(BaseModel):
students: list[AdminUserImportItem] = Field(default_factory=list)
class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100)
feishuNodeId: str = Field(min_length=1, max_length=100)
status: int = Field(default=1, ge=0, le=1)
remark: str | None = Field(default=None, max_length=255)
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
class PromptSaveRequest(BaseModel):
promptContent: str = Field(min_length=1)
class AgentDebugRequest(BaseModel):
promptContent: str = Field(min_length=1)
modelId: int = Field(gt=0)
knowledgeIds: list[int] = Field(default_factory=list)
knowledgeVersions: dict[int, int] = Field(default_factory=dict)
question: str = Field(min_length=1, max_length=2000)
temperature: float | None = Field(default=None, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int | None = Field(default=8192, ge=1, le=100000)
class AgentRuntimeConfigSaveRequest(BaseModel):
temperature: float | None = Field(default=None, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int = Field(default=8192, ge=256, le=100000)
class ModelSaveRequest(BaseModel):
provider: str = Field(min_length=1, max_length=50)
displayName: str | None = Field(default=None, max_length=100)
apiType: str = Field(default="openai_compatible", max_length=50)
modelName: str = Field(min_length=1, max_length=100)
baseUrl: str | None = Field(default=None, max_length=255)
apiUrl: str = Field(default="", max_length=255)
apiKey: str = ""
authType: str = Field(default="bearer", max_length=30)
apiVersion: str | None = Field(default=None, max_length=50)
temperature: float | None = Field(default=0.2, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int | None = Field(default=1024, ge=1, le=100000)
contextWindow: int | None = Field(default=None, ge=1, le=2000000)
streamEnabled: int = Field(default=1, ge=0, le=1)
responseFormat: str | None = Field(default=None, max_length=50)
extraParams: str | None = None
remark: str | None = Field(default=None, max_length=255)
timeoutSecond: int = Field(default=30, ge=1, le=300)
class EnableModelRequest(BaseModel):
modelId: int
class SystemConfigSaveRequest(BaseModel):
configKey: str = Field(min_length=1, max_length=100)
configValue: str
description: str | None = Field(default=None, max_length=255)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserProfile
class SendSmsRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
captchaId: str | None = Field(default=None, min_length=1, max_length=100)
captchaCode: str | None = Field(default=None, min_length=4, max_length=8)
class LoginRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
code: str = Field(min_length=4, max_length=8)
class LoginResponse(BaseModel):
token: str
expiredAt: datetime
user: UserProfile
class CaptchaResponse(BaseModel):
captchaId: str
imageBase64: str
expiresInSeconds: int

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import ORMModel
class CreateSessionResponse(BaseModel):
sessionId: int
class ChatSessionRead(ORMModel):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: int
title: str
messageCount: int = Field(alias="message_count")
updatedAt: datetime = Field(alias="updated_at")
class ChatMessageRead(ORMModel):
id: int
role: str
content: str
message_status: str
created_at: datetime
class UpdateSessionTitleRequest(BaseModel):
sessionId: int
title: str = Field(min_length=1, max_length=100)
class ChatCompletionRequest(BaseModel):
sessionId: int
message: str = Field(min_length=1, max_length=4000)
class StopChatRequest(BaseModel):
sessionId: int

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class KnowledgeSyncRequest(BaseModel):
mode: str = Field(default="review", pattern="^(review|replace)$")
class KnowledgeReviewRequest(BaseModel):
action: str = Field(pattern="^(approve|reject)$")
reason: str | None = Field(default=None, max_length=1000)
class KnowledgePublishRequest(BaseModel):
keepOpen: bool = False
class KnowledgeStatusRequest(BaseModel):
open: bool
class KnowledgeLifecycleRequest(BaseModel):
status: str = Field(pattern="^(active|archived)$")
class KnowledgeMetadataUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
knowledgeType: str = Field(pattern="^(course|qa|general)$")
reviewMode: str = Field(pattern="^(manual|automatic)$")
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$")
class AttentionUpdateRequest(BaseModel):
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
note: str | None = Field(default=None, max_length=2000)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from pydantic import Field
from app.schemas.common import ORMModel
class UserProfile(ORMModel):
id: int
phone: str
name: str
nickname: str | None = None
dailyLimit: int = Field(alias="daily_chat_limit")
todayUsed: int = Field(alias="daily_chat_used")
status: int
effective_at: datetime | None = None
expired_at: datetime | None = None
last_login_at: datetime | None = None

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import create_access_token, hash_password, verify_password
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import Knowledge
from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User
from app.services.security_state_service import SecurityStateService
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
UNSAFE_BOOTSTRAP_PASSWORDS = {
"123456",
"admin",
"admin123456",
"change-me-in-production",
"password",
"replace-with-strong-password",
}
def _is_production_env(app_env: str) -> bool:
return app_env.strip().lower() not in DEVELOPMENT_ENVS
def _get_bootstrap_admin_config() -> tuple[str, str, str]:
settings = get_settings()
username = settings.bootstrap_admin_username.strip()
password = settings.bootstrap_admin_password
name = settings.bootstrap_admin_name.strip() or username
if not username or not password:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="未配置首个管理员初始化账号,请配置 BOOTSTRAP_ADMIN_USERNAME/BOOTSTRAP_ADMIN_PASSWORD",
)
if _is_production_env(settings.app_env):
normalized_password = password.strip().lower()
if normalized_password in UNSAFE_BOOTSTRAP_PASSWORDS or len(password) < 12:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生产环境首个管理员初始化密码不安全,请重新配置 BOOTSTRAP_ADMIN_PASSWORD",
)
return username, password, name
class AdminAuthService:
@classmethod
def login(cls, db: Session, username: str, password: str, ip: str = "unknown") -> dict:
failure_key = f"auth:admin:fail:{username.lower()}:{ip}"
SecurityStateService.enforce_limit(
f"rate:admin_login:ip:{ip}", limit=30, window_seconds=600, message="登录请求过于频繁,请稍后再试"
)
SecurityStateService.ensure_not_locked(
failure_key, limit=5, message="账号或当前网络连续登录失败次数过多,请十五分钟后再试"
)
cls.ensure_bootstrap_admin(db)
admin = db.scalar(select(Admin).where(Admin.username == username))
if admin is None or not verify_password(password, admin.password):
SecurityStateService.record_failure(
failure_key,
limit=5,
lock_seconds=900,
message="账号或当前网络连续登录失败次数过多,请十五分钟后再试",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误")
if admin.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
SecurityStateService.clear(failure_key)
admin.last_login_at = datetime.now(UTC).replace(tzinfo=None)
db.add(admin)
db.commit()
db.refresh(admin)
token, expired_at = create_access_token(str(admin.id), "admin")
return {
"token": token,
"expiredAt": expired_at,
"admin": {
"id": admin.id,
"username": admin.username,
"name": admin.name,
"status": admin.status,
},
}
@staticmethod
def ensure_bootstrap_admin(db: Session) -> None:
has_admin = db.scalar(select(func.count(Admin.id))) or 0
if has_admin:
return
username, password, name = _get_bootstrap_admin_config()
admin = Admin(
username=username,
password=hash_password(password),
name=name,
status=1,
)
db.add(admin)
db.commit()
class AdminDashboardService:
@staticmethod
def stats(db: Session, start: datetime | None = None, end: datetime | None = None) -> dict:
from sqlalchemy import and_
# 基础表查询条件(时间范围)
user_filter = [User.is_deleted == 0]
session_filter = [ChatSession.is_deleted == 0]
msg_filter = []
ai_filter = []
if start:
user_filter.append(User.created_at >= start)
session_filter.append(ChatSession.created_at >= start)
msg_filter.append(ChatMessage.created_at >= start)
ai_filter.append(AiRequestLog.created_at >= start)
if end:
user_filter.append(User.created_at <= end)
session_filter.append(ChatSession.created_at <= end)
msg_filter.append(ChatMessage.created_at <= end)
ai_filter.append(AiRequestLog.created_at <= end)
return {
"userCount": db.scalar(select(func.count(User.id)).where(and_(*user_filter))) or 0,
"sessionCount": db.scalar(select(func.count(ChatSession.id)).where(and_(*session_filter))) or 0,
"messageCount": db.scalar(select(func.count(ChatMessage.id)).where(and_(*msg_filter))) or 0,
"aiRequestCount": db.scalar(select(func.count(AiRequestLog.id)).where(and_(*ai_filter))) or 0,
"knowledgeCount": db.scalar(select(func.count(Knowledge.id))) or 0,
"inputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.input_token), 0)).where(and_(*ai_filter))) or 0,
"outputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.output_token), 0)).where(and_(*ai_filter))) or 0,
"totalToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.total_token), 0)).where(and_(*ai_filter))) or 0,
}
class OperationLogService:
@staticmethod
def write(
db: Session,
*,
admin_id: int | None,
module: str,
action: str,
target_id: int | None = None,
result: str = "SUCCESS",
) -> None:
db.add(
OperationLog(
admin_id=admin_id,
module=module,
action=action,
target_id=target_id,
result=result,
)
)

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from sqlalchemy.orm import Session
from app.models.logs import AiRequestLog
from app.services.rag_service import RetrievedChunk
class AiRequestLogService:
@staticmethod
def write_success(
db: Session,
*,
session_id: int,
message_id: int | None,
user_id: int,
model_name: str,
prompt: str,
knowledge_ids: str,
retrieve_count: int,
input_token: int,
output_token: int,
cost_ms: int,
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
) -> None:
db.add(
AiRequestLog(
session_id=session_id,
message_id=message_id,
user_id=user_id,
model_name=model_name,
prompt=prompt,
knowledge_ids=knowledge_ids,
retrieve_count=retrieve_count,
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
input_token=input_token,
output_token=output_token,
total_token=input_token + output_token,
cost_ms=cost_ms,
status="SUCCESS",
)
)
@staticmethod
def write_failed(
db: Session,
*,
session_id: int,
message_id: int | None,
user_id: int,
model_name: str | None,
prompt: str | None,
knowledge_ids: str | None,
retrieve_count: int,
cost_ms: int,
error_message: str,
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
) -> None:
db.add(
AiRequestLog(
session_id=session_id,
message_id=message_id,
user_id=user_id,
model_name=model_name,
prompt=prompt,
knowledge_ids=knowledge_ids,
retrieve_count=retrieve_count,
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
cost_ms=cost_ms,
status="FAILED",
error_message=error_message,
)
)
def _dump_retrieved_chunks(chunks: Sequence[RetrievedChunk] | None) -> str | None:
if not chunks:
return None
payload = [
{
"index": index,
"knowledgeId": chunk.knowledge_id,
"knowledgeName": chunk.knowledge_name,
"title": chunk.title,
"content": chunk.content,
"sourceUrl": chunk.source_url,
}
for index, chunk in enumerate(chunks, start=1)
]
return json.dumps(payload, ensure_ascii=False)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import create_access_token
from app.models.user import User
from app.services.captcha_service import CaptchaService
from app.services.sms_code_service import SmsCodeService
from app.services.security_state_service import SecurityStateService
class AuthService:
CAPTCHA_REQUIRED_SECONDS = 60 * 60
@classmethod
def send_sms_code(
cls,
db: Session,
phone: str,
captcha_id: str | None,
captcha_code: str | None,
ip: str,
) -> None:
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
captcha_key = cls._captcha_required_key(phone)
has_captcha = bool(captcha_id or captcha_code)
if has_captcha:
if not captcha_id or not captcha_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请完整填写图形验证码")
CaptchaService.verify(captcha_id, captcha_code)
SecurityStateService.clear(captcha_key)
elif SecurityStateService.has_flag(captcha_key):
raise HTTPException(status_code=428, detail="需要完成图形验证码后再发送短信")
SecurityStateService.enforce_limit(
f"rate:sms:ip:{ip}", limit=20, window_seconds=3600, message="当前网络发送验证码过于频繁,请稍后再试"
)
SecurityStateService.enforce_limit(
f"rate:sms:phone:{phone}", limit=1, window_seconds=60, message="验证码发送过于频繁,请一分钟后再试"
)
SmsCodeService.send_code(db, phone)
SecurityStateService.mark_flag(captcha_key, cls.CAPTCHA_REQUIRED_SECONDS)
@classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
failure_key = f"auth:sms:fail:{phone}"
SecurityStateService.ensure_not_locked(
failure_key, limit=5, message="验证码连续错误次数过多,请十五分钟后重新获取"
)
try:
SmsCodeService.verify_code(db, phone, code)
except HTTPException:
SecurityStateService.mark_flag(cls._captcha_required_key(phone), cls.CAPTCHA_REQUIRED_SECONDS)
SecurityStateService.record_failure(
failure_key,
limit=5,
lock_seconds=900,
message="验证码连续错误次数过多,请十五分钟后重新获取",
)
raise
SecurityStateService.clear(failure_key)
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
now = datetime.now(UTC).replace(tzinfo=None)
user.last_login_at = now
db.add(user)
db.commit()
db.refresh(user)
token, expired_at = create_access_token(str(user.id), "user")
return {"token": token, "expiredAt": expired_at, "user": user}
@classmethod
def logout(cls, token_payload: dict) -> None:
jti = token_payload.get("jti")
if jti:
SecurityStateService.revoke_token(str(jti), token_payload.get("exp"))
@classmethod
def is_token_revoked(cls, token_payload: dict) -> bool:
jti = token_payload.get("jti")
return bool(jti and SecurityStateService.is_token_revoked(str(jti)))
@classmethod
def _get_existing_user(cls, db: Session, phone: str) -> User:
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
if user is not None:
return user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
@staticmethod
def _ensure_user_can_login(user: User) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
if user.status != 1:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
if user.effective_at and user.effective_at > now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
if user.expired_at and user.expired_at < now:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
@staticmethod
def _captcha_required_key(phone: str) -> str:
return f"auth:sms:captcha_required:{phone}"

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import base64
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from io import BytesIO
import logging
import random
import string
from uuid import uuid4
from fastapi import HTTPException, status
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from app.services.redis_client import get_sync_redis_client
logger = logging.getLogger(__name__)
CAPTCHA_EXPIRES_SECONDS = 300
CAPTCHA_WIDTH = 168
CAPTCHA_HEIGHT = 64
CAPTCHA_LENGTH = 4
@dataclass
class CaptchaRecord:
code: str
expires_at: datetime
class CaptchaService:
_records: dict[str, CaptchaRecord] = {}
@classmethod
def create(cls) -> dict:
cls._cleanup_expired()
captcha_id = str(uuid4())
code = _random_code()
if _set_redis_captcha(captcha_id, code):
cls._records.pop(captcha_id, None)
else:
cls._records[captcha_id] = CaptchaRecord(
code=code,
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
)
return {
"captchaId": captcha_id,
"imageBase64": _render_captcha_image(code),
"expiresInSeconds": CAPTCHA_EXPIRES_SECONDS,
}
@classmethod
def verify(cls, captcha_id: str, captcha_code: str) -> None:
redis_available, redis_code = _get_redis_captcha(captcha_id)
if redis_available and redis_code is not None:
if redis_code.lower() != captcha_code.strip().lower():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
_delete_redis_captcha(captcha_id)
cls._records.pop(captcha_id, None)
return
record = cls._records.get(captcha_id)
if record is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
if record.expires_at < datetime.now(UTC):
cls._records.pop(captcha_id, None)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码已过期")
if record.code.lower() != captcha_code.strip().lower():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
cls._records.pop(captcha_id, None)
@classmethod
def _cleanup_expired(cls) -> None:
now = datetime.now(UTC)
expired_ids = [captcha_id for captcha_id, record in cls._records.items() if record.expires_at < now]
for captcha_id in expired_ids:
cls._records.pop(captcha_id, None)
def _random_code() -> str:
alphabet = "".join(ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1IL")
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
def _captcha_key(captcha_id: str) -> str:
return f"auth:captcha:{captcha_id}"
def _set_redis_captcha(captcha_id: str, code: str) -> bool:
client = get_sync_redis_client()
if client is None:
return False
try:
return bool(client.set(_captcha_key(captcha_id), code, ex=CAPTCHA_EXPIRES_SECONDS))
except Exception:
logger.warning("Redis captcha write failed for %s", captcha_id, exc_info=True)
return False
def _get_redis_captcha(captcha_id: str) -> tuple[bool, str | None]:
client = get_sync_redis_client()
if client is None:
return False, None
try:
value = client.get(_captcha_key(captcha_id))
except Exception:
logger.warning("Redis captcha read failed for %s", captcha_id, exc_info=True)
return False, None
return True, value if isinstance(value, str) else None
def _delete_redis_captcha(captcha_id: str) -> None:
client = get_sync_redis_client()
if client is None:
return
try:
client.delete(_captcha_key(captcha_id))
except Exception:
logger.warning("Redis captcha delete failed for %s", captcha_id, exc_info=True)
def _render_captcha_image(code: str) -> str:
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
draw = ImageDraw.Draw(image)
rng = random.SystemRandom()
for _ in range(70):
x = rng.randint(0, CAPTCHA_WIDTH - 1)
y = rng.randint(0, CAPTCHA_HEIGHT - 1)
color = rng.choice(["#d7e5df", "#c7ddd5", "#e4ece9"])
draw.point((x, y), fill=color)
for _ in range(4):
start = (rng.randint(0, CAPTCHA_WIDTH // 2), rng.randint(0, CAPTCHA_HEIGHT))
end = (rng.randint(CAPTCHA_WIDTH // 2, CAPTCHA_WIDTH), rng.randint(0, CAPTCHA_HEIGHT))
draw.line([start, end], fill=rng.choice(["#b7cec5", "#cbdad5"]), width=1)
font = _load_font()
char_width = CAPTCHA_WIDTH // CAPTCHA_LENGTH
for index, char in enumerate(code):
layer = Image.new("RGBA", (char_width, CAPTCHA_HEIGHT), (255, 255, 255, 0))
layer_draw = ImageDraw.Draw(layer)
bbox = layer_draw.textbbox((0, 0), char, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (char_width - text_width) // 2
y = (CAPTCHA_HEIGHT - text_height) // 2 - 3
layer_draw.text((x, y), char, font=font, fill=rng.choice(["#0f735d", "#173f35", "#31564d"]))
layer = layer.rotate(rng.randint(-14, 14), resample=Image.Resampling.BICUBIC, expand=False)
image.paste(layer, (index * char_width, 0), layer)
image = image.filter(ImageFilter.SMOOTH)
output = BytesIO()
image.save(output, format="PNG")
encoded = base64.b64encode(output.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _load_font():
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/local/share/fonts/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf",
):
try:
return ImageFont.truetype(path, 38)
except OSError:
continue
logger.warning("Captcha font fallback to Pillow default font")
return ImageFont.load_default()

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from time import perf_counter
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ai_config import SystemConfig
from app.models.chat import ChatMessage, ChatSession
logger = logging.getLogger(__name__)
DEFAULT_CONTEXT_MESSAGE_COUNT = 10
MAX_CONTEXT_MESSAGE_COUNT = 100
@dataclass(frozen=True)
class SummaryUpdateResult:
trace: dict | None = None
@dataclass(frozen=True)
class _SummaryWork:
messages: list[ChatMessage]
messages_text: str
request: dict
class ChatContextService:
"""Owns the runtime policy for a session's conversational memory."""
@staticmethod
def message_limit(db: Session) -> int:
config = db.scalar(
select(SystemConfig).where(SystemConfig.config_key == "chat_context_message_count")
)
if config is None or not config.config_value.strip():
return DEFAULT_CONTEXT_MESSAGE_COUNT
try:
value = int(float(config.config_value.strip()))
except ValueError:
logger.warning(
"Invalid chat_context_message_count=%r; falling back to %s",
config.config_value,
DEFAULT_CONTEXT_MESSAGE_COUNT,
)
return DEFAULT_CONTEXT_MESSAGE_COUNT
return max(0, min(MAX_CONTEXT_MESSAGE_COUNT, value))
@classmethod
def select_memory(
cls,
db: Session,
history: list[ChatMessage] | None,
summary: str | None,
summary_up_to_message_id: int | None = None,
) -> tuple[list[ChatMessage], str | None]:
limit = cls.message_limit(db)
if limit == 0:
return [], None
recent = list((history or [])[-limit:])
if summary and summary_up_to_message_id is not None:
recent = [message for message in recent if message.id > summary_up_to_message_id]
return recent, summary
@classmethod
def update_summary(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> SummaryUpdateResult:
"""Roll older messages into one bounded summary as soon as they leave the raw window."""
work = cls._prepare_summary(db, session, history)
if work is None:
return SummaryUpdateResult()
started = perf_counter()
try:
# Local import avoids a rag_service -> context_service -> model_service cycle.
from app.services.model_service import ModelClientService
new_summary = ModelClientService.summarize_or_raise(
db,
work.messages_text,
previous_summary=session.summary,
)
return cls._apply_summary(session, work, new_summary, started)
except Exception as exc:
return cls._summary_failure(session, work, exc, started)
@classmethod
async def update_summary_async(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> SummaryUpdateResult:
work = cls._prepare_summary(db, session, history)
if work is None:
return SummaryUpdateResult()
started = perf_counter()
try:
from app.services.model_service import ModelClientService
new_summary = await ModelClientService.summarize_or_raise_async(
db,
work.messages_text,
previous_summary=session.summary,
)
return cls._apply_summary(session, work, new_summary, started)
except Exception as exc:
return cls._summary_failure(session, work, exc, started)
@classmethod
def _prepare_summary(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> _SummaryWork | None:
limit = cls.message_limit(db)
if limit == 0 or len(history) <= limit:
return None
cutoff = len(history) - limit
start_idx = cls._covered_history_index(session, history)
if cutoff <= start_idx:
return None
messages = history[start_idx:cutoff]
messages_text = cls.messages_text(messages)
if not messages_text:
return None
return _SummaryWork(
messages=messages,
messages_text=messages_text,
request={
"previousSummary": session.summary or "",
"messages": messages_text,
"messageCount": len(messages),
"contextMessageLimit": limit,
},
)
@classmethod
def _apply_summary(
cls,
session: ChatSession,
work: _SummaryWork,
summary: str,
started: float,
) -> SummaryUpdateResult:
summary = summary.strip()
if not summary:
raise ValueError("模型返回了空的历史摘要")
session.summary = summary
session.summary_up_to_message_id = work.messages[-1].id
duration_ms = int((perf_counter() - started) * 1000)
return SummaryUpdateResult(
trace=cls._trace(
work.request,
{"summary": summary, "count": len(work.messages)},
duration_ms=duration_ms,
)
)
@classmethod
def _summary_failure(
cls,
session: ChatSession,
work: _SummaryWork,
exc: Exception,
started: float,
) -> SummaryUpdateResult:
duration_ms = int((perf_counter() - started) * 1000)
logger.exception(
"Chat history summary failed for session_id=%s message_count=%s",
session.id,
len(work.messages),
exc_info=exc,
)
return SummaryUpdateResult(
trace=cls._trace(
work.request,
{"count": 0, "fallback": "recent_messages_only"},
duration_ms=duration_ms,
status="fallback",
error=str(exc)[:500],
)
)
@staticmethod
def _covered_history_index(session: ChatSession, history: list[ChatMessage]) -> int:
if session.summary_up_to_message_id is None:
return 0
for index, message in enumerate(history):
if message.id > session.summary_up_to_message_id:
return index
return len(history)
@staticmethod
def messages_text(messages: list[ChatMessage]) -> str:
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
parts: list[str] = []
for message in messages:
content = re.sub(
r"<think[\s\S]*?</think\s*>",
"",
message.content,
flags=re.IGNORECASE,
).strip()
if content:
parts.append(f"{role_labels.get(message.role, message.role)}{content}")
return "\n".join(parts)
@staticmethod
def _trace(
request: dict,
response: dict,
*,
duration_ms: int,
status: str = "success",
error: str | None = None,
) -> dict:
return {
"tool": "chat_memory_summary",
"order": 0,
"request": request,
"response": response,
**response,
"status": status,
"startedAtMs": 0,
"endedAtMs": duration_ms,
"durationMs": duration_ms,
"responseCount": response.get("count"),
"error": error,
"retries": 0,
}

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
import time
from uuid import uuid4
from redis.asyncio import Redis
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
from app.services.redis_client import get_redis_client
class RedisChatQueueManager:
_active_key = "ai_kb:chat_queue:active"
_waiting_key = "ai_kb:chat_queue:waiting"
_request_script = """
local active_key = KEYS[1]
local waiting_key = KEYS[2]
local now = tonumber(ARGV[1])
local token = ARGV[2]
local max_active = tonumber(ARGV[3])
local max_waiting = tonumber(ARGV[4])
local active_expire_at = tonumber(ARGV[5])
local key_ttl = tonumber(ARGV[6])
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
local active_count = redis.call('ZCARD', active_key)
local waiting_count = redis.call('LLEN', waiting_key)
if active_count < max_active and waiting_count == 0 then
redis.call('ZADD', active_key, active_expire_at, token)
redis.call('EXPIRE', active_key, key_ttl)
return {'acquired', token, 0, active_count + 1, 0}
end
if waiting_count >= max_waiting then
redis.call('EXPIRE', active_key, key_ttl)
redis.call('EXPIRE', waiting_key, key_ttl)
return {'rejected', token, 0, active_count, waiting_count}
end
redis.call('RPUSH', waiting_key, token)
redis.call('EXPIRE', waiting_key, key_ttl)
redis.call('EXPIRE', active_key, key_ttl)
return {'queued', token, waiting_count + 1, active_count, waiting_count + 1}
"""
_promote_script = """
local active_key = KEYS[1]
local waiting_key = KEYS[2]
local now = tonumber(ARGV[1])
local token = ARGV[2]
local max_active = tonumber(ARGV[3])
local active_expire_at = tonumber(ARGV[4])
local key_ttl = tonumber(ARGV[5])
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
local active_count = redis.call('ZCARD', active_key)
local waiting_count = redis.call('LLEN', waiting_key)
local head = redis.call('LINDEX', waiting_key, 0)
if head == token and active_count < max_active then
redis.call('LPOP', waiting_key)
redis.call('ZADD', active_key, active_expire_at, token)
redis.call('EXPIRE', active_key, key_ttl)
redis.call('EXPIRE', waiting_key, key_ttl)
return {'acquired', token, 0, active_count + 1, waiting_count - 1}
end
local position = redis.call('LPOS', waiting_key, token)
if not position then
return {'canceled', token, 0, active_count, waiting_count}
end
redis.call('EXPIRE', active_key, key_ttl)
redis.call('EXPIRE', waiting_key, key_ttl)
return {'queued', token, position + 1, active_count, waiting_count}
"""
async def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
client = _require_client()
now = time.time()
token = uuid4().hex
result = await client.eval(
self._request_script,
2,
self._active_key,
self._waiting_key,
now,
token,
config.max_active_requests,
config.max_queue_size,
now + config.active_lease_seconds,
_key_ttl(config),
)
return _queue_request(result)
async def wait_for_slot(self, token: str, config: ChatQueueConfig) -> QueueRequest:
client = _require_client()
deadline = time.monotonic() + config.queue_timeout_seconds
while True:
now = time.time()
result = await client.eval(
self._promote_script,
2,
self._active_key,
self._waiting_key,
now,
token,
config.max_active_requests,
now + config.active_lease_seconds,
_key_ttl(config),
)
request = _queue_request(result)
if request.status in {"acquired", "canceled"}:
return request
remaining = deadline - time.monotonic()
if remaining <= 0:
await self.cancel_waiter(token)
return QueueRequest(status="timeout", token=token)
await asyncio.sleep(min(0.25, remaining))
async def cancel_waiter(self, token: str) -> None:
client = _require_client()
await client.lrem(self._waiting_key, 0, token)
async def release_slot(self, token: str) -> None:
client = _require_client()
await client.zrem(self._active_key, token)
redis_chat_queue_manager = RedisChatQueueManager()
def _require_client() -> Redis:
client = get_redis_client()
if client is None:
raise RuntimeError("Redis is not configured")
return client
def _queue_request(result: list) -> QueueRequest:
status = str(result[0])
token = str(result[1]) if len(result) > 1 and result[1] else None
return QueueRequest(
status=status,
token=token,
position=int(result[2]) if len(result) > 2 else 0,
active_count=int(result[3]) if len(result) > 3 else 0,
waiting_count=int(result[4]) if len(result) > 4 else 0,
)
def _key_ttl(config: ChatQueueConfig) -> int:
return max(config.active_lease_seconds, config.queue_timeout_seconds) + 300

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from app.services.chat_queue_redis_service import redis_chat_queue_manager
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest, chat_queue_manager
from app.services.redis_client import ping_redis
@dataclass
class ChatQueueLease:
backend: str
request: QueueRequest
async def request_chat_slot(config: ChatQueueConfig) -> ChatQueueLease:
if await ping_redis():
try:
return ChatQueueLease("redis", await redis_chat_queue_manager.request_slot(config))
except Exception:
pass
request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
return ChatQueueLease("memory", request)
async def wait_for_chat_slot(lease: ChatQueueLease, config: ChatQueueConfig) -> ChatQueueLease:
token = lease.request.token
if lease.backend == "redis" and isinstance(token, str):
try:
lease.request = await redis_chat_queue_manager.wait_for_slot(token, config)
return lease
except Exception:
await cancel_chat_waiter(lease)
lease.backend = "memory"
lease.request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
if lease.request.status == "queued":
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, lease.request.token, config)
return lease
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, token, config)
return lease
async def cancel_chat_waiter(lease: ChatQueueLease) -> None:
token = lease.request.token
if lease.backend == "redis" and isinstance(token, str):
try:
await redis_chat_queue_manager.cancel_waiter(token)
except Exception:
pass
return
if token is not None:
await asyncio.to_thread(chat_queue_manager.cancel_waiter, token)
async def release_chat_slot(lease: ChatQueueLease) -> None:
token = lease.request.token
if lease.backend == "redis" and isinstance(token, str):
try:
await redis_chat_queue_manager.release_slot(token)
except Exception:
pass
return
await asyncio.to_thread(chat_queue_manager.release_slot)

View File

@@ -0,0 +1,154 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from threading import Condition
import time
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.ai_config import SystemConfig
@dataclass(frozen=True)
class ChatQueueConfig:
max_active_requests: int
max_queue_size: int
queue_timeout_seconds: int
active_lease_seconds: int
@dataclass(frozen=True)
class QueueRequest:
status: str
token: object | None = None
position: int = 0
active_count: int = 0
waiting_count: int = 0
class ChatQueueManager:
def __init__(self) -> None:
self._condition = Condition()
self._active_count = 0
self._waiters: deque[object] = deque()
def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
with self._condition:
if self._active_count < config.max_active_requests and not self._waiters:
self._active_count += 1
return QueueRequest(
status="acquired",
active_count=self._active_count,
waiting_count=len(self._waiters),
)
if len(self._waiters) >= config.max_queue_size:
return QueueRequest(
status="rejected",
active_count=self._active_count,
waiting_count=len(self._waiters),
)
token = object()
self._waiters.append(token)
return QueueRequest(
status="queued",
token=token,
position=len(self._waiters),
active_count=self._active_count,
waiting_count=len(self._waiters),
)
def wait_for_slot(self, token: object, config: ChatQueueConfig) -> QueueRequest:
deadline = time.monotonic() + config.queue_timeout_seconds
with self._condition:
while True:
if self._waiters and self._waiters[0] is token and self._active_count < config.max_active_requests:
self._waiters.popleft()
self._active_count += 1
self._condition.notify_all()
return QueueRequest(
status="acquired",
active_count=self._active_count,
waiting_count=len(self._waiters),
)
remaining = deadline - time.monotonic()
if remaining <= 0:
try:
self._waiters.remove(token)
except ValueError:
pass
self._condition.notify_all()
return QueueRequest(
status="timeout",
active_count=self._active_count,
waiting_count=len(self._waiters),
)
self._condition.wait(timeout=remaining)
def cancel_waiter(self, token: object) -> None:
with self._condition:
try:
self._waiters.remove(token)
except ValueError:
return
self._condition.notify_all()
def release_slot(self) -> None:
with self._condition:
if self._active_count > 0:
self._active_count -= 1
self._condition.notify_all()
chat_queue_manager = ChatQueueManager()
def load_chat_queue_config(db: Session) -> ChatQueueConfig:
settings = get_settings()
return ChatQueueConfig(
max_active_requests=_config_int(
db,
"chat_max_active_requests",
settings.chat_max_active_requests,
minimum=1,
maximum=1000,
),
max_queue_size=_config_int(
db,
"chat_max_queue_size",
settings.chat_max_queue_size,
minimum=0,
maximum=10000,
),
queue_timeout_seconds=_config_int(
db,
"chat_queue_timeout_seconds",
settings.chat_queue_timeout_seconds,
minimum=1,
maximum=3600,
),
active_lease_seconds=_config_int(
db,
"chat_active_lease_seconds",
settings.chat_active_lease_seconds,
minimum=60,
maximum=86400,
),
)
def _config_int(db: Session, key: str, default: int, *, minimum: int, maximum: int) -> int:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
if config is None or not config.config_value.strip():
return default
try:
value = int(float(config.config_value.strip()))
except ValueError:
return default
return max(minimum, min(maximum, value))

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
from datetime import UTC, datetime
from time import perf_counter
from zoneinfo import ZoneInfo
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.external_errors import ExternalServiceError
from app.services.chat_context_service import ChatContextService
from app.services.model_service import ModelClientService
from app.services.rag_service import RagService
class ChatService:
@staticmethod
def create_session(db: Session, user: User) -> ChatSession:
now = _now()
session = ChatSession(
user_id=user.id,
title="新聊天",
message_count=0,
last_message_at=now,
is_deleted=0,
)
db.add(session)
db.commit()
db.refresh(session)
return session
@staticmethod
def list_sessions(db: Session, user: User) -> list[ChatSession]:
return list(
db.scalars(
select(ChatSession)
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)
.order_by(ChatSession.updated_at.desc())
)
)
@staticmethod
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]:
session = ChatService._get_user_session(db, user, session_id)
return list(
db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session.id, ChatMessage.user_id == user.id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
@staticmethod
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
session = ChatService._get_user_session(db, user, session_id)
session.title = title.strip()
db.add(session)
db.commit()
db.refresh(session)
return session
@staticmethod
def delete_session(db: Session, user: User, session_id: int) -> None:
session = ChatService._get_user_session(db, user, session_id)
session.is_deleted = 1
db.add(session)
db.commit()
@staticmethod
def create_answer(db: Session, user: User, session_id: int, question: str) -> str:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user)
now = _now()
normalized_question = question.strip()
user_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="user",
content=normalized_question,
message_status="FINISHED",
created_at=now,
)
db.add(user_message)
db.flush()
started_at = perf_counter()
rag_result = None
try:
# 获取历史消息(不含刚插入的 user_message它还没 flush id
history = list(
db.scalars(
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
# 构建 prompt传入历史 + 摘要)
rag_result = RagService.build_result(
db, user, normalized_question,
history=history,
session_summary=session.summary,
summary_up_to_message_id=session.summary_up_to_message_id,
context_trace=context_trace,
)
completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000)
AiRequestLogService.write_failed(
db,
session_id=session.id,
message_id=user_message.id,
user_id=user.id,
model_name=None,
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms,
error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
cost_ms = int((perf_counter() - started_at) * 1000)
assistant_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="assistant",
content=completion.answer,
message_status="FINISHED",
token_input=completion.input_token,
token_output=completion.output_token,
response_time_ms=cost_ms,
model_id=completion.model_id,
created_at=now,
)
db.add(assistant_message)
db.flush()
session.message_count += 2
session.last_message_at = now
if session.title == "新聊天":
session.title = _title_from_question(normalized_question)
user.daily_chat_used += 1
db.add_all([session, user])
AiRequestLogService.write_success(
db,
session_id=session.id,
message_id=assistant_message.id,
user_id=user.id,
model_name=completion.model_name,
prompt=rag_result.prompt,
knowledge_ids=rag_result.knowledge_ids,
retrieve_count=len(rag_result.chunks),
input_token=completion.input_token,
output_token=completion.output_token,
cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks,
)
db.commit()
return completion.answer
@staticmethod
def stop_generation(db: Session, user: User, session_id: int) -> None:
ChatService._get_user_session(db, user, session_id)
@staticmethod
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession:
session = db.get(ChatSession, session_id)
if session is None or session.user_id != user.id or session.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
return session
@staticmethod
def _maybe_update_summary(db: Session, session: ChatSession, history: list[ChatMessage]) -> None:
ChatContextService.update_summary(db, session, history)
@staticmethod
def _ensure_quota(user: User) -> None:
if user.daily_chat_used >= user.daily_chat_limit:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完")
@staticmethod
def prepare_daily_quota(db: Session, user: User) -> User:
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update())
if locked_user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
if locked_user.daily_chat_reset_date != today:
locked_user.daily_chat_used = 0
locked_user.daily_chat_reset_date = today
db.add(locked_user)
db.flush()
return locked_user
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _title_from_question(question: str) -> str:
title = question.strip().replace("\n", " ")
return title[:20] if title else "新聊天"

View File

@@ -0,0 +1,391 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Iterator
from datetime import UTC, datetime
from inspect import signature
from time import perf_counter
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage
from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService
from app.services.external_errors import ExternalServiceError
from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService
from app.services.rag_async_service import AsyncRagService
from app.services.rag_service import RagService
class ChatStreamService:
@staticmethod
def stream_answer(db: Session, user: User, session_id: int, question: str) -> Iterator[str]:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user)
now = _now()
normalized_question = question.strip()
user_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="user",
content=normalized_question,
message_status="FINISHED",
created_at=now,
)
db.add(user_message)
db.flush()
history = list(
db.scalars(
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
started_at = perf_counter()
rag_result = None
model_response = None
answer_parts: list[str] = []
try:
rag_result = _build_rag_result(
db,
user,
normalized_question,
history,
getattr(session, "summary", None),
getattr(session, "summary_up_to_message_id", None),
context_trace,
)
model_response = ModelStreamService.stream(db, rag_result)
for chunk in model_response.chunks:
if chunk:
answer_parts.append(chunk)
yield chunk
except GeneratorExit:
db.rollback()
raise
except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000)
AiRequestLogService.write_failed(
db,
session_id=session.id,
message_id=user_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else None,
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms,
error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
answer = "".join(answer_parts)
if not answer.strip():
cost_ms = int((perf_counter() - started_at) * 1000)
AiRequestLogService.write_failed(
db,
session_id=session.id,
message_id=user_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else None,
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms,
error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
cost_ms = int((perf_counter() - started_at) * 1000)
assistant_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="assistant",
content=answer,
message_status="FINISHED",
token_input=model_response.input_token if model_response is not None else None,
token_output=_rough_token_count(answer),
response_time_ms=cost_ms,
model_id=model_response.model_id if model_response is not None else None,
created_at=now,
)
db.add(assistant_message)
db.flush()
session.message_count += 2
session.last_message_at = now
if session.title == "新聊天":
session.title = _title_from_question(normalized_question)
user.daily_chat_used += 1
db.add_all([session, user])
AiRequestLogService.write_success(
db,
session_id=session.id,
message_id=assistant_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else "unknown",
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
input_token=model_response.input_token if model_response is not None else 0,
output_token=_rough_token_count(answer),
cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
db.commit()
@staticmethod
async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]:
user = ChatService.prepare_daily_quota(db, user)
session = ChatService._get_user_session(db, user, session_id)
ChatService._ensure_quota(user)
now = _now()
normalized_question = question.strip()
user_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="user",
content=normalized_question,
message_status="FINISHED",
created_at=now,
)
db.add(user_message)
db.flush()
history = list(
db.scalars(
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
summary_result = await ChatContextService.update_summary_async(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
started_at = perf_counter()
rag_result = None
model_response = None
answer_parts: list[str] = []
try:
rag_result = await AsyncRagService.build_result(
db,
user,
normalized_question,
history=history,
session_summary=getattr(session, "summary", None),
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
session_id=session.id,
context_trace=context_trace,
)
model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks:
if chunk:
answer_parts.append(chunk)
yield chunk
except asyncio.CancelledError:
db.rollback()
raise
except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000)
AiRequestLogService.write_failed(
db,
session_id=session.id,
message_id=user_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else None,
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms,
error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
answer = "".join(answer_parts)
if not answer.strip():
cost_ms = int((perf_counter() - started_at) * 1000)
AiRequestLogService.write_failed(
db,
session_id=session.id,
message_id=user_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else None,
prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms,
error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
_write_success(
db,
user=user,
session=session,
question=normalized_question,
answer=answer,
rag_result=rag_result,
model_response=model_response,
started_at=started_at,
now=now,
)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2)
def _write_success(
db: Session,
*,
user: User,
session,
question: str,
answer: str,
rag_result,
model_response,
started_at: float,
now: datetime,
) -> None:
cost_ms = int((perf_counter() - started_at) * 1000)
assistant_message = ChatMessage(
session_id=session.id,
user_id=user.id,
role="assistant",
content=answer,
message_status="FINISHED",
token_input=model_response.input_token if model_response is not None else None,
token_output=_rough_token_count(answer),
response_time_ms=cost_ms,
model_id=model_response.model_id if model_response is not None else None,
created_at=now,
)
db.add(assistant_message)
db.flush()
session.message_count += 2
session.last_message_at = now
if session.title == "新聊天":
session.title = _title_from_question(question)
user.daily_chat_used += 1
db.add_all([session, user])
AiRequestLogService.write_success(
db,
session_id=session.id,
message_id=assistant_message.id,
user_id=user.id,
model_name=model_response.model_name if model_response is not None else "unknown",
prompt=rag_result.prompt if rag_result is not None else question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
input_token=model_response.input_token if model_response is not None else 0,
output_token=_rough_token_count(answer),
cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
)
if rag_result is not None and rag_result.retrieval_log_id:
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
if retrieval_log:
attention = HumanAttentionService.create_if_needed(
db,
session_id=session.id,
message_id=user_message_id_for_attention(db, session.id, assistant_message.id),
user_id=user.id,
question=question,
answer=answer,
knowledge_missing=not rag_result.allow_general_knowledge and not rag_result.is_hit,
)
retrieval_log.message_id = assistant_message.id
retrieval_log.final_answer = answer
retrieval_log.status = "success"
retrieval_log.total_cost_ms = cost_ms
retrieval_log.attention_created = 1 if attention else 0
db.add(retrieval_log)
db.commit()
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
message_id = db.scalar(
select(ChatMessage.id)
.where(
ChatMessage.session_id == session_id,
ChatMessage.role == "user",
ChatMessage.id < assistant_message_id,
)
.order_by(ChatMessage.id.desc())
.limit(1)
)
return int(message_id or assistant_message_id)
def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms: int) -> None:
if rag_result is None or not rag_result.retrieval_log_id:
return
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
if retrieval_log:
retrieval_log.status = "failed"
retrieval_log.error_message = error_message
retrieval_log.total_cost_ms = cost_ms
db.add(retrieval_log)
def _build_rag_result(
db: Session,
user: User,
question: str,
history: list[ChatMessage],
summary: str | None,
summary_up_to_message_id: int | None,
context_trace: list[dict] | None = None,
):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:
return RagService.build_result(
db,
user,
question,
history=history,
session_summary=summary,
summary_up_to_message_id=summary_up_to_message_id,
context_trace=context_trace,
)
return RagService.build_result(db, user, question)

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
class ExternalServiceError(RuntimeError):
def __init__(self, message: str, *, provider: str) -> None:
super().__init__(message)
self.provider = provider

View File

@@ -0,0 +1,436 @@
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
import httpx
from sqlalchemy.orm import Session
from app.services.external_errors import ExternalServiceError
from app.services.feishu_service import (
FEISHU_OPEN_API,
FeishuKnowledgeService,
FeishuRetrievalConfig,
_build_search_payload,
_feishu_retrieval_config,
_parse_docx_content,
_parse_search_chunks,
_response_error_detail,
_token_cache,
_token_cache_key,
)
from app.services.knowledge_service import KnowledgeScope
from app.services.redis_cache_service import redis_get_json, redis_get_text, redis_set_json, redis_set_text
if TYPE_CHECKING:
from app.services.rag_service import RetrievedChunk
class AsyncFeishuKnowledgeService:
"""飞书知识库异步检索服务,保持和同步检索相同的结果结构。"""
@classmethod
async def retrieve(
cls,
question: str,
scopes: list[KnowledgeScope],
db: Session | None = None,
) -> list[RetrievedChunk]:
if not scopes:
return []
config = _feishu_retrieval_config(db)
if config.search_url:
try:
return await cls._retrieve_from_search(question, scopes, config)
except ExternalServiceError as exc:
try:
return await cls._retrieve_from_feishu(question, scopes, config)
except ExternalServiceError as fallback_exc:
raise ExternalServiceError(
f"{exc};飞书直读也失败:{fallback_exc}",
provider="feishu",
) from fallback_exc
return await cls._retrieve_from_feishu(question, scopes, config)
@classmethod
async def _retrieve_from_search(
cls,
question: str,
scopes: list[KnowledgeScope],
config: FeishuRetrievalConfig,
) -> list[RetrievedChunk]:
from app.services.rag_service import RetrievedChunk
payload = _build_search_payload(question, scopes)
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
for _ in range(max(1, config.retry_count + 1)):
try:
response = await client.post(config.search_url, json=payload)
response.raise_for_status()
body = response.json()
return _parse_search_chunks(body, scopes, RetrievedChunk)
except (ValueError, httpx.HTTPError) as exc:
last_error = exc
raise ExternalServiceError(f"飞书搜索接口调用失败:{last_error}", provider="feishu-search")
@classmethod
async def _retrieve_from_feishu(
cls,
question: str,
scopes: list[KnowledgeScope],
config: FeishuRetrievalConfig,
) -> list[RetrievedChunk]:
from app.services.rag_service import RetrievedChunk
if not config.app_id or not config.app_secret:
raise ExternalServiceError(
"未配置飞书应用凭证,无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET"
"或在系统设置中填写飞书 AppID/AppSecret。",
provider="feishu",
)
semaphore = asyncio.Semaphore(3)
async def load_scope(scope: KnowledgeScope):
async with semaphore:
return await cls._load_documents(scope, config)
results = await asyncio.gather(*(load_scope(scope) for scope in scopes), return_exceptions=True)
all_chunks: list[tuple[KnowledgeScope, str, str, str | None]] = []
load_errors: list[str] = []
for scope, result in zip(scopes, results, strict=False):
if isinstance(result, ExternalServiceError):
load_errors.append(f"{scope.name}{result}")
continue
if isinstance(result, Exception):
load_errors.append(f"{scope.name}{result}")
continue
for title, content, source_url in result:
all_chunks.append((scope, title, content, source_url))
if not all_chunks:
if load_errors:
raise ExternalServiceError(
"飞书知识库读取失败:" + "".join(load_errors[:3]),
provider="feishu",
)
return []
scored_chunks = FeishuKnowledgeService._score_and_rank(question, all_chunks)
return [
RetrievedChunk(
knowledge_id=scope.id,
knowledge_name=scope.name,
title=title,
content=content,
source_url=source_url,
)
for scope, title, content, source_url in scored_chunks[:5]
]
@classmethod
async def _load_documents(
cls,
scope: KnowledgeScope,
config: FeishuRetrievalConfig,
) -> list[tuple[str, str, str | None]]:
node_id = scope.feishu_node_id
base_url = "https://zcn7hk6n047c.feishu.cn"
if not node_id:
return []
try:
node_info = await cls._get_node_info_with_cache(node_id, config)
except ExternalServiceError as node_exc:
try:
content = await cls._get_document_content_with_cache(node_id, config)
return [(node_id, content, f"{base_url}/docx/{node_id}")]
except ExternalServiceError as docx_exc:
raise ExternalServiceError(
f"飞书节点读取失败:{node_exc};按普通 docx token 读取也失败:{docx_exc}",
provider="feishu",
) from docx_exc
obj_type = node_info.get("obj_type", "")
obj_token = node_info.get("obj_token", "")
title = node_info.get("title", node_id)
space_id = node_info.get("space_id", scope.feishu_space_id)
if obj_type == "docx":
content = await cls._get_document_content_with_cache(obj_token, config)
return [(title, content, f"{base_url}/wiki/{node_id}")]
if obj_type == "file":
content = await cls._get_file_content_with_cache(obj_token, config)
return [(title, content, f"{base_url}/wiki/{node_id}")]
children = await cls._get_wiki_children_with_cache(space_id, node_id, config)
documents: list[tuple[str, str, str | None]] = []
for child in children:
child_node_token = child.get("node_token", "")
child_obj_type = child.get("obj_type", "")
child_obj_token = child.get("obj_token", "")
child_title = child.get("title", "")
try:
if child_obj_type == "docx" and child_obj_token:
content = await cls._get_document_content_with_cache(child_obj_token, config)
elif child_obj_type == "file" and child_obj_token:
content = await cls._get_file_content_with_cache(child_obj_token, config)
else:
continue
documents.append((child_title, content, f"{base_url}/wiki/{child_node_token}"))
except ExternalServiceError:
continue
if obj_type == "docx" and obj_token:
try:
content = await cls._get_document_content_with_cache(obj_token, config)
documents.insert(0, (title, content, f"{base_url}/wiki/{node_id}"))
except ExternalServiceError:
pass
return documents
@classmethod
async def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict:
cache_key = f"{config.app_id}:node_info:{node_token}"
redis_key = _redis_cache_key(config, "node", node_token)
now = time.time()
if cache_key in FeishuKnowledgeService._content_cache:
cached_data, ts = FeishuKnowledgeService._content_cache[cache_key]
if now - ts < FeishuKnowledgeService._CACHE_TTL:
return eval(cached_data) # noqa: S307
cached_info = await redis_get_json(redis_key)
if isinstance(cached_info, dict):
FeishuKnowledgeService._content_cache[cache_key] = (str(cached_info), now)
return cached_info
info = await _get_wiki_node_info_async(node_token, config)
FeishuKnowledgeService._content_cache[cache_key] = (str(info), now)
await redis_set_json(redis_key, info, FeishuKnowledgeService._CACHE_TTL)
return info
@classmethod
async def _get_document_content_with_cache(cls, doc_token: str, config: FeishuRetrievalConfig) -> str:
cache_key = f"{config.app_id}:doc:{doc_token}"
redis_key = _redis_cache_key(config, "doc", doc_token)
now = time.time()
if cache_key in FeishuKnowledgeService._content_cache:
content, ts = FeishuKnowledgeService._content_cache[cache_key]
if now - ts < FeishuKnowledgeService._CACHE_TTL:
return content
cached_content = await redis_get_text(redis_key)
if cached_content:
FeishuKnowledgeService._content_cache[cache_key] = (cached_content, now)
return cached_content
content = await _get_docx_content_async(doc_token, config)
FeishuKnowledgeService._content_cache[cache_key] = (content, now)
await redis_set_text(redis_key, content, FeishuKnowledgeService._CACHE_TTL)
return content
@classmethod
async def _get_file_content_with_cache(cls, file_token: str, config: FeishuRetrievalConfig) -> str:
cache_key = f"{config.app_id}:file:{file_token}"
redis_key = _redis_cache_key(config, "file", file_token)
now = time.time()
if cache_key in FeishuKnowledgeService._content_cache:
content, ts = FeishuKnowledgeService._content_cache[cache_key]
if now - ts < FeishuKnowledgeService._CACHE_TTL:
return content
cached_content = await redis_get_text(redis_key)
if cached_content:
FeishuKnowledgeService._content_cache[cache_key] = (cached_content, now)
return cached_content
content = await _get_file_content_async(file_token, config)
FeishuKnowledgeService._content_cache[cache_key] = (content, now)
await redis_set_text(redis_key, content, FeishuKnowledgeService._CACHE_TTL)
return content
@classmethod
async def _get_wiki_children_with_cache(
cls,
space_id: str,
node_token: str,
config: FeishuRetrievalConfig,
) -> list[dict]:
cache_key = f"{config.app_id}:children:{space_id}:{node_token}"
redis_key = _redis_cache_key(config, "children", space_id, node_token)
now = time.time()
if cache_key in FeishuKnowledgeService._content_cache:
cached_data, ts = FeishuKnowledgeService._content_cache[cache_key]
if now - ts < FeishuKnowledgeService._CACHE_TTL:
return eval(cached_data) # noqa: S307
cached_children = await redis_get_json(redis_key)
if isinstance(cached_children, list):
FeishuKnowledgeService._content_cache[cache_key] = (str(cached_children), now)
return cached_children
children = await _get_wiki_children_async(space_id, node_token, config)
FeishuKnowledgeService._content_cache[cache_key] = (str(children), now)
await redis_set_json(redis_key, children, FeishuKnowledgeService._CACHE_TTL)
return children
async def _get_tenant_token_async(config: FeishuRetrievalConfig) -> str:
now = time.time()
cache_key = _token_cache_key(config.app_id, config.app_secret)
redis_key = _redis_cache_key(config, "tenant_token")
cached = _token_cache.get(cache_key)
if cached and cached["token"] and now < cached["expires_at"]:
return cached["token"]
cached_token = await redis_get_text(redis_key)
if cached_token:
_token_cache[cache_key] = {"token": cached_token, "expires_at": now + 300}
return cached_token
url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
payload = {"app_id": config.app_id, "app_secret": config.app_secret}
try:
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ExternalServiceError(
f"获取飞书Token失败{data.get('msg', '未知错误')}",
provider="feishu",
)
token = data["tenant_access_token"]
expire = data.get("expire", 7200)
_token_cache[cache_key] = {"token": token, "expires_at": now + expire - 300}
await redis_set_text(redis_key, token, max(60, int(expire) - 300))
return token
except httpx.HTTPError as exc:
raise ExternalServiceError(f"获取飞书Token网络错误{exc}", provider="feishu") from exc
async def _feishu_get_async(path: str, *, params: dict | None = None, config: FeishuRetrievalConfig) -> dict:
token = await _get_tenant_token_async(config)
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
try:
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
resp = await client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
raise ExternalServiceError(
f"飞书HTTP请求失败 [{path}]{exc.response.status_code} {_response_error_detail(exc.response)}",
provider="feishu",
) from exc
except httpx.HTTPError as exc:
raise ExternalServiceError(f"飞书网络请求失败 [{path}]{exc}", provider="feishu") from exc
if data.get("code") != 0:
raise ExternalServiceError(
f"飞书API调用失败 [{path}]{data.get('msg', '未知错误')}",
provider="feishu",
)
return data
async def _feishu_download_async(path: str, config: FeishuRetrievalConfig) -> bytes:
token = await _get_tenant_token_async(config)
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
try:
async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
return resp.content
except httpx.HTTPStatusError as exc:
raise ExternalServiceError(
f"飞书文件下载失败 [{path}]{exc.response.status_code} {_response_error_detail(exc.response)}",
provider="feishu",
) from exc
except httpx.HTTPError as exc:
raise ExternalServiceError(f"飞书文件下载网络错误 [{path}]{exc}", provider="feishu") from exc
async def _get_docx_content_async(doc_token: str, config: FeishuRetrievalConfig) -> str:
path = f"/open-apis/docx/v1/documents/{doc_token}/blocks"
items: list[dict] = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": 500}
if page_token:
params["page_token"] = page_token
data = await _feishu_get_async(path, params=params, config=config)
page = data.get("data", {})
items.extend(page.get("items", []))
page_token = str(page.get("page_token") or "")
if not page.get("has_more") or not page_token:
break
return _parse_docx_content(items)
async def _get_file_content_async(file_token: str, config: FeishuRetrievalConfig) -> str:
content = await _feishu_download_async(f"/open-apis/drive/v1/files/{file_token}/download", config)
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return content.decode(encoding).strip()
except UnicodeDecodeError:
continue
raise ExternalServiceError("飞书文件不是可解析的文本内容", provider="feishu")
async def _get_wiki_node_info_async(node_token: str, config: FeishuRetrievalConfig) -> dict:
data = await _feishu_get_async("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token}, config=config)
node = data.get("data", {}).get("node", {})
return {
"space_id": node.get("space_id", ""),
"obj_token": node.get("obj_token", ""),
"obj_type": node.get("obj_type", ""),
"title": node.get("title", ""),
"node_token": node.get("node_token", ""),
}
async def _get_wiki_children_async(
space_id: str,
node_token: str,
config: FeishuRetrievalConfig,
page_size: int = 50,
) -> list[dict]:
if not space_id:
raise ExternalServiceError("飞书知识库缺少 SpaceID无法读取子节点", provider="feishu")
all_children = []
page_token = ""
while True:
params: dict[str, Any] = {"page_size": page_size, "parent_node_token": node_token}
if page_token:
params["page_token"] = page_token
data = await _feishu_get_async(f"/open-apis/wiki/v2/spaces/{space_id}/nodes", params=params, config=config)
children = data.get("data", {}).get("items", [])
all_children.extend(children)
page_token = data.get("data", {}).get("page_token", "")
if not page_token:
break
return all_children
def _redis_cache_key(config: FeishuRetrievalConfig, *parts: str) -> str:
namespace = _token_cache_key(config.app_id, config.app_secret)
suffix = ":".join(str(part) for part in parts)
return f"ai_kb:feishu:{namespace}:{suffix}"

Some files were not shown because too many files have changed in this diff Show More