From f368299eb587820fb81bc6da0e09d1df50d6972a Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 3 Oct 2026 13:46:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(hall):=20=E7=AB=99=E7=82=B9=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E4=B8=AD=E8=BD=AC=20+=20=E7=94=A8=E6=88=B7=E9=9D=A2?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E4=BF=A1=E6=81=AF=E7=AB=AF=E7=82=B9=EF=BC=88?= =?UTF-8?q?T2=EF=BC=89=E2=80=94=E2=80=94SiteOptionVo=20=E6=89=A9=20theme/t?= =?UTF-8?q?hemeParams=EF=BC=88@JsonIgnoreProperties=20=E5=85=9C=E6=9C=AA?= =?UTF-8?q?=E7=9F=A5=E9=94=AE=EF=BC=8C=E5=85=BC=E5=AE=B9=E6=97=A7=20CMS=20?= =?UTF-8?q?=E4=B8=89=E9=94=AE=E5=BD=A2=E6=80=81=EF=BC=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B7=B2=E8=AF=81=EF=BC=89=EF=BC=9BSiteDirectory=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20findOption=EF=BC=88resolveSite=20=E7=9A=84=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=BD=A2=E6=80=81=EF=BC=8CresolveSite=20=E5=A7=94?= =?UTF-8?q?=E6=89=98=E4=B9=8B=EF=BC=8CSiteInfo=20=E4=B8=89=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=8D=E5=8A=A8=EF=BC=89=EF=BC=9BHallController=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20GET=20/api/hall/site=EF=BC=88requireMember?= =?UTF-8?q?=20=E5=90=8C=E5=8F=A3=E5=BE=84=20+=20=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E6=80=A7=20SiteInfo=20=E8=A1=A5=E5=A3=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/HallController.java | 17 ++++ .../yunhai/svchall/model/svc/SvcModels.java | 8 +- .../yunhai/svchall/service/SiteDirectory.java | 6 ++ .../service/impl/CmsSiteDirectoryImpl.java | 19 +++- .../api/HallControllerSiteTest.java | 97 +++++++++++++++++++ .../impl/CmsSiteDirectoryImplTest.java | 42 ++++++++ 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java diff --git a/src/main/java/com/yunhai/svchall/controller/api/HallController.java b/src/main/java/com/yunhai/svchall/controller/api/HallController.java index a54ac1f..97881dd 100644 --- a/src/main/java/com/yunhai/svchall/controller/api/HallController.java +++ b/src/main/java/com/yunhai/svchall/controller/api/HallController.java @@ -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); diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java index ce47cca..a077a39 100644 --- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java +++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java @@ -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 themeParams = new java.util.LinkedHashMap<>(); } // ===== 字典 ===== diff --git a/src/main/java/com/yunhai/svchall/service/SiteDirectory.java b/src/main/java/com/yunhai/svchall/service/SiteDirectory.java index 25f0e6f..2e2136a 100644 --- a/src/main/java/com/yunhai/svchall/service/SiteDirectory.java +++ b/src/main/java/com/yunhai/svchall/service/SiteDirectory.java @@ -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 listSites(Long schId); diff --git a/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java index 64976a7..b863114 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java +++ b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java @@ -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 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 listSites(Long schId) { if (schId == null) { diff --git a/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java b/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java new file mode 100644 index 0000000..a9f1a61 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/controller/api/HallControllerSiteTest.java @@ -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")); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java index 7a3c2e8..c13d787 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java @@ -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() {