v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢

根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
This commit is contained in:
2026-10-15 20:19:09 +08:00
parent 4fbac180e1
commit ef574d82a4
5 changed files with 864 additions and 50 deletions
+57 -2
View File
@@ -272,10 +272,39 @@ final class CalDav
*/
public static function resourceOf(array $r, string $calName): array
{
$ics = Ics::export([$r], $calName);
$ics = Ics::export([self::withExceptions($r)], $calName);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/**
* 给一条日程行补上它的「重复系列例外」——
* `_skip_dates`(= `EXDATE`,哪几次被取消)与 `_overrides`(= `RECURRENCE-ID` 覆盖行)。
*
* 🔴 v52b(2026-10-15):这两样必须进正文,也必须进 ETag。
* 只进正文不进 ETag 的话:客户端推完覆盖行 → 服务器存下了 →
* 但 ETag 没变 ⇒ 客户端拿 If-Match 再推一次时**判不出"已经是最新的"**,
* 严重时会被判成"服务器没接受"而反复重推(甚至回滚自己的改动)。
* 所以 ETag 由**最终正文**算(本函数产出的东西直接进 Ics::export)。
*
* ⚠️ `isset` 判据是**故意**的:`exportRows()` 出来的行已经带好了这两样
* (批量查过,见 Calendar::exportRows),这里就别再逐条查 ——
* 整本 REPORT 时那会变成 N+1。
*/
public static function withExceptions(array $r): array
{
$eid = (int)($r['id'] ?? 0);
if ($eid <= 0) {
return $r;
}
if (!isset($r['_skip_dates'])) {
$r['_skip_dates'] = Calendar::skipsOf($eid);
}
if (!isset($r['_overrides'])) {
$r['_overrides'] = Calendar::overridesOf($eid);
}
return $r;
}
/**
* 日历本的 ctag 种子 —— 客户端拿 ctag 判「这本整体变没变」。
*
@@ -295,7 +324,33 @@ final class CalDav
);
$st->execute([(int)$cal['id']]);
$r = $st->fetch() ?: [];
return (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
/* 🔴 v52b:**例外也要算进 ctag** ——
客户端拿 ctag 判"这本整体变没变",没变就跳过整轮拉取。
"只改其中一次"(取消某次 / 把某次挪到别的时间)**不动 calendar_events 那一行**
(`updateEvent` 都没被调),所以只按上面那个种子算的话 ctag 恒定不变 ⇒
客户端以为没变、永远拉不到这次改动(表现:"我在手机上改的,另一台设备上看不见")。 */
$seed = (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
$st2 = Database::get()->prepare(
'SELECT COUNT(*) AS n, COALESCE(SUM(CAST(o.sequence AS SIGNED)), 0) AS sq,
COALESCE(MAX(o.updated_at), \'\') AS mx
FROM calendar_event_overrides o
JOIN calendar_events e ON e.id = o.event_id
WHERE e.calendar_id = ?'
);
$st2->execute([(int)$cal['id']]);
$o = $st2->fetch() ?: [];
$seed .= '|' . (string)($o['n'] ?? 0) . '|' . (string)($o['mx'] ?? '') . '|' . (string)($o['sq'] ?? 0);
$st3 = Database::get()->prepare(
'SELECT COUNT(*) AS n, COALESCE(MAX(s.created_at), \'\') AS mx
FROM calendar_event_skips s
JOIN calendar_events e ON e.id = s.event_id
WHERE e.calendar_id = ?'
);
$st3->execute([(int)$cal['id']]);
$s = $st3->fetch() ?: [];
return $seed . '|' . (string)($s['n'] ?? 0) . '|' . (string)($s['mx'] ?? '');
}
/**