From b25aa82c731b29aecd105f2bbca09e8620759e39 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:09:52 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=BC=95=E5=85=A5=20site?= =?UTF-8?q?=5Fsnapshots=20=E8=A1=A8=E7=BB=9F=E4=B8=80=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BF=AB=E7=85=A7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 site_snapshots 表 (V16 迁移),统一存储脚本+页面+规则的合并快照 - 新增 SiteSnapshot 实体、Mapper、Service - 配置变更时自动触发快照更新 (checksum 去重) - 新增 SnapshotController 提供 /api/v1/snapshots/{siteId} 端点,支持 ETag/304 - 重构 publishManifest 使用快照服务,只发布变更站点 - 保留七牛云 CDN 作为灾备层 - 兼容旧 /bundle 和 /bundles/ 路径 --- .../par/api/controller/AdminController.java | 37 ++- .../par/api/controller/ConfigController.java | 119 +++----- .../api/controller/SnapshotController.java | 137 +++++++++ .../com/par/core/entity/SiteSnapshot.java | 78 +++++ .../par/core/mapper/SiteSnapshotMapper.java | 22 ++ .../par/core/service/SiteSnapshotService.java | 47 +++ .../core/service/impl/ConfigServiceImpl.java | 8 +- .../service/impl/RiskRuleServiceImpl.java | 20 ++ .../service/impl/SitePagesServiceImpl.java | 4 + .../service/impl/SiteSnapshotServiceImpl.java | 269 ++++++++++++++++++ .../db/migration/V16__site_snapshots.sql | 39 +++ 11 files changed, 680 insertions(+), 100 deletions(-) create mode 100644 par-api/src/main/java/com/par/api/controller/SnapshotController.java create mode 100644 par-core/src/main/java/com/par/core/entity/SiteSnapshot.java create mode 100644 par-core/src/main/java/com/par/core/mapper/SiteSnapshotMapper.java create mode 100644 par-core/src/main/java/com/par/core/service/SiteSnapshotService.java create mode 100644 par-core/src/main/java/com/par/core/service/impl/SiteSnapshotServiceImpl.java create mode 100644 par-core/src/main/resources/db/migration/V16__site_snapshots.sql diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index e07ecee..b75301b 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -52,7 +52,7 @@ public class AdminController { private final com.par.core.mapper.RiskRuleMapper riskRuleMapper; private final com.par.core.service.SitePagesService sitePagesService; private final com.par.core.service.RiskRuleService riskRuleService; - private final ConfigController configController; + private final com.par.core.service.SiteSnapshotService snapshotService; /** * 获取所有用户列表 @@ -128,6 +128,8 @@ public class AdminController { } log.info("Config approved: id={}, reviewer={}", reviewId, reviewerId); + // 触发快照更新 + try { snapshotService.updateSnapshot(config.getSiteId()); } catch (Exception e) { log.warn("快照更新失败 siteId={}: {}", config.getSiteId(), e.getMessage()); } return ApiResponse.success(); } @@ -338,39 +340,46 @@ public class AdminController { // ===================== 站点审核 ===================== - /** 发布站点配置到七牛云(生成 manifest/manifest.json + 各站点 bundles/) */ + /** 发布站点配置到七牛云(生成 manifest + 快照发布 + 兼容旧 bundles/ 路径) */ @PostMapping("/sites/publish") public ApiResponse> publishManifest(HttpServletRequest request) { checkAdmin(request); try { - // 1. 生成并发布 manifest + // 1. 生成并发布 manifest(保持兼容) var manifest = buildManifest(); String json = new com.fasterxml.jackson.databind.ObjectMapper() .writerWithDefaultPrettyPrinter().writeValueAsString(manifest); storageService.store("manifest/manifest.json", json); log.info("Manifest published: {} sites", ((List)manifest.get("sites")).size()); - // 2. 为每个站点生成合并后的 bundle 文件,保证脚本+页面版本一致 + // 2. 使用快照服务发布有变更的站点 + int publishedCount = snapshotService.publishChanged(); + + // 3. 兼容:为所有站点生成旧的 bundles/ 路径(从快照读取) + int bundleCount = 0; @SuppressWarnings("unchecked") var sites = (List>) manifest.get("sites"); - int bundleCount = 0; for (var site : sites) { String siteId = (String) site.get("siteId"); try { - var bundle = configController.buildBundle(siteId); - if (bundle != null) { - String bundleJson = new com.fasterxml.jackson.databind.ObjectMapper() - .writeValueAsString(bundle); - storageService.store("bundles/" + siteId + ".json", bundleJson); - bundleCount++; + var active = snapshotService.getActive(siteId); + if (active != null) { + // 生成兼容的 bundle JSON 并上传到旧路径 + Map bundle = snapshotService.buildSnapshot(siteId); + if (bundle != null) { + String bundleJson = new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(bundle); + storageService.store("bundles/" + siteId + ".json", bundleJson); + bundleCount++; + } } } catch (Exception e) { - log.warn("生成 bundle 失败 siteId={}: {}", siteId, e.getMessage()); + log.warn("生成兼容 bundle 失败 siteId={}: {}", siteId, e.getMessage()); } } - log.info("Bundles generated: {} sites", bundleCount); + log.info("发布完成: {} sites published, {} bundles generated", publishedCount, bundleCount); return ApiResponse.success(Map.of("version", manifest.get("version"), - "sites", sites.size(), "bundles", bundleCount)); + "sites", sites.size(), "published", publishedCount, "bundles", bundleCount)); } catch (Exception e) { return ApiResponse.error(500, "发布失败: " + e.getMessage()); } } diff --git a/par-api/src/main/java/com/par/api/controller/ConfigController.java b/par-api/src/main/java/com/par/api/controller/ConfigController.java index c44e815..915a9a5 100644 --- a/par-api/src/main/java/com/par/api/controller/ConfigController.java +++ b/par-api/src/main/java/com/par/api/controller/ConfigController.java @@ -5,9 +5,10 @@ import com.par.core.dto.ApiResponse; import com.par.core.dto.SiteConfigDTO; import com.par.core.entity.SiteConfig; import com.par.core.adapter.GroovyScriptEngine; +import com.par.core.entity.SiteSnapshot; import com.par.core.mapper.SiteMapper; 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 jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -16,7 +17,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -32,18 +32,15 @@ public class ConfigController { private final ConfigService configService; private final CachedStorageService storageService; private final SiteMapper siteMapper; - private final SitePagesService sitePagesService; - private final com.par.core.mapper.RiskRuleMapper riskRuleMapper; + private final SiteSnapshotService snapshotService; private static final ObjectMapper objectMapper = new ObjectMapper(); public ConfigController(ConfigService configService, CachedStorageService storageService, - SiteMapper siteMapper, SitePagesService sitePagesService, - com.par.core.mapper.RiskRuleMapper riskRuleMapper) { + SiteMapper siteMapper, SiteSnapshotService snapshotService) { this.configService = configService; this.storageService = storageService; this.siteMapper = siteMapper; - this.sitePagesService = sitePagesService; - this.riskRuleMapper = riskRuleMapper; + this.snapshotService = snapshotService; } /** @@ -151,25 +148,41 @@ public class ConfigController { } /** - * 下载合并配置包(供 mediabot 使用) - * 返回 JSON 包含:脚本、页面配置、风险规则,保证版本一致性 - * 优先从云存储读取已发布的 bundle,无则实时合并 + * 下载合并配置包(供 mediabot 使用)— 兼容旧路径 + * 委托给快照服务,优先从数据库读取,回退到云存储 */ @GetMapping("/{siteId}/bundle") public ResponseEntity downloadBundle(@PathVariable("siteId") String siteId) { - // 1. 尝试从云存储读取已发布的 bundle - String bundlePath = "bundles/" + siteId + ".json"; + // 1. 尝试从数据库读取最新 published 快照 try { - String cached = storageService.read(bundlePath); - if (cached != null && !cached.isBlank()) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(cached); + SiteSnapshot active = snapshotService.getActive(siteId); + if (active != null) { + Map bundle = snapshotService.buildSnapshot(siteId); + if (bundle != null) { + String json = objectMapper.writeValueAsString(bundle); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(json); + } } - } catch (Exception ignored) {} + } catch (Exception e) { + log.warn("从数据库读取快照失败 siteId={}: {}", siteId, e.getMessage()); + } - // 2. 实时合并(兜底) - Map bundle = buildBundle(siteId); + // 2. 回退到云存储(旧 bundles/ 路径 + 新 snapshots/ 路径) + for (String path : new String[]{"bundles/" + siteId + ".json", "snapshots/" + siteId + ".json"}) { + try { + String cached = storageService.read(path); + if (cached != null && !cached.isBlank()) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(cached); + } + } catch (Exception ignored) {} + } + + // 3. 实时构建(兜底) + Map bundle = snapshotService.buildSnapshot(siteId); if (bundle == null) return ResponseEntity.notFound().build(); try { @@ -182,68 +195,4 @@ public class ConfigController { return ResponseEntity.status(500).body("{\"error\":\"serialization failed\"}"); } } - - /** - * 实时构建站点配置包(脚本 + 页面配置 + 风险规则) - */ - Map buildBundle(String siteId) { - Map bundle = new LinkedHashMap<>(); - bundle.put("siteId", siteId); - bundle.put("generatedAt", java.time.LocalDateTime.now().toString()); - - // 脚本 - var site = siteMapper.selectBySiteId(siteId); - String siteType = site != null && site.getSiteType() != null ? site.getSiteType().getValue() : null; - - 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()) { - bundle.put("script", script); - bundle.put("scriptVersion", latest.getVersion()); - bundle.put("scriptSource", "custom"); - } - } catch (Exception e) { log.warn("读取脚本失败 siteId={}: {}", siteId, e.getMessage()); } - } - if (!bundle.containsKey("script") && siteType != null) { - String defScript = GroovyScriptEngine.loadDefaultByType(siteType); - if (defScript != null) { - bundle.put("script", defScript); - bundle.put("scriptSource", "default"); - } - } - - // 页面配置(优先自定义,回退默认模板) - Map pages = sitePagesService.getPages(siteId); - if (pages == null && siteType != null) { - pages = sitePagesService.getPages("default:" + siteType); - } - if (pages != null) { - bundle.put("pages", pages.get("entries")); - bundle.put("pagesVersion", pages.get("version")); - bundle.put("pagesSource", pages.containsKey("source") ? pages.get("source") : "custom"); - } - - // 风险规则(通过 siteType 查找) - if (siteType != null) { - var rules = riskRuleMapper.selectList( - new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(com.par.core.entity.RiskRule::getSiteType, siteType) - .eq(com.par.core.entity.RiskRule::getDeleted, 0) - .orderByDesc(com.par.core.entity.RiskRule::getId)); - if (!rules.isEmpty()) { - var latestRule = rules.get(0); - try { - @SuppressWarnings("unchecked") - List> rulesList = objectMapper.readValue( - latestRule.getRulesJson(), List.class); - bundle.put("riskRules", rulesList); - bundle.put("riskRulesVersion", latestRule.getVersion()); - } catch (Exception ignored) {} - } - } - - return bundle.containsKey("script") ? bundle : null; - } } diff --git a/par-api/src/main/java/com/par/api/controller/SnapshotController.java b/par-api/src/main/java/com/par/api/controller/SnapshotController.java new file mode 100644 index 0000000..5afbd27 --- /dev/null +++ b/par-api/src/main/java/com/par/api/controller/SnapshotController.java @@ -0,0 +1,137 @@ +package com.par.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.par.core.dto.ApiResponse; +import com.par.core.entity.SiteSnapshot; +import com.par.core.service.SiteSnapshotService; +import com.par.core.storage.CachedStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 站点快照控制器 + * 为 mediabot 提供统一的站点配置获取接口 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/snapshots") +@RequiredArgsConstructor +public class SnapshotController { + + private final SiteSnapshotService snapshotService; + private final CachedStorageService storageService; + private static final ObjectMapper om = new ObjectMapper(); + + /** + * 获取站点当前生效配置快照(供 mediabot 使用) + * 支持 ETag (If-None-Match) 实现增量更新 + * 优先从数据库读取,数据库不可用时回退到七牛云 CDN + */ + @GetMapping("/{siteId}") + public ResponseEntity getSnapshot( + @PathVariable("siteId") String siteId, + @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) { + + try { + SiteSnapshot active = snapshotService.getActive(siteId); + + if (active == null) { + // 尝试从云存储 CDN 回退 + String cached = storageService.read("snapshots/" + siteId + ".json"); + if (cached != null && !cached.isBlank()) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(cached); + } + return ResponseEntity.notFound().build(); + } + + // ETag 检查 + if (ifNoneMatch != null && ifNoneMatch.equals(active.getChecksum())) { + return ResponseEntity.status(304) + .header(HttpHeaders.ETAG, active.getChecksum()) + .build(); + } + + String json = toJson(active); + return ResponseEntity.ok() + .header(HttpHeaders.ETAG, active.getChecksum()) + .contentType(MediaType.APPLICATION_JSON) + .body(json); + + } catch (Exception e) { + log.error("获取快照失败 siteId={}", siteId, e); + // 数据库异常时回退到云存储 + try { + String cached = storageService.read("snapshots/" + siteId + ".json"); + if (cached != null && !cached.isBlank()) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(cached); + } + } catch (Exception ignored) {} + return ResponseEntity.status(500).body("{\"error\":\"internal error\"}"); + } + } + + /** + * 检查快照是否有更新 + */ + @GetMapping("/{siteId}/check") + public ApiResponse> checkSnapshot(@PathVariable("siteId") String siteId) { + SiteSnapshot active = snapshotService.getActive(siteId); + Map result = new LinkedHashMap<>(); + if (active != null) { + result.put("checksum", active.getChecksum()); + result.put("version", active.getVersion()); + result.put("publishedAt", active.getPublishedAt() != null + ? active.getPublishedAt().toString() : null); + } else { + result.put("checksum", null); + } + return ApiResponse.success(result); + } + + private String toJson(SiteSnapshot snapshot) { + try { + Map bundle = new LinkedHashMap<>(); + bundle.put("siteId", snapshot.getSiteId()); + bundle.put("generatedAt", snapshot.getPublishedAt() != null + ? snapshot.getPublishedAt().toString() + : java.time.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) { + @SuppressWarnings("unchecked") + var pages = om.readValue(snapshot.getPages(), java.util.List.class); + bundle.put("pages", pages); + bundle.put("pagesVersion", snapshot.getPagesVersion()); + bundle.put("pagesSource", snapshot.getPagesSource()); + } + + if (snapshot.getRules() != null) { + @SuppressWarnings("unchecked") + var rules = om.readValue(snapshot.getRules(), java.util.List.class); + bundle.put("riskRules", rules); + bundle.put("riskRulesVersion", snapshot.getRulesVersion()); + } + + return om.writeValueAsString(bundle); + } catch (Exception e) { + log.error("序列化快照失败 siteId={}", snapshot.getSiteId(), e); + return "{}"; + } + } +} diff --git a/par-core/src/main/java/com/par/core/entity/SiteSnapshot.java b/par-core/src/main/java/com/par/core/entity/SiteSnapshot.java new file mode 100644 index 0000000..f741630 --- /dev/null +++ b/par-core/src/main/java/com/par/core/entity/SiteSnapshot.java @@ -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; +} diff --git a/par-core/src/main/java/com/par/core/mapper/SiteSnapshotMapper.java b/par-core/src/main/java/com/par/core/mapper/SiteSnapshotMapper.java new file mode 100644 index 0000000..aa4e1da --- /dev/null +++ b/par-core/src/main/java/com/par/core/mapper/SiteSnapshotMapper.java @@ -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 { + + /** 查询站点最新的 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); +} diff --git a/par-core/src/main/java/com/par/core/service/SiteSnapshotService.java b/par-core/src/main/java/com/par/core/service/SiteSnapshotService.java new file mode 100644 index 0000000..af2712b --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/SiteSnapshotService.java @@ -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 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(); +} diff --git a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java index 22cc1a7..d2b0af8 100644 --- a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java @@ -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; } diff --git a/par-core/src/main/java/com/par/core/service/impl/RiskRuleServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/RiskRuleServiceImpl.java index cb7fce7..93b242a 100644 --- a/par-core/src/main/java/com/par/core/service/impl/RiskRuleServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/RiskRuleServiceImpl.java @@ -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() .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 diff --git a/par-core/src/main/java/com/par/core/service/impl/SitePagesServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/SitePagesServiceImpl.java index b6e8910..d3d69b6 100644 --- a/par-core/src/main/java/com/par/core/service/impl/SitePagesServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/SitePagesServiceImpl.java @@ -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); diff --git a/par-core/src/main/java/com/par/core/service/impl/SiteSnapshotServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/SiteSnapshotServiceImpl.java new file mode 100644 index 0000000..714bd4c --- /dev/null +++ b/par-core/src/main/java/com/par/core/service/impl/SiteSnapshotServiceImpl.java @@ -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 buildSnapshot(String siteId) { + Map 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 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() + .eq(RiskRule::getSiteType, siteType) + .eq(RiskRule::getDeleted, 0) + .orderByDesc(RiskRule::getId)); + if (!rules.isEmpty()) { + var latestRule = rules.get(0); + try { + List> rulesList = om.readValue(latestRule.getRulesJson(), + new TypeReference>>() {}); + 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 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() + .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 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>>() {})); + bundle.put("pagesVersion", snapshot.getPagesVersion()); + bundle.put("pagesSource", snapshot.getPagesSource()); + } + + if (snapshot.getRules() != null) { + bundle.put("riskRules", om.readValue(snapshot.getRules(), new TypeReference>>() {})); + bundle.put("riskRulesVersion", snapshot.getRulesVersion()); + } + + return om.writeValueAsString(bundle); + } catch (Exception e) { + log.error("序列化 bundle 失败 siteId={}", snapshot.getSiteId(), e); + return "{}"; + } + } +} diff --git a/par-core/src/main/resources/db/migration/V16__site_snapshots.sql b/par-core/src/main/resources/db/migration/V16__site_snapshots.sql new file mode 100644 index 0000000..dfe1de0 --- /dev/null +++ b/par-core/src/main/resources/db/migration/V16__site_snapshots.sql @@ -0,0 +1,39 @@ +-- V16: 站点配置快照表 +-- 统一存储站点的脚本 + 页面配置 + 风险规则合并快照 +-- 替代运行时 buildBundle 拼接,支持版本管理和增量发布 +CREATE TABLE site_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + site_id VARCHAR(100) NOT NULL COMMENT '站点标识', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '快照版本号(统一整数)', + + -- 配置内容 + script MEDIUMTEXT COMMENT 'Groovy 脚本源码', + script_version VARCHAR(20) COMMENT '脚本来源版本号', + script_source VARCHAR(20) COMMENT '脚本来源: custom/default', + + pages JSON COMMENT '页面配置条目列表', + pages_version VARCHAR(20) COMMENT '页面配置版本号', + pages_source VARCHAR(20) COMMENT '页面来源: custom/default', + + rules JSON COMMENT '风险规则列表', + rules_version INT UNSIGNED COMMENT '风险规则版本号', + + -- 完整性校验 + checksum VARCHAR(64) NOT NULL COMMENT 'SHA-256(content) 用于增量检测', + + -- 状态管理 + status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft / published', + + -- 云存储 CDN 路径(可选,publish 时写入) + cdn_url VARCHAR(500) COMMENT '七牛云 CDN 地址', + + -- 审计 + published_at DATETIME COMMENT '发布时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (id), + UNIQUE KEY uk_site_version (site_id, version), + INDEX idx_site_status (site_id, status), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点配置快照表';