From fec898c18d2857590921f106d279461adfe0b746 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 13 Oct 2026 11:53:58 +0800 Subject: [PATCH] =?UTF-8?q?v46=EF=BC=9ACardDAV=20=E9=80=9A=E8=AE=AF?= =?UTF-8?q?=E5=BD=95=20=E2=80=94=E2=80=94=20=E6=98=BE=E7=A4=BA=E5=90=8D?= =?UTF-8?q?=E7=94=A8=E7=A7=B0=E5=91=BC=20+=20=E6=A1=A3=E6=A1=88=E8=BF=9B?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=20+=20=E5=8D=95=E4=BD=8D=E8=BF=9B=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=9C=B0=E5=9D=80=20+=20=E5=A4=B4=E5=83=8F=E5=86=85?= =?UTF-8?q?=E5=B5=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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:`(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"。 --- app/CardDav.php | 78 +++++++++++- app/ContactImport.php | 45 ++++--- app/Vcard.php | 274 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 368 insertions(+), 29 deletions(-) diff --git a/app/CardDav.php b/app/CardDav.php index 21c4811..7511988 100644 --- a/app/CardDav.php +++ b/app/CardDav.php @@ -31,6 +31,8 @@ declare(strict_types=1); namespace App; +use App\Storage\Storage; + final class CardDav { /** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */ @@ -146,9 +148,31 @@ final class CardDav $r['birthday'] = Birthday::solarOf($r); // 与我的关系(名册页上那句"我该怎么称呼他") $r['relation_word'] = Kinship::label($graph, $personId, $pid, $people); - // 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父") + // 🔴 v46(用户 2026-10-13):**同步出去的显示名 = 我叫他什么**,不是真名。 + // 真名改由 N 承载,并连同关系/备注/履历写进 NOTE(见 Vcard::buildNote)。 + // ⚠️ 这里不再往 `nickname` 里塞东西:FN 已经是称呼了, + // 再写一个 NICKNAME 会在手机上多出一行同名的"昵称"(纯噪音)。 $nick = Nickname::display($personId, $r); - $r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : ''; + $r['display_name'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : ''; + $r['nickname'] = ''; + + // 🔴 v46:**我给他写的私人备注**(`person_notes`,只有写的人自己看得到)。 + // 用户点名要的"我们是怎么认识的"就在这里。它按 viewer 维度取 —— + // 同步给谁,就带谁写的那一份(不是别人的)。 + $r['my_note'] = PersonNote::get($pid, $personId); + + // 🔴 v46:工作经历(多段)→ 拼进 NOTE。 + // `PersonJob::list()` 已保证"现在那段"排最前、其余按开始时间倒序; + // Vcard 那边只反转,不重排(避免两处排序口径打架)。 + $r['jobs'] = PersonJob::list($pid); + + // 🔴 v46:头像**原始字节**(vCard 的 PHOTO 要 base64 内嵌)。 + // ⚠️ 必须走 Storage::read:线上 `storage_driver=oss`, + // 实测有一半头像**不在本地** uploads/(早期的在 OSS 上)。 + // 直接 file_get_contents(FB_UPLOADS/...) 会漏掉一半人。 + // ⚠️ 读不到就当没有头像(不抛异常、不写半个 PHOTO)—— + // 一个头像取不到不该让整本通讯录同步失败。 + $r['photo_bin'] = self::photoBytes($familyId, (string)($r['avatar'] ?? '')); // 补充联系方式(多个电话/邮箱/地址/网址) $r['contacts'] = Person::contactsOf($pid); $out[] = $r; @@ -182,6 +206,56 @@ final class CardDav return Vcard::export(self::rows($familyId, $personId), '家庭名册'); } + /** + * 头像的**原始字节**(给 vCard 的 PHOTO 内嵌用)。 + * + * 「这是什么东西」与安全判据由**调用方**保证: + * · key 来自 `persons.avatar`(我们自己的上传器写的),不是用户随手传的路径; + * · 这个人**在不在 viewer 的名册里**由 `rows()` 已经筛过(越权在这里被挡掉)。 + * 所以这里只做两件事:读字节 + 判"是不是图片"。 + * + * ⚠️ 必须走 `Storage::read` 而不是 `file_get_contents`:线上存储后端是 OSS + * (`storage_driver=oss`),实测约一半头像**不在本地 uploads/**。 + * 只读本地会让那些人同步出去"没有头像",而且无声无息。 + * ⚠️ **绝不抛异常**:一个头像取不到,不该让整本通讯录同步失败。 + * 取不到就返回 '',Vcard 那边当"这人没头像"处理。 + */ + private static function photoBytes(int $familyId, string $key): string + { + $key = trim($key); + if ($key === '' || $familyId <= 0) { + return ''; + } + // 外部绝对地址(历史数据):不跟着抓远端(同步路径不该发起外网请求) + if (preg_match('#^https?://#i', $key)) { + return ''; + } + try { + $res = Storage::read($familyId, $key); + } catch (\Throwable $e) { + return ''; + } + if (empty($res['ok']) || (int)($res['status'] ?? 0) !== 200) { + return ''; + } + $bin = (string)($res['body'] ?? ''); + if ($bin === '' || strlen($bin) > 4 * 1024 * 1024) { + return ''; // 空 / 大得离谱(不该发生)→ 当没有头像 + } + // 用 finfo 认真实类型再放行:只会写图片格式,别的(被换成别的文件)一律不发。 + if (function_exists('finfo_open')) { + $fi = finfo_open(FILEINFO_MIME_TYPE); + if ($fi) { + $mime = (string)finfo_buffer($fi, $bin); + finfo_close($fi); + if (!preg_match('#^image/(jpeg|png|gif|webp)$#', $mime)) { + return ''; + } + } + } + return $bin; + } + /** * 一张卡:**正文与 ETag 成对返回**。 * diff --git a/app/ContactImport.php b/app/ContactImport.php index e215f26..e906d01 100644 --- a/app/ContactImport.php +++ b/app/ContactImport.php @@ -132,27 +132,36 @@ final class ContactImport $val = self::unescapeVcard($val); switch ($name) { - case 'FN': // 格式化姓名(首选) + case 'FN': // 格式化姓名(**显示名**;见下面的优先规则) $cur['name'] = $val; break; case 'N': // 结构化姓名:姓;名;中间名;前缀;后缀 → 拼成"名 姓"更符合中文习惯 - if ($cur['name'] === '') { - /* 🔴 v24 第 22 批:与 ADR 同一个坑 —— 分段必须走 `splitVcardSegments()` - (**认识转义**)。用 `explode(';', ...)` 的话, - 姓名里含分号的人(我们导出时把整名塞进"姓"格, - 而名字是「张三,李四;王五」这种)会被从那里切断。 */ - $parts = []; - foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) { - $parts[] = self::unescapeVcard($seg); - } - $fam = trim((string)($parts[0] ?? '')); - $giv = trim((string)($parts[1] ?? '')); - // ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写, - // 不加空格 —— 加了会变成"杨 永全"。 - $joined = $fam . $giv; - if ($joined !== '' && !preg_match('/[\x{4e00}-\x{9fa5}]/u', $joined)) { - $joined = trim($giv . ' ' . $fam); // 英文名习惯"名 姓" - } + /* 🔴 v46(用户 2026-10-13):**真名优先于显示名**。 + 我们的 vCard 有三个名字位: + · `N` —— 真名(`persons.name`) + · `FN` —— 显示名:走 CardDAV 同步时是**我叫他什么** + (如「老爸」),**往返导入/备份导出**时是真名 + · `NICKNAME` —— 小名(只在显示名≠小名时才写) + TB 之类的标准客户端**只写 FN**(不写 N,或 FN 与 N 等价), + 所以"FN 优先、N 兜底"的老规则在那边照样成立; + 而**我们自己的卡**(FN=称呼、N=真名)若按老规则走, + 导回来姓名会变成「老爸」—— 一个人被自己的称呼改掉了名字。 + ⇒ 改成:N 只要**有内容就覆盖** FN。两者的先后在文件里是固定的 + (见 `Vcard::card()`:UID → FN → N → …), + 所以"后出现的 N 覆盖先出现的 FN"是稳定的,不依赖遍历顺序的巧合。 */ + $parts = []; + foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) { + $parts[] = self::unescapeVcard($seg); + } + $fam = trim((string)($parts[0] ?? '')); + $giv = trim((string)($parts[1] ?? '')); + // ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写, + // 不加空格 —— 加了会变成"杨 永全"。 + $joined = $fam . $giv; + if ($joined !== '' && !preg_match('/[\x{4e00}-\x{9fa5}]/u', $joined)) { + $joined = trim($giv . ' ' . $fam); // 英文名习惯"名 姓" + } + if ($joined !== '') { $cur['name'] = $joined; } break; diff --git a/app/Vcard.php b/app/Vcard.php index d502fda..e382521 100644 --- a/app/Vcard.php +++ b/app/Vcard.php @@ -51,10 +51,23 @@ final class Vcard /** 一张卡(一个 BEGIN:VCARD … END:VCARD 块),已折行、已转义 */ public static function card(array $p): string { - $name = trim((string)($p['name'] ?? '')); - if ($name === '') { - $name = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉 + // 真名与显示名是**两个**,别再合成一个变量: + // · $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'; @@ -64,10 +77,23 @@ final class Vcard // 用 `person-@<域>`(不能用"姓名"——同名的人会互相覆盖, // 而且改名之后客户端会当成"删一张加一张")。 $lines[] = 'UID:' . self::esc(self::uid($p)); - $lines[] = 'FN:' . self::esc($name); - $lines[] = 'N:' . self::nField($name); - if (!empty($p['nickname'])) { - $lines[] = 'NICKNAME:' . self::esc((string)$p['nickname']); + // 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; } // ---- 电话 ---------------------------------------------------- @@ -133,6 +159,8 @@ final class Vcard } // ---- 单位 / 职务 -------------------------------------------- + // 🔴 v46:ORG 只写**当前**单位(`persons.org` 是 `PersonJob::syncCurrent()` + // 从最新那段经历回写的当前快照);历史与职称明细都进 NOTE。 if (trim((string)($p['org'] ?? '')) !== '') { $lines[] = 'ORG:' . self::esc((string)$p['org']); } @@ -140,6 +168,23 @@ final class Vcard $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 当成"群组/标签"显示,用户一眼能看出谁是家人。 @@ -149,8 +194,10 @@ final class Vcard if ($rel !== '') { $lines[] = 'CATEGORIES:' . self::esc($rel); } - if (trim((string)($p['note'] ?? '')) !== '') { - $lines[] = 'NOTE:' . self::esc((string)$p['note']); + // NOTE:把"真名 / 关系 / 我的备注 / 他写的自我介绍 / 工作经历"全拼进去。 + $note = self::buildNote($p, $real, $display); + if ($note !== '') { + $lines[] = 'NOTE:' . self::esc($note); } // REV:客户端的"这条改过没有"判据。用 updated_at(拿不到就用现在) @@ -167,6 +214,215 @@ final class Vcard 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 **不能折行**(折行是"行级"的事,由外层 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 {