diff --git a/docs/FL后端核心修复执行回执-Codex.md b/docs/FL后端核心修复执行回执-Codex.md new file mode 100644 index 0000000..33df08b --- /dev/null +++ b/docs/FL后端核心修复执行回执-Codex.md @@ -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 集成复验后另行安排。 diff --git a/docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt b/docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt new file mode 100644 index 0000000..76bbf34 --- /dev/null +++ b/docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt @@ -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): + * + * + */ +@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 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; + } +} diff --git a/docs/evidence/core-fix-20261027/FlowDefAdminService.before.java.txt b/docs/evidence/core-fix-20261027/FlowDefAdminService.before.java.txt new file mode 100644 index 0000000..da109e3 --- /dev/null +++ b/docs/evidence/core-fix-20261027/FlowDefAdminService.before.java.txt @@ -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 list(Long schId, Integer status, String appKey) { + List 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 distinctAppKeys(Long schId) { + TreeSet keys = new TreeSet<>(); + keys.addAll(defRepository.findDistinctAppKeys(schId)); + keys.addAll(regRepository.findDistinctAppKeysBySchId(schId)); + return new ArrayList<>(keys); + } + + /** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */ + public List 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 位)。
+ * 发布后不可改,改=升版本由既有升版分派天然保证(非 DRAFT 直改必走升版新行); + * 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库); + * 非空=设置,保存前按确切版本远端核验,失败不写入。 + *
v3/TASK_1 追加 {@code formVersion}:与 formUuid 成对,规则是—— + * ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验); + * ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜); + * ③ formUuid 给空串=取消关联,同时清空版本(留着一个没人认领的版本号是下一轮误用的起点); + * ④ 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 有启用映射)后置为已发布。
+ * 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照 + * 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验), + * 并做表达式键查①红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); + * 运行期对 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 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 snapshot, + List 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 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 versions(Long schId, String flowGuid) { + return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid); + } + + /** 在途办件数(议题二④):flowGuid → 审批中(10)办件数;口径=def_id 精确匹配该线各版本;请求的 guid 全部有键(无在途=0) */ + public Map inflightCounts(Long schId, Collection flowGuids) { + Map result = new HashMap<>(); + if (flowGuids == null || flowGuids.isEmpty()) return result; + flowGuids.forEach(g -> result.put(g, 0L)); + List defs = defRepository.findBySchIdAndFlowGuidIn(schId, flowGuids); + if (defs.isEmpty()) return result; + Map 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 + * 而不升版本——结构改动仍走 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 enabledDeptKeys(Long schId) { + // 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1) + return deptAdminService.enabledDeptKeys(schId); + } + + /** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */ + private Set 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; + } + +} diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.xml new file mode 100644 index 0000000..80a9975 --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.xml new file mode 100644 index 0000000..67032df --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.xml new file mode 100644 index 0000000..344f1e3 --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlApplyRecoveryTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlApplyRecoveryTest.xml new file mode 100644 index 0000000..5a44eb2 --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlApplyRecoveryTest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlDefLineOfflineTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlDefLineOfflineTest.xml new file mode 100644 index 0000000..bcb3eca --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlDefLineOfflineTest.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlNewInstancePathTest.xml b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlNewInstancePathTest.xml new file mode 100644 index 0000000..2c2b418 --- /dev/null +++ b/docs/evidence/core-fix-20261027/TEST-com.yunhai.flowcenter.service.FlNewInstancePathTest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/evidence/core-fix-20261027/baseline-status.txt b/docs/evidence/core-fix-20261027/baseline-status.txt new file mode 100644 index 0000000..60f2dea --- /dev/null +++ b/docs/evidence/core-fix-20261027/baseline-status.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/baseline-tracked.patch b/docs/evidence/core-fix-20261027/baseline-tracked.patch new file mode 100644 index 0000000..07dd7ed --- /dev/null +++ b/docs/evidence/core-fix-20261027/baseline-tracked.patch @@ -0,0 +1,5235 @@ +diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml +index 62261d1..e529d58 100644 +--- a/.gitea/workflows/build.yml ++++ b/.gitea/workflows/build.yml +@@ -16,6 +16,10 @@ on: + description: '构建前清空 maven m2 缓存' + required: false + default: 'false' ++ tests_only: ++ description: '只验证测试和 JAR,不构建推送镜像或通知建档' ++ required: false ++ default: 'true' + + jobs: + build-push: +@@ -36,6 +40,8 @@ jobs: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src ++ git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA" ++ git -C /tmp/src checkout --detach FETCH_HEAD + cp -a /tmp/src/. . + rm -rf /tmp/src + # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 +@@ -64,6 +70,7 @@ jobs: + # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。 + BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)" + echo "BUILD_CONTAINER=${BUILD_CONTAINER}" ++ trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT + docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true + docker run -d --name "$BUILD_CONTAINER" \ + --network infra-infra-net \ +@@ -71,13 +78,33 @@ jobs: + -w /build maven:3.6.3-jdk-8 sleep 3600 + docker cp "${PWD}/." "$BUILD_CONTAINER":/build/ + docker exec "$BUILD_CONTAINER" sh -c "printf '%s' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /root/.m2/settings.xml" +- docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package' ++ # 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。 ++ DB_CONTAINER="${BUILD_CONTAINER}-db" ++ trap 'docker rm -f "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true' EXIT ++ export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') ++ export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') ++ docker create --name "$DB_CONTAINER" --network infra-infra-net \ ++ -e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \ ++ -e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null ++ docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql ++ docker start "$DB_CONTAINER" >/dev/null ++ ready=0 ++ for n in $(seq 1 60); do ++ if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi ++ sleep 2 ++ done ++ [ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; } ++ export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" ++ export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD" ++ docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \ ++ "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package' + docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar' + mkdir -p "${PWD}/target" + docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar" + docker rm -f "$BUILD_CONTAINER" + + - name: 构建并推送镜像 ++ if: ${{ inputs.tests_only != 'true' }} + run: | + set -e + TAG=$(echo "$GITHUB_SHA" | cut -c1-8) +@@ -88,6 +115,7 @@ jobs: + + # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) + - name: 通知 projman 建档(部署由运维手动触发) ++ if: ${{ inputs.tests_only != 'true' }} + run: | + curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ + -H "Content-Type: application/json" \ +diff --git a/pom.xml b/pom.xml +index 8647587..1c95daf 100644 +--- a/pom.xml ++++ b/pom.xml +@@ -100,6 +100,14 @@ + spring-boot-starter-test + test + ++ ++ ++ com.yunhai.platform ++ yh-flow-callback-spring-boot-starter ++ test ++ + + + +diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java +index bec6659..e9a8cd1 100644 +--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java ++++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java +@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository; + import com.yunhai.flowcenter.repository.FlwInstanceRepository; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; ++import org.springframework.dao.DuplicateKeyException; + import org.springframework.scheduling.annotation.Scheduled; + import org.springframework.stereotype.Component; + import org.springframework.transaction.event.TransactionPhase; +@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService; + import java.util.concurrent.Executors; + + /** +- * 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg +- * (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。 ++ * 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 → ++ * 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。 + * 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。 + * @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。 ++ * ++ *

R2(Codex D04)投递可靠性口径: ++ *

    ++ *
  • 统一冻结信封——事实行在状态事务内冻结完整投递体(含 eventId/eventSeq/flowGuid); ++ * 正常分发优先复用冻结体,崩溃恢复分发原样复用同一冻结体,两条路径消息体同构;恢复体 ++ * 对 R2 前存量事实行按列值幂等补齐 eventId/eventSeq/flowGuid 后投出。
  • ++ *
  • 未可靠建行不记账——任一匹配订阅方的投递行建行失败(含持久化异常)时,事实行 ++ * 不标记 dispatched:正常链不标记(恢复扫描补建),恢复链保持 dispatched=0 等下轮重扫; ++ * 异步首推的提交失败不回退建行结果(行已在,重试扫描接手)。
  • ++ *
  • 并发幂等靠数据库唯一键——(event_id, reg_id) 复合唯一约束兜底 exists→insert 竞态, ++ * DuplicateKeyException 一律视为"已建行"幂等成功。
  • ++ *
+ */ + @Slf4j + @Component +@@ -48,6 +61,17 @@ public class CallbackDispatcher { + private final CallbackPushService pushService; + private final CallbackProperties properties; + private final ObjectMapper objectMapper; ++ private final com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository; ++ private final CallbackFactStore factStore; ++ ++ @org.springframework.beans.factory.annotation.Autowired ++ private CallbackSourcePolicy sourcePolicy; ++ ++ private boolean self(FlwCallbackReg reg, FlwInstance instance) { ++ if (instance.getSourcePrincipalId() == null) return true; // legacy cutover is a separate migration ++ if (sourcePolicy == null) throw new IllegalStateException("Missing formal callback source policy"); ++ return sourcePolicy.matches(reg, instance); ++ } + + private volatile ExecutorService pushExecutor; + +@@ -70,14 +94,129 @@ public class CallbackDispatcher { + } + try { + dispatch(event); ++ markFactDispatched(event); + } catch (Exception e) { +- // 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程 +- log.error("[flow-center] 回调分发异常:instance={} event={} {}", ++ // 分发/建行失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程; ++ // R2:不标记 dispatched——恢复扫描稍后按事实行补建缺失的投递行 ++ log.error("[flow-center] 回调分发异常(事实保持待补分发):instance={} event={} {}", + event.getInstanceId(), event.getType().getCode(), e.toString(), e); + } + } + +- private void dispatch(FlowEvent event) { ++ /** T05:事实行分发完成记账(崩溃窗口恢复扫描据此区分"已分发"与"待补分发") */ ++ private void markFactDispatched(FlowEvent event) { ++ if (event.getStableEventId() == null) { ++ return; // 无事实行(存量发布路径/记录器缺席),维持旧行为 ++ } ++ try { ++ // Cross the separate bean's proxy: the completed business transaction cannot commit this write. ++ factStore.markDispatched(event.getStableEventId()); ++ } catch (Exception e) { ++ log.warn("[flow-center] 事件事实分发记账失败(重扫会幂等补):eventId={} {}", ++ event.getStableEventId(), e.toString()); ++ } ++ } ++ ++ /** ++ * T05 崩溃窗口恢复扫描:状态事务已提交但分发未及执行(进程被杀/分发异常)的事实行, ++ * 过缓冲期后按存量事实补分发——幂等(同 eventId 已建投递行则跳过),不重放旧轮身份 ++ * (载荷即事实快照)。R2:任一订阅方建行失败则保持 dispatched=0,下轮重扫补齐。 ++ */ ++ @Scheduled(fixedDelay = 60_000) ++ public void sweepUndispatchedFacts() { ++ try { ++ java.util.List due = ++ stateEventRepository.findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc( ++ 0, java.time.LocalDateTime.now().minusSeconds(60)); ++ for (com.yunhai.flowcenter.entity.FlwStateEvent fact : due) { ++ executor().submit(() -> dispatchFact(fact)); ++ } ++ } catch (Exception e) { ++ log.warn("[flow-center] 事件事实补分发扫描异常:{}", e.toString()); ++ } ++ } ++ ++ @SuppressWarnings("unchecked") ++ private void dispatchFact(com.yunhai.flowcenter.entity.FlwStateEvent fact) { ++ try { ++ FlwInstance instance = instanceRepository.findById(fact.getInstanceId()).orElse(null); ++ if (instance == null) { ++ return; ++ } ++ FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); ++ if (def == null) { ++ return; ++ } ++ Map stored = objectMapper.readValue(fact.getPayloadJson(), Map.class); ++ java.util.List regs = regRepository.findBySchIdAndFlowGuidAndStatus( ++ instance.getSchId(), def.getFlowGuid(), 0); ++ boolean allDelivered = true; ++ for (FlwCallbackReg reg : regs) { ++ if (!self(reg, instance)) continue; ++ if (!subscribed(reg.getEventTypes(), fact.getEventType())) { ++ continue; ++ } ++ if (logRepository.existsByEventIdAndRegId(fact.getEventId(), reg.getId())) { ++ continue; // 幂等:崩溃前已建的投递行不重建 ++ } ++ try { ++ persistFactDelivery(reg, instance, def, fact, stored); ++ } catch (DuplicateKeyException dup) { ++ // uk(event_id, reg_id) 并发兜底:另一路径已建行,视为已投递 ++ } catch (Exception e) { ++ allDelivered = false; ++ log.error("[flow-center] 事件事实补分发建行失败(保留待重扫):eventId={} regId={} {}", ++ fact.getEventId(), reg.getId(), e.toString()); ++ } ++ } ++ if (!allDelivered) { ++ return; // R2:未全部可靠建行,不标记 dispatched ++ } ++ fact.setDispatched(1); ++ stateEventRepository.save(fact); ++ } catch (Exception e) { ++ log.warn("[flow-center] 事件事实补分发失败(下轮重扫):eventId={} {}", fact.getEventId(), e.toString()); ++ } ++ } ++ ++ /** ++ * 补分发建行:载荷以事实冻结体为准(R2 冻结信封原样复用,与正常链同构); ++ * R2 前存量事实行缺 eventId/eventSeq/flowGuid 时按列值幂等补齐。异步首推同正常链。 ++ */ ++ private void persistFactDelivery(FlwCallbackReg reg, FlwInstance instance, FlwDef def, ++ com.yunhai.flowcenter.entity.FlwStateEvent fact, ++ Map stored) throws Exception { ++ String payloadJson; ++ if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) { ++ // R2 冻结体:完整信封已含全部投递字段,原样复用(不可变) ++ payloadJson = fact.getPayloadJson(); ++ } else { ++ // R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复 ++ Map repaired = new HashMap<>(stored); ++ repaired.put("eventId", fact.getEventId()); ++ if (fact.getEventSeq() != null) { ++ repaired.put("eventSeq", fact.getEventSeq()); ++ } ++ repaired.put("flowGuid", def.getFlowGuid()); ++ payloadJson = objectMapper.writeValueAsString(repaired); ++ } ++ FlwCallbackLog callbackLog = new FlwCallbackLog(); ++ callbackLog.setSchId(instance.getSchId()); ++ callbackLog.setRegId(reg.getId()); ++ callbackLog.setEventId(fact.getEventId()); ++ callbackLog.setEvent(fact.getEventType()); ++ callbackLog.setInstanceId(instance.getId()); ++ callbackLog.setBizType(instance.getBizType()); ++ callbackLog.setBizId(instance.getBizId()); ++ callbackLog.setPayloadJson(payloadJson); ++ callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); ++ callbackLog.setRetryCount(0); ++ callbackLog.setNextRetryTime(LocalDateTime.now()); ++ Long logId = logStore.persist(callbackLog); ++ submitPush(logId); ++ } ++ ++ private void dispatch(FlowEvent event) throws Exception { + FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null); + if (instance == null) { + return; +@@ -90,6 +229,7 @@ public class CallbackDispatcher { + List regs = regRepository.findBySchIdAndFlowGuidAndStatus( + instance.getSchId(), def.getFlowGuid(), 0); + for (FlwCallbackReg reg : regs) { ++ if (!self(reg, instance)) continue; + if (!subscribed(reg.getEventTypes(), eventCode)) { + continue; + } +@@ -97,52 +237,85 @@ public class CallbackDispatcher { + } + } + +- private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) { ++ private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) ++ throws Exception { ++ // T05:eventId 用事实行稳定身份(全部订阅方/重试复用);事实行缺席(存量路径)才现场生成 ++ String eventId = event.getStableEventId() != null ? event.getStableEventId() ++ : UUID.randomUUID().toString().replace("-", "").toLowerCase(); ++ if (logRepository.existsByEventIdAndRegId(eventId, reg.getId())) { ++ return; // 幂等:补分发与正常分发竞争时不重复建投递行 ++ } ++ // R2:优先复用事实冻结信封(与崩溃恢复链同构、消息体恒含 eventId/eventSeq); ++ // 冻结体缺席(存量路径/记录器缺席)才现场构建 ++ String payloadJson = event.getStablePayloadJson() != null ++ ? event.getStablePayloadJson() ++ : objectMapper.writeValueAsString(legacyEnvelope(event, instance, def, eventId)); ++ FlwCallbackLog callbackLog = new FlwCallbackLog(); ++ callbackLog.setSchId(instance.getSchId()); ++ callbackLog.setRegId(reg.getId()); ++ callbackLog.setEventId(eventId); ++ callbackLog.setEvent(event.getType().getCode()); ++ callbackLog.setInstanceId(instance.getId()); ++ callbackLog.setBizType(instance.getBizType()); ++ callbackLog.setBizId(instance.getBizId()); ++ callbackLog.setPayloadJson(payloadJson); ++ callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); ++ callbackLog.setRetryCount(0); ++ callbackLog.setNextRetryTime(LocalDateTime.now()); ++ // REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务); ++ // R2:建行失败(含并发撞 uk)向外传播——调用方据此不标记事实已分发/保持待重扫 ++ Long logId; ++ try { ++ logId = logStore.persist(callbackLog); ++ } catch (DuplicateKeyException dup) { ++ return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功 ++ } ++ submitPush(logId); ++ } ++ ++ /** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */ ++ private void submitPush(Long logId) { + try { +- String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase(); +- Map payload = new HashMap<>(); +- payload.put("eventId", eventId); +- payload.put("event", event.getType().getCode()); +- payload.put("schId", instance.getSchId()); +- payload.put("flowGuid", def.getFlowGuid()); +- payload.put("instanceId", instance.getId()); +- payload.put("instanceVersion", instance.getVersion()); +- payload.put("bizType", instance.getBizType()); +- payload.put("bizId", instance.getBizId()); +- payload.put("bizTitle", instance.getBizTitle()); +- payload.put("occurredTime", LocalDateTime.now().format(TIME)); +- // 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null—— +- // 契约见《子应用接入流程中心规范.md》§6.3/§6.4) +- payload.put("taskId", event.getTaskId()); +- payload.put("operatorUserId", event.getOperatorId()); +- payload.put("operatorName", event.getOperatorName()); +- Map data = new HashMap<>(); +- if (event.getData() != null) { +- data.putAll(event.getData()); +- } +- payload.put("data", data); +- String payloadJson = objectMapper.writeValueAsString(payload); +- +- FlwCallbackLog callbackLog = new FlwCallbackLog(); +- callbackLog.setSchId(instance.getSchId()); +- callbackLog.setRegId(reg.getId()); +- callbackLog.setEventId(eventId); +- callbackLog.setEvent(event.getType().getCode()); +- callbackLog.setInstanceId(instance.getId()); +- callbackLog.setBizType(instance.getBizType()); +- callbackLog.setBizId(instance.getBizId()); +- callbackLog.setPayloadJson(payloadJson); +- callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); +- callbackLog.setRetryCount(0); +- callbackLog.setNextRetryTime(LocalDateTime.now()); +- // REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务) +- Long logId = logStore.persist(callbackLog); +- // 异步首推:失败由退避重试接手(1m 起) + executor().submit(() -> pushService.pushAsync(logId)); + } catch (Exception e) { +- log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}", +- instance.getId(), event.getType().getCode(), e.toString(), e); ++ log.warn("[flow-center] 异步首推提交失败(重试扫描接手):logId={} {}", logId, e.toString()); ++ } ++ } ++ ++ /** ++ * 存量现场信封(事实冻结体缺席的兜底路径):字段口径与 FlwStateEventRecorder 冻结体一致。 ++ */ ++ private Map legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def, ++ String eventId) { ++ Map payload = new HashMap<>(); ++ payload.put("eventId", eventId); ++ if (event.getStableEventSeq() != null) { ++ payload.put("eventSeq", event.getStableEventSeq()); ++ } ++ payload.put("event", event.getType().getCode()); ++ payload.put("schId", instance.getSchId()); ++ payload.put("flowGuid", def.getFlowGuid()); ++ payload.put("instanceId", instance.getId()); ++ payload.put("instanceVersion", instance.getVersion()); ++ payload.put("bizType", instance.getBizType()); ++ payload.put("bizId", instance.getBizId()); ++ payload.put("bizTitle", instance.getBizTitle()); ++ payload.put("occurredTime", LocalDateTime.now().format(TIME)); ++ // 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null—— ++ // 契约见《子应用接入流程中心规范.md》§6.3/§6.4) ++ payload.put("taskId", event.getTaskId()); ++ payload.put("operatorUserId", event.getOperatorId()); ++ payload.put("operatorName", event.getOperatorName()); ++ Map data = new HashMap<>(); ++ if (event.getData() != null) { ++ data.putAll(event.getData()); ++ } ++ // 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。 ++ if (event.getRoundIdentity() != null) { ++ data.putAll(event.getRoundIdentity()); + } ++ payload.put("data", data); ++ return payload; + } + + /** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */ +diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java +index 2ac94df..25c7707 100644 +--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java ++++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java +@@ -2,21 +2,45 @@ package com.yunhai.flowcenter.callback; + + import com.yunhai.flowcenter.entity.FlwCallbackLog; + import com.yunhai.flowcenter.repository.FlwCallbackLogRepository; +-import lombok.RequiredArgsConstructor; ++import com.yunhai.platform.apistd.BizException; ++import com.yunhai.platform.utils.exception.ErrorCode; ++import lombok.extern.slf4j.Slf4j; ++import org.springframework.beans.factory.annotation.Autowired; ++import org.springframework.jdbc.core.BeanPropertyRowMapper; + import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Propagation; + import org.springframework.transaction.annotation.Transactional; + ++import javax.sql.DataSource; ++import java.sql.*; ++import java.time.LocalDateTime; ++import java.util.*; ++ + /** + * 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交 + * 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress—— + * 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。 + */ + @Service +-@RequiredArgsConstructor ++@Slf4j + public class CallbackLogStore { + + private final FlwCallbackLogRepository logRepository; ++ private final DataSource dataSource; ++ private final int claimIdleSeconds; ++ ++ @Autowired ++ public CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource) { ++ this(logRepository, dataSource, 60); ++ } ++ ++ // 窄范围故障测试可缩短会话租期;生产固定 60s,大于正常 HTTP 5s 连接 + 10s 读取超时。 ++ CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource, int claimIdleSeconds) { ++ if (claimIdleSeconds < 1) throw new IllegalArgumentException("claimIdleSeconds must be positive"); ++ this.logRepository = logRepository; ++ this.dataSource = dataSource; ++ this.claimIdleSeconds = claimIdleSeconds; ++ } + + /** @return 落库后的日志行 id */ + @Transactional(propagation = Propagation.REQUIRES_NEW) +@@ -24,4 +48,198 @@ public class CallbackLogStore { + FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog); + return saved.getId(); + } ++ ++ /** ++ * MySQL 会话锁不是事务/行锁。专用借出连接始终 autocommit,HTTP 不占用数据库事务。 ++ * 连接空闲 60s 后由数据库关闭并释放锁;所有结果必须在原连接上验证锁所有权,禁止换连接补写。 ++ */ ++ public Claim tryClaim(Long id) { ++ Claim claim = openSession(); ++ try { ++ if (!claim.acquire(id)) { ++ claim.close(); ++ return null; ++ } ++ claim.row = claim.read(id); ++ if (claim.row == null) { ++ claim.close(); ++ return null; ++ } ++ return claim; ++ } catch (RuntimeException | SQLException failure) { ++ claim.close(); ++ throw new IllegalStateException("回调认领失败", failure); ++ } ++ } ++ ++ private Claim openSession() { ++ Connection connection = null; ++ try { ++ // 不用 DataSourceUtils:绝不借用调用方已绑定的业务事务连接。 ++ connection = dataSource.getConnection(); ++ if (!connection.getAutoCommit()) throw new SQLException("callback claim requires autocommit"); ++ long previous; ++ try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery("SELECT @@session.wait_timeout")) { ++ rs.next(); previous = rs.getLong(1); ++ } ++ try (Statement st = connection.createStatement()) { ++ st.execute("SET SESSION wait_timeout = " + claimIdleSeconds); ++ } ++ return new Claim(connection, previous); ++ } catch (SQLException failure) { ++ if (connection != null) { ++ try { connection.abort(Runnable::run); } catch (SQLException ignored) { } ++ try { connection.close(); } catch (SQLException ignored) { } ++ } ++ throw new IllegalStateException("回调认领连接不可用", failure); ++ } ++ } ++ ++ /** 人工重推先完成整批短事务,再交回推送层发 HTTP;成功行不能被旧管理快照复活。 */ ++ public List resetForReplay(Long school, Collection ids) { ++ try (Claim claim = openSession()) { ++ List sorted = new ArrayList<>(new TreeSet<>(ids)); ++ for (Long id : sorted) { ++ if (!claim.acquire(id)) throw new BizException(ErrorCode.CUSTOM, "回调正在派发,请稍后重推:" + id); ++ FlwCallbackLog row = claim.read(id); ++ if (row == null || !school.equals(row.getSchId())) throw new BizException(ErrorCode.FORBIDDEN, "回调日志不存在或不属于该校"); ++ } ++ claim.connection.setAutoCommit(false); ++ List reset = new ArrayList<>(); ++ try { ++ for (Long id : sorted) { ++ // 也检查所有权,旧 MySQL 若不支持同会话多锁会 fail closed,而不会无锁更新。 ++ if (!claim.owns(id)) throw new BizException(ErrorCode.CUSTOM, "回调派发权已失效,请重试"); ++ int changed = claim.update(id, "status=0, retry_count=0, next_retry_time=?, update_time=?, update_by=?", ++ "status in (0,2)", Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now()), actor()); ++ if (changed == 1) reset.add(id); ++ } ++ claim.connection.commit(); ++ return reset; ++ } catch (RuntimeException | SQLException failure) { ++ claim.connection.rollback(); ++ throw failure; ++ } finally { claim.connection.setAutoCommit(true); } ++ } catch (SQLException failure) { ++ throw new IllegalStateException("回调重推状态提交失败", failure); ++ } ++ } ++ ++ private static String actor() { ++ com.yunhai.platform.auth.LoginUser user = com.yunhai.platform.auth.UserContextHolder.get(); ++ return user == null ? null : user.getLoginName(); ++ } ++ ++ /** 令牌即拥有命名锁的原始数据库会话;状态更新只能在该连接上进行。 */ ++ public static final class Claim implements AutoCloseable { ++ private final Connection connection; ++ private final long previousWaitTimeout; ++ private final Map keys = new LinkedHashMap<>(); ++ private FlwCallbackLog row; ++ private boolean closed; ++ private boolean settled; ++ ++ private Claim(Connection connection, long previousWaitTimeout) { ++ this.connection = connection; ++ this.previousWaitTimeout = previousWaitTimeout; ++ } ++ ++ public FlwCallbackLog row() { return row; } ++ ++ public boolean active() { ++ try { return !closed && row != null && owns(row.getId()); } ++ catch (SQLException expired) { return false; } ++ } ++ ++ private boolean acquire(Long id) throws SQLException { ++ String key; ++ // 64 字符锁名:库名隔离,同 (eventId,regId) 的全部执行者用同一身份。 ++ try (PreparedStatement st = connection.prepareStatement( ++ "SELECT SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', event_id, ':', reg_id),256) FROM flw_callback_log WHERE id=?")) { ++ st.setLong(1, id); ++ try (ResultSet rs = st.executeQuery()) { if (!rs.next()) return false; key = rs.getString(1); } ++ } ++ try (PreparedStatement st = connection.prepareStatement("SELECT GET_LOCK(?,0)")) { ++ st.setString(1, key); ++ try (ResultSet rs = st.executeQuery()) { ++ rs.next(); ++ if (rs.getInt(1) != 1 || rs.wasNull()) return false; ++ } ++ } ++ keys.put(id, key); ++ return true; ++ } ++ ++ private FlwCallbackLog read(Long id) throws SQLException { ++ try (PreparedStatement st = connection.prepareStatement("SELECT * FROM flw_callback_log WHERE id=?")) { ++ st.setLong(1, id); ++ try (ResultSet rs = st.executeQuery()) { ++ return rs.next() ? new BeanPropertyRowMapper<>(FlwCallbackLog.class).mapRow(rs, 0) : null; ++ } ++ } ++ } ++ ++ private boolean owns(Long id) throws SQLException { ++ try (PreparedStatement st = connection.prepareStatement("SELECT IS_USED_LOCK(?)=CONNECTION_ID()")) { ++ st.setString(1, keys.get(id)); ++ try (ResultSet rs = st.executeQuery()) { rs.next(); return rs.getBoolean(1); } ++ } ++ } ++ ++ private int update(Long id, String assignments, String condition, Object... values) throws SQLException { ++ try (PreparedStatement st = connection.prepareStatement("UPDATE flw_callback_log SET " + assignments ++ + " WHERE id=? AND " + condition + " AND IS_USED_LOCK(?)=CONNECTION_ID()")) { ++ int i=1; ++ for (Object value : values) st.setObject(i++, value); ++ st.setLong(i++, id); st.setString(i, keys.get(id)); ++ return st.executeUpdate(); ++ } ++ } ++ ++ public boolean success(int httpStatus) { return finish("status=1, last_http_status=?, last_error=NULL, push_time=?, next_retry_time=NULL", ++ String.valueOf(httpStatus), Timestamp.valueOf(LocalDateTime.now())); } ++ ++ public boolean failure(String error, String httpStatus, long backoffMinutes) { ++ return finish("retry_count=COALESCE(retry_count,0)+1, last_http_status=?, last_error=?, next_retry_time=?", ++ httpStatus, error, Timestamp.valueOf(LocalDateTime.now().plusMinutes(backoffMinutes))); ++ } ++ ++ public boolean dead(String error) { return finish("status=2, last_error=?", error); } ++ ++ private boolean finish(String assignments, Object... values) { ++ if (closed || settled || row == null) return false; ++ Object[] audited = Arrays.copyOf(values, values.length+2); ++ audited[values.length] = Timestamp.valueOf(LocalDateTime.now()); audited[values.length+1] = actor(); ++ try { ++ // count 是附加 CAS;不是把旧实体全量 merge 回库。会话失权/成功/人工处理后均不能覆盖。 ++ settled = update(row.getId(), assignments + ", update_time=?, update_by=?", ++ "status=0 AND retry_count=" + row.getRetryCount(), audited) == 1; ++ if (!settled) log.debug("[flow-center] 回调旧结果已忽略:logId={}", row.getId()); ++ return settled; ++ } catch (SQLException failure) { ++ log.warn("[flow-center] 回调结果未记账(会话失效或数据库不可用,留待重试):logId={} {}", row.getId(), failure.toString()); ++ return false; ++ } ++ } ++ ++ @Override public void close() { ++ if (closed) return; ++ closed = true; ++ try { ++ for (String key : keys.values()) { ++ try (PreparedStatement st = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) { ++ st.setString(1, key); st.execute(); ++ } ++ } ++ try (Statement st = connection.createStatement()) { ++ st.execute("SET SESSION wait_timeout = " + previousWaitTimeout); ++ } ++ } catch (SQLException failedRelease) { ++ // 不能把可能残留会话锁的物理连接还给池;显式中止,让数据库释放全部锁。 ++ try { connection.abort(Runnable::run); } catch (SQLException ignored) { } ++ } finally { ++ try { connection.close(); } catch (SQLException ignored) { } ++ } ++ } ++ } + } +diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java +index e29c0dd..4bad120 100644 +--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java ++++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java +@@ -21,8 +21,8 @@ import javax.crypto.spec.SecretKeySpec; + import java.nio.charset.StandardCharsets; + import java.time.LocalDateTime; + import java.util.Base64; +-import java.util.HashSet; +-import java.util.Set; ++import java.util.HashMap; ++import java.util.Map; + import java.util.UUID; + + /** +@@ -32,7 +32,7 @@ import java.util.UUID; + * 持续重试不终止(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账); + * 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次; + * 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。 +- * 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。 ++ * HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。 + * 推送本身与落库解耦:payload_json 是完整快照,可重放。 + */ + @Slf4j +@@ -40,43 +40,72 @@ import java.util.UUID; + @RequiredArgsConstructor + public class CallbackPushService { + +- private static final Set IN_FLIGHT = new HashSet<>(); ++ /** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */ ++ private final Map IN_FLIGHT = new HashMap<>(); ++ ++ /** 行是否有本地执行者在途(仅观测;互斥由数据库认领负责)。 */ ++ boolean inFlight(Long logId) { ++ synchronized (IN_FLIGHT) { ++ return IN_FLIGHT.containsKey(logId); ++ } ++ } + + private final FlwCallbackLogRepository logRepository; + private final FlwCallbackRegRepository regRepository; + private final CallbackProperties properties; ++ private final CallbackLogStore logStore; + private final UserMsgClient userMsgClient; + private final org.springframework.beans.factory.ObjectProvider + appIdentityProvider; + ++ @org.springframework.beans.factory.annotation.Autowired ++ private CallbackSourcePolicy sourcePolicy; ++ + /** 推送指定日志行(幂等入口:成功/终态/在途跳过) */ ++ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED) + public void pushAsync(Long logId) { + synchronized (IN_FLIGHT) { +- if (!IN_FLIGHT.add(logId)) { ++ IN_FLIGHT.merge(logId, 1, Integer::sum); ++ } ++ try (CallbackLogStore.Claim claim = logStore.tryClaim(logId)) { ++ if (claim == null) { ++ log.debug("[flow-center] 回调数据库派发权未取得:logId={}", logId); + return; + } +- } +- try { +- doPush(logId); ++ doPush(claim); + } finally { + synchronized (IN_FLIGHT) { +- IN_FLIGHT.remove(logId); ++ IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1); + } + } + } + +- private void doPush(Long logId) { +- FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null); ++ private void doPush(CallbackLogStore.Claim claim) { ++ FlwCallbackLog callbackLog = claim.row(); + if (callbackLog == null + || callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) { ++ log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}", ++ callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus()); + return; + } + FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null); + if (reg == null || reg.getStatus() != 0) { + // 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理) +- markDead(callbackLog, "registration disabled"); ++ markDead(claim, "registration disabled"); + return; + } ++ if (sourcePolicy != null) { ++ try { ++ if (!sourcePolicy.permits(reg, callbackLog)) { ++ markDead(claim, "SELF source or command identity mismatch"); ++ return; ++ } ++ } catch (RuntimeException unavailable) { ++ markRetryOrFail(claim, "source authentication unavailable", null); ++ return; ++ } ++ } ++ if (!claim.active()) return; // 权威查询迟到/会话租期已失效:不再发送。 + String body = callbackLog.getPayloadJson(); + String ts = String.valueOf(System.currentTimeMillis()); + String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16); +@@ -94,41 +123,34 @@ public class CallbackPushService { + new HttpEntity<>(body, headers), String.class); + int status = resp.getStatusCodeValue(); + if (status >= 200 && status < 300) { +- markSuccess(callbackLog, status); ++ markSuccess(claim, status); + } else { +- markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status)); ++ markRetryOrFail(claim, "HTTP " + status, String.valueOf(status)); + } + } catch (Exception e) { +- markRetryOrFail(callbackLog, rootMessage(e), null); ++ markRetryOrFail(claim, rootMessage(e), null); + } + } + +- private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) { +- callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode()); +- callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()); +- callbackLog.setLastHttpStatus(String.valueOf(httpStatus)); +- callbackLog.setLastError(null); +- callbackLog.setPushTime(LocalDateTime.now()); +- saveQuietly(callbackLog); +- log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus); ++ private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) { ++ if (claim.success(httpStatus)) { ++ log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus); ++ } + } + + /** + * 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()} + * 封顶继续排期,绝不自动死信;retryCount 首次等于挂起阈值时告警恰一次。 + */ +- private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) { ++ private void markRetryOrFail(CallbackLogStore.Claim claim, String error, String httpStatus) { ++ FlwCallbackLog callbackLog = claim.row(); + int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1; +- callbackLog.setRetryCount(retryCount); +- callbackLog.setLastHttpStatus(httpStatus); +- callbackLog.setLastError(truncate(error)); + java.util.List backoff = properties.getRetryBackoffMinutes(); + long backoffMinutes = retryCount <= backoff.size() + ? backoff.get(retryCount - 1) + : Math.max(1, properties.getCapBackoffMinutes()); +- callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); +- callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes)); +- saveQuietly(callbackLog); ++ if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return; ++ callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。 + log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}", + retryCount, backoffMinutes, callbackLog.getEventId(), error); + // 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次; +@@ -163,9 +185,20 @@ public class CallbackPushService { + * 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。 + */ + public void markDead(FlwCallbackLog callbackLog, String error) { +- callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode()); +- callbackLog.setLastError(truncate(error)); +- saveQuietly(callbackLog); ++ try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) { ++ if (claim == null) throw new com.yunhai.platform.apistd.BizException( ++ com.yunhai.platform.utils.exception.ErrorCode.CUSTOM, "回调正在派发,请稍后标记"); ++ if (claim.row().getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) { ++ throw new com.yunhai.platform.apistd.BizException( ++ com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已成功日志无需标记死信"); ++ } ++ markDead(claim, error); ++ } ++ } ++ ++ private void markDead(CallbackLogStore.Claim claim, String error) { ++ if (!claim.dead(truncate(error))) return; ++ FlwCallbackLog callbackLog = claim.row(); + log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}", + callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error); + alertDead(callbackLog, error); +@@ -203,14 +236,6 @@ public class CallbackPushService { + } + } + +- private void saveQuietly(FlwCallbackLog callbackLog) { +- try { +- logRepository.save(callbackLog); +- } catch (Exception e) { +- log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString()); +- } +- } +- + /** + * 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping, + * 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。 +diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +index 7da759d..cdbe98c 100644 +--- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java ++++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg; + import com.yunhai.flowcenter.entity.FlwDef; + import com.yunhai.flowcenter.entity.FlwInstance; + import com.yunhai.flowcenter.model.FlowModels; ++import com.yunhai.flowcenter.enums.FormBindingErrorCode; + import com.yunhai.flowcenter.repository.FlwInstanceRepository; + import com.yunhai.flowcenter.service.FlowAdminGuard; + import com.yunhai.flowcenter.service.FlowCallbackAdminService; +@@ -108,6 +109,46 @@ public class FlowAdminController { + return appAdminService.stats(adminGuard.requireAdmin(user, null)); + } + ++ // ==================== 应用使用授权(T01) ==================== ++ ++ /** ++ * 使用授权管理面(字段注入:构造被既有单测逐位钉住,同 formCenterCatalogClient 先例)。 ++ * 授权对象是稳定应用主体(UserBase appId),非 appKey 凭据;撤权只拦新发起。 ++ */ ++ @org.springframework.beans.factory.annotation.Autowired ++ private com.yunhai.flowcenter.service.FlowUsageGrantAdminService usageGrantAdminService; ++ ++ /** 某流程线的使用授权清单(含已撤销行,审计可见) */ ++ @GetMapping("/usage-grant/list") ++ public List usageGrantList( ++ BoaUser user, @RequestParam("flowGuid") String flowGuid) { ++ return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid); ++ } ++ ++ /** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */ ++ @PostMapping("/usage-grant/grant") ++ public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant( ++ BoaUser user, @RequestBody Map body) { ++ Long schId = adminGuard.requireAdmin(user, null); ++ return usageGrantAdminService.grant(schId, ++ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")), ++ body.get("appKey") == null ? null : String.valueOf(body.get("appKey")), ++ body.get("remark") == null ? null : String.valueOf(body.get("remark")), ++ user.getName()); ++ } ++ ++ /** 撤销使用授权(enabled=0 行保留;只拦新发起,在途与已受理命令不受影响) */ ++ @PostMapping("/usage-grant/revoke") ++ public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantRevoke( ++ BoaUser user, @RequestBody Map body) { ++ Long schId = adminGuard.requireAdmin(user, null); ++ return usageGrantAdminService.revoke(schId, ++ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")), ++ body.get("appId") == null ? null : Integer.valueOf(String.valueOf(body.get("appId"))), ++ body.get("remark") == null ? null : String.valueOf(body.get("remark")), ++ user.getName()); ++ } ++ + @GetMapping("/def/get") + public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) { + return defAdminService.get(adminGuard.requireAdmin(user, null), id); +@@ -145,7 +186,16 @@ public class FlowAdminController { + (String) body.get("contentJson"), (String) body.get("baseGuid"), + (String) body.get("appKey"), (String) body.get("changeLog"), + (String) body.get("formUuid"), +- body.get("formVersion") == null ? null : Integer.valueOf(String.valueOf(body.get("formVersion")))); ++ parseFormVersion(body.get("formVersion"))); ++ } ++ ++ private static Integer parseFormVersion(Object value) { ++ if (value == null) return null; ++ try { ++ return Integer.valueOf(String.valueOf(value)); ++ } catch (NumberFormatException e) { ++ throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "formVersion 必须是正整数", e); ++ } + } + + /** +@@ -181,6 +231,24 @@ public class FlowAdminController { + return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid); + } + ++ // ==================== 线级停发/恢复(T03) ==================== ++ ++ /** 线级停发:整条 flowGuid 停止新发起/预览;在途实例按原版本跑完不受影响 */ ++ @PostMapping("/def/offlineLine") ++ public int defOfflineLine(BoaUser user, @RequestBody Map body) { ++ Long schId = adminGuard.requireAdmin(user, null); ++ return defAdminService.offlineLine(schId, ++ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid"))); ++ } ++ ++ /** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */ ++ @PostMapping("/def/enableLine") ++ public int defEnableLine(BoaUser user, @RequestBody Map body) { ++ Long schId = adminGuard.requireAdmin(user, null); ++ return defAdminService.enableLine(schId, ++ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid"))); ++ } ++ + /** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */ + @GetMapping("/def/inflight") + public Map defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) { +diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +index a3579ab..d414af4 100644 +--- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java ++++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.controller.api; + import com.yunhai.flowcenter.entity.FlwInstance; + import com.yunhai.flowcenter.entity.FlwTask; + import com.yunhai.flowcenter.model.FlowModels; ++import com.yunhai.flowcenter.model.OapiModels; + import com.yunhai.flowcenter.service.FlowAccessGuard; + import com.yunhai.flowcenter.service.FlowDiagramService; + import com.yunhai.flowcenter.service.FlowInstanceService; +@@ -41,6 +42,22 @@ public class FlowUserController { + private final FlowDiagramService diagramService; + private final com.yunhai.flowcenter.service.DiagramImageService imageService; + ++ /** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */ ++ @org.springframework.beans.factory.annotation.Autowired ++ private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService; ++ ++ /** ++ * 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮): ++ * CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)—— ++ * 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。 ++ * 只读预览:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审 ++ * (前端候选不是授权凭据)。 ++ */ ++ @GetMapping("/apply/preview") ++ public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) { ++ return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId()); ++ } ++ + /** 我提交的办件 */ + @GetMapping("/my") + public BasePageResult my(BoaUser user, +diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +index a9f54be..24fb747 100644 +--- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java ++++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +@@ -208,7 +208,7 @@ public class OapiFlowController { + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size, + @RequestAttribute(value = "yh.app.auth", required = false) String appKey) { +- return queryService.pageDone(schId, assigneeId, appKey, page, size); ++ return queryService.pageDone(schId, assigneeId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size); + } + + /** +@@ -222,7 +222,7 @@ public class OapiFlowController { + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size, + @RequestAttribute(value = "yh.app.auth", required = false) String appKey) { +- return queryService.pageCc(schId, userId, appKey, page, size); ++ return queryService.pageCc(schId, userId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size); + } + + /** 办件分页(oapi):bizType/status 可选过滤 */ +diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java +index 09da03b..8c187b5 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java +@@ -15,7 +15,8 @@ import java.util.List; + + /** + * 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源, +- * 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。 ++ * 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域); ++ * 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。 + */ + @Slf4j + @Service +@@ -32,13 +33,31 @@ public class AssigneeResolver { + } + List refs = new ArrayList<>(); + if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { ++ // D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有 ++ // “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。 ++ List rejected = new ArrayList<>(); + for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) { +- refs.add(new AssigneeRef(m.getUserId(), m.getUserName())); ++ try { ++ requireEligibleAssignee(schId, m.getUserId()); ++ refs.add(new AssigneeRef(m.getUserId(), m.getUserName())); ++ } catch (BizException e) { ++ if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) { ++ throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用” ++ } ++ rejected.add(m.getUserId() + "=" + e.getMessage()); ++ log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}", ++ schId, a.deptKey, m.getUserId(), e.getMessage()); ++ } ++ } ++ if (refs.isEmpty() && !rejected.isEmpty()) { ++ throw new BizException(ErrorCode.PARAM_ERROR, ++ "节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格(" ++ + String.join(";", rejected) + ")"); + } + } else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) { + if (a.userIds != null) { + for (Integer uid : a.userIds) { +- refs.add(new AssigneeRef(uid, fetchName(uid))); ++ refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid))); + } + } + } else { +@@ -51,10 +70,30 @@ public class AssigneeResolver { + return refs; + } + ++ /** ++ * D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校 ++ * 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。 ++ */ ++ public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) { ++ for (FlowDefModel.Node node : model.nodes) { ++ if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null ++ || !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) { ++ continue; ++ } ++ try { ++ resolve(schId, node); ++ } catch (BizException e) { ++ throw new BizException(e.getErrorCode(), ++ "节点[" + node.id + "]固定审批人配置非法:" + e.getMessage()); ++ } ++ } ++ } ++ + /** + * 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义), + * 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批, +- * 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。 ++ * 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验 ++ * 存在/启用/校域,直接复用既有姓名补全容错。 + */ + public List resolveTolerant(Long schId, FlowDefModel.Node node) { + FlowDefModel.Assignee a = node.receivers; +@@ -89,24 +128,52 @@ public class AssigneeResolver { + } + + /** +- * 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反): +- * 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。 +- * UserBase 不可达与"人不存在"分别报错,便于排查。 ++ * 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed) ++ * + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。 + */ +- public String requireUserName(Integer userId) { ++ public BoaUser requireEligibleAssignee(Long schId, Integer userId) { ++ BoaUser user = requireValidUser(userId); ++ Long userSchId = user.getSchool() == null || user.getSchool().getId() == null ++ ? null : Long.valueOf(user.getSchool().getId()); ++ if (schId == null || !schId.equals(userSchId)) { ++ log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}", ++ userId, userSchId, schId); ++ throw new BizException(ErrorCode.FORBIDDEN, ++ "办理人不属于该学校,不能办理任务:" + userId ++ + "(所属学校 " + (userSchId == null ? "未知" : userSchId) ++ + ",要求 " + schId + ")"); ++ } ++ return user; ++ } ++ ++ /** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */ ++ public String requireEligibleAssigneeName(Long schId, Integer userId) { ++ BoaUser user = requireEligibleAssignee(schId, userId); ++ return user.getName() == null ? "" : user.getName(); ++ } ++ ++ /** ++ * 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走 ++ * {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。 ++ */ ++ private BoaUser requireValidUser(Integer userId) { + if (userId == null) { +- throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象"); ++ throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人"); + } + BoaUser user; + try { + user = userClient.getUserById(String.valueOf(userId)); + } catch (Exception e) { +- log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage()); +- throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId); ++ log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage()); ++ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId); + } + if (user == null) { +- throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId); ++ throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId); + } +- return user.getName() == null ? "" : user.getName(); ++ if (!Integer.valueOf(1).equals(user.getStatus())) { ++ log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus()); ++ throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId); ++ } ++ return user; + } + } +diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +index be2ec02..aab4031 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +@@ -29,6 +29,7 @@ public final class DefValidator { + */ + public static void validate(FlowDefModel model, Set enabledDeptKeys, Set enabledRouterKeys) { + List problems = new ArrayList<>(); ++ VariableContract.validateDefinition(model, false); + + if (model.nodes == null || model.nodes.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点"); +@@ -248,7 +249,7 @@ public final class DefValidator { + * (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。 + * + *

EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦 +- * (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。 ++ * 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。 + */ + private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set enabledRouterKeys, + List problems) { +diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java +index 08f14ca..ef0e950 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java +@@ -1,81 +1,201 @@ + package com.yunhai.flowcenter.engine; + +-import com.yunhai.flowcenter.entity.FlwDef; +-import com.yunhai.flowcenter.entity.FlwInstance; +-import com.yunhai.flowcenter.entity.FlwTask; ++import com.yunhai.flowcenter.entity.*; + import com.yunhai.flowcenter.enums.FlwEnums; +-import com.yunhai.flowcenter.repository.FlwDefRepository; +-import com.yunhai.flowcenter.repository.FlwInstanceRepository; +-import com.yunhai.flowcenter.repository.FlwTaskRepository; ++import com.yunhai.flowcenter.repository.*; + import lombok.RequiredArgsConstructor; +-import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; +-import org.springframework.transaction.annotation.Propagation; +-import org.springframework.transaction.annotation.Transactional; ++import org.springframework.transaction.annotation.*; + +-/** +- * 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效—— +- * 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载): +- * 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。 +- */ +-@Slf4j ++import javax.persistence.EntityManager; ++import javax.persistence.PersistenceContext; ++import java.time.LocalDateTime; ++import java.util.*; ++ ++/** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */ + @Service + @RequiredArgsConstructor + public class FlowAdvanceService { +- + private final FlwInstanceRepository instanceRepository; + private final FlwDefRepository defRepository; + private final FlwTaskRepository taskRepository; + private final FlowEngine flowEngine; ++ private final FlwAdvanceIntentRepository intentRepository; ++ @PersistenceContext private EntityManager entityManager; ++ ++ /** The listener groups the existing totalTasks TASK_CREATED events within their creation transaction. */ ++ @Transactional(propagation = Propagation.MANDATORY) ++ public Long registerTask(FlowEvent event, Long activationIntentId) { ++ String nodeId = (String) event.getData().get("nodeId"); ++ FlwAdvanceIntent intent; ++ if (activationIntentId == null) { ++ FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElseThrow(IllegalStateException::new); ++ intent = new FlwAdvanceIntent(); ++ intent.setInstanceId(instance.getId());intent.setNodeId(nodeId); ++ intent.setActivationTaskId(event.getTaskId()); ++ intent.setActivationDefId(instance.getDefId());intent.setActivationDefVersion(instance.getDefVersion()); ++ intent.setActivationApplyRound(instance.getApplyRound()); ++ intent.setStatus(FlwAdvanceIntent.STATE_DONE); // Registration only; no advance work before handling. ++ intent.setNextRetryAt(LocalDateTime.now()); ++ } else { ++ intent = intentRepository.findById(activationIntentId).orElseThrow(IllegalStateException::new); ++ if (!Objects.equals(intent.getInstanceId(), event.getInstanceId()) || !Objects.equals(intent.getNodeId(), nodeId)) { ++ throw new IllegalStateException("Activation registration identity mismatch"); ++ } ++ } ++ intent.addTask(event.getTaskId()); ++ return intentRepository.save(intent).getId(); ++ } ++ ++ /** TRANSFERRED lacks the parent task ID. Accept only a uniquely proven parent activation, never guess by max ID. */ ++ @Transactional(propagation = Propagation.MANDATORY) ++ public void registerTransfer(FlowEvent event) { ++ String nodeId = (String) event.getData().get("fromNodeId"); ++ List generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId); ++ Set parents = new HashSet<>(); ++ List candidates = taskRepository.findTransferParentCandidates(event.getTaskId(), event.getOperatorId()); ++ for (Long taskId : candidates) { ++ FlwAdvanceIntent parent = memberOf(generations, taskId); ++ if (parent == null) throw new IllegalStateException("D09 transfer parent requires audited activation backfill"); ++ parents.add(parent.getId()); ++ } ++ if (parents.size() != 1) throw new IllegalStateException("D09 ambiguous transfer activation; parent identity is required"); ++ FlwAdvanceIntent parent = lockIntent(parents.iterator().next()); ++ parent.addTask(event.getTaskId()); ++ intentRepository.save(parent); ++ } ++ ++ /** Called synchronously in the task transaction, after claimAndFinish. The head lock orders commits, not clocks/IDs. */ ++ @Transactional(propagation = Propagation.MANDATORY) ++ public void recordHandled(FlowEvent event) { ++ String nodeId = (String) event.getData().get("nodeId"); ++ FlwAdvanceIntent candidate = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId), event.getTaskId()); ++ if (candidate == null) throw new IllegalStateException("D09 task has no proven activation; audited backfill required"); ++ FlwAdvanceIntent intent = lockIntent(candidate.getId()); ++ if (intent.handledTask(event.getTaskId())) return; // Duplicate/late publication cannot rewind the last handler. ++ intent.addHandledTask(event.getTaskId());intent.setTriggerTaskId(event.getTaskId()); ++ intent.setEdgeName((String) event.getData().get("edgeName")); ++ intent.setAssigneeId((Integer) event.getData().get("assigneeId")); ++ intent.setAssigneeName((String) event.getData().get("assigneeName")); ++ intent.setStatus(FlwAdvanceIntent.STATE_PENDING);intent.setAttempts(0); ++ intent.setNextRetryAt(LocalDateTime.now());intent.setLastError(null); ++ intentRepository.save(intent); ++ } ++ ++ public static final class IntentRef { ++ public final Long id; ++ public final Long revision; ++ public IntentRef(Long id, Long revision) { this.id = id; this.revision = revision; } ++ } ++ ++ @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) ++ public IntentRef pendingForTask(Long instanceId, String nodeId, Long taskId) { ++ FlwAdvanceIntent intent = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(instanceId, nodeId), taskId); ++ return intent == null || !FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) ? null ++ : new IntentRef(intent.getId(), intent.getRevision()); ++ } + + /** +- * @param nodeId 待放行节点 +- * @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点 +- * 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查, +- * 事件传参只是快路径,落库的值才是权威) +- * @param operator 本节点办理人(TASK_HANDLED 事件 data 穿透;推进产生的 +- * TASK_CREATED/COMPLETED 操作人信封字段来源),可空 +- * @return 是否实际推进了 ++ * One transaction owns validation, engine effects and settlement. RC sees the last committed task status; ++ * the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes. + */ +- @Transactional(propagation = Propagation.REQUIRES_NEW) +- public boolean tryAdvance(Long instanceId, String nodeId, String edgeName, FlowOperator operator) { +- FlwInstance instance = instanceRepository.findById(instanceId).orElse(null); +- if (instance == null +- || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { ++ @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) ++ public boolean tryAdvance(Long intentId, Long observedRevision) { ++ FlwAdvanceIntent intent = lockIntent(intentId); ++ if (!ownsRevision(intent, observedRevision)) return false; ++ FlwAdvanceIntent latest = intentRepository.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc( ++ intent.getInstanceId(), intent.getNodeId()).orElse(null); ++ if (latest != null && !Objects.equals(latest.getId(), intent.getId())) { ++ done(intent, "D09_STALE_ACTIVATION: successor=" + latest.getId()); ++ handoff(latest.getId()); + return false; + } +- // 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥) +- String current = instance.getCurrentNodes(); +- boolean active = current != null && java.util.Arrays.stream(current.split(",")) +- .anyMatch(nodeId::equals); +- if (!active) { ++ if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId()) ++ || intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) { ++ hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed"); + return false; + } +- long pending = taskRepository.findByInstanceIdAndStatus(instanceId, +- FlwEnums.TaskStatus.PENDING.getCode()).stream() +- .filter(t -> nodeId.equals(t.getNodeId())) +- .count(); +- if (pending > 0) { +- return false; ++ FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null); ++ if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode() ++ || !active(instance, intent.getNodeId())) { ++ done(intent, null);return false; + } +- FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); +- if (def == null) { +- log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}", +- instanceId, instance.getDefId()); +- return false; ++ if (!Objects.equals(instance.getDefId(), intent.getActivationDefId()) ++ || !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion()) ++ || !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) { ++ hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false; + } +- String chosen = edgeName; +- if (chosen == null) { +- chosen = taskRepository.findByInstanceIdAndStatus(instanceId, +- FlwEnums.TaskStatus.APPROVED.getCode()).stream() +- .filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null) +- .max(java.util.Comparator.comparing( +- FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))) +- .map(FlwTask::getEdgeName) +- .orElse(null); ++ boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()) ++ .stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId())); ++ if (pending) { ++ // A future handler holds/obtains this same head lock and must re-arm it in its task transaction. ++ done(intent, null);return false; + } +- flowEngine.continueAdvance(instance, def, nodeId, chosen, operator); +- instanceRepository.save(instance); ++ FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null); ++ if (trigger == null || !intent.containsTask(trigger.getId()) ++ || !Objects.equals(trigger.getInstanceId(), instance.getId()) ++ || !Objects.equals(trigger.getNodeId(), intent.getNodeId()) ++ || !Integer.valueOf(FlwEnums.TaskStatus.APPROVED.getCode()).equals(trigger.getStatus())) { ++ hold(intent, "D09_INVALID_LAST_HANDLER: not executed");return false; ++ } ++ FlwDef def = defRepository.findById(instance.getDefId()).orElseThrow(() -> new IllegalStateException("Missing activation definition")); ++ flowEngine.continueAdvance(instance, def, intent.getNodeId(), trigger.getEdgeName(), ++ FlowOperator.of(trigger.getAssigneeId(), trigger.getAssigneeName())); ++ if (active(instance, intent.getNodeId())) throw new IllegalStateException("Activation did not advance; retaining recoverable intent"); ++ instanceRepository.saveAndFlush(instance); // An optimistic conflict rolls back engine writes AND settlement. ++ done(intent, null); + return true; + } ++ ++ /** Never merge a worker's detached row. A refreshed revision owns its own retry budget. */ ++ @Transactional(propagation = Propagation.REQUIRES_NEW) ++ public void defer(Long intentId, Long observedRevision, String error) { ++ FlwAdvanceIntent intent = lockIntent(intentId); ++ if (!ownsRevision(intent, observedRevision)) return; ++ intent.setAttempts(intent.getAttempts() + 1);intent.setLastError(truncate(error)); ++ if (intent.getAttempts() >= 6) intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED); ++ else intent.setNextRetryAt(LocalDateTime.now().plusSeconds(Math.min(1800, 30L * (1L << (intent.getAttempts() - 1))))); ++ intentRepository.save(intent); ++ } ++ ++ private FlwAdvanceIntent lockIntent(Long id) { ++ // Queries in a handling transaction may already have loaded a stale RR snapshot into the identity map. ++ // A lock upgrade on that managed row checks its obsolete @Version before refresh can run. ++ // Flush our own changes, detach ONLY this row, then load a new managed row with a locking current read. ++ entityManager.flush(); ++ FlwAdvanceIntent cached = entityManager.find(FlwAdvanceIntent.class, id); ++ if (cached != null) entityManager.detach(cached); ++ return intentRepository.findByIdForUpdate(id).orElse(null); ++ } ++ ++ private void handoff(Long successorId) { ++ FlwAdvanceIntent successor = lockIntent(successorId); ++ // A stale worker must not consume its successor. Re-arm a previously waiting head, retaining its own payload. ++ if (successor != null && successor.getTriggerTaskId() != null && FlwAdvanceIntent.STATE_DONE.equals(successor.getStatus())) { ++ successor.setStatus(FlwAdvanceIntent.STATE_PENDING);successor.setAttempts(0); ++ successor.setNextRetryAt(LocalDateTime.now());successor.setLastError(null);intentRepository.save(successor); ++ } ++ } ++ ++ private static FlwAdvanceIntent memberOf(List intents, Long taskId) { ++ FlwAdvanceIntent found = null; ++ for (FlwAdvanceIntent intent : intents) if (intent.containsTask(taskId)) { ++ if (found != null && !Objects.equals(found.getId(), intent.getId())) throw new IllegalStateException("Task belongs to multiple activations"); ++ found = intent; ++ } ++ return found; ++ } ++ private static boolean ownsRevision(FlwAdvanceIntent intent, Long revision) { ++ return intent != null && FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) && Objects.equals(intent.getRevision(), revision); ++ } ++ private static boolean active(FlwInstance instance, String nodeId) { ++ return instance.getCurrentNodes() != null && Arrays.asList(instance.getCurrentNodes().split(",")).contains(nodeId); ++ } ++ private void done(FlwAdvanceIntent intent, String reason) { ++ intent.setStatus(FlwAdvanceIntent.STATE_DONE);intent.setLastError(reason);intentRepository.save(intent); ++ } ++ private void hold(FlwAdvanceIntent intent, String reason) { ++ intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);intent.setLastError(reason);intentRepository.save(intent); ++ } ++ private static String truncate(String s) { return s == null || s.length() <= 255 ? s : s.substring(0,255); } + } +diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +index db0076c..64c2f67 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +@@ -56,6 +56,18 @@ public class FlowDefModel { + public List nodes; + public List edges; + public Settings settings; ++ /** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */ ++ public List variables; ++ ++ @JsonIgnoreProperties(ignoreUnknown = true) ++ public static class Variable { ++ public String key; ++ /** string/number/boolean/date/datetime/money/stringArray/fileArray/userArray */ ++ public String type; ++ public Boolean required; ++ @com.fasterxml.jackson.annotation.JsonProperty("enum") ++ public List enumValues; ++ } + /** + * 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。 + * 缺失时前端自行做自动布局,故可选、可空。 +diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +index 6c9bb7f..d1b7543 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +@@ -45,6 +45,9 @@ public class FlowEngine { + /** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */ + public void startAdvance(FlwInstance instance, FlwDef def) { + FlowDefModel model = parseModel(def); ++ // D11:整份定义的固定 USERS 先整体预检,非法人不得等到后续节点才暴露(会留下卡单)。 ++ // 校验失败抛业务异常,由发起事务整体回滚(零实例零任务)。 ++ assigneeResolver.requireEligibleFixedAssignees(instance.getSchId(), model); + setCurrentNodes(instance, null); + advance(instance, model, model.start, null, null); + } +diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +index f500a59..84b7efc 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +@@ -69,6 +69,55 @@ public class FlowEvent { + private final String operatorName; + /** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */ + private final Map data; ++ private Map roundIdentity; ++ ++ // ===== T05 稳定事件身份(2026-10-25) ===== ++ /** 事实行 event_id:发布线程同步监听器在状态事务内落事实行时一次性赋值,全订阅方/重试复用 */ ++ private String stableEventId; ++ /** 事实行 event_seq(实例内单调序号) */ ++ private Integer stableEventSeq; ++ /** ++ * R2(Codex D04)冻结信封:事实落库时一次性构建的完整投递载荷 JSON(含 eventId/eventSeq/ ++ * flowGuid 等全部信封字段)。正常分发与崩溃恢复分发共用同一份冻结体——恢复消息体不再缺 ++ * eventId/eventSeq,两条路径字节级同构。事实行缺席(存量路径)为 null,由分发器现场构建。 ++ */ ++ private String stablePayloadJson; ++ ++ public void assignStableIdentity(String eventId, Integer eventSeq) { ++ if (this.stableEventId != null) { ++ return; // 只赋一次:幂等重入(同事件多监听器)不换身份 ++ } ++ this.stableEventId = eventId; ++ this.stableEventSeq = eventSeq; ++ } ++ ++ public String getStableEventId() { ++ return stableEventId; ++ } ++ ++ public Integer getStableEventSeq() { ++ return stableEventSeq; ++ } ++ ++ /** 随稳定身份一并冻结完整投递信封(只赋一次;身份与信封同源同事务) */ ++ public void assignStablePayload(String payloadJson) { ++ if (this.stablePayloadJson != null) { ++ return; ++ } ++ this.stablePayloadJson = payloadJson; ++ } ++ ++ public String getStablePayloadJson() { ++ return stablePayloadJson; ++ } ++ ++ public void captureRoundIdentity(Integer round, String requestToken) { ++ if (roundIdentity != null) return; ++ Map identity = new HashMap<>(); ++ identity.put("applyRound", round); ++ identity.put("requestToken", requestToken); ++ roundIdentity = java.util.Collections.unmodifiableMap(identity); ++ } + + public static FlowEvent of(Type type, Long instanceId) { + return new FlowEvent(type, instanceId, null, null, null, new HashMap<>()); +diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java +index 1ac00c3..d0e8a93 100644 +--- a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java ++++ b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java +@@ -1,50 +1,102 @@ + package com.yunhai.flowcenter.engine; + ++import com.yunhai.flowcenter.entity.FlwAdvanceIntent; ++import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; ++import org.springframework.context.event.EventListener; ++import org.springframework.core.Ordered; ++import org.springframework.core.annotation.Order; + import org.springframework.orm.ObjectOptimisticLockingFailureException; ++import org.springframework.scheduling.annotation.Scheduled; + import org.springframework.stereotype.Component; + import org.springframework.transaction.event.TransactionPhase; + import org.springframework.transaction.event.TransactionalEventListener; ++import org.springframework.transaction.support.TransactionSynchronization; ++import org.springframework.transaction.support.TransactionSynchronizationManager; + +-/** +- * 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查—— +- * 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。 +- * 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断 +- * "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底 +- * (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。 +- */ ++import java.time.LocalDateTime; ++import java.util.HashMap; ++import java.util.Map; ++ ++/** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */ + @Slf4j + @Component + @RequiredArgsConstructor + public class FlowNodeAdvanceListener { +- + private final FlowAdvanceService advanceService; ++ private final FlwAdvanceIntentRepository intentRepository; ++ ++ @EventListener ++ @Order(Ordered.HIGHEST_PRECEDENCE + 2) ++ public void persistIntent(FlowEvent event) { ++ if (event.getData() == null) return; ++ if (event.getType() == FlowEvent.Type.TASK_CREATED) { ++ ActivationBatch batch = batch(); ++ if (!batch.tasks.add(event.getTaskId())) return; ++ String key = event.getInstanceId() + ":" + event.getData().get("nodeId"); ++ int total = ((Number) event.getData().get("totalTasks")).intValue(); ++ if (total <= 0) throw new IllegalStateException("Missing activation batch size"); ++ Registration registration = batch.nodes.get(key); ++ if (registration == null) { registration = new Registration(total);batch.nodes.put(key, registration); } ++ if (registration.total != total) throw new IllegalStateException("Activation batch size changed"); ++ registration.intentId = advanceService.registerTask(event, registration.intentId); ++ if (--registration.remaining == 0) batch.nodes.remove(key); ++ } else if (event.getType() == FlowEvent.Type.TRANSFERRED) { ++ advanceService.registerTransfer(event); ++ } else if (event.getType() == FlowEvent.Type.TASK_HANDLED && event.getData().get("nodeId") != null) { ++ advanceService.recordHandled(event); ++ } ++ } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onTaskHandled(FlowEvent event) { +- if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) { +- return; +- } ++ if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return; + String nodeId = (String) event.getData().get("nodeId"); +- if (nodeId == null) { +- return; +- } +- String edgeName = (String) event.getData().get("edgeName"); +- // 办理人随事件 data 穿透进推进链:新 TASK_CREATED/COMPLETED 的操作人信封字段(2026-09-20) +- FlowOperator operator = FlowOperator.of( +- (Integer) event.getData().get("assigneeId"), +- (String) event.getData().get("assigneeName")); ++ if (nodeId == null) return; ++ FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId()); ++ if (ref == null) return; + for (int attempt = 1; attempt <= 3; attempt++) { +- try { +- advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName, operator); +- return; +- } catch (ObjectOptimisticLockingFailureException e) { +- log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}", +- attempt, event.getInstanceId(), nodeId); ++ try { advanceService.tryAdvance(ref.id, ref.revision);return; } ++ catch (ObjectOptimisticLockingFailureException conflict) { ++ log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision); ++ } catch (Exception failure) { ++ advanceService.defer(ref.id, ref.revision, failure.toString());return; + } + } +- log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}", +- event.getInstanceId(), nodeId); ++ advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume"); ++ } ++ ++ @Scheduled(fixedDelay = 30_000) ++ public void sweepIntents() { ++ for (FlwAdvanceIntent detached : intentRepository.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc( ++ FlwAdvanceIntent.STATE_PENDING, LocalDateTime.now())) { ++ Long id = detached.getId(), revision = detached.getRevision(); ++ try { advanceService.tryAdvance(id, revision); } ++ catch (Exception failure) { advanceService.defer(id, revision, failure.toString()); } ++ } ++ } ++ ++ /** Synchronizations are suspended with REQUIRES_NEW; an outer activation batch cannot leak into its child. */ ++ private static ActivationBatch batch() { ++ if (!TransactionSynchronizationManager.isSynchronizationActive()) throw new IllegalStateException("Activation requires its creation transaction"); ++ for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) { ++ if (sync instanceof ActivationBatch) return (ActivationBatch) sync; ++ } ++ ActivationBatch created = new ActivationBatch(); ++ TransactionSynchronizationManager.registerSynchronization(created);return created; ++ } ++ private static final class ActivationBatch implements TransactionSynchronization { ++ final Map nodes = new HashMap<>(); ++ final java.util.Set tasks = new java.util.HashSet<>(); ++ @Override public void beforeCommit(boolean readOnly) { ++ if (!nodes.isEmpty()) throw new IllegalStateException("Incomplete activation membership; creation transaction must roll back"); ++ } ++ } ++ private static final class Registration { ++ final int total; ++ int remaining; ++ Long intentId; ++ Registration(int total) { this.total = total;this.remaining = total; } + } + } +diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java +index a2cf36c..85d38c0 100644 +--- a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java ++++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java +@@ -12,14 +12,16 @@ import javax.persistence.UniqueConstraint; + import java.time.LocalDateTime; + + /** +- * 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重); ++ * 回调推送日志 = 重试队列(概览 §4.9;2026-10-25 R2 幂等键改复合):eventId 全订阅方复用 ++ * (重试不变,业务方按它幂等去重),(event_id, reg_id) 复合唯一——同一事件对同一订阅方只建 ++ * 一行,多订阅方各一行;并发 exists→insert 竞态由复合 uk 兜底(DuplicateKeyException=已建行)。 + * payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。 + */ + @Getter + @Setter + @Entity + @Table(name = "flw_callback_log", +- uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"), ++ uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event_reg", columnNames = {"event_id", "reg_id"}), + indexes = { + @Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"), + @Index(name = "idx_flw_log_inst", columnList = "instance_id")}) +diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java +index 5e04140..7f83a56 100644 +--- a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java ++++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java +@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint; + columnNames = {"sch_id", "app_key", "flow_guid"}), + indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status")) + public class FlwCallbackReg extends BaseEntity { ++ @Column(name="source_principal_id",length=36) ++ private String sourcePrincipalId; ++ + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) +diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java +index edc7724..9204b9a 100644 +--- a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java ++++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java +@@ -74,6 +74,15 @@ public class FlwDef extends BaseEntity { + @Column(name = "form_version") + private Integer formVersion; + ++ /** ++ * 线级停发标志(2026-10-25 T03):1=该 flowGuid 整线停发——阻止新发起/预览,不影响在途实例 ++ * (在途按实例固定 defId 原版推进)。与版本级 status(DISABLED) 正交:线级停发后 enable() ++ * 恢复的只是版本状态,线仍不可新发起;enableLine 才恢复线可用性,且不改写任何发布内容字节。 ++ * 批量 UPDATE 全版本行同值,读任一行即读线状态。 ++ */ ++ @Column(name = "line_offline", nullable = false, columnDefinition = "INT NOT NULL DEFAULT 0") ++ private Integer lineOffline = 0; ++ + /** §5.1 流程定义 JSON */ + @Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT") + private String contentJson; +diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +index 42be69b..b8e8143 100644 +--- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java ++++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +@@ -14,8 +14,9 @@ import java.time.LocalDateTime; + + /** + * 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存 +- * biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等—— +- * 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。 ++ * biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为 ++ * (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。 ++ * sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。 + * + *

version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件), + * 由 JPA @Version 在 flush 时自增。 +@@ -24,12 +25,19 @@ import java.time.LocalDateTime; + @Setter + @Entity + @Table(name = "flw_instance", +- uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz", +- columnNames = {"sch_id", "biz_type", "biz_id"}), ++ uniqueConstraints = { ++ @UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "biz_type", "biz_id", "legacy_slot"}), ++ @UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})}, + indexes = { + @Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"), + @Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")}) + public class FlwInstance extends BaseEntity { ++ @Column(name="source_principal_id",length=36,updatable=false) ++ private String sourcePrincipalId; ++ /** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */ ++ @Column(name="legacy_slot") ++ private Integer legacySlot = 1; ++ + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) +@@ -141,7 +149,7 @@ public class FlwInstance extends BaseEntity { + private String applyRequestToken; + + /** +- * 当前发起轮次(1 起;随新一轮请求标识进位)。 ++ * 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。 + * + *

轮次守卫是单调的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投, + * 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。

+@@ -149,6 +157,22 @@ public class FlwInstance extends BaseEntity { + @Column(name = "apply_round") + private Integer applyRound; + ++ /** ++ * 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹—— ++ * 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。 ++ * 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。 ++ */ ++ @Column(name = "apply_payload_hash", length = 64) ++ private String applyPayloadHash; ++ ++ /** ++ * 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内 ++ * 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会 ++ * 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。 ++ */ ++ @Column(name = "event_seq_counter") ++ private Integer eventSeqCounter; ++ + /** + * 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。 + * 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充, +diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java +index 946caf3..d31fc8e 100644 +--- a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java ++++ b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java +@@ -39,6 +39,11 @@ public final class FlowApplyRetryModels { + * 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。 + */ + private Boolean retryOnly; ++ /** ++ * 期望的前一轮次(T04 服务端轮次,可选):显式重提必须声明基于第 N 轮; ++ * 服务端在坐标锁内核对并只分配 N+1,不信客户端 attempt。null=既有语义。 ++ */ ++ private Integer expectedPrevRound; + } + + /** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */ +@@ -60,6 +65,9 @@ public final class FlowApplyRetryModels { + private String branch; + /** 行上的当前轮次(消费方据此校正自己的命令表认知) */ + private Integer applyRound; ++ /** 同一响应的真实状态/实例版本;分支判据保持不变。 */ ++ private Integer status; ++ private Integer version; + } + + /** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */ +@@ -71,6 +79,11 @@ public final class FlowApplyRetryModels { + public static final String REPLAY_ONLY = "REPLAY_ONLY"; + public static final String RESTARTED = "RESTARTED"; + public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED"; ++ /** ++ * 冲突(2026-10-25 T04):同 token 异载荷、或带 token 撞上他人/其他轮次的在途与通过件—— ++ * 调用方(OapiFlowService)把该分支翻译成业务异常,不伪装幂等成功。 ++ */ ++ public static final String CONFLICT = "CONFLICT"; + + private Branch() { + } +diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +index 4760a6c..611203f 100644 +--- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java ++++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +@@ -107,6 +107,13 @@ public final class OapiModels { + @Data + public static class InstanceVo { + private Long instanceId; ++ private Integer applyRound; ++ private String requestToken; ++ /** ++ * 来源应用快照(2026-10-25 T04):UNKNOWN 恢复链按"稳定来源+requestToken"核验命令归属的 ++ * 依据——查得实例但 applyAppKey 与本应用不符=他人办件,不得认领(P0-4 关闭点)。 ++ */ ++ private String applyAppKey; + private Integer status; + private Integer version; + private String nodeSummary; +diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java +index 03839d6..ee0a176 100644 +--- a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java ++++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java +@@ -13,6 +13,9 @@ import java.util.List; + + public interface FlwCallbackLogRepository extends JpaRepository { + ++ /** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */ ++ boolean existsByEventIdAndRegId(String eventId, Long regId); ++ + /** 重推扫描主路径:待推/重试中且到点 */ + List findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc( + Integer status, LocalDateTime now); +diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java +index 72c2488..cf8552a 100644 +--- a/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java ++++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java +@@ -14,7 +14,7 @@ public interface FlwCcRepository extends JpaRepository { + /** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */ + @Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId " + + "where c.schId = :schId and c.userId = :userId " +- + "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc") ++ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc") + Page pageCc(@Param("schId") Long schId, @Param("userId") Integer userId, + @Param("appKey") String appKey, Pageable pageable); + +diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java +index 83f11b7..c582182 100644 +--- a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java ++++ b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java +@@ -32,12 +32,22 @@ public interface FlwDefRepository extends JpaRepository { + + List findBySchIdOrderByUpdateTimeDesc(Long schId); + +- /** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */ +- @Query("select d from FlwDef d where d.schId = :schId and d.status = 1 " ++ /** ++ * 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。 ++ * 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源, ++ * 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。 ++ */ ++ @Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 " + + "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId " + + "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc") + List findPublishedLatestPerGuid(@Param("schId") Long schId); + ++ /** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */ ++ @org.springframework.data.jpa.repository.Modifying ++ @Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid") ++ int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid, ++ @Param("offline") Integer offline); ++ + /** 在途统计(议题二④):一次取回多条流程线的全部版本行 */ + List findBySchIdAndFlowGuidIn(Long schId, Collection flowGuids); + } +diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +index 1cc57d6..05e6cd0 100644 +--- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java ++++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +@@ -12,8 +12,35 @@ import java.util.List; + import java.util.Optional; + + public interface FlwInstanceRepository extends JpaRepository { ++ @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) ++ @Query("select i from FlwInstance i where i.id=:id") ++ Optional findFormalPreviousForUpdate(@Param("id") Long id); ++ ++ ++ /** ++ * T05 事件序号发号(2026-10-25 FlowEngineIT 会签并发实证三轮修正,终形态): ++ * ①FOR UPDATE 标量当前读发号(取行 X 锁,任何隔离级别都看到已提交最新值)→ ++ * ②按读到的值回写计数器(值已在我锁内计算,无竞态)→③事实行按该值落库。 ++ * 纯快照读 max+1 会撞 uk;实体 merge 会触发 @Version 冲突;自增 UPDATE 后普通 SELECT ++ * 的读回在并发窗口同样不可靠——FOR UPDATE 标量读是唯一两全形态。 ++ */ ++ @org.springframework.data.jpa.repository.Query( ++ value = "select coalesce(event_seq_counter, 0) + 1 from flw_instance where id = :id for update", ++ nativeQuery = true) ++ Integer nextEventSeqForUpdate(@Param("id") Long id); ++ ++ @org.springframework.data.jpa.repository.Modifying ++ @org.springframework.data.jpa.repository.Query( ++ value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true) ++ int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v); ++ ++ Optional findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz); ++ ++ @Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc") ++ Page pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable); + + /** biz uk 幂等主键 */ ++ @Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null") + Optional findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); + + List findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status); +diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java +index dc21dfc..f196414 100644 +--- a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java ++++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java +@@ -13,6 +13,12 @@ import java.util.List; + + public interface FlwTaskRepository extends JpaRepository { + ++ /** Additive D09 query: the real transfer parent is in this set. Ambiguous generations must be rejected. */ ++ @Query(value="select t.id from flw_task t join flw_task n on n.id=:newTaskId " ++ + "where t.instance_id=n.instance_id and t.node_id=n.node_id and t.status=5 " ++ + "and t.assignee_id=:operatorId and t.handle_time=n.arrive_time", nativeQuery=true) ++ List findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId); ++ + List findByInstanceIdOrderByIdAsc(Long instanceId); + + List findByInstanceIdAndStatus(Long instanceId, Integer status); +@@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository { + @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId") + int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId); + ++ @Query("select t from FlwTask t join FlwInstance i on i.id=t.instanceId where t.schId=:school and i.sourcePrincipalId=:source and t.assigneeId=:user and t.status=0 and (:type is null or i.bizType=:type) order by t.id asc") ++ Page pageSelfTodo(@Param("school") Long school,@Param("source") String source,@Param("user") Integer user,@Param("type") String type, Pageable pageable); ++ + /** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */ + @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId " + + "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 " +- + "and (:appKey is null or d.appKey = :appKey) " ++ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) " + + "order by t.arriveTime asc, t.id asc") + Page pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, + @Param("appKey") String appKey, Pageable pageable); +@@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository { + @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId " + + "where t.schId = :schId and t.assigneeId = :assigneeId " + + "and t.status in :statuses " +- + "and (:appKey is null or d.appKey = :appKey) " ++ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) " + + "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId " + + " and t2.assigneeId = :assigneeId and t2.status in :statuses) " + + "order by t.handleTime desc, t.id desc") +diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java +index e8d717d..1725612 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java +@@ -33,6 +33,7 @@ public class FlowCallbackAdminService { + private final FlwCallbackLogRepository logRepository; + private final CallbackPushService pushService; + private final CallbackProperties callbackProperties; ++ private final com.yunhai.flowcenter.callback.CallbackLogStore logStore; + + public List listRegs(Long schId) { + return regRepository.findBySchIdOrderByUpdateTimeDesc(schId); +@@ -104,7 +105,6 @@ public class FlowCallbackAdminService { + * 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。 + * 标记后可经既有 rePush 复活。 + */ +- @Transactional + public void markDead(Long schId, Long logId) { + FlwCallbackLog callbackLog = logRepository.findById(logId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId)); +@@ -119,7 +119,6 @@ public class FlowCallbackAdminService { + } + + /** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */ +- @Transactional + public void rePush(Long schId, List logIds) { + if (logIds == null || logIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志"); +@@ -128,20 +127,9 @@ public class FlowCallbackAdminService { + if (logs.size() != logIds.size()) { + throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志"); + } +- for (FlwCallbackLog callbackLog : logs) { +- if (callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) { +- continue; +- } +- callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); +- callbackLog.setRetryCount(0); +- callbackLog.setNextRetryTime(LocalDateTime.now()); +- logRepository.save(callbackLog); +- } +- logRepository.flush(); +- for (FlwCallbackLog callbackLog : logs) { +- if (callbackLog.getStatus() != null) { +- pushService.pushAsync(callbackLog.getId()); +- } ++ // 短事务提交并释放会话锁后才执行 HTTP;不能把管理端旧实体全量回写覆盖刚成功的状态。 ++ for (Long id : logStore.resetForReplay(schId, logIds)) { ++ pushService.pushAsync(id); + } + } + +diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +index 001e9d1..da109e3 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +@@ -3,10 +3,12 @@ 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; +@@ -192,12 +194,13 @@ public class FlowDefAdminService { + boolean pairing = formUuid != null && !clearing && formVersion == null; + boolean versionOnly = formUuid == null && formVersion != null; + if (pairing || versionOnly) { +- throw new BizException(ErrorCode.PARAM_ERROR, ++ 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. ++ checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save. + } + FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog); + if (formUuid != null) { +@@ -216,7 +219,7 @@ public class FlowDefAdminService { + * 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照 + * 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验), + * 并做表达式键查①红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); +- * 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */ ++ * 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */ + @Transactional + public FlwDef publish(Long schId, Long id) { + FlwDef def = requireDef(schId, id); +@@ -227,86 +230,33 @@ public class FlowDefAdminService { + 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()) { +- Map snapshot; +- try { +- // 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。 +- snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion()); +- } catch (FormCenterSchemaClient.SchemaUnavailableException e) { +- throw new BizException(ErrorCode.PARAM_ERROR, +- "关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage()); +- } catch (FormCenterSchemaClient.SchemaContractViolationException e) { +- throw new BizException(ErrorCode.PARAM_ERROR, +- "关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage()); +- } +- List problems = new ArrayList<>(); +- validateExpressionKeysAgainstSnapshot(model, snapshot, problems); +- validateExpressionTypesAndDomains(model, snapshot, problems); +- if (!problems.isEmpty()) { +- throw new BizException(ErrorCode.PARAM_ERROR, +- "发布校验失败(表达式三查):\n" + String.join("\n", problems)); +- } +- def.setContentJson(embedFormContract(def.getContentJson(), snapshot)); ++ Map 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); + } + +- /** +- * 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式, +- * 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。 +- * 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。 +- */ +- private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map snapshot, +- List problems) { +- @SuppressWarnings("unchecked") +- List> fields = (List>) snapshot.get("fields"); +- java.util.Set fieldKeys = new java.util.HashSet<>(); +- if (fields != null) { +- for (Map f : fields) { +- Object k = f.get("key"); +- if (k != null) { +- fieldKeys.add(String.valueOf(k)); +- } +- } +- } +- if (fieldKeys.isEmpty()) { +- problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布"); +- return; +- } +- for (FlowDefModel.Node n : model.nodes) { +- if (n == null || !n.expressionJudge()) { +- continue; +- } +- for (FlowDefModel.Edge e : model.outEdges(n.id)) { +- if (e.condition == null || e.condition.trim().isEmpty()) { +- continue; +- } +- List vars; +- try { +- vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim()); +- } catch (Exception ex) { +- // 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过 +- continue; +- } +- for (String v : vars) { +- if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) { +- problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v +- + " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)"); +- } +- } +- } +- } +- } +- + /** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */ +- private String embedFormContract(String contentJson, Map snapshot) { ++ private String embedFormContract(String contentJson, Map snapshot, ++ List 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; +@@ -315,107 +265,44 @@ public class FlowDefAdminService { + } + } + +- // ===================== 三查②③(2026-10-20 C1 第一刀余量项) ===================== +- +- /** 数值桶:可与数值字面量比大小的快照字段类型 */ +- private static final java.util.Set NUMERIC_TYPES = +- new java.util.HashSet<>(java.util.Arrays.asList("number")); +- /** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */ +- private static final java.util.Set ENUM_TYPES = +- new java.util.HashSet<>(java.util.Arrays.asList("select", "radio")); +- +- /** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */ +- private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile( +- "([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'"); +- private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile( +- "([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)"); +- private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile( +- "(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)"); ++ /** 停用:不可新发起,存量跑完 */ ++ @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); ++ } + + /** +- * 类型桶查②红牌 + 域查③红牌(启发式实现:正则模式匹配字面量比较形态,非 AST—— +- * v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与 +- * "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。 ++ * 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。 ++ * 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。 ++ * 不改写任何发布内容字节,恢复走 {@link #enableLine}。 + */ +- @SuppressWarnings("unchecked") +- private void validateExpressionTypesAndDomains(FlowDefModel model, Map snapshot, +- List problems) { +- List> fields = (List>) snapshot.get("fields"); +- if (fields == null || fields.isEmpty()) { +- return; +- } +- Map> fieldByKey = new HashMap<>(); +- for (Map f : fields) { +- Object k = f.get("key"); +- if (k != null) { +- fieldByKey.put(String.valueOf(k), f); +- } +- } +- for (FlowDefModel.Node n : model.nodes) { +- if (n == null || !n.expressionJudge()) { +- continue; +- } +- for (FlowDefModel.Edge e : model.outEdges(n.id)) { +- if (e.condition == null || e.condition.trim().isEmpty()) { +- continue; +- } +- String where = "节点「" + n.name + "」出口「" + e.name + "」"; +- String expr = e.condition.trim(); +- // ② 类型桶查:数值比较算子作用于非数值字段 → 红牌 +- java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr); +- while (m.find()) { +- checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems); +- } +- m = NUM_FIELD_CMP.matcher(expr); +- while (m.find()) { +- checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems); +- } +- // ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌 +- // ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌 +- m = FIELD_STR_EQ.matcher(expr); +- while (m.find()) { +- String key = m.group(1); +- String op = m.group(2); +- String literal = m.group(3); +- Map field = fieldByKey.get(key); +- if (field == null) { +- continue; // 内置变量或未知键:①已拦/不属本查 +- } +- String type = field.get("type") == null ? null : String.valueOf(field.get("type")); +- if (NUMERIC_TYPES.contains(type)) { +- problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '" +- + literal + "' 做 " + op + " 比较(类型桶查②)"); +- } else if (ENUM_TYPES.contains(type) && "==".equals(op)) { +- List enums = (List) field.get("enum"); +- if (enums != null && !enums.isEmpty() && !enums.contains(literal)) { +- problems.add(where + ":字段 " + key + " 的值域是 " + enums +- + ",字面量 '" + literal + "' 不在其中(域查③)"); +- } +- } +- } +- } +- } ++ @Transactional ++ public int offlineLine(Long schId, String flowGuid) { ++ requireLine(schId, flowGuid); ++ return defRepository.updateLineOffline(schId, flowGuid.trim(), 1); + } + +- private void checkNumericBucket(String where, String key, String op, +- Map> fieldByKey, List problems) { +- Map field = fieldByKey.get(key); +- if (field == null) { +- return; +- } +- String type = field.get("type") == null ? null : String.valueOf(field.get("type")); +- if (type != null && !NUMERIC_TYPES.contains(type)) { +- problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 " +- + key + " " + op + " 数字(类型桶查②)"); +- } ++ /** ++ * 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针 ++ * 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。 ++ */ ++ @Transactional ++ public int enableLine(Long schId, String flowGuid) { ++ requireLine(schId, flowGuid); ++ return defRepository.updateLineOffline(schId, flowGuid.trim(), 0); + } + +- /** 停用:不可新发起,存量跑完 */ +- @Transactional +- public void offline(Long schId, Long id) { +- FlwDef def = requireDef(schId, id); +- def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); +- defRepository.save(def); ++ 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); ++ } + } + + /** +@@ -435,22 +322,38 @@ public class FlowDefAdminService { + 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()); ++ 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 checkedSchema(Long schId, String uuid, Integer version) { +- if (version == null || version < 1) { +- throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择"); ++ private Map 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 +- | FormCenterSchemaClient.SchemaContractViolationException e) { +- throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage()); ++ } 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); + } + } + +@@ -509,7 +412,12 @@ public class FlowDefAdminService { + } + def.setName(cleanName); + def.setAppKey(cleanAppKey); +- def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName)); ++ // 发布封存(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) { +diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +index 8f8cdcc..0f54436 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +@@ -24,7 +24,8 @@ import java.util.HashMap; + import java.util.Map; + + /** +- * 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。 ++ * 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例, ++ * 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。 + * + *

R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿):biz uk 命中已终态办件 + * (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前 +@@ -63,6 +64,13 @@ public class FlowInstanceService { + public final String applyAppCode; + /** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */ + public final String assignJson; ++ ++ /** ++ * 受理载荷指纹(2026-10-25 T04,SHA-256 hex;非构造参数——13 参构造被 ++ * {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值; ++ * null=存量调用方,指纹核对缺省关)。 ++ */ ++ public String payloadHash; + } + + /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ +@@ -138,6 +146,10 @@ public class FlowInstanceService { + if (round != null) { + instance.setApplyRound(round); + } ++ if (cmd.payloadHash != null) { ++ // 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷) ++ instance.setApplyPayloadHash(cmd.payloadHash); ++ } + instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); + instance.setSubmitTime(LocalDateTime.now()); + instance.setFinishTime(null); +@@ -297,6 +309,16 @@ public class FlowInstanceService { + public final String requestToken; + public final Integer attempt; + public final boolean retryOnly; ++ /** ++ * 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。 ++ * 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1, ++ * 不信客户端 attempt。null=既有客户端 attempt 语义(存量兼容)。 ++ */ ++ public final Integer expectedPrevRound; ++ ++ public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) { ++ this(requestToken, attempt, retryOnly, null); ++ } + } + + /** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */ +@@ -345,6 +367,15 @@ public class FlowInstanceService { + // 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。 + int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound(); + if (token != null && token.equals(existing.getApplyRequestToken())) { ++ // T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token, ++ // 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。 ++ if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null ++ && !existing.getApplyPayloadHash().equals(cmd.payloadHash)) { ++ log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}", ++ existing.getId(), cmd.bizType, cmd.bizId); ++ return new GuardedOutcome(existing.getId(), true, ++ FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound()); ++ } + log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}", + existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus()); + return new GuardedOutcome(existing.getId(), true, +@@ -353,6 +384,15 @@ public class FlowInstanceService { + int status = existing.getStatus(); + if (status == FlwEnums.InstanceStatus.APPROVING.getCode() + || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { ++ // T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2: ++ // "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的 ++ // 存量调用方保持 IDEMPOTENT(守卫缺省关)。 ++ if (token != null) { ++ log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}", ++ existing.getId(), cmd.bizType, cmd.bizId); ++ return new GuardedOutcome(existing.getId(), true, ++ FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound()); ++ } + return new GuardedOutcome(existing.getId(), true, + FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound()); + } +@@ -363,21 +403,52 @@ public class FlowInstanceService { + return new GuardedOutcome(existing.getId(), true, + FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound()); + } +- int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt; +- if (incomingRound <= currentRound) { +- log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}", +- existing.getId(), currentRound, incomingRound); +- return new GuardedOutcome(existing.getId(), true, +- FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound()); ++ // T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1; ++ // 未显式时保持客户端 attempt 语义(存量兼容)。 ++ Integer expectedPrev = policy == null ? null : policy.expectedPrevRound; ++ int incomingRound; ++ if (expectedPrev != null) { ++ if (expectedPrev != currentRound) { ++ log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}", ++ existing.getId(), currentRound, expectedPrev); ++ return new GuardedOutcome(existing.getId(), true, ++ FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound()); ++ } ++ incomingRound = currentRound + 1; ++ } else { ++ incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt; ++ if (incomingRound <= currentRound) { ++ log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}", ++ existing.getId(), currentRound, incomingRound); ++ return new GuardedOutcome(existing.getId(), true, ++ FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound()); ++ } + } + restart(existing, cmd, token, incomingRound); + return new GuardedOutcome(existing.getId(), true, + FlowApplyRetryModels.Branch.RESTARTED, incomingRound); + } + +- /** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */ ++ /** Formal command caller must hold the coordinate lock in the same transaction. */ ++ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) ++ public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) { ++ return createInstance(cmd, token, round, source); ++ } ++ ++ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) ++ public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) { ++ flowEngine.startAdvance(instance, cmd.def); ++ publishSubmitted(instance); ++ } ++ + private void createInstance(ApplyCommand cmd, String token, Integer round) { ++ createInstance(cmd, token, round, null); ++ } ++ ++ private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) { + FlwInstance instance = new FlwInstance(); ++ instance.setSourcePrincipalId(source); ++ instance.setLegacySlot(source == null ? 1 : null); + instance.setSchId(cmd.schId); + instance.setDefId(cmd.def.getId()); + instance.setDefCode(cmd.def.getCode()); +@@ -393,6 +464,7 @@ public class FlowInstanceService { + instance.setApplyAppCode(cmd.applyAppCode); + instance.setApplyRequestToken(token); + instance.setApplyRound(round); ++ instance.setApplyPayloadHash(cmd.payloadHash); + instance.setSubmitterId(cmd.submitterId); + instance.setSubmitterName(cmd.submitterName); + instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); +@@ -400,8 +472,11 @@ public class FlowInstanceService { + instanceRepository.saveAndFlush(instance); + appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", + FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); +- flowEngine.startAdvance(instance, cmd.def); +- publishSubmitted(instance); ++ if (source == null) { ++ flowEngine.startAdvance(instance, cmd.def); ++ publishSubmitted(instance); ++ } ++ return instance; + } + + /** +diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java +index 91c6b07..63e90d6 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java +@@ -162,15 +162,18 @@ public class FlowRouterAdminService { + } + + /** +- * 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本, +- * 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/ +- * 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。 +- * 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照 +- * 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时 +- * 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。 ++ * 重跑路由(挂起恢复唯一入口,§4.4)。2026-10-25 T03 在途锁版收口:useLatestDef=true 的 ++ * "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报; ++ * 挂起件按原定义重跑(修复表达式后按原版本重新发布或由发起方重新申报)。 ++ * 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。 + */ + @Transactional + public void reroute(Long schId, Long instanceId, boolean useLatestDef) { ++ if (useLatestDef) { ++ throw new BizException(ErrorCode.PARAM_ERROR, ++ "在途换版已停用(在途实例固定使用原版本,新版本只影响新申报);" ++ + "请修复表达式后按原版本重新发布,或让发起方终态后重新申报"); ++ } + FlwInstance instance = instanceRepository.findById(instanceId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); + if (!instance.getSchId().equals(schId)) { +@@ -183,58 +186,15 @@ public class FlowRouterAdminService { + if (def == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失"); + } +- if (useLatestDef) { +- def = refreshToLatestPublishedDef(instance, def); +- } + String conditionNode = findParkedCondition(instance, def); + if (conditionNode == null) { +- throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef +- ? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑" +- : "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)"); ++ throw new BizException(ErrorCode.PARAM_ERROR, ++ "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)"); + } + flowEngine.rerouteCondition(instance, def, conditionNode); + instanceRepository.save(instance); + } + +- /** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */ +- private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) { +- FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) +- .orElse(null); +- if (latest == null) { +- throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑"); +- } +- if (latest.getId().equals(instance.getDefId())) { +- return latest; +- } +- instance.setDefId(latest.getId()); +- instance.setDefCode(latest.getCode()); +- instance.setDefVersion(latest.getVersion()); +- appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion()); +- return latest; +- } +- +- /** +- * 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用): +- * status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。 +- */ +- private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) { +- java.time.LocalDateTime now = java.time.LocalDateTime.now(); +- com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask(); +- row.setInstanceId(instance.getId()); +- row.setSchId(instance.getSchId()); +- row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF); +- row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")"); +- // assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径) +- row.setAssigneeId(0); +- row.setAssigneeName("管理端"); +- row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode()); +- row.setArriveTime(now); +- row.setHandleTime(now); +- row.setUrgeCount(0); +- taskRepository.save(row); +- } +- + /** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */ + private String findParkedCondition(FlwInstance instance, FlwDef def) { + String cur = instance.getCurrentNodes(); +diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java +index e397b50..ad9e74e 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java +@@ -246,7 +246,7 @@ public class FlowTaskService { + /** + * 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人 + * (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。 +- * 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。 ++ * 目标人资格由 AssigneeResolver 终审(存在/启用/本校,D11)——查不到必须拒绝,否则产生僵尸待办。 + */ + @Transactional + public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) { +@@ -259,7 +259,9 @@ public class FlowTaskService { + } + FlwInstance instance = instanceRepository.findById(task.getInstanceId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); +- String toUserName = assigneeResolver.requireUserName(toUserId); ++ // D11 移交终审:目标必须存在/启用/属于本校;全部校验在抢占原任务之前完成, ++ // 失败不会先替换人员或改写任务状态。 ++ String toUserName = assigneeResolver.requireEligibleAssigneeName(task.getSchId(), toUserId); + + LocalDateTime now = LocalDateTime.now(); + String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName(); +diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java +index faa1f5f..dbb2fb6 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java ++++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java +@@ -3,6 +3,9 @@ package com.yunhai.flowcenter.service; + import com.fasterxml.jackson.databind.JsonNode; + import com.fasterxml.jackson.databind.ObjectMapper; + import com.yunhai.platform.openapi.core.YschOpenCall; ++import com.yunhai.platform.openapi.core.OpenApiException; ++import com.yunhai.platform.utils.exception.DException; ++import com.yunhai.platform.utils.exception.ErrorCode; + import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; + +@@ -14,10 +17,10 @@ import java.util.Map; + + /** + * form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。
+- * GET {目录解析 form-center}/oapi/form/schema?schId=&uuid= ——按 form_uuid 拉当前已发布版 schema +- * (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}: ++ * GET {目录解析 form-center}/oapi/form/schema?schId=&uuid=&version= ——只取明确发布版本。 ++ * 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall: + * 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。
+- * 只在发布期调用(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
++ * 只在管理期调用(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
+ * audience 解析:契约 v2 C5/G3 起是字符串数组(breaking,T13 切换)——本客户端按数组读取, + * 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。 + */ +@@ -60,48 +63,28 @@ public class FormCenterSchemaClient { + } + } + +- /** +- * 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。 +- * fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关); +- * select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。 +- */ +- public Map fetchSchema(Long schId, String formUuid) { +- RuntimeException last = null; +- for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { +- try { +- Map query = new HashMap<>(); +- query.put("schId", String.valueOf(schId)); +- query.put("uuid", formUuid); +- Map data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class); +- if (data == null) { +- throw new IllegalStateException("schema 返回空 data"); +- } +- return toSnapshot(data); +- } catch (RuntimeException e) { +- last = e; +- log.warn("[flow-center] form-center schema 快照拉取失败(第 {}/{} 次):uuid={} {}", +- attempt, MAX_ATTEMPTS, formUuid, e.toString()); +- } catch (Exception e) { +- last = new IllegalStateException(e.toString(), e); +- log.warn("[flow-center] form-center schema 快照拉取异常(第 {}/{} 次):uuid={} {}", +- attempt, MAX_ATTEMPTS, formUuid, e.toString()); +- } ++ /** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */ ++ public static class SchemaResourceUnavailableException extends RuntimeException { ++ public SchemaResourceUnavailableException(Throwable cause) { ++ super("表单或指定发布版本不存在或不可读", cause); ++ } ++ } ++ ++ public static class SchemaAccessDeniedException extends RuntimeException { ++ public SchemaAccessDeniedException(Throwable cause) { ++ super("表单访问身份或授权被拒绝", cause); + } +- throw new SchemaUnavailableException( +- "关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last); + } + + /** + * 发布期快照的确切版本形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=}, + * 并把返回坐标与请求坐标逐件核对——不符即 {@link SchemaContractViolationException}(不重试)。 + * +- *

刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为 +- * ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。 +- * 新管理链(def 带 {@code formVersion})只走本方法。

++ * fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。 + */ + public Map fetchSchema(Long schId, String formUuid, Integer version) { +- if (version == null) { +- throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)"); ++ if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) { ++ throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version"); + } + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { +@@ -119,6 +102,20 @@ public class FormCenterSchemaClient { + return snapshot; + } catch (SchemaContractViolationException e) { + throw e; ++ } catch (DException e) { ++ int code = e.getCode() == null ? -1 : e.getCode().getCode(); ++ if (code == ErrorCode.PARAM_ERROR.getCode()) { ++ throw new SchemaResourceUnavailableException(e); ++ } ++ if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) { ++ throw new SchemaAccessDeniedException(e); ++ } ++ last = e; ++ } catch (OpenApiException e) { ++ if (e.getHttpStatus() == 404) { ++ throw new SchemaResourceUnavailableException(e); ++ } ++ last = e; + } catch (RuntimeException e) { + last = e; + log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}", +@@ -137,9 +134,15 @@ public class FormCenterSchemaClient { + private static void requireCoordinate(Map snapshot, String formUuid, Integer version) { + Object gotUuid = snapshot.get("formUuid"); + Object gotVersion = snapshot.get("formVersion"); +- if (gotUuid == null || gotVersion == null ++ Integer parsedVersion; ++ try { ++ parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion)); ++ } catch (NumberFormatException e) { ++ throw new SchemaContractViolationException("表单中心返回的版本不是合法整数"); ++ } ++ if (gotUuid == null || parsedVersion == null || parsedVersion < 1 + || !String.valueOf(gotUuid).equals(formUuid) +- || Integer.parseInt(String.valueOf(gotVersion)) != version) { ++ || !parsedVersion.equals(version)) { + throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 " + + formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")"); + } +diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +index d399120..2f1d870 100644 +--- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java ++++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +@@ -106,15 +106,114 @@ public class OapiFlowService { + this.transactionTemplate = template; + } + ++ /** ++ * T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。 ++ * setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new) ++ * 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。 ++ * enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。 ++ */ ++ private FlowUsageGrantGuard usageGrantGuard; ++ ++ @org.springframework.beans.factory.annotation.Autowired(required = false) ++ public void setUsageGrantGuard(FlowUsageGrantGuard guard) { ++ this.usageGrantGuard = guard; ++ } ++ ++ /** ++ * 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器, ++ * 所有拒绝均发生在实例创建之前。 ++ */ ++ private ApplyVariableValidator applyVariableValidator; ++ ++ @org.springframework.beans.factory.annotation.Autowired(required = false) ++ public void setApplyVariableValidator(ApplyVariableValidator validator) { ++ this.applyVariableValidator = validator; ++ } ++ ++ /** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */ ++ private FlowActorContextGate actorContextGate; ++ ++ @org.springframework.beans.factory.annotation.Autowired(required = false) ++ public void setActorContextGate(FlowActorContextGate gate) { ++ this.actorContextGate = gate; ++ } ++ ++ private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) { ++ ApplyVariableValidator validator = applyVariableValidator == null ++ ? new ApplyVariableValidator(objectMapper) : applyVariableValidator; ++ validator.requireVariablesPresent( ++ FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson); ++ } ++ + private FlowInstanceService.GuardedOutcome inTransaction( + org.springframework.transaction.support.TransactionCallback action) { + return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action); + } + ++ @Value("${flow-center.formal-command.enabled:false}") ++ private boolean formalCommandEnabled; ++ ++ @org.springframework.beans.factory.annotation.Autowired ++ private FlowSourcePrincipalResolver sourcePrincipalResolver; ++ @org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands; ++ @org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants; ++ ++ private void requireLegacyWriter() { ++ if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN, ++ "正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭"); ++ } ++ ++ /** New-command validation only. Never call this while recovering a settled receipt. */ ++ FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source, ++ com.yunhai.flowcenter.model.FormalApplyModels.Request body) { ++ BoaUser user; ++ try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); } ++ catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); } ++ if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null ++ || !schId.equals(Long.valueOf(user.getSchool().getId()))) { ++ throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校"); ++ } ++ if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) { ++ throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚"); ++ } ++ FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid()); ++ if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) { ++ throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID"); ++ } ++ requireExpressionVariablesPresent(def, asFormDataString(body.getFormData())); ++ // D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED, ++ // 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。 ++ assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson())); ++ String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); ++ return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(), ++ body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), ++ body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), ++ source.appKey, source.appCode, assigns); ++ } ++ ++ private boolean canReadFormalDefinition(Long school, String flow) { ++ String source = FlowOapiSourceScope.current(); ++ return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1) ++ || formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED"); ++ } ++ ++ private void requireFormalPreviewGrant(Long school, String flow) { ++ if (!formalCommandEnabled) return; ++ FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve( ++ (String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth")); ++ if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1) ++ || !formalActivation.isAppActivatedStrict(principal.appId, school)) { ++ throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限"); ++ } ++ } ++ ++ @org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation; ++ + /** 已发布流程列表(每条流程线取最高已发布版本) */ + public List listFlows(Long schId) { + List out = new ArrayList<>(); + for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) { ++ if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue; + out.add(toSummary(def)); + } + return out; +@@ -122,6 +221,7 @@ public class OapiFlowService { + + /** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */ + public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) { ++ if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null; + return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) + .map(this::toSummary).orElse(null); +@@ -131,16 +231,20 @@ public class OapiFlowService { + * 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */ + @Transactional + public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) { ++ requireLegacyWriter(); + if (schId == null || body.getFlowGuid() == null || body.getBizType() == null + || body.getBizId() == null || body.getSubmitterId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "缺少必要参数:schId/flowGuid/bizType/bizId/submitterId"); + } + requireSchoolMember(schId, body.getSubmitterId()); +- FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) +- .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, +- "流程不存在或未发布:" + body.getFlowGuid())); ++ String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); ++ // T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报, ++ // 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。 ++ requireUsageGrantLegacyApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), body.getBizId()); ++ FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid()); ++ // T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行 ++ requireExpressionVariablesPresent(def, asFormDataString(body.getFormData())); + // S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝, + // 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。 + if (body.getExpectedDefVersion() != null +@@ -150,7 +254,6 @@ public class OapiFlowService { + + ",当前已发布版本 " + def.getVersion() + ")"); + } + String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); +- String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); + FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( + schId, def, + body.getBizType(), body.getBizId(), body.getBizTitle(), +@@ -184,23 +287,40 @@ public class OapiFlowService { + */ + public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey, + FlowApplyRetryModels.ApplyRequestV2 body) { ++ requireLegacyWriter(); + if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null + || body.getBizId() == null || body.getSubmitterId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "缺少必要参数:schId/flowGuid/bizType/bizId/submitterId"); + } + requireSchoolMember(schId, body.getSubmitterId()); +- FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) +- .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, +- "流程不存在或未发布:" + body.getFlowGuid())); +- if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) { ++ String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); ++ // T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND ++ // 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮) ++ // 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序; ++ // 完整命令协议(requestId/指纹/服务端轮次)统一归 T04 重排,此处不重构。 ++ requireUsageGrantGuardedApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), ++ body.getBizId(), body.getRequestToken(), body.getAttempt(), ++ Boolean.TRUE.equals(body.getRetryOnly())); ++ FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId( ++ schId, body.getBizType(), body.getBizId()).orElse(null); ++ boolean replay = existing != null && body.getRequestToken() != null ++ && !body.getRequestToken().trim().isEmpty() ++ && body.getRequestToken().trim().equals(existing.getApplyRequestToken()); ++ // 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。 ++ FlwDef def = replay ? defRepository.findById(existing.getDefId()) ++ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在")) ++ : requirePublishedDefForApply(schId, body.getFlowGuid()); ++ if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) { ++ throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同"); ++ } ++ if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData())); ++ if (!replay && body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion() + + ",当前已发布版本 " + def.getVersion() + ")"); + } + String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); +- String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); + FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( + schId, def, + body.getBizType(), body.getBizId(), body.getBizTitle(), +@@ -208,8 +328,11 @@ public class OapiFlowService { + body.getSubmitterId(), body.getSubmitterName(), + asFormDataString(body.getFormData()), + cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); ++ // T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝 ++ cmd.payloadHash = payloadHash(cmd); + FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy( +- body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly())); ++ body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()), ++ body.getExpectedPrevRound()); + + FlowInstanceService.GuardedOutcome outcome; + try { +@@ -218,14 +341,155 @@ public class OapiFlowService { + // 事务边界外重读:另一个并发请求已用同一业务键建好办件 + return fromConcurrent(schId, cmd, policy); + } ++ requireNotConflict(outcome); + FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2(); + vo.setInstanceId(outcome.instanceId); + vo.setDuplicated(outcome.duplicated); + vo.setBranch(outcome.branch); + vo.setApplyRound(outcome.applyRound); ++ instanceRepository.findById(outcome.instanceId).ifPresent(current -> { ++ int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound(); ++ int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound; ++ if (currentRound == outcomeRound) { ++ vo.setStatus(current.getStatus()); ++ vo.setVersion(current.getVersion()); ++ } // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。 ++ ++ }); + return vo; + } + ++ /** ++ * 新发起的当前有效发布版解析(2026-10-25 T03 收口):最高已发布版 + 线级停发守卫—— ++ * 整线停发(line_offline=1)即拒绝新发起,且不回退到更旧的已发布版本(停发不是降级)。 ++ * 在途实例不受影响(推进按实例固定 defId,不经本方法)。 ++ */ ++ private FlwDef requirePublishedDefForApply(Long schId, String flowGuid) { ++ FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( ++ schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) ++ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, ++ "流程不存在或未发布:" + flowGuid)); ++ if (def.getLineOffline() != null && def.getLineOffline() == 1) { ++ throw new BizException(ErrorCode.PARAM_ERROR, ++ "流程线已停发,暂停新发起(在途办件不受影响):" + flowGuid); ++ } ++ return def; ++ } ++ ++ /** ++ * T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠 ++ * 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4 ++ * 重启即新一轮申报。guard 未装配或未 enforce 时零行为(灰度开关,见 {@link FlowUsageGrantGuard})。 ++ */ ++ private void requireUsageGrantLegacyApply(Long schId, String flowGuid, String appKey, ++ String bizType, String bizId) { ++ if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) { ++ return; ++ } ++ FlwInstance existing = instanceRepository ++ .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null); ++ if (!legacyApplyIsReceiptOnly(existing)) { ++ usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey); ++ } ++ } ++ ++ /** ++ * T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting} ++ * 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly}); ++ * 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。 ++ * 完整命令协议统一归 T04。 ++ */ ++ private void requireUsageGrantGuardedApply(Long schId, String flowGuid, String appKey, ++ String bizType, String bizId, ++ String requestToken, Integer attempt, boolean retryOnly) { ++ if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) { ++ return; ++ } ++ FlwInstance existing = instanceRepository ++ .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null); ++ if (!guardedApplyIsReceiptOnly(existing, requestToken, attempt, retryOnly)) { ++ usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey); ++ } ++ } ++ ++ /** ++ * v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。 ++ * 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。 ++ */ ++ static boolean legacyApplyIsReceiptOnly(FlwInstance existing) { ++ return existing != null ++ && (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode() ++ || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()); ++ } ++ ++ /** ++ * v2 回执判定:与 {@code applyGuarded/settleExisting} 同键同序—— ++ * SAME_REQUEST(同 token)→ REPLAY_ONLY(retryOnly)→ IDEMPOTENT(在途/通过)→ ++ * STALE_ROUND(请求轮次≤行轮次,缺省按 1)四类回执返回 true;CREATED/RESTARTED 返回 false。 ++ */ ++ static boolean guardedApplyIsReceiptOnly(FlwInstance existing, String requestToken, ++ Integer attempt, boolean retryOnly) { ++ if (existing == null) { ++ return false; ++ } ++ String token = requestToken == null ? null : requestToken.trim(); ++ if (token != null && !token.isEmpty() && token.equals(existing.getApplyRequestToken())) { ++ return true; ++ } ++ if (retryOnly) { ++ return true; ++ } ++ if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode() ++ || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) { ++ return true; ++ } ++ int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound(); ++ int incomingRound = attempt == null ? 1 : attempt; ++ return incomingRound <= currentRound; ++ } ++ ++ /** ++ * T04:CONFLICT 分支统一翻译成业务异常——冲突不是成功,调用方(SH 恢复链)收到 4xx ++ * 走 UNKNOWN → 按来源+token 复核,不得把他人/其他轮次的在途件当自己的受理结果。 ++ */ ++ private static void requireNotConflict(FlowInstanceService.GuardedOutcome outcome) { ++ if (outcome != null && FlowApplyRetryModels.Branch.CONFLICT.equals(outcome.branch)) { ++ throw new BizException(ErrorCode.CUSTOM, ++ "该业务键已有在途/终态申报且请求标识不符(另一命令或另一轮次)," ++ + "请按实例查询恢复,禁止跨命令认领;终态后重新申报请走显式重提"); ++ } ++ } ++ ++ /** ++ * 受理载荷指纹(T04):SHA-256(流序规范化) over flowGuid|bizType|bizId|submitterId|bizTitle| ++ * bizUrl|bizSnapshot|formData|assignJson。重发链载荷逐字节同源 → 同指纹; ++ * 任何业务字段改动 → 不同指纹 → 冲突。expectedDefVersion 刻意不参与(重试期间发布新版本 ++ * 不应把同一次命令误判成两份)。 ++ */ ++ private String payloadHash(FlowInstanceService.ApplyCommand cmd) { ++ String canon = String.join("|", ++ nullSafe(cmd.def == null ? null : cmd.def.getFlowGuid()), ++ nullSafe(cmd.bizType), nullSafe(cmd.bizId), ++ cmd.submitterId == null ? "" : String.valueOf(cmd.submitterId), ++ nullSafe(cmd.bizTitle), nullSafe(cmd.bizUrl), ++ nullSafe(cmd.bizSnapshot), nullSafe(cmd.formDataJson), nullSafe(cmd.assignJson)); ++ try { ++ byte[] digest = java.security.MessageDigest.getInstance("SHA-256") ++ .digest(canon.getBytes(java.nio.charset.StandardCharsets.UTF_8)); ++ StringBuilder sb = new StringBuilder(64); ++ for (byte b : digest) { ++ sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); ++ } ++ return sb.toString(); ++ } catch (java.security.NoSuchAlgorithmException e) { ++ throw new IllegalStateException("SHA-256 unavailable", e); ++ } ++ } ++ ++ private static String nullSafe(String v) { ++ return v == null ? "" : v; ++ } ++ + /** + * 并发撞键后的收口:重读既有实例 → 先核归属 → 同 token 记 {@code SAME_REQUEST}, + * 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。 +@@ -252,8 +516,18 @@ public class OapiFlowService { + vo.setInstanceId(existing.getId()); + vo.setDuplicated(true); + vo.setApplyRound(existing.getApplyRound()); +- vo.setBranch(token != null && token.equals(existing.getApplyRequestToken()) +- ? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); ++ vo.setStatus(existing.getStatus()); ++ vo.setVersion(existing.getVersion()); ++ if (token != null && !token.equals(existing.getApplyRequestToken())) { ++ // T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功 ++ //(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。 ++ log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}", ++ existing.getId(), cmd.bizType, cmd.bizId); ++ throw new BizException(ErrorCode.CUSTOM, ++ "该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发"); ++ } ++ vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST ++ : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); + log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}", + existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch()); + return vo; +@@ -287,12 +561,45 @@ public class OapiFlowService { + * apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。 + * assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。 + */ ++ /** ++ * T06 required 覆盖判定(包私有纯函数,规则单测钉住):定义里全部开启且必选的指派节点中, ++ * 提交项未覆盖的 nodeId 列表。空 assigns + 存在必选节点 ⇒ 全部缺失(空 assigns 不绕过)。 ++ */ ++ static List missingRequiredAssignNodes(FlowDefModel model, List assigns) { ++ java.util.Set submitted = new java.util.HashSet<>(); ++ if (assigns != null) { ++ for (OapiModels.AssignItem item : assigns) { ++ if (item.getNodeId() != null) { ++ submitted.add(item.getNodeId()); ++ } ++ } ++ } ++ List missing = new ArrayList<>(); ++ for (FlowDefModel.Node n : model.nodes) { ++ if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null ++ || !Boolean.TRUE.equals(n.assignPolicy.required)) { ++ continue; ++ } ++ if (!submitted.contains(n.id)) { ++ missing.add(n.id); ++ } ++ } ++ return missing; ++ } ++ + private String renderAssignJson(Long schId, FlwDef def, List assigns, + Integer submitterId) { ++ FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); ++ // T06(2026-10-25)required 覆盖终审:遍历定义里全部开启且必选的指派节点—— ++ // 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。 ++ List missing = missingRequiredAssignNodes(model, assigns); ++ if (!missing.isEmpty()) { ++ throw new BizException(ErrorCode.PARAM_ERROR, ++ "必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交"); ++ } + if (assigns == null || assigns.isEmpty()) { + return null; + } +- FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + Map>> snapshot = new java.util.LinkedHashMap<>(); + Set seenNodeIds = new LinkedHashSet<>(); + for (OapiModels.AssignItem item : assigns) { +@@ -318,6 +625,16 @@ public class OapiFlowService { + throw new BizException(ErrorCode.PARAM_ERROR, + "不允许指派发起人本人:" + item.getNodeId()); + } ++ // D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝, ++ // 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。 ++ List> rows = new ArrayList<>(); ++ for (Integer uid : uids) { ++ String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid); ++ Map row = new HashMap<>(); ++ row.put("userId", uid); ++ row.put("userName", userName); ++ rows.add(row); ++ } + // 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝 + List candidates = assigneeResolver.resolve(schId, node); + Set candidateIds = new LinkedHashSet<>(); +@@ -344,14 +661,6 @@ public class OapiFlowService { + "指派人数越界[" + min + "~" + max + "]:" + item.getNodeId()); + } + } +- List> rows = new ArrayList<>(); +- for (Integer uid : uids) { +- String userName = assigneeResolver.requireUserName(uid); +- Map row = new HashMap<>(); +- row.put("userId", uid); +- row.put("userName", userName); +- rows.add(row); +- } + snapshot.put(item.getNodeId(), rows); + } + try { +@@ -366,13 +675,11 @@ public class OapiFlowService { + * 节点级解析异常降级空列表(I6)。 + */ + public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) { ++ requireFormalPreviewGrant(schId, flowGuid); + if (schId == null || flowGuid == null || submitterId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId"); + } +- FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) +- .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, +- "流程不存在或未发布:" + flowGuid)); ++ FlwDef def = requirePublishedDefForApply(schId, flowGuid); + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo(); + vo.setFlowGuid(def.getFlowGuid()); +@@ -421,7 +728,7 @@ public class OapiFlowService { + + /** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */ + public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) { +- FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) ++ FlwInstance instance = findByBizForOapi(schId, bizType, bizId) + .orElse(null); + return instance == null ? null : toInstanceVo(instance); + } +@@ -437,8 +744,9 @@ public class OapiFlowService { + if (schId == null || assigneeId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId"); + } +- Page p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), +- PageRequest.of(normPage(page), normSize(size))); ++ Page p = formalCommandEnabled ++ ? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))) ++ : taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))); + List instanceIds = p.getContent().stream() + .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); + Map instances = instanceIds.isEmpty() +@@ -457,8 +765,9 @@ public class OapiFlowService { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); + } +- Page p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, +- PageRequest.of(normPage(page), normSize(size))); ++ Page p = formalCommandEnabled ++ ? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))) ++ : instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))); + List items = p.getContent().stream() + .map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList()); + return toPageResult(p.getTotalElements(), items, page, size); +@@ -490,8 +799,7 @@ public class OapiFlowService { + OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo(); + vo.setBizType(cleanBizType); + vo.setBizId(cleanBizId); +- FlwInstance instance = instanceRepository +- .findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null); ++ FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null); + if (instance == null) { + return vo; + } +@@ -546,6 +854,7 @@ public class OapiFlowService { + @Transactional + public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName, + String opinion, String edgeName) { ++ requireActorProof(schId, taskId, operatorId); + requireOperator(schId, taskId, operatorId, operatorName); + return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName); + } +@@ -554,10 +863,27 @@ public class OapiFlowService { + @Transactional + public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName, + String opinion) { ++ requireActorProof(schId, taskId, operatorId); + requireOperator(schId, taskId, operatorId, operatorName); + return taskService.handle(schId, taskId, false, opinion, operatorId); + } + ++ /** ++ * R1(Codex D03):oapi 审批统一过 ActorContext 门——门禁本身无条件 fail-closed; ++ * 未装配(手工构造的老单测)时同样拒绝,不存在"未装配=直通"豁免。 ++ */ ++ private void requireActorProof(Long schId, Long taskId, Integer operatorId) { ++ if (actorContextGate != null) { ++ actorContextGate.requireActorProof(schId, taskId, operatorId); ++ return; ++ } ++ log.warn("[actor-gate] oapi 快捷审批被拒(门禁未装配,fail-closed):schId={} taskId={} operatorId={}", ++ schId, taskId, operatorId); ++ throw new BizException(ErrorCode.FORBIDDEN, ++ "SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。" ++ + "请通过流程中心人工办理页(CAS)办理"); ++ } ++ + private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) { + if (schId == null || taskId == null || operatorId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId"); +@@ -662,7 +988,7 @@ public class OapiFlowService { + if (bizType == null || bizId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId"); + } +- instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) ++ instance = findByBizForOapi(schId, bizType, bizId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见")); + } + return toProgressVo(instance); +@@ -675,6 +1001,10 @@ public class OapiFlowService { + * 业务边界保证),浏览器侧只认 token。 + */ + public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) { ++ if (formalCommandEnabled && instanceId == null && !canReadFormalDefinition(schId, flowGuid)) { ++ throw new BizException(ErrorCode.FORBIDDEN, "没有此流程定义的读取权限"); ++ } ++ + if (flowGuid != null && !flowGuid.trim().isEmpty()) { + FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode()) +@@ -756,6 +1086,12 @@ public class OapiFlowService { + } + + /** 进度/换图共用的办件定位(schId 收敛在次) */ ++ private java.util.Optional findByBizForOapi(Long school, String type, String biz) { ++ return formalCommandEnabled ++ ? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz) ++ : instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz); ++ } ++ + private FlwInstance requireInstance(Long schId, Long instanceId) { + FlwInstance instance = instanceRepository.findById(instanceId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); +@@ -1020,6 +1356,15 @@ public class OapiFlowService { + reg.setAppKey(appKey); + reg.setFlowGuid(body.getFlowGuid()); + } ++ if (formalCommandEnabled) { ++ FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey); ++ com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null); ++ boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId()); ++ if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) { ++ throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限"); ++ } ++ reg.setSourcePrincipalId(source.id); ++ } + reg.setCallbackUrl(body.getCallbackUrl()); + reg.setEventTypes(body.getEventTypes()); + reg.setBasePath(body.getBasePath()); +@@ -1112,6 +1457,9 @@ public class OapiFlowService { + public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) { + OapiModels.InstanceVo vo = new OapiModels.InstanceVo(); + vo.setInstanceId(instance.getId()); ++ vo.setApplyRound(instance.getApplyRound()); ++ vo.setRequestToken(instance.getApplyRequestToken()); ++ vo.setApplyAppKey(instance.getApplyAppKey()); + vo.setStatus(instance.getStatus()); + vo.setVersion(instance.getVersion()); + vo.setBizTitle(instance.getBizTitle()); +diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java +index 5728e74..d29c65b 100644 +--- a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java ++++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java +@@ -171,9 +171,10 @@ class CallbackDispatchIT extends FlowTestSupport { + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + org.springframework.http.ResponseEntity applyResp = + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); +- // 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计) ++ // 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计;异步首推 ++ // 可能被启动扫描的遗留行恢复推送短暂占池,等待上限放宽——P1-1) + waitUntil(() -> received.stream().anyMatch(r -> r.body.contains("flow.submitted")) +- && received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 10_000); ++ && received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 60_000); + boolean hasSubmitted = false; + boolean hasTaskCreated = false; + for (Received r : received) { +@@ -221,10 +222,13 @@ class CallbackDispatchIT extends FlowTestSupport { + .filter(r -> r.body != null && r.body.contains("\"ping\"")).findFirst().orElse(null); + assertNotNull(ping, "注册探活应命中 stub"); + assertAppSignature("/callback/flow", ping); +- // 4. 日志表成功态(异步推送落定,轮询等待) +- assertTrue(logRepository.findAll().size() >= 2); ++ // 4. 日志表成功态(异步推送落定,轮询等待——只认本用例两行,残留行不计) + waitUntil(() -> logRepository.findAll().stream() +- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000); ++ .filter(l -> "cb-1".equals(l.getBizId())).count() >= 2, 60_000); ++ waitUntil(() -> logRepository.findAll().stream() ++ .filter(l -> "cb-1".equals(l.getBizId())) ++ .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), ++ 60_000); + } + + /** +@@ -261,7 +265,7 @@ class CallbackDispatchIT extends FlowTestSupport { + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + +- waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 10_000); ++ waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 60_000); + Received push = hits.stream() + .filter(r -> r.body != null && r.body.contains("flow.submitted")) + .findFirst().orElseThrow(IllegalStateException::new); +@@ -288,16 +292,14 @@ class CallbackDispatchIT extends FlowTestSupport { + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + +- // 首推失败:retryCount=1(异步首推,轮询等待) +- waitUntil(() -> logRepository.findAll().stream() +- .anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000); ++ // 首推失败:retryCount=1(异步首推,轮询等待——只认本用例行) ++ waitFirstFailureLanded("cb-2"); + // 手工逐次触发扫描语义再失败 5 轮(共 6 次,越过退避表 5 项) ++ FlwCallbackLog hung = soleRow("cb-2"); + for (int round = 0; round < 5; round++) { +- for (FlwCallbackLog row : logRepository.findAll()) { +- pushService.pushAsync(row.getId()); +- } ++ pushService.pushAsync(hung.getId()); ++ hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new); + } +- FlwCallbackLog hung = logRepository.findAll().get(0); + // T1.1 核心语义:持续重试不自动死信——status 仍=0、retryCount=6、nextRetry≈now+60min(封顶) + assertEquals(FlwEnums.CallbackLogStatus.PENDING.getCode(), hung.getStatus(), + "越过退避表后必须保持待推持续重试,不得自动死信"); +@@ -308,8 +310,9 @@ class CallbackDispatchIT extends FlowTestSupport { + && hung.getNextRetryTime().isBefore(expectedNext.plusMinutes(5)), + "越过退避表后 nextRetryTime 应封顶 +60 分钟: " + hung.getNextRetryTime()); + assertTrue(logRepository.findAll().stream() +- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()), +- "任何行都不应出现死信(status=2)"); ++ .filter(l -> "cb-2".equals(l.getBizId())) ++ .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()), ++ "本用例行不应出现死信(status=2)"); + assertNotNull(hung.getLastError()); + } + +@@ -330,23 +333,21 @@ class CallbackDispatchIT extends FlowTestSupport { + + "\"bizType\":\"svc_item\",\"bizId\":\"cb-4\",\"bizTitle\":\"张三的告警件\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); +- waitUntil(() -> logRepository.findAll().stream() +- .anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000); ++ waitFirstFailureLanded("cb-4"); + + // 失败到第 6 次(=挂起阈值):告警恰一次 ++ FlwCallbackLog row = soleRow("cb-4"); + for (int round = 0; round < 5; round++) { +- for (FlwCallbackLog row : logRepository.findAll()) { +- pushService.pushAsync(row.getId()); +- } ++ pushService.pushAsync(row.getId()); ++ row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new); + } + assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 6 次失败后挂起告警应恰发送一次"); + + // 第 7 次失败:仍是 1(恰一次) +- for (FlwCallbackLog row : logRepository.findAll()) { +- pushService.pushAsync(row.getId()); +- } ++ pushService.pushAsync(row.getId()); ++ row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new); + assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 7 次失败后告警计数仍应为 1"); +- assertEquals(7, logRepository.findAll().get(0).getRetryCount()); ++ assertEquals(7, row.getRetryCount()); + } + + @Test +@@ -373,13 +374,12 @@ class CallbackDispatchIT extends FlowTestSupport { + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + + // 首推 500 → 进入重试(status=0, retryCount=1) +- waitUntil(() -> logRepository.findAll().stream() +- .anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000); ++ waitFirstFailureLanded("cb-5"); + // 消费方恢复后(扫描/重推触发)→ 200 → status=1 + pushTime 落定 +- for (FlwCallbackLog row : logRepository.findAll()) { +- pushService.pushAsync(row.getId()); +- } +- FlwCallbackLog recovered = logRepository.findAll().get(0); ++ FlwCallbackLog failed = soleRow("cb-5"); ++ pushService.pushAsync(failed.getId()); ++ FlwCallbackLog recovered = logRepository.findById(failed.getId()) ++ .orElseThrow(IllegalStateException::new); + assertEquals(FlwEnums.CallbackLogStatus.SUCCESS.getCode(), recovered.getStatus(), + "瞬时失败恢复后应自动治愈为成功"); + assertNotNull(recovered.getPushTime()); +@@ -402,37 +402,39 @@ class CallbackDispatchIT extends FlowTestSupport { + + "\"bizType\":\"svc_item\",\"bizId\":\"cb-6\",\"bizTitle\":\"张三的管理件\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); +- waitUntil(() -> logRepository.findAll().stream() +- .anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000); ++ waitFirstFailureLanded("cb-6"); ++ FlwCallbackLog hung = soleRow("cb-6"); + for (int round = 0; round < 5; round++) { +- for (FlwCallbackLog row : logRepository.findAll()) { +- pushService.pushAsync(row.getId()); +- } ++ pushService.pushAsync(hung.getId()); ++ hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new); + } +- FlwCallbackLog hung = logRepository.findAll().get(0); + assertEquals(6, hung.getRetryCount()); ++ final FlwCallbackLog hungRow = hung; + +- // 挂起列表:status=0 且 retryCount≥阈值的分页行,不含正常行 ++ // 挂起列表:包含本用例挂起行,且该行合法(status=0、retryCount≥阈值) + com.yunhai.platform.utils.model.BasePageResult hungPage = + callbackAdminService.pageHung(SCH, 1, 10); +- assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hung.getId()))); +- assertTrue(hungPage.getContent().stream() +- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode() +- && l.getRetryCount() >= 6)); ++ assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hungRow.getId()))); ++ FlwCallbackLog listed = hungPage.getContent().stream() ++ .filter(l -> l.getId().equals(hungRow.getId())).findFirst() ++ .orElseThrow(IllegalStateException::new); ++ assertTrue(listed.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode() ++ && listed.getRetryCount() >= 6, ++ "挂起列表中的本用例行必须待推且达阈值"); + + // 管理端手工标记死信(唯一死信入口) +- callbackAdminService.markDead(SCH, hung.getId()); +- FlwCallbackLog dead = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new); ++ callbackAdminService.markDead(SCH, hungRow.getId()); ++ FlwCallbackLog dead = logRepository.findById(hungRow.getId()).orElseThrow(IllegalStateException::new); + assertEquals(FlwEnums.CallbackLogStatus.DEAD.getCode(), dead.getStatus()); + + // 恢复消费方地址后既有 rePush 复活:置回待推立即推送 → 成功 + regRow.setCallbackUrl("http://localhost:" + stubPort + "/callback/flow"); + regRepository.save(regRow); +- callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hung.getId())); ++ callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hungRow.getId())); + waitUntil(() -> { +- FlwCallbackLog row = logRepository.findById(hung.getId()).orElse(null); ++ FlwCallbackLog row = logRepository.findById(hungRow.getId()).orElse(null); + return row != null && row.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode(); +- }, 10_000); ++ }, 60_000); + } + + @Test +@@ -465,6 +467,29 @@ class CallbackDispatchIT extends FlowTestSupport { + return body; + } + ++ /** ++ * 本用例自建投递行(bizId 每用例唯一)。P1-1 教训:库中会残留上一轮运行未推完的 ++ * PENDING 行(崩溃/中断运行遗产),启动扫描会立即恢复推送它们——等待谓词与行定位 ++ * 一律以本用例 bizId 过滤,不与残留行混淆。 ++ */ ++ private FlwCallbackLog soleRow(String bizId) { ++ java.util.List rows = logRepository.findAll().stream() ++ .filter(l -> bizId.equals(l.getBizId())) ++ .collect(java.util.stream.Collectors.toList()); ++ assertEquals(1, rows.size(), "本用例应恰有一条投递行:bizId=" + bizId); ++ return rows.get(0); ++ } ++ ++ /** 等待本用例行首推失败落定(retryCount≥1)且首推线程已完全退出 IN_FLIGHT—— ++ * 否则紧随的手动重推首轮会被在途跳过静默吞计(P1-1 实测根因)。 */ ++ private void waitFirstFailureLanded(String bizId) { ++ waitUntil(() -> logRepository.findAll().stream().anyMatch(l -> ++ bizId.equals(l.getBizId()) && l.getRetryCount() != null && l.getRetryCount() >= 1), ++ 60_000); ++ FlwCallbackLog row = soleRow(bizId); ++ waitUntil(() -> !pushService.inFlight(row.getId()), 10_000); ++ } ++ + /** 应用签名断言:canonical = METHOD + "\n" + 剥离 base-path 后的 path(+排序 query) + "\n" + ts */ + private void assertAppSignature(String expectedTarget, Received r) { + assertNotNull(r.appKey, "推送必须带 X-App-Key"); +diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +index 3b78325..f35fb2a 100644 +--- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java ++++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +@@ -432,12 +432,58 @@ class DefValidatorTest { + } + + @Test +- void expressionMode_formVariables_passPublishTime() { +- // 表单变量(子应用运行时传)发布期不拦:缺失按 null 注入求值 ++ void declaredBusinessVariablesRequiredForContractExpressions() { ++ FlowDefModel model = parse(expressionDef("EXPRESSION", "amount > 1000", "amount <= 1000")); ++ model.variables = new java.util.ArrayList<>(); ++ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model, ++ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("amount")); ++ FlowDefModel.Variable v = new FlowDefModel.Variable(); v.key="amount"; v.type="number"; ++ model.variables.add(v); ++ DefValidator.validate(model, new HashSet<>(Collections.singletonList("ybb")), null); ++ model.variables.add(v); ++ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model, ++ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("重复")); ++ model.variables.remove(1); v.type="any"; ++ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model, ++ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("类型不支持")); ++ } ++ ++ @Test ++ void historicalExpressionsWithoutContractRemainCompatible() { + DefValidator.validate(parse(expressionDef("EXPRESSION", "form.level == 'A'", "true")), + new HashSet<>(Collections.singletonList("ybb")), null); + } + ++ @org.junit.jupiter.params.ParameterizedTest ++ @org.junit.jupiter.params.provider.ValueSource(strings={ ++ "[{\"key\":\"schId\",\"type\":\"number\"}]", ++ "[{\"key\":\"bad key\",\"type\":\"string\"}]", ++ "[{\"key\":\"amount\",\"type\":\"number\",\"enum\":[\"1\"]}]", ++ "[{\"key\":\"amount\",\"type\":\"string\",\"enum\":[]}]", ++ "[{\"key\":\"amount\",\"type\":\"string\",\"enum\":[\"x\",\"x\"]}]", ++ "[null]" ++ }) ++ void invalidDeclarationsRejected(String declarations) throws Exception { ++ FlowDefModel model = parse(expressionDef("EXPRESSION", "true", "false")); ++ model.variables = new ObjectMapper().readValue("{\"variables\":"+declarations+"}",FlowDefModel.class).variables; ++ assertThrows(BizException.class, () -> DefValidator.validate(model, Collections.singleton("ybb"), null)); ++ } ++ ++ @Test ++ void directExpressionLiteralsUseTheSameValueRulesAsApply() { ++ FlowDefModel model = parse(expressionDef("EXPRESSION", "true", "false")); ++ FlowDefModel.Variable v = new FlowDefModel.Variable();v.key="grade";v.type="string";v.enumValues=java.util.Arrays.asList("g1","g2"); ++ model.variables=Collections.singletonList(v); ++ for (String invalid : java.util.Arrays.asList("grade == 'g9'", "'g9' == grade", "grade != 'g9'", "grade > 3")) { ++ model.edges.get(1).condition=invalid; ++ assertThrows(BizException.class, () -> DefValidator.validate(model, Collections.singleton("ybb"), null)); ++ } ++ model.edges.get(1).condition="string.contains(grade, 'grade > 3')"; ++ DefValidator.validate(model, Collections.singleton("ybb"), null); ++ model.edges.get(1).condition="grade > 'g1'"; // 保持既有同类型比较能力,不新增运算符策略。 ++ DefValidator.validate(model, Collections.singleton("ybb"), null); ++ } ++ + @Test + void expressionMode_skipsRouterKeyCheck() { + // EXPRESSION 模式不配 routerKey(enabledRouterKeys=null 也不查注册表) +diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +index dee0ff9..0d52670 100644 +--- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java ++++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +@@ -4,6 +4,7 @@ import com.yunhai.flowcenter.entity.FlwBranch; + import com.yunhai.flowcenter.entity.FlwDef; + import com.yunhai.flowcenter.entity.FlwInstance; + import com.yunhai.flowcenter.entity.FlwTask; ++import com.yunhai.flowcenter.entity.FlwAdvanceIntent; + import com.yunhai.flowcenter.enums.FlwEnums; + import com.yunhai.flowcenter.service.FlowInstanceService; + import com.yunhai.flowcenter.service.FlowTaskService; +@@ -37,6 +38,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; + @ActiveProfiles("test") + class FlowEngineIT extends FlowTestSupport { + ++ @org.springframework.boot.test.mock.mockito.SpyBean ++ private FlowNodeAdvanceListener advanceListener; ++ @org.springframework.boot.test.mock.mockito.SpyBean ++ private com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository intents; ++ @org.springframework.boot.test.mock.mockito.MockBean ++ private org.springframework.scheduling.TaskScheduler controlledScheduler; ++ @Autowired private org.springframework.jdbc.core.JdbcTemplate d09Jdbc; ++ @org.springframework.boot.test.mock.mockito.SpyBean private FlowAdvanceService advanceService; ++ @org.springframework.boot.test.mock.mockito.SpyBean private com.yunhai.flowcenter.repository.FlwTaskRepository controlledTasks; ++ @org.springframework.boot.test.mock.mockito.SpyBean private FlowEngine controlledEngine; ++ + @Autowired + private FlowInstanceService instanceService; + @Autowired +@@ -92,6 +104,9 @@ class FlowEngineIT extends FlowTestSupport { + + @BeforeEach + void setUp() { ++ assertTrue(d09Jdbc.queryForObject("SELECT @@version", String.class).startsWith("8.")); ++ assertEquals(13429, d09Jdbc.queryForObject("SELECT @@port", Integer.class)); ++ assertEquals("fl_d09_20261026", d09Jdbc.queryForObject("SELECT DATABASE()", String.class)); + cleanDb(); + StubJudge.reset("甲线"); + } +@@ -112,6 +127,362 @@ class FlowEngineIT extends FlowTestSupport { + return instanceService.apply(cmd); + } + ++ @Test ++ void d09W_lastHandlerWithLowerTaskIdWinsAfterRecovery() { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ mapDept("d09a",201,"First");mapDept("d09a",202,"Last");mapDept("d09b",301,"Next"); ++ FlwDef def=saveAndPublishDef("d09w","D09 W",multiOutletDef("d09a","d09b")); ++ Long id=apply(def.getFlowGuid(),"d09-w",201,"Applicant").getInstanceId(); ++ List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ tasks.sort(java.util.Comparator.comparing(FlwTask::getId)); ++ FlwTask first=tasks.get(1),last=tasks.get(0); // Completion order deliberately reverses ID order. ++ taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过"); ++ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科"); ++ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id); ++ advanceListener.sweepIntents(); ++ assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ com.yunhai.flowcenter.entity.FlwStateEvent created=stateEventRepository.findAll().stream() ++ .filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType())) ++ .max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get(); ++ assertTrue(created.getPayloadJson().contains("\"operatorUserId\":"+last.getAssigneeId())); ++ } ++ ++ @Test ++ void d09V_claimedOldIntentCannotAdvanceRestartedDefinitionAndNewIntentTakesOver() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next"); ++ FlwDef def=saveAndPublishDef("d09v","D09 V",multiOutletDef("d09a","d09b")); ++ Long id=apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId(); ++ List old=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过"); ++ taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null); ++ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id); ++ FlwDef next=saveAndPublishDef("d09v2","D09 V2",multiOutletDef("d09a","d09b")); ++ next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next); ++ assertEquals(id,apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId()); ++ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{ ++ Object rows=d09DueFromDatabase();claimed.countDown(); ++ assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows; ++ }).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc( ++ org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(); ++ try { ++ java.util.concurrent.Future stale=pool.submit(()->advanceListener.sweepIntents()); ++ assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ List current=taskRepository.findByInstanceIdAndStatus(id,0); ++ for(FlwTask task:current)taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"转乙科"); ++ FlwInstance before=instanceRepository.findById(id).get(); ++ release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwInstance after=instanceRepository.findById(id).get(); ++ assertEquals(before.getVersion(),after.getVersion());assertEquals(10,after.getStatus()); ++ assertEquals("n1",after.getCurrentNodes());assertEquals(2,after.getDefVersion()); ++ assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size()); ++ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc( ++ org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ advanceListener.sweepIntents(); ++ assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } finally {release.countDown();pool.shutdownNow();} ++ } ++ ++ private List d09DueFromDatabase() { ++ return intents.findAll().stream().filter(i->"PENDING".equals(i.getStatus())) ++ .filter(i->i.getNextRetryAt()==null || !i.getNextRetryAt().isAfter(java.time.LocalDateTime.now())) ++ .limit(50).collect(java.util.stream.Collectors.toList()); ++ } ++ ++ private Long d09Multi(String biz) { ++ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next"); ++ FlwDef def=saveAndPublishDef(biz,biz,multiOutletDef("d09a","d09b")); ++ return apply(def.getFlowGuid(),biz,201,"Applicant").getInstanceId(); ++ } ++ ++ private FlwAdvanceIntent d09Head(Long id) { ++ return intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").get(); ++ } ++ ++ @Test void d09W_retryExhaustionRefreshesLastEdgeAndOperatorBeforeSweep() { ++ Long id=d09Multi("d09-retry");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ org.mockito.Mockito.doThrow(new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id)) ++ .when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong()); ++ FlwTask first=tasks.get(1),last=tasks.get(0); ++ taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过"); ++ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科"); ++ FlwAdvanceIntent head=d09Head(id); ++ assertEquals(last.getId(),head.getTriggerTaskId());assertEquals(last.getAssigneeId(),head.getAssigneeId()); ++ assertEquals("转乙科",head.getEdgeName());assertEquals("PENDING",head.getStatus());assertEquals(1,head.getAttempts()); ++ org.mockito.Mockito.verify(advanceService,org.mockito.Mockito.times(6)).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong()); ++ org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong()); ++ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",head.getId()); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ assertEquals("DONE",intents.findById(head.getId()).get().getStatus()); ++ assertEquals(last.getAssigneeId(),stateEventRepository.findAll().stream() ++ .filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType())) ++ .max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get().getOperatorId()); ++ } ++ ++ @Test void d09_claimSnapshotCannotSettleARefreshedSameGeneration() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-snapshot");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过"); ++ FlwAdvanceIntent observed=d09Head(id); ++ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;}) ++ .when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(); ++ try { ++ java.util.concurrent.Future old=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"); ++ FlwAdvanceIntent fresh=d09Head(id);assertTrue(fresh.getRevision()>observed.getRevision()); ++ release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision()); ++ assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId());assertEquals("转乙科",preserved.getEdgeName()); ++ assertEquals(0,preserved.getAttempts());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } finally {release.countDown();pool.shutdownNow();} ++ } ++ ++ @Test void d09_staleFailureCannotDeferOrOverwriteLastHandlerRefresh() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-defer-race");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过"); ++ java.util.concurrent.CountDownLatch entered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{entered.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ throw new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id);}) ++ .when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong()); ++ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(); ++ try { ++ java.util.concurrent.Future old=pool.submit(()->advanceListener.sweepIntents());assertTrue(entered.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"); ++ FlwAdvanceIntent fresh=d09Head(id);release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision());assertEquals(0,preserved.getAttempts()); ++ assertNull(preserved.getLastError());assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId()); ++ org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong()); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } finally {release.countDown();pool.shutdownNow();} ++ } ++ ++ @Test void d09_lockedSettlementThenLastHandlerCommitRearmsTheHead() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-settlement");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过"); ++ java.util.concurrent.CountDownLatch locked=new java.util.concurrent.CountDownLatch(1),handlerEntered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{ ++ if ("d09-settler".equals(Thread.currentThread().getName())) {locked.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));} ++ return controlledTasks.findAll().stream().filter(t->id.equals(t.getInstanceId()) && Integer.valueOf(0).equals(t.getStatus())).collect(java.util.stream.Collectors.toList()); ++ }).when(controlledTasks).findByInstanceIdAndStatus(id,0); ++ FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService); ++ org.mockito.Mockito.doAnswer(inv->{handlerEntered.countDown();return inv.callRealMethod();}) ++ .when(target).recordHandled(org.mockito.ArgumentMatchers.any()); ++ java.util.concurrent.ExecutorService worker=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-settler")); ++ java.util.concurrent.ExecutorService handler=java.util.concurrent.Executors.newSingleThreadExecutor(); ++ try { ++ java.util.concurrent.Future settling=worker.submit(()->advanceListener.sweepIntents());assertTrue(locked.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ java.util.concurrent.Future last=handler.submit(()->taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科")); ++ assertTrue(handlerEntered.await(20,java.util.concurrent.TimeUnit.SECONDS));assertFalse(last.isDone()); ++ release.countDown();settling.get(20,java.util.concurrent.TimeUnit.SECONDS);last.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwAdvanceIntent head=d09Head(id);assertEquals("PENDING",head.getStatus());assertEquals(tasks.get(0).getId(),head.getTriggerTaskId()); ++ assertEquals("转乙科",head.getEdgeName());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } finally {release.countDown();worker.shutdownNow();handler.shutdownNow();} ++ } ++ ++ @Test void d09H_oldClaimAcrossReturnBackHasNoSideEffectsAndNewGenerationTakesOver() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ mapDept("km",103,"Main");mapDept("ka",101,"A");mapDept("kb",102,"B"); ++ FlwDef def=saveAndPublishDef("d09h","D09 H",mainlineParallelTailDef("km","km")); ++ Long id=apply(def.getFlowGuid(),"d09-h",201,"Applicant").getInstanceId();FlwTask first=tasksOf(id,103).get(0); ++ taskService.handle(SCH,first.getId(),true,"first",103); ++ Long oldActivationId=d09Head(id).getId(); ++ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;}) ++ .when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(); ++ try { ++ java.util.concurrent.Future stale=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ // A competing fast worker legitimately advances the old activation before the return. ++ org.mockito.Mockito.doCallRealMethod().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ java.util.Map data=new java.util.HashMap<>();data.put("nodeId","n1"); ++ advanceListener.onTaskHandled(FlowEvent.of(FlowEvent.Type.TASK_HANDLED,id,first.getId(),data)); ++ taskService.handle(SCH,tasksOf(id,101).get(0).getId(),true,"A",101); ++ taskService.handle(SCH,tasksOf(id,102).get(0).getId(),true,"B",102); ++ FlwTask tail=tasksOf(id,103).get(0);assertEquals("n3",tail.getNodeId()); ++ taskService.returnBack(SCH,tail.getId(),"n1","return",103); ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ FlwTask current=tasksOf(id,103).get(0);assertNotEquals(first.getId(),current.getId()); ++ taskService.handle(SCH,current.getId(),true,"new generation",103); ++ FlwInstance before=instanceRepository.findById(id).get();List beforeTasks=allTasks(id); ++ release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion()); ++ assertEquals(10,after.getStatus());assertEquals("n1",after.getCurrentNodes());assertEquals(def.getId(),after.getDefId()); ++ assertEquals(before.getApplyRound(),after.getApplyRound());assertEquals(beforeTasks.size(),allTasks(id).size()); ++ for(FlwTask t:beforeTasks)assertEquals(t.getStatus(),taskRepository.findById(t.getId()).get().getStatus()); ++ // Also model a historical stranded PENDING row with a CURRENT revision: only activation identity can reject it. ++ FlwAdvanceIntent historical=intents.findById(oldActivationId).get();historical.setStatus("PENDING");historical=intents.saveAndFlush(historical); ++ assertFalse(advanceService.tryAdvance(historical.getId(),historical.getRevision())); ++ assertTrue(intents.findById(oldActivationId).get().getLastError().contains("STALE_ACTIVATION")); ++ assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ assertEquals("PENDING",d09Head(id).getStatus()); ++ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); ++ advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains("n1")); ++ assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size()); ++ } finally {release.countDown();pool.shutdownNow();} ++ } ++ ++ @Test void d09V_guardedRoundAndDefinitionChangeRejectsOldPayloadWithoutLosingNewRoute() { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next"); ++ FlwDef def=saveAndPublishDef("d09round","D09 round",multiOutletDef("d09a","d09b")); ++ FlowInstanceService.ApplyCommand command=new FlowInstanceService.ApplyCommand(SCH,def,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null); ++ Long id=instanceService.applyGuarded(command,new FlowInstanceService.ApplyRetryPolicy("d09-round-1",1,false)).instanceId; ++ List old=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过"); ++ taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null); ++ FlwAdvanceIntent stale=d09Head(id);assertEquals(1,stale.getActivationApplyRound()); ++ FlwDef next=saveAndPublishDef("d09round2","D09 new line",multiOutletDef("d09a","d09b").replace("转乙科","新边").replace("通过","完成")); ++ next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next); ++ FlowInstanceService.ApplyCommand nextCommand=new FlowInstanceService.ApplyCommand(SCH,next,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null); ++ FlowInstanceService.GuardedOutcome restarted=instanceService.applyGuarded(nextCommand,new FlowInstanceService.ApplyRetryPolicy("d09-round-2",2,false)); ++ assertEquals(id,restarted.instanceId);assertEquals(2,restarted.applyRound);assertEquals("RESTARTED",restarted.branch); ++ for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"新边"); ++ FlwInstance before=instanceRepository.findById(id).get(); ++ assertFalse(advanceService.tryAdvance(stale.getId(),stale.getRevision())); ++ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion()); ++ assertEquals("n1",after.getCurrentNodes());assertEquals(10,after.getStatus());assertEquals(2,after.getApplyRound()); ++ assertEquals(next.getId(),after.getDefId());assertEquals(2,after.getDefVersion());assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size()); ++ assertEquals("DONE",intents.findById(stale.getId()).get().getStatus());assertTrue(intents.findById(stale.getId()).get().getLastError().contains("STALE_ACTIVATION")); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } ++ ++ private FlwAdvanceIntent d09Legacy(Long id) { ++ FlwAdvanceIntent legacy=new FlwAdvanceIntent();legacy.setInstanceId(id);legacy.setNodeId("n1");legacy.setEdgeName("untrusted-old-edge"); ++ return intents.saveAndFlush(legacy); // NULL activation and NULL retry time deliberately model existing data. ++ } ++ ++ @Test void d09_nullLegacyIntentHandsOffToRegisteredSuccessorWithoutUsingOldPayload() { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-null-successor");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ for(FlwTask task:tasks)taskService.handle(SCH,task.getId(),true,"current",task.getAssigneeId(),"转乙科"); ++ FlwAdvanceIntent successor=d09Head(id),legacy=d09Legacy(id);FlwInstance before=instanceRepository.findById(id).get(); ++ assertFalse(advanceService.tryAdvance(legacy.getId(),legacy.getRevision())); ++ assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus()); ++ assertEquals("PENDING",intents.findById(successor.getId()).get().getStatus()); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } ++ ++ private void d09InstallBackfillProcedure() throws Exception { ++ String sql=org.springframework.util.StreamUtils.copyToString(new org.springframework.core.io.ClassPathResource( ++ "sql/20261026_d09_activation_intent_draft.sql").getInputStream(),java.nio.charset.StandardCharsets.UTF_8); ++ String procedure=sql.split("-- D09_BACKFILL_BEGIN",2)[1].split("-- D09_BACKFILL_END",2)[0].trim(); ++ d09Jdbc.execute("DROP PROCEDURE IF EXISTS d09_backfill_activation");d09Jdbc.execute(procedure); ++ } ++ ++ @Test void d09_nullLegacyPendingCohortIsHeldThenAuditedBackfillAndRealHandlingResume() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-null-pending");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id); ++ advanceListener.sweepIntents();assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus()); ++ assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size()); ++ assertThrows(IllegalStateException.class,()->taskService.handle(SCH,tasks.get(0).getId(),true,"must roll back",tasks.get(0).getAssigneeId(),"通过")); ++ assertEquals(0,taskRepository.findById(tasks.get(0).getId()).get().getStatus()); ++ d09InstallBackfillProcedure(); ++ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1","["+tasks.get(0).getId()+"]",null,"IT omitted member")); ++ assertFalse(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").isPresent()); ++ String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]"; ++ d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT audited pending cohort"); ++ assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus());assertTrue(intents.findById(legacy.getId()).get().getLastError().contains("HANDOFF")); ++ assertEquals("DONE",d09Head(id).getStatus());assertNull(d09Head(id).getTriggerTaskId()); ++ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过"); ++ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } ++ ++ @Test void d09_nullLegacyCompletedCohortRequiresProvenLastHandlerBeforeBackfill() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-null-completed");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过"); ++ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"); ++ intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id);advanceListener.sweepIntents(); ++ assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ d09InstallBackfillProcedure();String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]"; ++ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT missing last-handler evidence")); ++ assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus()); ++ d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT witnessed lower-ID last handler"); ++ assertEquals(tasks.get(0).getId(),d09Head(id).getTriggerTaskId());assertEquals("PENDING",d09Head(id).getStatus()); ++ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT cannot overwrite successor")); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ } ++ ++ @Test void d09_oldWorkerAlreadyPastGenerationGuardRollsBackAcrossRestart() throws Exception { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Cancel"); ++ FlwDef def=saveAndPublishDef("d09-fence","D09 fence",parallelDef("d09a","d09b")); ++ Long id=apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId(); ++ List initial=taskRepository.findByInstanceIdAndStatus(id,0); ++ String node=initial.stream().filter(t->t.getAssigneeId()==201).findFirst().get().getNodeId(); ++ for(FlwTask task:initial)if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"old approved",task.getAssigneeId()); ++ FlwAdvanceIntent oldHead=intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,node).get(); ++ java.util.concurrent.CountDownLatch validated=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1); ++ org.mockito.Mockito.doAnswer(inv->{ ++ if("d09-old-guard".equals(Thread.currentThread().getName())) {validated.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));} ++ return inv.callRealMethod(); ++ }).when(controlledEngine).continueAdvance(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq(node),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()); ++ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-old-guard")); ++ try { ++ java.util.concurrent.Future old=pool.submit(()->advanceListener.sweepIntents());assertTrue(validated.await(20,java.util.concurrent.TimeUnit.SECONDS)); ++ FlwTask cancel=initial.stream().filter(t->t.getAssigneeId()==301).findFirst().get(); ++ taskService.handle(SCH,cancel.getId(),false,"reject other branch",301); ++ assertEquals(30,instanceRepository.findById(id).get().getStatus()); ++ assertEquals(id,apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId()); ++ for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"new approved",task.getAssigneeId()); ++ FlwInstance before=instanceRepository.findById(id).get();List tasksBefore=allTasks(id); ++ java.util.List> branchesBefore=d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id); ++ release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS); ++ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());assertEquals(before.getCurrentNodes(),after.getCurrentNodes()); ++ assertEquals(10,after.getStatus());assertEquals(tasksBefore.size(),allTasks(id).size()); ++ for(FlwTask task:tasksBefore)assertEquals(task.getStatus(),taskRepository.findById(task.getId()).get().getStatus()); ++ assertEquals(branchesBefore,d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id)); ++ // Its failed settlement did not consume the new generation. That generation advances independently. ++ advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains(node)); ++ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",oldHead.getId()); ++ advanceListener.sweepIntents();assertEquals("DONE",intents.findById(oldHead.getId()).get().getStatus()); ++ assertTrue(intents.findById(oldHead.getId()).get().getLastError().contains("STALE_ACTIVATION")); ++ } finally {release.countDown();pool.shutdownNow();} ++ } ++ ++ @Test void d09_transferRemainsInItsActivationAndLowerIdLastHandlerStillWins() { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-transfer");List tasks=taskRepository.findByInstanceIdAndStatus(id,0); ++ FlwAdvanceIntent original=d09Head(id);FlwTask transferred=tasks.get(0),last=tasks.get(1); ++ taskService.transfer(SCH,transferred.getId(),301,"transfer",transferred.getAssigneeId()); ++ FlwTask replacement=taskRepository.findByInstanceIdAndStatus(id,0).stream().filter(t->t.getAssigneeId()==301).findFirst().get(); ++ assertTrue(replacement.getId()>last.getId());assertEquals(original.getId(),d09Head(id).getId());assertTrue(d09Head(id).containsTask(replacement.getId())); ++ taskService.handle(SCH,replacement.getId(),true,"first",301,"通过"); ++ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科"); ++ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes()); ++ assertEquals(last.getId(),d09Head(id).getTriggerTaskId());assertEquals(last.getAssigneeId(),d09Head(id).getAssigneeId()); ++ } ++ ++ @Test void d09_ambiguousTransferTimestampCannotGuessAcrossActivations() { ++ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any()); ++ Long id=d09Multi("d09-transfer-ambiguous");FlwInstance instance=instanceRepository.findById(id).get(); ++ FlwTask source=tasksOf(id,201).get(0);taskService.transfer(SCH,source.getId(),301,"old transfer",201); ++ FlwTask reject=tasksOf(id,202).get(0);taskService.handle(SCH,reject.getId(),false,"reject",202); ++ FlwDef def=defRepository.findById(instance.getDefId()).get();apply(def.getFlowGuid(),"d09-transfer-ambiguous",201,"Applicant"); ++ FlwTask current=tasksOf(id,201).get(0);int before=allTasks(id).size(); ++ FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService); ++ org.mockito.Mockito.doAnswer(inv->{FlowEvent event=inv.getArgument(0); ++ // Force equal millisecond timestamps in two generations; a timestamp is only a candidate filter. ++ d09Jdbc.update("UPDATE flw_task SET handle_time=(SELECT stamp FROM (SELECT arrive_time stamp FROM flw_task WHERE id=?) s) WHERE id=?",event.getTaskId(),source.getId()); ++ return inv.callRealMethod(); ++ }).when(target).registerTransfer(org.mockito.ArgumentMatchers.any()); ++ assertThrows(IllegalStateException.class,()->taskService.transfer(SCH,current.getId(),301,"ambiguous transfer",201)); ++ assertEquals(0,taskRepository.findById(current.getId()).get().getStatus());assertEquals(before,allTasks(id).size()); ++ assertFalse(d09Head(id).containsTask(source.getId()));assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes()); ++ } ++ + @Test + void apply_createsInstanceAndCountersignTasks_allApprove_completes() { + mapDept("ybb", 101, "甲"); +diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +index 2628057..afe349f 100644 +--- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java ++++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwDef; + import com.yunhai.flowcenter.entity.FlwRouter; + import com.yunhai.flowcenter.service.DiagramTokenService; + import com.yunhai.flowcenter.service.FlowRouterAdminService; ++import com.yunhai.flowcenter.service.FlowTaskService; + import com.yunhai.flowcenter.service.OapiFlowService; + import com.yunhai.flowcenter.support.FlowTestSupport; + import com.yunhai.flowcenter.support.OapiSignedClient; +@@ -52,6 +53,9 @@ class OapiFlowContractIT extends FlowTestSupport { + @Autowired + private OapiFlowService flowService; + ++ @Autowired ++ private FlowTaskService taskService; ++ + @Autowired + private FlowRouterAdminService routerAdminService; + +@@ -431,7 +435,12 @@ class OapiFlowContractIT extends FlowTestSupport { + assertEquals(0, finished.get("total").asInt()); + } + +- /** 办理(第三步 T-C1b):非办理人业务码拒绝不 500;同意推进+opinion 进回调 payload;拒绝必填意见 */ ++ /** ++ * 办理(第三步 T-C1b;2026-10-25 R1 返修重写):oapi approve/reject 无可验证用户证明 ++ * 一律 fail-closed(即便自报办理人 ID)——缺 operatorName/非办理人/办理人本人均业务码拒绝、 ++ * 任务保持待办;合法办理经 CAS 会话路径(FlowTaskService.handle,与 ++ * FlowUserController 同入口)完成:同意推进 + opinion 进回调 payload、拒绝必填意见。 ++ */ + @Test + void approveAndReject_operatorChecked_opinionPropagates() throws Exception { + mapDept("ybb", 101, "甲"); +@@ -464,12 +473,12 @@ class OapiFlowContractIT extends FlowTestSupport { + .get("instanceId").asLong(); + Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(); + +- // 缺 operatorName → 业务码拒绝 ++ // R1:oapi 缺 operatorName → 业务码拒绝(门禁先拒,fail-closed 优先于参数校验) + ResponseEntity noName = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101", "{}", JsonNode.class); + assertNotEquals(0, noName.getBody().get("code").asInt(), "缺 operatorName 应被拒绝"); + +- // 非办理人(999)→ 业务码拒绝(code!=0,HTTP 200),任务仍在待办 ++ // R1:非办理人(999)自报 → 业务码拒绝(code!=0,HTTP 200),任务仍在待办 + ResponseEntity forged = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=999&operatorName=路人", + "{\"opinion\":\"越权\"}", JsonNode.class); +@@ -478,31 +487,41 @@ class OapiFlowContractIT extends FlowTestSupport { + assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), + "越权后任务仍在待办"); + +- // 办理人(101)同意 → 任务 1、单节点单人会签放行 → 办件 20;opinion 进回调 payload +- ResponseEntity ok = client.post( ++ // R1:办理人(101)本人自报 operatorId → 同样拒绝(知道办理人 ID ≠ 该用户本人在操作) ++ ResponseEntity selfReported = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101&operatorName=甲", + "{\"opinion\":\"同意,情况属实\"}", JsonNode.class); +- assertEquals(0, ok.getBody().get("code").asInt(), "办理人同意应成功"); ++ assertNotEquals(0, selfReported.getBody().get("code").asInt(), ++ "R1:oapi 快捷审批即便自报真实办理人也不得放行"); ++ assertTrue(selfReported.getBody().get("msg").asText().contains("快捷审批未放行"), ++ "拒绝文案须指明迁移路径(CAS 人工办理)"); ++ assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), ++ "拒绝后任务仍在待办"); ++ ++ // 合法办理改走 CAS 会话路径(FlowUserController 同入口):同意 → 任务 1、办件 20; ++ // opinion 进回调 payload ++ taskService.handle(1L, taskId, true, "同意,情况属实", 101, null); + assertEquals(1, taskRepository.findById(taskId).get().getStatus()); + assertEquals(20, instanceRepository.findById(instanceId).get().getStatus()); + assertTrue(awaitPayloadContains(receivedPayloads, "同意,情况属实"), + "opinion 应透传到回调 payload"); + +- // 第二件:拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30 ++ // 第二件:oapi 拒绝(无意见/有意见)→ R1 一律业务码拒绝(门禁先于意见必填校验); ++ // 会话路径拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30 + Long id2 = unwrap(client.post("/oapi/flow/apply?schId=1", "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"ap-2\",\"bizTitle\":\"驳回件\"," + + "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody()) + .get("instanceId").asLong(); + Long taskId2 = taskRepository.findByInstanceIdAndStatus(id2, 0).get(0).getId(); +- ResponseEntity noOpinion = client.post( ++ ResponseEntity oapiNoOpinion = client.post( + "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲", + "{}", JsonNode.class); +- assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见"); +- ResponseEntity rejected = client.post( +- "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲", +- "{\"opinion\":\"材料不全\"}", JsonNode.class); +- assertEquals(0, rejected.getBody().get("code").asInt()); ++ assertNotEquals(0, oapiNoOpinion.getBody().get("code").asInt(), "R1:oapi 拒绝同样不放行"); ++ assertThrows(BizException.class, ++ () -> taskService.handle(1L, taskId2, false, null, 101), ++ "会话路径拒绝必须填写意见"); ++ taskService.handle(1L, taskId2, false, "材料不全", 101); + assertEquals(2, taskRepository.findById(taskId2).get().getStatus()); + assertEquals(30, instanceRepository.findById(id2).get().getStatus()); + assertTrue(awaitPayloadContains(receivedPayloads, "材料不全"), +@@ -649,7 +668,7 @@ class OapiFlowContractIT extends FlowTestSupport { + assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选"); + } + +- /** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */ ++ /** 合法 assigns:200 + 快照落库(姓名走 requireEligibleAssignee 严格口径)+ 只为被指派人建待办 */ + @Test + void apply_withAssigns_ok() throws Exception { + mapDept("ybb", 101, "甲"); +diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java +index 346c21a..8ad9bd5 100644 +--- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java ++++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java +@@ -2,6 +2,7 @@ package com.yunhai.flowcenter.oapi; + + import com.fasterxml.jackson.databind.JsonNode; + import com.yunhai.flowcenter.entity.FlwDef; ++import com.yunhai.flowcenter.service.FlowTaskService; + import com.yunhai.flowcenter.service.OapiFlowService; + import com.yunhai.flowcenter.support.FlowTestSupport; + import com.yunhai.flowcenter.support.OapiSignedClient; +@@ -46,8 +47,14 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { + @Autowired + private OapiFlowService flowService; + ++ @Autowired ++ private FlowTaskService taskService; ++ + private OapiSignedClient client; + ++ @Autowired ++ private javax.sql.DataSource dataSource; ++ + @TestConfiguration + static class StubConfig { + @Bean +@@ -58,7 +65,21 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { + } + + @BeforeEach +- void setUp() { ++ void setUp() throws Exception { ++ // 在 cleanDb 之前验证真实连接,不能只相信环境变量已覆盖 profile。 ++ try (java.sql.Connection connection = dataSource.getConnection()) { ++ String schema = connection.getCatalog(); ++ String host = java.net.URI.create(connection.getMetaData().getURL().substring(5)).getHost(); ++ System.out.println("CI_DB_TARGET=" + host + "/" + schema); ++ assertTrue("127.0.0.1".equals(host) || "localhost".equals(host) ++ || (host != null && host.startsWith("mavenbuild-") && host.endsWith("-db")), ++ "真库门禁只允许本机或本次 CI 容器"); ++ assertTrue("bsch_flowcenter_test".equals(schema) || "flowcenter_ci".equals(schema) ++ || (schema != null && schema.startsWith("flowcenter_ci_")), "拒绝清理非测试 schema"); ++ if (System.getenv("CI_TEST_DB_URL") != null) { ++ assertTrue(schema.startsWith("flowcenter_ci"), "CI 必须实际连接独立 schema"); ++ } ++ } + cleanDb(); + client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET); + } +@@ -126,9 +147,8 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { + FlwDef def = saveAndPublishDef("bb2", "已办结", linearDef("ybb", "审核")); + Long instanceId = applyOne(def, "done-1", "已办结通知"); + Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(); +- assertEquals(0, client.post("/oapi/flow/task/" + taskId +- + "/approve?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class) +- .getBody().get("code").asInt(), "办理人同意应成功"); ++ // R1:oapi 快捷审批已 fail-closed,办结动作改走 CAS 会话路径(FlowTaskService.handle) ++ taskService.handle(SCH, taskId, true, "同意", 101, null); + JsonNode data = byBiz("done-1", 101); + assertEquals(0, data.get("tasks").size(), "办结后本人无待办"); + assertEquals(20, data.get("status").asInt(), "终态可展示:回带办件状态"); +diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java +index 2dc0793..f08b6b2 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java +@@ -138,18 +138,66 @@ class FlApplyRecoveryTest { + @Test + @DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)") + void approvingOrCompletedStayIdempotent() { ++ // T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 → ++ // CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。 + FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); + when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) + .thenReturn(Optional.of(approving)); + assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) ++ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT); ++ // 存量无 token:幂等回读不变 ++ FlowInstanceService.ApplyCommand legacy = cmd(null); ++ assertThat(service.applyGuarded(legacy, policy(null, null, false)).branch) + .isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT); + + FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018"); + assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) +- .isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT); ++ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT); + verify(flowEngine, never()).startAdvance(any(), any()); + } + ++ @Test ++ @DisplayName("同 token 但载荷指纹不一致:CONFLICT(同 ID 不同载荷是两份命令,T04)") ++ void sameTokenDifferentPayloadIsConflict() { ++ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); ++ existing.setApplyPayloadHash("hash-a"); ++ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) ++ .thenReturn(Optional.of(existing)); ++ ++ FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A); ++ replay.payloadHash = "hash-b"; ++ assertThat(service.applyGuarded(replay, policy(TOKEN_A, 1, false)).branch) ++ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT); ++ ++ FlowInstanceService.ApplyCommand identical = cmd(TOKEN_A); ++ identical.payloadHash = "hash-a"; ++ assertThat(service.applyGuarded(identical, policy(TOKEN_A, 1, false)).branch) ++ .isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST); ++ } ++ ++ @Test ++ @DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)") ++ void serverAllocatesNextRoundFromExpectedPrev() { ++ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"); ++ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) ++ .thenReturn(Optional.of(existing)); ++ ++ // 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮 ++ FlowInstanceService.GuardedOutcome outcome = service.applyGuarded( ++ cmd(TOKEN_B), new FlowInstanceService.ApplyRetryPolicy(TOKEN_B, 99, false, 2)); ++ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED); ++ assertThat(outcome.applyRound).isEqualTo(3); ++ ++ // expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例) ++ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID)) ++ .thenReturn(Optional.of( ++ instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"))); ++ assertThat(service.applyGuarded(cmd(TOKEN_B), ++ new FlowInstanceService.ApplyRetryPolicy(TOKEN_B, 99, false, 1)).branch) ++ .isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND); ++ verify(flowEngine, org.mockito.Mockito.times(1)).startAdvance(any(), any()); ++ } ++ + // ==================== 轮次守卫 ==================== + + @Test +@@ -287,6 +335,37 @@ class FlApplyRecoveryTest { + assertThat(existing.getApplyRound()).isNull(); + } + ++ @Test ++ void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception { ++ FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018"); ++ row.setVersion(3); ++ when(instanceRepository.findById(900L)).thenReturn(Optional.of(row)); ++ com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of( ++ com.yunhai.flowcenter.engine.FlowEvent.Type.REJECTED, 900L); ++ new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event); ++ row.setApplyRound(2); row.setApplyRequestToken(TOKEN_B); row.setVersion(4); ++ new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event); ++ assertThat(event.getRoundIdentity().get("applyRound")).isEqualTo(1); ++ ++ com.yunhai.flowcenter.repository.FlwCallbackRegRepository regs = mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class); ++ com.yunhai.flowcenter.callback.CallbackLogStore store = mock(com.yunhai.flowcenter.callback.CallbackLogStore.class); ++ com.yunhai.flowcenter.entity.FlwCallbackReg reg = new com.yunhai.flowcenter.entity.FlwCallbackReg(); ++ reg.setId(7L); ++ when(regs.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(java.util.Collections.singletonList(reg)); ++ com.yunhai.flowcenter.callback.CallbackDispatcher dispatcher = new com.yunhai.flowcenter.callback.CallbackDispatcher( ++ instanceRepository, defRepository, regs, mock(com.yunhai.flowcenter.repository.FlwCallbackLogRepository.class), ++ store, mock(com.yunhai.flowcenter.callback.CallbackPushService.class), new com.yunhai.flowcenter.callback.CallbackProperties(), mapper, ++ mock(com.yunhai.flowcenter.repository.FlwStateEventRepository.class), ++ mock(com.yunhai.flowcenter.callback.CallbackFactStore.class)); ++ org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", mock(java.util.concurrent.ExecutorService.class)); ++ dispatcher.onFlowEvent(event); ++ ArgumentCaptor log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class); ++ verify(store).persist(log.capture()); ++ com.fasterxml.jackson.databind.JsonNode data = mapper.readTree(log.getValue().getPayloadJson()).get("data"); ++ assertThat(data.get("applyRound").asInt()).isEqualTo(1); ++ assertThat(data.get("requestToken").asText()).isEqualTo(TOKEN_A); ++ } ++ + // ==================== 夹具 ==================== + + /** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */ +diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java +index 1279736..c68c859 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java +@@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; + import com.yunhai.flowcenter.entity.FlwDef; + import com.yunhai.flowcenter.engine.FlowEngine; + import com.yunhai.flowcenter.enums.FlwEnums; ++import com.yunhai.flowcenter.enums.FormBindingErrorCode; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; + import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; + import com.yunhai.flowcenter.repository.FlwDefRepository; + import com.yunhai.flowcenter.repository.FlwInstanceRepository; +@@ -45,13 +48,12 @@ import static org.mockito.Mockito.when; + * 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点); + *
  • 发布取确切版本:带版本的关联走 {@code schema?uuid=&version=}; + * 中心返回的坐标与请求不符即阻断发布,而不是把错的快照嵌进 def 版本行; +- * 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;
  • ++ * 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本; + *
  • 目录客户端的调用形态:命名目标 {@code form-center} + 查询里带 schId/page/pageSize, + * 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。
  • + * + * +- *

    注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是 +- * 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。

    ++ *

    发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。

    + */ + @DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)") + class FlFormBindingTest { +@@ -125,6 +127,7 @@ class FlFormBindingTest { + f.put("key", key); + f.put("type", type); + f.put("required", Boolean.FALSE); ++ if ("select".equals(type)) f.put("enum", Arrays.asList("g1", "g2")); + return f; + } + +@@ -134,6 +137,7 @@ class FlFormBindingTest { + @DisplayName("成对提交:uuid 与 version 一起落行") + void pairIsSavedTogether() { + when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null))); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3)); + + FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON, + null, null, null, "FU1", 3); +@@ -198,7 +202,6 @@ class FlFormBindingTest { + FlwDef published = service.publish(SCH, 9L); + + verify(schemaClient).fetchSchema(SCH, "FU1", 3); +- verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); + assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson()); + assertTrue(published.getContentJson().contains("FU1")); + } +@@ -214,33 +217,123 @@ class FlFormBindingTest { + + BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + ++ assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode()); + assertTrue(e.getMessage().contains("核验失败"), e.getMessage()); + assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage()); + assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); + } + +- @Test +- @DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径") +- void legacyRowWithoutVersionKeepsOldBehaviour() { ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ @DisplayName("缺版本:发布和启用均阻断,不回落当前版本") ++ void missingVersionBlocksPublishAndEnable(boolean enable) { + FlwDef def = draft("FU1", null); ++ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9)); + +- service.publish(SCH, 9L); ++ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); + +- verify(schemaClient).fetchSchema(SCH, "FU1"); ++ assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode()); ++ assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); + verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); ++ verify(defRepository, never()).save(any()); ++ } ++ ++ private FlwDef changeStatus(boolean enable) { ++ return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void invalidVersionBlocksPublishAndEnable(boolean enable) { ++ FlwDef def = draft("FU1", 0); ++ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ when(defRepository.findById(9L)).thenReturn(Optional.of(def)); ++ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); ++ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); ++ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); ++ verify(defRepository, never()).save(any()); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void orphanVersionBlocksPublishAndEnable(boolean enable) { ++ FlwDef def = draft(null, 3); ++ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ when(defRepository.findById(9L)).thenReturn(Optional.of(def)); ++ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); ++ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); ++ verify(defRepository, never()).save(any()); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(booleans = {false, true}) ++ void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) { ++ FlwDef def = draft("FU1", 3); ++ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ when(defRepository.findById(9L)).thenReturn(Optional.of(def)); ++ RuntimeException[] failures = { ++ new FormCenterSchemaClient.SchemaUnavailableException("timeout", null), ++ new FormCenterSchemaClient.SchemaContractViolationException("wrong version"), ++ new FormCenterSchemaClient.SchemaResourceUnavailableException(null), ++ new FormCenterSchemaClient.SchemaAccessDeniedException(null) ++ }; ++ FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE, ++ FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE, ++ FormBindingErrorCode.ACCESS_DENIED}; ++ for (int i = 0; i < failures.length; i++) { ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]); ++ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); ++ assertEquals(codes[i].getCode(), ex.getErrorCode().getCode()); ++ assertSame(failures[i], ex.getCause()); ++ assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); ++ assertEquals(FLOW_JSON, def.getContentJson()); ++ org.mockito.Mockito.reset(schemaClient); ++ } ++ verify(defRepository, never()).save(any()); ++ } ++ ++ @Test ++ void enableUsesExactVersionAndPreservesPublishedSnapshot() { ++ FlwDef def = draft("FU1", 3); ++ def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1) ++ + ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}"); ++ String frozenJson = def.getContentJson(); ++ when(defRepository.findById(9L)).thenReturn(Optional.of(def)); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3)); ++ FlwDef enabled = service.enable(SCH, 9L); ++ assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus()); ++ assertEquals(frozenJson, enabled.getContentJson()); ++ verify(schemaClient).fetchSchema(SCH, "FU1", 3); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"}) ++ void malformedVersionAtHttpBoundaryHasStableCode(String value) { ++ FlowAdminGuard guard = mock(FlowAdminGuard.class); ++ when(guard.requireAdmin(any(), any())).thenReturn(SCH); ++ com.yunhai.flowcenter.controller.api.FlowAdminController controller = ++ new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service, ++ null, null, null, null, null, null, new ObjectMapper(), null, null, null); ++ Map body = new LinkedHashMap<>(); ++ body.put("formUuid", "FU1"); ++ body.put("formVersion", value); ++ BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body)); ++ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); ++ verify(defRepository, never()).save(any()); ++ org.mockito.Mockito.verifyNoInteractions(schemaClient); + } + + @Test + @DisplayName("未关联表单的流程:一次都不碰表单中心") + void noBindingNoCall() { + FlwDef def = draft(null, null); ++ def.setContentJson("{\"variables\":[{\"key\":\"days\",\"type\":\"number\"},{\"key\":\"grade\",\"type\":\"string\"}]," + FLOW_JSON.substring(1)); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + + service.publish(SCH, 9L); + +- verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); + verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); + } + +diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java +index 18f9c38..4d6d172 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java +@@ -85,6 +85,7 @@ class FlowDefAdminServicePublishContractTest { + d.setAppKey("notice"); + d.setContentJson(JSON); + d.setFormUuid(formUuid); ++ d.setFormVersion(formUuid == null ? null : 3); + return d; + } + +@@ -115,7 +116,7 @@ class FlowDefAdminServicePublishContractTest { + @Test + void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + FlwDef published = service.publish(SCH, 9L); + +@@ -134,7 +135,7 @@ class FlowDefAdminServicePublishContractTest { + // 出口条件改成引用快照外变量 + d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage()); +@@ -147,7 +148,7 @@ class FlowDefAdminServicePublishContractTest { + FlwDef d = draft("FU1"); + d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"), +@@ -159,7 +160,7 @@ class FlowDefAdminServicePublishContractTest { + FlwDef d = draft("FU1"); + d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"), +@@ -171,7 +172,7 @@ class FlowDefAdminServicePublishContractTest { + FlwDef d = draft("FU1"); + d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"), +@@ -184,7 +185,7 @@ class FlowDefAdminServicePublishContractTest { + FlwDef d = draft("FU1"); + d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); + } +@@ -195,29 +196,139 @@ class FlowDefAdminServicePublishContractTest { + FlwDef d = draft("FU1"); + d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'")); + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); +- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); + } + + @Test +- void legacyDefWithoutFormUuidSkipsSnapshotEntirely() { ++ void newUnboundVersionCannotOmitVariables() { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null))); ++ assertTrue(assertThrows(BizException.class, () -> service.publish(SCH, 9L)) ++ .getMessage().contains("必须声明 variables")); ++ verify(defRepository, never()).save(any()); ++ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); ++ } + +- FlwDef published = service.publish(SCH, 9L); ++ @Test ++ void draftCannotBypassPublishContractThroughOfflineAndEnable() { ++ FlwDef d = draft(null); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ assertThrows(BizException.class, () -> service.offline(SCH, 9L)); ++ assertThrows(BizException.class, () -> service.enable(SCH, 9L)); ++ assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), d.getStatus()); ++ verify(defRepository, never()).save(any()); ++ } + +- assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus()); +- assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动"); +- verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); ++ @Test ++ void unboundDeclarationsUseSameRulesAsFrozenFcDeclarations() throws Exception { ++ FlwDef bound = draft("FU1"); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(bound)); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); ++ String published = service.publish(SCH, 9L).getContentJson(); ++ FlwDef unbound = draft(null); ++ unbound.setContentJson(published); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(unbound)); ++ service.publish(SCH, 9L); ++ ApplyVariableValidator validator = new ApplyVariableValidator(mapper); ++ for (FlwDef d : Arrays.asList(bound, unbound)) { ++ com.yunhai.flowcenter.engine.FlowDefModel model = com.yunhai.flowcenter.engine.FlowDefModel.parse(mapper, d.getContentJson()); ++ validator.requireVariablesPresent(model, "{\"days\":4,\"grade\":\"g1\",\"reason\":\"ok\"}"); ++ for (String bad : Arrays.asList("{\"days\":4,\"grade\":\"g1\"}", ++ "{\"days\":4,\"grade\":\"g9\",\"reason\":\"ok\"}", ++ "{\"days\":\"4\",\"grade\":\"g1\",\"reason\":\"ok\"}", ++ "{\"days\":4,\"grade\":\"g1\",\"reason\":\"ok\",\"extra\":1}")) { ++ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, bad)); ++ } ++ } ++ } ++ ++ @Test ++ void enableUsesFrozenContractAndDoesNotReplaceItFromRemoteSchema() { ++ FlwDef d = draft("FU1"); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); ++ service.publish(SCH, 9L); ++ String frozen = d.getContentJson(); ++ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ Map changed = snapshot(); ++ changed.put("fields", Arrays.asList(field("other", "text", true, null))); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(changed); ++ service.enable(SCH, 9L); ++ assertEquals(frozen, d.getContentJson()); ++ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ d.setContentJson(frozen.replace("grade == 'g1'", "grade == 'g9'")); ++ assertThrows(BizException.class, () -> service.enable(SCH, 9L)); ++ } ++ ++ @Test ++ void historicalDisabledVersionWithoutDeclarationsCanBeEnabledUnchanged() { ++ FlwDef d = draft(null); ++ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ service.enable(SCH, 9L); ++ assertEquals(JSON, d.getContentJson()); ++ } ++ ++ @Test ++ void fcVariablesAreDerivedEvenIfDraftTriesToSupplyEmptyDeclarations() throws Exception { ++ FlwDef d = draft("FU1"); ++ d.setContentJson(JSON.replaceFirst("\\{", "{\"variables\":[],")); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); ++ service.publish(SCH, 9L); ++ JsonNode vars = mapper.readTree(d.getContentJson()).path("variables"); ++ assertEquals(3, vars.size()); ++ assertEquals("number", vars.get(0).path("type").asText()); ++ assertTrue(vars.get(2).path("required").asBoolean()); ++ assertEquals("g1", vars.get(1).path("enum").get(0).asText()); ++ } ++ ++ @Test ++ void allFcTypesPreserveActualSerializedShapes() { ++ java.util.ArrayList> fields = new java.util.ArrayList<>(); ++ for (String type : Arrays.asList("text", "textarea", "phone", "number", "date", "select", "radio", ++ "checkbox", "multiselect", "upload", "image", "address", "user", "users")) { ++ fields.add(field(type, type, false, Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type) ++ ? Arrays.asList("v1", "v2") : null)); ++ } ++ Map snap = snapshot(); snap.put("fields", fields); ++ FlwDef d = draft("FU1"); ++ d.setContentJson(JSON.replace("days > 3 && schId > 0", "schId > 0").replace("grade == 'g1'", "true")); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snap); ++ service.publish(SCH, 9L); ++ com.yunhai.flowcenter.engine.FlowDefModel model = com.yunhai.flowcenter.engine.FlowDefModel.parse(mapper, d.getContentJson()); ++ ApplyVariableValidator validator = new ApplyVariableValidator(mapper); ++ validator.requireVariablesPresent(model, "{\"text\":\"ok\",\"textarea\":\"ok\",\"phone\":\"123\",\"number\":1.5," ++ + "\"date\":\"2024-02-29\",\"select\":\"v1\",\"radio\":\"v2\",\"checkbox\":[\"v1\"],\"multiselect\":[\"v2\"]," ++ + "\"upload\":[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}],\"image\":[],\"address\":[\"广东省\"]," ++ + "\"user\":[{\"id\":101,\"name\":\"A\"}],\"users\":[]}"); ++ validator.requireVariablesPresent(model, "{\"date\":\"\",\"select\":\"\",\"number\":null,\"checkbox\":[]}"); ++ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"checkbox\":[\"forged\"]}")); ++ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"number\":\"\"}")); ++ } ++ ++ @Test ++ void unboundEmptyDeclarationsCannotHideExpressionReferencesAndNullCannotOptOut() { ++ for (String variables : Arrays.asList("[]", "null")) { ++ FlwDef d = draft(null); ++ d.setContentJson("{\"variables\":" + variables + "," + JSON.substring(1)); ++ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); ++ assertThrows(BizException.class, () -> service.publish(SCH, 9L)); ++ } ++ verify(defRepository, never()).save(any()); + } + + @Test + void schemaUnavailableBlocksPublishAsRetryableBizError() { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); +- when(schemaClient.fetchSchema(SCH, "FU1")) ++ when(schemaClient.fetchSchema(SCH, "FU1", 3)) + .thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null)); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); ++ assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(), ++ ex.getErrorCode().getCode()); + assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage()); + verify(defRepository, never()).save(any()); + } +diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java +index 01f655c..77cd33c 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java +@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; + import java.util.Optional; + + import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; + import static org.junit.jupiter.api.Assertions.assertNull; + import static org.junit.jupiter.api.Assertions.assertThrows; + import static org.junit.jupiter.api.Assertions.assertTrue; +@@ -64,7 +65,9 @@ class FlowDefAdminServiceUpdateMetaTest { + } + + @Test +- void 三不变_contentJson仅顶层name() throws Exception { ++ void 已发布版本_contentJson封存_仅行内元数据可改() throws Exception { ++ // T03(2026-10-25)发布封存:执行文档不可变——行 name 照改(目录元数据), ++ // content_json 顶层 name 保持发布时字节(旧断言"同步改 JSON"已按新原则作废) + String json = "{\"code\":\"work-leave\",\"name\":\"旧名\",\"start\":\"start\"," + + "\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"end\"}]}"; +@@ -79,23 +82,36 @@ class FlowDefAdminServiceUpdateMetaTest { + assertEquals("work-leave", saved.getCode()); + assertEquals("新名称", saved.getName()); + assertEquals("100008", saved.getAppKey()); +- assertEquals("新名称", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText()); +- // 结构零改动:节点/边/其余顶层字段原样保留 ++ // 发布字节冻结:文档内 name 不随目录改名漂移 ++ assertEquals("旧名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText()); + assertEquals("work-leave", new ObjectMapper().readTree(saved.getContentJson()).path("code").asText()); + assertTrue(saved.getContentJson().contains("\"type\":\"START\"")); + } + + @Test +- void contentJson无顶层name_补写() throws Exception { ++ void 已发布版本_无顶层name_不补写() throws Exception { ++ // 发布封存:缺顶层 name 的已发布文档维持原样,不补写 + FlwDef def = published("{\"code\":\"c\",\"start\":\"start\",\"nodes\":[],\"edges\":[]}"); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + + FlwDef saved = service.updateMeta(SCH, 9L, "补写名", null); + +- assertEquals("补写名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText()); ++ assertFalse(new ObjectMapper().readTree(saved.getContentJson()).has("name")); + assertNull(saved.getAppKey()); + } + ++ @Test ++ void 草稿版本_contentJson顶层name仍同步() throws Exception { ++ // 草稿文档可变:顶层 name 同步保留(与发布封存互为边界) ++ FlwDef def = published("{\"code\":\"c\",\"name\":\"旧名\",\"start\":\"start\",\"nodes\":[],\"edges\":[]}"); ++ def.setStatus(0); // DRAFT ++ when(defRepository.findById(9L)).thenReturn(Optional.of(def)); ++ ++ FlwDef saved = service.updateMeta(SCH, 9L, "草稿新名", null); ++ ++ assertEquals("草稿新名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText()); ++ } ++ + @Test + void appKey白名单外_被拦() { + when(appRepository.isEnabledApp(SCH, "888888")).thenReturn(false); +diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java +index 6f149f6..d8a2994 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java +@@ -29,8 +29,8 @@ import static org.mockito.Mockito.verify; + import static org.mockito.Mockito.when; + + /** +- * F5-4 僵尸逃生门(useLatestDef):v1 表达式必失败挂起 → 默认重跑仍按 v1 快照(行为逐字不变); +- * useLatestDef=true 改取当前已发布最高版本 v2 → 快照刷新 + trace 留痕 + 引擎按 v2 续走; ++ * T03 在途锁版(2026-10-25):默认重跑按 v1 快照(行为逐字不变);useLatestDef=true 已按 ++ * "在途实例固定原版本"原则停用——显式拒绝并说明替代路径,不静默切换; + * 在途 CONDITION 节点在新定义不存在时报人类可读业务错。锚定 128/129 僵尸案例。 + */ + @DisplayName("reroute useLatestDef(F5-4 僵尸逃生门)") +@@ -116,57 +116,43 @@ class FlowRouterAdminServiceRerouteDefTest { + assertTrue(e.getMessage().contains("判断仍失败"), "v1 必失败场景默认重跑仍失败挂起(逃生门入口即为此)"); + } + ++ // ==================== T03 在途锁版(2026-10-25):useLatestDef 旁路停用 ==================== ++ + @Test +- void useLatestDefSwitchesToV2AndRefreshesSnapshotWithTrace() { ++ void useLatestDefIsRejectedByDesign() { ++ // 在途实例固定原版本(主方案 §1.3 / T03):新发布版只影响新申报,不再提供"切最新版续走"旁路; ++ // 挂起件按原定义重跑,修复后的表达式按原版本重新发布生效 + FlwDef v1 = def(11L, 1, V1); +- FlwDef v2 = def(12L, 2, V2_WITH_COND); +- FlwInstance instance = parkedInstance(v1); +- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) +- .thenReturn(Optional.of(v2)); +- +- service.reroute(SCH, 500L, true); ++ parkedInstance(v1); + +- ArgumentCaptor defCap = ArgumentCaptor.forClass(FlwDef.class); +- verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1")); +- assertEquals(12L, defCap.getValue().getId(), "useLatestDef=true 引擎按 v2 续走"); +- assertEquals(12L, instance.getDefId(), "定义快照三列同步刷新(R4 同语义)"); +- assertEquals(Integer.valueOf(2), instance.getDefVersion()); +- ArgumentCaptor trace = ArgumentCaptor.forClass(FlwTask.class); +- verify(taskRepository).save(trace.capture()); +- assertEquals(FlwEnums.TaskStatus.NODE_REROUTE_DEF, trace.getValue().getNodeId(), +- "trace 落保留节点 id 的留痕行"); +- assertTrue(trace.getValue().getNodeName().contains("v1→v2"), "留痕行载明版本迁移"); +- verify(instanceRepository).save(instance); ++ BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true)); ++ assertTrue(e.getMessage().contains("在途换版已停用"), ++ "useLatestDef=true 必须显式拒绝并给出替代路径说明,不能静默忽略"); ++ verify(flowEngine, never()).rerouteCondition(any(), any(), any()); ++ verify(defRepository, never()) ++ .findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any()); ++ verify(taskRepository, never()).save(any()); + } + + @Test +- void useLatestDefWithSameVersionIsNoopRefresh() { +- FlwDef v1 = def(11L, 1, V1); +- FlwInstance instance = parkedInstance(v1); +- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) +- .thenReturn(Optional.of(v1)); +- +- service.reroute(SCH, 500L, true); +- +- verify(flowEngine).rerouteCondition(any(), eq(v1), eq("n1")); +- verify(taskRepository, never()).save(any()); +- assertEquals(11L, instance.getDefId(), "已是最新版不落留痕行、不刷新"); ++ void useLatestDefRejectionHappensBeforeAnyLookup() { ++ // 显式拒绝发生在任何查询之前:即使办件 id 不存在也是同一个"已停用"错(不泄存在性、不误查) ++ BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 99999L, true)); ++ assertTrue(e.getMessage().contains("在途换版已停用")); ++ verify(instanceRepository, never()).findById(any()); + } + + @Test +- void useLatestDefRejectsWhenNodeMissingInNewDef() { ++ void defaultRerouteStillWorksAfterLockdown() { ++ // 旁路停用不影响默认路径:按原版本快照重跑照常 + FlwDef v1 = def(11L, 1, V1); +- // v2 把 n1 改型为 APPROVAL:在途 CONDITION 节点在新定义不存在 +- FlwDef v2 = def(12L, 2, V2); +- parkedInstance(v1); +- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( +- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) +- .thenReturn(Optional.of(v2)); ++ FlwInstance instance = parkedInstance(v1); + +- BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true)); +- assertTrue(e.getMessage().contains("最新已发布定义中不存在"), "节点在新定义缺失要报人类可读错,不是笼统的无需重跑"); +- verify(flowEngine, never()).rerouteCondition(any(), any(), any()); ++ service.reroute(SCH, 500L, false); ++ ++ ArgumentCaptor defCap = ArgumentCaptor.forClass(FlwDef.class); ++ verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1")); ++ assertEquals(11L, defCap.getValue().getId(), "锁版后默认重跑仍按原版本(v1)"); ++ assertEquals(11L, instance.getDefId(), "定义快照零刷新"); + } + } +diff --git a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java +index 472668f..73aad4e 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java +@@ -2,6 +2,12 @@ package com.yunhai.flowcenter.service; + + import com.fasterxml.jackson.databind.ObjectMapper; + import com.yunhai.platform.openapi.core.YschOpenCall; ++import com.yunhai.platform.openapi.core.OpenApiException; ++import com.yunhai.platform.openapi.core.OpenApiErrorCode; ++import com.yunhai.platform.utils.exception.DException; ++import org.junit.jupiter.params.ParameterizedTest; ++import org.junit.jupiter.params.provider.ValueSource; ++import org.junit.jupiter.params.provider.NullSource; + import org.junit.jupiter.api.BeforeEach; + import org.junit.jupiter.api.Test; + +@@ -72,7 +78,7 @@ class FormCenterSchemaClientTest { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER"))); + +- Map snap = client.fetchSchema(27L, "U1"); ++ Map snap = client.fetchSchema(27L, "U1", 3); + + assertEquals("U1", snap.get("formUuid")); + assertEquals(3, snap.get("formVersion")); +@@ -88,11 +94,11 @@ class FormCenterSchemaClientTest { + + @Test + void legacySingleStringAudienceIsWrappedAsArray() { +- Map data = schemaData("U1", 1, null); ++ Map data = schemaData("U1", 3, null); + data.put("audience", "STUDENT"); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); + +- Map snap = client.fetchSchema(27L, "U1"); ++ Map snap = client.fetchSchema(27L, "U1", 3); + + assertEquals(Arrays.asList("STUDENT"), snap.get("audience"), + "C5 切换窗口的旧单值串按一元素数组容错"); +@@ -104,7 +110,7 @@ class FormCenterSchemaClientTest { + .thenThrow(new RuntimeException("connect refused")); + + assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, +- () -> client.fetchSchema(27L, "U1")); ++ () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) + .get(anyString(), anyString(), anyMap(), eq(Map.class)); + } +@@ -115,7 +121,77 @@ class FormCenterSchemaClientTest { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null); + + assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, +- () -> client.fetchSchema(27L, "U1")); +- assertTrue(true); ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) ++ .get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @ParameterizedTest ++ @NullSource ++ @ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"}) ++ void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) { ++ Map data = schemaData("U1", 3, Arrays.asList("STUDENT")); ++ data.put("version", returnedVersion); ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); ++ assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @ParameterizedTest ++ @NullSource ++ @ValueSource(strings = {"", "U2"}) ++ void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) { ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) ++ .thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT"))); ++ assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {0, -1}) ++ void illegalRequestedVersionMakesNoRemoteCall(int version) { ++ assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version)); ++ org.mockito.Mockito.verifyNoInteractions(openCall); ++ } ++ ++ @Test ++ void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() { ++ // SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。 ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) ++ .thenThrow(new DException(new OpenApiErrorCode(2, "opaque"))); ++ assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @ParameterizedTest ++ @ValueSource(ints = {3, 4}) ++ void authRejectionDoesNotBlindlyRetry(int code) { ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) ++ .thenThrow(new DException(new OpenApiErrorCode(code, "opaque"))); ++ assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @Test ++ void http404DoesNotBlindlyRetry() { ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) ++ .thenThrow(new OpenApiException("opaque", 404, "")); ++ assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); ++ } ++ ++ @Test ++ void internalRemoteFailureRemainsRetryable() { ++ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) ++ .thenThrow(new DException(new OpenApiErrorCode(6, "opaque"))); ++ assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, ++ () -> client.fetchSchema(27L, "U1", 3)); ++ verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) ++ .get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + } +diff --git a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java +index 24d705e..6e804ea 100644 +--- a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java ++++ b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java +@@ -47,6 +47,7 @@ class OapiFlowApplyDefVersionTest { + private FlowInstanceService instanceService; + private AssigneeResolver assigneeResolver; + private OapiFlowService service; ++ private FlwInstanceRepository instances; + + @BeforeEach + @SuppressWarnings("unchecked") +@@ -54,7 +55,8 @@ class OapiFlowApplyDefVersionTest { + defRepository = mock(FlwDefRepository.class); + instanceService = mock(FlowInstanceService.class); + assigneeResolver = mock(AssigneeResolver.class); +- service = new OapiFlowService(defRepository, mock(FlwInstanceRepository.class), ++ instances = mock(FlwInstanceRepository.class); ++ service = new OapiFlowService(defRepository, instances, + mock(FlwTaskRepository.class), mock(FlwCcRepository.class), + mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class), + mock(FlowEngine.class), assigneeResolver, new ObjectMapper(), +@@ -150,4 +152,20 @@ class OapiFlowApplyDefVersionTest { + assertEquals(Integer.valueOf(1), vo.getDefVersion()); + assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty()); + } ++ @Test ++ void v2ResponseCarriesRealRejectedStatusAndStableBranch() { ++ stubDef(3); ++ com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 body = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2(); ++ body.setFlowGuid(FLOW_GUID); body.setBizType("svc-hall"); body.setBizId("42"); body.setSubmitterId(21); ++ body.setRequestToken("token-1"); body.setAttempt(1); ++ when(instanceService.applyGuarded(any(), any())).thenReturn( ++ new FlowInstanceService.GuardedOutcome(500L, true, "SAME_REQUEST", 1)); ++ com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance(); ++ row.setId(500L); row.setStatus(30); row.setVersion(4); row.setApplyRound(1); ++ when(instances.findById(500L)).thenReturn(Optional.of(row)); ++ com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyResultV2 result = service.applyV2(SCH_ID, "100009", body); ++ assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound()); ++ assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion()); ++ } ++ + } +diff --git a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java +index 9c02828..339c7c3 100644 +--- a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java ++++ b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java +@@ -22,6 +22,14 @@ public class StubUserClient implements OpenApiUserClient { + + public static final long TEST_SCH_ID = 1L; + ++ /** D11 资格终审夹具:特殊账号按固定 ID 下发,避免各 IT 各自造 UserBase 桩。 */ ++ public static final int DISABLED_USER_ID = 9001; ++ public static final int OTHER_SCHOOL_USER_ID = 9002; ++ public static final int UNKNOWN_STATUS_USER_ID = 9003; ++ public static final int NO_SCHOOL_USER_ID = 9004; ++ public static final int MISSING_USER_ID = 9999; ++ public static final int USERBASE_UNAVAILABLE_ID = 9998; ++ + @Override + public BoaUser getUserById(String userId) { + return buildUser(Integer.valueOf(userId)); +@@ -33,8 +41,29 @@ public class StubUserClient implements OpenApiUserClient { + } + + private static BoaUser buildUser(int userId) { ++ if (userId == MISSING_USER_ID) { ++ return null; ++ } ++ if (userId == USERBASE_UNAVAILABLE_ID) { ++ throw new IllegalStateException("stub UserBase unavailable"); ++ } + BoaUser user = new BoaUser(userId, "user" + userId, "用户" + userId); +- user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校")); ++ if (userId == NO_SCHOOL_USER_ID) { ++ user.setSchool(null); ++ } else if (userId == OTHER_SCHOOL_USER_ID) { ++ user.setSchool(new BoaGroup(2, "外校")); ++ } else { ++ user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校")); ++ } ++ // T06 禁用账号终审(2026-10-25):stub 用户按在册正常账号下发(1=正常), ++ // 与 UserBase toLightBoaUser 口径对齐;个别用例自建 BoaUser 不受影响 ++ if (userId == DISABLED_USER_ID) { ++ user.setStatus(0); ++ } else if (userId == UNKNOWN_STATUS_USER_ID) { ++ user.setStatus(null); ++ } else { ++ user.setStatus(1); ++ } + return user; + } + +diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java +index 5e764f4..e71bcf6 100644 +--- a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java ++++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java +@@ -54,6 +54,10 @@ public abstract class FlowTestSupport { + protected FlwRouterRepository routerRepository; + @Autowired + protected FlwRouteLogRepository routeLogRepository; ++ @Autowired ++ protected com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository; ++ @Autowired ++ protected com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository advanceIntentRepository; + + /** tag → 实际 deptKey(flw_dept.id 字符串);mapDept 注册,def helper 消费 */ + private final Map deptKeyByTag = new HashMap<>(); +@@ -64,6 +68,11 @@ public abstract class FlowTestSupport { + } + + protected void cleanDb() { ++ // D09: intents must not outlive the tasks/instances whose activations they describe. ++ advanceIntentRepository.deleteAll(); ++ // 事实行先清:否则上一用例/上一轮遗留的 dispatched=0 事实会被 60s 补分发扫描 ++ // 按实例行复活重投(跨类注入并发推送者;FormalApplyCommandIT 此前被迫自清即此缺口) ++ stateEventRepository.deleteAll(); + logRepository.deleteAll(); + regRepository.deleteAll(); + ccRepository.deleteAll(); +diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml +index ac07ccc..baa7036 100644 +--- a/src/test/resources/application-test.yml ++++ b/src/test/resources/application-test.yml +@@ -10,6 +10,15 @@ spring: + yh: + app-auth: + trust-registry: false ++ # R2:test 域引入 yh-flow-callback-spring-boot-starter(真实接收端契约测试用), ++ # 关闭其自动装配——FL 是回调生产方不是消费方,测试上下文不应多出接收端点/存储表 ++ flow-callback: ++ enabled: false ++ ++# 回调推送管道 DEBUG(P1-1 插桩:在途跳过/行缺席早退此前零痕迹,计数被吞无从取证) ++logging: ++ level: ++ com.yunhai.flowcenter.callback: debug + + flow-center: + usermsg: diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.txt new file mode 100644 index 0000000..9b39eeb --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.txt new file mode 100644 index 0000000..046d57e --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.callback.FlStateEventRecoveryTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.txt new file mode 100644 index 0000000..b3fcf85 --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlApplyRecoveryTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlApplyRecoveryTest.txt new file mode 100644 index 0000000..15084f4 --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlApplyRecoveryTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlDefLineOfflineTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlDefLineOfflineTest.txt new file mode 100644 index 0000000..61dd17c --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlDefLineOfflineTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlNewInstancePathTest.txt b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlNewInstancePathTest.txt new file mode 100644 index 0000000..f98934f --- /dev/null +++ b/docs/evidence/core-fix-20261027/com.yunhai.flowcenter.service.FlNewInstancePathTest.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/environment.txt b/docs/evidence/core-fix-20261027/environment.txt new file mode 100644 index 0000000..0797343 --- /dev/null +++ b/docs/evidence/core-fix-20261027/environment.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/final-source-sha256.txt b/docs/evidence/core-fix-20261027/final-source-sha256.txt new file mode 100644 index 0000000..1883ffb --- /dev/null +++ b/docs/evidence/core-fix-20261027/final-source-sha256.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/final-status.txt b/docs/evidence/core-fix-20261027/final-status.txt new file mode 100644 index 0000000..ec6370c --- /dev/null +++ b/docs/evidence/core-fix-20261027/final-status.txt @@ -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 diff --git a/docs/evidence/core-fix-20261027/patch-check.txt b/docs/evidence/core-fix-20261027/patch-check.txt new file mode 100644 index 0000000..003556a --- /dev/null +++ b/docs/evidence/core-fix-20261027/patch-check.txt @@ -0,0 +1 @@ +git apply --reverse --check task-only.patch exit=0 diff --git a/docs/evidence/core-fix-20261027/task-only.patch b/docs/evidence/core-fix-20261027/task-only.patch new file mode 100644 index 0000000..92bb992 --- /dev/null +++ b/docs/evidence/core-fix-20261027/task-only.patch @@ -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 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 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 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 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()); ++ } ++} 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 index 0000000..0c35bb7 --- /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 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 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 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 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()); + } +}