# AI 药典助手 — 项目交接文档 > 最后更新: 2026-07-29 --- ## 一、项目概述 基于《中华人民共和国药典》(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, Brand, BrandRecommendRule │ │ ├── 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/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` | 分类树 | ### 品牌推荐 | 方法 | 路径 | 说明 | |------|------|------| | - | 品牌详情字段 | brand_id, brand_name, matched_keyword, function, usage_dosage, contraindication, ingredients, properties, specification, adverse_reactions, precautions, execution_standard, storage, jump_url | ### 管理端(需 JWT) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/admin/knowledge/brands` | 品牌列表(分页+搜索) | | POST | `/api/v1/admin/knowledge/brands` | 新增品牌 | | PUT | `/api/v1/admin/knowledge/brands/{id}` | 修改品牌 | | DELETE | `/api/v1/admin/knowledge/brands/{id}` | 删除品牌 | | POST | `/api/v1/admin/knowledge/brands/import` | 品牌 CSV 导入 | | GET | `/api/v1/admin/knowledge/brands/export` | 品牌 CSV 导出 | | GET | `/api/v1/admin/knowledge/brands/template` | 下载导入模板 | | GET | `/api/v1/admin/knowledge/brand-recommend-rules` | 规则列表(分页+搜索) | | POST | `/api/v1/admin/knowledge/brand-recommend-rules` | 新增规则 | | PUT | `/api/v1/admin/knowledge/brand-recommend-rules/{id}` | 修改规则 | | PATCH | `/api/v1/admin/knowledge/brand-recommend-rules/{id}/toggle` | 启用/停用切换 | | DELETE | `/api/v1/admin/knowledge/brand-recommend-rules/{id}` | 删除规则 | | POST | `/api/v1/admin/knowledge/brand-recommend-rules/import` | 规则 CSV 导入 | | GET | `/api/v1/admin/knowledge/brand-recommend-rules/export` | 规则 CSV 导出 | | GET | `/api/v1/admin/knowledge/brand-recommend-rules/template` | 下载导入模板 | --- ## 八、鉴权机制 | 配置 | 值 | 说明 | |------|-----|------| | `auth.enabled=false` | 开发模式 | 所有接口放行 | | `auth.enabled=true` | 生产模式 | `/api/**` 需要 Bearer token | **Token 流程:** 1. 外部系统通过 URL 参数 `?token=xxx` 传入 JWT,前端写入 localStorage 2. 发送消息时无 token → 弹登录框(微信小程序内跳转登录页,或跳转外部 `login_url`) 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 隔离、brand_recommendations JSONB 持久化) | | `brands` | 品牌药品详情(长文本信息 + 跳转链接) | | `brand_recommend_rules` | 品牌推荐匹配规则(关键词 → 品牌 ID 映射 + 层级) | | `users` | 用户表 | ### 用户隔离 - 登录用户:`user_key = JWT subject` - 匿名用户:`user_key = ip:xxx.xxx.xxx.xxx` ### 数据库迁移 ```sql -- 品牌推荐功能(2026-07-29) -- 执行: psql -h -U -d pharmacopoeia -f database/migrate_brand_recommend.sql -- 创建 brands 表、brand_recommend_rules 表(含外键),messages 加 brand_recommendations jsonb -- 最近新增字段 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 调用量 - [ ] 运营管理端对接品牌管理和匹配规则 CRUD 接口 --- ## 十六、品牌推荐功能(2026-07-29 新增) ### 架构 ``` 用户提问 → RAG检索 → LLM生成回答(SSE流) → buildSources提取药名 → BrandRecommendService 分层关键词匹配 → SSE 注入 brand_recommend 事件 → 结果持久化到 messages.brand_recommendations JSONB ``` ### 两张新表 ``` brands (品牌表) ├── id, name ├── 长文本: function_indication, usage_dosage, contraindication, │ ingredients, properties, specification, adverse_reactions, │ precautions, execution_standard, storage ├── jump_url — 跳转链接 ├── description — 简要描述 ├── sort_order — 排序 └── is_active — 启用开关 brand_recommend_rules (匹配规则表) ├── id ├── keyword — 匹配关键词 ├── brand_id — FK → brands.id ├── tier — 优先级(1最高) └── is_active — 启用开关 ``` ### 匹配逻辑 1. 从 `sources[].name` 提取药名集合 2. 按 tier 升序(1→2→3…)逐层匹配:source name **包含** keyword 3. 命中即停止,不再尝试更低 tier 4. 所有 tier 的 source 匹配都无命中 → 回退到全文匹配(fullAnswer 包含 keyword),同样按 tier 逐层 5. 同层级内去重,返回匹配到的规则对应的品牌详情(含所有长文本 + jump_url) ### SSE 事件顺序 ``` token → token → ... → brand_recommend → meta → 完成 ``` `brand_recommend` 事件数据格式: ```json { "recommendations": [ { "brand_id": 1, "matched_keyword": "布洛芬", "tier": 1, "brand_name": "芬必得布洛芬缓释胶囊", "function": "用于缓解轻至中度疼痛...", "usage_dosage": "口服。成人一次1粒...", "jump_url": "", ... } ] } ``` ### 历史持久化 品牌推荐结果在 SSE 流结束后写入 `messages.brand_recommendations`(JSONB 列),用户翻看历史对话时直接从该字段读取,不受后续规则表变更影响。`getConversationDetail` 和 `getRecentMessages` 接口已包含该字段。 ### 部署前注意 部署前必须执行 `database/migrate_brand_recommend.sql` 创建新表和新列。该脚本使用 `CREATE TABLE IF NOT EXISTS` 和 `ADD COLUMN IF NOT EXISTS`,可重复执行。 --- ## 十七、已知问题记录 ### 17.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` 代理对象。 ### 17.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`。