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