init local code
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
|
||||
/** 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<String> 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;
|
||||
|
||||
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
|
||||
public List<SiteVo> listFor(BoaUser user) {
|
||||
List<CmsSite> sites = authProperties.isSuperAdmin(user)
|
||||
? siteRepository.findAll()
|
||||
: (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null
|
||||
? java.util.Collections.emptyList()
|
||||
: siteRepository.findBySchId(user.getCurrentRole().getSchId()));
|
||||
Map<Integer, String> nameById = new java.util.HashMap<>();
|
||||
for (CmsSite site : sites) {
|
||||
nameById.put(site.getId(), site.getName());
|
||||
}
|
||||
List<SiteVo> 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<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
|
||||
return ExtJsonUtils.getString(config, key);
|
||||
}
|
||||
|
||||
/** 站点保存(仅超管;MVP 1-2 校以初始化脚本为主,本接口为后台兜底) */
|
||||
@Transactional
|
||||
public SiteVo save(BoaUser user, SiteSaveRequest req) {
|
||||
requireSuperAdmin(user);
|
||||
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);
|
||||
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site"));
|
||||
return SiteVo.of(site);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
|
||||
Map<String, String> 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user