v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)

用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
This commit is contained in:
2026-10-18 03:34:47 +08:00
parent 190dc56301
commit ebaed21920
8 changed files with 1329 additions and 140 deletions
+89 -3
View File
@@ -160,10 +160,96 @@ $rosterCount = $rosterCount ?? 0;
(一台设备一枚)、可逐枚吊销、可设有效期、带设备名。
两件事的生命周期完全不同(吊销 DAV 口令会连带断掉日历同步;
吊销一枚 App 令牌不该影响别人的手机)。 */ ?>
<!-- ==================== 设备管理(v57,用户 2026-10-17)==================== -->
<?php /* 用户原话:「在我们服务器端的个人设置那里面,就有一个这个设备管理,
就是说我都有哪些设备已经在登录这个东西。」
「允许我们的用户把其中的某一个客户端远程给它退出,相当于它这个 Token 给它删掉。
这样的话,这个应用一旦打开,就发现这个 Token 已经失效了,返回登录页面。」
⚠️ 这里列的是**设备**(按 device_id 聚合),不是令牌行:
同一台手机重登过 5 次,这一页只该出现 1 行。
⚠️ 文案里必须说清"下次联网才生效" —— 服务端没有推送通道,
写"立刻失效"是骗人;而 App 端每次启动都会打 `/me`,所以体感上很快。 */ ?>
<div class="card card-pad" id="devices" style="margin-top:20px">
<h2 class="section-title">
设备管理
<span class="hint">哪些手机 / 平板正在登录这个账号</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
在 App 里每登录一次,这里就多一台设备(最多 <?= (int)($deviceMax ?? 10) ?> 台)。
令牌有效期 <?= (int)($deviceTtl ?? 24) ?> 小时,
<b>只要设备在联网就会自动续期</b>,所以常用的手机不需要反复登录。
<br>手机丢了就在这里「移除」—— 那台设备下次联网时会立刻退回登录页,
本机已下载的数据将无法查看。
</p>
<?php $deviceRows = $devices ?? []; ?>
<?php if (!empty($deviceRows)): ?>
<table class="res-table" style="width:100%;margin:6px 0 10px">
<thead>
<tr><th>设备</th><th>系统 / 客户端</th><th>最近活跃</th><th>登录时间</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($deviceRows as $d): ?>
<?php
$dvName = (string)($d['device_name'] ?? '');
$dvPlat = trim((string)($d['platform'] ?? ''));
$dvVer = (string)($d['app_version'] ?? '');
if ($dvVer !== '') {
$dvPlat = ($dvPlat !== '' ? $dvPlat . ' · ' : '') . '客户端 ' . $dvVer;
}
$dvId = (string)($d['device_id'] ?? '');
?>
<tr>
<td>
<b><?= e($dvName !== '' ? $dvName : '未知设备') ?></b>
<?php if (strpos($dvId, 'legacy-') === 0): ?>
<span class="small muted">(升级前的旧会话)</span>
<?php elseif ($dvId === 'auto-unknown'): ?>
<span class="small muted">(旧版 App)</span>
<?php endif; ?>
</td>
<td class="small muted"><?= e($dvPlat !== '' ? $dvPlat : '—') ?></td>
<td class="small muted">
<?= e(((string)($d['last_seen_at'] ?? '')) !== '' ? (string)$d['last_seen_at'] : '还没用过') ?>
</td>
<td class="small muted"><?= e((string)($d['created_at'] ?? '')) ?></td>
<td>
<?php if (\App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('settings/device_remove')) ?>" class="res-inline"
onsubmit="return confirm('移除这台设备?它下次联网时会退回登录页,本机已下载的数据将无法查看。')">
<?= csrf_field() ?>
<input type="hidden" name="device_id" value="<?= e($dvId) ?>">
<button class="btn btn-ghost btn-sm" type="submit">移除</button>
</form>
<?php else: ?>
<span class="small muted">只读账号</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (count($deviceRows) > 1 && \App\Auth::canWrite()): ?>
<div class="btn-row">
<form method="post" action="<?= e(url('settings/api_revoke')) ?>" class="res-inline"
onsubmit="return confirm('退出全部设备?所有 App(包括你现在手里这台)都要重新登录。')">
<?= csrf_field() ?>
<button class="btn btn-ghost btn-sm" type="submit">退出全部设备</button>
</form>
</div>
<?php endif; ?>
<?php else: ?>
<p class="small muted">还没有设备登录过。在 App 里用这个账号登录后,这里就会出现它。</p>
<?php endif; ?>
</div>
<div class="card card-pad" id="api" style="margin-top:20px">
<h2 class="section-title">
App 接口令牌
<span class="hint">移动端 / 第三方程序调用用</span>
手动签发接口令牌
<span class="hint">第三方脚本 / 调试用;App 正常登录**不需要**这里</span>
</h2>
<p class="small muted" style="margin:0 0 14px">
接口地址前缀 <span class="mono"><b><?= e((string)$apiBase) ?>/api/v1/</b></span>,
@@ -218,7 +304,7 @@ $rosterCount = $rosterCount ?? 0;
</tbody>
</table>
<?php else: ?>
<p class="small muted">还没有令牌。给每台设备发一枚吧。</p>
<p class="small muted">还没有手动签发的令牌。App 登录走上面的「设备管理」,不用在这里生成。</p>
<?php endif; ?>
<?php if (\App\Auth::canWrite()): ?>