Use Cases (更新: 2026/6/1)

用 Claude Code 安全实现文件上传:FormData、校验、预览与 S3

面向 SaaS 的 Claude Code 文件上传实战:File API、FormData、fetch、服务端校验、预览、进度和 S3 取舍。

用 Claude Code 安全实现文件上传:FormData、校验、预览与 S3

文件上传看起来只是一个按钮,但在 SaaS 产品里,它很快会变成一个安全边界。头像、发票 PDF、CSV 导入、合同、聊天附件,每一种场景都需要考虑浏览器 API、请求格式、服务端校验、存储权限、公开范围、预览、进度、成本和审计日志。

Claude Code 很适合生成这类功能,因为它可以同时写 React 组件、API 路由、校验函数、存储适配层和 README。不过提示词必须足够具体。如果只说“做一个文件上传”,你可能会得到能跑的 demo,却把原始文件名直接保存、只相信浏览器传来的 MIME 类型、没有大小限制,或者把上传后的 URL 直接公开。

本文按可审查的顺序实现。先理解浏览器侧的 File APIFormDataFetch API,再做最小上传、React 预览和真实进度、服务端校验,最后讨论什么时候迁移到 S3、Cloud Storage、Azure Blob Storage 或 Cloudflare R2。

如果你已经要接 S3,可以配合阅读 Claude Code 和 AWS S3。安全审查的整体思路可以看 Claude Code 安全实践

先把浏览器侧职责拆开

第一层是 File API。用户通过<input type="file">选择文件,或者把文件拖到页面上时,浏览器会给你一个File对象。你可以读到file.namefile.sizefile.typefile.lastModified。如果是图片,还可以用URL.createObjectURL(file)生成临时预览地址。

第二层是 FormData。它是把普通字段和文件一起按multipart/form-data发送的容器。常见写法是formData.append("file", file)。这里有一个很实用的坑:用 FormData 配合 fetch 时,不要手动设置Content-Type。浏览器需要自动追加 multipart boundary,手写反而容易破坏请求。

第三层是 fetch。它负责发送 HTTP 请求。小文件上传可以直接这样写:

const formData = new FormData();
formData.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  body: formData
});

但如果产品需要真实进度条,fetch 并不是最顺手的选择。上传进度事件通常还是用XMLHttpRequest.upload.onprogress更直接。所以我会让 Claude Code 明确区分:简单上传用 fetch,需要精确进度时用 XMLHttpRequest。

最小 HTML 与 fetch 实现

先不要急着上 React 和 S3。下面这个版本完成选择文件、浏览器侧类型/大小检查、图片预览、FormData 发送。

<form id="upload-form">
  <input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
  <button type="submit">上传</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>

<script type="module">
  const MAX_BYTES = 5 * 1024 * 1024;
  const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
  const form = document.querySelector("#upload-form");
  const input = document.querySelector("#file-input");
  const preview = document.querySelector("#preview");
  const message = document.querySelector("#message");

  input.addEventListener("change", () => {
    const file = input.files?.[0];
    preview.style.display = "none";
    preview.removeAttribute("src");
    message.textContent = "";

    if (!file) return;
    if (!allowedTypes.has(file.type)) {
      message.textContent = "只允许 PNG、JPEG、PDF。";
      input.value = "";
      return;
    }
    if (file.size > MAX_BYTES) {
      message.textContent = "文件大小必须在 5MB 以内。";
      input.value = "";
      return;
    }
    if (file.type.startsWith("image/")) {
      preview.src = URL.createObjectURL(file);
      preview.style.display = "block";
      preview.onload = () => URL.revokeObjectURL(preview.src);
    }
  });

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const file = input.files?.[0];
    if (!file) {
      message.textContent = "请先选择文件。";
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData
    });

    const result = await response.json();
    message.textContent = response.ok ? `已保存: ${result.name}` : result.error;
  });
</script>

浏览器侧检查是为了体验,不是安全边界。攻击者可以绕过这些 JavaScript,所以服务端必须重新检查类型、扩展名、大小、保存名和保存位置。

React 预览与真实上传进度

React 版建议把状态拆清楚:选中的文件、预览 URL、进度、错误、保存后的文件名。以后 Claude Code 要加拖拽、取消上传或多文件时,也更容易改。

import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);

type UploadResult = { ok: true; name: string; size: number; type: string };

export function FileUploadBox() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const [uploadedName, setUploadedName] = useState<string | null>(null);
  const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);

  useEffect(() => {
    return () => {
      if (previewUrl) URL.revokeObjectURL(previewUrl);
    };
  }, [previewUrl]);

  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0] ?? null;
    setUploadedName(null);
    setProgress(0);
    setError(null);
    if (previewUrl) URL.revokeObjectURL(previewUrl);
    setPreviewUrl(null);

    if (!file) return setSelectedFile(null);
    if (!ALLOWED_TYPES.has(file.type)) {
      setSelectedFile(null);
      return setError("只允许 PNG、JPEG、PDF。");
    }
    if (file.size > MAX_BYTES) {
      setSelectedFile(null);
      return setError("文件大小必须在 5MB 以内。");
    }
    setSelectedFile(file);
    if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
  }

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!selectedFile) return;
    const formData = new FormData();
    formData.append("file", selectedFile);
    const result = await uploadWithProgress(formData, setProgress);
    setUploadedName(result.name);
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
      {previewUrl && <img src={previewUrl} alt="文件预览" width={240} />}
      {selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
      {error && <p role="alert">{error}</p>}
      <progress value={progress} max={100}>{progress}%</progress>
      <button type="submit" disabled={!canUpload}>上传</button>
      {uploadedName && <p>已保存: {uploadedName}</p>}
    </form>
  );
}

function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
  return new Promise<UploadResult>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/upload");
    xhr.upload.addEventListener("progress", (event) => {
      if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
    });
    xhr.addEventListener("load", () => {
      const body = JSON.parse(xhr.responseText || "{}");
      if (xhr.status >= 200 && xhr.status < 300) resolve(body);
      else reject(new Error(body.error ?? "Upload failed"));
    });
    xhr.addEventListener("error", () => reject(new Error("Network error")));
    xhr.send(formData);
  });
}

这里的原则是诚实。用 fetch 就显示“上传中”,不要假装知道百分比;需要真实百分比就用能拿到上传进度的方式。

服务端校验必须重新做

下面是 Next.js Route Handler。它保存到.local-uploads,适合第一版验证。生产环境可以把保存逻辑替换成对象存储。

// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
  ["image/png", ".png"],
  ["image/jpeg", ".jpg"],
  ["application/pdf", ".pdf"]
]);

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const value = formData.get("file");
  if (!(value instanceof File)) {
    return NextResponse.json({ error: "缺少文件。" }, { status: 400 });
  }

  const expectedExt = ALLOWED_TYPES.get(value.type);
  const originalExt = path.extname(value.name).toLowerCase();
  if (!expectedExt) return NextResponse.json({ error: "不允许的 MIME 类型。" }, { status: 400 });
  if (value.size === 0 || value.size > MAX_BYTES) {
    return NextResponse.json({ error: "文件必须大于 0 且不超过 5MB。" }, { status: 400 });
  }
  if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
    return NextResponse.json({ error: "JPEG 扩展名不正确。" }, { status: 400 });
  }
  if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
    return NextResponse.json({ error: "MIME 类型和扩展名不一致。" }, { status: 400 });
  }

  const bytes = Buffer.from(await value.arrayBuffer());
  const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
  const uploadDir = path.join(process.cwd(), ".local-uploads");
  await mkdir(uploadDir, { recursive: true });
  await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
  return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}

最少要检查 MIME、扩展名、大小、保存名、保存位置。更严格的场景还要检查文件魔数、实际解码、病毒扫描、登录用户、配额和审计日志。不要在代码注释里写“扩展名检查即可保证安全”,这会误导后续维护者。

什么时候迁移到 S3 或 Cloud Storage

本地保存适合学习、原型和内部临时工具。如果文件是客户资产,或者服务会扩展到多台机器,或者需要生命周期规则、异步缩略图、长期备份,就应该迁移到对象存储。

一开始不一定要做浏览器直传 S3。小文件可以先经过应用服务器:服务器接收、校验、生成安全 key,然后写入 S3。等文件变大或流量变高,再引入 presigned URL。这样学习曲线更平滑,也更容易审查权限。

请把现有 Next.js 本地文件上传改为 S3 保存。
现状: /api/upload 已经用 FormData 接收文件,并检查 MIME、扩展名、大小。
约束: 不使用原始文件名作为 S3 key,保存为 uploads/yyyy/mm/dd/{uuid}.ext。
约束: 只允许 image/png、image/jpeg、application/pdf,最大 5MB。
约束: bucket 保持 private,API 返回文件 ID,不返回公开 URL。
成果物: app/api/upload/route.ts、lib/storage/s3.ts、失败用例测试、README 环境变量。
确认: 说明超大文件、扩展名不一致、未登录、正常上传的测试方法。

三个实际场景

头像上传重视预览、裁剪和重试。建议先只允许 PNG、JPEG、WebP,不要轻易开放 SVG。

CSV 导入重视业务校验。上传之后要预览列名、行数、编码、错误行和回滚策略。提示 Claude Code 时要提供 CSV 样例和失败时的报告格式。

合同或发票 PDF 重视权限。文件应保存在 private 存储里,下载前检查登录和组织权限,再发短时间有效的签名 URL,并记录访问日志。

聊天附件则重视进度、取消、成本和过期规则。不要让附件 bucket 变成无限期归档。

常见失败与 Claude Code 提示词

常见失败包括:把accept当安全机制、直接保存原始文件名、上传后立即返回公开 URL、用定时器伪造进度、S3 权限过宽。提示词要把这些禁止项写进去。

为这个 Next.js 应用添加安全文件上传。
目标: SaaS 管理画面一次上传一个 PNG/JPEG/PDF。
客户端: React 组件使用 File API,显示文件名、大小、图片预览、错误和上传状态。
传输: 用 FormData POST 到 /api/upload。需要真实进度时用 XMLHttpRequest,并解释为什么不用 fetch 做进度。
服务端: 在 app/api/upload/route.ts 接收 FormData,校验 MIME、扩展名、5MB 上限和空文件。
保存: 不把原始文件名当保存名,使用 UUID + 扩展名保存到 .local-uploads。
禁止: 不直接保存到 public/。不声称扩展名检查就是完整安全。不把 S3 bucket 设为 public。
验证: 说明超大文件、扩展名不一致、缺少文件、正常上传的测试。
参考: MDN File API、FormData、Fetch API。

Masa 的验证笔记

我测试这个流程时,最明显的质量差异来自“fetch 简单上传”和“真实进度上传”的拆分。只说“做进度条”时,代码有时会生成看起来很漂亮但不是真进度的 UI。明确写出“需要真实进度时使用 XMLHttpRequest”后,结果更容易审查。

另一个有效做法是先本地保存,再迁移到 S3。File API、FormData、校验、预览都稳定后,再替换存储层。这样比一开始同时处理 IAM、CORS、签名 URL、React 状态和数据库记录更适合初学者。

总结

安全的文件上传不是一个输入框,而是用户设备、应用服务器、存储系统和权限模型之间的边界。Claude Code 可以很快实现它,但提示词必须写清楚约束:客户端用 File API,传输用 FormData,服务端重新校验 MIME、扩展名和大小,保存名由服务器生成,进度显示要诚实,生产文件默认放在 private 对象存储。

如果你想把这个模式落到真实仓库里,Claude Code 培训与咨询可以一起整理上传流程、S3 保存、签名 URL、测试和审查清单。也可以先从免费 PDF 和教材开始试用。

#Claude Code #file upload #FormData #S3 #React #security
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。