// 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;
}

 

posted on 2026-07-23 14:02  夜之独行者  阅读(5)  评论(0)    收藏  举报