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`。
This commit is contained in:
+161
-11
@@ -61,31 +61,69 @@ final class CalDav
|
||||
return self::ROOT . $calId . '.ics/';
|
||||
}
|
||||
|
||||
/** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */
|
||||
/**
|
||||
* 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程"。
|
||||
*
|
||||
* 🔴 v31 修的真 bug:`$event` 原来是 `(int)$evSeg` —— 于是**派生条目**的
|
||||
* 资源名(`movie-100_familybook`、`lunar-2026-10-01_familybook`)
|
||||
* `(int)` 之后全是 **0**,被当成"没指定单条资源" → 整本导出。
|
||||
* 实测:`GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 **400 条 VEVENT**。
|
||||
*
|
||||
* 这也正是**为什么不能只认数字 id**:资源名有两种(自定义日程用 id、
|
||||
* 派生条目用净化后的 uid),`(int)` 会把后者静默压成 0。
|
||||
* ⇒ 改为按 `$seg` 是否为空判断;`$event` 仍给**数字**那份(自定义日程用),
|
||||
* 同时把原始段原样带出去(`seg`),让调用方按资源名精确匹配。
|
||||
*
|
||||
* @return array{book:int, event:int, seg:string}
|
||||
*/
|
||||
public static function parsePath(string $path): array
|
||||
{
|
||||
$p = (string)parse_url($path, PHP_URL_PATH);
|
||||
if (strpos($p, self::ROOT) !== 0) {
|
||||
return ['book' => 0, 'event' => 0];
|
||||
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
||||
}
|
||||
$rest = substr($p, strlen(self::ROOT));
|
||||
$rest = trim($rest, '/');
|
||||
if ($rest === '') {
|
||||
return ['book' => 0, 'event' => 0];
|
||||
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
||||
}
|
||||
// 形如 3.ics 或 3.ics/<uid>.ics
|
||||
// 形如 3.ics 或 3.ics/<seg>.ics
|
||||
$parts = explode('/', $rest);
|
||||
$bookSeg = $parts[0];
|
||||
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
||||
$event = 0;
|
||||
$seg = '';
|
||||
if (isset($parts[1]) && $parts[1] !== '') {
|
||||
// 单条日程:<uid>.ics —— uid 里可能含点,所以只去掉结尾的 .ics
|
||||
$evSeg = preg_replace('/\.ics$/', '', $parts[1]);
|
||||
$event = (int)$evSeg; // 我们用的是 calendar_events.id
|
||||
// 单条资源:<seg>.ics —— 段里可能含点/短横,所以只去掉结尾的 .ics
|
||||
$seg = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
||||
}
|
||||
return ['book' => $book, 'event' => $event];
|
||||
return ['book' => $book, 'event' => (int)$seg, 'seg' => $seg];
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端**发现链**用的固定路径(RFC 6764 / 6352)。
|
||||
*
|
||||
* 🔴 v31 修的真 bug:原来这两个入口**完全没实现**,客户端的发现链第一句就断。
|
||||
* 实测(DAVx⁵ / iOS 每次添加账号都这么做):
|
||||
* PROPFIND / → 问 current-user-principal → 服务器根本没有 PROPFIND 处理,
|
||||
* 落到 index.php 的 ?r= 路由 → 返回登录页 HTML(客户端解析失败)
|
||||
* PROPFIND /dav/ 问 current-user-principal → 我们回的是"日历本清单",
|
||||
* 里面**一个 principal 都没有** → 客户端判定"这不是个 DAV 服务器"
|
||||
* 症状正是用户报的「同步不出任何数据」—— 连账号都建不起来,谈何同步。
|
||||
*
|
||||
* ⚠️ principal / home-set 不能"就地"写成 `/dav/` 自己:
|
||||
* 客户端会拿 home-set 当**集合根**再往下 PROPFIND Depth:1。
|
||||
* 直接回 /dav/ 的话,Depth:1 会要求列出 /dav/ 下所有日历本的**资源**,
|
||||
* 语义错位且体积巨大。这里给独立路径,行为与"标准 DAV 服务器"一致。
|
||||
*/
|
||||
public const PRINCIPAL_PATH = '/dav/principal/';
|
||||
|
||||
public const HOME_PATH = '/dav/calendars/';
|
||||
|
||||
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
|
||||
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
|
||||
|
||||
public const CARD_HOME_PATH = '/carddav/addressbooks/';
|
||||
|
||||
// ---------------- 应答 ----------------
|
||||
|
||||
public static function xmlHeader(): void
|
||||
@@ -117,6 +155,113 @@ final class CalDav
|
||||
return Calendar::exportRows($cal, $familyId, $me);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个日历本里,"**资源**"意义上的条目(每个 href 一条)。
|
||||
*
|
||||
* 🔴 v31 修的真 bug:原来直接把 rows() 当成资源列表用,而
|
||||
* `Calendar::exportRows()` 对**派生本**(观影 / 旅程)是按"日历视图"取数的 ——
|
||||
* 重复日程会展开成 N 次发生、跨天日程逐天出一条、纪念日/农历/节假日逐日派生。
|
||||
* 实测线上 `/dav/1.ics/`(观影本):**2981 个 `<d:response>`,只有 196 个不同 href**
|
||||
* (其中一条 `anniv-0_familybook.ics` 自己重复了 2399 次)。
|
||||
* CalDAV 的资源是**一个 .ics 文件**,同名重复出现会让客户端:
|
||||
* · 把同一条日程反复入库(删除/去重逻辑整天在跑);
|
||||
* · 或直接判定"服务器状态异常"而中止本轮同步。
|
||||
*
|
||||
* ⇒ 这里按 **UID** 去重(CalDAV 的资源名就是 UID)。同一个 UID 的多条谱系
|
||||
* (重复日程的每次发生)在**一个资源**里由 RRULE 自己表达,本就该合并。
|
||||
*
|
||||
* ⚠️ 保留**第一次**出现的那条:rows() 按时间升序,第一条就是系列里最早的那次,
|
||||
* 它的 title / 时长信息最完整(跨天条目里也只有第一天带 time)。
|
||||
*/
|
||||
public static function resources(array $cal, int $familyId, int $me): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach (self::rows($cal, $familyId, $me) as $r) {
|
||||
$uid = (string)($r['uid'] ?? '');
|
||||
if ($uid === '') {
|
||||
$out[] = $r; // 没有 UID 的(理论上不该有)不参与去重,照样列出
|
||||
continue;
|
||||
}
|
||||
if (isset($seen[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$uid] = true;
|
||||
$out[] = $r;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源名(href 的最后一段,不含 .ics)。
|
||||
*
|
||||
* - 自定义日程:用 calendar_events.id,稳定且与 oneEvent() 的入参一一对应;
|
||||
* - 派生条目:id 为 0,用 uid 的净化形式(同书注释:"派生本没有 calendar_events.id")。
|
||||
*/
|
||||
public static function segmentOf(array $r): string
|
||||
{
|
||||
$rid = (int)($r['id'] ?? 0);
|
||||
if ($rid > 0) {
|
||||
return (string)$rid;
|
||||
}
|
||||
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
|
||||
return $uid === null ? '' : $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个**资源**的 ICS 正文与 ETag —— 与 CardDav::cardOf 同一个套路:
|
||||
* 正文与 ETag **成对**算出来,不给"两处各算一次"留机会。
|
||||
*
|
||||
* 🔴 v31:ETag 必须由**内容**算出。原先 REPORT 里用 md5($evIcs),
|
||||
* 而 $evIcs 里含每次都在变的 DTSTAMP(见 Ics::stampOf)⇒ ETag 潮汐。
|
||||
* 现在 DTSTAMP 已确定,但**判据仍钉在"内容哈希"上** —— 这样以后
|
||||
* ICS 生成器再加什么时间性字段,也不会又变成"每轮都变"。
|
||||
*
|
||||
* @return array{ics:string, etag:string}
|
||||
*/
|
||||
public static function resourceOf(array $r, string $calName): array
|
||||
{
|
||||
$ics = Ics::export([$r], $calName);
|
||||
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
|
||||
*
|
||||
* ⚠️ 认两种资源名(与 segmentOf() 一一对应):
|
||||
* · 纯数字 = calendar_events.id(自定义本)
|
||||
* · 其它 = uid 净化形式(派生本:movie-100_familybook、lunar-2026-10-01_familybook)
|
||||
* v31 之前只认数字,于是派生本的单条资源请求会被当成"整本"处理
|
||||
* (实测 `GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 400 条 VEVENT)。
|
||||
*
|
||||
* ⚠️ 数字那一支**直接查库**(`oneEvent`),不走 resources():
|
||||
* 理论上"合法资源名"必然出现在 resources() 里(href 就是从它生成的),
|
||||
* 但"必须能取到"和"必须扫全表才能取到"是两件事 —— 一本几千条时
|
||||
* 每取一条都全量派生一遍(含 130 年农历换算)代价太大。
|
||||
* 派生本没有 id,只能从列表里找;量小(几十到几百条),可接受。
|
||||
*/
|
||||
public static function oneResource(array $cal, int $familyId, int $me, string $seg): ?array
|
||||
{
|
||||
$seg = trim($seg);
|
||||
if ($seg === '') {
|
||||
return null;
|
||||
}
|
||||
// 数字资源名 = calendar_events.id(自定义本)
|
||||
if (ctype_digit($seg)) {
|
||||
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
||||
if ($one) {
|
||||
return $one;
|
||||
}
|
||||
}
|
||||
// 派生资源名:从派生列表里按净化后的 uid 找
|
||||
foreach (self::resources($cal, $familyId, $me) as $r) {
|
||||
if (self::segmentOf($r) === $seg) {
|
||||
return $r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
||||
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
||||
{
|
||||
@@ -139,9 +284,14 @@ final class CalDav
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */
|
||||
/**
|
||||
* 一整本导出成 ics 文本(GET 与 REPORT 都用它)。
|
||||
*
|
||||
* ⚠️ 走 resources()(按 UID 去重)而不是 rows():日历视图里同一条日程会被
|
||||
* 重复日程/跨天/派生规则展开成很多条,整本导出时那些都是**同一个资源**。
|
||||
*/
|
||||
public static function ics(array $cal, int $familyId, int $me): string
|
||||
{
|
||||
return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']);
|
||||
return Ics::export(self::resources($cal, $familyId, $me), (string)$cal['name']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user