feat: 办事大厅后端源码首次入库(事项/字典/表单/申报 + 流程回调)
build / build-push (push) Failing after 39s

This commit is contained in:
chenxing
2026-09-15 11:00:00 +08:00
parent 1eeb7a4276
commit d47951abd7
70 changed files with 7569 additions and 0 deletions
@@ -0,0 +1,29 @@
package com.yunhai.svchall.config;
import com.yunhai.svchall.utils.FileUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
/**
* 文件存储配置(Notice FileStorageConfig 同款三件套之一):
* FILE_PATH 磁盘落盘根 + svchall.file.public-base-url 对外可达基址 → FileUtil.init()。
*/
@Slf4j
@Configuration
public class FileStorageConfig {
@Value("${FILE_PATH}")
private String filePath;
@Value("${svchall.file.public-base-url}")
private String publicBaseUrl;
@EventListener(ApplicationReadyEvent.class)
public void init() {
FileUtil.init(filePath, publicBaseUrl);
log.info("[svc-hall] 文件存储就绪:FILE_PATH={},publicBaseUrl={}", filePath, publicBaseUrl);
}
}
@@ -0,0 +1,151 @@
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));
}
}
}
@@ -0,0 +1,26 @@
package com.yunhai.svchall.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Paths;
/**
* 本地上传目录静态映射(Notice MyWebAppConfigurer 同款;仅本地开发生效——
* 生产走 Projman 生成的 Caddy 静态段 /svc-hall/upload/* 直出,不进应用)。
* 有 context-path 时该映射自动变成 /svc-hall/upload/**。
*/
@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {
@org.springframework.beans.factory.annotation.Value("${FILE_PATH}")
private String basePath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String root = Paths.get(basePath.replace("\\", "/")).toUri().toString();
registry.addResourceHandler("/upload/image/**").addResourceLocations(root + "image/");
registry.addResourceHandler("/upload/file/**").addResourceLocations(root + "file/");
}
}
@@ -0,0 +1,51 @@
package com.yunhai.svchall.config;
import com.yunhai.platform.appauth.AppAuthFilter;
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 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;
/**
* 开放接口签名强制(照 Cms OpenApiAuthEnforceConfiguration 范式):yh-app-auth 的
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 FlowCallbackVerifyFilter 用
* X-Flw-* 四头验签,两种签名体系互不干扰。
*/
@Configuration
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
chain.doFilter(request, response);
}
}
}