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:
@@ -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 条)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user