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:
chenxing
2026-10-03 13:46:31 +08:00
parent 4bbcd22d25
commit f368299eb5
6 changed files with 183 additions and 6 deletions
@@ -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() {