用户 2026-10-15 七条。
① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
**真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
⇒ 手机上每条行程的结束时间都等于开始。
**附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
(末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
每节点恰好 1 条 VEVENT。
⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。
② 笔记详情页标题是"看笔记"而不是笔记名
阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
(编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。
③ 加段落/加图片要在**输入框下面**
note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
挂 wrap 会变成"输入框右边的第三列",不是"下面"。
⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。
④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
保存 POST notes/block → 只换这一块。
服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
权限与整条编辑同一把尺(find() + canWrite())。
⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
(#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
PersonRoster::ensure(..., $meId, ...)。
顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。
⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
的 templates/_person_groups.php。
⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。
⑦ 个人设置:模块设置整合成一张表
原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
(非管理员那列是只读色块;提交时服务端静默忽略 c[])。
窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。
验证(新增 5 套件 + 更新若干旧断言):
· _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
· _b52_falsify.py(**12 注入 / 31 断言全抓**)
· _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
· e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
· 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致
⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
· 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
· **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
· 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
· 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
· 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
616 lines
30 KiB
PHP
616 lines
30 KiB
PHP
<?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;
|
||
}
|
||
|
||
/**
|
||
* 🔴 v52(用户 2026-10-15 第 5 条):按**姓名 / 手机号 / 身份证号**找一找
|
||
* "这个人家里是不是已经有了"。
|
||
*
|
||
* 用户原话:「名册功能,**从已有家里人中挑选这个按钮删除**,改为:当其他的用户新增名册中的
|
||
* 人员时,当输入的信息,例如**姓名、手机号、身份证号**,和名册中已有人员匹配时,
|
||
* 提示他**是不是这个人**,如果是,直接让他添加到自己的名册中即可。
|
||
* 而用户**导入数据**时,也是一样的,如果名册中有这个人,就不用再新增了,
|
||
* 而是根据名字、手机号等信息自动匹配后添加到这个用户的名册中。」
|
||
*
|
||
* ⚠️ 这是"判重"而不是"登录找人",所以:
|
||
* · 范围是**整个家庭**(`family_id`),不是"我的名册"—— 别人先录的也该能认出来;
|
||
* · **排除我自己在册的那个 id**(改资料时别把自己匹配上,见调用点传 `$exceptId`)。
|
||
* ⚠️ 手机号与身份证号都是**加密存**的:
|
||
* · 手机号有 `phone_hash`(确定性哈希)→ 可以一条 SQL 精确命中,**优先走它**;
|
||
* · 身份证号**没有**哈希列 → 只能把整表拉出来逐行解密比对。
|
||
* 家庭名册是百人量级,一次全解密可接受(这与 v51 那次手机号归一迁移同一量级)。
|
||
*
|
||
* @return array 命中的那条 person 行(附 `_match` = 命中原因),没命中返回 []
|
||
*/
|
||
public static function matchExisting(int $familyId, array $d, int $exceptId = 0): array
|
||
{
|
||
$name = trim((string)($d['name'] ?? ''));
|
||
$phone = self::normPhone($d['phone'] ?? '');
|
||
$idCard = trim((string)($d['id_card'] ?? ''));
|
||
if ($name === '' && $phone === '' && $idCard === '') {
|
||
return [];
|
||
}
|
||
|
||
// ① 手机号优先:有确定性哈希,一条 SQL 就能精确命中(最可靠,不会重名误判)
|
||
if ($phone !== '') {
|
||
$st = Database::get()->prepare(
|
||
'SELECT * FROM persons WHERE family_id = ? AND phone_hash = ? AND id <> ? LIMIT 1'
|
||
);
|
||
$st->execute([$familyId, Crypto::hash($phone), $exceptId]);
|
||
$row = $st->fetch();
|
||
if ($row) {
|
||
return $row + ['_match' => 'phone'];
|
||
}
|
||
}
|
||
|
||
// ② 身份证号 / 姓名:拉一次全家的行,在 PHP 里比(身份证要解密才能比)
|
||
$st = Database::get()->prepare(
|
||
'SELECT * FROM persons WHERE family_id = ? AND id <> ? AND kind = ?'
|
||
);
|
||
$st->execute([$familyId, $exceptId, 'member']);
|
||
$lower = function (string $s): string {
|
||
return mb_strtolower(trim($s), 'UTF-8');
|
||
};
|
||
$nameHit = [];
|
||
foreach ($st->fetchAll() as $row) {
|
||
if ($idCard !== '') {
|
||
$ic = Crypto::decrypt((string)($row['id_card_encrypted'] ?? ''));
|
||
if ($ic !== '' && $ic === $idCard) {
|
||
return $row + ['_match' => 'id_card'];
|
||
}
|
||
}
|
||
// ⚠️ 姓名命中先**记下**、不立刻返回:同名的人真的存在("张伟"),
|
||
// 先看完有没有身份证/手机号这种**精确**命中,再退到姓名。
|
||
if ($name !== '' && $lower((string)$row['name']) === $lower($name)) {
|
||
$nameHit = $row;
|
||
}
|
||
}
|
||
if ($nameHit) {
|
||
return $nameHit + ['_match' => 'name'];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 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] ?? '';
|
||
}
|
||
}
|