## Bug 调研报告：`BusinessObject.content_type/object_id` 为空导致状态流转记录不可追溯

### 背景与结论摘要
- **问题**：`stateflow.BusinessObject` 的 `content_type_id` / `object_id` 允许为空，导致部分流程实例无法追溯到真实业务对象；相关 `StateFlowRecord` / 参数记录即使存在，也无法再关联回订单/任务等业务数据。
- **结论**：`object_id=NULL` 不是“默认 process 能自动补齐”的字段；它只能在创建/绑定 `BusinessObject` 时显式写入。历史/旁路创建路径只写了 `process/name`，且 services 层此前允许在未绑定的 `BusinessObject` 上继续写日志，最终放大成数据不可追溯问题。
- **处理目标**：在 **API + services 层永久性杜绝新增未绑定 BusinessObject**，并提供历史数据回填/隔离方案。

### 现象与影响
- **现象**：
  - `BusinessObject.content_type_id IS NULL` 或 `BusinessObject.object_id IS NULL`
  - `StateFlowRecord.business_object_id` 通常不为空，但其指向的 `BusinessObject` 可能未绑定业务对象 → **日志“有记录但无归属”**
- **影响**：
  - 无法通过 `BusinessObject.content_object` 找到关联订单/任务（GenericForeignKey 失效）
  - 依赖“从流程实例回溯业务对象”的接口/审计/参数回显/报表逻辑可能崩溃或产生错误结果
  - 一旦继续在未绑定对象上推进流程，会持续产生日志与参数记录，扩大脏数据

### 数据证据（测试库抽样，2025-12-17）
> 注：以下数字来自本次调研时的测试库，仅用于说明问题形态与证据链；生产环境请用同样的统计口径核对。

- **表级统计（BusinessObject）**：
  - `BusinessObject.total = 119`
  - `content_type_id IS NULL = 70`
  - `object_id IS NULL = 65`
  - `content_type_id IS NULL AND object_id IS NULL = 65`（典型“完全未绑定”）
  - `content_type_id IS NULL AND object_id IS NOT NULL = 5`（“半绑定”异常，通常来自不完整回填/旧逻辑）
- **时间分布（出现明显分界）**：
  - 2025-11-18 ~ 2025-12-09：新增的 `BusinessObject` 几乎全部为未绑定
  - 2025-12-10 之后：新增 `BusinessObject` 基本为已绑定
  - 这与“历史旁路/旧版本逻辑 → 后续修复上线”的典型形态一致
- **关键反证（排除‘当前 printing 正确路径’）**：
  - 当前库中 `PrintingJob.min_id = 16`，`id <= 15` 的 `PrintingJob` 已不存在
  - 但仍存在多条 `BusinessObject.name = PrintingJob-1..15` 且 `content_type_id/object_id = NULL`
  - 说明这些 `BusinessObject` 是“创建时就未绑定”，且后续对应业务对象被清理/重建（GFK 不会级联清理 BusinessObject）后遗留为孤儿

### 根因分析（为什么会产生 NULL）
- **Schema 层允许为空（放行）**：
  - `stateflow/migrations/0012_alter_businessobject_content_type_and_more.py` 将 `content_type/object_id` 改为 `null=True, blank=True`
  - `stateflow/models.py::BusinessObject` 仍保持字段可空（历史兼容需要）
- **创建入口存在“只创建流程实例、不绑定业务对象”的旁路（直接成因）**：
  - 旁路创建方式通常类似：`BusinessObject.objects.create(name=..., process=...)`（不传 `content_type/object_id`）
  - 该模式在仓库测试代码中可见（反映团队历史习惯/旧实现可能性），且与库中 `PrintingJob-*` 未绑定样本吻合
- **services 层此前缺少硬防线（放大器）**：
  - 若允许在未绑定的 `BusinessObject` 上执行 `advance_to_next_state`，则会不断生成 `StateFlowRecord` / 参数记录，但永远无法追溯业务对象

### 为什么“默认 process”不能保证 `object_id` 非空
- **`process_id` 只决定流程模板**，并不能推出“关联的业务对象是谁”
- 业务对象 ID 必须来自具体实例（如 `PlateOrder.id` / `PrintingJob.id`），而这个 ID 只有在业务对象入库后才确定
- 因此任何“先建 BusinessObject、后忘记补绑定”的路径都会产生 `object_id=NULL`，且 DB 允许该脏数据落库

### 处理方法（已落地）
#### 1) API 层：禁止创建/更新未绑定 BusinessObject（永久杜绝新增）
- **位置**：`stateflow/serializers.py::BusinessObjectCreateUpdateSerializer`
- **规则**：
  - `content_type` 与 `object_id` **均为必填且不可为空**
  - `object_id` 必须能在 `content_type` 指向的模型中查到真实对象（避免“悬空绑定”）

#### 2) Services 层：禁止在未绑定 BusinessObject 上推进/克隆（永久杜绝新增日志污染）
- **推进**：`stateflow/services.py::advance_to_next_state`
  - 若 `content_type_id/object_id` 为空：直接返回失败（禁止推进）
  - 若 `content_object` 为 `None`（悬空绑定）：直接返回失败（禁止推进）
- **克隆**：`stateflow/services.py::clone_business_object`
  - 若源对象未绑定：抛错禁止克隆
  - 克隆目标 `new_object_id` 必填，且目标对象必须存在（避免生成新的悬空绑定）

#### 3) 历史数据修复：提供 printing 维度回填命令（修复“仍存在且可确定归属”的记录）
- **命令**：`stateflow/management/commands/repair_business_object_bindings.py`
- **用途**：对 `PrintingJob.business_object` / `PlateOrder.business_object` 一对一绑定关系做一致性回填（`content_type/object_id/name`）
- **用法**：
  - 仅统计不落库：`uv run manage.py repair_business_object_bindings --dry-run`
  - 分类型：`--only printingjob|plateorder|all`
  - 分批：`--limit N`
- **重要限制**：
  - 若 `BusinessObject` 已成为孤儿（业务对象已被删除/不存在），则无法可靠回填，只能**隔离/清理**（见下节建议）

### 建议补齐的“永久性治理”（防止新旁路再次引入）
- **收敛创建入口（强烈建议）**：
  - 统一通过“创建并绑定”的工厂函数/服务创建 BusinessObject，禁止散落的 `BusinessObject.objects.create(...)`
  - 对 printing 等业务模型，创建时必须使用“先保存业务对象 → 再创建并绑定 BusinessObject”的顺序
- **全仓扫描与 CI 约束**：
  - 对 `BusinessObject.objects.create(` 做静态扫描，若未同时出现 `content_type` 与 `object_id` 则在 CI 失败（或至少告警）
- **线上监控/告警**：
  - 增加周期性检查：若发现“近 X 分钟/小时新增的 BusinessObject 存在 NULL 绑定”，立刻告警（Sentry/日志/钉钉均可）
  - 目的：让问题从“长期潜伏”变为“即时可见”
- **（可选）DB 级兜底**：
  - 若允许：用触发器/约束保证“新写入必须非空”，同时保留历史空值（更强的最后一道防线）

### 历史孤儿数据建议处置
- **识别**：
  - `content_type_id/object_id` 均为空的 BusinessObject
  - 或者 `content_object is None` 的悬空绑定
- **处置选项**（需业务确认）：
  - **清理**：删除孤儿 BusinessObject 及其 state_logs（若这些日志对业务无意义且不会被引用）
  - **隔离**：保留数据但在查询/报表中排除，并记录“不可追溯原因”（便于审计）
  - **人工映射**：若能通过其它字段（如外部单号、备注）恢复归属，可进行一次性人工回填

### 验证与回归
- **新增防线验证点**：
  - 创建/更新 BusinessObject API：缺失绑定应直接拒绝
  - 推进/克隆：未绑定/悬空绑定应被禁止（不再产生新的 StateFlowRecord）
- **建议回归覆盖**：
  - printing 创建 PlateOrder/PrintingJob 后，关联的 BusinessObject 必须是已绑定状态
  - 批量推进/单条推进在遇到历史脏数据时应给出明确错误或被自愈修复（取决于调用方策略）


