FastAPI 后端设计
后端是Molore全部业务逻辑所在:检索问答、自动打标、图谱、wiki、同步、计费都在这一个 FastAPI 应用里。它的结构经过一次系统性重构(main.py 从 1051 行收敛到 261 行),形成了三条硬纪律:启动逻辑进注册表、巨型文件按「包 + façade」拆、通道判定单点化。本页讲清这三条纪律及迁移、错误处理、会话事件三件基础设施。
目录结构
| 目录 | 职责 | 代表模块 |
|---|---|---|
app/api/ | HTTP 端点层,v1/endpoints/ 按域拆分,只做参数解析与响应组装 | chat.py、notes.py、documents.py、graphify.py、llm/retrieval/ |
app/services/ | 领域服务层,业务逻辑全部在此 | autotag_service.py、graphify_service.py、wiki_service/、llm_service/ |
app/core/ | 基础设施:配置、数据库、启动编排、迁移、播种、安全中间件 | startup.py、db_migrations.py、seed_data.py、database.py、tenant_scope.py |
app/models/ | SQLAlchemy 模型 | content.py(notes/clips/knowledge/documents)、llm_billing.py |
端点层不直接写业务规则——典型反例正是被根治的「通道判定散在各调用链」:N 条调用链 × M 条规则靠自觉必漏,所以规则单点化到 channel_policy(见下文),review 新代码只问一句「过没过 channel_policy」。
启动编排:注册表而非意大利面
app/main.py 的 lifespan 只剩四步,顺序固定:
run_schema_migrations() # app/core/db_migrations.py,schema 迁移
seed_system_configs() # app/core/seed_data.py,数据播种
seed_plans(); seed_llm_catalog()
startup.run_startup(app) # app/core/startup.py,钩子注册表app/core/startup.py 维护 STARTUP_HOOKS / SHUTDOWN_HOOKS 两张注册表(23 个启动钩子),每项是 (名称, dotted path),执行器按序解析调用,支持零参/一参两种签名,协程自动 await,异常原样上抛并先 log 出是哪个钩子失败。图谱、自动打标、向量化、FTS、物理图预热、计费/同步/日报/复盘/wiki lint 等调度器全部以钩子形式登记,新增后台能力的标准动作就是往注册表加一行。
两个时序红线(血泪教训固化在注释里):app.mount 时序敏感,插件 MCP 挂载必须在 SPA catch-all 之前;静态段路由必须先于路径参数路由注册,否则 /batch、/orphaned 被 /{id} 抢成 404。
巨型文件拆包纪律
单文件超过可维护阈值时按域拆成子包,纪律四条:
- 包 + façade 再导出:原模块路径变成一个包,
__init__.py把全部公开名字原样再导出,调用方零改动。已完成的拆分:retrieval.py→app/api/v1/endpoints/llm/retrieval/九文件子包(core/fusion/guarantee/rows/anchors/assembly/keywords/rewrite等),wiki_service.py→ 七子模块包。 - 单命名空间重建:父包 façade 直写子包
__dict__时,需用FunctionType重建函数命名空间,让子模块函数共享一个 globals。 - re-export 清单同步:新增子模块里的函数和模块级常量必须同步进
__init__的 import 清单——漏常量会在运行时才炸NameError(同一批次犯过两次,现为拆包 review 首查项)。 - 部署显式删旧文件:tar 覆盖解压不删旧文件,拆包批次部署必须显式
rm旧单文件,否则旧码残留。
迁移与播种
| 机制 | 位置 | 要点 |
|---|---|---|
| schema 迁移 | app/core/db_migrations.py run_schema_migrations() | 启动期幂等执行:create_all 建表 + 各表 ALTER 补列(已有则跳过) |
| CHECK 约束升级 | db_migrations.py 内专项函数 | SQLite 不能 ALTER CHECK,只能整表重建:建新表 → INSERT SELECT → DROP → RENAME → 重建索引,靠 sqlite_master DDL 检测幂等 |
| 数据播种 | app/core/seed_data.py | 系统配置、订阅计划、模型计费目录(seed_llm_catalog) |
| 一次性回填 | 各服务自带 marker | 完成标记落 system_configs(如 extract_md_tables_v1),全成才落 marker,重跑幂等 |
迁移必须是幂等的:任何「建一次长期复用」的库(评测临时库、快照库、桌面老库)启动/复用路径都统一走 run_schema_migrations,不许自己 create_all 就算完——create_all 只建新表不补列,老库复用路径会 no such column 直接炸。
通道策略:channel_policy 单一事实源
app/services/llm_service/channel_policy.py 是三通道硬分流的唯一入口,别处不许再写 is_system/前缀/provider 判断:
channel_of(model_id, db):唯一分类函数。判定顺序为platform:前缀 → ModelConfig/ollama 前缀判 local → 计费目录行。local 判定必须先于目录行——本地模型为记用量在目录里也有is_system=0的 0 价行,只看is_system会把本地模型误判成 BYOK(自动打标曾因此漏到 BYOK DeepSeek,实捕事故的根修)。- 默认模型常量:
DEFAULT_LOCAL_MODEL_ID = "ollama-qwen3.5-0.8b"、DEFAULT_PLATFORM_MODEL_ID = "sys-glm-5.3-flash",端点/服务一律 import 常量,不写字面量。 fallback_candidates():回退策略唯一入口。显式选型零回退;local/platform 主模型零回退;仅 BYOK 通道保留同通道回退名单。后台任务一律allow_fallback=False——任务后台禁止外部大模型兜底。require_auto_chain_usable():自动链路门禁统一入口,免费档外部模型自动调用直接ValueError,不静默降级冒充。
模块落成时把散在各处的 13 处硬编码通道判断全部收口;契约测试(test_channel_policy,18 条)钉死语义。刻意旁路只有一条:纯本地零成本直连链(检索改写、复盘、打标的 _local_chat)触不到付费通道,合法,但默认模型口径仍用本模块常量。
错误处理纪律
- 模型不可用硬报错:自动链路模型不可用直接
ValueError,不静默换通道——两通道硬分流不许自由路由,动态兜底换通道等于偷扣费。 [Error:一律 502 原文透传:模型执行失败产生的[Error: ...]文本是「模型不通」的信号,不是输出畸形。端点层识别后原样以 502 透传给用户,绝不落兜底假结果——宁可报错,不可编造。- except 吞异常必须留痕:静默吞可以,但必须 warning 留痕;吞 commit 失败必须 rollback。大 try 块包循环时,循环体内的 KeyError/TypeError 是全链事故不是局部降级。
会话与事件:autoflush=False 与提交监听
app/core/database.py 全局 sessionmaker(autoflush=False)。两条由此衍生的纪律:
- 创建链路里查不到 pending 对象——监听器和后置评估要先认
session.new再查库。 after_commit里session.new/dirty/deleted已清空——事件监听必须 before_commit 捕获、after_commit 投递。图谱自进化等内容变更监听就是按这个模式实现的(graphify_service.py用_pending按 session 暂存目标,提交后统一分发)。