重构: 引入 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:
@@ -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()); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user