日历系统开发(二):数据库设计与日程模型

日历系统的数据库设计是整个项目的根基。设计过程中可以参考 RFC 5545(iCalendar 规范)和 Google Calendar API 的数据模型,在规范兼容性和工程实用性之间做取舍。

核心表设计

经过几轮讨论和调整,最终确定了5张核心表。

calendar 表

日历是日程的容器,类似于文件夹的概念。每个用户可以有多个日历(工作日历、个人日历等)。

字段 类型 说明
id BIGINT PK 主键
user_id BIGINT NOT NULL 所属用户
name VARCHAR(100) 日历名称
color VARCHAR(7) 颜色代码(如#FF5733)
description TEXT 描述
timezone VARCHAR(50) 默认时区(如Asia/Shanghai)
is_default BOOLEAN 是否为默认日历
is_visible BOOLEAN 前端是否显示
created_at TIMESTAMPTZ 创建时间
updated_at TIMESTAMPTZ 更新时间

每个用户注册时自动创建一个默认日历。timezone字段存储IANA时区标识符,这个很重要——后面所有时间计算都依赖它。

event 表

这是最核心的表,存储日程事件。

字段 类型 说明
id BIGINT PK 主键
calendar_id BIGINT FK 所属日历
title VARCHAR(200) 标题
description TEXT 描述(支持富文本)
location VARCHAR(500) 地点
start_time TIMESTAMPTZ 开始时间
end_time TIMESTAMPTZ 结束时间
all_day BOOLEAN 是否全天事件
status VARCHAR(20) confirmed/tentative/cancelled
visibility VARCHAR(20) public/private/confidential
recurrence_id BIGINT FK 关联的重复规则(可空)
recurring_event_id BIGINT FK 原始重复事件ID(修改单次时用)
original_start_time TIMESTAMPTZ 原始开始时间(修改单次时用)
organizer_id BIGINT 组织者用户ID
created_at TIMESTAMPTZ 创建时间
updated_at TIMESTAMPTZ 更新时间

几个设计决策的考虑:

start_timeend_time用TIMESTAMPTZ:PostgreSQL的TIMESTAMPTZ存储的是UTC时间戳,查询时自动按session时区转换。这样无论用户在哪个时区创建的事件,存储的都是同一个绝对时间点。

全天事件的处理:当all_day=true时,start_time存储的是当天00:00:00(日历时区),end_time存储的是结束日的00:00:00。比如"5月25日全天事件",start_time是2022-05-25T00:00:00+08:00,end_time是2022-05-26T00:00:00+08:00。

recurring_event_idoriginal_start_time:用于处理重复事件的单次修改(exception)。比如"每周一开会"改了某一周的时间,修改后的那一次会作为新记录插入,recurring_event_id指向原始事件,original_start_time记录它原本应该出现的时间。

event_recurrence 表

存储重复规则,遵循RFC 5545的RRULE格式。

字段 类型 说明
id BIGINT PK 主键
event_id BIGINT FK 关联事件
rrule TEXT RFC 5545 RRULE字符串
freq VARCHAR(20) DAILY/WEEKLY/MONTHLY/YEARLY
interval_val INT 间隔(每N天/周/月/年)
by_day VARCHAR(100) 按星期(如MO,WE,FR)
by_month_day VARCHAR(100) 按月中日期(如1,15)
by_month VARCHAR(50) 按月份(如1,6)
until_date TIMESTAMPTZ 结束日期(和count二选一)
count INT 重复次数(和until二选一)
created_at TIMESTAMPTZ 创建时间

我同时存了rrule原始字符串和解析后的各字段。rrule用于和外部系统(CalDAV)交互时的兼容性,解析后的字段用于数据库层面的查询优化。

例如"每周一、三、五重复,到2022年底":

  • rrule: FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20221231T235959Z
  • freq: WEEKLY
  • by_day: MO,WE,FR
  • until_date: 2022-12-31T23:59:59Z

event_attendee 表

参与者表。

字段 类型 说明
id BIGINT PK 主键
event_id BIGINT FK 关联事件
user_id BIGINT 参与者用户ID
email VARCHAR(200) 参与者邮箱(外部参与者)
display_name VARCHAR(100) 显示名
role VARCHAR(20) organizer/nequired/optional
status VARCHAR(20) accepted/declined/tentative/needsAction
responded_at TIMESTAMPTZ 最近一次响应时间
created_at TIMESTAMPTZ 创建时间

user_idemail至少有一个有值。内部用户填user_id,外部参与者填email

reminder 表

提醒表。每个事件可以有多个提醒。

字段 类型 说明
id BIGINT PK 主键
event_id BIGINT FK 关联事件
method VARCHAR(20) notification/email/webhook
minutes_before INT 事件前N分钟提醒
is_sent BOOLEAN 是否已发送
sent_at TIMESTAMPTZ 发送时间
created_at TIMESTAMPTZ 创建时间

ER关系

user 1──N calendar 1──N event
                           │
                           ├── 1──0..1 event_recurrence
                           ├── 1──N event_attendee
                           └── 1──N reminder
                           
event.recurring_event_id ──> event.id  (自关联,单次修改指向原始重复事件)

核心关系很清晰:用户拥有多个日历,日历包含多个事件,事件可以有重复规则、参与者和提醒。

时间存储策略

这是我在设计过程中花时间最多的部分。日历系统的时间处理非常容易踩坑。

原则:存储UTC,展示转换

所有时间字段使用TIMESTAMPTZ类型,PostgreSQL会把输入的时间转为UTC存储,查询时按当前session的timezone设置转换输出。

-- 设置session时区
SET timezone = 'Asia/Shanghai';

-- 插入事件(输入的是上海时间,存储为UTC)
INSERT INTO event (title, start_time, end_time)
VALUES ('团队会议', '2022-05-25 14:00:00', '2022-05-25 15:00:00');

-- 查询(自动转换为session时区)
SELECT title, start_time FROM event;
-- 结果: 团队会议 | 2022-05-25 14:00:00+08

-- 切换时区查看同一条记录
SET timezone = 'America/New_York';
SELECT title, start_time FROM event;
-- 结果: 团队会议 | 2022-05-25 02:00:00-04

全天事件的特殊处理

全天事件在概念上是"没有具体时间的",但数据库需要一个具体的时间戳。我的做法是用日历所属时区的当天00:00:00。

API层做了封装,前端只传日期字符串(如"2022-05-25"),后端根据日历时区补全时间部分。

跨时区会议

用户A在北京创建了"上午10点开会",用户B在纽约看到的应该是"晚上10点"。因为我们存的是UTC时间戳,每个用户按自己的时区查询就自动转换了。这也是选择TIMESTAMPTZ的核心原因。

索引设计

日历系统最频繁的查询是"某个时间段内有哪些事件",索引设计围绕这个场景。

event表核心索引

  1. 日历+时间范围:最高频查询

    • (calendar_id, start_time, end_time) 复合索引
  2. 用户事件查询:查看某用户所有日历的事件

    • (calendar_id) 外键索引配合calendar表的user_id
  3. 重复事件关联:查找某个重复事件的所有exception

    • (recurring_event_id) 索引
  4. 时间范围查询优化:利用PostgreSQL的BRIN索引

    • start_time上建BRIN索引,因为日程数据天然有时间顺序

attendee表索引

  • (event_id) 查询事件的参与者
  • (user_id, status) 查询用户待处理的邀请

reminder表索引

  • (event_id) 常规关联查询
  • (is_sent, minutes_before) 提醒调度服务扫描用

PostgreSQL的tstzrange类型也值得考虑——可以用GiST索引做更高效的时间范围重叠查询(会议室冲突检测场景),不过这个留到后面优化阶段再看。

小结

数据库设计阶段最关键的几个决策:

  1. 所有时间用TIMESTAMPTZ,存储UTC
  2. 重复规则独立成表,同时保留RRULE原始字符串和解析字段
  3. 重复事件的单次修改通过recurring_event_id自关联实现
  4. 全天事件用日历时区的00:00:00表示

下一篇会进入编码阶段,实现日程的CRUD API。