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:
@@ -568,7 +568,18 @@ final class Calendar
|
||||
$to = '2100-01-01';
|
||||
$out = [];
|
||||
$seen = [];
|
||||
$want = (int)$cal['id'];
|
||||
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
||||
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
|
||||
// **不带 kind 判断**的(它们本就不属于任何日历本,`cal` 给的是 0)。
|
||||
// 于是"取观影本"也会把全家 130 年的生日 + 所有任务一起带出来 ——
|
||||
// 实测线上 /dav/1.ics/ 的 2981 条里,绝大多数是 anniv(生日)。
|
||||
// ⇒ 导出必须只认**真正属于这一本**的条目。
|
||||
// ⚠️ 判据是 `cal` 相等,不是 `kind` 白名单:白名单每加一个
|
||||
// 系统日历本就要改一次,漏改就是"这本里混进别人"。
|
||||
if ((int)($e['cal'] ?? 0) !== $want) {
|
||||
continue;
|
||||
}
|
||||
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
|
||||
if (isset($seen[$key])) {
|
||||
continue; // 跨天日程会逐天出一条,导出时一条就够
|
||||
@@ -598,6 +609,51 @@ final class Calendar
|
||||
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
|
||||
);
|
||||
$st->execute([(int)$cal['id']]);
|
||||
|
||||
// 🔴 v31:系统日历本(农历 / 节假日 / 传统节日 / 节气)原来是**空的** ——
|
||||
// exportRows 只认 custom/movie/trip,于是手机上把这几个本订阅进来
|
||||
// 里面一条都没有,用户会以为"同步坏了"。
|
||||
// 它们的内容是纯日期推导(Holiday::*),没有 calendar_events 行,只能派生。
|
||||
//
|
||||
// ⚠️ 范围必须**封顶**,不能像派生本那样 1970→2100:
|
||||
// · 农历是**每天一条** —— 130 年 ≈ 47500 条,单个 multistatus 会有十几 MB;
|
||||
// · 客户端订阅的也是"现在前后若干年"。
|
||||
// 给 [今年-1, 今年+5]:往前一年覆盖"今年年初要看的去年腊月",往后五年
|
||||
// 够手机翻日历用,总量(农历 ≈ 2200 条)与真实使用相符。
|
||||
if ($kind !== 'custom') {
|
||||
$y0 = (int)date('Y') - 1;
|
||||
$y1 = (int)date('Y') + 5;
|
||||
$want = (int)$cal['id'];
|
||||
$out = [];
|
||||
foreach (self::month($familyId, [$cal], $me, $y0 . '-01-01', $y1 . '-12-31') as $e) {
|
||||
// 只认属于这一本的(`month()` 里任务/纪念日那两段不带 kind 判断,
|
||||
// 给任意 cal 都会一起出来 —— 见上面派生本那段注释)
|
||||
if ((int)($e['cal'] ?? 0) !== $want) {
|
||||
continue;
|
||||
}
|
||||
$day = (string)$e['date'];
|
||||
// ⚠️ UID 必须**带日期**:这些条目 ref_id 恒为 0,
|
||||
// 照搬 `kind-ref_id@familybook` 会让整本共用一个 UID,
|
||||
// 按 UID 去重后就只剩一天(见 CalDav::resources)。
|
||||
$out[] = [
|
||||
'uid' => (string)$e['kind'] . '-' . $day . '@familybook',
|
||||
'title' => (string)$e['title'],
|
||||
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
|
||||
'ends_at' => null,
|
||||
'all_day' => 1,
|
||||
'location' => null,
|
||||
'note' => null,
|
||||
'status' => 'confirmed',
|
||||
'transp' => 0,
|
||||
'sequence' => 0,
|
||||
'created_at' => null,
|
||||
'updated_at' => null,
|
||||
'reminder_minutes' => null,
|
||||
'rrule' => null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user