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:
+73
-2
@@ -166,6 +166,21 @@ final class Schema
|
||||
* ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工:
|
||||
* 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides
|
||||
* (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。
|
||||
* 35 → **设备会话(v57,用户 2026-10-17)**:`api_tokens` 从"一把令牌"升级成
|
||||
* "一台设备一个会话"。加 `device_id`(App 自生成 UUID,**不是**硬件号 ——
|
||||
* HarmonyOS 三方应用拿不到 UDID)/ `device_name` / `platform` /
|
||||
* `app_version` / `last_ip`,并加 `idx_api_device (person_id, device_id)`。
|
||||
* 动机(用户原话):「在服务器端的个人设置那里面,就有一个这个设备管理,
|
||||
* 就是说我都有哪些设备已经在登录这个东西」。
|
||||
* ⚠️ 为什么必须挂 `device_id` 而不是只挂 `label`:`label` 是**人填的文本**,
|
||||
* 两台设备可以填同一个名字;"踢掉丢了的那台"要求**精确到机器**。
|
||||
* ⚠️ 历史令牌没有设备号 → 回填 `legacy-<令牌 id>`。不回填的话,升级后
|
||||
* 旧令牌在设备列表里根本看不见 —— 等于一个**踢不掉的幽灵会话**。
|
||||
* ⚠️ **故意不加** `revoked_at`:踢设备是**硬删整行**(`DELETE FROM api_tokens`),
|
||||
* 不留"已吊销但还在"的死行 —— 那类行要每个查询都多判一次"还算不算数",
|
||||
* 漏一处就等于"被踢的设备还能用"。审计需求在本项目不存在(家人内部使用)。
|
||||
* ⚠️ **复用**既有的 `last_used_at` 当"最近活跃",不另立 `last_seen_at` ——
|
||||
* 两个语义相同的列并存,迟早会出现"一个更新了另一个没更新"的假活跃。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -173,7 +188,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '34';
|
||||
public const VERSION = '35';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -295,12 +310,18 @@ CREATE TABLE IF NOT EXISTS `api_tokens` (
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库',
|
||||
`label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)',
|
||||
`device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成的 UUID;不是硬件号)',
|
||||
`device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改,如「HUAWEI Mate 40」)',
|
||||
`platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)',
|
||||
`app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本(如 1.0.0)',
|
||||
`last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` DATETIME DEFAULT NULL,
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期(设备会话一律 24h 滑动)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_api_token` (`token_hash`),
|
||||
KEY `idx_api_person` (`person_id`),
|
||||
KEY `idx_api_device` (`person_id`, `device_id`),
|
||||
CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)'
|
||||
@@ -2267,6 +2288,56 @@ SQL);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- v35(v57,用户 2026-10-17):API 令牌挂到「设备」上 ----
|
||||
|
||||
用户原话:「在服务器端的个人设置那里面,就有一个设备管理,
|
||||
就是说我都有哪些设备已经在登录这个东西。」
|
||||
⇒ 一把令牌就是**一次设备会话**,得能按机器踢、能显示机器是谁。
|
||||
|
||||
⚠️ 必须排在 statements() 之后(那里才是 `api_tokens` 的 CREATE TABLE)——
|
||||
记忆里的红线:ALTER TABLE 排在同表 CREATE TABLE 之前,全新安装会 1146。
|
||||
⚠️ `label` 保留不动:网页端「手动签发令牌」那条老路径还在用它,
|
||||
而且是用户自己填的文本(设备管理里显示 `device_name`,两者不是一回事)。 */
|
||||
self::addColumn($db, 'api_tokens', 'device_id',
|
||||
"ADD COLUMN `device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成 UUID,不是硬件号)' AFTER `label`");
|
||||
self::addColumn($db, 'api_tokens', 'device_name',
|
||||
"ADD COLUMN `device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改)' AFTER `device_id`");
|
||||
self::addColumn($db, 'api_tokens', 'platform',
|
||||
"ADD COLUMN `platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)' AFTER `device_name`");
|
||||
self::addColumn($db, 'api_tokens', 'app_version',
|
||||
"ADD COLUMN `app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本' AFTER `platform`");
|
||||
self::addColumn($db, 'api_tokens', 'last_ip',
|
||||
"ADD COLUMN `last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)' AFTER `app_version`");
|
||||
|
||||
// 设备索引:设备列表与"按设备踢"都是 `WHERE person_id = ? AND device_id = ?`
|
||||
try {
|
||||
$hasDeviceIdx = (int)$db->query(
|
||||
"SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'api_tokens'
|
||||
AND INDEX_NAME = 'idx_api_device'"
|
||||
)->fetchColumn();
|
||||
if ($hasDeviceIdx === 0) {
|
||||
$db->exec('ALTER TABLE `api_tokens` ADD INDEX `idx_api_device` (`person_id`, `device_id`)');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 索引是优化项,加不上不该挡住"能按设备踢"这件事本身
|
||||
}
|
||||
|
||||
/* 历史令牌回填设备号。
|
||||
⚠️ 为什么必须回填:v35 之前的令牌只有 `label`,没有 `device_id`。
|
||||
不回填的话它们在设备列表里**根本查不出来** —— 用户在网页端看不到、
|
||||
也就踢不掉,等于一个"踢不掉的幽灵会话"。回填成 `legacy-<id>` 后
|
||||
它就是一个正常的、可踢的设备条目。
|
||||
⚠️ 幂等:条件只挑 `device_id` 为空的行,跑完自然不再命中。 */
|
||||
self::tryStep('历史令牌回填设备号', static function () use ($db) {
|
||||
$db->exec(
|
||||
"UPDATE `api_tokens`
|
||||
SET `device_id` = CONCAT('legacy-', `id`),
|
||||
`device_name` = COALESCE(NULLIF(`device_name`, ''), NULLIF(`label`, ''), '旧设备')
|
||||
WHERE `device_id` IS NULL OR `device_id` = ''"
|
||||
);
|
||||
});
|
||||
|
||||
/* 🔴 v51(用户 2026-10-15 第 5 条):把**已入库**的带分隔符手机号归一成纯数字。
|
||||
用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
|
||||
中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」
|
||||
|
||||
Reference in New Issue
Block a user