177 lines
5.0 KiB
TypeScript
177 lines
5.0 KiB
TypeScript
import axios, {
|
||
AxiosHeaders,
|
||
isAxiosError,
|
||
type AxiosRequestConfig,
|
||
type AxiosResponse,
|
||
} from "axios";
|
||
|
||
/** 后端 `{ code, msg, data }`;约定 `code === 0` 表示成功 */
|
||
export type ApiEnvelope<T = unknown> = {
|
||
code: number;
|
||
msg: string;
|
||
data: T;
|
||
};
|
||
|
||
/** HTTP 状态非 2xx(或 Axios 报错)但其 body 仍是业务信封且 `code !== 0` 时抛出 */
|
||
export class LotteryApiBizError extends Error {
|
||
readonly code: number;
|
||
readonly data: unknown;
|
||
|
||
constructor(message: string, code: number, data: unknown) {
|
||
super(message);
|
||
this.name = "LotteryApiBizError";
|
||
this.code = code;
|
||
this.data = data;
|
||
}
|
||
}
|
||
|
||
/** body 不是约定的信封结构 */
|
||
export class LotteryApiEnvelopeError extends Error {
|
||
constructor(message = "响应不是约定的 { code, msg, data }") {
|
||
super(message);
|
||
this.name = "LotteryApiEnvelopeError";
|
||
}
|
||
}
|
||
|
||
export function setLotteryRequestLocale(locale: string | null): void {
|
||
if (locale === null) {
|
||
overrideLocale = null;
|
||
return;
|
||
}
|
||
const p = locale.trim().toLowerCase().split("-")[0] ?? "";
|
||
overrideLocale =
|
||
p === "zh" || p === "en" || p === "ne" ? (p as "zh" | "en" | "ne") : null;
|
||
}
|
||
|
||
let overrideLocale: "zh" | "en" | "ne" | null = null;
|
||
|
||
function requestLocale(): "zh" | "en" | "ne" {
|
||
if (overrideLocale) {
|
||
return overrideLocale;
|
||
}
|
||
if (typeof document !== "undefined") {
|
||
const tag = document.documentElement.lang.trim().toLowerCase();
|
||
const primary = tag.split("-")[0] ?? tag;
|
||
if (primary === "zh" || primary === "en" || primary === "ne") {
|
||
return primary;
|
||
}
|
||
}
|
||
return "en";
|
||
}
|
||
|
||
function acceptLanguage(loc: ReturnType<typeof requestLocale>): string {
|
||
if (loc === "zh") {
|
||
return "zh-CN,zh;q=0.9,en;q=0.8";
|
||
}
|
||
if (loc === "ne") {
|
||
return "ne,ne-NP;q=0.9,en;q=0.8";
|
||
}
|
||
return "en-US,en;q=0.9";
|
||
}
|
||
|
||
function isEnvelope(v: unknown): v is ApiEnvelope {
|
||
if (v === null || typeof v !== "object") {
|
||
return false;
|
||
}
|
||
const o = v as Record<string, unknown>;
|
||
return (
|
||
typeof o.code === "number" &&
|
||
typeof o.msg === "string" &&
|
||
"data" in o
|
||
);
|
||
}
|
||
|
||
const baseURL = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
|
||
|
||
/**
|
||
* **第一层**:只挂 `baseURL` / `timeout` / 默认 `Accept`。业务解析、toast 都不在这里。
|
||
*/
|
||
export const lotteryHttp = axios.create({
|
||
baseURL: baseURL && baseURL !== "" ? baseURL : undefined,
|
||
timeout: 30_000,
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
|
||
function mergeLocaleHeaders(
|
||
config: AxiosRequestConfig,
|
||
): AxiosRequestConfig {
|
||
const loc = requestLocale();
|
||
const merged: AxiosRequestConfig = { ...config };
|
||
// Axios 的 RequestConfig.headers 类型比 concat 能接受的头对象更窄
|
||
const headers = AxiosHeaders.concat(
|
||
merged.headers as Parameters<typeof AxiosHeaders.concat>[0],
|
||
);
|
||
headers.set("X-Locale", loc);
|
||
headers.set("Accept-Language", acceptLanguage(loc));
|
||
merged.headers = headers;
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`;
|
||
* `code !== 0` 抛 {@link LotteryApiBizError}。
|
||
*/
|
||
export function unwrapData<T>(payload: unknown): T {
|
||
if (!isEnvelope(payload)) {
|
||
throw new LotteryApiEnvelopeError();
|
||
}
|
||
if (payload.code !== 0) {
|
||
throw new LotteryApiBizError(payload.msg, payload.code, payload.data);
|
||
}
|
||
return payload.data as T;
|
||
}
|
||
|
||
/** 对已拿到的 `AxiosResponse` 解一层 `unwrapData(response.data)` */
|
||
export function unwrapResponse<T>(res: AxiosResponse<unknown>): T {
|
||
return unwrapData<T>(res.data);
|
||
}
|
||
|
||
/**
|
||
* **第二层**:自动带语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
|
||
* 是否提示用户由各页面/特性自己 `catch` 决定。
|
||
*/
|
||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||
const merged = mergeLocaleHeaders(config);
|
||
try {
|
||
const res = await lotteryHttp.request<unknown>(merged);
|
||
return unwrapResponse<T>(res);
|
||
} catch (err: unknown) {
|
||
if (isAxiosError(err) && err.response?.data !== undefined) {
|
||
const body = err.response.data;
|
||
if (isEnvelope(body) && body.code !== 0) {
|
||
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** 第二层常用动词(内部都是 `request`) */
|
||
export const lotteryRequest = {
|
||
request,
|
||
|
||
get: <T>(url: string, config?: Omit<AxiosRequestConfig, "url" | "method">) =>
|
||
request<T>({ ...config, url, method: "GET" }),
|
||
|
||
delete: <T>(url: string, config?: Omit<AxiosRequestConfig, "url" | "method">) =>
|
||
request<T>({ ...config, url, method: "DELETE" }),
|
||
|
||
post: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "POST", data }),
|
||
|
||
put: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "PUT", data }),
|
||
|
||
patch: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "PATCH", data }),
|
||
};
|