- 回调迁 starter(2026-09-20):删本地实现 FlowCallbackService/Controller/ FilterConfiguration 及旧测试,X-Flw 验签/幂等/乱序门由 starter 承担, 业务映射入 SvcFlowCallbackHandler(biz-type svc_item);端点仍 /callback/flow, 存量注册 callback_url 不变、secret 零轮换,镜像表 flow_callback_reg 由 sql/20260920_flow_callback_starter_migration.sql 迁移 - 表单级流程绑定:ServiceForm 增 flowGuid,事项未绑流程时回退取绑定表单 最新版本(effectiveFlowGuid),绑定校验/在线办理口径同步 - FlowReconcileJob/FlowCenterClient/OpenApiAuthEnforceConfiguration 等 SDK 适配微调;补 .gitignore(target/、日志) - 全量 mvn test 本地绿(含新增 SvcFlowCallbackHandlerTest/ ServiceItemEffectiveFlowTest)
This commit is contained in:
@@ -1,151 +0,0 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import com.yunhai.svchall.utils.ReReadableRequestWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 流程中心回调验签(验收报告 §四.6,Notice FlowCallbackVerifyFilter 同构):
|
||||
* 只拦 /callback/*(CAS url-pattern /api/* 之外,独立于 oapi 的 appKey 签名体系)。
|
||||
* 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/
|
||||
* X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)),
|
||||
* secret 为 reg 接口按 flowGuid 下发的回调密钥(FlowCallbackService.secretOf 查找,须 status=0 且非空)。
|
||||
* <b>注册探活 ping 直接 200</b>——flow-center 探活的签名是字面量 "ping" 不可能验签通过,且实测把
|
||||
* 5xx 判为不可达(§四.6),以 ping 200 保证注册顺畅;探活只判连接级可达,无状态语义。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackFilterConfiguration {
|
||||
|
||||
/** ±5 分钟防重放窗口(对齐概览 §8.4) */
|
||||
private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L;
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<FlowCallbackVerifyFilter> flowCallbackVerifyFilter() {
|
||||
FilterRegistrationBean<FlowCallbackVerifyFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new FlowCallbackVerifyFilter());
|
||||
bean.addUrlPatterns("/callback/*");
|
||||
// 先于业务过滤器链(AppAuthFilter +10 与签名强制 +11 均在 HIGHEST_PRECEDENCE 之上)
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
|
||||
return bean;
|
||||
}
|
||||
|
||||
class FlowCallbackVerifyFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (!"POST".equalsIgnoreCase(req.getMethod())) {
|
||||
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
|
||||
return;
|
||||
}
|
||||
ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req);
|
||||
String body = wrapper.bodyString();
|
||||
// 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过)
|
||||
if (isPing(body)) {
|
||||
byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
res.setContentType("application/json");
|
||||
res.setContentLength(ok.length);
|
||||
res.setStatus(HttpServletResponse.SC_OK);
|
||||
res.getOutputStream().write(ok);
|
||||
return;
|
||||
}
|
||||
String eventId = req.getHeader("X-Flw-EventId");
|
||||
String timestamp = req.getHeader("X-Flw-Timestamp");
|
||||
String nonce = req.getHeader("X-Flw-Nonce");
|
||||
String signature = req.getHeader("X-Flw-Signature");
|
||||
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp)
|
||||
|| !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long ts = Long.parseLong(timestamp.trim());
|
||||
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp");
|
||||
return;
|
||||
}
|
||||
String flowGuid = extractFlowGuid(body);
|
||||
String secret = flowGuid == null ? null : callbackService.secretOf(flowGuid);
|
||||
if (secret == null) {
|
||||
log.warn("[svc-hall] 回调验签失败:无注册或无 secret,flowGuid={}", flowGuid);
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration");
|
||||
return;
|
||||
}
|
||||
String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body);
|
||||
if (!constantTimeEquals(expected, signature.trim())) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(wrapper, response);
|
||||
}
|
||||
|
||||
/** 注册探活判定:event=ping(flow-center 注册时发送,签名头是字面量 "ping") */
|
||||
private boolean isPing(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload != null && "ping".equals(payload.path("event").asText(null));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从推送体提取 flowGuid(验签必需的最小解析) */
|
||||
private String extractFlowGuid(String body) {
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(body);
|
||||
return payload.has("flowGuid") && !payload.get("flowGuid").isNull()
|
||||
? payload.get("flowGuid").asText() : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String hmacSha256Base64(String secret, String content) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HmacSHA256 计算失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String a, String b) {
|
||||
return java.security.MessageDigest.isEqual(
|
||||
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import java.io.IOException;
|
||||
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
|
||||
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
|
||||
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 yh-flow-callback starter 的 X-Flw 验签过滤器用
|
||||
* X-Flw-* 四头验签,两种签名体系互不干扰。
|
||||
*/
|
||||
@Configuration
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.yunhai.svchall.controller.callback;
|
||||
|
||||
import com.yunhai.svchall.service.impl.FlowCallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 流程中心回调接收端点(/callback/flow;不在 CAS pattern(/api/*)内、也不在 /oapi/*
|
||||
* (appKey 签名)内——独立走 FlowCallbackVerifyFilter 的 X-Flw-* 四头验签后才到达这里)。
|
||||
* <p>
|
||||
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
||||
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
||||
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
|
||||
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平
|
||||
* (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/callback")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackController {
|
||||
|
||||
private final FlowCallbackService callbackService;
|
||||
|
||||
@PostMapping("/flow")
|
||||
public ResponseEntity<String> flow(@RequestBody byte[] body) {
|
||||
try {
|
||||
callbackService.process(new String(body, StandardCharsets.UTF_8));
|
||||
return ResponseEntity.ok("{\"ok\":true}");
|
||||
} catch (FlowCallbackService.TransientCallbackException e) {
|
||||
log.error("[svc-hall] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
} catch (Exception e) {
|
||||
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
|
||||
log.error("[svc-hall] 回调意外异常(回 5xx 触发流程中心重试)", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,13 @@ public class ServiceForm extends BaseEntity {
|
||||
@Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String schemaJson;
|
||||
|
||||
/**
|
||||
* 表单级流程绑定(2026-09-20 表单级绑定):用本表单的事项在自身未绑流程时回退用它。
|
||||
* 绑定随版本行携带(克隆新版本继承);办件发起时快照生效值到 service_apply.flow_guid。
|
||||
*/
|
||||
@Column(name = "flow_guid", length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 发布时间 */
|
||||
@Column(name = "publish_time")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@@ -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.FlowCallbackService;
|
||||
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@@ -56,7 +56,7 @@ public class FlowReconcileJob {
|
||||
}
|
||||
try {
|
||||
Map<String, Object> remote = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (remote == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -343,6 +343,8 @@ public final class SvcModels {
|
||||
private String name;
|
||||
@NotBlank(message = "表单结构不得为空")
|
||||
private String schemaJson;
|
||||
/** 表单级流程绑定(可空;绑定事项未自身绑流时回退用它) */
|
||||
private String flowGuid;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -353,6 +355,8 @@ public final class SvcModels {
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
private String schemaJson;
|
||||
/** 表单级流程绑定(可空) */
|
||||
private String flowGuid;
|
||||
private LocalDateTime publishTime;
|
||||
private Boolean referenced;
|
||||
/** 绑定本表单的事项明细(仅 list 填充;表单页反查使用位置) */
|
||||
@@ -366,6 +370,7 @@ public final class SvcModels {
|
||||
vo.setVersion(form.getVersion());
|
||||
vo.setStatus(form.getStatus());
|
||||
vo.setSchemaJson(form.getSchemaJson());
|
||||
vo.setFlowGuid(form.getFlowGuid());
|
||||
vo.setPublishTime(form.getPublishTime());
|
||||
vo.setReferenced(referenced);
|
||||
return vo;
|
||||
|
||||
@@ -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.FlowCallbackService;
|
||||
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -96,7 +96,7 @@ public class ApplyViewGuard {
|
||||
List<Integer> assignees = Collections.emptyList();
|
||||
try {
|
||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||
assignees = (List<Integer>) instance.get("assigneeIds");
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.entity.ServiceFlowEvent;
|
||||
import com.yunhai.svchall.entity.ServiceFlowReg;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.repository.ServiceFlowEventRepository;
|
||||
import com.yunhai.svchall.repository.ServiceFlowRegRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程中心回调事件处理(验收报告 §五遗留批次"回调语义修订",Notice FlowCallbackService D2 同构)。
|
||||
* <b>返回语义(契约冻结,免对账核心)</b>:瞬时失败(DB 异常/锁冲突)抛 {@link TransientCallbackException}
|
||||
* → controller 回 5xx → flow-center 持续重试直至送达;毒 JSON/缺字段/eventId 去重命中/
|
||||
* 乱序丢弃/非本应用办件 → 正常返回(HTTP 200,防无限重放毒消息)。
|
||||
* 定位:按 payload.bizId(本应用行已先于代发起提交,竞态闭合)+ {@code findByIdForUpdate}
|
||||
* 行锁串行化并发推送;事件映射:submitted/task.created/task.handled → 10;completed → 20;
|
||||
* rejected → 30;withdrawn → 40(对齐流程中心概览 §8.3)。
|
||||
* 乱序闸门:instanceVersion <b>严格小于</b>已消费版本才丢弃——flow-center 存在同版本多事件
|
||||
* (submitted+task.created 同 v1,两会签 handled 同 v3),≤ 会误杀合法事件(验收报告 §四.3)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackService {
|
||||
|
||||
/** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */
|
||||
public static final String BIZ_TYPE = "svc_item";
|
||||
|
||||
/** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */
|
||||
public static class TransientCallbackException extends RuntimeException {
|
||||
public TransientCallbackException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private final ServiceFlowEventRepository eventRepository;
|
||||
private final ServiceFlowRegRepository regRepository;
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 处理一条回调推送体(已通过 FlowCallbackVerifyFilter 验签)。
|
||||
* 毒消息静默 200;瞬时失败抛 TransientCallbackException 且事务回滚(含去重留档行),重试从头再来。
|
||||
*/
|
||||
@Transactional
|
||||
public void process(String body) {
|
||||
JsonNode payload;
|
||||
try {
|
||||
payload = objectMapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
// 协议毒消息:不可重试,静默 200 防无限重放
|
||||
log.warn("[svc-hall] 回调体不是合法 JSON(丢弃)");
|
||||
return;
|
||||
}
|
||||
String eventId = text(payload, "eventId");
|
||||
String event = text(payload, "event");
|
||||
String bizType = text(payload, "bizType");
|
||||
String bizId = text(payload, "bizId");
|
||||
Long schId = longVal(payload, "schId");
|
||||
Long instanceId = longVal(payload, "instanceId");
|
||||
if (eventId == null || event == null || bizType == null || bizId == null
|
||||
|| schId == null || instanceId == null) {
|
||||
log.warn("[svc-hall] 回调缺少必要字段 eventId/event/bizType/bizId/schId/instanceId(丢弃):event={}", event);
|
||||
return;
|
||||
}
|
||||
if ("ping".equals(event)) {
|
||||
return; // 注册探活回声(过滤器已直接 200,防御性兜底)
|
||||
}
|
||||
if (!BIZ_TYPE.equals(bizType)) {
|
||||
log.warn("[svc-hall] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
|
||||
return;
|
||||
}
|
||||
// 第一道闸:eventId 去重(唯一约束兜底并发);重复投递在持续重试语义下必然发生
|
||||
if (eventRepository.existsByEventId(eventId)) {
|
||||
log.info("[svc-hall] 回调 eventId={} 重复投递,跳过", eventId);
|
||||
return;
|
||||
}
|
||||
ServiceFlowEvent record = new ServiceFlowEvent();
|
||||
record.setEventId(eventId);
|
||||
record.setEvent(event);
|
||||
record.setSchId(schId);
|
||||
record.setInstanceId(instanceId);
|
||||
record.setReceiveTime(LocalDateTime.now());
|
||||
record.setPayloadJson(body);
|
||||
try {
|
||||
eventRepository.saveAndFlush(record);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.info("[svc-hall] 回调 eventId={} 并发重复(uk 命中),跳过", eventId);
|
||||
return; // 事务回滚无副作用(尚未写办件)
|
||||
}
|
||||
|
||||
// 第二道闸前置:按 bizId 行锁定位(并发推送对同一办件排队,防丢更新)
|
||||
Integer applyId;
|
||||
try {
|
||||
applyId = Integer.valueOf(bizId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", bizId);
|
||||
return;
|
||||
}
|
||||
ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null);
|
||||
if (apply == null) {
|
||||
log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)", bizId, eventId);
|
||||
return;
|
||||
}
|
||||
Integer version = intVal(payload, "instanceVersion");
|
||||
// 乱序闸门:严格小于已消费版本才丢弃(同版本多事件合法)
|
||||
if (version != null && apply.getLastInstanceVersion() != null
|
||||
&& version < apply.getLastInstanceVersion()) {
|
||||
log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}",
|
||||
instanceId, version, apply.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
advance(apply, event, version, payload);
|
||||
} catch (TransientCallbackException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 持续重试追平(免对账核心语义)
|
||||
throw new TransientCallbackException("回调状态推进失败:apply=" + applyId + " event=" + event, e);
|
||||
}
|
||||
log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}",
|
||||
applyId, instanceId, event, apply.getFlowStatus());
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
private void advance(ServiceApply apply, String event, Integer version, JsonNode 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。命中守卫记 log 后正常返回(200,
|
||||
// 不触发重试)。重提不冲突: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;
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("event", event);
|
||||
JsonNode data = payload.get("data");
|
||||
if (data != null && data.isObject()) {
|
||||
if (data.has("result") && !data.get("result").isNull()) {
|
||||
result.put("result", data.get("result").asText());
|
||||
}
|
||||
if (data.has("opinion") && !data.get("opinion").isNull()) {
|
||||
result.put("opinion", data.get("opinion").asText());
|
||||
}
|
||||
if (data.has("rejectNode") && !data.get("rejectNode").isNull()) {
|
||||
result.put("rejectNode", data.get("rejectNode").asText());
|
||||
}
|
||||
}
|
||||
try {
|
||||
apply.setFlowResultJson(objectMapper.writeValueAsString(result));
|
||||
} catch (Exception ignored) {
|
||||
// 明细序列化失败不影响状态推进
|
||||
}
|
||||
apply.setFlowStatus(status);
|
||||
if (version != null) {
|
||||
apply.setLastInstanceVersion(version);
|
||||
}
|
||||
if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
apply.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
applyRepository.save(apply);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调验签密钥查找(FlowCallbackVerifyFilter 用;按 flowGuid 定注册)。
|
||||
* 仅有效注册(status=0 且 secret 非空)返回——失效注册按无注册处理(401),
|
||||
* 修复路径=管理端重新保存绑定触发重注册(重复注册会轮换 secret,绝不能静默轮换)。
|
||||
*/
|
||||
public String secretOf(String flowGuid) {
|
||||
ServiceFlowReg reg = regRepository.findByFlowGuid(flowGuid).orElse(null);
|
||||
boolean valid = reg != null && reg.getStatus() != null && reg.getStatus() == 0
|
||||
&& reg.getSecret() != null && !reg.getSecret().isEmpty();
|
||||
return valid ? reg.getSecret() : null;
|
||||
}
|
||||
|
||||
private static Integer mapStatus(String event) {
|
||||
switch (event) {
|
||||
case "flow.submitted":
|
||||
case "flow.task.created":
|
||||
case "flow.task.handled":
|
||||
return ApplyFlowStatusEnum.APPROVING.getCode();
|
||||
case "flow.completed":
|
||||
return ApplyFlowStatusEnum.COMPLETED.getCode();
|
||||
case "flow.rejected":
|
||||
return ApplyFlowStatusEnum.REJECTED.getCode();
|
||||
case "flow.withdrawn":
|
||||
return ApplyFlowStatusEnum.WITHDRAWN.getCode();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
private static Long longVal(JsonNode node, String field) {
|
||||
return node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null;
|
||||
}
|
||||
|
||||
private static Integer intVal(JsonNode node, String field) {
|
||||
return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ public class FlowCenterClient implements FlowClient {
|
||||
body.setCallbackUrl(callbackUrl);
|
||||
body.setEventTypes(eventTypes);
|
||||
// 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /callback/flow):
|
||||
// 推送方按它剥离后再签应用签名,与本服务 AppAuthFilter/FlowCallbackVerifyFilter 剥
|
||||
// 推送方按它剥离后再签应用签名,与本服务验签过滤器剥
|
||||
// contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变(不下发空串)。
|
||||
String basePath = deriveCallbackBasePath(callbackUrl);
|
||||
if (basePath != null && !basePath.isEmpty()) {
|
||||
|
||||
@@ -92,9 +92,6 @@ public class ServiceApplyService {
|
||||
if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理");
|
||||
}
|
||||
if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
// 表单解析 + 提交数据强校验(按最新已发布版本;发起时快照 form_version)
|
||||
ServiceForm form = null;
|
||||
if (item.getFormCode() != null && !item.getFormCode().isEmpty()) {
|
||||
@@ -105,6 +102,15 @@ public class ServiceApplyService {
|
||||
} else if (StringUtils.hasText(req.getFormData())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项无办理表单,不应提交表单数据");
|
||||
}
|
||||
// 生效流程(表单级绑定,2026-09-20):事项自身优先,为空回退已发布表单的绑定
|
||||
String effectiveFlowGuid = item.getFlowGuid();
|
||||
if ((effectiveFlowGuid == null || effectiveFlowGuid.isEmpty())
|
||||
&& form != null && form.getFlowGuid() != null && !form.getFlowGuid().isEmpty()) {
|
||||
effectiveFlowGuid = form.getFlowGuid();
|
||||
}
|
||||
if (effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
|
||||
ServiceApply apply = new ServiceApply();
|
||||
apply.setSchId(site.getSchId());
|
||||
@@ -117,7 +123,7 @@ public class ServiceApplyService {
|
||||
apply.setFormDataJson(req.getFormData());
|
||||
apply.setSubmitterId(user.getId());
|
||||
apply.setSubmitterName(user.getName());
|
||||
apply.setFlowGuid(item.getFlowGuid());
|
||||
apply.setFlowGuid(effectiveFlowGuid);
|
||||
apply = applyRepository.save(apply);
|
||||
|
||||
// 办件材料绑定(必须落库拿到 applyId 之后再绑)
|
||||
@@ -198,7 +204,7 @@ public class ServiceApplyService {
|
||||
if (apply.getFlowInstanceId() == null) {
|
||||
SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo();
|
||||
empty.setInstanceId(null);
|
||||
empty.setBizType(FlowCallbackService.BIZ_TYPE);
|
||||
empty.setBizType(SvcFlowCallbackHandler.BIZ_TYPE);
|
||||
empty.setBizId(String.valueOf(apply.getId()));
|
||||
empty.setBizTitle(apply.getItemTitle());
|
||||
empty.setStatus(apply.getFlowStatus());
|
||||
@@ -322,9 +328,6 @@ public class ServiceApplyService {
|
||||
if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理");
|
||||
}
|
||||
if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("siteCode", site.getSiteCode());
|
||||
out.put("itemCode", item.getItemCode());
|
||||
@@ -336,13 +339,27 @@ public class ServiceApplyService {
|
||||
out.put("promiseTimeLimit", item.getPromiseTimeLimit());
|
||||
out.put("contactPhone", item.getContactPhone());
|
||||
out.put("coverImage", com.yunhai.svchall.utils.FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
out.put("flowGuid", item.getFlowGuid());
|
||||
if (item.getFormCode() != null && !item.getFormCode().isEmpty()) {
|
||||
ServiceForm form = formService.latestPublished(site.getSchId(), item.getFormCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"办理表单未发布,请联系管理员"));
|
||||
out.put("formVersion", form.getVersion());
|
||||
out.put("schemaJson", form.getSchemaJson());
|
||||
// 生效流程(表单级绑定):事项自身优先,为空回退已发布表单的绑定
|
||||
String effectiveFlowGuid = item.getFlowGuid();
|
||||
if ((effectiveFlowGuid == null || effectiveFlowGuid.isEmpty())
|
||||
&& form.getFlowGuid() != null && !form.getFlowGuid().isEmpty()) {
|
||||
effectiveFlowGuid = form.getFlowGuid();
|
||||
}
|
||||
if (effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
out.put("flowGuid", effectiveFlowGuid);
|
||||
} else {
|
||||
if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理");
|
||||
}
|
||||
out.put("flowGuid", item.getFlowGuid());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -438,7 +455,7 @@ public class ServiceApplyService {
|
||||
FlowClient.ApplyResult result = flowClient.apply(
|
||||
apply.getSchId(),
|
||||
apply.getFlowGuid(),
|
||||
FlowCallbackService.BIZ_TYPE,
|
||||
SvcFlowCallbackHandler.BIZ_TYPE,
|
||||
String.valueOf(apply.getId()),
|
||||
apply.getSubmitterName() + "的" + apply.getItemTitle(),
|
||||
// 前端详情页是 hash 路由 + query 传参(routes.ts path="/apply-detail"、
|
||||
|
||||
@@ -36,6 +36,8 @@ public class ServiceFormService {
|
||||
private final ServiceFormRepository formRepository;
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final SiteGuard siteGuard;
|
||||
private final com.yunhai.svchall.service.FlowClient flowClient;
|
||||
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
|
||||
|
||||
/** 表单列表(sch 级;同 code 多版本聚合成一行最新版,referenced=是否被事项绑定,refItems=绑定事项明细) */
|
||||
public List<SvcFormVo> list(BoaUser user, String siteCode) {
|
||||
@@ -118,7 +120,34 @@ public class ServiceFormService {
|
||||
}
|
||||
form.setName(req.getName());
|
||||
form.setSchemaJson(req.getSchemaJson());
|
||||
return formRepository.save(form).getId();
|
||||
// 表单级流程绑定(2026-09-20):克隆新版本随请求携带(编辑页回显旧值);可空
|
||||
String flowGuid = req.getFlowGuid() == null || req.getFlowGuid().trim().isEmpty()
|
||||
? null : req.getFlowGuid().trim();
|
||||
if (flowGuid != null && flowClient.enabled()
|
||||
&& flowClient.getFlow(site.getSchId(), flowGuid) == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "绑定流程不存在或未发布:" + flowGuid);
|
||||
}
|
||||
form.setFlowGuid(flowGuid);
|
||||
Integer id = formRepository.save(form).getId();
|
||||
ensureFlowReg(site, flowGuid);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联动(与 ServiceItemService.ensureFlowReg 同语义):表单绑定流程即确保有效回调注册存在。
|
||||
* best-effort:失败记 warn 不阻断保存。
|
||||
*/
|
||||
private void ensureFlowReg(SiteInfo site, String flowGuid) {
|
||||
if (flowGuid == null || !flowClient.enabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
callbackRegistrar.ensureRegistered(
|
||||
site.getSchId() == null ? null : site.getSchId().intValue(), flowGuid);
|
||||
} catch (Exception e) {
|
||||
java.util.logging.Logger.getLogger(getClass().getName()).warning(
|
||||
"[svc-hall] 表单绑定回调注册失败(下次保存重试)flowGuid=" + flowGuid + ":" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布:草稿 → 已发布(版本固化;事项侧经 code 取到本版本) */
|
||||
|
||||
@@ -81,13 +81,8 @@ public class ServiceItemService {
|
||||
@Value("${svchall.item.dept-group-type:7}")
|
||||
private int deptGroupType = 7;
|
||||
|
||||
/** 本应用对外可达基址(回调注册 callbackUrl 前缀;projman 注入,本地默认 dev) */
|
||||
@Value("${svchall.public-base-url:http://localhost:8086}")
|
||||
private String callbackPublicBase;
|
||||
|
||||
private final ServiceItemRepository itemRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFormRepository formRepository;
|
||||
private final com.yunhai.svchall.repository.ServiceFlowRegRepository flowRegRepository;
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final ServiceDictService dictService;
|
||||
private final ServiceFormService formService;
|
||||
@@ -95,6 +90,7 @@ public class ServiceItemService {
|
||||
private final SiteGuard siteGuard;
|
||||
private final DeptSource deptSource;
|
||||
private final FlowClient flowClient;
|
||||
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
// ===== 管理端 =====
|
||||
@@ -206,44 +202,50 @@ public class ServiceItemService {
|
||||
item = itemRepository.save(item);
|
||||
|
||||
attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds());
|
||||
ensureFlowReg(site, item.getFlowGuid());
|
||||
// 注册以生效流程为准(表单级绑定:事项自身为空时用表单的)
|
||||
ensureFlowReg(site, effectiveFlowGuid(site.getSchId(), item.getFlowGuid(), item.getFormCode()));
|
||||
return item.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换):事项绑 flow_guid 时确保<b>有效</b>回调注册存在
|
||||
* (reg 下发 secret 落 service_flow_reg)。仅 reg 缺失/失效(secret 空或 status≠0)时才注册——
|
||||
* flow-center 重复注册会轮换 secret,导致已存密钥验签失配;失效注册原地更新同一行拿回新密钥。
|
||||
* 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换;2026-09-20 迁 starter
|
||||
* {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):绑定 flowGuid 时确保
|
||||
* <b>有效</b>回调注册存在(reg 下发 secret 落 starter 镜像表 flow_callback_reg)。
|
||||
* 仅镜像行缺失/失效时才注册——flow-center 重复注册会轮换 secret,导致已存密钥验签失配。
|
||||
* best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。
|
||||
*/
|
||||
void ensureFlowReg(SiteInfo site, String flowGuid) {
|
||||
if (flowGuid == null || !flowClient.enabled()) {
|
||||
return;
|
||||
}
|
||||
com.yunhai.svchall.entity.ServiceFlowReg reg = flowRegRepository
|
||||
.findBySchIdAndFlowGuid(site.getSchId(), flowGuid).orElse(null);
|
||||
if (reg != null && reg.getStatus() != null && reg.getStatus() == 0
|
||||
&& reg.getSecret() != null && !reg.getSecret().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String callbackUrl = callbackPublicBase + "/callback/flow";
|
||||
try {
|
||||
String secret = flowClient.registerCallback(site.getSchId(), flowGuid, callbackUrl, null);
|
||||
if (reg == null) {
|
||||
reg = new com.yunhai.svchall.entity.ServiceFlowReg();
|
||||
reg.setSchId(site.getSchId());
|
||||
reg.setFlowGuid(flowGuid);
|
||||
}
|
||||
reg.setCallbackUrl(callbackUrl);
|
||||
reg.setSecret(secret);
|
||||
reg.setStatus(0);
|
||||
flowRegRepository.save(reg);
|
||||
callbackRegistrar.ensureRegistered(
|
||||
site.getSchId() == null ? null : site.getSchId().intValue(), flowGuid);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}",
|
||||
site.getSiteCode(), flowGuid, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效流程解析(表单级绑定,2026-09-20):事项自身绑定优先,为空回退绑定表单最新版本的
|
||||
* flow_guid。返回 null=两侧都没绑(不可在线办理)。
|
||||
*/
|
||||
String effectiveFlowGuid(Long schId, String itemFlowGuid, String formCode) {
|
||||
if (itemFlowGuid != null && !itemFlowGuid.isEmpty()) {
|
||||
return itemFlowGuid;
|
||||
}
|
||||
if (formCode == null || formCode.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return formRepository
|
||||
.findBySchIdAndCodeOrderByVersionDesc(schId, formCode).stream()
|
||||
.findFirst()
|
||||
.map(com.yunhai.svchall.entity.ServiceForm::getFlowGuid)
|
||||
.filter(guid -> guid != null && !guid.isEmpty())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void adminPublish(BoaUser user, String siteCode, Integer id) {
|
||||
SiteInfo site = siteGuard.requireSiteManager(user, siteCode);
|
||||
@@ -657,7 +659,8 @@ public class ServiceItemService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ flowGuid 非空 */
|
||||
/** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);
|
||||
* 流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ 生效流程(事项自身或绑定表单的)非空 */
|
||||
private void validateBindings(SiteInfo site, SvcItemSaveRequest req) {
|
||||
String formCode = emptyToNull(req.getFormCode());
|
||||
if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) {
|
||||
@@ -670,9 +673,9 @@ public class ServiceItemService {
|
||||
}
|
||||
}
|
||||
boolean online = req.getOnlineFlag() == null || req.getOnlineFlag() != 0;
|
||||
if (online && flowGuid == null) {
|
||||
if (online && effectiveFlowGuid(site.getSchId(), flowGuid, formCode) == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"可在线办理的事项必须绑定流程(flowGuid);线下事项请关闭\"可在线办理\"开关");
|
||||
"可在线办理的事项必须绑定流程(自身或所选表单二选一);线下事项请关闭\"可在线办理\"开关");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
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。
|
||||
* 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
|
||||
* 正常返回(含办件不存在/未知事件等不可重试场景)→ 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";
|
||||
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public void onEvent(FlowCallbackEvent event) {
|
||||
Integer applyId;
|
||||
try {
|
||||
applyId = Integer.valueOf(event.getBizId().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", event.getBizId());
|
||||
return;
|
||||
}
|
||||
// 行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新
|
||||
ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null);
|
||||
if (apply == null) {
|
||||
log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)",
|
||||
event.getBizId(), event.getEventId());
|
||||
return;
|
||||
}
|
||||
Integer version = event.getInstanceVersion();
|
||||
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)——
|
||||
// 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1)
|
||||
if (version != null && apply.getLastInstanceVersion() != null
|
||||
&& version < apply.getLastInstanceVersion()) {
|
||||
log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}",
|
||||
event.getInstanceId(), version, apply.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
advance(apply, event.getEvent(), version, event);
|
||||
log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}",
|
||||
applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus());
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
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;
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("event", event);
|
||||
Map<String, Object> data = payload.getData();
|
||||
if (data != null) {
|
||||
copyIfPresent(data, result, "result");
|
||||
copyIfPresent(data, result, "opinion");
|
||||
copyIfPresent(data, result, "rejectNode");
|
||||
}
|
||||
try {
|
||||
apply.setFlowResultJson(objectMapper.writeValueAsString(result));
|
||||
} catch (Exception ignored) {
|
||||
// 明细序列化失败不影响状态推进
|
||||
}
|
||||
apply.setFlowStatus(status);
|
||||
if (version != null) {
|
||||
apply.setLastInstanceVersion(version);
|
||||
}
|
||||
if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
apply.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
applyRepository.save(apply);
|
||||
}
|
||||
|
||||
private static void copyIfPresent(Map<String, Object> data, Map<String, Object> target, String key) {
|
||||
Object v = data.get(key);
|
||||
if (v != null) {
|
||||
target.put(key, String.valueOf(v));
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer mapStatus(String event) {
|
||||
switch (event) {
|
||||
case "flow.submitted":
|
||||
case "flow.task.created":
|
||||
case "flow.task.handled":
|
||||
return ApplyFlowStatusEnum.APPROVING.getCode();
|
||||
case "flow.completed":
|
||||
return ApplyFlowStatusEnum.COMPLETED.getCode();
|
||||
case "flow.rejected":
|
||||
return ApplyFlowStatusEnum.REJECTED.getCode();
|
||||
case "flow.withdrawn":
|
||||
return ApplyFlowStatusEnum.WITHDRAWN.getCode();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后,
|
||||
* 可重读请求包装(历史工具,回调验签已迁 yh-flow-callback starter):
|
||||
* 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。
|
||||
*/
|
||||
public class ReReadableRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
Reference in New Issue
Block a user