- 回调迁 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:
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user