wsj
4 days ago fe1692fd7f4f307fea8054da6c6c50d5df2bf2d8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import axios from 'axios';
import type {
  AxiosError,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig,
} from 'axios';
import { showErrorMessage } from '@/utils/feedback';
 
/** 一个请求的配置。TReq 为请求参数 / 请求体的类型。 */
export type ApiRequestConfig<TReq = unknown> = Omit<
  AxiosRequestConfig<TReq>,
  'signal'
> & {
  url: string;
};
 
/** 每个调用方可以单独设置的请求选项。 */
export interface RequestOptions<TParse extends boolean = boolean> {
  /** 请求失败时是否自动提示错误信息,默认 true。 */
  showError?: boolean;
  /** 是否合并相同的并发请求,默认 true;为 false 时每次都单独发送。 */
  merge?: boolean;
  /** 是否解析响应:true 返回 response.data,false 返回完整 AxiosResponse。默认 true。 */
  parse?: TParse;
  /** 用于取消当前调用方发起的请求。 */
  signal?: AbortSignal;
}
 
/** 请求失败时抛出的统一错误,保留 Axios 返回的完整信息。 */
export class ApiError extends Error {
  readonly code?: string;
  readonly status?: number;
  readonly data?: unknown;
  readonly response?: AxiosResponse;
  readonly config?: InternalAxiosRequestConfig;
  readonly isCanceled: boolean;
  readonly originalError?: unknown;
  /** 相同请求被多个调用方共享时,保证错误信息只提示一次。 */
  notified = false;
 
  constructor(
    message: string,
    options: {
      code?: string;
      status?: number;
      data?: unknown;
      response?: AxiosResponse;
      config?: InternalAxiosRequestConfig;
      isCanceled?: boolean;
      originalError?: unknown;
    } = {},
  ) {
    super(message);
    this.name = 'ApiError';
    this.code = options.code;
    this.status = options.status;
    this.data = options.data;
    this.response = options.response;
    this.config = options.config;
    this.isCanceled = options.isCanceled ?? false;
    this.originalError = options.originalError;
  }
}
 
const http = axios.create({
  // 开发环境走 Vite 代理(同源),生产环境使用真实接口地址。
  baseURL: import.meta.env.DEV
    ? '/proxy-api'
    : import.meta.env.VITE_API_BASE_URL ?? '',
  timeout: 15000,
});
 
/** 递归排序对象字段,保证相同参数得到相同的请求 key。 */
const sortValue = (value: unknown): unknown => {
  if (Array.isArray(value)) return value.map(sortValue);
  if (value && typeof value === 'object') {
    return Object.keys(value as Record<string, unknown>)
      .sort()
      .reduce<Record<string, unknown>>((result, key) => {
        result[key] = sortValue((value as Record<string, unknown>)[key]);
        return result;
      }, {});
  }
  return value;
};
 
const stringify = (value: unknown): string => {
  if (value === undefined) return '';
  try {
    return JSON.stringify(sortValue(value));
  } catch {
    return String(value);
  }
};
 
const getRequestKey = <TReq>(config: ApiRequestConfig<TReq>): string =>
  [
    (config.method ?? 'GET').toUpperCase(),
    config.url,
    stringify(config.params),
    stringify(config.data),
  ].join('|');
 
/** 正在进行中的相同请求,值为完整的 AxiosResponse。 */
const pendingRequests = new Map<string, Promise<AxiosResponse>>();
 
const createCanceledError = () =>
  new ApiError('请求已取消', { isCanceled: true, code: 'ERR_CANCELED' });
 
const toApiError = (error: unknown): ApiError => {
  if (error instanceof ApiError) return error;
 
  const axiosError = error as AxiosError<{ message?: string; msg?: string }>;
  const response = axiosError?.response;
  const data = response?.data;
  const serverMessage =
    typeof data?.message === 'string'
      ? data.message
      : typeof data?.msg === 'string'
        ? data.msg
        : undefined;
  const isCanceled = axios.isCancel(error) || axiosError?.code === 'ERR_CANCELED';
 
  return new ApiError(
    isCanceled ? '请求已取消' : serverMessage ?? axiosError?.message ?? '网络请求失败',
    {
      code: axiosError?.code,
      status: response?.status,
      data,
      response,
      config: axiosError?.config,
      isCanceled,
      originalError: error,
    },
  );
};
 
/** 发送底层请求并始终保留完整响应;解析与提示交给每个调用方处理。 */
const execute = async <TReq, TRes>(
  config: ApiRequestConfig<TReq>,
  options: RequestOptions,
): Promise<AxiosResponse<TRes>> => {
  try {
    return await http.request<TRes, AxiosResponse<TRes>, TReq>({
      ...config,
      // 合并请求时不能把某个调用方的 signal 交给底层请求,否则会取消其他调用方。
      signal: options.merge === false ? options.signal : undefined,
    });
  } catch (error) {
    throw toApiError(error);
  }
};
 
/** 后端统一响应包装:{ code, message, data, success }。 */
interface ResultEnvelope {
  code: number | string;
  message?: string;
  data?: unknown;
  success?: boolean;
}
 
const isResultEnvelope = (body: unknown): body is ResultEnvelope => {
  if (!body || typeof body !== 'object') return false;
  const record = body as Record<string, unknown>;
  return (
    'code' in record &&
    ('data' in record || 'success' in record || 'message' in record)
  );
};
 
const isSuccessCode = (code: number | string) => code === 200;
 
const formatResponse = <TRes, TParse extends boolean>(
  response: AxiosResponse<TRes>,
  options: RequestOptions<TParse>,
): TParse extends true ? TRes : AxiosResponse<TRes> => {
  if (options.parse === false) {
    return response as TParse extends true ? TRes : AxiosResponse<TRes>;
  }
 
  const body = response.data as unknown;
  if (isResultEnvelope(body)) {
    const succeeded = isSuccessCode(body.code) || body.success === true ;
    if (!succeeded) {
      throw new ApiError(
        body.message || '请求失败',
        {
          code: String(body.code),
          status: response.status,
          data: body,
          response,
          config: response.config,
        },
      );
    }
    return body.data as TParse extends true ? TRes : AxiosResponse<TRes>;
  }
 
  return body as TParse extends true ? TRes : AxiosResponse<TRes>;
};
 
const notifyError = (error: ApiError, options: RequestOptions) => {
  if (error.isCanceled || options.showError === false || error.notified) return;
  error.notified = true;
  showErrorMessage(error.message);
};
 
/** 让共享请求对每个调用方单独支持取消。 */
const raceWithSignal = <T>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
  if (signal.aborted) return Promise.reject(createCanceledError());
 
  return new Promise<T>((resolve, reject) => {
    const onAbort = () => reject(createCanceledError());
    signal.addEventListener('abort', onAbort, { once: true });
 
    promise.then(
      (value) => {
        signal.removeEventListener('abort', onAbort);
        resolve(value);
      },
      (error) => {
        signal.removeEventListener('abort', onAbort);
        reject(error);
      },
    );
  });
};
 
/**
 * 发送网络请求。
 *
 * 默认合并相同的并发请求、自动提示错误,并且只返回服务端的 data。
 *
 * @example
 * const result = await request<{ keyword: string }, PageResult<User>>({
 *   url: '/api/users',
 *   method: 'GET',
 *   params: { keyword },
 * });
 *
 * // 不解析,拿到完整的 AxiosResponse
 * const response = await request<void, User[]>(
 *   { url: '/api/users' },
 *   { parse: false },
 * );
 *
 * // 不合并、不自动提示,并通过 signal 取消
 * const controller = new AbortController();
 * await request({ url: '/api/users' }, {
 *   merge: false,
 *   showError: false,
 *   signal: controller.signal,
 * });
 */
export const request = async <
  TReq = unknown,
  TRes = unknown,
  TParse extends boolean = true,
>(
  config: ApiRequestConfig<TReq>,
  options: RequestOptions<TParse> = {},
): Promise<TParse extends true ? TRes : AxiosResponse<TRes>> => {
  const { signal, merge = true } = options;
 
  if (signal?.aborted) {
    throw createCanceledError();
  }
 
  let task: Promise<AxiosResponse<TRes>>;
 
  if (merge) {
    const key = getRequestKey(config);
    const existing = pendingRequests.get(key) as Promise<AxiosResponse<TRes>> | undefined;
 
    if (existing) {
      task = existing;
    } else {
      task = execute<TReq, TRes>(config, options);
      pendingRequests.set(key, task);
 
      const cleanup = () => {
        if (pendingRequests.get(key) === task) {
          pendingRequests.delete(key);
        }
      };
      task.then(cleanup, cleanup);
    }
  } else {
    task = execute<TReq, TRes>(config, options);
  }
 
  try {
    const response = merge && signal ? await raceWithSignal(task, signal) : await task;
    console.log('response', response);
    return formatResponse(response, options);
  } catch (error) {
    const apiError = toApiError(error);
    notifyError(apiError, options);
    throw apiError;
  }
};