Require ProcessDefinition transitions so the engine can branch or end by conditionKey, persist them in JDBC, and pin repository text files to LF. Co-authored-by: Cursor <cursoragent@cursor.com>
178 lines
11 KiB
Markdown
178 lines
11 KiB
Markdown
# 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<ApprovalStep> steps, List<StepTransition> 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: <stepId> in instance: <instanceId>"`。
|
||
- 放在 `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<StepTransition>` 传入新的 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` 后)仍然可用
|
||
(非阻塞,可在实现完成后单独验证)。
|