动态网站新后端框架

项目总览: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=publishedGET /posts/:slugPOST /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 服务端内存。流程如下:
- 前端调用
POST /users/me/avatar/presign,后端校验用户身份,生成对象存储(S3 / R2 / MinIO 兼容)的预签名上传 URL,并返回该 URL 与最终的objectKey。 - 前端直接将图片文件
PUT到预签名 URL,完全绕过 NestJS 服务器。这是文件上传能够水平扩展的关键模式,避免了 Multer 内存存储在大文件或高并发下的内存压力。 - 上传成功后,前端调用
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,前后端独立扩缩容 |