修复同步后日程时间少 8 小时(服务器会删掉 DTSTART/DTEND 的 Z / TZID)
现象:客户端建的 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 创建的计时事件。
This commit is contained in:
@@ -229,7 +229,10 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。
|
* 由本地事件构建 VCALENDAR 文本。
|
||||||
|
*
|
||||||
|
* ⚠️ 事件时间写的是**浮动本地时间**(不是 UTC),原因见 `vevent()` 里的详细说明
|
||||||
|
* (服务器会删掉 `Z`/`TZID` 且不做时移)。`DTSTAMP` 仍按 RFC 规定写 UTC+`Z`。
|
||||||
*
|
*
|
||||||
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
|
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
|
||||||
* 含这些字符的标题在服务器端可能被截断或串行;
|
* 含这些字符的标题在服务器端可能被截断或串行;
|
||||||
@@ -273,10 +276,25 @@ export class IcsUtil {
|
|||||||
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
|
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
|
||||||
private static vevent(e: LocalEvent, now: string): string {
|
private static vevent(e: LocalEvent, now: string): string {
|
||||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
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);
|
const d = new Date(ms);
|
||||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
};
|
};
|
||||||
const fmtDate = (ms: number): string => {
|
const fmtDate = (ms: number): string => {
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
@@ -289,8 +307,8 @@ export class IcsUtil {
|
|||||||
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
||||||
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
||||||
} else {
|
} else {
|
||||||
dtstart = 'DTSTART:' + fmtUtc(e.startTime);
|
dtstart = 'DTSTART:' + fmtLocal(e.startTime);
|
||||||
dtend = 'DTEND:' + fmtUtc(e.endTime);
|
dtend = 'DTEND:' + fmtLocal(e.endTime);
|
||||||
}
|
}
|
||||||
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
|
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
|
||||||
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
|
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
|
||||||
@@ -298,7 +316,7 @@ export class IcsUtil {
|
|||||||
if (e.rrule === '' && e.recurrenceId > 0) {
|
if (e.rrule === '' && e.recurrenceId > 0) {
|
||||||
recId = e.isAllDay
|
recId = e.isAllDay
|
||||||
? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
|
? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
|
||||||
: `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`;
|
: `RECURRENCE-ID:${fmtLocal(e.recurrenceId)}\r\n`;
|
||||||
}
|
}
|
||||||
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
|
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
|
||||||
let exdate: string = '';
|
let exdate: string = '';
|
||||||
@@ -307,7 +325,7 @@ export class IcsUtil {
|
|||||||
for (const raw of e.exdate.split(';')) {
|
for (const raw of e.exdate.split(';')) {
|
||||||
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
||||||
if (t !== null) {
|
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) {
|
if (vals.length > 0) {
|
||||||
@@ -341,8 +359,9 @@ export class IcsUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。
|
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID / UNTIL 的 ICS 时间串。
|
||||||
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ`
|
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用**浮动本地时间** `YYYYMMDDTHHMMSS`
|
||||||
|
* (不带 `Z`:必须与 `DTSTART` 的形态一致,见 `vevent()` 里的说明)
|
||||||
*/
|
*/
|
||||||
static fmtIcsTime(ms: number, dateOnly: boolean): string {
|
static fmtIcsTime(ms: number, dateOnly: boolean): string {
|
||||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||||
@@ -350,20 +369,19 @@ export class IcsUtil {
|
|||||||
if (dateOnly) {
|
if (dateOnly) {
|
||||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
|
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
|
||||||
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
|
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
|
||||||
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒"
|
* - UNTIL 用**浮动本地时间**(RFC 5545:`DTSTART` 是本地时间时,`UNTIL` **必须**也是本地时间;
|
||||||
|
* 而我们把所有事件时间都写成浮动本地 —— 二者必须一致,否则不同阅读端的展开会错位)
|
||||||
|
* - `untilMs` 一般是"该次发生的开始时刻 − 1 秒"
|
||||||
*/
|
*/
|
||||||
static rruleWithUntil(rrule: string, untilMs: number): string {
|
static rruleWithUntil(rrule: string, untilMs: number): string {
|
||||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
const until: string = IcsUtil.fmtIcsTime(untilMs, false);
|
||||||
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 kept: string[] = [];
|
const kept: string[] = [];
|
||||||
for (const part of rrule.split(';')) {
|
for (const part of rrule.split(';')) {
|
||||||
const k: string = part.split('=')[0].trim().toUpperCase();
|
const k: string = part.split('=')[0].trim().toUpperCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user