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
沿用家族样式;涵盖决策(登录闸/踢旧/自动清理)、DB(无 schema 变更)、后端 (devices CheckDeviceLimit + auth 登录闸)、前端(DeviceLimitScreen)、验证与不在本轮。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
7.8 KiB
HTML
120 lines
7.8 KiB
HTML
<!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 > 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 > cap 即 <code>Over</code>,附活跃设备列表。设备登录时已注册,故「超」= <code>activeCount > 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>
|