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:
@@ -405,6 +405,87 @@ final class Home
|
||||
return (string)$r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
|
||||
*
|
||||
* 🔴 v54(用户 2026-10-16)—— 从 `index.php` 的 `home_memories()` **搬进来**。
|
||||
* 用户原话:「近期任务的下面,应该加上**往年的今天**,跟电脑端一样,
|
||||
* 你看看那个是什么逻辑。」
|
||||
* 搬的理由:App 端首页也要这一块,而 App 走 `/api/v1/home`;
|
||||
* **两端必须是同一份实现**,否则会出现"网页说 2 年前、手机说 3 年前"
|
||||
* (本项目在农历/节气上已经吃过一次这个亏,见 `ApiHandler::home()` 的注释)。
|
||||
* `index.php` 里那个同名函数现在只是一层转发。
|
||||
*
|
||||
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
|
||||
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
|
||||
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
|
||||
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
|
||||
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
|
||||
*
|
||||
* ⚠️ 旅程只看 `trip_nodes.stage='actual'`(**实际行程**),不是整趟旅程:
|
||||
* 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」,
|
||||
* 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。
|
||||
* 用 `n.starts_at` 而不是 `events.starts_at` —— 实际行程的时间才是"那天真的走了"。
|
||||
*
|
||||
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
|
||||
*/
|
||||
public static function 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;
|
||||
}
|
||||
|
||||
// ② 旅程的**实际行程**节点
|
||||
$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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user