模块四:后端 API 与数据管理 | 第07讲:项目实战——VibeNote V4.0 完整后端 API 与用户系统
本讲定位:把模块四沉淀为可演示、可扩展、可交接的 V4.0 后端切片:用户系统 + 笔记 CRUD + 搜索/标签 + AI 摘要。
技术栈:Next.js 14(App Router)、TypeScript、Tailwind、Drizzle ORM、PostgreSQL、Auth.js、Zod、AI SDK(可选)。
一、V4.0 范围声明:交付什么、不交付什么
交付
- 认证:Auth.js,
/api/auth/*,Middleware 保护/app与写操作 API。 - 数据:
users、notes、tags、note_tags,与第02/03讲一致,并增加可选summary、coverUrl字段用于 AI/上传演示。 - API:
GET/POST /api/notesGET/PATCH/DELETE /api/notes/[id]GET /api/notes/search(或合并到列表?q=)GET/POST /api/tagsPOST /api/ai/summarize
- 校验:Zod 三道防线 + 数据库约束。
本期不交付(留给后续模块)
- 协作与分享权限模型
- 全文索引(Postgres FTS)与复杂排序策略
- 管理后台与审计报表
flowchart TB
subgraph Edge["入口层"]
MW[Middleware 登录检查]
end
subgraph API["Route Handlers"]
N1["/api/notes"]
N2["/api/notes/[id]"]
T1["/api/tags"]
AI["/api/ai/summarize"]
end
subgraph Data["Drizzle + PG"]
DB[(PostgreSQL)]
end
MW --> N1
MW --> N2
MW --> T1
MW --> AI
N1 --> DB
N2 --> DB
T1 --> DB
AI -.可选写库.-> N2
二、ER 图(V4.0)
erDiagram
USERS ||--o{ NOTES : owns
USERS ||--o{ TAGS : defines
NOTES ||--o{ NOTE_TAGS : tagged
TAGS ||--o{ NOTE_TAGS : applied
USERS {
uuid id PK
text email UK
text name
timestamptz created_at
}
NOTES {
uuid id PK
uuid user_id FK
text title
text content_md
text summary
text cover_url
timestamptz created_at
timestamptz updated_at
}
TAGS {
uuid id PK
uuid user_id FK
text name
}
NOTE_TAGS {
uuid note_id FK
uuid tag_id FK
}
迁移提示:从 V3 升 V4,只需 ALTER TABLE notes ADD COLUMN summary text default '' 等与 AI/上传相关字段,并用 drizzle-kit generate。
三、目录结构(推荐终局形态)
app/
api/
auth/[...nextauth]/route.ts
uploadthing/...
notes/
route.ts
[id]/route.ts
tags/route.ts
ai/summarize/route.ts
app/...
middleware.ts
auth.ts
src/db/
index.ts
schema.ts
queries/
notes.ts
tags.ts
shared/
note.ts
tag.ts
四、Schema 增量(相对第03讲)
// src/db/schema.ts(节选增量)
import { text } from "drizzle-orm/pg-core";
// 在 notes 表内新增:
// summary: text("summary").notNull().default(""),
// coverUrl: text("cover_url"),
完整文件请复用第03讲基础定义并合并字段;保持索引仍以
(user_id, updated_at desc)为主。
五、queries 层:搜索与标签
// src/db/queries/notes.ts
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { db } from "../index";
import { noteTags, notes, tags } from "../schema";
export async function getNoteForUser(userId: string, noteId: string) {
const [row] = await db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), eq(notes.id, noteId)));
return row ?? null;
}
export async function searchNotes(userId: string, q: string, limit = 20) {
const pattern = `%${q}%`;
return db
.select()
.from(notes)
.where(
and(
eq(notes.userId, userId),
or(
ilike(notes.title, pattern),
ilike(notes.contentMd, pattern),
ilike(notes.summary, pattern)
)
)
)
.orderBy(desc(notes.updatedAt))
.limit(Math.min(limit, 50));
}
export async function listTags(userId: string) {
return db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.name);
}
export async function replaceNoteTags(input: {
userId: string;
noteId: string;
tagNames: string[];
}) {
return db.transaction(async (tx) => {
const note = await tx
.select()
.from(notes)
.where(and(eq(notes.id, input.noteId), eq(notes.userId, input.userId)));
if (!note[0]) return null;
await tx.delete(noteTags).where(eq(noteTags.noteId, input.noteId));
const unique = Array.from(new Set(input.tagNames.map((t) => t.trim()).filter(Boolean)));
const ids: string[] = [];
for (const name of unique) {
const [tag] = await tx
.insert(tags)
.values({ userId: input.userId, name })
.onConflictDoUpdate({
target: [tags.userId, tags.name],
set: { name },
})
.returning();
ids.push(tag.id);
}
if (ids.length) {
await tx.insert(noteTags).values(ids.map((tagId) => ({ noteId: input.noteId, tagId })));
}
return true;
});
}
六、API 路由完整示例
6.1 GET/POST /api/notes
// app/api/notes/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { NoteCreateSchema } from "@/shared/note";
import { createNoteWithTags, listNotesForUser, searchNotes } from "@/db/queries/notes";
export async function GET(req: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim();
const data = q ? await searchNotes(userId, q) : await listNotesForUser({ userId });
return NextResponse.json({ success: true, data });
}
export async function POST(req: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = NoteCreateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: { message: "参数错误", details: parsed.error.flatten() } },
{ status: 400 }
);
}
const note = await createNoteWithTags({ userId, ...parsed.data });
return NextResponse.json({ success: true, data: note }, { status: 201 });
}
6.2 GET/PATCH/DELETE /api/notes/[id]
// app/api/notes/[id]/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { z } from "zod";
import { db } from "@/db";
import { notes } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { getNoteForUser, replaceNoteTags } from "@/db/queries/notes";
const PatchSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
contentMd: z.string().max(200_000).optional(),
summary: z.string().max(500).optional(),
coverUrl: z.string().url().max(2000).optional().or(z.literal("")),
tagNames: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
});
export async function GET(_: Request, ctx: { params: { id: string } }) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const row = await getNoteForUser(userId, ctx.params.id);
if (!row) {
return NextResponse.json({ success: false, error: { message: "未找到" } }, { status: 404 });
}
return NextResponse.json({ success: true, data: row });
}
export async function PATCH(req: Request, ctx: { params: { id: string } }) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = PatchSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: { message: "参数错误", details: parsed.error.flatten() } },
{ status: 400 }
);
}
const { tagNames, ...rest } = parsed.data;
const [updated] = await db
.update(notes)
.set({ ...rest, updatedAt: new Date() })
.where(and(eq(notes.id, ctx.params.id), eq(notes.userId, userId)))
.returning();
if (!updated) {
return NextResponse.json({ success: false, error: { message: "未找到" } }, { status: 404 });
}
if (tagNames) {
await replaceNoteTags({ userId, noteId: ctx.params.id, tagNames });
}
return NextResponse.json({ success: true, data: updated });
}
export async function DELETE(_: Request, ctx: { params: { id: string } }) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const deleted = await db
.delete(notes)
.where(and(eq(notes.id, ctx.params.id), eq(notes.userId, userId)))
.returning({ id: notes.id });
if (!deleted.length) {
return NextResponse.json({ success: false, error: { message: "未找到" } }, { status: 404 });
}
return NextResponse.json({ success: true, data: { id: deleted[0].id } });
}
6.3 GET/POST /api/tags
// app/api/tags/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { listTags } from "@/db/queries/notes";
import { db } from "@/db";
import { tags } from "@/db/schema";
import { z } from "zod";
import { eq } from "drizzle-orm";
const TagCreate = z.object({ name: z.string().trim().min(1).max(40) });
export async function GET() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
return NextResponse.json({ success: true, data: await listTags(userId) });
}
export async function POST(req: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: { message: "未登录" } }, { status: 401 });
}
const json = await req.json().catch(() => null);
const parsed = TagCreate.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ success: false, error: { message: "参数错误" } }, { status: 400 });
}
const [tag] = await db
.insert(tags)
.values({ userId, name: parsed.data.name })
.onConflictDoNothing()
.returning();
return NextResponse.json({ success: true, data: tag }, { status: tag ? 201 : 200 });
}
onConflictDoNothing行为可按产品需求调整为update或返回已有标签。
七、AI 摘要接口(与第06讲对齐)
将第06讲 /api/ai/summarize 保留;在 PATCH 允许写入 summary 后,前端完成「预览 → 确认保存」闭环。
八、验收清单(建议你打印)
- 未登录访问
POST /api/notes→ 401 - 登录用户 A 无法
GET/PATCH/DELETE用户 B 的note id→ 404 或 403(本实现 404 隐藏存在性) -
title超长 → 400 + Zod details - 标签数超限 → 400
- 并发创建同标签 → 唯一索引不崩溃,API 返回可理解错误
-
pnpm db:studio可看到写入结果
九、思考题
- 为什么详情接口对用户不存在资源返回 404 而不是 403?什么场景必须返回 403?
- 搜索使用
ILIKE '%q%'在数据量大时瓶颈在哪?你会如何演进? - V4.0 若加「分享只读链接」,授权模型要改哪些表与路由?
十、模块四收束:你现在已经具备什么能力?
你能从 0 设计 REST API + PostgreSQL 模型 + Drizzle 迁移 + Auth 保护 + Zod 校验 + AI 侧能力,这是 AI-native 全栈产品「后端切片」的主干。下一模块通常进入 观测性、测试、部署与性能——把 VibeNote 从「能跑」推到「敢上线」。
十一、下一模块预告(建议路线)
部署与环境:DATABASE_URL 分环境、AUTH_SECRET 轮换、Vercel/自建 Node 运行时差异。
测试:对 Route Handler 做最小集成测试,锁住认证与校验回归。
日志:结构化日志字段(userId、route、latency)支撑排障。
至此,VibeNote V4.0 后端与用户系统的讲义闭环完成。把本节代码树导入你的仓库,打开 middleware.ts 与 queries/*,你就拥有了一份可对外演示的工程骨架。