From 52676f592a9d858a93890f6e728b052681e5ea60 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 7 Sep 2026 19:21:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(B1):=20=E5=BB=BA=E7=AB=99=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8B=86=E5=88=86-=E6=A0=A1=E7=BA=A7=E5=8F=AF?= =?UTF-8?q?=E5=BB=BA/=E7=BC=96=E8=BE=91=E6=9C=AC=E9=99=A2=E7=B3=BB?= =?UTF-8?q?=E5=AD=90=E7=AB=99,=E9=A1=B6=E5=B1=82=E7=AB=99=E4=BB=8D?= =?UTF-8?q?=E4=BB=85=E8=B6=85=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SiteService.save 入口换 requireSiteSavePermission 私有方法(安全边界单点): 超管直通;无校级角色/顶层编辑/新建顶层/子站提升/跨校主站/伪造schId 全拒 - SiteServiceIT 新增10例(方案7例+伪造schId/跨校接管/同校改挂3个边界) - mvn -o test 236/236 全绿(基线226+10) --- .../yunhai/cms/service/impl/SiteService.java | 48 ++++- .../cms/service/impl/SiteServiceIT.java | 168 ++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index 6f1ebf2..bc8b71f 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -106,10 +106,10 @@ public class SiteService { return ExtJsonUtils.getString(config, key); } - /** 站点保存(仅超管;MVP 1-2 校以初始化脚本为主,本接口为后台兜底) */ + /** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */ @Transactional public SiteVo save(BoaUser user, SiteSaveRequest req) { - requireSuperAdmin(user); + requireSiteSavePermission(user, req); CmsSite site; if (req.getId() == null) { if (siteRepository.findByCode(req.getCode()).isPresent()) { @@ -144,6 +144,50 @@ public class SiteService { return SiteVo.of(site); } + /** + * 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化): + * 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项—— + * ① 新建顶层站(parentSiteId≤0)→ 拒(仅平台超管可创建学校主站); + * ② 编辑对象为顶层站(含本校主站)→ 拒; + * ③ 编辑子站时 parentSiteId 传 0/null(提升为顶层)→ 拒; + * ④ 目标主站 schId 与 currentRole.schId 不一致(跨校/改挂接管)→ 拒; + * ⑤ 编辑对象本身跨校 → 拒(复用 canManage 同校语义); + * ⑥ 无校级角色(schId 为空)→ 拒; + * ⑦ 请求体显式 schId 与本人角色不一致(伪造通道)→ 拒(留空则由 validateParent 继承主站)。 + */ + private void requireSiteSavePermission(BoaUser user, SiteSaveRequest req) { + if (authProperties.isSuperAdmin(user)) { + return; + } + Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(需校级角色)"); + } + if (req.getId() != null) { + CmsSite target = siteRepository.findById(req.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); + boolean targetIsTop = target.getParentSiteId() == null || target.getParentSiteId() == 0; + if (targetIsTop) { + throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可管理学校主站(顶层站点)"); + } + if (!canManage(user, target)) { + throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点"); + } + } + Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId(); + if (parentSiteId <= 0) { + throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可创建学校主站(顶层站点)"); + } + CmsSite parent = siteRepository.findById(parentSiteId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在")); + if (parent.getSchId() == null || !schId.equals(parent.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点"); + } + if (req.getSchId() != null && !schId.equals(req.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(学校ID与本人角色不符)"); + } + } + /** * themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键, * 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。 diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java index 5933b7a..1c6cfc9 100644 --- a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java @@ -457,6 +457,174 @@ class SiteServiceIT extends CmsIntegrationTestBase { .hasMessageContaining("不能改为院系子站"); } + // ===== B1 建站权限拆分(校级可建/编辑本院系子站,顶层站仍仅超管)===== + + @Test + @DisplayName("B1: 校级用户新建院系子站成功,schId 自动继承本校主站") + void save_subSiteBySchoolAdmin_shouldSucceed() { + CmsSite main = persistSite("b1main", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("b1jdx"); + req.setParentSiteId(main.getId()); + req.setName("机电学院"); + + SiteVo vo = siteService.save(TestUsers.schoolUser(9), req); + + assertThat(vo.getParentSiteId()).isEqualTo(main.getId()); + CmsSite saved = siteService.getByCode("b1jdx"); + assertThat(saved.getSchId()).as("schId 留空自动继承主站").isEqualTo(9); + assertThat(saved.getSchName()).isEqualTo("学校-9"); + } + + @Test + @DisplayName("B1: 校级用户挂他校主站建子站被拒(跨校)") + void save_subSiteBySchoolAdmin_otherSchoolParent_forbidden() { + CmsSite otherMain = persistSite("b1other", 8, "他校门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("b1steal"); + req.setParentSiteId(otherMain.getId()); + req.setName("越权院系站"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("B1: 校级用户新建顶层站被拒(仅超管)") + void save_topSiteBySchoolAdmin_forbidden() { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("b1top"); + req.setSchId(9); + req.setName("越权顶层站"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("超管"); + } + + @Test + @DisplayName("B1: 校级编辑本校院系子站(改名/换肤)成功") + void save_editOwnSubSiteBySchoolAdmin_shouldSucceed() { + CmsSite main = persistSite("b1main2", 9, "云海大学门户"); + persistSite("b1jdx2", 9, "机电学院", main.getId()); + Integer subId = siteService.getByCode("b1jdx2").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(subId); + req.setCode("b1jdx2"); + req.setParentSiteId(main.getId()); + req.setName("机械与电气工程学院"); + req.setTheme("t-demo-red"); + + SiteVo vo = siteService.save(TestUsers.schoolUser(9), req); + + assertThat(vo.getName()).isEqualTo("机械与电气工程学院"); + CmsSite saved = siteService.getByCode("b1jdx2"); + assertThat(saved.getTheme()).as("换肤生效").isEqualTo("t-demo-red"); + assertThat(saved.getParentSiteId()).isEqualTo(main.getId()); + } + + @Test + @DisplayName("B1: 校级把院系子站改挂为顶层(parentSiteId 传 0)被拒") + void save_editSubSite_promoteToTopBySchoolAdmin_forbidden() { + CmsSite main = persistSite("b1main3", 9, "云海大学门户"); + persistSite("b1jdx3", 9, "机电学院", main.getId()); + Integer subId = siteService.getByCode("b1jdx3").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(subId); + req.setCode("b1jdx3"); + req.setParentSiteId(0); + req.setName("机电学院"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("超管"); + } + + @Test + @DisplayName("B1: 校级编辑顶层站(含本校主站)被拒") + void save_editTopSiteBySchoolAdmin_forbidden() { + CmsSite main = persistSite("b1main4", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(main.getId()); + req.setCode("b1main4"); + req.setParentSiteId(0); + req.setSchId(9); + req.setName("云海大学门户"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("超管"); + } + + @Test + @DisplayName("B1: 无校级角色(schId 为空)建子站被拒") + void save_subSiteBySchoolAdmin_noSchIdRole_forbidden() { + CmsSite main = persistSite("b1main5", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("b1norole"); + req.setParentSiteId(main.getId()); + req.setName("无角色院系站"); + + assertThatThrownBy(() -> siteService.save(TestUsers.plainUser(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("B1: 校级请求体伪造 schId(与他校一致)被拒(关闭伪造通道)") + void save_subSiteBySchoolAdmin_forgedSchId_forbidden() { + CmsSite main = persistSite("b1main6", 9, "云海大学门户"); + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode("b1forge"); + req.setParentSiteId(main.getId()); + req.setSchId(8); + req.setName("伪造学校院系站"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("B1: 校级编辑他校院系子站被拒(防改挂接管)") + void save_editOtherSchoolSubSiteBySchoolAdmin_forbidden() { + CmsSite ownMain = persistSite("b1main7", 9, "云海大学门户"); + CmsSite otherMain = persistSite("b1main8", 8, "他校门户"); + persistSite("b1otherjdx", 8, "他校机电学院", otherMain.getId()); + Integer otherSubId = siteService.getByCode("b1otherjdx").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(otherSubId); + req.setCode("b1otherjdx"); + req.setParentSiteId(ownMain.getId()); + req.setName("被他校改挂"); + + assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权"); + } + + @Test + @DisplayName("B1: 校级编辑子站改挂到另一同校主站仍放行(既有能力保留)") + void save_editSubSite_moveToAnotherSameSchoolMainBySchoolAdmin_shouldSucceed() { + CmsSite mainA = persistSite("b1maina", 9, "主站A"); + CmsSite mainB = persistSite("b1mainb", 9, "主站B"); + persistSite("b1jdx9", 9, "机电学院", mainA.getId()); + Integer subId = siteService.getByCode("b1jdx9").getId(); + + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(subId); + req.setCode("b1jdx9"); + req.setParentSiteId(mainB.getId()); + req.setName("机电学院"); + + SiteVo vo = siteService.save(TestUsers.schoolUser(9), req); + assertThat(vo.getParentSiteId()).isEqualTo(mainB.getId()); + } + @Test @DisplayName("编辑院系子站:可改挂到另一顶层主站(同校)") void save_collegeCanMoveToAnotherMain() {