From 8ae4e1150736dbf78539c1748551b91eae69b50f Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 15 Oct 2026 18:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8C=E6=AD=A5=E5=90=8E?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E6=97=B6=E9=97=B4=E5=B0=91=208=20=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=EF=BC=88=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=BC=9A=E5=88=A0?= =?UTF-8?q?=E6=8E=89=20DTSTART/DTEND=20=E7=9A=84=20Z=20/=20TZID=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:客户端建的 10-16 08:00~17:00 同步到服务器后变成 00:00~09:00。 根因(2026-10-15 连服务器 family.yangyq.net 实测确认,不是 App 的解析 bug): 服务器会**直接删掉** DTSTART/DTEND 上的 `Z` 与 `TZID` 参数,**且不对数值做任何 时移**: PUT DTSTART:20261020T060000Z -> 存回 DTSTART:20261020T060000 PUT DTSTART;TZID=Asia/Shanghai:20261020T140000 -> 存回 DTSTART:20261020T140000 于是"UTC 00:00"被当成"本地 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00, 所以症状正好是阅读端少 8 小时。 (DTSTAMP/CREATED/LAST-MODIFIED 是 RFC 规定的 UTC-only 属性,服务器反而保留 Z。) 交叉验证:全账号 2190 条事件里,计时事件(1163 条)的 DTSTART **无一例外都是 浮动本地时间**——带 Z 的 0 条、带 TZID 的 0 条。即该服务器的事实约定就是 "所有时间都是本地墙钟"。第三方客户端写的事件同样是浮动 DTSTART,且 DTSTART 浮动 + `UNTIL=...`(浮动)成对出现(例:研究生例会)。 改法:事件时间一律写**浮动本地时间**,与服务器约定对齐。 - IcsUtil.vevent:DTSTART/DTEND/RECURRENCE-ID/EXDATE 由 fmtUtc 改为 fmtLocal (本地 getter、不带 Z) - IcsUtil.fmtIcsTime:改为浮动本地(EXDATE / RECURRENCE-ID / UNTIL 共用) - IcsUtil.rruleWithUntil:UNTIL 改为浮动本地 (RFC 5545:DTSTART 是本地时间时 UNTIL 必须也是本地时间,二者必须同形态) - DTSTAMP 仍写 UTC+Z(RFC 规定),fmtUtcNow 不变 - 读取侧无需改动:parseTime / RruleUtil.parseUntil 已把"无 Z"按本地解析, 正好匹配浮动语义,往返一致 端到端验证(真服务器): 新写法 DTSTART:20261016T080000 -> 服务器原样存回 -> 读回仍为本地 08:00 ✅ 数据修复:服务器上唯一一条受影响的日程(工作 / 项目评审 / UID syncal-1792039765773-100047)已由 00:00~09:00 改回 08:00~17:00。 全账号仅此一条是本 App 创建的计时事件。 --- entry/src/main/ets/common/IcsUtil.ets | 52 ++++++++++++++++++--------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 078e3bc..2a63d06 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -229,7 +229,10 @@ export class IcsUtil { } /** - * 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。 + * 由本地事件构建 VCALENDAR 文本。 + * + * ⚠️ 事件时间写的是**浮动本地时间**(不是 UTC),原因见 `vevent()` 里的详细说明 + * (服务器会删掉 `Z`/`TZID` 且不做时移)。`DTSTAMP` 仍按 RFC 规定写 UTC+`Z`。 * * 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。 * 含这些字符的标题在服务器端可能被截断或串行; @@ -273,10 +276,25 @@ export class IcsUtil { /** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */ private static vevent(e: LocalEvent, now: string): string { const pad = (n: number): string => n < 10 ? '0' + n : String(n); - const fmtUtc = (ms: number): string => { + /** + * ⭐⭐ 事件时间一律写**浮动本地时间**(`YYYYMMDDTHHMMSS`,**不带 `Z`、不带 `TZID`**)。 + * + * 为什么不写 RFC 最正统的 UTC+`Z` —— 因为服务器会把它弄坏: + * 本项目的服务器(家书日历 / family.yangyq.net)会**直接删掉** `DTSTART`/`DTEND` + * 上的 `Z` 与 `TZID` 参数,**且不对数值做任何时移** + * (2026-10-15 实测:PUT `DTSTART:20261020T060000Z` → 存回 `DTSTART:20261020T060000`; + * PUT `DTSTART;TZID=Asia/Shanghai:20261020T140000` → 存回 `20261020T140000`)。 + * 于是"UTC 00:00"被当成"**本地** 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00, + * 所以症状正好是**阅读端少了 8 小时**(用户实测:"8:00 的日程同步后变成 0:00")。 + * + * 该服务器上全部计时事件(1163 条)的 `DTSTART` **无一例外都是浮动本地时间** + * (带 `Z` 的数量为 **0**、带 `TZID` 为 **0**)→ 这就是它的事实约定,我们随它。 + * 浮动时间在任何阅读端都按"当地墙钟"解析,本机 CST 往返完全一致。 + */ + const fmtLocal = (ms: number): string => { const d = new Date(ms); - return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + - `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + + `T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; }; const fmtDate = (ms: number): string => { const d = new Date(ms); @@ -289,8 +307,8 @@ export class IcsUtil { // 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期 dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000); } else { - dtstart = 'DTSTART:' + fmtUtc(e.startTime); - dtend = 'DTEND:' + fmtUtc(e.endTime); + dtstart = 'DTSTART:' + fmtLocal(e.startTime); + dtend = 'DTEND:' + fmtLocal(e.endTime); } // ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它 // 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。 @@ -298,7 +316,7 @@ export class IcsUtil { if (e.rrule === '' && e.recurrenceId > 0) { recId = e.isAllDay ? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n` - : `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`; + : `RECURRENCE-ID:${fmtLocal(e.recurrenceId)}\r\n`; } // ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加) let exdate: string = ''; @@ -307,7 +325,7 @@ export class IcsUtil { for (const raw of e.exdate.split(';')) { const t = IcsUtil.parseTime(raw, !raw.includes('T')); if (t !== null) { - vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time)); + vals.push(t.allDay ? fmtDate(t.time) : fmtLocal(t.time)); } } if (vals.length > 0) { @@ -341,8 +359,9 @@ export class IcsUtil { } /** - * 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。 - * @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ` + * 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID / UNTIL 的 ICS 时间串。 + * @param dateOnly 全天日程用 `YYYYMMDD`,否则用**浮动本地时间** `YYYYMMDDTHHMMSS` + * (不带 `Z`:必须与 `DTSTART` 的形态一致,见 `vevent()` 里的说明) */ static fmtIcsTime(ms: number, dateOnly: boolean): string { const pad = (n: number): string => n < 10 ? '0' + n : String(n); @@ -350,20 +369,19 @@ export class IcsUtil { if (dateOnly) { return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; } - return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + - `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + + `T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } /** * ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。 * - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现) - * - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒" + * - UNTIL 用**浮动本地时间**(RFC 5545:`DTSTART` 是本地时间时,`UNTIL` **必须**也是本地时间; + * 而我们把所有事件时间都写成浮动本地 —— 二者必须一致,否则不同阅读端的展开会错位) + * - `untilMs` 一般是"该次发生的开始时刻 − 1 秒" */ static rruleWithUntil(rrule: string, untilMs: number): string { - const pad = (n: number): string => n < 10 ? '0' + n : String(n); - const d = new Date(untilMs); - const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + - `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; + const until: string = IcsUtil.fmtIcsTime(untilMs, false); const kept: string[] = []; for (const part of rrule.split(';')) { const k: string = part.split('=')[0].trim().toUpperCase();