refactor(请求): 去掉 axios,所有请求统一从 authFetch 出门;换 token 时断网不再平白弹登录框
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:
xuyueandClaude Opus 5.5 committed 2026-10-20 19:39:19 +08:00
1 parent 13843c79a0
commit d6c3171d13
9 files changed
+103 -192

No files matched your search

+7 -6
View File
@@ -95,13 +95,14 @@ shared → ./src/shared
### HTTP Client
`utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`). It unwraps both
the axios envelope and the backend's `{ data }` envelope, so callers get the payload
directly. All API calls proxy through the dev server (see `vite.config.ts`).
`utils/api.ts` — 薄薄一层 fetch 封装(没有 axios),前缀 `/api`,get / post / put / delete
四个方法。它剥掉后端的 `{ data }` 信封,调用方直接拿到业务数据;失败抛 `ApiError`。
dev 下由 `vite.config.ts` 的 proxy 转给后端。
登录态是 `Authorization: Bearer` 的 access token(15 分钟)+ refresh token,都在
localStorage,换 token 的逻辑全在 `utils/auth.ts`。**不走 axios 的请求用 `authFetch`
代替裸 `fetch`**,否则不带 token、也不会在 `token-expired` 时换一张重试。
localStorage,换 token 的逻辑全在 `utils/auth.ts`。**所有请求都从 `authFetch` 出门**
(`utils/api` 也是),要自己发 fetch(流式、`keepalive`、下载二进制)时用它代替裸
`fetch`,否则不带 token、也不会在 `token-expired` 时换一张重试。
WebSocket 的 token 借子协议带(浏览器设不了请求头),见契约 `WS_AUTH_PROTOCOL`。
### Contract guard (`utils/contract.ts`)
@@ -179,7 +180,7 @@ Env files: `.env`(本机)、`.env.production`(服务器)、`.env.staging
| `PUBLIC_CODE_URL` | 代码分享服务(提交详情、题目页的「分享」) |
| `PUBLIC_ICONIFY_URL` | 自建 Iconify 图标源,不设则走公共 CDN |
后端地址**不在这里**:`utils/api.ts` 写死 `baseURL: "/api"`,dev 由 `vite.config.ts` 的
后端地址**不在这里**:`utils/api.ts` 写死前缀 `/api`,dev 由 `vite.config.ts` 的
proxy 转给 3000,线上由 Caddy 同源伺服。
### Routing
-1
View File
@@ -26,7 +26,6 @@
"@vueuse/router": "^15.0.0",
"@wangeditor-next/editor": "^6.5.0",
"@wangeditor-next/editor-for-vue": "^6.5.0",
"axios": "^1.20.0",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
+2 -6
View File
@@ -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 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
+4 -6
View File
@@ -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
View File
@@ -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
+7 -4
View File
@@ -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") {
+19 -42
View File
@@ -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()
+2 -2
View File
@@ -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 {