docs(stats): stats-overhaul 计划/测试清单 + index 登记

- stats-overhaul 实现计划(md 真相源 + html 阅读版)
- 功能×测试覆盖清单(feature-test-coverage-checklist.html)
- docs/index.html 登记上述文档

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-28 18:13:49 +08:00
parent b98ad9dce4
commit 1a6eb7ac9a
4 changed files with 619 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
<!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:1040px;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:46px 0 6px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16.5px;margin:26px 0 6px;color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.86em;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}
.domain{color:var(--accent2);font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;margin:30px 0 2px}
table{width:100%;border-collapse:collapse;margin:10px 0 18px;font-size:13px}
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)}
td.feat{color:var(--fg);font-weight:600;width:15%}
.tag{display:inline-block;font-size:10.5px;font-weight:700;padding:1px 7px;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)}
.t-man{background:rgba(168,175,189,.16);color:var(--fg2)}
.kbd{font-family:var(--mono);font-size:.84em;color:#f0d9c4}
.legend{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0 4px;font-size:13px}
.legend span{display:flex;align-items:center;gap:6px}
.ck{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 18px;margin:12px 0}
.ck li{margin:7px 0;list-style:none}
.ck ul{padding-left:4px}
.ck .box{color:var(--accent);font-family:var(--mono);margin-right:8px}
.pill{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-right:6px;background:rgba(95,176,201,.16);color:var(--accent2)}
.crit{color:#f0d9c4;font-weight:600}
</style>
</head>
<body>
<div class="wrap">
<h1>Pangolin 功能 × 测试覆盖清单</h1>
<p class="sub">逐功能盘点:现有能力 → 已覆盖测试(怎么覆盖)→ 欠缺 → 只能人工。作为「验证五个终端是否正确」的对照工作表。</p>
<div class="lead">
<strong>怎么用:</strong>本清单分两部分——<b>A 共享逻辑 + 后端(控制面 + 数据面)</b>,跑一次自动化套件即对全平台生效;<b>B 平台隧道层</b>,五端各自不同,须逐端按底部验收清单打勾。每行的「覆盖」标签直接告诉你这条是<b>已自动化</b><b>部分/契约级</b><b>零覆盖</b> 还是<b>本质上只能人工</b>
<div class="small" style="margin-top:8px">↔ 配套:<a href="test-architecture.html">统一测试框架·架构说明</a>(分层方法论 + ⑩ 已知缺口) · <a href="dev-conventions.html">开发规范·可测试性五支柱</a> · <a href="vpn-test-plan.md">VPN 测试计划</a>(站点矩阵)</div>
</div>
<div class="legend">
<span><span class="tag t-ok">自动</span> CI 里有断言守门,回归即红</span>
<span><span class="tag t-warn">部分</span> 只到契约/单元,未覆盖端到端或边界</span>
<span><span class="tag t-bad"></span> 当前无任何测试</span>
<span><span class="tag t-man">人工</span> 本质上只能人工/真设备验证</span>
</div>
<p class="note">「覆盖」列给的是<b>当前状态</b>,不是目标;标 <span class="tag t-bad"></span>/<span class="tag t-warn">部分</span> 的「欠缺」列写清下一步补什么。标 <span class="tag t-man">人工</span> 的不是缺陷,是真设备/真出网/真链路这类自动化跑不动、必须人测的部分——集中收在 <a href="#manual">§A.4 / §B 验收清单</a></p>
<!-- ============================ PART A ============================ -->
<h2>A · 共享逻辑 + 后端(控制面 + 数据面)</h2>
<p>这部分代码<b>四端共用</b>Flutter 业务逻辑 / Go 控制面 / 数据面渲染),自动化跑一次即对五个终端同时生效。验证策略:<b>能自动化的全压到 CI,逐端不重复测</b></p>
<!-- A.1 控制面 -->
<h3 id="a1">A.1 控制面(Go 控制面 HTTP API + gRPC<code>server/internal/*</code></h3>
<p class="domain">账号 / 鉴权</p>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">注册 / 登录 / 刷新 <span class="tag t-ok">自动</span></td>
<td>邮箱注册、密码登录、refresh token 轮换、登出;<code>/v1/auth/{register,login,refresh,code}</code></td>
<td><code>auth/service_test · handler_test · token_test · integration_test</code>:真库(SQLite)跑完整 register→login→refresh 链;token 签发/校验/过期纯函数单测;<code>password_test</code> 哈希;<code>helpers_test</code></td>
<td>真邮件投递(SMTP)只 mock;账号枚举/暴力破解的真实速率压测仅 <code>ratelimit_test</code> 逻辑级</td></tr>
<tr><td class="feat">邮箱验证码 <span class="tag t-ok">自动</span></td>
<td>发码、校验、节流;<code>emailcheck</code></td>
<td><code>auth/emailcheck_test · ratelimit_test</code>:码生成/校验/节流窗口逻辑断言</td>
<td>真 SMTP 通道、到达率、垃圾箱判定 → <span class="tag t-man">人工</span></td></tr>
<tr><td class="feat">两步验证 TOTP <span class="tag t-ok">自动</span></td>
<td>TOTP 绑定/校验、备份码</td>
<td><code>totp/totp_test</code>(算法)+ <code>auth/totp_user_test</code>(绑定/校验流程)</td>
<td>真 Authenticator app 互操作 → <span class="tag t-man">人工</span>(一次性)</td></tr>
<tr><td class="feat">管理后台 <span class="tag t-ok">自动</span></td>
<td>admin 登录/会话、IP 白名单、配置、加密</td>
<td><code>admin/{auth,session,mw_ipallow,config,crypto,handlers,services_real}_test</code>:会话签发、IP allowlist 中间件、handler 行为</td>
<td>后台前端页面交互 → <span class="tag t-man">人工</span></td></tr>
</table>
<p class="domain">业务 / 计费</p>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">兑换码 / 套餐 <span class="tag t-ok">自动</span></td>
<td>码生成、兑换、套餐授予;<code>/v1/redeem · /v1/plans</code></td>
<td><code>codes/{generator,service}_test</code>:码生成唯一性、兑换幂等、过期/已用拒绝</td>
<td></td></tr>
<tr><td class="feat">支付 webhook <span class="tag t-warn">部分</span></td>
<td>第三方支付回调 → 充值/开通</td>
<td><code>codes/webhook_test</code>:回调签名校验 + 入账逻辑(mock 上游)</td>
<td>真支付渠道端到端(下单→回调→开通)仅 mock;真渠道沙箱 → <span class="tag t-man">人工</span></td></tr>
<tr><td class="feat">激励解锁配额 <span class="tag t-ok">自动</span></td>
<td>看广告解锁额度;<code>/v1/ads/unlock</code></td>
<td><code>usage/ads_test</code>:解锁额度计算/上限</td>
<td>真广告 SDK 回调 → <span class="tag t-man">人工</span></td></tr>
<tr><td class="feat">设备管理 <span class="tag t-ok">自动</span></td>
<td>设备注册/列举/解绑、上限;<code>/v1/me/devices</code></td>
<td><code>devices/service_test · devices_integration_test</code>:真库注册→列举→解绑;<b>注册回填 last_seen</b>(修过的真 bug</td>
<td></td></tr>
</table>
<p class="domain">节点 / 调度 / 供给</p>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">节点注册表 + 连接 <span class="tag t-ok">自动</span></td>
<td>列节点、连接/断开、ping<code>/v1/nodes · /connect · /disconnect</code></td>
<td><code>nodes/{grpc,hub,lifecycle}_test</code>:gRPC hub 收发、节点生命周期状态机</td>
<td>真节点延迟/可用性探测准确度 → 见数据面 §A.2</td></tr>
<tr><td class="feat">Agent mTLS 接入 <span class="tag t-ok">自动</span></td>
<td>CA 签发、CRL 吊销、节点身份、bootstrap token</td>
<td><code>mtls/{ca,crl,identity,bootstrap}_test</code> + <code>nodectl/bootstrap_token_test</code>:证书链签发/校验/吊销</td>
<td>真 mTLS 握手在 §A.2 e2e 覆盖一段</td></tr>
<tr><td class="feat">自动调度 / 故障替换 <span class="tag t-ok">自动</span></td>
<td>探测引擎、熔断、容量、自动替换编排、探针接入</td>
<td><code>scheduler/detect/engine_test · orchestrate/{breaker,capacity,config,replacer}_test · probe/{ingest,prober_agent}_test · wiring_{lifecycle,provision}_test</code>:健康判定→熔断→替换决策全链逻辑</td>
<td>真云厂商触发的端到端替换 → <span class="tag t-man">人工</span>(贵/慢)</td></tr>
<tr><td class="feat">云供给 provision <span class="tag t-warn">部分</span></td>
<td>cloud-init 渲染、provider 注册表、节点替换</td>
<td><code>provision/{cloudinit,replace,service}_test · providers/registry_test</code>fakes 注入)</td>
<td>真厂商 API 开机/销毁 → <span class="tag t-man">人工</span>(按需,烧钱)</td></tr>
<tr><td class="feat">告警 <span class="tag t-ok">自动</span></td>
<td>Telegram 通知、runbook</td>
<td><code>alert/{notifier,runbook}_test</code>:触发条件 + 去抖逻辑</td>
<td>真 Telegram 投递 → <span class="tag t-man">人工</span></td></tr>
</table>
<p class="domain">持久层 / 可移植性</p>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">双数据库 SQLite/MySQL <span class="tag t-ok">自动</span></td>
<td>裸 SQL + 方言层;迁移分两套;upsert/行锁中性记法</td>
<td><code>db/dialect_test · sqlite_smoke_test</code><code>store/{sqlite_stores,sqlite_migrate,sqlite_per_device,mysql,mysql_integration}_test</code>SQLite 实库 + MySQL testcontainers 真库跑同一套断言;<code>ci/scan-portable-sql.sh</code> 扫禁用 MySQL 专属构造</td>
<td>真 512MB VPS 上的并发/锁竞争压测 → <span class="tag t-man">人工</span></td></tr>
</table>
<!-- A.2 数据面 -->
<h3 id="a2">A.2 数据面(sing-box 配置渲染 + per-user 记账,<code>httpapi/clientconfig · agentd/*</code></h3>
<p>「连上了能不能真出网、记账准不准」的源头逻辑。<b>渲染/解析有测试,真链路靠 e2e 一段 + 人工兜底。</b></p>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">客户端配置渲染 <span class="tag t-ok">自动</span></td>
<td><code>BuildClientConfig</code> 服务端渲染原样下发:REALITY 出站、TUN 入站(strict_route 杀开关)、<span class="crit">DNS 劫持首条规则</span>、国内分流</td>
<td><code>httpapi/clientconfig_test</code>:断言 REALITY 公钥/short-id/端口、TUN auto_route/strict_route、<b>hijack-dns 规则排在 LAN 前</b>、split_cn 开关</td>
<td>渲染对 ≠ 内核吃得下:真 sing-box 加载该配置并连通 → §B 人工</td></tr>
<tr><td class="feat">节点配置渲染(agent 侧)<span class="tag t-ok">自动</span></td>
<td>agent 渲染 sing-box 服务端配置 + 凭证增删/轮换/吊销</td>
<td><code>agentd/singbox_test</code>Upsert/Revoke/Rotate/ApplyConfig 状态机;<code>derive_test</code> 数据口凭证派生;<code>hy2cert_test</code> 证书;<code>command_test</code></td>
<td><code>systemctl restart sing-box</code> 后端口真监听 → <span class="tag t-man">人工</span></td></tr>
<tr><td class="feat">per-user 流量采集 <span class="tag t-warn">部分</span></td>
<td><code>V2RayUsageSource</code>agent 读 sing-box v2ray_api StatsService per-user 计数器 → 聚合 → ReportUsage</td>
<td><b>新增</b> <code>agentd/usage_v2ray_test</code>:表驱动 <code>parseUserStat</code> + loopback 假 StatsService 跑真 <code>Collect()</code>,验聚合/方向不串/全0丢弃/<code>reset=true</code> 窗口语义</td>
<td>假 StatsService ≠ 真 sing-box:真出网流量经真 v2ray_api 的计数准确度 → <span class="tag t-man">人工</span></td></tr>
<tr><td class="feat">记账全链 e2e <span class="tag t-warn">部分</span></td>
<td>enroll → ReportUsage → 统计入库 → API 读出</td>
<td><code>server/test/e2e/smoke_test</code>:进程级 gRPC 全链路(agent enroll→上报→控制面统计真入库真读出),HTTP 段 + miniredis</td>
<td><code>/v1/usage/devices</code> 分设备断言(stats-overhaul 合并后补回);真节点真流量不在 e2e</td></tr>
<tr><td class="feat">REALITY 数据口连通 <span class="tag t-man">人工</span></td>
<td>客户端经 REALITY 连节点 443 真出网</td>
<td>—(握手是真协议真节点,自动化跑不动)</td>
<td>真握手成功 + 出网 → §B 每端验收清单第 1–2 步</td></tr>
</table>
<!-- A.3 客户端共享逻辑 -->
<h3 id="a3">A.3 客户端共享逻辑(Flutter <code>client/lib/*</code>,四端共用 Dart</h3>
<table>
<tr><th>功能</th><th>现有能力</th><th>已覆盖 + 怎么覆盖</th><th>欠缺 / 只能人工</th></tr>
<tr><td class="feat">API 客户端 + 契约 <span class="tag t-ok">自动</span></td>
<td><code>ApiClient</code> 取数、错误映射;<code>/v1/me · usage · usage/devices</code></td>
<td><code>unit/api_client_test</code><code>contract/{api_contract,stats_contract}_test</code> 冻结 wire 字段形状;Go 侧 <code>httpapi/contract_test · usage/contract_test · pb/agentv1/contract_test</code> 两侧对齐</td>
<td></td></tr>
<tr><td class="feat">登录 / 续登 / 登出流程 <span class="tag t-ok">自动</span></td>
<td><code>AuthNotifier</code> 状态机 + token 存储</td>
<td><code>unit/flow_auth_test</code>:真控制器 + MockClient 注入,跑登录→重启续登→登出</td>
<td><code>flutter_secure_storage</code> keychain 行为 → <span class="tag t-man">人工</span>(一次性)</td></tr>
<tr><td class="feat">连接状态机 <span class="tag t-ok">自动</span></td>
<td><code>connectionProvider</code> connecting→connected→errorping 探测</td>
<td><code>unit/{connection_controller,flow_connect}_test</code>:真控制器 + 假桥,验状态流转;<code>connect_passthrough_test</code></td>
<td>真隧道回调时序(连上才启动 app 等触发条件)→ §B</td></tr>
<tr><td class="feat">统计计算 + 上屏 <span class="tag t-ok">自动</span></td>
<td>月 GB/时长聚合、周柱、分设备归因;<code>stats_page</code></td>
<td><code>unit/{device_usage,format}_test</code> 解析/格式化;<b>新增</b> <code>widget/stats_page_test</code>:真 wire 形态喂 StatsPage 断言指标卡/周柱/分设备<b>数值真上屏</b></td>
<td>—(解析对 + 上屏对都已守)</td></tr>
<tr><td class="feat">配额 / 节点列表 <span class="tag t-ok">自动</span></td>
<td><code>quotaProvider · nodesProvider</code></td>
<td><code>unit/{quota_controller,nodes_provider}_test</code></td>
<td></td></tr>
<tr><td class="feat">UI 视觉一致性 <span class="tag t-ok">自动</span></td>
<td>各页面/组件像素还原(亮/暗 × 中/英 × 手机/平板/桌面)</td>
<td><code>golden/{components,auth_redesign,desktop_pages,tablet_pages}_test</code> Linux 权威基线;<code>widget/{cards,connect_button}_test</code><code>responsive/form_factor_test</code><code>fonts_test</code> 字体锁</td>
<td>真机不同 DPI/字号/深色模式实机观感 → <span class="tag t-man">人工</span>(抽查)</td></tr>
<tr><td class="feat">桥接口契约 <span class="tag t-warn">部分</span></td>
<td><code>VpnBridge</code> 抽象 + 桌面子进程实现 + mock</td>
<td><code>bridge/{vpn_bridge_mock,kernel_process,desktop_vpn_bridge_m4m5}_test</code>:内核进程查找/启停、mock 桥行为</td>
<td>原生 MethodChannel 两侧真实编解码 → §B(各端原生层)</td></tr>
</table>
<p class="domain">设计契约 · 五端 UI 同源</p>
<p>五端跑<b>同一个 Flutter 工程</b><code>client/lib</code> 共享 Dart),UI 不按平台分叉——颜色/样式/组件<b>不是「约定一致」,是物理上同一份代码</b>。下表是「五端同源」各维度的<b>单源 + 守门</b>状态。两处关键盲区:<span class="crit">① token 有漂移闸,logo/app-icon 资产没有;② 「生成物不漂移」有闸,但「screen 是否真用真相源」(依从性) 零闸、且所有闸无反向 case 自检</span></p>
<table>
<tr><th>维度</th><th>单源(真相源 → 生成/消费)</th><th>守门机制</th><th>欠缺 / 风险</th></tr>
<tr><td class="feat">颜色/间距/圆角/字体 <span class="tag t-ok">自动</span></td>
<td><code>design/colors_and_type.css</code>clay/sand 色板)→ <code>gen_flutter_tokens.mjs</code><code>pangolin_tokens.gen.dart</code>(勿手改)→ <code>pangolin_theme.dart</code> 语义层;业务禁硬编码 hex</td>
<td><b>CI <code>codegen-drift</code></b><code>ci/check-codegen-drift.sh</code>):重生成与提交版不一致即红,防「改 CSS 没重生成 / 手改生成物」</td>
<td>—(单源 + 自动闸,最规范的一块)</td></tr>
<tr><td class="feat">组件 <span class="tag t-ok">自动</span></td>
<td><code>client/lib/widgets/</code> 唯一实现;<code>design/</code> 禁放 Dart 副本(会漂移);规格在 <code>design/CONTRACT.md</code> + <code>design/preview/</code></td>
<td><b>golden 测试</b>:组件 + 各页面 × 明/暗 × 中/英 × 手机/平板/桌面,像素回归即红</td>
<td></td></tr>
<tr><td class="feat">图标库 <span class="tag t-ok">自动</span></td>
<td><code>lucide_icons</code>(细线条)全端同一套字体图标</td>
<td>随 golden 像素守</td>
<td></td></tr>
<tr><td class="feat">品牌 logo(应用内)<span class="tag t-warn">部分</span></td>
<td><code>design/assets/*.svg</code>mark / wordmark / app-icon)→ <b>拷贝一份到</b> <code>client/assets/</code><code>flutter_svg</code> 加载,四端同一份矢量</td>
<td><b>无自动闸</b>——<code>design↔client</code> 双拷贝靠手动同步(当前 4 个 SVG 已 diff 确认一致)</td>
<td><code>design/assets</code> 那份忘同步 <code>client/assets</code>CI 静默放过 → <span class="tag t-warn">待补 drift 闸</span></td></tr>
<tr><td class="feat">app 启动图标 <span class="tag t-warn">部分</span></td>
<td><code>assets/app-icon.svg</code><code>app-icon-ios-1024.png</code><code>flutter_launcher_icons</code> 一键铺五端全尺寸(iOS appiconset / Android mipmap / macOS / Windows .ico</td>
<td><b>无 drift 闸</b>——生成物(各端 PNG/ico,如 iOS 22 张)入库,靠手动 <code>dart run flutter_launcher_icons</code></td>
<td><code>app-icon.svg</code> 忘重跑生成,五端图标与源图分叉,CI 静默放过 → <span class="tag t-warn">待补 drift 闸</span></td></tr>
<tr><td class="feat">设计契约文档 <span class="tag t-man">人工</span></td>
<td><code>design/CONTRACT.md</code>design-distill 蒸馏):token 映射 / 组件映射 / 逐屏像素规格 / 逐屏验收清单(明×暗×中×英)</td>
<td>人读契约;像素侧由 golden 兜底</td>
<td>「还原对不对」的逐屏验收是人工抽查(截图 diff 可半自动)</td></tr>
<tr><td class="feat">screen 依从性<br>(源自真相源)<span class="tag t-bad"></span></td>
<td>要求 screen/widget 的颜色/字号/间距<b>必须源自</b> <code>PangolinColors</code>/<code>PangolinText</code>/<code>PangolinSpacing</code>,不得硬编码 hex/数值或绕过 <code>widgets/</code> 自拼组件</td>
<td><b>无任何检测</b><code>codegen-drift</code> 只守「生成物=CSS 源」<b>不守消费端是否用它</b><code>analysis_options.yaml</code> 无禁硬编码规则;<code>design/_adherence.oxlintrc.json</code> 能抓 raw hex/px 但<b>只对 JSX 原型、warn 级、未接 CI</b></td>
<td>实测 <code>lib/screens</code> 已有 <b>62+ 处</b>硬编码 <code>fontSize:</code>/<code>EdgeInsets</code> 数值无人拦(如 <code>account_page.dart:187</code> <code>fontSize: 16</code>)→ <span class="tag t-warn">待补依从性扫描闸(第①档:正则扫 raw 字面量,仿 <code>scan-portable-sql.sh</code></span></td></tr>
<tr><td class="feat">闸的反向 case 自检 <span class="tag t-bad"></span></td>
<td>每个闸应有「注入违规 → 断言闸变红」的负向自检,证明闸<b>非永真</b>(怎么改都绿 = 形同虚设)</td>
<td><b>无任何反向 case</b>(全仓 grep 零结果)——含 <code>codegen-drift</code> 在内的所有闸都没验证过「真能拦住违规」</td>
<td><span class="tag t-warn">待补 fixture 自检</span>(备一行违规样本,CI 跑「扫它必须非零退出」;新依从性闸与 codegen-drift 都该配)</td></tr>
</table>
<p class="note"><b>关键区别:</b><code>codegen-drift</code>(生成物不漂移)<b></b> 依从性(消费端真用真相源)。前者保证<b>真相源本身</b>没被改歪,后者保证 <b>screen 真的去用</b>了真相源——当前<b>只有前者有闸</b>,后者零守门、且所有闸都缺反向 case 自检。规划见 <code>planUI 真相源依从性收口</code>(本轮仅登记,补闸后续做)。</p>
<p class="note"><b>待补(性价比高):</b>仿 <code>ci/check-codegen-drift.sh</code><b>logo SVG 双拷贝</b><b>app-icon 生成物</b> 各加一个 drift 闸——重新生成/比对 <code>design↔client</code>,不一致即红,把「图标 logo 五端同源」从<b>靠纪律</b>升成<b>靠闸</b>。这是 token 已有、资产尚缺的一行。</p>
<!-- A.4 manual consolidated -->
<h3 id="manual">A.4 后端「只能人工」汇总</h3>
<div class="ck">
<ul>
<li><span class="box"></span> 真 SMTP / Telegram / 支付渠道 / 广告 SDK 的真实投递与回调(外部第三方,仅 mock 到逻辑边界)</li>
<li><span class="box"></span> 真云厂商 provision 开机→销毁→替换端到端(按需手测,烧钱)</li>
<li><span class="box"></span> 真 sing-box 加载渲染配置后端口真监听、真 v2ray_api 计数准确(渲染/解析已自动,运行时人测)</li>
<li><span class="box"></span> 512MB VPS 真并发/锁竞争压测</li>
<li><span class="box"></span> 后台前端页面交互(admin UI</li>
</ul>
</div>
<!-- ============================ PART B ============================ -->
<h2>B · 平台隧道层(五端逐一)</h2>
<p>真正逐端不同的只有<b>原生隧道实现</b>——这是测试架构 ⑩ 标注的<b>零自动化盲区</b>。三种策略:</p>
<ul>
<li><span class="pill">策略①</span><b>内嵌 libbox + 系统 VPN 扩展</b>iOS / iPad<code>NEPacketTunnelProvider</code> + <code>VpnManager.swift</code>)、Android<code>libbox.aar</code> + <code>PangolinVpnService.kt</code> VpnService)、macOS<code>PacketTunnel</code> 系统扩展,<b>当前默认关</b> <code>kUseNativeVpnMacOS=false</code></li>
<li><span class="pill">策略②</span><b>sing-box 子进程 + TUN</b>macOS(默认,<code>DesktopVpnBridge</code> + <code>kernel_process.dart</code>)、Windows<code>sing-box.exe</code> + <code>wintun.dll</code>)、Linux</li>
<li><span class="pill">桥接</span><b>原生↔Dart</b>MethodChannel/EventChannel<code>VpnEventBus.kt</code> / <code>VpnChannel.swift</code> / <code>StatsClient.swift</code>)传状态与统计——「mac 统计恒为 0」就出在这条没契约守门</li>
</ul>
<h3>各端隧道状态矩阵</h3>
<table>
<tr><th>终端</th><th>隧道实现</th><th>已覆盖</th><th>欠缺 / 验证重点</th></tr>
<tr><td class="feat">Android</td>
<td><code>PangolinVpnService.kt</code>(VpnService) + libbox.aar<code>DefaultNetworkMonitor.kt</code> 网络监听;<code>VpnEventBus.kt</code> 事件桥</td>
<td><span class="tag t-bad"></span> 原生层无自动化测试</td>
<td>VpnService 授权弹窗、TUN fd 建立、libbox 启动、断网重连、统计回传桥;真机连通 + 出网</td></tr>
<tr><td class="feat">iOS</td>
<td><code>VpnManager.swift</code>(NEPacketTunnelProvider) + 内嵌 libbox<code>StatsClient.swift</code> 统计</td>
<td><span class="tag t-bad"></span> 原生层无自动化测试</td>
<td>NE 内存 ≤50MB 闸、NE profile 安装授权、libbox 后台队列启动、TestFlight 分发;真机连通 + 出网</td></tr>
<tr><td class="feat">iPad</td>
<td>同 iOS 二进制;额外横屏侧栏布局</td>
<td><span class="tag t-warn">部分</span> 布局走 golden(<code>tablet_pages</code>),隧道同 iOS <span class="tag t-bad"></span></td>
<td>同 iOS + 横屏/分屏多任务下隧道与 UI;真机连通 + 出网</td></tr>
<tr><td class="feat">macOS</td>
<td><b>默认</b>子进程(<code>DesktopVpnBridge</code>+sing-box)<b>可选</b> PacketTunnel 系统扩展(站外 Developer ID + 公证)</td>
<td><span class="tag t-warn">部分</span> 子进程查找/启停有 <code>kernel_process_test · desktop_vpn_bridge_m4m5_test</code>sysext realize <span class="tag t-bad"></span></td>
<td>sysext 能否被 sysextd realize(见<a href="macos-sysext-realize-troubleshooting.html">踩坑复盘</a>)、CFBundleVersion 递增、公证/staple、三方死锁规避;真机连通 + 出网</td></tr>
<tr><td class="feat">Windows</td>
<td><code>sing-box.exe</code> 子进程 + <code>wintun.dll</code> TUN<code>kernel_process.dart</code> 管理</td>
<td><span class="tag t-warn">部分</span> 进程管理逻辑同 <code>kernel_process_test</code>(跨平台共用);wintun/打包 <span class="tag t-bad"></span></td>
<td>wintun.dll 随 exe 落位、TUN 适配器创建、UAC 提权、安装包;真机连通 + 出网</td></tr>
</table>
<h3 id="acceptance">每端真连通验收清单(同一张表,逐端打勾)</h3>
<p>第 17 步对五端是<b>同一份判据</b>,差异只在第 8 步平台专项。用<b>客观信号</b>代替「看着像连上了」。当前节点:<code>107.172.55.251</code>REALITY 443)。</p>
<div class="ck">
<ul>
<li><span class="box"></span> <b>1 流量真走节点</b> · 连前/连后各查出口 IP<code class="kbd">curl ifconfig.me</code> / ip.sb)。必须从本地 IP 变成节点 IP——<span class="crit">没变 = 隧道没真接管</span>,最易自欺的一步。</li>
<li><span class="box"></span> <b>2 DNS 劫持 + 路由对</b> · 浏览器开 youtube / google 能通 → 证明 <code>hijack-dns</code> 首条规则在设备上真生效(缺它「连上也打不开网站」)。</li>
<li><span class="box"></span> <b>3 国内分流不绕道</b> · 开 bilibili 等国内站走直连不进隧道(<code>split_cn</code>)。</li>
<li><span class="box"></span> <b>4 统计记账对</b> · 跑一段已知流量,对客户端统计页 GB/时长 vs 节点端 v2ray 计数器(咬合 §A.2,验 stats-overhaul 是否真对的天然 E2E)。</li>
<li><span class="box"></span> <b>5 断网保护 KillSwitch</b> · 隧道中途断开,确认无明文泄漏(分级见 <a href="killswitch-design.html">KillSwitch 设计</a>TUN <code>strict_route</code> 已渲染)。</li>
<li><span class="box"></span> <b>6 切节点</b> · 切换后出口 IP 跟着变、不掉线、不卡 connecting。</li>
<li><span class="box"></span> <b>7 重连 / 网络切换</b> · Wi-Fi↔蜂窝切换、息屏/休眠恢复后隧道自愈。</li>
<li><span class="box"></span> <b>8 平台专项</b>
<ul style="margin-top:4px">
<li>iOS/iPadNE 内存 ≤50MBInstruments)· profile 授权弹窗 · 后台保活</li>
<li>macOS(sysext)sysextd realize 成功 · 已公证 · CFBundleVersion 已递增</li>
<li>macOS(子进程)/Windows:内核子进程提权(sudo/UAC)· 退出清理 TUN</li>
<li>AndroidVpnService 授权弹窗 · 厂商省电杀后台白名单</li>
<li>Windowswintun.dll 落位 · TUN 适配器创建 · 安装包</li>
</ul>
</li>
</ul>
</div>
<p class="note"><b>半自动化建议:</b>第 1–3 步(出口 IP / 站点可达 / 分流判定)可写成一个连通探针脚本自动跑,把客观判据固化下来,只留第 4–8 步手测——这是把 §B 盲区往自动化推进的最小一步。</p>
<h3>下一步「功能更完善」可补的自动化(从盲区往里推)</h3>
<ul>
<li><b>原生↔Dart 统计契约</b>:给 <code>pangolin/vpn/stats</code> MethodChannel 两侧加契约测试(防「mac 统计恒为 0」复发)——这是 §B 里<b>唯一能自动化</b>的一块,性价比最高。</li>
<li><b>连通探针脚本</b>:固化验收清单第 1–3 步(出口 IP 变化 / 墙外站可达 / 国内站直连),CI 之外按需跑真节点。</li>
<li><b>记账对账</b>stats-overhaul 合并后,补 e2e <code>/v1/usage/devices</code> 分设备断言 + 客户端↔节点计数对账口径。</li>
<li><b>真 sing-box 烟测</b>(重,可选):起真 sing-box 吃渲染配置,验端口监听 + v2ray_api 真计数,替换当前假 StatsService 的一段。</li>
<li><b>资产 drift 闸</b>(见 §A.3 设计契约):仿 <code>ci/check-codegen-drift.sh</code>,给 <b>logo SVG 双拷贝(design↔client</b><b>app-icon 生成物</b> 各加比对闸,把「图标 logo 五端同源」从靠纪律升成靠闸——token 已有、资产尚缺。</li>
<li><b>Flutter 依从性扫描闸</b>(见 §A.3):仿 <code>ci/scan-portable-sql.sh</code>,正则扫 <code>lib/screens</code>+<code>lib/widgets</code> 的 raw 颜色/字号/间距字面量(第①档),命中即红,守「screen 真用真相源」——需先清存量 62+ 处或白名单冻结增量守门。</li>
<li><b>闸反向 case 自检</b>(见 §A.3):给依从性闸 + <code>codegen-drift</code> 各配一个违规 fixture,CI 跑「扫它必须非零退出」,证明闸非永真——当前所有闸都缺这一类自检。</li>
</ul>
<p class="small" style="margin-top:40px">维护:新功能上线时同步更新本表对应行的「覆盖」标签与「欠缺」列;与 <a href="test-architecture.html">test-architecture.html ⑩ 已知缺口</a> 互为索引——本表是「逐功能」视角,那里是「分层方法论 + 盲区跟踪」视角。</p>
</div>
</body>
</html>
+5
View File
@@ -93,6 +93,11 @@
<div class="d">多端项目可复用的分层测试方法论(L0 契约→L4 E2E)+ 四类诉求(界面一致/交互/数值/后端记账)→层→工具→量化指标映射 + 五张架构图(金字塔/契约接缝/CI 流水线/E2E 拓扑)+ ⑩「已知缺口/测试盲区」单一跟踪源(没测什么·为什么·何时补)。</div>
<div class="path">docs/test-architecture.html</div>
</a>
<a class="doc" href="feature-test-coverage-checklist.html">
<div class="t">功能 × 测试覆盖清单 <span class="tag html">HTML</span></div>
<div class="d">逐功能盘点验证五端是否正确:A 共享逻辑+后端(控制面 21 包/数据面渲染+记账/Flutter 共享),每行「现有能力→已覆盖+怎么覆盖→欠缺→只能人工」;B 平台隧道层五端矩阵 + 每端真连通验收清单(出口IP变化/DNS劫持/分流/记账对账/KillSwitch/切节点)。配套 test-architecture.html 的「逐功能」视角。</div>
<div class="path">docs/feature-test-coverage-checklist.html</div>
</a>
<a class="doc" href="killswitch-design.html">
<div class="t">KillSwitch 设计与跨平台能力矩阵 <span class="tag html">HTML</span></div>
<div class="d">断网保护 L0–L3 分级模型 + 各平台能力天花板 / 当前实现矩阵。KillSwitch 决策依据。</div>
+111
View File
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统计体系整改 · 实现计划(阅读版)</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 32px}
h2{font-size:21px;margin:40px 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}
.phase{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0}
.phase .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-bottom:8px}
.b1{background:rgba(94,194,122,.16);color:var(--ok)}
.b2{background:rgba(224,184,79,.16);color:var(--warn)}
.b3{background:rgba(95,176,201,.16);color:var(--accent2)}
.note{color:var(--fg2);font-size:13px;border-left:3px solid var(--border);padding-left:12px;margin:10px 0}
</style>
</head>
<body>
<div class="wrap">
<h1>统计体系整改 · 实现计划</h1>
<p class="sub">todo #5 · 四端实时统计 + 多设备归因 + GB 配额 + 统计页重设计 · 阅读版</p>
<div class="lead">
<strong>执行真相源(带 checkbox):</strong><code>docs/superpowers/plans/2026-06-24-stats-overhaul.md</code>。本 HTML 仅供阅读,不驱动执行。
</div>
<h2>目标</h2>
<p>四端实时统计真生效(上传/下载/延迟/累计流量)+ 服务端按设备归因并按 GB 账户综合卡控 + 统计页按新设计(上聚合·下分设备)像素级还原。</p>
<p class="note">已敲定决策:租户=用户账户(无需层级表);配额改 GB、按账户综合卡;每设备独立 <code>dp_uuid</code>;分三期;页面改动先设计后开发、用真相源、四端统一元素。</p>
<h2>现状关键事实(探查结论)</h2>
<h3>客户端实时管线</h3>
<p>Dart 契约(<code>pangolin/vpn/stats</code>)与 UI 都正确,缺口全在原生侧:</p>
<ul>
<li><b>iOS</b><code>PacketTunnelProvider</code> 起了 libbox 但 <code>writeStatus/writeGroups</code> 空实现,stats EventChannel 注册了无人 push → 0 数据。</li>
<li><b>macOS 原生</b><code>VpnChannel.swift</code> 硬编码推 0;扩展回调全空。</li>
<li><b>Android</b>:上下行已工作;延迟(<code>writeGroups</code> urltest)疑似 stale。</li>
<li><b>Windows</b>:上下行已工作(Clash <code>/connections</code>);延迟解析 <code>/proxies</code> urltest 疑似拿不到 URLTest 组。</li>
</ul>
<h3>服务端用量/配额/归因</h3>
<ul>
<li>记账链路通:agent V2Ray stats(按 dp_uuid)→ <code>ReportUsage</code><code>dp_uuid→user_id</code><code>usage_daily(user_id,date)</code></li>
<li><b>无法按设备归因</b>:每账户仅一个 <code>users.dp_uuid</code><code>devices</code> 无 dp_uuid 列,<code>usage_daily</code> 无设备维度。</li>
<li>配额分钟制(<code>plans.daily_minutes</code>,连接时凭证 TTL 卡,非持续)。</li>
<li>多租户:user==account 已成立,JWT 全程隔离,无需新表。</li>
</ul>
<h3>统计页设计/实现</h3>
<p>现有 stats 设计在 mobile/tablet/desktop <b>仅聚合</b><b>分设备明细任何端都没设计</b> → Phase 3 必须 design-first。<code>_MetricCard</code> 私有于 stats_page,应提升为公共组件。</p>
<h2>三期方案</h2>
<div class="phase">
<span class="badge b1">Phase 1 · 无 schema 改动</span>
<h3 style="margin-top:0">四端实时统计修复</h3>
<ul>
<li><b>iOS/macOS(核心)</b>:主 App 用 <code>LibboxCommandClient</code> 连 App Group 容器订阅扩展 CommandServer 的 <code>writeStatus/writeGroups</code>,转推 stats channel——对齐 Android StatsHandler 模型。</li>
<li><b>Android</b>:修延迟(groups 订阅 + latestUrltest 刷新)。</li>
<li><b>Windows</b>:修 <code>extractUrltestResults()</code> 解析 + 核下发配置含 URLTest 组。</li>
<li><b>服务端核实</b>:节点 stats API 启用、agent 真上报,使累计流量/周柱有值。</li>
</ul>
</div>
<div class="phase">
<span class="badge b2">Phase 2 · 后端重型</span>
<h3 style="margin-top:0">每设备归因 + GB 综合配额</h3>
<ul>
<li><b>Schema(双驱动)</b><code>devices.dp_uuid</code> 每设备凭证;新表 <code>usage_device_daily</code><code>plans.daily_gb</code></li>
<li><b>每设备 dp_uuid</b>:注册时 mint,connect 按设备下发;控制面 <code>dp_uuid→(user_id,device_id)</code> 解析,双写设备表 + 账户 rollup。</li>
<li><b>GB 综合卡控</b>:账户当日综合 GB 超额即拒;跨阈值 revoke 凭证近持续卡控。</li>
<li><b>API</b>:暴露按设备用量。</li>
</ul>
</div>
<div class="phase">
<span class="badge b3">Phase 3 · design-first</span>
<h3 style="margin-top:0">统计页重设计 + 四端实现</h3>
<ul>
<li><b>设计</b>:走 design-distill,为分设备明细出 mobile/tablet/desktop 原型 + 更新 CONTRACT.md。</li>
<li><b>实现</b>:提升 <code>metric_card.dart</code>、新增 <code>device_stat_row.dart</code>;四端接分设备 provider;语义 token;红线扫描。</li>
<li><b>验收</b>:截图 diff + 新增分设备 golden。</li>
</ul>
</div>
<h2>执行顺序</h2>
<p>先 Phase 1(见效快、零 schema 风险)→ 合并验收 → Phase 2(后端)→ Phase 3(设计可与 Phase 2 并行起草,实现接线依赖 Phase 2 API)。</p>
<p class="small">不在本次范围:组织级多租户层级、协议选择(#8)、KillSwitch#1/#2/#3)。</p>
</div>
</body>
</html>
@@ -0,0 +1,163 @@
# Pangolin 统计体系整改 Implementation Plantodo #5
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 四端实时统计真生效(上传/下载/延迟/累计流量)+ 服务端按设备归因并按 GB 账户综合卡控 + 统计页按新设计(上聚合·下分设备)像素级还原。
**Architecture:** Flutter UI 全平台共享。实时指标经冻结的 `pangolin/vpn/stats` channel 由各原生侧(libbox CommandClient / Clash API)生产;累计用量由服务端 `/v1/usage``/v1/me` 提供。服务端控制面(Go,mysql/sqlite 双驱动)经 agent gRPC `ReportUsage``dp_uuid` 记账;本计划把记账粒度从「账户」细化到「设备」(每设备独立 dp_uuid),并把配额单位从分钟改 GB、按账户综合卡控。
**Tech Stack:** Flutter/Dart、SwiftiOS/macOS NEPacketTunnelProvider + libbox)、KotlinAndroid VpnService + libbox)、Go(控制面 + agent)、sing-box libbox、SQLite/MySQLdialect 层)。
**设计依据:** `design/CONTRACT.md``design/ui_kits/{mobile,tablet,desktop}``docs/ios-ipad-support-design.html`;探查结论见审批计划 `~/.claude/plans/crystalline-exploring-lerdorf.md`
## Global Constraints
- Channel 契约冻结:`pangolin/vpn/stats` 字段 = uploadBytes/downloadBytes/uploadSpeed/downloadSpeed/urltestResults`client/lib/bridge/vpn_bridge.dart:27-37`),**不得私改**。
- 配置由服务端渲染下发,客户端绝不拼配置。
- 服务端可移植铁律:走 `dialect.Upsert/LockForUpdate``server/internal/db/dialect.go`),时间 Go 端算,禁 `UTC_TIMESTAMP()/NOW()/FIELD()` 等 MySQL 专属;迁移 `server/migrations/{mysql,sqlite}` 双份。
- token 真相源单源:只改 `design/colors_and_type.css` + 跑 `node design/codegen/gen_flutter_tokens.mjs``client/lib/pangolin_tokens.gen.dart` 勿手改。
- 统一元素:复用/提升 `client/lib/widgets/`,禁就地造元素;颜色禁硬编码 hex,走语义 token。
- 文案禁红线词(VPN/翻墙/科学上网…),用「加速」口径,过 `ci/scan-redline.sh`;双语单显不并排。
- iOS App Group `group.com.pangolin.pangolinVpn`macOS App Group `BYL4KQHMTN.com.pangolin.pangolin`
---
## Phase 1 — 四端实时统计修复(无 schema 改动)
让上传/下载/延迟/累计流量在四端全部可见。优先级最高、风险最低、见效最快。
### Task 1.1: 服务端记账核实(先确认数据源)
**Files:** `server/internal/httpapi/clientconfig.go``server/internal/agentd/usage*.go``server/internal/nodes/handler_grpc.go`
- [x] **Step 1:** 已核实——节点配置 `render.go` 启用 `experimental.v2ray_api.stats``enabled:true`+`users:statsUsers(creds)`),inbound 用户 `name=dp_uuid`agent `usage_v2ray.go``user>>>{dp_uuid}>>>traffic` 精确读取。链路代码完整正确。
- [x] **Step 2:** 已核实——`handler_grpc.go:282-313` `dp_uuid→user_id``store.go AccumulateUsage` 累加 `usage_daily(user_id,date)`,全程 dialect 可移植。
- [ ] **Step 3(运行时):** 「累计流量没数据」非代码缺陷,是运行时产物(需真实连接产生被记账的流量 / 确认节点 build 含 `with_v2ray_api`)——真机连一次验证。**利好**:dp_uuid 改每设备后这套命名天然按设备拆分,Phase 2 节点+agent 侧近零改动。
### Task 1.2: iOS 实时 stats 生产者(核心)
**Files:** `client/ios/PacketTunnel/PacketTunnelProvider.swift``client/ios/Runner/VpnManager.swift``client/ios/Runner/AppDelegate.swift`
**Interfaces:** Produces → `pangolin/vpn/stats` EventChannel 真实帧。
- [x] **Step 1:** 新增 `client/ios/Runner/StatsClient.swift`——`LibboxNewCommandClient`CommandStatus+CommandGroupstatusInterval 1s)连 App Group 容器(`LibboxSetup` 同扩展路径),含 10 次重试。
- [x] **Step 2:** `writeStatus` 映射 uplinkTotal/downlinkTotal→uploadBytes/downloadBytes、uplink/downlink→uploadSpeed/downloadSpeed。
- [x] **Step 3:** `writeGroups` 遍历组成员 `urlTestDelay`→契约 urltestResults。
- [x] **Step 4:**`VpnStatsStreamHandler.shared.push` 推 EventChannel`VpnManager` 状态观察 connected→start / disconnected→stop;断开推零帧。pbxproj 四处登记(plutil OK)。
- [ ] **Step 5(真机):** Release build 装真机,连接后验证上传/下载/延迟实时跳动。
### Task 1.3: macOS 实时 stats 生产者
**Files:** `client/macos/Runner/VpnChannel.swift``client/macos/PacketTunnel/PacketTunnelProvider.swift`
- [x] **Step 1:** 新增 `client/macos/Runner/StatsClient.swift`(镜像 iOSapp group `BYL4KQHMTN.com.pangolin.pangolin`);替换 `VpnChannel.swift` 旧占位(旧帧 schema 也错,推 up/down 非契约字段)。
- [x] **Step 2:** `VpnChannel` 状态观察驱动 start/stop`onStats` 回调推 statsSink`onStatsListen` 去掉占位 Timer。pbxproj 四处登记(plutil OK)。
- [ ] **Step 2 验证(真机):** 递增 CFBundleVersion + 公证流程(CLAUDE.md),装机验证。
### Task 1.4: Android 延迟修复
**Files:** `client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt`
- [x] **Step 1(代码核查):** `PangolinVpnService.kt``addCommand(CommandGroup)``writeGroups` 正确解析 `getURLTestDelay()`——读取链路无缺陷。
- [x] **Step 2(主动探测修复):** 补偿 sing-box urltest 惰性探测——`writeGroups` 捕获 `groupTags`,新增 `startUrlTestTimer()``java.util.Timer` 每 12s 对各组 `client.urlTest(tag)`),`doStop` 取消。让延迟稳定及时出现,不等 3min 默认 interval。同样的主动触发也加到了 iOS/macOS `StatsClient``urlTestTimer`)。
- [ ] **Step 3(真机):** 装机验证延迟出现。
### Task 1.5: Windows 延迟修复
**Files:** `client/lib/bridge/kernel_process.dart`、(必要时)`server/internal/httpapi/clientconfig.go`
- [x] **Step 1(根因定位+修复):** 真正 bug——`getGroupDelay()` 早已实现却**从未被调用**,轮询只读 `/proxies` 缓存,urltest 惰性探测下 `history` 长期为空 → 延迟空白。修复:`_startStatsPoll``_urlTestEvery=12` 拍对提取出的 `_urltestGroups` fire-and-forget `getGroupDelay()` 主动触发探测;新增 `extractUrltestGroups()` 提取 URLTest/Fallback 组名。
- [x] **Step 2(单测):** `kernel_process_test.dart` 新增「URLTest extraction」组 3 用例(组名提取/最新 history delay/空组)——`flutter test` 26 全过。
- [ ] **Step 3(真机):** Windows 装机验证延迟出现。
### Task 1.6: Phase 1 验收
- [ ] `flutter analyze` + `flutter test`(含现有 golden)通过。
- [ ] 四端连接后:上传/下载/延迟实时跳动;累计流量 + 周柱图有值。
- [ ] 提交,合并验收后再进 Phase 2。
---
## Phase 2 — 每设备归因 + GB 综合配额(后端重型)
### Task 2.1: Schema 迁移(mysql + sqlite 双份)
**Files:** `server/migrations/{mysql,sqlite}/0000X_*.up/down.sql`
- [x] **Step 1:** `devices.dp_uuid`sqlite TEXT / mysql CHAR(36)NULL+ 独立 `CREATE UNIQUE INDEX idx_devices_dp_uuid`SQLite ADD COLUMN 不支持内联 UNIQUE,两库对齐)。
- [x] **Step 2:** 新表 `usage_device_daily`PK `(device_id, date)`,索引 `(user_id, date)`
- [x] **Step 3:** `plans.daily_mb`(MB 整数,支持免费 500MB;与 `daily_minutes` 并存);seed free 500 / pro 102400(100GB) / team 204800(200GB),按用户确认数值。
- [x] **Step 4:** `000015_per_device_usage.{up,down}.sql` 双驱动;sqlite `TestSQLiteMigrateUpDown` up+down 干净(版本→15、新表/列断言已加),`run_sqlite_test.sh` 全过、`go build ./...` OK。mysql 侧待 `run_mysql_test.sh`(需 docker)。
### Task 2.2: 每设备 dp_uuid 链路
**Files:** `server/internal/devices/service.go``server/internal/httpapi/nodes.go``server/internal/nodes/store.go``server/proto/agent/v1/agent.proto``server/internal/nodes/handler_grpc.go`
- [x] **Step 1:** `store.EnsureDeviceDpUUID(userID, deviceUUID)`——connect 时按设备**懒 mint** `devices.dp_uuid``idgen.NewString()`guarded UPDATE + re-read 处理并发竞态)。
- [x] **Step 2:** `ConnectNode` 用设备 dp_uuid 建 cred + 渲染 config`EnsureDeviceDpUUID` 失败则**回退账户 dp_uuid**,不破坏未注册设备的旧客户端)。
- [x] **Step 3:** `store.UserDeviceByDpUUID(dpUUID) → (user, device)`——优先查 `devices.dp_uuid`,回退 legacy `users.dp_uuid`device=0)。
- [x] **Step 4:** `ReportUsage` 双写——账户 `AccumulateUsage` + 设备 `AccumulateDeviceUsage`(仅 device>0)。
- [x] **Step 5:** 兼容——懒 mint 使存量设备下次 connect 自动补发;legacy 账户 dp_uuid 仍解析(device=0,不归设备)。节点侧零改动:每设备 dp_uuid 作独立 connect_credential → `render.go statsUsers`/inbound 天然按设备拆。
- [x] **验证:** `sqlite_per_device_test.go` 3 用例(mint 幂等/区分/未知设备报错、dp_uuid→(user,device) 解析+账户回退、设备用量累加分离)+ `grpc_test.go TestReportUsage_PerDevice`(双写)/`TestReportUsage_Accumulates`(账户级 device=0 不写设备)——`go test ./...` 全过。
### Task 2.3: GB 综合配额卡控
**Files:** `server/internal/usage/quota.go``server/internal/httpapi/nodes.go`
- [x] **决策(用户):** 免费=看广告解锁+分钟与 GB **双卡**;免费 500MB/天;付费高 GB 上限(pro 100GB/team 200GB)。
- [x] **Step 1:** GB 综合卡控落在**真实 connect 路径** `ConnectNode``CheckFreeConnect` 实为未接线死代码)——`ent.DailyMB.Valid` 时取 `store.AccountDayBytes`(账户当日 up+down),`≥ daily_mb*1MB` 即拒 403 `QUOTA_EXHAUSTED`;免费/付费统一。`EntitlementForUser` 加载 `daily_mb`free 回退 500)。
- [x] **验证:** `TestSQLite_AccountDayBytes`(求和/隔离日期)+ `TestSQLite_EntitlementForUser_DailyMB`free 回退 500 / pro 订阅 102400)——全过。
- [ ] **Step 2(后续):** 复用 `ReportUsage` 周期路径跨阈值 revoke 凭证,实现近持续卡控(当前仅 connect 时卡)。
- [ ] **Step 3(后续):** 完整 ad-unlock 门控接线(ConnectNode 现为 MVP「flat 10min」,未强制看广告);`/v1/me` quota 语义改 GB(并入 Task 2.4)。
### Task 2.4: 按设备用量 API ✅
**Files:** `server/internal/usage/{store,service,handler}.go``server/cmd/server/main.go``client/lib/models/device_usage.dart``client/lib/services/account_api.dart``client/lib/state/account_providers.dart`
- [x] **Step 1:** 新增 `GET /v1/usage/devices?days=N`(独立端点,复用 `auth.RequireAuth` group)。后端:`usage.Store.DeviceUsageRange`JOIN devices + GROUP BY 设备,窗口求和,busiest-first,可移植 SQL)→ `Service.DeviceUsage``DeviceUsageHandler`,返回 `{devices:[{uuid,name,platform,bytes_up,bytes_down,minutes_used}]}`
- [x] **Step 2:** 客户端 model `DeviceUsage` + `AccountApi.deviceUsage({days})` + `deviceUsageProvider`family days)。UI 渲染留到 Phase 3(设计先行)。
- [x] **验证:** sqlite 实库测试 `TestSQLite_DeviceUsageRange`(分组求和/窗口隔离/账户隔离/busiest-first/JOIN 元数据);handler guard 测试 `TestDeviceUsageHandler_Guards`401/405/400);Dart `device_usage_test.dart`fromJson + 默认值)。全量 `go test ./...` 绿、`flutter test` 146 过。
### Task 2.5: Phase 2 验收
- [ ] sqlite 实库测试(`go test ./...` + `./server/run_sqlite_test.sh`)覆盖每设备累加、综合卡控、dp_uuid 解析。
- [ ] mysql 集成测试(`./server/run_mysql_test.sh`)通过。
- [ ] 端到端两设备分别连接、用量各归各账;GB 超额被拒。
---
## Phase 3 — 统计页重设计(design-first+ 四端实现
### Task 3.1: 分设备明细设计(design-source 同步)✅
**Files:** `design/ui_kits/{mobile,tablet,desktop}/*``design/CONTRACT.md`
- [x] **Step 1:** 「设备明细 / By device」section 加入三端原型(mobile `screens.jsx StatsScreen`、tablet `tabapp.jsx TabStats`、desktop `dapp.jsx DStats`):上聚合沿用现 3 指标卡 + 周柱;下分设备列表 = 平台 Lucide 图标盒 38(accent-subtle) + 名称 + 占比迷你条(height 4, accent .85) + 流量(mono)/时长。字串入各端 dict`byDevice`/`noDeviceUsage`,双语)。
- [x] **Step 2:** `design/CONTRACT.md` §3.3 增分设备明细像素规格 + 数据源标注。
### Task 3.2: 统一组件提升 ✅
**Files:** `client/lib/widgets/metric_card.dart`(新)、`client/lib/widgets/device_stat_row.dart`(新)。
- [x] **Step 1:** `stats_page.dart` 私有 `_MetricCard` → 公共 `widgets/metric_card.dart``MetricCard`)。
- [x] **Step 2:** 新增 `widgets/device_stat_row.dart``DeviceStatRow` + 静态 `iconFor` 平台图标,与 account_screens 同一视觉语言)。
### Task 3.3: 四端 stats_page 接线 ✅
**Files:** `client/lib/screens/stats_page.dart``client/lib/l10n/*`
- [x] **Step 1:** stats_pagemobile/tablet 窄宽分支 + desktop isWide)接 `deviceUsageProvider(30)`;上聚合下分设备(`_DeviceDetail`);占比条按最忙设备归一;全程语义 token(占比条 accent/border,无硬编码 hex)。
- [x] **Step 2:** 文案入 `l10n/app_text.dart` + `strings_{zh,en}.dart``byDevice`/`noDeviceUsage`),双语单显,无红线词(「设备明细」中性)。
### Task 3.4: Phase 3 验收 ✅
- [x] golden 回归闸(CONTRACT §4 既定 pixel gate):tablet 注入 demo 设备用量 → `tablet_stats_{light_zh,light_en,dark_zh}` 重生;desktop 零数据态 → `desktop_stats` 重生(空态 section)。
- [x] `flutter analyze` 无 error/warning`flutter test` **146 过**`go test ./...` 绿。
- [ ] 真机/模拟器多端目测(待用户验证)。
---
## 不在本次范围
- 组织级多租户层级(父子账户)——user==account 已满足。
- 协议选择(todo #8)、KillSwitch#1/#2/#3)。