diff --git a/docs/mediabot-api-migration.md b/docs/mediabot-api-migration.md new file mode 100644 index 0000000..65f99ae --- /dev/null +++ b/docs/mediabot-api-migration.md @@ -0,0 +1,273 @@ +# PAR Server 接口升级对比文档 (供 MediaBot 迁移) + +> **版本**: V2.0(SiteSnapshot 重构) +> **日期**: 2026-07-07 +> **面向对象**: MediaBot 开发团队 + +--- + +## 一、升级概览 + +本次重构将站点配置的存储和分发方式从 **"分散存储 + 运行时合并"** 升级为 **"统一快照 + 增量更新"**。核心变化: + +| 维度 | 旧方案 (V1) | 新方案 (V2) | +|------|------------|------------| +| **存储** | 脚本/页面/规则分别存七牛云 | MySQL `site_snapshots` 表统一存储 + 七牛云 CDN 灾备 | +| **获取方式** | 多次请求分别获取脚本、页面、规则 | **一次请求获取完整 bundle** | +| **更新检测** | 无/各接口各自判断 | **统一 checksum + ETag/304** 增量更新 | +| **可用性** | 依赖七牛云 | 数据库为主 + 七牛云 CDN 自动灾备 | + +--- + +## 二、新增接口(推荐 MediaBot 迁移到这些) + +### 2.1 `GET /api/v1/snapshots/{siteId}` ⭐ 推荐 + +**用途**: 获取站点完整配置快照(一站式获取脚本 + 页面配置 + 风险规则) + +**请求头**: +``` +If-None-Match: {上次返回的 ETag checksum} +``` + +**响应 200 OK**(内容未变化时): +```json +{ + "siteId": "example.com", + "generatedAt": "2026-07-07T10:30:00", + "script": "// Groovy 脚本完整源码...", + "scriptVersion": "3", + "scriptSource": "custom", + "pages": [ + { + "pageKey": "INDEX", + "url": "https://example.com/torrents.php", + "method": "GET", + "...": "..." + } + ], + "pagesVersion": "2", + "pagesSource": "custom", + "riskRules": [ + { + "field": "title", + "operator": "contains", + "value": "xxx", + "level": "WARN" + } + ], + "riskRulesVersion": 1 +} +``` + +**响应 304 Not Modified**(内容未变化): +``` +HTTP/1.1 304 Not Modified +ETag: "abc123def456..." +``` +_(Body 为空,节省带宽)_ + +**可用性保障**: +- 优先从 MySQL 数据库读取 +- 数据库不可用时自动回退到七牛云 CDN `snapshots/{siteId}.json` +- 两层级联失败返回 500 + +--- + +### 2.2 `GET /api/v1/snapshots/{siteId}/check` ⭐ 推荐 + +**用途**: 轻量级检查站点是否有配置更新(不传输配置内容) + +**响应**: +```json +{ + "code": 200, + "data": { + "checksum": "abc123def456...", + "version": 5, + "publishedAt": "2026-07-07T10:30:00" + } +} +``` + +**使用模式**: +1. MediaBot 先调 `/check` 获取最新 `checksum` +2. 与本地缓存的 checksum 比较 +3. 不同时再调 `/snapshots/{siteId}` 拉取完整配置 + +--- + +## 三、旧接口兼容状态 + +以下接口**继续可用**,但推荐逐步迁移到新接口: + +### 3.1 `GET /api/v1/configs/{siteId}/bundle` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 继续可用,底层已委托给快照服务 | +| **变化** | 无破坏性变化,响应格式不变 | +| **建议** | 可继续使用,但建议迁移到 `GET /api/v1/snapshots/{siteId}` | + +**数据流变化**: +``` +旧: 七牛云 bundles/{siteId}.json → 返回 +新: MySQL site_snapshots → 返回 (优先) + ↓ 失败 + 七牛云 bundles/{siteId}.json / snapshots/{siteId}.json → 返回 (回退) + ↓ 失败 + 实时构建 → 返回 (兜底) +``` + +### 3.2 `GET /api/v1/configs/{siteId}/script` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化,功能一致 | +| **建议** | 如需单独获取脚本,可继续使用;否则建议用 snapshot 一次性获取 | + +### 3.3 `GET /api/v1/scripts/{siteId}` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化,功能一致 | +| **说明** | 仅返回 Groovy 脚本纯文本,无 JSON 包装 | +| **建议** | 如需单独获取脚本,可继续使用 | + +### 3.4 `GET /api/v1/scripts/{siteId}/version` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化 | +| **响应** | `{ "version": "3", "source": "custom" }` | +| **建议** | 可用 `/snapshots/{siteId}/check` 替代,后者提供更全面的版本信息 | + +### 3.5 `GET /api/v1/manifest` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化,仍支持 ETag | +| **说明** | 返回所有活跃站点配置索引(siteId、站点类型、是否有脚本/页面/规则等元信息) | +| **建议** | 继续用于获取全量站点列表和配置状态概览 | + +### 3.6 `GET /api/v1/sites/{siteId}/pages` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化 | +| **建议** | 如只需页面配置,可继续使用;否则建议用 snapshot | + +### 3.7 `GET /api/v1/risk-rules/{siteType}` — ⚠️ 保持兼容 + +| 项目 | 说明 | +|------|------| +| **状态** | 无变化,仍支持版本号增量判断 | +| **建议** | 如只需风险规则,可继续使用;否则建议用 snapshot | + +--- + +## 四、推荐 MediaBot 迁移方案 + +### 方案 A:完全迁移到 Snapshot(推荐) + +``` +MediaBot 启动 / 定时任务: + 1. GET /api/v1/manifest → 获取全量站点列表 + 2. 对每个站点: + a. GET /api/v1/snapshots/{siteId}/check → 获取最新 checksum + b. 与本地缓存的 checksum 比较 + c. 不同时: GET /api/v1/snapshots/{siteId} → 拉取完整 bundle + d. 缓存 checksum 和完整 bundle +``` + +**优点**: +- 每次只需 1~2 次 HTTP 请求即可获取站点全部配置 +- ETag/304 支持,配置未变时不传输内容 +- 三层可用性保障(数据库 → CDN → 实时构建) + +### 方案 B:渐进式迁移(兼容期) + +``` +1. 优先调用新接口 GET /api/v1/snapshots/{siteId} +2. 失败时 fallback 到旧接口组合: + - GET /api/v1/configs/{siteId}/script + - GET /api/v1/sites/{siteId}/pages + - GET /api/v1/risk-rules/{siteType} +``` + +--- + +## 五、响应格式对比 + +### 旧方式(分散获取) + +需要 **3~4 次 HTTP 请求** 才能凑齐一个站点的完整配置: + +``` +请求 1: GET /api/v1/configs/{siteId}/script → 返回 Groovy 脚本纯文本 +请求 2: GET /api/v1/sites/{siteId}/pages → 返回页面入口配置 +请求 3: GET /api/v1/risk-rules/{siteType} → 返回风险规则 +请求 4: (可选) GET /api/v1/manifest → 返回全局站点列表 +``` + +### 新方式(统一快照) + +**1~2 次 HTTP 请求** 获取站点完整配置: + +``` +请求 1: GET /api/v1/snapshots/{siteId} (带 If-None-Match) → 返回完整 bundle + - 配置未变化: 304 Not Modified (0 流量) + - 配置有变化: 200 OK + 完整 JSON + +(可选) 请求 0: GET /api/v1/manifest → 获取全量站点列表 +``` + +### Bundle JSON 响应字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `siteId` | string | 站点标识 | +| `generatedAt` | string | 快照生成时间 (ISO 8601) | +| `script` | string | Groovy 脚本完整源码 | +| `scriptVersion` | string | 脚本版本号 | +| `scriptSource` | string | `"custom"` 或 `"default"` | +| `pages` | array | 页面入口配置条目列表 | +| `pagesVersion` | string | 页面配置版本号 | +| `pagesSource` | string | `"custom"` 或 `"default"` | +| `riskRules` | array | 风险规则列表 | +| `riskRulesVersion` | integer | 风险规则版本号 | + +--- + +## 六、鉴权说明 + +**无变化**。所有配置读取接口(snapshot、manifest、script、pages、risk-rules)均为**公开接口**,无需鉴权。提交类接口需 HMAC/PSK 或 JWT 认证。 + +--- + +## 七、迁移时间线建议 + +| 阶段 | 时间 | 操作 | +|------|------|------| +| **Phase 1** | 立即 | MediaBot 开始调用 `/snapshots/{siteId}` 作为主路径,旧接口作为 fallback | +| **Phase 2** | 稳定运行 2 周后 | 移除旧接口 fallback 逻辑 | +| **Phase 3** | 后续版本 | 视情况废弃分散获取接口(script/pages/rules 独立端点) | + +--- + +## 八、FAQ + +**Q: 旧接口会下线吗?** +A: 短期内不会。但建议尽快迁移到 snapshot 接口以获得更好的性能和可用性。 + +**Q: snapshot 接口响应时间会不会更长?** +A: 不会。数据库查询是单次 SQL(按 site_id + status 索引),且应用层有 Caffeine 缓存。304 场景下完全不传输内容。 + +**Q: 如果 PAR Server 完全挂了怎么办?** +A: Snapshot 在发布时会同步上传到七牛云 CDN (`snapshots/{siteId}.json`),MediaBot 可以直接从 CDN 读取作为终极灾备。 + +**Q: checksum 是用什么算法计算的?** +A: SHA-256,对完整 bundle JSON 内容计算。 + +**Q: bundle 和 snapshot 的区别?** +A: 同一份数据。`/configs/{siteId}/bundle` 是兼容旧路径,`/snapshots/{siteId}` 是新路径,底层数据源相同。 diff --git a/par-api/src/main/java/com/par/api/ParApplication.java b/par-api/src/main/java/com/par/api/ParApplication.java index ad931ac..b576a63 100644 --- a/par-api/src/main/java/com/par/api/ParApplication.java +++ b/par-api/src/main/java/com/par/api/ParApplication.java @@ -3,9 +3,11 @@ package com.par.api; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication(scanBasePackages = {"com.par"}) @MapperScan("com.par.core.mapper") +@EnableAsync public class ParApplication { public static void main(String[] args) { 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 b75301b..d04697a 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 @@ -9,6 +9,7 @@ import com.par.core.entity.Psk; import com.par.core.entity.SiteConfig; import com.par.core.enums.ConfigStatus; import com.par.core.enums.TrustLevel; +import com.par.core.event.SnapshotUpdateEvent; import com.par.core.mapper.AccountMapper; import com.par.core.mapper.ConfigReviewMapper; import com.par.core.mapper.SiteConfigMapper; @@ -53,6 +54,7 @@ public class AdminController { private final com.par.core.service.SitePagesService sitePagesService; private final com.par.core.service.RiskRuleService riskRuleService; private final com.par.core.service.SiteSnapshotService snapshotService; + private final org.springframework.context.ApplicationEventPublisher eventPublisher; /** * 获取所有用户列表 @@ -128,8 +130,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()); } + // 发布事件,异步触发快照更新 + eventPublisher.publishEvent(new SnapshotUpdateEvent(config.getSiteId())); return ApiResponse.success(); } diff --git a/par-core/src/main/java/com/par/core/event/SnapshotUpdateEvent.java b/par-core/src/main/java/com/par/core/event/SnapshotUpdateEvent.java new file mode 100644 index 0000000..3423a01 --- /dev/null +++ b/par-core/src/main/java/com/par/core/event/SnapshotUpdateEvent.java @@ -0,0 +1,17 @@ +package com.par.core.event; + +import lombok.Getter; + +/** + * 快照更新事件 + * 当脚本、页面配置或风险规则发生变更时触发 + */ +@Getter +public class SnapshotUpdateEvent { + + private final String siteId; + + public SnapshotUpdateEvent(String siteId) { + this.siteId = siteId; + } +} diff --git a/par-core/src/main/java/com/par/core/event/SnapshotUpdateListener.java b/par-core/src/main/java/com/par/core/event/SnapshotUpdateListener.java new file mode 100644 index 0000000..12c437f --- /dev/null +++ b/par-core/src/main/java/com/par/core/event/SnapshotUpdateListener.java @@ -0,0 +1,30 @@ +package com.par.core.event; + +import com.par.core.service.SiteSnapshotService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 快照更新事件监听器 + * 异步处理,避免阻塞主流程 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SnapshotUpdateListener { + + private final SiteSnapshotService snapshotService; + + @Async + @EventListener + public void handleSnapshotUpdate(SnapshotUpdateEvent event) { + try { + snapshotService.updateSnapshot(event.getSiteId()); + } catch (Exception e) { + log.warn("快照更新失败 siteId={}: {}", event.getSiteId(), e.getMessage()); + } + } +} 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 d2b0af8..35cf07a 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 @@ -4,12 +4,13 @@ import com.par.common.util.HmacUtil; import com.par.core.dto.SiteConfigDTO; import com.par.core.entity.SiteConfig; import com.par.core.enums.ConfigStatus; +import com.par.core.event.SnapshotUpdateEvent; 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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.List; @@ -22,16 +23,16 @@ public class ConfigServiceImpl implements ConfigService { private final ManifestVersionService versionService; private final SiteConfigMapper siteConfigMapper; private final CachedStorageService storageService; - private final SiteSnapshotService snapshotService; + private final ApplicationEventPublisher eventPublisher; public ConfigServiceImpl(ManifestVersionService versionService, SiteConfigMapper siteConfigMapper, CachedStorageService storageService, - SiteSnapshotService snapshotService) { + ApplicationEventPublisher eventPublisher) { this.versionService = versionService; this.siteConfigMapper = siteConfigMapper; this.storageService = storageService; - this.snapshotService = snapshotService; + this.eventPublisher = eventPublisher; } @Override @@ -89,8 +90,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()); } + // 发布事件,异步触发快照更新 + eventPublisher.publishEvent(new SnapshotUpdateEvent(siteId)); 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 93b242a..2ee71f3 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 @@ -3,12 +3,13 @@ package com.par.core.service.impl; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.par.core.entity.RiskRule; +import com.par.core.event.SnapshotUpdateEvent; 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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.*; @@ -22,7 +23,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 final ApplicationEventPublisher eventPublisher; private static final ObjectMapper om = new ObjectMapper(); private RiskRule findByIdentifier(String identifier) { @@ -122,11 +123,10 @@ public class RiskRuleServiceImpl implements RiskRuleService { .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()); } + eventPublisher.publishEvent(new SnapshotUpdateEvent(site.getSiteId())); } } catch (Exception e) { - log.warn("批量快照更新失败 siteType={}: {}", siteType, e.getMessage()); + log.warn("批量快照更新事件发布失败 siteType={}: {}", siteType, e.getMessage()); } } 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 d3d69b6..46e6fb0 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 @@ -3,12 +3,13 @@ package com.par.core.service.impl; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.par.core.entity.SitePages; +import com.par.core.event.SnapshotUpdateEvent; 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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.*; @@ -21,7 +22,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 final ApplicationEventPublisher eventPublisher; private static final ObjectMapper om = new ObjectMapper(); @Override @@ -84,8 +85,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()); } + // 发布事件,异步触发快照更新 + eventPublisher.publishEvent(new SnapshotUpdateEvent(siteId)); 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 index 714bd4c..5614a73 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 @@ -11,7 +11,6 @@ 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; @@ -36,7 +35,6 @@ 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; @@ -52,7 +50,7 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService { String siteType = site != null && site.getSiteType() != null ? site.getSiteType().getValue() : null; // 1. 脚本 - SiteConfig latest = configService.getLatestConfig(siteId); + SiteConfig latest = siteConfigMapper.selectLatestBySiteId(siteId); if (latest != null && latest.getScriptStoragePath() != null && !latest.getScriptStoragePath().isBlank()) { try { String script = storageService.read(latest.getScriptStoragePath());