文件上传接口

要点

  • AI 项目的文件上传和普通 CRUD 接口不同——用户上传 PDF、图片、音频,交给后端解析、转写、识别,文件本身就是业务数据
  • Hono 通过 c.req.parseBody() 解析 multipart/form-data,拿到文件字段后得到的是一个 File 对象,可以读 ArrayBuffer、转 Buffer、直接写入 R2
  • 单文件上传和多文件上传在 parseBody() 的用法上有区别——多文件需要加 { all: true } 参数
  • 文件大小限制、Content-Type 校验、临时数据清理,这三件事在上传接口里必须同时处理,否则线上会出问题

1. AI 项目为什么特别依赖文件上传

传统的 Web 后端也有文件上传——头像、附件、图片。但那些场景里,文件通常是「附属品」,核心业务数据还是通过 JSON 传递。

AI 项目不太一样。用户的核心输入往往就是一个文件:

  • 上传一份 PDF 合同,让 AI 提取关键条款
  • 上传一张产品图片,让 AI 生成描述文案
  • 上传一段会议录音,让 AI 转写成文字并生成摘要

文件不是附件,文件就是请求本身。上传接口的稳定性直接影响核心业务——上传挂了,AI 处理链路就断了。

AI 项目里的上传接口还要做文件类型和大小校验、把内容传给下游解析模块、把解析结果和原始文件关联起来。这篇先解决怎么在 Hono 里接收和存储文件。

2. multipart/form-data 基础

前端上传文件,用的不是 application/json,而是 multipart/form-data。这是 HTTP 协议里专门为文件传输设计的编码格式。

请求长什么样

假设前端有一个文件选择框,用户选了一张图片后提交。浏览器发出的请求大致是这样的:

POST /api/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----FormBoundary123
 
------FormBoundary123
Content-Disposition: form-data; name="title"
 
产品说明图
------FormBoundary123
Content-Disposition: form-data; name="file"; filename="product.png"
Content-Type: image/png
 
[二进制文件内容]
------FormBoundary123--

几个关键点:Content-Typemultipart/form-data,带一个 boundary 分隔符;每个字段之间用 --boundary 隔开;文本字段和文件字段混在一起,文件字段多了 filename 和文件自己的 Content-Type;文件内容是原始二进制,不是 Base64。

前端怎么发

FormData API,把文件和文本字段一起塞进去,然后用 fetch 发出去。不要手动设置 Content-Type——浏览器会自动加上正确的 multipart/form-data 和 boundary:

// frontend-upload.ts
const form = new FormData()
form.append('title', '产品说明图')
form.append('file', fileInput.files![0])
 
const res = await fetch('/api/upload', {
  method: 'POST',
  body: form,
  // 不要写 Content-Type,浏览器会自动处理
})

手动设置 Content-Type: multipart/form-data 反而会出问题——你写的 header 里没有 boundary,后端拿到请求后没法拆分字段。

3. Hono 怎么接收文件:c.req.parseBody()

Hono 提供了 c.req.parseBody() 来解析 multipart/form-data 请求,返回一个对象,键是字段名,值是字段内容。

最简单的单文件上传

// routes/upload.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
 
const app = new Hono<AppEnv>()
 
app.post('/upload', async (c) => {
  const body = await c.req.parseBody()
 
  // body.file 是一个 File 对象
  const file = body['file']
 
  // 如果不是 File 类型(比如用户没传文件),返回错误
  if (!(file instanceof File)) {
    return c.json({ error: '请上传一个文件' }, 400)
  }
 
  // 读取文件基本信息
  console.log(file.name)     // product.png
  console.log(file.size)     // 102400(字节)
  console.log(file.type)     // image/png
 
  return c.json({
    message: '上传成功',
    filename: file.name,
    size: file.size,
  })
})

parseBody() 返回的字段值有两种可能:文本字段是 string,文件字段是 File 对象。

如果同一个字段名出现了多次(比如多文件上传),默认只返回最后一个值。要拿到所有值,需要加参数——后面会讲到。

拿到文件内容

File 对象是元数据容器(文件名、大小、MIME 类型)。要读取二进制内容,调用它的方法:

// 读成 ArrayBuffer(Workers 环境最常用)
const buffer = await file.arrayBuffer()
 
// 读成 text(适合纯文本文件,比如 CSV)
const text = await file.text()

在 Cloudflare Workers 环境下,arrayBuffer() 是最常用的。R2 的 put() 方法可以直接接收 ArrayBuffer

4. 多文件上传

如果前端一次上传多个文件,FormData 里会有多个同名字段:

// frontend-multi-upload.ts
const form = new FormData()
for (const file of fileInput.files!) {
  form.append('files', file)
}

这时候 parseBody() 默认只返回最后一个文件。要拿到全部文件,需要传 &#123; all: true &#125;

// routes/multi-upload.ts
app.post('/upload', async (c) => {
  const body = await c.req.parseBody({ all: true })
 
  // body['files'] 现在是一个数组
  const files = body['files']
 
  // 可能是单个 File,也可能是 File[]
  // 需要做类型判断
  const fileArray = Array.isArray(files) ? files : [files]
 
  const results = []
  for (const file of fileArray) {
    if (!(file instanceof File)) continue
 
    results.push({
      name: file.name,
      size: file.size,
      type: file.type,
    })
  }
 
  return c.json({ uploaded: results.length, files: results })
})

注意 Array.isArray(files) 这个判断——用户只传一个文件时,&#123; all: true &#125; 返回的也可能是单个 File 而不是数组。

5. 完整的上传接口:带校验和存储

一个能上生产的上传接口,至少要处理这几件事:校验文件是否存在、校验文件大小和类型、存储文件、返回结果。

// routes/upload.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
 
const app = new Hono<AppEnv>()
 
// 配置
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_TYPES = [
  'image/png',
  'image/jpeg',
  'image/webp',
  'application/pdf',
  'audio/mpeg',
  'audio/wav',
]
 
app.post('/upload', async (c) => {
  // 1. 解析请求体
  const body = await c.req.parseBody()
  const file = body['file']
 
  // 2. 校验文件是否存在
  if (!(file instanceof File)) {
    return c.json({ error: '缺少文件字段' }, 400)
  }
 
  // 3. 校验文件大小
  if (file.size > MAX_FILE_SIZE) {
    return c.json({
      error: `文件过大,最大允许 ${MAX_FILE_SIZE / 1024 / 1024}MB`,
    }, 400)
  }
 
  // 4. 校验文件类型
  if (!ALLOWED_TYPES.includes(file.type)) {
    return c.json({
      error: `不支持的文件类型:${file.type},允许的类型:${ALLOWED_TYPES.join(', ')}`,
    }, 400)
  }
 
  // 5. 生成唯一的存储路径
  const ext = file.name.split('.').pop()
  const key = `uploads/${Date.now()}-${crypto.randomUUID()}.${ext}`
 
  // 6. 写入 R2
  const bucket = c.env.BUCKET
  await bucket.put(key, await file.arrayBuffer(), {
    httpMetadata: { contentType: file.type },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  })
 
  // 7. 返回结果
  return c.json({
    message: '上传成功',
    key,
    filename: file.name,
    size: file.size,
  })
})

几个值得注意的细节:

  • 存储路径用 UUID 生成:不要直接用用户上传的文件名,文件名可能有中文、特殊字符或路径穿越
  • customMetadata 保留原始文件名:需要的时候可以查回来
  • httpMetadata 设置 Content-Type:从 R2 下载时浏览器能正确识别文件类型

6. 文件存储到 Cloudflare R2

上面的代码已经演示了 R2 写入的基本用法。R2 的几个常用操作:

// 写入
await c.env.BUCKET.put(key, data, {
  httpMetadata: { contentType: 'image/png' },
})
 
// 读取
const object = await c.env.BUCKET.get(key)
if (object) {
  const data = await object.arrayBuffer()
  const contentType = object.httpMetadata?.contentType
}
 
// 删除
await c.env.BUCKET.delete(key)
 
// 列出文件
const listed = await c.env.BUCKET.list({ prefix: 'uploads/' })
for (const obj of listed.objects) {
  console.log(obj.key, obj.size)
}

R2 绑定在 wrangler.jsonc 里配置:

// wrangler.jsonc
{
  "r2_buckets": [
    {
      "binding": "BUCKET",
      "bucket_name": "my-ai-uploads"
    }
  ]
}

R2 的 put() 方法接受多种数据类型:stringArrayBufferUint8ArrayReadableStreamBlob。从 parseBody() 拿到的 File 对象调用 arrayBuffer() 之后直接传进去。R2 的完整用法会在后续专题里展开。

7. 上传进度反馈

fetch + FormData 本身不支持上传进度回调。对于小文件(几十 KB 到几 MB),用户感知不明显。但上传几百 MB 的文件时,没有进度条会让用户很焦虑。

方案一:用 XMLHttpRequest

XMLHttpRequestupload.onprogress 事件可以拿到已发送的字节数:

// frontend-progress-xhr.ts
function uploadWithProgress(file: File, onProgress: (percent: number) => void) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100))
      }
    }
    xhr.onload = () => resolve(xhr.response)
    xhr.onerror = () => reject(new Error('上传失败'))
 
    const form = new FormData()
    form.append('file', file)
 
    xhr.open('POST', '/api/upload')
    xhr.send(form)
  })
}

方案二:分片上传

把大文件切成小块,逐片上传,每传完一片更新进度。这种方式支持断点续传,但后端需要配合实现分片接收和合并逻辑。

对于 AI 项目常见的 PDF、图片、短音频,文件大小通常在几十 MB 以内,方案一已经够用。

8. 前端完整示例:上传表单 + fetch

把前端代码拼成一个完整的页面示例,实际项目里可以改成 React/Vue 组件:

<!-- upload-form.html -->
<form id="uploadForm">
  <input type="text" name="title" placeholder="文件描述" required />
  <input type="file" name="file" id="fileInput"
         accept=".png,.jpg,.jpeg,.pdf,.mp3,.wav" required />
  <button type="submit">上传</button>
  <div id="progress"></div>
</form>
 
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
  e.preventDefault()
 
  const form = new FormData(e.target)
  const file = form.get('file')
 
  // 前端也做一次校验,减少无效请求
  if (file.size > 10 * 1024 * 1024) {
    alert('文件不能超过 10MB')
    return
  }
 
  const res = await fetch('/api/upload', {
    method: 'POST',
    body: form,
  })
 
  const data = await res.json()
 
  if (res.ok) {
    alert(`上传成功:${data.filename}`)
  } else {
    alert(`上传失败:${data.error}`)
  }
})
</script>

注意 accept 属性——它在文件选择对话框里过滤了可选文件类型,给用户一个初步引导。但前端校验只是辅助,后端必须做完整校验。

9. 常见坑

Content-Type 被前端覆盖

最常见的错误:前端用 fetch 上传文件时,手动设置了 Content-Type: multipart/form-data,但没带 boundary。后端收到请求后无法解析字段,直接报 400。

解决办法:不要手动设置 Content-Typefetch 发送 FormData 时会自动设置正确的 Content-Type,包含 boundary。

文件大小没有限制

parseBody() 默认没有大小限制。如果用户上传了一个几 GB 的文件,Workers 会先把整个请求体读到内存里再交给代码处理。Worker 内存上限 128MB,超出就会直接失败。

解决办法:先检查 Content-Length,超过限制直接拒绝,不进入 parseBody()

// size-guard.ts
app.post('/upload', async (c) => {
  const contentLength = Number(c.req.header('content-length') ?? 0)
  if (contentLength > MAX_FILE_SIZE) {
    return c.json({ error: '文件过大' }, 413)
  }
 
  const body = await c.req.parseBody()
  // ...
})

更保险的做法是在 Cloudflare 的 WAF 规则里做一层拦截,不依赖应用代码。

文件字段名不一致

前端用 form.append('myfile', file),后端用 body['file'] 来取——字段名不一致导致拿到 undefined。建议前后端约定常量或在代码里加注释标明字段名。

忘记处理 File vs string 的类型判断

parseBody() 返回值可能是 string 也可能是 File。不做类型判断直接调用 file.name,当用户没传文件时会报错。每次取文件字段后先做 instanceof File 判断。

R2 写入失败没有重试

网络抖动可能导致 bucket.put() 抛异常。对于重要的上传请求,可以加一层重试:

// retry-upload.ts
async function putWithRetry(
  bucket: R2Bucket, key: string, data: ArrayBuffer,
  options: R2PutOptions, retries = 3
) {
  for (let i = 0; i < retries; i++) {
    try {
      await bucket.put(key, data, options)
      return
    } catch (err) {
      if (i === retries - 1) throw err
      await new Promise((r) => setTimeout(r, 2 ** i * 1000))
    }
  }
}

总结

这篇讲了 Hono 里文件上传接口的基本实现:

  • c.req.parseBody() 解析 multipart/form-data,文件字段返回 File 对象
  • 多文件上传需要加 &#123; all: true &#125; 参数
  • 上传接口必须做大小校验、类型校验、安全存储路径生成
  • R2 的 put() 方法直接接收 ArrayBuffer,适合 Cloudflare Workers 环境
  • 前端用 FormData + fetch 上传文件,不要手动设置 Content-Type

下一篇会讲文件校验的进阶处理——文件内容嗅探、恶意文件检测、以及在 AI 处理链路里怎样做异步任务编排。