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
+70 -2
View File
@@ -146,6 +146,26 @@ final class Schema
* 「哪些行被删了」(没有 deleted_at 就只能硬删除 ⇒ 客户端永远看不到墓碑
* ⇒ 一直显示幽灵数据)。⛔ 凭据/字典/本地偏好等 8 张表**故意不加** ——
* 同步白名单应当显式而简短。
* 34 → **CalDAV 的重复日程例外(2026-10-15 用户:「既然发现了就直接全部修了」)**:
* ① `calendar_events` 加 `extra` —— **未建模的 VEVENT 属性原样保留**
* (`X-*` / `RDATE` / `CATEGORIES`…)。原来 parser 只认固定 9 个属性,
* 其余一律丢,客户端写上去的东西**读不回来**(实测 PUT 带 `X-` 属性,
* 回来一个都不剩)。
* ② 新增 `calendar_event_overrides` —— 重复系列里**被改到别的时间的某一次**。
* 动机(实测出来的真 bug):CalDAV 客户端把"仅这一次改到周五"写成
* **同一个 .ics 里的第二个 VEVENT**(同 UID + `RECURRENCE-ID`),
* 而原来的 `dav.php` PUT 只取 `$list[0]` ⇒ 覆盖行直接进垃圾桶,
* 于是"改某一次"永远同步不过去,下轮还会把被排除那次又拉回来(幽灵日程)。
* ⚠️ 为什么**不**把覆盖行塞进 `calendar_events`(加一个 `recurrence_id` 列):
* 那一列会被所有既有查询看到 —— `exportRows()` 的
* `SELECT * FROM calendar_events WHERE calendar_id = ?`、日历视图的展开、
* 提醒扫描…**每一处都得多判一次"这行是不是主事件"**,漏一处就是
* "同一次日程出现两条"。而"被改掉的某一次"本来就不是"日程本体",
* 与 `calendar_event_skips` 是同一类东西 ⇒ 并列放一张卫星表,
* 既有查询**一行都不用改**。
* ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工:
* 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides
* (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -153,7 +173,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '33';
public const VERSION = '34';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -1742,6 +1762,54 @@ CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
SQL);
// ---- CalDAV 的重复日程例外(版本 34,2026-10-15)----
/* ① 「被改到别的时间的某一次」= 覆盖行(RFC 5545 §3.8.4.4 / RFC 4791 §4.1)。
CalDAV 客户端表达"把第 3 次改到周五"用的是**同一个 .ics 里的第二个 VEVENT**
(同 UID + `RECURRENCE-ID: <原发生时刻>` + 自己的 `DTSTART` = 新时间)。
原来 dav.php 的 PUT 只取 `$list[0]` ⇒ 覆盖行被丢掉 ⇒ 手机上改的那一次
**永远同步不过去**,而且下一轮拉取还会把被排除的那次又画回来。
⚠️ 为什么不加在 `calendar_events` 里(加 `recurrence_id` 列):
那样所有既有查询都会看到这些行,**每一处都得补一句"排除覆盖行"**
(`exportRows` 的 `SELECT *`、日历视图展开、提醒扫描、API…),
漏一处就是"同一次日程显示两条"。放卫星表则既有查询**一行都不用改** ——
与 `calendar_event_skips` 同一个道理。
⚠️ `UNIQUE KEY (event_id, recurrence_id)` 是幂等的基石:客户端每轮都把这
整个系列推一遍,没有它就每同步一次多一条覆盖行。
⚠️ 与 skips 的分工:**取消**这一次 → skips;**挪走**这一次 → 本表。
两者都可能同时存在(一个系列里既有取消的、又有挪走的)。 */
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendar_event_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程的主事件(calendar_events.id)',
`recurrence_id` VARCHAR(64) NOT NULL COMMENT 'RFC 5545 RECURRENCE-ID:被替换的那次**原发生时刻**(本地浮动 ICS 形态,如 20261021T090000 / 20261021)',
`title` VARCHAR(200) NOT NULL,
`location` VARCHAR(255) DEFAULT NULL,
`starts_at` DATETIME NOT NULL COMMENT '改到的新开始时刻',
`ends_at` DATETIME DEFAULT NULL,
`all_day` TINYINT(1) NOT NULL DEFAULT 0,
`note` TEXT DEFAULT NULL,
`status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed',
`sequence` INT NOT NULL DEFAULT 0,
`extra` TEXT DEFAULT NULL COMMENT '该 VEVENT 里未被建模的属性原文(X-* 等),往返带过',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ceo` (`event_id`, `recurrence_id`),
CONSTRAINT `fk_ceo_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='重复日程里被改到别的时间的某一次(RFC 5545 的 RECURRENCE-ID 覆盖行)'
SQL);
/* ② `calendar_events.extra`:未被建模的 VEVENT 属性原样保留。
parser 原来只认固定 9 个属性,其余一律丢 —— 实测 PUT 一份带 `X-` 自定义
属性的 ICS,读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**:
`RDATE`(额外发生日)丢 = 系列少几次;别人的 `X-APPLE-STRUCTURED-LOCATION`
丢 = 位置没了;第三方客户端发现"我写的没了"还可能改判为冲突。
⇒ 原样存、原样吐回,成本只有一列。 */
self::addColumn($db, 'calendar_events', 'extra',
"ADD COLUMN `extra` TEXT DEFAULT NULL COMMENT '未被建模的 VEVENT 属性原文(X-* 等,按行存),CalDAV 导出时原样带回' AFTER `sequence`");
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
@@ -2138,7 +2206,7 @@ SQL);
'person_tags', 'note_shares', 'notifications',
// ---- ensure() 版本迁移段里建的 ----
'trip_node_revisions', 'task_boards', 'task_categories',
'calendar_event_skips', 'anniversaries', 'anniversary_people',
'calendar_event_skips', 'calendar_event_overrides', 'anniversaries', 'anniversary_people',
'ledgers', 'ledger_entries', 'ledger_shares',
'ledger_entry_snapshots', 'person_jobs', 'person_notes',
];