Files
FamilyBook/app/CardDav.php
T
laoyang be582569dd v31 第 31 批:DAV(CalDAV/CardDAV)同步修复 —— 发现链缺失 + ETag 潮汐 + 资源重复
用户原话:「carddav 和 caldav 好像都不好用,没有任何数据被同步出来。
同步的客户端,在 D:/Yongquan/code/SyncCalendar 中有,你可以用这个里面的代码试试同步一下。」

三处真根因**全部靠实测**拿到(不是"看代码猜"):写了 _fb_dav_diag.py,
逐字复刻 SyncCalendar 的 DavClient 请求序列(PROPFIND/REPORT/GET + 它那几个 XML 体),
打线上一次就复现出用户症状(15 条失败),修完线上 28/0。

1. 发现链(discovery)**整段缺失** ← 这就是"没有任何数据"的根因
   标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号按 RFC 6764 走:
     GET /.well-known/caldav → 302 到 /dav/        ← 原来 404(落到 index.php 去登录页)
     PROPFIND <上下文路径> → current-user-principal ← 原来回的是"日历本清单",没有 principal
     PROPFIND <principal> → calendar-home-set       ← 原来没这个属性
     PROPFIND <home> Depth:1 / <book> / REPORT      ← 这两步一直是通的
   ① 就断 ⇒ 客户端**在填完账号点保存那一刻**就失败,一条数据都拉不到。
   为什么历次验收全绿:上批只验了 PROPFIND /dav/ 与 /carddav/ —— 恰好是**一直通**的那两步,
   断点在其**前面**,从来没被测过。
   改:两个入口补 PRINCIPAL_PATH/HOME_PATH + /.well-known/* 301(**在鉴权之前**)+
   根/principal/列表三处都给 current-user-principal。.htaccess 用**独立标记块**追加
   well-known 重写规则(本仓库第 4 次"独立入口必须独立块")。

2. DTSTAMP 取 time() → ETag 潮汐(增量同步**永远不收敛**)
   App\Ics::stampOf() 原为"没给时间就 time()",而**派生条目**(观影/行程/生日/农历/
   节假日/节气)没有 updated_at/created_at ⇒ 每生成一次 ICS 就写一个新"现在" ⇒ 正文变
   ⇒ ETag 变。线上实测:同一本连续两次 REPORT **每一条 ETag 都不同**,整本 GET 的 ETag
   也在变 ⇒ 客户端把"增量同步"退化成"每轮全量重拉",且拉回来的对不上、永远不收敛。
   改:缺时间写固定值 19700101T000000Z(确定优先于"诚实的当前时间")。

3. 派生本把"日历视图条目"当"CalDAV 资源"
   Calendar::exportRows() 对派生本按**日历视图**取数(重复日程展开 N 次、跨天逐日、
   生日/农历逐日派生)。线上 /dav/1.ics/ 实测 **2981 个 response / 只有 196 个不同 href**
   (anniv-0_familybook.ics 自己重复 **2399** 次)。CalDAV 的资源是**一个 .ics 文件** ⇒
   客户端要么把同一条反复入库,要么判定服务器异常而中止本轮同步。
   改:CalDav::resources() 按 **UID 去重**(资源名本就是 UID)。

4. 连带两个真 bug(同一段代码顺出来的)
   · exportRows() 派生分支**没按本过滤**:month() 里"任务""纪念日"两段不带 kind 判断
     (它们 cal=0)⇒ 取"观影本"会把全家所有生日一起带出来(2981 条里绝大多数是 anniv)。
   · CalDav::parsePath() 的 $event=(int)$evSeg:派生资源名(movie-100_familybook)
     (int) 后全是 0 ⇒ 单条资源请求被当"整本",GET /dav/4.ics/lunar-...ics 实测回 **400 条**。

5. 顺带补全:系统日历本(农历/节假日/传统节日/节气)原来**导出是空的**
   它们只走派生、没有 calendar_events 行,而 exportRows 只认 custom/movie/trip ⇒
   手机上订阅这几个本一条都没有。改:按 [今年-1, 今年+5] 派生(**封顶**,农历每天一条,
   130 年会有 4.7 万条)。实测耗时 lunarRange 31ms / traditional 51ms / Anniversary 30ms。

改:.htaccess、router.php、dav.php、carddav.php、app/CalDav.php、app/CardDav.php、
app/Calendar.php、app/Ics.php、templates/settings/me.php(补"或只填服务器地址"的提示)。

验证(全部 0 失败):DAV 探针 28/0(**线上修前 15 失败 → 修后 28/0**,同一套探针两处都跑)、
DAV 反向验证 8/0(7 处修复各自改回旧行为,断言全变红)、e2e 1478/0、升级 153/0、
城市 32/0、几何 428/0 + 移动 680/0 + 交互 92/0、b24 41/0、b27 34/0、b28 39/0、b29 38/0、
b30_import 26/0、b30_ui2 16/0、b30_notify 23/0;部署静态断言新增 12 条守卫并逐条证伪通过。
线上 7 个文件 SHA256 与本地一致;/.well-known/caldav→301 到 /dav/ 实测生效。

⚠️ 本轮在部署脚本上真踩到并修掉两个坑(已写进 pitfalls.md):
   · router.php **不发到线上**,把它写进部署断言 → grep "No such file" → 部署中止在
     "改了一半"的状态(迁到 push.sh 第 1 段)。
   · `set -euo pipefail` 下 `X=$(... | grep -c ...)` 在 **0 匹配**时退出码 1 ⇒
     **整个脚本静默退出**(不打 FAIL,日志停在上一行)。已给全部 7 处补 `|| true`。
2026-10-08 20:05:01 +08:00

225 lines
10 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
/**
* CardDAV 服务端(v24 第 22 批)
*
* 用户原话:「名册,增加cardav同步功能。」
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里。
*
* 与 CalDav 是**并列的两个入口**,刻意不合并成一个 /dav/ 前缀:
* · /dav/ → 日历(本类旁边的 App\CalDav)
* · /carddav/ → 通讯录(本类)
* ⚠️ 为什么不共用 /dav/ 一棵树:两边的"资源类型"(calendar vs addressbook)、
* 命名空间(caldav vs carddav)与属性名完全不同,混在一棵树里要靠
* PROPFIND 的请求体去猜客户端想要哪种 —— 猜错的表现是"客户端连上了但一本都看不到"。
* 分开之后各自只有一种语义,iOS/DAVx⁵ 都是探测到哪个地址就用哪套。
* ⚠️ 但**鉴权是同一套**(Auth::davAuth + dav_credentials 表):一把口令管两种同步,
* 这正是用户要的"用用户名和口令来同步",也省得在两个页面各配一遍。
*
* 客户端的请求顺序(必须按这个最小集实现):
* OPTIONS /carddav/ → DAV: 1, addressbook
* PROPFIND /carddav/ (Depth: 0) → 当前用户的通讯录清单(通常只有一本)
* PROPFIND /carddav/1.vcf/ (Depth: 1) → 这本里所有卡片的资源清单(getetag)
* REPORT /carddav/1.vcf/ → addressbook-query / multiget,取正文
* GET /carddav/1.vcf/ → 整本导出成一个 vcf(很多客户端走这条)
*
* ⚠️ 只读:写方法(PUT/DELETE/PROPPATCH)一律 403。
* 与 CalDav 同样的取舍 —— 与其"半支持"(客户端以为改成功了、服务端却没落库),
* 不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
*/
declare(strict_types=1);
namespace App;
final class CardDav
{
/** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */
public const ROOT = '/carddav/';
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
public const BOOK_ID = 1;
/**
* 客户端**发现链**用的固定路径(RFC 6352 §6 / RFC 6764)。
*
* 🔴 v31 修的真 bug:与 CalDAV 同一个病 —— 这两个入口原来**完全没实现**。
* 实测线上:`PROPFIND /carddav/` 问 current-user-principal 回的是"通讯录清单"
* (一个 principal 都没有)→ 客户端判定"这不是个 CardDAV 服务器" →
* 建账号那一步就失败,一张卡片都同步不出来。
*
* ⚠️ 与 CalDav 刻意**同名同义**(PRINCIPAL_PATH / HOME_PATH 语义一一对应),
* 两边漂移的话,手机上"日历能连、通讯录不能连"这类半通状态会很难查。
*/
public const PRINCIPAL_PATH = '/carddav/principal/';
public const HOME_PATH = '/carddav/addressbooks/';
/**
* 当前用户可见的"通讯录"。
*
* ⚠️ 家庭场景只有**一本**:你的名册。所以这里不查库、直接回一本固定的,
* 是为了让 URL(/carddav/1.vcf/)稳定 —— 客户端配好之后,
* 哪怕名册里的人数变了,地址也不会变(变了就要重新配)。
* ⚠️ 口径必须与名册页**完全一致**(用户 2026-10-06 第 22 批确认):
* 名册页看到谁就同步谁。所以名册页怎么取的(Person::roster + kind='member'),
* 这里就怎么取(见 rows()),不另发明一套可见性判据 ——
* 否则手机上少几个人、网页上有,用户会以为"同步漏了"。
*/
public static function books(int $familyId, int $personId): array
{
return [[
'id' => self::BOOK_ID,
'name' => '家庭名册',
'description' => '家人与联系人(与网页上的名册一致)',
'count' => count(self::rows($familyId, $personId)),
]];
}
public static function bookHref(int $bookId): string
{
return self::ROOT . $bookId . '.vcf/';
}
public static function hrefOf(array $book): string
{
return self::bookHref((int)$book['id']);
}
/** 从 URL 段解出「这是哪一本 / 要哪一张卡」 */
public static function parsePath(string $path): array
{
$p = (string)parse_url($path, PHP_URL_PATH);
if (strpos($p, self::ROOT) !== 0) {
return ['book' => 0, 'card' => 0];
}
$rest = trim(substr($p, strlen(self::ROOT)), '/');
if ($rest === '') {
return ['book' => 0, 'card' => 0];
}
$parts = explode('/', $rest);
$bookSeg = (string)$parts[0];
$book = (int)preg_replace('/\.vcf$/', '', $bookSeg);
$card = 0;
if (isset($parts[1]) && $parts[1] !== '') {
// 单张卡:<id>.vcf —— 我们用的是 persons.id
$card = (int)preg_replace('/\.vcf$/', '', (string)$parts[1]);
}
return ['book' => $book, 'card' => $card];
}
/**
* 这本通讯录里的所有"卡"(一个人一张),并且**已经把字段备齐**——
* 电话解密、关系称呼、我叫他什么都在这里算好,Vcard 那层只管写文本。
*
* ⚠️ 解密(persons.phone_encrypted 是 AES 密文)为什么放在这一层:
* Vcard 是纯文本生成器,不该碰密钥;而且"取数据"这一步与网页名册
* 走同一个 PersonQuery::phonePlain(**同一套判据**),
* 不会出现"页面上显示得出手机号、同步出去却是空的"。
*/
public static function rows(int $familyId, int $personId): array
{
if ($familyId <= 0 || $personId <= 0) {
return [];
}
$rows = Person::roster($familyId, $personId, 'member', '');
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
$out = [];
foreach ($rows as $r) {
$pid = (int)$r['id'];
$r['phone'] = PersonQuery::phonePlain($r);
// 生日:走 Birthday::solarOf 取**真实阳历**(本项目里 birthday 列就是这个语义,
// 生日表单字段叫 birthday_solar,两者是两个键名、同一个东西 —— 见该函数注释)。
// ⚠️ 不写农历:vCard 没有农历字段,硬塞进 NOTE 会让手机上出现一句
// 看不懂的"农历三月廿一",而过年过生日那件事日历那边已经管了。
// 也只有"有年份"的阳历生日才写(Vcard 那边会把 "YYYY-MM-DD 00:00:00"
// 截成日期段)。
$r['birthday'] = Birthday::solarOf($r);
// 与我的关系(名册页上那句"我该怎么称呼他")
$r['relation_word'] = Kinship::label($graph, $personId, $pid, $people);
// 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父")
$nick = Nickname::display($personId, $r);
$r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
// 补充联系方式(多个电话/邮箱/地址/网址)
$r['contacts'] = Person::contactsOf($pid);
$out[] = $r;
}
return $out;
}
/**
* 单张卡(按 persons.id)。
*
* ⚠️ 必须**先判断这张卡在不在这个人的名册里**,而不是直接按 id 查 persons:
* 直接查的话,任何人拿到一个 id 就能把全家的信息拉走
* (CardDAV 走的是 Basic 口令、无会话,越权在这里尤其危险)。
*/
public static function one(int $familyId, int $personId, int $cardId): ?array
{
if ($cardId <= 0) {
return null;
}
foreach (self::rows($familyId, $personId) as $r) {
if ((int)$r['id'] === $cardId) {
return $r;
}
}
return null;
}
/** 整本导出成 vcf 文本(GET 与 REPORT 都用它) */
public static function vcf(int $familyId, int $personId): string
{
return Vcard::export(self::rows($familyId, $personId), '家庭名册');
}
/**
* 一张卡:**正文与 ETag 成对返回**。
*
* 🔴 v24 第 22 批修的真 bug:这两个值原本在 carddav.php 里各算一次,
* 于是 PROPFIND 用 `etag(Vcard::card($r))`、REPORT 用
* `etag(Vcard::card($r) . "\r\n")` —— **同一张卡算出两个不同的 ETag**。
* 客户端的典型行为是:先 PROPFIND 拿清单(记下 ETag A),
* 再 REPORT 取正文(拿到 ETag B),发现"对不上" → 认为这张卡一直在变,
* 于是**反复重拉**(同步永远不收敛),或者干脆把本地那份标成脏的。
* 症状是"手机通讯录一直在转圈同步",而服务端日志里一切正常。
*
* ⚠️ 修法不是"两处都改成一样",而是**让它们没有机会不一样**:
* 正文里的结尾 CRLF(REPORT 的 address-data 需要一个完整的 vCard 文本)
* 由调用方按需加,ETag **只由这一处**从**不带结尾 CRLF** 的卡算出来。
* 以后再加第三条路径时,它也只能走这里。
*
* @return array{body:string, etag:string}
*/
public static function cardOf(array $row): array
{
$body = Vcard::card($row);
return ['body' => $body, 'etag' => self::etag($body)];
}
/**
* ETag:客户端靠它判断"这张卡改过没有"。
*
* ⚠️ 用**内容**哈希而不是 updated_at 的最大值:名册的可见性还受"关系/分享"影响
* (同一个人没改,但别人把他分享给了我 → 我这本的内容变了,而 updated_at 没变)。
* 内容哈希对这种"看不见的改动"也敏感,是这里唯一稳的判据。
*/
public static function etag(string $vcf): string
{
return '"' . md5($vcf) . '"';
}
// ---------------- 应答辅助(与 CalDav 同名同义,刻意保持一致) ----------------
public static function multis(int $code = 207): void
{
http_response_code($code);
header('Content-Type: application/xml; charset=utf-8');
}
public static function x(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
}