重构: 引入 site_snapshots 表统一站点配置快照管理

- 新增 site_snapshots 表 (V16 迁移),统一存储脚本+页面+规则的合并快照
- 新增 SiteSnapshot 实体、Mapper、Service
- 配置变更时自动触发快照更新 (checksum 去重)
- 新增 SnapshotController 提供 /api/v1/snapshots/{siteId} 端点,支持 ETag/304
- 重构 publishManifest 使用快照服务,只发布变更站点
- 保留七牛云 CDN 作为灾备层
- 兼容旧 /bundle 和 /bundles/ 路径
This commit is contained in:
mediabot-pt
2026-07-07 11:09:52 +08:00
parent 98bc5b3183
commit b25aa82c73
11 changed files with 680 additions and 100 deletions

View File

@@ -0,0 +1,78 @@
package com.par.core.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 站点配置快照实体
* 统一存储站点的脚本 + 页面配置 + 风险规则合并快照
*/
@Data
@TableName("site_snapshots")
public class SiteSnapshot {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("site_id")
private String siteId;
@TableField("version")
private Integer version;
/** Groovy 脚本源码 */
@TableField("script")
private String script;
/** 脚本来源版本号 */
@TableField("script_version")
private String scriptVersion;
/** 脚本来源: custom / default */
@TableField("script_source")
private String scriptSource;
/** 页面配置条目 JSON */
@TableField("pages")
private String pages;
/** 页面配置版本号 */
@TableField("pages_version")
private String pagesVersion;
/** 页面来源: custom / default */
@TableField("pages_source")
private String pagesSource;
/** 风险规则 JSON */
@TableField("rules")
private String rules;
/** 风险规则版本号 */
@TableField("rules_version")
private Integer rulesVersion;
/** SHA-256 内容校验 */
@TableField("checksum")
private String checksum;
/** 状态: draft / published */
@TableField("status")
private String status;
/** 七牛云 CDN 地址 */
@TableField("cdn_url")
private String cdnUrl;
/** 发布时间 */
@TableField("published_at")
private LocalDateTime publishedAt;
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.par.core.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.par.core.entity.SiteSnapshot;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 站点配置快照 Mapper
*/
@Mapper
public interface SiteSnapshotMapper extends BaseMapper<SiteSnapshot> {
/** 查询站点最新的 draft 快照 */
@Select("SELECT * FROM site_snapshots WHERE site_id = #{siteId} AND status = 'draft' ORDER BY version DESC LIMIT 1")
SiteSnapshot selectLatestDraft(@Param("siteId") String siteId);
/** 查询站点最新的 published 快照 */
@Select("SELECT * FROM site_snapshots WHERE site_id = #{siteId} AND status = 'published' ORDER BY version DESC LIMIT 1")
SiteSnapshot selectLatestPublished(@Param("siteId") String siteId);
}

View File

@@ -0,0 +1,47 @@
package com.par.core.service;
import com.par.core.entity.SiteSnapshot;
import java.util.Map;
/**
* 站点配置快照服务
* 统一管理站点脚本 + 页面配置 + 风险规则的合并快照
*/
public interface SiteSnapshotService {
/**
* 构建站点配置快照(不持久化,仅返回)
* @return null 表示该站点没有任何有效配置
*/
Map<String, Object> buildSnapshot(String siteId);
/**
* 更新站点的 draft 快照(任意配置变更后调用)
* 内容未变化(checksum 相同)则不产生新版本
* @return 新快照或 null(内容未变)
*/
SiteSnapshot updateSnapshot(String siteId);
/**
* 发布指定站点的快照(draft → published)
* 同时上传到七牛云 CDN 作为灾备
*/
SiteSnapshot publish(String siteId);
/**
* 获取站点当前生效快照(最新 published)
*/
SiteSnapshot getActive(String siteId);
/**
* 按 checksum 判断是否需要更新
*/
boolean isChanged(String siteId, String checksum);
/**
* 批量发布有变更的站点(只发布 checksum 变化的)
* @return 成功发布的站点数
*/
int publishChanged();
}

View File

@@ -7,6 +7,7 @@ import com.par.core.enums.ConfigStatus;
import com.par.core.mapper.SiteConfigMapper;
import com.par.core.service.ConfigService;
import com.par.core.service.ManifestVersionService;
import com.par.core.service.SiteSnapshotService;
import com.par.core.storage.CachedStorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -21,13 +22,16 @@ public class ConfigServiceImpl implements ConfigService {
private final ManifestVersionService versionService;
private final SiteConfigMapper siteConfigMapper;
private final CachedStorageService storageService;
private final SiteSnapshotService snapshotService;
public ConfigServiceImpl(ManifestVersionService versionService,
SiteConfigMapper siteConfigMapper,
CachedStorageService storageService) {
CachedStorageService storageService,
SiteSnapshotService snapshotService) {
this.versionService = versionService;
this.siteConfigMapper = siteConfigMapper;
this.storageService = storageService;
this.snapshotService = snapshotService;
}
@Override
@@ -85,6 +89,8 @@ public class ConfigServiceImpl implements ConfigService {
siteConfigMapper.insert(config);
versionService.increment(siteId);
// 触发快照更新(异步更新 draft)
try { snapshotService.updateSnapshot(siteId); } catch (Exception e) { log.warn("快照更新失败 siteId={}: {}", siteId, e.getMessage()); }
log.info("Config submitted: siteId={}, version={}, id={}, scriptPath={}", siteId, version, config.getId(), scriptPath);
return config;
}

View File

@@ -6,6 +6,7 @@ import com.par.core.entity.RiskRule;
import com.par.core.mapper.RiskRuleMapper;
import com.par.core.service.ManifestVersionService;
import com.par.core.service.RiskRuleService;
import com.par.core.service.SiteSnapshotService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -21,6 +22,7 @@ public class RiskRuleServiceImpl implements RiskRuleService {
private final RiskRuleMapper mapper;
private final com.par.core.storage.CachedStorageService storageService;
private final com.par.core.service.SiteService siteService;
private final SiteSnapshotService snapshotService;
private static final ObjectMapper om = new ObjectMapper();
private RiskRule findByIdentifier(String identifier) {
@@ -77,6 +79,8 @@ public class RiskRuleServiceImpl implements RiskRuleService {
String storagePath = String.format("rules/%s/v%d.json", siteType, nextVer);
storageService.store(storagePath, json);
versionService.incrementByType(siteType);
// 触发同类型所有站点快照更新
updateSnapshotsByType(siteType);
log.info("风险规则已保存: key={}, version={}, path={}", siteType, nextVer, storagePath);
} catch (Exception e) {
log.error("保存风险规则失败 key={}", siteType, e);
@@ -108,6 +112,22 @@ public class RiskRuleServiceImpl implements RiskRuleService {
public void deleteRules(String siteType) {
mapper.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<RiskRule>()
.eq(RiskRule::getSiteId, siteType).eq(RiskRule::getDeleted, 0));
updateSnapshotsByType(siteType);
}
/** 触发同类型下所有站点的快照更新 */
private void updateSnapshotsByType(String siteType) {
try {
var sites = siteService.listAllSites().stream()
.filter(s -> s.getSiteType() != null && siteType.equals(s.getSiteType().getValue()))
.toList();
for (var site : sites) {
try { snapshotService.updateSnapshot(site.getSiteId()); }
catch (Exception e) { log.warn("快照更新失败 siteId={}: {}", site.getSiteId(), e.getMessage()); }
}
} catch (Exception e) {
log.warn("批量快照更新失败 siteType={}: {}", siteType, e.getMessage());
}
}
@Override

View File

@@ -6,6 +6,7 @@ import com.par.core.entity.SitePages;
import com.par.core.mapper.SitePagesMapper;
import com.par.core.service.ManifestVersionService;
import com.par.core.service.SitePagesService;
import com.par.core.service.SiteSnapshotService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -20,6 +21,7 @@ public class SitePagesServiceImpl implements SitePagesService {
private final ManifestVersionService versionService;
private final SitePagesMapper mapper;
private final com.par.core.storage.CachedStorageService storageService;
private final SiteSnapshotService snapshotService;
private static final ObjectMapper om = new ObjectMapper();
@Override
@@ -82,6 +84,8 @@ public class SitePagesServiceImpl implements SitePagesService {
String storagePath = String.format("pages/%s/v%s.json", siteId, version);
storageService.store(storagePath, json);
versionService.increment(siteId);
// 触发快照更新
try { snapshotService.updateSnapshot(siteId); } catch (Exception e) { log.warn("快照更新失败 siteId={}: {}", siteId, e.getMessage()); }
log.info("页面配置已保存: siteId={}, version={}, path={}", siteId, version, storagePath);
} catch (Exception e) {
log.error("保存页面配置失败 siteId={}", siteId, e);

View File

@@ -0,0 +1,269 @@
package com.par.core.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.par.common.util.HmacUtil;
import com.par.core.adapter.GroovyScriptEngine;
import com.par.core.entity.RiskRule;
import com.par.core.entity.SiteConfig;
import com.par.core.entity.SiteSnapshot;
import com.par.core.mapper.RiskRuleMapper;
import com.par.core.mapper.SiteConfigMapper;
import com.par.core.mapper.SiteMapper;
import com.par.core.mapper.SiteSnapshotMapper;
import com.par.core.service.ConfigService;
import com.par.core.service.SitePagesService;
import com.par.core.service.SiteSnapshotService;
import com.par.core.storage.CachedStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 站点配置快照服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SiteSnapshotServiceImpl implements SiteSnapshotService {
private final SiteSnapshotMapper snapshotMapper;
private final SiteConfigMapper siteConfigMapper;
private final SiteMapper siteMapper;
private final ConfigService configService;
private final SitePagesService sitePagesService;
private final RiskRuleMapper riskRuleMapper;
private final CachedStorageService storageService;
private static final ObjectMapper om = new ObjectMapper();
@Override
public Map<String, Object> buildSnapshot(String siteId) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("siteId", siteId);
snapshot.put("generatedAt", LocalDateTime.now().toString());
var site = siteMapper.selectBySiteId(siteId);
String siteType = site != null && site.getSiteType() != null ? site.getSiteType().getValue() : null;
// 1. 脚本
SiteConfig latest = configService.getLatestConfig(siteId);
if (latest != null && latest.getScriptStoragePath() != null && !latest.getScriptStoragePath().isBlank()) {
try {
String script = storageService.read(latest.getScriptStoragePath());
if (script != null && !script.isBlank()) {
snapshot.put("script", script);
snapshot.put("scriptVersion", latest.getVersion());
snapshot.put("scriptSource", "custom");
}
} catch (Exception e) {
log.warn("读取脚本失败 siteId={}: {}", siteId, e.getMessage());
}
}
if (!snapshot.containsKey("script") && siteType != null) {
String defScript = GroovyScriptEngine.loadDefaultByType(siteType);
if (defScript != null) {
snapshot.put("script", defScript);
snapshot.put("scriptSource", "default");
}
}
// 2. 页面配置
Map<String, Object> pages = sitePagesService.getPages(siteId);
if (pages == null && siteType != null) {
pages = sitePagesService.getPages("default:" + siteType);
}
if (pages != null) {
snapshot.put("pages", pages.get("entries"));
snapshot.put("pagesVersion", pages.get("version"));
snapshot.put("pagesSource", pages.containsKey("source") ? pages.get("source") : "custom");
}
// 3. 风险规则
if (siteType != null) {
var rules = riskRuleMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<RiskRule>()
.eq(RiskRule::getSiteType, siteType)
.eq(RiskRule::getDeleted, 0)
.orderByDesc(RiskRule::getId));
if (!rules.isEmpty()) {
var latestRule = rules.get(0);
try {
List<Map<String, Object>> rulesList = om.readValue(latestRule.getRulesJson(),
new TypeReference<List<Map<String, Object>>>() {});
snapshot.put("riskRules", rulesList);
snapshot.put("riskRulesVersion", latestRule.getVersion());
} catch (Exception ignored) {}
}
}
return snapshot.containsKey("script") ? snapshot : null;
}
@Override
@Transactional
public SiteSnapshot updateSnapshot(String siteId) {
Map<String, Object> data = buildSnapshot(siteId);
if (data == null) {
log.debug("站点 {} 无有效配置,跳过快照更新", siteId);
return null;
}
try {
// 计算 checksum
String contentJson = om.writeValueAsString(data);
String checksum = HmacUtil.sha256(contentJson);
// 查询最新 draft
SiteSnapshot latestDraft = snapshotMapper.selectLatestDraft(siteId);
if (latestDraft != null && checksum.equals(latestDraft.getChecksum())) {
log.debug("站点 {} 配置未变化,跳过快照生成", siteId);
return null;
}
// 确定新版本号
int newVersion = (latestDraft != null) ? latestDraft.getVersion() + 1 : 1;
SiteSnapshot snapshot = new SiteSnapshot();
snapshot.setSiteId(siteId);
snapshot.setVersion(newVersion);
// 脚本
if (data.containsKey("script")) {
snapshot.setScript((String) data.get("script"));
snapshot.setScriptVersion((String) data.get("scriptVersion"));
snapshot.setScriptSource((String) data.get("scriptSource"));
}
// 页面
if (data.containsKey("pages")) {
snapshot.setPages(om.writeValueAsString(data.get("pages")));
snapshot.setPagesVersion((String) data.get("pagesVersion"));
snapshot.setPagesSource((String) data.get("pagesSource"));
}
// 规则
if (data.containsKey("riskRules")) {
snapshot.setRules(om.writeValueAsString(data.get("riskRules")));
snapshot.setRulesVersion((Integer) data.get("riskRulesVersion"));
}
snapshot.setChecksum(checksum);
snapshot.setStatus("draft");
snapshotMapper.insert(snapshot);
log.info("快照已更新: siteId={}, version={}, checksum={}", siteId, newVersion, checksum.substring(0, 8));
return snapshot;
} catch (Exception e) {
log.error("更新快照失败 siteId={}", siteId, e);
return null;
}
}
@Override
@Transactional
public SiteSnapshot publish(String siteId) {
SiteSnapshot draft = snapshotMapper.selectLatestDraft(siteId);
if (draft == null) {
log.warn("站点 {} 无 draft 快照可发布", siteId);
return null;
}
// 如果已经是最新 published(相同 checksum),跳过
SiteSnapshot current = snapshotMapper.selectLatestPublished(siteId);
if (current != null && draft.getChecksum().equals(current.getChecksum())) {
log.info("站点 {} 已是最新版本,跳过发布", siteId);
return current;
}
// 更新状态为 published
draft.setStatus("published");
draft.setPublishedAt(LocalDateTime.now());
snapshotMapper.updateById(draft);
// 上传到七牛云 CDN 作为灾备
try {
String bundleJson = toBundleJson(draft);
String cdnPath = "snapshots/" + siteId + ".json";
storageService.store(cdnPath, bundleJson);
// 也保存版本化副本
storageService.store("snapshots/" + siteId + "/v" + draft.getVersion() + ".json", bundleJson);
draft.setCdnUrl(cdnPath);
snapshotMapper.updateById(draft);
log.info("快照已发布到 CDN: siteId={}, path={}", siteId, cdnPath);
} catch (Exception e) {
log.error("上传快照到 CDN 失败 siteId={}: {}", siteId, e.getMessage());
// 不影响数据库状态
}
log.info("快照已发布: siteId={}, version={}", siteId, draft.getVersion());
return draft;
}
@Override
public SiteSnapshot getActive(String siteId) {
return snapshotMapper.selectLatestPublished(siteId);
}
@Override
public boolean isChanged(String siteId, String checksum) {
if (checksum == null) return true;
SiteSnapshot active = getActive(siteId);
return active == null || !checksum.equals(active.getChecksum());
}
@Override
public int publishChanged() {
var allSites = siteMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.par.core.entity.Site>()
.eq(com.par.core.entity.Site::getDeleted, 0));
int count = 0;
for (var site : allSites) {
try {
SiteSnapshot result = publish(site.getSiteId());
if (result != null) count++;
} catch (Exception e) {
log.error("发布快照失败 siteId={}: {}", site.getSiteId(), e.getMessage());
}
}
log.info("批量发布完成: {}/{} 个站点", count, allSites.size());
return count;
}
/** 将快照转为 mediabot 可用的 JSON 字符串 */
private String toBundleJson(SiteSnapshot snapshot) {
try {
Map<String, Object> bundle = new LinkedHashMap<>();
bundle.put("siteId", snapshot.getSiteId());
bundle.put("generatedAt", snapshot.getPublishedAt() != null
? snapshot.getPublishedAt().toString() : LocalDateTime.now().toString());
if (snapshot.getScript() != null) {
bundle.put("script", snapshot.getScript());
bundle.put("scriptVersion", snapshot.getScriptVersion());
bundle.put("scriptSource", snapshot.getScriptSource());
}
if (snapshot.getPages() != null) {
bundle.put("pages", om.readValue(snapshot.getPages(), new TypeReference<List<Map<String, Object>>>() {}));
bundle.put("pagesVersion", snapshot.getPagesVersion());
bundle.put("pagesSource", snapshot.getPagesSource());
}
if (snapshot.getRules() != null) {
bundle.put("riskRules", om.readValue(snapshot.getRules(), new TypeReference<List<Map<String, Object>>>() {}));
bundle.put("riskRulesVersion", snapshot.getRulesVersion());
}
return om.writeValueAsString(bundle);
} catch (Exception e) {
log.error("序列化 bundle 失败 siteId={}", snapshot.getSiteId(), e);
return "{}";
}
}
}