# 技术债与架构演进 — 主题综合 > 日期: 2026-06-25 | 分支: feat/media-library-banner | 阶段: V1 CONDITIONAL GO(上线临门一脚) > 视角: 不看上线前 P0/P1(由安全/DevOps 主题覆盖),专攻"架构债"——即代码当前能跑,但 6-12 个月后会成为微服务化/SaaS 化拦路虎的结构性缺陷。 > 前序: 决策简报 `00-INDEX.md`(综合 6.9/10,TOP 5 痛点含 PP-01/PP-02/PP-07);本章节聚焦"结构性演进"而非"上线就绪度"。 > 证据口径: 所有论断附文件路径:行号或 grep 结果,已逐项核验。 --- ## 一、主题愿景(专家共识收敛) HMS 的架构骨架(L1/L2/L3 分层 + Outbox 事件总线 + 双层多租户 + ErpModule trait)是全系统最扎实的资产(决策简报后端架构 8.0 分),**但当前"模块化单体"已部分名义化**:模块边界在组装层被绕过、事件契约仅靠文档约定、后台任务无声明式注册入口、多租户隔离依赖"竞态防护"的反模式、数据库 schema 演进无兼容窗口纪律。这些债现在不疼,但 6-12 个月后微服务化/SaaS 化启动时,**会从"重构"退化为"重写"**。 **主题愿景:** 在保持业务迭代速度的同时,把五类结构性缺陷(事件契约 / 模块边界 / 后台任务 / 多租户隔离 / schema 演进)从"文档约定 + 人肉记忆"升级为"编译期/CI 可校验的类型契约 + 机制层防护",为 2-3 年后的微服务化打下"可演进而非可重写"的架构地基。核心判断标准:**这笔债如果现在不还,拆服务当天是要重写而不是重构**。 --- ## 二、专家提案摘要与交叉验证 三位专家(首席架构师 / 后端架构师 / 数据架构师)独立提案,**核心诊断高度一致**,差异仅在实施手法与优先级排序。已核验的关键证据: | 诊断项 | 专家共识 | 代码证据(已核验) | alreadyKnown | |--------|---------|-------------------|--------------| | 死信重试未接线(PP-01) | 三人一致认定是"缺少任务注册框架"症状,非单点 bug | `erp-core/events.rs:382` 定义 `retry_dead_letters`,`main.rs:425-673` + `tasks.rs` 全部 spawn 点无调用 | V2 审计已识别为痛点,但"根因是缺 framework"是新视角 | | AI 队列死存储(PP-05) | claim_next 消费循环未接线,与 PP-01 同源 | 与 PP-01 共享"无 TaskRegistry"根因 | 已识别,归入半成品自动化主题 | | RLS 无 FORCE + SET 串扰(PP-07/PP-08) | 三人一致:是同一缺陷两面,须合并修 | grep `FORCE ROW LEVEL SECURITY` 全仓 **0 命中**;`tenant_rls.rs:31` 共享池 `SET app.current_tenant_id`,:44 `RESET` | 已识别,但"SET LOCAL + 事务作用域"是机制层新解法 | | 分区硬编码(PP-02) | 确定性硬截止,优先级高于概率性 bug | `m000073:43-46` 硬编码 2026_05~2026_08,启动路径外无自动建分区 | 已识别,pg_partman + 应用层兜底是增量 | | 事件 schema 无版本治理 | EVENT_SCHEMA_VERSION="v1" 仅写入 payload,无消费者校验 | `events.rs:67` `pub const EVENT_SCHEMA_VERSION: &str = "v1"` | **新发现**(历史只规定命名规范,未约束 schema 演进) | | ErpModule trait 名实不符 | register_event_handlers 8 模块中 4 空壳 | `module.rs:69` default `{}`;auth/config/core/message 全空或 stub;health:408 空函数体 | **新发现**(认知债,非功能 bug) | | 组装层边界泄漏 | dialysis 业务编排下沉到 erp-server | `erp-server/src/dialysis_workflow.rs` 直接订阅 `dialysis.record.created` 并编排 BPMN,违反 §1.3 L2 零直接依赖 | **新发现**(架构铁律被默默绕过) | --- ## 三、战略举措(归并为 5 项) ### 举措 A:后台任务声明式注册(TaskRegistry + spawn_workers)— 偿还"未接线死代码"债 **Rationale:** PP-01(retry_dead_letters)和 PP-05(claim_next)反复出现的根因不是"忘了接线",而是 ErpModule 没有"后台工作器"这一等公民概念,每个新任务都靠开发者记得在 `main.rs` 手动 spawn。一次性建立 framework 后,未来新增定时任务(分区维护、归档、留存策略)都有标准路径。 **Phases:** 1. **Phase A1(接线,1 周):** `erp-core/src/module.rs` 的 ErpModule trait 新增 `async fn spawn_workers(&self, ctx) -> AppResult>>`(default 空 Vec);把 `main.rs:425-673` 散落的 `start_event_cleanup / start_pool_metrics / start_auto_analysis / start_dialysis_workflow_orchestrator / start_outbox_relay / start_timeout_checker` 全部下沉到各模块 `spawn_workers`;erp-health 在 spawn_workers 中以 `tokio::time::interval(3600s)` 驱动 `retry_dead_letters`,erp-ai 驱动 `claim_next` 消费循环(每 30s 批量 20 条,`SELECT FOR UPDATE SKIP LOCKED` 防多副本)。 2. **Phase A2(防回归,0.5 周):** 编译期静态断言 + 集成测试:grep `pub async fn retry_dead_letters` 和 `pub async fn claim_next` 必须在 spawn_workers 实现中被引用;扩展 `CronHeartbeat` 为 `CancellationToken` 统一 graceful shutdown。 3. **Phase A3(可观测,0.5 周):** `/health/tasks` 端点暴露每个任务 `last_heartbeat / max_expected_interval`,对接 Alertmanager "任务卡死"告警。 - **effortEstimate:** 2 周(含测试 + graceful shutdown 改造) - **expectedImpact:** 兑现"主动关怀引擎"承诺(死信不再永久滞留)+ 消除 2 处死代码认知污染 + 为未来 HA 多副本选主铺路 - **kpis:** retry_dead_letters 每小时执行且 heartbeat 可观测;claim_next 队列积压 < 100;死信表 resolved_at 非空率 > 95% - **dependencies:** PP-04 可观测性三件套(Alertmanager)配合;多副本部署时需 PG advisory lock 选主(Phase C 蓝绿引入时) ### 举措 B:多租户隔离机制层根治(FORCE RLS + SET LOCAL 事务作用域)— 偿还"竞态防护负价值"债 **Rationale:** 三位专家一致裁定:PP-07(无 FORCE)和 PP-08(共享池 SET/RESET 串扰)是同一架构缺陷两面——"数据库层做应用层的事"(SET 会话变量)和"应用层依赖数据库兜底但没真兜底"(无 FORCE)互相强化。安全专家"多一层防护更安全"的直觉在此是**负价值**:SET/RESET 在共享池上的毫秒级窗口产生的间歇性泄漏比"无 RLS 兜底"更危险,因为它制造了"有防护"的假象。正确做法是机制层消除窗口,而非"小心地 RESET"。 **Phases:** 1. **Phase B1(FORCE RLS,0.5 周):** 新增迁移 `m000170` 遍历所有 tenant_id 业务表 `ALTER TABLE ... FORCE ROW LEVEL SECURITY`;新建非 owner 应用角色 `app_user`(REVOKE 所有 + GRANT CRUD + LOGIN),运行时连接改用 app_user——否则 FORCE 对 owner 无效。 2. **Phase B2(SET LOCAL 事务作用域,1.5 周):** 废弃 `tenant_rls.rs:31` 共享池 `SET app.current_tenant_id` 反模式;重构中间件为 `db.transaction(|txn| async { txn.execute(SET LOCAL app.current_tenant_id = $1); next.run(req).await })`——`SET LOCAL` 作用域严格限于事务内,commit/rollback 后自动消失,物理上不可能被连接复用读到。同步修订 wiki §4 RLS 描述(消除文档代码漂移)。 3. **Phase B3(并发隔离测试,0.5 周):** 新增多租户测试 crate(wiki §4.1 规划未实现),`#[tokio::test(flavor="multi_thread", worker_threads=8)]` 并发 100 个不同 tenant 请求断言零泄漏,覆盖 FHIR `allowed_patient_ids` 复杂范围过滤端点。 - **effortEstimate:** 2.5 周 - **expectedImpact:** 从机制层消灭跨租户泄漏窗口(医疗 SaaS 合规红线)+ 为读写分离/PgBouncer transaction pooling 铺路(SET LOCAL 是唯一可用租户上下文传递方式) - **kpis:** 并发隔离测试 100 线程零泄漏;FORCE RLS 覆盖所有 79 业务表;连接池 max_connections 调优后无等待超时 - **dependencies:** 连接池利用率评估(事务包裹增加连接占用时长,可能需调大 max_connections);只读端点统一事务路径回归测试 - **调和分歧:** 采纳首席架构师 A 方案 + 后端/数据架构师的 SET LOCAL——不删除 RLS 中间件(保留 tenant_id 注入 extension),但删除共享池 SET/RESET,改为事务内 SET LOCAL。这是机制层根治,安全专家"多一层防护"诉求由 FORCE RLS 兜底满足,竞态窗口由 SET LOCAL 消除。 ### 举措 C:模块边界复位(Saga 下沉 + ErpModule trait 裂变)— 偿还"组装层业务泄漏"债 **Rationale:** `erp-server/src/dialysis_workflow.rs` 在 L3 组装层承载透析业务编排,违反 §1.3"L2 间零直接依赖"铁律。ErpModule trait 11 个方法中 `register_event_handlers` 在 8 模块里 4 空壳,是典型的"接口名实不符"——新人会误以为事件注册走这个方法。这两点是微服务化时最痛的债:如果 erp-dialysis 核心逻辑物理上无法脱离 erp-server 运行,"按模块拆服务"就是空话。 **Phases:** 1. **Phase C1(编排下沉,1.5 周):** `erp-core` 定义 `trait DomainSaga { fn name(); async fn handle(&event, &ctx); }` + `SagaContext { db, event_bus }`;把 `dialysis_workflow.rs` 的 `handle_dialysis_record_created` 整体迁移到 `erp-dialysis/src/sagas/dialysis_session_saga.rs`,由 erp-dialysis 在 `on_startup` 自注册到 SagaRegistry;同理 erp-health 的"告警→AI→推送"链路、erp-ai 的"化验上传→解读→推送"链路各归其位。**验收:** `erp-server/Cargo.toml` 不再直接依赖业务 entity crate 内部类型。 2. **Phase C2(trait 裂变,1 周):** 废弃 `register_event_handlers`(deprecate 一版后删除),事件订阅统一在 `on_startup`;替换为 `fn capabilities(&self) -> &'static [ModuleCapability]` 显式能力声明(HttpRoutes / EventConsumer / EventPublisher / BackgroundWorker);按需 impl 而非大杂烩 trait。可选把单一 trait 裂变为 LifecycleModule / RoutingModule / EventModule(首席架构师提案),但为控制 breaking change 范围,建议先做能力声明,裂变留待 V2。 3. **Phase C3(archlint,0.5 周):** `cargo xtask archlint` 扫描 `erp-server/src/` 下是否有跨 crate 业务 import,CI 拦截边界泄漏回归。 - **effortEstimate:** 3 周 - **expectedImpact:** 模块具备"可独立部署"属性(微服务化前置)+ 消除 erp-server 业务逻辑认知陷阱 + 死 trait 方法清理降低新人认知负载 - **kpis:** erp-server 不再 import 业务 entity crate;archlint CI 零违规;register_event_handlers 调用点清零 - **dependencies:** Saga 间状态机/补偿事务本次只做"位置下沉"不引入完整 Saga 框架(避免过度设计);erp-dialysis 若需依赖 erp-workflow entity,通过 erp-core trait 反向依赖反转 ### 举措 D:事件契约治理(Schema 注册表 + Consumer Manifest + CI 校验)— 偿还"v1 永冻"债 **Rationale:** 51 个事件类型声明在 `docs/event-registry.md`,但代码侧无强约束。`events.rs:67` 的 schema_version 仅写入 payload,无消费者校验。这正是 PP-01/PP-05(事件无消费者/死存储)反复出现的元根因——没有编译期/CI 约束,全靠人记忆。微服务化前不补这课,拆服务当天就要停机重写(微服务间事件 schema 演进是分布式系统头号痛点)。 **Phases:** 1. **Phase D1(注册表,1 周):** `erp-core` 新增 `EventDescriptor { event_type, schema_version_range, payload_json_schema, deprecated_since }` 静态注册表;每个 crate 通过 `inventory` crate(编译期收集,fallback `build.rs` 代码生成)自动注册本模块发布/消费的事件描述符,生成 consumer manifest。 2. **Phase D2(消费校验,1 周):** `consume_with_retry` 入口按 event_type 查表,schema_version 做语义版本范围匹配(`^v1` 兼容 v1.x),不匹配事件进新表 `incompatible_events` 并告警(不进 dead_letter);为 `health.patient.created` / `health.alert.triggered` / `article.published` 等核心事件补 JSON Schema 草稿(`jsonschema` crate 校验,采样率控制 ~0.1ms/事件开销)。 3. **Phase D3(CI 拦截,0.5 周):** `cargo run --bin verify-event-contracts` 校验每个被 enqueue 的事件必须有至少一个 consumer manifest(直接拦截 PP-05 类死存储回归)。 - **effortEstimate:** 2.5 周 - **expectedImpact:** 从文档约定升级为编译期可校验的类型契约 + CI 直接拦截"事件无消费者"回归 + 为微服务间 schema 演进(expand-contract)打地基 - **kpis:** 51 事件类型 100% 有 consumer manifest;CI 零"无消费者"违规;核心事件 JSON Schema 覆盖率 > 60% - **dependencies:** 举措 A(spawn_workers 接线后才有真实消费者可 manifest);inventory crate 构建稳定性需验证 ### 举措 E:可演进 schema 与数据生命周期(独立 migrate + 分区自动化 + 物化视图 + 归档) **Rationale:** `main.rs:233` `Migrator::up` 在应用启动路径执行(PP-11),破坏性 DDL 在启动瞬间跑,多副本并发迁移会冲突;device_readings 分区硬编码到 2026-08(PP-02 确定性硬截止);stats_service 全是实时 count + date_trunc,Dashboard 随患者量增长拖垮 DB CPU;dead_letter_events / ai_analysis_queue / audit_logs / domain_events 无归档只增不减。这是 V1 上线后 6-12 个月迭代速度的核心瓶颈。 **Phases:** 1. **Phase E1(分区自愈 + 死信索引,1 周,含确定性硬截止解除):** 立即补丁迁移 `m000170` 用 `generate_series` 动态补建 2026_06 起未来 12 个月分区(模板提取自 `m000073:42-55`);安装 pg_partman 5.x + `part_config(premake=3, infinite_time_partitions=true)`;erp-health `spawn_workers` 新增 `start_partition_maintenance`(每 6h `SELECT partman.run_maintenance('device_readings')` 应用层兜底,防 pg_cron 缺失)。同步给 `dead_letter_events` 补 `idx_dead_letter_unresolved ON (tenant_id) WHERE resolved_at IS NULL` + `idx_dead_letter_created`(配合举措 A 重试扫描性能)。 2. **Phase E2(物化视图 + 归档,1.5 周):** stats 高频查询建物化视图 `mv_patient_stats_by_tenant` / `mv_consultation_stats_by_tenant`,`REFRESH CONCURRENTLY` 每 10 分钟(`tasks.rs` 新增 `start_stats_refresh`);Dashboard 读物化视图,UI 标注"数据更新于 X 分钟前"管理预期。归档函数 `archive_old_rows(table, days)` 把 > 180 天 resolved/completed 数据迁到 `_archive` 表(医疗病历留存 15 年合规要求,冷数据不删除只迁移);明确热(<30 天)/温(30-180 天)/冷(>180 天)三级生命周期写入 `wiki/database.md`。 3. **Phase E3(独立 migrate 子命令,1 周):** 拆出 `erp-server migrate` 子命令,pre-deploy 阶段独立执行迁移,应用启动不再跑 `Migrator::up`;建立 schema 兼容窗口规则——破坏性变更必须分两次发布(v1 加新列双写 / v2 删旧列,expand-contract pattern),CI lint 强制。 4. **Phase E4(双副本蓝绿,3-4 周,演进式不一步到位):** compose + 双副本 + Nginx upstream 切换 + PG 副本(`alerts.yml:70` pg_replication_lag 告警已定义但无副本);配合举措 A 的 PG advisory lock 选主。**异见采纳:** 医疗 SaaS 早期团队(<10 人)K8s 运维成本超过收益,compose + 双副本拿 80% HA/回滚收益用 20% 复杂度。 - **effortEstimate:** 6.5 周(E1-E3 可独立交付,E4 演进式) - **expectedImpact:** 解除 2026-09-01 确定性硬截止 + Dashboard DB CPU 降 50%+ + 破坏性迁移可安全回滚 + 为多租户 SaaS 报表打地基 - **kpis:** device_readings 分区自动维护至 2027_06;Dashboard 物化视图刷新延迟 < 10min;破坏性迁移 100% 走 expand-contract;erp-server migrate 独立子命令上线 - **dependencies:** 举措 A(spawn_workers 注册 partition_maintenance / stats_refresh);双副本引入分布式问题需配套选主;团队接受 expand-contract 两次迁移纪律(CI lint 强制) --- ## 四、速赢(1-2 周可落地) 1. **PP-01 死信重试接线(2-3 天):** 在 `tasks.rs` 新增 `start_dead_letter_retry`(每小时,调用 `retry_dead_letters` 并 touch heartbeat),与 `start_event_cleanup` 对称注册到 `main.rs`;补集成测试;修正 wiki 误记。**ROI 极高**——代码已实现 90%,差最后 10% 接线,解锁危急值告警/积分发放/预约提醒重试链路。 2. **PP-02 分区补建迁移(2 天):** 立即写 `m000170` 用 `generate_series` 补建 2026_09~2027_06 分区(确定性硬截止解除,距今 ~10 周)。pg_partman 自动化可随后跟进,但手动补建是上线前必须项。 3. **PP-07 FORCE RLS 迁移(1 天):** 单个迁移 `ALTER TABLE ... FORCE ROW LEVEL SECURITY` 遍历所有 tenant_id 表(沿用 m000088 DO 块模板),立即堵死 owner-bypass 路径。SET LOCAL 事务改造(举措 B Phase B2)随后跟进。 4. **register_event_handlers 死方法清理(1 天):** deprecate 标注 + 文档迁移到 on_startup,低风险高回报的认知债偿还。 --- ## 五、主题级风险 1. **多副本引入分布式问题:** 蓝绿/双副本后,后台任务(举措 A)会重复执行,需 PG advisory lock 选主;session 一致性、token 黑名单(当前 DashMap 非分布式)需迁 Redis。建议举措 A/E4 打包推进。 2. **trait 裂变是 breaking change:** ErpModule trait 裂变需一次性迁移 8 个模块,建议放在 V1 上线后第一个迭代;archlint 误报可能拖慢迭代,需白名单机制。 3. **SET LOCAL 性能开销:** 每请求 BEGIN/COMMIT 约 1-2ms,医疗后台可接受,但 device_readings 高频写入路径需评估;连接池利用率可能下降,需调大 max_connections。 4. **加密与搜索契约冲突:** 患者姓名加密(PP-12)会破坏 `Name.contains`(ILIKE '%x%')模糊搜索契约,FHIR handler 依赖此契约。gin(trgm) 索引在加密后失效,必须在加密方案落地前确定搜索降级策略(prefix-only / HMAC 盲索引精确匹配),而非事后补救。 5. **inventory crate 构建稳定性:** 某些构建配置下不稳定,需 fallback 到 build.rs 代码生成;JSON Schema 校验增加每事件 ~0.1ms 开销,需采样率控制。 6. **确定性 vs 概率性优先级分歧:** 数据架构师主张 PP-02(确定性硬截止)优先级高于一切概率性 bug;其他专家按影响面排序。**裁定:** Phase 0 两者都做(速赢 1+2 并行),不排序。 --- ## 六、调和专家分歧后的最终取舍 | 分歧点 | 安全/DevOps 立场 | 首席/后端/数据架构师立场 | 最终取舍 | |--------|-----------------|----------------------|---------| | tenant_rls SET 逻辑 | "多一层防护更安全,保留 SET" | "竞态防护是负价值,SET/RESET 窗口比无兜底更危险" | **采纳机制层根治:** FORCE RLS 兜底 + SET LOCAL 事务作用域,删除共享池 SET/RESET。安全诉求由 FORCE 满足,竞态由 SET LOCAL 消除 | | CD 工具选型 | "直接上 Kubernetes/ArgoCD" | "K8s 运维成本超过收益,compose + 双副本够用" | **采纳演进式:** compose + 双副本 + 独立 migrate,拿 80% 收益用 20% 复杂度。真正难的是 schema 兼容窗口纪律(expand-contract),与工具无关 | | 事件 schema 注册表 | "过度工程,补测试更重要" | "PP-01/PP-05 反复出现的元根因,微服务化前必修" | **采纳注册表:** 但分阶段(注册表 → 消费校验 → CI 拦截),先解决"无消费者"回归,JSON Schema 全覆盖可渐进 | | Repository trait 层 | — | 后端架构师主张抽出(消灭漏 tenant_id 再生土壤) | **暂缓:** 工作量大(16+ 处 begin/commit 迁移),且当前 SeaORM Filter + 举措 B 机制层防护已大幅降低泄漏风险。列为 V2 候选,先做试点(appointment/consultation/follow_up)验证抽象价值 | | trait 裂变 vs 能力声明 | — | 首席主张三 trait 裂变,后端主张能力声明 | **采纳能力声明优先:** 先 `capabilities()` 显式声明 + deprecate 死方法,裂变留待 V2,控制 breaking change 范围 | --- ## 七、路线图(与决策简报 Phase 对齐) | Phase | 时窗 | 本主题举措 | 交付价值 | |-------|------|-----------|---------| | Phase 0 护航 | 0-2 周 | 速赢 1-4(PP-01 接线 / PP-02 分区补建 / PP-07 FORCE RLS / 死方法清理) | 消灭确定性硬截止 + 兑现死信重试承诺 + 堵死 owner-bypass | | Phase 1 稳固 | 1-3 月 | 举措 A 全量 + 举措 B 全量 + 举措 D Phase D1-D2 | 后台任务可观测 + 多租户机制层根治 + 事件契约注册表 | | Phase 2 深化 | 3-6 月 | 举措 C 全量 + 举措 D Phase D3 + 举措 E Phase E1-E3 | 模块边界复位 + CI 拦截无消费者 + schema 兼容窗口纪律 | | Phase 3 规模化 | 6-12 月 | 举措 E Phase E4(双副本蓝绿)+ Repository trait 试点 | 多副本 HA + 数据访问契约接缝(微服务化前置) | --- > 本章节所有论断已附代码证据(文件:行号或 grep 结果)。核心架构债(事件契约 / 模块边界 / 后台任务 / 多租户隔离 / schema 演进)现在偿还成本是"重构级",6-12 个月后微服务化启动时将退化为"重写级"。决策层建议:Phase 0 速赢立即启动,Phase 1-2 把五类结构性缺陷升级为编译期/CI 可校验契约。