后端与接口
在 Caffeine AI 应用中接入用户自己的…
当用户、规格或既有任务提到日程、日历事件、预约、会议、"加入日历"等需求,且需要在 Caffeine AI canister 中代表已登录用户读写其 Google Calendar 时使用。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 38/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 59/100 ×0.15
适用场景
在 Caffeine AI 应用中接入用特定
解决思路
- 添加依赖:`mops add googlecalendar-client@0.2.0`、`mops add google-oauth@0.2.1`、`mops add caffeineai-authorization@1.0.1`。
- 唯一支持路径是 `googlecalendar-client` + `google-oauth` 组合;禁止手写 `ic.http_request` 直接调用 `oauth2.googleapis.com` 或 `www.googleapis.com/calendar/v3`。
- 采用 OAuth 2.0 Authorization Code + PKCE 流程,每个终端用户独立授权:canister 生成 `code_verifier`/`code_challenge`(`OAuth.generateCodeVerifier` / `OAuth.computeCodeChallenge`),用 `OAuth.buildAuthorizeUrl` 构造授权 URL,前端跳转 Google 同意后带 `code` 回调,canister 在链上非复制地调用 `OAuth.exchangeAuthorizationCode` 换取 token,并按 `caller : Principal` 存储 `access_token` + `refresh_token`。
- Google Cloud Console 配置:创建 OAuth 2.0 **Web application** 客户端;在应用设置页展示可复制的回调 URI `window.location.origin + "/connect/calendar"`,管理员手动将其填入 Google Cloud Console 的 Authorized redirect URIs;为每个部署源(draft、live 等)分别注册;只启用所需 Calendar scope;通过应用管理页输入 Client ID 与 Client Secret,secret 只留在 canister,绝不返回前端。
- OAuth scope 选择:典型 CRUD 应用请求 `https://www.googleapis.com/auth/calendar`(完整读写);只读视图用 `calendar.readonly` 或 `calendar.events.readonly`;仅事件读写用 `calendar.events`。
- Token 存储:用 `Map<Principal, CalendarConnection>` 按 caller 存储;只暴露 `isMyCalendarConnected`、`startCalendarOAuth`、`completeCalendarOAuth`、`listUpcomingEvents`、`createEvent`、`disconnectMyCalendar` 这些端点,全部以 `not caller.isAnonymous()` 为门禁;绝不添加返回 `access_token`/`refresh_token`/完整 `CalendarConnection` 的端点。
- 每个 caller 只保留一个待处理 OAuth 流程:PKCE `code_verifier`、精确 `redirectUri`、随机 `state` nonce;回调完成时消费它,不接受前端替换 redirect URI。
- Google 的 refresh token 不会轮换:刷新时只持久化新的 `access_token`,保留原 `refresh_token`。
- 所有 `Config` 必须显式设置 `is_replicated = ?false`(0.2.0 起包已默认,但仍显式写出以保持可见):复制 HTTP outcall 会从子网每个节点发出并携带 Bearer 头(安全风险)、产生 N 倍调用与约 13 倍 cycles 计费、且写响应非确定性会导致共识失败。
- 从 0.1.x 升级时检查代码:0.1.x 的 `defaultConfig` 中 `is_replicated = null` 意味着复制,14 个 POST 操作(如 `events.insert`、`events.quickAdd`、`calendars.insert`)会继承默认值,导致每次调用创建约 13 个重复事件。
- 按规范布局组织代码:`src/backend/main.mo`(actor:状态 + include)、`src/backend/mixins/calendar-config.mo`(管理员门禁的 Client ID + Secret)、`src/backend/mixins/calendar-messaging.mo`(每用户 OAuth + 事件操作)、`src/backend/lib/calendar.mo`(`googlecalendar-client` + `google-oauth` 胶水)。
- 迁移链头 `migrations/00000000_000000.mo` 初始化 `accessControlState`、`calendarConfig`(空 clientId/clientSecret)、`calendarConnections`、`pendingCalendarFlows`。
- `calendar-config.mo` 提供 `isCalendarConfigured()` 查询和 `setCalendarCredentials(clientId, clientSecret)`,后者用 `AccessControl.hasPermission(..., #admin)` 门禁,非管理员 `Runtime.trap`。
- `calendar-messaging.mo` 中 `startCalendarOAuth(redirectUri)` 校验非匿名且已配置,调用 `LibCalendar.startAuthorize`;`completeCalendarOAuth(code, state)` 校验非匿名、已配置、存在 pending 流程且 `state` 匹配,移除 pending 后调用 `LibCalendar.exchangeCode` 并存入连接。
- `lib/calendar.mo` 中 `configForToken` 使用 `{ defaultConfig with auth = ?#bearer(token); is_replicated = ?false; max_response_bytes = ?Nat64.fromNat(2_000_000) }`。
- `refreshIfNeeded` 辅助函数:仅当错误信息包含 `401` 或 `Unauthorized` 时调用 `OAuth.refreshAccessToken`,更新 map 中的 `accessToken` 并返回新 token;否则 `Runtime.trap`。
- `listUpcomingEvents` 调用 `calendar_events_list`(`calendarId = "primary"`、`alt = ?#json`、`maxAttendees = 10`、`maxResults` 由调用方传入、`orderBy = ?#starttime`、`singleEvents = true`),捕获异常后走 `refreshIfNeeded` 并重试一次;`timeMin` 必须为 RFC 3339,`timeMax` 传 `""` 表示开放式。
- `eventSummariesOf` 提取 `id`、`summary`、`start`/`end`(优先 `dateTime`,否则 `date`)、`isAllDay`(有 `date` 无 `dateTime`)、`transparency`、`eventType`,供前端区分真实会议与标记。
- 可用性/忙碌时间必须用 FreeBusy(`calendar_freebusy_query`)而非 `calendar_events_list`:一次 POST 返回跨日历合并后的忙碌区间,循环事件已在服务端展开,无需分页、展开或手动合并。
- `busyTimes` 辅助函数:构造 `FreeBusyRequest`(`items = [{ id = "primary" }]`、`timeMin`/`timeMax`),调用 `calendar_freebusy_query`,401 时刷新重试一次;响应 `calendars` map 以**解析后的日历 ID(用户邮箱)**为键而非 `"primary"`,必须遍历**所有**返回的日历并合并其 `busy` 区间。
- 解析 RFC 3339 必须用 `LibCalendar.rfc3339ToNanos`(`mo:google-oauth/DateTime` 的再导出),不要手写解析器:忘记减 `'0'`(48)会把 `"2026"` 读成 `55354`,导致忙碌区间落在错误年份、重叠检查永不匹配、可用性静默出错(能编译且不 trap)。
- 可用性计算全程用纳秒整数:用 `LibCalendar.rfc3339ToNanos` 锚定窗口,用整数算术构建候选槽位网格,用 `LibCalendar.isSlotFree` 过滤,再用 `LibCalendar.nanosToRfc3339` 格式化回 RFC 3339 文本,可直接传给 `createEvent`。
- `createEvent` 构造 `EventDateTime`(`init {}` 后 record-update 设置 `dateTime`)和 `Event`(设置 `summary`、`start`、`end`),调用 `calendar_events_insert`(`calendarId = "primary"`、`alt = ?#json`、`maxAttendees = 10`、`sendUpdates = ?#all`),401 时刷新重试一次,返回 `created.id ?? ""`。
- 0.2.0 起可选枚举查询参数改为 `?T`(如 `alt`、`orderBy`、`sendUpdates`),调用点需加 `?`(如 `?#json`、`?#starttime`、`?#all`),传 `null` 表示省略;非枚举参数不变(空 `Text` 和 `0` 仍表示省略)。
- 0.2.0 新增 `src/Client.mo` 嵌套类门面(`cal.events.insert`、`cal.freebusy.query_` 等,注意 `query_` 因 `query` 是 Motoko 关键字被转义);门面方法为 `async`,而扁平 `Apis/*.mo` 自由函数为 `async*`,在 `async*` 辅助函数内门面会多一个 `await` 边界,故 §4 的 mixin 布局保持扁平形式。
- cycles 与响应大小:`google-oauth` 用 `mo:ic/Call` 的 `Call.httpRequest` 自动附加所需 cycles,无需手动预算;`googlecalendar-client` 的 `defaultConfig.cycles = 30_000_000_000`,典型 list/insert 约 10–15B cycles;事件列表读取设 `max_response_bytes = ?2_000_000`。
- 前端必须交付四项(验收标准):登录流程(来自 `extension-authorization`,Calendar 必须有非匿名 caller);管理员设置页 `/settings/calendar`(含 Google 凭据获取步骤说明、可复制的重定向 URI 只读字段、Client ID/Secret 密码输入、`isCalendarConfigured()` 状态指示,并在主导航中为管理员提供链接);`/connect/calendar` 真实回调路由(处理未配置情况、绑定 `startCalendarOAuth(calendarRedirectUri())`、从 `URLSearchParams` 读取 `error`/`code`/`state`、等待 actor 就绪后再 `await completeCalendarOAuth(code, state)`、完成后 `history.replaceState` 清除查询参数、`isMyCalendarConnected()` 状态、可选断开按钮);主页面日历 UI(`listUpcomingEvents(new Date().toISOString(), "", 10)`、创建事件表单将 `datetime-local` 值经 `new Date(...).toISOString()` 转为 RFC 3339、未连接时内联链接到 `/connect/calendar`)。
- 管理员设置页必须渲染实际 URI 到只读可复制字段,使用单一共享辅助函数 `const calendarRedirectUri = () => window.location.origin + "/connect/calendar";`,绝不显示 `<app-domain>` 占位符或让管理员自行拼接。
- `/connect/calendar` 必须是真实应用路由,不能落入 catch-all 重定向或首页;回调处理必须在导航或清除 URL 之前完成,且必须等待 `useInternetIdentity().isAuthenticated` 与 `useActor(createActor)` 提供非 null、非 fetching 的 actor 后再调用一次性 `completeCalendarOAuth`,不要过早设置 `startedRef`/一次性守卫。
- 当应用同时使用 Gmail 与 Calendar 连接器时,构建**一个**共享 Google 连接:一个 `/settings/google` 管理页(单一 Client ID/Secret 表单、单一 `isGoogleConfigured` 状态、显示 `window.location.origin + "/connect/google"` 的可复制字段)、一个 `/connect/google` 回调路由;不要构建 `/settings/gmail`、`/connect/gmail`、`/settings/calendar`、`/connect/calendar`。
- 合并应用的 backend 共享流程只写一次:一个 `#admin` 门禁的配置 setter、`SCOPES` 为并集(`openid email` + `gmail.send` + `calendar`,仅当应用读邮件才加 `gmail.readonly`)、`completeGoogleOAuth` 用 `OAuth.getUserEmail` 获取邮箱并存储 `{ accessToken; refreshToken; emailAddress }` 到单一 `Map<Principal, GoogleConnection>`;Gmail 与 Calendar 各自从同一 `accessToken` 构建自己的 `Config` 并保留各自的 401 刷新重试;把共享的 config 与 connection 作为参数传给两个 mixin,不要各自声明。
- 所有 Calendar 相关路由都要求登录,通过 `extension-authorization` 的 auth guard(`useInternetIdentity` + `!isAuthenticated` 时重定向)接入。
- 前端绝不持久化 token:不用 `localStorage`、`IndexedDB`、cookie;浏览器只看到 `Bool` 状态标志和 OAuth 重定向 URL。
- OAuth `state` 由 canister 生成并校验,前端必须把 `code` 和 `state` 都传给完成调用,绝不自行创建或修改。
- 日历 UI 保持简单:即将到来事件列表 + 含 summary 与起止 datetime 输入的创建表单;不使用客户端 Google SDK、不处理 token、不做 JSON 序列化。
- `createEvent` 传入的 `dateTime` 必须带 UTC 偏移(`…Z` 或 `…+02:00`),或同时设置 `EventDateTime.timeZone`(IANA 名称如 `"Europe/Zurich"`);裸 `2026-07-10T15:00:00` 会被 Google 拒绝。
- 全天事件用 `EventDateTime.date`(`YYYY-MM-DD`)而非 `dateTime`。
- `calendarId = "primary"` 指认证用户的默认日历;命名/共享日历用其日历 ID(类似邮箱地址)。
- `maxAttendees` 与 `maxResults` 必须 ≥ 1,Google 对 0 返回 HTTP 400;`listUpcomingEvents` 与 `createEvent` 传 `maxAttendees = 10`,任何 events 端点都不要传 0。
- HTTP 429 限流时把错误暴露给调用方,绝不在 canister 内静默重试写操作(可能创建重复事件)。
- `alt = ?#json` 用于所有 Calendar API v3 调用;可选字符串参数留 `""`,`prettyPrint = false`。
- 查询参数按位置传递:`Text` 传 `""`、`Bool` 传 `false`、`Nat` 传真实数字(遵守文档最小值),可选枚举传 `?#json`/`?#starttime`/`?#all` 或 `null` 省略;非枚举参数不接受 `null`。
- 用 `init {}` 后 record-update 构建 `Event`/`EventDateTime`,所有字段都是可选 `?T`,其余留 null。
- PATCH/PUT/DELETE 在生成客户端中已强制非复制;GET/POST 需在 `Config` 中显式设置 `is_replicated = ?false`。
- 回调 URI 必须精确匹配:每个字符(尾斜杠、查询串、端口)都要在授权 URL 与重定向之间一致,否则 Google 返回 `redirect_uri_mismatch`;用固定的 `window.location.origin + "/connect/calendar"`,不要用 `window.location.pathname` 构造。
- 把设置页显示的值原样传给 `startCalendarOAuth`,绝不传原始 `*.icp0.io` canister URL;在**一个**共享辅助函数中计算重定向 URI,设置页可复制字段与 `startCalendarOAuth` 都用它。
- `/settings/calendar` 页面必须可达:在共享 Layout 主导航中当 `isCallerAdmin` 为 true 时显示链接,否则隐藏;不要只依赖导航,未配置提示是用户发现需要设置的主要途径。
适用边界 · 注意事项
- 不使用 Caffeine AI 平台、不需要在 canister 中代表已登录用户操作其 Google Calendar 的场景。
- 只需要从应用发送 iCalendar 邀请邮件(应使用平台 `email-calendar-events` 扩展)而非操作用户自己的 Google Calendar。
- 无法提供 Google Cloud OAuth 2.0 Web application 客户端(Client ID/Secret)或无法注册重定向 URI 的场景。