动态网站新后端框架

2026-10-31 13:423 次阅读
动态网站新后端框架

项目总览:Nuxt 3 + NestJS + Prisma 前后端分离内容平台

1. 项目定位与技术选型

本项目是一个以内容呈现为核心、带完整用户与权限体系的前后端分离 Web 应用,涵盖博客、新闻、视频、动态导航、轮播图五大内容功能域,以及用户管理(认证、角色权限、个人资料、头像) 这一基础能力域。前端负责 SSR/SSG 渲染与用户交互,后端提供纯 REST API 与数据持久化。

技术栈

层级 技术 版本基线 角色
前端 Nuxt 3 ^3.14+ SSR/SSG、页面路由、API 代理、状态管理
后端 NestJS ^10.x REST API、模块化业务逻辑、认证授权
ORM Prisma ^6.x / ^7.x 类型安全的数据访问与迁移
数据库 PostgreSQL ^16 主数据存储
语言 TypeScript(全栈 strict) — 前后端统一语言,共享类型契约
对象存储 S3 兼容(MinIO / R2 / S3) — 头像与媒体文件直传

1.1 为什么选 Prisma 而不是 TypeORM

这是一个需要明确记录的决策。TypeORM 在 NestJS 生态中历史更久,不少企业级模板默认使用它;但 Prisma 在查询边界的类型安全上明显更深一层:select、include、where 的返回形状由 schema 编译生成,字段重命名会在编译期报错,而不是等到运行时才暴露。对于本项目这种内容类型多、关联关系频繁(文章 ↔ 分类 ↔ 标签、用户 ↔ 角色 ↔ 权限、视频 ↔ 作者)的场景,Prisma 生成的精确类型能显著降低 DTO 与数据库之间的漂移风险。Prisma 的迁移文件也是自动生成的,prisma migrate dev 会基于 schema 差异产出 SQL,对独立开发或小团队更为顺手。

代价也需要提前知晓:Prisma 的嵌套过滤写法比 TypeORM 的 QueryBuilder 更啰嗦,prisma generate 在活跃开发中会增加一步操作感,极端高吞吐场景下 WASM 引擎仍略慢于调优后的查询构建器。对本项目的预期流量而言,这些代价可以接受。如果后续出现极复杂的报表查询,可以在 NestJS 中通过 $queryRaw 局部绕过,不必整体切换 ORM。

1.2 为什么选独立 NestJS 后端而非 Nitro 全栈

Nuxt 3 的 Nitro 足以承载 MVP 级后端,但本项目预期包含角色权限、内容审核、对象存储直传、多模块业务规则,且未来可能扩展为多端消费(Web + 小程序 + 移动端)。将后端独立为 NestJS 服务,可以获得更强的架构约束、更清晰的模块边界和更成熟的 DI 生态;前后端通过 REST 通信,天然支持横向扩展与独立部署。代价是需要维护两个进程与一套 API 契约,这部分复杂度由 Prisma 的类型导出与轻量类型共享机制来缓解。


2. 项目结构

采用扁平双应用结构,而不是 Nx monorepo。Nx 适合多应用、多团队共享库的大型工程,但会引入生成器、项目图和缓存层的心智负担。对于功能域明确、前后端各一的中型项目,两个独立目录配合同步的类型文件已经足够。

project-root/
├── frontend/                      # Nuxt 3 应用
│   ├── components/
│   │   ├── content/               # 文章卡片、视频卡片、轮播图等展示组件
│   │   ├── navigation/            # 动态导航渲染
│   │   ├── account/               # 个人资料、头像上传、密码修改
│   │   └── ui/
│   ├── composables/
│   │   ├── useApi.ts
│   │   ├── useContent.ts
│   │   └── useAuth.ts             # 登录态、角色、权限、can()
│   ├── layouts/
│   ├── middleware/
│   │   ├── auth.ts                # 登录校验
│   │   └── admin.ts               # ADMIN 角色校验
│   ├── pages/
│   │   ├── index.vue              # 首页(轮播图 + 最新内容聚合)
│   │   ├── blog/
│   │   ├── news/
│   │   ├── videos/
│   │   ├── login.vue
│   │   ├── account/
│   │   │   ├── profile.vue        # 个人资料与头像
│   │   │   └── security.vue       # 修改密码、会话管理
│   │   └── admin/                 # 内容与用户管理(受保护)
│   ├── server/
│   │   └── api/
│   │       └── [...path].ts       # catch-all 代理到 NestJS
│   ├── nuxt.config.ts
│   └── .env
│
├── backend/                       # NestJS 应用
│   ├── src/
│   │   ├── common/                # 全局拦截器、过滤器、分页 DTO、装饰器
│   │   ├── prisma/                # PrismaModule + PrismaService(@Global)
│   │   ├── auth/                  # 登录、JWT、刷新令牌
│   │   ├── users/                 # 用户、个人资料、头像、角色管理
│   │   ├── content/               # 内容核心域
│   │   │   ├── posts/             # 博客 + 新闻(统一 Post 模型)
│   │   │   ├── videos/
│   │   │   ├── categories/
│   │   │   └── tags/
│   │   ├── banners/               # 轮播图
│   │   ├── navigation/            # 动态导航菜单
│   │   └── app.module.ts
│   ├── prisma/
│   │   ├── schema.prisma
│   │   └── seed.ts                # 内置角色与权限种子
│   ├── .env
│   └── package.json
│
└── README.md

共享类型的处理:优先在 frontend/types/ 中通过 import type 引用后端导出的 DTO 类型定义。如果前后端不在同一 TypeScript 项目引用范围内,采用“后端导出 .d.ts → 前端复制为契约文件”的轻量方式,避免为此搭建完整的 monorepo 共享包体系。


3. 后端模块划分(NestJS)

NestJS 的核心优势在于按领域分 Module,每个模块自带 controller / service / DTO,边界清晰。模块划分如下。

3.1 Prisma 全局模块

PrismaService 继承 PrismaClient,实现 OnModuleInit 与 OnModuleDestroy,在应用启动时 $connect(),关闭时 $disconnect()。使用 @Global() 装饰 PrismaModule,使所有业务模块无需重复 import 即可注入 PrismaService。

backend/src/prisma/
├── prisma.module.ts      # @Global()
└── prisma.service.ts     # extends PrismaClient, implements OnModuleInit/OnModuleDestroy

3.2 AuthModule —— 认证

负责注册、登录、刷新令牌与登出。登录成功后签发 Access Token(短期,如 15 分钟)与 Refresh Token(长期,如 7 天),Refresh Token 的哈希存入 Session 表,支持令牌轮换与服务端主动吊销。密码使用 argon2 或 bcrypt 哈希,绝不存储明文。

JwtAuthGuard 注册为全局守卫,默认所有路由都需要登录,再用 @Public() 装饰器标记登录、注册等公开端点。

3.3 UsersModule —— 用户、资料、头像、角色

提供两类端点,权限要求截然不同:

个人资料端点(登录用户本人可操作)

方法 路径 说明
GET /users/me 获取当前登录用户的完整资料(含 profile、roles、permissions)
PATCH /users/me 更新 displayName、bio、website、timezone
POST /users/me/avatar/presign 获取头像上传的预签名 URL
PATCH /users/me/avatar 确认头像已上传,更新 avatarUrl 与 avatarKey
DELETE /users/me/avatar 移除头像,回退到默认占位图
PATCH /users/me/password 修改密码(需验证旧密码)

管理端点(仅 ADMIN 可访问)

方法 路径 说明
GET /admin/users 分页用户列表,支持按邮箱、状态、角色筛选
PATCH /admin/users/:id/status 启用 / 禁用用户
PUT /admin/users/:id/roles 全量替换用户的角色集合
GET /admin/roles 角色列表(含每个角色的权限)
PATCH /admin/roles/:id/permissions 调整角色的权限组合

3.4 内容核心模块:PostsModule

博客与新闻共用一个 Post 模型,通过 type 字段区分("blog" 或 "news")。两者在数据形态上高度重叠(标题、slug、正文、摘要、封面、作者、发布时间、状态),分表会带来大量重复的 controller 和 service;统一模型配合类型过滤,在 NestJS 中可以很自然地实现“同一个模块,两套查询入口”。

PostsController 提供:

  • GET /posts?type=blog&page=1&limit=10&category=...&tag=...&status=published
  • GET /posts/:slug
  • POST /posts(拥有 post:create 权限的角色)
  • PATCH /posts/:id(拥有 post:update 权限的角色)
  • DELETE /posts/:id(拥有 post:delete 权限的角色,软删除)

分页通过通用的 PaginationQueryDto(page、limit)配合 Prisma 的 skip / take 实现,返回结构统一为 { data: [], meta: { total, page, limit, totalPages } }。

作者边界:AUTHOR 角色在写操作时,service 层通过 authorId 过滤,确保其仅能修改/删除自己创建的文章。这一约束可在后续引入 CASL 时升级为行级权限规则。

3.5 VideosModule

视频是独立模型,不复用 Post。原因在于视频的元数据维度不同:需要 duration、coverUrl、videoUrl、provider(自托管 / 外链)、viewCount 等字段,且未来可能接入 HLS 分片播放。将视频与文章硬合并到一个模型中会导致大量字段为 null,得不偿失。

视频播放本身不在 NestJS 中做流媒体转码或切片;后端只负责元数据 CRUD 与文件上传对接,前端通过 HLS 播放器消费视频地址。

3.6 BannersModule —— 轮播图

轻量但独立的模块,包含:

  • imageUrl、title、subtitle、linkUrl、sortOrder、isActive、startAt、endAt

对外提供 GET /banners/active,只返回当前在生效时间内且 isActive = true 的轮播项,按 sortOrder 升序。轮播图不直接关联到具体的 Post 或 Video,而是通过 linkUrl 自由指向任意前端路由或外部链接,保持模块的低耦合。

3.7 NavigationModule —— 动态导航

动态导航的复杂度取决于项目是否需要基于角色的菜单可见性。当前采用轻量模型:Menu { id, label, path, icon, sortOrder, parentId, isExternal, isActive },返回树形结构供前端渲染。

如果后续需要管理后台的动态路由与权限菜单,则扩展为 User ↔ Role ↔ Menu 多对多关联,后端根据当前用户角色返回过滤后的菜单树。初期先实现无权限的前台导航,将 RBAC 菜单作为后续迭代内容。


4. 数据模型(Prisma Schema 核心片段)

以下为 schema.prisma 中与核心功能直接相关的模型设计。

// ═══════════════════════════════════════════════
// 用户与权限
// ═══════════════════════════════════════════════

model User {
  id           String    @id @default(cuid())
  email        String    @unique
  passwordHash String
  isActive     Boolean   @default(true)
  lastLoginAt  DateTime?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt

  profile      UserProfile?
  userRoles    UserRole[]
  posts        Post[]
  sessions     Session[]

  @@index([email])
}

model UserProfile {
  id          String   @id @default(cuid())
  userId      String   @unique
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  displayName String
  bio         String?
  avatarUrl   String?
  avatarKey   String?
  website     String?
  timezone    String?  @default("Asia/Shanghai")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model Role {
  id          String           @id @default(cuid())
  name        String           @unique         // ADMIN / EDITOR / AUTHOR / MEMBER
  description String?
  isSystem    Boolean          @default(false) // 系统内置角色,不允许删除
  userRoles   UserRole[]
  permissions RolePermission[]

  @@index([name])
}

model Permission {
  id          String           @id @default(cuid())
  resource    String                           // post / video / banner / menu / user
  action      String                           // create / read / update / delete / manage
  description String?
  rolePerms   RolePermission[]

  @@unique([resource, action])
}

model UserRole {
  userId String
  roleId String
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
  role   Role @relation(fields: [roleId], references: [id], onDelete: Cascade)

  @@id([userId, roleId])
  @@index([roleId])
}

model RolePermission {
  roleId       String
  permissionId String
  role         Role       @relation(fields: [roleId], references: [id], onDelete: Cascade)
  permission   Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)

  @@id([roleId, permissionId])
  @@index([permissionId])
}

model Session {
  id               String    @id @default(cuid())
  userId           String
  user             User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  refreshTokenHash String                    // 存储刷新令牌的哈希,不存明文
  userAgent        String?
  ip               String?
  expiresAt        DateTime
  revokedAt        DateTime?
  createdAt        DateTime  @default(now())

  @@index([userId, expiresAt])
}

// ═══════════════════════════════════════════════
// 内容核心:博客 + 新闻
// ═══════════════════════════════════════════════

model Post {
  id          String     @id @default(cuid())
  type        PostType   @default(BLOG)          // BLOG | NEWS
  title       String
  slug        String     @unique
  excerpt     String?
  body        String                              // Markdown
  coverUrl    String?
  status      PostStatus @default(DRAFT)         // DRAFT | PUBLISHED | ARCHIVED
  isFeatured  Boolean    @default(false)
  readTime    Int?
  publishedAt DateTime?
  deletedAt   DateTime?                           // 软删除
  authorId    String
  author      User       @relation(fields: [authorId], references: [id])
  categoryId  String?
  category    Category?  @relation(fields: [categoryId], references: [id])
  tags        PostTag[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([type, status, publishedAt])
  @@index([slug])
  @@index([deletedAt])
}

enum PostType   { BLOG NEWS }
enum PostStatus { DRAFT PUBLISHED ARCHIVED }

// ═══════════════════════════════════════════════
// 分类与标签
// ═══════════════════════════════════════════════

model Category {
  id    String @id @default(cuid())
  name  String @unique
  slug  String @unique
  posts Post[]
}

model Tag {
  id    String    @id @default(cuid())
  name  String    @unique @db.VarChar(50)        // 存储小写
  slug  String    @unique
  posts PostTag[]
}

model PostTag {
  postId String
  tagId  String
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag    Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@index([tagId])
}

// ═══════════════════════════════════════════════
// 视频
// ═══════════════════════════════════════════════

model Video {
  id          String        @id @default(cuid())
  title       String
  slug        String        @unique
  description String?
  coverUrl    String?
  videoUrl    String                              // 播放地址(MP4 / HLS master)
  provider    VideoProvider @default(SELF_HOSTED)
  duration    Int?                                // 秒
  viewCount   Int           @default(0)
  status      PostStatus    @default(DRAFT)
  publishedAt DateTime?
  deletedAt   DateTime?
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt

  @@index([status, publishedAt])
}

enum VideoProvider { SELF_HOSTED EXTERNAL HLS }

// ═══════════════════════════════════════════════
// 轮播图
// ═══════════════════════════════════════════════

model Banner {
  id        String    @id @default(cuid())
  imageUrl  String
  title     String?
  subtitle  String?
  linkUrl   String?
  sortOrder Int       @default(0)
  isActive  Boolean   @default(true)
  startAt   DateTime?
  endAt     DateTime?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  @@index([isActive, sortOrder])
}

// ═══════════════════════════════════════════════
// 动态导航(前台轻量版)
// ═══════════════════════════════════════════════

model Menu {
  id         String  @id @default(cuid())
  label      String
  path       String
  icon       String?
  sortOrder  Int     @default(0)
  parentId   String?
  parent     Menu?   @relation("MenuChildren", fields: [parentId], references: [id])
  children   Menu[]  @relation("MenuChildren")
  isExternal Boolean @default(false)
  isActive   Boolean @default(true)

  @@index([parentId, sortOrder])
}

设计取舍说明

  • 用户资料拆分:将 displayName、bio、avatarUrl 等公开资料从 User 拆分到 UserProfile。User 只承载认证与授权相关的敏感字段(passwordHash、isActive),未来对外提供“用户公开主页”或“作者信息”接口时,可以直接查询 UserProfile 而不必在 select 中小心排除密码字段,降低敏感字段泄露风险。代价是个人资料读取需要多一次 include,在 Prisma 中只是一行配置。
  • 软删除:Post 与 Video 的 deletedAt 字段用于软删除。查询时在 service 层默认追加 deletedAt: null 条件,或在 Prisma Client Extension 中统一拦截。项目初期先通过 service 层约定实现,保持依赖精简,不引入第三方软删除扩展。
  • 权限二元组:Permission 以 (resource, action) 唯一约束,action 使用 create / read / update / delete / manage 五种。manage 作为通配动作覆盖该资源所有具体动作,简化 ADMIN 的规则定义。

5. 用户管理与权限体系详解

用户管理在本项目中承担两个职责:前台用户的个人身份(资料、头像、偏好)与后台内容管理的操作权限(谁可以发布文章、删除视频、管理轮播图)。两者共用同一套 User 实体,但权限边界必须清晰——前台注册用户默认没有任何后台内容管理权限,后台能力通过角色显式授予。

5.1 认证与授权的分层

认证(Authentication)回答“你是谁”,授权(Authorization)回答“你能做什么”。本项目采用 JWT 认证 + RBAC 授权 的分层结构:

  • 认证层:AuthModule 负责登录、JWT 签发与校验。JwtAuthGuard 解析请求中的 JWT,将用户信息挂载到 request.user。这一层不关心角色,只确认身份有效。
  • 粗粒度授权层:RolesGuard 配合 @Roles() 装饰器,在 controller 或路由方法级别检查用户是否拥有所需角色。适合“整个模块仅 Admin 可访问”这类简单场景,例如 BannersController 的写操作。
  • 细粒度授权层:当权限需求超出“角色存在性检查”(例如“编辑只能改自己创建的文章”“作者不能删除已发布内容”),引入 CASL + Prisma,将权限规则定义为 Prisma WhereInput,实现行级和字段级的精细控制。

推荐先落地 RolesGuard,再按需引入 CASL。过早引入 CASL 会增加 Ability 定义与调试成本;一旦出现“同一角色内部权限因资源归属不同而分化”的需求,CASL 几乎是 NestJS 生态中的标准答案。

守卫绑定方式:JwtAuthGuard 注册为全局守卫,默认所有路由都需要登录,再用 @Public() 装饰器标记登录、注册等公开端点。RolesGuard 同样全局注册,但在没有 @Roles() 元数据时直接放行,只有声明了角色要求的路由才会实际校验。这种“默认拒绝 + 按需声明”的模式比在每个 controller 上手动挂守卫更不容易遗漏。

5.2 内置角色与权限种子数据

项目初始化时通过 Prisma seed 写入以下系统角色和权限组合。角色名称保持英文枚举风格,便于代码中引用。

角色 权限范围 适用人员
ADMIN 全部资源的 manage(含用户管理、角色分配) 平台管理员
EDITOR post / video / banner / menu 的 create、read、update、delete 内容运营
AUTHOR post 的 create、read、update(仅自己的文章);video 的 read 投稿作者
MEMBER 仅前台个人资料读写,无任何内容管理端点访问权 普通注册用户

5.3 头像上传:预签名 URL 直传

头像文件不经过 NestJS 服务端内存。流程如下:

  1. 前端调用 POST /users/me/avatar/presign,后端校验用户身份,生成对象存储(S3 / R2 / MinIO 兼容)的预签名上传 URL,并返回该 URL 与最终的 objectKey。
  2. 前端直接将图片文件 PUT 到预签名 URL,完全绕过 NestJS 服务器。这是文件上传能够水平扩展的关键模式,避免了 Multer 内存存储在大文件或高并发下的内存压力。
  3. 上传成功后,前端调用 PATCH /users/me/avatar,将 objectKey 提交给后端。后端验证对象确实存在(通过存储服务的 HeadObject),更新 UserProfile.avatarKey 与 avatarUrl,并删除旧头像对象(如果存在且不同于新 key)。

安全要点:预签名 URL 的过期时间建议设为 60–120 秒;后端在生成预签名时限定 Content-Type 为 image/jpeg、image/png、image/webp,并限制文件大小(如 2 MB)。不要信任客户端提交的文件扩展名或 Content-Type。如果图片需要进一步处理(压缩、裁剪),应在对象存储的 onUpload 事件或队列 worker 中异步完成,而不是在 HTTP 请求周期内同步处理。

如果项目初期不想引入对象存储依赖,可以暂时使用 NestJS 的 FileInterceptor + 本地磁盘存储作为过渡,但需要将头像上传的 service 接口设计为可替换的抽象(如 AvatarStorageService 接口,本地实现与 S3 实现分别注入),避免未来迁移时牵动 controller 层。

5.4 前端(Nuxt 3)的权限对接

用户状态管理:使用 useAuth() composable 封装登录状态、当前用户、角色与权限集合。用户信息在 SSR 期间通过 useFetch 配合 Cookie 中的 JWT 获取,确保首屏渲染时权限状态已经就绪,避免“先渲染再重定向”的闪烁。建议暴露以下派生状态:

// composables/useAuth.ts 的核心接口
const { user, roles, permissions, isLoggedIn } = useAuth()
// isLoggedIn: 是否有有效会话
// roles: string[],如 ['EDITOR']
// permissions: string[],如 ['post:create', 'post:update']
// can(perm: string): boolean

路由中间件:使用命名中间件,而不是在组件内做权限判断。auth 中间件检查登录状态,未登录时重定向到登录页并携带 redirect 参数;admin 中间件在 auth 之后运行,检查用户是否拥有 ADMIN 角色,不满足时调用 abortNavigation() 而非重定向,避免将用户推向另一个同样会被拒绝的页面。

// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
  const { user } = useAuth()
  if (!user.value) return navigateTo('/login')
  if (!user.value.roles?.includes('ADMIN')) return abortNavigation()
})

在页面中使用时按顺序声明:definePageMeta({ middleware: ['auth', 'admin'] })。中间件数组保证 auth 先建立用户上下文,admin 再进行角色判断。

按钮/元素的权限控制:对于页面内“是否显示删除按钮”“是否允许编辑”这类细粒度 UI 控制,使用 v-if="can('post:delete')" 配合 useAuth() 中的 can 方法。需要明确的是,前端的权限判断只影响用户体验,不构成安全边界。所有真正的权限校验必须在 NestJS 端完成;前端隐藏按钮只是为了避免用户点击后才收到 403 的挫败感。

个人资料页面路由:/account/profile(查看与编辑资料、更换头像)、/account/security(修改密码、查看登录会话)。这两个页面挂载 auth 中间件即可,无需角色限制。头像上传组件在获取预签名 URL 后直接 fetch(presignedUrl, { method: 'PUT', body: file }),然后在确认接口成功后刷新 useAuth() 中的用户信息。

JWT 存储:Access Token 推荐存储在 Cookie 中(而非 localStorage),因为 Cookie 在 SSR 期间可被 useCookie 读取,且 Nuxt 的 useCookie 是 SSR-safe 的。NestJS 端签发 JWT 后通过 Set-Cookie 返回,前端在 useFetch / $fetch 中依赖浏览器自动携带,无需在每个请求中手动拼接 Authorization 头。如果安全要求更高,可改用 httpOnly Cookie 并由 Nuxt 服务端路由代理所有 API 请求,但会牺牲部分客户端直接调用的便利性。


6. 前后端对接的关键配置

6.1 Nuxt 3 侧的环境变量与 runtimeConfig

后端地址不能硬编码。在 nuxt.config.ts 中使用 runtimeConfig 管理 API 基地址,并区分服务端与客户端可见性:

// frontend/nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '',                    // 仅服务端可见
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3001/api',
    },
  },
})

Nuxt 3 的 runtimeConfig 是推荐的运行时配置方式,构建后可通过环境变量覆盖,无需重新构建。对应的 .env:

NUXT_PUBLIC_API_BASE=http://localhost:3001/api

6.2 API 调用的代理策略

前后端分离下,Nuxt 端调用 NestJS API 有三种可选路径,建议开发环境用 Nitro 代理,生产环境用同域反向代理(Nginx / Caddy)。

开发环境下,nitro.devProxy 在涉及 SSR 的相对路径调用时存在已知局限:Nitro 会将 /api/* 识别为内部调用并尝试模拟,而不是真正转发到外部后端。更稳妥的开发代理方案是在 server/api/[...path].ts 中使用 proxyRequest 做 catch-all 转发:

// frontend/server/api/[...path].ts
export default defineEventHandler((event) => {
  const config = useRuntimeConfig()
  const target = `${config.public.apiBase}/**`
  return proxyRequest(event, target.replace('/**', event.context.params.path))
})

前端所有请求统一走 /api/*,SSR 与客户端行为一致,且 apiBase 可在运行时通过环境变量切换。

6.3 认证的令牌存储

JWT 的 Access Token 推荐存储在 Cookie 中(详见 5.4)。Refresh Token 建议使用 httpOnly Cookie,由 /auth/refresh 端点负责轮换;Session 表存储 Refresh Token 的哈希,支持“主动踢下线”与“查看登录设备”。


7. 功能模块与前端路由对应

功能 后端模块 前端路由 权限要求
博客列表 / 详情 PostsModule(type=blog) /blog、/blog/[slug] 公开
新闻列表 / 详情 PostsModule(type=news) /news、/news/[slug] 公开
视频列表 / 播放 VideosModule /videos、/videos/[slug] 公开
首页轮播 BannersModule /(index.vue) 公开
动态导航 NavigationModule 全局 layout 公开
登录 / 注册 AuthModule /login、/register 公开
个人资料与头像 UsersModule /account/profile 登录用户
修改密码 / 会话 AuthModule /account/security 登录用户
内容管理 各内容模块 Admin 端点 /admin/content/... ADMIN、EDITOR、AUTHOR(按资源)
用户管理 UsersModule(Admin) /admin/users ADMIN
角色与权限管理 UsersModule(Admin) /admin/roles ADMIN

Admin 端点的粗粒度权限要求

端点前缀 最低角色
/posts(写) AUTHOR(仅自己的)/ EDITOR / ADMIN
/videos(写) EDITOR / ADMIN
/banners(写) EDITOR / ADMIN
/menus(写) EDITOR / ADMIN
/admin/users、/admin/roles ADMIN

8. 开发与部署基线

本地启动:后端 pnpm start:dev(默认 3001),前端 pnpm dev(3000)。前端通过上述 catch-all 代理将 /api/* 转发至 http://localhost:3001/api。

数据库迁移流程:修改 schema.prisma → npx prisma migrate dev --name <描述> → npx prisma generate。后端 service 中直接注入 PrismaService,无需额外维护 repository 层。

种子数据:npx prisma db seed 执行 prisma/seed.ts,写入内置角色(ADMIN / EDITOR / AUTHOR / MEMBER)、权限二元组、角色权限映射,以及一个初始管理员账号(凭据从环境变量读取,不硬编码)。

部署:前后端作为两个独立服务部署。生产环境由 Nginx / Caddy 将 /api/* 反向代理到 NestJS 服务,Nuxt 的 SSR 服务处理其余路由。这种部署方式天然规避 CORS,且前端构建产物与后端进程解耦,可以独立扩缩容。

安全基线

  • passwordHash 使用 argon2 或 bcrypt(cost ≥ 12),任何接口响应中不得包含该字段。
  • JWT 签名密钥、对象存储凭证、数据库连接串全部来自环境变量,.env 不提交仓库。
  • 预签名 URL 有效期 60–120 秒,限定 Content-Type 与文件大小。
  • Admin 端点全部挂 JwtAuthGuard + RolesGuard,缺省拒绝。
  • 登录接口做速率限制(@nestjs/throttler),防止暴力破解。

9. 建议的迭代顺序

Phase 1(基础内容骨架):PrismaModule + PostsModule(博客 + 新闻)+ Nuxt 博客/新闻页面。先跑通“后端 CRUD → 前端 SSR 渲染 → 代理链路”的最短闭环。

Phase 2(多媒体与首页):VideosModule + BannersModule + 首页聚合。视频先以 MP4 直链或外链嵌入起步,HLS 适配作为后续优化。

Phase 2.5(用户与权限基础):AuthModule + UsersModule + UserProfile + RolesGuard + 角色/权限种子数据 + 前端 useAuth 与 auth/admin 中间件 + 头像预签名直传。这一步放在 Admin 管理界面之前,确保后续所有后台页面都建立在有效的登录与角色体系之上,而不是先做无保护的 Admin 页面再回头补权限。

Phase 3(动态导航与管理界面):NavigationModule + Admin 内容管理界面(文章、视频、轮播图)+ Admin 用户与角色管理界面。导航先做前台配置版;如果需要多角色后台,再引入 RBAC 菜单权限。

Phase 4(体验与工程化):搜索(Post 的全文索引)、SEO 增强(useSeoMeta、sitemap)、缓存策略(NestJS 端对轮播图和导航做短 TTL 缓存)、CASL 细粒度权限(当“作者只能改自己的文章”等规则复杂到 RolesGuard 难以表达时)。


附录:关键决策速查

决策点 选择 主要理由
后端形态 独立 NestJS 服务 预期多端消费、复杂业务规则、独立扩缩容
ORM Prisma 查询边界类型安全、迁移自动生成、与 TS 深度契合
博客/新闻 统一 Post + type 字段 数据形态高度重叠,避免重复 controller/service
视频 独立 Video 模型 元数据维度不同,避免大量 null 字段
用户资料 拆出 UserProfile 敏感字段与公开字段隔离,降低泄露风险
权限模型 RBAC(Role ↔ Permission 多对多) 覆盖粗粒度场景,为 CASL 行级权限预留扩展点
授权实现 RolesGuard 先行,CASL 按需引入 避免过早引入 Ability 定义成本
头像上传 预签名 URL 直传对象存储 绕过服务端内存,水平扩展友好
前端权限 中间件 + can() 控制 UI UI 控制仅为体验,安全边界在后端
JWT 存储 Cookie(SSR-safe) Nuxt useCookie 原生支持,避免手动拼 Authorization
开发代理 server/api/[...path].ts + proxyRequest 规避 devProxy 在 SSR 相对路径上的局限
部署 Nginx/Caddy 同域反代 /api/* 天然规避 CORS,前后端独立扩缩容
← 返回新闻列表