Files
FamilyBook/app/PersonQuery.php
T

195 lines
8.1 KiB
PHP
Raw Normal View History

<?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;
}
}