Files
laoyang fec898c18d v46:CardDAV 通讯录 —— 显示名用称呼 + 档案进备注 + 单位进工作地址 + 头像内嵌
用户 2026-10-13 四条(含一条安全确认):

① 【安全确认,无需改代码】「加日历时要输 Token,加通讯录时不再要了,这权限不对吧?」
   实测线上:匿名 PROPFIND/GET /carddav/ → 401;错口令 → 401;正确 DAV 口令 → 207。
   **CardDAV 与 CalDAV 共用同一把 DAV 口令、且都强制鉴权**,不存在绕过。
   用户没被再问一次是因为 Thunderbird 会复用**同一主机**上已存的 Basic 凭据
   (realm 都是 "FamilyBook DAV")—— 那是客户端的设计,不是服务端的洞。

② 【头像】「通讯录同步都没有头像,但咱们是有头像的……清晰度调到最高」
   · 新增 PHOTO 行:`PHOTO;ENCODING=b;TYPE=JPEG:<base64>`(vCard 3.0 内嵌,原字节不二次压缩)
   · 库里那张已是上传时重采样的 512×512 JPEG q88 ⇒ 清晰度已到顶;
     仅当 >300KB 时才等比缩到 384(shrinkImage)
   · 🔴 字节**必须走 Storage::read**:线上 storage_driver=oss,
     实测 20 个有头像的人里**只有 5 个在本地 uploads/、15 个只在 OSS**
     ⇒ 只读本地会静默漏掉 15 个人的头像。已实测 photoBytes 取到 20/20。
   ⚠️ 已实测告知:**Thunderbird 不会显示**这张照片 —— TB 157 的
      `modules/VCardUtils.sys.mjs` typeMap 里**没有 photo**;
      但 iOS 通讯录 / DAVx⁵ / 群晖 Contacts 支持,所以照写。

③ 【显示名 + 备注】「名字用我们给他起的称呼,把原名/关系/我写的备注都放进备注」
   · `FN` = 称呼(`nicknames`,viewer 维度;没起过则回退真名)
   · `N`   = 真名(iOS 靠它排序/搜索;缺了会按"无名"排)
   · `NICKNAME` 只在"显示名≠小名"时写(同步时 FN 已是小名 ⇒ 不重复;
     备份导出时 FN=真名 ⇒ NICKNAME 保住小名,往返不丢)
   · `NOTE` 富化:原名 / 与我的关系 / **我写的私人备注**(person_notes)/
     自我介绍 / 现单位 / 现职称 / 工作经历(多段,从早到晚,末段标"现在")

④ 【单位 + 工作地址】「职称和单位只写现在的;工作经历也放备注;单位放到工作地址栏」
   · ORG/TITLE 只写当前(persons.org/title 本就是 PersonJob::syncCurrent 的当前快照)
   · 新增 `ADR;TYPE=WORK:;;<当前单位>;;;;`(用户明确"就是工作单位,不需要单位地址";
     该栏在 TB 的 typeMap 里有 `adr.work`,是它通讯录**会显示**的字段)

🔴 一条关键设计约束:**富化只在"同步模式"下做**(`$display !== $real`)。
   备份导出那条路径(显示名=真名)NOTE 只放原有备注、不写 ADR;TYPE=WORK ——
   否则既有 e2e 的**往返一致**守卫会红:导出的富化备注会被当备注再存一份,
   越导越多且不可逆。第一版只给"自我介绍"打了门禁、其余照拼,往返断言当场变红。

🔴 配套改 `ContactImport`:姓名改成 **N(真名)只要非空就覆盖 FN(显示名)**
   (原来是 FN 优先)。否则我们的卡(FN=称呼、N=真名)导回来会
   **让一个人被自己的称呼改掉名字**。安全性已逐条确认:我们自己的卡字段顺序固定、
   标准客户端只写 FN、老手机只写 N —— 三种情况结果都正确。

验证(新增两个套件):
  · `_fb_v46_carddav.py`(29 条):造"信息最全"夹具(称呼/私人备注/自我介绍/当前单位职称/
    三段经历/真 JPEG 头像)→ 真发 CardDAV 请求拉回逐字段断言 + 鉴权四态。**29/0**
  · `_b46_carddav_falsify.py`(9 个注入):FN 改回真名 / 备注少项 / 不写 PHOTO /
    头像数据源切断 / 放行匿名 —— **9/9 全抓住**
  · `_fb_b22_vcf_spec.php`(vCard 规格 + 往返一致):**40/0**
  · 全量 e2e **1510/0**;TB 探针 44/0;DAV 诊断 28/0;v45 双向写 24/0
  · 全流水线 **PUSH_RC=0**(含部署静态断言"第46批形态")
  · **线上终验 13/0**:真公网拉 801 张卡;FN='胖胖' / N='杨永全' /
    NOTE='原名:杨永全;与我的关系:丈夫;现单位:中国海洋大学;现职称:副教授;
    工作经历:中国海洋大学(副教授)';20 张卡带 PHOTO 且 base64 可解回真图;
    匿名/错口令 401

顺带修掉一个**探针自身的偶发假红**(v45c 引入的,与 v46 无关):
  `_fb_b44_ui.py` 的 3b 用"浏览器此刻分钟"当基准,而页面是服务端在 10:07:59 渲染的
  ⇒ 时钟漂移 1 分钟;跨度 3 小时时 1 分钟=0.556% 又超 0.3% 容差 ⇒ 两条判据同时违反。
  改成用**服务端自报的 data-now-min 反算它自己该放的位置**(两端同源、零容差、不受漂移影响);
  `_b44_falsify.py` 的关键词同步更新。v44 反向验证恢复"基线全绿 + 19/19"。
2026-10-13 11:53:58 +08:00

649 lines
32 KiB
PHP
Raw Permalink 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
/**
* vCard 3.0 导出(RFC 2426)—— 名册 → 通讯录卡片。
*
* 用户原话(2026-10-06 第 22 批):「名册,增加cardav同步功能。」
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里的文本。
*
* ⚠️ 为什么导出 **3.0** 而不是 4.0:
* iOS 与 DAVx⁵ 都推荐 3.0(4.0 在 iOS 上会被"降级兼容",个别字段干脆丢),
* 而且 3.0 的 TEL/EMAIL 参数写法最简单 —— 少写兼容分支就少一处出错。
* 真正要紧的两条 3.0 规矩:
* · `FN` **必须**有(3.0 里 FN 是必需属性,没有它 iOS 会整张卡丢掉);
* · `N` 也要有(缺了 iOS 通讯录会按"无名"排序,找不着人)。
*
* ⚠️ 与「导入」是同一份字段表(App\ContactImport::parseVcard 的反向):
* 导进来的卡再导出去必须还是同一个人(往返不丢字段)。
* 那边的解析分支就是这边的书写清单,改动时要**两边一起看**。
*
* ⚠️ 三件必须做对、否则客户端会静默丢数据的事:
* ① **转义**:文本值里的 `\` `;` `,` 换行都要转(RFC 2426 3.4)——
* "青岛市,市南区"这种地址不转义,客户端会把逗号当**分段符**,地址后半截跑到别的字段;
* ② **折行**:一行最多 75 **字节**、续行以一个空格开头。中文一个字 3 字节,
* 按字符数切会切出半个 UTF-8 字 → 客户端解出来是乱码。这里直接复用 Ics 的实现思路。
* ③ **CSV 式的逗号列表**:N 与 ADR 是"用分号连接的分段",而每段内部还要再转义。
* 顺序不能反(先转义后拼分号,否则真分段符也会被转义掉)。
*/
declare(strict_types=1);
namespace App;
final class Vcard
{
/**
* 把一批"人"导出成一张 .vcf 文本(多张卡拼在一起,这是 CardDAV 资源的通行形态:
* 一本通讯录 = 一个 vcf 文件,里面 N 张卡)。
*
* @param array<int,array> $people 每人一行(含 contacts 附件信息,见 rosterCards())
*/
public static function export(array $people, string $bookName = '家庭名册'): string
{
$out = [];
foreach ($people as $p) {
$out[] = self::card($p);
}
// ⚠️ CRLF 行尾是规范要求(RFC 2426 3.3 明确写了 lines 以 CRLF 分隔)。
// 用 \n 的话,iOS 大多能忍,但 Outlook 那一路会整张卡读不出来。
return implode("\r\n", $out) . "\r\n";
}
/** 一张卡(一个 BEGIN:VCARD … END:VCARD 块),已折行、已转义 */
public static function card(array $p): string
{
// 真名与显示名是**两个**,别再合成一个变量:
// · $real —— `persons.name`,写进 N(结构化姓名,客户端排序/去重用);
// · $display —— **我叫他什么**(`nicknames` 表,`CardDav::rows` 已算好),写进 FN。
// 🔴 v46(用户 2026-10-13)原话:
// 「同步通讯录的时候,名字不要用他的真实姓名,而是用我们在系统里给其他名册里的人
// 起的称呼……同时把原名、我跟他之间的关系,包括我给他写的备注,都放在备注里。」
// ⇒ FN = 称呼(手机上看到的就是我平常叫他的那两个字);
// 真名 / 关系 / 私人备注 / 工作经历全部进 NOTE(见 buildNote())。
$real = trim((string)($p['name'] ?? ''));
if ($real === '') {
$real = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉
}
$display = trim((string)($p['display_name'] ?? ''));
if ($display === '') {
$display = $real; // 没给他起过称呼 → 退回真名(老行为,不能变成空名字)
}
$lines = [];
$lines[] = 'BEGIN:VCARD';
$lines[] = 'VERSION:3.0';
// ---- 姓名 ----------------------------------------------------
// ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。
// 用 `person-<id>@<域>`(不能用"姓名"——同名的人会互相覆盖,
// 而且改名之后客户端会当成"删一张加一张")。
$lines[] = 'UID:' . self::esc(self::uid($p));
// FN:**显示名** —— 走 CardDAV 同步时它就是"我叫他什么"(见上)。
$lines[] = 'FN:' . self::esc($display);
// N:**真名**。缺了它 iOS 会把这卡按"无名"排序、搜真名也搜不到。
$lines[] = 'N:' . self::nField($real);
// NICKNAME:只在"显示名不等于小名"时才写。
// ⚠️ 这条判断是必要的,不是可有可无:走 CardDAV 时 `display` 就是小名,
// 再写一行 NICKNAME 会让手机上出现**两行一模一样的名字**(纯噪音);
// 而"用真名当显示名"的场合(往返导入 / 备份导出)里,NICKNAME 才是
// 唯一能保住小名的地方 —— 少了它小名就丢了。
$nick = trim((string)($p['nickname'] ?? ''));
if ($nick !== '' && $nick !== $display) {
$lines[] = 'NICKNAME:' . self::esc($nick);
}
// ---- 头像 ----------------------------------------------------
foreach (self::photoLine($p) as $ln) {
$lines[] = $ln;
}
// ---- 电话 ----------------------------------------------------
// ⚠️ 主手机号在 persons.phone_encrypted 里是**密文**(AES),
// 调用方(CardDav::rows)已经解好放在 $p['phone'],这里只管写。
// 为什么不在这一层解密:导出类是"文本生成器",不该碰密钥;
// 而且一处解密、两处用(vcf 与页面)才不会出现"页面看得到、同步看不到"。
$mainPhone = trim((string)($p['phone'] ?? ''));
if ($mainPhone !== '') {
$lines[] = 'TEL;TYPE=CELL:' . self::esc($mainPhone);
}
foreach (self::contactRows($p, 'phone') as $row) {
$t = trim((string)($row['value'] ?? ''));
if ($t === '' || $t === $mainPhone) {
continue; // 与主手机号重复就不再写一遍(客户端会显示两条一样的)
}
$lines[] = self::telLine($t, (string)($row['label'] ?? ''));
}
// ---- 邮箱 ----------------------------------------------------
$mainMail = trim((string)($p['email'] ?? ''));
if ($mainMail !== '') {
$lines[] = 'EMAIL;TYPE=INTERNET:' . self::esc($mainMail);
}
foreach (self::contactRows($p, 'email') as $row) {
$t = trim((string)($row['value'] ?? ''));
if ($t === '' || $t === $mainMail) {
continue;
}
$lines[] = self::mailLine($t, (string)($row['label'] ?? ''));
}
// ---- 网址 ----------------------------------------------------
foreach (self::contactRows($p, 'url') as $row) {
$t = trim((string)($row['value'] ?? ''));
if ($t !== '') {
$lines[] = 'URL:' . self::esc($t);
}
}
// ---- 地址 ----------------------------------------------------
// ⚠️ 主地址有两个来源:persons.address(自由文本,可能是"XX小区3号楼"这种
// 纯街道)与 person_contacts 里 kind=address 的行。两者都写会重复,
// 所以:contacts 里没有 address 行时才写 persons.address。
$addrRows = self::contactRows($p, 'address');
foreach ($addrRows as $row) {
$t = trim((string)($row['value'] ?? ''));
if ($t !== '') {
$lines[] = 'ADR;TYPE=HOME:' . self::adrField($t, (string)($p['province'] ?? ''));
}
}
if (!$addrRows && trim((string)($p['address'] ?? '')) !== '') {
$lines[] = 'ADR;TYPE=HOME:' . self::adrField((string)$p['address'], (string)($p['province'] ?? ''));
}
// ---- 生日 ----------------------------------------------------
// ⚠️ vCard 3.0 的 BDAY 允许 `YYYY-MM-DD`,但**不允许只有月日**(那要写 `--MM-DD`)。
// 没年份的人(只知道月日)按 3.0 的写法给 `--MM-DD`;
// 两者都没有就整条不写 —— 写个空 BDAY: 反而会让 iOS 认成一个"无效日期"。
$bday = self::bday((string)($p['birthday'] ?? ''));
if ($bday !== '') {
$lines[] = 'BDAY:' . $bday;
}
// ---- 单位 / 职务 --------------------------------------------
// 🔴 v46:ORG 只写**当前**单位(`persons.org` 是 `PersonJob::syncCurrent()`
// 从最新那段经历回写的当前快照);历史与职称明细都进 NOTE。
if (trim((string)($p['org'] ?? '')) !== '') {
$lines[] = 'ORG:' . self::esc((string)$p['org']);
}
if (trim((string)($p['title'] ?? '')) !== '') {
$lines[] = 'TITLE:' . self::esc((string)$p['title']);
}
// ---- 工作地址 ------------------------------------------------
// 🔴 v46(用户 2026-10-13)原话:
// 「把现在的工作单位放在他现在工作单位的地址栏里。」
// 用户同时明确「就是工作单位就行,不需要工作单位的地址」—— 也就是说
// 这个"工作地址"栏里放的是**单位名称**,不是门牌。
// 花钱买这一条的理由(实测 TB 157 · `modules/VCardUtils.sys.mjs` 的 typeMap):
// `adr.work` 在映射表里(工作地址是 TB 通讯录**会显示**的字段之一),
// 而 `org`/`title` 只映射到 Company/JobTitle 两个内置行;
// 把单位再放进 ADR;TYPE=WORK 能让它在详情里多一个明确的位置。
// ⚠️ 分段位置必须与 `adrField()` 一致(段2=街道…):单位名当"街道"那段,
// 城市/省/邮编/国家全留空 —— 写错段位会像 v24 那次一样"看着有、显示不出来"。
// ⚠️ 同样**只在同步模式**下写:备份导出里凭空多一条 ADR 会让
// `_fb_b22_vcf_spec.php` 的"补充地址只有 1 条"断言变红(那是往返一致性的守卫)。
if ($display !== $real && trim((string)($p['org'] ?? '')) !== '') {
$lines[] = 'ADR;TYPE=WORK:' . self::adrField((string)$p['org'], '');
}
// ---- 与我的关系 / 备注 --------------------------------------
// ⚠️ 关系写进 CATEGORIES 而不是自己造一个 X- 字段:
// iOS 通讯录会把 CATEGORIES 当成"群组/标签"显示,用户一眼能看出谁是家人。
// 写 X-FAMILY-RELATION 那种私有字段的话,手机上什么都看不到
// (导出了但等于没导出)。
$rel = trim((string)($p['relation_word'] ?? ''));
if ($rel !== '') {
$lines[] = 'CATEGORIES:' . self::esc($rel);
}
// NOTE:把"真名 / 关系 / 我的备注 / 他写的自我介绍 / 工作经历"全拼进去。
$note = self::buildNote($p, $real, $display);
if ($note !== '') {
$lines[] = 'NOTE:' . self::esc($note);
}
// REV:客户端的"这条改过没有"判据。用 updated_at(拿不到就用现在)
$lines[] = 'REV:' . self::stamp((string)($p['updated_at'] ?? ''));
$lines[] = 'END:VCARD';
// 逐行折行(折行要在**转义之后**:先折再转义会把续行前导空格也转义掉)
$flat = [];
foreach ($lines as $ln) {
foreach (self::fold($ln) as $piece) {
$flat[] = $piece;
}
}
return implode("\r\n", $flat);
}
/**
* NOTE 的内容:把"手机上这一个字段"能装的信息尽量装进去。
*
* 🔴 v46(用户 2026-10-13)原话:
* 「把他点进去之后,就能看到这个人原名叫什么、他跟我是什么关系,
* 以及我们是怎么认识的,还包括他的职称、工作经历之类的信息……
* 看看备注有没有字数限制,如果没有的话,就都放在这里面。
* 他的职称和工作单位,只写现在的。……把他的工作经历也放在他的备注里。」
*
* 🔴🔴 **只在"同步模式"下做富化**(`$display !== $real`,即显示名已被换成了
* 我叫他的称呼)。理由是一条硬约束:**导出必须与导入往返一致**。
* 备份导出那条路径(显示名就是真名)里,caller 没有算 `relation_word` /
* `jobs` / `my_note`,若这里也去拼 NOTE,会在往返导入后再叠一层 ——
* 每导一次多一截,且不可逆。所以那种场合 NOTE = 原有备注,与 v24 一字不差。
*
* ⚠️ 为什么用 `;` `|` 这类全角标点做分隔而不是换行:
* vCard 的换行是 `\n`,写进去在 iOS 上是**一个软换行**、在 TB 里可能被折掉;
* 而各个客户端对 NOTE 里的换行渲染差异很大(有的折成一行)。
* 用全角分隔符,无论折行还是单行显示,可读性都还在。
*
* ⚠️ 容量:vCard 3.0 对 NOTE 没有长度限制;本项目两个备注列都是 TEXT
* (65535 字节)。逐行折行由 fold() 负责,不会撑坏。
*
* @param array $p 一行人员数据(`CardDav::rows` 的输出)
* @param string $real 真名(persons.name)
* @param string $display 显示名(FN 用的那个)
*/
private static function buildNote(array $p, string $real, string $display): string
{
$theirs = trim((string)($p['note'] ?? ''));
// ── 备份导出模式:NOTE 就是原有备注(保持 v24 的往返语义)──
if ($display === $real) {
return $theirs;
}
// ── 同步模式:富化 ──
$parts = [];
// ① 真名
$parts[] = '原名:' . $real;
// ② 与我的关系
$rel = trim((string)($p['relation_word'] ?? ''));
if ($rel !== '' && $rel !== '我') {
$parts[] = '与我的关系:' . $rel;
}
// ③ **我给他写的私人备注**(`person_notes`,只有写的人自己看得到)——
// 用户点名要的"我们是怎么认识的"就在这一条里。排在自我介绍前面,
// 因为它是"我的事",比履历更需要一眼看到。
$mine = trim((string)($p['my_note'] ?? ''));
if ($mine !== '') {
$parts[] = $mine;
}
// ④ 他自己写的自我介绍
if ($theirs !== '') {
$parts[] = '自我介绍:' . $theirs;
}
// ⑤ 当前单位 / 职称(只写现在的 —— 用户明确要求)
$org = trim((string)($p['org'] ?? ''));
if ($org !== '') {
$parts[] = '现单位:' . $org;
}
$title = trim((string)($p['title'] ?? ''));
if ($title !== '') {
$parts[] = '现职称:' . $title;
}
// ⑥ 工作经历(多段,从早到晚)—— 用户要的"三年前在哪、两年前在哪、现在在哪"
$jobs = self::jobLines($p['jobs'] ?? []);
if ($jobs !== '') {
$parts[] = '工作经历:' . $jobs;
}
return implode(';', array_filter($parts, static fn($s) => $s !== ''));
}
/**
* 工作经历 → 一段文字:`2020-01~2023-06 某大学(讲师)|2023-07~现在 另一大学(副教授)`
*
* ⚠️ `person_jobs` 的排序由 `PersonJob::list()` 保证:**没有结束年月的那段
* (=现在)排最前**,其余按开始时间倒序。这里照它的顺序写,
* 再反转成"从早到晚"读起来更顺("以前在哪、现在在哪")。
* 但**反转要稳**:只用 `array_reverse`,不重新排序,避免两处排序口径打架。
*/
private static function jobLines(array $jobs): string
{
$out = [];
foreach (array_reverse(array_values($jobs)) as $j) {
$org = trim((string)($j['org'] ?? ''));
if ($org === '') {
continue; // 没有单位的经历没法读,跳过(App\PersonJob 也允许这种空行)
}
$seg = '';
$start = trim((string)($j['start_ym'] ?? ''));
$end = trim((string)($j['end_ym'] ?? ''));
if ($start !== '' || $end !== '') {
// 结束为空 = 现在(与 App\PersonJob 列表里标「现在」同一口径)
$seg .= ($start !== '' ? $start : '?') . '~' . ($end !== '' ? $end : '现在') . ' ';
}
$seg .= $org;
// 职务与职称分开拼,两个都有就都写上
$extra = [];
$t = trim((string)($j['title'] ?? ''));
if ($t !== '') { $extra[] = $t; }
$rk = trim((string)($j['rank_name'] ?? ''));
if ($rk !== '' && $rk !== $t) { $extra[] = $rk; }
if ($extra) {
$seg .= '(' . implode('·', $extra) . ')';
}
$out[] = $seg;
}
return implode('|', $out);
}
/**
* PHOTO 行(vCard 3.0 的 `ENCODING=b` 内嵌 base64)。
*
* 🔴 v46(用户 2026-10-13):「通讯录同步都没有头像,但咱们是有头像的……
* 我记得 CardDav 这种 ICS 同步的时候,可以把头像放在 ICS 文件里面。
* 虽然可能清晰度没那么高,但我们尽可能把清晰度调到最高。」
*
* ⚠️ 先泼一盆冷水(实测,别让用户白期待):**Thunderbird 看不到这张照片。**
* TB 157 的 vCard↔通讯录映射表(`modules/VCardUtils.sys.mjs` 的 `typeMap`)
* 里没有 `photo` 这一项 —— CardDAV 同步进来的联系人,TB 不会渲染头像。
* 但 **iOS 通讯录 / DAVx⁵ / 群晖 Contacts 都支持**,所以照写不白写。
*
* ⚠️ 清晰度:库里那张已经是 512×512、JPEG 质量 88 的成品(上传时就重采样过)。
* PHOTO 是**原字节内嵌**、不会再压一次 ⇒ 同步出去的就是库里那张,没有可再提的空间。
* 这里仍然"能多大给多大",且**超过 $maxBytes 时等比缩一档**守住体积:
* 一张卡几 MB 的情形容户端会整本拉得很慢(多张卡拼接成一本 vcf)。
*
* @return string[] 0 或 1 行
*/
private static function photoLine(array $p): array
{
$bin = (string)($p['photo_bin'] ?? '');
if ($bin === '') {
return [];
}
$type = 'JPEG';
if (strncmp($bin, "\x89PNG", 4) === 0) {
$type = 'PNG';
} elseif (strncmp($bin, 'GIF8', 4) === 0) {
$type = 'GIF';
} elseif (strncmp($bin, 'RIFF', 4) === 0 && substr($bin, 8, 4) === 'WEBP') {
$type = 'WEBP';
}
// 太大就缩一档(只在真的超了才动它,且缩完还不合格就直接不发 —— 发个坏图更糟)
if (strlen($bin) > self::PHOTO_MAX_BYTES) {
$small = self::shrinkImage($bin, 384);
if ($small === '') {
return [];
}
$bin = $small;
$type = 'JPEG'; // shrinkImage 一律输出 JPEG
}
// vCard 3.0:`PHOTO;ENCODING=b;TYPE=JPEG:<base64>`。
// ⚠️ base64 **不能折行**(折行是"行级"的事,由外层 fold() 统一做)——
// 在这里自己插入换行会把 ENCODING=b 的连续值切断,客户端解出坏图。
// 3.0 里 `ENCODING=b` 表示"base64 二进制",不要写成 4.0 的 `data:` URI
//(TB 与 iOS 对 3.0 里塞 data URI 的支持并不一致)。
return ['PHOTO;ENCODING=b;TYPE=' . $type . ':' . base64_encode($bin)];
}
/**
* 把图片二进制等比缩到最长边 $max,输出 JPEG(质量 88)。
*
* ⚠️ 与 `App\Upload::image` 的思路一致但**不复用**:那一个要处理裁剪区域、
* 槽位比例、落盘、主色提取,牵进 vCard 生成器会让依赖方向反过来
* (文本生成器不该依赖上传器)。这里只要"缩一下",二十行写清。
* ⚠️ 没有 GD 时返回 ''(调用方就当没有头像,绝不半途发一张坏图)。
*/
private static function shrinkImage(string $bin, int $max): string
{
if (!function_exists('imagecreatefromstring')) {
return '';
}
$src = @imagecreatefromstring($bin);
if (!$src) {
return '';
}
$w = imagesx($src);
$h = imagesy($src);
if ($w <= 0 || $h <= 0) {
imagedestroy($src);
return '';
}
$scale = $max / max($w, $h);
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
$dst = imagecreatetruecolor($nw, $nh);
if (!$dst) {
imagedestroy($src);
return '';
}
// JPEG 无 alpha:白底铺一层,否则 PNG 头像的透明区会变成黑块
imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
ob_start();
$ok = imagejpeg($dst, null, 88);
$out = (string)ob_get_clean();
imagedestroy($dst);
imagedestroy($src);
return $ok && $out !== '' ? $out : '';
}
/** PHOTO 的体积上限:超了就缩一档(一张头像到这个量级已经远超"够看") */
private const PHOTO_MAX_BYTES = 300 * 1024;
/** UID:稳定且跨系统唯一(改名、改电话都不该让它变) */
public static function uid(array $p): string
{
$id = (int)($p['id'] ?? 0);
if ($id > 0) {
return 'person-' . $id . '@familybook';
}
// 兜底:没有 id 的行(理论上不会)用一个姓名哈希,至少内部一致
return 'person-' . substr(sha1((string)($p['name'] ?? '')), 0, 12) . '@familybook';
}
/** 从 contacts 数组里取某一类(kind)的行 */
private static function contactRows(array $p, string $kind): array
{
$all = $p['contacts'] ?? [];
if (!is_array($all)) {
return [];
}
$rows = $all[$kind] ?? [];
return is_array($rows) ? $rows : [];
}
/**
* 电话行:尽量带上"手机/住宅/工作"。
*
* ⚠️ label 是我们自己的中文标注(「奶奶家」「工作」),vCard 3.0 的 TYPE
* 只认一组固定词(CELL/HOME/WORK/VOICE/FAX…)。做法:
* · label 能对上固定词(手机/座机/家/单位…)→ 写成 TYPE,
* 这样手机上会显示成"手机"/"工作",是用户期待的样子;
* · 对不上 → 不猜,原样放进 X-ABLabel(苹果认这个私有字段,
* 会把它当自定义标签显示出来)。猜错成 HOME 比不写更糟:
* 用户会看到"家"却其实是"奶奶家"。
*/
private static function telLine(string $phone, string $label): string
{
$map = [
'手机' => 'CELL', '移动' => 'CELL', 'cell' => 'CELL', 'mobile' => 'CELL',
'家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME',
'工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK',
'传真' => 'FAX', 'fax' => 'FAX',
];
$l = trim($label);
$type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? '');
if ($type !== '') {
return 'TEL;TYPE=' . $type . ':' . self::esc($phone);
}
if ($l !== '') {
return 'TEL;TYPE=VOICE;X-ABLabel=' . self::escParam($l) . ':' . self::esc($phone);
}
return 'TEL:' . self::esc($phone);
}
/** 邮箱行(与电话同样的思路:能对上固定词就用 TYPE,否则原样当标签) */
private static function mailLine(string $mail, string $label): string
{
$map = ['家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME',
'工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK'];
$l = trim($label);
$type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? '');
if ($type !== '') {
return 'EMAIL;TYPE=INTERNET,' . $type . ':' . self::esc($mail);
}
if ($l !== '') {
return 'EMAIL;TYPE=INTERNET;X-ABLabel=' . self::escParam($l) . ':' . self::esc($mail);
}
return 'EMAIL;TYPE=INTERNET:' . self::esc($mail);
}
/**
* N 字段(结构化姓名)。
*
* ⚠️ 中文名不能按西文拆:"杨永全" 拆成 姓=杨 / 名=永全 是**猜**,
* 猜错(复姓"欧阳"拆成"欧"+"阳")比不拆更糟。
* 所以:纯中文名字**整体放"姓"那一格、名留空** —— 显示出来仍是"杨永全",
* 排序也按拼音走得通(iOS 用 FN 显示、用 N 排序)。
* ⚠️ 空格分得开的西文名("John Smith")才按空格拆成 名 / 姓。
*/
private static function nField(string $name): string
{
$name = trim($name);
if ($name === '') {
return ';;;;';
}
$hasCjk = (bool)preg_match('/[\x{4e00}-\x{9fff}]/u', $name);
if ($hasCjk) {
// 姓=整名,名=空。分号是分段符,末尾四个空段按 3.0 补齐。
return self::esc($name) . ';;;;';
}
$parts = preg_split('/\s+/u', $name) ?: [];
if (count($parts) >= 2) {
$family = array_pop($parts); // 最后一段当姓("John Smith" → Smith)
$given = implode(' ', $parts);
return self::esc($family) . ';' . self::esc($given) . ';;;';
}
return self::esc($name) . ';;;;';
}
/**
* ADR 字段:PO盒;扩展;街道;城市;省;邮编;国家(3.0 共 7 段)。
*
* ⚠️ 我们**没有**可靠的城市/省/邮编拆分(persons.address 是自由文本)。
* 所以整串塞进"街道"那一格,其余留空。
* 为什么不去猜:把"青岛市市南区XX路"硬拆成市/区×邮编,猜错了手机上
* 会显示成一张地址错乱的卡(而整串在街道格子里至少读起来是对的)。
*/
private static function adrField(string $addr, string $province): string
{
/* ADR 有 **7 段**(PO;扩展;街道;城市;省;邮编;国家),所以需要 **6 个分号**。
要落位的是:段2=街道、段4=省、其余留空 →
'' ; '' ; 街道 ; '' ; 省 ; '' ; ''
即 `;;` + 街道 + `;;` + 省 + `;;`。
🔴 这里第一版写成了 `;;` + 街道 + `;;;` + 省 + `;` —— 分号总数也是 6,
但**多出来的那一个挤在前面**,于是省落到了"邮编"那一段
(解析器读段4 拿到空串 → 省看起来"没填")。
症状非常隐蔽:段数对、总数对、只有**位置**错一位。
这是靠"导出→导入往返一致"那条断言抓出来的(见 _fb_b22_vcf_spec.php 第 6 节):
只数分号个数的检查发现不了它。 */
return ';;' . self::esc($addr) . ';;' . self::esc(trim($province)) . ';;';
}
/**
* BDAY:优先真实阳历生日(有年份),否则只给月日(3.0 的 `--MM-DD`)。
*
* ⚠️ 为什么不能直接把 "2026-03-05" 写进 BDAY 就完事:
* 本项目的 birthday 是 **DATETIME**(精确到时分秒)。写全会被某些客户端
* 当成"生日有具体时刻",iOS 会显示成 00:00 —— 无害但多余,所以只取日期段。
*/
private static function bday(string $birthday): string
{
$birthday = trim($birthday);
// ⚠️ 本项目的 birthday 是 **DATETIME**(精确到时分秒),所以只取日期段:
// 写全会被客户端当成"生日有具体时刻",iOS 上显示成 00:00(无害但多余)。
if ($birthday !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $birthday, $m)) {
return $m[1] . '-' . $m[2] . '-' . $m[3];
}
// 只有月日(`--MM-DD`)是 3.0 的合法写法,但**本项目不产出**:
// 库里存的阳历生日一定有年份(没有年份是"农历"那一路的事,
// 而 vCard 没有农历字段 —— 硬塞会让手机上多一句看不懂的话,见 CardDav::rows)。
// 这里留着分支只为"万一将来某条数据真的只有月日"时不至于写出非法 BDAY。
if (preg_match('/^(\d{2})-(\d{2})$/', $birthday, $m)) {
return '--' . $m[1] . '-' . $m[2];
}
return '';
}
/** REV 用的时间戳(UTC,3.0 要求 ISO8601 基本型) */
private static function stamp(string $dt): string
{
$dt = trim($dt);
$t = $dt === '' ? time() : (strtotime($dt) ?: time());
return gmdate('Ymd\THis\Z', $t);
}
/** 文本值转义(RFC 2426 3.4):`\` `;` `,` 与换行 */
public static function esc(string $v): string
{
$v = str_replace('\\', '\\\\', $v);
$v = str_replace(';', '\;', $v);
$v = str_replace(',', '\,', $v);
return str_replace(["\r\n", "\r", "\n"], '\n', $v);
}
/**
* 参数值转义(RFC 2426:参数里的 `,` `;` `:` 要加反斜杠或用引号)。
* 用双引号包起来最简单,也不会踩"中文标点"的坑。
*/
private static function escParam(string $v): string
{
return '"' . str_replace(['"', '\\'], ['', '\\\\'], $v) . '"';
}
/**
* 折行:一行最多 75 **字节**,续行以一个空格开头,且不切在多字节字符中间。
*
* ⚠️ 与 App\Ics::fold 是同一套算法。**刻意复制而不是共用**:
* Ics 的 fold 是 private、而且它按"iCalendar 不折参数行"的经验调过;
* 把两个协议的折行强行抽成一个公共函数,将来任一方按自己的规范微调
* 都会波及另一边(vCard 与 iCalendar 的折行规则虽然像,但不是同一段规范)。
* 复制一份二十行,换两边互不牵连。
*/
private static function fold(string $line): array
{
if (strlen($line) <= 75) {
return [$line];
}
$out = [];
$cur = '';
$len = 0;
$bytes = strlen($line);
$i = 0;
while ($i < $bytes) {
$o = ord($line[$i]);
$n = 1;
if ($o >= 0xF0) { $n = 4; }
elseif ($o >= 0xE0) { $n = 3; }
elseif ($o >= 0xC0) { $n = 2; }
$tok = substr($line, $i, $n);
$i += $n;
$room = $out === [] ? 75 : 74; // 续行要多一个前导空格
if ($len + $n > $room) {
$out[] = $cur;
$cur = '';
$len = 0;
}
$cur .= $tok;
$len += $n;
}
if ($cur !== '') {
$out[] = $cur;
}
// 🔴 续行**必须**补前导空格(RFC 2426 3.2:换行后以一个空格开头表示"这是上一行的延续")。
// ⚠️ 漏掉这一步的后果极隐蔽:折行切出来的那几段会变成**独立的属性行**,
// 客户端读到的就是半个名字 / 半个地址("青岛市\,市南区\;某某路1号 这是一个特别特"
// 后面那两段被当成垃圾属性丢掉),而且服务端看不出任何异常 ——
// 本地校验"每行 ≤75 字节""折行确实发生了"全是绿的。
// 这是本轮唯一一处靠"折行能否无损还原"这条断言才抓出来的 bug
// (见 _fb_b22_vcf.py 的"折行可无损还原"),所以那条断言必须留着。
$res = [];
foreach ($out as $k => $v) {
$res[] = $k === 0 ? $v : ' ' . $v;
}
return $res;
}
}