docs: 设备数量限制 + 超限 UX 设计文档(#16) + 登记 index
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled

沿用家族样式;涵盖决策(登录闸/踢旧/自动清理)、DB(无 schema 变更)、后端
(devices CheckDeviceLimit + auth 登录闸)、前端(DeviceLimitScreen)、验证与不在本轮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-01 19:26:51 +08:00
parent 34d980d875
commit e6f622d605
2 changed files with 124 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设备数量限制 + 超限 UX(设计 · #16)</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:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:26px 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:13px;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.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}
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}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>设备数量限制 + 超限 UX<span class="small">(设计 · #16</span></h1>
<p class="sub">Pangolin · 真正启用套餐设备上限 + 非硬拒登的「移除设备」体验 · 2026-07-01</p>
<div class="lead">
<code>plans.max_devices</code>free 1 / <b>pro 3</b> / team 10)过去<b>只展示、不强制</b>:登录注册写死
<code>MaxDevices=0</code><code>CheckDeviceQuota</code> 是死代码。本次真正启用上限,按<b>卡在登录(非硬拒登)
+ 选择移除/一键踢最旧 + 服务端自动清理久不活跃</b>落地。
</div>
<h2>1. 关键决策</h2>
<table>
<thead><tr><th>维度</th><th>选择</th><th>理由</th></tr></thead>
<tbody>
<tr><td>执行点</td><td><b>登录</b>+ 连接 backstop 待硬化)</td><td>登录照常签发 token,超限只返回 <code>device_limit</code> 信号、客户端挡在「移除设备」页;不硬拒登。</td></tr>
<tr><td>解决方式</td><td><b>选择移除 + 一键「移除最久未用」</b></td><td>兼顾控制与便捷;移除即调现成 <code>DeleteDevice</code>(撤销会话+删行+召回 dp_uuid 凭证→设备立即离线登出)。</td></tr>
<tr><td>免费重装 churn</td><td><b>服务端自动清理久不活跃</b></td><td>count 只算近 <code>staleWindow=30d</code><code>last_seen</code>)活跃设备;登录 best-effort 删超期僵尸行 → 重装刷新的 UUID 自愈,不锁死用户。</td></tr>
</tbody>
</table>
<h2>2. 数据库</h2>
<p><b>无 schema 变更</b>:复用 <code>plans.max_devices</code><code>devices.last_seen</code><code>sessions</code>。仅新增常量 <code>staleWindow=30d</code>
活跃计数 <code>SELECT COUNT(1) FROM devices WHERE user_id=? AND last_seen IS NOT NULL AND last_seen &gt; now-30d</code></p>
<p class="small">另:本次把 <code>pro</code> 上限从 5 改为 3(seed + 迁移 <code>000019</code>;线上需 <code>migrate up</code> 生效)。</p>
<h2>3. 后端</h2>
<div class="card">
<b><code>internal/devices</code></b>
<ul>
<li><code>store.CountActiveDevices</code>(近 30d+ <code>store.PruneStaleDevices</code>(删超期僵尸行,best-effort)。</li>
<li><code>service.CheckDeviceLimit</code>prune → <code>ResolvePlan</code> → 活跃 count &gt; cap 即 <code>Over</code>,附活跃设备列表。设备登录时已注册,故「超」= <code>activeCount &gt; cap</code></li>
</ul>
</div>
<div class="card">
<b><code>internal/auth</code>(登录闸,非硬拒登)</b>
<ul>
<li>设备仍照常 <code>RegisterIfAbsent(MaxDevices=0)</code> + 建 session(不动 best-effort)。</li>
<li><code>DeviceRegistrar</code><code>CheckDeviceLimit</code><code>recordLogin</code> 回传 <code>*DeviceLimit</code><code>LoginOutcome.DeviceLimit</code><code>Login</code> 透传;handler <code>tokenPairResponse.device_limit</code>omitempty)。</li>
<li><code>main.go</code><code>authDeviceRegistrar</code> 适配 <code>devices.CheckDeviceLimit → auth.DeviceLimit</code></li>
</ul>
</div>
<p>登录响应(超限时):</p>
<pre>{ "access_token":"…", "refresh_token":"…", "expires_in":900,
"device_limit": { "max_devices":1,
"devices":[ {"uuid":"…","name":"iPhone","platform":"ios","last_seen":"2026-06-30T…Z"} ] } }</pre>
<p class="small"><span class="tag warn">待硬化</span> 连接 backstop<code>ConnectNode</code> 拒超限)本轮从简未做:登录闸是服务端权威信号、客户端据此自挡;后续在 <code>Entitlement</code> + <code>NodeStore</code> 加 count 做服务端硬拦。</p>
<h2>4. 前端</h2>
<ul>
<li><code>auth_api.dart</code><code>AuthTokens</code> 解析 <code>device_limit</code>;新增 <code>DeviceLimit</code>/<code>DeviceBrief</code> 模型。</li>
<li><code>auth_provider.dart</code><code>deviceLimitProvider</code>(挡板状态);<code>auth_screen</code> 登录后置入。</li>
<li><code>main.dart</code><code>isLoggedIn</code> 后若 <code>deviceLimit!=null</code><code>DeviceLimitScreen</code>(挡 <code>HomeShell</code>)。</li>
<li><b><code>screens/device_limit_screen.dart</code></b>:设备列表(<b>本机标注、不可删</b>)+ 每台移除 + 一键「移除最久未用」(取 <code>last_seen</code> 最旧的非本机)+ 退出登录;移除后 <code>devicesProvider</code> 刷新,<code>count≤max</code> 自动清挡板放行。</li>
<li>l10n<code>deviceLimitTitle/Desc/RemoveOldest</code>zh/en)。</li>
</ul>
<h2>5. 验证</h2>
<ul>
<li>后端单测:<code>devices</code> within/over/prune-stale<code>TestCheckDeviceLimit</code>integration 标签,需 Docker/CI);<code>auth</code> 登录透传超限信号(仍签发 token)。</li>
<li>前端:<code>flutter analyze</code> 干净;client 全量测试 161 通过。</li>
<li>真机手测:free(cap1) 第 2 台登录 → 登录成功但挡「超限」页 → 一键踢最旧/选择移除 → 进主界面且可连接;pro(cap3) 第 4 台同理;把某设备 <code>last_seen</code> 改到 30d 前 → 不计数且被自动清理。</li>
</ul>
<h2>6. 不在本轮</h2>
<ul>
<li>连接 backstop 服务端硬拦(见 §3 待硬化)。</li>
<li>设备 UUID 跨重装稳定(客户端 keychain 持久化)——本轮用服务端清理替代。</li>
<li>TOTP 登录路径的设备注册/超限(TOTP 未启用,见 #4)。</li>
</ul>
</div>
</body>
</html>
+5
View File
@@ -44,6 +44,11 @@
</div>
<h2>设计方案 / Specs</h2>
<a class="doc" href="device-limit-design.html">
<div class="t">设备数量限制 + 超限 UX <span class="tag html">HTML</span></div>
<div class="d">真正启用套餐设备上限(free 1 / pro 3 / team 10):卡在登录(非硬拒登,返回 device_limit 信号)+ 选择移除/一键踢最旧 + 服务端按 last_seen 自动清理久不活跃。复用现成 DeleteDevice/ResolvePlan。无 DB schema 变更。#16。</div>
<div class="path">docs/device-limit-design.html</div>
</a>
<a class="doc" href="device-session-management-design.html">
<div class="t">设备 &amp; 会话管理 + 每设备流量归因 <span class="tag html">HTML</span></div>
<div class="d">「我的设备」管理(名称/平台/客户端版本/在线/最后登录 + 强制退出/清除登录信息)+ 控制面 sessions 表绑设备 + 数据面每设备 dp_uuid 记账。核心洞察:后端大半已建但 RegisterIfAbsent 未接线致 devices 表空。P1P5 路线。</div>