Files
FamilyBook/app/Vcard.php
T
laoyang 6ceae11293 v24 第 22 批:名册加 CardDAV 同步 + 一把通用 DAV 口令(日历与通讯录共用)
用户 2 条原话:
  ①「名册,增加cardav同步功能。」
  ②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
     避免在第三方客户端进行同步时自己的密码泄露。」

用户拍板的两个岔路(AskUserQuestion):
  · 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
    手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
  · 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。

════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
  · 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
  · 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
  · 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
   只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
   用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。

Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。

口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
  · 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
  · 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
    两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。

════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
  FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
  标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
  X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
  只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
  不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
  完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
  但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
  这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
  ⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
  `# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
  **整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
  都漏过一次,症状都是"线上那个功能 404、本地一切正常")。

════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:

 ① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
    后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
    而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。

 ② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
    值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
    从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
    而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
    → 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
    ⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。

 ③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
    第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
    解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
    所以"数分号个数"的检查发现不了它。

════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。

════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
  (只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
  指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。

════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
  一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
  空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
  口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
  撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
  去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
  折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
  撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。

════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:

 ① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
    但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
    所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
    不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。

 ② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
    `$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
    实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
        基线            : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
        `CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
    ⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
      本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
      又一个"本地好好的、线上不行"。

  修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
  `CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
  仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。

  修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
    · CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
    · CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
      整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
      折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
      `OPTIONS → 200 DAV: 1, 2, 3, addressbook`
    · 导出的卡数 **1089 == 名册人数 1089**(口径一致)
    · 口令错 → `401`
  临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。

════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
2026-10-06 17:33:48 +08:00

393 lines
18 KiB
PHP
Raw 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
{
$name = trim((string)($p['name'] ?? ''));
if ($name === '') {
$name = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉
}
$lines = [];
$lines[] = 'BEGIN:VCARD';
$lines[] = 'VERSION:3.0';
// ---- 姓名 ----------------------------------------------------
// ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。
// 用 `person-<id>@<域>`(不能用"姓名"——同名的人会互相覆盖,
// 而且改名之后客户端会当成"删一张加一张")。
$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']);
}
// ---- 电话 ----------------------------------------------------
// ⚠️ 主手机号在 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;
}
// ---- 单位 / 职务 --------------------------------------------
if (trim((string)($p['org'] ?? '')) !== '') {
$lines[] = 'ORG:' . self::esc((string)$p['org']);
}
if (trim((string)($p['title'] ?? '')) !== '') {
$lines[] = 'TITLE:' . self::esc((string)$p['title']);
}
// ---- 与我的关系 / 备注 --------------------------------------
// ⚠️ 关系写进 CATEGORIES 而不是自己造一个 X- 字段:
// iOS 通讯录会把 CATEGORIES 当成"群组/标签"显示,用户一眼能看出谁是家人。
// 写 X-FAMILY-RELATION 那种私有字段的话,手机上什么都看不到
// (导出了但等于没导出)。
$rel = trim((string)($p['relation_word'] ?? ''));
if ($rel !== '') {
$lines[] = 'CATEGORIES:' . self::esc($rel);
}
if (trim((string)($p['note'] ?? '')) !== '') {
$lines[] = 'NOTE:' . self::esc((string)$p['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);
}
/** 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;
}
}