- 回调迁 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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user