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
+51
View File
@@ -94,6 +94,57 @@ final class PersonQuery
}
}
/**
* 一个人的**全部电话号码**(主手机号在前,补充电话在后)。
*
* 🔴 为什么需要它(2026-10-15,App 端"点人名直接拨号"):
* 服务端其实**支持多号码**(`person_contacts` 表存"工作号 / 老人机 / 家里座机"),
* 但 `/roster` 只下发主手机号 ⇒ App 没法知道还有别的号可打。
* ⇒ 这里把补充电话一起解出来,**主号排第一**(App 的规则是"有主号打主号")。
*
* ⚠️ 返回的是**明文**(`person_contacts.value` 里 kind=phone 是密文,这里解密)。
* 调用方要注意:这是敏感信息,只在**名册**这类"本人可见自己名册"的场景下发,
* 不要塞进公开列表(与 `stripSensitivePerson` 的取舍一致)。
* ⚠️ 空值一律不返回;两个都空 ⇒ 返回空数组(App 端据此走"打开详情"而不是拨号)。
* ⚠️ **不打码**:打码(`maskPhone`)是给"共享给别人的页面"看的;
* App 名册是本人看自己的名册,打码会导致拨不出去。
*
* @param array $row persons 行(需含 id 与 phone_encrypted)
* @return string[] 明文号码,主号在前;可能为空
*/
public static function phonesOf(array $row): array
{
$out = [];
$main = self::phonePlain($row);
if ($main !== '') {
$out[] = $main;
}
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
return $out;
}
try {
$contacts = Person::contactsOf($id);
foreach (($contacts['phone'] ?? []) as $c) {
$enc = (string)($c['value'] ?? '');
if ($enc === '') {
continue;
}
try {
$plain = (string)Crypto::decrypt($enc);
} catch (\Throwable $ex) {
continue;
}
if ($plain !== '' && !in_array($plain, $out, true)) {
$out[] = $plain;
}
}
} catch (\Throwable $ex) {
// 取补充电话失败**不影响**主号:至少还能打主号。
}
return $out;
}
/**
* 给列表用的行补上派生字段。幂等(已有值就不覆盖)。
*