axios-annotations
v3.0.0
Published
HTTP client library uses Axios without Typescript.
Downloads
62
Maintainers
Readme
axios-annotations
Quick Configuration Framework for Axios with TypeScript support.
声明式
API配置工具。将 请求的构建声明 与 业务数据的提供 完全分离。不再手动拼装URL、请求头和请求体,而是通过装饰器来“声明”一个请求应该如何被构建。
装饰器可用范围
| 装饰器 | 可用范围 | 描述 |
|:------------------|:-------|:--------------------------|
| @RequestConfig | 类 / 方法 | 设置请求配置(例如 signal)。 |
| @RequestMapping | 类 / 方法 | 定义 URL 路径和 HTTP 请求方法。 |
| @RequestWith | 方法 | 重定向请求方法使用的配置。 |
| @RequestBody | 方法 | 标记一个参数作为请求体。 |
| @RequestHeader | 方法 | 标记一个参数作为请求头。 |
| @RequestParam | 方法 | 标记一个参数作为 URL 查询参数。 |
| @PathVariables | 方法 | 标记一个参数作为 URL 路径变量。 |
Usage
流程简述
- 声明式构建:使用
@RequestMapping,@RequestParam等装饰器来描述一个请求的静态结构。 - 提供数据源:业务方法接收参数,并
return一个由这些参数组装成的普通JavaScript对象。这个对象将作为所有装饰器的数据源 (Data Source)。 - 自动执行:框架会拦截方法调用,使用装饰器定义的规则,从返回的数据源对象中提取数据,自动构建并执行请求。
创建配置
框架内置默认的全局配置,默认指向本地的8080端口,但建议自行创建和管理配置。
例:
// config.ts
import {Config} from "axios-annotations";
export const localConfig = new Config({
protocol: 'http',
host: 'localhost',
prefix: '', // 请求前缀,可选
port: 5173
});通过 @RequestConfig 注入服务类,注意服务类需要继承 Service。
import {localConfig} from "./config.ts";
import {Service, RequestConfig} from "axios-annotations";
@RequestConfig(localConfig)
export class DemoService extends Service {
// ...
}请求前缀可以在 Config 的 prefix 中指定,或者使用 @RequestMapping 指定前缀。
@RequestMapping
- 声明服务使用的统一请求前缀。
- 声明业务方法的请求方法和请求路径,注解方法的时候第二参数必填。
import {localConfig} from "./config.ts";
import {Expect, Service, RequestConfig, RequestMapping} from "axios-annotations";
@RequestConfig(localConfig)
@RequestMapping("/api")
export class DemoService extends Service {
// [GET] http://localhost:5173/api/foo
@RequestMapping("/foo", "GET")
foo() {
return Expect<Record<any>>({});
}
// [POST] http://localhost:5173/api/bar
@RequestMapping("/bar", "POST")
bar() {
return Expect<Record<any>>({});
}
}@RequestWith
- 重定向方法使用的配置,服务类的业务划分需要用到不同的服务器地址。 例子,请求不同服务器的文件:
import {Config, Expect, Service, RequestConfig, RequestMapping, RequestWith} from "axios-annotations";
const config = new Config({
protocol: "http",
host: "localhost",
prefix: "/resources",
port: 5173
});
const config2 = new Config({
protocol: "http",
host: "localhost",
prefix: "/data",
port: 5173
});
@RequestConfig(config)
export class FileService extends Service {
@RequestMapping("/test1.json", "GET")
getFile() {
// [GET] http://localhost:5173/resources/test1.json
return Expect<any>({
params: {}
});
}
@RequestWith(config2)
@RequestMapping("/test2.json", "GET")
getFile2() {
// [GET] http://localhost:5173/data/test2.json
return Expect<any>({
params: {}
});
}
}注意,如果服务类使用 @RequestMapping 声明了统一前缀,那么使用 @RequestWith 重定向仍然会拼接服务类的统一前缀。
如果需要服务类方法使用不同的请求前缀,你可能需要将服务的统一前缀提升到 Config 的 prefix 字段或者取消前缀相关配置。
// ...config
const config2 = new Config({
protocol: "http",
host: "localhost",
prefix: "/data",
port: 5173
});
@RequestConfig(config)
@RequestMapping("/api")
export class FileService extends Service {
// ...
@RequestWith(config2)
@RequestMapping("/test2.json", "GET")
getFile2() {
// [GET] http://localhost:5173/data/api/test2.json
return Expect<any>({
params: {}
});
}
}@RequestBody
- 仅用于声明请求体的取值字段,默认不传则为
"body"字段,@RequestBody()跟@RequestBody("body")等价: 注意请求体因类型问题无法合并,多次声明请求体取值以最后执行的为准。
@RequestConfig(config)
@RequestMapping("/api")
export class FileService extends Service {
// ...
@RequestMapping("/files/package_graph.json", "POST")
@RequestBody()
getPackageGraph() {
return Expect<Record<string, any>>({
body: {
version: '3.x',
description: '示例数据'
}
});
}
}@RequestBody 的扩展写法,用于指定静态值或自定义取值逻辑。
指定静态值:
@RequestConfig(config)
export class FileService extends Service {
// ...
@RequestMapping("/files/package_graph.json", "POST")
@RequestBody({
value: {
version: '3.x',
description: '示例数据'
}
})
getPackageGraph() {
return Expect<Record<string, any>>({});
}
}自定义取值逻辑:
@RequestConfig(config)
export class FileService extends Service {
// ...
@RequestMapping("/files/package_graph.json", "POST")
@RequestBody({
value: function () {
return {
version: '3.x',
description: '示例数据2'
};
}
})
getPackageGraph() {
return Expect<Record<string, any>>({});
}
}@RequestHeader
声明请求头的取值,有以下的取值方式:
- 声明数据源字段,
@RequestHeader(key: string, required?: boolean = true)第二参数required默认必填,value为空值时(null/undefined/'')默认转为空字符串。 - 声明静态值,
key必填。 - 声明自定义取值逻辑,
key必填。 请求头取值可合并,所以这里一次性给出取值示例:
@RequestConfig(config)
export class FileService extends Service {
// ...
// 声明数据源字段,且必填
@RequestHeader("Custom-Header", true)
// 声明静态值
@RequestHeader({
key: "Custom-Header-2",
value: "static-header-value",
required: true,
})
// 声明自定义取值逻辑
@RequestHeader({
key: "Custom-Header-3",
value: function (source: Record<string, any>) {
return source.num1 + source.num2;
}
})
getPackageGraph() {
return Expect<Record<string, any>>({
"Custom-Header": "header-value-from-source",
num1: 100,
num2: 200
});
}
}生成的请求头为:
Custom-Header: header-value-from-source
Custom-Header-2: static-header-value
Custom-Header-3: 300@RequestParam
声明查询参数的取值,取值方式用法跟请求头的声明类似:
- 声明数据源字段,
@RequestParam(key: string, required?: boolean = false)第二参数默认选填,空值不会拼接到请求地址。 - 声明静态值,
key必填。 - 声明自定义取值逻辑,
key必填。
const config = new Config({
protocol: "http",
host: "localhost",
prefix: "/resources",
port: 5173
});
@RequestConfig(config)
export class FileService extends Service {
@RequestMapping("/demo.json", "POST")
@RequestParam("param1") // 基本形式
@RequestParam("param2") // 基本形式
// 扩展写法(函数)
@RequestParam({
key: "sum",
value: function (source: Record<string, any>) {
return Number(source['param1']) + Number(source['param2']);
}
})
// 扩展写法(静态值)
@RequestParam({
key: 'static',
value: 'foo'
})
@RequestBody()
getJson() {
return Expect<Record<string, any>>({
param1: '114',
param2: '514',
body: {
employees: [
{
firstName: "John",
lastName: "Doe"
}
]
}
});
}
}调用 getJson 生成的地址:
http://localhost:5173/resources/demo.json?static=foo&sum=628¶m2=514¶m1=114@PathVariables
启用路径参数,从数据源或数据源字段中取值,取值必须为 PlainObject,替换 url 中的占位符。
启用路径参数请至少确保有一个 @PathVariables 声明,不指定路径参数数据源字段时,直接从数据源本体取值。
由于路径参数取值(PlainObject)可合并,此处给出所有取值示例:
export const localConfig = new Config({
protocol: 'http',
host: 'localhost',
port: 5173
});
@RequestConfig(config)
export class FileService extends Service {
@RequestMapping("/files/{fileName}?a={a}&c={c}&e={e}", "GET")
@PathVariables() // 不指定字段,从数据源本体取值,此处会查询出 "fileName"
@PathVariables({
// 自定义数据源生成逻辑
value: function (source: Record<string, any>) {
return {
a: 100,
c: 300,
d: source.d
}
}
})
// 此处 pathVariablesKey 的值会跟数据源本体合并为一个新的对象,所以会查询出 "e"
@PathVariables('pathVariablesKey') // => "e"
// 注意这个是查询参数,生成逻辑跟路径参数不一样
@RequestParam({
key: 'b',
value: 200
})
getFileInfo(fileName: string) {
return Expect<Record<string, any>>({
fileName,
d: 400,
pathVariablesKey: {
e: 500
}
});
}
}路径参数可用于查询参数的填充,但跟 @RequestParam 有区别:
@RequestParam合并值直接传给axios的params参数,对于数组等复合类型,使用axios的生成逻辑,路径参数则直接转为JSON字符串,所以不要传非基本类型。- 占位符是固定的字符序列,路径参数没有选填逻辑。
- 转换非基本类型失败一律转为
"undefined"。
@RequestConfig
- 注解类时,用于设置服务类使用的配置。
- 注解方法时,合并
AxiosRequestConfig配置,例如有N个查询参数/请求头不想一个个配置,可以使用@RequestConfig统一返回。
const config = new Config({
protocol: "http",
host: "localhost",
prefix: "/data",
port: 5173
});
@RequestConfig(config)
export class FileService extends Service {
@RequestMapping("/test1.json", "GET")
@RequestConfig({
headers: {
'X-Source': 'class',
'Authorization': 'Bearer token'
}
})
@RequestConfig(function (source: Record<string, any>) {
return {
headers: {
'Token1': '1'
},
params: source.params
};
})
getData() {
// http://localhost:5173/data/test1.json?a=1&b=2
// ----
// Headers:
//
// X-Source: class
// Token1: 1
// Authorization: Bearer token
return Expect<Record<string, any>>({
params: {
'a': 1,
'b': 2
}
});
}
}@RequestConfig 可用于注入 AbortController 或 CancelToken 中断源。
众所周知,React 18+组件在开发环境下会重复挂载和卸载(<React.StrictMode>),导致 useEffect(() => {}, []) 中的请求会发送两次。
此处举例如何限制发送一次请求,即组件卸载时候自动取消请求:
@RequestConfig(config)
export class FileService extends Service {
@RequestMapping("/test1.json", "GET")
@RequestConfig(function (source: Record<string, any>) {
return {
signal: source.signal
};
})
getData(signal: AbortSignal) {
return Expect<Record<string, any>>({
signal
});
}
}
// 管理你的服务实例
export const ApiManager = {
fileService: new FileService()
}React组件部分逻辑如下:
import {ApiManager} from './api-manager'
import {useEffect} from "react";
export function ReactFunctionComponent() {
useEffect(function () {
const controller: AbortController = new AbortController();
ApiManager.fileService.getData(controller.signal);
return function () {
controller.abort();
};
}, []);
return (
<div>
<p>AbortController Demo</p>
<p>开发环境下只有第二次挂载的请求会被接收</p>
</div>
);
}Expect
函数原型参考:
export default function Expect<T, D = AxiosPromise<T>>(params: any): D;由于 TypeScript 的限制,装饰器无法在编译时改变一个方法的返回类型。方法在代码层面返回的是一个普通对象(数据源),但框架在运行时实际返回的是一个AxiosPromise。
Expect<T> 的作用就是解决这个“类型不匹配”的问题。它是一个类型桥梁,通过类型断言绕过静态检查,从而让你在调用代码时能够获得完整的类型安全和IDE 代码提示。
Expect<T> 的泛型 T 至关重要,它 定义了你期望服务器响应体 data 的类型。
如果接口返回一个具体的
JSON对象,你应该为其定义一个接口MyData并使用Expect<MyData>(...)。如果接口返回纯文本,你应该使用
Expect<string>(...)。
不支持装饰器的环境
import {RequestBuilder} from "axios-annotations";
import {config} from './config';
async function foo() {
const response = await new RequestBuilder().param({
key: 'param',
value: function (source: Record<string, any>) {
return source.param;
}
}).buildWith(config, "/demo.json", "GET", {
param: 666
});
console.log(response.data);
}运行环境
微信小程序配置
更新开发工具以支持装饰器语法。
小程序Typescript环境不支持装饰器编译,但是Javascript环境可以。把涉及到API配置的*.ts文件扩展名改为*.js,绕过部分环境对装饰器支持限制,本地配置勾选上将JS编译成ES5,正常引入即可。
开发工具BUG:
TS环境npm构建失败找到
project.config.json文件,setting下面添加如下配置:{ "packNpmManually": true, "packNpmRelationList": [ { "packageJsonPath": "./package.json", "miniprogramNpmDistDir": "./miniprogram/" } ] }开发工具
项目→重新打开此项目,然后构建npm。
安装第三方axios实现:
方案 1,使用适配器,
axios-miniprogram-adapteraxios需要降级,版本再高就得报错:npm install [email protected] npm install axios-miniprogram-adapter开发工具如果编译报错
module is not defined, 在app.js头部补充缺失组件的声明:import { Config, Service, Expect, AxiosStaticInstanceProvider, RequestBody, RequestConfig, RequestHeader, RequestMapping, RequestParam, PathVariables, RequestWith, RequestBuilder } from "axios-annotations";方案 2,使用第三方实现,不限于
axios-miniprogramnpm install axios-miniprogram如果
npm构建失败直接将node_modules下的目录复制出来。 实现AxiosStaticInstanceProvider并配置,如果IDE警告provide返回类型,可忽略掉:import mpAxios from 'axios-miniprogram'; class ThirdAxiosStaticInstanceProvider extends AxiosStaticInstanceProvider { provide() { return mpAxios; } } const config = new Config({ plugins: [], protocol: "http", host: "localhost", port: 8888, prefix: "/test", axiosProvider: new ThirdAxiosStaticInstanceProvider(), });
插件
自定义插件
插件函数接收配置对象为参数,出于扩展性考虑,通常由高阶函数返回。
插件在Config对象的axios实例创建时注入,建议在Config构造函数配置。
import {Config} from "axios-annotations"
import type {AxiosInstance} from "axios";
export function ToastPlugin(fnToast) {
return function (config: Config, axios: AxiosInstance) {
axios.interceptors.response.use(function (e) {
return Promise.resolve(e);
}, function (e) {
fnToast(e);
return Promise.reject(e);
});
axios.interceptors.request.use(function (e) {
return Promise.resolve(e);
});
}
}配置插件:
new Config({
plugins: [
ToastPlugin(function (e) {
if (typeof wx !== "undefined") {
wx.showToast({
icon: "none",
title: `[${e.response.status}]` + ' ' + e.config.url
});
}
})
]
})授权插件 (可选)
使用该插件用于自动为请求写入授权信息(自动携带Bearer Token请求头等),默认适配OAuth2标准,直接给出示例:
- 定义会话存储器
import {SessionStorage} from "axios-annotations/plugins/auth";
class WebSessionStorage extends SessionStorage {
async set(key: string, value: any): Promise<void> {
return sessionStorage.setItem(key, JSON.stringify(value));
}
async get(key: string): Promise<any> {
const str = sessionStorage.getItem(key);
if (str) {
return JSON.parse(str);
} else {
return null;
}
}
async remove(key: string): Promise<void> {
return sessionStorage.removeItem(key);
}
}- 实例化并导出
Authorizer对象,并注入SessionStorage
这里导出的
Authorizer对象将用于首次登录后会话信息写入(调用storageSession)和获取会话(getSession)。
import {Authorizer, type BasicSession} from "axios-annotations/plugins/auth";
class LocalAuthorizer extends Authorizer {
constructor() {
super();
this.sessionStorage = new WebSessionStorage();
}
// 刷新 access_token,这里直接返回修改后的 session 对象即可
async refreshSession(session: BasicSession): Promise<any> {
const response = await new OAuthService().refreshToken({
refresh_token: session.refresh_token
});
console.log(`刷新 ${JSON.stringify(response.data)}`);
// 此处组装新的 session 对象并返回, onAuthorizedDenied 会捕获该方法运行时异常
return {
...response.data
};
}
// 附加 session 字段到请求头
withAuthentication(request: InternalAxiosRequestConfig, session: BasicSession) {
// 这里使用默认实现,如果 refreshSession 返回 null, withAuthentication 的逻辑不会执行
super.withAuthentication(request, session);
console.log(request.headers);
}
// 会话刷新过程抛出异常
// refresh_token 失效,约等于 refreshSession 返回 null
async onAuthorizedDenied(error: unknown): Promise<void> {
console.error(error); // 这个是 refreshSession 抛出的异常
// 删除会话信息
// 调用 invalidateSession 触发 onSessionInvalidated
await this.invalidateSession();
}
onSessionInvalidated() {
// 跳转回登录页/首页
window.history.replaceState({}, document.title, window.location.pathname);
}
}
export const authorizer = new LocalAuthorizer();- 定义授权链服务配置和业务链服务配置
授权链的接口应该跟业务链的接口分别独立
Service,防止请求头冲突。OAuthService示例代码适配spring-boot-starter-oauth2-authorization-server 4.0.x,仅供参考,看不懂可直接跳到Authorizer说明。
import AuthorizationPlugin, {type BasicSession} from "axios-annotations/plugins/auth";
const oauthConfig = new Config({
protocol: 'http',
host: 'localhost',
port: 8080
});
// 这里业务接口跟授权接口使用同一服务器
const businessConfig = new Config({
protocol: 'http',
host: 'localhost',
port: 8080,
plugins: [
AuthorizationPlugin(authorizer)
]
});
@RequestConfig(oauthConfig)
export class OAuthService extends Service {
// 随机字符串
generateVerifier() {
const array = new Uint32Array(56);
window.crypto.getRandomValues(array);
return Array.from(array, dec => ('0' + dec.toString(16)).slice(-2)).join('');
}
async getCodeChallenge(verifier: string) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
// 使用浏览器原生 Web Crypto API 进行 SHA-256 加密
const digest = await window.crypto.subtle.digest('SHA-256', data);
// Base64Url 编码 (注意:不是普通的 Base64)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 根据 code 换取 accessToken 和 refreshToken
@RequestMapping('/oauth2/token', 'POST')
@RequestConfig(function (source) {
const {signal, ...data} = source;
return {
headers: {
'Authorization': 'Basic ' + btoa('test-client:test-secret'),
'Content-Type': 'application/x-www-form-urlencoded'
},
signal: signal,
data,
}
})
getToken(params: {
code_verifier: string;
code: string;
redirect_uri: string;
signal?: AbortSignal
}) {
return Expect<{
access_token: string;
refresh_token: string;
}>({
signal: params.signal,
grant_type: 'authorization_code',
code: params.code,
client_id: 'test-client',
redirect_uri: params.redirect_uri,
code_verifier: params.code_verifier
});
}
// 刷新 accessToken
@RequestMapping('/oauth2/token', 'POST')
@RequestConfig(function (source) {
const {signal, ...data} = source;
return {
headers: {
'Authorization': 'Basic ' + btoa('test-client:test-secret'),
'Content-Type': 'application/x-www-form-urlencoded'
},
signal: signal,
data,
}
})
refreshToken(params: {
refresh_token: string;
signal?: AbortSignal
}) {
return Expect<{
"access_token": string;
"refresh_token": string;
"scope": string;
"token_type": string;
"expires_in": number;
}>({
signal: params.signal,
grant_type: 'refresh_token',
refresh_token: params.refresh_token,
client_id: 'test-client'
});
}
}
// 业务接口
@RequestConfig(businessConfig)
export class BusinessService extends Service {
// ... 全部省略
}- 登录并写入授权信息
const oauthService = new OAuthService();
async function handleLoginButtonClick() {
const verifier = oauthService.generateVerifier();
sessionStorage.setItem('code_verifier', verifier);
const challenge = await oauthService.getCodeChallenge(verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'test-client',
scope: 'read',
redirect_uri: 'http://localhost:5173',
code_challenge: challenge,
code_challenge_method: 'S256'
});
window.location.href = `http://localhost:8080/oauth2/authorize?${params.toString()}`
}
// 服务器回调的路由页面,这里直接用首页
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const controller = new AbortController();
const verifier = sessionStorage.getItem('code_verifier');
if (code && verifier) {
oauthService.getToken({
code: code,
redirect_uri: 'http://localhost:5173',
code_verifier: verifier,
signal: controller.signal,
}).then((response) => {
const {access_token, refresh_token} = response.data;
authorizer.storageSession({access_token, refresh_token}).then(() => {
console.log({access_token, refresh_token});
});
// 消除地址栏的 code
window.history.replaceState({}, document.title, window.location.pathname);
}).catch(error => {
if (error.name !== 'AbortError') console.error("Failed to get token:", error);
});
} else {
authorizer.getSession().then(session => {
// ...
});
}
return () => controller.abort();
}, []);Authorizer
会话对象结构:
export type BasicSession = {
access_token?: string;
accessToken?: string;
token?: string;
refreshToken?: string;
refresh_token?: string;
} & Record<string, any>;非标准授权凭证需要映射到access_token/accessToken/token中的一个或多个字段。
非标准授权流程不支持refreshToken,可设置为不重复的随机值。
必须重载的方法只有onAuthorizedDenied、refreshSession。
方法说明:
- invalidateSession(): Promise
删除会话信息,并触发
onSessionInvalidated回调。
- onSessionInvalidated(): void
可重载该方法返回登录页或首页。
- onAuthorizedDenied(error: unknown): Promise
处理
refreshSession运行时异常(HTTP 401/500/RuntimeError),通常就是refreshToken过期需要重新登录。 重载该方法执行清理工作,默认实现直接rethrow异常,你的控制台会始终报错误信息。 该方法应始终重载,务必调用invalidateSession删除会话信息。
- refreshSession(session: BasicSession): Promise<BasicSession | null>
该方法应始终重载,参数是当前会话信息,根据
session.refreshToken换取新的accessToken,返回新会话对象(PlainObject),插件会自动持久化。 如果是非标准的授权流程(不支持续期,无refreshToken等)直接返回null即可,并调用invalidateSession移除会话信息。或者直接抛出自定义异常走onAuthorizedDenied清理流程。 如果支持无感知的refreshToken重新获取,也在该方法进行。
- checkResponse(response: AxiosResponse): boolean
检查请求是否触发了授权过期错误,默认只检查状态码是否为
401,返回false表示授权过期。 授权过期触发续期流程,插件会先调用getSession获取会话对象,会话对象将用于:
- 传给
checkSession进行会话信息和请求的匹配,因为当前会话信息已过期,应当返回false表示校验不通过,返回true直接抛出原始异常,跳过续期流程。checkSession返回false将调用refreshSession,并传入当前带有refreshToken的会话对象作为参数。
- checkSession(request: InternalAxiosRequestConfig, session: BasicSession): boolean
默认实现为提取
authorizer.withAuthentication写入请求头的accessToken与当前过期会话信息的accessToken匹配,相等返回false。
- withAuthentication(request: InternalAxiosRequestConfig, session: BasicSession): void
拦截请求注入会话信息,默认实现为将
accessToken写入请求头Authorization: Bearer ${accessToken}。 如果refreshSession返回null,那么插件正常重载的情况下getSession会返回null,会话对象为空withAuthentication不会执行请求头写入。
- getSession(): Promise
获取会话信息对象,其实就是调用
sessionStorage.get。
- storageSession(session: BasicSession | null)
调用
sessionStorage.set。
graph TD
%% 请求拦截器部分
Start((开始请求)) --> ReqInterceptor[<b>请求拦截器</b>]
ReqInterceptor --> GetSession[authorizer.getSession 获取会话]
GetSession --> InjectHeader[authorizer.withAuthentication<br/>注入 Authorization 响应头]
InjectHeader --> SendRequest[发送 Axios 请求]
%% 响应拦截器部分
SendRequest --> RespInterceptor{<b>响应拦截器</b><br/>状态码判断}
RespInterceptor -- "2xx (成功)" --> Success((返回数据))
RespInterceptor -- "401 (授权失效)" --> CheckUnauthorized{当前是否正在<br/>处理刷新流程?}
%% 队列逻辑
CheckUnauthorized -- "是 (unauthorized=true)" --> QueueRequest[将请求放入 PendingQueue 队列等待]
QueueRequest --> WaitRefresh[等待刷新成功后自动重发]
%% 核心刷新逻辑
CheckUnauthorized -- "否 (unauthorized=false)" --> SetLock[设置锁 unauthorized=true]
SetLock --> CheckHistory{refreshToken<br/>是否已作废?}
CheckHistory -- "是 (已作废)" --> Denied[authorizer.onAuthorizedDenied<br/>跳转登录页/清理会话]
CheckHistory -- "否" --> CheckSessionMatch{checkSession 当前 Session 与<br/>请求 Session 是否一致?}
%% 避免重复刷新
CheckSessionMatch -- "不一致 (已被他人刷新)" --> PopQueue[直接开始消耗队列重发]
CheckSessionMatch -- "一致 (需执行刷新)" --> RefreshAction[<b>authorizer.refreshSession</b><br/>调用刷新接口]
RefreshAction -- "刷新失败 (Token过期)" --> Deprecate[标记当前 Session 作废]
Deprecate --> Denied
RefreshAction -- "刷新成功" --> UpdateSession[authorizer.storageSession<br/>更新持久化存储]
UpdateSession --> ReleaseLock[释放锁 unauthorized=false]
ReleaseLock --> PopQueue
PopQueue --> ResendAll[依次重发队列中的所有请求]
ResendAll --> Success
%% 其他错误
RespInterceptor -- "5xx/其他错误" --> Error((抛出异常))