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

183 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
后端提供**协议**,前端提供**组件**。任何用户态数据接口(DataApi)在令牌缺失时返回统一错误码 `FS_AUTH_REQUIRED`,前端拦截器识别后驱动授权流程,授权完成自动重放原请求。业务方感知为零。
**授权发起方式变更**:废弃「后端发送飞书卡片消息引导授权」的交互(`POST /fs/auth/sendAuthCard` 不再用于本流程)。改为后端提供纯数据接口「准备授权」,前端拿到授权 URL 后自行决定打开方式与 UI 呈现。IM 卡片仅保留为后台任务(如公共用户令牌失效)的兜底通知手段。
```mermaid
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` 时返回:
```json
{
"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」,前端无需再分别调两个接口:
请求体:
```json
{
"corpNo": "FEBG3X19R5J",
"appId": "cli_xxx",
"scopes": ["search:docs:read", "drive:drive:readonly"]
}
```
响应 `data`:
```json
{
"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 使用方契约
```ts
// 伪代码,前端仓库实现
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 过期场景验证二次授权引导。