重构: 引入 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

@@ -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<Map<String,Object>> 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<Map<String, Object>>) 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<String, Object> 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()); }
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> buildBundle(String siteId) {
Map<String, Object> 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<String, Object> 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<com.par.core.entity.RiskRule>()
.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<Map<String, Object>> 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;
}
}

View File

@@ -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<Map<String, Object>> checkSnapshot(@PathVariable("siteId") String siteId) {
SiteSnapshot active = snapshotService.getActive(siteId);
Map<String, Object> 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<String, Object> 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 "{}";
}
}
}