Files
pangolin/docs/dev-conventions.html
wangjia 29ddd5b453 docs(test): 测试框架架构 + 开发规范五支柱文档
- docs/test-architecture.html:分层测试金字塔(L0契约→L4 E2E) + 契约接缝
  数据流 + 四类诉求映射 + CI 流水线 + E2E 拓扑,5 张内联 SVG/表格图
- docs/dev-conventions.html:可测试性五支柱(接缝即接口/契约单源/纯逻辑分离/
  错误是值/可观测) + 支柱↔测试层咬合矩阵 + 反例对照表 + 执行机制四道闸
- docs/index.html:登记两份文档到知识库/调研,双向互链

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:25:14 +08:00

294 lines
22 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 开发规范 · 可测试性五支柱</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 28px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16.5px;margin:24px 0 8px;color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline}
ul{margin:8px 0;padding-left:22px}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
.note{color:var(--fg2);font-size:13px;border-left:3px solid var(--border);padding-left:12px;margin:10px 0}
.fig{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;margin:18px 0;overflow-x:auto}
.fig .cap{color:var(--fg2);font-size:12.5px;margin:0 0 10px;font-weight:600;letter-spacing:.02em}
.fig svg{display:block;max-width:100%;height:auto}
.pillar{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0}
.pillar .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-bottom:8px;background:rgba(224,136,79,.16);color:var(--accent)}
.pillar .rule{font-size:15px;margin:6px 0 12px;padding:10px 12px;background:var(--panel2);border-radius:8px;border-left:3px solid var(--accent)}
.kv{margin:6px 0}
.kv .k{display:inline-block;min-width:84px;color:var(--fg2);font-size:12.5px;font-weight:700;vertical-align:top}
.kv .v{display:inline-block;width:calc(100% - 92px);font-size:13.5px;vertical-align:top}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--fg);font-weight:600}
td{color:var(--fg2)}
td b,td strong{color:var(--fg)}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;white-space:nowrap}
.t-ok{background:rgba(94,194,122,.16);color:var(--ok)}
.t-warn{background:rgba(224,184,79,.16);color:var(--warn)}
.t-bad{background:rgba(224,106,106,.16);color:var(--bad)}
.ex-ok{color:var(--ok);font-weight:700}
.ex-bad{color:var(--bad);font-weight:700}
.kbd{font-family:var(--mono);font-size:.86em;color:#f0d9c4}
.xref{display:inline-block;margin-top:6px;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<h1>Pangolin 开发规范 · 可测试性五支柱</h1>
<p class="sub">「怎么写才好测」——开发规范是可测试性的前置条件 · 与测试框架咬合</p>
<div class="lead">
<strong>核心立场:</strong>测试做不做得起来,根子在代码<b>有没有遵守可测试的结构约定</b>。哪里没遵守,哪里就测不了。这份规范把决定「能不能自动化测」的约定收成 <b>5 条支柱</b>,每条回答三件事:<b>怎么写 → 所以测得了 → 由哪层守门</b>。它与 <a href="test-architecture.html">测试框架架构说明</a> 一一咬合:规范定义接缝,测试在接缝下刀。
<div class="xref">↔ 配套文档:<a href="test-architecture.html">统一测试框架 · 架构说明(L0–L4 分层)</a></div>
</div>
<h2>五支柱速览</h2>
<p>写法采用<b>通用原则 + Pangolin 实例</b>双层——原则可复制到任何多端项目,实例替换即可。</p>
<div class="pillar">
<span class="badge">支柱 1 · 接缝即接口</span>
<div class="rule">所有跨进程 / 外部依赖(原生隧道、HTTP、子进程、存储、<b>时钟、随机</b>)藏在<b>接口 / 可注入构造</b>之后;业务逻辑里禁止直接 <code>new</code> 或调静态单例。分层<b>单向依赖</b>(token→实现→组件/页面),生成物 <code>*.gen.dart</code> 勿手改。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">L2 集成/交互能换假替身,无需真依赖即可驱动真实路径。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">每个外部依赖都存在 Fake/Mock + 一条 flow 测试;依赖方向 lintdart import / go internal 包边界)。</span></div>
<div class="kv"><span class="k">正 / 反例</span><span class="v"><span class="ex-ok"></span> <code>VpnBridge</code> 接口 → 可换 <code>VpnBridgeMock</code><code>AuthApi(baseUrl, http.Client)</code> 可注入。<span class="ex-bad"></span> <code>NETunnelProviderManager</code> 直接散在 UI / 业务里 new。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 2 · 契约单源</span>
<div class="rule">每段跨边界数据(<code>pangolin/vpn/stats</code> EventChannel、HTTP JSON、gRPC <code>UsageEntry</code> proto、设计 token)有<b>唯一定义源</b>,两端从它<b>生成或校验</b>,禁止两端各写各的。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">L0 契约快照能守住「字段形状」,接缝两端永不漂移。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">契约快照测试(字段 diff=0)+ codegen 漂移检查(<code>colors_and_type.css</code><code>*.gen.dart</code>、proto→桩,生成物与源一致)。</span></div>
<div class="kv"><span class="k">针对</span><span class="v">这条正是「mac 实时统计恒为 0」的根因解药——两端各写 stats、无单源、无守门。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 3 · 纯逻辑与 IO 分离</span>
<div class="rule">计算 / 换算 / 状态机是<b>纯函数</b>,不混时间、网络、IO、随机;这些都当「可注入依赖」从外面传,IO 收进薄壳(functional core / imperative shell)。<b>纯核心天然确定 → 不 flaky。</b></div>
<div class="kv"><span class="k">测试红利</span><span class="v">L1 单元无依赖、可重复、毫秒级;确定性是 E2E 不 flaky 的前提。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">覆盖率阈值;纯函数测试<b>零 mock</b>;时钟/随机可注入。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>bytes→速率/时长</code> 格式化、配额计算、<b>时间 Go 端算好传 <code>?</code></b>(不用 MySQL <code>NOW()</code>)、golden <b>字体锁定</b></span></div>
</div>
<div class="pillar">
<span class="badge">支柱 4 · 错误是有类型的值</span>
<div class="rule">错误带<b>类型 / 码</b>、可断言;<b>禁止吞异常、禁止只打日志不返回</b>。失败路径和成功路径一样要可观测、可测。</div>
<div class="kv"><span class="k">测试红利</span><span class="v">测试能断言失败分支(401、超额、连接失败),而不是只测 happy path。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">错误路径用例;lint 禁空 <code>catch {}</code> / 裸吞;错误类型可枚举。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>AuthApiException(statusCode, messageZh, messageEn)</code>——带码、带双语、可断言。</span></div>
</div>
<div class="pillar">
<span class="badge">支柱 5 · 可观测即可断言</span>
<div class="rule">关键状态 / 事件经<b>稳定通道</b>暴露(status / stats stream、结构化日志、<code>/healthz</code><code>/v1/usage</code>),让测试<b>读数值断言,而非截屏猜</b></div>
<div class="kv"><span class="k">测试红利</span><span class="v">L4 端到端能断言:bytes 增长、限流触发、延迟 &gt;0、状态流转。</span></div>
<div class="kv"><span class="k">验证钩子</span><span class="v">每个关键状态有可程序读取的出口;E2E 冒烟据此断言。</span></div>
<div class="kv"><span class="k">正例</span><span class="v"><code>VpnStatusStreamHandler</code> / <code>stats</code> EventChannel / <code>/v1/usage</code> 数值出口。</span></div>
</div>
<h2>支柱 ↔ 测试层 咬合矩阵</h2>
<p>每条支柱直接<b>解锁</b>测试架构里的某些层。这张图就是规范与测试的咬合证明:</p>
<div class="fig">
<p class="cap">图 1 · 五支柱解锁五测试层(● 主要解锁 ○ 间接帮助)</p>
<svg viewBox="0 0 760 380" role="img" aria-label="支柱与测试层咬合矩阵">
<!-- column headers -->
<g text-anchor="middle" font-size="12" font-weight="700" fill="var(--fg)">
<text x="345" y="34">L0</text><text x="345" y="50" fill="var(--accent2)" font-size="11">契约</text>
<text x="440" y="34">L1</text><text x="440" y="50" fill="var(--ok)" font-size="11">单元</text>
<text x="535" y="34">L2</text><text x="535" y="50" fill="var(--accent2)" font-size="11">集成·交互</text>
<text x="630" y="34">L3</text><text x="630" y="50" fill="var(--accent)" font-size="11">视觉</text>
<text x="710" y="34">L4</text><text x="710" y="50" fill="var(--bad)" font-size="11">E2E</text>
</g>
<!-- grid -->
<g stroke="var(--border)" stroke-width="1">
<line x1="300" y1="62" x2="755" y2="62"/>
<line x1="300" y1="122" x2="755" y2="122"/>
<line x1="300" y1="177" x2="755" y2="177"/>
<line x1="300" y1="232" x2="755" y2="232"/>
<line x1="300" y1="287" x2="755" y2="287"/>
<line x1="300" y1="342" x2="755" y2="342"/>
<line x1="300" y1="62" x2="300" y2="342"/>
<line x1="392" y1="62" x2="392" y2="342" stroke-dasharray="2 3"/>
<line x1="487" y1="62" x2="487" y2="342" stroke-dasharray="2 3"/>
<line x1="582" y1="62" x2="582" y2="342" stroke-dasharray="2 3"/>
<line x1="675" y1="62" x2="675" y2="342" stroke-dasharray="2 3"/>
<line x1="755" y1="62" x2="755" y2="342"/>
</g>
<!-- row labels -->
<g font-size="12.5" fill="var(--fg)" font-weight="600">
<text x="14" y="96">支柱 1 · 接缝即接口</text>
<text x="14" y="151">支柱 2 · 契约单源</text>
<text x="14" y="206">支柱 3 · 纯逻辑/IO 分离</text>
<text x="14" y="261">支柱 4 · 错误是值</text>
<text x="14" y="316">支柱 5 · 可观测</text>
</g>
<!-- dots: ● solid (strong), ○ open (weak) -->
<g>
<!-- 支柱1 (y92): L1○ L2● L4○ -->
<circle cx="440" cy="92" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="535" cy="92" r="8" fill="var(--accent2)"/>
<circle cx="710" cy="92" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱2 (y147): L0● L3○ -->
<circle cx="345" cy="147" r="8" fill="var(--accent2)"/>
<circle cx="630" cy="147" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱3 (y202): L1● L2○ L3○ -->
<circle cx="440" cy="202" r="8" fill="var(--accent2)"/>
<circle cx="535" cy="202" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="630" cy="202" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱4 (y257): L1● L2● L4○ -->
<circle cx="440" cy="257" r="8" fill="var(--accent2)"/>
<circle cx="535" cy="257" r="8" fill="var(--accent2)"/>
<circle cx="710" cy="257" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<!-- 支柱5 (y312): L2○ L4● -->
<circle cx="535" cy="312" r="7" fill="none" stroke="var(--accent2)" stroke-width="2"/>
<circle cx="710" cy="312" r="8" fill="var(--accent2)"/>
</g>
<!-- legend -->
<g font-size="11.5" fill="var(--fg2)">
<circle cx="312" cy="366" r="7" fill="var(--accent2)"/><text x="326" y="370">主要解锁</text>
<circle cx="430" cy="366" r="6" fill="none" stroke="var(--accent2)" stroke-width="2"/><text x="444" y="370">间接帮助</text>
</g>
</svg>
</div>
<p class="note">读法:补齐<b>支柱 2</b>(契约单源)就点亮 <b>L0</b>;做好<b>支柱 1</b> 才有 <b>L2</b> 的假桥;<b>支柱 5</b><b>L4</b> 端到端能断言的唯一前提。规范不是道德要求,是测试能力的开关。</p>
<h2>反例 → 真实 bug → 本可由哪条支柱防住</h2>
<p>规范的说服力来自真实的坑。下表把项目里遇到/潜在的问题,回溯到被违反的支柱:</p>
<table>
<thead><tr><th style="width:26%">现象 / 真实 bug</th><th style="width:38%">根因(违反了什么)</th><th style="width:36%">本可由哪条支柱防住</th></tr></thead>
<tbody>
<tr>
<td><b>mac 实时统计恒为 0</b></td>
<td>原生侧产出、Dart 侧消费 <code>stats</code>,两端各写、无单源;触发条件一变即静默失效,无任何测试守门。</td>
<td><span class="tag t-bad">支柱 2 契约单源</span> + <span class="tag t-bad">支柱 5 可观测</span></td>
</tr>
<tr>
<td><b>延迟显示 stale / 拿不到</b><br><span class="small">Android·Windows urltest</span></td>
<td>延迟值没有稳定出口、没有契约校验,靠各端自行解析、刷新时机不一致。</td>
<td><span class="tag t-bad">支柱 5 可观测</span> + <span class="tag t-bad">支柱 2 契约单源</span></td>
</tr>
<tr>
<td><b>记账若用 MySQL 专属时间函数</b></td>
<td>时间/方言混进 SQL,无法在 SQLite 真库测、不可移植。</td>
<td><span class="tag t-bad">支柱 3 纯逻辑/IO 分离</span><br><span class="small">时间 Go 端算、方言走 dialect</span></td>
</tr>
<tr>
<td><b>连接交互测不了</b><br><span class="small">(通用风险)</span></td>
<td>连接逻辑直接依赖 <code>NETunnelProviderManager</code> / 真子进程,不连真隧道就无法驱动。</td>
<td><span class="tag t-bad">支柱 1 接缝即接口</span><br><span class="small">藏到 VpnBridge 后面</span></td>
</tr>
<tr>
<td><b>失败路径不可测 / UI 卡死</b><br><span class="small">(通用风险)</span></td>
<td>连接失败只打日志不返回、空 <code>catch</code> 吞异常,调用方拿不到可断言的错误。</td>
<td><span class="tag t-bad">支柱 4 错误是有类型的值</span></td>
</tr>
</tbody>
</table>
<h2>执行机制 · 四道闸</h2>
<p>规范只靠文档一定漂。执行 = 尽量把每条往<b>机器自动守门</b>推,推不动的用<b>脚手架降低犯错成本</b> + <b>评审清单兜底</b>。这不是一个动作,是从「写之前」卡到「push」的<b>四道闸</b>——越早拦,成本越低。</p>
<div class="fig">
<p class="cap">图 2 · 四道闸:从「写之前」预防到「push」硬闸,外加评审兜底</p>
<svg viewBox="0 0 760 250" role="img" aria-label="规范执行四道闸时间线">
<text x="20" y="18" font-size="11" fill="var(--fg2)">← 越早拦,成本越低</text>
<g text-anchor="middle" font-size="11.5" fill="var(--fg2)" font-weight="700">
<text x="95" y="42">写之前</text>
<text x="267" y="42">写的时候</text>
<text x="439" y="42">提交时</text>
<text x="638" y="42">push · PR</text>
</g>
<!-- gates -->
<rect x="20" y="58" width="150" height="112" rx="10" fill="rgba(94,194,122,.10)" stroke="var(--ok)" stroke-width="1.5"/>
<rect x="192" y="58" width="150" height="112" rx="10" fill="rgba(95,176,201,.10)" stroke="var(--accent2)" stroke-width="1.5"/>
<rect x="364" y="58" width="150" height="112" rx="10" fill="rgba(224,184,79,.10)" stroke="var(--warn)" stroke-width="1.5"/>
<rect x="536" y="58" width="204" height="112" rx="10" fill="rgba(224,106,106,.10)" stroke="var(--bad)" stroke-width="1.5"/>
<!-- gate texts -->
<g text-anchor="middle">
<text x="95" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 1 · 脚手架</text>
<text x="95" y="108" font-size="10.5" fill="var(--fg2)">接口 + Fake 骨架</text>
<text x="95" y="125" font-size="10.5" fill="var(--fg2)">codegen 默认</text>
<text x="95" y="155" font-size="11" font-weight="700" fill="var(--ok)">预防</text>
<text x="267" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 2 · lint/analyze</text>
<text x="267" y="108" font-size="10.5" fill="var(--fg2)">空 catch · import 方向</text>
<text x="267" y="125" font-size="10.5" fill="var(--fg2)">IDE 即时报</text>
<text x="267" y="155" font-size="11" font-weight="700" fill="var(--accent2)">即时</text>
<text x="439" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 3 · pre-commit</text>
<text x="439" y="108" font-size="10.5" fill="var(--fg2)">format · 快检</text>
<text x="439" y="125" font-size="10.5" fill="var(--fg2)">codegen 漂移检查</text>
<text x="439" y="155" font-size="11" font-weight="700" fill="var(--warn)">本地拦</text>
<text x="638" y="86" font-size="12.5" font-weight="700" fill="var(--fg)">闸 4 · CI 硬闸</text>
<text x="638" y="108" font-size="10.5" fill="var(--fg2)">全量测试 · 契约快照</text>
<text x="638" y="125" font-size="10.5" fill="var(--fg2)">覆盖率 · golden · E2E</text>
<text x="638" y="155" font-size="11" font-weight="700" fill="var(--bad)">挡 merge</text>
</g>
<!-- arrows -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="170" y1="114" x2="190" y2="114"/><polygon points="192,114 182,110 182,118"/>
<line x1="342" y1="114" x2="362" y2="114"/><polygon points="364,114 354,110 354,118"/>
<line x1="514" y1="114" x2="534" y2="114"/><polygon points="536,114 526,110 526,118"/>
</g>
<!-- review bar -->
<rect x="20" y="196" width="720" height="38" rx="8" fill="var(--panel2)" stroke="var(--border)" stroke-width="1"/>
<text x="380" y="219" text-anchor="middle" font-size="11.5" fill="var(--fg2)"><tspan fill="var(--accent)" font-weight="700">评审清单(人治兜底)</tspan> · 机器判不了的:接缝抽象是否合理 / 关键状态有没有留可观测出口</text>
</svg>
</div>
<ul>
<li><b>闸 1 · 脚手架(预防)</b>:让对的写法成默认路径——新模块生成器自带「接口 + Fake 骨架」,token/proto 一律走 codegen。不主动绕,自然合规。</li>
<li><b>闸 2 · lint/analyze(即时)</b><code>flutter analyze</code> + <code>go vet</code> + 自定义规则(空 <code>catch</code>、import 方向、业务层禁直接 new 外部依赖),编辑器当场红线。</li>
<li><b>闸 3 · pre-commit(本地把关)</b>:format + 快检 + 红线词扫描 + codegen 漂移检查(生成物与源不一致即拦,逼你别手改 <code>*.gen.dart</code>)。</li>
<li><b>闸 4 · CI 硬闸(守门,失败挡 merge)</b>:全量测试 + 覆盖率阈值 + 契约快照 diff=0 + 可移植 SQL 扫描 + golden 像素闸 +(按需)E2E。不可绕过的最终防线。</li>
</ul>
<p>诚实区分「能不能机器强制」——能强制的硬卡 CI,强制不了的靠脚手架 + 评审兜底:</p>
<table>
<thead><tr><th style="width:20%">支柱</th><th style="width:40%">主执行手段</th><th style="width:16%">强制度</th><th style="width:24%">现状</th></tr></thead>
<tbody>
<tr><td>1 接缝即接口</td><td>import 方向 lint + 脚手架 + 评审清单</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">待补</span></td></tr>
<tr><td>2 契约单源</td><td>契约快照 diff=0 + codegen 漂移检查</td><td><span class="tag t-ok">强制</span></td><td><span class="tag t-bad">待补</span><span class="small">OpenAPI 已有)</span></td></tr>
<tr><td>3 纯逻辑/IO 分离</td><td>覆盖率阈值 + 可移植 SQL 扫描</td><td><span class="tag t-ok">强制</span></td><td><span class="tag t-warn">部分已有</span></td></tr>
<tr><td>4 错误是值</td><td>lint 禁空 catch + 失败路径用例</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">待补</span></td></tr>
<tr><td>5 可观测</td><td>关键状态出口评审 + L4 冒烟据出口断言</td><td><span class="tag t-warn">半自动</span></td><td><span class="tag t-bad">随 L4</span></td></tr>
</tbody>
</table>
<p class="note">这个仓库的落点:<code>.gitea/workflows/ci.yml</code>shellcheck、OpenAPI 校验、红线词扫描、<code>flutter analyze + test</code>= 闸 4 骨架已在。<b></b>:契约快照、golden 进 CI、可移植 SQL 扫描、pre-commit hook、自定义 lint、PR checklist。</p>
<h2>可复用:通用原则 vs 本项目实例</h2>
<p>换到你别的多端项目时,<b>五条原则不变</b>,只替换右列实例:</p>
<table>
<thead><tr><th style="width:30%">通用原则</th><th style="width:70%">Pangolin 实例(换项目时替换)</th></tr></thead>
<tbody>
<tr><td>接缝即接口</td><td><code>VpnBridge</code> / <code>KernelProcess</code> / <code>TokenStore</code> / <code>AuthApi(http.Client)</code></td></tr>
<tr><td>契约单源</td><td><code>stats</code> EventChannel · <code>/v1/*</code> JSON · <code>UsageEntry</code> proto · <code>colors_and_type.css</code></td></tr>
<tr><td>纯逻辑/IO 分离</td><td>速率/时长格式化 · 配额计算 · 时间 Go 端算 · dialect 方言层</td></tr>
<tr><td>错误是值</td><td><code>AuthApiException</code> · 控制面错误码</td></tr>
<tr><td>可观测</td><td>status/stats stream · 结构化日志 · <code>/v1/usage</code> · <code>/healthz</code></td></tr>
</tbody>
</table>
<p class="note">本规范为「怎么写」的真相源;「怎么验证」见 <a href="test-architecture.html">测试框架架构说明</a>。两份配套维护,新增接缝时同步更新两边。</p>
</div>
</body>
</html>