feat(flow): 回调链路迁平台 flow-callback starter + 表单级流程绑定
build / build-push (push) Failing after 8s

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