262 lines
12 KiB
Markdown
262 lines
12 KiB
Markdown
# 疑问与决策记录
|
||||
|
|
|
|||
|
|
## 一、垂直科室 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`
|
|||
|
|
|
|||
|
|
---
|