# SciLit Oncology — 生产部署文档

## 目录

1. [前置准备](#1-前置准备)
2. [服务器基础环境](#2-服务器基础环境)
3. [中间件安装](#3-中间件安装)
4. [域名与SSL](#4-域名与ssl)
5. [应用部署](#5-应用部署)
6. [数据库初始化](#6-数据库初始化)
7. [第三方服务注册](#7-第三方服务注册)
8. [上线验证清单](#8-上线验证清单)
9. [日常运维](#9-日常运维)
10. [应急预案](#10-应急预案)

---

## 1. 前置准备

### 1.1 你需要准备好的东西

| 项目 | 说明 | 在哪里获取 |
|------|------|----------|
| ☁️ 服务器 | Linux (Ubuntu 22.04 推荐), 2C4G 起步，50G SSD | 阿里云/腾讯云/AWS |
| 🔑 SSH 密钥 | 用于免密登录服务器 | `ssh-keygen` 本地生成，公钥上传到云控制台 |
| 🌐 域名 | `oncolit.gonsun.com` 或你的域名 | 阿里云/腾讯云/Namecheap |
| 📧 邮箱 | 用于 SMTP 发件和系统通知 | 阿里云邮件推送 / SendGrid |

### 1.2 本地先确认项目能跑

```bash
cd d:/ClaudeCode/backend
python scripts/seed_data.py
uvicorn app.main:app --port 8000

cd d:/ClaudeCode/frontend
npm run build
```

---

## 2. 服务器基础环境

### 2.1 登录服务器

```bash
ssh root@<你的服务器IP>

# 创建非 root 用户
adduser scilit
usermod -aG sudo scilit
su - scilit
```

### 2.2 更新系统 + 安装基础工具

```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git vim htop net-tools ufw

# 防火墙：只开放需要的端口
sudo ufw allow 22    # SSH
sudo ufw allow 80    # HTTP
sudo ufw allow 443   # HTTPS
sudo ufw enable
```

### 2.3 安装 Docker

```bash
curl -fsSL https://get.docker.com | sudo bash
sudo usermod -aG docker $USER
newgrp docker

# 安装 Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# 验证
docker --version
docker-compose --version
```

### 2.4 安装 Nginx（宿主机，用于反向代理和 SSL）

```bash
sudo apt install -y nginx certbot python3-certbot-nginx
```

---

## 3. 中间件安装

### 3.1 方案选择

| 方案 | 适用场景 | 运维成本 |
|------|---------|:--:|
| **A: 阿里云 RDS + Redis + ES** | 有预算，想省运维 | 低 |
| **B: Docker Compose 自建全部** | 小规模起步，成本敏感 | 中 |

下面按**方案 B（自建）**编写。方案 A 请参考各云服务商的快速入门文档。

### 3.2 PostgreSQL（自建，使用 pgvector 镜像支持未来向量搜索）

Docker Compose 中已定义，无需额外操作：

```yaml
# docker-compose.prod.yml 中
postgres:
  image: pgvector/pgvector:pg16
  environment:
    POSTGRES_DB: scilit
    POSTGRES_USER: scilit
    POSTGRES_PASSWORD: ${PG_PASSWORD}  # 在 .env 中设置
  volumes:
    - /data/postgres:/var/lib/postgresql/data  # 持久化到宿主机
  restart: unless-stopped
```

### 3.3 Redis（自建）

```yaml
redis:
  image: redis:7-alpine
  command: redis-server --appendonly yes  # 开启持久化
  volumes:
    - /data/redis:/data
  restart: unless-stopped
```

### 3.4 Elasticsearch（可选，搜索增强）

```yaml
elasticsearch:
  image: elasticsearch:8.11.0
  environment:
    discovery.type: single-node
    xpack.security.enabled: "false"
    "ES_JAVA_OPTS": "-Xms512m -Xmx512m"
    bootstrap.memory_lock: "true"
  volumes:
    - /data/es:/usr/share/elasticsearch/data
  restart: unless-stopped
  ulimits:
    memlock: { soft: -1, hard: -1 }
```

> 小规模起步可以先不启 ES（`ES_URL` 为空时系统自动回退 PostgreSQL tsvector 搜索），等数据量达到 200-300 万条再启。启动方式：取消注释 docker-compose 中的 `elasticsearch` 服务段，然后运行 `python scripts/es_index.py` 全量重建索引。

### 3.5 MinIO（对象存储，存储用户文件 + 文献 PDF）

MinIO 是兼容 AWS S3 协议的自托管对象存储，用于存储用户头像、上传文件以及未来的文献 PDF 原文。

```yaml
minio:
  image: minio/minio:latest
  command: server /data --console-address ":9001"
  environment:
    MINIO_ROOT_USER: minioadmin        # 在 .env 中用 MINIO_ROOT_USER 覆盖
    MINIO_ROOT_PASSWORD: minioadmin    # 在 .env 中用 MINIO_ROOT_PASSWORD 覆盖
  volumes:
    - /data/minio:/data
  restart: unless-stopped
```

> MinIO Web 控制台端口为 9001，API 端口为 9000。生产环境不建议对外暴露端口，通过后端代理访问。首次部署后登录控制台创建 `scilit-files` 桶（或后端自动创建）。

#### MinIO vs 阿里云 OSS 对比

| | MinIO（自建） | 阿里云 OSS |
|--|:--:|:--:|
| 成本 | 仅占磁盘空间 | ¥0.12/GB/月 + 流量费 |
| 延迟 | ~0.1ms（内网） | 2-5ms |
| 运维 | 需管理磁盘 | 托管 |
| 带宽 | 受服务器带宽限制 | 不限速 |
| 适用场景 | 小规模起步、内部使用 | 大规模、需 CDN 分发 |

> **决策记录（2026-07-09）：** 先用 MinIO 自托。主要考量：用户量初期不大、PDF 存储主要为内部解析 + 用户自行下载（需 VPN），MinIO 内网延迟更低，省云费用。

### 3.5 关于 Elasticsearch

docker-compose.prod.yml 中已配置 ES 8.11.0 容器，但**生产环境默认不启用**（`ES_URL` 为空时系统自动回退 PostgreSQL tsvector 搜索）。如需启用，在 `.env` 中设置 `ES_URL=http://elasticsearch:9200` 即可。

> ES 服务至少需要 2GB 内存（JVM heap 512MB-1GB），小规模部署（<200 万条文献）不需要 ES。

---

## 4. 域名与SSL

### 4.1 DNS 解析

在域名服务商后台添加 A 记录：

```
类型    主机记录    记录值
A       @          你的服务器IP
A       www        你的服务器IP
```

### 4.2 Nginx 配置

**方案 A（推荐）：前端 Nginx 容器** — `docker-compose.prod.yml` 中 `frontend` 服务使用 `Dockerfile.prod`（基于 Nginx），自动处理静态文件托管 + API 反向代理。无需宿主机 Nginx。

```bash
# docker-compose.prod.yml 启动后，frontend 容器监听 80 端口
# 宿主机只需安装 Nginx 做 SSL 终止和域名转发：
sudo apt install -y nginx certbot python3-certbot-nginx
```

```nginx
# /etc/nginx/sites-available/scilit-oncology
server {
    listen 80;
    server_name oncolit.gonsun.com www.oncolit.gonsun.com;

    location / {
        proxy_pass http://127.0.0.1:80;  # 转发到 frontend Nginx 容器
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

**方案 B（不推荐）：宿主机 Nginx 直接托管前端文件**

```bash
sudo vim /etc/nginx/sites-available/scilit-oncology
```

```nginx
# /etc/nginx/sites-available/scilit-oncology
server {
    listen 80;
    server_name oncolit.gonsun.com www.oncolit.gonsun.com;

    # 前端静态文件
    location / {
        root /home/scilit/sci-lit-manager/frontend/dist;
        try_files $uri $uri/ /index.html;
    }

    # API 反代到 Docker 容器内的 FastAPI
    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
    }

    # WebSocket
    location /api/v1/ws/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }

    # Swagger 文档
    location /docs {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
    }

    # 静态资源缓存
    location /assets/ {
        root /home/scilit/sci-lit-manager/frontend/dist;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}
```

```bash
# 启用站点
sudo ln -s /etc/nginx/sites-available/scilit-oncology /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```

### 4.3 SSL 证书（Let's Encrypt 免费）

```bash
sudo certbot --nginx -d oncolit.gonsun.com -d www.oncolit.gonsun.com
# 按提示输入邮箱，同意条款

# 设置自动续期
sudo certbot renew --dry-run
```

---

## 5. 应用部署

### 5.1 上传项目

```bash
# 本地打包（排除 node_modules 和 __pycache__）
cd d:/ClaudeCode
tar -czf scilit-oncology.tar.gz \
    --exclude='node_modules' --exclude='__pycache__' \
    --exclude='*.db' --exclude='dist' \
    --exclude='.git' \
    backend/ frontend/ docker-compose.yml docker-compose.prod.yml \
    scripts/ CLAUDE.md README.md .github/ .env.example

# 上传到服务器
scp scilit-oncology.tar.gz scilit@<IP>:/home/scilit/

# 服务器端解压
ssh scilit@<IP>
cd /home/scilit
mkdir -p sci-lit-manager
tar -xzf scilit-oncology.tar.gz -C sci-lit-manager
cd sci-lit-manager
```

### 5.2 创建生产环境变量

```bash
# /home/scilit/sci-lit-manager/.env
vim .env
```

```bash
# ===== 数据库（必须修改） =====
PG_PASSWORD=<生成强密码: openssl rand -hex 16>
REDIS_PASSWORD=<生成强密码: openssl rand -hex 16>

# ===== JWT（必须修改） =====
JWT_SECRET=<运行 openssl rand -hex 32 生成>

# ===== S3 / MinIO =====
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=scilit-files

# ===== 邮件（腾讯云邮件推送 SES） =====
SMTP_HOST=smtp.qcloudmail.com
SMTP_PORT=465
SMTP_USER=service@oncolit.gonsun.com
SMTP_PASSWORD=<腾讯云SES SMTP密码>
SMTP_FROM=service@oncolit.gonsun.com

# ===== AI 摘要（DeepSeek） =====
AI_API_KEY=sk-your-deepseek-api-key
AI_BASE_URL=https://api.deepseek.com/v1
AI_MODEL=deepseek-chat

# ===== PubMed =====
PUBMED_API_KEY=your-ncbi-api-key

# ===== 搜索 =====
ES_URL=http://elasticsearch:9200

# ===== URL 配置 =====
PUBLIC_BASE_URL=https://oncolit.gonsun.com
CORS_ORIGINS=["https://oncolit.gonsun.com"]

# ===== 专科配置 =====
SPECIALTY=oncology
DEBUG=false
```

### 5.3 构建 Docker 镜像并启动

```bash
cd /home/scilit/sci-lit-manager

# 使用生产环境 Docker Compose 文件
docker compose -f docker-compose.prod.yml --env-file .env up -d postgres redis

# 等待数据库就绪
sleep 10

# 运行数据库迁移（migrate 服务会自动执行，也可手动）
docker compose -f docker-compose.prod.yml run --rm migrate

# 加载种子数据
docker compose -f docker-compose.prod.yml run --rm backend python scripts/seed_data.py

# 启动全部服务
docker compose -f docker-compose.prod.yml --env-file .env up -d

# 查看日志确认正常
docker compose -f docker-compose.prod.yml logs -f backend
```

### 5.4 验证服务

```bash
# 健康检查
curl https://oncolit.gonsun.com/api/v1/health

# 应该返回
# {"status":"ok","specialty":"oncology","db":"ok","version":"0.1.0"}

# 公开API
curl https://oncolit.gonsun.com/api/v1/public/feed?page_size=1

# 登录测试
curl -X POST https://oncolit.gonsun.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"demo@test.cn","password":"123456"}'
```

---

## 6. 数据库初始化

### 6.1 首次部署

服务器上已经用 Docker Compose 启动了 PostgreSQL。首次初始化：

```bash
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"

# 运行迁移
$COMPOSE run --rm migrate

# 导入种子数据
$COMPOSE run --rm backend python scripts/seed_data.py
```

### 6.2 数据备份

```bash
# 每日备份脚本
# /home/scilit/backup.sh
#!/bin/bash
BACKUP_DIR=/home/scilit/backups
mkdir -p $BACKUP_DIR
DATE=$(date +%Y%m%d_%H%M%S)
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml"

# 备份 PostgreSQL
$COMPOSE exec -T postgres pg_dump -U scilit scilit > $BACKUP_DIR/scilit_$DATE.sql

# 保留最近 7 天
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete

echo "Backup completed: $DATE"
```

```bash
# 添加定时任务（每天凌晨 3 点备份）
crontab -e
# 添加：
0 3 * * * /home/scilit/backup.sh >> /home/scilit/backups/backup.log 2>&1
```

### 6.3 PubMed 基线数据导入

```bash
# 下载 PubMed 2025 基线（约 40GB）
# 服务器上使用 aria2 多连接下载
aria2c -x 8 -s 8 https://ftp.ncbi.nlm.nih.gov/pubmed/baseline/pubmed25n0001.xml.gz

# 导入（仅肿瘤科相关，会自动过滤）
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"
$COMPOSE exec backend python scripts/pubmed_baseline.py \
  --dir /data/pubmed/baseline \
  --demo  # 先试 5 个文件，确认没问题后去掉 --demo
```

---

## 7. 第三方服务注册

### 7.1 SMTP 邮件（腾讯云邮件推送 SES）

使用腾讯云邮件推送（Simple Email Service）发送通知/邀请邮件。SMTP 服务器 `smtp.qcloudmail.com:465`，密码需在腾讯云 SES 控制台生成 SMTP 密码。验证方式：

```bash
# 发送测试邮件
docker compose exec backend python -c "
from app.services.email import send_email
import asyncio
asyncio.run(send_email('your@email.com', 'Test', '<p>OK</p>'))
"

### 7.2 Stripe 支付

1. 注册 [stripe.com](https://stripe.com)（需要真实的公司/个人信息）
2. 先在 Test Mode 测试：
   - 获取 `sk_test_xxx` 和 `pk_test_xxx`
   - 在 Stripe Dashboard 创建 Products：
     - Pro Monthly: ¥35/month
     - Team Monthly: ¥70/user/month
     - Enterprise: Custom quote
   - 获取 Price IDs
3. 生产上线前切换到 Live Mode
4. 配置 Webhook：
   - URL: `https://oncolit.gonsun.com/api/v1/webhooks/stripe`
   - Events: `checkout.session.completed`, `customer.subscription.*`, `invoice.*`
   - 获取 `whsec_xxx` Signing Secret

### 7.3 AI API（DeepSeek，已配置）

已在 `.env` 中配置 DeepSeek API：

```bash
AI_API_KEY=sk-f59fabc2884d49328505567518729685
AI_BASE_URL=https://api.deepseek.com/v1
AI_MODEL=deepseek-chat
```

如需切换为 OpenAI 或其他模型，修改 `.env` 中对应项即可。

### 7.4 对象存储（MinIO 自建）

已在 `docker-compose.prod.yml` 中配置 MinIO 服务。首次部署后操作：

```bash
# 访问 MinIO Web 控制台（先暴露端口调试）
# 默认：http://<服务器IP>:9001，账号 minioadmin / minioadmin

# 或通过 mc 客户端配置
docker compose exec minio mc alias local http://localhost:9000 minioadmin minioadmin
docker compose exec minio mc mb local/scilit-files

# 验证
docker compose exec minio mc ls local/scilit-files
```

> **安全提示：** 初始账号密码（minioadmin/minioadmin）在生产环境应立即通过 `.env` 中的 `MINIO_ROOT_USER` 和 `MINIO_ROOT_PASSWORD` 修改。

### 7.5 PubMed API Key + 数据填充（部署后一次性执行）

#### 7.5.1 PubMed API Key

已在 `.env` 中配置，速率 10 req/s：

```bash
PUBMED_API_KEY=692e319f33129b7a5d2d94c57b67739e0a08
```

如 Key 过期或被轮换，登录 [NCBI Account](https://account.ncbi.nlm.nih.gov/settings/) → API Key Management 更新。

#### 7.5.2 部署后一次性数据填充

按以下顺序执行（越靠前的越关键）：

```bash
# 0. 获取管理员 Token
admin_token=$(curl -s -X POST https://oncolit.gonsun.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@yourhospital.com","password":"你的密码"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token']['access_token'])")

# 1. 导入种子标签 + 期刊
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"
$COMPOSE exec backend python scripts/seed_tags_only.py

# 2. 回填 MeSH 标签（将已有 mesh_headings 匹配到 GlobalTag）
#    预期：~50% 的文献获得标签
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/retag-tags" \
  -H "Authorization: Bearer $admin_token"

# 3. 刷新引用次数（全库跑一次，约 15 分钟，3 req/s）
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/refresh-citations" \
  -H "Authorization: Bearer $admin_token"

# 4. 提取 PICO 要素（从摘要中抽取，调用 DeepSeek API，3 并发）
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/extract-pico?limit=500" \
  -H "Authorization: Bearer $admin_token"

# 5. 生成 AI 一句话摘要（每次处理最近 30 篇有摘要的文献）
#    可运行多次，每次处理 30 篇
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/ai/summarize" \
  -H "Authorization: Bearer $admin_token"

# 6. 回填 OA 全文（仅 ~0.5% 的文献有 PMC 全文，轻量）
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/backfill-oa-text?limit=5000" \
  -H "Authorization: Bearer $admin_token"

# 7. 回填研究设计分类（基于 PubType 映射）
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/backfill-study-designs" \
  -H "Authorization: Bearer $admin_token"

# 8. 触发一次完整的文献检索管道
curl -X POST "https://oncolit.gonsun.com/api/v1/admin/pipeline/run" \
  -H "Authorization: Bearer $admin_token"
```

**部署后首次填充预期效果（Dashboard 数据质量面板可验证）：**

| 指标 | 预期值 | 说明 |
|------|--------|------|
| 已打标文献 | ~50% | MeSH UI 匹配精度 |
| PICO 覆盖 | ~36% | 有摘要的文献 |
| 引用次数 | 依文献年代 | PubMed elink 实时查询。2026 年新文献引用数多为 0 |
| OA 全文 | ~0.5% | PMC 平台全文 |
| AI 摘要 | 30篇/次 | 可重复触发 |

> **定时任务说明：** ARQ Worker 在 Docker Compose 启动后自动运行，无需手动配置。
> - 每日 03:07 UTC — 精搜
> - 每周日 03:37 UTC — 宽搜
> - 每日 05:13 UTC — 引用刷新
> - 每日 22:30 UTC — 摘要邮件

### 7.6 微信服务号

1. 注册 [微信公众平台](https://mp.weixin.qq.com/)
2. 选择"服务号" → 提交营业执照 → 等待审核（1-7天）
3. 认证费用 ¥300/年
4. 认证后在"开发 → 基本配置"获取 AppID + AppSecret
5. 在"功能 → 模板消息"申请模板（审核 1-3 天）
6. 在项目中配置：
```bash
WECHAT_APP_ID=wx...
WECHAT_APP_SECRET=...
WECHAT_TEMPLATE_ID=...
```

---

## 8. 上线验证清单

### 8.1 安全

- [ ] `JWT_SECRET` 已随机生成，不是 `dev-secret-...`
- [ ] SSL 证书生效（浏览器显示 🔒）
- [ ] 防火墙只开放 22/80/443，数据库端口不对外
- [ ] PostgreSQL 密码已改（非默认），Redis 密码已设
- [ ] `/admin` 普通用户无法访问
- [ ] S3/MinIO 桶已创建，应用可读写
- [ ] 忘记密码功能发真实的邮件（不是 dev 模式的 reset_link）

### 8.2 功能

- [ ] 公开首页可访问，能浏览文献
- [ ] 注册流程走通（验证码 → 注册 → 设置领域 → 看Feed）
- [ ] Demo 账号能登录并看到 42 篇个性化推送
- [ ] 管理员能登录 /admin 并看到看板数据
- [ ] 搜索返回正确结果
- [ ] 引用导出 BibTeX/RIS 正常
- [ ] API 文档 /docs 可访问

### 8.3 数据管道

- [ ] 种子标签已导入（`seed_tags_only.py`）
- [ ] MeSH 标签回填已执行（`retag-tags`）
- [ ] 引用次数已刷新（`refresh-citations`）
- [ ] PICO 提取已执行（`extract-pico`）
- [ ] AI 摘要已生成（`ai/summarize`）
- [ ] OA 全文已回填（`backfill-oa-text`）
- [ ] 管道定时任务已启动（ARQ Worker 存活）
- [ ] Dashboard 数据质量面板各指标在预期范围内
- [ ] 首次完整管道运行无异常

### 8.3 性能

- [ ] 公开 Feed 首屏加载 < 1 秒
- [ ] 登录后 Feed < 2 秒
- [ ] API 响应有 `X-Response-Time-Ms` 头
- [ ] 静态资源有 Cache-Control 头

---

## 8.5 数据库连接池

当前使用 SQLAlchemy 内置连接池（[backend/app/db.py](backend/app/db.py)）：

- `pool_size=10`, `max_overflow=20` → 单 worker 最大 30 连接
- 4 个 uvicorn worker → 最大 120 个 PG 连接
- PG 16 默认 `max_connections=100`，已超限

> **决定：暂不引入 PgBouncer。** 当前用户量未达瓶颈，后续并发增长后需在应用层与 PG 之间添加 PgBouncer（transaction 模式）。届时需下调 pool_size 避免连接堆积。

## 8.6 首页 Feed 缓存

首页文献列表使用**预缓存**策略（类似热搜缓存 `hot_articles_cache.py`）。

| 项 | 值 |
|---|---|
| 缓存 Key | `homepage:feed` |
| TTL | 1800s（30 分钟） |
| 刷新任务 | `refresh_homepage_feed`，ARQ cron 每 0/30 分 |
| 降级策略 | 缓存未命中时走实时简化查询 |
| 预计算条数 | 50 条（返回 20 条） |

**设计意图：** 避免每次首页加载走 `POST /features/search/advanced` 高级搜索全套流程（COUNT+SELECT+标签JOIN+期刊JOIN）。预计算只取 `pub_date` 索引的最新 50 条，不取 JSON 大字段（authors/mesh_headings/ai_summary），传输量从 400KB+ 降到 ~10KB。

---

## 9. 日常运维

### 9.1 查看日志

```bash
# 所有服务（使用生产 Compose 文件）
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"
$COMPOSE logs -f

# 只看后端
$COMPOSE logs -f backend

# 最近 100 行
$COMPOSE logs --tail=100 backend
```

### 9.2 重启服务

```bash
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"
$COMPOSE restart backend
```

### 9.3 更新代码

```bash
# 服务器
SSH_HOST=scilit@<服务器IP>
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"

# 更新后端代码并重建
ssh $SSH_HOST "cd /home/scilit/sci-lit-manager && git pull"
ssh $SSH_HOST "$COMPOSE build backend && $COMPOSE up -d backend"

# 更新前端代码并重建
ssh $SSH_HOST "$COMPOSE build frontend && $COMPOSE up -d frontend"
```

### 9.4 数据库迁移（新增表/字段）

```bash
# 本地先写 Alembic 迁移
cd backend
alembic revision --autogenerate -m "描述"

# 服务器上运行
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"
$COMPOSE run --rm migrate
```

### 9.5 监控

```bash
# 系统资源
htop

# Docker 容器资源
docker stats

# 磁盘
df -h
```

---

## 10. 应急预案

### 10.1 服务挂了

```bash
# 查看日志
docker compose logs --tail=50 backend

# 重启
docker compose restart backend

# 如果起不来
docker compose down && docker compose up -d
```

### 10.2 数据库满了

```bash
# 清理 user_feed 旧分区
docker compose exec backend python -c "
from app.db import engine
from sqlalchemy import text
# 删除 3 个月前的 feed 分区表
"
```

### 10.3 回滚

```bash
# 如果新版本有问题
COMPOSE="docker compose -f /home/scilit/sci-lit-manager/docker-compose.prod.yml --env-file /home/scilit/sci-lit-manager/.env"

# 回滚后端
git revert <commit_hash>
$COMPOSE build backend && $COMPOSE up -d backend
```

### 10.4 紧急联系

| 服务 | 控制台 |
|------|------|
| 阿里云 | https://ecs.console.aliyun.com/ |
| Stripe | https://dashboard.stripe.com/ |
| 域名 DNS | 你的域名服务商后台 |
| SSL 证书 | `certbot certificates` 查看 |

---

## 附录 A：成本估算（小规模）

| 项目 | 月费 | 年费 |
|------|:--:|:--:|
| 阿里云 ECS 2C4G 40G SSD | ¥100 | ¥1,200 |
| 域名 oncolit.gonsun.com | ¥5 | ¥60 |
| SSL (Let's Encrypt) | ¥0 | ¥0 |
| MinIO 对象存储（额外磁盘） | ¥0（用 ECS 磁盘） | ¥0 |
| SMTP（日<200封） | ¥0 | ¥0 |
| DeepSeek（日200篇摘要，约5M tokens） | ~¥5 | ~¥60 |
| Stripe 交易费 | 按交易 2.9% | — |
| **合计** | **~¥110/月** | **~¥1,320/年** |

## 附录 B：常用命令速查

```bash
# SSH 登录
ssh scilit@<IP>

# 进入项目目录
cd /home/scilit/sci-lit-manager

# 定义 Compose 别名
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"

# 查看服务状态
$COMPOSE ps

# 日志
$COMPOSE logs -f backend

# 重启
$COMPOSE restart backend

# 查看所有容器资源占用
docker stats

# 数据库备份
bash /home/scilit/backup.sh

# SSL 证书续期
sudo certbot renew

# 系统更新
sudo apt update && sudo apt upgrade -y

# 磁盘/内存
df -h && free -h
```

---

## 11. 实际部署流程（每日代码更新）

> 本项目不使用 git flow 部署，采用 SCP + docker cp 直接替换文件。

### 11.1 如何判断是否需要重建镜像

| 场景 | 部署方式 | 原因 |
|------|----------|------|
| 改 `.py` / `.vue` / `.ts` | `docker cp` | 文件替换即可 |
| 加/改 pip 包（`requirements.txt`） | `docker compose build` | 容器内没有新包 |
| 加/改 npm 包（`package.json`） | `docker compose build` | 容器内没有新包 |
| 改 `Dockerfile` / `nginx.conf` | `docker compose build` | 容器配置变了 |
| 改环境变量（`.env`） | `docker compose up -d` | 不需要重建，重启读取即可 |

需要重建的唯一情况是：**容器内需要的东西不在容器里**（新依赖、新配置）。

### 11.2 确认 requirements.txt 是否有变更

```bash
# 拉取生产上的文件对比
ssh -i ~/.ssh/id_ed25519 root@123.207.9.209 \
  "docker exec scilit-backend-1 cat /app/requirements.txt" \
  > /tmp/requirements_prod.txt
diff d:/ClaudeCode/backend/requirements.txt /tmp/requirements_prod.txt
```

有输出 → 有变更，需要重建镜像。无输出 → 没变，直接 cp。

### 11.3 前端部署（Vue/TypeScript — 需要编译）

```bash
# 1. 构建生产包（编译 .vue/.ts → dist/）
cd frontend && npm run build

# 2. 复制到服务器
ssh -i ~/.ssh/id_ed25519 root@123.207.9.209 "mkdir -p /tmp/frontend-dist"
scp -i ~/.ssh/id_ed25519 -r frontend/dist/* root@123.207.9.209:/tmp/frontend-dist/

# 3. 替换容器内文件 + 重载 nginx
ssh -i ~/.ssh/id_ed25519 root@123.207.9.209 \
  "docker cp /tmp/frontend-dist/. scilit-frontend-1:/usr/share/nginx/html/ \
   && docker exec scilit-frontend-1 nginx -s reload \
   && rm -rf /tmp/frontend-dist"
```

### 11.4 后端部署（Python — 不需要编译）

#### 单个文件

```bash
scp -i ~/.ssh/id_ed25519 backend/app/services/xxx.py root@123.207.9.209:/tmp/
ssh -i ~/.ssh/id_ed25519 root@123.207.9.209 \
  "docker cp /tmp/xxx.py scilit-backend-1:/app/app/services/xxx.py \
   && docker restart scilit-backend-1 && rm /tmp/xxx.py"
```

#### 多个文件（tar 打包一次性覆盖）

```bash
tar czf /tmp/backend_update.tar.gz -C backend app/services/ app/api/ app/schemas/
scp /tmp/backend_update.tar.gz root@123.207.9.209:/tmp/
ssh root@123.207.9.209 \
  "docker cp /tmp/backend_update.tar.gz scilit-backend-1:/tmp/ \
   && docker exec scilit-backend-1 tar xzf /tmp/backend_update.tar.gz -C /app/ \
   && docker restart scilit-backend-1"
```

#### 新增依赖时（必须重建镜像）

```bash
# 在服务器上执行
cd /home/scilit/sci-lit-manager
docker compose -f docker-compose.prod.yml build backend
docker compose -f docker-compose.prod.yml up -d backend
```

### 11.5 关于 Docker 构建缓存

Dockerfile 层序决定了缓存策略：

```
1. FROM python:3.10          ← 缓存（镜像标签没变）
2. COPY requirements.txt     ← 缓存（文件没变）
3. RUN pip install -r ...    ← 缓存命中（requirements.txt 不变时跳过）
4. COPY app/                 ← 不命中（代码改了）
5. CMD uvicorn ...           ← 跟随上层，重建
```

不改 `requirements.txt` 时重建很快（~3-5s，pip install 直接跳过），只改代码没必要重建，`docker cp` 秒级。

---

## 12. 生产部署严格规则（2026-07-14 总结）

> 以下规则来自实际踩坑，每次部署前必须逐条对照。

### 规则 1：永远要先清理 `__pycache__`

```bash
# docker cp 后，务必执行：
docker exec scilit-backend-1 find /app/app -name __pycache__ -exec rm -rf {} + 2>/dev/null
```

否则 Python 会加载旧的 `.pyc` 字节码，导致神秘的 ImportError（如 `cap_pub_date`、`is_superuser` 等问题）。

### 规则 2：后端部署后必须同时重启 worker

```bash
docker restart scilit-backend-1
docker restart scilit-worker-1
```

Worker 使用独立进程，不重启则继续执行旧代码，导致定时任务（PubMed 管道、引用刷新等）行为不一致。

### 规则 3：前端部署必须先清理旧 assets

```bash
# 先清空旧文件，再 docker cp，否则多轮构建产物污染
ssh root@123.207.9.209 \
  "rm -rf /usr/share/nginx/html/assets /usr/share/nginx/html/index.html /usr/share/nginx/html/favicon* /usr/share/nginx/html/icon-*.png \
   && docker cp /tmp/frontend-dist/. scilit-frontend-1:/usr/share/nginx/html/ \
   && docker exec scilit-frontend-1 nginx -s reload"
```

不清理会导致 assets 目录混入多个构建版本的 chunk 文件（同组件 5 个不同 hash 版本），浪费磁盘且可能加载错误资源。

### 规则 4：部署后必须验证

```bash
# 4.1 健康检查
docker exec scilit-backend-1 curl -sf http://localhost:8000/health

# 4.2 新模块导入测试（新增文件时）
docker exec scilit-backend-1 python -c "from app.api.v1 import admin_roles, journals; print('OK')"
docker exec scilit-backend-1 python -c "from app.core import audit; print('OK')"

# 4.3 md5 抽查（确认容器与本地一致）
docker exec scilit-backend-1 md5sum /app/app/api/v1/router.py
# 与本地比对：md5sum backend/app/api/v1/router.py

# 4.4 检查是否有放错位置的文件
docker exec scilit-backend-1 ls /app/app/models/auth.py 2>/dev/null && echo "WARNING: misplaced file!" || echo "clean"
```

### 规则 5：本地新文件必须先同步到服务器项目目录

服务器项目目录 `/root/scilit/` 是 Docker 镜像的构建源目录。新文件（如 `models/audit.py`）必须同时：
1. 同步到服务器项目目录（scp）
2. 复制到容器（docker cp）

否则容器重建后会丢失文件。

### 规则 6：永远不要 docker cp 到错误的目录

`models/` 目录只放数据库模型类，不要放路由代码或安全工具函数。

```bash
# ❌ 错误的放法（曾经踩坑）：
# 把 api/v1/auth.py → docker cp 到了 /app/app/models/auth.py

# ✅ 正确的目录对应关系：
# backend/app/api/v1/xxx.py → /app/app/api/v1/xxx.py
# backend/app/core/xxx.py  → /app/app/core/xxx.py
# backend/app/models/xxx.py → /app/app/models/xxx.py
# backend/app/services/xxx.py → /app/app/services/xxx.py
# backend/app/schemas/xxx.py → /app/app/schemas/xxx.py
```

### 规则 7：确认 pyproject.toml 无变化才用 docker cp

```bash
# 对比容器与本地 pyproject.toml
docker exec scilit-backend-1 cat /app/pyproject.toml | md5sum
md5sum backend/pyproject.toml
# 不一致 → 必须重建镜像
```

有变化（新增 pip 依赖）时，`docker compose build backend` 重建。依赖不存在于容器中会导致 ModuleNotFoundError。

### 规则 8：多文件部署一律用 tar 打包，不倒腾单个文件

```bash
# ✅ 正确：tar 打包整个目录
tar czf /tmp/backend_update.tar.gz -C backend app/services/ app/api/ app/core/ app/models/ app/schemas/ app/tasks/
scp /tmp/backend_update.tar.gz root@123.207.9.209:/tmp/
ssh root@123.207.9.209 \
  "docker cp /tmp/backend_update.tar.gz scilit-backend-1:/tmp/ \
   && docker exec scilit-backend-1 tar xzf /tmp/backend_update.tar.gz -C /app/ \
   && docker exec scilit-backend-1 find /app/app -name __pycache__ -exec rm -rf {} + 2>/dev/null \
   && docker restart scilit-backend-1 && docker restart scilit-worker-1"
```

单文件 scp 容易漏复制依赖文件，且文件较小时 tar 的开销可忽略。

### 规则 8b：不确定改了什么文件时，全量同步 `app/`

```bash
# ✅ 最安全：整个 app/ 目录打包
cd d:/ClaudeCode/backend
tar czf /tmp/backend-app.tar.gz --exclude="__pycache__" --exclude="*.pyc" app/
scp /tmp/backend-app.tar.gz root@123.207.9.209:/tmp/
ssh root@123.207.9.209 \
  "docker cp /tmp/backend-app.tar.gz scilit-backend-1:/tmp/ \
   && docker exec scilit-backend-1 tar xzf /tmp/backend-app.tar.gz -C /app/ \
   && docker exec scilit-backend-1 find /app/app -name __pycache__ -exec rm -rf {} + 2>/dev/null \
   && docker restart scilit-backend-1 && docker restart scilit-worker-1"
```

**为什么要全量？** 模型文件（`models/literature.py` 等）可能被多个路由和 services 引用。只 cp 改了的 API 文件而漏掉模型更新，启动后出现 `AttributeError: type object 'GlobalTag' has no attribute 'source'` 或 `'User' object has no attribute 'admin_note'`，症状是后台大面积请求失败。

**何时用：**
- 改的是 `models/`、`schemas/` 下的文件 → 全量
- 改的是 `core/` 下的基础设施类 → 全量
- 只改单个 API 路由（如 `admin.py`），无公共依赖 → 单个 tar 可接受
- 不确改了什么 → 全量，10s 的事比诊断 AttributeError 快

### 规则 9：服务器不是 git 仓库，手动同步代替 git pull

服务器 `/root/scilit/` 不是 git 仓库。同步方式：

```bash
# 方式 A：全量 tar 同步
tar czf /tmp/scilit_sync.tar.gz --exclude=node_modules --exclude='__pycache__' --exclude=.git backend/ frontend/ docker-compose* .env.example
scp /tmp/scilit_sync.tar.gz root@123.207.9.209:/root/
ssh root@123.207.9.209 "cd /root/scilit && tar xzf /tmp/scilit_sync.tar.gz"

# 方式 B：仅增量同步后端 Python 文件
# 由 Claude 自动完成，使用 tar + scp + docker cp 流程
```

### 规则 10：容器内文件用 `scilit` 用户身份运行

Dockerfile 中有 `USER scilit`，容器内 Python 进程以 scilit 用户运行。docker cp 复制进去的文件属主为 root，但只要文件对 scilit 可读（`-rw-r--r--`），Python 就能正常使用。如果遇到权限问题：

```bash
docker exec scilit-backend-1 chown -R scilit:scilit /app/app
```

### 规则 11：新增 Alembic 迁移时，迁移文件必须在容器内

```bash
# 本地生成迁移后：
scp -r backend/alembic/versions/xxx.py root@123.207.9.209:/tmp/
ssh root@123.207.9.209 \
  "docker cp /tmp/xxx.py scilit-backend-1:/app/alembic/versions/xxx.py \
   && docker exec scilit-backend-1 alembic -c alembic/alembic.ini upgrade head"
```

没有迁移文件在容器内，`alembic upgrade head` 会报 `Target database is not up to date`。

---

## 13. 事故记录：2026-07-17 部署回退问题复盘

### 背景

一次正常的安全加固部署（CSP 统一、Refresh token 告警、验证码集成、JWT 校验、注册限速、Nginx 日志持久化），先后出现多个问题，反复修复。

### 问题链（按时间顺序）

#### 问题 1：docker build cache 被清除 → 25min 重建

**现象：** `docker compose build backend` 重新下载所有 pip 包，构建超 25 分钟。

**根因：** 上一轮部署执行了 `docker builder prune -a -f`，所有构建缓存层被清除。

**教训：** 不改 `requirements.txt` 时，pip install 是缓存的，构建只要几秒。永远不要在生产服务器上 `prune -a`。如需清理磁盘空间，指定 `docker builder prune`（不加 -a，只清 dangling 层）。

**正确做法：** 不能构建时用 `docker cp` 热更新，不需要构建。能构建时用缓存，几秒完事。

---

#### 问题 2：tar 路径前缀嵌套

**现象：** `tar xzf backend.tar.gz -C /root/scilit/backend/` 后，文件到了 `/root/scilit/backend/backend/app/...`。

**根因：** tar 包内的路径前缀是 `backend/`（`tar czf backend.tar.gz backend/app/`），而解压目标已经是 `backend/`，导致嵌套。

**教训：** 打包时用 `--strip-components=1`，或直接在 `backend/` 目录内打包。

**正确做法：**
```bash
# 方式 A：在子目录打包
cd backend && tar czf /tmp/backend-app.tar.gz app/

# 方式 B：strip-components
tar czf /tmp/backend-app.tar.gz -C backend app/
ssh ... "tar xzf /tmp/backend-app.tar.gz --strip-components=1 -C /root/scilit/backend/"
```

---

#### 问题 3：Backend 容器和 Postgres 在不同 Docker 网络

**现象：** backend 容器启动后 `Health check DB connection failed`，`alembic upgrade head` 报 `Name or service not known`。

**根因：** compose 文件定义了两个 network（`scilit` 和 `scilit_default`）。postgres 在 `scilit_default`，backend 在 `scilit_scilit`。

**教训：** 生产 compose 文件定义了 `networks: scilit`，但 postgres 服务没有指定 `networks:`，Docker 自动为其创建默认的 `scilit_default` 网络。该问题在容器重启后反复出现。

**正确做法：** 所有服务统一在同一网络。compose 文件中每个服务都显式指定 `networks: - scilit`（已修复）。

---

#### 问题 4：Nginx 502 Bad Gateway（Backend 容器 IP 变化）

**现象：** backend 容器重启后，nginx 代理报 502。直接访问 backend（127.0.0.1:8000）正常。

**根因：** Nginx `proxy_pass http://backend:8000` 使用固定字符串解析，只在启动时解析一次 DNS。Backend 容器重启后 Docker 为它分配了新 IP，nginx 仍用旧 IP 连接。

**教训：** Docker 环境中，nginx 代理上游必须用变量形式 + resolver 指令触发动态 DNS 解析。

**正确做法：**
```nginx
resolver 127.0.0.11 ipv6=off valid=30s;
server {
    set $backend_upstream http://backend:8000;
    location /api/ {
        proxy_pass $backend_upstream;
    }
}
```

---

#### 问题 5：`docker compose up -d --no-deps frontend` 覆盖了 `docker cp` 的文件

**现象：** 前端标签显示空白。`docker cp` 进去的新前端文件（RegisterView 等）消失了。

**根因：** 修改 compose 文件（加日志 volume）后执行 `docker compose up -d --no-deps frontend`，Docker 重建了容器，从**旧镜像**启动。之前 `docker cp` 进去的文件不在镜像中，所以全部丢失。

**教训：** `docker cp` 修改的是运行中的容器文件系统，不是镜像。容器重建后镜像内容胜出。容器不重建时 cp 永久有效。

**正确做法：**
```bash
# 方案一：cp 后立即保存为新镜像（最简单）
docker cp dist/. frontend:/usr/share/nginx/html/
docker commit scilit-frontend-1 scilit-frontend:latest

# 方案二：改 compose 前先构建新镜像
docker compose build frontend
# 再改 compose 文件 → up -d

# 方案三：不改 compose，只在运行中容器 cp
```

---

### 根本原因

全部问题有一个共同模式：**每次部署依赖"上下文常识"，而不是依赖流程。** 具体来说：

1. 不知道 `proxy_pass` 固定字符串会缓存 DNS → nginx 502
2. 不知道 `docker compose up -d` 会重建容器 → cp 的内容丢失
3. 不知道 prune -a 会清 pip 缓存 → 多等 25 分钟
4. 不知道 tar 路径前缀 → 文件放到错误目录

每个问题单独看都是 Docker 基础常识，但在多步骤部署中，每步引入一个"我不知道这里还有这个坑"的盲点，累积后大面积出错。

### 改进点

| 问题 | 预防措施 | 状态 |
|------|---------|:--:|
| 构建缓存被清 | 不执行 `docker builder prune -a` | ✅ 已记录 memory |
| tar 嵌套 | 统一用 `--strip-components=1` 或先 cd 再打包 | ✅ 规则 8 已更新 |
| 网络不一致 | 所有服务显式指定 networks | ✅ compose 已修 |
| nginx 502 | 变量形式 proxy_pass + resolver | ✅ nginx.conf 已修 |
| `docker cp` 被覆盖 | 改 compose 前先 build，或 cp 后立即 commit | ✅ 规则 12 见下 |

### 规则 12：前端 docker cp 后立即 commit 镜像

```bash
# 前端部署后必须保存为新镜像
docker commit scilit-frontend-1 scilit-frontend:latest
```

否则任何导致容器重建的操作（`docker compose up -d`、改 compose 文件、服务器重启后 `compose up`）都会丢失 cp 的文件。后端容器（`scilit-backend-1`）很少重建，不需要 commit，但前端容器需要。

### 规则 13：改 compose 文件前先构建镜像

```bash
# 先确保镜像包含最新代码
docker compose build frontend   # 前端（npm，几秒）
# 或
docker compose build backend    # 后端（pip 缓存命中时几秒）

# 再改 compose 配置 → up -d 重建容器
```

改 `docker-compose.prod.yml` 必然触发容器重建，镜像必须包含最新代码。
