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
+47 -61
View File
@@ -319,6 +319,15 @@ function render_me_settings(array $person, array $errors = []): void
'apiPlain' => (string)($_SESSION['api_plain'] ?? ''),
'apiLabel' => (string)($_SESSION['api_plain_label'] ?? ''),
'apiBase' => rtrim((string)fb_config('base_url', ''), '/'),
/* 🔴 v57(用户 2026-10-17):「在个人设置里面有这个设备管理」。
⚠️ 这里传的是**按设备聚合**的列表(`listDevices`),不是令牌清单 ——
同一台手机重登过 5 次,网页上只该出现 1 行。
与上面 `apiTokens`(原始令牌行、只给"手动签发/吊销"用)不是一回事。 */
'devices' => \App\ApiToken::listDevices($me),
'deviceMax' => \App\ApiToken::MAX_DEVICES,
'deviceTtl' => \App\ApiToken::TTL_HOURS,
// 网页这边不知道"哪台是当前设备"(发请求的是浏览器,不是 App)
// ⇒ 页面不标"当前设备",只标"App 设备"。
], 'settings');
// ⚠️ 取完即清:明文令牌只该被看到一次(刷新页面就没了)。
// 放在 View::render **之后** —— 早了就拿不到值了。
@@ -646,72 +655,23 @@ function read_due_input(): ?string
/**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
*
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
* 🔴 v54(用户 2026-10-16):**实现已搬到 `App\Home::memories()`**,
* 这里只做一次转发。
* 为什么搬:App 端首页也要显示「往年的今天」(用户原话
* 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样」),
* 而 App 走的是 `/api/v1/home`。**两端必须同一份实现** ——
* 否则会出现"网页说 2 年前、手机说 3 年前"这种对不上的事
* (本项目在农历/节气上已经吃过一次这个亏)。
*
* ⚠️ 保留这个函数名而不是把调用点全改掉:模板里 `$memories` 的取用位置、
* 以及可能的旧书签/脚本都还在按老路径走,删了就静默少一块。
* 顺便这个包装也让"模板注释里提到的 home_memories()"仍然指得到东西。
*
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/
function home_memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$md = substr($today, 5, 5);
$rows = [];
// ① 观影:照旧(events kind=movie)
$sql = "SELECT e.id, e.title, e.starts_at, e.kind,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n
FROM events e
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, $md]);
foreach ($st->fetchAll() as $r) {
$rows[] = $r;
}
// ② 旅程:改成看**实际行程**(trip_nodes stage='actual'),不是整趟旅程。
// 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」,
// 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。
// ⚠️ 用 starts_at 而不是 events.starts_at —— 实际行程的时间才是"那天真的走了"。
$sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind,
(SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n,
e.id AS trip_id, e.title AS trip_title, n.node_type
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL
AND COALESCE(d.is_public, 1) = 1
AND YEAR(n.starts_at) < ?
AND DATE_FORMAT(n.starts_at, '%m-%d') = ?
ORDER BY n.starts_at DESC LIMIT " . (int)$limit;
$st2 = Database::get()->prepare($sql2);
$st2->execute([$familyId, $year, $md]);
foreach ($st2->fetchAll() as $r) {
// 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的
$r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title'];
$rows[] = $r;
}
// 合并后按时间倒序,取前 N
usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at']));
$rows = array_slice($rows, 0, $limit);
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
if ($r['kind'] === 'trip_node') {
$r['href'] = url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id'];
} else {
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
}
}
unset($r);
return ['rows' => $rows, 'exact' => true];
return \App\Home::memories($familyId, $limit);
}
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
@@ -5016,6 +4976,32 @@ $want = array_keys($want);
redirect('settings/me', [], 'api');
break;
/* 🔴 v57(用户 2026-10-17):「设备管理」——把某一台设备远程退掉。
用户原话:「我们就允许我们的用户在个人设置页面,把其中的某一个客户端
远程给它退出,相当于它这个 Token 给它删掉。」
「这样的话,这个应用一旦打开,就发现这个 Token 已经失效了,
那么它就返回登录页面了。」
⚠️ 移的是**设备**不是"令牌":同一台手机重登过 5 次,列表里只有 1 行,
点一次就该把它连着的那几枚全删掉(按 device_id 整组删,见 ApiToken 的
revokeDevice)。
⚠️ 移除后那台设备**下一次请求就是 401**(服务端没有推送通道,
"立刻失效"是下一次联网时生效 —— 网页上这句提示必须说实话)。 */
case 'settings/device_remove':
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
$deviceId = trim((string)($_POST['device_id'] ?? ''));
$n = \App\ApiToken::revokeDevice($me, $deviceId);
if ($n > 0) {
flash('ok', '已移除这台设备。它下次联网时会收到"令牌无效",并退回登录页。');
} else {
flash('err', '没找到这台设备(可能已经被移除了)。');
}
redirect('settings/me', [], 'devices');
break;
/**
* 日程「显示设置」独立页(v51 用户 2026-10-15 第 6 条)。
*