最后更新: 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
# --- 核心 ---
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、部署后验证和回滚流程。
# 启动/停止/重启
./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 个。
| 方法 | 路径 | 说明 |
|---|---|---|
| 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 |
| 方法 | 路径 | 说明 |
|---|---|---|
| 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 流程:
?token=xxx 传入 JWT,前端写入 localStoragelogin_url)公共路径(无需 token): /health, /api/v1/auth/**, /static/**, /
| 层级 | 配置 | 作用 |
|---|---|---|
| 用户侧 | rate-limit.* |
每用户/IP 的聊天频率限制 |
| 百炼 API | dashscope.rate-limit.* |
全局 LLM 调用量控制(默认关闭) |
仅对 POST /chat/ 接口生效,药品库查询不限制。
aiyaodian:qa:{sha256}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 subjectuser_key = ip:xxx.xxx.xxx.xxx-- 品牌推荐功能(2026-07-29)
-- 执行: psql -h <host> -U <user> -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);
@media(max-width:400px) 针对 iPhone SE、小米 12 等设备缩小按钮和间距
后端 buildSources() 两层去重:映射前原始去重 + 映射后别名去重
data-pipeline/docx_ingest.pydocs/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 环境变量用户提问 → 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 — 启用开关
sources[].name 提取药名集合token → token → ... → brand_recommend → meta → 完成
brand_recommend 事件数据格式:
{
"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,可重复执行。
现象: 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 代理对象。
现象: 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。