- 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.
9.9 KiB
后端提供协议,前端提供组件。任何用户态数据接口(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
}
服务端内部逻辑:
- 从登录态取
mainUserId,生成authId(UUID); - 授权会话预存 Redis:
COMMON_FS_SELF_BUILT_APP_AUTH_CARD + authId->{ corpNo, appId, businessId=authId, mainUserId, scopes },TTL 10 分钟; - scope 拼接:
业务 scopes + offline_access(为 refresh_token 做准备),空格分隔; 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 -> 返回自闭合页面。需要补两点:
- 回写 per-user token 缓存:callback 拿到
FSUserInfoCO后,调用新增的 token 存储(见 2.4),把access_token + refresh_token + expires_in持久化。这是当前链路缺失的一环。 - 会话校验:比对会话中的
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):- 缓存命中且剩余有效期 >= 360s -> 直接返回 accessToken;
- 临期且有 refresh_token -> 调
/open-apis/authen/v2/oauth/token(grant_type=refresh_token)刷新后回写(refresh_token 一次性,必须整体覆盖缓存); - 刷新失败且错误码为 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)):
- 调用 DataApi;
- 响应
code === 'FS_AUTH_REQUIRED'-> 进入授权流程(4.2),成功后自动重放原请求(最多重放 1 次,防循环); - 其他错误原样抛出。
4.2 授权流程状态机
idle -> preparing(调 /fs/auth/prepare) -> authorizing(打开授权窗口 + 轮询) -> success -> 重放原请求
\-> failed(超时120s / 用户关窗 / 结果含 error / prepare 失败) -> 提示重试
- 环境判断:飞书客户端内(
window.h5sdk存在)用 applinkmode=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. 落地拆分建议(实现阶段子任务)
- 后端-令牌层:
FSUserTokenCO缓存结构 + 刷新共用抽取 +getValidUserAccessToken。 - 后端-协议层:
FS_AUTH_REQUIRED+ 新增POST /fs/auth/prepare(authId 会话预存 + 授权 URL)+ callback 回写 per-user 缓存与校验 + authResult 失败态。 - 后端-首个 DataApi 改造:
FSDocsController.search接入新协议(含 corpNo fixme)。 - 前端组件:拦截器 + 授权引导 UI + 轮询重放(前端仓库实施)。
7. 验证方式
- 单测:令牌刷新分支(命中/临期刷新/refresh 失效);callback 串号校验。
- 联调:UAT 环境用真实飞书账号走通「未授权 -> 弹窗授权 -> 自动重放搜索 -> 返回文档列表」全链路;构造 refresh_token 过期场景验证二次授权引导。