baseService.ts•13.5 kB
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
import { Logger } from '../utils/logger.js';
import { formatErrorMessage, AuthRequiredError } from '../utils/error.js';
import { Config } from '../utils/config.js';
import { TokenCacheManager, UserContextManager,AuthUtils } from '../utils/auth/index.js';
/**
 * API请求错误接口
 */
export interface ApiError {
  status: number;
  err: string;
  apiError?: any;
  logId?: string;
}
/**
 * API响应接口
 */
export interface ApiResponse<T = any> {
  code: number;
  msg: string;
  data: T;
  log_id?: string;
}
/**
 * API服务基类
 * 提供通用的HTTP请求处理和认证功能
 */
export abstract class BaseApiService {
  /**
   * 获取API基础URL
   * @returns API基础URL
   */
  protected abstract getBaseUrl(): string;
  /**
   * 获取访问令牌
   * @param userKey 用户标识(可选)
   * @returns 访问令牌
   */
  protected abstract getAccessToken(userKey?: string): Promise<string>;
  /**
   * 处理API错误
   * @param error 错误对象
   * @param message 错误上下文消息
   * @throws 标准化的API错误
   */
  protected handleApiError(error: any, message: string): never {
    Logger.error(`${message}:`, error);
    
    // 如果已经是格式化的API错误,直接重新抛出
    if (error && typeof error === 'object' && 'status' in error && 'err' in error) {
      throw error;
    }
    
    // 处理Axios错误
    if (error instanceof AxiosError && error.response) {
      const responseData = error.response.data;
      const apiError: ApiError = {
        status: error.response.status,
        err: formatErrorMessage(error, message),
        apiError: responseData,
        logId: responseData?.log_id
      };
      throw apiError;
    }
    
    // 处理其他类型的错误
    const errorMessage = error instanceof Error 
      ? error.message 
      : (typeof error === 'string' ? error : '未知错误');
    
    throw {
      status: 500,
      err: formatErrorMessage(error, message),
      apiError: {
        code: -1,
        msg: errorMessage,
        error
      }
    } as ApiError;
  }
  
  /**
   * 执行API请求
   * @param endpoint 请求端点
   * @param method 请求方法
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @param additionalHeaders 附加请求头
   * @param responseType 响应类型
   * @param retry 是否允许重试,默认为false
   * @returns 响应数据
   */
  protected async request<T = any>(
    endpoint: string,
    method: string = 'GET',
    data?: any,
    needsAuth: boolean = true,
    additionalHeaders?: Record<string, string>,
    responseType?: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream',
    retry: boolean = false
  ): Promise<T> {
    // 获取用户上下文
    const userContextManager = UserContextManager.getInstance();
    const userKey = userContextManager.getUserKey();
    const baseUrl = userContextManager.getBaseUrl();
    const clientKey = AuthUtils.generateClientKey(userKey);
    Logger.debug(`[BaseService] Request context - userKey: ${userKey}, baseUrl: ${baseUrl}`);
    try {
      // 构建请求URL
      const url = `${this.getBaseUrl()}${endpoint}`;
      // 准备请求头
      const headers: Record<string, string> = {
        ...additionalHeaders
      };
      // 如果数据是FormData,合并FormData的headers
      // 否则设置为application/json
      if (data instanceof FormData) {
        Object.assign(headers, data.getHeaders());
      } else {
        headers['Content-Type'] = 'application/json';
      }
      
      // 添加认证令牌
      if (needsAuth) {
        const accessToken = await this.getAccessToken(userKey);
        headers['Authorization'] = `Bearer ${accessToken}`;
      }
      
      // 记录请求信息
      Logger.debug('准备发送请求:');
      Logger.debug(`请求URL: ${url}`);
      Logger.debug(`请求方法: ${method}`);
      if (data) {
        Logger.debug(`请求数据:`, data);
      }
      
      // 构建请求配置
      const config: AxiosRequestConfig = {
        method,
        url,
        headers,
        data: method !== 'GET' ? data : undefined,
        params: method === 'GET' ? data : undefined,
        responseType: responseType || 'json'
      };
      
      // 发送请求
      const response = await axios<ApiResponse<T>>(config);
      
      // 记录响应信息
      Logger.debug('收到响应:');
      Logger.debug(`响应状态码: ${response.status}`);
      Logger.debug(`响应头:`, response.headers);
      Logger.debug(`响应数据:`, response.data);
      
      // 对于非JSON响应,直接返回数据
      if (responseType && responseType !== 'json') {
        return response.data as T;
      }
      
      // 检查API错误(仅对JSON响应)
      if (response.data && typeof response.data.code === 'number' && response.data.code !== 0) {
        Logger.error(`API返回错误码: ${response.data.code}, 错误消息: ${response.data.msg}`);
        throw {
          status: response.status,
          err: response.data.msg || 'API返回错误码',
          apiError: response.data,
          logId: response.data.log_id
        } as ApiError;
      }
      
      // 返回数据
      return response.data.data;
    } catch (error) {
      const config = Config.getInstance().feishu;
      // 处理授权异常
      if (error instanceof AuthRequiredError) {
         return this.handleAuthFailure(config.authType==="tenant", clientKey, baseUrl, userKey);
      }
      // 处理认证相关错误(401, 403等)
      if (error instanceof AxiosError && error.response &&  (error.response.status >= 400 || error.response.status <= 499)) {
        Logger.warn(`认证失败 (${error.response.status}): ${endpoint}`);
        // 获取配置和token缓存管理器
        const tokenCacheManager = TokenCacheManager.getInstance();
        // 如果已经重试过,直接处理认证失败
        if (retry) {
          return this.handleAuthFailure(config.authType==="tenant", clientKey, baseUrl, userKey);
        }
        // 根据认证类型处理token过期
        if (config.authType === 'tenant') {
          return this.handleTenantTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType);
        } else {
          return this.handleUserTokenExpired(tokenCacheManager, clientKey, endpoint, method, data, needsAuth, additionalHeaders, responseType,baseUrl, userKey);
        }
      }
      // 处理其他错误
      this.handleApiError(error, `API请求失败 (${endpoint})`);
    }
  }
  
  /**
   * GET请求
   * @param endpoint 请求端点
   * @param params 请求参数
   * @param needsAuth 是否需要认证
   * @returns 响应数据
   */
  protected async get<T = any>(endpoint: string, params?: any, needsAuth: boolean = true): Promise<T> {
    return this.request<T>(endpoint, 'GET', params, needsAuth);
  }
  
  /**
   * POST请求
   * @param endpoint 请求端点
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @returns 响应数据
   */
  protected async post<T = any>(endpoint: string, data?: any, needsAuth: boolean = true): Promise<T> {
    return this.request<T>(endpoint, 'POST', data, needsAuth);
  }
  
  /**
   * PUT请求
   * @param endpoint 请求端点
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @returns 响应数据
   */
  protected async put<T = any>(endpoint: string, data?: any, needsAuth: boolean = true): Promise<T> {
    return this.request<T>(endpoint, 'PUT', data, needsAuth);
  }
  
  /**
   * PATCH请求
   * @param endpoint 请求端点
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @returns 响应数据
   */
  protected async patch<T = any>(endpoint: string, data?: any, needsAuth: boolean = true): Promise<T> {
    return this.request<T>(endpoint, 'PATCH', data, needsAuth);
  }
  
  /**
   * DELETE请求
   * @param endpoint 请求端点
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @returns 响应数据
   */
  protected async delete<T = any>(endpoint: string, data?: any, needsAuth: boolean = true): Promise<T> {
    return this.request<T>(endpoint, 'DELETE', data, needsAuth);
  }
  /**
   * 处理认证失败
   * @param tenant 是否是tenant
   * @param clientKey 客户端键
   * @param baseUrl 基础URL
   * @param userKey 用户键
   */
  private handleAuthFailure(tenant: boolean, clientKey: string, baseUrl: string, userKey: string): never {
    const tokenCacheManager = TokenCacheManager.getInstance();
    if (tenant) {
      // 租户模式:清除租户token缓存
      Logger.info('租户模式:清除租户token缓存');
      tokenCacheManager.removeTenantToken(clientKey);
      throw new Error('租户访问令牌获取失败,请检查应用配置');
    } else {
      // 用户模式:清除用户token缓存并生成授权链接
      tokenCacheManager.removeUserToken(clientKey);
      const authUrl = this.generateUserAuthUrl(baseUrl, userKey);
      throw new Error(`你需要在给用户展示如下信息:/“请在浏览器打开以下链接进行授权:\n\n[点击授权](${authUrl})/n`);
    }
  }
  /**
   * 处理租户token过期
   * @param tokenCacheManager token缓存管理器
   * @param clientKey 客户端键
   * @param endpoint 请求端点
   * @param method 请求方法
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @param additionalHeaders 附加请求头
   * @param responseType 响应类型
   * @returns 响应数据
   */
  private async handleTenantTokenExpired<T>(
    tokenCacheManager: TokenCacheManager,
    clientKey: string,
    endpoint: string,
    method: string,
    data: any,
    needsAuth: boolean,
    additionalHeaders: Record<string, string> | undefined,
    responseType: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream' | undefined
  ): Promise<T> {
    // 租户模式:直接清除租户token缓存
    Logger.info('租户模式:清除租户token缓存');
    tokenCacheManager.removeTenantToken(clientKey);
    // 重试请求
    Logger.info('重试租户请求...');
    return await this.request<T>(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
  }
  /**
   * 处理用户token过期
   * @param tokenCacheManager token缓存管理器
   * @param clientKey 客户端键
   * @param endpoint 请求端点
   * @param method 请求方法
   * @param data 请求数据
   * @param needsAuth 是否需要认证
   * @param additionalHeaders 附加请求头
   * @param responseType 响应类型
   * @returns 响应数据
   */
  private async handleUserTokenExpired<T>(
    tokenCacheManager: TokenCacheManager,
    clientKey: string,
    endpoint: string,
    method: string,
    data: any,
    needsAuth: boolean,
    additionalHeaders: Record<string, string> | undefined,
    responseType: 'json' | 'arraybuffer' | 'blob' | 'document' | 'text' | 'stream' | undefined,
    baseUrl: string,
    userKey: string
  ): Promise<T> {
    // 用户模式:检查用户token状态
    const tokenStatus = tokenCacheManager.checkUserTokenStatus(clientKey);
    Logger.debug(`用户token状态:`, tokenStatus);
    if (tokenStatus.canRefresh && !tokenStatus.isExpired) {
      // 有有效的refresh_token,设置token为过期状态,让下次请求时刷新
      Logger.info('用户模式:token过期,将在下次请求时刷新');
      const tokenInfo = tokenCacheManager.getUserTokenInfo(clientKey);
      if (tokenInfo) {
        // 设置access_token为过期,但保留refresh_token
        tokenInfo.expires_at = Math.floor(Date.now() / 1000) - 1;
        tokenCacheManager.cacheUserToken(clientKey, tokenInfo);
      }
      // 重试请求
      Logger.info('重试用户请求...');
      return await this.request<T>(endpoint, method, data, needsAuth, additionalHeaders, responseType, true);
    } else {
      // refresh_token已过期或不存在,直接清除缓存
      Logger.warn('用户模式:refresh_token已过期,清除用户token缓存');
      tokenCacheManager.removeUserToken(clientKey);
      return this.handleAuthFailure(true,clientKey,baseUrl,userKey);
    }
  }
  /**
   * 生成用户授权URL
   * @param baseUrl 基础URL
   * @param userKey 用户键
   * @returns 授权URL
   */
  private generateUserAuthUrl(baseUrl: string, userKey: string): string {
    const { appId, appSecret } = Config.getInstance().feishu;
    const clientKey = AuthUtils.generateClientKey(userKey);
    const redirect_uri = `${baseUrl}/callback`;
    const scope = encodeURIComponent('base:app:read bitable:app bitable:app:readonly board:whiteboard:node:read contact:user.employee_id:readonly docs:document.content:read docx:document docx:document.block:convert docx:document:create docx:document:readonly drive:drive drive:drive:readonly drive:file drive:file:upload sheets:spreadsheet sheets:spreadsheet:readonly space:document:retrieve space:folder:create wiki:space:read wiki:space:retrieve wiki:wiki wiki:wiki:readonly offline_access');
    const state = AuthUtils.encodeState(appId, appSecret, clientKey, redirect_uri);
    return `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${scope}&state=${state}`;
  }
}