feat: 快照改为预览版/正式版两阶段发布 - 新增 saveDraft 接口手动保存草稿快照 - 新增 getDraftBundleJson 方法读取预览版(Caffeine缓存) - SnapshotController 支持 ?preview=true 参数读取 draft - 前端编辑页改为'保存草稿'+'发布正式版'两个按钮 - AdminController 新增单站点 save-draft/publish 接口 - 复用 populateSnapshotFields 消除重复代码
This commit is contained in:
@@ -385,6 +385,44 @@ public class AdminController {
|
|||||||
} catch (Exception e) { return ApiResponse.error(500, "发布失败: " + e.getMessage()); }
|
} catch (Exception e) { return ApiResponse.error(500, "发布失败: " + e.getMessage()); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存站点配置草稿(预览版,不上传 CDN) */
|
||||||
|
@PostMapping("/sites/{siteId}/save-draft")
|
||||||
|
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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")
|
@GetMapping("/sites")
|
||||||
public ApiResponse<List<com.par.core.dto.SiteDTO>> listAllSites(HttpServletRequest request) {
|
public ApiResponse<List<com.par.core.dto.SiteDTO>> listAllSites(HttpServletRequest request) {
|
||||||
checkAdmin(request);
|
checkAdmin(request);
|
||||||
|
|||||||
@@ -34,22 +34,28 @@ public class SnapshotController {
|
|||||||
* 获取站点当前生效配置快照(供 mediabot 使用)
|
* 获取站点当前生效配置快照(供 mediabot 使用)
|
||||||
* 支持 ETag (If-None-Match) 实现增量更新
|
* 支持 ETag (If-None-Match) 实现增量更新
|
||||||
* 优先从 Caffeine 缓存读取,缓存未命中查数据库并回退七牛云 CDN
|
* 优先从 Caffeine 缓存读取,缓存未命中查数据库并回退七牛云 CDN
|
||||||
|
* @param preview true=读取最新 draft(预览版),false/不传=读取最新 published(正式版)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{siteId}")
|
@GetMapping("/{siteId}")
|
||||||
public ResponseEntity<?> getSnapshot(
|
public ResponseEntity<?> getSnapshot(
|
||||||
@PathVariable("siteId") String siteId,
|
@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 {
|
try {
|
||||||
String bundleJson = snapshotService.getActiveBundleJson(siteId);
|
String bundleJson = preview
|
||||||
|
? snapshotService.getDraftBundleJson(siteId)
|
||||||
|
: snapshotService.getActiveBundleJson(siteId);
|
||||||
|
|
||||||
if (bundleJson == null || bundleJson.isEmpty()) {
|
if (bundleJson == null || bundleJson.isEmpty()) {
|
||||||
// 尝试从云存储 CDN 回退
|
// 尝试从云存储 CDN 回退(仅正式版有 CDN 备份)
|
||||||
String cached = storageService.read("snapshots/" + siteId + ".json");
|
if (!preview) {
|
||||||
if (cached != null && !cached.isBlank()) {
|
String cached = storageService.read("snapshots/" + siteId + ".json");
|
||||||
return ResponseEntity.ok()
|
if (cached != null && !cached.isBlank()) {
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
return ResponseEntity.ok()
|
||||||
.body(cached);
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(cached);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -68,16 +74,18 @@ public class SnapshotController {
|
|||||||
.body(bundleJson);
|
.body(bundleJson);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("获取快照失败 siteId={}", siteId, e);
|
log.error("获取快照失败 siteId={} preview={}", siteId, preview, e);
|
||||||
// 数据库异常时回退到云存储
|
// 数据库异常时回退到云存储(仅正式版)
|
||||||
try {
|
if (!preview) {
|
||||||
String cached = storageService.read("snapshots/" + siteId + ".json");
|
try {
|
||||||
if (cached != null && !cached.isBlank()) {
|
String cached = storageService.read("snapshots/" + siteId + ".json");
|
||||||
return ResponseEntity.ok()
|
if (cached != null && !cached.isBlank()) {
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
return ResponseEntity.ok()
|
||||||
.body(cached);
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
}
|
.body(cached);
|
||||||
} catch (Exception ignored) {}
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
return ResponseEntity.status(500).body("{\"error\":\"internal error\"}");
|
return ResponseEntity.status(500).body("{\"error\":\"internal error\"}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public interface SiteSnapshotService {
|
|||||||
*/
|
*/
|
||||||
SiteSnapshot updateSnapshot(String siteId);
|
SiteSnapshot updateSnapshot(String siteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动保存草稿快照(管理员触发,强制生成新 draft)
|
||||||
|
* @return 新快照或 null
|
||||||
|
*/
|
||||||
|
SiteSnapshot saveDraft(String siteId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布指定站点的快照(draft → published)
|
* 发布指定站点的快照(draft → published)
|
||||||
* 同时上传到七牛云 CDN 作为灾备
|
* 同时上传到七牛云 CDN 作为灾备
|
||||||
@@ -37,10 +43,17 @@ public interface SiteSnapshotService {
|
|||||||
/**
|
/**
|
||||||
* 获取站点当前生效快照的预序列化 JSON(带 Caffeine 缓存)
|
* 获取站点当前生效快照的预序列化 JSON(带 Caffeine 缓存)
|
||||||
* 供 SnapshotController 使用,避免重复序列化
|
* 供 SnapshotController 使用,避免重复序列化
|
||||||
* @return JSON 字符串,或 null 表示无快照
|
* @return JSON 字符串,空串表示无快照
|
||||||
*/
|
*/
|
||||||
String getActiveBundleJson(String siteId);
|
String getActiveBundleJson(String siteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取站点最新 draft 快照的预序列化 JSON(预览版,带 Caffeine 缓存)
|
||||||
|
* @param siteId 站点标识
|
||||||
|
* @return JSON 字符串,空串表示无 draft
|
||||||
|
*/
|
||||||
|
String getDraftBundleJson(String siteId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 checksum 判断是否需要更新
|
* 按 checksum 判断是否需要更新
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService {
|
|||||||
.maximumSize(500)
|
.maximumSize(500)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
/** draft 快照 JSON 缓存:key=siteId, value=预序列化的 bundle JSON */
|
||||||
|
private final Cache<String, String> draftJsonCache = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(Duration.ofMinutes(5))
|
||||||
|
.maximumSize(500)
|
||||||
|
.build();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> buildSnapshot(String siteId) {
|
public Map<String, Object> buildSnapshot(String siteId) {
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
@@ -139,27 +145,7 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService {
|
|||||||
SiteSnapshot snapshot = new SiteSnapshot();
|
SiteSnapshot snapshot = new SiteSnapshot();
|
||||||
snapshot.setSiteId(siteId);
|
snapshot.setSiteId(siteId);
|
||||||
snapshot.setVersion(newVersion);
|
snapshot.setVersion(newVersion);
|
||||||
|
populateSnapshotFields(snapshot, data);
|
||||||
// 脚本
|
|
||||||
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.setChecksum(checksum);
|
||||||
snapshot.setStatus("draft");
|
snapshot.setStatus("draft");
|
||||||
|
|
||||||
@@ -172,6 +158,67 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SiteSnapshot saveDraft(String siteId) {
|
||||||
|
Map<String, Object> 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<String, Object> 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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public SiteSnapshot publish(String siteId) {
|
public SiteSnapshot publish(String siteId) {
|
||||||
@@ -196,6 +243,8 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService {
|
|||||||
// 序列化并刷新缓存
|
// 序列化并刷新缓存
|
||||||
String bundleJson = toBundleJson(draft);
|
String bundleJson = toBundleJson(draft);
|
||||||
bundleJsonCache.put(siteId, bundleJson);
|
bundleJsonCache.put(siteId, bundleJson);
|
||||||
|
// 发布后 draft 缓存已过时,清除
|
||||||
|
draftJsonCache.invalidate(siteId);
|
||||||
|
|
||||||
// 上传到七牛云 CDN 作为灾备(复用已序列化的 JSON)
|
// 上传到七牛云 CDN 作为灾备(复用已序列化的 JSON)
|
||||||
try {
|
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
|
@Override
|
||||||
public boolean isChanged(String siteId, String checksum) {
|
public boolean isChanged(String siteId, String checksum) {
|
||||||
if (checksum == null) return true;
|
if (checksum == null) return true;
|
||||||
|
|||||||
@@ -56,8 +56,8 @@
|
|||||||
<h2 style="margin:0">站点编辑: {{ editSiteId }}</h2>
|
<h2 style="margin:0">站点编辑: {{ editSiteId }}</h2>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<el-button size="small" @click="editSiteId=''">返回列表</el-button>
|
<el-button size="small" @click="editSiteId=''">返回列表</el-button>
|
||||||
<el-button size="small" type="primary" @click="saveSite" :loading="saving">保存</el-button>
|
<el-button size="small" @click="saveDraft" :loading="savingDraft">保存草稿</el-button>
|
||||||
<el-button size="small" @click="publishAll" :loading="publishing">发布</el-button>
|
<el-button size="small" type="success" @click="publishSite" :loading="publishing">发布正式版</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
const loading = ref(false), saving = ref(false), publishing = ref(false), pendingCount = ref(0)
|
const loading = ref(false), saving = ref(false), savingDraft = ref(false), publishing = ref(false), pendingCount = ref(0)
|
||||||
const sites = ref([]), editSiteId = ref(''), editorJson = ref('{}'), jsonError = ref(false)
|
const sites = ref([]), editSiteId = ref(''), editorJson = ref('{}'), jsonError = ref(false)
|
||||||
const form = reactive({ siteId:'', namesStr:'', urlsStr:'', tagsStr:'', desc:'', siteType:'nexusphp', status:'active' })
|
const form = reactive({ siteId:'', namesStr:'', urlsStr:'', tagsStr:'', desc:'', siteType:'nexusphp', status:'active' })
|
||||||
let syncing = false
|
let syncing = false
|
||||||
@@ -154,11 +154,20 @@ const newSite = () => {
|
|||||||
editorJson.value = JSON.stringify({siteId:'',names:[''],urls:[''],siteType:'nexusphp',status:'active'}, null, 2)
|
editorJson.value = JSON.stringify({siteId:'',names:[''],urls:[''],siteType:'nexusphp',status:'active'}, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishAll = async () => {
|
const saveDraft = async () => {
|
||||||
|
savingDraft.value = true
|
||||||
|
try {
|
||||||
|
await api.raw.post('/admin/sites/' + editSiteId.value + '/save-draft')
|
||||||
|
ElMessage.success('草稿已保存(预览版)')
|
||||||
|
} catch { ElMessage.error('保存草稿失败') }
|
||||||
|
finally { savingDraft.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishSite = async () => {
|
||||||
publishing.value = true
|
publishing.value = true
|
||||||
try {
|
try {
|
||||||
await api.raw.post('/admin/sites/publish')
|
await api.raw.post('/admin/sites/' + editSiteId.value + '/publish')
|
||||||
ElMessage.success('已发布到云存储')
|
ElMessage.success('已发布为正式版')
|
||||||
} catch { ElMessage.error('发布失败') }
|
} catch { ElMessage.error('发布失败') }
|
||||||
finally { publishing.value = false }
|
finally { publishing.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user