4.6 文件上传与 AI 集成——从 Markdown 编辑到 AI 总结的完整实现

4 阅读5分钟

模块四:后端 API 与数据管理 | 第06讲:文件上传与 AI 集成——从 Markdown 编辑到 AI 总结的完整实现

本讲定位:把「附件/封面图」与「AI 总结/自动标签」做成可观测、可限流、可替换模型的服务端能力,而不是前端直连大模型。
项目锚点:VibeNote(Next.js 14 App Router)。
阅读线索:结合 course/part3-fullstack/21-file-upload.md20-ai-integration.md 的思路,本节给出UploadThing + AI SDK 流式的可运行骨架。


一、开篇:为什么上传与 AI 都要走后端?

1.1 上传

浏览器直连存储(若配置不当)容易暴露上传凭证;文件类型与大小校验如果在前端 alone,攻击者可以绕过。正确姿势:前端拿「短期可写 URL」或通过受控 API 上传,服务端(或受信边缘)做病毒扫描、MIME 校验、尺寸限制与存储路径规范。

1.2 AI

OPENAI_API_KEY 写进前端是重大事故(参考第04讲泄露案例)。模型调用必须在服务端,并配合:

  • 速率限制(每用户/每 IP)
  • 输入长度上限(防账单爆炸)
  • 输出流式(改善体验)
  • 审计日志(可选)
flowchart TB
    subgraph Client["浏览器"]
        E[Markdown 编辑器]
        U[选择图片上传]
    end
    subgraph Next["Next.js"]
        UT[UploadThing Handler]
        AI["/api/ai/summarize"]
    end
    subgraph External["外部服务"]
        S3[(对象存储)]
        LLM[(模型供应商)]
    end
    U --> UT
    UT --> S3
    E -->|POST noteId + text| AI
    AI --> LLM

二、方案选择:UploadThing vs 自建 multipart

方案优点注意
UploadThing与 Next.js 集成快,签名与回调清晰依赖第三方,需阅读其隐私与区域
formidable / busboy自控强需自建存储与 CDN,运维成本高

VibeNote MVP 推荐 UploadThing 做「用户头像/笔记插图」,把精力放在产品与 AI 流程。

2.1 UploadThing 服务端(示意)

pnpm add uploadthing @uploadthing/react
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { auth } from "@/auth";

const f = createUploadthing();

export const ourFileRouter = {
  noteImage: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
    .middleware(async () => {
      const session = await auth();
      if (!session?.user?.id) throw new Error("未登录");
      return { userId: session.user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return { url: file.url, userId: metadata.userId };
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;
// app/api/uploadthing/route.ts
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
});

真实项目还需在环境变量配置 UPLOADTHING_SECRET 等,请让 AI 按官方文档补齐。

2.2 前端最小使用(节选)

"use client";
import { UploadButton } from "@/lib/uploadthing";

export function CoverUpload({ onUploaded }: { onUploaded: (url: string) => void }) {
  return (
    <UploadButton
      endpoint="noteImage"
      onClientUploadComplete={(res) => {
        const url = res?.[0]?.url;
        if (url) onUploaded(url);
      }}
    />
  );
}

三、Vercel AI SDK:流式总结笔记

安装:

pnpm add ai @ai-sdk/openai

3.1 路由:/api/ai/summarize(可运行骨架)

// app/api/ai/summarize/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { auth } from "@/auth";
import { z } from "zod";

export const runtime = "nodejs";

const BodySchema = z.object({
  title: z.string().min(1).max(200),
  contentMd: z.string().min(1).max(50_000),
});

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user?.id) {
    return new Response("Unauthorized", { status: 401 });
  }

  const json = await req.json().catch(() => null);
  const parsed = BodySchema.safeParse(json);
  if (!parsed.success) {
    return new Response(JSON.stringify({ error: "Invalid body" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  const { title, contentMd } = parsed.data;

  const result = streamText({
    model: openai("gpt-4o-mini"),
    system:
      "你是 VibeNote 助手。请用中文输出 JSON,字段 summary(string<=200字), tags(string数组,<=5个,每个<=20字)。不要 Markdown。",
    prompt: `标题: ${title}\n\n正文(Markdown):\n${contentMd}`,
  });

  return result.toTextStreamResponse();
}

说明:示例为教学用「流式文本」;若你强制 JSON,可在客户端用 experimental_streamObject 或改为非流式 generateObject——让 AI 按你锁定的 ai 包版本生成。

3.2 客户端读取流(简化)

// lib/ai-client.ts
export async function summarizeNote(input: { title: string; contentMd: string }) {
  const res = await fetch("/api/ai/summarize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });
  if (!res.ok || !res.body) throw new Error("AI 请求失败");

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let text = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    text += decoder.decode(value, { stream: true });
  }
  return text;
}

四、把 AI 结果写回笔记:事务与幂等

推荐流程:

  1. 用户点击「生成摘要」→ 前端调用 /api/ai/summarize 流式展示。
  2. 用户确认 → 调用 /api/notes/:id PATCH 写入 summaryai_tags(字段可在 schema 扩展)。
  3. 服务端再次校验归属与长度。
sequenceDiagram
    participant U as 用户
    participant FE as 前端
    participant AI as /api/ai/summarize
    participant N as /api/notes/:id

    U->>FE: 点击生成
    FE->>AI: POST title+content
    AI-->>FE: stream tokens
    FE->>U: 实时显示
    U->>FE: 确认写入
    FE->>N: PATCH summary/tags
    N-->>FE: 200

幂等提示:若用户多次点击生成,不必每次都写库;只有「确认」触发 PATCH。


五、成本与滥用防护(比功能更重要)

  1. 每用户速率限制:Edge Config / Upstash Redis / 自建计数。
  2. 输入上限contentMd 50k 字符与 token 估算写进 PRD。
  3. 模型降级gpt-4o-mini 作为默认,高阶模型走付费开关。
  4. 日志脱敏:不要记录全文笔记到日志。
mindmap
  root((AI 上线清单))
    Keys
      仅服务端
      环境隔离
    Limits
      用户 QPS
      文本长度
    UX
      流式输出
      可取消
    Ops
      费用告警
      失败重试

六、Markdown 编辑器侧:粘贴图片与 URL

当 UploadThing 返回 url 后,前端在光标处插入 ![](url)。编辑器可选用 Tiptap、CodeMirror、或轻量 textarea + 预览——VibeNote MVP 可用「双栏 Markdown」降低复杂度。


七、思考题

  1. 为什么 AI 路由必须复用认证会话,而不是匿名开放?
  2. 流式输出失败中途断开,前端该如何恢复状态与提示重试?
  3. 若要把图片存到自有 S3,而不是 UploadThing,核心差异在哪些步骤?

八、本节小结

  • 上传与 AI 调用都属于高敏感能力,必须服务端收口。
  • UploadThing 适合 MVP 快速集成;校验与登录 middleware 仍需严谨。
  • Vercel AI SDK 让流式体验工程化;限流与上限是账单防线。
  • AI 生成内容应「预览 → 确认 → 写库」,避免不可控覆盖。

九、从 Markdown 到「可引用资产」:产品向补充

VibeNote 不只是编辑器。笔记上传图片后,建议统一用 HTTPS 绝对 URL 写入 Markdown,避免本地路径在部署后失效。若未来做「导出 PDF / 静态站点」,图片域名还要考虑跨域与缓存策略

AI 摘要建议保存为独立字段 summary,不要直接覆盖用户正文——这是「人机共治」的基本礼仪:AI 负责提议,用户负责拍板。你在数据模型里保留 updated_at 与可选 ai_generated_at,就能在界面上标注「摘要由 AI 于某日生成」,降低信任争议。


十、下一讲预告

第07讲:项目实战——VibeNote V4.0 完整后端 API 与用户系统
我们将把本模块内容收敛为可交付版本:完整路由表、Drizzle schema、Auth 集成、校验、搜索与标签、AI 摘要接口,并给出 ER 图与架构图,作为你简历与作品集里的「后端章节」。