Files
FamilyBook/app/Person.php
T
laoyang 52cc1ecccb v24 第 9 批:名册九条(分组按钮/加入名册/生日月日/地址四级/千人性能/拖动修复/删除人员/返回名册/Semi DatePicker)
- #131 名册卡片加「分组」按钮(details+radio,显示当前分组,选中即改)
- #132 修复「从已有人里加入名册」无反应
- #133 生日只显示月和日(阳历 MM-DD / 农历中文名),PersonQuery::birthdaysOf 取代 birthdayOf
- #134 地址结构化为 国家/省/市/区+详细:regions 表(3429 行全国区划)+ Region.php +
  ?r=regions 级联接口 + 表单 .addr-grid + 老地址回填(Schema v16)
- #135 千人名册性能:.person content-visibility:auto + 名册页 ob_gzhandler
  (实测 服务端 1.15s/3.8MB → 0.17s/87KB;客户端导航→load 1.35s → 0.37s)
- #136 拖动排序改 Pointer Events(Chrome 下 HTML5 DnD 在 DOM 变动时断)
- #137 个人详情页底部「删除人员」
- #138 编辑资料页加「返回名册」
- #139 所有日期选择换 Semi Design DatePicker(渐进增强:原生四字段保 POST 契约,
  懒加载 vendor semi UMD,值回写原字段;工作经历 type=month 一并升级;宣纸主题对齐)

验证:e2e 785/0、batch9 37/0、upgrade 126/0、picker 28/0、mobile 688/0、interact 97/0、
batch_e 91/0、import 21/0、douban 22/0、drag 5/0、semi-integrate 28/0、semi-probe 17/0、
geom 5/5;部署后线上自检 16/16(含 4 个 semi 资源 200 + regions 路由)。
2026-10-04 08:56:40 +08:00

491 lines
22 KiB
PHP
Raw 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
/**
* 家庭名册(家庭成员 + 外部联系人共用一张表)。
*
* 之所以不拆成两张表:两者字段几乎一样;而且需要互相转换 ——
* 一个「认识的人」随时可能变成家庭成员,一个家庭成员也可能只作为联系人存在。
*
* 三处刻意的设计(都是被纠正过的):
* · **小名不在这里**。称呼是"谁在叫"的属性,存在 nicknames 表(见 Nickname 类)。
* · 生日不是一个字段,是三个(身份证 / 阳历 / 农历),见 Birthday 类。
* · 姓名与称呼的读取都要过 `Nickname::display()`,页面不要直接 echo name。
*/
declare(strict_types=1);
namespace App;
final class Person
{
/**
* 名册列表(连带账号信息与「我叫他什么」)。
*
* `my_nickname` 是**当前登录者**给这个人起的称呼 —— 同一份名册,
* 爸爸看到的是"儿子",妈妈看到的是"小宝",靠的就是这个字段。
*/
public static function all(int $familyId, string $q = '', string $kind = 'member', int $viewerId = 0): array
{
$sql = 'SELECT p.*, a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE p.family_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $kind;
if ($q !== '') {
// ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤 —— 判据见 PersonQuery。
// 这里与 roster() 用的是**同一个** PersonQuery::needsSql /
// PersonQuery::refine,所以"找个人"在两处的结果一定一致。
if (PersonQuery::needsSql($q)) {
$sql .= PersonQuery::SQL_WHERE;
foreach (PersonQuery::likeArgs($q) as $a) {
$args[] = $a;
}
}
}
$sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
$rows = $st->fetchAll();
if ($q !== '') {
foreach ($rows as &$r) {
if (!array_key_exists('phone_plain', $r)) {
$r['phone_plain'] = PersonQuery::phonePlain($r);
}
}
unset($r);
$rows = PersonQuery::refine($rows, $q);
}
return $rows;
}
/**
* **我的名册**:只列我自己的名册里的家庭成员(带分组与顺序)。
*
* 与 all() 的分工(v5 起两者不再是一回事):
* · all() —— 全家庭的成员,不管是谁的名册(首页徽标、邀请访客等内部用途);
* · roster() —— 名册页该显示的那些:我加的人 + 别人分享给我的,顺序是我的。
*
* 分组与顺序都从 person_roster 取(列名故意与老库的 persons.group_id /
* persons.sort_order 保持一致,调用方几乎不用改)。
*/
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
{
// 用户 2026-10-03 第 4 条:名册改成列表,每行要显示「手机号 / 生日 / 与我的关系」
// → 手机号是加密存的,得在这里一并取出密文(下面解密,避免逐行再查一次)。
// → 关系称呼走 Kinship(每人算一次),不在 SQL 里。
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM person_roster r
JOIN persons p ON p.id = r.person_id
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $viewerId;
$args[] = $kind;
if ($q !== '') {
// ⚠️ SQL 只在**能帮忙**时才粗筛 —— 判据在 PersonQuery::needsSql(**唯一一处**):
// · 纯字母(yyq)是"姓名首字母",SQL 里没法把汉字转拼音,一 LIKE 就筛成空;
// · 纯数字(手机号)是**加密存的**,库里只有密文,LIKE 永远匹配不到明文。
// 这两类都交给下面的 PHP 精筛(它会解密、会算首字母)。
// 这条判断以前在 roster() 和 all() 里各写了一遍,改的时候踩过"只改了一处",
// 所以抽成了 PersonQuery::needsSql。
if (PersonQuery::needsSql($q)) {
$sql .= PersonQuery::SQL_WHERE;
foreach (PersonQuery::likeArgs($q) as $a) {
$args[] = $a;
}
}
}
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
$rows = $st->fetchAll();
// 用户 2026-10-03 第 4 条:再加**姓名首字母**检索(y / yy / yyq 都命中「杨永全」)。
// 精筛一律走 PersonQuery::refine —— 覆盖:姓名 / 称呼 / 邮箱 /
// **解密后的手机号** / 姓名首字母。见该类的类注释(判据必须与挑人页一致)。
if ($q !== '') {
foreach ($rows as &$r) {
if (!array_key_exists('phone_plain', $r)) {
$r['phone_plain'] = PersonQuery::phonePlain($r);
}
}
unset($r);
$rows = PersonQuery::refine($rows, $q);
}
return $rows;
}
public static function find(int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
return $row ?: null;
}
/** 带账号信息的详情(详情页用;$viewerId 用来取「我叫他什么」) */
public static function findWithAccount(int $id, int $viewerId = 0): ?array
{
$st = Database::get()->prepare(
'SELECT p.*, a.id AS account_id, a.role AS account_role,
a.status AS account_status, a.last_login_at, n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.id = ?'
);
$st->execute([$viewerId, $id]);
$row = $st->fetch();
return $row ?: null;
}
public static function findByPhoneHash(string $hash): ?array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE phone_hash = ? LIMIT 1');
$st->execute([$hash]);
$row = $st->fetch();
return $row ?: null;
}
/**
* 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。
* @return int 新 id
*/
public static function create(int $familyId, array $d): int
{
$phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'INSERT INTO persons
(family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
phone_hash, phone_encrypted, id_card_encrypted, email, address,
addr_country, addr_province, addr_city, addr_district,
org, title, note, is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql);
$st->execute([
$familyId,
$d['kind'] ?? 'member',
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
]);
return (int)Database::get()->lastInsertId();
}
public static function update(int $id, array $d): void
{
$phone = (string)($d['phone'] ?? '');
$bd = Birthday::normalize($d);
$sql = 'UPDATE persons SET
name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?,
addr_country = ?, addr_province = ?, addr_city = ?, addr_district = ?,
org = ?, title = ?, note = ?, is_placeholder = ?
WHERE id = ?';
Database::get()->prepare($sql)->execute([
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
$phone !== '' ? Crypto::hash($phone) : null,
$phone !== '' ? Crypto::encrypt($phone) : null,
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
$id,
]);
}
/**
* 地址四段:空串一律存 NULL。
*
* 为什么不用 `$d[$k] ?? null` 直接塞:表单里四个下拉没选时提交的是**空串**,
* 存进去就变成"国家 = ''",页面上判断"有没有填"永远为真,
* 于是每个没填地址的人都显示一个空的国家名(看着像数据坏了)。
*/
private static function addrOrNull(array $d, string $key): ?string
{
$v = trim((string)($d[$key] ?? ''));
return $v === '' ? null : mb_substr($v, 0, 60, 'UTF-8');
}
/**
* 自助改自己的资料。
*
* 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高;
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
* 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
*
* ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上,
* 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。
*/
public static function updateSelf(int $id, array $d): void
{
$bd = Birthday::normalize($d);
Database::get()->prepare(
'UPDATE persons SET
name = ?, gender = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
email = ?, address = ?,
addr_country = ?, addr_province = ?, addr_city = ?, addr_district = ?,
org = ?, title = ?, note = ?
WHERE id = ?'
)->execute([
$d['name'],
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
$bd['solar'],
$bd['id_birthday'],
$bd['lunar'],
$bd['lunar_leap'],
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
$id,
]);
}
// ---------------- 补充联系方式(vCard 对齐,v6 起) ----------------
//
// 「主手机号」不在这里:它是登录凭据(phone_hash 唯一约束、按它找人),
// 永远留在 persons.phone_*。contacts 存的是**额外的**电话 / 邮箱 / 地址 / 网址。
// 电话与主手机号同一把钥匙加密存(value 是密文),展示时用 Crypto::maskPhone 打码。
/** 某个人的全部补充联系方式,按 kind 分组;kind=phone 的 value 是**密文** */
public static function contactsOf(int $personId): array
{
$st = Database::get()->prepare(
'SELECT kind, label, value FROM person_contacts
WHERE person_id = ? ORDER BY kind, position, id'
);
$st->execute([$personId]);
$out = ['phone' => [], 'email' => [], 'address' => [], 'url' => []];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']][] = ['label' => (string)$r['label'], 'value' => (string)$r['value']];
}
return $out;
}
/**
* 整组替换一个人的补充联系方式(delete + insert,幂等且不留下悬旧行)。
*
* @param string[][][] $byKind kind => 行列表,每行 ['label' => .., 'value' => ..]
* value 一律传**明文**;kind=phone 在这里统一加密。
* value 为空的行直接丢弃(表单会带出一排空行)。
*/
public static function replaceContacts(int $personId, array $byKind): void
{
$db = Database::get();
$ins = $db->prepare(
'INSERT INTO person_contacts (person_id, kind, label, value, position) VALUES (?,?,?,?,?)'
);
$db->prepare('DELETE FROM person_contacts WHERE person_id = ?')->execute([$personId]);
foreach (['phone', 'email', 'address', 'url'] as $kind) {
$pos = 100;
foreach (($byKind[$kind] ?? []) as $row) {
$value = trim((string)($row['value'] ?? ''));
if ($value === '') {
continue; // 空行是表单带出来的占位,不是数据
}
$label = mb_substr(trim((string)($row['label'] ?? '')), 0, 20, 'UTF-8');
if ($kind === 'phone') {
$value = Crypto::encrypt(mb_substr($value, 0, 60, 'UTF-8'));
} else {
$value = mb_substr($value, 0, 500, 'UTF-8');
}
$ins->execute([$personId, $kind, $label, $value, $pos]);
$pos += 10;
}
}
}
/** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
public static function setAvatar(int $id, ?string $avatar): void
{
Database::get()->prepare('UPDATE persons SET avatar = ? WHERE id = ?')
->execute([$avatar, $id]);
}
/**
* 只改头像主色。
*
* ⚠️ 空串**不写库**:算不出主色(灰片/纯色证件照)时应该让它保持 NULL,
* 让 AvatarColor::for() 落到「按姓名取色」的兜底上 ——
* 这时候才不会每次换图颜色乱跳。
*/
public static function setAvatarColor(int $id, ?string $hex): void
{
$hex = AvatarColor::normalize($hex);
if ($hex === '') {
return;
}
Database::get()->prepare('UPDATE persons SET avatar_color = ? WHERE id = ?')
->execute([$hex, $id]);
}
public static function delete(int $id): void
{
Database::get()->prepare('DELETE FROM persons WHERE id = ?')->execute([$id]);
}
/** 全部家庭成员,以 id 为键 —— 家谱推导要按 id 频繁取人,这样最省事 */
public static function map(int $familyId, string $kind = 'member'): array
{
$st = Database::get()->prepare('SELECT * FROM persons WHERE family_id = ? AND kind = ?');
$st->execute([$familyId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** 只给「选人」用的精简列表(下拉框、关系选择器),带「我叫他什么」 */
public static function options(int $familyId, int $exceptId = 0, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder, n.nickname AS my_nickname
FROM persons p
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
ORDER BY p.id ASC'
);
$st->execute([$viewerId, $familyId, $exceptId]);
return $st->fetchAll();
}
/**
* 最近要过生日的人(首页用)。
*
* ⚠️ 这里返回的是「**一个生日事件**」而不是「一个人」:
* 一个人可能同时有身份证生日与农历生日两条(不重合时),
* 也可能它们正好落在同一天(那就是 1 条,kinds 里有两个)。
* 早先按"人"来算,于是农历生日完全不会被提醒 —— 而家里老人过的正是农历。
*/
public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$out = [];
foreach (self::map($familyId) as $p) {
foreach (Birthday::nextOccurrences($p, $today, $withinDays) as $occ) {
$out[] = [
'id' => (int)$p['id'],
'name' => (string)$p['name'],
'avatar' => $p['avatar'],
'date' => $occ['date'],
'days_left' => $occ['days'],
'kinds' => $occ['kinds'],
'kind_words' => array_map(fn($k) => Birthday::KINDS[$k] ?? $k, $occ['kinds']),
'lunar_label' => $occ['lunar_label'],
'age' => $occ['age'],
];
}
}
usort($out, function ($a, $b) {
return $a['days_left'] <=> $b['days_left'] ?: strcmp($a['date'], $b['date']);
});
return $out;
}
/** 校验表单,返回错误数组(空数组表示通过) */
public static function validate(array $d): array
{
$err = [];
if (trim((string)($d['name'] ?? '')) === '') {
$err['name'] = '姓名不能为空';
}
$phone = trim((string)($d['phone'] ?? ''));
if ($phone !== '' && !preg_match('/^\+?[0-9\-]{6,20}$/', $phone)) {
$err['phone'] = '手机号格式看起来不对';
}
$email = trim((string)($d['email'] ?? ''));
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$err['email'] = '邮箱格式看起来不对';
}
// 生日现在是「日期 + 时分秒」,但要宽松:只填日期也必须收下
foreach (['birthday_solar' => '阳历生日', 'birthday_lunar' => '农历生日'] as $key => $word) {
$v = trim((string)($d[$key] ?? ''));
if ($v === '') {
continue;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}([ ]\d{2}:\d{2}(:\d{2})?)?$/', $v)) {
$err[$key] = $word . '格式不对';
continue;
}
[$yy, $mm, $dd] = array_map('intval', explode('-', substr($v, 0, 10)));
if (!checkdate($mm, $dd, $yy) || $yy < Lunar::MIN_YEAR || $yy > Lunar::MAX_YEAR) {
$err[$key] = $word . '看起来不是一个真实的日子';
}
}
// 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思
$idc = trim((string)($d['id_card'] ?? ''));
if ($idc !== '' && !preg_match('/^\d{17}[\dXx]$|^\d{15}$/', $idc)) {
$err['id_card'] = '身份证号应为 18 位(或 15 位老证)';
}
$rank = $d['birth_rank'] ?? '';
if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) {
$err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)';
}
$slogan = trim((string)($d['slogan'] ?? ''));
if (mb_strlen($slogan, 'UTF-8') > 200) {
$err['slogan'] = '一句话请控制在 200 字以内';
}
return $err;
}
/** 角色与人物的中文名 */
public static function genderWord(string $g): string
{
return ['m' => '男', 'f' => '女'][$g] ?? '';
}
}