feat(p3.2): bizType 方案B——常量换终值 svc-hall + 双值双查 + P1-02 行级绑定校验 + P1-03 R4 时序修复
build / build-push (push) Successful in 48s
build / build-push (push) Successful in 48s
- 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)
This commit is contained in:
@@ -4,7 +4,7 @@ import com.yunhai.svchall.entity.ServiceApply;
|
|||||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||||
import com.yunhai.svchall.service.FlowClient;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
@@ -55,8 +55,18 @@ public class FlowReconcileJob {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Map<String, Object> remote = flowClient.getInstanceByBiz(
|
// 双值双查(P3.2 方案B):先终值后存量,首个命中实例为准——命中判据用内容
|
||||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
// (id/status 非空)而非"非 null",对 miss 形态(运行时 data=null / 空载体)都稳
|
||||||
|
Map<String, Object> remote = null;
|
||||||
|
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||||
|
Map<String, Object> 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) {
|
if (remote == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ public interface ServiceApplyRepository extends JpaRepository<ServiceApply, Inte
|
|||||||
* 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。
|
* 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。
|
||||||
* 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫
|
* 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫
|
||||||
* 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作,
|
* 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作,
|
||||||
* submitTime 覆盖为当下、finishTime 清空。WHERE 限定 30/40,并发窗口内若状态已被推进
|
* submitTime 覆盖为当下、finishTime 清空、<b>旧轮 flowResultJson 清空</b>(P1-03:新轮
|
||||||
* (如新一轮回调先置 10)则本次为幂等空更新。
|
* 事件版本更高,Handler 已按版本感知接受,旧结果残留会误挡新轮终态/误导详情渲染);
|
||||||
|
* lastInstanceVersion 刻意保留(跨轮乱序防护:旧轮迟到事件仍按版本门丢弃)。
|
||||||
|
* WHERE 限定 30/40,并发窗口内若状态已被推进(如新一轮回调先置 10)则本次为幂等空更新。
|
||||||
*/
|
*/
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10,"
|
@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)")
|
+ " where a.id = :id and a.flowStatus in (30, 40)")
|
||||||
int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId,
|
int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId,
|
||||||
@Param("submitTime") java.time.LocalDateTime submitTime);
|
@Param("submitTime") java.time.LocalDateTime submitTime);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import com.yunhai.platform.apistd.BizException;
|
|||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
import com.yunhai.svchall.entity.ServiceApply;
|
import com.yunhai.svchall.entity.ServiceApply;
|
||||||
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
|
import com.yunhai.svchall.service.SvcBizTypes;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -94,11 +94,16 @@ public class ApplyViewGuard {
|
|||||||
return cached.userIds;
|
return cached.userIds;
|
||||||
}
|
}
|
||||||
List<Integer> assignees = Collections.emptyList();
|
List<Integer> assignees = Collections.emptyList();
|
||||||
|
// 双值双查(P3.2 方案B):先终值后存量各查一次(S0-H2 实测中心存量=0,次查为
|
||||||
|
// 防御性兼容),首个命中的实例为准
|
||||||
try {
|
try {
|
||||||
|
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
apply.getSchId(), bizType, String.valueOf(apply.getId()));
|
||||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||||
assignees = (List<Integer>) instance.get("assigneeIds");
|
assignees = (List<Integer>) instance.get("assigneeIds");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 流程中心不可达:fail-closed(不放行),日志留痕
|
// 流程中心不可达:fail-closed(不放行),日志留痕
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
|
|||||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||||
import com.yunhai.svchall.service.FlowClient;
|
import com.yunhai.svchall.service.FlowClient;
|
||||||
|
import com.yunhai.svchall.service.SvcBizTypes;
|
||||||
import com.yunhai.svchall.service.SiteDirectory;
|
import com.yunhai.svchall.service.SiteDirectory;
|
||||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||||
import com.yunhai.svchall.service.SiteGuard;
|
import com.yunhai.svchall.service.SiteGuard;
|
||||||
@@ -199,7 +200,7 @@ public class ServiceApplyService {
|
|||||||
if (apply.getFlowInstanceId() == null) {
|
if (apply.getFlowInstanceId() == null) {
|
||||||
SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo();
|
SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo();
|
||||||
empty.setInstanceId(null);
|
empty.setInstanceId(null);
|
||||||
empty.setBizType(SvcFlowCallbackHandler.BIZ_TYPE);
|
empty.setBizType(SvcBizTypes.CURRENT);
|
||||||
empty.setBizId(String.valueOf(apply.getId()));
|
empty.setBizId(String.valueOf(apply.getId()));
|
||||||
empty.setBizTitle(apply.getItemTitle());
|
empty.setBizTitle(apply.getItemTitle());
|
||||||
empty.setStatus(apply.getFlowStatus());
|
empty.setStatus(apply.getFlowStatus());
|
||||||
@@ -467,6 +468,14 @@ public class ServiceApplyService {
|
|||||||
// 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted)
|
// 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted)
|
||||||
final Integer applyIdFinal = apply.getId();
|
final Integer applyIdFinal = apply.getId();
|
||||||
final Long instanceId = result.getInstanceId();
|
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 -> {
|
transactionTemplate.executeWithoutResult(tx -> {
|
||||||
if (resubmit) {
|
if (resubmit) {
|
||||||
applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now());
|
applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
|
|||||||
import com.yunhai.svchall.entity.ServiceApply;
|
import com.yunhai.svchall.entity.ServiceApply;
|
||||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||||
|
import com.yunhai.svchall.service.SvcBizTypes;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -18,18 +19,23 @@ import java.util.Map;
|
|||||||
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
|
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
|
||||||
* X-Flw 验签(starter 可选过滤器)、ping、必填校验、bizType 过滤、eventId 幂等、
|
* X-Flw 验签(starter 可选过滤器)、ping、必填校验、bizType 过滤、eventId 幂等、
|
||||||
* instanceVersion 乱序门由 starter 接收管线承担;本类只做办件镜像状态机推进——
|
* instanceVersion 乱序门由 starter 接收管线承担;本类只做办件镜像状态机推进——
|
||||||
* 按 bizId 行锁定位 + 单据级乱序闸门 + 事件映射(submitted/task.*→10;completed→20;
|
* 按 bizId 行锁定位 + 行级事件绑定校验(P1-02)+ 单据级乱序闸门 + 事件映射
|
||||||
* rejected→30;withdrawn→40)+ 终态不回退守卫 + 结果明细快照 + 终态 finishTime。
|
* (submitted/task.*→10;completed→20;rejected→30;withdrawn→40)+ 终态不回退守卫
|
||||||
|
* (版本感知,P1-03)+ 结果明细快照 + 终态 finishTime。
|
||||||
* 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
|
* 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
|
||||||
* 正常返回(含办件不存在/未知事件等不可重试场景)→ 200。
|
* 正常返回(含办件不存在/未知事件/绑定不匹配等不可重试场景)→ 200。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
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 ServiceApplyRepository applyRepository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -50,6 +56,22 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
|||||||
event.getBizId(), event.getEventId());
|
event.getBizId(), event.getEventId());
|
||||||
return;
|
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();
|
Integer version = event.getInstanceVersion();
|
||||||
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)——
|
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)——
|
||||||
// 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1)
|
// 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1)
|
||||||
@@ -64,18 +86,26 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
|||||||
applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus());
|
applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
/**
|
||||||
|
* 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态)。
|
||||||
|
* 终态守卫按<b>版本感知</b>(P1-03):行已终态时,仅当事件版本>已消费版本(=新一轮
|
||||||
|
* R4 重启的回调早于 markResubmitted 回开的竞态窗口)才放行推进——新轮回调先到/新轮
|
||||||
|
* 快速终态两类时序不再被"旧结果非空"一刀切拒绝;旧轮迟到/重复(版本不高于已消费)
|
||||||
|
* 仍按原守卫拒收,不回退不覆盖。
|
||||||
|
*/
|
||||||
private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload) {
|
private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload) {
|
||||||
Integer status = mapStatus(event);
|
Integer status = mapStatus(event);
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event);
|
log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) {
|
||||||
|
boolean newRound = version != null && apply.getLastInstanceVersion() != null
|
||||||
|
&& version > apply.getLastInstanceVersion();
|
||||||
|
if (!newRound) {
|
||||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||||
// 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10
|
// 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10
|
||||||
// 并用 handled 明细覆盖已落定的 flow_result_json。重提不冲突:resubmit 成功后先以
|
// 并用 handled 明细覆盖已落定的 flow_result_json。
|
||||||
// 窄列原子置 10,新一轮事件到达时行已非终态。
|
|
||||||
if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) {
|
|
||||||
if (!ApplyFlowStatusEnum.byCode(status).terminal()) {
|
if (!ApplyFlowStatusEnum.byCode(status).terminal()) {
|
||||||
log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})",
|
log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})",
|
||||||
apply.getId(), apply.getFlowStatus(), event, version);
|
apply.getId(), apply.getFlowStatus(), event, version);
|
||||||
@@ -86,6 +116,11 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
|||||||
apply.getId(), apply.getFlowStatus(), event);
|
apply.getId(), apply.getFlowStatus(), event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log.info("[svc-hall] 终态行收到更高版本事件(新轮回调先于回填的竞态窗口,放行):"
|
||||||
|
+ "applyId={} oldStatus={} event={} v{} > v{}",
|
||||||
|
apply.getId(), apply.getFlowStatus(), event, version, apply.getLastInstanceVersion());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("event", event);
|
result.put("event", event);
|
||||||
@@ -108,6 +143,9 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
|||||||
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||||
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||||
apply.setFinishTime(LocalDateTime.now());
|
apply.setFinishTime(LocalDateTime.now());
|
||||||
|
} else if (apply.getFinishTime() != null) {
|
||||||
|
// 非终态推进清残留 finishTime(P1-03:新轮回调先到时行仍带旧轮 finishTime)
|
||||||
|
apply.setFinishTime(null);
|
||||||
}
|
}
|
||||||
applyRepository.save(apply);
|
applyRepository.save(apply);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ yh:
|
|||||||
# Notice 线上实证此形态);secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
|
# Notice 线上实证此形态);secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
|
||||||
flow-callback:
|
flow-callback:
|
||||||
path: /oapi/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 与当前期望不一致时
|
# 漂移自动核对(Notice 同款):端点路径/ctx 变更后,镜像行有效但登记 URL 与当前期望不一致时
|
||||||
# 自动重新注册(会轮换 secret)——2026-10-05 改路径后 160 存量 /callback/flow 行靠它收敛
|
# 自动重新注册(会轮换 secret)——2026-10-05 改路径后 160 存量 /callback/flow 行靠它收敛
|
||||||
reconcile-on-register: true
|
reconcile-on-register: true
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class ServiceApplyFlowSubmitTest {
|
|||||||
ApplyVo vo = service.submit(user(9, 1), request());
|
ApplyVo vo = service.submit(user(9, 1), request());
|
||||||
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转");
|
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转");
|
||||||
ArgumentCaptor<String> bizId = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> 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());
|
anyString(), any(), any(), eq(9), eq("张三"), any());
|
||||||
assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)");
|
assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)");
|
||||||
verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
|
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)
|
// router.replace 同形);路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2)
|
||||||
service.submit(user(9, 1), request());
|
service.submit(user(9, 1), request());
|
||||||
ArgumentCaptor<String> bizUrl = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> 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());
|
anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any());
|
||||||
assertEquals("/apply-detail?id=42", bizUrl.getValue());
|
assertEquals("/apply-detail?id=42", bizUrl.getValue());
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ class ServiceApplyFlowSubmitTest {
|
|||||||
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
|
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
|
||||||
service.resubmit(user(9, 1), 42);
|
service.resubmit(user(9, 1), 42);
|
||||||
// 同 bizId=42(flow-center R4 命中 30/40 → 复用同一办件行重启推进)
|
// 同 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());
|
anyString(), anyString(), any(), eq(9), eq("张三"), any());
|
||||||
verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class));
|
verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,4 +162,81 @@ class SvcFlowCallbackHandlerTest {
|
|||||||
assertThrows(org.springframework.dao.DataAccessResourceFailureException.class,
|
assertThrows(org.springframework.dao.DataAccessResourceFailureException.class,
|
||||||
() -> handler.onEvent(event("e10", "flow.completed", 3)));
|
() -> 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(), "旧轮结果不被覆盖");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user