23.12-错误提示规范

要点

  • 统一的错误格式让前端可以一致地处理错误
  • 错误响应应该包含:错误代码、错误信息、详细信息
  • 前端需要根据错误类型展示不同的提示(Toast、弹窗、表单错误)
  • 国际化支持:错误信息应该支持多语言

内容

1. 后端统一错误格式

1.1 定义错误响应结构

// src/lib/errors.ts
export interface ApiError {
  code: string           // 错误代码,如 'VALIDATION_ERROR'
  message: string        // 人类可读的错误信息
  details?: any          // 详细错误信息(如字段校验错误)
  requestId?: string     // 请求 ID,用于追踪
}
 
export class AppError extends Error {
  constructor(
    public code: string,
    public message: string,
    public statusCode: number = 500,
    public details?: any
  ) {
    super(message)
  }
 
  toJSON(): ApiError {
    return {
      code: this.code,
      message: this.message,
      details: this.details,
    }
  }
}

1.2 预定义错误类型

// src/lib/errors.ts
export class ValidationError extends AppError {
  constructor(details: any) {
    super('VALIDATION_ERROR', '请求参数验证失败', 400, details)
  }
}
 
export class AuthenticationError extends AppError {
  constructor(message = '未登录或登录已过期') {
    super('AUTHENTICATION_ERROR', message, 401)
  }
}
 
export class AuthorizationError extends AppError {
  constructor(message = '没有权限执行此操作') {
    super('AUTHORIZATION_ERROR', message, 403)
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource = '资源') {
    super('NOT_FOUND', `${resource}不存在`, 404)
  }
}
 
export class ConflictError extends AppError {
  constructor(message = '资源冲突') {
    super('CONFLICT_ERROR', message, 409)
  }
}
 
export class RateLimitError extends AppError {
  constructor(message = '请求过于频繁,请稍后再试') {
    super('RATE_LIMIT_ERROR', message, 429)
  }
}
 
export class InternalError extends AppError {
  constructor(message = '服务器内部错误') {
    super('INTERNAL_ERROR', message, 500)
  }
}

1.3 全局错误处理

// src/index.ts
import { Hono } from 'hono'
import { AppError, InternalError } from './lib/errors'
 
const app = new Hono()
 
// 全局错误处理
app.onError((err, c) => {
  const requestId = c.get('requestId')
 
  // 如果是自定义错误
  if (err instanceof AppError) {
    console.error(`[${err.code}] ${err.message}`, {
      requestId,
      details: err.details,
    })
 
    return c.json(
      {
        ...err.toJSON(),
        requestId,
      },
      err.statusCode
    )
  }
 
  // 未知错误
  console.error('Unhandled error:', err, { requestId })
 
  const error = new InternalError()
 
  return c.json(
    {
      ...error.toJSON(),
      requestId,
    },
    500
  )
})
 
// 404 处理
app.notFound((c) => {
  return c.json(
    {
      code: 'NOT_FOUND',
      message: '接口不存在',
    },
    404
  )
})

1.4 在路由中使用

// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { ValidationError, NotFoundError } from '../lib/errors'
 
const app = new Hono()
 
const CreateUserSchema = z.object({
  name: z.string().min(1, '名称不能为空'),
  email: z.string().email('邮箱格式不正确'),
})
 
app.post('/api/users', zValidator('json', CreateUserSchema), async (c) => {
  const result = c.req.valid('json')
 
  try {
    const user = await createUser(result)
    return c.json(user, 201)
  } catch (error) {
    // 转换 Zod 错误为 ValidationError
    if (error instanceof z.ZodError) {
      throw new ValidationError(error.errors)
    }
 
    throw error
  }
})
 
app.get('/api/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await getUser(id)
 
  if (!user) {
    throw new NotFoundError('用户')
  }
 
  return c.json(user)
})

2. 前端错误处理

2.1 定义错误类型

// src/lib/api-error.ts
export interface ApiErrorResponse {
  code: string
  message: string
  details?: any
  requestId?: string
}
 
export class ApiError extends Error {
  constructor(
    public code: string,
    public message: string,
    public statusCode: number,
    public details?: any,
    public requestId?: string
  ) {
    super(message)
  }
 
  static fromResponse(response: ApiErrorResponse, statusCode: number): ApiError {
    return new ApiError(
      response.code,
      response.message,
      statusCode,
      response.details,
      response.requestId
    )
  }
}

2.2 统一处理 API 错误

// src/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
import { ApiError } from './api-error'
 
async function handleResponse<T>(res: Response): Promise<T> {
  if (!res.ok) {
    const error = await res.json().catch(() => null)
 
    throw ApiError.fromResponse(
      error || { code: 'UNKNOWN_ERROR', message: '未知错误' },
      res.status
    )
  }
 
  return res.json()
}
 
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
  fetch: async (input, init) => {
    const res = await fetch(input, init)
    return res
  },
})
 
// 封装带错误处理的方法
export const apiClient = {
  async getUser(userId: string) {
    const res = await api.api.users[':id'].$get({
      param: { id: userId },
    })
    return handleResponse(res)
  },
 
  async createUser(data: any) {
    const res = await api.api.users.$post({
      json: data,
    })
    return handleResponse(res)
  },
}

3. 错误提示 UI

3.1 Toast 提示

// src/components/Toast.tsx
import { useState, useEffect } from 'react'
 
interface Toast {
  id: string
  type: 'success' | 'error' | 'warning' | 'info'
  message: string
}
 
let toasts: Toast[] = []
let listeners: (() => void)[] = []
 
function notify() {
  listeners.forEach(listener => listener())
}
 
export function showToast(type: Toast['type'], message: string) {
  const id = crypto.randomUUID()
  toasts = [...toasts, { id, type, message }]
  notify()
 
  // 3 秒后自动关闭
  setTimeout(() => {
    toasts = toasts.filter(t => t.id !== id)
    notify()
  }, 3000)
}
 
export function ToastContainer() {
  const [, forceUpdate] = useState({})
 
  useEffect(() => {
    const listener = () => forceUpdate({})
    listeners.push(listener)
    return () => {
      listeners = listeners.filter(l => l !== listener)
    }
  }, [])
 
  return (
    <div className="toast-container">
      {toasts.map(toast => (
        <div key={toast.id} className={`toast toast-${toast.type}`}>
          {toast.message}
        </div>
      ))}
    </div>
  )
}
// 使用
import { showToast } from '@/components/Toast'
 
async function handleCreateUser() {
  try {
    await apiClient.createUser({ name: 'Alice', email: '[email protected]' })
    showToast('success', '用户创建成功')
  } catch (error) {
    if (error instanceof ApiError) {
      showToast('error', error.message)
    } else {
      showToast('error', '操作失败')
    }
  }
}

3.2 表单错误提示

// src/components/UserForm.tsx
import { useState } from 'react'
import { ApiError } from '@/lib/api-error'
import { apiClient } from '@/lib/api'
 
export function UserForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [submitting, setSubmitting] = useState(false)
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setErrors({})
    setSubmitting(true)
 
    try {
      await apiClient.createUser({ name, email })
      // 成功处理
    } catch (error) {
      if (error instanceof ApiError) {
        if (error.code === 'VALIDATION_ERROR' && error.details) {
          // 转换字段错误
          const fieldErrors: Record<string, string> = {}
          for (const detail of error.details) {
            const field = detail.path[0]
            fieldErrors[field] = detail.message
          }
          setErrors(fieldErrors)
        } else {
          // 其他错误
          setErrors({ _form: error.message })
        }
      }
    } finally {
      setSubmitting(false)
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {errors._form && (
        <div className="form-error">{errors._form}</div>
      )}
 
      <div>
        <label>名称</label>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        {errors.name && <div className="field-error">{errors.name}</div>}
      </div>
 
      <div>
        <label>邮箱</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {errors.email && <div className="field-error">{errors.email}</div>}
      </div>
 
      <button type="submit" disabled={submitting}>
        {submitting ? '提交中...' : '提交'}
      </button>
    </form>
  )
}

4. 错误代码映射

// src/lib/error-messages.ts
const ERROR_MESSAGES: Record<string, string> = {
  VALIDATION_ERROR: '请求参数有误',
  AUTHENTICATION_ERROR: '请先登录',
  AUTHORIZATION_ERROR: '没有权限执行此操作',
  NOT_FOUND: '资源不存在',
  CONFLICT_ERROR: '资源冲突',
  RATE_LIMIT_ERROR: '请求过于频繁,请稍后再试',
  INTERNAL_ERROR: '服务器内部错误',
  NETWORK_ERROR: '网络连接失败',
}
 
export function getErrorMessage(code: string, fallback?: string): string {
  return ERROR_MESSAGES[code] || fallback || '未知错误'
}
// 使用
import { getErrorMessage } from '@/lib/error-messages'
 
catch (error) {
  if (error instanceof ApiError) {
    showToast('error', getErrorMessage(error.code, error.message))
  }
}

5. 国际化支持

// src/i18n/errors.ts
const ERROR_MESSAGES = {
  zh: {
    VALIDATION_ERROR: '请求参数有误',
    AUTHENTICATION_ERROR: '请先登录',
    // ...
  },
  en: {
    VALIDATION_ERROR: 'Validation failed',
    AUTHENTICATION_ERROR: 'Please login first',
    // ...
  },
}
 
export function getErrorMessage(code: string, locale = 'zh'): string {
  return ERROR_MESSAGES[locale]?.[code] || ERROR_MESSAGES.zh[code] || 'Unknown error'
}

6. 小结

错误提示规范的关键点:

  1. 后端统一格式:错误响应包含 code、message、details
  2. 预定义错误类型:ValidationError、AuthenticationError 等
  3. 前端错误分类:根据错误代码展示不同的提示
  4. 表单错误:字段级别的错误提示
  5. Toast 提示:全局错误提示
  6. 错误代码映射:统一的错误信息映射表
  7. 国际化:支持多语言错误信息

一句话带走:

统一的错误格式让前后端协作更顺畅。后端返回结构化的错误信息,前端根据错误代码展示合适的提示,提升用户体验。