Files
obsidian-vault/projects/feishu/飞书用户态接口授权流程(Auth -> Callback -> DataApi).md
yuxuanhui dcd6d44960 feat: add Feishu user authorization flow documentation and update frontend guidelines
- Added new documentation for the Feishu user authorization flow, detailing the backend protocol, authorization initiation, callback handling, and token management.
- Updated frontend index to link to the new authorization flow documentation for better accessibility and guidance on UI design consistency.
2026-08-31 09:18:03 +08:00

9.9 KiB
Raw Permalink Blame History

后端提供协议,前端提供组件。任何用户态数据接口(DataApi)在令牌缺失时返回统一错误码 FS_AUTH_REQUIRED,前端拦截器识别后驱动授权流程,授权完成自动重放原请求。业务方感知为零。

授权发起方式变更:废弃「后端发送飞书卡片消息引导授权」的交互(POST /fs/auth/sendAuthCard 不再用于本流程)。改为后端提供纯数据接口「准备授权」,前端拿到授权 URL 后自行决定打开方式与 UI 呈现。IM 卡片仅保留为后台任务(如公共用户令牌失效)的兜底通知手段。

sequenceDiagram
    participant U as 用户
    participant FE as 前端组件
    participant GW as appcenter 网关
    participant FS as 飞书开放平台

    U->>FE: 触发功能(如搜索云文档)
    FE->>GW: POST /fs/docs/search
    GW->>GW: mainUserId -> openId -> Redis 取 user_access_token
    alt 令牌缺失/失效
        GW-->>FE: code=FS_AUTH_REQUIRED { corpNo, appId, scopes }
        FE->>FE: 弹授权引导层(用户点击触发,防浏览器拦截)
        FE->>GW: POST /fs/auth/prepare { corpNo, appId, scopes }
        GW->>GW: 生成 authId,授权会话预存 Redis(绑定 mainUserId)
        GW-->>FE: { authId, authUrl }(state=authId)
        FE->>FS: window.open / applink 打开授权页
        U->>FS: 点击授权
        FS->>GW: 回调 /anonymous/fs/auth/callback.cl?code&state
        GW->>FS: code 换 token(authen/v2/oauth/token)
        GW->>GW: 缓存 per-user token(+refresh_token) + 授权结果
        GW-->>FS: 返回自动 window.close() 页面
        loop 轮询(2s 间隔,120s 超时)
            FE->>GW: GET /fs/auth/authResult?messageId=authId
        end
        GW-->>FE: FSUserInfoCO(授权成功)
    end
    FE->>GW: 重放 POST /fs/docs/search
    GW-->>FE: 搜索结果

2. 后端协议设计

2.1 统一未授权信号(改造点 A)

新增响应码 FS_AUTH_REQUIRED(AppCenterResponseCode)。所有用户态 DataApi 在取不到有效 user_access_token 时返回:

{
  "success": false,
  "code": "FS_AUTH_REQUIRED",
  "message": "需要飞书授权",
  "data": {
    "corpNo": "FEBG3X19R5J",
    "appId": "cli_xxx",
    "scopes": ["search:docs:read", "drive:drive:readonly"]
  }
}
  • 此响应只携带授权上下文;authId 由前端随后调用 /fs/auth/prepare(见 2.2)时生成,充当授权流程的 state 与结果查询的 messageId。
  • 落地方式:在 BaseFeishuController 增加 requireUserToken(corpNo, appId, openId, scopes) 帮助方法,DataApi 统一调用,避免每个 Controller 重复判断。
  • 首个改造对象:FSDocsController.search(顺带落实 FSDocSearchQry.corpNo 的 fixme,由后端根据绑定租户推导)。

2.2 授权发起接口(新增,替代卡片模式)

新增 POST /fs/auth/prepare(需登录态),一步完成「创建授权会话 + 构造授权 URL」,前端无需再分别调两个接口:

请求体:

{
  "corpNo": "FEBG3X19R5J",
  "appId": "cli_xxx",
  "scopes": ["search:docs:read", "drive:drive:readonly"]
}

响应 data:

{
  "authId": "uuid",
  "authUrl": "https://accounts.feishu.cn/open-apis/authen/v1/authorize?...&state=<authId>",
  "expireSeconds": 600
}

服务端内部逻辑:

  1. 从登录态取 mainUserId,生成 authId(UUID);
  2. 授权会话预存 Redis:COMMON_FS_SELF_BUILT_APP_AUTH_CARD + authId -> { corpNo, appId, businessId=authId, mainUserId, scopes },TTL 10 分钟;
  3. scope 拼接:业务 scopes + offline_access(为 refresh_token 做准备),空格分隔;
  4. redirectUri 固定为 {gatewayHost}/appcenter/anonymous/fs/auth/callback.cl(必须已在开发者后台「重定向 URL」白名单中,否则换 token 时报 20071),复用 FSService.oauth2buildAuthorizationUrl 构造 URL。

说明:

  • 前端可以在收到 FS_AUTH_REQUIRED 后带着响应里的上下文直接调本接口;也可以在进入功能页时预检(可选调用,scopes 由前端声明),提前完成授权再使用功能。
  • 旧的 POST /fs/auth/sendAuthCard 与 POST /anonymous/fs/auth/authorizationUrl 保留不动(兼容存量调用方),新流程不使用。

2.3 授权回调(复用现有 callback.cl,改造点 B)

现有 FSBridgeController.callback 已完成:state 取会话 -> fsService.userInfo() 换 token -> 写 AUTH_CARD_RESULT + businessId -> 返回自闭合页面。需要补两点:

  1. 回写 per-user token 缓存:callback 拿到 FSUserInfoCO 后,调用新增的 token 存储(见 2.4),把 access_token + refresh_token + expires_in 持久化。这是当前链路缺失的一环。
  2. 会话校验:比对会话中的 mainUserId 与授权返回的用户是否一致(防授权串号),不一致则结果标记失败。

2.4 per-user 令牌存储与刷新(改造点 C,核心新增)

现状 FSService.setUserAccessToken 只存 token 字符串,无法支撑刷新。设计:

  • 缓存结构升级:key 不变(COMMON_FS_SELF_BUILT_APP_USER_ACCESS_TOKEN + corpNo:appId:openId),value 从 String 升级为对象 FSUserTokenCO{ accessToken, refreshToken, expiresIn, refreshTokenExpiresIn }。读侧做兼容(旧值为 String 时视为仅 accessToken)。
  • 新增 FSUserAuthService(或扩展 FSService)提供 getValidUserAccessToken(corpNo, appId, openId):
    1. 缓存命中且剩余有效期 >= 360s -> 直接返回 accessToken;
    2. 临期且有 refresh_token -> 调 /open-apis/authen/v2/oauth/token(grant_type=refresh_token)刷新后回写(refresh_token 一次性,必须整体覆盖缓存);
    3. 刷新失败且错误码为 20037/20064/20073(refresh_token 失效)-> 清缓存,返回 null,由 DataApi 抛 FS_AUTH_REQUIRED 引导重新授权。
  • 刷新逻辑从 FSCommonUserAuthService.refreshUserAccessToken 抽取共用方法,公共用户与 per-user 两条线复用同一段换 token 代码。

2.5 授权结果查询(复用)

复用 GET /fs/auth/authResult?messageId=authId。建议响应增加失败态(当前只有 null/有值两种),FSUserInfoCO 增加可选 error 字段,前端据此区分「还在等」与「授权失败」。

3. Redis key 一览

Key Value TTL
COMMON_FS_SELF_BUILT_APP_AUTH_CARD + authId 授权会话 { corpNo, appId, mainUserId, businessId, scopes } 600s
COMMON_FS_SELF_BUILT_APP_AUTH_CARD_RESULT + authId FSUserInfoCO(含 error 可选) 600s
COMMON_FS_SELF_BUILT_APP_USER_ACCESS_TOKEN + corpNo:appId:openId FSUserTokenCO expiresIn(用 Redis TTL 表达)

4. 前端组件行为规格

本仓库不含前端代码,以下为前端实现须遵守的规格(接口协议 + 状态机)。

4.1 封装入口

提供一个高阶封装(如 axios 响应拦截器或 callFsUserApi(fn)):

  1. 调用 DataApi;
  2. 响应 code === 'FS_AUTH_REQUIRED' -> 进入授权流程(4.2),成功后自动重放原请求(最多重放 1 次,防循环);
  3. 其他错误原样抛出。

4.2 授权流程状态机

idle -> preparing(调 /fs/auth/prepare) -> authorizing(打开授权窗口 + 轮询) -> success -> 重放原请求
                                     \-> failed(超时120s / 用户关窗 / 结果含 error / prepare 失败) -> 提示重试
  • 环境判断:飞书客户端内(window.h5sdk 存在)用 applink mode=sidebar-semi 打开;浏览器用 window.open(url, '_blank', 'width=600,height=700')。
  • 去重:同一 appId + scopes 已有进行中的授权流程时,复用该流程的 Promise,不重复调 prepare、不重复开窗。
  • 轮询:每 2s 调 /fs/auth/authResult?messageId=authId,拿到 FSUserInfoCO 即成功;120s 超时判失败。
  • 成功展示:可用返回的 name/avatar 提示「已授权:张三」。
  • UI 自主:授权引导层(文案、按钮、弹窗样式)完全由前端实现,后端只提供 prepare/authResult 两个数据接口,不发卡片、不推消息。

4.3 使用方契约

// 伪代码,前端仓库实现
const result = await callFsUserApi(() =>
  post('/appcenter/fs/docs/search', { searchKey, count: 20, offset: 0 })
);
// 未授权时自动弹授权,授权后自动返回搜索结果

5. 边界与风险

风险 对策
授权码 5 分钟过期、一次性 callback 立即换 token;20003/20004/20065 记日志并在结果中标记失败
redirect_uri 不一致(20071) 构造 URL 与 callback 换 token 使用同一 gatewayHost + 固定路径,不从前端传
refresh_token 一次性 刷新成功后必须整体覆盖缓存;刷新与读取加用户级锁(Redis 分布式锁,key 含 openId)防并发刷新互相覆盖
用户更换飞书账号授权 会话绑定 mainUserId,callback 校验 openId 与 UC 绑定关系,不一致判失败
弹窗被浏览器拦截 授权引导层用「点击按钮打开」而非自动 window.open
scope 裁剪 以 token 接口返回的 scope 字段为准,授权结果中落库/缓存实际授予范围

6. 落地拆分建议(实现阶段子任务)

  1. 后端-令牌层:FSUserTokenCO 缓存结构 + 刷新共用抽取 + getValidUserAccessToken。
  2. 后端-协议层:FS_AUTH_REQUIRED + 新增 POST /fs/auth/prepare(authId 会话预存 + 授权 URL)+ callback 回写 per-user 缓存与校验 + authResult 失败态。
  3. 后端-首个 DataApi 改造:FSDocsController.search 接入新协议(含 corpNo fixme)。
  4. 前端组件:拦截器 + 授权引导 UI + 轮询重放(前端仓库实施)。

7. 验证方式

  • 单测:令牌刷新分支(命中/临期刷新/refresh 失效);callback 串号校验。
  • 联调:UAT 环境用真实飞书账号走通「未授权 -> 弹窗授权 -> 自动重放搜索 -> 返回文档列表」全链路;构造 refresh_token 过期场景验证二次授权引导。