模块四:后端 API 与数据管理 | 第06讲:文件上传与 AI 集成——从 Markdown 编辑到 AI 总结的完整实现
本讲定位:把「附件/封面图」与「AI 总结/自动标签」做成可观测、可限流、可替换模型的服务端能力,而不是前端直连大模型。
项目锚点:VibeNote(Next.js 14 App Router)。
阅读线索:结合course/part3-fullstack/21-file-upload.md与20-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 结果写回笔记:事务与幂等
推荐流程:
- 用户点击「生成摘要」→ 前端调用
/api/ai/summarize流式展示。 - 用户确认 → 调用
/api/notes/:idPATCH 写入summary与ai_tags(字段可在 schema 扩展)。 - 服务端再次校验归属与长度。
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。
五、成本与滥用防护(比功能更重要)
- 每用户速率限制:Edge Config / Upstash Redis / 自建计数。
- 输入上限:
contentMd50k 字符与 token 估算写进 PRD。 - 模型降级:
gpt-4o-mini作为默认,高阶模型走付费开关。 - 日志脱敏:不要记录全文笔记到日志。
mindmap
root((AI 上线清单))
Keys
仅服务端
环境隔离
Limits
用户 QPS
文本长度
UX
流式输出
可取消
Ops
费用告警
失败重试
六、Markdown 编辑器侧:粘贴图片与 URL
当 UploadThing 返回 url 后,前端在光标处插入 。编辑器可选用 Tiptap、CodeMirror、或轻量 textarea + 预览——VibeNote MVP 可用「双栏 Markdown」降低复杂度。
七、思考题
- 为什么 AI 路由必须复用认证会话,而不是匿名开放?
- 流式输出失败中途断开,前端该如何恢复状态与提示重试?
- 若要把图片存到自有 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 图与架构图,作为你简历与作品集里的「后端章节」。