Files
pangolin/docs/device-session-management-design.html
T
wangjia c1994ad048 docs(design): devices 表补全字段 + name来源/三时间戳/按设备限登录数 释疑
§3.2 列出 devices 完整 9 列(含来源迁移 + platform CHECK 需加 linux);
新增 §3.2.1:① name 由客户端 device_info_plus 按平台取(主机名/机型)上报;
② created_at/last_seen/最后登录(session) 三时间戳辨析;③ 设备身份键
(user_id,uuid) + RegisterIfAbsent maxDevices 作「按设备控制登录数」抓手
(累计 vs 同时在线两种口径)。

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

292 lines
25 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>
<p>既有 7 列(000001 建表 + 000015 加 dp_uuid+ 本方案新增 2 列(000016)。完整 schema</p>
<pre>
devices(
id INTEGER PK AUTOINCREMENT, -- 000001 内部自增主键
uuid TEXT NOT NULL UNIQUE, -- 000001 客户端生成的稳定设备 UUID(API 对外用它)
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 不冲突,存量待回填)
</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 UNIQUE</td><td>000001</td><td>客户端生成的稳定设备 UUID<code>/v1/me/devices/{uuid}</code> 路径参数即它</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> 由客户端生成、持久化在 <code>flutter_secure_storage</code>,同一台机器重装 app 内仍稳定 → 一台真实设备恒等于一行 <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>
<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>