|
|
@@ -0,0 +1,296 @@
|
|
|
+# AI 药典助手 — 项目交接文档
|
|
|
+
|
|
|
+> 最后更新: 2026-07-24
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、项目概述
|
|
|
+
|
|
|
+基于《中华人民共和国药典》(2025年版)的 AI 智能问答系统,支持文本/图片/视频多模态查询,面向药师、医生、患者提供药品信息检索和用药指导。
|
|
|
+
|
|
|
+**在线地址:** https://pharmacopoeia.kailin.com.cn
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、技术栈
|
|
|
+
|
|
|
+| 层 | 技术 | 说明 |
|
|
|
+|---|------|------|
|
|
|
+| 前端 | 单页 HTML + JS | `static/index.html`,无框架 |
|
|
|
+| 后端 | Java 21 + Spring Boot 3 | 端口 9000 |
|
|
|
+| 数据库 | PostgreSQL 16 + pgvector | 向量检索 |
|
|
|
+| 缓存 | Redis 7 | QA 缓存 + Embedding 缓存 |
|
|
|
+| LLM | 阿里云百炼 DashScope | Qwen 模型 |
|
|
|
+| 部署 | Docker Compose | Nginx 反向代理 |
|
|
|
+| 数据管道 | Python | docx_ingest.py, embed_only.py |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、目录结构
|
|
|
+
|
|
|
+```
|
|
|
+/opt/pharmacopoeia-ai/
|
|
|
+├── static/ # 前端静态页面
|
|
|
+│ └── index.html # 主页面(需复制到部署目录)
|
|
|
+├── backend-java/ # Java 后端
|
|
|
+│ ├── src/main/java/com/pharmacopoeia/
|
|
|
+│ │ ├── controller/ # ChatController, DrugController, AuthController
|
|
|
+│ │ ├── service/ # LLMService, RetrieverService, PromptService, QACacheService
|
|
|
+│ │ ├── repository/ # JPA Repository
|
|
|
+│ │ ├── entity/ # Drug, Message, Conversation, User
|
|
|
+│ │ ├── security/ # JwtAuthFilter, RateLimitFilter, JwtUtil
|
|
|
+│ │ └── config/ # AuthProperties, DashScopeRateLimitProperties
|
|
|
+│ └── pom.xml
|
|
|
+├── data-pipeline/ # 数据导入管道
|
|
|
+│ ├── docx_ingest.py # DOCX 解析入库
|
|
|
+│ ├── embed_only.py # 向量化
|
|
|
+│ └── import_all.py # 全量导入
|
|
|
+├── deploy/ # 部署配置
|
|
|
+│ ├── docker-compose.yml # PostgreSQL + Redis
|
|
|
+│ ├── nginx-pharmacopoeia.conf
|
|
|
+│ └── supervisord-java.conf
|
|
|
+├── tools/
|
|
|
+│ └── java-deploy.sh # 启停脚本
|
|
|
+├── .env # 环境变量(生产环境)
|
|
|
+└── docs/ # 文档
|
|
|
+ ├── HANDOVER.md # 项目交接文档
|
|
|
+ └── DEPLOY.md # 上线部署 SOP(每次部署必须参考)
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、部署架构
|
|
|
+
|
|
|
+```
|
|
|
+用户 → Nginx(:80) → /api/* → Java(:9000)
|
|
|
+ → / → static/index.html
|
|
|
+
|
|
|
+Java(:9000) → PostgreSQL(:5432) # 数据存储 + 向量检索
|
|
|
+ → Redis(:6379) # QA 缓存 + Embedding 缓存
|
|
|
+ → DashScope API # 阿里云百炼 LLM
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、环境变量 (.env)
|
|
|
+
|
|
|
+```bash
|
|
|
+# --- 核心 ---
|
|
|
+SERVER_PORT=9000
|
|
|
+POSTGRES_PASSWORD=pharma2025
|
|
|
+QWEN_API_KEY=sk-xxx # 必填,阿里云百炼
|
|
|
+QWEN_MODEL=qwen3.7-max
|
|
|
+
|
|
|
+# --- 鉴权 ---
|
|
|
+AUTH_ENABLED=false # 上线后改为 true
|
|
|
+JWT_SECRET=YourSuperSecretKey... # 生产环境更换
|
|
|
+
|
|
|
+# --- 限流 ---
|
|
|
+RATE_LIMIT_PER_MINUTE=200
|
|
|
+RATE_LIMIT_PER_HOUR=5000
|
|
|
+RATE_LIMIT_PER_DAY=200
|
|
|
+
|
|
|
+# --- 百炼 API 全局限流(客户确认后启用)---
|
|
|
+DASHSCOPE_RATE_LIMIT_ENABLED=false
|
|
|
+DASHSCOPE_RATE_LIMIT_PER_MINUTE=100
|
|
|
+DASHSCOPE_RATE_LIMIT_PER_HOUR=2000
|
|
|
+DASHSCOPE_RATE_LIMIT_PER_DAY=50000
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、部署命令
|
|
|
+
|
|
|
+> **每次部署必须参考《上线部署 SOP》(docs/DEPLOY.md)**,包含完整的部署前 Checklist、部署后验证和回滚流程。
|
|
|
+
|
|
|
+```bash
|
|
|
+# 启动/停止/重启
|
|
|
+./tools/java-deploy.sh start
|
|
|
+./tools/java-deploy.sh stop
|
|
|
+./tools/java-deploy.sh restart
|
|
|
+
|
|
|
+# 一键部署(自动拉代码 + 编译 + 备份 + 重启)
|
|
|
+./tools/java-deploy.sh deploy
|
|
|
+
|
|
|
+# 回滚到上一个备份
|
|
|
+./tools/java-deploy.sh rollback
|
|
|
+
|
|
|
+# 查看状态
|
|
|
+./tools/java-deploy.sh status
|
|
|
+
|
|
|
+# 查看日志
|
|
|
+./tools/java-deploy.sh logs # 最近 100 行
|
|
|
+./tools/java-deploy.sh logf # 实时跟踪
|
|
|
+```
|
|
|
+
|
|
|
+**备份说明:** `deploy` 命令会在 `backend-java/backups/` 目录下按时间戳保存备份 JAR,自动保留最近 5 个。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、API 接口
|
|
|
+
|
|
|
+### 认证
|
|
|
+| 方法 | 路径 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| POST | `/api/v1/auth/guest` | 获取 guest token |
|
|
|
+| POST | `/api/v1/auth/login/wechat` | 微信登录 |
|
|
|
+
|
|
|
+### 聊天
|
|
|
+| 方法 | 路径 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| POST | `/api/v1/chat/ask` | 文本问答(非流式) |
|
|
|
+| POST | `/api/v1/chat/stream` | 文本问答(SSE 流式) |
|
|
|
+| POST | `/api/v1/chat/ask-image` | 图片问答 |
|
|
|
+| POST | `/api/v1/chat/stream-image` | 图片问答(流式) |
|
|
|
+| POST | `/api/v1/chat/ask-multimodal` | 多模态问答(文本+图片+视频) |
|
|
|
+| POST | `/api/v1/chat/stream-multimodal` | 多模态问答(流式) |
|
|
|
+| GET | `/api/v1/chat/recent-messages?limit=50` | 恢复最近对话 |
|
|
|
+| GET | `/api/v1/chat/history` | 对话历史列表 |
|
|
|
+| POST | `/api/v1/chat/feedback` | 提交反馈 |
|
|
|
+
|
|
|
+### 药品库
|
|
|
+| 方法 | 路径 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| GET | `/api/v1/drug/search?page=1&pageSize=20&keyword=` | 药品搜索 |
|
|
|
+| GET | `/api/v1/drug/{drugId}` | 药品详情 |
|
|
|
+| GET | `/api/v1/drug/category/tree` | 分类树 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 八、鉴权机制
|
|
|
+
|
|
|
+| 配置 | 值 | 说明 |
|
|
|
+|------|-----|------|
|
|
|
+| `auth.enabled=false` | 开发模式 | 所有接口放行 |
|
|
|
+| `auth.enabled=true` | 生产模式 | `/api/**` 需要 Bearer token |
|
|
|
+
|
|
|
+**Token 流程:**
|
|
|
+1. 前端页面加载 → 自动调 `/api/v1/auth/guest` 获取 guest token
|
|
|
+2. 发送消息时无 token → 弹登录框(微信小程序内跳转登录页)
|
|
|
+3. API 返回 401 → 前端自动弹登录框
|
|
|
+
|
|
|
+**公共路径(无需 token):** `/health`, `/api/v1/auth/**`, `/static/**`, `/`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 九、限流机制
|
|
|
+
|
|
|
+| 层级 | 配置 | 作用 |
|
|
|
+|------|------|------|
|
|
|
+| 用户侧 | `rate-limit.*` | 每用户/IP 的聊天频率限制 |
|
|
|
+| 百炼 API | `dashscope.rate-limit.*` | 全局 LLM 调用量控制(默认关闭) |
|
|
|
+
|
|
|
+仅对 POST `/chat/` 接口生效,药品库查询不限制。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十、QA 缓存
|
|
|
+
|
|
|
+- 同一问题(规范化文本)24 小时内只调一次 LLM
|
|
|
+- 缓存存 Redis,key: `aiyaodian:qa:{sha256}`
|
|
|
+- 并发控制:同一问题同时到达的请求,只有第一个调 LLM,其余等待
|
|
|
+- 清缓存:`redis-cli KEYS "aiyaodian:qa:*" | xargs redis-cli DEL`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十一、数据库
|
|
|
+
|
|
|
+### 核心表
|
|
|
+| 表 | 用途 |
|
|
|
+|----|------|
|
|
|
+| `drugs` | 药品主表(含 sections JSONB) |
|
|
|
+| `drug_chunks` | 药品分块 + pgvector 向量 |
|
|
|
+| `conversations` | 对话会话 |
|
|
|
+| `messages` | 对话消息(含 user_key 隔离) |
|
|
|
+| `users` | 用户表 |
|
|
|
+
|
|
|
+### 用户隔离
|
|
|
+- 登录用户:`user_key = JWT subject`
|
|
|
+- 匿名用户:`user_key = ip:xxx.xxx.xxx.xxx`
|
|
|
+
|
|
|
+### 数据库迁移
|
|
|
+```sql
|
|
|
+-- 最近新增字段
|
|
|
+ALTER TABLE conversations ADD COLUMN IF NOT EXISTS user_key VARCHAR(128);
|
|
|
+ALTER TABLE messages ADD COLUMN IF NOT EXISTS user_key VARCHAR(128);
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十二、前端(static/index.html)
|
|
|
+
|
|
|
+### 功能
|
|
|
+- AI 对话:文本 + 图片/视频上传识别
|
|
|
+- 药品库:分页浏览、搜索、详情查看
|
|
|
+- 本地持久化:IndexedDB(128MB/500条),微信 WebView 降级为后端恢复
|
|
|
+- 登录:微信小程序内跳转登录,桌面端弹框提示
|
|
|
+
|
|
|
+### 小屏适配
|
|
|
+`@media(max-width:400px)` 针对 iPhone SE、小米 12 等设备缩小按钮和间距
|
|
|
+
|
|
|
+### 来源明细去重
|
|
|
+后端 `buildSources()` 两层去重:映射前原始去重 + 映射后别名去重
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十三、药品库数据
|
|
|
+
|
|
|
+- 数据来源:2025年版《中国药典》一至四部 DOCX 文件
|
|
|
+- 解析脚本:`data-pipeline/docx_ingest.py`
|
|
|
+- 排除类别:通则、凡例与纲要、凡例、概要、生物制品(默认不显示)
|
|
|
+- 药品清单:`docs/all_drugs.txt`(共 6316 种)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十四、常见问题
|
|
|
+
|
|
|
+**Q: 缓存未生效?**
|
|
|
+清 Redis 缓存后重启:`docker exec pharmacopoeia-redis redis-cli KEYS "aiyaodian:qa:*" | xargs redis-cli DEL`
|
|
|
+
|
|
|
+**Q: 分页不正常?**
|
|
|
+前端参数名必须是 `pageSize`(驼峰),不是 `page_size`。后端 `@RequestParam` 严格匹配。
|
|
|
+
|
|
|
+**Q: 微信 WebView 无历史记录?**
|
|
|
+IndexedDB 可能被清理,前端会自动降级调 `/api/v1/chat/recent-messages` 从后端恢复。
|
|
|
+
|
|
|
+**Q: 对话框按钮被遮挡?**
|
|
|
+已添加 `@media(max-width:400px)` 小屏适配,如仍有问题可调整媒体查询断点。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十五、后续待办
|
|
|
+
|
|
|
+- [ ] 上线 `auth.enabled=true`,配置 `AUTH_ENABLED` 环境变量
|
|
|
+- [ ] 微信小程序跳转登录页联调
|
|
|
+- [ ] 百炼 API 限流值客户确认后启用
|
|
|
+- [ ] 定期清理过期对话数据(messages 表)
|
|
|
+- [ ] 监控 DashScope API 调用量
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 十六、已知问题记录
|
|
|
+
|
|
|
+### 16.1 HttpServletRequest 代理对象线程安全问题(已修复 2026-07-24)
|
|
|
+
|
|
|
+**现象:** SSE 流式回答写完后,日志出现:
|
|
|
+```
|
|
|
+IllegalStateException: No thread-bound request found
|
|
|
+```
|
|
|
+调用链:`Reactor doOnComplete 回调 → getCurrentUserKey() → request.getRemoteAddr() → RequestContextHolder 抛出异常`
|
|
|
+
|
|
|
+**根因:** `HttpServletRequest` 以构造注入方式注入 Controller,Spring 注入的是代理对象(`RequestObjectFactory`),每次方法调用都通过 `RequestContextHolder`(ThreadLocal)查找当前线程绑定的 request。Reactor 回调运行在 reactor-http-nio 线程上,原始请求线程已释放,ThreadLocal 中不再有 request。
|
|
|
+
|
|
|
+**修复:** Controller 所有请求方法入口处提前调用 `getCurrentUserKey()` 获取 plain `String`,在 Reactor lambda 中只使用已捕获的 `final` 局部变量,绝不在回调线程中访问 `request` 代理对象。
|
|
|
+
|
|
|
+### 16.2 SSE 流式结束后 AccessDeniedException(已修复 2026-07-24)
|
|
|
+
|
|
|
+**现象:** SSE 流式 (`/api/v1/chat/stream`) 完成时日志出现:
|
|
|
+```
|
|
|
+AccessDeniedException: Access Denied
|
|
|
+ at AuthorizationFilter.doFilter
|
|
|
+```
|
|
|
+堆栈中出现 `AsyncContextImpl$AsyncRunnable.run`,前端收到网络异常。
|
|
|
+
|
|
|
+**根因:** Spring MVC SSE 响应走 Servlet 3.0 异步模式。流式写入完成后,Tomcat 触发异步分发(Async Dispatch)通知框架。`JwtAuthFilter extends OncePerRequestFilter` 默认跳过异步分发(`shouldNotFilterAsyncDispatch()` 返回 `true`),导致 `SecurityContext` 为空。后续 `AuthorizationFilter` 检查时找不到认证信息,抛出 `AccessDeniedException`。走 `ExceptionTranslationFilter` → `GlobalExceptionHandler` → 前端 SSE 连接中断 → 显示网络异常。
|
|
|
+
|
|
|
+**修复:** `JwtAuthFilter` 覆写 `shouldNotFilterAsyncDispatch()` 返回 `false`,让异步分发时也重新解析 JWT 并设置 `SecurityContext`。
|