This commit is contained in:
oneao committed 2026-10-13 17:29:55 +08:00
1 parent 7bd5f1dde9
commit f3936cec61
70 files changed
+13074 -187

No files matched your search

@@ -0,0 +1,261 @@
# 工作流模块迁移 —— 交接说明
> 用途:把「把原型 fms-api 的工作流引擎迁移进 g3soft-erp/server 并修复一批缺陷」这件事的**当前状态、已定决策、踩过的坑、剩余工作**完整交接出去。
> 接手者(人或 AI)只读本文件即可继续,不需要回溯之前的对话。
> 最近更新:2026-10-13
---
## 一、背景
- **原型**:`D:/workspace/code/fms/fms-api`(包名 `cn.g3soft.fmsapi`),含一套**手写的轻量流程引擎**(无 Flowable/Activiti/Camunda 依赖)。
- **目标**:`d:/workspace/code/g3soft-erp/server`(包名 `cn.g3soft.erp`,Spring Boot 4.1.0 / Java 21 / 纯 JDBC / 无 `@Transactional` / 多机构按 `OrgContext` 路由数据源)。
- **原始设计文档**:`D:/workspace/code/fms/FMS工作流与审批设计.md`(§ 编号在本文件与代码注释里频繁引用)。
- **迁移动因**:g3soft-erp 是 fms-api 的续作,工作流当初"未带入";本次要迁入并**同时修掉评审发现的一批缺陷**。
> 迁移前的评审结论:设计(文档)质量不错,但实现只落地七八成,且有几个硬伤(串行会签不通、乐观锁是假的、运行时零测试)。详见下文。
---
## 二、当前状态总览
| 阶段 | 内容 | 状态 |
|---|---|---|
| **P0** | 建 `sql/` 目录与脚本;对真库执行补丁 | ✅ |
| **P1** | 8 个 service + 2 个 controller 改包名迁移;2 个测试随迁 | ✅ |
| **P1.5** | 删 `WorkflowConfigService` 私有 SQL 层(修 C2 时间口径) | ✅ |
| **P2** | A1 串行会签 / A2 claim 越权 / A3 投影丢更新;C2/C4/C5/C6/C7 | ✅ |
| **P3** | B1 乐观锁 / B2 锁序死锁 / B3 严格条件解析 | ✅ |
| **C3** | `WorkflowTx` 事务模板;`WorkflowRuntimeService` 8/8 转换;删孤儿方法 | ✅ |
| **P4** | D1 委托 / D2 超时扫描 / D3 路由留痕 / D4 职责分离 / D5 审计探写 | ✅ |
| **P5** | 拆两个上帝类 | 🔶 **仅完成 1 项**(`WorkflowInstanceRow` 抽取) |
| **P6** | 补测试 + 端到端验证 | ❌ **未开始** |
### 尚未完成、但**已被批准**的一项(容易漏)
**把工作流的动作权限校验改为复用 `SqlPermissionService`**:
- 已定方案:给 `SqlPermissionService` 加一个**带 `Connection` 的重载**(现有方法自己开连接、不进 workflow 事务),workflow 侧删除私有的重复 `s_power` 查询与硬编码管理员账号(改用 `isAdministrator`)。
- **现状**:`SqlPermissionService` 没有 Connection 重载;`WorkflowRuntimeService.java:1285` 仍是私有 `requireAction(Connection, moduleId, userId, action)`,内部硬编码 `ADMIN_ACCOUNT = "g3soft"`。
- 兼容性已核实:`WorkflowConfigService.ensureActionPowers` 写的是 `b_type='action', b_object_type='module', b_object_id=模块, b_action=动作`,与 `SqlPermissionService.requireAction` 的匹配列**一致**,可以直接对接。
- 附带:`requireEnabledUser` 在目标侧**没有等价实现**,仍是 `WorkflowRuntimeService` 的私有方法,需自行决定是否抽到 `service/auth`。
---
## 三、环境与命令
### 数据库
- 配置:`server/config/dbconfigs/G3HD.properties`(**已 gitignore**)
- `url=...databaseName=G3ERP...`,机构码 `G3HD`,主机 `118.89.70.199:1433`
- 该库**已具备**:13 张 `wf_*` 表、核心表(`s_module`/`s_field`/`s_power`/`s_user_power`/`s_menu`/`b_user`/`b_dept`)、组织扩展(`b_position`/`b_user_position`/`b_org_role`/`b_user_org_role`、`b_dept.b_manager_id`、`b_user.b_leader_id`)、审计表(`s_log_audit`/`s_log_audit_field`)。
- 已有 mock 数据:定义 `expense_approval`(14 节点 / 17 连线 / 13 审批人规则)、版本 `v1`(published) + `v2`(draft)、1 条**停用**绑定 `demo_expense_binding`(模块 `v_b_othercompany`,`b_canuse=0`)、6 个运行实例、31 条任务、4 条抄送。
### 构建 / 测试 / 运行
```bash
cd d:/workspace/code/g3soft-erp/server
export JAVA_HOME="D:\\devtool\\jdk\\jdk-21"
export PATH="/d/devtool/jdk/jdk-21/bin:$PATH"
./mvnw.cmd -q -o compile # 编译
./mvnw.cmd -o test -Dtest='WorkflowConditionEvaluatorTests,WorkflowValidatorTests' # 现有 19 个测试
./mvnw.cmd -o spring-boot:run -Dspring-boot.run.arguments="--server.port=18090" # 起服务
```
- 上下文:`/api`,默认端口 8088。
- 工作流接口:`/api/workflow/**`(配置侧)、`/api/workflow/runtime/**`(运行侧),全部 POST、body 为 `Map<String,Object>`。
- 无 DB 时 `mvnw test` 会因 `ExportServiceIntegrationTests` 失败(它打真库)。
### 执行 SQL 脚本
仓库里**没有** SQL 运行器(无 Flyway/Liquibase)。本轮是用一个临时 Java 程序(`java -cp <mssql-jdbc.jar> Xxx.java`,读 `G3HD.properties`,按 `GO` 分批 `execute`)跑完即删。JDBC 驱动在 `~/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/13.4.0.jre11/`。
---
## 四、已完成工作明细
### 4.1 新增文件
**`g3soft-erp/sql/`(10 个,全部 LF)**
```
README.md # 执行顺序 / 幂等 / 两个引导陷阱
00_core_companion/workflow_audit_tables.sql # s_log_audit + s_log_audit_field(幂等,D5 用)
10_workflow/01_fms_workflow.sql # 13 张 wf_* 表(不幂等)
10_workflow/02_fms_workflow_org_ext.sql # 组织主数据扩展
20_registration/10_fms_workflow_module.sql # 4 个视图 + s_module/s_field/s_module_schema/s_menu_module
20_registration/11_fms_workflow_menu.sql # 菜单 + i18n
20_registration/12_fms_workflow_power.sql # 权限点(菜单 + 11 个动作)
30_patches/01_fms_workflow_index_patch.sql # 补 3 个索引(已执行)
30_patches/02_fms_workflow_unique_index_patch.sql # 恢复 4 个缺失的筛选唯一索引(已执行)
90_mock/01_fms_workflow_mock.sql # 示例流程定义
90_mock/02_fms_workflow_runtime_mock.sql # 示例运行数据
```
**`server/src/main/java/cn/g3soft/erp/service/workflow/`**(迁移 + 新增)
| 文件 | 行数 | 说明 |
|---|---|---|
| `WorkflowRuntimeService.java` | 1616 | 运行门面(**待拆**) |
| `WorkflowConfigService.java` | 1026 | 配置门面(**待拆**,另 8 处事务样板待转) |
| `WorkflowRuntimeQueryService.java` | 522 | 待办/已办/进度图等读模型 |
| `WorkflowValidator.java` | 402 | 发布期校验 |
| `WorkflowActorResolver.java` | 325 | 审批人解析 |
| `WorkflowDraft.java` | 224 | 领域草稿 + 结构解析 |
| `WorkflowConditionEvaluator.java` | 198 | 条件 AST 求值(纯函数) |
| `WorkflowSql.java` | 97 | 包内 JDBC 小工具 |
| **`WorkflowTx.java`** | — | **新**:事务模板(2 参 / 3 参带恢复钩子 / `isUniqueViolation`) |
| **`WorkflowJson.java`** | — | **新**:JSON 严格/宽松双入口 |
| **`WorkflowConfigException.java`** | — | **新**:配置损坏异常(extends BusinessException) |
| **`WorkflowInstanceRow.java`** | 64 | **新**:由内嵌类提升为顶层包内类 |
| **`WorkflowDelegationService.java`** | — | **新**:委托(D1) |
| **`WorkflowExpirySweepService.java`** | — | **新**:超时扫描(D2) |
**其他新增**
- `server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java`、`WorkflowRuntimeController.java`
- `server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java`、`WorkflowValidatorTests.java`(19 个测试,全绿)
- `server/config/dbconfigs/G3HD.properties`(gitignored)
### 4.2 修改文件
- `database/org/OrgDatabaseConfigLoader.java` — 新增 `listOrgIds()`(供无请求上下文的后台任务 fan-out)
- `src/main/resources/application.yaml` — 新增 `erp.workflow.expire-sweep-interval: 5m`
### 4.3 关键改动点(每项都有对应缺陷)
| 编号 | 缺陷 | 改法 |
|---|---|---|
| **A1** | 串行会签完全不通:任务建为 `waiting` 后**没有任何语句把它改回 `pending`**,节点永远凑不满票数 | 新增 `promoteNextSerialTask()`:在"未凑够票数"分支把 `(b_seq,b_id)` 最小的 `waiting` 翻成 `pending`,写 `activate` 动作 + outbox |
| **A2** | `claim` 只查动作权限,**不校验调用者是不是候选审批人** → 有 claim 权限可抢任意待办 | 锁 `node_run` 读 `b_actor_snapshot_json.resolvedUsers` 校验;**候选取自快照而非任务表**(`single` 模式只给第一个候选建任务);解析异常→空集合→拒绝(fail-closed) |
| **A3** | 业务状态投影 `update 业务表 set 状态=? where 主键=?`,**无行数校验、无版本谓词** → 静默丢更新 | 读投影前旧值;业务表有 `b_row_version` 时一并自增(否则会让通用保存的乐观锁读到陈旧版本);行数≠1 写 `wf_outbox(instance.projection_missing)` 留痕 |
| **B1** | 文档承诺的乐观锁**是假的**:`b_row_version` 只递增、从不进 `WHERE` | 新增 `updateInstance(...)` 统一入口,追加 `and b_id=? and b_row_version=?` 并校验行数;`WorkflowInstanceRow` 改**可变**(同事务内会多次更新实例) |
| **B2** | 锁序不一致:`act/claim/transfer/addApprover/urge` 是"任务→实例",`withdraw/cancel/finishInstance` 是"实例→任务" → **AB-BA 死锁** | 统一为**实例优先**:新增 `lockTask(...)`(无锁预读拿 instance_id → `lockInstance` → UPDLOCK 重读并复查状态/归属);`cancelSiblingTasks` 加 `UPDLOCK` |
| **B3** | 脏条件 JSON 被静默降级成空 → **当成"无条件边"命中**,静默错路由;绑定匹配同理 | 新增 `WorkflowJson.readMapOrNull`(严格)/`readMapOrEmpty`(宽松);`WorkflowDraft.conditionOf` 在**发布期**就拒绝"给了值却不是对象";`advance` 捕获 `WorkflowConfigException` → `failInstance` 置 error **并保留本次表决** |
| **C2** | `WorkflowConfigService` 私有复制了一份 `WorkflowSql`,**漏了 `TimeUtils.toApiValue`** → 配置侧时间出参不带 `Z` | 删除私有层,49 处调用统一走 `WorkflowSql` |
| **C4** | 缺 `wf_binding(b_version_id)`、`wf_task(b_completed_by)`、`wf_task(node_run,status,seq)` | `sql/30_patches/01_*`(已在真库执行) |
| **C5** | `wf_actor_rule` 的 `b_condition_json`/`b_fallback_type`/`b_fallback_ref` 解析器会读、但设计器**从来写不进去** | 扩展 `WorkflowDraft.ActorRule`(保留 2 参便捷构造以免测试改动);`writeNode` 补写、`loadDraft` 回读(**严格**,因为 loadDraft 是发布校验的输入);`WorkflowValidator` 补兜底类型 + 规则条件校验 |
| **C6** | `return_previous` 重进上一节点时**没关当前 node_run** → 永久 `running`,会让并行汇合误放行 | 补 `completeNodeRun(...,"completed")` |
| **C7** | `service_task` 的动作码被误标成 `"cc"`(看起来像抄送) | 改为 `"service"`,措辞去掉"第一阶段占位" |
| **C3** | 事务样板块板 | 新增 `WorkflowTx`;`WorkflowRuntimeService` 8/8 转换;删私有 `rollback`/`restoreAutoCommit`/`isUniqueViolation` |
| **D1** | `wf_delegation` 表建了但 **Java 零引用** | `WorkflowDelegationService`;**任务生成时物化**到代理人(`b_assignee_type='delegate'`),原审批人+委托单号进任务快照;动作期 `verifyDelegation` 复核 `actions` 范围 |
| **D2** | `expired` 状态从不写入、没有调度器 | `WorkflowExpirySweepService`(裸 `ScheduledExecutorService`,无请求上下文时按机构 fan-out) |
| **D3** | §9.5 要求的路由留痕从未实现 | `chooseEdge` 输出决策轨迹(每条出线的求值结果 / 是否默认边);`advance` 在**出线>1 的分支点**写一条 `route` 动作 |
| **D4** | §10.3 职责分离策略从不读取(只读了 `allowClaim`) | `WorkflowActorResolver.resolve` 加 `excludeUserIds`;`excludedActors(...)` 支持 `allowInitiator=false` / `excludeCreator` / `excludePreviousActors`;新增三态 `configFlagOrNull` 区分"没配"与"配了 false" |
| **D5** | §11 要求写 `s_log_audit` 但目标库原来没有该表 | 探写:表存在则写事件 + 字段级前后值;不存在写 `wf_outbox(audit.skipped)` 并继续 |
---
## 五、数据库侧的两个重要发现
### 5.1 四个"筛选唯一索引"在真库里全部缺失(已修)
`fms_workflow.sql` 里定义了 4 个 filtered unique index,但 G3ERP 库里**一个都没有**。后果是:
| 缺失索引 | 后果 |
|---|---|
| `ux_wf_instance_idempotency` | **`submit` 幂等键形同虚设**——重复提交不被拦(代码靠捕获 SQL 2601/2627/23000 做幂等恢复,索引不在就等于没有) |
| `ux_wf_action_request` | **重复审批不被拦** |
| `ux_wf_version_draft` | 同一定义可并存多条草稿 |
| `ux_wf_binding_active_priority` | 同模块/事件可有两条同优先级启用绑定 |
`sql/30_patches/02_*.sql` 已幂等恢复(创建前先查重,冲突则 `RAISERROR` 报错)。**已在真库执行并确认 7 个索引全部建上。**
### 5.2 权限点脚本的引导顺序陷阱
`20_registration/12_fms_workflow_power.sql` 的动作权限点由
`select distinct b_module_id from wf_binding where b_canuse=1` 生成——**binding 为空时生成 0 条**。
缓解:① `sql/README.md` 写明了顺序;② 应用侧 `WorkflowConfigService.saveBinding` 每次保存绑定都会调 `ensureActionPowers` 自愈;③ 脚本可重复执行。
---
## 六、剩余工作
### 6.1 P5:拆两个上帝类(纯移动,零功能价值)
**`WorkflowRuntimeService`(1616 行)→**
| 新类 | 职责 / 方法 |
|---|---|
| `WorkflowRuntimeService` | 对外门面,持事务边界,public 方法签名**保持不变** |
| `WorkflowRoutingEngine` | `advance` / `enterNode` / `activateUserTask` / `runCcTask` / `reenterPreviousNode` / `chooseEdge` / `outgoingEdges` / `incomingEdges` / `allIncomingCompleted` / `recordNodeRun` / `completeNodeRun` |
| `WorkflowTaskService` | `claim` / `transfer` / `addApprover` / `urge` / `cancelSiblingTasks` / `cancelAllTasks` / `promoteNextSerialTask` / `writeAction` / `writeOutbox` / `requiredCount` / `dueMinutesOf` |
| `WorkflowInstanceService` | `withdraw` / `cancel` / `finishInstance` / `failInstance` / `lockInstance` / `updateInstance` / `projectBusinessStatus` / `writeAudit` / `requireAction` / `requireEnabledUser` |
| `WorkflowSubmissionService` | `submit` / `matchBinding` / `submittedResult` / `currentStatus` |
**`WorkflowConfigService`(1026 行)→** `WorkflowDefinitionService` / `WorkflowVersionService` / `WorkflowBindingService` / `WorkflowDraftMapper`,门面保留、控制器签名不变。`WorkflowDraft` / `WorkflowValidator` / `WorkflowActorResolver` 原地保留;`WorkflowSql` 保持包内可见。
**拆类时顺手做完 C3 的余量**:`WorkflowConfigService` 还有 **8 处手写事务样板**(`restoreAutoCommit(connection, originalAutoCommit)` 在 150/210/287/372/404/487/590/611 附近),改用 `WorkflowTx.run(...)`,然后删掉它私有的 `rollback` / `restoreAutoCommit`。
> **注意**:`WorkflowConfigService` 的 8 处**是否与 RuntimeService 的一样统一**,接手时要**先核实再动手**(本轮在 RuntimeService 上发现"16 处里有 2 处不同",见 §七.2)。
### 6.2 P6:测试 + 端到端(**建议先做这个,见 §八**)
**迁移测试**(已存在,2 个文件 19 个用例,只改了 package)。
**新增纯单测**(`cn.g3soft.erp.service.workflow`,JUnit 5 + AssertJ):
| 覆盖 | 测试类 |
|---|---|
| `WorkflowJson` 严格/宽松(缺省→null;损坏→抛;非对象→抛;合法→Map) | `WorkflowJsonTests` |
| 委托 scope 匹配真值表(空数组=不限、moduleIds/orgIds/actions) | `WorkflowDelegationTests` |
| 投影守卫决策矩阵(有/无版本列 × 行数 0/1) | `WorkflowProjectionGuardTests` |
| `WorkflowDraft.ActorRule` 的 condition/fallback 往返解析 | `WorkflowDraftTests` |
| 职责分离三态开关(`configFlagOrNull` + `excludedActors` 语义) | `WorkflowActorResolverTests`(纯函数部分) |
| 串行提升的排序选择、`successorCount(serial)=候选数` | `WorkflowSerialTests` |
| `WorkflowInstanceRow` 内存版本推进 | `WorkflowInstanceRowTests` |
**DB 集成测试**(真机 SQL Server,仿 `ExportServiceIntegrationTests`:`@SpringBootTest` + `OrgContext.setOrgId("G3HD")` + `setUserId("g3soft")`):
A1 串行三连(通过→提升→中途驳回→waiting 取消→全通过→节点完成)/A2 非候选 claim 被拒/A3 投影并发冲突与 0 行/B1 陈旧版本更新失败/B2 两线程 `act` vs `withdraw` 无死锁(SQL 无 1205)/B3 库中坏条件→实例 `error`/C2 配置侧时间出参带 `Z`/C6 `return_previous` 不留 `running` node_run/C7 动作码为 `service`/D1 委托物化/D2 超时只标记不改流程/D3 `route` 动作落库/D5 审计两表落库。
> **门禁**:这些 DB 测试**必须加 `@Tag("db")` + Maven profile(默认不随 `mvnw test` 跑)**,否则在没有 workflow 表的库上会构建失败。
**端到端(真机)**:启用 `demo_expense_binding`(把 `b_canuse` 置 1),走完 mock 的 6 个实例 + 串行链 + 委托 + 超时 + 审计;确认无死锁、日志正常。
---
## 七、踩过的坑(接手者务必先看)
1. **换行符**:fms-api 源文件是 **CRLF**,g3soft-erp 的 `.gitattributes` 强制 `*.java`/`*.sql`/`*.md` 为 **LF**。迁移时逐文件 `sed 's/\r$//'`。
2. **事务样板"看似统一、实则不统一"**:`WorkflowRuntimeService` 的 16 个块里,只有 6 个是规整的 `catch (SQLException | RuntimeException)`;`submit` / `act` 各自**拆成两段 catch**,SQLException 分支要**先回滚、再查库**判定是否重复请求。简单模板表达不了这个语义——这就是 `WorkflowTx` 有 **3 参版 + `SqlErrorHandler` 钩子**的原因。**不要试图用统一脚本盲改。**
3. **lambda 体里的两条硬约束**(本轮编译失败两次):
- 被捕获的局部变量必须 **effectively final**。`event`(`if (event.isEmpty()) event = "submit"`)和 `deptId`(连续三次回退赋值)都是因此改成单次表达式。
- **回调里不要自己 commit**。转换后有 6 处遗留了 `connection.commit();`,必须删掉(模板负责提交;留着会破坏恢复钩子的事务语义)。
4. **`WorkflowInstanceRow` 必须可变**:一个事务内可能对同一实例多次更新(`activateUserTask` 写摘要 → 随后 `failInstance` 改状态)。每次成功更新都要推进**内存版本**,否则第二次更新会拿旧版本号去比对上一条 UPDATE 已经 +1 的值而误判"并发推进"。
5. **机械替换后一定要看"孤儿方法"是否真的孤儿**:本轮原以为转换完 `rollback`/`restoreAutoCommit` 就没人用了,实际 `submit`/`act` 还在用,要等那两处也转换完才成立。
6. **`wf_action.b_action` 词表被扩展**了 4 个码:`activate`(串行提升)、`service`(服务节点)、`route`(路由留痕)、`expire`(超时)。该列**无 CHECK 约束**,不需改表;但下游若按枚举过滤要同步。
7. **超时扫描有真实副作用**:默认间隔 5 分钟,启动后会把**已到期且仍 pending/claimed** 的任务标成 `expired`。当前库里的示例任务有 31 条带 `b_due_at`,**其中已到期的会被标记**。不想动示例数据就把 `erp.workflow.expire-sweep-interval` 设为 `0s`。
8. **`WorkflowActorResolver.resolve` 多了第 6 个参数** `excludeUserIds`(D4);抄送节点传 `null`(不参与审批判定,不做职责分离)。
9. **`WorkflowJson` 用独立 `ObjectMapper` 只做读取**;序列化仍走各服务注入的 mapper(保留项目 `JacksonConfig` 对 Long→String 的处理)。不要把它改成序列化工具。
10. **`excludeCreator` 目前以实例发起人近似**(业务表创建人字段未统一),代码注释已标明,待业务口径统一后再换。
---
## 八、已定的设计决策(沿用时不要再讨论)
| 议题 | 决定 |
|---|---|
| 修复范围 | 全部 A+B+C+D(缺陷 + 并发 + 工程债含拆类 + 补齐文档承诺未实现的功能) |
| SQL 落地 | 新建 `g3soft-erp/sql/`,DDL + 注册 + mock |
| 权限服务 | 给 `SqlPermissionService` 加带 `Connection` 的重载,workflow 共享;删硬编码管理员 —— **⚠️ 尚未实现** |
| 测试 | 每个修复项补单测;DB 依赖的加门禁 |
| **D2 超时语义** | **只标记不自动改流程**:任务置 `expired` 并从待办过滤,实例保持 `running`,由管理员加签/转派。**不自动提升串行后继、不置 error、不自动通过** |
| **A3 投影 0 行** | 写 `wf_outbox(instance.projection_missing)` 并继续,不阻断审批 |
| **B3 坏条件 JSON** | 运行期重入该节点时实例置 `error`,**保留本次已记录的表决** |
| **C3 顺序** | 分两步:`WorkflowRuntimeService` 现在做(已完成);`WorkflowConfigService` 并进 P5 |
| **提交时点** | 接受"先 commit 再 return(且返回前查库)"的那 10 处读**挪进事务内**(结果相同,失败时改为回滚,更原子) |
| 前端 | **不改**(审批交互留在后端,前端照旧) |
---
## 九、待确认事项
1. **P5 与 P6 的先后**:建议**先补测试(P6a)再拆类(P5)**。原因见下——这块大搬移目前**只有编译能验证**。
2. **`excludeCreator` 的真实字段来源**:需要业务表创建人的统一口径。
3. **`requireEnabledUser` 是否抽到 `service/auth`**。
4. **串行链中当前审批人超期会停住**(D2 只标记不前进的必然结果)——确认这是可接受的运维语义。
---
## 十、给接手者的一句话提醒
**先补测试,再动 P5 的搬移。** 本轮在 RuntimeService 上做"以为机械"的替换时,编译失败过两次(lambda final、构造函数名),而那是 6 处、有明确模式的小改动;P5 是在**没有任何运行时测试**的 1600 行类上做结构性搬移(`RoutingEngine` 与 `TaskService`/`InstanceService` 互相调用),验证手段仍然只有编译。测试是这次搬移的唯一安全网,而它现在还不存在。