做企业内部工具的人,大概率都接到过这样一个需求:把会议室预订情况放到一个电视大屏上,让大家一进门就能看到哪个房间空着、哪个房间马上开始。这个需求听起来简单,真正做起来要处理的东西不少,核心就是把 Google Workspace 里的资源日历(Resource Calendar)数据拉出来,通过 API 读成结构化数据,再交给前端渲染成看板。我这次用的是 Google Workspace Calendar API 的 freebusy 查询和 events.list 两个接口,外加 Admin SDK Directory API 拉房间元数据,整套流程从零跑通,半天时间足够。
如果你之前只写过业务 CRUD,没碰过 Google Workspace 的 API,这篇文章刚好可以当一份可直接抄的作业。我会按“整体思路 -> 前置准备 -> 核心实现 -> 错误排查 -> 扩展方案”的顺序来写,里面所有代码和配置都是我在真实项目里验证过的。
1. 整体设计:一个会议室看板背后的接口组合
1.1 选 Calendar API 而不是自己造轮子
会议室预订在日常里最常用的载体就是日历系统,Google Workspace 的 Calendar 天然支持“资源”这个概念。也就是说,管理员可以把每个会议室建成一个“资源日历”,它有自己的日历 ID、名称、容量、楼层等属性,别人预订会议室时通过邀请这个资源日历完成占用。这种方式比自建数据库存预订记录要稳得多,因为日历本身就处理了冲突检测、循环事件、参会人提醒这些复杂逻辑,我们只要负责把数据捞出来展示即可。
所以选型上我没有用数据库表存预订,也没有去解析 CSV 导出,而是直接面向 Calendar API 开发。这样做的好处有三个:第一是数据实时性有保证,任何人通过日历客户端改了预订,API 立刻能查到;第二是不用维护一套数据同步逻辑,避免两边数据不一致;第三是 API 本身免费,只要控制好配额,运营成本几乎为零。坏处也有,其中一个就是 API 的权限模型比较绕,尤其是服务账号访问资源日历时,坑不少,这个后面会展开讲。
有朋友可能会问,Google Apps Script 不是也能读取日历数据吗?确实能,而且写起来更简单。但 Apps Script 不容易承载一个独立的网页服务,而且它的触发器和执行时间限制比较多,适合做轻量自动化,不适合做 7 x 24 小时运行的看板后端。从稳定性和可维护性角度,我选择了写一个小服务,通过 RESTful API 接口调 Calendar API,然后前端定时拉取数据。
1.2 两种鉴权方式和服务账号方案
Google Workspace 的 API 鉴权常见是两种方式:OAuth 2.0 用户授权和服务账号。
OAuth 2.0 用户授权适合有“当前登录用户”的场景,比如用户自己点击“登录”按钮,浏览器弹出授权页,应用拿到一个短期有效期 token,再拿 token 去访问“这个用户”的数据。会议室展示终端这种无人值守的场景用起来很别扭,因为 token 会过期,不能总让保洁阿姨去帮忙重新授权。
服务账号则是应用自己拥有一套密钥,不依赖人的操作。只要你在 Google Workspace 管理后台配置好域范围委派(domain-wide delegation),服务账号就可以模拟某个有权限的账号去访问域内数据。我最终用的是服务账号加模拟用户(impersonate)的方式:服务账号本身不是真人,但通过管理员提前授权,它可以“扮演”一个拥有会议室查看权限的账号,去读取资源日历。
这里必须提醒一句:有人在网上说服务账号可以直接访问任意日历,这是不对的。服务账号模拟的目标用户,必须在目标日历上有适当权限。最简单的做法是用一个超级管理员账号作为模拟目标,但在真实企业里权限不宜给这么大,更稳妥的办法是创建一个专门的服务账号,在管理后台给它授予读取日历资源的权限,或者把目标日历共享给它。看板类应用只读就够了,不要申请写权限。
1.3 整体数据流
- 第一步,从 Admin SDK Directory API 拉取全部房间资源,得到每个房间的资源名称、容量、楼层和资源邮箱(这其实就是日历 ID)。
- 第二步,用 Calendar API 的 freebusy 接口,一次查询多个房间在某个时间段内的 busy 区间。
- 第三步,如果只想显示“占用/空闲”,第二步就已经够了;如果还想显示预订标题、预订人、开始结束时间,就用 events.list 把每个资源日历的事件拉出来。
- 第四步,后端把数据整合成 JSON 返回给前端,前端按“房间 x 时间”二维表格渲染,定时刷新。
这个数据流按天跑、按分钟跑都行,完全看你的展示精度需求。我最后做的是每 60 秒刷新一次当前时间和占用状态,事件列表每 5 分钟刷新一次,这样既保证实时性,又不浪费配额。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备:资源日历、项目与权限配置
2.1 把每个房间变成“资源日历”
如果你的企业已经通过 Google Workspace 管理后台创建了会议室资源,那这一步可以跳过。我实际遇到的情况是,很多团队的会议室是在日历客户端里手工建一个日历当房间用,这种方式不是不行,但拿不到结构化的房间元数据,比如所在楼层和容量。所以我建议走正规路线:让 Workspace 管理员在管理控制台里创建资源。
具体路径一般是:管理控制台 -> 日历 -> 资源和日历(不同版本入口名称略有差异)。在这里可以创建建筑物(Building)、楼层(Floor)和房间资源(Resource)。创建房间资源时,系统会要求填名称、容量、所属建筑和楼层,部分版本还支持填入房间邮箱。创建完成后,该资源会对应一个资源日历,有一个类似 conference-room-astana@yourdomain.com 的地址,这就是后续调用 Calendar API 时要用的日历 ID。
如果你现在是测试环境、没有管理控制台权限,也可以先手工建一个普通日历当作房间日历,照样能跑通后面的代码。真正区别只是少了一些元数据字段。建议先用手工日历完成开发,最后再让管理员补资源数据。
2.2 GCP 项目、服务账号与域范围委派
打开 Google Cloud Console,新建一个项目(或者复用现有项目)。在“API 和服务”里启用两个 API:Google Calendar API 和 Admin SDK API。
然后是创建服务账号。在“凭据 -> 创建凭据 -> 服务账号”里填写名称,创建完成后,给服务账号创建一个 JSON 类型的密钥,下载保存好。这个 JSON 文件包含了服务账号的私钥信息,务必放到安全目录,不要提交进代码仓库,我见过有人把密钥直接写在前端代码里,结果被人拿去做恶意操作。
接着要配置域范围委派。在 Workspace 管理控制台里找到“安全 -> API 控件 -> 域范围委派”,填入服务账号的客户端 ID(在 GCP 服务账号详情里可以看到),然后添加要授权的 OAuth 范围。只读展示的话,建议只授权这两个范围:
https://www.googleapis.com/auth/calendar.readonlyhttps://www.googleapis.com/auth/admin.directory.resource.calendar.readonly
注意,域范围委派从配置到生效可能会延迟一段时间。我第一次配置完就急着测试,结果一直报 403,后来查文档才知道要等一会儿,少则几分钟多则一个小时。遇到权限类报错先别怀疑代码,看看委派是否真的生效。
2.3 拿全房间清单和日历 ID
用 Admin SDK Directory API 获取资源列表,请求方式是一个 GET 请求:
code复制GET https://admin.googleapis.com/admin/directory/v1/customer/my_customer/resources/calendars
返回结果里每个资源会包含 resourceName、resourceEmail、buildingId、floorName、capacity 等字段。其中 resourceEmail 就是日历 ID。
Python 代码示例:
python复制from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession
SERVICE_ACCOUNT_FILE = "service_account.json"
SCOPES = [
"https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
]
IMPERSONATE_USER = "admin@yourdomain.com"
credentials = service_account.Credentials.from_service_account_file(
SERVICE_ACCOUNT_FILE,
scopes=SCOPES,
subject=IMPERSONATE_USER,
)
authed_session = AuthorizedSession(credentials)
url = "https://admin.googleapis.com/admin/directory/v1/customer/my_customer/resources/calendars?maxResults=100"
resp = authed_session.get(url)
resources = resp.json().get("items", [])
for r in resources:
print(r["resourceName"], r["resourceEmail"], r.get("capacity"), r.get("floorName"))
很多教程只教你用 calendarId 去查事件,却没说 calendarId 从哪来。对于手工创建的普通日历,你在日历设置里能看到类似 xxxx@group.calendar.google.com 的地址;对于资源日历,就是 resourceEmail。我封装了一个简单函数,把资源清单缓存起来,后续轮询时直接按名称索引,避免每次都去拉 Directory API。
3. 核心实现:从查询到展示
3.1 用 freebusy 一次性判断一堆房间的占用情况
Calendar API 里面最简单粗暴也是我推荐首选的方式,是调 freeBusy 接口。它一次可以传入多个日历 ID,返回每个日历在指定时间范围内的 busy 段,免费额度消耗也比较低。
请求参数大致这样:
python复制import datetime
from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession
SERVICE_ACCOUNT_FILE = "service_account.json"
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
IMPERSONATE_USER = "admin@yourdomain.com"
credentials = service_account.Credentials.from_service_account_file(
SERVICE_ACCOUNT_FILE,
scopes=SCOPES,
subject=IMPERSONATE_USER,
)
authed_session = AuthorizedSession(credentials)
calendar_ids = [
"room-a@yourdomain.com",
"room-b@yourdomain.com",
"room-c@yourdomain.com",
]
time_min = "2025-01-01T09:00:00+08:00"
time_max = "2025-01-01T10:00:00+08:00"
body = {
"timeMin": time_min,
"timeMax": time_max,
"timeZone": "Asia/Shanghai",
"items": [{"id": cid} for cid in calendar_ids],
}
url = "https://www.googleapis.com/calendar/v3/freeBusy"
resp = authed_session.post(url, json=body)
data = resp.json()
for cal_id, result in data.get("calendars", {}).items():
busy_list = result.get("busy", [])
if busy_list:
print(f"{cal_id} 忙碌: {busy_list}")
else:
print(f"{cal_id} 空闲")
这里我故意把时间范围设置成 9 点到 10 点,如果一个房间在 9 点半到 9 点 45 分有一场会,返回的 busy 数组里就会有一个 start 和 end。我可以直接把 busy 段转成前端可用的“占用区间”,前端画一个时间轴,把重叠的矩形块涂上颜色就行。
要注意的是,freebusy 里的时间字符串必须是 RFC3339 格式,也就是带时区偏移,比如 2025-01-01T09:00:00+08:00。如果你只传 2025-01-01T09:00:00,某些 SDK 会默认当成 UTC,然后在东八区显示就差 8 个小时。这种时区问题隐蔽得很,第一次踩坑的时候我甚至怀疑是网络问题。
3.2 用 events.list 拉取预订详情
freebusy 只告诉你有事,不告诉是什么事。如果前端看板想显示“小会议室 10:00-11:00 产品评审会,预订人张三”,那就得用 events.list 去拉具体事件。
接口长这样:
code复制GET https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events?timeMin=...&timeMax=...&singleEvents=true&orderBy=startTime&maxResults=100
Python 代码:
python复制from urllib.parse import quote
event_url = (
"https://www.googleapis.com/calendar/v3/calendars/"
+ quote(calendar_id, safe="")
+ "/events"
+ "?timeMin=2025-01-01T00:00:00%2B08:00"
+ "&timeMax=2025-01-02T00:00:00%2B08:00"
+ "&singleEvents=true"
+ "&orderBy=startTime"
+ "&maxResults=100"
)
resp = authed_session.get(event_url)
events = resp.json().get("items", [])
for event in events:
summary = event.get("summary", "(无标题)")
creator = event.get("creator", {}).get("email", "")
start = event["start"].get("dateTime", event["start"].get("date"))
end = event["end"].get("dateTime", event["end"].get("date"))
print(summary, creator, start, end)
singleEvents=true 这个参数很重要。它可以让循环事件展开成具体的单次实例,比如一个每周一重复的例会,不加这个参数时返回的是一个带 recurrence 的母事件,你很难判断它本周一是否真的举行;加了之后,每次实例作为独立事件返回,时间判断就非常直接。
这里再提醒一个细节:orderBy=startTime 只有在设置了 singleEvents=true 时才是合法的,否则 API 会报错。如果你不关心顺序,干脆不要传 orderBy,避免多一个报错点。
3.3 前端大屏渲染的最小实现
后端拿到数据后,可以只暴露两个接口:一个返回房间列表,一个返回某一天各房间的占用区间。前端只需要一个 HTML 页面,用 JavaScript 的 setInterval 每隔一分钟请求一次数据,然后重绘时间轴即可。
我做的简化版逻辑如下:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>会议室预订看板</title>
<style>
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ddd; padding: 6px; text-align: center; }
.busy { background: #f28b82; }
.free { background: #ccf2d4; }
</style>
</head>
<body>
<h1>会议室预订状态</h1>
<table id="board">
<thead><tr><th>房间</th><th>9:00</th><th>10:00</th><th>11:00</th></tr></thead>
<tbody></tbody>
</table>
<script>
async function refresh() {
const resp = await fetch('/api/schedule?date=2025-01-01');
const rooms = await resp.json();
const tbody = document.querySelector('#board tbody');
tbody.innerHTML = '';
for (const room of rooms) {
const row = document.createElement('tr');
row.innerHTML = `<td>${room.name}</td>`;
const slots = [9, 10, 11];
for (const slot of slots) {
const occupied = room.intervals.some(iv => iv.start === slot);
row.innerHTML += `<td class="${occupied ? 'busy' : 'free'}">${occupied ? '占用' : '空闲'}</td>`;
}
tbody.appendChild(row);
}
}
setInterval(refresh, 60000);
refresh();
</script>
</body>
</html>
这个极简版本只演示了“整点占用”的判断,真正生产环境应该画连续时间轴。你可以用 Canvas 或者 SVG,或者直接用 CSS 绝对定位 div,把事件开始时间和结束时间换算成像素位置。画连续区间并不复杂,核心是:先把一天 24 小时映射成宽度百分比,然后事件 (start_time - 00:00) / 86400 * 100% 作为 left,(end_time - start_time) / 86400 * 100% 作为宽度。
3.4 刷新策略与配额控制
Calendar API 是有配额限制的,尤其是企业账号下未付费的 GCP 项目,每天可用配额有限。如果你有 30 个房间,每 30 秒轮询一次全量 events.list,一天下来就是 86400 次请求,这肯定超。所以刷新策略必须控制好。
我的做法是分三层:
- 第一层,前端 30 秒轮询一次后端接口,这个接口走本地内存缓存,不直接触发 Google API。
- 第二层,后端每 5 分钟通过 freebusy 批量刷新一次未来 4 小时的占用状态。freebusy 一次可以传最多 50 个日历 ID,所以 30 个房间其实 1 次请求就搞定。
- 第三层,每 15 分钟用 events.list 拉一次未来 24 小时的事件详情,用于展示标题和预订人。
如果你需要更高实时性,可以引入 Redis 或者 Memcached 做缓存,设置不同的过期时间。但别真从数据库读,因为数据本身就是日历状态,日历才是唯一数据源。
另外,如果多个房间需要逐个查 events.list,建议用并发但限制在 5 个以下,避免瞬间打爆配额。Python 里可以用 ThreadPoolExecutor(max_workers=5),如果你用 Node,可以用 p-limit 做并发限制。
4. 常见 API 错误排查实录
4.1 鉴权与权限类错误
我自己调试和帮同事看代码时,遇到最多的就是 401 和 403,报错信息大概分几种:
| 错误信息片段 | 原因 | 处理方法 |
|---|---|---|
401 Invalid Credentials |
服务账号 JSON 密钥错误或者时钟偏差太大 | 检查密钥文件路径,确认服务器系统时间正确 |
403 CalendarPermission |
模拟的目标用户没有该日历的权限 | 把目标用户添加到日历共享列表,或改用有权限的管理员账号 |
403 Domain policy |
域范围委派未配置或未生效 | 去管理后台核对服务账号客户端 ID 和 scope,等待几分钟到一小时 |
404 Calendar not found |
传入的 calendarId 不对 | 确认 resourceEmail 或日历地址完整,注意检查拼写 |
400 Invalid grant |
服务账号无法模拟指定用户 | 确认 subject 参数是真实存在于该域内的账号 |
我有个同事曾经在 401 上卡了一下午,后来发现是他把 JSON 密钥文件里的 client_email 和服务账号主体搞混了。在代码里,你指定 subject 参数时,填的是你要模拟的真实用户邮箱,不是服务账号的邮箱。这两个邮箱长得有点像,但服务账号通常带 .iam.gserviceaccount.com 后缀,千万别填错。
4.2 参数、时区与数据细节坑
API 返回 400 类错误,大概率是参数问题。Google Calendar API 对时间格式非常严格,必须是 RFC3339,而且建议带上时区偏移。如果你传 2025-01-01T09:00:00Z,代表 UTC 时间;在东八区就变成了当天 17 点,看板上显示会乱套。我习惯统一在服务端把输入转成 Asia/Shanghai 偏移格式,再传给 Google API。
另一个坑是循环事件。如果你没有加 singleEvents=true,events.list 返回的母事件可能只有一条,开始时间可能是几个月前或几年后的第一次,直接拿它去判断当前占用会出错。加了之后,展开出来的单个实例会有新的 id,这些实例不需要再去查一次,直接用就行。
分页也值得注意。maxResults 最大是 2500,但通常不建议设这么大。我的习惯是一次 250,然后判断返回里有没有 nextPageToken,有就继续请求下一页,防止数据被截断。如果只看未来几小时,一个房间通常不到几十个事件,250 已经足够,但别以为永远足够。
4.3 限流和过载错误的通用处理
调用第三方 API,总会碰到服务端返回 429、5xx 或者类似 api error: 529 overloaded 这样表示服务暂时过载的提示。这类错误有个共同特点:它不是你请求参数造成的,而是服务端繁忙或配额不足,通常是暂时性的。我的处理原则是重试 + 退避。
写一个带重试的请求函数,捕获 429、500、503、529 这类状态码,按指数退避重试三次,间隔分别是 1 秒、2 秒、4 秒。如果三次后还是失败,就返回一个“数据暂不可用”的状态,前端显示灰色降级,而不是让整块看板白屏。
python复制import time
import requests
def request_with_retry(func, *args, retries=3, **kwargs):
for attempt in range(retries):
resp = func(*args, **kwargs)
if resp.status_code in (200, 201):
return resp
if resp.status_code in (429, 500, 503, 529):
wait_time = 2 ** attempt
time.sleep(wait_time)
continue
resp.raise_for_status()
raise Exception(f"Request failed after {retries} retries")
过去我遇到过一次看板连续 10 分钟拉不到会议的占用情况,排查后发现不是 Google 的问题,而是网络出口不稳定。加上了重试机制和缓存降级后,即使 API 暂时连不上,看板也能显示最近一次成功获取的数据,用户体验会好很多。
另一个容易被忽略的点是:日志里一定要记录 X-RateLimit 相关的响应头或错误响应体的完整内容。Google API 的 403 错误响应体里会有一长串 JSON,其中 reason 字段可能写着 quotaExceeded、rateLimitExceeded 或 accessNotConfigured。不看完整响应体,光看状态码根本没法判断是权限问题还是配额问题。
5. 让“能看”变成“好用”的扩展思路
5.1 在预约页嵌入空闲时段查询
如果你不只是要展示,还想让别人在网页上直接预约,那可以在现有预约表单里加一个“查询空闲时段”按钮。前端把房间 ID 和日期发给后端,后端调 freebusy 拿到 busy 区间后,把空闲时段截出来返回给前端,用户就从空闲时段里选一个时间提交。
这个方案比直接展示预订状态更麻烦一点,因为你要处理跨天、午休不可预约时间等业务规则。我的做法是在配置表里维护一个“可预约时间段”列表,比如周一到周五 9:00-18:00,然后从可预约时间里减掉 busy 区间,得到真正可选的时间块。注意 Google 的 busy 区间是按 timeMin 到 timeMax 返回的,如果一个会议从 9:30 到 10:00,那这个区间都要从可预约时间里剔除,不能只看开始时间。
5.2 自动预订与释放
如果你需要一个机器人来帮助预约,可以用 Calendar API 的 events.insert 创建事件,并把资源日历作为参会人添加进去。这样会议室就会自动出现在事件的资源列表里,日历系统自己会做冲突检测。
创建事件时有一个容易踩的坑:如果你用服务账号模拟普通用户去创建事件,事件的 creator 会是那个模拟用户,而不是你希望显示的某个服务账号。另外,资源日历通常不允许被直接设置成事件的 organizer,正确做法是把它放在 attendees 里,带上 resource 字段标记。有些企业还设置了“不允许双重预订”的规则,如果冲突,API 会返回一个错误,此时要捕获并友好提示用户。
写权限比读权限风险高很多,建议先把自动预订做成只允许特定场景触发,比如管理员发起或表单校验通过后才调用,避免出现脚本误操作把会议室全部订满的情况。
5.3 利用率分析与提醒机器人
有了房间清单和占用数据之后,还能盘活一个数据资产:会议室利用率。把每天的 freebusy 结果落库,按周、按月统计每个房间的占用时长和空闲时长,就能看出哪些会议室经常被订满、哪些基本闲置。这个统计对于行政采购和工位规划很有价值。
另外,如果你接入了企业微信、钉钉或 Slack 机器人,可以把看板查询做成对话式接口。用户发送“帮我看看今天下午哪个会议室空着”,机器人调同一个后端接口,把空闲房间列表格式化后发出来。这部分不复杂,只要保证后端接口是 RESTful 风格的,返回 JSON,机器人组件只做一次字符串拼接就行。
我做的扩展里还有一个小功能:在会议开始前 10 分钟,如果有房间还没被“签到”,就发送一条提醒给预订人,让他确认是否按时开会。这个要用到事件查询和延迟任务,不是非做不可,但做完之后会议室被放鸽子的情况明显减少了。
最后分享一个经验:如果你打算长期维护一个会议室预订展示系统,先别急着写一堆高级功能,第一版把 freebusy 查询、events.list 详情、缓存这老三样做好,后面所有扩展都围绕这三个基础能力展开。我在实际使用中最大的教训就是一开始追求功能大而全,结果权限配置还没理顺就上了自动预订,导致测试时误创建了好几条无效事件,清理起来特别麻烦。先把只读看板跑稳定,再谈自动化,这才是最省心的路线。
