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

427 lines
20 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': // 结构化姓名:姓;名;中间名;前缀;后缀 → 拼成"名 姓"更符合中文习惯
/* 🔴 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;
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;
}
}