Files
pangolin/docs/device-limit-design.html
wangjia e6f622d605
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
docs: 设备数量限制 + 超限 UX 设计文档(#16) + 登记 index
沿用家族样式;涵盖决策(登录闸/踢旧/自动清理)、DB(无 schema 变更)、后端
(devices CheckDeviceLimit + auth 登录闸)、前端(DeviceLimitScreen)、验证与不在本轮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:26:51 +08:00

120 lines
7.8 KiB
HTML
Raw Permalink 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>设备数量限制 + 超限 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>