公司行政最近提了个需求:二十多间会议室都挂在Google Workspace日历上,前台和每层电梯口要放个屏幕,实时显示会议室当前有没有人订、下一场几点开始。接Google Workspace API来做这个“预订房间展示”的项目,前后花了差不多一周,把权限、接口、前端渲染整条链路跑通了。这篇文章不聊虚的,直接把方案选型、代码、报错排查全部摊开讲,准备用Google Workspace API做会议室预订/展示的同学可以直接照着抄。
先给一个整体结论:展示预订房间这件事,核心不是“写一个页面”,而是想清楚怎么用Google Calendar API读取会议室的实时占用状态,再决定是自己写接口还是用别人封装好的方案。房间能不能被API查到、服务账号有没有权限读日历,这两步决定了项目80%的成败。下面我按实际开发顺序来拆解。
1. 先理清需求边界:展示预订房间到底要展示什么
1.1 从最终效果反推API能力
接到需求别急着写代码,先花半天把“展示什么”问清楚。会议室展示屏,常见的展示内容有四类:全部房间列表、当前时刻每个房间的空闲/占用状态、每个房间当天的完整预订排期、未来的可预订时间段。这四类需求对应的API能力完全不一样。
- 房间列表:来自Admin SDK Directory API的“资源日历”接口。
- 空闲/占用状态:用Calendar API的freebusy一次批量查询,效率最高。
- 当天排期:用Calendar API的events.list,按时间范围拉事件列表。
- 未来可预订时间段:需要在events.list基础上自己算空档区间,稍微复杂一点。
我这次做的版本是前三者的组合:一层楼一块屏,屏幕默认展示该楼层所有房间的“当前状态”,点进去某个房间,再展示今天的会议排期。后来加了一个“未来两小时可预订”的快捷入口,需要在events.list返回的数据里做时间区间合并,这个后面细说。
1.2 为什么不自建数据库,而是直接读Google日历
很多人第一反应是:把Google日历的预订数据同步到自己的MySQL/PostgreSQL里,然后展示屏读自己的库。这个思路在“数据量极大、需要复杂报表”的场景下成立,但在会议室预订这个场景下纯属自找麻烦。
原因在于:预订行为的发生地是Google日历。用户在Outlook、手机日历、或自己的OA里发起会议邀请时,以“会议室资源”为参与人的事件会实时写入会议室的资源日历。你如果同步到自建库,就必然面临两个问题——实时性(同步延迟)和一致性(改期/取消的增量同步)。与其维护一套同步任务,不如展示层直接走API,Google日历就是唯一的真相源。
当然,纯API方案也有代价:每次页面刷新都要请求Google,对配额敏感。所以展示层要做缓存和合理的轮询周期,这个我在第4部分会讲。
1.3 链路选型:直连API还是套一层服务端
能不能在浏览器里直接调Google Calendar API?技术上可以,但我不建议。第一,前端直连必须暴露API Key,虽然可以把Key限制到指定域名,但会议室数据毕竟是公司内部信息,暴露Key等于把读取权限开放给了任何能访问页面的人。第二,Google API的CORS策略、配额管理,在纯前端场景很难精细控制。第三,你迟早要加“创建预订”“取消预订”这类写操作,写操作不能靠前端直连。
所以推荐的架构是:浏览器 → 自己的后端服务 → Google Workspace API → 返回JSON → 前端渲染。后端只暴露几个业务接口,比如“获取房间列表”“获取某房间当天排期”,Google的鉴权信息全部收在后端,前端只拿业务数据。这个架构一开始就定型,后面加功能非常顺。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开工前必须做好的账号与权限准备
2.1 Google Cloud项目与API启用
打开Google Cloud控制台(console.cloud.google.com),新建一个项目,比如“room-display”。这个项目就是你和Google API之间的“租户”,所有的凭据、配额、API开关都在这里管理。
接下来要启用两个API:
- Google Calendar API:房间日历事件的读写,核心中的核心。
- Admin SDK API:用来读取会议室资源列表,也就是“哪些房间存在”。
如果你只需要读不需要写,Calendar API可以只开读权限范围的凭据,但API本身还是要启用。在“已启用的API和服务”里搜索这两个API,逐个Enable。这一步不做,后面所有请求都会返回404或403。
2.2 三种凭据怎么选:API Key、OAuth还是服务账号
Google提供了几种凭据类型,很多人第一次做容易混。我的选择逻辑是这样的:
| 凭据类型 | 适用场景 | 是否能读会议室日历 | 是否要人工授权 |
|---|---|---|---|
| API Key | 访问公开数据、地图等 | 不能,Calendar API不支持纯Key访问私人日历 | 不需要 |
| OAuth 2.0 | 代表某个用户操作 | 能,但需要用户手动同意授权弹窗 | 需要 |
| 服务账号 | 代表应用自身访问域内数据 | 能,配合域范围委派后无需弹窗 | 需要在管理后台授权一次 |
会议室展示屏是一个“无人值守”的后台程序,没有用户坐在那里点“授权”。所以服务账号是唯一正确选择。在Cloud控制台创建服务账号,下载JSON格式的私钥,这个JSON文件就是后端程序的身份证明。注意,服务账号本身有自己的邮箱地址,格式类似 room-display@your-project.iam.gserviceaccount.com,后面授权要用到。
2.3 Scope:最容易翻车的权限声明
Google API的权限控制用的是OAuth Scope机制。Scope就是一段URL字符串,代表“请求方想访问哪类数据”。常见的有:
https://www.googleapis.com/auth/calendar.readonly:只读日历。https://www.googleapis.com/auth/calendar:读写日历,包含删除。https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly:只读会议室资源列表。https://www.googleapis.com/auth/admin.directory.resource.calendar:读写会议室资源。
Scope给多给少都有问题。给少了,API直接拒绝说权限不足;给多了,安全风险大。我的建议是:如果只是做展示屏,统一用readonly版本,一行只读代码都不会用到写权限。只有当你确实要做“在页面上直接预订”的功能时,才在服务账号上额外加calendar写权限。Scope是通过代码里加载到Credentials对象上的,服务账号的JSON文件本身不绑Scope,这点和OAuth客户端不一样,代码里写什么Scope就请求什么权限。
2.4 把服务账号变成“有权看日历的人”
这是整个项目最隐蔽的坑,没有之一。服务账号创建成功,并不代表它能读会议室日历。默认情况下,服务账号只是一个“域外的幽灵账号”,它对域内数据没有任何权限。
要让服务账号读取公司域内的资源日历和资源列表,需要两步授权:
-
域范围委派(Domain-wide Delegation):在Google Workspace管理后台,进入“安全性 → API控件 → 管理域范围委派”,把服务账号的Client ID和上面列出的Scope填进去。这样服务账号就能代表域内用户访问数据了。
-
把会议室日历共享给服务账号:有些人做了域范围委派还是403,缺的就是这一步。在Google日历里,找到会议室资源对应的日历,设置“共享给特定用户”,填入服务账号的邮箱,权限设为“查看所有活动详情”。
顺序不能反,先做域范围委派,再共享日历。我踩过最深的坑就是:域范围委派配好了,但忘了几十间会议室日历的共享权限,结果查的每个房间都返回403,排查到怀疑人生。后来写了个脚本批量把所有资源日历逐个共享给服务账号,才彻底解决。
3. 核心接口实操:查房间、读状态、订房间
3.1 拉取会议室资源清单
启用Admin SDK API并配好权限后,第一步是把会议室资源列表拉出来。调用方式是GET请求:
code复制GET https://admin.googleapis.com/admin/directory/v1/customer/my_customer/resources/calendars
Python代码示例(用google-auth库和requests):
python复制import requests
from google.oauth2 import service_account
from google.auth.transport.requests import Request
SCOPES = [
'https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly',
]
credentials = service_account.Credentials.from_service_account_file(
'service_account.json', scopes=SCOPES)
credentials.refresh(Request())
access_token = credentials.token
resp = requests.get(
'https://admin.googleapis.com/admin/directory/v1/customer/my_customer/resources/calendars',
headers={'Authorization': f'Bearer {access_token}'},
params={'maxResults': 100}
)
calendars = resp.json().get('items', [])
for cal in calendars:
print(cal['resourceName'], cal['resourceEmail'], cal.get('buildingId'))
返回的数据里,最关键的字段是resourceEmail,形如meeting-room-01@resource.calendar.google.com。这个邮箱就是“会议室日历的ID”,后面所有查询房间状态的请求都拿它当参数。所以在设计数据库或缓存时,建议直接用resourceEmail作为Room的唯一标识。
还有个点要提:如果会议室很多,Admin API的列表接口默认分页,maxResults最大可以调到500。要做成定时同步任务,把房间清单同步到本地缓存,避免每次展示都去调Admin API(这个接口的配额比Calendar API紧得多)。
3.2 用freebusy批量查房间实时状态
“当前房间有没有被预订”这个需求,用Calendar API的events.list当然也能查,但有一个接口是专门为它设计的:freebusy。它的好处是支持一次请求传多个日历ID,批量返回时间段内每个日历的忙碌区间,快且省配额。
请求地址是:
code复制POST https://www.googleapis.com/calendar/v3/freeBusy
请求体长这样:
json复制{
"timeMin": "2025-01-06T00:00:00+08:00",
"timeMax": "2025-01-06T23:59:59+08:00",
"timeZone": "Asia/Shanghai",
"items": [
{"id": "meeting-room-01@resource.calendar.google.com"},
{"id": "meeting-room-02@resource.calendar.google.com"}
]
}
Python实现:
python复制import requests
def query_room_busy(room_emails, time_min, time_max, access_token):
resp = requests.post(
'https://www.googleapis.com/calendar/v3/freeBusy',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
},
json={
'timeMin': time_min,
'timeMax': time_max,
'timeZone': 'Asia/Shanghai',
'items': [{'id': email} for email in room_emails]
}
)
return resp.json()['calendars']
返回结构里,每个日历对应一个busy数组,数组里每个元素是{start, end}的区间。如果busy为空数组,说明该房间在查询区间内完全空闲。
这里有一个非常重要的判断方式:判断“当前是否占用”,不是看现在有没有事件,而是看当前时间点是否落在某个busy区间内。比如现在是10:30,返回的busy区间是10:00-11:00,那这个房间就是占用中;如果busy数组为空,就是空闲。这个逻辑写好之后,前端展示的红绿状态就非常准确了。还要注意,因为Google日历支持“临时时间”和“全天事件”,freebusy返回的区间可能很碎,如果你做的是“未来可预订时间段”功能,需要做区间合并和反向取空闲区间。
3.3 用events接口创建和取消预订
展示屏跑通之后,行政大概率会追加一个需求:直接在屏上订下一场会议。这就涉及写操作了。创建预订的推荐方式是:把新事件插入到“预定者自己的日历”或系统专用日历,并把会议室资源作为参会人(attendee)带上。这样资源日历上会自动出现这个事件,实现占房。
python复制event_body = {
'summary': '产品周会',
'description': '每周产品例会',
'start': {
'dateTime': '2025-01-06T10:00:00+08:00',
'timeZone': 'Asia/Shanghai'
},
'end': {
'dateTime': '2025-01-06T11:00:00+08:00',
'timeZone': 'Asia/Shanghai'
},
'attendees': [
{'email': 'meeting-room-01@resource.calendar.google.com', 'resource': True},
{'email': 'zhangsan@company.com'}
]
}
resp = requests.post(
'https://www.googleapis.com/calendar/v3/calendars/zhangsan@company.com/events',
headers={'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json'},
json=event_body
)
注意两个细节。第一,resource: True必须带上,Google才能识别这个是房间资源而不是普通参会人。第二,calendarId参数填的是“在哪个日历上创建事件”,这里填的是发起人自己的日历,而不是房间日历;房间日历会自动收到该事件的邀请。如果填房间日历,也能创建成功,但后续的冲突检测和资源管理行为会和标准流程不一致。
取消预订用DELETE请求,endpoint是:
code复制DELETE https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events/{eventId}
修改预订用PATCH,只传需要改的字段。我建议在写操作前先用freebusy查一下目标时间段是否已被占用,因为Calendar API在创建事件时不会自动阻止“房间时间冲突”——两个会议可以在API层面同时关联同一个房间资源,资源日历上会出现双订,这种事在系统里发生了非常尴尬。所以正确顺序永远是:先freebusy查空闲,再events.insert创建,中间可以加个简单的乐观锁(比如用Redis记录最近1秒的创建请求)。
3.4 参数细节与返回结构解读
用错误的时间格式是新手最常见的报错。Google Calendar API要求时间必须是RFC3339格式,比如2025-01-06T10:00:00+08:00。如果你传2025-01-06 10:00:00或者不带时区偏移,API直接400。另外,API里timeMin和timeMax这两个参数,如果你只传timeMin不传timeMax,返回结果会按默认范围截断,很容易漏掉数据;反之亦然,两个都传才最稳妥。
读取事件列表时还有一个很容易被忽视的字段:singleEvents。如果你不设这个参数,events.list返回的是“重复事件规则”,一条规则代表一个系列的会议,你拿到手还要自己展开成具体实例;设成true后,API直接返回展开后的单个事件,展示屏用起来省很多事。orderBy设成startTime后,返回结果会按开始时间排序,做“即将开始”列表时非常方便。
4. 把状态真正“展示”出来:三种前端集成方案
4.1 方案A:Python后端渲染HTML页面
如果公司内部已经有一套Python服务,最自然的做法是在后端把Google API的数据拉好,渲染成HTML片段,前端定时刷新。我这里用的Flask,核心路由就两个:
python复制from flask import Flask, jsonify, render_template
import requests
app = Flask(__name__)
@app.route('/api/rooms')
def rooms():
rooms = get_room_list_from_cache()
busy_result = query_room_busy(
[r['resourceEmail'] for r in rooms],
now_iso(), now_plus_2hours_iso(),
get_access_token()
)
data = []
for room in rooms:
busy = busy_result.get(room['resourceEmail'], {}).get('busy', [])
data.append({
'id': room['resourceEmail'],
'name': room['resourceName'],
'occupied': len(busy) > 0,
'next_free_time': busy[0]['end'] if busy else None
})
return jsonify(data)
@app.route('/')
def index():
return render_template('rooms.html')
前端用一个简单的轮询循环,每30秒或60秒请求一次/api/rooms,更新屏幕上的红绿状态。注意,轮询的是你自己的后端,而不是Google API,这样Google配额压力很小,用户体验也流畅。如果你有多个展示屏,建议在后端做一层内存缓存,比如缓存10秒,避免10块屏同时刷新时后端并发打满Google配额。
4.2 方案B:用Apps Script做零运维Web App
如果公司没有后端服务,或者不想维护服务器,Google Apps Script是一个非常好的轻量方案。它本身可以发布成Web App,提供一个URL,任何人都能访问;它也能用Calendar API访问会议室日历。这样“展示预订房间”这个小工具就可以做到零运维、零成本。
javascript复制function doGet(e) {
const roomId = 'meeting-room-01@resource.calendar.google.com';
const now = new Date();
const end = new Date(now.getTime() + 60 * 60 * 1000);
const events = Calendar.Events.list(roomId, {
timeMin: now.toISOString(),
timeMax: end.toISOString(),
singleEvents: true,
orderBy: 'startTime'
});
const output = events.items.map(function(ev) {
return {
title: ev.summary,
start: ev.start.dateTime,
end: ev.end.dateTime
};
});
return ContentService.createTextOutput(JSON.stringify(output))
.setMimeType(ContentService.MimeType.JSON);
}
这段代码里,doGet是整个Web App的入口,只要访问Web App的URL,就会返回该房间接下来一个小时的会议JSON。实际落地时,建议在doGet里加一个room参数,比如?room=meeting-room-02,就能做一个通用的房间信息查询接口。Apps Script发布时选择“任何人(使用匿名身份)或公司内部用户访问”,配合前面提到的域范围委派,就能在无登录状态下拿到数据。要注意Apps Script有每日触发器配额,展示屏轮询建议至少间隔30秒以上。
4.3 方案C:iframe直接嵌Calendar视图
如果想最快看到效果,还有一个“懒人方案”:Google Calendar本身提供嵌入视图。在日历设置里找到“嵌入日历”,会生成一段iframe代码,放进网页就能显示月视图或周视图,其中就包含会议室资源日历的事件。
这个方案的优点是完全不用写API代码,缺点也很明显:样式不能定制,只能显示Calendar默认的格子;不能做到“按房间卡片展示红绿状态”;用户点进去还能对日历进行修改操作,风险较大。我的建议是:只用于快速验证需求和给领导看效果,正式产品不要用iframe方案。
4.4 刷新频率、缓存和并发设计
展示屏是公共大屏,没人会去“刷新页面”,所以数据必须自己更新。我最后采用的策略是:前端30秒轮询后端一次,后端每次收到请求后,优先返回10秒内的缓存,只有当缓存过期时才去请求Google API。这个策略下,一台房间数量在30间的展示屏,每天对Google API的请求量大约是2000次左右,远低于Calendar API的默认配额,很安全。
如果要进一步降低配额压力,可以按整点/半点设置一个定时任务,把下一小时每个房间的占用状态提前算好存到Redis或内存里,展示屏只读预计算结果。这个方案对“未来2小时可预订”功能尤其有用,因为计算未来空档需要拉取多个房间的排期,计算量大,不适合在页面请求时实时算。
5. 报错排查与避坑实录
5.1 529 Overloaded:服务端过载时怎么办
如果你在调Google API时看到类似api error: 529 overloaded. this is a server-side issue, usually temporary的报错,先别去改代码。这是Google服务端临时过载的返回码,说明你的请求没问题,是Google那边“堵车”了。最常见的诱因是短时间内的并发请求量过大,比如展示屏刚上线时,十几块屏同时启动,后端一次性发出几十个freebusy请求。
遇到529,有效策略是“指数退避重试”:第一次失败后等1秒重试,第二次等2秒,第三次等4秒,最多重试3-4次。不要做“失败后立刻无脑重试100次”的操作,那只会让服务端过载更严重,还可能触发限流。同时在代码里把529和正常的4xx区分开,529到了重试上限就返回缓存数据,不要直接报错给前端。
5.2 403权限和400参数错误:从报错文本里定位
掌握一个排查原则:Google API的报错body里必然有reason字段,先看reason。
403+reason: insufficientPermissions:说明Scope不够,或者服务账号没有被授权。检查两处,一是代码里加载的Scope是不是只读的,二是管理后台域范围委派里是否把该Scope加上了。403+reason: forbidden:说明身份是通的,但对某个具体日历没权限。这几乎都是因为“日历共享”这一步忘了做。检查房间日历是否共享给了服务账号。400+reason: invalidParameter:说明请求参数有问题。优先检查时间格式是不是RFC3339、resourceEmail有没有拼错、attendees数组里有没有空对象。
这三个原因占了我整个调试过程90%的报错,其余都是网络或时区问题。
5.3 配额超限:请求规划与配额提升
Google Calendar API默认配额一般是每用户每秒若干次请求、每天也有总配额限制。展示屏场景下,用户是服务账号,配额按“服务账号”统计。如果你严格按照我前面说的缓存策略,基本碰不到配额上限。但如果你写了个bug,比如每个房间都单独调一次events.list而不是用freebusy批量查,那几十个房间一次轮询就要几十次请求,很容易触发429 Too Many Requests。
一旦触发429,Google会在响应头里返回Retry-After,告诉你要等多少秒。代码里捕获这个字段,按它提示的时间等待后再重试,比任何自定义退避都准确。如果项目确实需要更多配额,可以在Google Cloud控制台的“IAM和管理 → 配额”页面提交配额提升申请,说明用途,一般一两个工作日就能批下来。
5.4 高频问题速查表
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 请求返回404,接口不存在 | API未启用 | 去Cloud控制台启用Calendar API/Admin SDK API |
| 请求返回401 Unauthorized | 令牌未刷新或无效 | 检查credentials.token;服务账号JSON是否加载正确 |
| 403 insufficientPermissions | Scope不足 | 在代码里增加读/写Scope,并更新域范围委派 |
| 403 forbidden | 日历未共享给服务账号 | 在Google日历中把房间日历共享给服务账号邮箱 |
| 400 invalidParameter | 时间格式错误 | 统一使用RFC3339格式,带时区 |
| 429 Too Many Requests | 请求频率超限 | 按Retry-After等待,加缓存、改批量接口 |
| 529 Overloaded | Google服务端过载 | 指数退避重试,3-4次后返回缓存 |
| 房间状态显示不准 | 时区没传 | freebusy和events.list的timeZone统一设置 |
在实际开发过程中,我最深的体会是:Google Workspace API这套体系,权限链路是“能力”和“身份”两件事,能力靠API启用和Scope声明,身份靠服务账号和共享授权。很多同学卡在403半天,都是因为在管理后台少做了一个动作。建议你刚开始做的时候,只拿一个测试房间日历跑通全链路,确认无误后再批量加到生产环境的房间列表。后续如果你想扩展,这个方案还可以加“会议开始前15分钟在大屏上自动弹提醒”之类的功能,无非是在现有事件读取能力上再做一层定时任务,架构不用动。
