api.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import axios, {
  2. AxiosError,
  3. type AxiosInstance,
  4. type AxiosRequestConfig,
  5. } from "axios";
  6. import { getApiBaseUrl } from "@/lib/env";
  7. /**
  8. * 业务后端接口约定:
  9. * - 一律使用 POST(含「查询类」路径),新接口请用 {@link apiPost}。
  10. * - 响应体若包含 `code` 字段,仅当 `code === 200` 视为成功;否则抛出 {@link ApiError},优先使用 `msg`。
  11. * 若接入后端 JWT,可把 token 写入该 key,请求会自动带 Authorization。
  12. */
  13. export const API_TOKEN_STORAGE_KEY = "jchl_api_token";
  14. export const API_ACCESS_TOKEN_HEADER = "access-token";
  15. export const api: AxiosInstance = axios.create({
  16. timeout: 30_000,
  17. headers: {
  18. "Content-Type": "application/json",
  19. },
  20. });
  21. /**
  22. * 每次请求解析 baseURL,以便 `__ORIGIN__` 与 window / 部署域名一致。
  23. * - `baseURL: ""`:走当前站点根路径(如 `/api/...` 的 Route Handler)。
  24. * - 调用方已传入非空 `baseURL`(如 {@link postRemittance} 的 `/api-backend-remittance`):不得覆盖,否则会误打到主后端 `/api-backend`。
  25. */
  26. api.interceptors.request.use((config) => {
  27. if (config.baseURL === "") {
  28. config.baseURL =
  29. typeof window !== "undefined"
  30. ? undefined
  31. : (process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "") || undefined);
  32. return config;
  33. }
  34. const callerBase =
  35. config.baseURL !== undefined &&
  36. config.baseURL !== null &&
  37. String(config.baseURL).trim() !== "";
  38. if (callerBase) {
  39. return config;
  40. }
  41. const base = getApiBaseUrl();
  42. config.baseURL = base || undefined;
  43. return config;
  44. });
  45. api.interceptors.request.use((config) => {
  46. if (typeof window !== "undefined") {
  47. const token = localStorage.getItem(API_TOKEN_STORAGE_KEY);
  48. if (token) {
  49. config.headers[API_ACCESS_TOKEN_HEADER] = token;
  50. config.headers.Authorization = `Bearer ${token}`;
  51. }
  52. }
  53. return config;
  54. });
  55. export class ApiError extends Error {
  56. constructor(
  57. message: string,
  58. public status?: number,
  59. public payload?: unknown,
  60. ) {
  61. super(message);
  62. this.name = "ApiError";
  63. }
  64. }
  65. let authTimeoutDialogShown = false;
  66. function notifySessionTimeoutAndRedirect() {
  67. if (typeof window === "undefined") return;
  68. if (authTimeoutDialogShown) return;
  69. authTimeoutDialogShown = true;
  70. window.alert("登录超时,请重新登录");
  71. window.location.assign("/auth/login");
  72. }
  73. function isPlainObject(v: unknown): v is Record<string, unknown> {
  74. return typeof v === "object" && v !== null && !Array.isArray(v);
  75. }
  76. /** 业务层约定:响应体含 `code` 时,仅 `200` 为成功,其余走失败(含 HTTP 200 但 code≠200) */
  77. function businessMessage(body: Record<string, unknown>): string {
  78. const msg = body.msg;
  79. if (typeof msg === "string" && msg.trim()) return msg.trim();
  80. const message = body.message;
  81. if (typeof message === "string" && message.trim()) return message.trim();
  82. return "请求失败";
  83. }
  84. function isBusinessSuccessCode(code: unknown): boolean {
  85. return code === 200 || code === "200";
  86. }
  87. api.interceptors.response.use(
  88. (res) => {
  89. const body = res.data;
  90. if (!isPlainObject(body) || !("code" in body)) return res;
  91. if (body.code === 600 || body.code === "600") {
  92. setApiAuthToken(null);
  93. notifySessionTimeoutAndRedirect();
  94. return Promise.reject(new ApiError("登录状态失效,请重新登录", 600, body));
  95. }
  96. if (isBusinessSuccessCode(body.code)) return res;
  97. const msg = businessMessage(body);
  98. const codeNum =
  99. typeof body.code === "number"
  100. ? body.code
  101. : Number(body.code) || res.status || 400;
  102. return Promise.reject(new ApiError(msg, codeNum, body));
  103. },
  104. (error: AxiosError<{ message?: string; error?: string; msg?: string }>) => {
  105. const status = error.response?.status;
  106. const body = error.response?.data;
  107. const msg =
  108. (typeof body === "object" && body !== null && "msg" in body
  109. ? String((body as { msg?: unknown }).msg)
  110. : undefined) ??
  111. (typeof body === "object" && body !== null && "message" in body
  112. ? body.message
  113. : undefined) ??
  114. (typeof body === "object" && body !== null && "error" in body
  115. ? String((body as { error?: unknown }).error)
  116. : undefined) ??
  117. (error.message?.trim() || "请求失败");
  118. return Promise.reject(new ApiError(String(msg), status, body));
  119. },
  120. );
  121. export function setApiAuthToken(token: string | null) {
  122. if (typeof window === "undefined") return;
  123. if (token) localStorage.setItem(API_TOKEN_STORAGE_KEY, token);
  124. else localStorage.removeItem(API_TOKEN_STORAGE_KEY);
  125. }
  126. export async function apiPost<T, B = unknown>(
  127. url: string,
  128. body?: B,
  129. config?: AxiosRequestConfig,
  130. ): Promise<T> {
  131. const { data } = await api.post<T>(url, body, config);
  132. return data;
  133. }
  134. export async function apiPut<T, B = unknown>(
  135. url: string,
  136. body?: B,
  137. config?: AxiosRequestConfig,
  138. ): Promise<T> {
  139. const { data } = await api.put<T>(url, body, config);
  140. return data;
  141. }
  142. export async function apiPatch<T, B = unknown>(
  143. url: string,
  144. body?: B,
  145. config?: AxiosRequestConfig,
  146. ): Promise<T> {
  147. const { data } = await api.patch<T>(url, body, config);
  148. return data;
  149. }
  150. export async function apiDelete<T>(
  151. url: string,
  152. config?: AxiosRequestConfig,
  153. ): Promise<T> {
  154. const { data } = await api.delete<T>(url, config);
  155. return data;
  156. }