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.
This commit is contained in:
yuxuanhui
2026-08-31 09:18:03 +08:00
parent 3d838866cd
commit dcd6d44960
15 changed files with 3817 additions and 732 deletions
@@ -0,0 +1,182 @@
后端提供**协议**,前端提供**组件**。任何用户态数据接口(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 过期场景验证二次授权引导。
+1 -1
View File
@@ -49,7 +49,7 @@
| 规范 | 作用 | 适用场景 |
|---|---|---|
| [前端结构规范](./frontend-structure-guidelines.md) | 约束 `src` 下各层目录职责、页面私有结构和命名方式 | 新建页面、重构目录、抽离公共能力前必读 |
| [UI 设计规范](../../../DESIGN.md) | 规范界面实现方式、视觉一致性和交互呈现 | 新做页面、优化样式、补充组件展示时阅读 |
| [UI 设计规范](飞书用户态接口授权流程(Auth%20->%20Callback%20->%20DataApi).md) | 规范界面实现方式、视觉一致性和交互呈现 | 新做页面、优化样式、补充组件展示时阅读 |
| [设计变量使用规范](./design-tokens-guidelines.md) | 规范 `@oppein-react/design-tokens` 的接入、变量消费、主题切换与 UnoCSS 使用方式 | 新增样式、主题切换、替换硬编码颜色、接入 UnoCSS token 时必读 |
| [接口契约规范](./api-guidelines.md) | 规范前端接口文件、请求封装、错误处理和兼容性 | 新增接口、调整请求参数、封装请求工具时阅读 |
| [类型定义规范](./dto-guidelines.md) | 规范请求参数、响应数据、页面消费模型和类型边界 | 新增类型、重构数据结构、拆分页面模型时阅读 |