// http-error.handler.ts import { HttpErrorResponse } from '@angular/common/http'; /** 将 HttpErrorResponse 转为可读的用户提示信息 */ export function resolveErrorMessage(error: HttpErrorResponse): string { // 网络断线或 CORS 阻断,status 为 0 if (error.status === 0) { return '网络连接失败,请检查网络设置'; } // 优先使用后端返回的 message 字段 const serverMsg: string | undefined = error.error?.message ?? error.error?.msg ?? error.error?.error; if (serverMsg) return serverMsg; // 兜底:HTTP 语义描述 const HTTP_MESSAGES: Record<number, string> = { 400: '请求参数错误', 401: '登录已过期,请重新登录', 403: '暂无权限执行此操作', 404: '请求的资源不存在', 408: '请求超时,请稍后重试', 409: '数据冲突,请刷新后重试', 422: '提交的数据格式不正确', 429: '操作过于频繁,请稍后再试', 500: '服务器内部错误,请稍后重试', 502: '网关错误,请稍后重试', 503: '服务暂时不可用,请稍后重试', 504: '网关超时,请稍后重试', }; return HTTP_MESSAGES[error.status] ?? `请求失败(${error.status})`; }
// http.service.ts import { Injectable, inject, DestroyRef } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Router } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observable, throwError, BehaviorSubject, Subject, Subscription, catchError, finalize, share, switchMap, filter, take, tap, } from 'rxjs'; import { ConfigService } from '@app/services/config.service'; import { resolveErrorMessage } from './http-error.handler'; import { RequestOptions, RefreshTokenResponse } from './http.types'; import { storageGet, storageSet, storageRemove } from '@app/common/utils/storage'; const ACCESS_TOKEN_KEY = 'access_token'; const REFRESH_TOKEN_KEY = 'refresh_token'; /** * 统一 HTTP 请求服务,提供以下能力: * * 1. 统一网络错误信息:HTTP 状态码映射为可读文字,挂在 err.userMessage * 2. Token 认证 + 自动刷新:access_token 过期(401)时自动刷新后重试原请求; * 并发的多个 401 只触发一次 refresh,其余排队等待新 token * 3. 防抖:传 { debounce: true },相同请求进行中时拦截重复发起 * 4. 请求随页面生命周期结束自动取消:传入组件的 DestroyRef * 5. 全局加载状态:订阅 loading$,计数器模式支持并发请求 * * 用法: * @Component(...) * export class MyComponent { * // inject() 必须在类的注入上下文(字段初始化)中调用 * private http = inject(HttpService); * private destroyRef = inject(DestroyRef); * * load() { * // 基本 GET * this.http.get<User[]>('/api/users').subscribe(...) * * // 防抖(进行中时拒绝重复发起) * this.http.post('/api/submit', body, { debounce: true }).subscribe(...) * * // 随组件销毁自动取消 * this.http.get('/api/data', {}, this.destroyRef).subscribe(...) * * // 防抖 + 随组件销毁自动取消 * this.http.post('/api/submit', body, { debounce: true }, this.destroyRef).subscribe(...) * } * } * * refresh_token 过期时服务内部自动跳转 /login,同时通过 refreshFailed$ 发出通知供外部扩展: * inject(HttpService).refreshFailed$.subscribe(() => { ... }); */ @Injectable({ providedIn: 'root' }) export class HttpService { private readonly httpClient = inject(HttpClient); private readonly config = inject(ConfigService); private readonly router = inject(Router); // ── 防抖:记录进行中的请求 key ────────────────────────────────────────────── private readonly _pending = new Set<string>(); // ── 全局加载计数器 ───────────────────────────────────────────────────────── // 计数器模式:多个并发请求时只在第一个开始 / 最后一个结束时切换状态 private _loadingCount = 0; readonly loading$ = new BehaviorSubject<boolean>(false); // ── Token 刷新状态机 ─────────────────────────────────────────────────────── // true = 空闲(可发起刷新) // null = 刷新进行中(后续 401 排队等待结果) // false = 刷新失败(排队请求收到 false 后报错) private readonly _refreshing$ = new BehaviorSubject<boolean | null>(true); // 持有刷新请求的订阅,用于取消或判断是否重复订阅 private _refreshSub: Subscription | null = null; /** refresh_token 失效时发出,外部订阅以跳转登录页 */ readonly refreshFailed$ = new Subject<void>(); // ───────────────────────────────────────────────────────────────────────── get<T>(url: string, options: RequestOptions = {}, destroyRef?: DestroyRef): Observable<T> { return this._request<T>('GET', url, null, options, destroyRef); } post<T>(url: string, body: unknown, options: RequestOptions = {}, destroyRef?: DestroyRef): Observable<T> { return this._request<T>('POST', url, body, options, destroyRef); } put<T>(url: string, body: unknown, options: RequestOptions = {}, destroyRef?: DestroyRef): Observable<T> { return this._request<T>('PUT', url, body, options, destroyRef); } patch<T>(url: string, body: unknown, options: RequestOptions = {}, destroyRef?: DestroyRef): Observable<T> { return this._request<T>('PATCH', url, body, options, destroyRef); } delete<T>(url: string, options: RequestOptions = {}, destroyRef?: DestroyRef): Observable<T> { return this._request<T>('DELETE', url, null, options, destroyRef); } // ── Token 存取 ───────────────────────────────────────────────────────────── setTokens(accessToken: string, refreshToken?: string) { storageSet(ACCESS_TOKEN_KEY, accessToken, 'local'); if (refreshToken) storageSet(REFRESH_TOKEN_KEY, refreshToken, 'local'); } clearTokens() { storageRemove(ACCESS_TOKEN_KEY, 'local'); storageRemove(REFRESH_TOKEN_KEY, 'local'); } getAccessToken(): string | null { return storageGet<string>(ACCESS_TOKEN_KEY, 'local'); } // ── 核心请求 ─────────────────────────────────────────────────────────────── private _request<T>( method: string, url: string, body: unknown, options: RequestOptions, destroyRef?: DestroyRef, ): Observable<T> { const fullUrl = this._buildUrl(url); const debounceKey = options.debounceKey ?? `${method}:${fullUrl}`; // 防抖拦截:相同 key 的请求已在进行中,直接拒绝 if (options.debounce && this._pending.has(debounceKey)) { return throwError(() => new Error(`[HttpService] 请求 ${debounceKey} 正在进行中,已拦截重复发起`)); } const req$ = this._send<T>(method, fullUrl, body, options, debounceKey).pipe( catchError((err: HttpErrorResponse) => this._handleError<T>(err, method, fullUrl, body, options, debounceKey) ), ); // 传入 DestroyRef:组件销毁时自动取消,finalize 会同步清理 loading 和 pending return destroyRef ? req$.pipe(takeUntilDestroyed(destroyRef)) : req$; } private _send<T>( method: string, url: string, body: unknown, options: RequestOptions, debounceKey: string, ): Observable<T> { if (options.debounce) this._pending.add(debounceKey); this._incLoading(); const httpOptions = { headers: this._buildHeaders(options), params: options.params, context: options.context, }; const source$: Observable<T> = (() => { switch (method) { case 'GET': return this.httpClient.get<T>(url, httpOptions); case 'POST': return this.httpClient.post<T>(url, body, httpOptions); case 'PUT': return this.httpClient.put<T>(url, body, httpOptions); case 'PATCH': return this.httpClient.patch<T>(url, body, httpOptions); case 'DELETE': return this.httpClient.delete<T>(url, httpOptions); default: return throwError(() => new Error(`不支持的方法: ${method}`)); } })(); return source$.pipe( finalize(() => { // 无论成功、失败、还是被 takeUntilDestroyed 取消,都清理状态 this._pending.delete(debounceKey); this._decLoading(); }), // 多个订阅者共享同一请求,不重复发送 share(), ); } // ── 错误处理 ─────────────────────────────────────────────────────────────── private _handleError<T>( err: HttpErrorResponse, method: string, url: string, body: unknown, options: RequestOptions, debounceKey: string, ): Observable<T> { // 401 且非刷新请求本身 → 进入 token 刷新流程 if (err.status === 401 && !options._isRefreshRequest) { return this._refreshAndRetry<T>(method, url, body, options, debounceKey); } const message = resolveErrorMessage(err); console.error(`[HttpService] ${method} ${url} → ${message}`, err); return throwError(() => Object.assign(err, { userMessage: message })); } // ── Token 刷新 + 重试 ────────────────────────────────────────────────────── private _refreshAndRetry<T>( method: string, url: string, body: unknown, options: RequestOptions, debounceKey: string, ): Observable<T> { // 当前空闲(true)才发起刷新;进行中(null)则直接排队等待结果 if (this._refreshing$.getValue() === true) { this._refreshing$.next(null); this._doRefresh(); } // 排队等待刷新结果,成功后用新 token 重试原请求 return this._refreshing$.pipe( filter((result): result is boolean => result !== null), take(1), switchMap(success => { if (!success) { return throwError(() => new Error('登录已过期,请重新登录')); } // 标记为重试请求,避免再次触发刷新 return this._send<T>(method, url, body, { ...options, _isRefreshRequest: true }, debounceKey); }), ); } private _doRefresh() { const refreshToken = storageGet<string>(REFRESH_TOKEN_KEY, 'local'); if (!refreshToken) { this._onRefreshFailed(); return; } // 取消上次未完成的刷新订阅,防止重复 this._refreshSub?.unsubscribe(); this._refreshSub = this.httpClient.post<RefreshTokenResponse>( this._buildUrl('/auth/refresh'), { refresh_token: refreshToken }, // 直接走 httpClient,不经过 _request,避免 401 递归触发刷新 { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }, ).pipe( tap(res => { this.setTokens(res.access_token, res.refresh_token); this._refreshSub = null; // 广播成功,所有排队的 401 请求开始用新 token 重试 this._refreshing$.next(true); }), catchError(() => { this._onRefreshFailed(); return throwError(() => new Error('Token 刷新失败')); }), ).subscribe(); } private _onRefreshFailed() { this._refreshSub = null; this.clearTokens(); // 先广播 false,排队的请求全部报错并结束 this._refreshing$.next(false); // 同步重置为 true(空闲),下次登录后首个 401 能再次触发刷新 // 必须同步执行:take(1) 订阅者收到 false 后已完成,不会收到后续的 true this._refreshing$.next(true); this.refreshFailed$.next(); // refresh_token 已过期,跳转登录页 this.router.navigate(['/login']); } // ── 工具方法 ─────────────────────────────────────────────────────────────── private _buildUrl(url: string): string { if (url.startsWith('http')) return url; return `${this.config.api?.baseUrl ?? ''}${url}`; } private _buildHeaders(options: RequestOptions): Record<string, string> { const token = this.getAccessToken(); return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), // 合并调用方自定义 headers(HttpHeaders 实例由 Angular 内部处理,不需手动合并) ...(options.headers && !(options.headers instanceof HttpHeaders) ? (options.headers as Record<string, string>) : {}), }; } private _incLoading() { this._loadingCount++; if (this._loadingCount === 1) this.loading$.next(true); } private _decLoading() { this._loadingCount = Math.max(0, this._loadingCount - 1); if (this._loadingCount === 0) this.loading$.next(false); } }
// http.types.ts import { HttpContext, HttpHeaders, HttpParams } from '@angular/common/http'; /** 统一 API 响应结构,后端可依实际格式调整 */ export interface ApiResponse<T = unknown> { code: number; message: string; data: T; } /** 请求选项 */ export interface RequestOptions { headers?: HttpHeaders | Record<string, string>; params?: HttpParams | Record<string, string | number | boolean>; context?: HttpContext; /** * 启用防抖:相同 key 的请求进行中时拒绝重复发起。 * 默认 key 为 "{method}:{url}",可用 debounceKey 自定义。 */ debounce?: boolean; /** 自定义防抖 key */ debounceKey?: string; /** 内部标记:刷新请求本身,避免 401 时再次触发刷新造成无限循环(外部不要使用) */ _isRefreshRequest?: boolean; } /** /auth/refresh 接口响应结构 */ export interface RefreshTokenResponse { access_token: string; refresh_token?: string; expires_in?: number; }
浙公网安备 33010602011771号