楠渡余生楠渡余生
首页
笔记
作品集
留言板
关于
GitHub
CSDN
首页
笔记
作品集
留言板
关于
GitHub
CSDN
  • 前端开发

    • React Server Components(RSC)学习笔记

      • React Server Components(RSC)学习笔记
    • 全栈框架学习笔记

      • 全栈框架学习笔记
    • jQuery 学习笔记

      • jQuery 学习笔记
    • React 学习笔记

      • React 学习笔记
    • AJAX 学习笔记

      • AJAX 学习笔记
    • Axios 完整学习笔记

      • Axios 完整学习笔记
    • CSS 属性速查手册

      • CSS 属性速查手册
    • HTML5 与 CSS 综合学习笔记

      • HTML5 与 CSS 综合学习笔记
    • JavaScript 学习笔记

      • JavaScript 学习笔记
    • Promise 学习笔记

      • Promise 学习笔记
    • Tailwind CSS 完整笔记

      • Tailwind CSS 完整笔记
    • TypeScript 快速上手

      • TypeScript 快速上手
    • Vue3 学习笔记

      • Vue3 学习笔记
  • 元框架与全栈路由

    • Next.js App Router 最佳实践

      • Next.js App Router 最佳实践
    • 跨域与服务端组件数据预取

      • 跨域与服务端组件数据预取
  • 现代数据流与安全

    • Prisma Schema 全栈类型生成

      • Prisma Schema 全栈类型生成
    • Supabase RLS 行级安全策略

      • Supabase RLS 行级安全策略
  • 商业化与支付闭环

    • SaaS 订阅制用户表结构设计

      • SaaS 订阅制用户表结构设计
    • Stripe Webhook 接入避坑指南

      • Stripe Webhook 接入避坑指南
  • 零运维与边缘计算

    • Cloudflare 基础防护与 CDN

      • Cloudflare 基础防护与 CDN
    • Vercel 自动化部署与环境变量

      • Vercel 自动化部署与环境变量
  • AI 赋能与集成

    • Vercel AI SDK 流式输出实战

      • Vercel AI SDK 流式输出实战
  • 增长、监控与运营

    • Resend 事务性邮件模板

      • Resend 事务性邮件模板
    • Sentry 前端异常捕获与报警

      • Sentry 前端异常捕获与报警
  • Node.js 深入学习

    • MongoDB 常用命令速查表

      • MongoDB 常用命令速查表
    • Node.js + MongoDB 生产级最佳实践指南

      • Node.js + MongoDB 生产级最佳实践指南
    • Node.js Express 框架

      • Node.js Express 框架
    • Node.js HTTP 模块

      • Node.js HTTP 模块
    • Node.js NPM 包管理

      • Node.js NPM 包管理
    • Node.js 文件系统模块

      • Node.js 文件系统模块
    • Node.js 模块化设计

      • Node.js 模块化设计
  • 后端开发

    • Express 基本使用

      • Express 基本使用
    • Node.js 学习笔记

      • Node.js 学习笔记
    • SpringBoot 完整学习笔记

      • SpringBoot 完整学习笔记
  • 开发工具

    • Windows + WSL + Docker 踩坑与通关指南

      • Windows + WSL + Docker 踩坑与通关指南
    • GitHub 新手完全指南

      • GitHub 新手完全指南
    • 个人博客搭建指南

      • 个人博客搭建指南

Prisma Schema 全栈类型生成

1. 为什么 ORM 类型安全很重要

全栈项目最常见的问题之一是:

数据库字段改了
后端类型没改
前端表单还在提交旧字段
线上运行时才报错

Prisma / Drizzle 的价值是把数据库结构变成 TypeScript 类型来源,让字段变化能在开发阶段暴露。

2. Prisma Schema 示例

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserRole {
  USER
  ADMIN
}

enum SubscriptionStatus {
  ACTIVE
  CANCELED
  PAST_DUE
  INCOMPLETE
}

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String?
  role          UserRole       @default(USER)
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt
  subscriptions Subscription[]
}

model Plan {
  id            String         @id @default(cuid())
  name          String
  stripePriceId String         @unique
  priceCents    Int
  active        Boolean        @default(true)
  createdAt     DateTime       @default(now())
  subscriptions Subscription[]
}

model Subscription {
  id                   String             @id @default(cuid())
  userId               String
  planId               String
  stripeSubscriptionId String             @unique
  status               SubscriptionStatus
  currentPeriodEnd     DateTime
  createdAt            DateTime           @default(now())
  updatedAt            DateTime           @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)

  @@index([userId])
  @@index([planId])
  @@index([status])
}

3. 生成类型

npx prisma generate
npx prisma migrate dev --name init

生成后可直接在服务端使用类型:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }

export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

4. 服务端查询:类型安全返回

// lib/users.ts
import { prisma } from './prisma'
import type { User } from '@prisma/client'

export type PublicUser = Pick<User, 'id' | 'email' | 'name' | 'role'>

export async function getPublicUser(userId: string): Promise<PublicUser | null> {
  if (!userId) return null

  try {
    return await prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
      },
    })
  } catch (error) {
    console.error('[getPublicUser]', error)
    return null
  }
}

5. Route Handler 返回给前端

// app/api/me/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPublicUser } from '@/lib/users'

function getUserIdFromRequest(request: NextRequest): string | null {
  const userId = request.cookies.get('user_id')?.value
  return userId && userId.length < 100 ? userId : null
}

export async function GET(request: NextRequest) {
  const userId = getUserIdFromRequest(request)

  if (!userId) {
    return NextResponse.json({ error: '未登录' }, { status: 401 })
  }

  try {
    const user = await getPublicUser(userId)

    if (!user) {
      return NextResponse.json({ error: '用户不存在' }, { status: 404 })
    }

    return NextResponse.json({ user })
  } catch (error) {
    console.error('[GET /api/me]', error)
    return NextResponse.json({ error: '服务暂时不可用' }, { status: 500 })
  }
}

6. 前端消费类型

生产项目建议把 API 返回类型抽到共享文件。

// types/api.ts
export type ApiError = {
  error: string
}

export type MeResponse = {
  user: {
    id: string
    email: string
    name: string | null
    role: 'USER' | 'ADMIN'
  }
}
// app/profile/ProfileClient.tsx
'use client'

import { useEffect, useState } from 'react'
import type { ApiError, MeResponse } from '@/types/api'

export function ProfileClient() {
  const [data, setData] = useState<MeResponse | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const controller = new AbortController()

    async function loadProfile() {
      try {
        const res = await fetch('/api/me', { signal: controller.signal })
        const json = await res.json().catch(() => null) as MeResponse | ApiError | null

        if (!res.ok) {
          throw new Error((json as ApiError | null)?.error || '获取用户信息失败')
        }

        if (!json || !('user' in json)) {
          throw new Error('响应格式错误')
        }

        setData(json)
      } catch (error) {
        if (error instanceof DOMException && error.name === 'AbortError') return
        console.error('[ProfileClient]', error)
        setError(error instanceof Error ? error.message : '加载失败')
      } finally {
        setLoading(false)
      }
    }

    loadProfile()
    return () => controller.abort()
  }, [])

  if (loading) return <p>加载中...</p>
  if (error) return <p role="alert">{error}</p>
  if (!data) return <p>暂无数据</p>

  return (
    <section>
      <h2>个人资料</h2>
      <p>{data.user.email}</p>
      <p>{data.user.role}</p>
    </section>
  )
}

7. Drizzle Schema 示例

Drizzle 更接近 SQL,类型推导也很强。

// db/schema.ts
import { pgEnum, pgTable, text, timestamp, integer, boolean, index } from 'drizzle-orm/pg-core'

export const userRole = pgEnum('user_role', ['USER', 'ADMIN'])

export const users = pgTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  role: userRole('role').notNull().default('USER'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})

export const plans = pgTable('plans', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  stripePriceId: text('stripe_price_id').notNull().unique(),
  priceCents: integer('price_cents').notNull(),
  active: boolean('active').notNull().default(true),
})

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
// db/users.ts
import { eq } from 'drizzle-orm'
import { db } from './client'
import { users, type User } from './schema'

export type PublicUser = Pick<User, 'id' | 'email' | 'name' | 'role'>

export async function findPublicUser(id: string): Promise<PublicUser | null> {
  if (!id) return null

  try {
    const rows = await db
      .select({
        id: users.id,
        email: users.email,
        name: users.name,
        role: users.role,
      })
      .from(users)
      .where(eq(users.id, id))
      .limit(1)

    return rows[0] ?? null
  } catch (error) {
    console.error('[findPublicUser]', error)
    return null
  }
}

8. Migration 标准流程

不要在生产控制台直接改字段。

标准流程:

1. 本地修改 schema
2. 生成 migration 文件
3. 本地跑测试库验证
4. 提交 schema + migration
5. CI 执行类型检查和测试
6. 预览环境执行 migration
7. 生产环境发布前或发布中执行 migration
8. 验证日志和关键链路

Prisma:

npx prisma migrate dev --name add_subscription_status
npx prisma migrate deploy

Drizzle:

npx drizzle-kit generate
npx drizzle-kit migrate

9. 生产迁移的坑点

9.1 直接删除字段

危险做法:

删除字段 -> 老代码还在读写 -> 线上报错

安全做法:

第一版:新增字段,代码同时兼容新旧字段
第二版:后台回填数据
第三版:代码只读新字段
第四版:确认无旧字段访问后删除旧字段

9.2 给大表加非空字段

如果表很大,直接加 NOT NULL 可能锁表。

安全流程:

1. 先加 nullable 字段
2. 分批回填
3. 确认无 null
4. 再加 NOT NULL 约束

9.3 本地 schema 和生产数据库不一致

必须以 migration 文件为准,不要靠口头记忆。

10. 取舍

方案优点缺点
Prisma开发体验好,类型友好,生态成熟SQL 灵活度较低,复杂查询有时绕
Drizzle接近 SQL,类型推导强,轻量团队需要更懂 SQL
手写 SQL极致灵活类型闭环弱,维护成本高

建议:

  • 初学和中小项目:Prisma 更容易上手。
  • 对 SQL 控制要求高:Drizzle 更适合。
  • 复杂报表:ORM + 手写 SQL 混合使用。
最后更新: 2026/6/13 21:40
贡献者: 52nnnn, Claude Opus 4.7