任务时间看起来只是一个日期选择器,实际承载着不同的意思。“会议在北京时间 9 点开始”“每天在当地时间 7 点晨跑”“周六整理资料”,并不能用同一种转换规则处理。
这是一篇从时间基础笔记与任务系统方案整理出的设计笔记。这里讨论模型选择和验证条件,示例不代表已经实现或经过生产验证的任务系统。
先问时间改变时,什么应该保持不变
可以从三个问题识别任务的语义:用户去了另一个时区,任务是否应该挪动?这是一条具体时刻,还是每天重复的钟表时间?用户是否真的指定了几点?
| 用户输入 | 应保持的含义 | 模型需要保留的内容 |
|---|---|---|
| 10 月 10 日北京时间 9 点开会 | 同一个绝对时刻 | 时刻;必要时保留原始时区 |
| 无论出差到哪里,每天当地时间 7 点晨跑 | 当地钟表上的 7 点 | 当地时间、重复规则、时区跟随策略 |
| 10 月 10 日全天整理资料 | 日历上的日期 | 日期或日期范围 |
表格里的分类首先是产品约定。数据库字段应表达约定,不能仅凭字段名字反过来决定用户输入的含义。
绝对时刻适合统一比较
会议只发生一次。2026-10-10T09:00:00+08:00 与 2026-10-10T01:00:00Z 表示同一时刻。用户出国后,界面上的小时数会改变,会议本身没有改期。
这类数据适合归一化为 UTC 时刻。API 应明确接受带偏移量的时间字符串,或明确单位的时间戳;不能让一方按秒、另一方按毫秒解释同一个数字。
在 PostgreSQL 中,timestamp with time zone,也就是 timestamptz,会把输入转换为 UTC 存储,输出时按会话时区展示,不会保留输入时使用的原始时区名称。如果任务还需要“始终以会议组织者的时区编辑”,应另存时区标识。PostgreSQL 日期时间文档说明了这一区别。
这里的 UTC 是时刻的统一表示。它没有替产品回答“这条任务是否应该随用户旅行而改变”。
当地时间还需要说明跟随谁
“每天当地时间 7 点”保留的是钟表时间。北京的 7 点与另一座城市的 7 点,可以对应不同的 UTC 时刻。
一个示意模型可以写成:
{
"kind": "local-time",
"localTime": "07:00:00",
"repeat": "daily",
"timezoneMode": "follow-user"
}
follow-user 仍然不是完整规则。它跟随用户手动设置的时区,还是设备检测到的时区?多个设备时区不一致时,谁负责产生提醒?用户切换时区后,下一次提醒如何重新计算?这些都需要在调度之前确定。
另一种需求是“每个工作日,纽约当地时间 9 点开会”。它绑定某个地区的钟表时间,而不是跟随参与者的位置。此时需要地区时区标识和重复规则,生成每次发生时再解析出时刻。RFC 5545 的日期时间定义区分了浮动时间、UTC 时间和带时区引用的当地时间。
固定偏移量只能描述某次转换;地区时区名称还关联随日期变化的规则。讨论长期重复任务时,应保留地区时区标识,并明确不存在或重复出现的当地时间怎样处理,而不是简单地给上次发生时刻加 24 小时。
“全天”优先表达为日期
“10 月 10 日整理资料”没有指定时刻。把它存成某个时区的零点,再交给通用时区转换显示,可能在另一地被展示成前一天。转换没有算错,模型却给日期添加了用户没有选择的含义。
对于这种按日历安排的任务,可以使用日期范围:
{
"kind": "all-day",
"startDate": "2026-10-10",
"endDateExclusive": "2026-10-11"
}
这里包含 10 月 10 日,结束日期不包含在范围内。多日任务也可以沿用相同规则,不必构造 23:59:59.999 这样的“最后一刻”。iCalendar 的全天事件采用日期值,事件结束为排除边界;这是一个可参考的表示方式,不要求应用完整实现 iCalendar。RFC 5545 事件定义给出了这项约定。
日期本身不携带时区,但“在全天任务开始时发通知”仍需要解释时区。可以把提醒策略作为独立规则:按任务指定的地区时区,或按用户设置的时区计算。跨时区后保持日期标签,还是绑定某地的营业日,也应由业务决定。
“今天”和“本月”是在日历上划范围
查询某个用户今天的定时任务,先在用户时区中求出当天开始与下一天开始,再把两个边界转换为 UTC 查询。不要直接用服务器的日期,也不要假定每个当地日都等于固定的 24 小时。夏令时切换可能使一天的实际时长发生变化,PostgreSQL 的区间说明也解释了日历日与固定小时数的区别。
对于“开始时刻在窗口内”的查询,半开区间可以写成:
WHERE starts_at >= :window_start
AND starts_at < :window_end
月视图还需要区分另一种含义:事件可能从上个月开始,但在这个月仍未结束。若查询的是与窗口有交集的持续事件,可以使用:
WHERE starts_at < :window_end
AND ends_at > :window_start
两个条件回答不同的问题。以上是非零时长事件的示意,不包含用户数据范围、空值和取消状态等约束;单点任务、全天日期和重复任务也需要各自的查询规则。
API 和测试围绕语义组织
如果 API 只给出 startTime、endTime 和 isAllDay,调用者可能还要猜:没有偏移量的字符串是什么?全天任务的结束日期是否包含?用户改时区后会不会改动任务?
可以先通过 kind 区分输入,再让每种输入携带必需字段。绝对时刻输入要能确定唯一时刻;当地时间输入要有解释规则;全天输入要有清晰的日期边界。无效日期、未知时区和含糊的时间应在边界处处理,不能悄悄变成另一个合法任务。
设计稿至少需要验证这些场景:
- 同一个时刻切换显示时区,保存值与提醒时刻不变。
- 当地时间任务切换时区,按明确策略重算提醒。
- 全天任务跨时区浏览,保持约定的日历日期。
- 夏令时切换附近的重复或缺失时间,符合选定策略。
- 跨月持续事件出现在预期日历窗口,边界事件不重复归类。
- 输入时间戳单位错误或时区无效时,返回可理解的错误。
这些是验证目标,本文没有运行对应实现。后续实现的起点应是确定用户要保留的时间含义,再为每种含义选择存储、查询和提醒方式。