From 05d2143ce1de713c5f1d396d76a8577ecfa41aa1 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 19 Oct 2026 23:49:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(p3.2):=20bizType=20=E6=96=B9=E6=A1=88B?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B8=B8=E9=87=8F=E6=8D=A2=E7=BB=88=E5=80=BC?= =?UTF-8?q?=20svc-hall=20+=20=E5=8F=8C=E5=80=BC=E5=8F=8C=E6=9F=A5=20+=20P1?= =?UTF-8?q?-02=20=E8=A1=8C=E7=BA=A7=E7=BB=91=E5=AE=9A=E6=A0=A1=E9=AA=8C=20?= =?UTF-8?q?+=20P1-03=20R4=20=E6=97=B6=E5=BA=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SvcFlowCallbackHandler.BIZ_TYPE → SvcBizTypes.CURRENT(svc-hall,H-1 实核 appKey=100008 注册行 app_code);yml 回调白名单双值 [svc-hall, svc_item](收敛条件=另一类型不再可能 产生事件,本轮不收敛;回滚也保双值) - 双值双查补丁(先终值后存量,S0-H2 实测中心 svc_item 存量=0,次查为防御性兼容): ApplyViewGuard 办理人分支 / FlowReconcileJob 对账(命中判据=内容非空,兼容 miss 空载体)/ progress 空态出参 / S2 审批代理 byBiz·todo(S2 已落) - P1-02 行级事件绑定校验:事件 schId 与行一致、行有镜像实例时事件 instanceId 必须一致; 不匹配 200 消费但不推进+告警日志(不抛 5xx 防重推风暴);行无值(首发回调早于回填)放行, doFlowApply 回填侧对实例不一致告警留痕(方案B 书面接受第二实例降级) - P1-03 R4 时序修复:markResubmitted 清旧 flowResultJson(保留 lastInstanceVersion 跨轮 乱序防护);Handler 终态守卫改版本感知(事件版本>已消费版本=新轮竞态窗口放行,不再以 旧结果非空一刀切拒绝);非终态推进清残留 finishTime;三场景测试静态化注入 (新轮回调先于 apply 响应/新轮快速终态/旧轮迟到重复) - 测试断言按新语义更新:新件出站断言 svc-hall×3;全量 163/163 绿(Stage S3/P3.2) --- .../yunhai/svchall/job/FlowReconcileJob.java | 16 +++- .../repository/ServiceApplyRepository.java | 8 +- .../svchall/service/ApplyViewGuard.java | 15 ++-- .../service/impl/ServiceApplyService.java | 11 ++- .../service/impl/SvcFlowCallbackHandler.java | 76 +++++++++++++----- src/main/resources/application.yml | 5 +- .../impl/ServiceApplyFlowSubmitTest.java | 6 +- .../impl/SvcFlowCallbackHandlerTest.java | 77 +++++++++++++++++++ 8 files changed, 179 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java index 5fbce11..ff20439 100644 --- a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java +++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java @@ -4,7 +4,7 @@ import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.enums.ApplyFlowStatusEnum; import com.yunhai.svchall.repository.ServiceApplyRepository; import com.yunhai.svchall.service.FlowClient; -import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler; +import com.yunhai.svchall.service.SvcBizTypes; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; @@ -55,8 +55,18 @@ public class FlowReconcileJob { continue; } try { - Map remote = flowClient.getInstanceByBiz( - apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId())); + // 双值双查(P3.2 方案B):先终值后存量,首个命中实例为准——命中判据用内容 + // (id/status 非空)而非"非 null",对 miss 形态(运行时 data=null / 空载体)都稳 + Map remote = null; + for (String bizType : SvcBizTypes.queryOrder()) { + Map candidate = flowClient.getInstanceByBiz( + apply.getSchId(), bizType, String.valueOf(apply.getId())); + if (candidate != null + && (candidate.get("id") != null || candidate.get("status") != null)) { + remote = candidate; + break; + } + } if (remote == null) { continue; } diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java index eee3d49..056620e 100644 --- a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java +++ b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java @@ -54,12 +54,14 @@ public interface ServiceApplyRepository extends JpaRepository旧轮 flowResultJson 清空(P1-03:新轮 + * 事件版本更高,Handler 已按版本感知接受,旧结果残留会误挡新轮终态/误导详情渲染); + * lastInstanceVersion 刻意保留(跨轮乱序防护:旧轮迟到事件仍按版本门丢弃)。 + * WHERE 限定 30/40,并发窗口内若状态已被推进(如新一轮回调先置 10)则本次为幂等空更新。 */ @Modifying @Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10," - + " a.submitTime = :submitTime, a.finishTime = null" + + " a.submitTime = :submitTime, a.finishTime = null, a.flowResultJson = null" + " where a.id = :id and a.flowStatus in (30, 40)") int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId, @Param("submitTime") java.time.LocalDateTime submitTime); diff --git a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java index 97266cf..12aa9d0 100644 --- a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java +++ b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java @@ -4,7 +4,7 @@ import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.entity.ServiceApply; -import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler; +import com.yunhai.svchall.service.SvcBizTypes; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -94,11 +94,16 @@ public class ApplyViewGuard { return cached.userIds; } List assignees = Collections.emptyList(); + // 双值双查(P3.2 方案B):先终值后存量各查一次(S0-H2 实测中心存量=0,次查为 + // 防御性兼容),首个命中的实例为准 try { - Map instance = flowClient.getInstanceByBiz( - apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId())); - if (instance != null && instance.get("assigneeIds") instanceof List) { - assignees = (List) instance.get("assigneeIds"); + for (String bizType : SvcBizTypes.queryOrder()) { + Map instance = flowClient.getInstanceByBiz( + apply.getSchId(), bizType, String.valueOf(apply.getId())); + if (instance != null && instance.get("assigneeIds") instanceof List) { + assignees = (List) instance.get("assigneeIds"); + break; + } } } catch (Exception e) { // 流程中心不可达:fail-closed(不放行),日志留痕 diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java index 1451378..56e24fc 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java @@ -16,6 +16,7 @@ import com.yunhai.svchall.model.svc.SvcModels.ApplyVo; import com.yunhai.svchall.repository.ServiceApplyRepository; import com.yunhai.svchall.repository.ServiceItemRepository; import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SvcBizTypes; import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteDirectory.SiteInfo; import com.yunhai.svchall.service.SiteGuard; @@ -199,7 +200,7 @@ public class ServiceApplyService { if (apply.getFlowInstanceId() == null) { SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo(); empty.setInstanceId(null); - empty.setBizType(SvcFlowCallbackHandler.BIZ_TYPE); + empty.setBizType(SvcBizTypes.CURRENT); empty.setBizId(String.valueOf(apply.getId())); empty.setBizTitle(apply.getItemTitle()); empty.setStatus(apply.getFlowStatus()); @@ -467,6 +468,14 @@ public class ServiceApplyService { // 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted) final Integer applyIdFinal = apply.getId(); final Long instanceId = result.getInstanceId(); + // P1-02 回填侧校验:行已有镜像实例而中心返回不同实例 = 第二实例产生(方案B 书面 + // 接受的存量降级:R4 按 bizType 键 miss 新建)。仅告警留痕不阻断——阻断重提没有 + // 意义(实例已建),镜像以新实例为准回填。 + if (apply.getFlowInstanceId() != null && !apply.getFlowInstanceId().equals(instanceId)) { + log.warn("[svc-hall] 代发起返回实例与行镜像不一致(第二实例,方案B 已接受降级):" + + "applyId={} oldInstance={} newInstance={}", + applyIdFinal, apply.getFlowInstanceId(), instanceId); + } transactionTemplate.executeWithoutResult(tx -> { if (resubmit) { applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now()); diff --git a/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java index 1c50ed5..53a88ce 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java +++ b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java @@ -6,6 +6,7 @@ import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent; import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.enums.ApplyFlowStatusEnum; import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.SvcBizTypes; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -18,18 +19,23 @@ import java.util.Map; * 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter): * X-Flw 验签(starter 可选过滤器)、ping、必填校验、bizType 过滤、eventId 幂等、 * instanceVersion 乱序门由 starter 接收管线承担;本类只做办件镜像状态机推进—— - * 按 bizId 行锁定位 + 单据级乱序闸门 + 事件映射(submitted/task.*→10;completed→20; - * rejected→30;withdrawn→40)+ 终态不回退守卫 + 结果明细快照 + 终态 finishTime。 + * 按 bizId 行锁定位 + 行级事件绑定校验(P1-02)+ 单据级乱序闸门 + 事件映射 + * (submitted/task.*→10;completed→20;rejected→30;withdrawn→40)+ 终态不回退守卫 + * (版本感知,P1-03)+ 结果明细快照 + 终态 finishTime。 * 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账); - * 正常返回(含办件不存在/未知事件等不可重试场景)→ 200。 + * 正常返回(含办件不存在/未知事件/绑定不匹配等不可重试场景)→ 200。 */ @Slf4j @Service @RequiredArgsConstructor public class SvcFlowCallbackHandler implements FlowCallbackHandler { - /** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */ - public static final String BIZ_TYPE = "svc_item"; + /** + * 本应用业务类型(flow-center apply/bizType,uk(sch_id, biz_type, biz_id))。 + * P3.2 方案B(2026-10-19)起=终值 {@link SvcBizTypes#CURRENT}(H-1 实核注册 app_code); + * 存量 svc_item 由 yml 回调白名单双值承接(读路径双查见 {@link SvcBizTypes#queryOrder()})。 + */ + public static final String BIZ_TYPE = SvcBizTypes.CURRENT; private final ServiceApplyRepository applyRepository; private final ObjectMapper objectMapper; @@ -50,6 +56,22 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler { event.getBizId(), event.getEventId()); return; } + // 行级事件绑定校验(P1-02):双值白名单允许"收"不等于"该事件属于该行"。不匹配一律 + // 200 消费但不推进(拒收记日志,不抛 5xx——重推风暴只会刷屏不会追平,且这类错投 + // 不是重试可修复的)。行 flow_instance_id 为空(首发回调早于 apply 响应回填的合法 + // 窗口)放行,回填侧(doFlowApply)对实例不一致另有告警。 + if (event.getSchId() != null && apply.getSchId() != null + && event.getSchId().intValue() != apply.getSchId()) { + log.warn("[svc-hall] 回调学校不匹配,拒收:applyId={} applySch={} eventSch={} eventId={}", + applyId, apply.getSchId(), event.getSchId(), event.getEventId()); + return; + } + if (apply.getFlowInstanceId() != null && event.getInstanceId() != null + && !apply.getFlowInstanceId().equals(event.getInstanceId())) { + log.warn("[svc-hall] 回调实例不匹配,拒收:applyId={} applyInstance={} eventInstance={} eventId={}", + applyId, apply.getFlowInstanceId(), event.getInstanceId(), event.getEventId()); + return; + } Integer version = event.getInstanceVersion(); // 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)—— // 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1) @@ -64,27 +86,40 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler { applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus()); } - /** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */ + /** + * 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态)。 + * 终态守卫按版本感知(P1-03):行已终态时,仅当事件版本>已消费版本(=新一轮 + * R4 重启的回调早于 markResubmitted 回开的竞态窗口)才放行推进——新轮回调先到/新轮 + * 快速终态两类时序不再被"旧结果非空"一刀切拒绝;旧轮迟到/重复(版本不高于已消费) + * 仍按原守卫拒收,不回退不覆盖。 + */ private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload) { Integer status = mapStatus(event); if (status == null) { log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event); return; } - // P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled - // 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10 - // 并用 handled 明细覆盖已落定的 flow_result_json。重提不冲突:resubmit 成功后先以 - // 窄列原子置 10,新一轮事件到达时行已非终态。 if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) { - if (!ApplyFlowStatusEnum.byCode(status).terminal()) { - log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})", - apply.getId(), apply.getFlowStatus(), event, version); - return; - } - if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) { - log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})", - apply.getId(), apply.getFlowStatus(), event); - return; + boolean newRound = version != null && apply.getLastInstanceVersion() != null + && version > apply.getLastInstanceVersion(); + if (!newRound) { + // P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled + // 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10 + // 并用 handled 明细覆盖已落定的 flow_result_json。 + if (!ApplyFlowStatusEnum.byCode(status).terminal()) { + log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})", + apply.getId(), apply.getFlowStatus(), event, version); + return; + } + if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) { + log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})", + apply.getId(), apply.getFlowStatus(), event); + return; + } + } else { + log.info("[svc-hall] 终态行收到更高版本事件(新轮回调先于回填的竞态窗口,放行):" + + "applyId={} oldStatus={} event={} v{} > v{}", + apply.getId(), apply.getFlowStatus(), event, version, apply.getLastInstanceVersion()); } } Map result = new HashMap<>(); @@ -108,6 +143,9 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler { || status.equals(ApplyFlowStatusEnum.REJECTED.getCode()) || status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) { apply.setFinishTime(LocalDateTime.now()); + } else if (apply.getFinishTime() != null) { + // 非终态推进清残留 finishTime(P1-03:新轮回调先到时行仍带旧轮 finishTime) + apply.setFinishTime(null); } applyRepository.save(apply); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d7b27ea..9123b41 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,7 +91,10 @@ yh: # Notice 线上实证此形态);secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/) flow-callback: path: /oapi/flow/callback - biz-types: [svc_item] + # P3.2 方案B(2026-10-19)双值白名单:终值 svc-hall(H-1 实核 app_code)+ 存量 svc_item。 + # 收敛条件=另一类型不再可能产生事件(覆盖 0 态重试/30/40 重提/积压核清),本轮不收敛单值; + # 回滚也保双值。读路径(guard/对账/审批代理)双查见 SvcBizTypes#queryOrder()。 + biz-types: [svc-hall, svc_item] # 漂移自动核对(Notice 同款):端点路径/ctx 变更后,镜像行有效但登记 URL 与当前期望不一致时 # 自动重新注册(会轮换 secret)——2026-10-05 改路径后 160 存量 /callback/flow 行靠它收敛 reconcile-on-register: true diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java index 927da1a..5a8660f 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceApplyFlowSubmitTest.java @@ -135,7 +135,7 @@ class ServiceApplyFlowSubmitTest { ApplyVo vo = service.submit(user(9, 1), request()); assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转"); ArgumentCaptor bizId = ArgumentCaptor.forClass(String.class); - verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), bizId.capture(), + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), bizId.capture(), anyString(), any(), any(), eq(9), eq("张三"), any()); assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)"); verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class)); @@ -148,7 +148,7 @@ class ServiceApplyFlowSubmitTest { // router.replace 同形);路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2) service.submit(user(9, 1), request()); ArgumentCaptor bizUrl = ArgumentCaptor.forClass(String.class); - verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"), + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), eq("42"), anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any()); assertEquals("/apply-detail?id=42", bizUrl.getValue()); } @@ -249,7 +249,7 @@ class ServiceApplyFlowSubmitTest { when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply)); service.resubmit(user(9, 1), 42); // 同 bizId=42(flow-center R4 命中 30/40 → 复用同一办件行重启推进) - verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"), + verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), eq("42"), anyString(), anyString(), any(), eq(9), eq("张三"), any()); verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class)); } diff --git a/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java index 182f830..ee2d232 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java @@ -162,4 +162,81 @@ class SvcFlowCallbackHandlerTest { assertThrows(org.springframework.dao.DataAccessResourceFailureException.class, () -> handler.onEvent(event("e10", "flow.completed", 3))); } + + // ===== P1-02 行级事件绑定校验(双值白名单下"收"≠"属于该行") ===== + + @Test + void binding_schoolMismatch_rejectedWithoutAdvance() { + FlowCallbackEvent e = event("b1", "flow.completed", 3); + e.setSchId(2); // 办件行 schId=1 + handler.onEvent(e); // 200 消费但不推进(不抛) + assertEquals(10, apply.getFlowStatus().intValue(), "学校不匹配拒收,状态不动"); + assertNull(apply.getFlowResultJson()); + org.mockito.Mockito.verify(applyRepository, org.mockito.Mockito.never()).save(any(ServiceApply.class)); + } + + @Test + void binding_instanceMismatch_rejectedWithoutAdvance() { + apply.setFlowInstanceId(100L); // 行已镜像实例 100 + FlowCallbackEvent e = event("b2", "flow.completed", 3); // 事件实例 9527 + handler.onEvent(e); + assertEquals(10, apply.getFlowStatus().intValue(), "实例不匹配拒收,状态不动"); + org.mockito.Mockito.verify(applyRepository, org.mockito.Mockito.never()).save(any(ServiceApply.class)); + } + + @Test + void binding_rowWithoutInstance_firstCallbackAllowed() { + // 首发回调早于 apply 响应回填的合法窗口:行无镜像实例 → 放行推进 + assertNull(apply.getFlowInstanceId()); + handler.onEvent(event("b3", "flow.task.created", 2)); + assertEquals(10, apply.getFlowStatus().intValue()); + assertEquals(2, apply.getLastInstanceVersion().intValue()); + } + + // ===== P1-03 R4 时序(H-9 三场景静态化:可控顺序注入,无真实延迟) ===== + + /** 场景 1:新轮回调先于 apply 响应(行仍 30 终态,新轮 submitted v4 先到) */ + @Test + void r4_newRoundCallbackBeforeApplyResponse_advances() { + apply.setFlowStatus(30); + apply.setFlowResultJson("{\"event\":\"flow.rejected\"}"); + apply.setFinishTime(java.time.LocalDateTime.now()); + apply.setLastInstanceVersion(3); + handler.onEvent(event("r1", "flow.submitted", 4)); + assertEquals(10, apply.getFlowStatus().intValue(), "新轮(版本更高)回调放行,回开为 10"); + assertEquals(4, apply.getLastInstanceVersion().intValue()); + assertNull(apply.getFinishTime(), "回开为在途时清残留 finishTime"); + assertTrue(apply.getFlowResultJson().contains("flow.submitted")); + } + + /** 场景 2:新轮快速终态(行仍 30,新轮 completed v4 在 markResubmitted 前落定) */ + @Test + void r4_newRoundQuickTerminal_overwritesOldResult() { + apply.setFlowStatus(30); + apply.setFlowResultJson("{\"event\":\"flow.rejected\",\"opinion\":\"旧轮意见\"}"); + apply.setFinishTime(java.time.LocalDateTime.now()); + apply.setLastInstanceVersion(3); + FlowCallbackEvent e = event("r2", "flow.completed", 4); + e.getData().put("result", "APPROVED"); + handler.onEvent(e); + assertEquals(20, apply.getFlowStatus().intValue(), "新轮终态覆盖旧轮终态"); + assertNotNull(apply.getFinishTime()); + assertTrue(apply.getFlowResultJson().contains("APPROVED"), "结果快照按新轮覆盖"); + assertEquals(4, apply.getLastInstanceVersion().intValue()); + } + + /** 场景 3:旧轮迟到/重复(版本更低被乱序门丢弃;同版本另一终态被结果守卫拒绝;同版本在途被终态守卫拒绝) */ + @Test + void r4_oldRoundLateDuplicate_rejected() { + apply.setFlowStatus(30); + apply.setFlowResultJson("{\"event\":\"flow.rejected\"}"); + apply.setLastInstanceVersion(3); + String frozen = apply.getFlowResultJson(); + + handler.onEvent(event("r3a", "flow.completed", 2)); // 旧版本:乱序门丢弃 + handler.onEvent(event("r3b", "flow.withdrawn", 3)); // 同版本另一终态:结果已落定拒 + handler.onEvent(event("r3c", "flow.task.handled", 3)); // 同版本在途:终态不回退拒 + assertEquals(30, apply.getFlowStatus().intValue(), "旧轮事件全部拒收"); + assertEquals(frozen, apply.getFlowResultJson(), "旧轮结果不被覆盖"); + } }