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 d04697a..4bd584c 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 @@ -385,6 +385,44 @@ public class AdminController { } catch (Exception e) { return ApiResponse.error(500, "发布失败: " + e.getMessage()); } } + /** 保存站点配置草稿(预览版,不上传 CDN) */ + @PostMapping("/sites/{siteId}/save-draft") + public ApiResponse> saveDraft(@PathVariable("siteId") String siteId, + HttpServletRequest request) { + checkAdmin(request); + try { + var draft = snapshotService.saveDraft(siteId); + if (draft == null) { + return ApiResponse.error(400, "该站点无有效配置,无法保存草稿"); + } + return ApiResponse.success(Map.of( + "siteId", draft.getSiteId(), + "version", draft.getVersion(), + "checksum", draft.getChecksum().substring(0, 8))); + } catch (Exception e) { + return ApiResponse.error(500, "保存草稿失败: " + e.getMessage()); + } + } + + /** 发布指定站点快照(预览版 → 正式版,上传 CDN) */ + @PostMapping("/sites/{siteId}/publish") + public ApiResponse> publishSite(@PathVariable("siteId") String siteId, + HttpServletRequest request) { + checkAdmin(request); + try { + var published = snapshotService.publish(siteId); + if (published == null) { + return ApiResponse.error(400, "该站点无 draft 快照可发布"); + } + return ApiResponse.success(Map.of( + "siteId", published.getSiteId(), + "version", published.getVersion(), + "checksum", published.getChecksum().substring(0, 8))); + } catch (Exception e) { + return ApiResponse.error(500, "发布失败: " + e.getMessage()); + } + } + @GetMapping("/sites") public ApiResponse> listAllSites(HttpServletRequest request) { checkAdmin(request); 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 index ed36218..b5bfeb1 100644 --- a/par-api/src/main/java/com/par/api/controller/SnapshotController.java +++ b/par-api/src/main/java/com/par/api/controller/SnapshotController.java @@ -34,22 +34,28 @@ public class SnapshotController { * 获取站点当前生效配置快照(供 mediabot 使用) * 支持 ETag (If-None-Match) 实现增量更新 * 优先从 Caffeine 缓存读取,缓存未命中查数据库并回退七牛云 CDN + * @param preview true=读取最新 draft(预览版),false/不传=读取最新 published(正式版) */ @GetMapping("/{siteId}") public ResponseEntity getSnapshot( @PathVariable("siteId") String siteId, - @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) { + @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch, + @RequestParam(value = "preview", required = false) boolean preview) { try { - String bundleJson = snapshotService.getActiveBundleJson(siteId); + String bundleJson = preview + ? snapshotService.getDraftBundleJson(siteId) + : snapshotService.getActiveBundleJson(siteId); if (bundleJson == null || bundleJson.isEmpty()) { - // 尝试从云存储 CDN 回退 - String cached = storageService.read("snapshots/" + siteId + ".json"); - if (cached != null && !cached.isBlank()) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_JSON) - .body(cached); + // 尝试从云存储 CDN 回退(仅正式版有 CDN 备份) + if (!preview) { + 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(); } @@ -68,16 +74,18 @@ public class SnapshotController { .body(bundleJson); } 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) {} + log.error("获取快照失败 siteId={} preview={}", siteId, preview, e); + // 数据库异常时回退到云存储(仅正式版) + if (!preview) { + 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\"}"); } } 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 index e5d3548..771f1e9 100644 --- a/par-core/src/main/java/com/par/core/service/SiteSnapshotService.java +++ b/par-core/src/main/java/com/par/core/service/SiteSnapshotService.java @@ -23,6 +23,12 @@ public interface SiteSnapshotService { */ SiteSnapshot updateSnapshot(String siteId); + /** + * 手动保存草稿快照(管理员触发,强制生成新 draft) + * @return 新快照或 null + */ + SiteSnapshot saveDraft(String siteId); + /** * 发布指定站点的快照(draft → published) * 同时上传到七牛云 CDN 作为灾备 @@ -37,10 +43,17 @@ public interface SiteSnapshotService { /** * 获取站点当前生效快照的预序列化 JSON(带 Caffeine 缓存) * 供 SnapshotController 使用,避免重复序列化 - * @return JSON 字符串,或 null 表示无快照 + * @return JSON 字符串,空串表示无快照 */ String getActiveBundleJson(String siteId); + /** + * 获取站点最新 draft 快照的预序列化 JSON(预览版,带 Caffeine 缓存) + * @param siteId 站点标识 + * @return JSON 字符串,空串表示无 draft + */ + String getDraftBundleJson(String siteId); + /** * 按 checksum 判断是否需要更新 */ 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 index 6f0f4a9..3261d31 100644 --- 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 @@ -49,6 +49,12 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { .maximumSize(500) .build(); + /** draft 快照 JSON 缓存:key=siteId, value=预序列化的 bundle JSON */ + private final Cache draftJsonCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(5)) + .maximumSize(500) + .build(); + @Override public Map buildSnapshot(String siteId) { Map snapshot = new LinkedHashMap<>(); @@ -139,27 +145,7 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { 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")); - } - + populateSnapshotFields(snapshot, data); snapshot.setChecksum(checksum); snapshot.setStatus("draft"); @@ -172,6 +158,67 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { } } + @Override + @Transactional + public SiteSnapshot saveDraft(String siteId) { + Map data = buildSnapshot(siteId); + if (data == null) { + log.warn("站点 {} 无有效配置,无法保存草稿", siteId); + return null; + } + + try { + String contentJson = om.writeValueAsString(data); + String checksum = HmacUtil.sha256(contentJson); + + // 查询最新 draft,内容相同则跳过 + SiteSnapshot latestDraft = snapshotMapper.selectLatestDraft(siteId); + if (latestDraft != null && checksum.equals(latestDraft.getChecksum())) { + log.info("站点 {} 草稿内容未变化,跳过保存", siteId); + return latestDraft; + } + + int newVersion = (latestDraft != null) ? latestDraft.getVersion() + 1 : 1; + + SiteSnapshot snapshot = new SiteSnapshot(); + snapshot.setSiteId(siteId); + snapshot.setVersion(newVersion); + populateSnapshotFields(snapshot, data); + snapshot.setChecksum(checksum); + snapshot.setStatus("draft"); + + snapshotMapper.insert(snapshot); + + // 刷新 draft 缓存 + String bundleJson = toBundleJson(snapshot); + draftJsonCache.put(siteId, bundleJson); + + log.info("草稿已保存: siteId={}, version={}, checksum={}", siteId, newVersion, checksum.substring(0, 8)); + return snapshot; + } catch (Exception e) { + log.error("保存草稿失败 siteId={}", siteId, e); + return null; + } + } + + /** 将 buildSnapshot 的数据填充到实体字段 */ + private void populateSnapshotFields(SiteSnapshot snapshot, Map data) throws Exception { + 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")); + } + } + @Override @Transactional public SiteSnapshot publish(String siteId) { @@ -196,6 +243,8 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { // 序列化并刷新缓存 String bundleJson = toBundleJson(draft); bundleJsonCache.put(siteId, bundleJson); + // 发布后 draft 缓存已过时,清除 + draftJsonCache.invalidate(siteId); // 上传到七牛云 CDN 作为灾备(复用已序列化的 JSON) try { @@ -233,6 +282,19 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { }); } + @Override + public String getDraftBundleJson(String siteId) { + return draftJsonCache.get(siteId, key -> { + SiteSnapshot draft = snapshotMapper.selectLatestDraft(key); + if (draft == null) { + log.debug("站点 {} 无 draft 快照", key); + return ""; // 空字符串表示无数据,Caffeine 会缓存 + } + log.debug("draft 缓存未命中,序列化 siteId={}", key); + return toBundleJson(draft); + }); + } + @Override public boolean isChanged(String siteId, String checksum) { if (checksum == null) return true; diff --git a/web/src/views/Sites.vue b/web/src/views/Sites.vue index aaf6ee0..a512822 100644 --- a/web/src/views/Sites.vue +++ b/web/src/views/Sites.vue @@ -56,8 +56,8 @@

站点编辑: {{ editSiteId }}

返回列表 - 保存 - 发布 + 保存草稿 + 发布正式版
@@ -107,7 +107,7 @@