Files
par/docs/par-system-design.html

1912 lines
107 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- Generated by Trae Work -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAR 系统设计文档 — PT Adapter Registry</title>
<style>
@font-face {
font-family: 'WorkSans';
src: url('./_shared/fonts/WorkSans-Regular.ttf') format('truetype');
font-weight: 400;
}
@font-face {
font-family: 'WorkSans';
src: url('./_shared/fonts/WorkSans-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'JetBrainsMono';
src: url('./_shared/fonts/JetBrainsMono-Regular.ttf') format('truetype');
font-weight: 400;
}
</style>
<style>
:root {
--bg: #fafafa;
--bg2: #f0f1f3;
--bg3: #e8eaed;
--ink: #1a1d23;
--muted: #5f6672;
--rule: #d1d5db;
--accent: #0d6efd;
--accent2: #7c3aed;
--green: #16a34a;
--orange: #ea580c;
--red: #dc2626;
--font: 'WorkSans', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrainsMono', 'Consolas', 'Courier New', monospace;
--max: 960px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font);
color: var(--ink);
background: var(--bg);
line-height: 1.75;
padding: 2rem 1rem;
}
article.page {
max-width: var(--max);
margin: 0 auto;
}
/* === Header === */
.doc-header {
text-align: center;
padding: 4rem 1rem 3rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 3rem;
}
.doc-header .badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
background: var(--bg2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 0.2em 0.8em;
margin-bottom: 1.2rem;
}
.doc-header h1 {
font-size: 2.4rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 0.6rem;
}
.doc-header .subtitle {
font-size: 1.1rem;
color: var(--muted);
margin-bottom: 1.5rem;
}
.doc-header .meta {
font-size: 0.85rem;
color: var(--muted);
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.doc-header .meta span { display: flex; align-items: center; gap: 0.3rem; }
/* === TOC === */
.toc {
background: var(--bg2);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 1.5rem 2rem;
margin-bottom: 3rem;
}
.toc h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.8rem;
}
.toc ol {
list-style: none;
counter-reset: toc-counter;
}
.toc ol > li {
counter-increment: toc-counter;
margin-bottom: 0.5rem;
}
.toc ol > li::before {
content: counter(toc-counter) ".";
color: var(--accent);
font-weight: 700;
margin-right: 0.5rem;
min-width: 1.5em;
display: inline-block;
}
.toc a {
color: var(--ink);
text-decoration: none;
}
.toc a:hover { text-decoration: underline; }
.toc .sub { margin-left: 2rem; margin-top: 0.3rem; }
.toc .sub li::before { content: "-"; color: var(--muted); font-weight: 400; }
/* === Sections === */
section { margin-bottom: 2.5rem; }
h2 {
font-size: 1.6rem;
font-weight: 700;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--accent);
margin-bottom: 1.2rem;
}
h3 {
font-size: 1.2rem;
font-weight: 700;
color: var(--ink);
margin: 1.8rem 0 0.8rem;
padding-left: 0.5rem;
border-left: 3px solid var(--accent);
}
h4 {
font-size: 1rem;
font-weight: 700;
margin: 1.2rem 0 0.6rem;
color: var(--muted);
}
p { margin-bottom: 0.8rem; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* === Lists === */
ul, ol { margin: 0.5rem 0 1rem 1.5rem; }
li { margin-bottom: 0.4rem; }
/* === Tables === */
.table-wrap {
overflow-x: auto;
overflow-y: auto;
max-height: 600px;
margin: 1rem 0 1.5rem;
border: 1px solid var(--rule);
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
min-width: 600px;
}
thead {
position: sticky;
top: 0;
z-index: 2;
}
th {
background: var(--bg3);
font-weight: 700;
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 2px solid var(--rule);
white-space: nowrap;
}
td {
padding: 0.5rem 0.8rem;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
tbody tr:hover { background: var(--bg2); }
td code, th code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg2);
padding: 0.15em 0.4em;
border-radius: 3px;
}
/* === Code blocks === */
pre {
background: #1e1e2e;
color: #cdd6f4;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.5;
padding: 1.2rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0 1.5rem;
white-space: pre;
tab-size: 2;
}
pre .comment { color: #6c7086; font-style: italic; }
pre .keyword { color: #cba6f7; }
pre .string { color: #a6e3a1; }
pre .type { color: #89b4fa; }
pre .func { color: #f9e2af; }
pre .number { color: #fab387; }
pre .accent { color: var(--accent); }
code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg2);
padding: 0.15em 0.4em;
border-radius: 3px;
}
pre code {
background: none;
padding: 0;
border-radius: 0;
}
/* === Callouts === */
.callout {
border-radius: 6px;
padding: 1rem 1.2rem;
margin: 1rem 0 1.5rem;
font-size: 0.92rem;
}
.callout-info {
background: #eff6ff;
border-left: 4px solid var(--accent);
}
.callout-warn {
background: #fffbeb;
border-left: 4px solid var(--orange);
}
.callout-tip {
background: #f0fdf4;
border-left: 4px solid var(--green);
}
.callout-title {
font-weight: 700;
margin-bottom: 0.3rem;
font-size: 0.9rem;
}
.callout p:last-child { margin-bottom: 0; }
/* === Diagrams === */
.diagram { margin: 1.5rem 0; text-align: center; }
.diagram figcaption {
font-size: 0.82rem;
color: var(--muted);
margin-top: 0.5rem;
}
pre.mermaid {
background: var(--bg);
border: 1px solid var(--rule);
color: var(--ink);
padding: 1rem;
}
/* === Inline labels === */
.label {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15em 0.5em;
border-radius: 3px;
vertical-align: middle;
}
.label-get { background: #dbeafe; color: #1d4ed8; }
.label-post { background: #dcfce7; color: #15803d; }
.label-patch { background: #fef3c7; color: #92400e; }
.label-del { background: #fee2e2; color: #991b1b; }
.label-public { background: #e0e7ff; color: #4338ca; }
.label-auth { background: #fce7f3; color: #9d174d; }
/* === API card === */
.api-card {
background: var(--bg2);
border: 1px solid var(--rule);
border-radius: 8px;
margin: 1rem 0 1.5rem;
overflow: hidden;
}
.api-card-header {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--rule);
background: var(--bg3);
}
.api-method {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 700;
padding: 0.15em 0.5em;
border-radius: 3px;
}
.api-method-get { background: #dbeafe; color: #1d4ed8; }
.api-method-post { background: #dcfce7; color: #15803d; }
.api-method-patch { background: #fef3c7; color: #92400e; }
.api-method-delete { background: #fee2e2; color: #991b1b; }
.api-path {
font-family: var(--font-mono);
font-size: 0.9rem;
}
.api-card-body { padding: 1rem; }
.api-card-body h4 { margin-top: 0; color: var(--ink); font-size: 0.9rem; }
/* === SQL block === */
.sql-block {
background: #f8fafc;
border: 1px solid var(--rule);
border-radius: 6px;
margin: 0.8rem 0 1.2rem;
overflow: hidden;
}
.sql-block-header {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--rule);
background: var(--bg2);
}
.sql-block pre {
margin: 0;
border-radius: 0;
background: #1e1e2e;
font-size: 0.82rem;
line-height: 1.45;
max-height: 500px;
}
/* === Phase markers === */
.phase-tag {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.1em 0.6em;
border-radius: 3px;
margin-right: 0.4rem;
vertical-align: middle;
}
.phase-p1 { background: #dcfce7; color: #15803d; }
.phase-p2 { background: #fef3c7; color: #92400e; }
/* === Phase section wrapper === */
.phase-section-p2 {
opacity: 0.65;
border-left: 3px solid #fef3c7;
padding-left: 1rem;
margin-left: 0.5rem;
}
.phase-section-p2:hover { opacity: 1; }
/* === Flow diagram (text-based) === */
.flow-box {
display: flex;
align-items: center;
gap: 0;
margin: 1rem 0;
flex-wrap: wrap;
justify-content: center;
}
.flow-item {
background: var(--bg2);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
text-align: center;
min-width: 100px;
}
.flow-item.primary {
background: #dbeafe;
border-color: var(--accent);
font-weight: 600;
}
.flow-arrow {
color: var(--muted);
font-size: 1.2rem;
padding: 0 0.3rem;
}
/* === Footer === */
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--rule);
font-size: 0.85rem;
color: var(--muted);
}
/* === Responsive === */
@media (max-width: 768px) {
.doc-header h1 { font-size: 1.8rem; }
.toc { padding: 1rem; }
table { min-width: 500px; }
}
@media (max-width: 600px) {
body { padding: 1rem 0.5rem; }
.doc-header { padding: 2rem 0.5rem 1.5rem; }
table { min-width: 400px; }
}
@media print {
.toc, .callout, .api-card { break-inside: avoid; }
pre { max-height: none; }
}
</style>
</head>
<body>
<article class="page">
<!-- ========== HEADER ========== -->
<header class="doc-header">
<div class="badge">System Design Document</div>
<h1>PAR — PT Adapter Registry</h1>
<p class="subtitle">PT 站点适配配置的版本化注册中心,负责配置的存储、校验、分发和生命周期管理</p>
<div class="meta">
<span>版本: 2.0</span>
<span>日期: 2026-06-29</span>
<span>状态: 最终设计定稿</span>
</div>
</header>
<!-- ========== TOC ========== -->
<nav class="toc">
<h2>目录</h2>
<ol>
<li><a href="#sec1">系统定位与设计目标</a></li>
<li><a href="#sec2">分阶段实施计划</a></li>
<li><a href="#sec3">整体架构</a></li>
<li><a href="#sec4">配置 Schema 设计</a></li>
<li><a href="#sec5">API 设计</a></li>
<li><a href="#sec6">数据库设计</a></li>
<li><a href="#sec7">分发可靠性</a></li>
<li><a href="#sec8">质量与信任体系</a></li>
<li><a href="#sec9">引擎模板系统</a> <span class="phase-tag phase-p2">P2</span></li>
<li><a href="#sec10">前端设计</a></li>
<li><a href="#sec11">认证、HMAC 签名与安全设计</a></li>
<li><a href="#sec12">匿名统计与注册关联</a></li>
<li><a href="#sec13">站点元信息版本化</a></li>
<li><a href="#sec14">Schema 演进与站点生命周期</a></li>
<li><a href="#sec15">通知、限流与监控</a></li>
</ol>
</nav>
<main>
<!-- ========== 1. 系统定位与设计目标 ========== -->
<section id="sec1">
<h2>1. 系统定位与设计目标</h2>
<p><strong>PAR(PT Adapter Registry)</strong>是一个面向 PT 自动化工具生态的站点适配配置注册中心,以"静态分发 + 受控写入"为核心模型,提供配置的版本化管理、自动化校验、可靠分发和质量保障。</p>
<p>系统主要配套给 PT 自动化管理工具(如 mediabot),作为 PT 站点目录、HTML 解析规则、接口数据处理策略等配置的共享和管理中心,让多个使用 PT 自动化工具的系统可以方便地获取和更新这些配置。</p>
<h3>1.1 设计目标(按优先级排列)</h3>
<ol>
<li><strong>分发可靠性第一</strong> — 工具拿不到配置就等于瘫痪,可用性比功能丰富更重要</li>
<li><strong>多工具兼容</strong> — 配置格式与具体工具解耦,mediabot、自定义脚本等都能消费</li>
<li><strong>接入成本趋近于零</strong> — 客户端不需要注册、不需要登录,开箱即用</li>
<li><strong>写入质量可控</strong> — 发布有门槛,防止劣质配置污染生态</li>
<li><strong>运维成本可控</strong> — 社区项目,资源有限,架构必须简单</li>
</ol>
<h3>1.2 核心设计原则</h3>
<h4>配置即包(Config as Package)</h4>
<p>借鉴 npm/Docker Hub 的模型:每个站点有一个"包",包有多个版本,每个版本是一个不可变的配置快照。不存在"覆盖"的概念,只有"发布新版本"。客户端始终通过 <code>siteId + version</code> 精确获取。<code>latest</code> 标签指向当前推荐版本,可回退。</p>
<h4>读开放、写受控</h4>
<p>读取配置无需鉴权(和病毒库一样),降低客户端接入成本。写入(发布配置)需要认证 + 权限,保证质量。类比:任何人可以 <code>npm install</code>,但发布包需要登录。</p>
<h4>校验前置</h4>
<p>配置在入库前必须通过 Schema 校验,并支持自动化冒烟测试(用配置去实际请求站点页面,验证选择器是否有效)。<span class="phase-tag phase-p2">冒烟测试为 Phase 2</span></p>
<!-- ========== 1.1 技术选型 ========== -->
<h3>1.3 技术选型</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>类别</th><th>技术</th></tr>
</thead>
<tbody>
<tr><td>后端</td><td>Java 17 + Spring Boot 3.x + MyBatis-Plus</td></tr>
<tr><td>数据库</td><td>MySQL 8.0</td></tr>
<tr><td>认证</td><td>bcrypt 密码存储 + HMAC-SHA256 请求签名</td></tr>
<tr><td>构建</td><td>Maven 多模块</td></tr>
<tr><td>部署</td><td>Docker(纯 Java 镜像,JDK 17 JRE Alpine)</td></tr>
<tr><td>文档</td><td>SpringDoc OpenAPI(自动生成)<span class="phase-tag phase-p2">P2</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== 2. 分阶段实施计划(从第14章提前) ========== -->
<section id="sec2">
<h2>2. 分阶段实施计划</h2>
<div class="callout callout-info">
<p class="callout-title">阅读指引</p>
<p>本章定义了 Phase 1(最小可用版本)和 Phase 2(质量体系增强)的功能边界。后续各章节中,Phase 1 内容正常展示,Phase 2 内容以 <span class="phase-tag phase-p2">P2</span> 标签标记并以半透明样式呈现,方便读者快速区分。</p>
</div>
<h3>2.1 Phase 1 — 最小可用版本 <span class="phase-tag phase-p1">P1</span></h3>
<p>核心目标:让 mediabot 能够通过 PAR 完成配置的发布、审核和同步。</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>模块</th><th>范围</th></tr>
</thead>
<tbody>
<tr><td>配置 Schema</td><td>固定 v2 Schema,覆盖 search/detail/upload 页面</td></tr>
<tr><td>API 核心</td><td>CRUD + ETag 条件请求 + Manifest</td></tr>
<tr><td>认证</td><td>手动注册(邮箱 + 密码)+ 登录获取 API Key + HMAC 请求签名</td></tr>
<tr><td>匿名统计</td><td>可选 X-Anonymous-Id 头,注册后自动关联匿名数据</td></tr>
<tr><td>静态分发</td><td>Spring Boot 内嵌 Tomcat 直接服务静态文件(CDN 可后加)</td></tr>
<tr><td>校验</td><td>JSON Schema 校验 + 选择器语法检查</td></tr>
<tr><td>审核</td><td>基础审核流程(配置 + 站点信息)</td></tr>
<tr><td>PAR Admin</td><td>审核工作台 + 用户管理 + 站点管理 + 回滚</td></tr>
<tr><td>客户端 SDK</td><td>Manifest 同步 + ETag + 本地缓存 + checksum 校验</td></tr>
<tr><td>部署</td><td>纯 Java Docker 镜像(JDK 17 JRE Alpine + Spring Boot Fat JAR)</td></tr>
</tbody>
</table>
</div>
<h3>2.2 Phase 2 — 质量体系增强 <span class="phase-tag phase-p2">P2</span></h3>
<p>核心目标:提升配置质量保障和分发可靠性。</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>模块</th><th>范围</th></tr>
</thead>
<tbody>
<tr><td>引擎模板</td><td>模板管理 + 差异提交 + 发布时合并</td></tr>
<tr><td>冒烟测试</td><td>自动请求站点页面验证选择器(校验流水线第 3 步)</td></tr>
<tr><td>配置签名</td><td>JWS 签名 + 客户端公钥验证</td></tr>
<tr><td>CDN 分发</td><td>引入 Nginx 前置代理 + CDN 回源</td></tr>
<tr><td>通知</td><td>Webhook + 邮件通知</td></tr>
<tr><td>站点生命周期</td><td>弃用归档、迁移引导</td></tr>
<tr><td>OpenAPI 规范</td><td>自动生成 API 文档 + SDK</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== 3. 整体架构 ========== -->
<section id="sec3">
<h2>3. 整体架构</h2>
<p>PAR 采用<strong>静态优先(Static-First)</strong>架构。PT 站点适配配置天然适合静态分发:体积极小(单个配置 2-8 KB,全量不到 1 MB),读写比极高,更新频率低。</p>
<div class="diagram">
<pre class="mermaid">
flowchart TB
subgraph Clients["客户端工具"]
A[mediabot]
B[自定义脚本]
C[其他 PT 工具]
end
subgraph CDN_Layer["CDN 缓存(Phase 2)"]
CDN[CDN 节点]
end
subgraph Server["API 服务器(Spring Boot 内嵌 Tomcat)"]
API["API 服务<br>动态请求处理"]
STATIC["静态配置文件分发<br>(内嵌 Tomcat 直接服务)"]
DB[(MySQL 数据库)]
STORE[本地静态存储 JSON]
end
subgraph Admin["管理面板"]
PANEL["PAR Admin<br>(Spring Boot 托管 SPA)"]
end
A -->|读取配置| API
B -->|读取配置| API
C -->|读取配置| API
A -->|写入配置| API
API --> DB
API -->|发布时写入| STORE
PANEL -->|审核/管理| API
CDN -.->|回源| API
</pre>
<figcaption>图 1:PAR 系统整体架构(纯 Java 部署,CDN 为 Phase 2)</figcaption>
</div>
<h3>3.1 工作方式</h3>
<ol>
<li><strong>配置发布流程</strong>:通过管理 API 提交 -> 校验通过 -> 写入数据库元信息 + 生成静态 JSON 文件到本地磁盘</li>
<li><strong>读取流程</strong>:客户端请求 <code>GET /v1/configs/{siteId}</code>,由 Spring Boot Controller 处理,支持 ETag 条件请求,响应携带 <code>Cache-Control</code> 头<span class="phase-tag phase-p2">CDN 为 Phase 2,Phase 1 仅 API 同域</span></li>
<li><strong>管理 API</strong>:只处理写入、审核、账户等需要鉴权的操作,读压力极小</li>
</ol>
<div class="callout callout-tip">
<p class="callout-title">设计优势</p>
<p>Phase 1 采用纯 Java 部署(Spring Boot 内嵌 Tomcat),动态 API 和静态文件均由同一 Java 进程处理,架构极简。Spring Boot 的 <code>ResourceHandler</code> 可高效服务静态文件,配合 ETag 和 <code>Cache-Control</code> 响应头实现客户端缓存。Phase 2 引入 CDN 后,CDN 回源到 Spring Boot,CDN 不可用时仍可直接访问 API 源。<span class="phase-tag phase-p2">CDN 和多源回退为 Phase 2</span></p>
</div>
<h3>3.2 与 mediabot 的职责划分</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>能力</th><th>mediabot(客户端工具)</th><th>PAR(注册中心)</th></tr>
</thead>
<tbody>
<tr><td>站点录入入口</td><td>提供录入表单</td><td>接收注册 + 唯一性校验</td></tr>
<tr><td>适配器编辑</td><td>内置可视化编辑器</td><td>-</td></tr>
<tr><td>配置发布</td><td>调用 PAR API 提交</td><td>校验 + 存储 + 版本化</td></tr>
<tr><td>配置预览</td><td>实时预览解析结果</td><td>-</td></tr>
<tr><td>配置分发</td><td>拉取并本地缓存</td><td>Manifest + ETag + 静态文件</td></tr>
<tr><td>配置审核</td><td>-</td><td>审核工作台(PAR Admin)</td></tr>
<tr><td>用户/信任管理</td><td>PAR 账户注册/登录入口,本地多账户切换</td><td>账户认证 + 信任等级 + HMAC 签名验证</td></tr>
<tr><td>回滚操作</td><td>-</td><td>版本历史 + 一键回滚</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== 4. 配置 Schema ========== -->
<section id="sec4">
<h2>4. 配置 Schema 设计</h2>
<h3>4.1 设计原则</h3>
<ul>
<li><strong>按页面功能组织</strong>(和用户心智对齐)</li>
<li><strong>声明式为主</strong>(尽量不依赖代码逻辑)</li>
<li><strong>可扩展但不松散</strong>(核心字段必填,扩展字段可选)</li>
</ul>
<h3>4.2 完整 Schema 结构</h3>
<pre><span class="comment">// ═══════ 站点元信息 ═══════</span>
{
<span class="string">"$schema"</span>: <span class="string">"https://par.dev/schema/v2"</span>,
<span class="string">"schemaVersion"</span>: <span class="number">2</span>,
<span class="string">"siteId"</span>: <span class="string">"ptfans"</span>, <span class="comment">// 唯一标识</span>
<span class="string">"siteName"</span>: <span class="string">"PTFans"</span>,
<span class="string">"siteType"</span>: <span class="string">"nexusphp"</span>, <span class="comment">// 站点引擎类型,决定解析框架</span>
<span class="string">"domains"</span>: [<span class="string">"ptfans.cc"</span>],
<span class="string">"homeUrl"</span>: <span class="string">"https://ptfans.cc"</span>,
<span class="string">"tags"</span>: [<span class="string">"影视"</span>, <span class="string">"综合"</span>],
<span class="string">"description"</span>: <span class="string">"综合类PT站点,以影视资源为主"</span>,
<span class="comment">// ═══════ 认证配置 ═══════</span>
<span class="string">"auth"</span>: {
<span class="string">"type"</span>: <span class="string">"cookie"</span>, <span class="comment">// cookie | basic | api_key | oauth</span>
<span class="string">"loginUrl"</span>: <span class="string">"/takelogin.php"</span>,
<span class="string">"loginMethod"</span>: <span class="string">"POST"</span>,
<span class="string">"loginFields"</span>: {
<span class="string">"username"</span>: { <span class="string">"selector"</span>: <span class="string">"#username"</span>, <span class="string">"type"</span>: <span class="string">"text"</span> },
<span class="string">"password"</span>: { <span class="string">"selector"</span>: <span class="string">"#password"</span>, <span class="string">"type"</span>: <span class="string">"password"</span> },
<span class="string">"captcha"</span>: { <span class="string">"selector"</span>: <span class="string">"#imagehash"</span>, <span class="string">"type"</span>: <span class="string">"image"</span>, <span class="string">"optional"</span>: <span class="keyword">true</span> }
},
<span class="string">"cookie"</span>: {
<span class="string">"requiredKeys"</span>: [<span class="string">"nexusphp_*"</span>],
<span class="string">"sessionKey"</span>: <span class="string">"nexusphp_*"</span>,
<span class="string">"estimatedExpiry"</span>: <span class="string">"30d"</span>
},
<span class="string">"signIn"</span>: {
<span class="string">"url"</span>: <span class="string">"/attendance.php"</span>,
<span class="string">"method"</span>: <span class="string">"GET"</span>,
<span class="string">"successIndicator"</span>: <span class="string">"class:success_msg"</span>
}
},
<span class="comment">// ═══════ 页面解析规则(开放式 map)═══════</span>
<span class="string">"pages"</span>: {
<span class="string">"search"</span>: {
<span class="string">"url"</span>: <span class="string">"/torrents.php?inclbookmarked=0&amp;incldead=0"</span>,
<span class="string">"method"</span>: <span class="string">"GET"</span>,
<span class="string">"listContainer"</span>: <span class="string">"table.torrents tbody tr"</span>,
<span class="string">"skipRows"</span>: <span class="number">0</span>,
<span class="string">"fields"</span>: {
<span class="string">"title"</span>: { <span class="string">"selector"</span>: <span class="string">"td.name a"</span>, <span class="string">"type"</span>: <span class="string">"text"</span> },
<span class="string">"size"</span>: { <span class="string">"selector"</span>: <span class="string">"td.size"</span>, <span class="string">"type"</span>: <span class="string">"text"</span>, <span class="string">"transform"</span>: <span class="string">"fileSize"</span> },
<span class="string">"seeders"</span>: { <span class="string">"selector"</span>: <span class="string">"td.seeders"</span>, <span class="string">"type"</span>: <span class="string">"text"</span>, <span class="string">"transform"</span>: <span class="string">"int"</span> },
<span class="string">"leechers"</span>: { <span class="string">"selector"</span>: <span class="string">"td.leechers"</span>, <span class="string">"type"</span>: <span class="string">"text"</span>, <span class="string">"transform"</span>: <span class="string">"int"</span> },
<span class="string">"detailUrl"</span>: { <span class="string">"selector"</span>: <span class="string">"td.name a"</span>, <span class="string">"type"</span>: <span class="string">"attribute"</span>,
<span class="string">"attribute"</span>: <span class="string">"href"</span>, <span class="string">"transform"</span>: <span class="string">"absoluteUrl"</span> },
<span class="string">"freeFlag"</span>: { <span class="string">"selector"</span>: <span class="string">"td.pro_free, td.pro_2xup"</span>,
<span class="string">"type"</span>: <span class="string">"attribute"</span>, <span class="string">"attribute"</span>: <span class="string">"class"</span>,
<span class="string">"transform"</span>: <span class="string">"freeFlag"</span>, <span class="string">"optional"</span>: <span class="keyword">true</span> }
},
<span class="string">"pagination"</span>: {
<span class="string">"containerSelector"</span>: <span class="string">"div.page_nav"</span>,
<span class="string">"nextSelector"</span>: <span class="string">"a.next"</span>,
<span class="string">"pageParam"</span>: <span class="string">"page"</span>,
<span class="string">"maxPages"</span>: <span class="number">50</span>
},
<span class="string">"errorDetection"</span>: {
<span class="string">"captcha"</span>: { <span class="string">"selector"</span>: <span class="string">"#captchaimg"</span>, <span class="string">"present"</span>: <span class="keyword">true</span> },
<span class="string">"rateLimited"</span>: { <span class="string">"selector"</span>: <span class="string">".error"</span>,
<span class="string">"textContains"</span>: [<span class="string">"频率"</span>, <span class="string">"frequent"</span>] },
<span class="string">"loginRequired"</span>: { <span class="string">"selector"</span>: <span class="string">"#loginform"</span>, <span class="string">"present"</span>: <span class="keyword">true</span> }
},
<span class="string">"smokeTest"</span>: {
<span class="string">"url"</span>: <span class="string">"/torrents.php"</span>,
<span class="string">"expectMinRows"</span>: <span class="number">1</span>,
<span class="string">"requiredFields"</span>: [<span class="string">"title"</span>, <span class="string">"size"</span>, <span class="string">"seeders"</span>]
}
},
<span class="string">"detail"</span>: {
<span class="string">"urlPattern"</span>: <span class="string">"/details.php?id={torrentId}"</span>,
<span class="string">"method"</span>: <span class="string">"GET"</span>,
<span class="string">"fields"</span>: { <span class="comment">/* ... */</span> }
},
<span class="string">"upload"</span>: {
<span class="string">"url"</span>: <span class="string">"/upload.php"</span>,
<span class="string">"method"</span>: <span class="string">"POST"</span>,
<span class="string">"enctype"</span>: <span class="string">"multipart/form-data"</span>,
<span class="string">"fields"</span>: { <span class="comment">/* ... */</span> }
},
<span class="string">"attendance"</span>: { <span class="comment">// 扩展页面</span>
<span class="string">"url"</span>: <span class="string">"/attendance.php"</span>,
<span class="string">"method"</span>: <span class="string">"GET"</span>,
<span class="string">"fields"</span>: { <span class="comment">/* ... */</span> }
}
},
<span class="comment">// ═══════ 内置转换器 ═══════</span>
<span class="string">"transforms"</span>: {
<span class="string">"fileSize"</span>: { <span class="string">"type"</span>: <span class="string">"regex"</span>, <span class="string">"pattern"</span>: <span class="string">"^(\\d+\\.?\\d*)\\s*(GB|MB|KB|TB|B)$"</span> },
<span class="string">"int"</span>: { <span class="string">"type"</span>: <span class="string">"builtIn"</span>, <span class="string">"name"</span>: <span class="string">"parseInt"</span> },
<span class="string">"dateTime"</span>: { <span class="string">"type"</span>: <span class="string">"builtIn"</span>, <span class="string">"name"</span>: <span class="string">"parseDate"</span> },
<span class="string">"absoluteUrl"</span>: { <span class="string">"type"</span>: <span class="string">"builtIn"</span>, <span class="string">"name"</span>: <span class="string">"resolveUrl"</span>, <span class="string">"base"</span>: <span class="string">"https://ptfans.cc"</span> },
<span class="string">"freeFlag"</span>: { <span class="string">"type"</span>: <span class="string">"mapping"</span>, <span class="string">"values"</span>: {
<span class="string">"pro_free"</span>: <span class="string">"free"</span>, <span class="string">"pro_2xup"</span>: <span class="string">"2x_upload"</span>,
<span class="string">"pro_2xfree"</span>: <span class="string">"2x_free"</span>, <span class="string">"pro_50pctdown"</span>: <span class="string">"50%_down"</span> }},
<span class="string">"imdbId"</span>: { <span class="string">"type"</span>: <span class="string">"regex"</span>, <span class="string">"pattern"</span>: <span class="string">"(tt\\d+)"</span> },
<span class="string">"doubanId"</span>: { <span class="string">"type"</span>: <span class="string">"regex"</span>, <span class="string">"pattern"</span>: <span class="string">"(\\d+)"</span> }
},
<span class="comment">// ═══════ API 配置(站点如果有 REST API)═══════</span>
<span class="string">"api"</span>: {
<span class="string">"baseUrl"</span>: <span class="string">"https://ptfans.cc/api/v1"</span>,
<span class="string">"auth"</span>: { <span class="string">"type"</span>: <span class="string">"cookie"</span> },
<span class="string">"endpoints"</span>: {
<span class="string">"search"</span>: { <span class="string">"path"</span>: <span class="string">"/torrents"</span>, <span class="string">"method"</span>: <span class="string">"GET"</span>, <span class="comment">/* ... */</span> }
}
},
<span class="comment">// ═══════ 站点能力声明 ═══════</span>
<span class="string">"capabilities"</span>: {
<span class="string">"search"</span>: <span class="keyword">true</span>,
<span class="string">"upload"</span>: <span class="keyword">true</span>,
<span class="string">"rss"</span>: { <span class="string">"supported"</span>: <span class="keyword">true</span>, <span class="string">"url"</span>: <span class="string">"/rss.php"</span> },
<span class="string">"imdbSearch"</span>: { <span class="string">"supported"</span>: <span class="keyword">true</span> },
<span class="string">"doubanSearch"</span>: { <span class="string">"supported"</span>: <span class="keyword">false</span> },
<span class="string">"anonymousSearch"</span>: <span class="keyword">false</span>,
<span class="string">"captcha"</span>: { <span class="string">"login"</span>: <span class="keyword">false</span>, <span class="string">"search"</span>: <span class="keyword">false</span>, <span class="string">"upload"</span>: <span class="keyword">false</span> },
<span class="string">"downloadRequiresCookie"</span>: <span class="keyword">true</span>
},
<span class="comment">// ═══════ 速率限制(指导客户端行为)═══════</span>
<span class="string">"rateLimits"</span>: {
<span class="string">"search"</span>: { <span class="string">"maxRequests"</span>: <span class="number">30</span>, <span class="string">"perSeconds"</span>: <span class="number">60</span> },
<span class="string">"download"</span>: { <span class="string">"maxRequests"</span>: <span class="number">10</span>, <span class="string">"perSeconds"</span>: <span class="number">60</span> },
<span class="string">"upload"</span>: { <span class="string">"maxRequests"</span>: <span class="number">5</span>, <span class="string">"perSeconds"</span>: <span class="number">3600</span> },
<span class="string">"global"</span>: { <span class="string">"maxRequests"</span>: <span class="number">60</span>, <span class="string">"perSeconds"</span>: <span class="number">60</span> }
},
<span class="comment">// ═══════ 站点特殊行为 ═══════</span>
<span class="string">"behaviors"</span>: {
<span class="string">"encoding"</span>: <span class="string">"UTF-8"</span>,
<span class="string">"timezone"</span>: <span class="string">"Asia/Shanghai"</span>,
<span class="string">"downloadReferer"</span>: <span class="keyword">true</span>,
<span class="string">"specialRules"</span>: [
{ <span class="string">"name"</span>: <span class="string">"转免时间"</span>, <span class="string">"selector"</span>: <span class="string">"td.pro_free .expires"</span>,
<span class="string">"type"</span>: <span class="string">"relativeTime"</span> }
]
}
}</pre>
<h3>4.3 关键设计要点</h3>
<h4><code>siteType</code> 与引擎模板的关系 <span class="phase-tag phase-p2">模板系统为 Phase 2</span></h4>
<p><code>siteType</code> 是分类标记(nexusphp、unit3d、gazelle 等),决定 mediabot 用哪套解析框架。每种引擎有一个默认模板,覆盖该引擎的通用选择器和转换器。<strong>合并发生在发布时,不在运行时。</strong>客户端获取到的永远是完整的、可直接使用的配置,不需要了解模板系统的存在。<span class="phase-tag phase-p2">P2</span></p>
<h4><code>pages</code> 的开放性</h4>
<p><code>search</code>、<code>detail</code>、<code>upload</code> 是约定优先级最高的 key,所有客户端都应支持。<code>attendance</code>、<code>userProfile</code> 等扩展 key 是可选的,客户端按需实现。遇到未知的 key,客户端应安全跳过,不阻塞核心功能。</p>
<h4><code>errorDetection</code> 的价值</h4>
<p>让客户端能自动识别并处理验证码、限速、登录失效等异常,而不是静默返回空结果。这直接决定了工具在生产环境中的可靠性。</p>
<h4><code>transforms</code> 的混合模型</h4>
<div class="table-wrap">
<table>
<thead>
<tr><th>类型</th><th>用途</th><th>示例</th></tr>
</thead>
<tbody>
<tr><td><code>regex</code></td><td>正则匹配提取</td><td>fileSize、imdbId</td></tr>
<tr><td><code>builtIn</code></td><td>内置解析函数</td><td>parseInt、parseDate、resolveUrl</td></tr>
<tr><td><code>mapping</code></td><td>枚举值映射</td><td>freeFlag 的多种 CSS class 到统一状态</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== 5. API 设计 ========== -->
<section id="sec5">
<h2>5. API 设计</h2>
<h3>5.1 路由总览</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>方法</th><th>路径</th><th>鉴权</th><th>说明</th><th>阶段</th></tr>
</thead>
<tbody>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/manifest</code></td><td class="label label-public">公开</td><td>全局清单(version + checksum)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/sites</code></td><td class="label label-public">公开</td><td>站点目录</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/sites/{siteId}</code></td><td class="label label-public">公开</td><td>单个站点元信息</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/sites</code></td><td class="label label-auth">API Key + HMAC</td><td>注册新站点</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-patch">PATCH</code></td><td><code>/v1/sites/{siteId}</code></td><td class="label label-auth">API Key + HMAC</td><td>修改站点元信息(需审核)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/configs/{siteId}</code></td><td class="label label-public">公开</td><td>下载最新配置(支持 ETag)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/configs/{siteId}/versions</code></td><td class="label label-public">公开</td><td>版本历史</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/configs/batch?sites=a,b,c</code></td><td class="label label-public">公开</td><td>批量获取配置</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/configs/{siteId}</code></td><td class="label label-auth">API Key + HMAC</td><td>发布新版本配置</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/configs/{siteId}/validate</code></td><td class="label label-auth">API Key + HMAC</td><td>预校验(不真正发布)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/configs/{siteId}/rollback</code></td><td class="label label-auth">API Key + HMAC</td><td>回滚到指定版本(trusted+)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/register</code></td><td class="label label-public">公开</td><td>注册(email + password)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/login</code></td><td class="label label-public">公开</td><td>登录(email + password),返回 apiKey</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/forgot-password</code></td><td class="label label-public">公开</td><td>忘记密码(发送邮件验证码)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/reset-password</code></td><td class="label label-public">公开</td><td>重置密码(code + newPassword)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/account/me</code></td><td class="label label-auth">API Key</td><td>查询自身账户信息</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/reset-key</code></td><td class="label label-auth">API Key + HMAC</td><td>重置 API Key</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/refresh-key</code></td><td class="label label-auth">API Key + HMAC</td><td>刷新 API Key</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/account/webhooks</code></td><td class="label label-auth">API Key + HMAC</td><td>注册 Webhook 回调</td><td><span class="phase-tag phase-p2">P2</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/reviews/pending</code></td><td class="label label-auth">API Key</td><td>待审核列表(reviewer+)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/reviews/{siteId}/config/v{ver}/approve</code></td><td class="label label-auth">API Key + HMAC</td><td>批准配置变更</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/reviews/{siteId}/config/v{ver}/reject</code></td><td class="label label-auth">API Key + HMAC</td><td>驳回配置变更</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/reviews/{siteId}/meta/v{ver}/approve</code></td><td class="label label-auth">API Key + HMAC</td><td>批准站点信息变更</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/reviews/{siteId}/meta/v{ver}/reject</code></td><td class="label label-auth">API Key + HMAC</td><td>驳回站点信息变更</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-post">POST</code></td><td><code>/v1/admin/trust/{accountId}</code></td><td class="label label-auth">Admin + HMAC</td><td>调整信任等级(作用于账户)</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/templates/*</code></td><td class="label label-public">公开</td><td>引擎模板相关接口</td><td><span class="phase-tag phase-p2">P2</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/schemas</code></td><td class="label label-public">公开</td><td>Schema 版本文档</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/health</code></td><td class="label label-public">公开</td><td>健康检查</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code class="api-method-get">GET</code></td><td><code>/v1/openapi.json</code></td><td class="label label-public">公开</td><td>OpenAPI 规范文档</td><td><span class="phase-tag phase-p2">P2</span></td></tr>
</tbody>
</table>
</div>
<h3>5.2 核心接口详情</h3>
<h4>静态文件处理方式</h4>
<div class="callout callout-info">
<p class="callout-title">Phase 1:Spring Boot 统一处理</p>
<p>配置 JSON 文件由 Java 应用在发布时写入本地磁盘。<code>GET /v1/configs/{siteId}</code> 由 Spring Boot Controller 处理,读取本地 JSON 文件并返回。响应头携带 <code>ETag</code>(基于文件内容的 MD5)和 <code>Cache-Control: public, max-age=3600</code>,客户端使用 <code>If-None-Match</code> 实现条件请求,无更新时返回 <code>304 Not Modified</code>。<span class="phase-tag phase-p2">Phase 2 将引入 Nginx 前置代理 + CDN 回源</span></p>
</div>
<h4>Manifest(同步入口)</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-get">GET</span>
<span class="api-path">/v1/manifest</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<pre>{
<span class="string">"schemaVersion"</span>: <span class="number">2</span>,
<span class="string">"generatedAt"</span>: <span class="string">"2026-06-28T00:00:00Z"</span>,
<span class="string">"totalSites"</span>: <span class="number">42</span>,
<span class="string">"sites"</span>: {
<span class="string">"ptfans"</span>: {
<span class="string">"version"</span>: <span class="number">3</span>,
<span class="string">"channel"</span>: <span class="string">"stable"</span>,
<span class="string">"status"</span>: <span class="string">"active"</span>,
<span class="string">"checksum"</span>: <span class="string">"sha256:abc123..."</span>,
<span class="string">"size"</span>: <span class="number">4200</span>,
<span class="string">"updatedAt"</span>: <span class="string">"2026-06-28T18:00:00Z"</span>
}
}
}</pre>
<p>客户端同步流程:请求 Manifest -> 与本地对比 version + checksum -> 对有更新的站点请求配置 -> 校验 checksum -> 更新本地文件。</p>
</div>
</div>
<h4>配置下载(ETag 条件请求)</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-get">GET</span>
<span class="api-path">/v1/configs/{siteId}?version=latest&amp;channel=stable</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<pre>{
<span class="string">"siteId"</span>: <span class="string">"ptfans"</span>,
<span class="string">"version"</span>: <span class="number">4</span>,
<span class="string">"channel"</span>: <span class="string">"stable"</span>,
<span class="string">"publishedAt"</span>: <span class="string">"2026-06-28T20:00:00Z"</span>,
<span class="string">"publisher"</span>: <span class="string">"user@example.com"</span>,
<span class="string">"changelog"</span>: <span class="string">"修复搜索页标题选择器"</span>,
<span class="string">"checksum"</span>: <span class="string">"sha256:..."</span>,
<span class="string">"signature"</span>: <span class="string">"eyJhbGci..."</span>, <span class="comment">// Phase 2 启用 JWS 签名</span>
<span class="string">"config"</span>: { <span class="comment">/* 完整配置 JSON */</span> }
}</pre>
<p>HTTP 响应头携带 <code>ETag</code>、<code>Cache-Control: public, max-age=3600</code>。客户端使用 <code>If-None-Match</code> 实现条件请求,无更新时返回 <code>304 Not Modified</code>。<span class="phase-tag phase-p2">signature 字段 Phase 2 启用</span></p>
</div>
</div>
<h4>预校验接口</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/configs/{siteId}/validate</span>
<span class="label label-auth">API Key</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>供 mediabot 在发布前调用,不真正创建版本,只返回校验结果。</p>
<pre>{
<span class="string">"valid"</span>: <span class="keyword">false</span>,
<span class="string">"errors"</span>: [
{ <span class="string">"path"</span>: <span class="string">"pages.search.fields.title.selector"</span>,
<span class="string">"message"</span>: <span class="string">"无效的 CSS 选择器语法"</span> }
]
}</pre>
</div>
</div>
<h4>注册</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/account/register</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>邮箱注册,密码使用 bcrypt 存储。可选携带 <code>anonymousId</code>,注册后自动关联匿名统计数据。</p>
<pre><span class="comment">// 请求</span>
{
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>,
<span class="string">"password"</span>: <span class="string">"yourPassword123"</span>,
<span class="string">"anonymousId"</span>: <span class="string">"sha256_hash_optional"</span> <span class="comment">// 可选</span>
}
<span class="comment">// 响应</span>
{
<span class="string">"accountId"</span>: <span class="string">"acct_a1b2c3d4"</span>,
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>,
<span class="string">"trustLevel"</span>: <span class="string">"member"</span>,
<span class="string">"createdAt"</span>: <span class="string">"2026-06-29T10:00:00Z"</span>
}</pre>
</div>
</div>
<h4>登录</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/account/login</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>邮箱 + 密码登录,返回 API Key。客户端本地加密存储,后续写入请求携带 API Key 和 HMAC 签名。</p>
<pre><span class="comment">// 请求</span>
{
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>,
<span class="string">"password"</span>: <span class="string">"yourPassword123"</span>
}
<span class="comment">// 响应</span>
{
<span class="string">"accountId"</span>: <span class="string">"acct_a1b2c3d4"</span>,
<span class="string">"apiKey"</span>: <span class="string">"mbt_k7x9m2p4q8..."</span>,
<span class="string">"trustLevel"</span>: <span class="string">"member"</span>,
<span class="string">"permissions"</span>: {
<span class="string">"canPublish"</span>: <span class="keyword">true</span>, <span class="string">"autoApprove"</span>: <span class="keyword">false</span>,
<span class="string">"canReview"</span>: <span class="keyword">false</span>, <span class="string">"canRollback"</span>: <span class="keyword">false</span>
}
}</pre>
</div>
</div>
<h4>忘记密码</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/account/forgot-password</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>发送邮件验证码到注册邮箱,验证码 15 分钟内有效。</p>
<pre><span class="comment">// 请求</span>
{
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>
}
<span class="comment">// 响应</span>
{
<span class="string">"sent"</span>: <span class="keyword">true</span>,
<span class="string">"message"</span>: <span class="string">"验证码已发送到邮箱,15分钟内有效"</span>
}</pre>
</div>
</div>
<h4>重置密码</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/account/reset-password</span>
<span class="label label-public">公开</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>使用邮件验证码重置密码,重置成功后当前 API Key 失效,需重新登录。</p>
<pre><span class="comment">// 请求</span>
{
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>,
<span class="string">"code"</span>: <span class="string">"123456"</span>,
<span class="string">"newPassword"</span>: <span class="string">"newPassword456"</span>
}
<span class="comment">// 响应</span>
{
<span class="string">"success"</span>: <span class="keyword">true</span>,
<span class="string">"message"</span>: <span class="string">"密码已重置,请重新登录"</span>
}</pre>
</div>
</div>
<h4>刷新 API Key</h4>
<div class="api-card">
<div class="api-card-header">
<span class="api-method api-method-post">POST</span>
<span class="api-path">/v1/account/refresh-key</span>
<span class="label label-auth">API Key + HMAC</span>
<span class="phase-tag phase-p1">P1</span>
</div>
<div class="api-card-body">
<p>主动刷新 API Key,旧 key 5 分钟宽限期后失效。需要 HMAC 签名验证。</p>
<pre><span class="comment">// 请求(空体,通过 Authorization 识别)</span>
{ }
<span class="comment">// 响应</span>
{
<span class="string">"apiKey"</span>: <span class="string">"mbt_newkey_xyz..."</span>,
<span class="string">"refreshedAt"</span>: <span class="string">"2026-06-29T10:00:00Z"</span>
}</pre>
</div>
</div>
<h4>HMAC 请求签名说明</h4>
<div class="callout callout-info">
<p class="callout-title">写入 API 必须携带 HMAC 签名</p>
<p>所有写入操作(<code>POST</code> / <code>PATCH</code> / <code>DELETE</code>)除 <code>Authorization: Bearer {apiKey}</code> 外,还必须携带以下请求头:</p>
<ul>
<li><code>X-Timestamp</code>:Unix 时间戳(秒),与服务器时间差必须 &lt;= 60 秒</li>
<li><code>X-Signature</code>:HMAC-SHA256 签名</li>
</ul>
<p><strong>签名内容</strong>:<code>HTTP方法 + "\n" + 请求路径 + "\n" + 时间戳 + "\n" + 请求体SHA-256(无请求体时为空字符串)</code></p>
<p><strong>签名计算</strong>:<code>HMAC-SHA256(apiKey, 签名内容)</code></p>
<p>PAR 验证:时间戳窗口 + 签名匹配 + 防重放(缓存最近 60 秒内的签名,重复拒绝)。读取 API(公开接口)不需要签名。</p>
</div>
</section>
<!-- ========== 6. 数据库设计 ========== -->
<section id="sec6">
<h2>6. 数据库设计</h2>
<div class="callout callout-info">
<p class="callout-title">核心原则</p>
<p>配置体不存数据库 — 数据库只存元信息和校验数据,完整 JSON 配置存在静态存储中,数据库里只保存文件路径引用。版本不可变 — 所有配置表只 INSERT 不 UPDATE。软删除 — 用 <code>deleted_at</code> 标记。</p>
</div>
<h3>6.1 ER 关系</h3>
<div class="diagram">
<pre class="mermaid">
erDiagram
accounts ||--o{ site_configs : "publisher_id"
accounts ||--o{ site_meta_versions : "submitted_by"
accounts ||--o{ config_reviews : "reviewer_id"
accounts ||--o{ trust_level_changes : "account_id"
accounts ||--o{ anonymous_stats : "linked_anonymous_id"
sites ||--o{ site_configs : "site_id (1:N)"
sites ||--o{ site_meta_versions : "site_id (1:N)"
site_configs ||--o| config_reviews : "config_id (1:1)"
engine_templates ||--o{ template_versions : "template_id"
engine_templates ||--o{ site_configs : "template_id (可选继承)"
</pre>
<figcaption>图 2:数据库 ER 关系图(v2.0 简化版)</figcaption>
</div>
<h3>6.2 表结构</h3>
<h4>accounts — 邮箱账户(简化)</h4>
<div class="sql-block">
<div class="sql-block-header">accounts</div>
<pre><span class="keyword">CREATE TABLE</span> accounts (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
account_id <span class="type">VARCHAR(32)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- 对外标识 acct_xxxx</span>
email <span class="type">VARCHAR(255)</span> <span class="keyword">NOT NULL</span>
password_hash <span class="type">VARCHAR(255)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- bcrypt 哈希</span>
api_key_hash <span class="type">VARCHAR(255)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- API Key 的 SHA-256</span>
api_key_prefix <span class="type">VARCHAR(12)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- 日志脱敏 mbt_abc1</span>
trust_level <span class="type">ENUM</span>(<span class="string">'member'</span>,<span class="string">'trusted'</span>,<span class="string">'admin'</span>) <span class="keyword">NOT NULL DEFAULT</span> <span class="string">'member'</span>,
permissions <span class="type">JSON</span> <span class="keyword">NOT NULL</span>
linked_anonymous_id <span class="type">VARCHAR(64)</span> <span class="keyword">NULL</span> <span class="comment">-- 关联的匿名 ID(SHA-256)</span>
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
updated_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)</span>,
last_login_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>,
deleted_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">UNIQUE KEY</span> uk_account_id (account_id),
<span class="keyword">UNIQUE KEY</span> uk_email (email),
<span class="keyword">UNIQUE KEY</span> uk_api_key_hash (api_key_hash),
<span class="keyword">KEY</span> idx_linked_anon (linked_anonymous_id)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>sites — 站点元信息</h4>
<div class="sql-block">
<div class="sql-block-header">sites</div>
<pre><span class="keyword">CREATE TABLE</span> sites (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
site_id <span class="type">VARCHAR(64)</span> <span class="keyword">NOT NULL</span>
site_name <span class="type">VARCHAR(128)</span> <span class="keyword">NOT NULL</span>
site_type <span class="type">VARCHAR(32)</span> <span class="keyword">NOT NULL</span>
home_url <span class="type">VARCHAR(512)</span> <span class="keyword">NULL</span>
description <span class="type">TEXT</span> <span class="keyword">NULL</span>
tags <span class="type">JSON</span> <span class="keyword">NULL</span>
domains <span class="type">JSON</span> <span class="keyword">NOT NULL</span>
logo_url <span class="type">VARCHAR(512)</span> <span class="keyword">NULL</span>
maintainers <span class="type">JSON</span> <span class="keyword">NULL</span>
metadata <span class="type">JSON</span> <span class="keyword">NULL</span>
current_version <span class="type">INT UNSIGNED</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">1</span>
review_status <span class="type">ENUM</span>(<span class="string">'active'</span>,<span class="string">'pending_review'</span>,<span class="string">'rejected'</span>) <span class="keyword">NOT NULL DEFAULT</span> <span class="string">'active'</span>
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
updated_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)</span>,
deleted_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">UNIQUE KEY</span> uk_site_id (site_id),
<span class="keyword">KEY</span> idx_site_type (site_type)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>site_configs — 站点配置版本(核心表)</h4>
<div class="sql-block">
<div class="sql-block-header">site_configs</div>
<pre><span class="keyword">CREATE TABLE</span> site_configs (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
site_id <span class="type">VARCHAR(64)</span> <span class="keyword">NOT NULL</span>,
version <span class="type">INT UNSIGNED</span> <span class="keyword">NOT NULL</span> <span class="comment">-- 单调递增</span>
channel <span class="type">ENUM</span>(<span class="string">'stable'</span>,<span class="string">'beta'</span>) <span class="keyword">NOT NULL DEFAULT</span> <span class="string">'stable'</span>,
status <span class="type">ENUM</span>(<span class="string">'published'</span>,<span class="string">'pending_review'</span>,<span class="string">'rejected'</span>) <span class="keyword">NOT NULL</span>,
config_path <span class="type">VARCHAR(512)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- 静态存储中的文件路径</span>
config_size <span class="type">INT UNSIGNED</span> <span class="keyword">NOT NULL</span>
checksum <span class="type">CHAR(64)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- SHA-256</span>
publisher_id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL</span>
template_id <span class="type">VARCHAR(64)</span> <span class="keyword">NULL</span> <span class="comment">-- 继承的引擎模板(Phase 2 启用)</span>
extends_version <span class="type">INT UNSIGNED</span> <span class="keyword">NULL</span>
changelog <span class="type">TEXT</span> <span class="keyword">NULL</span>
schema_valid <span class="type">TINYINT(1)</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">0</span>
selector_valid <span class="type">TINYINT(1)</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">0</span>
smoke_tested <span class="type">TINYINT(1)</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">0</span> <span class="comment">-- Phase 2 启用</span>
smoke_test_pass <span class="type">TINYINT(1)</span> <span class="keyword">NULL</span> <span class="comment">-- Phase 2 启用</span>
smoke_test_url <span class="type">VARCHAR(512)</span> <span class="keyword">NULL</span> <span class="comment">-- Phase 2 启用</span>
smoke_test_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span> <span class="comment">-- Phase 2 启用</span>
is_latest <span class="type">TINYINT(1)</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">0</span> <span class="comment">-- 同 channel 下唯一</span>
published_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">UNIQUE KEY</span> uk_site_version (site_id, version),
<span class="keyword">UNIQUE KEY</span> uk_site_channel_latest (site_id, channel, is_latest),
<span class="keyword">KEY</span> idx_status (status),
<span class="keyword">KEY</span> idx_publisher (publisher_id)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>site_meta_versions — 站点元信息变更历史</h4>
<div class="sql-block">
<div class="sql-block-header">site_meta_versions</div>
<pre><span class="keyword">CREATE TABLE</span> site_meta_versions (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
site_id <span class="type">VARCHAR(64)</span> <span class="keyword">NOT NULL</span>,
version <span class="type">INT UNSIGNED</span> <span class="keyword">NOT NULL</span>,
site_name <span class="type">VARCHAR(128)</span> <span class="keyword">NOT NULL</span>,
site_type <span class="type">VARCHAR(32)</span> <span class="keyword">NOT NULL</span>,
domains <span class="type">JSON</span> <span class="keyword">NOT NULL</span>,
tags <span class="type">JSON</span> <span class="keyword">NULL</span>,
description <span class="type">TEXT</span> <span class="keyword">NULL</span>,
submitted_by <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL</span>
changelog <span class="type">TEXT</span> <span class="keyword">NULL</span>
status <span class="type">ENUM</span>(<span class="string">'published'</span>,<span class="string">'pending_review'</span>,<span class="string">'rejected'</span>) <span class="keyword">NOT NULL</span>,
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
reviewed_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>,
reviewed_by <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NULL</span>,
review_comment <span class="type">TEXT</span> <span class="keyword">NULL</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">UNIQUE KEY</span> uk_site_version (site_id, version),
<span class="keyword">KEY</span> idx_status (status)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>anonymous_stats — 匿名统计数据</h4>
<div class="sql-block">
<div class="sql-block-header">anonymous_stats</div>
<pre><span class="keyword">CREATE TABLE</span> anonymous_stats (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
anonymous_id <span class="type">VARCHAR(64)</span> <span class="keyword">NOT NULL</span> <span class="comment">-- SHA-256 哈希</span>
first_seen <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
last_seen <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
request_count <span class="type">INT UNSIGNED</span> <span class="keyword">NOT NULL DEFAULT</span> <span class="number">0</span>,
ip_address <span class="type">VARCHAR(64)</span> <span class="keyword">NULL</span>
user_agent <span class="type">VARCHAR(512)</span> <span class="keyword">NULL</span>
linked_account_id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NULL</span> <span class="comment">-- 注册后关联的账户</span>
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
updated_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">UNIQUE KEY</span> uk_anonymous_id (anonymous_id),
<span class="keyword">KEY</span> idx_linked_account (linked_account_id),
<span class="keyword">KEY</span> idx_last_seen (last_seen)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>verification_codes — 邮件验证码</h4>
<div class="sql-block">
<div class="sql-block-header">verification_codes</div>
<pre><span class="keyword">CREATE TABLE</span> verification_codes (
id <span class="type">BIGINT UNSIGNED</span> <span class="keyword">NOT NULL AUTO_INCREMENT</span>,
email <span class="type">VARCHAR(255)</span> <span class="keyword">NOT NULL</span>
code <span class="type">VARCHAR(16)</span> <span class="keyword">NOT NULL</span>
purpose <span class="type">ENUM</span>(<span class="string">'reset_password'</span>) <span class="keyword">NOT NULL DEFAULT</span> <span class="string">'reset_password'</span>,
expires_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL</span>
used_at <span class="type">DATETIME(3)</span> <span class="keyword">NULL</span>
created_at <span class="type">DATETIME(3)</span> <span class="keyword">NOT NULL DEFAULT CURRENT_TIMESTAMP(3)</span>,
<span class="keyword">PRIMARY KEY</span> (id),
<span class="keyword">KEY</span> idx_email (email),
<span class="keyword">KEY</span> idx_expires (expires_at)
) <span class="keyword">ENGINE</span>=InnoDB <span class="keyword">DEFAULT CHARSET</span>=utf8mb4;</pre>
</div>
<h4>其他表</h4>
<div class="table-wrap">
<table>
<thead>
<tr><th>表名</th><th>用途</th><th>关键说明</th><th>阶段</th></tr>
</thead>
<tbody>
<tr><td><code>engine_templates</code></td><td>引擎模板定义</td><td>template_id、template_name、current_version</td><td><span class="phase-tag phase-p2">P2</span></td></tr>
<tr><td><code>template_versions</code></td><td>引擎模板版本快照</td><td>不可变,存储 config_path 和 checksum</td><td><span class="phase-tag phase-p2">P2</span></td></tr>
<tr><td><code>config_reviews</code></td><td>配置审核记录</td><td>与 site_configs 1:1,含审核意见</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code>trust_level_changes</code></td><td>信任等级变更审计</td><td>记录 account_id、old_level、new_level、reason、operator</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code>rollback_history</code></td><td>回滚操作记录</td><td>from_version、to_version、operator_id</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code>api_access_log</code></td><td>API 访问日志</td><td>高频写入,建议定期归档</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code>anonymous_stats</code></td><td>匿名统计数据</td><td>anonymous_id、request_count、ip_address、linked_account_id</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
<tr><td><code>verification_codes</code></td><td>邮件验证码</td><td>email、code、purpose、expires_at、used_at</td><td><span class="phase-tag phase-p1">P1</span></td></tr>
</tbody>
</table>
</div>
<h3>6.3 核心查询示例</h3>
<h4>获取站点最新配置</h4>
<pre><span class="keyword">SELECT</span> sc.*
<span class="keyword">FROM</span> site_configs sc
<span class="keyword">WHERE</span> sc.site_id = <span class="string">'ptfans'</span>
<span class="keyword">AND</span> sc.channel = <span class="string">'stable'</span>
<span class="keyword">AND</span> sc.is_latest = <span class="number">1</span>
<span class="keyword">AND</span> sc.status = <span class="string">'published'</span>
<span class="keyword">LIMIT</span> <span class="number">1</span>;</pre>
<h4>回滚操作(事务中执行)</h4>
<pre><span class="keyword">START TRANSACTION</span>;
<span class="comment">-- 取消当前版本的 is_latest 标记</span>
<span class="keyword">UPDATE</span> site_configs <span class="keyword">SET</span> is_latest = <span class="number">0</span>
<span class="keyword">WHERE</span> site_id = <span class="string">'ptfans'</span> <span class="keyword">AND</span> channel = <span class="string">'stable'</span> <span class="keyword">AND</span> is_latest = <span class="number">1</span>;
<span class="comment">-- 设置目标版本为 is_latest</span>
<span class="keyword">UPDATE</span> site_configs <span class="keyword">SET</span> is_latest = <span class="number">1</span>
<span class="keyword">WHERE</span> site_id = <span class="string">'ptfans'</span> <span class="keyword">AND</span> channel = <span class="string">'stable'</span> <span class="keyword">AND</span> version = <span class="number">2</span> <span class="keyword">AND</span> status = <span class="string">'published'</span>;
<span class="comment">-- 记录回滚历史</span>
<span class="keyword">INSERT INTO</span> rollback_history (site_id, from_version, to_version, operator_id, reason)
<span class="keyword">VALUES</span> (<span class="string">'ptfans'</span>, <span class="number">4</span>, <span class="number">2</span>, <span class="number">123</span>, <span class="string">'搜索页选择器在新版本失效'</span>);
<span class="keyword">COMMIT</span>;</pre>
<h4>查询匿名统计与注册关联</h4>
<pre><span class="keyword">SELECT</span> a.email, a.trust_level, s.request_count, s.first_seen, s.last_seen
<span class="keyword">FROM</span> anonymous_stats s
<span class="keyword">LEFT JOIN</span> accounts a <span class="keyword">ON</span> s.linked_account_id = a.id
<span class="keyword">WHERE</span> s.anonymous_id = <span class="string">'sha256_abc123...'</span>;</pre>
<h4>关联匿名数据到注册账户</h4>
<pre><span class="keyword">UPDATE</span> anonymous_stats
<span class="keyword">SET</span> linked_account_id = <span class="number">123</span>
<span class="keyword">WHERE</span> anonymous_id = <span class="string">'sha256_abc123...'</span>
<span class="keyword">AND</span> linked_account_id <span class="keyword">IS NULL</span>;
<span class="keyword">UPDATE</span> accounts
<span class="keyword">SET</span> linked_anonymous_id = <span class="string">'sha256_abc123...'</span>
<span class="keyword">WHERE</span> id = <span class="number">123</span>;</pre>
<h4>查询信任等级变更历史</h4>
<pre><span class="keyword">SELECT</span> a.account_id, tlc.old_level, tlc.new_level, tlc.reason, tlc.created_at
<span class="keyword">FROM</span> trust_level_changes tlc
<span class="keyword">JOIN</span> accounts a <span class="keyword">ON</span> tlc.account_id = a.id
<span class="keyword">WHERE</span> a.account_id = <span class="string">'acct_a1b2c3d4'</span>
<span class="keyword">ORDER BY</span> tlc.created_at <span class="keyword">DESC</span>;</pre>
</section>
<!-- ========== 7. 分发可靠性 ========== -->
<section id="sec7">
<h2>7. 分发可靠性设计</h2>
<h3>7.1 分层分发模型</h3>
<div class="diagram">
<pre class="mermaid">
flowchart TB
CLIENT[客户端工具]
LOCAL[本地文件缓存<br>~/.par/cache/]
API_SERVER["优先源:API 服务器(Phase 1)"]
CDN_SERVER["回退源:CDN / 静态存储(Phase 2)"]
FALLBACK[兜底:使用本地缓存]
CLIENT -->|缓存命中| LOCAL
CLIENT -->|缓存未命中| API_SERVER
API_SERVER -->|超时/不可用| CDN_SERVER
CDN_SERVER -->|仍然不可用| FALLBACK
</pre>
<figcaption>图 3:分层分发模型(CDN 和多源回退为 Phase 2)</figcaption>
</div>
<h3>7.2 Phase 1 分发方案</h3>
<p>Phase 1 采用 Spring Boot 内嵌 Tomcat 直接服务静态文件。配置 JSON 文件由 Java 应用在发布时写入本地磁盘,客户端通过 <code>GET /v1/configs/{siteId}</code> 由 Spring Boot Controller 读取并返回。API 同域静态文件服务,配合 ETag(基于文件内容的 MD5)和 <code>Cache-Control</code> 响应头实现高效同步。CDN 层和三级回退中的多源回滚将在 Phase 2 引入。</p>
<div class="callout callout-info">
<p class="callout-title">Phase 2 扩展</p>
<p>Phase 2 将在 Spring Boot 前面增加 Nginx 反向代理,Nginx 负责静态文件缓存和 CDN 回源,实现 CDN -> Nginx -> Spring Boot -> 本地缓存的三级回退。Nginx 不可用时仍可直连 Spring Boot 源。</p>
</div>
<h3>7.3 客户端本地存储结构</h3>
<pre>~/.par/
├── cache/
│ ├── manifest.json <span class="comment"># 全局清单</span>
│ └── configs/
│ ├── ptfans/
│ │ ├── v1.json <span class="comment"># 历史版本保留</span>
│ │ ├── v2.json
│ │ ├── v3.json <span class="comment"># 当前最新</span>
│ │ └── current.json → v3.json <span class="comment"># 软链接</span>
│ └── ...
├── registry-pubkey.pem <span class="comment"># Registry 公钥(Phase 2 启用签名验证)</span>
└── client-state.json <span class="comment"># 客户端状态</span></pre>
<p>回滚只需切换 <code>current.json</code> 的软链接目标,客户端代码零改动。</p>
<h3>7.4 更新策略</h3>
<ul>
<li>默认每 6 小时检查一次 manifest</li>
<li>客户端启动时检查一次</li>
<li>遇到解析失败时,主动触发该站点的配置更新</li>
<li>配置签名(JWS)在 Phase 2 引入,客户端用内嵌公钥验证真实性<span class="phase-tag phase-p2">P2</span></li>
</ul>
</section>
<!-- ========== 8. 质量与信任体系 ========== -->
<section id="sec8">
<h2>8. 质量与信任体系</h2>
<h3>8.1 校验流水线</h3>
<div class="diagram">
<pre class="mermaid">
flowchart TD
SUBMIT[提交配置] --> SCHEMA[1. JSON Schema 校验]
SCHEMA -->|通过| SELECTOR[2. 选择器语法检查]
SCHEMA -->|失败| ERR1[返回错误]
SELECTOR -->|通过| SMOKE["3. 冒烟测试 - 可选(Phase 2)"]
SELECTOR -->|失败| ERR2[返回错误]
SMOKE -->|通过/跳过| ROUTE[4. 信任路由]
ROUTE -->|trusted+ : 直接发布| PUBLISH[发布为 stable]
ROUTE -->|member : 需审核| REVIEW[进入审核队列]
</pre>
<figcaption>图 4:配置校验流水线</figcaption>
</div>
<div class="callout callout-warn">
<p class="callout-title">Phase 1 vs Phase 2</p>
<p>Phase 1 仅执行第 1 步(JSON Schema 校验)和第 2 步(选择器语法检查)。第 3 步冒烟测试为 <span class="phase-tag phase-p2">P2</span>,需要自动化请求站点页面验证选择器有效性。</p>
</div>
<h3>8.2 三级信任体系</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>等级</th><th>获取方式</th><th>发布权限</th><th>审核权限</th><th>其他</th></tr>
</thead>
<tbody>
<tr>
<td><strong>member</strong></td>
<td>手动注册(邮箱 + 密码)</td>
<td>发布(需审核)</td>
<td>无</td>
<td>审核超时 72h 自动通知<span class="phase-tag phase-p2">通知为 P2</span></td>
</tr>
<tr>
<td><strong>trusted</strong></td>
<td>账户维度:连续 5 次发布无驳回 + 至少维护 2 个站点</td>
<td>免审核发布</td>
<td>审核他人配置</td>
<td>可紧急回滚</td>
</tr>
<tr>
<td><strong>admin</strong></td>
<td>项目维护者指定</td>
<td>全部</td>
<td>全部</td>
<td>管理信任等级 + 账户管理</td>
</tr>
</tbody>
</table>
</div>
<h3>8.3 统一审核模型</h3>
<p>站点元信息变更和配置变更共享同一个审核队列。站点元信息也版本化,采用"提交 -> 审核 -> 发布"流程。</p>
<h4>站点信息变更的权限矩阵</h4>
<div class="table-wrap">
<table>
<thead>
<tr><th>操作</th><th>member</th><th>trusted</th><th>admin</th></tr>
</thead>
<tbody>
<tr><td>新建站点</td><td>提交 -> 审核</td><td>提交 -> 审核</td><td>直接生效</td></tr>
<tr><td>修改 tags/description</td><td>提交 -> 审核</td><td>直接生效</td><td>直接生效</td></tr>
<tr><td>修改 domains</td><td>提交 -> 审核</td><td>提交 -> 审核</td><td>直接生效</td></tr>
<tr><td>修改 siteType</td><td>不允许</td><td>提交 -> 审核</td><td>直接生效</td></tr>
<tr><td>弃用站点</td><td>不允许</td><td>不允许</td><td>直接生效<span class="phase-tag phase-p2">弃用归档为 P2</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- ========== 9. 引擎模板系统 (Phase 2) ========== -->
<section id="sec9">
<h2>9. 引擎模板系统 <span class="phase-tag phase-p2">P2</span></h2>
<div class="phase-section-p2">
<p>对于同一引擎类型的站点(如 NexusPHP),80% 的配置是相同的。模板系统减少配置编写工作量。整个引擎模板系统属于 Phase 2 功能范围。</p>
<h3>9.1 模板继承模型</h3>
<div class="diagram">
<pre class="mermaid">
flowchart LR
TEMPLATE["引擎模板<br>nexusphp-default-v2.json<br>(服务端维护)<br>默认选择器 + 转换器 + capabilities"]
SITE["站点配置<br>ptfans-config.json<br>(提交者编写)<br>覆盖差异部分"]
RESULT["最终存储的完整配置<br>ptfans-v3.json<br>(不可变快照)"]
TEMPLATE -->|"发布时自动合并"| RESULT
SITE -->|"overrides"| RESULT
</pre>
<figcaption>图 5:模板继承模型</figcaption>
</div>
<div class="callout callout-tip">
<p class="callout-title">关键原则</p>
<p>合并发生在发布时,不在运行时。客户端获取到的永远是完整的、可直接使用的配置,不需要了解模板系统的存在。</p>
</div>
<h3>9.2 差异提交</h3>
<p>发布配置时支持只写差异部分:</p>
<pre><span class="keyword">POST</span> /v1/configs/ptfans
{
<span class="string">"extends"</span>: <span class="string">"nexusphp@v2"</span>, <span class="comment">// 继承哪个模板</span>
<span class="string">"overrides"</span>: { <span class="comment">// 只写差异部分</span>
<span class="string">"siteId"</span>: <span class="string">"ptfans"</span>,
<span class="string">"domains"</span>: [<span class="string">"ptfans.cc"</span>],
<span class="string">"pages"</span>: {
<span class="string">"search"</span>: {
<span class="string">"fields"</span>: {
<span class="string">"title"</span>: { <span class="string">"selector"</span>: <span class="string">"td.name a font"</span>, <span class="string">"type"</span>: <span class="string">"text"</span> }
}
}
}
},
<span class="string">"changelog"</span>: <span class="string">"覆盖默认标题选择器"</span>
}</pre>
</div>
</section>
<!-- ========== 10. 前端设计 ========== -->
<section id="sec10">
<h2>10. 前端设计</h2>
<h3>10.1 mediabot 侧(主要前端)</h3>
<p>mediabot 是面向用户的完整前端,承担以下职责:</p>
<h4>站点录入表单</h4>
<p>mediabot 适配器管理页面提供 [新增站点] 按钮,填写 siteId(自动校验可用性)、siteName、siteType(下拉选择)、domains、tags。支持 [仅本地创建] 和 [提交到 PAR] 两种模式。</p>
<h4>适配器编辑器</h4>
<p>mediabot 内置的可视化编辑器,支持实时预览解析结果。编辑完成后通过 PAR API 提交发布。</p>
<h4>PAR 账户注册与登录</h4>
<p>mediabot 设置页提供 PAR 账户管理入口。用户首次点击"发布到 PAR"时,mediabot 引导用户注册(邮箱 + 密码)或登录,获取 API Key 后本地加密存储。后续写入操作自动携带 API Key 和 HMAC 请求签名。</p>
<h4>多账户管理 <span class="phase-tag phase-p1">P1</span></h4>
<p>mediabot 支持在同一设备上管理多个 PAR 账户。用户在 mediabot 本地添加多个账户,每个账户独立存储 API Key。切换账户时 mediabot 自动使用对应账户的 API Key 和 HMAC 签名。PAR 端不感知多账户关系,每个账户独立认证、独立计算信任等级。</p>
<h3>10.2 PAR Admin(管理面板)</h3>
<p>纯内部管理工具,只有 admin 和 trusted 用户使用。API Key 直接登录,不需要自建登录系统。</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>页面</th><th>路径</th><th>功能</th><th>优先级</th></tr>
</thead>
<tbody>
<tr>
<td>仪表盘</td><td><code>/</code></td>
<td>站点总数、配置总数、待审核数、近期活动时间线</td>
<td><span class="phase-tag phase-p1">P1</span></td>
</tr>
<tr>
<td>审核工作台</td><td><code>/reviews</code></td>
<td>待审核队列(配置变更 + 站点变更混合)、配置 diff 对比、批准/驳回</td>
<td><span class="phase-tag phase-p1">P1</span></td>
</tr>
<tr>
<td>用户管理</td><td><code>/admin/users</code></td>
<td>用户列表、信任等级调整、封禁/解封、账户操作记录</td>
<td><span class="phase-tag phase-p1">P1</span></td>
</tr>
<tr>
<td>站点管理</td><td><code>/admin/sites</code></td>
<td>站点列表、版本历史、回滚操作</td>
<td><span class="phase-tag phase-p1">P1</span></td>
</tr>
<tr>
<td>模板管理</td><td><code>/admin/templates</code></td>
<td>引擎模板列表、版本管理、编辑模板</td>
<td><span class="phase-tag phase-p2">P2</span></td>
</tr>
<tr>
<td>全局设置</td><td><code>/admin/settings</code></td>
<td>Schema 版本管理、冒烟测试开关、公钥轮换</td>
<td><span class="phase-tag phase-p2">P2</span></td>
</tr>
</tbody>
</table>
</div>
<h4>技术选型</h4>
<ul>
<li>React + Vite(或其他 SPA 框架)</li>
<li>Monaco Diff Editor(审核时的配置对比)</li>
<li>部署方式:Spring Boot 直接托管 SPA 静态资源,<code>/admin/*</code> 路径由 Spring Boot 的 <code>ResourceHandler</code> 处理,无需 Nginx</li>
</ul>
</section>
<!-- ========== 11. 认证、HMAC 签名与安全设计 ========== -->
<section id="sec11">
<h2>11. 认证、HMAC 签名与安全设计</h2>
<h3>11.1 认证模型:手动注册 + 登录</h3>
<p>PAR 采用简单的账户模型,取消了两层模型(Account + Identity)和自动关联机制:</p>
<ul>
<li><strong>读取完全公开</strong>:任何人无需注册即可下载配置,降低客户端接入成本。</li>
<li><strong>写入需要注册</strong>:使用邮箱 + 密码手动注册,密码使用 bcrypt 存储。</li>
<li><strong>登录获取 API Key</strong>:登录成功后返回 API Key,后续写入请求用 API Key 进行 HMAC 请求签名认证。</li>
<li><strong>找回密码</strong>:通过邮件验证码重置密码。</li>
</ul>
<h3>11.2 HMAC 请求签名</h3>
<p>所有写入 API 除 <code>Authorization: Bearer {apiKey}</code> 外,还必须携带 HMAC 请求签名,防止 API Key 泄露后被滥用。</p>
<h4>签名机制</h4>
<ul>
<li><strong>请求头</strong>:
<ul>
<li><code>X-Timestamp</code>:Unix 时间戳(秒)</li>
<li><code>X-Signature</code>:HMAC-SHA256 签名值(Base64 编码)</li>
</ul>
</li>
<li><strong>签名内容</strong>:
<pre>HTTP方法 + "\n" + 请求路径 + "\n" + 时间戳 + "\n" + 请求体SHA-256</pre>
<p>无请求体时,最后一项为空字符串。</p>
</li>
<li><strong>签名计算</strong>:<code>HMAC-SHA256(apiKey, 签名内容)</code></li>
</ul>
<h4>PAR 验证逻辑</h4>
<ol>
<li>时间戳校验:与服务器时间差 &lt;= 60 秒</li>
<li>签名匹配:使用同一 apiKey 重新计算签名,与请求头对比</li>
<li>防重放:缓存最近 60 秒内的签名,重复签名直接拒绝</li>
</ol>
<div class="callout callout-tip">
<p class="callout-title">为什么读取 API 不需要签名</p>
<p>读取 API 是公开资源,不需要 API Key,自然也不需要 HMAC 签名。这保证了客户端(包括未注册用户)可以零成本获取配置。HMAC 签名仅保护写入操作,即使 API Key 在传输中被截获,攻击者也无法在 60 秒窗口外重放请求。</p>
</div>
<h3>11.3 注册与登录流程</h3>
<div class="diagram">
<pre class="mermaid">
sequenceDiagram
participant U as mediabot 用户
participant M as mediabot
participant P as PAR
U->>M: 点击"发布到 PAR"
M->>M: 提示注册或登录
alt 注册
U->>M: 输入邮箱 + 密码
M->>P: POST /v1/account/register<br>{email, password, anonymousId?}
Note right of P: bcrypt 存储密码<br>生成 API Key
P-->>M: 返回 accountId
M-->>U: 注册成功,请登录
end
U->>M: 输入邮箱 + 密码
M->>P: POST /v1/account/login<br>{email, password}
Note right of P: 验证 bcrypt 密码<br>返回 API Key
P-->>M: 返回 apiKey + trustLevel
M->>M: 本地加密存储 apiKey
U->>M: 提交配置发布
M->>M: 计算 HMAC 签名<br>X-Timestamp + X-Signature
M->>P: POST /v1/configs/ptfans<br>Authorization: Bearer {apiKey}<br>X-Timestamp: 1234567890<br>X-Signature: hmac_sha256(...)
Note right of P: 验证时间戳<br>验证 HMAC 签名<br>防重放检查
P-->>M: 返回校验结果 + 版本号
M-->>U: 显示发布结果
</pre>
<figcaption>图 6:mediabot -> PAR 注册登录与 HMAC 签名流程</figcaption>
</div>
<h3>11.4 API Key 安全实现</h3>
<ul>
<li><strong>生成</strong>:<code>mbt_</code> 前缀 + 32 字节 <code>secrets.token_urlsafe</code> 随机值</li>
<li><strong>存储</strong>:数据库只存 SHA-256 哈希,明文只在登录响应中返回</li>
<li><strong>验证</strong>:客户端发送 Bearer token + HMAC 签名 -> 服务端计算哈希查库 -> 校验签名 -> 校验账户权限</li>
<li><strong>日志脱敏</strong>:只记录前 8 字符前缀(<code>mbt_k7x9...</code>)</li>
<li><strong>重置/刷新</strong>:支持主动刷新,旧 key 5 分钟宽限期后失效</li>
</ul>
<h3>11.5 密码安全</h3>
<ul>
<li><strong>存储</strong>:bcrypt(cost factor 12+),不存明文密码</li>
<li><strong>重置</strong>:邮件验证码 15 分钟有效期,使用后立即失效</li>
<li><strong>限制</strong>:连续 5 次登录失败锁定账户 15 分钟</li>
</ul>
<h3>11.6 配置签名 <span class="phase-tag phase-p2">P2</span></h3>
<div class="phase-section-p2">
<p>每个配置版本由 registry 私钥签名(JWS 格式),客户端用内嵌公钥验证。确保即使通过非安全渠道获取配置,也能确认来源可信。<code>checksum</code>(SHA-256)保证完整性,<code>signature</code> 保证真实性,两者缺一不可。Phase 1 暂不启用配置签名。</p>
</div>
</section>
<!-- ========== 12. 匿名统计与注册关联 ========== -->
<section id="sec12">
<h2>12. 匿名统计与注册关联</h2>
<h3>12.1 设计目标</h3>
<p>PAR 需要了解配置的活跃使用情况(哪些站点配置下载最多、有多少活跃用户),但不想强制用户注册即可读取配置。匿名统计机制在保护隐私的前提下提供基础使用数据:</p>
<ul>
<li><strong>完全可选</strong>:客户端可选择不发送匿名 ID,不影响任何功能</li>
<li><strong>隐私保护</strong>:匿名 ID 是邮箱的 SHA-256 哈希,不可逆推原始邮箱</li>
<li><strong>注册后自动关联</strong>:同一邮箱的哈希值匹配,匿名数据与注册账户自动关联</li>
<li><strong>用户可控</strong>:用户可在 mediabot 设置中关闭匿名统计</li>
</ul>
<h3>12.2 匿名 ID 生成</h3>
<p>mediabot 计算用户邮箱的 SHA-256 哈希作为 <code>anonymous_id</code>:</p>
<pre>anonymous_id = SHA-256(email.trim().toLowerCase())</pre>
<p>读取请求可选携带 <code>X-Anonymous-Id: {anonymous_id}</code> 请求头。PAR 用此 ID 统计活跃用户数、配置下载热度等。</p>
<div class="callout callout-info">
<p class="callout-title">为什么不直接用 deviceId</p>
<p>deviceId 会暴露设备指纹,且重装后变化。邮箱哈希虽然也不完美,但用户更换邮箱的概率低于重装工具,且哈希不可逆推,隐私风险可控。用户关闭统计后,mediabot 不再发送该头。</p>
</div>
<h3>12.3 注册时自动关联</h3>
<p>用户注册时,mediabot 可选择将当前 <code>anonymous_id</code> 一并提交:</p>
<pre>POST /v1/account/register
{
<span class="string">"email"</span>: <span class="string">"user@qq.com"</span>,
<span class="string">"password"</span>: <span class="string">"yourPassword123"</span>,
<span class="string">"anonymousId"</span>: <span class="string">"sha256_of_user@qq.com"</span> <span class="comment">// 可选</span>
}</pre>
<p>PAR 收到后:</p>
<ol>
<li>创建账户,计算该邮箱的 SHA-256 哈希</li>
<li>如果 <code>anonymous_stats</code> 表中存在匹配的 <code>anonymous_id</code>,将 <code>linked_account_id</code> 指向新账户</li>
<li>更新 <code>accounts.linked_anonymous_id</code> 字段</li>
<li>后续该用户的匿名统计数据即与注册账户关联</li>
</ol>
<h3>12.4 数据统计用途</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>指标</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>活跃用户数</td><td>按 anonymous_id 去重统计最近 30 天有读取请求的用户数</td></tr>
<tr><td>配置下载热度</td><td>各站点配置的下载次数排名</td></tr>
<tr><td>注册转化率</td><td>匿名用户中后续注册并关联的比例</td></tr>
<tr><td>客户端分布</td><td>按 User-Agent 分析使用的工具类型和版本</td></tr>
</tbody>
</table>
</div>
<h3>12.5 隐私与关闭机制</h3>
<ul>
<li>mediabot 默认开启匿名统计,但用户可在设置中关闭</li>
<li>关闭后,mediabot 不再发送 <code>X-Anonymous-Id</code> 头</li>
<li>PAR 不存储任何可逆推个人身份的信息(只存 SHA-256 哈希)</li>
<li>匿名统计数据保留 90 天,过期自动清理</li>
<li>用户注销账户时,可选择删除关联的匿名统计数据</li>
</ul>
<div class="callout callout-tip">
<p class="callout-title">与 GDPR 的兼容性</p>
<p>匿名 ID 是单向哈希,技术上无法还原为邮箱。如果用户关闭统计,PAR 不记录任何该用户的数据。注销时删除关联数据即可满足"被遗忘权"要求。</p>
</div>
</section>
<!-- ========== 13. 站点元信息版本化 ========== -->
<section id="sec13">
<h2>13. 站点元信息版本化</h2>
<p>mediabot 侧提供站点信息编辑入口,提交到 PAR 后进入审核流程。站点元信息和配置版本共享统一的"提交 -> 审核 -> 发布"模型。</p>
<h3>13.1 版本历史可追溯</h3>
<pre><span class="keyword">GET</span> /v1/sites/ptfans/meta/versions
→ {
<span class="string">"versions"</span>: [
{ <span class="string">"version"</span>: <span class="number">3</span>, <span class="string">"status"</span>: <span class="string">"published"</span>,
<span class="string">"domains"</span>: [<span class="string">"ptfans.to"</span>, <span class="string">"ptfans.org"</span>],
<span class="string">"changelog"</span>: <span class="string">"站点域名迁移到 .to"</span>,
<span class="string">"submittedBy"</span>: <span class="string">"user@qq.com"</span> },
{ <span class="string">"version"</span>: <span class="number">2</span>, <span class="string">"status"</span>: <span class="string">"published"</span>,
<span class="string">"domains"</span>: [<span class="string">"ptfans.cc"</span>],
<span class="string">"changelog"</span>: <span class="string">"更新描述信息"</span> }
]
}</pre>
<h3>13.2 审核工作台统一视图</h3>
<p>PAR Admin 审核工作台混合展示配置变更和站点信息变更,支持按类型筛选:</p>
<pre>/reviews(审核工作台)
├── 待审核队列(混合显示)
│ ├── [config] ptfans v4 — 修复搜索选择器
│ ├── [site] ptfans 域名变更为 ptfans.to
│ ├── [config] hdfans v2 — 新增签到页解析
│ └── [site] hdfans 新增备用域名
├── 筛选标签:全部 / 配置变更 / 站点变更
└── 操作:批准 / 驳回(驳回必填原因)</pre>
</section>
<!-- ========== 14. Schema 演进与站点生命周期 ========== -->
<section id="sec14">
<h2>14. Schema 演进与站点生命周期</h2>
<h3>14.1 Schema 演进策略</h3>
<p>Schema 版本号规则:主版本.次版本(如 2.0 -> 2.1 -> 3.0)</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>类型</th><th>范围</th><th>客户端处理</th></tr>
</thead>
<tbody>
<tr><td>次版本更新(向后兼容)</td><td>新增可选字段、新增 transform 类型、新增 page key</td><td>忽略未知字段</td></tr>
<tr><td>主版本更新(不兼容)</td><td>删除/重命名核心字段、改变字段语义</td><td>需要迁移工具适配</td></tr>
</tbody>
</table>
</div>
<p>每个配置快照记录 <code>$schema</code> 字段,Manifest 中声明 <code>minClientSchemaVersion</code>,客户端发现不兼容时提示升级。通过 <code>GET /v1/schemas</code> 接口提供所有 Schema 版本的 JSON Schema 文档。<span class="phase-tag phase-p2">OpenAPI 规范自动生成 API 文档为 Phase 2</span></p>
<h3>14.2 站点弃用与归档 <span class="phase-tag phase-p2">P2</span></h3>
<div class="phase-section-p2">
<p>PT 站点会关闭、合并、更换域名,PAR 提供站点生命周期管理:</p>
<pre><span class="keyword">POST</span> /v1/sites/{siteId}/deprecate
Authorization: Bearer {apiKey}
{
<span class="string">"reason"</span>: <span class="string">"站点已关闭"</span>,
<span class="string">"redirectSiteId"</span>: <span class="string">"newsite"</span>,
<span class="string">"sunsetAt"</span>: <span class="string">"2026-08-01"</span>
}</pre>
<div class="flow-box">
<div class="flow-item">active</div>
<div class="flow-arrow">→</div>
<div class="flow-item">deprecated</div>
<div class="flow-arrow">→</div>
<div class="flow-item">inactive</div>
<div class="flow-arrow">→</div>
<div class="flow-item">archived</div>
</div>
<p>Manifest 中增加 <code>status</code> 和 <code>deprecation</code> 信息,客户端据此引导用户迁移。</p>
</div>
</section>
<!-- ========== 15. 通知、限流与监控 ========== -->
<section id="sec15">
<h2>15. 通知、限流与监控</h2>
<h3>15.1 通知机制 <span class="phase-tag phase-p2">P2</span></h3>
<div class="phase-section-p2">
<p>双通道通知:Webhook(可选,给实时系统用)+ 邮件(兜底,审核结果通知提交者)。</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>事件</th><th>触发时机</th><th>通知对象</th></tr>
</thead>
<tbody>
<tr><td><code>config.pending_review</code></td><td>member 提交配置</td><td>审核员</td></tr>
<tr><td><code>config.approved</code></td><td>审核通过</td><td>提交者</td></tr>
<tr><td><code>config.rejected</code></td><td>审核驳回</td><td>提交者</td></tr>
<tr><td><code>config.published</code></td><td>新版本发布</td><td>订阅 Webhook 的系统</td></tr>
<tr><td><code>config.rolled_back</code></td><td>执行回滚</td><td>订阅 Webhook 的系统</td></tr>
<tr><td><code>site.deprecated</code></td><td>站点标记为弃用</td><td>订阅 Webhook 的系统</td></tr>
</tbody>
</table>
</div>
</div>
<h3>15.2 API 限流(写入侧)</h3>
<p>读取不限流(公开资源),写入 API 按账户限流:</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>接口</th><th>限流规则</th></tr>
</thead>
<tbody>
<tr><td><code>POST /v1/configs/*</code></td><td>每账户每分钟 10 次,每小时 30 次</td></tr>
<tr><td><code>POST /v1/sites</code></td><td>每账户每小时 5 次</td></tr>
<tr><td><code>POST /v1/account/register</code></td><td>每 IP 每小时 5 次</td></tr>
<tr><td><code>POST /v1/account/login</code></td><td>每 IP 每分钟 10 次,连续失败 5 次锁定 15 分钟</td></tr>
<tr><td><code>POST /v1/account/forgot-password</code></td><td>每 IP 每小时 3 次</td></tr>
<tr><td><code>POST /v1/account/reset-key</code></td><td>每 API Key + HMAC 每天 3 次</td></tr>
<tr><td>其他管理 API</td><td>每 API Key 每分钟 30 次</td></tr>
</tbody>
</table>
</div>
<p>响应头携带 <code>X-RateLimit-Limit</code>、<code>X-RateLimit-Remaining</code>、<code>X-RateLimit-Reset</code>,超限返回 <code>429 Too Many Requests</code>。</p>
<h3>15.3 监控与告警</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>指标</th><th>告警阈值</th><th>含义</th></tr>
</thead>
<tbody>
<tr><td>配置下载成功率</td><td>&lt; 99.5%</td><td>Spring Boot 服务或存储故障</td></tr>
<tr><td>API 响应延迟 P99</td><td>&gt; 2s</td><td>数据库或服务端问题</td></tr>
<tr><td>待审核队列积压</td><td>&gt; 24h</td><td>审核员不活跃</td></tr>
<tr><td>冒烟测试失败率</td><td>&gt; 10%(单站点)</td><td>站点可能改版<span class="phase-tag phase-p2">P2</span></td></tr>
</tbody>
</table>
</div>
<p>通过 <code>GET /v1/health</code> 接口暴露健康状态,结合 UptimeRobot 等外部监控即可满足需求。</p>
</section>
</main>
<footer>
<p style="text-align:center;color:var(--muted);font-size:0.82rem;margin-bottom:1.5rem;">
PAR System Design Document v2.0 &mdash; 2026-06-29 — 最终设计定稿
</p>
</footer>
</article>
<script src="./_shared/js/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
securityLevel: 'loose',
themeVariables: {
fontFamily: 'WorkSans, system-ui, sans-serif',
fontSize: '14px'
}
});
</script>
</body>
</html>