Pangolin · 「我的设备」管理 + 控制面会话 + 数据面按设备记账 · 2026-06-28 · → 实现计划 P1–P6
devices 表、每设备数据面凭证 dp_uuid、
usage_device_daily、设备列表/删除接口都在——但 RegisterIfAbsent 生产代码从不调用,
导致 devices 表永远是空的,整条每设备链路退回账户级。本方案 = 打通三条已存在的链路 + 新增 sessions 表,
让设备/会话/每设备流量三件事一次成型。
排查三端(控制面 Go / 数据面 sing-box / 客户端 Flutter)后的对照:
| 能力 | 现状 | 位置 | 缺口 |
|---|---|---|---|
| devices 表 + 列表/删除接口 | 已建 | migrations/*/000001、internal/devices/{store,service,handler}.go、GET/DELETE /v1/me/devices | 表始终为空(无人写入) |
设备隐式注册 RegisterIfAbsent | 未接线 | devices/service.go | 已实现,生产请求路径从不调用(仅单测用) |
每设备数据面凭证 dp_uuid | 就绪未生效 | migrations/*/000015、nodes/store.go: EnsureDeviceDpUUID | 依赖 device 行存在;表空 → 退回账户级 users.dp_uuid |
每设备用量表 usage_device_daily | 就绪未生效 | migrations/*/000015、nodes/store.go: AccumulateDeviceUsage | 因 deviceID 永为 0 → 从不写入 |
| sing-box 按 dp_uuid 计量 | 已建 | agentd/render.go、usage_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_uuid → UserDeviceByDpUUID 返回 deviceID=0 → AccumulateDeviceUsage 被 if deviceID>0 跳过 → usage_device_daily 永远空。补上「登录即注册设备」这一步,整条链路自洽。
devices.last_seen 在阈值内(建议 3 分钟)才算「在线」,另列「最后登录时间」。sessions 表(DB,可查历史、能按设备精准吊销),保留 Redis 白名单做快速校验。三支柱:设备身份(客户端稳定 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
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 |
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),换用户需放开为复合键
| 列 | 类型 | 来源 | 用途 |
|---|---|---|---|
id | INTEGER PK | 000001 | 内部自增主键(sessions/usage_device_daily 用 device_id 引用它) |
uuid | TEXT,UNIQUE(user_id,uuid) | 000001 ·唯一键 000016 | 客户端生成的稳定设备 UUID;/v1/me/devices/{uuid} 路径参数即它。安装级、与账户无关,换用户不变(§3.2.1 ⑤) |
user_id | FK users.id | 000001 | 设备归属用户 |
name | TEXT | 000001 | 设备名(主机名/机型,normalizeName 截断 ≤64 runes) |
platform | TEXT CHECK | 000001 | 平台枚举。*现有 CHECK 仅 4 值,需 000016 放宽加 linux |
last_seen | DATETIME NULL | 000001 | 数据面最后活跃;<3min=在线。由 connect + ReportUsage touch |
created_at | DATETIME | 000001 | 设备首次注册时间(≠最后登录,后者取 session.created_at) |
dp_uuid | CHAR(36) NULL UNIQUE | 000015 | 每设备数据面凭证;EnsureDeviceDpUUID 首连铸入,作 sing-box VLESS user uuid |
client_version | TEXT NULL | 000016 新增 | 该设备最近上报的客户端版本,列表展示 |
totp_trusted_until | DATETIME NULL | 000016 新增 | 预留:未来 2FA「信任设备」过期点;清除登录信息时一并清空 |
*platform CHECK 放宽:000001 的约束是 CHECK(platform IN ('ios','android','windows','macos')),不含 linux,但桌面客户端支持 Linux。000016 需重建该约束加入 'linux'(SQLite 改 CHECK 要走「建新表→拷数据→换名」;MySQL 同步改)。
name 怎么来:由客户端在登录/注册时填好上报(不是服务端造)。客户端用 device_info_plus 按平台取一个可读名:
| 平台 | 取值 | 示例 |
|---|---|---|
| macOS / Linux | 主机名(Platform.localHostname) | Wangjia-MacBook-Pro |
| Windows | 计算机名(WindowsDeviceInfo.computerName) | DESKTOP-7F3K |
| iOS | 设备名/机型(IosDeviceInfo.name 或 .utsname.machine) | iPhone 15 |
| Android | 厂商 + 机型(manufacturer + model) | Xiaomi 13 |
RegisterIfAbsent 经 normalizeName 截断 ≤64 runes 存库。后续可允许用户在「我的设备」里改名(PATCH,二期可选)。
created_at(devices)= 设备首次注册时间,只写一次。last_seen(devices)= 数据面最后活跃,即「此刻是不是正连着节点走流量」。由 connect + 每条 ReportUsage(连着时 ~60s 一次)touch;now - last_seen < 3min ⇒ 「在线」。sessions.created_at(auth 层,与是否正在走流量无关)。列表上「在线」红绿点看 last_seen,「最后登录 N 天前」看 session。(user_id, devices.uuid)。uuid 是客户端首启生成的随机 UUID v4,持久化在 flutter_secure_storage;同一安装内跨重启/更新恒定 → 一台真机恒等一行 devices,不因重登重复计数(生成方式见下 ④)。RegisterIfAbsent(userID, deviceUUID, name, platform, maxDevices) 在注册新设备时已会按套餐上限校验设备数(maxDevices 来自计划:免费 1 / PRO 5,对应 Me.devicesMax)。超限时拒绝注册新设备(返回「设备数已达上限」),即「按设备控制用户能登录的设备数」。sessions(未撤销)或 last_seen 在线数为准,限「并发」而非「累计」。sessions 表 + devices.id 同时支持这两种统计。uuid 怎么生成(替换现有硬编码 _kDeviceId='mac-001'):
uuid 包的 Uuid().v4(),128 位随机),立即写入 flutter_secure_storage(key 如 device_id);之后每次启动先读,读到就复用。ANDROID_ID、桌面 MAC 地址易变/隐私敏感——「随机 UUID + 安全存储持久化」是跨五端唯一可靠且合规的做法。device_id 是安装级的,登录前就生成、与账户无关 → 登出 A 登入 B,uuid 不变,同一台机两个账号上报同一个 device uuid。(user_id, uuid),不能是全局 uuid UNIQUE。现 000001 是 uuid TEXT NOT NULL UNIQUE(全局),B 用同 uuid 注册会撞约束 → 000016 改为 UNIQUE(user_id, uuid)(dp_uuid 仍全局唯一)。dp_uuid → 流量按 (user, device) 分别归因,互不串。每个用户的「我的设备」只看到自己名下的行。uuid 重装可能 churn(见 ④),若以「终身累计 + 严格按 uuid 去重」做限额会偏脆。设计取 best-effort 键 + 策略兜底:
即:uuid 是 best-effort 稳定键,配「回收 + 自助清理」即可稳健限额,不因偶发 churn 卡死用户。
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_daily(AccumulateUsage 始终写);设备明细 = usage_device_daily(AccumulateDeviceUsage,deviceID>0 时写)。二者由同一份 ReportUsage 增量同时落库。
| 端点 | 变化 |
|---|---|
POST /auth/loginPOST /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) |
devices.last_seen 距今 < 阈值(建议 3 分钟)。connect 时 touch;② ReportUsage 时 touch——连着时每 ~60s 一次。当前 handler_grpc.go: ReportUsage 解析 dp_uuid→(user,device) 后只累流量、不 touch last_seen,需补这一步。last_seen < 3min → 绿点「在线」;否则灰点「离线」,并显示「最后登录 · {time}」。| 强制退出 | 清除登录信息 | |
|---|---|---|
| 端点 | POST …/devices/{uuid}/logout | DELETE …/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)。删除/退出他机需二次确认弹窗(危险样式)。
沿用既有 DevicesScreen 卡片列表,行内信息扩展 + 行尾操作。颜色/字号/间距全走唯一真相源 token(pangolin_theme),不硬编码。
↑ 用项目真实 token 配色(clay/sand 暖色浅色主题)的还原稿。三态:A 列表(在线=绿点光晕 / 离线=灰点 + 最后登录;本机带「本机」标)→ B 行尾 ⋯ 弹出菜单(强制退出 / 清除登录信息·危险红)→ C 清除前危险二次确认。强制退出走相同弹窗、文案改为「踢下线、设备保留」。
Device 加 clientVersion / online(bool) / lastLogin(DateTime?)。_kDeviceId='mac-001',首启生成 UUID v4 存 flutter_secure_storage;登录/注册请求体带 device:{id,name,platform,client_version}。account_api 加 forceLogout(uuid);devicesProvider 加对应方法(remove 已有)。app_text.dart + strings_{zh,en}.dart),脱敏不含红线词。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 后续单独排期。