Files
pangolin/docs/device-session-management-design.html
T
wangjia 31ac76e3d5 docs(design): 设备&会话管理 + 每设备流量归因 全局设计(HTML)
新增 docs/device-session-management-design.html 并登记进 docs/index.html
「设计方案」分类。覆盖三端:控制面 sessions 表绑设备 + RegisterIfAbsent
接线、数据面每设备 dp_uuid 记账(usage_device_daily)、前端「我的设备」
(在线/客户端版本/最后登录 + 强制退出/清除登录信息)。核心洞察:后端大半
已建但 RegisterIfAbsent 未接线致 devices 表空。含 P1–P5 实现路线。

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

233 lines
19 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)}
</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</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>
<table>
<thead><tr><th></th><th>类型</th><th>用途</th></tr></thead>
<tbody>
<tr><td><code>client_version</code></td><td>TEXT NULL</td><td>该设备最近上报的客户端版本,列表展示</td></tr>
<tr><td><code>totp_trusted_until</code></td><td>DATETIME NULL</td><td><b>预留</b>:未来 2FA「信任设备」过期点;清除登录信息时一并清空</td></tr>
</tbody>
</table>
<p class="small">既有列:<code>uuid / user_id / name / platform / last_seen / created_at / dp_uuid(000015)</code><b>设备「最后登录时间」</b>= 该设备最近一条 <code>session.created_at</code>(不复用 last_seen,后者是数据面活跃度)。</p>
<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>
<pre>
┌─────────────────────────────────────────────────────────────┐
│ [▦] MacBook Pro ● 在线 [ ⋯ ] │ ⋯ 菜单:
│ macOS · v1.0.10 · 本机 │ ├ 强制退出
├─────────────────────────────────────────────────────────────┤ └ 清除登录信息
│ [▦] iPhone 15 ○ 离线 [ ⋯ ] │
│ iOS · v1.0.9 · 最后登录 2 小时前 │
├─────────────────────────────────────────────────────────────┤
│ [▦] Windows-PC ○ 离线 [ ⋯ ] │
│ windows · v1.0.10 · 最后登录 3 天前 │
└─────────────────────────────────────────────────────────────┘
PRO 套餐最多 5 台设备同时在线 · 已用 3 / 5
</pre>
<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>