Files
juip-web/src/api/base.ts
2026-08-14 16:30:41 +08:00

257 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
API_BASE_URL,
type ApiResponse,
CLIENT_ID,
CLIENT_SECRET,
} from "@/lib/api"
import { clearSession, getUserToken } from "./token"
type CallMode = "public" | "device" | "user"
const DEFAULT_TIMEOUT = 15_000
function captureHeaders(response: Response): Record<string, string> {
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value
})
return headers
}
async function request<R = undefined>(
endpoint: string,
init: {
method: "GET" | "POST"
body?: string
auth?: string
timeout?: number
},
): Promise<ApiResponse<R>> {
const controller = new AbortController()
const timer = setTimeout(
() => controller.abort(),
init.timeout ?? DEFAULT_TIMEOUT,
)
try {
const headers: HeadersInit = {}
if (init.body !== undefined) {
headers["Content-Type"] = "application/json"
}
if (init.auth) headers.Authorization = init.auth
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: init.method,
headers,
body: init.body,
signal: controller.signal,
})
const responseHeaders = captureHeaders(response)
const contentType = response.headers.get("Content-Type") ?? "text/plain"
if (contentType.includes("text/plain")) {
const text = await response.text()
if (!response.ok) {
return {
success: false,
status: response.status,
message: text || "请求失败",
}
}
return { success: true, data: undefined as R, headers: responseHeaders }
}
if (contentType.includes("application/json")) {
const json = (await response.json()) as R & {
message?: string
error_description?: string
Code?: number
Message?: string
Data?: unknown
data?: unknown
}
if (!response.ok) {
return {
success: false,
status: response.status,
message: json.message || json.error_description || "请求失败",
}
}
if (typeof json.Code === "number") {
if (json.Code === 10000) {
return {
success: true,
data: (json.Data ?? json.data ?? undefined) as R,
headers: responseHeaders,
}
}
return {
success: false,
status: response.status,
message: json.Message || "请求失败",
}
}
return { success: true, data: json, headers: responseHeaders }
}
throw new Error(`无法解析响应数据: ${contentType}`)
} catch (e) {
if (controller.signal.aborted) {
return {
success: false,
status: 408,
message: "请求超时,请稍后重试",
}
}
console.error("后端请求异常:", e)
return {
success: false,
status: 500,
message: (e as Error).message || "网络错误",
}
} finally {
clearTimeout(timer)
}
}
let deviceToken: string | null = null
let deviceTokenExpire = 0
let deviceTokenPromise: Promise<string | null> | null = null
async function acquireDeviceToken(): Promise<string | null> {
if (deviceToken && Date.now() < deviceTokenExpire) return deviceToken
if (!deviceTokenPromise) {
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
deviceTokenPromise = request<{
access_token: string
expires_in: number
}>("/api/auth/token", {
method: "POST",
body: JSON.stringify({ grant_type: "client_credentials" }),
auth: `Basic ${basic}`,
})
.then(resp => {
if (!resp.success) {
deviceToken = null
return null
}
deviceToken = resp.data.access_token
deviceTokenExpire = Date.now() + (resp.data.expires_in - 60) * 1000
return resp.data.access_token
})
.finally(() => {
deviceTokenPromise = null
})
}
return deviceTokenPromise
}
const inflight = new Map<string, Promise<ApiResponse<unknown>>>()
function dedupGet<R>(
endpoint: string,
run: () => Promise<ApiResponse<R>>,
): Promise<ApiResponse<R>> {
const existing = inflight.get(endpoint)
if (existing) return existing as Promise<ApiResponse<R>>
const promise = run().finally(() => inflight.delete(endpoint))
inflight.set(endpoint, promise)
return promise
}
async function call<R = undefined>(
mode: CallMode,
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
const body = data === undefined ? undefined : JSON.stringify(data)
const run = async (): Promise<ApiResponse<R>> => {
const maxAttempts = mode === "device" ? 2 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let auth: string | undefined
if (mode === "user") {
const token = getUserToken()
if (!token) {
return {
success: false,
status: 401,
message: "未登录或会话已过期",
}
}
auth = `Bearer ${token}`
} else if (mode === "device") {
if (attempt > 1) {
// 上次请求 401强制作废旧 token 再重新获取
deviceToken = null
deviceTokenExpire = 0
}
const token = await acquireDeviceToken()
if (!token) {
return {
success: false,
status: 401,
message: "设备凭证获取失败",
}
}
auth = `Bearer ${token}`
}
const resp = await request<R>(endpoint, { method, body, auth })
if (!resp.success && resp.status === 401) {
if (mode === "user") {
clearSession()
return resp
}
if (mode === "device" && attempt < maxAttempts) {
continue
}
}
return resp
}
return {
success: false,
status: 401,
message: "请求未授权",
}
}
if (method === "GET" && data === undefined) {
return dedupGet<R>(endpoint, run)
}
return run()
}
async function callPublic<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
return call<R>("public", endpoint, data, method)
}
async function callByDevice<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
return call<R>("device", endpoint, data, method)
}
async function callByUser<R = undefined>(
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
return call<R>("user", endpoint, data, method)
}
export { callByDevice, callByUser, callPublic }