889cff4556
新增 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>
423 lines
34 KiB
HTML
423 lines
34 KiB
HTML
<!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>设备 & 会话管理 + 每设备流量归因<span class="small">(设计方案)</span></h1>
|
||
<p class="sub">Pangolin · 「我的设备」管理 + 控制面会话 + 数据面按设备记账 · 2026-06-28 · <a href="device-session-management-plan.html"><b>→ 实现计划 P1–P6</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>>>{dp_uuid}>>>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>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>>>{dp_uuid}>>>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 000016,mysql + 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) -- 000015(NULL 不冲突,存量待回填)
|
||
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>数据面最后活跃;<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 < 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>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> 距今 < 阈值(建议 <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 < 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>吊销该设备所有 session(revoked_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>;实现按 P1–P5 后续单独排期。</p>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|