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

- 回调迁 starter(2026-09-20):删本地实现 FlowCallbackService/Controller/
  FilterConfiguration 及旧测试,X-Flw 验签/幂等/乱序门由 starter 承担,
  业务映射入 SvcFlowCallbackHandler(biz-type svc_item);端点仍 /callback/flow,
  存量注册 callback_url 不变、secret 零轮换,镜像表 flow_callback_reg
  由 sql/20260920_flow_callback_starter_migration.sql 迁移
- 表单级流程绑定:ServiceForm 增 flowGuid,事项未绑流程时回退取绑定表单
  最新版本(effectiveFlowGuid),绑定校验/在线办理口径同步
- FlowReconcileJob/FlowCenterClient/OpenApiAuthEnforceConfiguration 等
  SDK 适配微调;补 .gitignore(target/、日志)
- 全量 mvn test 本地绿(含新增 SvcFlowCallbackHandlerTest/
  ServiceItemEffectiveFlowTest)
This commit is contained in:
chenxing
2026-09-21 09:37:33 +08:00
parent 27e19923fb
commit b08d076f0b
26 changed files with 568 additions and 1052 deletions
@@ -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