跳转到内容

分层最佳实践

本章以 User(用户管理) 为例,提供完整的 Controller → Service → Prisma 分层开发规范,覆盖 Zod 验证、软删除、审计字段的全链路。

分层职责

职责不做什么
Controller接收参数 + Zod() 验证不处理业务逻辑
Service业务编排 + 数据访问不操作 Prisma 时间戳
Prisma@default(now()) + @updatedAt时间戳由数据库/扩展管理
Extensions软删除 + 审计字段自动填充业务层无需手写

Service 直接操作 Prisma

Service 直接注入 PrismaService,使用 this.prisma.xxx 配合工具函数(prismaPagebuildPrismaQueryprismaSoftRemove 等)完成数据访问。 事务场景使用 @PrismaTransactional() + usePrismaClient(),天然支持跨类传播。

Repository 层(PrismaRepository)为可选层,适合需要统一封装复用查询逻辑的场景。


1. Prisma Schema 定义

prisma
enum Role {
  ADMIN
  USER
  GUEST
}

model User {
  id        String    @id @default(cuid())
  username  String    @unique @db.VarChar(50)
  password  String    @db.VarChar(255)
  phone     String?   @db.VarChar(20)
  nickname  String?   @db.VarChar(50)
  avatar    String?   @db.VarChar(500)
  role      Role      @default(USER)
  status    Int       @default(1) @db.TinyInt    // 0=禁用 1=启用

  // 审计字段(由中间件自动管理)
  createdAt DateTime  @default(now()) @map("created_at")
  createdBy String?   @map("created_by")
  updatedAt DateTime  @updatedAt @map("updated_at")
  updatedBy String?   @map("updated_by")
  deletedAt DateTime? @map("deleted_at")

  @@map("sys_user")
}

注意: createdAtupdatedAt 由 Prisma 自动管理,createdByupdatedBy 由审计中间件填充,deletedAt 由软删除中间件处理。


2. 模块注册(声明式扩展)

AuthModule 内置了基于 nestjs-clsClsModule)的请求上下文管理。AuthModule.register() 自动注册 ClsModule.forRoot({ global: true, middleware: { mount: true } }),在请求进入时由 ClsMiddleware 创建 CLS 上下文,RequestContextInterceptor 在 Guard 之后将 request.user 写入 CLS 存储。业务代码通过 getCurrentUserId() / getCurrentUser() 即可获取当前用户,无需手动注册 ClsModule

typescript
// app.module.ts
import { Module } from '@nestjs/common'
import { PrismaModule, AuthModule, getCurrentUserId } from '@maxtan/nest-core'
import { PrismaService } from './prisma/prisma.service'

@Module({
  imports: [
    // AuthModule 内置 ClsModule(nestjs-cls),自动创建请求 CLS 上下文
    // ClsMiddleware 创建上下文 → Guard 解析 JWT → 拦截器将 request.user 写入 CLS
    AuthModule.register({ secret: process.env.JWT_SECRET! }),

    PrismaModule.forRoot({
      service: PrismaService,
      middlewares: {
        softDelete: {
          enabled: true,
          models: 'auto'       // 自动发现含 deletedAt 的模型
        },
        audit: {
          enabled: true,
          getUserId: getCurrentUserId,  // 直接从 core 导入,零配置
          fallbackUserId: 'system'      // 匿名/定时任务回退值
        }
      }
    })
  ]
})
export class AppModule {}

执行时序:

请求 → ClsMiddleware (CLS 上下文创建) → AuthGuard (JWT 验证)

                                        RequestContextInterceptor
                                        (自动将 request.user 写入 CLS 存储)

                                        Pipes → Controller → Service → Prisma Extensions
                                                                         ├── 审计扩展调用 getCurrentUserId()
                                                                         └── 软删除扩展自动追加条件

为什么用拦截器而不是中间件写入用户? NestJS 中间件在 Guard 之前执行,此时 request.user 尚未被 AuthGuard 填充。CLS 上下文由 ClsMiddleware 创建,但用户数据由全局拦截器在 Guard 之后写入。

非鉴权场景的自动降级:

场景request.usergetCurrentUserId()审计字段
已登录用户AuthPayload'user-id-xxx'填充实际用户 ID
@AuthPublic() 公开路由undefinednull使用 fallbackUserId(如 'system'
定时任务 / 队列消费无请求上下文null使用 fallbackUserId

3. Zod Schema 定义

typescript
// user.schema.ts
import { z } from 'zod'
import {
  zId, zIds, zStr, zStrReq, zStrNull, zVarChar, zVarCharNull,
  zEnumInt, zEnumStr, zQueryEnumInt, zQueryEnumStr, zPageWithKeyword, zDateRange
} from '@maxtan/nest-core'

// ─── 枚举常量(单一来源,Schema + 业务逻辑共享) ───

/** 用户状态:0-禁用 1-启用(对应 @db.TinyInt) */
export enum UserStatus {
  DISABLED = 0,
  ENABLED = 1
}

/** 用户角色值(对应 Prisma enum Role) */
const ROLE_VALUES = ['ADMIN', 'USER', 'GUEST'] as const
const role = zEnumStr(...ROLE_VALUES)

// ─── Schema ───

// ═══ 创建用户 ═══
export const CreateUser = z.object({
  username: zVarChar(50, 2),                    // @db.VarChar(50),最少 2 字符
  password: z.string().min(6).max(255),         // @db.VarChar(255),最少 6 字符
  nickname: zVarCharNull(50),                   // 可空列,空串表示清空 -> null
  phone: zVarCharNull(20),                      // 同上
  avatar: zVarChar(500).optional(),             // 可空列 → .optional()
  intro: zStrNull,                              // Text? / Json 内部普通可空文本
  role: role.optional(),                        // 有默认值 → .optional()
  status: zEnumInt(0, 1).optional()             // TinyInt 枚举,有默认值 → .optional()
})
export type CreateUser = z.infer<typeof CreateUser>

// ═══ 更新用户 ═══
export const UpdateUser = CreateUser.partial().extend({
  id: zId                                       // 更新必须指定用户 ID
})
export type UpdateUser = z.infer<typeof UpdateUser>

// ═══ 列表查询 ═══
export const ListUser = zPageWithKeyword().extend({
  status: zQueryEnumInt(0, 1),
  role: zQueryEnumStr(...ROLE_VALUES)
}).merge(zDateRange())                           // 时间范围
export type ListUser = z.infer<typeof ListUser>

// ═══ 批量删除 ═══
export const DeleteUsers = z.object({ ids: zIds })
export type DeleteUsers = z.infer<typeof DeleteUsers>

原则:

  • 字符串优先 zVarChar,长度对齐 Prisma @db.VarChar(N)
  • 普通可选文本优先 zStr,可空文本优先 zStrNull
  • 整型枚举用 zEnumInt,字符串枚举用 zEnumStr
  • ID 用 zId / zIds
  • 查询过滤优先 zQueryEnumInt / zQueryEnumStr
  • 列表用 zPageWithKeyword().extend(...) + zDateRange()
  • 枚举常量集中声明,Schema + Service 共享

字符串 helper 的实践边界:

  • zStr:空串视为未传,适合 keyword、标题、备注等普通可选字段
  • zStrNull / zVarCharNull:空串视为清空,适合允许 NULL 的数据库字段
  • zStrReq:trim 后必须有值,适合必填文本
  • zStrArr({ min: 1 }):字符串数组逐项 trim,适合标签、ID 列表

查询过滤优先使用 query helper 的原因:

  • 前端下拉框、选择器常把初始值设为 null''
  • 列表查询的真实语义通常是“不传就不过滤”,而不是“空值非法”
  • 把这层语义放进 Schema,可以避免在 Service 里手动写 status || undefined 之类兼容代码
  • 同时还能保留对 true{}ROOT 这类真正非法值的报错能力

4. Service 层(业务编排 + 数据访问)

Service 直接注入 PrismaService,使用工具函数完成数据访问,天然支持事务。

typescript
// user.service.ts
import { Injectable } from '@nestjs/common'
import {
  AppException, MSG, PrismaTransactional,
  prismaPage, prismaKeyword, prismaSoftRemove, prismaExists
} from '@maxtan/nest-core'
import { PrismaService } from '../prisma/prisma.service'
import type {
  CreateUser, UpdateUser,
  ListUser, DeleteUsers
} from './user.schema'

@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  /** 创建用户 */
  async create(dto: CreateUser) {
    // 业务判断:用户名唯一性校验
    const exists = await this.prisma.user.findUnique({
      where: { username: dto.username }
    })
    if (exists) throw new AppException('用户名已存在')

    return this.prisma.user.create({ data: dto })
    // createdBy / updatedBy 由审计中间件自动填充
    // createdAt 由 @default(now()) 自动填充
  }

  /** 更新用户 */
  async update(dto: UpdateUser) {
    const { id, ...data } = dto
    const user = await this.prisma.user.findFirst({ where: { id } })
    if (!user) throw new AppException(MSG.NOT_FOUND_DATA)

    return this.prisma.user.update({ where: { id }, data })
    // updatedBy 由审计中间件自动填充
    // updatedAt 由 @updatedAt 自动填充
  }

  /** 分页查询 */
  async list(dto: ListUser) {
    const { current, size, keyword, status, role } = dto
    return prismaPage(this.prisma.user, {
      where: {
        ...prismaKeyword(keyword, ['username', 'nickname']),
        ...(status !== undefined && { status }),
        ...(role !== undefined && { role }),
      },
      current,
      size,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true, username: true, nickname: true,
        phone: true, avatar: true, role: true,
        status: true, createdAt: true
      }
    })
  }

  /** 详情 */
  async detail(id: string) {
    const record = await this.prisma.user.findFirst({ where: { id } })
    if (!record) throw new AppException(MSG.NOT_FOUND_DATA)
    return record
  }

  /** 批量删除 */
  async remove(dto: DeleteUsers) {
    return prismaSoftRemove(this.prisma.user, dto.ids!)
    // 软删除由 prismaSoftRemove 处理(update deletedAt)
  }

  /** 事务示例:创建用户并初始化配置 */
  @PrismaTransactional()
  async createWithProfile(dto: CreateUser) {
    // this.prisma 在事务内自动返回 tx,无需手动 usePrismaClient
    const user = await this.prisma.user.create({ data: dto })
    await this.prisma.profile.create({ data: { userId: user.id } })
    return user
  }
}

原则:

  • Service 直接注入 PrismaService,使用 this.prisma.xxx 操作数据
  • 分页用 prismaPage(),模糊搜索用 prismaKeyword(),软删除用 prismaSoftRemove()
  • 事务用 @PrismaTransactional() + usePrismaClient()
  • 不手写 createdAt / updatedAt / createdBy / updatedBy
  • 业务判断(如唯一性校验)在 Service 层

5. Controller 层(参数接收 + 验证)

typescript
// user.controller.ts
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common'
import { Zod } from '@maxtan/nest-core'
import { UserService } from './user.service'
import {
  CreateUser, UpdateUser, ListUser, DeleteUsers,
} from './user.schema'

@Controller('user')
export class UserController {
  constructor(private readonly service: UserService) {}

  @Post('create')
  create(@Body(Zod(CreateUser)) dto: CreateUser) {
    return this.service.create(dto)
  }

  @Post('update')
  update(@Body(Zod(UpdateUser)) dto: UpdateUser) {
    return this.service.update(dto)
  }

  @Get('list')
  list(@Query(Zod(ListUser)) dto: ListUser) {
    return this.service.list(dto)
  }

  @Get('detail/:id')
  detail(@Param('id') id: string) {
    return this.service.detail(id)
  }

  @Post('delete')
  remove(@Body(Zod(DeleteUsers)) dto: DeleteUsers) {
    return this.service.remove(dto)
  }
}

原则:

  • 参数级 Zod(schema) 验证(推荐方式)
  • Controller 只做接收 + 转发

6. Repository 层(可选,复用查询逻辑)

简单 CRUD 在 Service 中直接操作 prisma.client 即可。当多个 Service 需要共享相同的查询逻辑(如复杂列表筛选)时,使用 Repository:

typescript
// user.repository.ts
import { Injectable } from '@nestjs/common'
import { PrismaRepository, buildPrismaQuery, prismaKeyword } from '@maxtan/nest-core'
import { PrismaService } from '../prisma/prisma.service'
import type { User } from '@prisma/client'
import type { ListUser } from './user.schema'

@Injectable()
export class UserRepository extends PrismaRepository<User> {
  constructor(private prisma: PrismaService) {
    // 传入 PrismaService 和模型名,Repository 自动感知事务上下文
    super(prisma, 'user')
    // softDelete: false (默认,由中间件处理)
    // defaultOrderBy: { createdAt: 'desc' } (默认)
  }

  /**
   * 复杂列表查询(使用增强版 buildPrismaQuery)
   */
  async findPageByDto(dto: ListUser) {
    const { keyword, current, size, ...rest } = dto

    const query = buildPrismaQuery({
      where: rest,
      containsFields: [],
      inFields: [],
      dateRangeFields: [
        { start: 'startAt', end: 'endAt', target: 'createdAt' }
      ],
      ignoreFields: [],
      defaultWhere: {
        ...prismaKeyword(keyword, ['username', 'nickname'])
      },
      softDelete: false,
      current,
      size,
      sortBy: 'createdAt',
      sortOrder: 'desc',
      select: {
        id: true, username: true, nickname: true,
        phone: true, avatar: true, role: true,
        status: true, createdAt: true
      }
    })

    const [records, total] = await Promise.all([
      this.model.findMany(query),
      this.model.count({ where: query.where })
    ])

    return {
      records,
      total,
      current: current ?? 1,
      size: size ?? 20,
      pages: Math.ceil(total / (size ?? 20))
    }
  }
}

注意:

  • Repository 构造使用 super(prisma, 'user') 传入 PrismaService + 模型名(小写)
  • Repository 内部通过 usePrismaClient 动态获取 model,天然支持 @PrismaTransactional 事务
  • 软删除由中间件统一处理,Repository 的 softDelete 默认为 false

7. Prisma → Zod 辅助工具

在开发过程中,可以使用辅助工具快速生成 Zod schema 建议:

typescript
import { getModelFields, generateZodSuggestions, listPrismaModels } from '@maxtan/nest-core'

// 列出所有模型
const models = listPrismaModels(prisma)
// => ['User', 'Post', ...]

// 获取字段信息
const fields = getModelFields(prisma, 'User')
fields.forEach(f => {
  console.log(`${f.name}: ${f.zodSuggestion}  // ${f.type} ${f.dbType ?? ''}`)
})

// 生成完整建议代码
console.log(generateZodSuggestions(prisma, 'User'))

输出示例:

// ═══ User Zod Schema Suggestions ═══
// export const CreateUser = z.object({
//   username: zVarChar(50),     // String @db.VarChar(50)
//   password: zVarChar(255),    // String @db.VarChar(255)
//   phone: zVarChar(20).optional(),      // String? @db.VarChar(20)
//   nickname: zVarChar(50).optional(),   // String? @db.VarChar(50)
//   avatar: zVarChar(500).optional(),    // String? @db.VarChar(500)
//   role: zEnumStr(/* Role */).optional(),       // Role @default(USER)
//   status: zEnumInt(/* values */).optional(),   // Int @db.TinyInt
// })

完整数据流总结

请求 → ClsMiddleware (CLS 上下文创建)

       AuthGuard (JWT 验证) → RequestContextInterceptor (user → CLS)

       Controller (Zod 验证)

       Service (业务编排 + 数据访问)
         ├── prisma.client.xxx (直接操作,自动感知事务上下文)
         ├── prismaPage / prismaKeyword / buildPrismaQuery (工具函数)
         ├── prismaSoftRemove / prismaExists (CRUD 工具)
         └── @PrismaTransactional (声明式事务,自动跨类传播)

       Prisma Extensions
         ├── 软删除扩展:查询自动追加 deletedAt: null
         ├── 审计扩展:getCurrentUserId() 从 CLS 读取 → 填充 createdBy / updatedBy
         └── Prisma Engine
               ├── createdAt: @default(now())
               └── updatedAt: @updatedAt

关键原则:

  1. Zod 约束对齐 Prisma@db.VarChar(50)zVarChar(50)
  2. 时间戳零手写:全部由 Prisma + 扩展管理
  3. Service 直接操作:不需要 DAO 层,直接用 this.prisma + 工具函数
  4. 事务天然集成this.prisma 自动感知事务上下文,@PrismaTransactional 跨类自动传播
  5. 扩展声明式注册PrismaModule.forRoot({ middlewares: {...} })
  6. 软删除自动发现models: 'auto' 从 DMMF 读取
  7. 敏感字段保护:列表查询通过 select 排除 password 等字段
  8. Repository 可选:需要跨 Service 复用查询逻辑时才引入 PrismaRepository
  9. 请求上下文:基于 nestjs-cls(CLS),getCurrentUserId() 在任意异步代码中可用

相关文档