← 文档索引

设备 & 会话管理 + 每设备流量归因(设计方案)

Pangolin · 「我的设备」管理 + 控制面会话 + 数据面按设备记账 · 2026-06-28 · → 实现计划 P1–P6

一句话:后端绝大部分已经建好,只是「没接线」devices 表、每设备数据面凭证 dp_uuidusage_device_daily、设备列表/删除接口都在——但 RegisterIfAbsent 生产代码从不调用, 导致 devices 表永远是空的,整条每设备链路退回账户级。本方案 = 打通三条已存在的链路 + 新增 sessions 表, 让设备/会话/每设备流量三件事一次成型。

1. 背景与现状(已建 vs 缺口)

排查三端(控制面 Go / 数据面 sing-box / 客户端 Flutter)后的对照:

能力现状位置缺口
devices 表 + 列表/删除接口已建migrations/*/000001internal/devices/{store,service,handler}.goGET/DELETE /v1/me/devices表始终为空(无人写入)
设备隐式注册 RegisterIfAbsent未接线devices/service.go已实现,生产请求路径从不调用(仅单测用)
每设备数据面凭证 dp_uuid就绪未生效migrations/*/000015nodes/store.go: EnsureDeviceDpUUID依赖 device 行存在;表空 → 退回账户级 users.dp_uuid
每设备用量表 usage_device_daily就绪未生效migrations/*/000015nodes/store.go: AccumulateDeviceUsage因 deviceID 永为 0 → 从不写入
sing-box 按 dp_uuid 计量已建agentd/render.gousage_v2ray.go无(已按 user>>>{dp_uuid}>>>traffic 计量、reset 取增量)
会话(JWT + Redis 白名单)已建auth/token.go(access 15min / refresh 30day)会话不绑设备 → 无法「按设备强制退出」
客户端「我的设备」页已建widgets/account_screens.dart: DevicesScreen无在线状态/客户端版本;操作仅「移除」
客户端设备身份硬编码connection_provider.dart: _kDeviceId='mac-001'固定值;版本(appVersionProvider 有)没上报
核心缺口一句话RegisterIfAbsent 没被调用 → devices 表空 → EnsureDeviceDpUUID 找不到设备行 → 退回账户级 dp_uuidUserDeviceByDpUUID 返回 deviceID=0AccumulateDeviceUsageif deviceID>0 跳过 → usage_device_daily 永远空。补上「登录即注册设备」这一步,整条链路自洽。

已确认的设计决策

2. 总体架构(三端数据流)

三支柱:设备身份(客户端稳定 UUID + 上报元数据)→ 会话(每次登录建 session 绑设备)→ 每设备流量(设备注册后 dp_uuid 自然铺开)。

  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

3. 数据模型

3.1 新增 sessions 表(migration 000016,mysql + sqlite)

每次登录一条;绑定设备与 refresh-token JTI,支撑「按设备强制退出 / 最后登录 / 会话历史」。SQL 走中性方言(不用 NOW()/UTC_TIMESTAMP(),时间 Go 端算好传参)。

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)
时机对 sessions 的动作
登录 / 注册TokenManager.Issue 同事务 INSERT 一条(写 device_id / jti / ip / version / created_at)
刷新令牌rotation:更新该行 refresh_jti(新 jti)+ last_active
登出按 refresh-jti 置 revoked_at=now + Redis 删 jti
强制退出按 device_id 把该设备所有未撤销 session 置 revoked_at + Redis 删各 jti

3.2 devices 表(完整字段)

既有 7 列(000001 建表 + 000015 加 dp_uuid)+ 本方案新增 2 列(000016)。完整 schema:

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),换用户需放开为复合键
类型来源用途
idINTEGER PK000001内部自增主键(sessions/usage_device_daily 用 device_id 引用它)
uuidTEXT,UNIQUE(user_id,uuid)000001
·唯一键 000016
客户端生成的稳定设备 UUID;/v1/me/devices/{uuid} 路径参数即它。安装级、与账户无关,换用户不变(§3.2.1 ⑤)
user_idFK users.id000001设备归属用户
nameTEXT000001设备名(主机名/机型,normalizeName 截断 ≤64 runes)
platformTEXT CHECK000001平台枚举。*现有 CHECK 仅 4 值,需 000016 放宽加 linux
last_seenDATETIME NULL000001数据面最后活跃;<3min=在线。由 connect + ReportUsage touch
created_atDATETIME000001设备首次注册时间(≠最后登录,后者取 session.created_at)
dp_uuidCHAR(36) NULL UNIQUE000015每设备数据面凭证;EnsureDeviceDpUUID 首连铸入,作 sing-box VLESS user uuid
client_versionTEXT NULL000016 新增该设备最近上报的客户端版本,列表展示
totp_trusted_untilDATETIME NULL000016 新增预留:未来 2FA「信任设备」过期点;清除登录信息时一并清空

*platform CHECK 放宽:000001 的约束是 CHECK(platform IN ('ios','android','windows','macos'))不含 linux,但桌面客户端支持 Linux。000016 需重建该约束加入 'linux'(SQLite 改 CHECK 要走「建新表→拷数据→换名」;MySQL 同步改)。

3.2.1 字段释疑(name 来源 / 三个时间戳 / 设备 id 与限额)

name 怎么来:由客户端在登录/注册时填好上报(不是服务端造)。客户端用 device_info_plus 按平台取一个可读名:
平台取值示例
macOS / Linux主机名(Platform.localHostnameWangjia-MacBook-Pro
Windows计算机名(WindowsDeviceInfo.computerNameDESKTOP-7F3K
iOS设备名/机型(IosDeviceInfo.name.utsname.machineiPhone 15
Android厂商 + 机型(manufacturer + modelXiaomi 13
服务端 RegisterIfAbsentnormalizeName 截断 ≤64 runes 存库。后续可允许用户在「我的设备」里改名(PATCH,二期可选)。
② 三个时间戳别混——分别答「装没装过 / 此刻在不在用 / 上次什么时候登的」:
③ 设备 id 与「按设备控制登录数」(你提的将来需求,本设计已为其留好抓手):
uuid 怎么生成(替换现有硬编码 _kDeviceId='mac-001'):
⑤ 换用户 / 共享设备:uuid 不随账户变(含一处 schema 修正)
⑥ 「按设备去重/限额」的健壮性——别用终身严格去重

uuid 重装可能 churn(见 ④),若以「终身累计 + 严格按 uuid 去重」做限额会偏脆。设计取 best-effort 键 + 策略兜底:

即:uuid 是 best-effort 稳定键,配「回收 + 自助清理」即可稳健限额,不因偶发 churn 卡死用户。

3.3 usage_device_daily(已存在,本方案使其真正写入)

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

账户总流量 = 账户级 usage_dailyAccumulateUsage 始终写);设备明细 = usage_device_dailyAccumulateDeviceUsage,deviceID>0 时写)。二者由同一份 ReportUsage 增量同时落库。

4. API 增量一览

端点变化
POST /auth/login
POST /auth/register
请求体增 device:{id,name,platform,client_version} → 触发 RegisterIfAbsent + 建 session
POST /auth/refresh更新 session.last_active + jti 轮换(同步换 session.refresh_jti)
POST /auth/logout按 refresh-jti 置 session.revoked_at(+ Redis 删,已有)
GET /v1/me/devices响应每项增 client_version / online(bool) / last_login(RFC3339)
POST /v1/me/devices/{uuid}/logout 强制退出:吊销该设备所有 session
DELETE /v1/me/devices/{uuid}增强:吊销 session + 真实吊销 dp_uuid(落地 CredentialRevoker,当前是 NoopRevoker

5. 在线 / 离线判定(数据面在线)

为什么不用「会话有效」当在线:refresh token 30 天,会话有效不代表正在用。VPN 语境下「在线」应是「此刻正连着节点走流量」,故以数据面 last_seen 为准。

6. 两个操作的语义

强制退出清除登录信息
端点POST …/devices/{uuid}/logoutDELETE …/devices/{uuid}
会话吊销该设备所有 session(revoked_at + Redis 删 jti)同左(一并吊销)
devices 行保留(仍在列表,转「离线/已退出」)删除
数据面凭证 dp_uuid不动(重登后仍可用)吊销:经 nodes.Hub 给各节点推 upsert 去掉该 dp_uuid 的 sing-box user
效果该设备下次 refresh 失败 → 需重新登录设备从信任列表彻底剔除
未来 2FA同时清 totp_trusted_until → 重登需重做 2FA

本机:列表高亮「本机」;本机一般不显示「强制退出」(或显示「退出登录」= 本地 logout)。删除/退出他机需二次确认弹窗(危险样式)。

7. 前端 UI(「我的 → 我的设备」)

沿用既有 DevicesScreen 卡片列表,行内信息扩展 + 行尾操作。颜色/字号/间距全走唯一真相源 token(pangolin_theme),不硬编码。

我的设备 · 列表
💻
MacBook Pro 本机
macOS · v1.0.10
在线
📱
iPhone 15
iOS · v1.0.9 · 最后登录 2 小时前
离线
🖥️
DESKTOP-7F3K
windows · v1.0.10 · 最后登录 3 天前
离线
PRO 套餐最多 5 台同时在线 · 已用 3 / 5
行尾 ⋯ 操作菜单
📱
iPhone 15
iOS · v1.0.9
清除登录信息 · 二次确认
⚠️
清除「iPhone 15」?
将从设备列表彻底移除此设备,并吊销其登录会话与数据面凭证。该设备需重新登录才能再次使用。
取消
清除

↑ 用项目真实 token 配色(clay/sand 暖色浅色主题)的还原稿。三态:A 列表(在线=绿点光晕 / 离线=灰点 + 最后登录;本机带「本机」标)→ B 行尾 ⋯ 弹出菜单(强制退出 / 清除登录信息·危险红)→ C 清除前危险二次确认。强制退出走相同弹窗、文案改为「踢下线、设备保留」。

8. 实现路线(分期;本设计文档不含编码)

P1 · 设备注册打通 — 客户端稳定 device_id + 登录上报元数据;控制面接线 RegisterIfAbsent
产出:「我的设备」列表显示真实登录过的设备(最大缺口闭合)。

P2 · sessions 表 + 在线/最后登录 — migration 000016;Issue/Refresh/Logout 写 session;ReportUsage touch last_seen;GET /devices 返回 online/last_login/client_version

P3 · 两个操作 — 强制退出端点 + 清除增强(session 吊销 + CredentialRevoker 真实吊销 dp_uuid)。

P4 · 每设备流量验证 — 端到端确认 usage_device_daily 填充 + 统计页设备下拉接真数据(对接已有 deviceUsageProvider,呼应统计整改 #10)。

P5 · (未来)2FA 信任设备 — 清除登录信息联动 2FA 重验(totp_trusted_until)。


关联:统计体系整改(#5)(每设备归因 / 统计页设备下拉)· 功能 × 测试覆盖清单(设备/会话验收)。本文为设计方案;实现按 P1–P5 后续单独排期。