usAppApiRequest.ts 4.89 KB
import { buildApiUrl } from './apiBase'
import { clearAuthSession } from './authSession'

const SESSION_EXPIRED_TOAST = 'Session expired. Please sign in again.'
const LOGIN_PATH = '/pages/login/login'

let sessionExpiredHandling = false

/** 已触发登出跳转时抛出,调用方可忽略二次 Toast */
export class UsAppSessionExpiredError extends Error {
  constructor(message = 'Unauthorized') {
    super(message)
    this.name = 'UsAppSessionExpiredError'
  }
}

export function isUsAppSessionExpiredError(e: unknown): e is UsAppSessionExpiredError {
  return e instanceof UsAppSessionExpiredError
}

function handleSessionExpiredAndGoLogin(): void {
  if (sessionExpiredHandling) return
  sessionExpiredHandling = true
  clearAuthSession()
  uni.showToast({
    title: SESSION_EXPIRED_TOAST,
    icon: 'none',
    duration: 2500,
  })
  setTimeout(() => {
    sessionExpiredHandling = false
    uni.reLaunch({ url: LOGIN_PATH })
  }, 400)
}

/**
 * 取出真实业务负载:支持 ABP `result`、以及统一包装 `{ succeeded, data }`
 */
export function unwrapApiPayload<T>(data: unknown): T {
  if (data == null || typeof data !== 'object') return data as T
  const o = data as Record<string, unknown>
  if ('result' in o && o.result !== undefined) {
    return o.result as T
  }
  const payload = o.data ?? o.Data
  if (payload !== undefined && payload !== null) {
    return payload as T
  }
  return data as T
}

export function parseApiErrorMessage(data: unknown): string {
  if (data == null) return 'Request failed'
  if (typeof data === 'string') return data
  if (typeof data === 'object') {
    const o = data as Record<string, unknown>
    const errorsRaw = o.errors ?? o.Errors
    if (typeof errorsRaw === 'string' && errorsRaw.trim()) return errorsRaw.trim()
    if (Array.isArray(errorsRaw)) {
      const parts = errorsRaw.map((x) => String(x)).filter(Boolean)
      if (parts.length) return parts.join('; ')
    }
    const err = o.error as Record<string, unknown> | undefined
    if (err && typeof err.message === 'string') return err.message
    if (typeof o.message === 'string') return o.message
    if (typeof o.error_description === 'string') return o.error_description
  }
  return 'Request failed'
}

function isBusinessFailurePayload(data: unknown): boolean {
  if (data == null || typeof data !== 'object') return false
  const o = data as Record<string, unknown>
  if (o.succeeded === false || o.Succeeded === false) return true
  const inner = o.statusCode ?? o.StatusCode
  if (typeof inner === 'number' && inner >= 400) return true
  return false
}

/** HTTP 200 但 JSON 内 statusCode 为 401(少数网关/包装) */
function isBodyUnauthorized(data: unknown): boolean {
  if (data == null || typeof data !== 'object') return false
  const o = data as Record<string, unknown>
  const inner = o.statusCode ?? o.StatusCode
  return inner === 401
}

export type UsAppApiRequestOptions = {
  path: string
  method: 'GET' | 'POST' | 'PUT' | 'DELETE'
  data?: unknown
  auth?: boolean
  /** 为 true 时收到 401 不清理会话、不跳转(用于登录等匿名接口) */
  skipUnauthorizedRedirect?: boolean
}

/**
 * 美国版 App 统一请求:401 时 Toast(英文)+ 清会话 + 回登录页
 */
export function usAppApiRequest<T>(options: UsAppApiRequestOptions): Promise<T> {
  const header: Record<string, string> = {
    'Content-Type': 'application/json',
    Accept: 'application/json',
  }
  if (options.auth) {
    const token = uni.getStorageSync('access_token')
    if (token) header.Authorization = `Bearer ${token}`
  }

  const skipRedirect = !!options.skipUnauthorizedRedirect

  return new Promise((resolve, reject) => {
    uni.request({
      url: buildApiUrl(options.path),
      method: options.method,
      data: options.data,
      header,
      success: (res) => {
        const status = res.statusCode ?? 0

        if (status === 401) {
          if (!skipRedirect) {
            handleSessionExpiredAndGoLogin()
            reject(new UsAppSessionExpiredError(parseApiErrorMessage(res.data) || 'Unauthorized'))
          } else {
            reject(new Error(parseApiErrorMessage(res.data) || 'Unauthorized'))
          }
          return
        }

        if (status >= 400) {
          reject(new Error(parseApiErrorMessage(res.data)))
          return
        }

        if (!skipRedirect && isBodyUnauthorized(res.data)) {
          handleSessionExpiredAndGoLogin()
          reject(new UsAppSessionExpiredError(parseApiErrorMessage(res.data) || 'Unauthorized'))
          return
        }

        if (isBusinessFailurePayload(res.data)) {
          reject(new Error(parseApiErrorMessage(res.data)))
          return
        }

        try {
          const body = unwrapApiPayload<T>(res.data as unknown)
          resolve(body)
        } catch {
          reject(new Error('Invalid response'))
        }
      },
      fail: (err) => {
        reject(new Error(err.errMsg || 'Network error'))
      },
    })
  })
}