Published on

构建现代Web应用的用户系统:Neon PostgreSQL与NextAuth的完美结合

Authors

构建现代Web应用的用户系统:Neon PostgreSQL与NextAuth的完美结合

在SynthesizerFlow项目的发展过程中,我们决定添加用户系统,以支持项目保存、权限控制和分享功能。这篇文章将详细介绍我们如何使用Vercel托管的Neon PostgreSQL和NextAuth(Auth.js)创建一个健壮的用户系统。

为什么选择Neon PostgreSQL和NextAuth?

在众多数据库和认证方案中,我们选择了这个组合有几个关键原因:

  • Neon PostgreSQL

    • 完全托管的无服务器PostgreSQL,无需管理基础设施
    • 与Vercel平台无缝集成,减少了配置复杂度
    • 自动扩展能力,可以应对从小型项目到企业级应用的需求
    • 具有分支功能,方便开发和测试环境的分离
  • NextAuth/Auth.js

    • 专为NextJS应用设计的认证解决方案
    • 支持OAuth提供商(GitHub、Google等)快速实现社交登录
    • 与Prisma数据库工具无缝集成
    • TypeScript友好的API设计

这个组合不仅减少了我们自行实现用户系统的工作量,还提供了高度的安全性和可扩展性。

技术栈概览

我们的用户系统建立在以下技术上:

  • Next.js 15+:React框架,用于构建Web应用
  • Prisma ORM:用于数据库交互和模型定义
  • NextAuth/Auth.js:处理用户认证和会话管理
  • Neon PostgreSQL:云数据库,用于存储用户数据和项目
  • Zod:用于输入验证和运行时类型安全

实现步骤

1. 数据库模型设计

首先,我们通过Prisma定义了用户系统所需的数据库模型:

// 用户系统模型
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  role          UserRole  @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts      Account[]
  sessions      Session[]
  projects      Project[]
}

enum UserRole {
  USER
  ADMIN
}

// NextAuth所需的关联模型
model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  // ...其他OAuth相关字段
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

// 项目模型
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  content     Json     // 存储项目数据的JSON
  isPublic    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  userId      String
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

这个模型设计允许我们:

  • 存储用户基本信息和角色
  • 关联OAuth认证数据
  • 管理用户会话
  • 存储用户创建的项目

2. NextAuth配置与集成

NextAuth的配置是用户系统的核心。以下是我们如何设置它与Prisma和Neon PostgreSQL协同工作:

import { PrismaAdapter } from '@auth/prisma-adapter'
import { NextAuthOptions } from 'next-auth'
import NextAuth from 'next-auth/next'
import { prisma } from '@/lib/prisma'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
import { UserRole } from '@/generated/prisma'

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    // 配置OAuth提供商
    GoogleProvider({
      clientId: process.env.GOOGLE_ID || '',
      clientSecret: process.env.GOOGLE_SECRET || '',
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_ID || '',
      clientSecret: process.env.GITHUB_SECRET || '',
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      // 扩展会话,添加用户ID和角色
      if (session.user && token.sub) {
        session.user.id = token.sub

        try {
          const dbUser = await prisma.user.findUnique({
            where: { id: token.sub },
            select: { role: true },
          })

          if (dbUser) {
            session.user.role = dbUser.role
          }
        } catch (error) {
          console.error('获取用户角色失败:', error)
          session.user.role = UserRole.USER
        }
      }
      return session
    },
  },
}

这个配置使用PrismaAdapter将认证数据存储到我们的Neon PostgreSQL数据库,并通过session回调扩展了用户会话数据,添加了用户ID和角色信息。

3. 用户界面组件

我们创建了几个关键的UI组件来支持用户交互:

登录页面

export default function LoginPage() {
  const { data: session } = useSession()
  const router = useRouter()
  const callbackUrl = useSearchParams().get('callbackUrl') || '/'

  // 已登录用户重定向
  useEffect(() => {
    if (session) router.push(callbackUrl)
  }, [session, router, callbackUrl])

  return (
    <div className="flex min-h-screen items-center justify-center">
      <Card className="mx-auto w-full max-w-md">
        <CardHeader>
          <CardTitle className="text-center text-2xl font-bold">登录</CardTitle>
          <CardDescription className="text-center">
            登录后即可保存和分享你的音频合成项目
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="flex flex-col space-y-3">
            <Button onClick={() => signIn('github', { callbackUrl })}>
              <GitHubIcon className="mr-2" />
              使用GitHub登录
            </Button>
            <Button onClick={() => signIn('google', { callbackUrl })}>
              <GoogleIcon className="mr-2" />
              使用Google登录
            </Button>
          </div>
        </CardContent>
      </Card>
    </div>
  )
}

用户菜单组件

export function NavUser() {
  const { data: session } = useSession()

  if (!session) {
    return (
      <div onClick={() => signIn()} className="cursor-pointer">
        <UserIcon className="mr-2 h-4 w-4" />
        <span>登录</span>
      </div>
    )
  }

  const user = session.user

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <div className="cursor-pointer">
          <Avatar>
            <AvatarImage src={user.image || ''} />
            <AvatarFallback>{user.name?.substring(0, 2).toUpperCase() || 'U'}</AvatarFallback>
          </Avatar>
        </div>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuGroup>
          <DropdownMenuItem asChild>
            <Link href="/dashboard">我的项目</Link>
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => signOut()}>退出登录</DropdownMenuItem>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

4. API路由和项目管理

为了支持项目的创建和分享,我们创建了几个关键API端点:

// 获取项目列表
export async function GET(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions)
    const userId = session?.user?.id
    const { searchParams } = new URL(req.url)
    const isPublic = searchParams.get('public') === 'true'

    // 构建查询条件
    let whereClause: any = {}
    if (isPublic) {
      whereClause.isPublic = true
    } else if (userId) {
      whereClause.userId = userId
    } else {
      return NextResponse.json({ error: '未授权' }, { status: 401 })
    }

    const projects = await prisma.project.findMany({
      where: whereClause,
      orderBy: { updatedAt: 'desc' },
      select: {
        id: true,
        name: true,
        description: true,
        isPublic: true,
        createdAt: true,
        updatedAt: true,
        userId: true,
        user: {
          select: { name: true, image: true },
        },
      },
    })

    return NextResponse.json(projects)
  } catch (error) {
    return NextResponse.json({ error: '获取项目失败' }, { status: 500 })
  }
}

// 创建新项目
export async function POST(req: NextRequest) {
  try {
    const session = await getServerSession(authOptions)
    const userId = session?.user?.id

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

    const body = await req.json()
    const validatedData = projectCreateSchema.parse(body)

    const project = await prisma.project.create({
      data: {
        name: validatedData.name,
        description: validatedData.description,
        content: validatedData.content,
        isPublic: validatedData.isPublic,
        userId,
      },
    })

    return NextResponse.json(project)
  } catch (error) {
    console.error('创建项目失败:', error)
    return NextResponse.json({ error: '创建项目失败' }, { status: 500 })
  }
}

5. 部署到Vercel与CI/CD考虑

在部署到Vercel时,我们需要确保Prisma能正确工作。为此,我们修改了package.json中的构建命令:

{
  "scripts": {
    "build": "prisma generate && next build"
  }
}

并添加了vercel.json配置:

{
  "buildCommand": "prisma generate && next build",
  "installCommand": "npm install",
  "framework": "nextjs"
}

这确保了在构建过程中会生成Prisma客户端,然后再进行Next.js应用的构建,解决了CI/CD环境中的依赖顺序问题。

解决的挑战

1. 嵌套按钮导致的水合错误

在实现用户菜单时,我们遇到了React组件嵌套导致的水合错误:

In HTML, <button> cannot be a descendant of <button>.
This will cause a hydration error.

这是因为我们的组件结构中,Button组件内嵌套了另一个Button组件。我们通过将内部的Button替换为样式化的div元素解决了这个问题:

// 替换前
<Button>
  <NavUser />  // 内部也包含Button组件
</Button>

// 替换后
<div className="[按钮样式]">
  <NavUser />
</div>

2. Prisma客户端导入路径问题

由于Prisma生成的客户端位于自定义路径src/generated/prisma,我们遇到了导入路径不一致的问题。特别是在导入UserRole枚举时:

模块"@prisma/client"没有导出的成员"UserRole"

我们通过统一所有导入路径解决了这个问题:

// 错误的导入
import { UserRole } from '@prisma/client'

// 修复后的导入
import { UserRole } from '@/generated/prisma'

3. CI/CD环境中的Prisma生成问题

在Vercel部署过程中,我们遇到了构建时找不到生成的Prisma客户端的问题。通过修改构建命令和添加Vercel配置,我们确保了在构建过程中先生成Prisma客户端:

"build": "prisma generate && next build"

经验教训

通过构建这个用户系统,我们学到了几个重要的经验:

  1. 认证与数据库的紧密集成:使用NextAuth的PrismaAdapter简化了认证数据的存储和管理,但需要确保模型定义正确。

  2. 类型安全至关重要:在处理用户数据和认证时,TypeScript和Zod的组合提供了强大的类型检查和运行时验证。

  3. 组件嵌套需谨慎:React组件嵌套可能导致意外的DOM结构,特别是使用像button这样的HTML元素时。

  4. CI/CD环境中的生成资源:在构建过程中生成依赖文件(如Prisma客户端)需要特别注意构建步骤的顺序。

下一步计划

完成用户系统的基础架构后,我们计划进一步扩展功能:

  1. 实现项目协作功能,允许多用户共同编辑项目
  2. 添加项目版本控制,记录修改历史
  3. 构建公共项目库,展示和分享优秀作品
  4. 实现更细粒度的权限控制系统

结论

通过Neon PostgreSQL和NextAuth的结合,我们成功地为SynthesizerFlow添加了一个强大而灵活的用户系统。这个系统不仅满足了基本的身份验证需求,还提供了项目持久化存储和分享功能,为应用的进一步发展奠定了坚实的基础。

这种模式也可以轻松适应其他NextJS项目,特别是那些需要用户认证和数据存储的应用。Vercel托管的Neon PostgreSQL提供了一个简单而强大的数据库解决方案,而NextAuth则简化了认证逻辑的实现。


你有使用Neon PostgreSQL或NextAuth的经验吗?欢迎在评论中分享你的见解和建议!