Files
juip-web/src/api/base.ts

257 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-07-30 13:55:28 +08:00
import {
API_BASE_URL,
type ApiResponse,
CLIENT_ID,
CLIENT_SECRET,
} from "@/lib/api"
2026-08-13 18:28:20 +08:00
import { clearSession, getUserToken } from "./token"
2026-07-30 13:55:28 +08:00
2026-08-13 18:28:20 +08:00
type CallMode = "public" | "device" | "user"
2026-07-30 13:55:28 +08:00
2026-08-14 16:30:41 +08:00
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
}
2026-08-13 18:28:20 +08:00
async function request<R = undefined>(
2026-07-30 13:55:28 +08:00
endpoint: string,
2026-08-13 18:28:20 +08:00
init: {
method: "GET" | "POST"
body?: string
auth?: string
2026-08-14 16:30:41 +08:00
timeout?: number
2026-08-13 18:28:20 +08:00
},
): Promise<ApiResponse<R>> {
2026-08-14 16:30:41 +08:00
const controller = new AbortController()
const timer = setTimeout(
() => controller.abort(),
init.timeout ?? DEFAULT_TIMEOUT,
)
2026-07-30 13:55:28 +08:00
try {
2026-08-13 18:28:20 +08:00
const headers: HeadersInit = {}
if (init.body !== undefined) {
headers["Content-Type"] = "application/json"
2026-07-30 13:55:28 +08:00
}
2026-08-13 18:28:20 +08:00
if (init.auth) headers.Authorization = init.auth
2026-07-30 13:55:28 +08:00
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
2026-08-13 18:28:20 +08:00
method: init.method,
2026-07-30 13:55:28 +08:00
headers,
2026-08-13 18:28:20 +08:00
body: init.body,
2026-08-14 16:30:41 +08:00
signal: controller.signal,
2026-07-30 13:55:28 +08:00
})
2026-08-14 16:30:41 +08:00
const responseHeaders = captureHeaders(response)
2026-07-30 13:55:28 +08:00
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 || "请求失败",
}
}
2026-08-14 16:30:41 +08:00
return { success: true, data: undefined as R, headers: responseHeaders }
2026-07-30 13:55:28 +08:00
}
if (contentType.includes("application/json")) {
2026-08-13 18:28:20 +08:00
const json = (await response.json()) as R & {
message?: string
error_description?: string
Code?: number
Message?: string
Data?: unknown
data?: unknown
}
2026-07-30 13:55:28 +08:00
if (!response.ok) {
return {
success: false,
status: response.status,
message: json.message || json.error_description || "请求失败",
}
}
2026-08-13 18:28:20 +08:00
if (typeof json.Code === "number") {
if (json.Code === 10000) {
return {
success: true,
data: (json.Data ?? json.data ?? undefined) as R,
2026-08-14 16:30:41 +08:00
headers: responseHeaders,
2026-08-13 18:28:20 +08:00
}
}
return {
success: false,
2026-08-14 16:30:41 +08:00
status: response.status,
2026-08-13 18:28:20 +08:00
message: json.Message || "请求失败",
}
}
2026-08-14 16:30:41 +08:00
return { success: true, data: json, headers: responseHeaders }
2026-07-30 13:55:28 +08:00
}
throw new Error(`无法解析响应数据: ${contentType}`)
} catch (e) {
2026-08-14 16:30:41 +08:00
if (controller.signal.aborted) {
return {
success: false,
status: 408,
message: "请求超时,请稍后重试",
}
}
2026-07-30 13:55:28 +08:00
console.error("后端请求异常:", e)
return {
success: false,
status: 500,
message: (e as Error).message || "网络错误",
}
2026-08-14 16:30:41 +08:00
} finally {
clearTimeout(timer)
2026-07-30 13:55:28 +08:00
}
}
2026-08-13 18:28:20 +08:00
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>(
2026-07-30 13:55:28 +08:00
endpoint: string,
2026-08-13 18:28:20 +08:00
run: () => Promise<ApiResponse<R>>,
2026-07-30 13:55:28 +08:00
): Promise<ApiResponse<R>> {
2026-08-13 18:28:20 +08:00
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
2026-07-30 13:55:28 +08:00
}
2026-08-13 18:28:20 +08:00
async function call<R = undefined>(
mode: CallMode,
2026-07-30 13:55:28 +08:00
endpoint: string,
data?: unknown,
2026-08-13 18:28:20 +08:00
method: "GET" | "POST" = "POST",
2026-07-30 13:55:28 +08:00
): Promise<ApiResponse<R>> {
2026-08-13 18:28:20 +08:00
const body = data === undefined ? undefined : JSON.stringify(data)
const run = async (): Promise<ApiResponse<R>> => {
2026-08-14 16:30:41 +08:00
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: "未登录或会话已过期",
}
2026-08-13 18:28:20 +08:00
}
2026-08-14 16:30:41 +08:00
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: "设备凭证获取失败",
}
2026-08-13 18:28:20 +08:00
}
2026-08-14 16:30:41 +08:00
auth = `Bearer ${token}`
2026-08-13 18:28:20 +08:00
}
2026-08-14 16:30:41 +08:00
const resp = await request<R>(endpoint, { method, body, auth })
2026-08-13 18:28:20 +08:00
2026-08-14 16:30:41 +08:00
if (!resp.success && resp.status === 401) {
if (mode === "user") {
clearSession()
return resp
}
if (mode === "device" && attempt < maxAttempts) {
continue
}
2026-08-13 18:28:20 +08:00
}
2026-08-14 16:30:41 +08:00
return resp
}
return {
success: false,
status: 401,
message: "请求未授权",
2026-07-30 13:55:28 +08:00
}
}
2026-08-13 18:28:20 +08:00
if (method === "GET" && data === undefined) {
return dedupGet<R>(endpoint, run)
}
return run()
2026-07-30 13:55:28 +08:00
}
2026-08-13 18:28:20 +08:00
async function callPublic<R = undefined>(
2026-07-30 13:55:28 +08:00
endpoint: string,
data?: unknown,
2026-08-13 18:28:20 +08:00
method: "GET" | "POST" = "POST",
2026-07-30 13:55:28 +08:00
): Promise<ApiResponse<R>> {
2026-08-13 18:28:20 +08:00
return call<R>("public", endpoint, data, method)
}
2026-07-30 13:55:28 +08:00
2026-08-13 18:28:20 +08:00
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)
2026-07-30 13:55:28 +08:00
}
export { callByDevice, callByUser, callPublic }