fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
build / build-push (push) Successful in 2m0s

requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
This commit is contained in:
chenxing
2026-10-28 03:18:43 +08:00
parent 8d4a0bdba8
commit 302216a07c
25 changed files with 7203 additions and 30 deletions
@@ -0,0 +1,105 @@
# FL 后端核心修复执行回执 — Codex
执行日期:2026-10-27(Asia/Shanghai)。范围依据:《FL后端核心修复任务书_Codex_无人值守版.md》。
结论:**已核实**线级守卫的取反缺陷已作最小修复,测试改为真实调用生产 Service;定向测试 43/43 通过。**待核实(环境阻塞)**真实数据库提交后刷新回读、HTTP 联调及真实 AFTER_COMMIT 行为。本回执不代表整个 FL 终审通过。
## 1. 基线与范围
- 仓库:`D:/SoftProject/yunhai/flow-center/service`。
- **已核实**分支 `main`,HEAD `a10f2e054c2422ae2dfde82926ae567ad9096093`。这是本地仓库基线,不是本次核实过的线上运行版本。
- **已核实**开工时 `git status --short` 共 104 项;`FlowDefAdminService.java` 已有修改,`FlDefLineOfflineTest.java` 已存在但未跟踪。均按已有 WIP 保护。
- 开工状态和已跟踪差异见 [baseline-status.txt](evidence/core-fix-20261027/baseline-status.txt)、[baseline-tracked.patch](evidence/core-fix-20261027/baseline-tracked.patch);两个目标文件的原始快照也保存在同目录。
- 已读上级 `D:/SoftProject/yunhai/AGENTS.md`、`docs/云海AI代理探索与性能纪律.md`、`docs/云海项目导航索引.md`。入口所指 `docs/云海平台架构画像_资产与文档基线盘点.md` 在指定路径不存在,未据此推断环境事实。
- 任务书所列线上验收结论按**历史材料记载**引用:新实例 152–157 的回调 36/36 对账成功;本轮未重新访问生产或重跑矩阵,也未取得那些报告的独立正文。
## 2. 本轮代码差异
仅包含三份 Java 文件;以开工 WIP 为基线的独立差异见 [task-only.patch](evidence/core-fix-20261027/task-only.patch)。该补丁不是相对 HEAD 的完整发布补丁;应用前应保留原 WIP。
| 文件 | 本轮修改与原因 |
|---|---|
| `src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java:302` | 在存在性查询前补 `!`,改为缺失时拒绝。保留 null/空白拒绝、trim 和 `(schId, flowGuid)` 查询范围。生产代码仅改这一处判断。 |
| `src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java` | 删除复制业务逻辑的 `FlwDefAdminServiceSpy`,用已有构造辅助方法创建真实 `FlowDefAdminService`;补齐停发/恢复非法 GUID、空值、空白、错误学校、trim、发布内容及在途协作隔离断言。 |
| `src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java` | 增加新实例路径定向测试:真实创建 Service、引擎、任务 Service、激活意图 Service 和监听器串接;repository、人员解析和发布器为 mock。 |
未改 pom、生产配置、前端、FC、Notice、UserBase、网关或 SQL;未新增回填机制,未关闭 D09,未改状态机或事件契约。已有这些路径的差异属于开工 WIP。
## 3. 真实调用与缺陷捕获证据
先仅替换测试替身,保留错误生产判断,执行原 7 项停发测试:**已核实**失败 1、错误 1,跳过 0。
- `lineOfflineDelegatesBulkUpdate`:真实存在的 `g-line` 被生产 `requireLine` 抛错拒绝。
- `unknownLineRejected`:不存在的 `g-nope` 未抛异常,断言失败。
原始输出见 [red-test.log](evidence/core-fix-20261027/red-test.log)。随后才修复生产判断并补测试。当前测试字段类型是 `FlowDefAdminService`,由 `new FlowDefAdminService(...)` 实例化,直接调用其公开 `offlineLine` / `enableLine`;被测类和被测方法均未 mock/spy,也没有测试替身里的守卫副本。这一真实失败足以证明重新写反判断会被捕获。
## 4. 测试结果及实际验证层级
| 范围 | 结果 | 状态与限制 |
|---|---:|---|
| `FlDefLineOfflineTest` | 14/14 | **已核实**真实管理 Service:已有线双路径通过,分别委托批量更新 1/0;未知 GUID、null、空串、两种空白和错误学校双路径拒绝且不更新;trim;发布版本、内容及在途协作隔离;新发起/预览停发守卫。repository 为 mock。 |
| `FlNewInstancePathTest` | 1/1 | **已核实**新实例创建预期办理人的 pending 任务;任务创建事件通过生产监听器/Service 登记本次真实任务成员、定义 ID 和轮次;合法审批记录意图并推进到完成。把定义线标志置为停发后,在途任务仍使用原 defId/version 完成;未查询最新已发布版本替换原版本。 |
| `FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity` | 1/1 | **已核实**新建实例分支、token/轮次、审批中状态与 START 委托。仅执行这个方法。 |
| `FlAdvanceIntentRecoveryTest` | 11/11 | **已核实**生产激活结算 Service 的推进/重试/代次保护定向回归。 |
| `FlStateEventRecoveryTest` | 7/7 | **已核实**生产事实记录器的事件身份及冻结载荷等断言。 |
| `CallbackRecoveryDeliveryTest` | 9/9 | **已核实**生产分发/恢复逻辑和接收契约的内存协作测试;不是线上网络送达验证。 |
| 合计 | **43/43** | Failures 0 / Errors 0 / Skipped 0;Maven BUILD SUCCESS,43.529 秒。 |
完整输出见 [targeted-test.log](evidence/core-fix-20261027/targeted-test.log),同目录保存了这六个类的 Surefire XML 和文本报告,避免后续运行覆盖证据。Windows Maven 输出部分中文因原工具链编码显示乱码,测试名、数量及 XML 可复核。
新路径测试直接调用真实业务代码;测试显式驱动同步监听器和后提交入口,并仅初始化 Spring synchronization 以校验激活批次完整性。**没有真实事务、Spring AOP、JPA 锁或数据库提交;不能把这个测试描述为真实 AFTER_COMMIT/数据库集成通过。**
## 5. 实际命令
在 service 仓库执行:
```powershell
git status --short
git branch --show-current
git rev-parse HEAD
git diff --binary
mvn -version
# 生产缺陷仍存在,仅将测试改为调用真实 Service;预期失败,exit=1
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest' test
# 修复后定向验证;exit=0
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest,FlNewInstancePathTest,FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity,FlAdvanceIntentRecoveryTest,FlStateEventRecoveryTest,CallbackRecoveryDeliveryTest' test
git diff --check -- src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
git diff --no-index --check docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
git apply --reverse --check docs/evidence/core-fix-20261027/task-only.patch
```
**已核实**JDK `1.8.0_121`、Maven `3.9.16`;复用了既有私有依赖本地仓库,离线构建测试可用,没有变更依赖或全局配置。末项只检查补丁,不应用补丁;最终 exit=0,见 [patch-check.txt](evidence/core-fix-20261027/patch-check.txt)。补丁初次导出为 CRLF 导致检查失败,改为 UTF-8/LF 后通过,Java 源码不受此导出修正影响。
## 6. 环境阻塞与未处理事项
**已核实**使用 `Get-Command docker`、环境变量存在性检查、`System.Net.Sockets.TcpClient.ConnectAsync('127.0.0.1', port)` 检查本地已知测试入口;证据见 [environment.txt](evidence/core-fix-20261027/environment.txt):
- Docker 命令不可用,`CI_TEST_DB_URL` / `SPRING_DATASOURCE_URL` 未配置。
- `127.0.0.1:3306` 与 `127.0.0.1:13429` 未连接成功。这仅说明所查入口不可用,不声称全机不存在其他数据库。
- 当前 `application-test.yml` 指向本机测试库,`FlowEngineIT` 又要求端口 13429 / `fl_d09_20261026`;共享 `FlowTestSupport.cleanDb()` 含全表删除。未启动此类 IT,未尝试使用开发或生产库替代。
因此以下为**待核实(环境阻塞)**:停发提交后 `line_offline=1`、恢复提交后 `line_offline=0` 的数据库刷新/回读;真实仓储批量更新的全版本/学校隔离;真实事务下新实例、任务推进与回调提交链。所需条件是明确隔离、可写且专用的测试数据库及连接参数,后续测试应只创建新测试记录并限定清理范围,禁止直接运行共享清库 helper。
**已核实(静态检查)**仓储 `updateLineOffline` JPQL 仅更新匹配 `schId + flowGuid` 的 `lineOffline`,不更新发布状态、版本、内容或实例。数据库行为仍需上项补证。
未运行 C01 八项、`CallbackDispatchIT` 七项或完整 CI;本次只有一个管理守卫字符变更,定向回归已覆盖直接影响。历史任务 339、notice.id=78、旧激活意图回填、历史事件补发、重置数据、D14 均不在范围。未发现需要扩展异常语义的已证实新路径缺陷。
## 7. WorkBuddy 联调说明(准备完成,未执行外部写入)
在获准的隔离测试环境、使用新数据,按以下顺序复验:
1. 用本校管理员创建/发布一条测试流程线,先发起新实例并记录 defId/version、任务 ID;确认该任务在 `flw_advance_intent.activation_task_ids` 中有本次激活证明。
2. `POST /api/flow/admin/def/offlineLine`,JSON `{"flowGuid":"实际测试GUID"}`。学校来自登录管理员上下文,不从请求体指定。期望返回受影响行数;读本校该 GUID 全版本 `flw_def.line_offline` 应全为 1。
3. 刷新页面并用 `GET /api/flow/admin/def/versions?flowGuid=...` 回读,仍为停发;新发起和选人预览应提示停发。继续合法办理第 1 步的新实例,在途仍应按原版本推进完成。
4. `POST /api/flow/admin/def/enableLine`,同一 JSON。期望受影响行数,刷新/数据库回读全版本 `line_offline=0`;之后新发起使用最高已发布版。
5. 对照测试前后各版本 `status/version/content_json` 及原实例 `def_id/def_version` 应保持不变;空白、不存在 GUID 和另一学校管理员访问应被拒绝且不更新。
路由如有服务 context-path,再加环境配置中的前缀。观察 `flw_instance`、`flw_task`、`flw_advance_intent` 的新记录身份/状态,以及对应新 `flw_state_event`、`flw_callback_log` 的稳定 event_id 和投递状态;本次用少量定向新事件,无需重跑六类 36 项矩阵。若出现 D09 无激活证明,先核对是否确为新建实例及其 TASK_CREATED 注册,禁止对旧任务绕过 fail-closed。
## 8. 交付边界
已交付:最小生产修复、真实调用回归测试、新实例路径测试、实际红/绿日志、可复核代码差异与本回执。未执行历史回填、生产数据库操作、部署或 Git commit/push。数据库与环境联调阻塞不影响已完成代码工作;最终 FL 代码审计仍待前端交付和 WorkBuddy 集成复验后另行安排。
@@ -0,0 +1,259 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 线级停发与发布封存的单测(2026-10-25 T03):
*
* <ul>
* <li><b>线级停发</b>:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级);
* 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。</li>
* <li><b>发布封存</b>:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节
* (审计 E02 关闭:已发布文档不再被原地改写)。</li>
* </ul>
*/
@DisplayName("线级停发/恢复 + 发布封存(T03)")
class FlDefLineOfflineTest {
private static final Long SCH = 1L;
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
private FlwDefAdminServiceSpy adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
static class FlwDefAdminServiceSpy {
final FlwDefRepository defRepository;
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
this.defRepository = defRepository;
}
int offlineLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
int enableLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
mock(DiagramTokenService.class), mock(ObjectProvider.class));
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
.thenReturn(Optional.of(def(9, 3, 0)));
}
// ==================== 线级停发/恢复(管理面委托) ====================
@Test
@DisplayName("offlineLine:批量置 1;enableLine:批量归零")
void lineOfflineDelegatesBulkUpdate() {
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
assertThat(adminService.offlineLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 1);
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
assertThat(adminService.enableLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 0);
}
@Test
@DisplayName("不存在的流程线:拒绝且不出更新")
void unknownLineRejected() {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
// ==================== 新发起/预览的线级守卫 ====================
@Test
@DisplayName("线停发:applyV2 新发起被拒且不建实例(不回退旧版)")
void applyRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
assertThatThrownBy(() -> oapiService.applyV2(SCH, "100018", body))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
verify(instanceService, never()).applyGuarded(any(), any());
}
@Test
@DisplayName("线在线:新发起照常解析最高已发布版")
void applyPassesWhenLineOnline() {
FlwDef online = def(9, 3, 0);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(online));
when(instanceService.applyGuarded(any(), any()))
.thenReturn(new FlowInstanceService.GuardedOutcome(
500L, false, FlowApplyRetryModels.Branch.CREATED, 1));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
FlowApplyRetryModels.ApplyResultV2 vo = oapiService.applyV2(SCH, "100018", body);
assertThat(vo.getInstanceId()).isEqualTo(500L);
ArgumentCaptor<FlowInstanceService.ApplyCommand> cmd =
ArgumentCaptor.forClass(FlowInstanceService.ApplyCommand.class);
verify(instanceService).applyGuarded(cmd.capture(), any());
assertThat(cmd.getValue().def.getId()).isEqualTo(9L);
}
@Test
@DisplayName("预览同口径:线停发拒绝(选人预览不是新发起旁路)")
void previewRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
assertThatThrownBy(() -> oapiService.applyPreview(SCH, GUID, 7))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
}
// ==================== 发布封存(updateMeta) ====================
@Test
@DisplayName("updateMeta 对已发布版本:行 name/appKey 可改,content_json 保持发布字节")
void updateMetaFreezesPublishedDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef published = def(9, 3, FlwEnums.DefStatus.PUBLISHED.getCode());
published.setName("旧名");
String originalJson = published.getContentJson();
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getName()).isEqualTo("新名");
assertThat(saved.getContentJson()).isEqualTo(originalJson);
}
@Test
@DisplayName("updateMeta 对草稿:content_json 顶层 name 照旧同步(草稿文档可变)")
void updateMetaStillSyncsDraftDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef draft = def(9, 3, 0);
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
draft.setName("旧名");
draft.setContentJson("{\"name\":\"旧名\",\"nodes\":[],\"edges\":[]}");
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getContentJson()).contains("\"新名\"");
}
// ==================== fixtures ====================
private FlowDefAdminService newFlowDefAdminService() {
// 依赖协作全部 mock(构造器形参多,反射装配与生产 Spring 无关,只测本类逻辑)
return new FlowDefAdminService(
defRepository,
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
mock(FormCenterSchemaClient.class));
}
private com.yunhai.flowcenter.repository.FlwAppRepository appRepo(boolean enabled) {
com.yunhai.flowcenter.repository.FlwAppRepository repo =
mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
when(repo.isEnabledApp(anyLong(), anyString())).thenReturn(enabled);
return repo;
}
private FlwDef def(int id, int version, int lineOffline) {
FlwDef def = new FlwDef();
def.setId((long) id);
def.setSchId(SCH);
def.setFlowGuid(GUID);
def.setCode("leave");
def.setName("请假");
def.setVersion(version);
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setLineOffline(lineOffline);
def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}");
return def;
}
}
@@ -0,0 +1,462 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.DefValidator;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.VariableContract;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 流程定义管理(M1:JSON 文本编辑模式,概览 §9.2):
* 保存=草稿直改/已发布升版本(flowGuid 跨版本不变);发布跑静态校验(把"任务无主"拦在发布期)。
*/
@Service
@RequiredArgsConstructor
public class FlowDefAdminService {
private final FlwDefRepository defRepository;
private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository;
private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository;
private final com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
private final com.yunhai.flowcenter.repository.FlwInstanceRepository instanceRepository;
private final FlowDeptAdminService deptAdminService;
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
private final FormCenterSchemaClient formCenterSchemaClient;
/** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */
public List<FlwDef> list(Long schId, Integer status, String appKey) {
List<FlwDef> rows = status != null
? defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status)
: defRepository.findBySchIdOrderByUpdateTimeDesc(schId);
String key = appKey == null ? null : appKey.trim();
if (key != null && !key.isEmpty()) {
rows = rows.stream().filter(d -> key.equals(d.getAppKey())).collect(Collectors.toList());
}
return rows;
}
/** 应用清单("按应用筛选"下拉数据源):本校 flw_def 已标注 ∪ 已注册回调的应用,去重排序 */
public List<String> distinctAppKeys(Long schId) {
TreeSet<String> keys = new TreeSet<>();
keys.addAll(defRepository.findDistinctAppKeys(schId));
keys.addAll(regRepository.findDistinctAppKeysBySchId(schId));
return new ArrayList<>(keys);
}
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
public List<FlwDef> listAll(Long schId) {
return defRepository.findPublishedLatestPerGuid(schId);
}
public FlwDef get(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
return def;
}
/**
* 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签):
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
* - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示
* appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行,
* 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校)
* changeLog=升版本改动说明(议题二③):只随新建行落库(三个建新行分支),草稿直改行不写也不清空
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog) {
String cleanLog = (changeLog == null || changeLog.trim().isEmpty()) ? null
: changeLog.trim().substring(0, Math.min(500, changeLog.trim().length()));
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
// 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1);
// 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
}
// JSON 先解析再落库(语法错误不进库)
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填");
}
FlwDef def;
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
// 在既有流程线上开新版本草稿(appKey 缺省继承源线)
FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
def = new FlwDef();
def.setSchId(schId);
def.setFlowGuid(baseGuid.trim());
def.setCode(code.trim());
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
def.setFormUuid(base.getFormUuid());
def.setFormVersion(base.getFormVersion());
} else if (id == null) {
// 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
def = new FlwDef();
def.setSchId(schId);
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
def.setCode(code.trim());
def.setVersion(1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey);
} else {
def = requireDef(schId, id);
if (!def.getCode().equals(code.trim())) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改");
}
if (cleanAppKey != null) {
def.setAppKey(cleanAppKey);
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
// 升版本:复制行,保持 flowGuid
FlwDef next = new FlwDef();
next.setSchId(schId);
next.setFlowGuid(def.getFlowGuid());
next.setCode(def.getCode());
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
next.setFormUuid(def.getFormUuid());
next.setFormVersion(def.getFormVersion());
def = next;
}
}
def.setCode(code.trim());
def.setName(name.trim());
// 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传;
// 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛)
if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)");
}
// 改动说明只写新建行(新建/baseGuid 开版/已发布复制升版三分支 id 必为 null);草稿直改不动
if (def.getId() == null) {
def.setChangeLog(cleanLog);
}
def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot.
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
def.setCode(parsed.code.trim());
}
if (parsed.name != null && !parsed.name.trim().isEmpty()) {
def.setName(parsed.name.trim());
}
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
// 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误")
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/**
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
* 非空=设置,保存前按确切版本远端核验,失败不写入。
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) {
boolean clearing = formUuid != null && formUuid.trim().isEmpty();
boolean pairing = formUuid != null && !clearing && formVersion == null;
boolean versionOnly = formUuid == null && formVersion != null;
if (pairing || versionOnly) {
throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
: FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
}
if (formUuid != null && !clearing) {
checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
}
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) {
String clean = formUuid.trim();
saved.setFormUuid(clean.isEmpty() ? null : clean);
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
}
try {
return defRepository.save(saved);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。<br>
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
* 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
def.getFormVersion(), "发布");
model.variables = VariableContract.fromSnapshot(snapshot);
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot,
List<FlowDefModel.Variable> variables) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
root.set("variables", objectMapper.valueToTree(variables));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
}
}
/** 停用:不可新发起,存量跑完 */
@Transactional
public void offline(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验");
}
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
defRepository.save(def);
}
/**
* 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。
* 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。
* 不改写任何发布内容字节,恢复走 {@link #enableLine}。
*/
@Transactional
public int offlineLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
/**
* 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针
* 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。
*/
@Transactional
public int enableLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
}
/**
* 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
* 被停用,这里会点名报错(如"科室 X 无启用办理人"),管理员在办理人映射页点"启用"即可解。
*/
@Transactional
public FlwDef enable(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "该流程已是发布状态,无需启用");
}
if (def.getStatus() != FlwEnums.DefStatus.DISABLED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用");
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
if (version == null) {
throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
+ operation + "已阻断,请重新选择表单与版本");
}
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
}
try {
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
"关联表单快照拉取失败," + operation + "已阻断(可重试)", e);
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION,
"关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e);
} catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) {
throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE,
"关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e);
} catch (FormCenterSchemaClient.SchemaAccessDeniedException e) {
throw new BizException(FormBindingErrorCode.ACCESS_DENIED,
"关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e);
}
}
private String withoutFormContract(String contentJson) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.remove("formContract");
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象");
}
}
/** 同一流程线全部版本(降序) */
public List<FlwDef> versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
}
/** 在途办件数(议题二④):flowGuid → 审批中(10)办件数;口径=def_id 精确匹配该线各版本;请求的 guid 全部有键(无在途=0) */
public Map<String, Long> inflightCounts(Long schId, Collection<String> flowGuids) {
Map<String, Long> result = new HashMap<>();
if (flowGuids == null || flowGuids.isEmpty()) return result;
flowGuids.forEach(g -> result.put(g, 0L));
List<FlwDef> defs = defRepository.findBySchIdAndFlowGuidIn(schId, flowGuids);
if (defs.isEmpty()) return result;
Map<Long, String> idToGuid = new HashMap<>();
defs.forEach(d -> idToGuid.put(d.getId(), d.getFlowGuid()));
instanceRepository.countApprovingByDefIds(schId, idToGuid.keySet()).forEach(r -> {
Long defId = ((Number) r[0]).longValue();
Long cnt = ((Number) r[1]).longValue();
result.merge(idToGuid.get(defId), cnt, Long::sum);
});
return result;
}
/**
* 元数据原地编辑(2026-10-10 四议题·议题2,拍板 D4):已发布/停用/草稿均可只改 name/appKey
* 而<b>不升版本</b>——结构改动仍走 save 的升版本路径,两者完全隔离(本方法不碰
* code/flow_guid/version/status,不进升版本分支)。appKey 校验同 save 口径(显式传入须在白名单)。
* content_json 顶层 name 同步更新(只动顶层字段不动结构,消除"行 name 与 JSON name 的脏窗口";
* 亦修复 API 直调方经 parsed.name 覆盖行字段时读到旧名的口径)。
* 并发说明:与升版本路径无共享可变行(升版本建新行),不加锁。
*/
@Transactional
public FlwDef updateMeta(Long schId, Long id, String name, String appKey) {
FlwDef def = requireDef(schId, id);
if (name == null || name.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程名称不能为空");
}
String cleanName = name.trim();
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
}
def.setName(cleanName);
def.setAppKey(cleanAppKey);
// 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据),
// content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路
//(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。
if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
}
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时编辑,请刷新后重试");
}
}
/** contentJson 顶层 name 同步(ObjectNode 保字段顺序;结构零改动),返回新 JSON 串 */
private String syncTopLevelName(String contentJson, String name) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.put("name", name);
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
// contentJson 在 save 期已保证可解析;防御口径:解析失败不同步(行 name 已更新,JSON 维持原值)
return contentJson;
}
}
private Set<String> enabledDeptKeys(Long schId) {
// 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1)
return deptAdminService.enabledDeptKeys(schId);
}
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
private Set<String> enabledRouterKeys() {
return routerRepository.findByStatusOrderByIdAsc(1).stream()
.map(com.yunhai.flowcenter.entity.FlwRouter::getRouterKey)
.collect(Collectors.toSet());
}
private FlwDef requireDef(Long schId, Long id) {
FlwDef def = defRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id));
if (!def.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "流程定义不属于该学校");
}
return def;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
M .gitea/workflows/build.yml
M pom.xml
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
M src/test/resources/application-test.yml
?? ci/
?? docs/form-binding-contract.md
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
?? src/main/resources/sql/20261026_formal_commands_draft.sql
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
-------------------------------------------------------------------------------
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.69 s - in com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
-------------------------------------------------------------------------------
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s - in com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
-------------------------------------------------------------------------------
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.471 s - in com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlApplyRecoveryTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.093 s - in com.yunhai.flowcenter.service.FlApplyRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlDefLineOfflineTest
-------------------------------------------------------------------------------
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.394 s - in com.yunhai.flowcenter.service.FlDefLineOfflineTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlNewInstancePathTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.078 s - in com.yunhai.flowcenter.service.FlNewInstancePathTest
@@ -0,0 +1,6 @@
Checked: 2026-10-27 Asia/Shanghai
Docker command available: False
CI_TEST_DB_URL configured: False
SPRING_DATASOURCE_URL configured: False
127.0.0.1:3306 OPEN=False
127.0.0.1:13429 OPEN=False
@@ -0,0 +1,3 @@
4CD39FBEC874DB0A2B5EF20CA5F6A713ADF7E8D9E3B834DF4ECCDFEE51435E04 D:\SoftProject\yunhai\flow-center\service\src\main\java\com\yunhai\flowcenter\service\FlowDefAdminService.java
DB6BEBF60CD77FEF1C696527C23B65170E8EAFC108D980352361BA01EB1A0EB0 D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlDefLineOfflineTest.java
E925A3D742801E7666FAC9C4ED1E628F8C5C91C4E9B5ED5A6B2755F1567F29DE D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlNewInstancePathTest.java
@@ -0,0 +1,107 @@
M .gitea/workflows/build.yml
M pom.xml
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
M src/test/resources/application-test.yml
?? ci/
?? "docs/FL\345\220\216\347\253\257\346\240\270\345\277\203\344\277\256\345\244\215\346\211\247\350\241\214\345\233\236\346\211\247-Codex.md"
?? docs/evidence/
?? docs/form-binding-contract.md
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
?? src/main/resources/sql/20261026_formal_commands_draft.sql
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
@@ -0,0 +1 @@
git apply --reverse --check task-only.patch exit=0
@@ -0,0 +1,281 @@
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
index da109e3..3837c1a 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
@@ -299,7 +299,7 @@ public class FlowDefAdminService {
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
- || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
+ || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
index 76bbf34..3cff9e9 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
@@ -18,6 +18,9 @@ import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
@@ -33,7 +36,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -53,43 +58,17 @@ class FlDefLineOfflineTest {
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
- private FlwDefAdminServiceSpy adminService;
+ private FlowDefAdminService adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
- /** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
- static class FlwDefAdminServiceSpy {
- final FlwDefRepository defRepository;
- FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
- this.defRepository = defRepository;
- }
- int offlineLine(Long schId, String flowGuid) {
- if (flowGuid == null || flowGuid.trim().isEmpty()
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
- .isPresent()) {
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
- "流程线不存在:" + flowGuid);
- }
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
- }
- int enableLine(Long schId, String flowGuid) {
- if (flowGuid == null || flowGuid.trim().isEmpty()
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
- .isPresent()) {
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
- "流程线不存在:" + flowGuid);
- }
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
- }
- }
-
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
- adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
+ adminService = newFlowDefAdminService();
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
@@ -121,9 +100,61 @@ class FlDefLineOfflineTest {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope"))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("流程线不存在");
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {" ", "\t\r\n"})
+ void blankGuidRejectedOnBothPaths(String guid) {
+ assertThatThrownBy(() -> adminService.offlineLine(SCH, guid))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(SCH, guid))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
+ verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any());
+ }
+
+ @Test
+ void otherSchoolCannotChangeExistingLine() {
+ assertThatThrownBy(() -> adminService.offlineLine(2L, GUID))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(2L, GUID))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID);
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
+ @Test
+ void guidTrimmedForBothLookupAndUpdate() {
+ when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
+ when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
+ assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3);
+ assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3);
+ verify(defRepository).updateLineOffline(SCH, GUID, 1);
+ verify(defRepository).updateLineOffline(SCH, GUID, 0);
+ }
+
+ @Test
+ void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() {
+ FlwDef published = def(9, 3, 0);
+ String frozenJson = published.getContentJson();
+ when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID))
+ .thenReturn(Optional.of(published));
+
+ adminService.offlineLine(SCH, GUID);
+ adminService.enableLine(SCH, GUID);
+
+ assertThat(published.getVersion()).isEqualTo(3);
+ assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode());
+ assertThat(published.getContentJson()).isEqualTo(frozenJson);
+ verify(defRepository, never()).save(any());
+ verifyNoInteractions(instances, instanceService);
+ }
+
// ==================== 新发起/预览的线级守卫 ====================
@Test
@@ -229,7 +260,7 @@ class FlDefLineOfflineTest {
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
- mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
+ instances,
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
new file mode 100644
--- /dev/null
+++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
@@ -0,0 +1,122 @@
+package com.yunhai.flowcenter.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.flowcenter.engine.*;
+import com.yunhai.flowcenter.entity.*;
+import com.yunhai.flowcenter.repository.*;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import javax.persistence.EntityManager;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */
+class FlNewInstancePathTest {
+ @Test
+ void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() {
+ ObjectMapper mapper = new ObjectMapper();
+ FlwDefRepository defs = mock(FlwDefRepository.class);
+ FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
+ FlwTaskRepository tasks = mock(FlwTaskRepository.class);
+ FlwBranchRepository branches = mock(FlwBranchRepository.class);
+ FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class);
+ AssigneeResolver assignees = mock(AssigneeResolver.class);
+ ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
+ List<FlowEvent> events = new ArrayList<>();
+ doAnswer(call -> { events.add(call.getArgument(0)); return null; })
+ .when(publisher).publishEvent(any(FlowEvent.class));
+ FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
+ assignees, mock(ConditionRouteService.class), publisher);
+ FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
+ FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
+ assignees, engine, publisher);
+ FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
+ ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class));
+ FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents);
+
+ FlwDef def = new FlwDef();
+ def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line");
+ def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0);
+ def.setContentJson("{\"start\":\"start\",\"nodes\":["
+ + "{\"id\":\"start\",\"type\":\"START\"},"
+ + "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"},"
+ + "{\"id\":\"end\",\"type\":\"END\"}],"
+ + "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}");
+ when(defs.findById(7L)).thenReturn(Optional.of(def));
+ when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer")));
+ AtomicReference<FlwInstance> created = new AtomicReference<>();
+ when(instances.saveAndFlush(any())).thenAnswer(call -> {
+ FlwInstance instance = call.getArgument(0);
+ if (instance.getId() == null) instance.setId(900L);
+ created.set(instance); return instance;
+ });
+ when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get()));
+ AtomicReference<FlwTask> task = new AtomicReference<>();
+ when(tasks.save(any())).thenAnswer(call -> {
+ FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row;
+ });
+ when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get()));
+ when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call ->
+ task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList());
+ when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> {
+ task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1;
+ });
+ AtomicReference<FlwAdvanceIntent> activation = new AtomicReference<>();
+ when(intents.save(any())).thenAnswer(call -> {
+ FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row;
+ });
+ when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get()));
+ when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review"))
+ .thenAnswer(call -> Collections.singletonList(activation.get()));
+ when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review"))
+ .thenAnswer(call -> Optional.of(activation.get()));
+
+ FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
+ 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
+ null, "100001", "notice", null);
+ FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
+ new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
+ assertThat(outcome.duplicated).isFalse();
+ assertThat(created.get().getCurrentNodes()).isEqualTo("review");
+ assertThat(task.get().getStatus()).isZero();
+ assertThat(task.get().getAssigneeId()).isEqualTo(201);
+ FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED)
+ .findFirst().orElseThrow(AssertionError::new);
+ // Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim.
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ listener.persistIntent(taskCreated);
+ TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false));
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+ assertThat(activation.get().containsTask(task.get().getId())).isTrue();
+ assertThat(activation.get().getActivationDefId()).isEqualTo(7L);
+ assertThat(activation.get().getActivationApplyRound()).isEqualTo(1);
+
+ // Existing tasks resolve their pinned definition even when the line is stopped for new applications.
+ def.setLineOffline(1);
+ assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1);
+ FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED)
+ .findFirst().orElseThrow(AssertionError::new);
+ listener.persistIntent(handled);
+ listener.onTaskHandled(handled);
+ assertThat(activation.get().getStatus()).isEqualTo("DONE");
+ assertThat(activation.get().getLastError()).isNull();
+ assertThat(created.get().getStatus()).isEqualTo(20);
+ assertThat(created.get().getCurrentNodes()).isNullOrEmpty();
+ assertThat(created.get().getDefId()).isEqualTo(7L);
+ assertThat(created.get().getDefVersion()).isEqualTo(1);
+ assertThat(events).extracting(FlowEvent::getType)
+ .contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED,
+ FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED);
+ verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
+ }
+}
@@ -299,7 +299,7 @@ public class FlowDefAdminService {
private void requireLine(Long schId, String flowGuid) { private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty() if (flowGuid == null || flowGuid.trim().isEmpty()
|| defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) { .isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid); throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
} }
@@ -18,6 +18,9 @@ import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -33,7 +36,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
@@ -53,43 +58,17 @@ class FlDefLineOfflineTest {
private static final String GUID = "g-line"; private static final String GUID = "g-line";
private FlwDefRepository defRepository; private FlwDefRepository defRepository;
private FlwDefAdminServiceSpy adminService; private FlowDefAdminService adminService;
private OapiFlowService oapiService; private OapiFlowService oapiService;
private FlwInstanceRepository instances; private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService; private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
static class FlwDefAdminServiceSpy {
final FlwDefRepository defRepository;
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
this.defRepository = defRepository;
}
int offlineLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
int enableLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
}
@BeforeEach @BeforeEach
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void setUp() { void setUp() {
defRepository = mock(FlwDefRepository.class); defRepository = mock(FlwDefRepository.class);
adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class); instances = mock(FlwInstanceRepository.class);
adminService = newFlowDefAdminService();
instanceService = mock(FlowInstanceService.class); instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances, oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class), mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
@@ -121,9 +100,61 @@ class FlDefLineOfflineTest {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope")) assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在"); .hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt()); verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
} }
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t\r\n"})
void blankGuidRejectedOnBothPaths(String guid) {
assertThatThrownBy(() -> adminService.offlineLine(SCH, guid))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(SCH, guid))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any());
}
@Test
void otherSchoolCannotChangeExistingLine() {
assertThatThrownBy(() -> adminService.offlineLine(2L, GUID))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
assertThatThrownBy(() -> adminService.enableLine(2L, GUID))
.isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID);
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
@Test
void guidTrimmedForBothLookupAndUpdate() {
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3);
assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 1);
verify(defRepository).updateLineOffline(SCH, GUID, 0);
}
@Test
void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() {
FlwDef published = def(9, 3, 0);
String frozenJson = published.getContentJson();
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID))
.thenReturn(Optional.of(published));
adminService.offlineLine(SCH, GUID);
adminService.enableLine(SCH, GUID);
assertThat(published.getVersion()).isEqualTo(3);
assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode());
assertThat(published.getContentJson()).isEqualTo(frozenJson);
verify(defRepository, never()).save(any());
verifyNoInteractions(instances, instanceService);
}
// ==================== 新发起/预览的线级守卫 ==================== // ==================== 新发起/预览的线级守卫 ====================
@Test @Test
@@ -229,7 +260,7 @@ class FlDefLineOfflineTest {
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class), mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true), appRepo(true),
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), instances,
mock(FlowDeptAdminService.class), mock(FlowDeptAdminService.class),
mock(FlowEngine.class), mock(FlowEngine.class),
new ObjectMapper(), new ObjectMapper(),
@@ -0,0 +1,122 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.*;
import com.yunhai.flowcenter.entity.*;
import com.yunhai.flowcenter.repository.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import javax.persistence.EntityManager;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */
class FlNewInstancePathTest {
@Test
void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() {
ObjectMapper mapper = new ObjectMapper();
FlwDefRepository defs = mock(FlwDefRepository.class);
FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
FlwTaskRepository tasks = mock(FlwTaskRepository.class);
FlwBranchRepository branches = mock(FlwBranchRepository.class);
FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class);
AssigneeResolver assignees = mock(AssigneeResolver.class);
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
List<FlowEvent> events = new ArrayList<>();
doAnswer(call -> { events.add(call.getArgument(0)); return null; })
.when(publisher).publishEvent(any(FlowEvent.class));
FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
assignees, mock(ConditionRouteService.class), publisher);
FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
assignees, engine, publisher);
FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class));
FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents);
FlwDef def = new FlwDef();
def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line");
def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0);
def.setContentJson("{\"start\":\"start\",\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}");
when(defs.findById(7L)).thenReturn(Optional.of(def));
when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer")));
AtomicReference<FlwInstance> created = new AtomicReference<>();
when(instances.saveAndFlush(any())).thenAnswer(call -> {
FlwInstance instance = call.getArgument(0);
if (instance.getId() == null) instance.setId(900L);
created.set(instance); return instance;
});
when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get()));
AtomicReference<FlwTask> task = new AtomicReference<>();
when(tasks.save(any())).thenAnswer(call -> {
FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row;
});
when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get()));
when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call ->
task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList());
when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> {
task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1;
});
AtomicReference<FlwAdvanceIntent> activation = new AtomicReference<>();
when(intents.save(any())).thenAnswer(call -> {
FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row;
});
when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get()));
when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review"))
.thenAnswer(call -> Collections.singletonList(activation.get()));
when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review"))
.thenAnswer(call -> Optional.of(activation.get()));
FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
null, "100001", "notice", null);
FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
assertThat(outcome.duplicated).isFalse();
assertThat(created.get().getCurrentNodes()).isEqualTo("review");
assertThat(task.get().getStatus()).isZero();
assertThat(task.get().getAssigneeId()).isEqualTo(201);
FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED)
.findFirst().orElseThrow(AssertionError::new);
// Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim.
TransactionSynchronizationManager.initSynchronization();
try {
listener.persistIntent(taskCreated);
TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false));
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
assertThat(activation.get().containsTask(task.get().getId())).isTrue();
assertThat(activation.get().getActivationDefId()).isEqualTo(7L);
assertThat(activation.get().getActivationApplyRound()).isEqualTo(1);
// Existing tasks resolve their pinned definition even when the line is stopped for new applications.
def.setLineOffline(1);
assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1);
FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED)
.findFirst().orElseThrow(AssertionError::new);
listener.persistIntent(handled);
listener.onTaskHandled(handled);
assertThat(activation.get().getStatus()).isEqualTo("DONE");
assertThat(activation.get().getLastError()).isNull();
assertThat(created.get().getStatus()).isEqualTo(20);
assertThat(created.get().getCurrentNodes()).isNullOrEmpty();
assertThat(created.get().getDefId()).isEqualTo(7L);
assertThat(created.get().getDefVersion()).isEqualTo(1);
assertThat(events).extracting(FlowEvent::getType)
.contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED,
FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED);
verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
}
}