self::BOOK_ID, 'name' => '家庭名册', 'description' => '家人与联系人(与网页上的名册一致)', 'count' => count(self::rows($familyId, $personId)), ]]; } public static function bookHref(int $bookId): string { return self::ROOT . $bookId . '.vcf/'; } public static function hrefOf(array $book): string { return self::bookHref((int)$book['id']); } /** 从 URL 段解出「这是哪一本 / 要哪一张卡」 */ public static function parsePath(string $path): array { $p = (string)parse_url($path, PHP_URL_PATH); if (strpos($p, self::ROOT) !== 0) { return ['book' => 0, 'card' => 0]; } $rest = trim(substr($p, strlen(self::ROOT)), '/'); if ($rest === '') { return ['book' => 0, 'card' => 0]; } $parts = explode('/', $rest); $bookSeg = (string)$parts[0]; $book = (int)preg_replace('/\.vcf$/', '', $bookSeg); $card = 0; if (isset($parts[1]) && $parts[1] !== '') { // 单张卡:.vcf —— 我们用的是 persons.id $card = (int)preg_replace('/\.vcf$/', '', (string)$parts[1]); } return ['book' => $book, 'card' => $card]; } /** * 这本通讯录里的所有"卡"(一个人一张),并且**已经把字段备齐**—— * 电话解密、关系称呼、我叫他什么都在这里算好,Vcard 那层只管写文本。 * * ⚠️ 解密(persons.phone_encrypted 是 AES 密文)为什么放在这一层: * Vcard 是纯文本生成器,不该碰密钥;而且"取数据"这一步与网页名册 * 走同一个 PersonQuery::phonePlain(**同一套判据**), * 不会出现"页面上显示得出手机号、同步出去却是空的"。 */ public static function rows(int $familyId, int $personId): array { if ($familyId <= 0 || $personId <= 0) { return []; } $rows = Person::roster($familyId, $personId, 'member', ''); $people = Person::map($familyId); $graph = Kinship::graph($familyId); $out = []; foreach ($rows as $r) { $pid = (int)$r['id']; $r['phone'] = PersonQuery::phonePlain($r); // 生日:走 Birthday::solarOf 取**真实阳历**(本项目里 birthday 列就是这个语义, // 生日表单字段叫 birthday_solar,两者是两个键名、同一个东西 —— 见该函数注释)。 // ⚠️ 不写农历:vCard 没有农历字段,硬塞进 NOTE 会让手机上出现一句 // 看不懂的"农历三月廿一",而过年过生日那件事日历那边已经管了。 // 也只有"有年份"的阳历生日才写(Vcard 那边会把 "YYYY-MM-DD 00:00:00" // 截成日期段)。 $r['birthday'] = Birthday::solarOf($r); // 与我的关系(名册页上那句"我该怎么称呼他") $r['relation_word'] = Kinship::label($graph, $personId, $pid, $people); // 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父") $nick = Nickname::display($personId, $r); $r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : ''; // 补充联系方式(多个电话/邮箱/地址/网址) $r['contacts'] = Person::contactsOf($pid); $out[] = $r; } return $out; } /** * 单张卡(按 persons.id)。 * * ⚠️ 必须**先判断这张卡在不在这个人的名册里**,而不是直接按 id 查 persons: * 直接查的话,任何人拿到一个 id 就能把全家的信息拉走 * (CardDAV 走的是 Basic 口令、无会话,越权在这里尤其危险)。 */ public static function one(int $familyId, int $personId, int $cardId): ?array { if ($cardId <= 0) { return null; } foreach (self::rows($familyId, $personId) as $r) { if ((int)$r['id'] === $cardId) { return $r; } } return null; } /** 整本导出成 vcf 文本(GET 与 REPORT 都用它) */ public static function vcf(int $familyId, int $personId): string { return Vcard::export(self::rows($familyId, $personId), '家庭名册'); } /** * 一张卡:**正文与 ETag 成对返回**。 * * 🔴 v24 第 22 批修的真 bug:这两个值原本在 carddav.php 里各算一次, * 于是 PROPFIND 用 `etag(Vcard::card($r))`、REPORT 用 * `etag(Vcard::card($r) . "\r\n")` —— **同一张卡算出两个不同的 ETag**。 * 客户端的典型行为是:先 PROPFIND 拿清单(记下 ETag A), * 再 REPORT 取正文(拿到 ETag B),发现"对不上" → 认为这张卡一直在变, * 于是**反复重拉**(同步永远不收敛),或者干脆把本地那份标成脏的。 * 症状是"手机通讯录一直在转圈同步",而服务端日志里一切正常。 * * ⚠️ 修法不是"两处都改成一样",而是**让它们没有机会不一样**: * 正文里的结尾 CRLF(REPORT 的 address-data 需要一个完整的 vCard 文本) * 由调用方按需加,ETag **只由这一处**从**不带结尾 CRLF** 的卡算出来。 * 以后再加第三条路径时,它也只能走这里。 * * @return array{body:string, etag:string} */ public static function cardOf(array $row): array { $body = Vcard::card($row); return ['body' => $body, 'etag' => self::etag($body)]; } /** * ETag:客户端靠它判断"这张卡改过没有"。 * * ⚠️ 用**内容**哈希而不是 updated_at 的最大值:名册的可见性还受"关系/分享"影响 * (同一个人没改,但别人把他分享给了我 → 我这本的内容变了,而 updated_at 没变)。 * 内容哈希对这种"看不见的改动"也敏感,是这里唯一稳的判据。 */ public static function etag(string $vcf): string { return '"' . md5($vcf) . '"'; } // ---------------- 应答辅助(与 CalDav 同名同义,刻意保持一致) ---------------- public static function multis(int $code = 207): void { /* 🔴 v45b(与 CalDav::xmlHeader 同一改动,两个 DAV 入口保持一致): **每个响应**都带上 DAV 能力头(原来只有 OPTIONS 带)。 CardDAV 这边声明的是 `addressbook`(CalDAV 那边是 `calendar-access`)。 ⚠️ Content-Type 对齐参照的字面形态 `text/xml`。 */ http_response_code($code); header('Content-Type: text/xml; charset="utf-8"'); header('DAV: 1, 2, 3, addressbook', false); header('DAV: extended-mkcol', false); } public static function x(string $s): string { return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); } }