文件上传接口
要点
- 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-Type 是 multipart/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() 默认只返回最后一个文件。要拿到全部文件,需要传 { all: true }:
// 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) 这个判断——用户只传一个文件时,{ all: true } 返回的也可能是单个 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() 方法接受多种数据类型:string、ArrayBuffer、Uint8Array、ReadableStream、Blob。从 parseBody() 拿到的 File 对象调用 arrayBuffer() 之后直接传进去。R2 的完整用法会在后续专题里展开。
7. 上传进度反馈
fetch + FormData 本身不支持上传进度回调。对于小文件(几十 KB 到几 MB),用户感知不明显。但上传几百 MB 的文件时,没有进度条会让用户很焦虑。
方案一:用 XMLHttpRequest
XMLHttpRequest 的 upload.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-Type。fetch 发送 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对象- 多文件上传需要加
{ all: true }参数 - 上传接口必须做大小校验、类型校验、安全存储路径生成
- R2 的
put()方法直接接收ArrayBuffer,适合 Cloudflare Workers 环境 - 前端用
FormData+fetch上传文件,不要手动设置Content-Type
下一篇会讲文件校验的进阶处理——文件内容嗅探、恶意文件检测、以及在 AI 处理链路里怎样做异步任务编排。