Files
pangolin/docs/device-session-management-design.html
T
wangjia 889cff4556 docs(plan): 设备&会话管理 实现计划 P1-P6(md执行源 + html阅读版 + 登记 + 链接)
新增 docs/superpowers/plans/2026-06-29-device-session-management.md(执行真相,
checkbox) + docs/device-session-management-plan.html(阅读版);登记进 index.html
「实现计划」;总方案顶部加「→ 实现计划」链接。P1 设备注册打通→P2 sessions表+
在线→P3 强制退出/清除→P4 每设备流量→P5 2FA(future)→P6 UI重做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:15:35 +08:00

423 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)} .ac-c{color:var(--accent2)}
ul,ol{padding-left:22px;margin:10px 0}
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}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
.lvl{font-weight:700}
.phase{font-weight:700;color:var(--accent)}
/* —— UI 视觉稿:还原 app 暖色 token(浅色主题),嵌在暗色文档中作截图式预览 —— */
.mock-wrap{display:flex;flex-wrap:wrap;gap:20px;margin:18px 0 6px}
.mock{--m-bg:#FAF8F4;--m-surface:#FFFFFF;--m-sub:#F2EEE7;--m-fg1:#1F1C18;--m-fg2:#6B6253;--m-fg3:#8C8270;
--m-accent:#B96A3D;--m-accentsub:#FAF3ED;--m-accentbd:#E6C7AC;--m-border:#E6DFD3;--m-ok:#5B8C5A;--m-danger:#C0533B;
background:var(--m-bg);border-radius:20px;padding:18px;width:344px;box-shadow:0 10px 34px rgba(0,0,0,.4);border:1px solid rgba(0,0,0,.15)}
.mock *{box-sizing:border-box}
.mock .cap{font-size:11px;font-weight:700;letter-spacing:.05em;color:#b0a491;margin:0 0 11px;text-transform:uppercase}
.mock .panel{background:var(--m-surface);border:1px solid var(--m-border);border-radius:14px;overflow:hidden}
.mock .row{display:flex;align-items:center;gap:12px;padding:13px 14px;border-bottom:1px solid var(--m-border)}
.mock .row:last-child{border-bottom:none}
.mock .ava{width:38px;height:38px;border-radius:10px;background:var(--m-accentsub);display:flex;align-items:center;justify-content:center;font-size:18px;flex:none}
.mock .meta{flex:1;min-width:0}
.mock .nm{display:flex;align-items:center;gap:7px;font-size:14.5px;font-weight:600;color:var(--m-fg1)}
.mock .badge{font-size:10px;font-weight:700;padding:1px 7px;border-radius:6px;background:var(--m-accentsub);color:var(--m-accent);border:1px solid var(--m-accentbd)}
.mock .sub2{font-size:12px;color:var(--m-fg3);margin-top:3px}
.mock .stat{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;flex:none}
.mock .dot{width:7px;height:7px;border-radius:50%}
.mock .on{color:var(--m-ok)} .mock .on .dot{background:var(--m-ok);box-shadow:0 0 0 3px rgba(91,140,90,.18)}
.mock .off{color:#a89e8e} .mock .off .dot{background:#cfc6b8}
.mock .kebab{flex:none;width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--m-fg3);font-size:17px}
.mock .kebab.act{background:var(--m-sub);color:var(--m-fg1)}
.mock .foot{font-size:12px;color:var(--m-fg3);margin-top:12px;padding-left:2px}
.mock .foot b{color:var(--m-accent);font-weight:700}
.mock .menu{background:var(--m-surface);border:1px solid var(--m-border);border-radius:13px;padding:6px;box-shadow:0 12px 30px rgba(0,0,0,.16);width:216px;margin-top:6px}
.mock .mi{display:flex;align-items:center;gap:11px;padding:10px 11px;border-radius:9px;font-size:13.5px;font-weight:500;color:var(--m-fg1)}
.mock .mi .ic{font-size:14px;width:17px;text-align:center}
.mock .mi.danger{color:var(--m-danger)}
.mock .mi.sel{background:var(--m-sub)}
.mock.dlg{display:flex;flex-direction:column;justify-content:center;background:linear-gradient(160deg,#efe7dc,#e6dccf)}
.mock .sheet{background:var(--m-surface);border-radius:18px;padding:22px 20px;text-align:center;box-shadow:0 18px 50px rgba(31,28,24,.22)}
.mock .warnico{width:48px;height:48px;border-radius:50%;background:rgba(192,83,59,.12);color:var(--m-danger);display:flex;align-items:center;justify-content:center;font-size:22px;margin:2px auto 13px}
.mock .dt{font-size:16px;font-weight:700;color:var(--m-fg1)}
.mock .dd{font-size:12.5px;color:var(--m-fg2);margin:9px 4px 18px;line-height:1.55}
.mock .dd b{color:var(--m-fg1)}
.mock .btns{display:flex;gap:10px}
.mock .btn{flex:1;padding:11px;border-radius:999px;font-size:13.5px;font-weight:600;text-align:center}
.mock .btn.ghost{background:var(--m-sub);color:var(--m-fg2)}
.mock .btn.danger{background:var(--m-danger);color:#fff}
.mock .pin{display:inline-block;font-size:10px;color:#b0a491;margin-left:6px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>设备 &amp; 会话管理 + 每设备流量归因<span class="small">(设计方案)</span></h1>
<p class="sub">Pangolin · 「我的设备」管理 + 控制面会话 + 数据面按设备记账 · 2026-06-28 · <a href="device-session-management-plan.html"><b>→ 实现计划 P1P6</b></a></p>
<div class="lead">
一句话:<b>后端绝大部分已经建好,只是「没接线」</b><code>devices</code> 表、每设备数据面凭证 <code>dp_uuid</code>
<code>usage_device_daily</code>、设备列表/删除接口都在——但 <code>RegisterIfAbsent</code> 生产代码从不调用,
导致 devices 表永远是空的,整条每设备链路退回账户级。本方案 = <b>打通三条已存在的链路 + 新增 sessions 表</b>
让设备/会话/每设备流量三件事一次成型。
</div>
<h2>1. 背景与现状(已建 vs 缺口)</h2>
<p>排查三端(控制面 Go / 数据面 sing-box / 客户端 Flutter)后的对照:</p>
<table>
<thead><tr><th>能力</th><th>现状</th><th>位置</th><th>缺口</th></tr></thead>
<tbody>
<tr><td><b>devices 表 + 列表/删除接口</b></td><td><span class="tag ok">已建</span></td><td><code>migrations/*/000001</code><code>internal/devices/{store,service,handler}.go</code><code>GET/DELETE /v1/me/devices</code></td><td>表始终为空(无人写入)</td></tr>
<tr><td><b>设备隐式注册</b> <code>RegisterIfAbsent</code></td><td><span class="tag bad">未接线</span></td><td><code>devices/service.go</code></td><td>已实现,<b>生产请求路径从不调用</b>(仅单测用)</td></tr>
<tr><td><b>每设备数据面凭证</b> <code>dp_uuid</code></td><td><span class="tag warn">就绪未生效</span></td><td><code>migrations/*/000015</code><code>nodes/store.go: EnsureDeviceDpUUID</code></td><td>依赖 device 行存在;表空 → 退回账户级 <code>users.dp_uuid</code></td></tr>
<tr><td><b>每设备用量表</b> <code>usage_device_daily</code></td><td><span class="tag warn">就绪未生效</span></td><td><code>migrations/*/000015</code><code>nodes/store.go: AccumulateDeviceUsage</code></td><td>因 deviceID 永为 0 → <b>从不写入</b></td></tr>
<tr><td><b>sing-box 按 dp_uuid 计量</b></td><td><span class="tag ok">已建</span></td><td><code>agentd/render.go</code><code>usage_v2ray.go</code></td><td>无(已按 <code>user&gt;&gt;&gt;{dp_uuid}&gt;&gt;&gt;traffic</code> 计量、reset 取增量)</td></tr>
<tr><td><b>会话(JWT + Redis 白名单)</b></td><td><span class="tag ok">已建</span></td><td><code>auth/token.go</code>access 15min / refresh 30day</td><td><b>会话不绑设备</b> → 无法「按设备强制退出」</td></tr>
<tr><td><b>客户端「我的设备」页</b></td><td><span class="tag ok">已建</span></td><td><code>widgets/account_screens.dart: DevicesScreen</code></td><td>无在线状态/客户端版本;操作仅「移除」</td></tr>
<tr><td><b>客户端设备身份</b></td><td><span class="tag bad">硬编码</span></td><td><code>connection_provider.dart: _kDeviceId='mac-001'</code></td><td>固定值;版本(<code>appVersionProvider</code> 有)<b>没上报</b></td></tr>
</tbody>
</table>
<div class="card root">
<b>核心缺口一句话</b><code>RegisterIfAbsent</code> 没被调用 → <code>devices</code> 表空 → <code>EnsureDeviceDpUUID</code> 找不到设备行 → 退回账户级 <code>dp_uuid</code><code>UserDeviceByDpUUID</code> 返回 <code>deviceID=0</code><code>AccumulateDeviceUsage</code><code>if deviceID&gt;0</code> 跳过 → <code>usage_device_daily</code> 永远空。<b>补上「登录即注册设备」这一步,整条链路自洽。</b>
</div>
<h3>已确认的设计决策</h3>
<ul>
<li><b>在线判定</b> = <span class="ac-c">数据面在线</span><code>devices.last_seen</code> 在阈值内(建议 3 分钟)才算「在线」,另列「最后登录时间」。</li>
<li><b>两个操作</b><b>强制退出</b> = 吊销该设备会话踢下线(设备<u></u>在列表);<b>清除登录信息</b> = 彻底移除(删设备 + 吊销会话 + 吊销 dp_uuid)。</li>
<li><b>会话存储</b> = 新增 <code>sessions</code> 表(DB,可查历史、能按设备精准吊销),保留 Redis 白名单做快速校验。</li>
</ul>
<h2>2. 总体架构(三端数据流)</h2>
<p>三支柱:<b>设备身份</b>(客户端稳定 UUID + 上报元数据)→ <b>会话</b>(每次登录建 session 绑设备)→ <b>每设备流量</b>(设备注册后 dp_uuid 自然铺开)。</p>
<pre>
CLIENT (Flutter) CONTROL PLANE (Go) AGENT + SING-BOX
─────────────── ────────────────── ────────────────
device_id (UUID v4, POST /auth/login|register
secure storage) ──▶ body: device{id,name, ┌───────────────┐
name / platform / platform,client_version} │ devices 表 │
client_version (上报) │ ├─ RegisterIfAbsent ──────────────▶│ (终于被填充) │
│ └─ TokenManager.Issue └───────┬───────┘
│ └─ INSERT sessions(jti,device) │
│ EnsureDeviceDpUUID
POST /v1/nodes/{id}/connect ─▶ ConnectNode 铸 per-device dp_uuid
body: {device_id} │ └─ EnsureDeviceDpUUID ────推 Hub──▶ 下发 sing-box
│ (命中 device 行) VLESS user.uuid=dp_uuid
│ │
◀── ReportUsage(dp_uuid,up,down) ── sing-box 按 dp_uuid 计量
│ UserDeviceByDpUUID→(user,device) user&gt;&gt;&gt;{dp_uuid}&gt;&gt;&gt;traffic
│ ├─ AccumulateUsage (账户级 usage_daily)
│ ├─ AccumulateDeviceUsage(设备级 usage_device_daily)
GET /v1/me/devices ◀───────────┤ └─ touch devices.last_seen ← 驱动「在线」判定
{name,platform,client_version, │
online,last_login} │ POST /v1/me/devices/{uuid}/logout → 吊销 session(强制退出)
│ DELETE /v1/me/devices/{uuid} → 删设备+吊销会话+吊销 dp_uuid
</pre>
<h2>3. 数据模型</h2>
<h3>3.1 新增 <code>sessions</code> 表(migration 000016mysql + sqlite</h3>
<p>每次登录一条;绑定设备与 refresh-token JTI,支撑「按设备强制退出 / 最后登录 / 会话历史」。SQL 走中性方言(不用 <code>NOW()</code>/<code>UTC_TIMESTAMP()</code>,时间 Go 端算好传参)。</p>
<pre>
sessions(
id INTEGER PK AUTOINCREMENT,
user_id NOT NULL, -- FK users.id
device_id NOT NULL, -- FK devices.id(会话归属哪台设备)
refresh_jti TEXT NOT NULL UNIQUE,-- 绑定当前 refresh token 的 JTI
client_ip TEXT, -- 登录来源 IP
client_version TEXT, -- 登录时客户端版本(历史留痕)
created_at DATETIME NOT NULL, -- 登录时间(设备「最后登录」取最近一条)
last_active DATETIME, -- refresh 轮换时更新
revoked_at DATETIME NULL -- 强制退出/登出置位;非空=已失效
)
INDEX (user_id), INDEX (device_id), UNIQUE (refresh_jti)
</pre>
<table>
<thead><tr><th>时机</th><th>对 sessions 的动作</th></tr></thead>
<tbody>
<tr><td>登录 / 注册</td><td><code>TokenManager.Issue</code> 同事务 <code>INSERT</code> 一条(写 device_id / jti / ip / version / created_at</td></tr>
<tr><td>刷新令牌</td><td>rotation:更新该行 <code>refresh_jti</code>(新 jti+ <code>last_active</code></td></tr>
<tr><td>登出</td><td>按 refresh-jti 置 <code>revoked_at=now</code> + Redis 删 jti</td></tr>
<tr><td>强制退出</td><td>按 device_id 把该设备所有未撤销 session 置 <code>revoked_at</code> + Redis 删各 jti</td></tr>
</tbody>
</table>
<h3>3.2 <code>devices</code> 表(完整字段)</h3>
<p>既有 7 列(000001 建表 + 000015 加 dp_uuid+ 本方案新增 2 列(000016)。完整 schema</p>
<pre>
devices(
id INTEGER PK AUTOINCREMENT, -- 000001 内部自增主键
uuid TEXT NOT NULL, -- 000001 客户端生成的稳定设备 UUID(API 对外用它)。
-- *000016 唯一键改 UNIQUE(user_id,uuid),见 §3.2.1 ⑤
user_id NOT NULL, -- 000001 FK users.id(设备归属用户)
name TEXT NOT NULL, -- 000001 设备名(主机名/机型,≤64 runes)
platform TEXT NOT NULL CHECK(...), -- 000001 ios|android|windows|macos|linux *
last_seen DATETIME NULL, -- 000001 数据面最后活跃(connect + ReportUsage touch)→ 在线判定
created_at DATETIME NOT NULL, -- 000001 设备首次注册时间
dp_uuid CHAR(36) NULL UNIQUE, -- 000015 每设备数据面凭证(sing-box VLESS user uuid
client_version TEXT NULL, -- 000016 最近上报的客户端版本(列表展示) ← 新增
totp_trusted_until DATETIME NULL -- 000016 预留:2FA「信任设备」过期点;清除登录时清空 ← 新增
)
INDEX (user_id) -- 000001
UNIQUE INDEX (dp_uuid) -- 000015NULL 不冲突,存量待回填)
UNIQUE (user_id, uuid) -- 000016 *改:原 000001 是全局 UNIQUE(uuid),换用户需放开为复合键
</pre>
<table>
<thead><tr><th></th><th>类型</th><th>来源</th><th>用途</th></tr></thead>
<tbody>
<tr><td><code>id</code></td><td>INTEGER PK</td><td>000001</td><td>内部自增主键(sessions/usage_device_daily 用 device_id 引用它)</td></tr>
<tr><td><code>uuid</code></td><td>TEXT<b>UNIQUE(user_id,uuid)</b></td><td>000001<br><span class="ac-c">·唯一键 000016</span></td><td>客户端生成的稳定设备 UUID<code>/v1/me/devices/{uuid}</code> 路径参数即它。安装级、与账户无关,<b>换用户不变</b>(§3.2.1 ⑤)</td></tr>
<tr><td><code>user_id</code></td><td>FK users.id</td><td>000001</td><td>设备归属用户</td></tr>
<tr><td><code>name</code></td><td>TEXT</td><td>000001</td><td>设备名(主机名/机型,<code>normalizeName</code> 截断 ≤64 runes</td></tr>
<tr><td><code>platform</code></td><td>TEXT CHECK</td><td>000001</td><td>平台枚举。<b>*现有 CHECK 仅 4 值,需 000016 放宽加 <code>linux</code></b></td></tr>
<tr><td><code>last_seen</code></td><td>DATETIME NULL</td><td>000001</td><td>数据面最后活跃;&lt;3min=在线。由 connect + ReportUsage touch</td></tr>
<tr><td><code>created_at</code></td><td>DATETIME</td><td>000001</td><td>设备首次注册时间(≠最后登录,后者取 session.created_at</td></tr>
<tr><td><code>dp_uuid</code></td><td>CHAR(36) NULL UNIQUE</td><td>000015</td><td>每设备数据面凭证;<code>EnsureDeviceDpUUID</code> 首连铸入,作 sing-box VLESS user uuid</td></tr>
<tr><td><code>client_version</code></td><td>TEXT NULL</td><td><span class="ac-c">000016 新增</span></td><td>该设备最近上报的客户端版本,列表展示</td></tr>
<tr><td><code>totp_trusted_until</code></td><td>DATETIME NULL</td><td><span class="ac-c">000016 新增</span></td><td><b>预留</b>:未来 2FA「信任设备」过期点;清除登录信息时一并清空</td></tr>
</tbody>
</table>
<p class="small"><b>*platform CHECK 放宽</b>000001 的约束是 <code>CHECK(platform IN ('ios','android','windows','macos'))</code><u>不含 linux</u>,但桌面客户端支持 Linux。000016 需重建该约束加入 <code>'linux'</code>SQLite 改 CHECK 要走「建新表→拷数据→换名」;MySQL 同步改)。</p>
<h3>3.2.1 字段释疑(name 来源 / 三个时间戳 / 设备 id 与限额)</h3>
<div class="card">
<b><code>name</code> 怎么来</b>:由<b>客户端</b>在登录/注册时填好上报(不是服务端造)。客户端用 <code>device_info_plus</code> 按平台取一个可读名:
<table>
<thead><tr><th>平台</th><th>取值</th><th>示例</th></tr></thead>
<tbody>
<tr><td>macOS / Linux</td><td>主机名(<code>Platform.localHostname</code></td><td><code>Wangjia-MacBook-Pro</code></td></tr>
<tr><td>Windows</td><td>计算机名(<code>WindowsDeviceInfo.computerName</code></td><td><code>DESKTOP-7F3K</code></td></tr>
<tr><td>iOS</td><td>设备名/机型(<code>IosDeviceInfo.name</code><code>.utsname.machine</code></td><td><code>iPhone 15</code></td></tr>
<tr><td>Android</td><td>厂商 + 机型(<code>manufacturer + model</code></td><td><code>Xiaomi 13</code></td></tr>
</tbody>
</table>
服务端 <code>RegisterIfAbsent</code><code>normalizeName</code> 截断 ≤64 runes 存库。后续可允许用户在「我的设备」里改名(PATCH,二期可选)。
</div>
<div class="card">
<b>② 三个时间戳别混</b>——分别答「装没装过 / 此刻在不在用 / 上次什么时候登的」:
<ul>
<li><code>created_at</code>devices= 设备<b>首次注册</b>时间,只写一次。</li>
<li><code>last_seen</code>devices= <b>数据面最后活跃</b>,即「此刻是不是正连着节点走流量」。由 <code>connect</code> + 每条 <code>ReportUsage</code>(连着时 ~60s 一次)touch<code>now - last_seen &lt; 3min</code> ⇒ 「在线」。</li>
<li><b>最后登录</b> = 该设备最近一条 <code>sessions.created_at</code>(auth 层,与是否正在走流量无关)。列表上「在线」红绿点看 <code>last_seen</code>,「最后登录 N 天前」看 session。</li>
</ul>
</div>
<div class="card root">
<b>③ 设备 id 与「按设备控制登录数」</b>(你提的将来需求,本设计已为其留好抓手):
<ul>
<li><b>设备身份键</b> = <code>(user_id, devices.uuid)</code><code>uuid</code> 是客户端首启生成的随机 UUID v4,持久化在 <code>flutter_secure_storage</code>;同一安装内跨重启/更新恒定 → 一台真机恒等一行 <code>devices</code>,不因重登重复计数(生成方式见下 ④)。</li>
<li><b>限额抓手已就绪</b><code>RegisterIfAbsent(userID, deviceUUID, name, platform, <u>maxDevices</u>)</code> 在注册新设备时已会<b>按套餐上限校验设备数</b><code>maxDevices</code> 来自计划:免费 1 / PRO 5,对应 <code>Me.devicesMax</code>)。超限时拒绝注册新设备(返回「设备数已达上限」),即「按设备控制用户能登录的设备数」。</li>
<li><b>两种限额口径</b>(将来按需选):<b>(a) 累计设备数</b>——devices 行数 ≤ 上限(当前 RegisterIfAbsent 口径,超了要先「清除登录信息」踢掉旧设备);<b>(b) 同时在线/登录数</b>——以 <code>sessions</code>(未撤销)或 <code>last_seen</code> 在线数为准,限「并发」而非「累计」。<code>sessions</code> 表 + <code>devices.id</code> 同时支持这两种统计。</li>
</ul>
</div>
<div class="card">
<b><code>uuid</code> 怎么生成</b>(替换现有硬编码 <code>_kDeviceId='mac-001'</code>):
<ul>
<li><b>随机 UUID v4</b>,不绑硬件标识——客户端首启用安全随机数生成(Dart <code>uuid</code> 包的 <code>Uuid().v4()</code>128 位随机),<u>立即写入 <code>flutter_secure_storage</code></u>key 如 <code>device_id</code>);之后每次启动先读,读到就复用。</li>
<li><b>为什么不用硬件 ID</b>:iOS 无稳定硬件序列号、Android 限制 <code>ANDROID_ID</code>、桌面 MAC 地址易变/隐私敏感——「随机 UUID + 安全存储持久化」是跨五端唯一可靠且合规的做法。</li>
<li><b>存储位置</b>=各平台安全区:iOS/macOS Keychain、Android Keystore 加密的 EncryptedSharedPreferences、Windows DPAPI 凭据库。普通卸载重装的影响:<span class="warn-c">iOS Keychain 通常<b>跨重装保留</b>uuid 不变);Android 卸载即清</span>(重装会得新 uuid,算一台新设备)。</li>
<li><b>对设备计数的含义</b>:正常使用(重启/更新)uuid 恒定,计数准;卸载重装在部分平台会多记一台——故「累计设备数」超限时需用户「清除登录信息」手动清旧设备,或将来加「30 天未活跃自动回收」。</li>
</ul>
</div>
<div class="card root">
<b>⑤ 换用户 / 共享设备:uuid 不随账户变(含一处 schema 修正)</b>
<ul>
<li><code>device_id</code><b>安装级</b>的,登录前就生成、与账户无关 → 登出 A 登入 B,<b>uuid 不变</b>,同一台机两个账号上报同一个 device uuid。</li>
<li><b>因此唯一键必须是 <code>(user_id, uuid)</code>,不能是全局 <code>uuid UNIQUE</code></b>。现 000001 是 <code>uuid TEXT NOT NULL UNIQUE</code>(全局),B 用同 uuid 注册会撞约束 → <b>000016 改为 <code>UNIQUE(user_id, uuid)</code></b>dp_uuid 仍全局唯一)。</li>
<li>正确模型:同一台机两个账号 = <b>两行 devices</b>(各自 user_id),各自独立 <code>dp_uuid</code> → 流量按 (user, device) 分别归因,互不串。每个用户的「我的设备」只看到自己名下的行。</li>
</ul>
</div>
<div class="card">
<b>⑥ 「按设备去重/限额」的健壮性——别用终身严格去重</b>
<p class="small" style="margin-top:4px">uuid 重装可能 churn(见 ④),若以「终身累计 + 严格按 uuid 去重」做限额会偏脆。设计取 best-effort 键 + 策略兜底:</p>
<ul>
<li><b>iOS 天然稳</b>Keychain 跨重装保留),churn 主要在 Android。</li>
<li><b>按「活跃/在线」计数</b>而非终身累计 → 离线的重装孤儿不占额度(口径 (b))。</li>
<li><b>N 天未活跃自动回收</b>last_seen / 最近 session 过期)→ 孤儿自动老化。</li>
<li><b>用户自助</b>「清除登录信息」手动清旧设备。</li>
</ul>
<p class="small">即:uuid 是 best-effort 稳定键,配「回收 + 自助清理」即可稳健限额,不因偶发 churn 卡死用户。</p>
</div>
<h3>3.3 <code>usage_device_daily</code>(已存在,本方案使其真正写入)</h3>
<pre>
usage_device_daily(user_id, device_id, date, bytes_up, bytes_down, minutes_used)
PK(device_id, date) -- 增量累加:bytes_up = bytes_up + EXCLUDED.bytes_up
</pre>
<p>账户总流量 = 账户级 <code>usage_daily</code><code>AccumulateUsage</code> 始终写);设备明细 = <code>usage_device_daily</code><code>AccumulateDeviceUsage</code>deviceID&gt;0 时写)。二者由同一份 <code>ReportUsage</code> 增量同时落库。</p>
<h2>4. API 增量一览</h2>
<table>
<thead><tr><th>端点</th><th>变化</th></tr></thead>
<tbody>
<tr><td><code>POST /auth/login</code><br><code>POST /auth/register</code></td><td>请求体增 <code>device:{id,name,platform,client_version}</code> → 触发 <code>RegisterIfAbsent</code> + 建 session</td></tr>
<tr><td><code>POST /auth/refresh</code></td><td>更新 <code>session.last_active</code> + jti 轮换(同步换 session.refresh_jti</td></tr>
<tr><td><code>POST /auth/logout</code></td><td>按 refresh-jti 置 <code>session.revoked_at</code>+ Redis 删,已有)</td></tr>
<tr><td><code>GET /v1/me/devices</code></td><td>响应每项增 <code>client_version</code> / <code>online</code>(bool) / <code>last_login</code>(RFC3339)</td></tr>
<tr><td><code>POST /v1/me/devices/{uuid}/logout</code></td><td><span class="tag info"></span> 强制退出:吊销该设备所有 session</td></tr>
<tr><td><code>DELETE /v1/me/devices/{uuid}</code></td><td>增强:吊销 session + <b>真实吊销 dp_uuid</b>(落地 <code>CredentialRevoker</code>,当前是 <code>NoopRevoker</code></td></tr>
</tbody>
</table>
<h2>5. 在线 / 离线判定(数据面在线)</h2>
<ul>
<li><b>在线</b> = <code>devices.last_seen</code> 距今 &lt; 阈值(建议 <b>3 分钟</b>)。</li>
<li><b>谁刷 last_seen</b>:① <code>connect</code> 时 touch;② <b><code>ReportUsage</code> 时 touch</b>——连着时每 ~60s 一次。当前 <code>handler_grpc.go: ReportUsage</code> 解析 dp_uuid→(user,device) 后<u>只累流量、不 touch last_seen</u>,需补这一步。</li>
<li><b>前端</b><code>last_seen &lt; 3min</code> → 绿点「在线」;否则灰点「离线」,并显示「最后登录 · {time}」。</li>
</ul>
<div class="card"><b>为什么不用「会话有效」当在线</b>refresh token 30 天,会话有效不代表正在用。VPN 语境下「在线」应是「此刻正连着节点走流量」,故以数据面 last_seen 为准。</div>
<h2>6. 两个操作的语义</h2>
<table>
<thead><tr><th></th><th>强制退出</th><th>清除登录信息</th></tr></thead>
<tbody>
<tr><td><b>端点</b></td><td><code>POST …/devices/{uuid}/logout</code></td><td><code>DELETE …/devices/{uuid}</code></td></tr>
<tr><td><b>会话</b></td><td>吊销该设备所有 sessionrevoked_at + Redis 删 jti</td><td>同左(一并吊销)</td></tr>
<tr><td><b>devices 行</b></td><td><span class="ok-c">保留</span>(仍在列表,转「离线/已退出」)</td><td><span class="bad-c">删除</span></td></tr>
<tr><td><b>数据面凭证 dp_uuid</b></td><td>不动(重登后仍可用)</td><td><b>吊销</b>:经 <code>nodes.Hub</code> 给各节点推 upsert 去掉该 dp_uuid 的 sing-box user</td></tr>
<tr><td><b>效果</b></td><td>该设备下次 refresh 失败 → 需重新登录</td><td>设备从信任列表彻底剔除</td></tr>
<tr><td><b>未来 2FA</b></td><td></td><td>同时清 <code>totp_trusted_until</code> → 重登需<b>重做 2FA</b></td></tr>
</tbody>
</table>
<p class="small">本机:列表高亮「本机」;本机一般不显示「强制退出」(或显示「退出登录」= 本地 logout)。删除/退出他机需二次确认弹窗(危险样式)。</p>
<h2>7. 前端 UI(「我的 → 我的设备」)</h2>
<p>沿用既有 <code>DevicesScreen</code> 卡片列表,行内信息扩展 + 行尾操作。颜色/字号/间距全走唯一真相源 token(<code>pangolin_theme</code>),不硬编码。</p>
<div class="mock-wrap">
<!-- A · 设备列表 -->
<div class="mock">
<div class="cap">我的设备 · 列表</div>
<div class="panel">
<div class="row">
<div class="ava">💻</div>
<div class="meta">
<div class="nm">MacBook Pro <span class="badge">本机</span></div>
<div class="sub2">macOS · v1.0.10</div>
</div>
<div class="stat on"><span class="dot"></span>在线</div>
<div class="kebab"></div>
</div>
<div class="row">
<div class="ava">📱</div>
<div class="meta">
<div class="nm">iPhone 15</div>
<div class="sub2">iOS · v1.0.9 · 最后登录 2 小时前</div>
</div>
<div class="stat off"><span class="dot"></span>离线</div>
<div class="kebab act"></div>
</div>
<div class="row">
<div class="ava">🖥️</div>
<div class="meta">
<div class="nm">DESKTOP-7F3K</div>
<div class="sub2">windows · v1.0.10 · 最后登录 3 天前</div>
</div>
<div class="stat off"><span class="dot"></span>离线</div>
<div class="kebab"></div>
</div>
</div>
<div class="foot">PRO 套餐最多 5 台同时在线 · 已用 <b>3 / 5</b></div>
</div>
<!-- B · 行尾操作菜单 -->
<div class="mock">
<div class="cap">行尾 ⋯ 操作菜单</div>
<div class="panel" style="border-radius:13px">
<div class="row" style="border-bottom:none;background:#FAF3ED55">
<div class="ava">📱</div>
<div class="meta">
<div class="nm">iPhone 15</div>
<div class="sub2">iOS · v1.0.9</div>
</div>
<div class="kebab act"></div>
</div>
</div>
<div class="menu">
<div class="mi"><span class="ic"></span>强制退出<span class="pin">踢下线,设备留存</span></div>
<div class="mi danger sel"><span class="ic">🗑</span>清除登录信息<span class="pin" style="color:#d09c8c">彻底移除</span></div>
</div>
</div>
<!-- C · 危险二次确认 -->
<div class="mock dlg">
<div class="cap">清除登录信息 · 二次确认</div>
<div class="sheet">
<div class="warnico">⚠️</div>
<div class="dt">清除「iPhone 15」?</div>
<div class="dd">将从设备列表<b>彻底移除</b>此设备,并吊销其登录会话与数据面凭证。该设备需<b>重新登录</b>才能再次使用。</div>
<div class="btns">
<div class="btn ghost">取消</div>
<div class="btn danger">清除</div>
</div>
</div>
</div>
</div>
<p class="small">↑ 用项目真实 token 配色(clay/sand 暖色浅色主题)的还原稿。三态:<b>A</b> 列表(在线=绿点光晕 / 离线=灰点 + 最后登录;本机带「本机」标)→ <b>B</b> 行尾 ⋯ 弹出菜单(强制退出 / 清除登录信息·危险红)→ <b>C</b> 清除前危险二次确认。强制退出走相同弹窗、文案改为「踢下线、设备保留」。</p>
<ul>
<li><b>model</b> <code>Device</code><code>clientVersion</code> / <code>online</code>(bool) / <code>lastLogin</code>(DateTime?)。</li>
<li><b>设备身份</b>:弃用 <code>_kDeviceId='mac-001'</code>,首启生成 UUID v4 存 <code>flutter_secure_storage</code>;登录/注册请求体带 <code>device:{id,name,platform,client_version}</code></li>
<li><b>API/provider</b><code>account_api</code><code>forceLogout(uuid)</code><code>devicesProvider</code> 加对应方法(<code>remove</code> 已有)。</li>
<li><b>l10n</b>:新增 在线/离线/客户端版本/最后登录/强制退出/清除登录信息/本机/确认弹窗(zh+en,<code>app_text.dart</code> + <code>strings_{zh,en}.dart</code>),脱敏不含红线词。</li>
</ul>
<h2>8. 实现路线(分期;本设计文档不含编码)</h2>
<div class="card">
<p><span class="phase">P1 · 设备注册打通</span> — 客户端稳定 device_id + 登录上报元数据;控制面接线 <code>RegisterIfAbsent</code><br><span class="small">产出:「我的设备」列表显示真实登录过的设备(最大缺口闭合)。</span></p>
<p><span class="phase">P2 · sessions 表 + 在线/最后登录</span> — migration 000016<code>Issue/Refresh/Logout</code> 写 session<code>ReportUsage</code> touch last_seen<code>GET /devices</code> 返回 <code>online/last_login/client_version</code></p>
<p><span class="phase">P3 · 两个操作</span> — 强制退出端点 + 清除增强(session 吊销 + <code>CredentialRevoker</code> 真实吊销 dp_uuid)。</p>
<p><span class="phase">P4 · 每设备流量验证</span> — 端到端确认 <code>usage_device_daily</code> 填充 + 统计页设备下拉接真数据(对接已有 <code>deviceUsageProvider</code>,呼应统计整改 #10)。</p>
<p><span class="phase">P5 · (未来)2FA 信任设备</span> — 清除登录信息联动 2FA 重验(<code>totp_trusted_until</code>)。</p>
</div>
<hr>
<p class="small">关联:<a href="stats-overhaul-plan.html">统计体系整改(#5</a>(每设备归因 / 统计页设备下拉)· <a href="feature-test-coverage-checklist.html">功能 × 测试覆盖清单</a>(设备/会话验收)。本文为<b>设计方案</b>;实现按 P1P5 后续单独排期。</p>
</div>
</body>
</html>