「2」基于Next.js的低代码平台:用户服务开发(上)

176 阅读4分钟

前言

因为是练手项目,所以我们用户校验使用jwt,而不是Clerk等,欢迎来到这一节

token配置

安装依赖:

pnpm i jsonwebtoken
pnpm i @types/jsonwebtoken -D

新建helpers/auth.ts用于生成和解析token

import jwt from 'jsonwebtoken';
import type { NextRequest } from 'next/server';

const TOKEN_SECRET = process.env.NEXT_PUBLIC_ACCESS_TOKEN_SECRET || '';
export async function verifyToken(req: NextRequest, isJwt: boolean) {
  try {
    const token = req?.headers?.get('authorization');
    if (!token) {
      throw new Error('No token provided');
    }
    const decoded: any = jwt.verify(token, TOKEN_SECRET);
    const id = decoded?.id;
    return new Promise((resolve) => resolve(id));
  } catch (error) {
    if (isJwt) {
      throw error;
    }
  }
}

export async function createAccessToken(payload: any) {
  if (!payload) {
    throw new Error('No payload provided');
  }
  return jwt.sign(payload, TOKEN_SECRET, {
    expiresIn: '1d',
  });
}

NEXT_PUBLIC_ACCESS_TOKEN_SECRET 自行在.env下面自定义一个字符串即可

然后我们新建helpers/jwt-middleware.ts用于校验解析authorization并将解析后的userId保存到headers里面

import type { NextRequest } from 'next/server';
import { verifyToken } from './auth';

export default async function jwtMiddleware(req: NextRequest, isJwt: boolean = false) {
  const id: string = (await verifyToken(req, isJwt)) as string;
  req.headers.set('userId', id);
}

修改helpers/apiHandler.ts,使用我们新增的jwt中间件

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { errorHandler } from './errorHandler';
import validateMiddleware from './validate-middleware';
import jwtMiddleware from './jwt-middleware';

export function apiHandler(
  handler: (req: NextRequest, ...args: any[]) => any,
  { isJwt, schema }: { isJwt?: boolean; schema?: any } = {},
) {
  return async (req: NextRequest, ...args: any[]) => {
    try {
      await jwtMiddleware(req, isJwt);
      await validateMiddleware(req, schema);
      const responseBody = await handler(req, ...args);
      return NextResponse.json(responseBody || {});
    } catch (err: any) {
      return errorHandler(err);
    }
  };
}

prisma配置

修改prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

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

// 用户
model User {
  id             String     @id @default(cuid())
  email          String     @unique
  name           String
  password       String
  createdAt      DateTime   @default(now())
  updatedAt      DateTime   @default(now()) @updatedAt
  createdSystems System[]   @relation("CreatedSystems")
  updatedSystems System[]   @relation("UpdatedSystems")
  createdRoles   Role[]     @relation("CreatedRoles")
  updatedRoles   Role[]     @relation("UpdatedRoles")
  userRoles      UserRole[]

  @@index([email])
}

// 系统
model System {
  id          String      @id @default(cuid())
  name        String
  description String?
  status      STATUS?     @default(ENABLED)
  creator     User        @relation("CreatedSystems", fields: [creatorId], references: [id])
  creatorId   String
  updator     User?       @relation("UpdatedSystems", fields: [updatorId], references: [id])
  updatorId   String?
  createdAt   DateTime    @default(now())
  updatedAt   DateTime    @default(now()) @updatedAt
  resources   Resource[]
  privileges  Privilege[]
  roles       Role[]
}

// 资源
model Resource {
  id         String        @id @default(cuid())
  name       String
  // 对应资源的可识别key,并不等同于系统自建id
  key        String        @unique
  // 菜单类的资源才会有排序的功能
  sort       Int?          @default(0)
  // 父子嵌套,当为null为顶级资源
  parent     Resource?     @relation("ParentChild", fields: [parentId], references: [id])
  parentId   String?
  children   Resource[]    @relation("ParentChild")
  system     System?       @relation(fields: [systemId], references: [id])
  systemId   String?
  // 资源类型
  type       RESOURCE_TYPE @default(NORMAL)
  createdAt  DateTime      @default(now())
  updatedAt  DateTime      @default(now()) @updatedAt
  privileges Privilege[]

  @@index([key])
}

// 权限管理
model Privilege {
  id             String           @id @default(cuid())
  system         System           @relation(fields: [systemId], references: [id])
  systemId       String
  resource       Resource         @relation(fields: [resourceKey], references: [key])
  resourceKey    String
  name           String
  description    String?
  action         ACTION
  status         PRIVILEGE_STATUS @default(ALLOW)
  createdAt      DateTime         @default(now())
  updatedAt      DateTime         @default(now()) @updatedAt
  rolePrivileges RolePrivilege[]
}

// 角色管理
model Role {
  id             String          @id @default(cuid())
  name           String
  description    String?
  creator        User            @relation("CreatedRoles", fields: [createdId], references: [id])
  createdId      String
  updator        User            @relation("UpdatedRoles", fields: [updatedId], references: [id])
  updatedId      String
  system         System          @relation(fields: [systemId], references: [id])
  systemId       String
  status         STATUS          @default(ENABLED)
  createdAt      DateTime        @default(now())
  updatedAt      DateTime        @default(now()) @updatedAt
  rolePrivileges RolePrivilege[]
  userRoles      UserRole[]

  @@index([name])
}

// 角色 <-> 权限
model RolePrivilege {
  role        Role      @relation(fields: [roleId], references: [id])
  roleId      String
  privilege   Privilege @relation(fields: [privilegeId], references: [id])
  privilegeId String
  createdAt   DateTime  @default(now())

  @@id([roleId, privilegeId])
}

// 用户<->角色
model UserRole {
  user      User     @relation(fields: [userId], references: [id])
  userId    String
  role      Role     @relation(fields: [roleId], references: [id])
  roleId    String
  createdAt DateTime @default(now())

  @@id([userId, roleId])
}

enum STATUS {
  DISABLED
  ENABLED
}

enum RESOURCE_TYPE {
  MENU
  NORMAL
}

enum ACTION {
  MANAGE
  CREATE
  READ
  UPDATE
  DELETE
}

enum PRIVILEGE_STATUS {
  DENY
  ALLOW
  NOT_SET
}

其中包含以下模块:

  • 用户模块
  • 系统模块
  • 资源模块
  • 权限模块
  • 角色模块
  • 角色权限多对多
  • 用户角色多对多

为啥会将角色权限和用户角色拆成单独的表呢,因为可以可以更加灵活管理,且包含createdAt可以更好追踪数据的变更,本用户服务基于RBAC模型设计

image.png

这里就不过多阐述了有兴趣可以直接查看小册的设计篇juejin.cn/book/691897…

现在我们更新了prisma配置,需要依次运行如下命令:

npx prisma migrate reset
npx prisma migrate dev

运行npx prisma studio可以查看新的配置是否应用到数据库

登录注册

先定义登录注册的数据校验吧

新建schemas/auth.ts

import { z } from 'zod';

export const signUpSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
  password: z.string().min(6),
});

export type signUpSchema = z.infer<typeof signUpSchema>;

export const signInSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

export type signInSchema = z.infer<typeof signInSchema>;

主要就是利用zod定义schema

然后我们就可以开始数据库相关的逻辑了

新建helpers/dbRepo/auth.ts

import bcrypt from 'bcryptjs';
import { createAccessToken } from '@/helpers/auth';
import prisma from '@/helpers/prisma';
import type { signUpSchema, signInSchema } from '@/schemas/auth';

async function signUp(data: signUpSchema) {
  const { email, name, password } = data;
  if (
    await prisma.user.findUnique({
      where: {
        email,
      },
    })
  ) {
    const userExistsError = new Error('email ' + email + '账户已存在');
    userExistsError.name = 'UserExistsError';
    throw userExistsError;
  }

  const hashPassword = await bcrypt.hash(password, 12);
  const newUser = { name, email, password: hashPassword };
  const result = await prisma.user.create({
    data: newUser,
  });

  const token = await createAccessToken({ id: result.id });
  return {
    user: {
      name: result.name,
      email: result.email,
    },
    token,
  };
}

async function signIn(data: signInSchema) {
  const { email, password } = data;
  const user = await prisma.user.findUnique({
    where: {
      email,
    },
  });

  if (!user) {
    throw new Error('用户不存在');
  }

  const isMatch = await bcrypt.compare(password, user.password);

  if (!isMatch) {
    throw new Error('邮箱或密码错误');
  }

  const token = await createAccessToken({ id: user.id });
  return {
    user: {
      name: user.name,
      email: user.email,
    },
    token,
  };
}

const authRepo = {
  signUp,
  signIn,
};
export default authRepo;

这里需要安装一下bcrypt依赖

pnpm i bcryptjs
pnpm i @types/bcryptjs -D

分别是:

  • 注册:注册的时候查看是否是已有用户,将password进行加密,然后注册用户,并且生成token返回
  • 登录:根据email查询用户,匹配密码,最后返回数据

接口

新建app/api/signup/route.ts

import authRepo from '@/helpers/dbRepo/auth';
import { apiHandler, transformInterceptor } from '@/helpers';
import type { NextRequest } from 'next/server';
import { signUpSchema } from '@/schemas/auth';

const signUp = apiHandler(
  async (req: NextRequest) => {
    const body = await req.json();
    const result = await authRepo.signUp(body);

    return transformInterceptor({
      data: result,
    });
  },
  {
    schema: signUpSchema,
  },
);

export const POST = signUp;

新建app/api/signin/route.ts

import { apiHandler, transformInterceptor } from '@/helpers';
import authRepo from '@/helpers/dbRepo/auth';
import { signInSchema } from '@/schemas/auth';
import type { NextRequest } from 'next/server';

const signIn = apiHandler(
  async (req: NextRequest) => {
    const body = await req.json();
    const result = await authRepo.signIn(body);

    return transformInterceptor({
      data: result,
    });
  },
  {
    schema: signInSchema,
  },
);

export const POST = signIn;

简单的逻辑,想必不用过多解释了

验证

image.png

image.png

image.png

仓库代码:github.com/liyunfu1998…