Files
laoyang ebaed21920 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} 复核通过。
2026-10-18 03:34:47 +08:00

195 lines
8.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 「找个人」的统一查法(v24 用户 2026-10-03 第 2 条)。
*
* 用户原话:「所有人员检索的地方,输入汉字或者首字母,自动筛选,而不需要按回车。」
*
* 这里只负责**服务端那一半**:把一份人员行按查询词筛一遍。
* 前端「输入即筛选」那一半在 assets/js/filter.js(纯 DOM 显隐,不发请求)。
*
* 为什么要把这段抽出来(而不是各页各写一遍):
* · 名册(Person::roster)、家里已有的人(Person::all)、以及以后任何"找个人"的地方,
* 判据必须**完全一致** —— 否则同一句「yyq」在名册里能搜到、在挑人页搜不到,
* 用户只会觉得"搜索坏了",而不会想到是两处实现不一样。
* · 上半段那个「要不要给 SQL 加 LIKE」的判断很容易写错(本项目已经错过一次):
* 纯字母(yyq)与纯数字(手机号)**都不能**交给 SQL 的 LIKE ——
* 前者 SQL 里没法把汉字转拼音,后者手机号是密文存的,一 LIKE 结果就成空。
*
* 分工:
* · needsSql() / likeArgs() → SQL 侧的**粗筛**(只在能帮忙时才加条件)
* · refine() → PHP 侧的**精筛**(一律跑,结果才是最终答案)
* · decorate() → 把「解密后的手机号 / 生日 / 我怎么称呼他」补齐给列表用
*/
declare(strict_types=1);
namespace App;
final class PersonQuery
{
/**
* 这个查询词该不该让 SQL 帮忙粗筛?
*
* 只有**库里有明文可比**的查询词值得:中文姓名、邮箱里的字母+符号。
* ⚠️ 纯字母(yyq = 姓名首字母)与纯数字(手机号)一律交给 PHP 精筛:
* · SQL 里没有"汉字→拼音"的函数,对 yyq 做 LIKE 必然 0 行;
* · 手机号是 Crypto 加密后存的(phone_encrypted),明文数字 SQL 永远匹配不到。
* 这两类一旦加了 SQL 条件,精筛阶段就已经没有数据可筛了,
* 症状是"搜 yyq 一个人都没有" —— 看着像功能没做。
*/
public static function needsSql(string $q): bool
{
$q = trim($q);
if ($q === '') {
return false;
}
return (bool)preg_match('/[^A-Za-z0-9]/u', $q);
}
/** SQL 粗筛要绑的参数(name / email / nickname 三个 LIKE) */
public static function likeArgs(string $q): array
{
$like = '%' . trim($q) . '%';
return [$like, $like, $like];
}
/** SQL 粗筛的 WHERE 片段(只在 needsSql() 为真时调用) */
public const SQL_WHERE = ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
/**
* PHP 精筛:姓名 / 我怎么称呼他 / 库里的旧小名 / 邮箱 / 解密后的手机号 / 姓名首字母。
*
* ⚠️ 进来之前**必须**先给每行补上 phone_plain(见 phonePlain()),
* 否则手机号这一路永远匹配不上(Pinyin::matches 认的是 phone_plain)。
*/
public static function refine(array $rows, string $q): array
{
$q = trim($q);
if ($q === '') {
return $rows;
}
$out = [];
foreach ($rows as $row) {
if (Pinyin::matches($row, $q)) {
$out[] = $row;
}
}
return array_values($out);
}
/**
* 解密手机号(失败一律当"没填",绝不让一行坏数据把整页筛挂)。
* @return string 明文;没有/解不开返回 ''
*/
public static function phonePlain(array $row): string
{
$enc = (string)($row['phone_encrypted'] ?? '');
if ($enc === '') {
return '';
}
try {
return (string)Crypto::decrypt($enc);
} catch (\Throwable $ex) {
return '';
}
}
/**
* 一个人的**全部电话号码**(主手机号在前,补充电话在后)。
*
* 🔴 为什么需要它(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;
}
/**
* 给列表用的行补上派生字段。幂等(已有值就不覆盖)。
*
* @param int $viewerId 谁在看(决定"我怎么称呼他")
* @param int $meId 用来标"这是我"(0=不标)
*/
public static function decorate(array $rows, int $viewerId, int $meId = 0): array
{
$graph = null;
$people = null;
foreach ($rows as &$row) {
if (!array_key_exists('phone_plain', $row)) {
$row['phone_plain'] = self::phonePlain($row);
}
if (!isset($row['shown'])) {
$row['shown'] = Nickname::display($viewerId, $row) ?: (string)($row['name'] ?? '');
}
}
unset($row);
return $rows;
}
/**
* 生日展示串(用户 2026-10-04 第 3 条:**阴历和阳历都显示,只显示月和日,不需要年**)。
*
* 两种记法是同一个人的同一天,所以不像以前那样"二选一"——都列出来,
* 长辈看农历、年轻人看阳历,各自认自己的那个。
*
* 农历不写 '02-03' 这种数字:**写成「二月初三」**,否则"农历 02-03"看着就像
* 阳历的 2 月 3 号,反而比不显示更误导(闰月还要带「闰」字)。
*
* @return array<int,array{0:string,1:string}> [['阳历','03-07'], ['农历','二月初三']](没有的就不出现)
*/
public static function birthdaysOf(array $row): array
{
$out = [];
$solar = Birthday::dateOnly((string)Birthday::solarOf($row));
if (preg_match('/^\d{4}-(\d{2})-(\d{2})$/', $solar, $m) === 1) {
$out[] = ['阳历', $m[1] . '-' . $m[2]];
}
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if (preg_match('/^\d{4}-(\d{2})-(\d{2})$/', $lun, $m) === 1) {
$leap = !empty($row['birthday_lunar_leap']);
$out[] = ['农历', Lunar::monthName((int)$m[1], $leap) . Lunar::dayName((int)$m[2])];
}
return $out;
}
}