4.7 项目实战——VibeNote V4.0 完整后端 API 与用户系统

4 阅读5分钟

模块四:后端 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。
  • 数据:usersnotestagsnote_tags,与第02/03讲一致,并增加可选 summarycoverUrl 字段用于 AI/上传演示。
  • API:
    • GET/POST /api/notes
    • GET/PATCH/DELETE /api/notes/[id]
    • GET /api/notes/search(或合并到列表 ?q=
    • GET/POST /api/tags
    • POST /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 可看到写入结果

九、思考题

  1. 为什么详情接口对用户不存在资源返回 404 而不是 403?什么场景必须返回 403?
  2. 搜索使用 ILIKE '%q%' 在数据量大时瓶颈在哪?你会如何演进?
  3. V4.0 若加「分享只读链接」,授权模型要改哪些表与路由?

十、模块四收束:你现在已经具备什么能力?

你能从 0 设计 REST API + PostgreSQL 模型 + Drizzle 迁移 + Auth 保护 + Zod 校验 + AI 侧能力,这是 AI-native 全栈产品「后端切片」的主干。下一模块通常进入 观测性、测试、部署与性能——把 VibeNote 从「能跑」推到「敢上线」。


十一、下一模块预告(建议路线)

部署与环境DATABASE_URL 分环境、AUTH_SECRET 轮换、Vercel/自建 Node 运行时差异。
测试:对 Route Handler 做最小集成测试,锁住认证与校验回归。
日志:结构化日志字段(userIdroutelatency)支撑排障。

至此,VibeNote V4.0 后端与用户系统的讲义闭环完成。把本节代码树导入你的仓库,打开 middleware.tsqueries/*,你就拥有了一份可对外演示的工程骨架。