init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -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;
}
});
}
}