# 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/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. 外部系统通过 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 隔离） |
| `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`。