refactor(请求): 去掉 axios,所有请求统一从 authFetch 出门;换 token 时断网不再平白弹登录框
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
utils/api 改成基于 authFetch 的一层 fetch 封装,调用方用法不变;下载测试点也走 authFetch,token 过期时会换一张重试。换 token 那一步断网时,AI 流式接口和自学上报 原来会拿掉 token 重发、弹登录框,现在和普通请求一样报「网络异常」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13843c79a0
commit
d6c3171d13
9 files changed
+103
-192
No files matched your search
@@ -304,9 +304,7 @@ export async function uploadImage(file: File): Promise<string> {
|
||||
success: boolean
|
||||
filePath: string
|
||||
msg: string
|
||||
}>("admin/upload-image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
}>("admin/upload-image", form)
|
||||
return res.success ? res.filePath : ""
|
||||
}
|
||||
|
||||
@@ -317,9 +315,7 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
||||
if (options.sql) {
|
||||
form.append("sql", "1")
|
||||
}
|
||||
return api.post<UploadTestCaseResponse>("admin/test-cases", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
return api.post<UploadTestCaseResponse>("admin/test-cases", form)
|
||||
}
|
||||
|
||||
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
|
||||
|
||||
@@ -93,7 +93,7 @@ export function getProblemProgress() {
|
||||
|
||||
/**
|
||||
* 「随便来一道」:当前知识点和类型里,先挑能用 language 做的、自己没做对的简单 / 中等题,
|
||||
* 返回题号。空串的参数拦截器会去掉
|
||||
* 返回题号。空串的参数 utils/api 会去掉
|
||||
*/
|
||||
export function getRandomProblem(params: { tag: string; type: string; language: string }) {
|
||||
return api.get<string>("problems/random", { params })
|
||||
@@ -357,9 +357,7 @@ export function getContestClassView(contestID: string) {
|
||||
export function uploadAvatar(file: File) {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
return api.post("me/avatar", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
return api.post("me/avatar", form)
|
||||
}
|
||||
|
||||
export function updateProfile(data: { realName?: string; mood?: string }) {
|
||||
@@ -551,8 +549,8 @@ export function getLearnProgress(type: "python" | "c") {
|
||||
/**
|
||||
* 上报自学留痕。`opened` 为真表示刚进这一课,否则只是补停留时长。
|
||||
*
|
||||
* 走裸 fetch 而不是 axios,是为了 `keepalive`:离开页面那一下的最后一次上报,
|
||||
* axios 发出去也会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
|
||||
* 直接走 authFetch 而不是 utils/api,是为了 `keepalive`:离开页面那一下的最后一次上报,
|
||||
* 不带它会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
|
||||
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
|
||||
*/
|
||||
export function reportLearnProgress(
|
||||
|
||||
+61
-79
@@ -1,11 +1,6 @@
|
||||
import axios, {
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { getAccessToken, refreshAccessToken } from "./auth"
|
||||
import { authFetch } from "./auth"
|
||||
import { STORAGE_KEY } from "./constants"
|
||||
import storage from "./storage"
|
||||
|
||||
@@ -20,7 +15,7 @@ interface ErrorPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截器 reject 出来的就是它:`error` 是错误码,`data` 是后端文案。
|
||||
* 请求失败时抛的就是它:`error` 是错误码,`data` 是后端文案。
|
||||
*
|
||||
* 字段名沿用原来那个裸对象 `{ error, data }`,存量调用点读 `err.error` / `err.data`
|
||||
* 的语义不变;做成 Error 的子类是为了在 catch 里能和代码 bug(TypeError 之类)分开,
|
||||
@@ -50,26 +45,12 @@ export function errorMessage(err: unknown, fallback = "未知错误") {
|
||||
return isApiError(err) && err.data ? err.data : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功时直接拿到业务数据本身。后端成功响应是 `{ data }`(见 apps/api/src/http.ts
|
||||
* 的 success),拦截器把 axios 外层和这层信封一起剥掉。
|
||||
*
|
||||
* 失败走 reject,形状是 `{ error: 错误码, data: 文案 }` —— 和成功路径不对称是
|
||||
* 故意的:成功没有错误码可言。分支处理一律判 `err.error` 里的错误码。
|
||||
*/
|
||||
interface ApiClient {
|
||||
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>
|
||||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
||||
* 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
|
||||
* 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
|
||||
*
|
||||
* 导出给不走 axios 的请求用(AI 的流式接口是裸 fetch,见 utils/stream.ts)——
|
||||
* 导出给不走 api 客户端的请求用(AI 的流式接口,见 utils/stream.ts)——
|
||||
* 同一个错误码从两条路进来,学生看到的结果不该有两个样子。
|
||||
*/
|
||||
/** handleGlobalApiError 已经替调用方提示过的错误码,调用方别再弹第二遍 */
|
||||
@@ -94,68 +75,69 @@ export function handleGlobalApiError(code: string, message?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: "/api",
|
||||
})
|
||||
const NETWORK_ERROR_MESSAGE = "网络异常,请检查网络后重试"
|
||||
|
||||
/** 已经因为 token-expired 重试过一次的请求,别再进第二轮,也别再被请求拦截器换 token */
|
||||
type RetriableConfig = InternalAxiosRequestConfig & { authRetried?: boolean }
|
||||
interface RequestConfig {
|
||||
/** 拼进 query string。值是 ""、null、undefined 的键直接丢掉,调用方不用自己挑 */
|
||||
params?: Record<string, unknown>
|
||||
/** 请求体,只给 delete 用:别的方法的请求体走第二个参数 */
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
instance.interceptors.request.use(async (config: RetriableConfig) => {
|
||||
if (!config.authRetried) {
|
||||
const token = await getAccessToken()
|
||||
if (token) config.headers.set("Authorization", `Bearer ${token}`)
|
||||
function withQuery(url: string, params?: Record<string, unknown>) {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (value !== "" && value !== null && value !== undefined) query.append(key, String(value))
|
||||
}
|
||||
if (config.params) {
|
||||
config.params = Object.fromEntries(
|
||||
Object.entries(config.params).filter(
|
||||
([, value]) => value !== "" && value !== null && value !== undefined,
|
||||
),
|
||||
)
|
||||
const search = query.toString()
|
||||
return search ? `${url}?${search}` : url
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body: unknown, config: RequestConfig = {}) {
|
||||
const init: RequestInit = { method }
|
||||
// FormData 不能手动设 Content-Type:multipart 的 boundary 得让浏览器自己写进去
|
||||
if (body instanceof FormData) {
|
||||
init.body = body
|
||||
} else if (body !== undefined) {
|
||||
init.headers = { "Content-Type": "application/json" }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
// 这里**故意**不返回 AxiosResponse:把 axios 的外层和后端的 { data } 信封一起
|
||||
// 剥掉,让调用方直接拿到业务数据。类型上和 axios 的拦截器签名对不上(它期望原样
|
||||
// 返回响应),文件末尾的 `as unknown as ApiClient` 就是为了把真实形状交出去。
|
||||
((response: AxiosResponse) => response.data.data) as unknown as (
|
||||
response: AxiosResponse,
|
||||
) => AxiosResponse,
|
||||
async (error) => {
|
||||
const payload = error.response?.data as ErrorPayload | undefined
|
||||
const code = payload?.error?.code ?? "network-error"
|
||||
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
|
||||
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
|
||||
let response: Response
|
||||
try {
|
||||
response = await authFetch(withQuery(`/api/${url}`, config.params), init)
|
||||
} catch {
|
||||
// 请求根本没到后端(断网、反代挂了),学生看到的就是这一句
|
||||
throw new ApiError("network-error", NETWORK_ERROR_MESSAGE)
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as ({ data?: T } & ErrorPayload) | null
|
||||
if (response.ok) return payload?.data as T
|
||||
|
||||
// access token 不能用了(过期、被吊销):拿 refresh token 换一张,原请求重发一次。
|
||||
// 重发的结果照样走这两个拦截器,调用方拿到的和一次成功没有区别
|
||||
const config = error.config as RetriableConfig | undefined
|
||||
if (code === "token-expired" && config && !config.authRetried) {
|
||||
config.authRetried = true
|
||||
const sent = String(config.headers.get("Authorization") ?? "").replace(/^Bearer /, "")
|
||||
const outcome = await refreshAccessToken(sent)
|
||||
if (outcome.token) {
|
||||
config.headers.set("Authorization", `Bearer ${outcome.token}`)
|
||||
return instance.request(config)
|
||||
}
|
||||
if (outcome.code === "network-error") {
|
||||
return Promise.reject(new ApiError("network-error", "网络异常,请检查网络后重试"))
|
||||
}
|
||||
if (outcome.code === "account-disabled") {
|
||||
handleGlobalApiError("account-disabled")
|
||||
return Promise.reject(new ApiError("account-disabled", "账号已被禁用,请联系老师"))
|
||||
}
|
||||
// 登录真的过期了。拿掉 token 重发:要登录的接口回 login-required 弹登录框,
|
||||
// 可选登录的(首页、题目列表……)照常当游客看,不平白弹框
|
||||
config.headers.delete("Authorization")
|
||||
return instance.request(config)
|
||||
}
|
||||
// 没有 JSON 信封 = 不是后端回的(网关的错误页之类),当网络问题
|
||||
const code = payload?.error?.code ?? "network-error"
|
||||
const message = payload?.error?.message ?? NETWORK_ERROR_MESSAGE
|
||||
handleGlobalApiError(code, message)
|
||||
throw new ApiError(code, message)
|
||||
}
|
||||
|
||||
handleGlobalApiError(code, message)
|
||||
return Promise.reject(new ApiError(code, message))
|
||||
},
|
||||
)
|
||||
/**
|
||||
* 成功时直接拿到业务数据本身:后端成功响应是 `{ data }`(见 apps/api/src/http.ts
|
||||
* 的 success),这层信封在这里剥掉。
|
||||
*
|
||||
* 失败抛 ApiError,`error` 是错误码、`data` 是文案 —— 和成功路径不对称是故意的:
|
||||
* 成功没有错误码可言。分支处理一律判 `err.error` 里的错误码。
|
||||
*
|
||||
* token 过期换一张重发、换不到当游客重发,都在 authFetch 里,调用方拿到的和一次
|
||||
* 成功没有区别。
|
||||
*/
|
||||
const api = {
|
||||
get: <T>(url: string, config?: RequestConfig) => request<T>("GET", url, undefined, config),
|
||||
post: <T>(url: string, data?: unknown, config?: RequestConfig) =>
|
||||
request<T>("POST", url, data, config),
|
||||
put: <T>(url: string, data?: unknown, config?: RequestConfig) =>
|
||||
request<T>("PUT", url, data, config),
|
||||
delete: <T>(url: string, config?: RequestConfig) =>
|
||||
request<T>("DELETE", url, config?.data, config),
|
||||
}
|
||||
|
||||
export default instance as unknown as ApiClient
|
||||
export default api
|
||||
@@ -86,7 +86,7 @@ async function runRefresh(stale?: string): Promise<RefreshOutcome> {
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
// 裸 fetch:走 utils/api 的 axios 会再进它的拦截器,绕回这里
|
||||
// 裸 fetch:走 authFetch / utils/api 会再来换 token,绕回这里
|
||||
response = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -135,9 +135,11 @@ async function responseCode(response: Response) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不走 axios 的请求(AI 流式接口、离开页面时的 keepalive 上报)用它代替 fetch:
|
||||
* 带上 token,后端回 `token-expired` 就换一张重试一次 —— 和 utils/api.ts 拦截器
|
||||
* 是同一套规矩。
|
||||
* 带登录态的 fetch,所有发往后端的请求都从这里出门(utils/api 也是):带上 token,
|
||||
* 后端回 `token-expired` 就换一张重试一次。
|
||||
*
|
||||
* 换 token 那一步断了网就照 fetch 的样子 reject —— 这时候拿掉 token 重发,要登录的
|
||||
* 接口会回 login-required,学生平白看到登录框,而真正的原因只是网络抖了一下。
|
||||
*/
|
||||
export async function authFetch(input: string, init: RequestInit = {}) {
|
||||
const send = (token: string | null) =>
|
||||
@@ -154,6 +156,7 @@ export async function authFetch(input: string, init: RequestInit = {}) {
|
||||
if ((await responseCode(response)) !== "token-expired") return response
|
||||
|
||||
const outcome = await refreshAccessToken(token)
|
||||
if (outcome.code === "network-error") throw new TypeError("换 token 时网络异常")
|
||||
// 账号被禁用:换 token 那一步就知道了,原请求不必再发。造一个和后端同形的响应,
|
||||
// 调用方按原样处理(弹「账号已被禁用」而不是登录框)
|
||||
if (outcome.code === "account-disabled") {
|
||||
|
||||
@@ -1,58 +1,35 @@
|
||||
import axios from "axios"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { getAccessToken } from "./auth"
|
||||
|
||||
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
|
||||
// 所以不能复用 utils/api 的拦截器(它会把 response.data.data 取出来)。
|
||||
const http = axios.create({
|
||||
baseURL: "/api/admin",
|
||||
responseType: "blob",
|
||||
})
|
||||
|
||||
http.interceptors.request.use(async (config) => {
|
||||
const token = await getAccessToken()
|
||||
if (token) config.headers.set("Authorization", `Bearer ${token}`)
|
||||
return config
|
||||
})
|
||||
import { authFetch } from "./auth"
|
||||
|
||||
// 脱离 n-message-provider 也能弹,理由同 utils/api.ts
|
||||
const { message: toast } = createDiscreteApi(["message"])
|
||||
|
||||
/**
|
||||
* 失败时把错误信封读出来。`responseType: "blob"` 是给成功路径(zip)设的,
|
||||
* 出错时 axios 照样把 JSON 信封包成 Blob 交回来,直接 `.error.message` 取不到。
|
||||
*/
|
||||
async function failureMessage(error: unknown) {
|
||||
const data = (error as { response?: { data?: unknown } }).response?.data
|
||||
if (data instanceof Blob) {
|
||||
try {
|
||||
const payload = JSON.parse(await data.text())
|
||||
const message = payload?.error?.message
|
||||
if (typeof message === "string" && message) return message
|
||||
} catch {
|
||||
// 不是 JSON(网关的 HTML 错误页之类),走下面的兜底文案
|
||||
}
|
||||
}
|
||||
return "下载失败"
|
||||
/** 失败时读后端的错误信封;不是 JSON(网关的 HTML 错误页之类)就用兜底文案 */
|
||||
async function failureMessage(response: Response) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: { message?: string }
|
||||
} | null
|
||||
const message = payload?.error?.message
|
||||
return typeof message === "string" && message ? message : "下载失败"
|
||||
}
|
||||
|
||||
// 指向 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,所以不经 utils/api
|
||||
async function download(url: string) {
|
||||
let res
|
||||
let response: Response
|
||||
try {
|
||||
res = await http.get(url)
|
||||
} catch (error) {
|
||||
response = await authFetch(`/api/admin/${url}`)
|
||||
} catch {
|
||||
toast.error("下载失败")
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
// 以前这里什么都不接:题目还没传测试点时后端回 404,页面上一点反应都没有
|
||||
toast.error(await failureMessage(error))
|
||||
toast.error(await failureMessage(response))
|
||||
return
|
||||
}
|
||||
const headers = res.headers
|
||||
const link = document.createElement("a")
|
||||
link.href = window.URL.createObjectURL(
|
||||
new window.Blob([res.data], {
|
||||
type: String(headers["content-type"] ?? ""),
|
||||
}),
|
||||
)
|
||||
link.download = (headers["content-disposition"] || "").split("filename=")[1]
|
||||
link.href = window.URL.createObjectURL(await response.blob())
|
||||
link.download = (response.headers.get("content-disposition") || "").split("filename=")[1] ?? ""
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
|
||||
@@ -100,8 +100,8 @@ export async function consumeJSONEventStream<T = any>(
|
||||
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
|
||||
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
|
||||
*
|
||||
* 登录失效 / 账号禁用走和 axios 拦截器同一套处理(弹登录框 / 提示禁用)——
|
||||
* 原来这里裸 fetch 绕过了拦截器,未登录时只显示一句「AI 分析生成失败」。
|
||||
* 登录失效 / 账号禁用走和 utils/api 同一套处理(弹登录框 / 提示禁用)——
|
||||
* 原来这里裸 fetch 绕过了那边,未登录时只显示一句「AI 分析生成失败」。
|
||||
*/
|
||||
async function aiStreamError(response: Response) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
|
||||
Reference in new issue
Block a user