Files
FamilyBook/app/Person.php
T
laoyang 4fbac180e1 v51:旅程编辑保存后刷新 / 名册编辑页可改标签 / 手机号归一 / 反复点击带入时间+默认1h+分栏 / 日程设置拆分
用户 2026-10-15 六条。

① 旅程详情「编辑」保存后页面不刷新(改了题图/标题,页头纹丝不动)
   **两个 bug 串在一起**:
   A. 只换 `#nodeListHost`(行程列表),而标题/题图/时间城市都在那容器**外面**(服务端直出);
   B. 🔴 `form.id === 'tripForm'` **永远为假** —— HTML 表单把**同名具名控件**挂成自己的属性,
      `tripForm` 里有 `<input name="id">` ⇒ `form.id` 返回的是**那个 input**
      (`String(form.id)` = `[object HTMLInputElement]`)。同类还有 form.name/action/method。
   ⇒ 修法:`form.getAttribute('id')` 判型;tripForm(编辑旅程本体)→ **整页刷新**,
      nodeForm(行程增删改)→ 仍只换行程列表(那是 v41 用户明确要的"不整页跳")。

② 名册编辑页不能选标签("应该能编辑这个人的所有信息才对")
   `templates/people/form.php` 里压根没有标签 UI ⇒ 新增 `#ptBlock`:
   候选勾选(PersonTag::cloud)+ 自由输入(`name="tags"`,唯一提交源,勾选框只是快捷方式)。
   ⚠️ `PersonTag::setFor(家庭, **我**, 他, …)` —— owner 永远是"我",不影响全家的标签。
   ⚠️ 守卫按**字段存在与否**分流(array_key_exists),不是"值是否为空"——否则改自己时
      表单没这个字段,按"空值=清空"会把人家的标签抹掉。
   ⚠️ 校验失败的两条重渲染路径也要带上 `myTags`/`tagChoices`(取 `$_POST['tags']`)。

③ 导入的手机号带空格 → 编辑时被判"格式不对"("统一处理一下")
   真根因:主手机号正则 `^\+?[0-9\-]{6,20}$` **空格一律判错**,而补充电话那条收 `[0-9\- ]`
   ⇒ 同一个号码填主字段报错、填补充字段通过。
   ⇒ ① `Person::normPhone()` 去空格/短横/括号/**全角空格 U+3000**;
      ② 校验改成「先归一再判纯数字」——⚠️ 只把正则放宽到含 `\s` **不够**:
         PCRE 的 `\s`(无 /u/UCP)**不认全角空格**,通讯录导出里真有;
      ③ create/update/replaceContacts **入库前归一**(原来 hash 内部归一但**密文存原样**);
      ④ `Schema::ensure` 加**一次性迁移**(哨兵 `phone_normalized_v51`)把已入库的
         带分隔符号码解密→归一→写回(手机号是加密存的,SQL 里没法 LIKE/REPLACE)。

④ 周/日视图"只有第一次带入的时间是对的"
   真根因(真机给 `__fbRemount` 装 getter/setter 才抓到):起止字段藏在弹框里 ⇒
   boot 时 `upgradeRange()` 因"两端不可见"提前 return ⇒ 那一刻**只有 mountAntd** 给它赋值
   (会"重读原生值+重绘 antd");而**一打开弹框** `fbUpgradeRange()` 补跑 ⇒
   在尾部把它**重新赋成"只对齐原生下拉、不碰 antd"**的一版 ⇒ 此后 antd 控件永远停在第一次的值。
   ⇒ `upgradeRange()` 不再降级覆盖更强的实现,改成"把对齐下拉串在它前面"(`__fbRangeSync` 防自套)。

⑤ 默认结束 = 开始 + 1 小时;新增日程要按分栏错开(不再叠盖)
   · `setStart()` 里顺带设 ends_at = +1h(⚠️ 跨午夜顺延到次日;⚠️ 不捆进 fbSetDT,
     那个接口被"编辑已有日程"复用,强改会弄丢用户填的时长)。
   · 真根因:`putTimed()` 只把自己那条**整宽**铺上去,不知道同列已有日程
     (服务端整页渲染有分栏,但那是整页重绘时才算的)⇒ 前端 `relayoutColumn(col)`
     **照抄服务端 `conflictLanes`** 重排整列;模板加 `data-s`/`data-e`(**声明的起止分钟**,
     不能从 top/height 反推 —— 那是"实际显示结束",不是真实结束分钟)。

⑥ 日程设置下拉太长(日历本一多就把下面的设置挤出屏幕)→ 拆成"四行列表 + 显示设置独立页"
   新建 `templates/calendar/display.php` + 路由 `calendar/display`(默认视图 + 显示哪些本子);
   `_settings_menu.php` 从"内联表单"改成**纯列表四行**(显示设置/日历本管理/订阅管理/DAV 口令);
   ⚠️ `.cal-set-books` 原本 `max-height:42vh`("塞在下拉里"的妥协)⇒ 用 `.cal-set-page`
      作用域覆盖,否则整页里再套一层内滚 = 要滚两次。
   保存仍走既有 `calendar/prefs`(一个字段没改)。

验证:`_fb_v51_ui.py`(25 条真机)+ `_b51_falsify.py`(**12 注入 / 25 断言全抓**);
e2e 1511/0;`_fb_batch_e.py` 与 `familybook_deploy.sh` 里"设置菜单含 show_present"等
旧断言随 #6 搬到 display 页(意图一字不改);全流水线 PUSH_RC=0、部署 0 FAIL(含"第51批形态");
线上 10 文件 md5 逐字节一致。

⚠️ 排查纪律(已写进 pitfalls.md):
 · `form.id` 被同名控件遮蔽 —— 判型一律用 `getAttribute('id')`;
 · 反向验证的注入可能"打偏"(改的那处不是探针实际走的分支)——
   本轮 12 个注入有 2 个一开始没变红,都是注入打偏、不是断言弱。
2026-10-15 10:37:07 +08:00

547 lines
26 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
{
/* 🔴 v51(用户第 5 条):主手机号**入库前归一**成纯数字。
`Crypto::hash()` 内部本来就归一(所以"138 0000 0000"与"13800000000"
一直是**同一个人**,登录不受影响),但**密文里存的是原样**
⇒ 名册列表/详情页显示出来就是"138 0000 0000"这种带空格的形态。
用户这次要求"统一处理一下" ⇒ 这里也归一,两处产物(hash / 密文)口径一致。
⚠️ 只用 `normalizePhone` 的"去分隔符"这一半,**不**去掉国别前缀 86
(那是登录查找用的宽松口径;显示与存储要保留用户填的完整号码)。 */
$phone = self::normPhone($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
{
// v51:与 create 同一口径(主手机号入库前归一成纯数字,见上面的说明)
$phone = self::normPhone($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');
}
/**
* 主手机号入库前的归一(v51 用户第 5 条)。
*
* 用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
* 中间有两个空格。这个看看能不能把它给处理一下,**统一处理一下**。……
* 都提示我数据不对,当我编辑它的时候,提示我数据的格式不对。」
*
* 做什么:去掉号码里的**空格 / 短横 / 圆括号**(号码里最常见的人类排版),
* 并把全角空格一并处理掉 —— 通讯录导出里真的有全角空格。
*
* ⚠️ 与 `Crypto::normalizePhone()` 的差别(别把两者合并):
* · `normalizePhone` 是**登录查找**口径,还会把前缀 `+86`/`86` 去掉
* ("13800000000" 与 "+8613800000000" 必须是同一个人);
* · 本方法是**存储与显示**口径,**不去国别前缀** ——
* 用户填的是什么号就存什么号(只是把分隔符抹平),显示出来才是他认得的号码。
* 两者都从同一套分隔符集合出发,所以 hash 与密文永远指向同一个号码。
* ⚠️ 保留 `+`:`+8613800000000` 这种要原样留着(去掉会变成另一个语义的号码)。
*/
private static function normPhone($raw): string
{
$s = (string)$raw;
// 全角空格 → 半角(通讯录导出的常见形态),再去掉所有分隔符
$s = str_replace(["\xE3\x80\x80", "\xC2\xA0"], ' ', $s); // U+3000 / U+00A0
$s = (string)preg_replace('/[\s\-()]/u', '', trim($s));
return $s;
}
/**
* 自助改自己的资料。
*
* 与上面的 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') {
/* 🔴 v51(用户第 5 条):补充电话与主手机号**同一口径** —— 入库前去掉
分隔符(空格 / 短横 / 括号 / 全角空格),避免同一个人在"主号 / 补充号"
两处存出两种排版(名册列表显示时一个带空格一个不带,看着像两种数据)。 */
$value = self::normPhone($value);
if ($value === '') {
continue;
}
$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'] ?? ''));
/* 🔴🔴 v51(用户 2026-10-15 第 5 条):「名册的导进来的手机号,有些它是一共 11 个数字,
前 3 个数字和后 4 个数字,中间有两个空格……看起来还有不少这样的数据呢,
都提示我数据不对,当我编辑它的时候,提示我数据的格式不对。」
⇒ 根因就是这个正则:原来只收 `[0-9-]`,**空格一律判错** ⇒
通讯录导入进来的 "138 0000 0000" 这种(vCard/CSV 里很常见的人类排版)
一进编辑页就红着,保存不了。
⇒ 做法:**先按存储口径归一、再校验归一后的串**。
这样校验的与真正入库的是**同一个值**,两边不会互相打脸
(分隔符、全角空格都已被 `normPhone()` 抹平)。
⚠️ 曾经试过"把正则的字符集放宽到含 `\s`"——**不够**:
PCRE 的 `\s`(不加 /u / UCP)只认 ASCII 空白,**不认全角空格 U+3000**,
而通讯录导出里真的会出现全角空格 ⇒ 那种号码照样被判错。
"先归一再校验"从根上避免了这类"字符集列举不全"的问题。 */
$phone = self::normPhone($phone);
if ($phone !== '' && !preg_match('/^\+?[0-9]{5,25}$/', $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] ?? '';
}
}