码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
api.ts4.8 KB
// 统一 API 客户端:携带 cookie 凭证;对后端声明 X-Requested-With 以便 admin 动作返回 JSON
// (后端对 fetch 请求返回 JSON,对浏览器导航重定向,两者互不干扰)

import { logStore } from './logStore'

export interface ApiError extends Error {
  status?: number
  data?: any
}

function safeBody(body: unknown): unknown {
  if (body instanceof FormData) {
    const files: string[] = []
    body.forEach((_v, k) => files.push(k))
    return `[FormData: ${files.join(', ')}]`
  }
  if (typeof body === 'string' && body.length > 2000) return body.slice(0, 2000) + '…'
  return body
}

// 请求超时(毫秒):避免部署环境网络/反代异常时前端永久转圈
const REQUEST_TIMEOUT = 20000

async function request(path: string, opts: { method?: string; body?: any } = {}): Promise<any> {
  const method = opts.method || 'POST'
  const isForm = opts.body instanceof FormData
  const headers: Record<string, string> = { 'X-Requested-With': 'XMLHttpRequest' }
  if (!isForm) headers['Content-Type'] = 'application/json'

  // 超时控制:超过阈值主动 abort,让 loading 状态能结束并暴露错误
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)

  const t0 = performance.now()

  // 记录请求日志
  logStore.add({
    level: 'info',
    type: 'request',
    method,
    path,
    message: `${method} ${path}`,
    requestBody: safeBody(opts.body),
  })

  let res: Response
  try {
    res = await fetch(path, {
      method,
      credentials: 'include',
      headers,
      signal: controller.signal,
      body: isForm ? (opts.body as FormData) : opts.body ? JSON.stringify(opts.body) : undefined,
    })
  } catch (e: any) {
    const dur = Math.round(performance.now() - t0)
    clearTimeout(timer)
    const isTimeout = e?.name === 'AbortError'
    logStore.add({
      level: 'error',
      type: 'error',
      method,
      path,
      duration: dur,
      message: isTimeout
        ? `请求超时(>${REQUEST_TIMEOUT / 1000}s 无响应),请检查服务器/反向代理是否可达`
        : `网络错误: ${e.message || '无法连接服务器'}`,
    })
    const err: ApiError = new Error(isTimeout ? '请求超时,服务器无响应' : '网络连接失败')
    throw err
  }
  clearTimeout(timer)

  const dur = Math.round(performance.now() - t0)
  const text = await res.text()
  let data: any = null
  try {
    data = text ? JSON.parse(text) : {}
  } catch {
    data = { ok: false, msg: text || '服务器返回非 JSON 响应' }
  }

  // 记录响应日志
  const respPreview = typeof data === 'object' && data !== null
    ? { ok: data.ok, msg: data.msg, ...(data.summary ? { summary: data.summary } : {}) }
    : data

  if (!res.ok || (data && data.ok === false)) {
    logStore.add({
      level: 'error',
      type: 'response',
      method,
      path,
      status: res.status,
      duration: dur,
      message: data?.msg || `HTTP ${res.status}`,
      responseBody: respPreview,
    })
    const err: ApiError = new Error(data?.msg || `请求失败 (${res.status})`)
    err.status = res.status
    err.data = data
    throw err
  }

  logStore.add({
    level: 'info',
    type: 'response',
    method,
    path,
    status: res.status,
    duration: dur,
    message: `${res.status} ${dur}ms`,
    responseBody: respPreview,
  })

  return data
}

export const api = {
  get: (path: string) => request(path, { method: 'GET' }),
  post: (path: string, body?: any) => request(path, { method: 'POST', body }),
  upload: (path: string, formData: FormData) => request(path, { method: 'POST', body: formData }),
  /** bot_media 等二进制接口直接作为 <img src> 使用,无需经过本方法 */
  mediaUrl: (botId: number, msgId: number) => `/api/bot_media?bot_id=${botId}&msg_id=${msgId}`,
}

// ---- 类型定义(与后端 routes/api.js 对齐)----
export interface Bot {
  id: number
  bot_code: string
  name: string
  login_status: 'none' | 'wait' | 'confirmed' | string
  wechat_uin?: string | null
  bind_at?: number | null
}

export interface MeResponse {
  ok: boolean
  id: number
  username: string
  email: string
  created_at: number | null
  is_admin: number
  user_code: string
  ai_persona: string
  companion_enabled: number
  bots: Bot[]
}

export interface ChatMessage {
  id: number
  direction: 'in' | 'out'
  peer_id: string | null
  content: string | null
  msg_type: string
  status: string
  error_msg: string
  created_at: number
}

export interface MsgEvent {
  id: number
  bot_id: number
  ts: number
  type: 'inbound' | 'processing' | 'outbound_ok' | 'outbound_fail' | 'error' | 'system'
  msg: string
  detail?: string
}