feat(hall): 站点主题中转 + 用户面站点信息端点(T2)——SiteOptionVo 扩 theme/themeParams(@JsonIgnoreProperties 兜未知键,兼容旧 CMS 三键形态,测试已证);SiteDirectory 新增 findOption(resolveSite 的全量形态,resolveSite 委托之,SiteInfo 三字段不动);HallController 新增 GET /api/hall/site(requireMember 同口径 + 防御性 SiteInfo 补壳)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user