package com.yunhai.cms.service.impl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.cms.config.CmsAuthProperties; import com.yunhai.cms.entity.CmsSite; import com.yunhai.cms.event.SiteContentChangedEvent; import com.yunhai.cms.model.dto.SiteSaveRequest; import com.yunhai.cms.model.vo.SiteVo; import com.yunhai.cms.repository.CmsSiteRepository; import com.yunhai.cms.utils.ExtJsonUtils; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; /** * 站点与校级权限(方案 §7.2): * ① 平台超管(BoaUser.type==1)→ 所有站点;② 学校角色(currentRole.schId)→ 仅本校站点(site.sch_id 匹配)。 * 所有管理端写操作入口先过 {@link #requireManageable}。 */ @Slf4j @Service @RequiredArgsConstructor public class SiteService { private final CmsSiteRepository siteRepository; private final CmsAuthProperties authProperties; private final ApplicationEventPublisher eventPublisher; private final com.yunhai.cms.theme.ThemeRegistry themeRegistry; /** * B2 附件引用:站点配置 logo/favicon 回写场景引用。 * 字段注入 + @Lazy 打破与 AttachmentService 的构造器循环依赖(AttachmentService → SiteService)。 */ @org.springframework.beans.factory.annotation.Autowired @org.springframework.context.annotation.Lazy private AttachmentService attachmentService; /** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */ private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper(); private static final Pattern HOST_PATTERN = Pattern.compile( "^(\\*\\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://\\S+$"); private static final Pattern ASSET_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$"); private static final Pattern CONFIG_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); private static final Set RESERVED_CONFIG_KEYS = java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params"))); private static final int CONFIG_VALUE_MAX_LEN = 500; /** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */ private static final int CUSTOM_CSS_MAX_LEN = 10240; private static final List CUSTOM_CSS_BLACKLIST = java.util.Collections.unmodifiableList(java.util.Arrays.asList( " listFor(BoaUser user) { List sites = authProperties.isSuperAdmin(user) ? siteRepository.findAll() : (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null ? java.util.Collections.emptyList() : siteRepository.findBySchId(user.getCurrentRole().getSchId())); Map nameById = new java.util.HashMap<>(); for (CmsSite site : sites) { nameById.put(site.getId(), site.getName()); } List result = new ArrayList<>(); for (CmsSite site : sites) { SiteVo vo = SiteVo.of(site); if (vo.getParentSiteId() != null && vo.getParentSiteId() > 0) { vo.setParentName(nameById.get(vo.getParentSiteId())); } result.add(vo); } return result; } /** 校验可管理权限并返回站点;越权抛 FORBIDDEN */ public CmsSite requireManageable(BoaUser user, Integer siteId) { CmsSite site = siteRepository.findById(siteId == null ? -1 : siteId) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); if (!canManage(user, site)) { throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点"); } return site; } public boolean canManage(BoaUser user, CmsSite site) { if (authProperties.isSuperAdmin(user)) { return true; } Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); return schId != null && site.getSchId() != null && schId.equals(site.getSchId()); } public void requireSuperAdmin(BoaUser user) { if (!authProperties.isSuperAdmin(user)) { throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作"); } } public CmsSite getByCode(String code) { return siteRepository.findByCode(code).orElse(null); } public String configValue(CmsSite site, String key) { Map config = ExtJsonUtils.parse(site.getConfigJson()); return ExtJsonUtils.getString(config, key); } /** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */ @Transactional public SiteVo save(BoaUser user, SiteSaveRequest req) { requireSiteSavePermission(user, req); CmsSite site; if (req.getId() == null) { if (siteRepository.findByCode(req.getCode()).isPresent()) { throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在"); } site = new CmsSite(); site.setCode(req.getCode()); } else { site = siteRepository.findById(req.getId()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); } validateParent(req, site); // B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配, // 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响) if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) { throw new BizException(ErrorCode.PARAM_ERROR, "顶层站点必须指定学校ID(schId),否则校级角色无法管理"); } site.setSchId(req.getSchId()); site.setSchName(req.getSchName()); site.setName(req.getName()); String resolvedTheme = StringUtils.hasText(req.getTheme()) ? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME; if (!themeRegistry.contains(resolvedTheme)) { throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme); } site.setTheme(resolvedTheme); validateConfigJson(req.getConfigJson()); site.setConfigJson(mergeThemeParams(req, resolvedTheme)); site.setStatus(req.getStatus() == null ? 1 : req.getStatus()); site = siteRepository.save(site); // B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值) attachmentService.markSiteConfigUsed(site.getId(), ExtJsonUtils.parse(site.getConfigJson())); eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site")); 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 丢弃(不回传错误)。 * SPA 不再手拼 configJson 中的该节点。 */ private String mergeThemeParams(SiteSaveRequest req, String theme) { if (req.getThemeParams() == null) { return req.getConfigJson(); } Map config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson())); Map filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams()); if (filtered.isEmpty()) { config.remove("theme_params"); } else { config.put("theme_params", filtered); } try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config); } catch (Exception e) { throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败"); } } /** * 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、 * 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。 */ private void validateParent(SiteSaveRequest req, CmsSite site) { Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId(); if (parentSiteId <= 0) { site.setParentSiteId(0); return; } if (site.getId() != null && site.getId().equals(parentSiteId)) { throw new BizException(ErrorCode.PARAM_ERROR, "上级站点不能是自身"); } CmsSite parent = siteRepository.findById(parentSiteId) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在")); if (parent.getParentSiteId() != null && parent.getParentSiteId() > 0) { throw new BizException(ErrorCode.PARAM_ERROR, "院系子站下不允许再挂子站"); } if (req.getSchId() == null) { req.setSchId(parent.getSchId()); req.setSchName(parent.getSchName()); } else if (parent.getSchId() != null && !req.getSchId().equals(parent.getSchId())) { throw new BizException(ErrorCode.PARAM_ERROR, "院系子站与主站不属于同一学校"); } boolean wasTopLevel = site.getId() == null || site.getParentSiteId() == null || site.getParentSiteId() == 0; if (wasTopLevel && site.getId() != null && siteRepository.existsByParentSiteId(site.getId())) { throw new BizException(ErrorCode.PARAM_ERROR, "该站点下已有院系子站,不能改为院系子站"); } site.setParentSiteId(parentSiteId); } /** * config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定): * ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线; * ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。 * 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行—— * 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。 */ private void validateConfigJson(String configJson) { if (!StringUtils.hasText(configJson)) { return; } JsonNode node; try { node = CONFIG_MAPPER.readTree(configJson); } catch (Exception e) { throw new BizException(ErrorCode.PARAM_ERROR, "站点配置不是合法的 JSON"); } if (!node.isObject()) { throw new BizException(ErrorCode.PARAM_ERROR, "站点配置必须是 JSON 对象"); } node.fields().forEachRemaining(entry -> { String key = entry.getKey(); if (RESERVED_CONFIG_KEYS.contains(key)) { return; } if (!CONFIG_KEY_PATTERN.matcher(key).matches()) { throw new BizException(ErrorCode.PARAM_ERROR, "站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)"); } JsonNode value = entry.getValue(); if (!value.isTextual()) { return; } String text = value.asText(); if (!StringUtils.hasText(text)) { return; } // custom_css 独立校验(B3):上限 10KB + 黑名单,不走通用 500 字上限 if ("custom_css".equals(key)) { validateCustomCss(text); return; } if (text.length() > CONFIG_VALUE_MAX_LEN) { throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)"); } switch (key) { case "domain": if (!HOST_PATTERN.matcher(text).matches()) { throw new BizException(ErrorCode.PARAM_ERROR, "绑定域名格式不正确:" + text + "(示例 www.xxx.edu.cn,不带协议)"); } break; case "public_base_url": if (!URL_PATTERN.matcher(text).matches()) { throw new BizException(ErrorCode.PARAM_ERROR, "对外基地址必须是完整的 http(s):// 地址:" + text); } break; case "logo": case "favicon": if (!ASSET_PATTERN.matcher(text).matches()) { throw new BizException(ErrorCode.PARAM_ERROR, key + " 必须是站点上传路径(/ 开头)或 http(s) 地址"); } break; default: break; } }); } /** * custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝 * {@code CUSTOM_CSS_MAX_LEN) { throw new BizException(ErrorCode.PARAM_ERROR, "custom_css 过长(" + css.length() + " 字符,上限 " + CUSTOM_CSS_MAX_LEN + ")"); } String lower = css.toLowerCase(java.util.Locale.ROOT); for (String banned : CUSTOM_CSS_BLACKLIST) { if (lower.contains(banned)) { throw new BizException(ErrorCode.PARAM_ERROR, "custom_css 含禁用内容:" + banned + "(不允许