2 Commits
Author SHA1 Message Date
chenxing eb0a8ded2f refactor(oapi): 收敛 Notice 范式——回调收口+提供方强制验签迁 starter(审计 P1 / L2-7 L2-8)
build / build-push (push) Successful in 1m37s
回调(对齐《子应用流程与消息对接手册-20260929》Notice 线上形态):
- yh.flow-callback.path 迁 /oapi/flow/callback;删 callback-url(改 starter 三段推导:
  base-host+前缀三层回落+path,生产=http://open-gateway-service:10013/svc-hall/oapi/flow/callback)
- verify-flw-signature=false(平台应用签名承担,X-Flw 不启用);reconcile-on-register=true
  (路径漂移自动重注册,160 存量 /callback/flow 行靠它收敛)
- 删 svchall.public-base-url(唯一消费方即 callback-url 拼接)与 yh.open.base-path
  (对齐 Notice 形态;该键代码默认值即 /userbase,删键无行为变化)
- deriveCallbackBasePath 剥尾段口径同步 /oapi/flow/callback(契约测试断言更新)

提供方(flow-center 同款组合链):
- 删自写 OpenApiAuthEnforceConfiguration(starter 版 RequireAppSignFilter 承接,order+11 等价)
- yh.app-auth.server.enforce-patterns=[/oapi/*] + trust-registry=true;trusted-apps 保留
  100005(CMS) 并增 100019(flow-center 回调推送兜底);pom 显式引 data-redis(optional 不传递)
- management.health.redis.enabled=false(Redis 抖动不拉 DOWN 整机健康)

门禁:mvn test 127/127 绿
2026-10-05 22:26:22 +08:00
chenxing f368299eb5 feat(hall): 站点主题中转 + 用户面站点信息端点(T2)——SiteOptionVo 扩 theme/themeParams(@JsonIgnoreProperties 兜未知键,兼容旧 CMS 三键形态,测试已证);SiteDirectory 新增 findOption(resolveSite 的全量形态,resolveSite 委托之,SiteInfo 三字段不动);HallController 新增 GET /api/hall/site(requireMember 同口径 + 防御性 SiteInfo 补壳) 2026-10-03 13:46:31 +08:00
13 changed files with 253 additions and 85 deletions
+8
View File
@@ -57,6 +57,14 @@
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
</dependency>
<!-- 入站验签走中央密钥链(注册表模式 yh.app-auth.trust-registry)必须显式引 data-redis:
yh-app-auth 的该依赖是 optional、不传递(Notice/flow-center 同款)。副作用是
RedisHealthIndicator 会被装配,故 yml 里关掉 management.health.redis.enabled,
防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- CAS 客户端(UserHome/Cms 同款显式声明) -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
@@ -1,51 +0,0 @@
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/**(不在本过滤器范围),由 yh-flow-callback starter 的 X-Flw 验签过滤器用
* 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);
}
}
}
@@ -2,6 +2,7 @@ package com.yunhai.svchall.controller.api;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.svchall.model.oapi.OapiModels;
import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import com.yunhai.svchall.service.impl.ServiceItemService;
@@ -54,6 +55,22 @@ public class HallController {
return itemService.oapiFilters(site.getSchId(), site.getSiteCode());
}
/** 站点信息(含 CMS 主题 raw 透传;用户面弱联动数据源,不保证字段非空) */
@GetMapping("/site")
public SvcModels.SiteOptionVo site(BoaUser authUserWithDept,
@RequestParam("siteCode") String siteCode) {
SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode);
SvcModels.SiteOptionVo option = siteDirectory.findOption(siteCode);
// requireMember 已校验存在性与成员身份,这里理论上非空;防御性兜底用 SiteInfo 补壳
if (option == null) {
option = new SvcModels.SiteOptionVo();
option.setSiteCode(site.getSiteCode());
option.setName(site.getName());
option.setSchId(site.getSchId());
}
return option;
}
/** 本校成员 + 站点存在(与办件发起同一口径:只服务本校站点,不做跨校聚合) */
private SiteDirectory.SiteInfo requireMember(BoaUser user, String siteCode) {
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
@@ -31,7 +31,7 @@ public class ServiceFlowReg extends BaseEntity {
@Column(name = "flow_guid", nullable = false, length = 32)
private String flowGuid;
/** 回调接收端点(= 本应用对外可达地址 + /callback/flow) */
/** 回调接收端点(2026-10-05 起 = 平台推导地址 + /oapi/flow/callback;权威镜像在 starter 表 flow_callback_reg) */
@Column(name = "callback_url", nullable = false, length = 500)
private String callbackUrl;
@@ -9,6 +9,7 @@ import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 办事大厅业务模型收口(管理端/用户面 DTO 与 VO;嵌套静态类收口减少文件数)。
@@ -39,12 +40,17 @@ public final class SvcModels {
private Integer sortNo;
}
/** 站点选项(CMS 站点目录代理,管理端站点选择器) */
/** 站点选项(CMS 站点目录代理,管理端站点选择器;T2 起含主题 raw 透传,供用户面弱联动) */
@Data
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public static class SiteOptionVo {
private String siteCode;
private String name;
private Long schId;
/** CMS 皮肤包名(raw 透传;空=default 由消费方兜底) */
private String theme;
/** CMS config_json.theme_params 原样键值(raw,消费方按皮肤声明过滤;未配=空对象) */
private Map<String, String> themeParams = new java.util.LinkedHashMap<>();
}
// ===== 字典 =====
@@ -15,6 +15,12 @@ public interface SiteDirectory {
/** 站点信息(不可达/不存在返回 null——消费方 fail-closed) */
SiteInfo resolveSite(String siteCode);
/**
* resolveSite 的全量形态(T2 主题联动):不出站、不裁字段,供用户面站点信息端点
* 取 CMS 主题 raw 透传值;不可达/不存在返回 null。
*/
SvcModels.SiteOptionVo findOption(String siteCode);
/** 本校可选站点列表(管理端站点选择器;不可达抛业务异常) */
List<SvcModels.SiteOptionVo> listSites(Long schId);
@@ -72,16 +72,17 @@ public class CmsSiteDirectoryImpl implements SiteDirectory {
}
@Override
public SiteInfo resolveSite(String siteCode) {
public SvcModels.SiteOptionVo findOption(String siteCode) {
if (siteCode == null || siteCode.trim().isEmpty()) {
return null;
}
// 站点列表按 schId 下发,siteCode → schId 需要反向索引:本地缓存"siteCode → 站点"二级缓存
// 站点列表按 schId 下发,siteCode → 站点需要反向索引:本地缓存"siteCode → 站点"二级缓存
List<SvcModels.SiteOptionVo> sites = siteCache.getIfPresent(ALL_SITES_KEY);
if (sites == null) {
try {
// 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId,
// 由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解,权限判定仍走 requireSchoolMember。
// 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId
// /theme/themeParams,由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解与
// 用户面站点信息端点,权限判定仍走 requireSchoolMember。
sites = fetchSites(null);
} catch (Exception e) {
log.warn("[svc-hall] CMS 站点目录不可达:{}{}", e.getMessage(), rootCauseBrief(e));
@@ -97,12 +98,20 @@ public class CmsSiteDirectoryImpl implements SiteDirectory {
String code = siteCode.trim();
for (SvcModels.SiteOptionVo site : sites) {
if (code.equals(site.getSiteCode())) {
return new SiteInfo(site.getSiteCode(), site.getName(), site.getSchId());
return site;
}
}
return null;
}
@Override
public SiteInfo resolveSite(String siteCode) {
SvcModels.SiteOptionVo option = findOption(siteCode);
// SiteInfo 三字段结构不动(T2 决策):主题字段只经 findOption 供用户面端点取用
return option == null ? null
: new SiteInfo(option.getSiteCode(), option.getName(), option.getSchId());
}
@Override
public List<SvcModels.SiteOptionVo> listSites(Long schId) {
if (schId == null) {
@@ -161,9 +161,10 @@ public class FlowCenterClient implements FlowClient {
body.setFlowGuid(flowGuid);
body.setCallbackUrl(callbackUrl);
body.setEventTypes(eventTypes);
// 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /callback/flow):
// 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /oapi/flow/callback):
// 推送方按它剥离后再签应用签名,与本服务验签过滤器剥
// contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变(不下发空串)。
// 生产注册路径由 starter FlowCallbackRegistrar 承担(basePath 与 URL 前缀段同源推导)。
String basePath = deriveCallbackBasePath(callbackUrl);
if (basePath != null && !basePath.isEmpty()) {
body.setBasePath(basePath);
@@ -293,9 +294,9 @@ public class FlowCenterClient implements FlowClient {
}
/**
* 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/callback/flow"
* (如 /svc-hall/callback/flow → /svc-hall;裸地址 /callback/flow → 空串)。
* 包可见:契约测试冻结推导形态。
* 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/oapi/flow/callback"
* (如 /svc-hall/oapi/flow/callback → /svc-hall;裸地址 → 空串)。
* 包可见:契约测试冻结推导形态(2026-10-05 随回调端点收敛 Notice 形态同步)。
*/
static String deriveCallbackBasePath(String callbackUrl) {
if (callbackUrl == null) {
@@ -306,7 +307,7 @@ public class FlowCenterClient implements FlowClient {
if (path == null || path.isEmpty()) {
return "";
}
int idx = path.lastIndexOf("/callback/flow");
int idx = path.lastIndexOf("/oapi/flow/callback");
if (idx > 0) {
return path.substring(0, idx);
}
+40 -14
View File
@@ -45,7 +45,7 @@ yh:
allow-headers: all
allow-origins: all
# 用户面(发起/我的办件)与管理面全在 /api/* 下;本应用无匿名面。
# 流程中心回调走 /callback/*(不在 pattern 内,由 FlowCallbackVerifyFilter 验签保护)
# 流程中心回调与业务供数都在 /oapi/*(不在 pattern 内,由平台应用签名强制验签保护)
url-pattern: /api/*
app-auth:
enabled: true
@@ -55,35 +55,56 @@ yh:
# 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致);
# 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号
app-secret: "svc-hall-dev-secret-100018"
# 入站验签白名单:CMS(供数消费方,appKey=100005);流程中心 flow-center 分配 appKey 后在此追加
# 提供方强制验签(两级模型 §4.1,flow-center 同款):本地 OpenApiAuthEnforceConfiguration 副本已删,
# 换此一行——/oapi/**(含 /oapi/svc/** 供数与 /oapi/flow/callback 回调接收)无签名三件套即 401
# (starter 版 RequireAppSignFilter,order +11 行为等价)
server:
enforce-patterns: [/oapi/*]
# 中央密钥链(两级模型 §4.2):trusted-apps 先(确定性兜底),未命中走 SignKeyResolver
# (Redis sign:app:* → Caffeine → UserBase 兜底端点)——任何在 UserBase 注册过的 appKey 自动可验。
# 需 data-redis 依赖(yh-app-auth 侧 optional 不传递,pom 已显式引入)
trust-registry: true
# 入站验签白名单(组合链第一层,Redis 停摆时确定性工作):CMS(/oapi/svc/** 消费方,appKey=100005)、
# flow-center(回调推送方,appKey=100019 联调态密钥;生产密钥由平台注入)
trusted-apps:
100005: 2c7885de594742cc840b8762910b2d2e
"100019": "flow-center-dev-secret-100019"
open:
# 身份密钥唯一来源 = yh.app-auth.app-key/app-secret(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com
base-path: /userbase
# 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 /
# YschOpenCall。cms 未晋升 PlatformService 枚举(按名直调);flow-center 已入枚举,此处
# 显式覆盖为本地端口。完整 URL 拆 host+ctx,per-target base-path 与提供方 context-path
# 对齐 → 签名天然一致。生产改容器直连:flow-center=http://flow-center-service:10008/flow-center、
# YschOpenCall。cms 未晋升 PlatformService 枚举(按名直调);flow-center 已入枚举。
# 完整 URL 拆 host+ctx,per-target base-path 与提供方 context-path 对齐 → 签名天然一致。
# 生产改容器直连:flow-center=http://flow-center-service:10008/flow-center、
# cms=http://yschcms-service:10005/yschcms。
# (base-path /userbase 已删 2026-10-05,对齐 Notice 形态:该键代码默认值即 /userbase,删键无行为变化,
# 回调前缀推导读的是 yh.flow-callback.target/base-path 三层回落,与本键无关)
services:
# 生产零配置:不配则枚举兜底 = base-host(网关)+/flow-center;此处显式覆盖仅本地开发用
# (Notice 同款)。生产如仍被平台层注入容器直连值,属平台配置项(projman yh.open.services 段)。
flow-center: 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/)
# 流程中心回调接收端 starter(2026-09-20 迁移;2026-10-05 收敛 Notice 形态):
# 端点/幂等/乱序门由 starter 承担,业务映射见 SvcFlowCallbackHandler;
# 验签由上方 /oapi/* 平台应用签名强制验签负责(X-Flw 不启用;flow-center 推送三件套+X-Flw 双带,
# Notice 线上实证此形态);secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
flow-callback:
path: /callback/flow
path: /oapi/flow/callback
biz-types: [svc_item]
verify-flw-signature: true
callback-url: ${svchall.public-base-url}/callback/flow
# 漂移自动核对(Notice 同款):端点路径/ctx 变更后,镜像行有效但登记 URL 与当前期望不一致时
# 自动重新注册(会轮换 secret)——2026-10-05 改路径后 160 存量 /callback/flow 行靠它收敛
reconcile-on-register: true
# 注册地址不再显式配置(callback-url 已删):由 starter 推导
# (open-host ?: yh.open.base-host) + /{前缀} + path,前缀三层回落 target → base-path → 运行时自证
# context-path(本仓三层均未显式配置 → 生产 ctx=/svc-hall 自证)。
# ⇒ 生产 = http://open-gateway-service:10013/svc-hall/oapi/flow/callback(base-host 平台注入)
# 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
# ===== 办事大厅专属配置 =====
svchall:
# 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入)
public-base-url: http://localhost:8086
# (public-base-url 已删 2026-10-05:唯一消费方是回调注册 callback-url 拼接,回调地址改由
# yh.flow-callback 三段推导(见上方该段),本应用地址类配置进一步清零;file.public-base-url 是另一键不受影响)
# 办事大厅前端地址(办件 bizUrl 落地页用)
front:
base-url: http://localhost:8087
@@ -121,6 +142,11 @@ management:
endpoint:
health:
show-details: never
# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health
# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用(Notice/flow-center 同口径)
health:
redis:
enabled: false
# 文件存储磁盘路径(Notice 三件套约定;Docker 部署时与 volume 挂载一致)
FILE_PATH: D:/bsch/svchall/upload/
@@ -0,0 +1,97 @@
package com.yunhai.svchall.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.when;
/**
* 用户面站点信息端点测试(T2 主题联动,2026-10-03 执行方案)。
* GET /api/hall/site?siteCode=:出参=SiteOptionVo 全量(siteCode/name/schId/theme/themeParams)。
* 未登录拦截由平台 CAS 过滤器在 /api/hall/** 统一承担,与 items/filters 同 URL 前缀同口径,
* 控制器层无差异可断言;此处冻结控制器级三态。
*/
@DisplayName("HallController /api/hall/site - 用户面站点信息(T2)")
class HallControllerSiteTest {
private SiteDirectory siteDirectory;
private SiteGuard siteGuard;
private HallController controller;
@BeforeEach
void setUp() {
siteDirectory = mock(SiteDirectory.class);
siteGuard = mock(SiteGuard.class);
controller = new HallController(mock(com.yunhai.svchall.service.impl.ServiceItemService.class),
siteDirectory, siteGuard);
}
@Test
@DisplayName("本校成员 + siteCode 命中:出参含 siteCode/name/schId/theme/themeParams")
void site_memberAndHit_returnsFullOption() {
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
SvcModels.SiteOptionVo option = new SvcModels.SiteOptionVo();
option.setSiteCode("S1");
option.setName("站点一");
option.setSchId(1L);
option.setTheme("campus-glow");
option.getThemeParams().put("primary", "#0e8a5f");
when(siteDirectory.findOption("S1")).thenReturn(option);
SvcModels.SiteOptionVo result = controller.site(null, "S1");
assertEquals("S1", result.getSiteCode());
assertEquals("站点一", result.getName());
assertEquals(1L, result.getSchId());
assertEquals("campus-glow", result.getTheme());
assertEquals("#0e8a5f", result.getThemeParams().get("primary"));
}
@Test
@DisplayName("findOption 防御性为 null:用 SiteInfo 补壳返回(三键保底)")
void site_nullOption_fallsBackToSiteInfoShell() {
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
when(siteDirectory.findOption("S1")).thenReturn(null);
SvcModels.SiteOptionVo result = controller.site(null, "S1");
assertNotNull(result);
assertEquals("S1", result.getSiteCode());
assertEquals("站点一", result.getName());
assertEquals(1L, result.getSchId());
}
@Test
@DisplayName("siteCode 不存在/服务不可达:requireSite 的业务异常原样上抛(与 items 同口径)")
void site_unknownSiteCode_propagatesBizException() {
doThrow(new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:NOPE"))
.when(siteGuard).requireSchoolMember(isNull(), isNull());
when(siteDirectory.requireSite("NOPE")).thenThrow(
new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:NOPE"));
BizException e = assertThrows(BizException.class, () -> controller.site(null, "NOPE"));
assertTrue(e.getMessage().contains("站点不存在"));
}
@Test
@DisplayName("非本校成员:siteGuard 拦截异常上抛(requireMember 口径不变)")
void site_notSchoolMember_propagatesGuardException() {
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 99L));
doThrow(new BizException(ErrorCode.FORBIDDEN, "仅本校用户可办理本校事项"))
.when(siteGuard).requireSchoolMember(isNull(), eq(99L));
assertThrows(BizException.class, () -> controller.site(null, "S1"));
}
}
@@ -128,6 +128,48 @@ class CmsSiteDirectoryImplTest {
assertThrows(BizException.class, () -> directory.listSites(null));
}
@Test
@DisplayName("T2 主题联动:theme/themeParams 随全量 VO 透传(findOption),resolveSite 三字段结构不变")
void findOption_mapsThemeFieldsThrough() {
SvcModels.SiteOptionVo row = vo("S1", "站点一", 1L);
row.setTheme("campus-glow");
row.getThemeParams().put("primary", "#0e8a5f");
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)))
.thenReturn(Collections.singletonList(row));
SvcModels.SiteOptionVo option = directory.findOption("S1");
assertEquals("S1", option.getSiteCode());
assertEquals("campus-glow", option.getTheme());
assertEquals("#0e8a5f", option.getThemeParams().get("primary"));
// resolveSite 委托 findOption:SiteInfo 三字段结构不动(避免 ripple)
SiteDirectory.SiteInfo site = directory.resolveSite("S1");
assertEquals("S1", site.getSiteCode());
assertEquals("站点一", site.getName());
assertEquals(1L, site.getSchId());
// 同一全量缓存:再取不重复出站
assertNull(directory.findOption("NOPE"));
verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE));
}
@Test
@DisplayName("T2 兼容性证明:旧三键+未知键 JSON 反序列化不炸(theme=null),新键正常映射")
void siteOptionVo_backwardCompatibleDeserialization() throws Exception {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
// 旧形态(CMS 未升级)+ 未来未知键:不得抛异常(@JsonIgnoreProperties 兜住)
SvcModels.SiteOptionVo legacy = mapper.readValue(
"{\"siteCode\":\"S1\",\"name\":\"站点一\",\"schId\":2,\"someFutureKey\":true}",
SvcModels.SiteOptionVo.class);
assertEquals("S1", legacy.getSiteCode());
assertNull(legacy.getTheme());
// 新形态:theme/themeParams 正常映射
SvcModels.SiteOptionVo fresh = mapper.readValue(
"{\"siteCode\":\"S2\",\"name\":\"站点二\",\"schId\":3,\"theme\":\"campus-glow\","
+ "\"themeParams\":{\"primary\":\"#0e8a5f\"}}",
SvcModels.SiteOptionVo.class);
assertEquals("campus-glow", fresh.getTheme());
assertEquals("#0e8a5f", fresh.getThemeParams().get("primary"));
}
@Test
@DisplayName("rootCauseBrief:ioError 包装层要透出最内层原因(区分\"对端没起\"与\"读超时\")")
void rootCauseBrief_exposesInnermostCause() {
@@ -6,27 +6,34 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 回调注册 basePath 推导契约:接收方 context-path 从回调 URL path 段去掉尾部
* /callback/flow 推导(部署带 ctx=签名对齐关键;本地裸地址=空串保持旧形态)。
* /oapi/flow/callback 推导(部署带 ctx=签名对齐关键;本地裸地址=空串保持旧形态)。
* 2026-09-16 修复:注册漏带 basePath,flow-center 推送按整路径签名 →
* FlowCallbackVerifyFilter 剥 ctx 后对不上 → 401(本地无 ctx 掩盖)。
* 验签过滤器剥 ctx 后对不上 → 401(本地无 ctx 掩盖)。
* 2026-10-05:回调端点收敛 Notice 形态(/oapi/flow/callback),剥尾段口径同步。
*/
class FlowCenterClientCallbackBasePathTest {
@Test
void derive_withContextPath() {
assertEquals("/svc-hall",
FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/callback/flow"));
FlowCenterClient.deriveCallbackBasePath("http://open-gateway-service:10013/svc-hall/oapi/flow/callback"));
}
@Test
void derive_bareLocalAddressIsEmpty() {
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://localhost:8086/callback/flow"));
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://localhost:8086/oapi/flow/callback"));
}
@Test
void derive_gatewayForm() {
void derive_containerDirectForm() {
assertEquals("/svc-hall",
FlowCenterClient.deriveCallbackBasePath("http://base.yunhaixingtong.com/svc-hall/callback/flow"));
FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/oapi/flow/callback"));
}
@Test
void derive_legacyOldEndpointNotStripped() {
// 旧端点尾段不再是合法推导输入:不命中剥尾段即返回空串(显式冻结,不静默兼容旧路径)
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/callback/flow"));
}
@Test
@@ -127,14 +127,14 @@ class FlowCenterClientFacadeTest {
any(FlowCallbackRegBody.class), eq(SvcModels.CallbackRegVo.class))).thenReturn(stub);
String secret = client().registerCallback(3L, "guid-1",
"http://svc-hall-service:10007/svc-hall/callback/flow", null);
"http://open-gateway-service:10013/svc-hall/oapi/flow/callback", null);
assertEquals("secret-1", secret);
ArgumentCaptor<FlowCallbackRegBody> body = ArgumentCaptor.forClass(FlowCallbackRegBody.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
body.capture(), eq(SvcModels.CallbackRegVo.class));
assertEquals("/svc-hall", body.getValue().getBasePath());
assertEquals("http://svc-hall-service:10007/svc-hall/callback/flow", body.getValue().getCallbackUrl());
assertEquals("http://open-gateway-service:10013/svc-hall/oapi/flow/callback", body.getValue().getCallbackUrl());
assertNull(body.getValue().getEventTypes());
}