后端提供**协议**,前端提供**组件**。任何用户态数据接口(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=", "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 过期场景验证二次授权引导。