Files
FamilyBook/app/ContactImport.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

418 lines
19 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
/**
* 联系人导入(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「名册模块,需要增加一个导入功能,可以导入常见的联系人格式,例如 vcf 等。
* 导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
* 如果这些信息在系统中已经存在,则只导入其他信息。」
*
* 读法:**按姓名或手机号判重** ——
* · 命中已有的人 → 这条记录不进新人,只把"他缺的信息"补上(**绝不覆盖已有值**);
* · 没命中 → 新建一条。
*
* 支持格式(都能从手机/邮箱/通讯录导出):
* · **vCard 3.0 / 4.0**(.vcf)—— from iPhone / Android / Google Contacts / Outlook
* · **CSV**(.csv / .txt)—— 表头含 姓名/手机/电话/邮箱 之类即可(中英文表头都认)
*
* ⚠️ 只做"补充缺失字段",不做"覆盖":
* 导入是**批量**操作,一旦覆盖,用户就没法把它退回去(哪个字段被改过都看不出来)。
* 而"补上没填的"是安全的 —— 最坏情况也只是多了一条信息,不会丢掉原有内容。
*/
declare(strict_types=1);
namespace App;
final class ContactImport
{
/** vCard / CSV 认得的字段 → 我们的字段 */
private const HEADER_MAP = [
// 姓名
'name' => 'name', '姓名' => 'name', '名字' => 'name', 'fullname' => 'name',
'displayname' => 'name', 'fn' => 'name',
// 电话
'phone' => 'phone', 'tel' => 'phone', 'mobile' => 'phone', 'cell' => 'phone',
'telephone' => 'phone', '手机' => 'phone', '手机号' => 'phone', '电话' => 'phone',
'电话号码' => 'phone', '手机号码' => 'phone',
// 邮箱
'email' => 'email', 'mail' => 'email', '邮箱' => 'email', '电子邮件' => 'email',
'e-mail' => 'email',
// 单位/职务/职称
'org' => 'org', 'organization' => 'org', 'company' => 'org', '单位' => 'org',
'工作单位' => 'org', '公司' => 'org',
'title' => 'title', '职务' => 'title', '职称' => 'title', 'jobtitle' => 'title',
// 地址
'address' => 'address', 'adr' => 'address', '地址' => 'address', '住址' => 'address',
// 备注/生日(生日只认 YYYY-MM-DD,认不出就不导 —— 猜生日比不导更糟)
'note' => 'note', '备注' => 'note', 'memo' => 'note',
'birthday' => 'birthday', 'bday' => 'birthday', '生日' => 'birthday',
// 其他
'nickname' => 'nickname', '昵称' => 'nickname', '小名' => 'nickname',
'url' => 'url', 'website' => 'url', '网站' => 'url', '主页' => 'url',
];
/**
* 解析一份上传文件 → 记录数组。
*
* @return array<int,array> 每条:name/phone/phones[]/email/emails[]/org/title/address/note/birthday/url
*/
public static function parse(string $raw, string $filename = ''): array
{
$raw = self::toUtf8($raw);
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// 判据用**内容**而不是扩展名:手机导出的 .vcf 有时被改名成 .txt,
// 而这种文件里的 BEGIN:VCARD 是最硬的信号。
if (stripos($raw, 'BEGIN:VCARD') !== false || $ext === 'vcf') {
return self::parseVcard($raw);
}
if ($ext === 'csv' || strpos($raw, ',') !== false || strpos($raw, "\t") !== false) {
return self::parseCsv($raw);
}
return [];
}
/**
* vCard 3.0 / 4.0。
*
* 三条要注意的:
* · **折行**:iCalendar/vCard 规范要求长行按"字节数"折行、续行以空格/制表开头
* —— 姓名与地址经常被折开,不接回去就会得到半个名字。
* · **转义**:文本值里的 `\,` `\;` `\n` 要还原(否则名字里会留下反斜杠)。
* · **多值**:TEL / EMAIL / ADR 都可能出现多次(手机 + 座机 + 工作邮箱),
* 全部收下来(第 1 个当主值)。
*/
public static function parseVcard(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
// ① 接回折行:以空格或制表开头的行是上一行的续行
$lines = [];
foreach (explode("\n", $raw) as $ln) {
if ($ln !== '' && ($ln[0] === ' ' || $ln[0] === "\t")) {
if ($lines) {
$lines[count($lines) - 1] .= substr($ln, 1);
}
continue;
}
$lines[] = $ln;
}
$out = [];
$cur = null;
foreach ($lines as $ln) {
$ln = trim($ln);
if ($ln === '') {
continue;
}
$up = strtoupper($ln);
if ($up === 'BEGIN:VCARD') {
$cur = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
continue;
}
if ($up === 'END:VCARD') {
if ($cur !== null) {
$out[] = $cur;
}
$cur = null;
continue;
}
if ($cur === null) {
continue;
}
$pos = strpos($ln, ':');
if ($pos === false) {
continue;
}
$head = substr($ln, 0, $pos);
$val = substr($ln, $pos + 1);
// 属性名可能带参数:TEL;TYPE=CELL / EMAIL;TYPE=WORK / N;CHARSET=UTF-8
$name = strtoupper(trim(explode(';', $head)[0]));
$val = self::unescapeVcard($val);
switch ($name) {
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); // 英文名习惯"名 姓"
}
$cur['name'] = $joined;
}
break;
case 'TEL':
if ($val !== '') {
$cur['phones'][] = $val;
if ($cur['phone'] === '') {
$cur['phone'] = $val;
}
}
break;
case 'EMAIL':
if ($val !== '') {
$cur['emails'][] = $val;
if ($cur['email'] === '') {
$cur['email'] = $val;
}
}
break;
case 'ORG':
if ($cur['org'] === '') {
// ORG 也可能是 "公司;部门" 结构
$cur['org'] = trim(str_replace(';', ' ', $val));
}
break;
case 'TITLE':
$cur['title'] = $val;
break;
case 'ROLE':
// ROLE 常放职务、TITLE 放职称(各家不同)—— 两个都收,空位才填
if ($cur['title'] === '') {
$cur['title'] = $val;
} else {
$cur['jobrank'] = $val;
}
break;
case 'ADR':
if ($cur['address'] === '' && $cur['addr_province'] === '') {
/* vCard 的 ADR **本身就是分段的**(RFC 6350):
PO盒;扩展;街道;城市;省/州;邮编;国家
所以这里不再拼成一整串 —— 直接拆成国家/省/市/街道四段存下来,
再由 ContactImporter 拿它们去对标准行政区划名。
⚠️ 区县(第 8 段)几乎没人填,剩下那段街道再交给 Region::parse()
去认区 —— 它本来就是干这个的。
🔴 v24 第 22 批修的真 bug:分段必须走 `splitVcardSegments()`
(**认识转义**),不能用 `explode(';', ...)`。
详见那个函数的注释:PHP 的 explode 不认识转义,
值里写了 `\;` 照样在那里拆开,地址就会被从中间切断
(后半截含省名跑到别的段上)。
⚠️ 也不要"先 unescape 再分段":那是同一个错的另一种写法
(本轮第一版就这么改,往返测试照样红)。
这是靠"导出→导入往返一致"那条断言才抓出来的 ——
单看解析器自己、或用不含分号的样本测,永远是绿的。 */
$segments = [];
foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) {
$segments[] = self::unescapeVcard($seg);
}
$street = trim((string)($segments[2] ?? ''));
$city = trim((string)($segments[3] ?? ''));
$prov = trim((string)($segments[4] ?? ''));
$ctry = trim((string)($segments[6] ?? ''));
$cur['addr_country'] = $ctry;
$cur['addr_province'] = $prov;
$cur['addr_city'] = $city;
$cur['address'] = $street;
}
break;
case 'NOTE':
if ($cur['note'] === '') {
$cur['note'] = $val;
}
break;
case 'BDAY':
$cur['birthday'] = self::normalizeBirthday($val);
break;
case 'NICKNAME':
$cur['nickname'] = $val;
break;
case 'URL':
if ($cur['url'] === '') {
$cur['url'] = $val;
}
break;
}
}
// 丢掉落不到"能建一条人"的(既没名字也没电话)—— 空卡片没有意义
return array_values(array_filter($out, static fn($r) => trim((string)$r['name']) !== ''
|| trim((string)$r['phone']) !== ''));
}
/**
* CSV / TSV。表头中英文都认(见 HEADER_MAP)。
*
* ⚠️ 分隔符靠**第一行的逗号与制表符数量**投票决定,不写死逗号:
* 从 Excel 另存为"文本文件"得到的是制表符分隔,写死逗号会把整行当成一列。
*/
public static function parseCsv(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
$raw = self::stripBom($raw);
$lines = array_values(array_filter(explode("\n", $raw), static fn($l) => trim($l) !== ''));
if (count($lines) < 2) {
return []; // 只有表头(或全空)→ 没有可导入的行
}
$head = $lines[0];
$delim = (substr_count($head, "\t") > substr_count($head, ',')) ? "\t" : ',';
$cols = array_map(static fn($s) => self::keyOf($s), str_getcsv($head, $delim, '"', '\\'));
$out = [];
for ($i = 1; $i < count($lines); $i++) {
$cells = str_getcsv($lines[$i], $delim, '"', '\\');
$rec = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
$filled = false;
foreach ($cols as $idx => $key) {
if ($key === '') {
continue;
}
$v = trim((string)($cells[$idx] ?? ''));
if ($v === '') {
continue;
}
// 一个表头可能对应"多个电话"("手机;电话"这种合并列)
foreach (preg_split('/[;、|]/u', $v) as $piece) {
$piece = trim($piece);
if ($piece === '') {
continue;
}
if ($key === 'phone') {
$rec['phones'][] = $piece;
if ($rec['phone'] === '') {
$rec['phone'] = $piece;
}
} elseif ($key === 'email') {
$rec['emails'][] = $piece;
if ($rec['email'] === '') {
$rec['email'] = $piece;
}
} elseif ($key === 'birthday') {
$rec['birthday'] = self::normalizeBirthday($piece);
} elseif ($key === 'name' && $rec['name'] === '') {
$rec['name'] = $piece;
} elseif (isset($rec[$key]) && $rec[$key] === '') {
$rec[$key] = $piece;
}
}
$filled = true;
}
if ($filled && (trim((string)$rec['name']) !== '' || trim((string)$rec['phone']) !== '')) {
$out[] = $rec;
}
}
return $out;
}
/** 表头归一化:小写、去空格/下划线/全角括号注释 */
private static function keyOf(string $h): string
{
$h = self::stripBom(trim($h));
$h = mb_strtolower($h, 'UTF-8');
$h = preg_replace('/[\s_\-\/]+/u', '', $h) ?? $h;
// "手机号(必填)" 这类 → 只留"手机号"
$h = preg_replace('/[((].*?[))]/u', '', $h) ?? $h;
return self::HEADER_MAP[$h] ?? '';
}
/**
* 生日归一化:只认能**确定**的格式(YYYY-MM-DD / YYYYMMDD / YYYY/MM/DD)。
* ⚠️ 认不出就返回 ''(不导入)—— 生日猜错比不导更糟("1990-03-07" 猜成 "03-07" 会丢年份)。
*/
public static function normalizeBirthday(string $v): string
{
$v = trim($v);
if ($v === '') {
return '';
}
// vCard 的 BDAY 可能带 --MM-DD(只知道月日)或 X.500 时间戳;只认四位年
if (preg_match('/(\d{4})[-\/.]?(\d{1,2})[-\/.]?(\d{1,2})/', $v, $m)) {
$y = (int)$m[1];
$mo = (int)$m[2];
$d = (int)$m[3];
if ($y >= 1900 && $y <= (int)date('Y') && checkdate($mo, $d, $y)) {
return sprintf('%04d-%02d-%02d', $y, $mo, $d);
}
}
return '';
}
/**
* 按**未被转义的**分号把值切成段(vCard 的 N / ADR 用分号分段)。
*
* 🔴 v24 第 22 批修的真 bug:原来直接用 `explode(';', $val)`,而 PHP 的 explode
* **不认识转义** —— 值里写了 `\;`(字面分号)照样在它上面拆开。
* 后果:地址「青岛市,市南区;某某路1号」被从中间切断,后半截(含省名)
* 跑到别的段上;导进来的人地址只剩前半句,而且看着像"省没填"。
* 症状极隐蔽 —— 手机通讯录里带分号的地址("XX路1号;3单元")不算罕见,
* 而这类解析错误只在**恰好含分号的字段**上出现,量少了会被当成"个别数据问题"。
*
* ⚠️ 与「先 unescape 再分段」是两个不同的错,别只修一个:
* 先 unescape → `\;` 变成真 `;`,再 explode 必然在那里断开(同一个后果)。
* 正确做法是**这一步就不拆转义过的分号**,拆完再逐段 unescape。
* (本轮第一版就是这么改的,往返测试照样红 —— 所以才有了这个函数。)
* ⚠️ `\\` 要当成一个整体跳过两个字符,否则 `\\;`(转义反斜杠 + 真分段符)
* 会被误当成"转义过的分号"而漏掉那个真正的分段点。
*/
private static function splitVcardSegments(string $v): array
{
$out = [];
$cur = '';
$len = strlen($v);
for ($i = 0; $i < $len; $i++) {
$ch = $v[$i];
if ($ch === '\\' && $i + 1 < $len) { // 转义序列整体跳过(`\\` 与 `\;` 都算)
$cur .= $ch . $v[$i + 1];
$i++;
continue;
}
if ($ch === ';') { // 只有**未转义**的分号才是分段符
$out[] = $cur;
$cur = '';
continue;
}
$cur .= $ch;
}
$out[] = $cur;
return $out;
}
/** vCard 文本转义还原:\, \; \n \\ */
private static function unescapeVcard(string $v): string
{
$v = str_replace(['\\n', '\\N'], "\n", $v);
$v = str_replace(['\\,', '\\;', '\\\\'], [',', ';', '\\'], $v);
return trim($v);
}
/** 统一成 UTF-8(手机导出的 vcf 可能是 UTF-16 或 GBK) */
public static function toUtf8(string $raw): string
{
if ($raw === '') {
return '';
}
// UTF-16 BOM
if (strncmp($raw, "\xFF\xFE", 2) === 0) {
$raw = (string)mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16LE');
} elseif (strncmp($raw, "\xFE\xFF", 2) === 0) {
$raw = (string)mb_convert_encoding(substr($raw, 2), 'UTF-8', 'UTF-16BE');
} elseif (strncmp($raw, "\xEF\xBB\xBF", 3) === 0) {
$raw = substr($raw, 3);
} elseif (!mb_check_encoding($raw, 'UTF-8')) {
// 不是合法 UTF-8 → 多半是 GBK(国内手机导出的常见情况)
$raw = (string)mb_convert_encoding($raw, 'UTF-8', 'GBK');
}
return $raw;
}
private static function stripBom(string $s): string
{
return strncmp($s, "\xEF\xBB\xBF", 3) === 0 ? substr($s, 3) : $s;
}
}