diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..783f90a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +*.log +.idea/ +*.iml +.DS_Store diff --git a/pom.xml b/pom.xml index e702a3d..d980c35 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,13 @@ com.yunhai.platform yh-spring-boot-starter + + + + com.yunhai.platform + yh-flow-callback-spring-boot-starter + org.jasig.cas.client diff --git a/sql/20260920_flow_callback_starter_migration.sql b/sql/20260920_flow_callback_starter_migration.sql new file mode 100644 index 0000000..0c7eced --- /dev/null +++ b/sql/20260920_flow_callback_starter_migration.sql @@ -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 不删(存档),代码不再读写。 diff --git a/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java b/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java deleted file mode 100644 index 65e1be9..0000000 --- a/src/main/java/com/yunhai/svchall/config/FlowCallbackFilterConfiguration.java +++ /dev/null @@ -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 且非空)。 - * 注册探活 ping 直接 200——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() { - FilterRegistrationBean bean = new FilterRegistrationBean<>(); - bean.setFilter(new FlowCallbackVerifyFilter()); - bean.addUrlPatterns("/callback/*"); - // 先于业务过滤器链(AppAuthFilter +10 与签名强制 +11 均在 HIGHEST_PRECEDENCE 之上) - bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20); - return bean; - } - - class FlowCallbackVerifyFilter implements Filter { - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse res = (HttpServletResponse) response; - if (!"POST".equalsIgnoreCase(req.getMethod())) { - res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); - return; - } - ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req); - String body = wrapper.bodyString(); - // 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过) - if (isPing(body)) { - byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); - res.setContentType("application/json"); - res.setContentLength(ok.length); - res.setStatus(HttpServletResponse.SC_OK); - res.getOutputStream().write(ok); - return; - } - String eventId = req.getHeader("X-Flw-EventId"); - String timestamp = req.getHeader("X-Flw-Timestamp"); - String nonce = req.getHeader("X-Flw-Nonce"); - String signature = req.getHeader("X-Flw-Signature"); - if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp) - || !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) { - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers"); - return; - } - try { - long ts = Long.parseLong(timestamp.trim()); - if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) { - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window"); - return; - } - } catch (NumberFormatException e) { - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp"); - return; - } - String flowGuid = extractFlowGuid(body); - String secret = flowGuid == null ? null : callbackService.secretOf(flowGuid); - if (secret == null) { - log.warn("[svc-hall] 回调验签失败:无注册或无 secret,flowGuid={}", flowGuid); - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration"); - return; - } - String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body); - if (!constantTimeEquals(expected, signature.trim())) { - res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature"); - return; - } - chain.doFilter(wrapper, response); - } - - /** 注册探活判定:event=ping(flow-center 注册时发送,签名头是字面量 "ping") */ - private boolean isPing(String body) { - try { - JsonNode payload = objectMapper.readTree(body); - return payload != null && "ping".equals(payload.path("event").asText(null)); - } catch (Exception e) { - return false; - } - } - - /** 从推送体提取 flowGuid(验签必需的最小解析) */ - private String extractFlowGuid(String body) { - try { - JsonNode payload = objectMapper.readTree(body); - return payload.has("flowGuid") && !payload.get("flowGuid").isNull() - ? payload.get("flowGuid").asText() : null; - } catch (Exception e) { - return null; - } - } - - private String hmacSha256Base64(String secret, String content) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(raw); - } catch (Exception e) { - throw new IllegalStateException("HmacSHA256 计算失败", e); - } - } - - private boolean constantTimeEquals(String a, String b) { - return java.security.MessageDigest.isEqual( - a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); - } - } -} diff --git a/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java index f2b791e..2e7adf3 100644 --- a/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/svchall/config/OpenApiAuthEnforceConfiguration.java @@ -20,7 +20,7 @@ import java.io.IOException; * {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流; * 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。 * /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。 - * 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用 + * 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 yh-flow-callback starter 的 X-Flw 验签过滤器用 * X-Flw-* 四头验签,两种签名体系互不干扰。 */ @Configuration diff --git a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java deleted file mode 100644 index 09d8081..0000000 --- a/src/main/java/com/yunhai/svchall/controller/callback/FlowCallbackController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yunhai.svchall.controller.callback; - -import com.yunhai.svchall.service.impl.FlowCallbackService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.nio.charset.StandardCharsets; - -/** - * 流程中心回调接收端点(/callback/flow;不在 CAS pattern(/api/*)内、也不在 /oapi/* - * (appKey 签名)内——独立走 FlowCallbackVerifyFilter 的 X-Flw-* 四头验签后才到达这里)。 - *

- * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/ - * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放); - * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/ - * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平 - * (替代旧"恒 200 + 每日对账"架构,验收报告 §五遗留批次)。 - */ -@Slf4j -@RestController -@RequestMapping("/callback") -@RequiredArgsConstructor -public class FlowCallbackController { - - private final FlowCallbackService callbackService; - - @PostMapping("/flow") - public ResponseEntity flow(@RequestBody byte[] body) { - try { - callbackService.process(new String(body, StandardCharsets.UTF_8)); - return ResponseEntity.ok("{\"ok\":true}"); - } catch (FlowCallbackService.TransientCallbackException e) { - log.error("[svc-hall] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); - } catch (Exception e) { - // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) - log.error("[svc-hall] 回调意外异常(回 5xx 触发流程中心重试)", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); - } - } -} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java index bcc006c..ae33c77 100644 --- a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java +++ b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java @@ -50,6 +50,13 @@ public class ServiceForm extends BaseEntity { @Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT") private String schemaJson; + /** + * 表单级流程绑定(2026-09-20 表单级绑定):用本表单的事项在自身未绑流程时回退用它。 + * 绑定随版本行携带(克隆新版本继承);办件发起时快照生效值到 service_apply.flow_guid。 + */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + /** 发布时间 */ @Column(name = "publish_time") private LocalDateTime publishTime; diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java index 20d94f8..5fbce11 100644 --- a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java +++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java @@ -4,7 +4,7 @@ import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.enums.ApplyFlowStatusEnum; import com.yunhai.svchall.repository.ServiceApplyRepository; import com.yunhai.svchall.service.FlowClient; -import com.yunhai.svchall.service.impl.FlowCallbackService; +import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; @@ -56,7 +56,7 @@ public class FlowReconcileJob { } try { Map remote = flowClient.getInstanceByBiz( - apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId())); + apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId())); if (remote == null) { continue; } diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java index 8c18e9e..ce47cca 100644 --- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java +++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java @@ -343,6 +343,8 @@ public final class SvcModels { private String name; @NotBlank(message = "表单结构不得为空") private String schemaJson; + /** 表单级流程绑定(可空;绑定事项未自身绑流时回退用它) */ + private String flowGuid; } @Data @@ -353,6 +355,8 @@ public final class SvcModels { private Integer version; private Integer status; private String schemaJson; + /** 表单级流程绑定(可空) */ + private String flowGuid; private LocalDateTime publishTime; private Boolean referenced; /** 绑定本表单的事项明细(仅 list 填充;表单页反查使用位置) */ @@ -366,6 +370,7 @@ public final class SvcModels { vo.setVersion(form.getVersion()); vo.setStatus(form.getStatus()); vo.setSchemaJson(form.getSchemaJson()); + vo.setFlowGuid(form.getFlowGuid()); vo.setPublishTime(form.getPublishTime()); vo.setReferenced(referenced); return vo; diff --git a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java index c312607..97266cf 100644 --- a/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java +++ b/src/main/java/com/yunhai/svchall/service/ApplyViewGuard.java @@ -4,7 +4,7 @@ import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.entity.ServiceApply; -import com.yunhai.svchall.service.impl.FlowCallbackService; +import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -96,7 +96,7 @@ public class ApplyViewGuard { List assignees = Collections.emptyList(); try { Map instance = flowClient.getInstanceByBiz( - apply.getSchId(), FlowCallbackService.BIZ_TYPE, String.valueOf(apply.getId())); + apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId())); if (instance != null && instance.get("assigneeIds") instanceof List) { assignees = (List) instance.get("assigneeIds"); } diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java deleted file mode 100644 index 12bdf4f..0000000 --- a/src/main/java/com/yunhai/svchall/service/impl/FlowCallbackService.java +++ /dev/null @@ -1,231 +0,0 @@ -package com.yunhai.svchall.service.impl; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yunhai.svchall.entity.ServiceApply; -import com.yunhai.svchall.entity.ServiceFlowEvent; -import com.yunhai.svchall.entity.ServiceFlowReg; -import com.yunhai.svchall.enums.ApplyFlowStatusEnum; -import com.yunhai.svchall.repository.ServiceApplyRepository; -import com.yunhai.svchall.repository.ServiceFlowEventRepository; -import com.yunhai.svchall.repository.ServiceFlowRegRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - -/** - * 流程中心回调事件处理(验收报告 §五遗留批次"回调语义修订",Notice FlowCallbackService D2 同构)。 - * 返回语义(契约冻结,免对账核心):瞬时失败(DB 异常/锁冲突)抛 {@link TransientCallbackException} - * → controller 回 5xx → flow-center 持续重试直至送达;毒 JSON/缺字段/eventId 去重命中/ - * 乱序丢弃/非本应用办件 → 正常返回(HTTP 200,防无限重放毒消息)。 - * 定位:按 payload.bizId(本应用行已先于代发起提交,竞态闭合)+ {@code findByIdForUpdate} - * 行锁串行化并发推送;事件映射:submitted/task.created/task.handled → 10;completed → 20; - * rejected → 30;withdrawn → 40(对齐流程中心概览 §8.3)。 - * 乱序闸门:instanceVersion 严格小于已消费版本才丢弃——flow-center 存在同版本多事件 - * (submitted+task.created 同 v1,两会签 handled 同 v3),≤ 会误杀合法事件(验收报告 §四.3)。 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class FlowCallbackService { - - /** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */ - public static final String BIZ_TYPE = "svc_item"; - - /** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */ - public static class TransientCallbackException extends RuntimeException { - public TransientCallbackException(String message, Throwable cause) { - super(message, cause); - } - } - - private final ServiceFlowEventRepository eventRepository; - private final ServiceFlowRegRepository regRepository; - private final ServiceApplyRepository applyRepository; - private final ObjectMapper objectMapper; - - /** - * 处理一条回调推送体(已通过 FlowCallbackVerifyFilter 验签)。 - * 毒消息静默 200;瞬时失败抛 TransientCallbackException 且事务回滚(含去重留档行),重试从头再来。 - */ - @Transactional - public void process(String body) { - JsonNode payload; - try { - payload = objectMapper.readTree(body); - } catch (Exception e) { - // 协议毒消息:不可重试,静默 200 防无限重放 - log.warn("[svc-hall] 回调体不是合法 JSON(丢弃)"); - return; - } - String eventId = text(payload, "eventId"); - String event = text(payload, "event"); - String bizType = text(payload, "bizType"); - String bizId = text(payload, "bizId"); - Long schId = longVal(payload, "schId"); - Long instanceId = longVal(payload, "instanceId"); - if (eventId == null || event == null || bizType == null || bizId == null - || schId == null || instanceId == null) { - log.warn("[svc-hall] 回调缺少必要字段 eventId/event/bizType/bizId/schId/instanceId(丢弃):event={}", event); - return; - } - if ("ping".equals(event)) { - return; // 注册探活回声(过滤器已直接 200,防御性兜底) - } - if (!BIZ_TYPE.equals(bizType)) { - log.warn("[svc-hall] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId); - return; - } - // 第一道闸:eventId 去重(唯一约束兜底并发);重复投递在持续重试语义下必然发生 - if (eventRepository.existsByEventId(eventId)) { - log.info("[svc-hall] 回调 eventId={} 重复投递,跳过", eventId); - return; - } - ServiceFlowEvent record = new ServiceFlowEvent(); - record.setEventId(eventId); - record.setEvent(event); - record.setSchId(schId); - record.setInstanceId(instanceId); - record.setReceiveTime(LocalDateTime.now()); - record.setPayloadJson(body); - try { - eventRepository.saveAndFlush(record); - } catch (DataIntegrityViolationException e) { - log.info("[svc-hall] 回调 eventId={} 并发重复(uk 命中),跳过", eventId); - return; // 事务回滚无副作用(尚未写办件) - } - - // 第二道闸前置:按 bizId 行锁定位(并发推送对同一办件排队,防丢更新) - Integer applyId; - try { - applyId = Integer.valueOf(bizId.trim()); - } catch (NumberFormatException e) { - log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", bizId); - return; - } - ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null); - if (apply == null) { - log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)", bizId, eventId); - return; - } - Integer version = intVal(payload, "instanceVersion"); - // 乱序闸门:严格小于已消费版本才丢弃(同版本多事件合法) - if (version != null && apply.getLastInstanceVersion() != null - && version < apply.getLastInstanceVersion()) { - log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}", - instanceId, version, apply.getLastInstanceVersion()); - return; - } - try { - advance(apply, event, version, payload); - } catch (TransientCallbackException e) { - throw e; - } catch (Exception e) { - // DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 持续重试追平(免对账核心语义) - throw new TransientCallbackException("回调状态推进失败:apply=" + applyId + " event=" + event, e); - } - log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}", - applyId, instanceId, event, apply.getFlowStatus()); - } - - /** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */ - private void advance(ServiceApply apply, String event, Integer version, JsonNode payload) { - Integer status = mapStatus(event); - if (status == null) { - log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event); - return; - } - // P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled - // 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10 - // 并用 handled 明细覆盖已落定的 flow_result_json。命中守卫记 log 后正常返回(200, - // 不触发重试)。重提不冲突:resubmit 成功后先以窄列原子置 10,新一轮事件到达时行已非终态。 - if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) { - if (!ApplyFlowStatusEnum.byCode(status).terminal()) { - log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})", - apply.getId(), apply.getFlowStatus(), event, version); - return; - } - if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) { - log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})", - apply.getId(), apply.getFlowStatus(), event); - return; - } - } - Map result = new HashMap<>(); - result.put("event", event); - JsonNode data = payload.get("data"); - if (data != null && data.isObject()) { - if (data.has("result") && !data.get("result").isNull()) { - result.put("result", data.get("result").asText()); - } - if (data.has("opinion") && !data.get("opinion").isNull()) { - result.put("opinion", data.get("opinion").asText()); - } - if (data.has("rejectNode") && !data.get("rejectNode").isNull()) { - result.put("rejectNode", data.get("rejectNode").asText()); - } - } - try { - apply.setFlowResultJson(objectMapper.writeValueAsString(result)); - } catch (Exception ignored) { - // 明细序列化失败不影响状态推进 - } - apply.setFlowStatus(status); - if (version != null) { - apply.setLastInstanceVersion(version); - } - if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode()) - || status.equals(ApplyFlowStatusEnum.REJECTED.getCode()) - || status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) { - apply.setFinishTime(LocalDateTime.now()); - } - applyRepository.save(apply); - } - - /** - * 回调验签密钥查找(FlowCallbackVerifyFilter 用;按 flowGuid 定注册)。 - * 仅有效注册(status=0 且 secret 非空)返回——失效注册按无注册处理(401), - * 修复路径=管理端重新保存绑定触发重注册(重复注册会轮换 secret,绝不能静默轮换)。 - */ - public String secretOf(String flowGuid) { - ServiceFlowReg reg = regRepository.findByFlowGuid(flowGuid).orElse(null); - boolean valid = reg != null && reg.getStatus() != null && reg.getStatus() == 0 - && reg.getSecret() != null && !reg.getSecret().isEmpty(); - return valid ? reg.getSecret() : null; - } - - private static Integer mapStatus(String event) { - switch (event) { - case "flow.submitted": - case "flow.task.created": - case "flow.task.handled": - return ApplyFlowStatusEnum.APPROVING.getCode(); - case "flow.completed": - return ApplyFlowStatusEnum.COMPLETED.getCode(); - case "flow.rejected": - return ApplyFlowStatusEnum.REJECTED.getCode(); - case "flow.withdrawn": - return ApplyFlowStatusEnum.WITHDRAWN.getCode(); - default: - return null; - } - } - - private static String text(JsonNode node, String field) { - return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; - } - - private static Long longVal(JsonNode node, String field) { - return node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null; - } - - private static Integer intVal(JsonNode node, String field) { - return node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null; - } -} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java index a738c21..d5064b5 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java @@ -162,7 +162,7 @@ public class FlowCenterClient implements FlowClient { body.setCallbackUrl(callbackUrl); body.setEventTypes(eventTypes); // 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /callback/flow): - // 推送方按它剥离后再签应用签名,与本服务 AppAuthFilter/FlowCallbackVerifyFilter 剥 + // 推送方按它剥离后再签应用签名,与本服务验签过滤器剥 // contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变(不下发空串)。 String basePath = deriveCallbackBasePath(callbackUrl); if (basePath != null && !basePath.isEmpty()) { diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java index 4ff1e0e..6ee0c5e 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java @@ -92,9 +92,6 @@ public class ServiceApplyService { if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理"); } - if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); - } // 表单解析 + 提交数据强校验(按最新已发布版本;发起时快照 form_version) ServiceForm form = null; if (item.getFormCode() != null && !item.getFormCode().isEmpty()) { @@ -105,6 +102,15 @@ public class ServiceApplyService { } else if (StringUtils.hasText(req.getFormData())) { throw new BizException(ErrorCode.PARAM_ERROR, "该事项无办理表单,不应提交表单数据"); } + // 生效流程(表单级绑定,2026-09-20):事项自身优先,为空回退已发布表单的绑定 + String effectiveFlowGuid = item.getFlowGuid(); + if ((effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) + && form != null && form.getFlowGuid() != null && !form.getFlowGuid().isEmpty()) { + effectiveFlowGuid = form.getFlowGuid(); + } + if (effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } ServiceApply apply = new ServiceApply(); apply.setSchId(site.getSchId()); @@ -117,7 +123,7 @@ public class ServiceApplyService { apply.setFormDataJson(req.getFormData()); apply.setSubmitterId(user.getId()); apply.setSubmitterName(user.getName()); - apply.setFlowGuid(item.getFlowGuid()); + apply.setFlowGuid(effectiveFlowGuid); apply = applyRepository.save(apply); // 办件材料绑定(必须落库拿到 applyId 之后再绑) @@ -198,7 +204,7 @@ public class ServiceApplyService { if (apply.getFlowInstanceId() == null) { SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo(); empty.setInstanceId(null); - empty.setBizType(FlowCallbackService.BIZ_TYPE); + empty.setBizType(SvcFlowCallbackHandler.BIZ_TYPE); empty.setBizId(String.valueOf(apply.getId())); empty.setBizTitle(apply.getItemTitle()); empty.setStatus(apply.getFlowStatus()); @@ -322,9 +328,6 @@ public class ServiceApplyService { if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理"); } - if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); - } Map out = new HashMap<>(); out.put("siteCode", site.getSiteCode()); out.put("itemCode", item.getItemCode()); @@ -336,13 +339,27 @@ public class ServiceApplyService { out.put("promiseTimeLimit", item.getPromiseTimeLimit()); out.put("contactPhone", item.getContactPhone()); out.put("coverImage", com.yunhai.svchall.utils.FileUtil.absolutizePlain(item.getCoverImage())); - out.put("flowGuid", item.getFlowGuid()); if (item.getFormCode() != null && !item.getFormCode().isEmpty()) { ServiceForm form = formService.latestPublished(site.getSchId(), item.getFormCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办理表单未发布,请联系管理员")); out.put("formVersion", form.getVersion()); out.put("schemaJson", form.getSchemaJson()); + // 生效流程(表单级绑定):事项自身优先,为空回退已发布表单的绑定 + String effectiveFlowGuid = item.getFlowGuid(); + if ((effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) + && form.getFlowGuid() != null && !form.getFlowGuid().isEmpty()) { + effectiveFlowGuid = form.getFlowGuid(); + } + if (effectiveFlowGuid == null || effectiveFlowGuid.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } + out.put("flowGuid", effectiveFlowGuid); + } else { + if (item.getFlowGuid() == null || item.getFlowGuid().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } + out.put("flowGuid", item.getFlowGuid()); } return out; } @@ -438,7 +455,7 @@ public class ServiceApplyService { FlowClient.ApplyResult result = flowClient.apply( apply.getSchId(), apply.getFlowGuid(), - FlowCallbackService.BIZ_TYPE, + SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()), apply.getSubmitterName() + "的" + apply.getItemTitle(), // 前端详情页是 hash 路由 + query 传参(routes.ts path="/apply-detail"、 diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java index fb17136..2d99628 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceFormService.java @@ -36,6 +36,8 @@ public class ServiceFormService { private final ServiceFormRepository formRepository; private final ServiceItemRepository itemRepository; private final SiteGuard siteGuard; + private final com.yunhai.svchall.service.FlowClient flowClient; + private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar; /** 表单列表(sch 级;同 code 多版本聚合成一行最新版,referenced=是否被事项绑定,refItems=绑定事项明细) */ public List list(BoaUser user, String siteCode) { @@ -118,7 +120,34 @@ public class ServiceFormService { } form.setName(req.getName()); form.setSchemaJson(req.getSchemaJson()); - return formRepository.save(form).getId(); + // 表单级流程绑定(2026-09-20):克隆新版本随请求携带(编辑页回显旧值);可空 + String flowGuid = req.getFlowGuid() == null || req.getFlowGuid().trim().isEmpty() + ? null : req.getFlowGuid().trim(); + if (flowGuid != null && flowClient.enabled() + && flowClient.getFlow(site.getSchId(), flowGuid) == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "绑定流程不存在或未发布:" + flowGuid); + } + form.setFlowGuid(flowGuid); + Integer id = formRepository.save(form).getId(); + ensureFlowReg(site, flowGuid); + return id; + } + + /** + * 绑定联动(与 ServiceItemService.ensureFlowReg 同语义):表单绑定流程即确保有效回调注册存在。 + * best-effort:失败记 warn 不阻断保存。 + */ + private void ensureFlowReg(SiteInfo site, String flowGuid) { + if (flowGuid == null || !flowClient.enabled()) { + return; + } + try { + callbackRegistrar.ensureRegistered( + site.getSchId() == null ? null : site.getSchId().intValue(), flowGuid); + } catch (Exception e) { + java.util.logging.Logger.getLogger(getClass().getName()).warning( + "[svc-hall] 表单绑定回调注册失败(下次保存重试)flowGuid=" + flowGuid + ":" + e.getMessage()); + } } /** 发布:草稿 → 已发布(版本固化;事项侧经 code 取到本版本) */ diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java index ff62ddd..a0244e8 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java @@ -81,13 +81,8 @@ public class ServiceItemService { @Value("${svchall.item.dept-group-type:7}") private int deptGroupType = 7; - /** 本应用对外可达基址(回调注册 callbackUrl 前缀;projman 注入,本地默认 dev) */ - @Value("${svchall.public-base-url:http://localhost:8086}") - private String callbackPublicBase; - private final ServiceItemRepository itemRepository; private final com.yunhai.svchall.repository.ServiceFormRepository formRepository; - private final com.yunhai.svchall.repository.ServiceFlowRegRepository flowRegRepository; private final SiteDirectory siteDirectory; private final ServiceDictService dictService; private final ServiceFormService formService; @@ -95,6 +90,7 @@ public class ServiceItemService { private final SiteGuard siteGuard; private final DeptSource deptSource; private final FlowClient flowClient; + private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar; private final TransactionTemplate transactionTemplate; // ===== 管理端 ===== @@ -206,44 +202,50 @@ public class ServiceItemService { item = itemRepository.save(item); attachmentService.bindGuide(site.getSchId(), item.getId(), req.getAttachmentIds()); - ensureFlowReg(site, item.getFlowGuid()); + // 注册以生效流程为准(表单级绑定:事项自身为空时用表单的) + ensureFlowReg(site, effectiveFlowGuid(site.getSchId(), item.getFlowGuid(), item.getFormCode())); return item.getId(); } /** - * 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换):事项绑 flow_guid 时确保有效回调注册存在 - * (reg 下发 secret 落 service_flow_reg)。仅 reg 缺失/失效(secret 空或 status≠0)时才注册—— - * flow-center 重复注册会轮换 secret,导致已存密钥验签失配;失效注册原地更新同一行拿回新密钥。 + * 流程绑定联动(V2.0 B.2.6 + 契约冻结防轮换;2026-09-20 迁 starter + * {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):绑定 flowGuid 时确保 + * 有效回调注册存在(reg 下发 secret 落 starter 镜像表 flow_callback_reg)。 + * 仅镜像行缺失/失效时才注册——flow-center 重复注册会轮换 secret,导致已存密钥验签失配。 * best-effort:注册失败记 warn 不阻断保存,下次保存重试。包级可见以便单测直测。 */ void ensureFlowReg(SiteInfo site, String flowGuid) { if (flowGuid == null || !flowClient.enabled()) { return; } - com.yunhai.svchall.entity.ServiceFlowReg reg = flowRegRepository - .findBySchIdAndFlowGuid(site.getSchId(), flowGuid).orElse(null); - if (reg != null && reg.getStatus() != null && reg.getStatus() == 0 - && reg.getSecret() != null && !reg.getSecret().isEmpty()) { - return; - } - String callbackUrl = callbackPublicBase + "/callback/flow"; try { - String secret = flowClient.registerCallback(site.getSchId(), flowGuid, callbackUrl, null); - if (reg == null) { - reg = new com.yunhai.svchall.entity.ServiceFlowReg(); - reg.setSchId(site.getSchId()); - reg.setFlowGuid(flowGuid); - } - reg.setCallbackUrl(callbackUrl); - reg.setSecret(secret); - reg.setStatus(0); - flowRegRepository.save(reg); + callbackRegistrar.ensureRegistered( + site.getSchId() == null ? null : site.getSchId().intValue(), flowGuid); } catch (Exception e) { log.warn("[svc-hall] 回调注册失败(下次保存重试)siteCode={} flowGuid={}:{}", site.getSiteCode(), flowGuid, e.getMessage()); } } + /** + * 生效流程解析(表单级绑定,2026-09-20):事项自身绑定优先,为空回退绑定表单最新版本的 + * flow_guid。返回 null=两侧都没绑(不可在线办理)。 + */ + String effectiveFlowGuid(Long schId, String itemFlowGuid, String formCode) { + if (itemFlowGuid != null && !itemFlowGuid.isEmpty()) { + return itemFlowGuid; + } + if (formCode == null || formCode.isEmpty()) { + return null; + } + return formRepository + .findBySchIdAndCodeOrderByVersionDesc(schId, formCode).stream() + .findFirst() + .map(com.yunhai.svchall.entity.ServiceForm::getFlowGuid) + .filter(guid -> guid != null && !guid.isEmpty()) + .orElse(null); + } + @Transactional public void adminPublish(BoaUser user, String siteCode, Integer id) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); @@ -657,7 +659,8 @@ public class ServiceItemService { } } - /** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析);流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ flowGuid 非空 */ + /** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析); + * 流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ 生效流程(事项自身或绑定表单的)非空 */ private void validateBindings(SiteInfo site, SvcItemSaveRequest req) { String formCode = emptyToNull(req.getFormCode()); if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) { @@ -670,9 +673,9 @@ public class ServiceItemService { } } boolean online = req.getOnlineFlag() == null || req.getOnlineFlag() != 0; - if (online && flowGuid == null) { + if (online && effectiveFlowGuid(site.getSchId(), flowGuid, formCode) == null) { throw new BizException(ErrorCode.PARAM_ERROR, - "可在线办理的事项必须绑定流程(flowGuid);线下事项请关闭\"可在线办理\"开关"); + "可在线办理的事项必须绑定流程(自身或所选表单二选一);线下事项请关闭\"可在线办理\"开关"); } } diff --git a/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java new file mode 100644 index 0000000..1c50ed5 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandler.java @@ -0,0 +1,138 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.flowcallback.FlowCallbackHandler; +import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter): + * X-Flw 验签(starter 可选过滤器)、ping、必填校验、bizType 过滤、eventId 幂等、 + * instanceVersion 乱序门由 starter 接收管线承担;本类只做办件镜像状态机推进—— + * 按 bizId 行锁定位 + 单据级乱序闸门 + 事件映射(submitted/task.*→10;completed→20; + * rejected→30;withdrawn→40)+ 终态不回退守卫 + 结果明细快照 + 终态 finishTime。 + * 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账); + * 正常返回(含办件不存在/未知事件等不可重试场景)→ 200。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SvcFlowCallbackHandler implements FlowCallbackHandler { + + /** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */ + public static final String BIZ_TYPE = "svc_item"; + + private final ServiceApplyRepository applyRepository; + private final ObjectMapper objectMapper; + + @Override + public void onEvent(FlowCallbackEvent event) { + Integer applyId; + try { + applyId = Integer.valueOf(event.getBizId().trim()); + } catch (NumberFormatException e) { + log.warn("[svc-hall] 回调 bizId 非法(丢弃):{}", event.getBizId()); + return; + } + // 行锁串行化:并发推送(flow-center 推送池)对同一办件的处理排队,防丢更新 + ServiceApply apply = applyRepository.findByIdForUpdate(applyId).orElse(null); + if (apply == null) { + log.warn("[svc-hall] 回调找不到对应办件 bizId={} eventId={}(忽略)", + event.getBizId(), event.getEventId()); + return; + } + Integer version = event.getInstanceVersion(); + // 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)—— + // 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1) + if (version != null && apply.getLastInstanceVersion() != null + && version < apply.getLastInstanceVersion()) { + log.info("[svc-hall] 回调乱序丢弃 instanceId={} incoming={} last={}", + event.getInstanceId(), version, apply.getLastInstanceVersion()); + return; + } + advance(apply, event.getEvent(), version, event); + log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}", + applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus()); + } + + /** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */ + private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload) { + Integer status = mapStatus(event); + if (status == null) { + log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event); + return; + } + // P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled + // 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10 + // 并用 handled 明细覆盖已落定的 flow_result_json。重提不冲突:resubmit 成功后先以 + // 窄列原子置 10,新一轮事件到达时行已非终态。 + if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) { + if (!ApplyFlowStatusEnum.byCode(status).terminal()) { + log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})", + apply.getId(), apply.getFlowStatus(), event, version); + return; + } + if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) { + log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})", + apply.getId(), apply.getFlowStatus(), event); + return; + } + } + Map result = new HashMap<>(); + result.put("event", event); + Map data = payload.getData(); + if (data != null) { + copyIfPresent(data, result, "result"); + copyIfPresent(data, result, "opinion"); + copyIfPresent(data, result, "rejectNode"); + } + try { + apply.setFlowResultJson(objectMapper.writeValueAsString(result)); + } catch (Exception ignored) { + // 明细序列化失败不影响状态推进 + } + apply.setFlowStatus(status); + if (version != null) { + apply.setLastInstanceVersion(version); + } + if (status.equals(ApplyFlowStatusEnum.COMPLETED.getCode()) + || status.equals(ApplyFlowStatusEnum.REJECTED.getCode()) + || status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) { + apply.setFinishTime(LocalDateTime.now()); + } + applyRepository.save(apply); + } + + private static void copyIfPresent(Map data, Map target, String key) { + Object v = data.get(key); + if (v != null) { + target.put(key, String.valueOf(v)); + } + } + + private static Integer mapStatus(String event) { + switch (event) { + case "flow.submitted": + case "flow.task.created": + case "flow.task.handled": + return ApplyFlowStatusEnum.APPROVING.getCode(); + case "flow.completed": + return ApplyFlowStatusEnum.COMPLETED.getCode(); + case "flow.rejected": + return ApplyFlowStatusEnum.REJECTED.getCode(); + case "flow.withdrawn": + return ApplyFlowStatusEnum.WITHDRAWN.getCode(); + default: + return null; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java index 00546bc..09e0a2a 100644 --- a/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java +++ b/src/main/java/com/yunhai/svchall/utils/ReReadableRequestWrapper.java @@ -11,7 +11,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; /** - * 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后, + * 可重读请求包装(历史工具,回调验签已迁 yh-flow-callback starter): * 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。 */ public class ReReadableRequestWrapper extends HttpServletRequestWrapper { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4aa8c59..f73298a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -71,6 +71,15 @@ yh: flowcenter: http://localhost:8085 cms: http://base.yunhaixingtong.com:10005/yschcms + # 流程中心回调接收端 starter(2026-09-20 迁移,X-Flw 验签式):端点保持既有 /callback/flow, + # 存量注册的 callback_url 不变、零 secret 轮换;幂等/乱序门由 starter 承担, + # 业务映射见 SvcFlowCallbackHandler;secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/) + flow-callback: + path: /callback/flow + biz-types: [svc_item] + verify-flw-signature: true + callback-url: ${svchall.public-base-url}/callback/flow + # ===== 办事大厅专属配置 ===== svchall: # 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入) diff --git a/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java b/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java deleted file mode 100644 index 7155da6..0000000 --- a/src/test/java/com/yunhai/svchall/config/FlowCallbackVerifyFilterTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.yunhai.svchall.config; - -import com.yunhai.svchall.service.impl.FlowCallbackService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import javax.servlet.Filter; -import javax.servlet.FilterChain; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * 回调验签过滤器单测(对齐 Notice FlowCallbackVerifyFilterTest): - * 注册探活 ping 直接 200(探活签名是字面量 "ping" 无法验签,flow-center 实测把 5xx 判不可达); - * 缺头/坏时间戳/±5 分钟窗外/无注册/坏签名 → 401;合法签名放行。 - */ -class FlowCallbackVerifyFilterTest { - - private static final String SECRET = "unit-test-secret"; - private static final String BODY = - "{\"eventId\":\"e-9\",\"event\":\"flow.completed\",\"flowGuid\":\"fg-1\",\"schId\":1," - + "\"instanceId\":9001,\"bizType\":\"svc_item\",\"bizId\":\"42\",\"instanceVersion\":4}"; - - private FlowCallbackService callbackService; - private Filter filter; - - @BeforeEach - void setUp() { - callbackService = mock(FlowCallbackService.class); - FlowCallbackFilterConfiguration configuration = - new FlowCallbackFilterConfiguration(callbackService, new com.fasterxml.jackson.databind.ObjectMapper()); - FilterRegistrationBean bean = configuration.flowCallbackVerifyFilter(); - filter = bean.getFilter(); - when(callbackService.secretOf("fg-1")).thenReturn(SECRET); - } - - @Test - void pingIsAnswered200WithoutVerification() throws Exception { - MockHttpServletResponse response = doFilter( - request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", "1", "n", "bad-signature"), new MockFilterChain()); - assertEquals(200, response.getStatus()); - assertEquals("{\"ok\":true}", response.getContentAsString()); - } - - @Test - void pingWithoutHeadersStill200() throws Exception { - MockHttpServletResponse response = doFilter( - request("{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}", null, null, null), new MockFilterChain()); - assertEquals(200, response.getStatus()); - } - - @Test - void missingHeadersIs401() throws Exception { - MockHttpServletResponse response = doFilter(request(BODY, null, null, null), new MockFilterChain()); - assertEquals(401, response.getStatus()); - } - - @Test - void badTimestampIs401() throws Exception { - MockHttpServletResponse response = doFilter( - request(BODY, "not-a-number", "n", "sig"), new MockFilterChain()); - assertEquals(401, response.getStatus()); - } - - @Test - void staleTimestampBeyondFiveMinutesIs401() throws Exception { - String stale = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L); - MockHttpServletResponse response = doFilter( - request(BODY, stale, "n", "sig"), new MockFilterChain()); - assertEquals(401, response.getStatus()); - } - - @Test - void unknownRegistrationIs401() throws Exception { - when(callbackService.secretOf("fg-1")).thenReturn(null); - MockHttpServletResponse response = doFilter(signedRequest(SECRET), new MockFilterChain()); - assertEquals(401, response.getStatus()); - } - - @Test - void badSignatureIs401() throws Exception { - MockHttpServletResponse response = doFilter(signedRequest(null), new MockFilterChain()); - assertEquals(401, response.getStatus()); - } - - @Test - void validSignaturePassesThrough() throws Exception { - MockFilterChain chain = new MockFilterChain(); - MockHttpServletResponse response = doFilter(signedRequest(SECRET), chain); - assertEquals(200, response.getStatus()); - assertNotNull(chain.getRequest(), "合法签名应放行到后续链路"); - } - - @Test - void nonPostIs405() throws Exception { - MockHttpServletRequest request = request(BODY, "1", "n", "sig"); - request.setMethod("GET"); - MockHttpServletResponse response = doFilter(request, new MockFilterChain()); - assertEquals(405, response.getStatus()); - } - - // ===== helpers ===== - - private MockHttpServletRequest request(String body, String ts, String nonce, String signature) { - MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow"); - request.setContent(body.getBytes(StandardCharsets.UTF_8)); - if (ts != null) { - request.addHeader("X-Flw-EventId", "e-9"); - request.addHeader("X-Flw-Timestamp", ts); - request.addHeader("X-Flw-Nonce", nonce); - request.addHeader("X-Flw-Signature", signature); - } - return request; - } - - /** 头齐全、时间戳取当前时刻;secret 为 null 时签名置垃圾值 */ - private MockHttpServletRequest signedRequest(String secret) { - String ts = String.valueOf(System.currentTimeMillis()); - String signature; - if (secret == null) { - signature = "garbage-signature"; - } else { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - signature = Base64.getEncoder().encodeToString( - mac.doFinal((ts + ".nonce-1." + BODY).getBytes(StandardCharsets.UTF_8))); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - return request(BODY, ts, "nonce-1", signature); - } - - private MockHttpServletResponse doFilter(MockHttpServletRequest request, FilterChain chain) throws Exception { - MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(request, response, chain); - return response; - } -} diff --git a/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java b/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java deleted file mode 100644 index 3adfc1f..0000000 --- a/src/test/java/com/yunhai/svchall/controller/callback/FlowCallbackControllerTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.yunhai.svchall.controller.callback; - -import com.yunhai.svchall.service.impl.FlowCallbackService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; - -/** - * 回调接收端点返回语义单测(契约冻结 D2,免对账前提): - * 成功/毒消息 → 200(毒消息由 service 静默消化,不抛); - * TransientCallbackException / 意外 RuntimeException → 5xx——flow-center 持续重试直至送达。 - */ -class FlowCallbackControllerTest { - - private FlowCallbackService callbackService; - private FlowCallbackController controller; - - @BeforeEach - void setUp() { - callbackService = mock(FlowCallbackService.class); - controller = new FlowCallbackController(callbackService); - } - - @Test - void successReturns200() { - ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); - assertEquals(HttpStatus.OK, resp.getStatusCode()); - assertEquals("{\"ok\":true}", resp.getBody()); - } - - @Test - void transientFailureReturns5xx() { - doThrow(new FlowCallbackService.TransientCallbackException("db down", new RuntimeException("db down"))) - .when(callbackService).process("{\"event\":\"flow.completed\"}"); - ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); - assertEquals("{\"ok\":false}", resp.getBody()); - } - - @Test - void unexpectedExceptionAlsoReturns5xx() { - // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) - doThrow(new IllegalStateException("boom")) - .when(callbackService).process("{\"event\":\"flow.completed\"}"); - ResponseEntity resp = controller.flow("{\"event\":\"flow.completed\"}".getBytes(StandardCharsets.UTF_8)); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); - } -} diff --git a/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java index ab54070..81d72da 100644 --- a/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java +++ b/src/test/java/com/yunhai/svchall/service/ApplyViewGuardTest.java @@ -3,7 +3,7 @@ package com.yunhai.svchall.service; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.entity.ServiceApply; -import com.yunhai.svchall.service.impl.FlowCallbackService; +import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -97,7 +97,7 @@ class ApplyViewGuardTest { void assigneePasses() { assignees(30, 31); assertDoesNotThrow(() -> guard.requireVisible(apply, user(30))); - verify(flowClient).getInstanceByBiz(eq(1L), eq(FlowCallbackService.BIZ_TYPE), eq("42")); + verify(flowClient).getInstanceByBiz(eq(1L), eq(SvcFlowCallbackHandler.BIZ_TYPE), eq("42")); } @Test diff --git a/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java deleted file mode 100644 index db15a3a..0000000 --- a/src/test/java/com/yunhai/svchall/service/impl/FlowCallbackServiceTest.java +++ /dev/null @@ -1,287 +0,0 @@ -package com.yunhai.svchall.service.impl; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yunhai.svchall.entity.ServiceApply; -import com.yunhai.svchall.entity.ServiceFlowEvent; -import com.yunhai.svchall.enums.ApplyFlowStatusEnum; -import com.yunhai.svchall.repository.ServiceApplyRepository; -import com.yunhai.svchall.repository.ServiceFlowEventRepository; -import com.yunhai.svchall.repository.ServiceFlowRegRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * 回调事件处理单测(对齐 Notice FlowCallbackServiceTest,5xx 语义 D2 核心断言): - * 六事件映射;eventId 重复/并发重复跳过;乱序闸门严格小于(同版本事件不丢); - * 按 payload.bizId 定位办件(findByIdForUpdate 行锁);毒 JSON/缺字段/非 svc_item/ping 静默; - * DB 异常 → TransientCallbackException(→ controller 回 5xx 触发 flow-center 持续重试)。 - */ -class FlowCallbackServiceTest { - - private ServiceFlowEventRepository eventRepository; - private ServiceFlowRegRepository regRepository; - private ServiceApplyRepository applyRepository; - private FlowCallbackService service; - private ServiceApply apply; - - @BeforeEach - void setUp() { - eventRepository = mock(ServiceFlowEventRepository.class); - regRepository = mock(ServiceFlowRegRepository.class); - applyRepository = mock(ServiceApplyRepository.class); - service = new FlowCallbackService(eventRepository, regRepository, applyRepository, new ObjectMapper()); - - apply = new ServiceApply(); - ReflectionTestUtils.setField(apply, "id", 42); - apply.setSchId(1L); - apply.setItemId(7); - apply.setItemCode("ITEM-1"); - apply.setItemTitle("测试事项"); - apply.setSubmitterId(9); - apply.setSubmitterName("张三"); - apply.setFlowGuid("fg-1"); - apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); - apply.setLastInstanceVersion(1); - when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(apply)); - } - - // ===== 六事件映射 ===== - - @Test - void submitted_taskCreated_taskHandled_mapToApproving() { - for (String event : new String[]{"flow.submitted", "flow.task.created", "flow.task.handled"}) { - ServiceApply a = freshApply(); - service.process(body(event, 2)); - assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), a.getFlowStatus(), event); - assertNull(a.getFinishTime(), event + " 非终态不应置 finishTime"); - } - } - - @Test - void completed_mapsToCompletedAndStampsFinishTime() { - service.process(body("flow.completed", 4)); - assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); - assertNotNull(apply.getFinishTime()); - } - - @Test - void rejected_mapsToRejectedAndStampsFinishTime() { - service.process(body("flow.rejected", 4)); - assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); - assertNotNull(apply.getFinishTime()); - } - - @Test - void withdrawn_mapsToWithdrawnAndStampsFinishTime() { - service.process(body("flow.withdrawn", 4)); - assertEquals(ApplyFlowStatusEnum.WITHDRAWN.getCode(), apply.getFlowStatus()); - assertNotNull(apply.getFinishTime()); - } - - // ===== 乱序闸门:严格小于(<),同版本不丢 ===== - - @Test - void sameVersionEventIsProcessed_notDiscarded() { - // flow-center 实测同版本多事件(submitted+task.created 同 v1)——旧 <= 闸门会误杀 - apply.setLastInstanceVersion(1); - service.process(body("flow.task.created", 1)); - assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); - verify(applyRepository).save(apply); - } - - @Test - void olderVersionEventIsDiscarded() { - apply.setLastInstanceVersion(3); - service.process(body("flow.completed", 2)); - assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); - verify(applyRepository, never()).save(any()); - } - - @Test - void missingVersionIsProcessedWithoutInventingVersion() { - // version 缺失放行推进,但不伪造 last+1 - service.process(bodyWithout("instanceVersion")); - verify(applyRepository).save(apply); - assertEquals(Integer.valueOf(1), apply.getLastInstanceVersion()); - } - - // ===== eventId 去重(两道:先查后落,uk 兜底并发)===== - - @Test - void duplicateEventIdIsSkipped() { - when(eventRepository.existsByEventId("e-1")).thenReturn(true); - service.process(body("flow.completed", 4)); - verify(applyRepository, never()).save(any()); - assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); - } - - @Test - void concurrentDuplicateViaUniqueConstraintIsSkipped() { - doThrow(new DataIntegrityViolationException("uk")).when(eventRepository).saveAndFlush(any(ServiceFlowEvent.class)); - service.process(body("flow.completed", 4)); - verify(applyRepository, never()).save(any()); - assertEquals(ApplyFlowStatusEnum.APPROVING.getCode(), apply.getFlowStatus()); - } - - // ===== 定位:按 payload.bizId + 行锁;bizType 守卫 ===== - - @Test - void locateByBizIdWithPessimisticLock() { - service.process(body("flow.completed", 4)); - verify(applyRepository).findByIdForUpdate(42); - verify(applyRepository, never()).findBySchIdAndFlowInstanceId(any(), any()); - } - - @Test - void nonSvcItemBizTypeIsSilentlyDropped() { - service.process(body("flow.completed", 4).replace("\"svc_item\"", "\"notice_item\"")); - verify(applyRepository, never()).findByIdForUpdate(any()); - verify(applyRepository, never()).save(any()); - } - - @Test - void unknownBizIdIsSilentlyDropped() { - when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.empty()); - service.process(body("flow.completed", 4)); - verify(applyRepository, never()).save(any()); - } - - @Test - void nonNumericBizIdIsSilentlyDropped() { - service.process(body("flow.completed", 4).replace("\"bizId\":\"42\"", "\"bizId\":\"abc\"")); - verify(applyRepository, never()).findByIdForUpdate(any()); - } - - // ===== 毒消息/未知事件/ping:静默(协议层不可重试,防无限重放)===== - - @Test - void poisonJsonIsSilentlyIgnored() { - service.process("not-json{{"); - verify(applyRepository, never()).save(any()); - } - - @Test - void missingRequiredFieldIsSilentlyIgnored() { - service.process(body("flow.completed", 4).replace("\"bizId\":\"42\",", "")); - verify(applyRepository, never()).save(any()); - } - - @Test - void unknownEventTypeIsSilentlyIgnored() { - service.process(body("flow.handed", 2)); - verify(applyRepository, never()).save(any()); - } - - @Test - void pingEventIsSilentlyIgnored() { - // 过滤器已直通 200,这里兜底防穿透 - service.process(body("ping", 0)); - verify(applyRepository, never()).findByIdForUpdate(any()); - } - - // ===== 5xx 语义:DB 瞬时失败 → TransientCallbackException ===== - - @Test - void transientDbFailureThrowsTransientCallbackException() { - doThrow(new RuntimeException("connection reset")).when(applyRepository).save(any(ServiceApply.class)); - assertThrows(FlowCallbackService.TransientCallbackException.class, - () -> service.process(body("flow.completed", 4))); - } - - // ===== 终态明细快照 ===== - - @Test - void terminalEventWritesResultSnapshot() { - service.process(body("flow.completed", 4)); - assertNotNull(apply.getFlowResultJson()); - assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); - } - - // ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置 ===== - - @Test - void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() { - // completed(v4) 先落库(20 + result),迟到的 task.handled(v4) 不得打回 10、不得覆盖结果 - service.process(body("e-a", "flow.completed", 4)); - assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); - String frozen = apply.getFlowResultJson(); - Mockito.clearInvocations(applyRepository); - - service.process(body("e-b", "flow.task.handled", 4)); // 不抛 → 200 - assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus(), "终态行拒绝非终态事件"); - assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被 handled 明细覆盖"); - verify(applyRepository, never()).save(any()); - } - - @Test - void terminalRow_rejectsNonTerminalEvent_higherVersion() { - service.process(body("e-c", "flow.rejected", 4)); - assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); - service.process(body("e-d", "flow.task.handled", 5)); - assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus(), - "终态后任何非终态事件都不回退(重提走 resubmit 先置 10,不冲突)"); - } - - @Test - void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() { - service.process(body("e-e", "flow.rejected", 4)); - assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); - String frozen = apply.getFlowResultJson(); - service.process(body("e-f", "flow.withdrawn", 4)); - assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus()); - assertEquals(frozen, apply.getFlowResultJson(), "已终态不被另一终态改写"); - } - - // ===== helpers ===== - - private ServiceApply freshApply() { - ServiceApply a = new ServiceApply(); - ReflectionTestUtils.setField(a, "id", 42); - a.setSchId(1L); - a.setItemId(7); - a.setItemCode("ITEM-1"); - a.setItemTitle("测试事项"); - a.setSubmitterId(9); - a.setSubmitterName("张三"); - a.setFlowGuid("fg-1"); - a.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode()); - a.setLastInstanceVersion(1); - when(applyRepository.findByIdForUpdate(42)).thenReturn(Optional.of(a)); - return a; - } - - private String body(String event, Integer version) { - return body("e-1", event, version); - } - - private String body(String eventId, String event, Integer version) { - return "{\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\",\"schId\":1,\"instanceId\":9001," - + "\"bizType\":\"svc_item\",\"bizId\":\"42\",\"flowGuid\":\"fg-1\"," - + "\"instanceVersion\":" + version + "," - + "\"data\":{\"result\":\"同意\",\"opinion\":\"情况属实\"}}"; - } - - private String bodyWithout(String field) { - String b = body("flow.task.created", 2); - String needle = "\"" + field + "\":2,"; - assertEquals(true, b.contains(needle), "测试体应包含待删字段 " + needle); - return b.replace(needle, ""); - } -} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java index f5afb28..137ace2 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java @@ -7,6 +7,7 @@ import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest; import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo; import com.yunhai.svchall.repository.ServiceFormRepository; import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.FlowClient; import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteGuard; import org.junit.jupiter.api.BeforeEach; @@ -43,7 +44,10 @@ class ServiceFormServiceTest { itemRepository = mock(ServiceItemRepository.class); SiteGuard siteGuard = mock(SiteGuard.class); when(siteGuard.requireSiteManager(null, "S1")).thenReturn(site); - service = new ServiceFormService(formRepository, itemRepository, siteGuard); + FlowClient flowClient = mock(FlowClient.class); + when(flowClient.enabled()).thenReturn(false); + service = new ServiceFormService(formRepository, itemRepository, siteGuard, + flowClient, mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class)); } private ServiceForm form(String code, int version, int status) { diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java new file mode 100644 index 0000000..ac1005b --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemEffectiveFlowTest.java @@ -0,0 +1,80 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.flowcallback.FlowCallbackRegistrar; +import com.yunhai.svchall.entity.ServiceForm; +import com.yunhai.svchall.repository.ServiceFormRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.DeptSource; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 表单级绑定生效流程解析单测(2026-09-20):事项自身绑定优先;为空回退绑定表单 + * 最新版本的 flow_guid(草稿也算——版本行携带绑定,发布后自然生效);两侧都空=null。 + */ +class ServiceItemEffectiveFlowTest { + + private ServiceFormRepository formRepository; + private ServiceItemService service; + + @BeforeEach + void setUp() { + formRepository = mock(ServiceFormRepository.class); + service = new ServiceItemService( + mock(ServiceItemRepository.class), formRepository, + mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class), + mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class), + mock(FlowClient.class), mock(FlowCallbackRegistrar.class), mock(TransactionTemplate.class)); + when(formRepository.findBySchIdAndCodeOrderByVersionDesc(anyLong(), anyString())) + .thenReturn(Collections.emptyList()); + } + + private ServiceForm form(String flowGuid, Integer version) { + ServiceForm f = new ServiceForm(); + f.setSchId(1L); + f.setCode("F1"); + f.setVersion(version); + f.setFlowGuid(flowGuid); + return f; + } + + @Test + void itemBindingWins() { + assertEquals("item-flow", service.effectiveFlowGuid(1L, "item-flow", "F1")); + } + + @Test + void nullItemBindingFallsBackToLatestFormVersion() { + when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1")) + .thenReturn(java.util.Arrays.asList(form("v3-flow", 3), form("v2-flow", 2))); + assertEquals("v3-flow", service.effectiveFlowGuid(1L, null, "F1"), + "回退取最新版本行的绑定"); + } + + @Test + void noFormOrUnboundForm_yieldsNull() { + assertNull(service.effectiveFlowGuid(1L, null, null), "没绑表单且事项未绑 → 不可在线办理"); + when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "F1")) + .thenReturn(Collections.singletonList(form(null, 1))); + assertNull(service.effectiveFlowGuid(1L, null, "F1"), "表单也没绑 → null"); + } + + @Test + void emptyStringsTreatedAsUnbound() { + assertNull(service.effectiveFlowGuid(1L, "", "")); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java index 72aacf2..adc86ef 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceItemFlowRegTest.java @@ -1,7 +1,6 @@ package com.yunhai.svchall.service.impl; -import com.yunhai.svchall.entity.ServiceFlowReg; -import com.yunhai.svchall.repository.ServiceFlowRegRepository; +import com.yunhai.platform.flowcallback.FlowCallbackRegistrar; import com.yunhai.svchall.repository.ServiceFormRepository; import com.yunhai.svchall.repository.ServiceItemRepository; import com.yunhai.svchall.service.DeptSource; @@ -10,117 +9,59 @@ import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteGuard; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionTemplate; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 回调注册防轮换单测(契约冻结:flow-center 重复注册会轮换 secret,导致已存密钥验签失配): - * 仅 reg 缺失/失效(secret 空或 status≠0)时才(重)注册;有效注册绝不重复调用; - * 注册失败 best-effort 不阻断事项保存。 + * 回调注册联动单测(2026-09-20 迁 starter Registrar 后改版): + * "仅有效注册才不重注册"的防轮换语义已收进 starter FlowCallbackRegistrar(其侧测试覆盖), + * 本处只验联动开关——绑定 flowGuid 即委托 Registrar(Long→Integer schId 适配)、 + * 未绑/流程中心停用不委托、注册失败 best-effort 不阻断保存。 */ class ServiceItemFlowRegTest { - private ServiceFlowRegRepository flowRegRepository; private FlowClient flowClient; + private FlowCallbackRegistrar registrar; private ServiceItemService service; private SiteDirectory.SiteInfo site = new SiteDirectory.SiteInfo("S1", "站点一", 1L); @BeforeEach void setUp() { - flowRegRepository = mock(ServiceFlowRegRepository.class); flowClient = mock(FlowClient.class); + registrar = mock(FlowCallbackRegistrar.class); service = new ServiceItemService( - mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), flowRegRepository, + mock(ServiceItemRepository.class), mock(ServiceFormRepository.class), mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class), mock(AttachmentService.class), mock(SiteGuard.class), mock(DeptSource.class), - flowClient, mock(TransactionTemplate.class)); - ReflectionTestUtils.setField(service, "callbackPublicBase", "http://localhost:8086"); + flowClient, registrar, mock(TransactionTemplate.class)); when(flowClient.enabled()).thenReturn(true); } - private ServiceFlowReg reg(Integer status, String secret) { - ServiceFlowReg reg = new ServiceFlowReg(); - reg.setSchId(1L); - reg.setFlowGuid("fg-1"); - reg.setStatus(status); - reg.setSecret(secret); - return reg; - } - @Test - void validRegistrationIsNeverRepeated() { - when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")) - .thenReturn(Optional.of(reg(0, "stored-secret"))); + void boundFlow_delegatesToRegistrar() { service.ensureFlowReg(site, "fg-1"); - verify(flowClient, never()).registerCallback(any(), anyString(), anyString(), any()); + verify(registrar).ensureRegistered(1, "fg-1"); } @Test - void missingRegistrationIsCreated() { - when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty()); - when(flowClient.registerCallback(eq(1L), eq("fg-1"), - eq("http://localhost:8086/callback/flow"), isNull())).thenReturn("issued-secret"); - service.ensureFlowReg(site, "fg-1"); - assertEquals("issued-secret", firstSavedReg().getSecret()); - assertEquals(Integer.valueOf(0), firstSavedReg().getStatus()); - assertEquals("http://localhost:8086/callback/flow", firstSavedReg().getCallbackUrl()); - } - - @Test - void registrationWithEmptySecretIsReissued() { - // 失效注册(secret 空)→ 重新注册并原地更新同一行(拿回新 secret) - ServiceFlowReg existing = reg(0, null); - when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing)); - when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull())) - .thenReturn("rotated-secret"); - service.ensureFlowReg(site, "fg-1"); - assertSame(existing, firstSavedReg(), "失效注册应原地更新同一行"); - assertEquals("rotated-secret", firstSavedReg().getSecret()); - } - - @Test - void registrationWithDisabledStatusIsReissued() { - ServiceFlowReg existing = reg(1, "old-secret"); - when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.of(existing)); - when(flowClient.registerCallback(eq(1L), eq("fg-1"), anyString(), isNull())) - .thenReturn("rotated-secret"); - service.ensureFlowReg(site, "fg-1"); - verify(flowRegRepository).save(existing); - } - - @Test - void registrationFailureIsBestEffort() { - when(flowRegRepository.findBySchIdAndFlowGuid(1L, "fg-1")).thenReturn(Optional.empty()); - when(flowClient.registerCallback(any(), anyString(), anyString(), any())) - .thenThrow(new RuntimeException("flow-center down")); - service.ensureFlowReg(site, "fg-1"); // 不抛——事项保存不被注册失败阻断 - verify(flowRegRepository, never()).save(any()); - } - - @Test - void flowClientDisabledSkipsEverything() { + void nullFlowOrDisabledClient_noDelegation() { + service.ensureFlowReg(site, null); when(flowClient.enabled()).thenReturn(false); service.ensureFlowReg(site, "fg-1"); - verify(flowRegRepository, never()).findBySchIdAndFlowGuid(any(), anyString()); + verify(registrar, never()).ensureRegistered(anyInt(), anyString()); } - private ServiceFlowReg firstSavedReg() { - org.mockito.ArgumentCaptor captor = - org.mockito.ArgumentCaptor.forClass(ServiceFlowReg.class); - verify(flowRegRepository).save(captor.capture()); - return captor.getValue(); + @Test + void registrarFailure_swallowedBestEffort() { + doThrow(new RuntimeException("probe unreachable")).when(registrar) + .ensureRegistered(anyInt(), anyString()); + service.ensureFlowReg(site, "fg-1"); // 不抛:下次保存重试 } } diff --git a/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java new file mode 100644 index 0000000..182f830 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/SvcFlowCallbackHandlerTest.java @@ -0,0 +1,165 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 svc-hall 侧状态机; + * 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的 + * FlowCallbackReceiverTest 覆盖):事件映射;终态置 finishTime + 结果快照; + * 单据级乱序丢弃;终态不回退守卫;异常外抛(starter 回 5xx)。 + */ +class SvcFlowCallbackHandlerTest { + + private ServiceApplyRepository applyRepository; + private SvcFlowCallbackHandler handler; + private ServiceApply apply; + + @BeforeEach + void setUp() { + applyRepository = mock(ServiceApplyRepository.class); + handler = new SvcFlowCallbackHandler(applyRepository, new ObjectMapper()); + + apply = new ServiceApply(); + apply.setId(7); + apply.setSchId(1L); + apply.setFlowStatus(10); + apply.setLastInstanceVersion(1); + when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.of(apply)); + when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + private FlowCallbackEvent event(String eventId, String event, Integer version) { + FlowCallbackEvent e = new FlowCallbackEvent(); + e.setEventId(eventId); + e.setEvent(event); + e.setSchId(1); + e.setFlowGuid("fg-1"); + e.setInstanceId(9527L); + e.setInstanceVersion(version); + e.setBizType(SvcFlowCallbackHandler.BIZ_TYPE); + e.setBizId("7"); + e.setTaskId(33L); + e.setOperatorUserId(201); + e.setOperatorName("张三"); + Map data = new HashMap<>(); + e.setData(data); + return e; + } + + @Test + void inFlightEvents_mapToApproving() { + handler.onEvent(event("e1", "flow.submitted", 1)); + assertEquals(10, apply.getFlowStatus().intValue()); + handler.onEvent(event("e2", "flow.task.created", 1)); + assertEquals(10, apply.getFlowStatus().intValue()); + assertNull(apply.getFinishTime(), "在途事件不置办结时间"); + } + + @Test + void completed_setsFinishTimeAndResultSnapshot() { + FlowCallbackEvent e = event("e3", "flow.completed", 3); + e.getData().put("result", "APPROVED"); + e.getData().put("opinion", "同意"); + handler.onEvent(e); + assertEquals(20, apply.getFlowStatus().intValue()); + assertNotNull(apply.getFinishTime(), "终态置 finish_time"); + assertNotNull(apply.getFlowResultJson()); + assertTrue(apply.getFlowResultJson().contains("APPROVED")); + assertTrue(apply.getFlowResultJson().contains("同意")); + assertEquals(3, apply.getLastInstanceVersion().intValue()); + } + + @Test + void rejected_mapsTo30_withRejectNode() { + FlowCallbackEvent e = event("e4", "flow.rejected", 3); + e.getData().put("result", "REJECTED"); + e.getData().put("rejectNode", "终审"); + handler.onEvent(e); + assertEquals(30, apply.getFlowStatus().intValue()); + assertTrue(apply.getFlowResultJson().contains("终审")); + } + + @Test + void withdrawn_mapsTo40() { + handler.onEvent(event("e5", "flow.withdrawn", 2)); + assertEquals(40, apply.getFlowStatus().intValue()); + } + + @Test + void illegalBizId_silentReturn() { + FlowCallbackEvent e = event("e6", "flow.completed", 3); + e.setBizId("not-a-number"); + handler.onEvent(e); // 不抛(starter 回 200) + } + + @Test + void unknownApply_silentReturn() { + when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.empty()); + handler.onEvent(event("e7", "flow.completed", 3)); // 不抛 + } + + @Test + void staleVersion_dropped() { + apply.setLastInstanceVersion(5); + handler.onEvent(event("e8", "flow.completed", 3)); + assertEquals(10, apply.getFlowStatus().intValue(), "乱序事件丢弃"); + } + + @Test + void sameVersionDifferentEvent_processed() { + apply.setLastInstanceVersion(2); + handler.onEvent(event("e9", "flow.task.handled", 2)); + assertEquals(10, apply.getFlowStatus().intValue(), "同版本事件合法(严格小于才丢弃)"); + } + + @Test + void terminalRow_rejectsNonTerminalEvent() { + handler.onEvent(event("t1", "flow.completed", 3)); + assertEquals(20, apply.getFlowStatus().intValue()); + String frozen = apply.getFlowResultJson(); + handler.onEvent(event("t2", "flow.task.handled", 3)); // 迟到的同版本在途事件 + assertEquals(20, apply.getFlowStatus().intValue(), "终态不回退"); + assertEquals(frozen, apply.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖"); + } + + @Test + void terminalGuard_doesNotBlockNewRound_afterResubmission() { + handler.onEvent(event("t3", "flow.rejected", 3)); + apply.setFlowStatus(10); // resubmit 成功后窄列原子置 10 + handler.onEvent(event("t4", "flow.submitted", 4)); + assertEquals(10, apply.getFlowStatus().intValue(), "新一轮事件正常推进"); + assertEquals(4, apply.getLastInstanceVersion().intValue()); + } + + @Test + void unknownEvent_silentReturn() { + handler.onEvent(event("u1", "flow.returned", 3)); + assertEquals(10, apply.getFlowStatus().intValue(), "未消费的已知事件码静默忽略"); + } + + @Test + void dbFailure_propagates() { + when(applyRepository.save(any(ServiceApply.class))) + .thenThrow(new org.springframework.dao.DataAccessResourceFailureException("db down")); + assertThrows(org.springframework.dao.DataAccessResourceFailureException.class, + () -> handler.onEvent(event("e10", "flow.completed", 3))); + } +}