# Plan: 条件路由 (Conditional Routing / RoutingCondition) ## 已与用户确认的关键决策 1. transitions 挂在 `ProcessDefinition` 级别(独立列表),不改动 `ApprovalStep`。 2. `RoutingCondition` 采用与 `AssigneeResolver` 一致的模式:全局单例 bean, `boolean matches(String conditionKey, ProcessContext context)`;数据库只存 `conditionKey` 字符串,不持久化任何 Java lambda/表达式(因为 JDBC 场景下引擎重启要从数据库重新装配 `ProcessDefinition`,无法反序列化任意代码逻辑)。 3. **不保留线性回退**:每个 `ProcessDefinition` 必须显式声明 transitions,覆盖每个 step 的 出边;没有 transitions 完全是非法状态(构造期校验报错),不再依赖 `steps()` 列表顺序做 执行期推进(`step_order`/列表顺序仅用于候选人/去重校验和展示,不再决定执行流)。 4. 匹配不到任何 transition 时,fail-fast 抛出 `NoRouteFoundException`(不静默回退)。 ## 模型设计 (ordo-api) ### 新增 `StepTransition` record ``` record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) ``` - `toStepId == null` 表示"终止流程"(当匹配到这条边时,实例被判 APPROVED 并完成)。 - `conditionKey == null/blank` 表示无条件(总是匹配),用作 else/默认分支。 - 便捷工厂: - `StepTransition.always(fromStepId, toStepId)` — 无条件跳转,priority=0 - `StepTransition.when(fromStepId, toStepId, conditionKey, priority)` — 条件跳转 - `StepTransition.end(fromStepId)` — 无条件终止 - `StepTransition.endWhen(fromStepId, conditionKey, priority)` — 条件终止 - 评估顺序:按 `priority` 升序,第一个匹配的生效(无条件 = 恒真)。 ### 新增 `RoutingCondition` functional interface(与 AssigneeResolver 同目录/同风格) ``` @FunctionalInterface public interface RoutingCondition { boolean matches(String conditionKey, ProcessContext context); static RoutingCondition always() { return (key, context) -> true; } } ``` ### `ProcessDefinition` 改为 4 个分量(破坏性变更) ``` record ProcessDefinition(String id, String name, List steps, List transitions) ``` 构造期校验(在现有 id/name/steps 非空+去重校验基础上新增): - `transitions` 非 null,`List.copyOf` 防御性拷贝。 - 每条 transition 的 `fromStepId` 必须是 `steps` 中存在的 id;`toStepId`(非 null 时)也必须存在。 - 每个 step 的 id 必须至少作为一条 transition 的 `fromStepId` 出现一次(强制显式声明出边), 否则 `IllegalArgumentException("step X has no outgoing transition")`。 - 同一 `(fromStepId, priority)` 不能重复(避免评估顺序有歧义)。 - 新增便捷工厂 `ProcessDefinition.linear(id, name, steps)`:自动生成"上一步无条件指向下一步、 最后一步无条件终止"的 transitions,等价于旧的线性行为,用于降低无需真正条件路由的测试/demo 的迁移成本(生成的仍然是显式 transitions,满足"强制显式"的约束,只是自动拼装)。 ### 新增异常 `NoRouteFoundException extends OrdoException` - message 形如 `"no matching transition for step: in instance: "`。 - 放在 `com.jetlumen.ordo.api.exception` 包,命名/风格对齐现有 `DefinitionNotFoundException` 等。 ## 引擎改造 (ordo-core) ### `DefaultOrdoEngine` - 构造函数新增 `RoutingCondition routingCondition` 参数(在 `assigneeResolver` 之后插入, 与 Spring autoconfigure bean 顺序一致)。 - 删除 `indexOf(...)` 私有方法(不再需要基于下标推进)。 - `advanceOrComplete(instance, definition, step, now)` 重写为: 1. 调用新的私有 `resolveTransition(definition, step, context)`: 过滤 `definition.transitions()` 中 `fromStepId == step.id()` 的项,按 `priority` 升序排序, 依次判断 `conditionKey` 为空/blank(无条件恒真)或 `routingCondition.matches(conditionKey, context)`, 返回第一个匹配的 `StepTransition`;若无匹配,抛出 `NoRouteFoundException`。 2. 若匹配结果 `toStepId() == null` → `completeInstance(instance, ProcessStatus.APPROVED, now)`。 3. 否则 → `createStepTasks(instance, requireStep(definition, matched.toStepId()), now)`。 - `advanceAfterDecision` 中原有 ANY/ALL 策略判断逻辑不变,只是"推进到下一步"的调用点改为走上面的 transition 解析,不再用 `stepIndex == size-1` 判断是否最后一步。 - `NoRouteFoundException` 在 `transactionExecutor.execute(...)` 内抛出,天然触发整个 approve/reject 事务回滚(复用现有 `rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated` 验证过的回滚机制)。 - `start()` 的起始 step 仍取 `definition.steps().getFirst()`(未改变,属于本次范围外的假设, 如需显式声明起始 step 可后续再提出)。 ### `InMemoryOrdoEngine` - 仿照现有 `AssigneeResolver` 构造函数重载模式,新增: - `InMemoryOrdoEngine(RoutingCondition routingCondition)` - `InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition)` - `InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition)` - 默认无参/仅 Clock 的构造函数内部使用 `RoutingCondition.always()`。 ## 持久化 (ordo-storage-jdbc) ### 新迁移 `V3__add_step_transitions.sql` ```sql CREATE TABLE ordo_step_transition ( definition_id VARCHAR(64) NOT NULL, from_step_id VARCHAR(64) NOT NULL, to_step_id VARCHAR(64), condition_key VARCHAR(255), priority INTEGER NOT NULL, PRIMARY KEY (definition_id, from_step_id, priority), CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id) REFERENCES ordo_approval_step (definition_id, step_id), CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id) REFERENCES ordo_approval_step (definition_id, step_id) ); ``` **重要提醒(已踩过的坑,来自 /memories/repo/any-all-multi-approval.md)**: - 必须同步把这个新迁移文件加进 `ordo-storage-jdbc/src/test/java/.../JdbcTestSupport.java` 的 `MIGRATIONS` 数组,否则测试库缺表导致全部 JDBC 测试失败。 - 注释里不要出现分号(或依赖已有的按行剥离 `--` 注释逻辑,已经是健壮的,无需再担心)。 ### 新增 `StepTransitionMapper`(仿照 `ApprovalStepMapper` 的 `StepRow` 模式) - `readRow(ResultSet) -> TransitionRow(fromStepId, toStepId, conditionKey, priority)`。 ### `JdbcProcessDefinitionRepository` - `insertIfAbsent`:在插入 steps/candidates 之后,遍历 `definition.transitions()` 执行 `INSERT INTO ordo_step_transition (...)`。 - `findById`:新增 `SELECT_TRANSITIONS`(按 `from_step_id, priority` 排序),组装 `List` 传入新的 4 参 `ProcessDefinition` 构造函数。 ## Spring Boot 自动配置 (ordo-spring-boot-autoconfigure) - `OrdoJdbcAutoConfiguration` 新增: ```java @Bean @ConditionalOnMissingBean public RoutingCondition ordoRoutingCondition() { return RoutingCondition.always(); } ``` - `ordoEngine(...)` 方法签名新增 `RoutingCondition ordoRoutingCondition` 参数,传给 `DefaultOrdoEngine` 构造函数。 - `OrdoJdbcAutoConfigurationTest`:更新 `LEAVE_REQUEST` 定义使用 `ProcessDefinition.linear(...)`; 新增一个 `honoursUserDefinedRoutingCondition` 测试,仿照现有 `honoursUserDefinedAssigneeResolver` 的写法。 ## 调用点迁移(约 29 处 `new ProcessDefinition(...)`) - 绝大多数纯线性场景(测试/demo 不需要真正的条件路由)直接把 `new ProcessDefinition(id, name, steps)` 换成 `ProcessDefinition.linear(id, name, steps)`。 - 受影响文件: - `ordo-api/src/test/java/.../ProcessDefinitionTest.java`(同时新增 transitions 相关校验用例: 缺少出边报错、fromStepId/toStepId 引用不存在的 step 报错、重复 priority 报错) - `ordo-core/src/test/java/.../InMemoryOrdoEngineTest.java`(8 处调用点 + 新增条件路由专项测试) - `ordo-spring-boot-autoconfigure/src/test/java/.../OrdoJdbcAutoConfigurationTest.java` - `ordo-storage-jdbc/src/test/java/.../Jdbc*Test.java`(约 10 处,含 `JdbcProcessDefinitionRepositoryTest` 需要新增"读写 transitions 往返"专项用例) - `ordo-storage-jdbc/src/main/java/.../JdbcProcessDefinitionRepository.java`(`findById` 内部 组装 `ProcessDefinition` 那一处,改为传入真实加载到的 transitions) - `ordo-example/src/main/java/.../LeaveRequestExample.java` - `d:\Projects\Personal\java\rhizome\src\main\java\...\LeaveRequestDemoService.java` ## 新增测试(条件路由专项,主要在 `InMemoryOrdoEngineTest` 或新建 `ConditionalRoutingTest`) 1. 基础分支:manager 通过后,根据 `conditionKey`(如 `"amount-gt-1000"`)走向不同的下一 step (高额 -> director 审批 step;低额 -> 直接 end),验证 `RoutingCondition.matches` 被正确调用 并影响推进结果。 2. 无匹配 transition 时抛出 `NoRouteFoundException`,且整个 approve 事务回滚(沿用现有 rollback 测试模式)。 3. `ProcessDefinition` 构造期校验: - 某 step 没有任何出边 -> `IllegalArgumentException`。 - transition 引用不存在的 `fromStepId`/`toStepId` -> `IllegalArgumentException`。 - 同一 `(fromStepId, priority)` 重复 -> `IllegalArgumentException`。 4. JDBC 往返测试:`JdbcProcessDefinitionRepositoryTest` 新增用例,插入一个带条件分支的 definition,`findById` 读回后 `transitions()` 内容与写入一致(包括 `toStepId == null` 终止边)。 ## 范围外 / 明确不做 - 不支持在 transition 上直接内嵌 Java lambda(会破坏 JDBC 持久化重启场景,已由用户确认)。 - 不引入显式 `startStepId` 概念,起始步骤仍是 `steps().getFirst()`。 - 不做并行分支/合并(fork-join),仅支持"单一当前 step -> 单一下一 step 或终止"的有向图推进, 与现有 ANY/ALL 多候选人机制正交(transition 发生在"某个 step 整体被判定通过/拒绝"之后)。 ## 实施顺序(有依赖关系,需按序执行) 1. ordo-api:`StepTransition`、`RoutingCondition`、`ProcessDefinition`(含 `linear` 工厂)、 `NoRouteFoundException`。 2. ordo-core:`DefaultOrdoEngine` 改造、`InMemoryOrdoEngine` 构造函数重载。 3. ordo-storage-jdbc:`V3` 迁移 + `StepTransitionMapper` + `JdbcProcessDefinitionRepository` 改造 + 同步更新 `JdbcTestSupport.MIGRATIONS`。 4. ordo-spring-boot-autoconfigure:`RoutingCondition` bean 及 `ordoEngine(...)` 装配。 5. 迁移全部 ~29 处旧调用点(`ProcessDefinition.linear(...)`),含 rhizome demo。 6. 新增条件路由专项测试(引擎行为 + 校验 + JDBC 往返)。 ## 验证 - `ordo-api`/`ordo-core`/`ordo-storage-jdbc`/`ordo-spring-boot-autoconfigure` 全量单元测试通过。 - 手动确认 rhizome 端到端 curl 流程(线性场景切到 `ProcessDefinition.linear` 后)仍然可用 (非阻塞,可在实现完成后单独验证)。