浏览代码

部署文档和交接文档提交

liuchengsen 1 月之前
父节点
当前提交
fd1e237b14
共有 3 个文件被更改,包括 486 次插入20 次删除
  1. 121 0
      docs/DEPLOY.md
  2. 296 0
      docs/HANDOVER.md
  3. 69 20
      tools/java-deploy.sh

+ 121 - 0
docs/DEPLOY.md

@@ -0,0 +1,121 @@
+# 上线部署 SOP(Standard Operating Procedure)
+
+> 本文件是每次部署的唯一依据,执行前必须完整阅读并遵守。
+
+---
+
+## 零、核心原则
+
+1. **不影响现有功能** — 改动必须向后兼容,已有的 API、前端行为一个都不能破
+2. **增量提交** — 每次只改一个关注点,小步 commit,便于定位和回滚
+3. **快速回滚** — 任何时候出问题,3 分钟内回滚到上一个正常版本
+4. **部署前自检,部署后观察** — 绝不跳过任何一步
+
+---
+
+## 一、部署前 Checklist
+
+在 `./tools/java-deploy.sh deploy` 之前,逐项确认:
+
+- [ ] 代码已 push 到 git 仓库
+- [ ] 编译通过(本地或 CI)
+- [ ] 确认没有新增 `AccessDeniedException` 或 `No thread-bound request` 等已知异常
+- [ ] 对关键接口心中有数:`/health`、`/api/v1/chat/stream`、`/api/v1/drug/search`
+- [ ] 已知回滚路径(见第五节)
+- [ ] 如果是数据库变更,已确认向前兼容
+
+---
+
+## 二、标准上线步骤
+
+```bash
+# Step 1: 确认当前状态
+./tools/java-deploy.sh status
+
+# Step 2: 一键部署(自动拉代码、编译、备份、重启)
+./tools/java-deploy.sh deploy
+
+# Step 3: 确认服务正常
+curl -s http://localhost:9000/health | python3 -m json.tool
+
+# Step 4: 检查启动日志无异常
+./tools/java-deploy.sh logs 50
+
+# Step 5: 等待 2-3 分钟,观察日志无新的 ERROR
+sleep 180
+tail -50 /var/log/pharmacopoeia/backend.log | grep -i "error\|exception\|access denied"
+```
+
+## 三、部署后验证
+
+| 检查项 | 命令 | 预期 |
+|--------|------|------|
+| 健康检查 | `curl -s http://localhost:9000/health` | 200 OK |
+| 药品库搜索 | `curl -s 'http://localhost:9000/api/v1/drug/search?keyword=布洛芬'` | 返回数据 |
+| 对话流式 | 用浏览器访问首页,发送一条问题 | SSE 正常走完,无网络异常 |
+| 日志无异常 | `tail -100 /var/log/pharmacopoeia/backend.log \| grep -i "error\|exception"` | 无新增报错 |
+
+---
+
+## 四、部署脚本说明
+
+所有操作统一用 `./tools/java-deploy.sh`,不手动操作 jar 文件或进程。
+
+| 命令 | 用途 |
+|------|------|
+| `./tools/java-deploy.sh status` | 查看运行状态 |
+| `./tools/java-deploy.sh deploy` | 一键上线(pull → build → backup → restart) |
+| `./tools/java-deploy.sh rollback` | 回滚到上一个备份 |
+| `./tools/java-deploy.sh rollback <path>` | 回滚到指定备份文件 |
+| `./tools/java-deploy.sh restart` | 重启(不重新编译) |
+| `./tools/java-deploy.sh logs` | 最近 100 行日志 |
+| `./tools/java-deploy.sh logf` | 实时日志跟踪 |
+
+`deploy` 命令会自动:
+- 拉取 git 最新代码
+- 编译打包
+- **备份当前 JAR** 到 `backend-java/backups/` 目录(按时间戳命名)
+- 保留最近 5 个备份,自动清理更早的
+
+---
+
+## 五、紧急回滚
+
+```bash
+# 方式 1: 自动回滚(使用最新备份)
+./tools/java-deploy.sh rollback
+
+# 方式 2: 回滚到指定备份
+./tools/java-deploy.sh rollback /opt/pharmacopoeia-ai/backend-java/backups/pharmacopoeia-ai-20260724-143000.jar
+
+# 方式 3: 回退 git commit 后重新部署
+cd /opt/pharmacopoeia-ai
+git revert <坏commit> --no-edit
+./tools/java-deploy.sh deploy
+```
+
+**回滚决策:**
+- 用户反馈功能异常 → 立即回滚
+- 日志 5 分钟内出现 5+ ERROR → 立即回滚
+- 仅 UI 展示问题 → 评估是否需要回滚,优先热修复
+
+---
+
+## 六、数据库变更(额外谨慎)
+
+任何表结构或索引变更,必须:
+
+- [ ] 先在 dev 环境验证
+- [ ] 使用 `IF NOT EXISTS`(向前兼容)
+- [ ] 不删除列、不重命名列(除非确认无依赖)
+- [ ] 变更完成后验证关键查询正常
+
+---
+
+## 七、禁止事项
+
+- ❌ 跳过部署前 Checklist
+- ❌ 手动 `kill` 进程或直接替换 jar(用脚本)
+- ❌ 生产环境用 `-SNAPSHOT` 版本
+- ❌ 不做备份就部署
+- ❌ 部署后不观察日志就离开

+ 296 - 0
docs/HANDOVER.md

@@ -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`。

+ 69 - 20
tools/java-deploy.sh

@@ -1,7 +1,7 @@
 #!/bin/bash
 # ============================================
 # 中华药典 AI Agent — Java 后端部署脚本
-# 用法: ./java-deploy.sh {start|stop|restart|status|logs|build|deploy}
+# 用法: ./java-deploy.sh {start|stop|restart|status|logs|build|deploy|rollback}
 # ============================================
 
 set -e
@@ -199,7 +199,7 @@ build() {
 }
 
 # ============================================
-# 一键部署(拉代码 → 编译 → 重启)
+# 一键部署(拉代码 → 编译 → 备份 → 重启)
 # ============================================
 
 deploy() {
@@ -224,7 +224,54 @@ deploy() {
 
     echo "✅ 编译完成: $(ls -lh "$JAR_FILE" | awk '{print $5}')"
 
+    # 备份当前 JAR
+    BACKUP_DIR="$APP_DIR/backups"
+    mkdir -p "$BACKUP_DIR"
+    if [ -f "$JAR_FILE" ]; then
+        BACKUP_FILE="$BACKUP_DIR/pharmacopoeia-ai-$(date +%Y%m%d-%H%M%S).jar"
+        cp "$JAR_FILE" "$BACKUP_FILE"
+        echo "📦 已备份: $BACKUP_FILE"
+
+        # 保留最近 5 个备份
+        ls -t "$BACKUP_DIR"/*.jar 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
+    fi
+
+    echo ""
+    restart
+}
+
+# ============================================
+# 回滚
+# ============================================
+
+rollback() {
+    BACKUP_DIR="$APP_DIR/backups"
+
+    if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then
+        echo "❌ 没有可用的备份"
+        return 1
+    fi
+
+    echo "可用备份:"
+    ls -lht "$BACKUP_DIR"/*.jar 2>/dev/null | head -10
+
+    echo ""
+    if [ -n "$1" ]; then
+        ROLLBACK_FILE="$1"
+    else
+        ROLLBACK_FILE=$(ls -t "$BACKUP_DIR"/*.jar 2>/dev/null | head -1)
+        echo "回滚到最新备份: $(basename "$ROLLBACK_FILE")"
+    fi
+
+    if [ ! -f "$ROLLBACK_FILE" ]; then
+        echo "❌ 备份文件不存在: $ROLLBACK_FILE"
+        return 1
+    fi
+
     echo ""
+    echo "⏪ 回滚..."
+    cp "$ROLLBACK_FILE" "$JAR_FILE"
+    echo "✅ 已替换为: $(basename "$ROLLBACK_FILE")"
     restart
 }
 
@@ -233,29 +280,31 @@ deploy() {
 # ============================================
 
 case "${1:-start}" in
-    start)   start ;;
-    stop)    stop ;;
-    restart) restart ;;
-    status)  status ;;
-    logs)    logs "${2:-100}" ;;
-    logf)    logs -f ;;
-    build)   build ;;
-    deploy)  deploy ;;
+    start)    start ;;
+    stop)     stop ;;
+    restart)  restart ;;
+    status)   status ;;
+    logs)     logs "${2:-100}" ;;
+    logf)     logs -f ;;
+    build)    build ;;
+    deploy)   deploy ;;
+    rollback) rollback "$2" ;;
     help|--help|-h)
-        echo "用法: $0 {start|stop|restart|status|logs|logf|build|deploy}"
+        echo "用法: $0 {start|stop|restart|status|logs|logf|build|deploy|rollback}"
         echo ""
-        echo "  start    启动后端"
-        echo "  stop     停止后端"
-        echo "  restart  重启后端"
-        echo "  status   查看状态"
-        echo "  logs     查看最近 100 行日志"
-        echo "  logf     实时跟踪日志"
-        echo "  build    拉代码 + 编译"
-        echo "  deploy   拉代码 + 编译 + 重启(一键上线)"
+        echo "  start     启动后端"
+        echo "  stop      停止后端"
+        echo "  restart   重启后端"
+        echo "  status    查看状态"
+        echo "  logs      查看最近 100 行日志"
+        echo "  logf      实时跟踪日志"
+        echo "  build     拉代码 + 编译"
+        echo "  deploy    拉代码 + 编译 + 备份 + 重启(一键上线)"
+        echo "  rollback  回滚到上一个备份(可指定备份文件路径)"
         exit 0
         ;;
     *)
-        echo "用法: $0 {start|stop|restart|status|logs|logf|build|deploy}"
+        echo "用法: $0 {start|stop|restart|status|logs|logf|build|deploy|rollback}"
         exit 1
         ;;
 esac