1912 lines
107 KiB
HTML
1912 lines
107 KiB
HTML
<!-- 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&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&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 时间戳(秒),与服务器时间差必须 <= 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>时间戳校验:与服务器时间差 <= 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>< 99.5%</td><td>Spring Boot 服务或存储故障</td></tr>
|
||
<tr><td>API 响应延迟 P99</td><td>> 2s</td><td>数据库或服务端问题</td></tr>
|
||
<tr><td>待审核队列积压</td><td>> 24h</td><td>审核员不活跃</td></tr>
|
||
<tr><td>冒烟测试失败率</td><td>> 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 — 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> |