Files
backend/docs/08-疑问与决策记录.md
T
34047007@qq.com a6cd99a4ca
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
feat: initial commit - oncology literature search platform
OncoLit: a multi-tenant oncology literature search, feed, and
collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL.
Includes PubMed pipeline, drug approvals, AI summaries, and
systematic review tools.
2026-07-27 07:59:18 +08:00

262 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 疑问与决策记录
## 一、垂直科室 vs 多科室 SaaS
**决策**:一套代码 = 一个垂直科室产品,每个部署实例独立。
**背景**:最开始讨论时考虑过在一套系统里支持多科室切换(肿瘤/心血管/神经…),平台端建科室模板。
**结论**:不做多科室共享框架。肿瘤科是一个独立部署实例;将来做心内科或神经科时,用同一套代码 + 不同的科室配置文件,在另一台服务器上独立部署运行。
**原因**
- 垂直领域数据隔离要求更高(医院不愿自己的肿瘤数据和其他科室混在一起)
- 配置驱动的单部署架构更简单、更安全
- 部署成本低(Docker Compose 一条命令)
- 新科室部署只需 3 步:写配置 → 生成 Docker → 启动
```yaml
# 不同科室不同的配置文件
config/specialties/oncology.yaml → scilit-oncology.com
config/specialties/cardiology.yaml → scilit-cardiology.com
config/specialties/neurology.yaml → scilit-neurology.com
```
---
## 二、TIMESTAMPTZ vs Unix 时间戳
**决策**:数据库存储使用 `TIMESTAMPTZ`API JSON 输出转 10 位 Unix 时间戳。
**背景**:讨论过用整数时间戳存储,省存储空间、无时区问题。
**结论**:禁止在数据库中用整数代替时间类型。
**原因**
- PostgreSQL `TIMESTAMPTZ` 内部就是 UTC,数据库自动处理时区转换
- SQL 中日期/时间运算自然(`date_trunc``interval``now()`
- 调试时可读性决定排查效率(`2025-07-04 08:30` 一眼看懂 vs `1751596200` 要去算)
- 存储空间差 4 bytes 在现在的硬件上完全可忽略
- API 序列化时可以在 Pydantic schema 里转成时间戳,这是应用层的事
---
## 三、分表 vs 分区
**决策**:不需要分表。唯一需要分区的是 `user_feed`
**背景**`global_literature` 预估约 600 万行,担心性能。
**结论**
- `global_literature` (600万):不需要分区。B-tree 索引足够,600万对 PostgreSQL 是小意思(单表 1000-2000万以下不需要分)
- `global_literature_tags` (6000万):不需要分区。两种查询模式都命中 B-tree 索引,分区反而跨区扫描
- `user_feed` (年增数千万):**唯一需要分区的表**。`PARTITION BY RANGE (pushed_at)` 按月分区 + 自动删除旧分区(保留最近 3 个月)
**分区管理**:定时任务每月 25 号创建下月分区 + 删除 3 个月前的分区(`DROP TABLE` 秒删,无需 VACUUM
---
## 四、FastapiAdmin 版权风险
**决策**:无风险。基于 MIT 协议,可以 Fork、修改、商用、闭源。
**背景**:评估了是否基于 Gitee 上的 `tao__tao/FastapiAdmin` 搭建。需要确认协议兼容商业化。
**结论**
- MIT License:允许商业使用、修改、分发、私用,唯一要求是保留版权声明
- 实际操作:在项目根目录 LICENSE 文件保留 `Copyright (c) 2025 1014TaoTao` 声明即可
- 能直接复用的:RBAC + 认证 + 日志 + 监控 + 定时任务 + 前端布局 ≈ 基础设施的 30%
- 必须自研的:多租户 RLS、PubMed 管道、标签引擎、Stripe 计费、审批流、AI 功能 ≈ 核心差异化的 70%
- 这不是法律问题,是效率问题——复用它省的主要是基础骨架,不是业务代码
---
## 五、注册流程的设计
**决策**:注册后直接进入肿瘤科文献平台,不需要选择科室。
**背景**:最早一版设计里有"注册→选择肿瘤科→设置癌种偏好"的流程。
**结论**:既然定位为垂直肿瘤科产品,注册后默认就是肿瘤科。用户直接进入"设置关注的癌种和靶点"环节。
**类似产品参考**
- 不是"先选科室再看内容"(像 UpToDate
- 而是"直接看肿瘤科内容,再微调个性化"(像 Feedly for oncology
---
## 六、标签体系的"可配置但预设"
**决策**:标签树在配置文件里预设完整的肿瘤科标签树,平台管理员可在后台微调。
**背景**MeSH 有 31,000 个描述符,但用户只需要肿瘤相关的。而且 MeSH 不覆盖靶点、临床分期、终点等临床维度。
**结论**
- MeSH C04 + E02(治疗) + D27(药物) ≈ 标签树的 60%
- 靶点/基因(EGFR/ALK…)+ 临床场景(新辅助/一线…)+ 终点(OS/PFS…)≈ 标签树的 40%,手工维护
- 不是"让用户从 31000 个 MeSH 词里选",而是"预设肿瘤科精选标签,用户勾选感兴趣的子集"
---
## 七、AI 功能的成本控制
**决策**:只用 GPT-4.1-mini 做摘要/翻译/解读。全量用规则引擎。
**背景**:每天 400-600 篇肿瘤文献,如果每篇都调 GPT-4.1-mini,成本会很高。
**结论**
- 只对用户"必读"must_read)级别的文献调 AI(日均约 100 篇/全部用户共享)
- GPT-4.1-mini~$0.02/篇 → 日均 $2 → 月均 $60(全平台)
- 其余文献用模板规则生成摘要(填充式:"{drug} 联合 {drug2} 治疗 {cancer}{endpoint} 显著改善"
- 成本计入 Enterprise 方案定价($20/人/月 含 AI 功能)
---
## 八、个人→团队升级的数据迁移
**决策**:零数据迁移。个人版租户有一个 tenant_id,升级时只改 `is_personal=FALSE` + `plan_type`
**背景**:参考了 Claude、Manus、Neon 的个人→组织迁移实践。
**结论**
- 所有文献/笔记/标签都 scoped 到个人版的 tenant_id
- 升级后同一 tenant_id 变成企业版
- 用户已经是这个租户的 owner
- 不需要数据迁移脚本,不需要复制数据
- 零停机,零风险
---
## 九、首发为什么是肿瘤科
**决策**:肿瘤科是第一垂直领域,最优选。
| 维度 | 肿瘤科优势 |
|------|-----------|
| 文献量最大 | PubMed 约 18% 是肿瘤相关 → 自动化价值最高 |
| 亚专科最多 | 肺/乳腺/消化/血液/妇科/泌尿/头颈… 每个都是独立市场 |
| 付费意愿最强 | 抗癌新药层出不穷 → 错过一篇可能意味着"不知道有更好的治疗方案" |
| MDT 天然需要协作 | 肿瘤内科+外科+放疗+病理+影像 → 企业版团队功能的完美场景 |
| 药企预算 | 药企医学部可以为科室采购 → B2B 付费路径清晰 |
---
## 十、配置驱动 vs 代码硬编码
**决策**:所有科室差异化逻辑走配置文件(YAML),不走代码分支。
```yaml
# 正确:配置在 YAML 里
pubmed_filter:
mesh_include_categories: ["C04"] # 肿瘤科
# 错误:硬编码在代码里
if specialty == "oncology":
filter_mesh = ["C04"]
```
**原因**
- 新增科室只需要改 YAML,不需要改 Python 代码
- 配置文件可以做版本管理(Git
- 可视化编辑器可以直接操作 YAML
- 部署脚本模板化,`./deploy.sh --specialty cardiology` 自动生成
---
## 十一、参考文献与数据源
| 用途 | 来源 |
|------|------|
| PubMed 全量数据 | ftp.ncbi.nlm.nih.gov/pubmed/ |
| MeSH 词表 (XML) | ftp.ncbi.nlm.nih.gov/mesh/2025/ |
| FDA 新药审批 | https://www.fda.gov/drugs/development-approval-process-drugs |
| NMPA 批件 | https://www.nmpa.gov.cn |
| NCCN 指南 | https://www.nccn.org/guidelines |
| CSCO 指南 | http://www.csco.org.cn |
| ESMO 指南 | https://www.esmo.org/guidelines |
| Stripe 计费 | https://docs.stripe.com/billing |
| PostgreSQL RLS | https://www.postgresql.org/docs/current/ddl-rowsecurity.html |
| ARQ 任务队列 | https://github.com/python-arq/arq |
| FastapiAdmin (参考) | https://gitee.com/tao__tao/FastapiAdmin |
| Karakeep (AI打标参考) | https://github.com/karakeep-app/karakeep |
| I, Librarian (参考) | https://github.com/mkucej/i-librarian-free |
---
## 十二、管道增强字段的两条入库路径需保持同步
**决策**`_process_article()`(新建)和 `_update_lit_from_article()`(更新)两条路径必须同时维护。
**背景**Phase 1 实现 PubMed 增强字段(ChemicalList、GeneSymbolList、NumberOfReferences 等 7 个)时,只给 `_update_lit_from_article()` 的 fields 列表追加了字段名,忘记在 `_process_article()``GlobalLiterature()` 构造器参数中传递——导致新建的文章增强字段全部为空。
**教训**
- 两条路径通过的字段列表必须人工保持一致
- 增加新字段需要同时检查两个地点:
1. `_process_article()``GlobalLiterature()` 构造器参数(约第 938 行)
2. `_update_lit_from_article()` → fields 列表(约第 833 行)
- 最好加一个共享的 `ENRICHMENT_FIELDS` 常量列表,避免再次不同步
---
## 十三、Windows 进程管理:kill 不掉旧进程的原因
**决策**Windows 开发环境下使用 `taskkill //f //im python.exe`(双斜杠防止 Git Bash 路径转义)。
**背景**:修改代码后 API 仍返回旧数据,猜测是旧 uvicorn worker 进程残留。`kill -9` 在 Git Bash 内不工作(Windows 无 SIGKILL),`taskkill /f /im python.exe` 起效但 Git Bash 把 `/f` 转换为 `F:/` 路径导致失败。
**教训**
- Git Bash 中单斜杠参数(`/f``/im`)会被 MSYS 解释为 Windows 路径
- 必须用双斜杠 `//f``//im` 阻止转义
- 或使用 `cmd //c "taskkill /f /im python.exe"` 也是一个选择
- 之后再清除 `__pycache__` 确保 bytecode 缓存刷新
- Docker Compose 部署场景无此问题(容器内 Linux 环境)
---
## 十四、管道依赖种子数据的启动顺序
**决策**:新建 PostgreSQL 数据库后,必须先运行种子数据脚本再启动管道。
**背景**:第 0 次管道运行时,`global_tags``global_journals` 为空(TRUNCATE CASCADE 不会删除标签,但全新 DB 确实无标签)。`_tag_article()` 通过 `mesh_ui` 查找 `global_tags`,空表导致 0 打标——前端标签页面全部报"加载标签失败"。
**教训**
- `scripts/seed_tags_only.py` 是专为此场景设计的种子脚本
- 管道只创建 `GlobalLiterature``GlobalLiteratureTag`,不负责标签定义
- 部署顺序必须为:seed_tags_only → pipeline run → verify tag coverage
- 全量测试时若 TRUNCATE 后重新导入,只需重新运行管道(打标会自动完成),不需要再次 seed
---
## 十五、团队邀请流程设计
**决策**:创建邀请时异步发送邮件 + 未注册用户引导注册。
**背景**:初始版本邀请功能只创建数据库记录并返回 invite_link,不发送邮件通知。被邀请通过邀请链接进入后,未注册用户直接被跳转到登录页,账号不存在则卡死。
**结论**
1. **邮件发送**`create_invitation` 端点中通过 FastAPI `BackgroundTasks` 异步调用 `send_email()`。邮件发送在 HTTP 响应返回后执行,不阻塞接口响应。
2. **SMTP 配置要求**`send_email()` 只有在 `SMTP_HOST` 配置了值时才真实发送邮件。开发环境(`SMTP_HOST` 为空)仅记日志 `"DEV mode — email to ..."`,不会真实发信。生产环境需在 `.env` 中配置 `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_FROM`。代码已支持 465(SSL)和 587(TLS)两种端口自动切换。
3. **SMTP 配置** — 使用**腾讯云邮件推送 SES**(`smtp.qcloudmail.com:465`)。密码需在腾讯云 SES 控制台生成 SMTP 密码,非登录密码。代码已支持 465(SSL)和 587(TLS)两种端口自动切换。
4. **未注册用户处理**`AcceptInviteView.vue` 中不再自动跳转登录页,而是并行显示"去登录"和"去注册"两个按钮。注册页面(`RegisterView.vue`)支持 `?redirect` 参数,注册成功后自动跳回邀请接受页完成流程。
5. **Token 传递** — 邀请 token 通过 `sessionStorage` 暂存(`pending_invite`),不在 URL 中持久暴露。登录/注册完成后自动取回。
**Flow**
```
邀请人 → POST /teams/invitations → INSERT invitation + 异步发送邮件
被邀请人点击邮件链接 → /auth/accept?token=xxx
├─ 已登录 → 自动接受邀请,加入租户
└─ 未登录 → 显示"去登录"和"去注册"
├─ 登录成功 → 自动取回 token 接受邀请
└─ 注册成功 → redirect=/auth/accept → 接受邀请
```
**实现文件:** `backend/app/api/v1/teams.py``backend/app/services/email_service.py``backend/app/services/email_templates.py``frontend/src/views/auth/AcceptInviteView.vue``frontend/src/views/auth/RegisterView.vue`
---