【全栈进阶篇】Prisma、Neon、AuthJs在NextJs下的RBAC探索

22 阅读8分钟

背景

NextJs 13版本时我开始接触NextJs,那时学习了Prisma,开始做一些简单接口。后来随着AI的出现,我可以写一些有难度的操作数据库的prisma语句。半年前决定利用NextJs写一个小型管理系统,毕竟做了多年各种各样的中后台系统的开发,打算正式进军全栈中后台开发。那时做了一部分包括基础的登录注册权限设计等其他业务功能写了七八个页面,二三十个接口,但随着工作变的忙碌,没有太多空余时间所以就搁置了。 今年年初,我觉得很多东西可以渐进式的实现,所以打算还是从基础的RBAC开始,写一个可以之后直接使用的开源内容。

回顾

登录

在此之前,我现回顾了半年前我是如何做的。我设计了两张表来实现登录功能,一个是用户表,一个是session表。

  1. 整个流程可以从注册说起。用户通过填写表单写入用户名和密码,密码通过bcrypt进行加密,传到后端,这一部分的原因是在传递过程中不能有明文密码。
  2. 用户在登录过程中,提交表单后密码同样是经过加密的,后端和数据库的密码进行比对,比对成功进行下一步。
  3. 比对成功后向session表里插入一条数据,里面的关键信息是用户、登录时间和限制时间。
  4. 当用户登出的时候,删除用户在session中的数据。
  5. 在用户使用系统的过程中,每次跳转页面或使用接口都在middleware和session表中的限制时间进行比对,当用户的当前调用时间超出限制时间,自动登出。 整个流程符合主流登录和验证的方式,和同样主流的jwt相比,jwt令牌一旦签发就无法回收,在设定的期限内一直有效。如果想要用好jwt需要在存储、有效期等增加一些更加安全的考量。

数据库

在此之前,数据库是利用的本地数据库。当时就有一个问题,假如我的产品进行推广,用户还需要自己手动部署数据库,我当时的想法是系统和数据库打包到一个docker中,这样用户是无感的,但还是觉得这样太过麻烦。 对于小型的应用应该有一个更加方便免费的数据库。

项目启动

此次项目,我使用了AI进行辅助,没有完全依赖AI生成的方式关键原因我一部分还是抱着学习的心态。

技术选型

  • Next JS ^15
  • Prisma ^6
  • Authjs ^5
  • Neon 无服务数据库

截止我写这篇文章的时候NextJs 应该是在16.2版本,Prisma应该在7.2版本。选择老版本的原因还是因为Prisma属于大版本更新,无论是技术文档还是AI对它都不熟悉。刚开始官方文档和AI给出的差别很大,而我又不太想自己探索,所以使用上一代版本。

因为技术选型的这些库都相互独立,我在这里记录每一个的用法(取自官方文档),之后记录他们搭配的使用方式。(这里有坑,此套架构和他们两两结合使用完全不一样)

Prisma

之前写过一篇Prisma的文章,浅浅的写了如何通过prisma进行安装和增删改查,这里简单的记录一下。

pnpm add prisma@6 -D
pnpm add @prisma/client@6 
npx prisma init

此时就会出现prisma/schema.prisma文件,可以在里面构建表结构。以下是我针对rbac模型创建的表结构。其中User,Account,Session,VerificationToken是Authjs所需要的表

generator client {
  provider   = "prisma-client-js"
  engineType = "library"

}

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

  

model User {
  id            String     @id @default(cuid())
  userName      String?
  email         String?    @unique
  emailVerified DateTime?
  image         String?
  password      String? // 可选:如果你支持邮箱密码登录(非 OAuth)
  createdAt     DateTime   @default(now())
  updatedAt     DateTime   @updatedAt
  // 关联
  userRoles     UserRole[]
  roleIds       String[] // 可选:用于快速查询(非必须,可删)
  accounts      Account[]
  sessions      Session[]
  
  @@map("users")
}

  
model Account {
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

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

  @@id([provider, providerAccountId])
  @@map("accounts")
}

  

model Session {
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("sessions")
}

  

model VerificationToken {
  identifier String
  token      String
  expires    DateTime
  
  @@id([identifier, token])
  @@map("verification_tokens")
}

  

model Role {
  id          String  @id @default(cuid())
  name        String  @unique // e.g., "admin", "editor", "viewer"
  description String?

  // 关联
  userRoles       UserRole[]
  menuRoles       MenuRole[]
  permissions     Permission[]     @relation("RolePermissions")
  permissionRoles PermissionRole[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("roles")
}

  

model Menu {
  menuKey   String     @id
  menuName  String
  parentKey String?
  status    String
  sort      Int?       @default(1)
  menuRoles MenuRole[]

  @@map("menus")
}

  

model Permission {
  id          String  @id @default(cuid())
  name        String  @unique
  description String?

  roles           Role[]           @relation("RolePermissions")

  permissionRoles PermissionRole[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("permissions")
}

  

// ========================
// 多对多中间表(显式定义以支持额外字段,虽然这里不需要)
// ========================

  

model UserRole {
  id         String   @id @default(cuid())
  userId     String
  roleId     String
  assignedAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id])
  role Role @relation(fields: [roleId], references: [id])

  @@unique([userId, roleId])
  @@map("user_roles")
}

  

model PermissionRole {
  id           String   @id @default(cuid())
  roleId       String
  permissionId String
  description  String?
  assignedAt   DateTime @default(now())

  role       Role       @relation(fields: [roleId], references: [id])
  permission Permission @relation(fields: [permissionId], references: [id])

  @@unique([roleId, permissionId])
  @@map("permission_roles")
}

  

model MenuRole {
  id        String @id @default(cuid())
  roleId    String
  menuKeyId String

  role Role @relation(fields: [roleId], references: [id])
  menu Menu @relation(fields: [menuKeyId], references: [menuKey])

  @@unique([roleId, menuKeyId])
  @@map("menu_roles")
}

有了表之后,可以创建数据库,这个命令会根据你的模式创建数据库表。npx prisma migrate dev 之后想使用prisma操作数据库,需要用到prisma客户端npx prisma generate 接下来需要实例化prisma客户端(实测与Authjs不兼容,但是在这儿记录一下)src/lib/prisma.ts

import "dotenv/config";
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'

const connectionString = `${process.env.DATABASE_URL}`

const adapter = new PrismaPg({ connectionString })
const prisma = new PrismaClient({ adapter })

export { prisma }

Neon

Neon 是一个无服务器的 Postgres 平台,旨在帮助你更快地构建可靠且可扩展的应用。我们将计算和存储分离,提供现代开发者功能,如自动扩展分支即时还原等。

首先需要参考官网文档,可视化的注册一个PostgreSQL数据库,并记录下DATABASE_URL,之后把它加入到env中。这里默认prisma已安装

连接prisma

  1. 官网提供安装(最后不需要) pnpm add @neondatabase/serverless 官网给到的连接方式(实测与Authjs不兼容,但是在这儿记录一下)src/lib/prisma.ts
// Prisma example with the Neon serverless driver 
import { neon } from '@neondatabase/serverless'; 
import { PrismaNeonHTTP } from '@prisma/adapter-neon'; 
import { PrismaClient } from '@prisma/client'; 
const sql = neon(process.env.DATABASE_URL); 
const adapter = new PrismaNeonHTTP(sql); 
const prisma = new PrismaClient({ adapter });

Authjs

这个库如果之前了解的话,它的前身是Next-Auth。升级到五版本之后不仅名字改了,而且支持了更多nodejs框架。

  1. 安装pnpm add next-auth@beta
  2. 配置环境变量,可以理解为设置一个系统专属令牌npx auth secret
  3. 创建配置文件,这里可以理解类似于webpack.config.ts的配置项。官网说在根目录,实测可以在任何目录,这里我放在了src/lib/auth.ts
import NextAuth from "next-auth"
 
export const { handlers, signIn, signOut, auth } = NextAuth({
	providers: [],
})
  1. 创建api路由 /app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
  1. 根目录添加中间件 ./middleware.ts
export { auth as middleware } from "@/auth"
  1. 选择一种认证方式这里我选择的是Credentials
  2. 在配置文件中添加这样的代码src/lib/auth.ts(来自官网)
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
	// 你可以通过向 `credentials` 对象添加键来指定需要提交哪些字段。  
	// 例如,域名、用户名、密码、双因素验证令牌等。
      credentials: {
        email: {},
        password: {},
      },
      authorize: async (credentials) => {
        let user = null
 
        // 对密码进行加盐和哈希处理的逻辑
        const pwHash = saltAndHashPassword(credentials.password)
 
        // 验证用户是否存在的逻辑
        user = await getUserFromDb(credentials.email, pwHash)
 
        if (!user) {
	    // 没有找到用户,因此这是他们的首次登录尝试  
		// 如果需要,你也可以在这里进行用户注册
          throw new Error("Invalid credentials.")
        }
 
        // 返回用户数据
        return user
      },
    }),
  ],
})
  1. 登录方法./components/sign-in.tsx
"use client"
import { signIn } from "next-auth/react"
 
export function SignIn() {
  const credentialsAction = (formData: FormData) => {
    signIn("credentials", formData)
  }
 
  return (
    <form action={credentialsAction}>
      <label htmlFor="credentials-email">
        Email
        <input type="email" id="credentials-email" name="email" />
      </label>
      <label htmlFor="credentials-password">
        Password
        <input type="password" id="credentials-password" name="password" />
      </label>
      <input type="submit" value="Sign In" />
    </form>
  )
}

连接prisma

这里是官方给的方案,实际方案略有差别,这里默认prisma已安装

  1. 安装 @auth/prisma-adapter没有用到;@prisma/extension-accelerate性能提升可用可不用。
pnpm add @prisma/extension-accelerate @auth/prisma-adapter
  1. 更改实例,这里是prisma实例(差异点,非最终版),可以在prisma看到src/lib/prisma.ts
import { PrismaClient } from "../src/generated/client"
import { withAccelerate } from "@prisma/extension-accelerate"
 
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
 
export const prisma =
  globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate())
 
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
  1. 更改配置`src/lib/auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma" 
export const { handlers, auth, signIn, signOut } = NextAuth({ 
	adapter: PrismaAdapter(prisma), 
	providers: [],
	})

综合解决问题

为了同时满足 Authjs(要求标准 PrismaClient)和 Neon 数据库(在边缘环境连接需优化)的需求,我们放弃了 Neon 官方推荐的专用 HTTP 驱动,而是采用了折中方案:通过为标准 PrismaClient 实例的数据库连接字符串添加特定的连接池参数(如 pgbouncer=true),来提升其在边缘运行时中的兼容性与连接稳定性。此方案在保障 Authjs 正常工作的前提下,解决了关键连接问题。

// lib/db.ts - 核心兼容文件
import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";

// 1. 标准 Prisma 实例(兼容 NextAuth)
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

  

// 2. 创建实例时添加 Neon 优化
export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    datasources: {
      db: {
        // 关键:添加连接池参数
        url:
          process.env.DATABASE_URL +
          (process.env.DATABASE_URL?.includes("?") ? "&" : "?") +
          "pgbouncer=true&connection_limit=10&pool_timeout=60",
      },
    },
    log:
      process.env.NODE_ENV === "development"
        ? ["query", "error", "warn"]
        : ["error"],

  });

  

// 3. 可选:添加 Accelerate 扩展
export const acceleratedPrisma = prisma.$extends(withAccelerate())
  
// 4. 开发环境优化
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

export default prisma;

综上整体架构就搭建完成。按照文档提供的方式一直无法成功跑通,数据库连接成功却无法认证,卡了一整天,后来才想到不兼容的可能。 以下是AI解释

核心矛盾:Prisma的架构与Edge Runtime的约束 问题的根源在于 Prisma Client 的架构并非为 Edge Runtime 设计,而 Neon 推荐的 @prisma/adapter-neon 方案强制要求使用 @neondatabase/serverless 驱动,这暴露了更深层的运行时冲突。

  1. 架构错配:Prisma的“重型”依赖
    Prisma Client 的设计核心是 生成类型安全的查询引擎,它严重依赖 Node.js 的原生模块(如 net.Socketfs)和同步 I/O 操作来建立和管理数据库连接。这些 API 在 Edge Runtime(如 Vercel Edge Functions、Cloudflare Workers)中要么不存在,要么行为受限。Edge 环境是高度沙盒化、无状态的,旨在进行轻量级、无阻塞的快速响应。 2. Neon 方案的“边缘”假设
    Neon 提供的 @prisma/adapter-neon + @neondatabase/serverless 组合,是一个 纯为 Edge 优化的方案@neondatabase/serverless 驱动本身是 Edge-friendly 的,它使用 Fetch API 通过 HTTP/WebSocket 与 Neon 的 SQL over HTTP 端点通信,完美规避了底层 TCP 套接字。(然而,这个方案是“排他性”的。它创建的 PrismaClient 实例被这个特定的适配器深度绑定,其内部连接方式已完全改变。) 3. Auth.js(NextAuth.js)的“服务器”假设
    Auth.js 的 Prisma 适配器(@auth/prisma-adapter)在设计时,预期接收的是一个标准的、基于 Node.js 驱动(如 pg)的 PrismaClient 实例。它内部的查询模式和生命周期钩子(例如,在 authorize 回调或 callbacks 中访问 prisma.user)与 Prisma 的标准查询引擎紧密耦合。(当你试图将一个由 @prisma/adapter-neon 包装过的、底层已替换为 HTTP 驱动的 PrismaClient 实例传给 Auth.js 适配器时,Auth.js 适配器在执行某些操作时可能会触发适配器无法正确处理的、或 Edge 环境不支持的原生 Prisma 内部方法,从而导致运行时错误或无法建立会话。)

总结

这套方案解决了一些问。

  1. 在学习阶段我们可以去通过手写去了解一些流程的底层逻辑,但是到应用层面,如果有公认好用的库就应该利用起来。
  2. 这个问题卡了一段时间的主要因素是因为没有和AI正确的沟通导致它不知道是由多条件构成的问题。