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));
- }
- }
-}
diff --git a/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java
index f2b791e..2e7adf3 100644
--- a/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java
+++ b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java
@@ -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
diff --git a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java
deleted file mode 100644
index 09d8081..0000000
--- a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java
+++ /dev/null
@@ -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-* 四头验签后才到达这里)。
- *
- * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/
- * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放);
- * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
- * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平
- * (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。
- */
-@Slf4j
-@RestController
-@RequestMapping("/callback")
-@RequiredArgsConstructor
-public class FlowCallbackController {
-
- private final FlowCallbackService callbackService;
-
- @PostMapping("/flow")
- public ResponseEntity 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}");
- }
- }
-}
diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java
index bcc006c..ae33c77 100644
--- a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java
+++ b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java
@@ -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;
diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java
index 20d94f8..5fbce11 100644
--- a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java
+++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java
@@ -4,7 +4,7 @@ import com.yunhai.svchall.entity.ServiceApply;
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
import com.yunhai.svchall.repository.ServiceApplyRepository;
import com.yunhai.svchall.service.FlowClient;
-import com.yunhai.svchall.service.impl.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 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;
}
diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
index 8c18e9e..ce47cca 100644
--- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
+++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java
@@ -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;
diff --git a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java
index c312607..97266cf 100644
--- a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java
+++ b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java
@@ -4,7 +4,7 @@ import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.entity.ServiceApply;
-import com.yunhai.svchall.service.impl.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 assignees = Collections.emptyList();
try {
Map 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) instance.get("assigneeIds");
}
diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java
deleted file mode 100644
index 12bdf4f..0000000
--- a/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java
+++ /dev/null
@@ -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 同构)。
- * 返回语义(契约冻结,免对账核心):瞬时失败(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 严格小于已消费版本才丢弃——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 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;
- }
-}
diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
index a738c21..d5064b5 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java
@@ -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()) {
diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
index 4ff1e0e..6ee0c5e 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java
@@ -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 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"、
diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java
index fb17136..2d99628 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java
@@ -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 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 取到本版本) */
diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java
index ff62ddd..a0244e8 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java
@@ -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 时确保有效回调注册存在
- * (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 时确保
+ * 有效回调注册存在(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);线下事项请关闭\"可在线办理\"开关");
+ "可在线办理的事项必须绑定流程(自身或所选表单二选一);线下事项请关闭\"可在线办理\"开关");
}
}
diff --git a/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java
new file mode 100644
index 0000000..1c50ed5
--- /dev/null
+++ b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java
@@ -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 result = new HashMap<>();
+ result.put("event", event);
+ Map 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 data, Map 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;
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java
index 00546bc..09e0a2a 100644
--- a/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java
+++ b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java
@@ -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 {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 4aa8c59..f73298a 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -71,6 +71,15 @@ yh:
flowcenter: http://localhost:8085
cms: http://base.yunhaixingtong.com:10005/yschcms
+ # 流程中心回调接收端 starter(2026-09-20 迁移,X-Flw 验签式):端点保持既有 /callback/flow,
+ # 存量注册的 callback_url 不变、零 secret 轮换;幂等/乱序门由 starter 承担,
+ # 业务映射见 SvcFlowCallbackHandler;secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
+ flow-callback:
+ path: /callback/flow
+ biz-types: [svc_item]
+ verify-flw-signature: true
+ callback-url: ${svchall.public-base-url}/callback/flow
+
# ===== 办事大厅专属配置 =====
svchall:
# 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入)
diff --git a/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java b/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java
deleted file mode 100644
index 7155da6..0000000
--- a/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java
+++ /dev/null
@@ -1,151 +0,0 @@
-package com.yunhai.svchall.config;
-
-import com.yunhai.svchall.service.impl.FlowCallbackService;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.web.servlet.FilterRegistrationBean;
-import org.springframework.mock.web.MockFilterChain;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-/**
- * 回调验签过滤器单测(对齐 Notice FlowCallbackVerifyFilterTest):
- * 注册探活 ping 直接 200(探活签名是字面量 "ping" 无法验签,flow-center 实测把 5xx 判不可达);
- * 缺头/坏时间戳/±5 分钟窗外/无注册/坏签名 → 401;合法签名放行。
- */
-class FlowCallbackVerifyFilterTest {
-
- private static final String SECRET = "unit-test-secret";
- private static final String BODY =
- "{\"eventId\":\"e-9\",\"event\":\"flow.completed\",\"flowGuid\":\"fg-1\",\"schId\":1,"
- + "\"instanceId\":9001,\"bizType\":\"svc_item\",\"bizId\":\"42\",\"instanceVersion\":4}";
-
- private FlowCallbackService callbackService;
- private Filter filter;
-
- @BeforeEach
- void setUp() {
- callbackService = mock(FlowCallbackService.class);
- FlowCallbackFilterConfiguration configuration =
- new FlowCallbackFilterConfiguration(callbackService, new com.fasterxml.jackson.databind.ObjectMapper());
- FilterRegistrationBean> bean = configuration.flowCallbackVerifyFilter();
- filter = bean.getFilter();
- when(callbackService.secretOf("fg-1")).thenReturn(SECRET);
- }
-
- @Test
- void pingIsAnswered200WithoutVerification() throws Exception {
- MockHttpServletResponse response = doFilter(
- request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", "1", "n", "bad-signature"), new MockFilterChain());
- assertEquals(200, response.getStatus());
- assertEquals("{\"ok\":true}", response.getContentAsString());
- }
-
- @Test
- void pingWithoutHeadersStill200() throws Exception {
- MockHttpServletResponse response = doFilter(
- request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", null, null, null), new MockFilterChain());
- assertEquals(200, response.getStatus());
- }
-
- @Test
- void missingHeadersIs401() throws Exception {
- MockHttpServletResponse response = doFilter(request(BODY, null, null, null), new MockFilterChain());
- assertEquals(401, response.getStatus());
- }
-
- @Test
- void badTimestampIs401() throws Exception {
- MockHttpServletResponse response = doFilter(
- request(BODY, "not-a-number", "n", "sig"), new MockFilterChain());
- assertEquals(401, response.getStatus());
- }
-
- @Test
- void staleTimestampBeyondFiveMinutesIs401() throws Exception {
- String stale = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L);
- MockHttpServletResponse response = doFilter(
- request(BODY, stale, "n", "sig"), new MockFilterChain());
- assertEquals(401, response.getStatus());
- }
-
- @Test
- void unknownRegistrationIs401() throws Exception {
- when(callbackService.secretOf("fg-1")).thenReturn(null);
- MockHttpServletResponse response = doFilter(signedRequest(SECRET), new MockFilterChain());
- assertEquals(401, response.getStatus());
- }
-
- @Test
- void badSignatureIs401() throws Exception {
- MockHttpServletResponse response = doFilter(signedRequest(null), new MockFilterChain());
- assertEquals(401, response.getStatus());
- }
-
- @Test
- void validSignaturePassesThrough() throws Exception {
- MockFilterChain chain = new MockFilterChain();
- MockHttpServletResponse response = doFilter(signedRequest(SECRET), chain);
- assertEquals(200, response.getStatus());
- assertNotNull(chain.getRequest(), "合法签名应放行到后续链路");
- }
-
- @Test
- void nonPostIs405() throws Exception {
- MockHttpServletRequest request = request(BODY, "1", "n", "sig");
- request.setMethod("GET");
- MockHttpServletResponse response = doFilter(request, new MockFilterChain());
- assertEquals(405, response.getStatus());
- }
-
- // ===== helpers =====
-
- private MockHttpServletRequest request(String body, String ts, String nonce, String signature) {
- MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow");
- request.setContent(body.getBytes(StandardCharsets.UTF_8));
- if (ts != null) {
- request.addHeader("X-Flw-EventId", "e-9");
- request.addHeader("X-Flw-Timestamp", ts);
- request.addHeader("X-Flw-Nonce", nonce);
- request.addHeader("X-Flw-Signature", signature);
- }
- return request;
- }
-
- /** 头齐全、时间戳取当前时刻;secret 为 null 时签名置垃圾值 */
- private MockHttpServletRequest signedRequest(String secret) {
- String ts = String.valueOf(System.currentTimeMillis());
- String signature;
- if (secret == null) {
- signature = "garbage-signature";
- } else {
- try {
- Mac mac = Mac.getInstance("HmacSHA256");
- mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
- signature = Base64.getEncoder().encodeToString(
- mac.doFinal((ts + ".nonce-1." + BODY).getBytes(StandardCharsets.UTF_8)));
- } catch (Exception e) {
- throw new IllegalStateException(e);
- }
- }
- return request(BODY, ts, "nonce-1", signature);
- }
-
- private MockHttpServletResponse doFilter(MockHttpServletRequest request, FilterChain chain) throws Exception {
- MockHttpServletResponse response = new MockHttpServletResponse();
- filter.doFilter(request, response, chain);
- return response;
- }
-}
diff --git a/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java b/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java
deleted file mode 100644
index 3adfc1f..0000000
--- a/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.yunhai.svchall.controller.callback;
-
-import com.yunhai.svchall.service.impl.FlowCallbackService;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-
-import java.nio.charset.StandardCharsets;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.mock;
-
-/**
- * 回调接收端点返回语义单测(契约冻结 D2,免对账前提):
- * 成功/毒消息 → 200(毒消息由 service 静默消化,不抛);
- * TransientCallbackException / 意外 RuntimeException → 5xx——flow-center 持续重试直至送达。
- */
-class FlowCallbackControllerTest {
-
- private FlowCallbackService callbackService;
- private FlowCallbackController controller;
-
- @BeforeEach
- void setUp() {
- callbackService = mock(FlowCallbackService.class);
- controller = new FlowCallbackController(callbackService);
- }
-
- @Test
- void successReturns200() {
- ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
- assertEquals(HttpStatus.OK, resp.getStatusCode());
- assertEquals("{\"ok\":true}", resp.getBody());
- }
-
- @Test
- void transientFailureReturns5xx() {
- doThrow(new FlowCallbackService.TransientCallbackException("db down", new RuntimeException("db down")))
- .when(callbackService).process("{\"event\":\"flow.completed\"}");
- ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
- assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
- assertEquals("{\"ok\":false}", resp.getBody());
- }
-
- @Test
- void unexpectedExceptionAlsoReturns5xx() {
- // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
- doThrow(new IllegalStateException("boom"))
- .when(callbackService).process("{\"event\":\"flow.completed\"}");
- ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8));
- assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
- }
-}
diff --git a/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java
index ab54070..81d72da 100644
--- a/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java
+++ b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java
@@ -3,7 +3,7 @@ package com.yunhai.svchall.service;
import com.yunhai.platform.apistd.BizException;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -97,7 +97,7 @@ class ApplyViewGuardTest {
void assigneePasses() {
assignees(30, 31);
assertDoesNotThrow(() -> guard.requireVisible(apply, user(30)));
- verify(flowClient).getInstanceByBiz(eq(1L), eq(FlowCallbackService.BIZ_TYPE), eq("42"));
+ verify(flowClient).getInstanceByBiz(eq(1L), eq(SvcFlowCallbackHandler.BIZ_TYPE), eq("42"));
}
@Test
diff --git a/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java
deleted file mode 100644
index db15a3a..0000000
--- a/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java
+++ /dev/null
@@ -1,287 +0,0 @@
-package com.yunhai.svchall.service.impl;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.yunhai.svchall.entity.ServiceApply;
-import com.yunhai.svchall.entity.ServiceFlowEvent;
-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 org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
-import org.springframework.dao.DataIntegrityViolationException;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import java.util.Optional;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * 回调事件处理单测(对齐 Notice FlowCallbackServiceTest,5xx 语义 D2 核心断言):
- * 六事件映射;eventId 重复/并发重复跳过;乱序闸门严格小于(同版本事件不丢);
- * 按 payload.bizId 定位办件(findByIdForUpdate 行锁);毒 JSON/缺字段/非 svc_item/ping 静默;
- * DB 异常 → TransientCallbackException(→ controller 回 5xx 触发 flow-center 持续重试)。
- */
-class FlowCallbackServiceTest {
-
- private ServiceFlowEventRepository eventRepository;
- private ServiceFlowRegRepository regRepository;
- private ServiceApplyRepository applyRepository;
- private FlowCallbackService service;
- private ServiceApply apply;
-
- @BeforeEach
- void setUp() {
- eventRepository = mock(ServiceFlowEventRepository.class);
- regRepository = mock(ServiceFlowRegRepository.class);
- applyRepository = mock(ServiceApplyRepository.class);
- service = new FlowCallbackService(eventRepository, regRepository, applyRepository, new ObjectMapper());
-
- apply = new ServiceApply();
- ReflectionTestUtils.setField(apply, "id", 42);
- apply.setSchId(1L);
- apply.setItemId(7);
- apply.setItemCode("ITEM-1");
- apply.setItemTitle("测试事项");
- apply.setSubmitterId(9);
- apply.setSubmitterName("张三");
- apply.setFlowGuid("fg-1");
- apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
- apply.setLastInstanceVersion(1);
- when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(apply));
- }
-
- // ===== 六事件映射 =====
-
- @Test
- void submitted_taskCreated_taskHandled_mapToApproving() {
- for (String event : new String[]{"flow.submitted", "flow.task.created", "flow.task.handled"}) {
- ServiceApply a = freshApply();
- service.process(body(event, 2));
- assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), a.getFlowStatus(), event);
- assertNull(a.getFinishTime(), event + " 非终态不应置 finishTime");
- }
- }
-
- @Test
- void completed_mapsToCompletedAndStampsFinishTime() {
- service.process(body("flow.completed", 4));
- assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
- assertNotNull(apply.getFinishTime());
- }
-
- @Test
- void rejected_mapsToRejectedAndStampsFinishTime() {
- service.process(body("flow.rejected", 4));
- assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
- assertNotNull(apply.getFinishTime());
- }
-
- @Test
- void withdrawn_mapsToWithdrawnAndStampsFinishTime() {
- service.process(body("flow.withdrawn", 4));
- assertEquals(ApplyFlowStatusEnum.WITHDRAWN.getCode(), apply.getFlowStatus());
- assertNotNull(apply.getFinishTime());
- }
-
- // ===== 乱序闸门:严格小于(<),同版本不丢 =====
-
- @Test
- void sameVersionEventIsProcessed_notDiscarded() {
- // flow-center 实测同版本多事件(submitted+task.created 同 v1)——旧 <= 闸门会误杀
- apply.setLastInstanceVersion(1);
- service.process(body("flow.task.created", 1));
- assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
- verify(applyRepository).save(apply);
- }
-
- @Test
- void olderVersionEventIsDiscarded() {
- apply.setLastInstanceVersion(3);
- service.process(body("flow.completed", 2));
- assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void missingVersionIsProcessedWithoutInventingVersion() {
- // version 缺失放行推进,但不伪造 last+1
- service.process(bodyWithout("instanceVersion"));
- verify(applyRepository).save(apply);
- assertEquals(Integer.valueOf(1), apply.getLastInstanceVersion());
- }
-
- // ===== eventId 去重(两道:先查后落,uk 兜底并发)=====
-
- @Test
- void duplicateEventIdIsSkipped() {
- when(eventRepository.existsByEventId("e-1")).thenReturn(true);
- service.process(body("flow.completed", 4));
- verify(applyRepository, never()).save(any());
- assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
- }
-
- @Test
- void concurrentDuplicateViaUniqueConstraintIsSkipped() {
- doThrow(new DataIntegrityViolationException("uk")).when(eventRepository).saveAndFlush(any(ServiceFlowEvent.class));
- service.process(body("flow.completed", 4));
- verify(applyRepository, never()).save(any());
- assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus());
- }
-
- // ===== 定位:按 payload.bizId + 行锁;bizType 守卫 =====
-
- @Test
- void locateByBizIdWithPessimisticLock() {
- service.process(body("flow.completed", 4));
- verify(applyRepository).findByIdForUpdate(42);
- verify(applyRepository, never()).findBySchIdAndFlowInstanceId(any(), any());
- }
-
- @Test
- void nonSvcItemBizTypeIsSilentlyDropped() {
- service.process(body("flow.completed", 4).replace("\"svc_item\"", "\"notice_item\""));
- verify(applyRepository, never()).findByIdForUpdate(any());
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void unknownBizIdIsSilentlyDropped() {
- when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
- service.process(body("flow.completed", 4));
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void nonNumericBizIdIsSilentlyDropped() {
- service.process(body("flow.completed", 4).replace("\"bizId\":\"42\"", "\"bizId\":\"abc\""));
- verify(applyRepository, never()).findByIdForUpdate(any());
- }
-
- // ===== 毒消息/未知事件/ping:静默(协议层不可重试,防无限重放)=====
-
- @Test
- void poisonJsonIsSilentlyIgnored() {
- service.process("not-json{{");
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void missingRequiredFieldIsSilentlyIgnored() {
- service.process(body("flow.completed", 4).replace("\"bizId\":\"42\",", ""));
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void unknownEventTypeIsSilentlyIgnored() {
- service.process(body("flow.handed", 2));
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void pingEventIsSilentlyIgnored() {
- // 过滤器已直通 200,这里兜底防穿透
- service.process(body("ping", 0));
- verify(applyRepository, never()).findByIdForUpdate(any());
- }
-
- // ===== 5xx 语义:DB 瞬时失败 → TransientCallbackException =====
-
- @Test
- void transientDbFailureThrowsTransientCallbackException() {
- doThrow(new RuntimeException("connection reset")).when(applyRepository).save(any(ServiceApply.class));
- assertThrows(FlowCallbackService.TransientCallbackException.class,
- () -> service.process(body("flow.completed", 4)));
- }
-
- // ===== 终态明细快照 =====
-
- @Test
- void terminalEventWritesResultSnapshot() {
- service.process(body("flow.completed", 4));
- assertNotNull(apply.getFlowResultJson());
- assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
- }
-
- // ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置 =====
-
- @Test
- void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
- // completed(v4) 先落库(20 + result),迟到的 task.handled(v4) 不得打回 10、不得覆盖结果
- service.process(body("e-a", "flow.completed", 4));
- assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
- String frozen = apply.getFlowResultJson();
- Mockito.clearInvocations(applyRepository);
-
- service.process(body("e-b", "flow.task.handled", 4)); // 不抛 → 200
- assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus(), "终态行拒绝非终态事件");
- assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被 handled 明细覆盖");
- verify(applyRepository, never()).save(any());
- }
-
- @Test
- void terminalRow_rejectsNonTerminalEvent_higherVersion() {
- service.process(body("e-c", "flow.rejected", 4));
- assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
- service.process(body("e-d", "flow.task.handled", 5));
- assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus(),
- "终态后任何非终态事件都不回退(重提走 resubmit 先置 10,不冲突)");
- }
-
- @Test
- void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
- service.process(body("e-e", "flow.rejected", 4));
- assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
- String frozen = apply.getFlowResultJson();
- service.process(body("e-f", "flow.withdrawn", 4));
- assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus());
- assertEquals(frozen, apply.getFlowResultJson(), "已终态不被另一终态改写");
- }
-
- // ===== helpers =====
-
- private ServiceApply freshApply() {
- ServiceApply a = new ServiceApply();
- ReflectionTestUtils.setField(a, "id", 42);
- a.setSchId(1L);
- a.setItemId(7);
- a.setItemCode("ITEM-1");
- a.setItemTitle("测试事项");
- a.setSubmitterId(9);
- a.setSubmitterName("张三");
- a.setFlowGuid("fg-1");
- a.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
- a.setLastInstanceVersion(1);
- when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(a));
- return a;
- }
-
- private String body(String event, Integer version) {
- return body("e-1", event, version);
- }
-
- private String body(String eventId, String event, Integer version) {
- return "{\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\",\"schId\":1,\"instanceId\":9001,"
- + "\"bizType\":\"svc_item\",\"bizId\":\"42\",\"flowGuid\":\"fg-1\","
- + "\"instanceVersion\":" + version + ","
- + "\"data\":{\"result\":\"同意\",\"opinion\":\"情况属实\"}}";
- }
-
- private String bodyWithout(String field) {
- String b = body("flow.task.created", 2);
- String needle = "\"" + field + "\":2,";
- assertEquals(true, b.contains(needle), "测试体应包含待删字段 " + needle);
- return b.replace(needle, "");
- }
-}
diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java
index f5afb28..137ace2 100644
--- a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java
+++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java
@@ -7,6 +7,7 @@ import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest;
import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
+import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
@@ -43,7 +44,10 @@ class ServiceFormServiceTest {
itemRepository = mock(ServiceItemRepository.class);
SiteGuard siteGuard = mock(SiteGuard.class);
when(siteGuard.requireSiteManager(null, "S1")).thenReturn(site);
- service = new ServiceFormService(formRepository, itemRepository, siteGuard);
+ FlowClient flowClient = mock(FlowClient.class);
+ when(flowClient.enabled()).thenReturn(false);
+ service = new ServiceFormService(formRepository, itemRepository, siteGuard,
+ flowClient, mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class));
}
private ServiceForm form(String code, int version, int status) {
diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java
new file mode 100644
index 0000000..ac1005b
--- /dev/null
+++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java
@@ -0,0 +1,80 @@
+package com.yunhai.svchall.service.impl;
+
+import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
+import com.yunhai.svchall.entity.ServiceForm;
+import com.yunhai.svchall.repository.ServiceFormRepository;
+import com.yunhai.svchall.repository.ServiceItemRepository;
+import com.yunhai.svchall.service.DeptSource;
+import com.yunhai.svchall.service.FlowClient;
+import com.yunhai.svchall.service.SiteDirectory;
+import com.yunhai.svchall.service.SiteGuard;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.util.Collections;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 表单级绑定生效流程解析单测(2026-09-20):事项自身绑定优先;为空回退绑定表单
+ * 最新版本的 flow_guid(草稿也算——版本行携带绑定,发布后自然生效);两侧都空=null。
+ */
+class ServiceItemEffectiveFlowTest {
+
+ private ServiceFormRepository formRepository;
+ private ServiceItemService service;
+
+ @BeforeEach
+ void setUp() {
+ formRepository = mock(ServiceFormRepository.class);
+ service = new ServiceItemService(
+ mock(ServiceItemRepository.class), formRepository,
+ mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
+ mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class),
+ mock(FlowClient.class), mock(FlowCallbackRegistrar.class), mock(TransactionTemplate.class));
+ when(formRepository.findBySchIdAndCodeOrderByVersionDesc(anyLong(), anyString()))
+ .thenReturn(Collections.emptyList());
+ }
+
+ private ServiceForm form(String flowGuid, Integer version) {
+ ServiceForm f = new ServiceForm();
+ f.setSchId(1L);
+ f.setCode("F1");
+ f.setVersion(version);
+ f.setFlowGuid(flowGuid);
+ return f;
+ }
+
+ @Test
+ void itemBindingWins() {
+ assertEquals("item-flow", service.effectiveFlowGuid(1L, "item-flow", "F1"));
+ }
+
+ @Test
+ void nullItemBindingFallsBackToLatestFormVersion() {
+ when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1"))
+ .thenReturn(java.util.Arrays.asList(form("v3-flow", 3), form("v2-flow", 2)));
+ assertEquals("v3-flow", service.effectiveFlowGuid(1L, null, "F1"),
+ "回退取最新版本行的绑定");
+ }
+
+ @Test
+ void noFormOrUnboundForm_yieldsNull() {
+ assertNull(service.effectiveFlowGuid(1L, null, null), "没绑表单且事项未绑 → 不可在线办理");
+ when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1"))
+ .thenReturn(Collections.singletonList(form(null, 1)));
+ assertNull(service.effectiveFlowGuid(1L, null, "F1"), "表单也没绑 → null");
+ }
+
+ @Test
+ void emptyStringsTreatedAsUnbound() {
+ assertNull(service.effectiveFlowGuid(1L, "", ""));
+ }
+}
diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java
index 72aacf2..adc86ef 100644
--- a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java
+++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java
@@ -1,7 +1,6 @@
package com.yunhai.svchall.service.impl;
-import com.yunhai.svchall.entity.ServiceFlowReg;
-import com.yunhai.svchall.repository.ServiceFlowRegRepository;
+import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.DeptSource;
@@ -10,117 +9,59 @@ import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
-import java.util.Optional;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * 回调注册防轮换单测(契约冻结:flow-center 重复注册会轮换 secret,导致已存密钥验签失配):
- * 仅 reg 缺失/失效(secret 空或 status≠0)时才(重)注册;有效注册绝不重复调用;
- * 注册失败 best-effort 不阻断事项保存。
+ * 回调注册联动单测(2026-09-20 迁 starter Registrar 后改版):
+ * "仅有效注册才不重注册"的防轮换语义已收进 starter FlowCallbackRegistrar(其侧测试覆盖),
+ * 本处只验联动开关——绑定 flowGuid 即委托 Registrar(Long→Integer schId 适配)、
+ * 未绑/流程中心停用不委托、注册失败 best-effort 不阻断保存。
*/
class ServiceItemFlowRegTest {
- private ServiceFlowRegRepository flowRegRepository;
private FlowClient flowClient;
+ private FlowCallbackRegistrar registrar;
private ServiceItemService service;
private SiteDirectory.SiteInfo site = new SiteDirectory.SiteInfo("S1", "站点一", 1L);
@BeforeEach
void setUp() {
- flowRegRepository = mock(ServiceFlowRegRepository.class);
flowClient = mock(FlowClient.class);
+ registrar = mock(FlowCallbackRegistrar.class);
service = new ServiceItemService(
- mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), flowRegRepository,
+ mock(ServiceItemRepository.class), mock(ServiceFormRepository.class),
mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class),
- flowClient, mock(TransactionTemplate.class));
- ReflectionTestUtils.setField(service, "callbackPublicBase", "http://localhost:8086");
+ flowClient, registrar, mock(TransactionTemplate.class));
when(flowClient.enabled()).thenReturn(true);
}
- private ServiceFlowReg reg(Integer status, String secret) {
- ServiceFlowReg reg = new ServiceFlowReg();
- reg.setSchId(1L);
- reg.setFlowGuid("fg-1");
- reg.setStatus(status);
- reg.setSecret(secret);
- return reg;
- }
-
@Test
- void validRegistrationIsNeverRepeated() {
- when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1"))
- .thenReturn(Optional.of(reg(0, "stored-secret")));
+ void boundFlow_delegatesToRegistrar() {
service.ensureFlowReg(site, "fg-1");
- verify(flowClient, never()).registerCallback(any(), anyString(), anyString(), any());
+ verify(registrar).ensureRegistered(1, "fg-1");
}
@Test
- void missingRegistrationIsCreated() {
- when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
- when(flowClient.registerCallback(eq(1L), eq("fg-1"),
- eq("http://localhost:8086/callback/flow"), isNull())).thenReturn("issued-secret");
- service.ensureFlowReg(site, "fg-1");
- assertEquals("issued-secret", firstSavedReg().getSecret());
- assertEquals(Integer.valueOf(0), firstSavedReg().getStatus());
- assertEquals("http://localhost:8086/callback/flow", firstSavedReg().getCallbackUrl());
- }
-
- @Test
- void registrationWithEmptySecretIsReissued() {
- // 失效注册(secret 空)→ 重新注册并原地更新同一行(拿回新 secret)
- ServiceFlowReg existing = reg(0, null);
- when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
- when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
- .thenReturn("rotated-secret");
- service.ensureFlowReg(site, "fg-1");
- assertSame(existing, firstSavedReg(), "失效注册应原地更新同一行");
- assertEquals("rotated-secret", firstSavedReg().getSecret());
- }
-
- @Test
- void registrationWithDisabledStatusIsReissued() {
- ServiceFlowReg existing = reg(1, "old-secret");
- when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing));
- when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull()))
- .thenReturn("rotated-secret");
- service.ensureFlowReg(site, "fg-1");
- verify(flowRegRepository).save(existing);
- }
-
- @Test
- void registrationFailureIsBestEffort() {
- when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty());
- when(flowClient.registerCallback(any(), anyString(), anyString(), any()))
- .thenThrow(new RuntimeException("flow-center down"));
- service.ensureFlowReg(site, "fg-1"); // 不抛——事项保存不被注册失败阻断
- verify(flowRegRepository, never()).save(any());
- }
-
- @Test
- void flowClientDisabledSkipsEverything() {
+ void nullFlowOrDisabledClient_noDelegation() {
+ service.ensureFlowReg(site, null);
when(flowClient.enabled()).thenReturn(false);
service.ensureFlowReg(site, "fg-1");
- verify(flowRegRepository, never()).findBySchIdAndFlowGuid(any(), anyString());
+ verify(registrar, never()).ensureRegistered(anyInt(), anyString());
}
- private ServiceFlowReg firstSavedReg() {
- org.mockito.ArgumentCaptor captor =
- org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class);
- verify(flowRegRepository).save(captor.capture());
- return captor.getValue();
+ @Test
+ void registrarFailure_swallowedBestEffort() {
+ doThrow(new RuntimeException("probe unreachable")).when(registrar)
+ .ensureRegistered(anyInt(), anyString());
+ service.ensureFlowReg(site, "fg-1"); // 不抛:下次保存重试
}
}
diff --git a/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java
new file mode 100644
index 0000000..182f830
--- /dev/null
+++ b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java
@@ -0,0 +1,165 @@
+package com.yunhai.svchall.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
+import com.yunhai.svchall.entity.ServiceApply;
+import com.yunhai.svchall.repository.ServiceApplyRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 svc-hall 侧状态机;
+ * 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的
+ * FlowCallbackReceiverTest 覆盖):事件映射;终态置 finishTime + 结果快照;
+ * 单据级乱序丢弃;终态不回退守卫;异常外抛(starter 回 5xx)。
+ */
+class SvcFlowCallbackHandlerTest {
+
+ private ServiceApplyRepository applyRepository;
+ private SvcFlowCallbackHandler handler;
+ private ServiceApply apply;
+
+ @BeforeEach
+ void setUp() {
+ applyRepository = mock(ServiceApplyRepository.class);
+ handler = new SvcFlowCallbackHandler(applyRepository, new ObjectMapper());
+
+ apply = new ServiceApply();
+ apply.setId(7);
+ apply.setSchId(1L);
+ apply.setFlowStatus(10);
+ apply.setLastInstanceVersion(1);
+ when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.of(apply));
+ when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> inv.getArgument(0));
+ }
+
+ private FlowCallbackEvent event(String eventId, String event, Integer version) {
+ FlowCallbackEvent e = new FlowCallbackEvent();
+ e.setEventId(eventId);
+ e.setEvent(event);
+ e.setSchId(1);
+ e.setFlowGuid("fg-1");
+ e.setInstanceId(9527L);
+ e.setInstanceVersion(version);
+ e.setBizType(SvcFlowCallbackHandler.BIZ_TYPE);
+ e.setBizId("7");
+ e.setTaskId(33L);
+ e.setOperatorUserId(201);
+ e.setOperatorName("张三");
+ Map data = new HashMap<>();
+ e.setData(data);
+ return e;
+ }
+
+ @Test
+ void inFlightEvents_mapToApproving() {
+ handler.onEvent(event("e1", "flow.submitted", 1));
+ assertEquals(10, apply.getFlowStatus().intValue());
+ handler.onEvent(event("e2", "flow.task.created", 1));
+ assertEquals(10, apply.getFlowStatus().intValue());
+ assertNull(apply.getFinishTime(), "在途事件不置办结时间");
+ }
+
+ @Test
+ void completed_setsFinishTimeAndResultSnapshot() {
+ FlowCallbackEvent e = event("e3", "flow.completed", 3);
+ e.getData().put("result", "APPROVED");
+ e.getData().put("opinion", "同意");
+ handler.onEvent(e);
+ assertEquals(20, apply.getFlowStatus().intValue());
+ assertNotNull(apply.getFinishTime(), "终态置 finish_time");
+ assertNotNull(apply.getFlowResultJson());
+ assertTrue(apply.getFlowResultJson().contains("APPROVED"));
+ assertTrue(apply.getFlowResultJson().contains("同意"));
+ assertEquals(3, apply.getLastInstanceVersion().intValue());
+ }
+
+ @Test
+ void rejected_mapsTo30_withRejectNode() {
+ FlowCallbackEvent e = event("e4", "flow.rejected", 3);
+ e.getData().put("result", "REJECTED");
+ e.getData().put("rejectNode", "终审");
+ handler.onEvent(e);
+ assertEquals(30, apply.getFlowStatus().intValue());
+ assertTrue(apply.getFlowResultJson().contains("终审"));
+ }
+
+ @Test
+ void withdrawn_mapsTo40() {
+ handler.onEvent(event("e5", "flow.withdrawn", 2));
+ assertEquals(40, apply.getFlowStatus().intValue());
+ }
+
+ @Test
+ void illegalBizId_silentReturn() {
+ FlowCallbackEvent e = event("e6", "flow.completed", 3);
+ e.setBizId("not-a-number");
+ handler.onEvent(e); // 不抛(starter 回 200)
+ }
+
+ @Test
+ void unknownApply_silentReturn() {
+ when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.empty());
+ handler.onEvent(event("e7", "flow.completed", 3)); // 不抛
+ }
+
+ @Test
+ void staleVersion_dropped() {
+ apply.setLastInstanceVersion(5);
+ handler.onEvent(event("e8", "flow.completed", 3));
+ assertEquals(10, apply.getFlowStatus().intValue(), "乱序事件丢弃");
+ }
+
+ @Test
+ void sameVersionDifferentEvent_processed() {
+ apply.setLastInstanceVersion(2);
+ handler.onEvent(event("e9", "flow.task.handled", 2));
+ assertEquals(10, apply.getFlowStatus().intValue(), "同版本事件合法(严格小于才丢弃)");
+ }
+
+ @Test
+ void terminalRow_rejectsNonTerminalEvent() {
+ handler.onEvent(event("t1", "flow.completed", 3));
+ assertEquals(20, apply.getFlowStatus().intValue());
+ String frozen = apply.getFlowResultJson();
+ handler.onEvent(event("t2", "flow.task.handled", 3)); // 迟到的同版本在途事件
+ assertEquals(20, apply.getFlowStatus().intValue(), "终态不回退");
+ assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
+ }
+
+ @Test
+ void terminalGuard_doesNotBlockNewRound_afterResubmission() {
+ handler.onEvent(event("t3", "flow.rejected", 3));
+ apply.setFlowStatus(10); // resubmit 成功后窄列原子置 10
+ handler.onEvent(event("t4", "flow.submitted", 4));
+ assertEquals(10, apply.getFlowStatus().intValue(), "新一轮事件正常推进");
+ assertEquals(4, apply.getLastInstanceVersion().intValue());
+ }
+
+ @Test
+ void unknownEvent_silentReturn() {
+ handler.onEvent(event("u1", "flow.returned", 3));
+ assertEquals(10, apply.getFlowStatus().intValue(), "未消费的已知事件码静默忽略");
+ }
+
+ @Test
+ void dbFailure_propagates() {
+ when(applyRepository.save(any(ServiceApply.class)))
+ .thenThrow(new org.springframework.dao.DataAccessResourceFailureException("db down"));
+ assertThrows(org.springframework.dao.DataAccessResourceFailureException.class,
+ () -> handler.onEvent(event("e10", "flow.completed", 3)));
+ }
+}