[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"menus-public":3,"blog-10057":83,"blog-10057-comments":92},[4,15,43,66],{"id":5,"title":6,"link":7,"icon":8,"target":9,"sort_order":10,"is_active":11,"parent_id":12,"created_at":13,"children":14},10001,"首页","\u002F","","_blank",0,true,null,"2026-04-04T12:03:45",[],{"id":16,"title":17,"link":18,"icon":8,"target":19,"sort_order":20,"is_active":11,"parent_id":12,"created_at":21,"children":22},10038,"墨言博客","\u002Fblog","_self",1,"2026-10-30T23:08:14",[23,29,36],{"id":24,"title":25,"link":26,"icon":8,"target":19,"sort_order":20,"is_active":11,"parent_id":16,"created_at":27,"children":28},10039,"技术前沿","\u002Fblog?category=10001","2026-10-30T23:10:46",[],{"id":30,"title":31,"link":32,"icon":8,"target":19,"sort_order":33,"is_active":11,"parent_id":16,"created_at":34,"children":35},10042,"生活随笔","\u002Fblog?category=20007",2,"2026-10-30T23:55:19",[],{"id":37,"title":38,"link":39,"icon":8,"target":19,"sort_order":40,"is_active":11,"parent_id":16,"created_at":41,"children":42},10043,"旅游日记","\u002Fblog?category=20006",3,"2026-10-31T00:00:11",[],{"id":33,"title":44,"link":45,"icon":8,"target":19,"sort_order":33,"is_active":11,"parent_id":12,"created_at":46,"children":47},"新闻中心","\u002Fnews","2026-10-24T19:09:40",[48,54,60],{"id":49,"title":50,"link":51,"icon":8,"target":19,"sort_order":20,"is_active":11,"parent_id":33,"created_at":52,"children":53},10046,"产品动态","\u002Fnews?category=20010","2026-10-31T00:17:38",[],{"id":55,"title":56,"link":57,"icon":8,"target":19,"sort_order":33,"is_active":11,"parent_id":33,"created_at":58,"children":59},10047,"新闻热点","\u002Fnews?category=20009","2026-10-31T00:17:55",[],{"id":61,"title":62,"link":63,"icon":8,"target":19,"sort_order":40,"is_active":11,"parent_id":33,"created_at":64,"children":65},10045,"公司动态","\u002Fnews?category=20008","2026-10-31T00:17:11",[],{"id":67,"title":68,"link":69,"icon":8,"target":19,"sort_order":67,"is_active":11,"parent_id":12,"created_at":46,"children":70},4,"精彩视频","\u002Fvideo",[71,77],{"id":72,"title":73,"link":74,"icon":8,"target":19,"sort_order":10,"is_active":11,"parent_id":67,"created_at":75,"children":76},13,"活动记录","\u002Fvideo?category=6","2026-10-25T21:04:39",[],{"id":78,"title":79,"link":80,"icon":8,"target":19,"sort_order":20,"is_active":11,"parent_id":67,"created_at":81,"children":82},11,"产品展示","\u002Fvideo?category=5","2026-10-25T21:02:59",[],{"id":84,"category_id":5,"title":85,"author":8,"summary":86,"cover":87,"content":88,"tags":8,"seo_title":8,"seo_description":8,"seo_keywords":8,"og_image":8,"views":89,"comment_count":10,"is_top":90,"is_published":11,"created_at":91},10057,"动态网站前后端布暑","本项目是一个以内容呈现为核心、带完整用户与权限体系的前后端分离 Web 应用，涵盖博客、新闻、视频、动态导航、轮播图五大内容功能域，以及用户管理（认证、角色权限、个人资料、头像） 这一基础能力域。前端负责 SSR\u002FSSG 渲染与用户交互，后端提供纯 REST API 与数据持久化。","\u002Fstatic\u002Fuploads\u002Fblogs\u002F1793240130667-92277984.jpg","# 项目总览：Nuxt 3 + NestJS + Prisma 前后端分离内容平台\n\n## 1. 项目定位与技术选型\n\n本项目是一个以内容呈现为核心、带完整用户与权限体系的前后端分离 Web 应用，涵盖**博客、新闻、视频、动态导航、轮播图**五大内容功能域，以及**用户管理（认证、角色权限、个人资料、头像）** 这一基础能力域。前端负责 SSR\u002FSSG 渲染与用户交互，后端提供纯 REST API 与数据持久化。\n\n**技术栈**\n\n| 层级 | 技术 | 版本基线 | 角色 |\n|:---|:---|:---|:---|\n| 前端 | Nuxt 3 | ^3.14+ | SSR\u002FSSG、页面路由、API 代理、状态管理 |\n| 后端 | NestJS | ^10.x | REST API、模块化业务逻辑、认证授权 |\n| ORM | **Prisma** | ^6.x \u002F ^7.x | 类型安全的数据访问与迁移 |\n| 数据库 | PostgreSQL | ^16 | 主数据存储 |\n| 语言 | TypeScript（全栈 strict） | — | 前后端统一语言，共享类型契约 |\n| 对象存储 | S3 兼容（MinIO \u002F R2 \u002F S3） | — | 头像与媒体文件直传 |\n\n### 1.1 为什么选 Prisma 而不是 TypeORM\n\n这是一个需要明确记录的决策。TypeORM 在 NestJS 生态中历史更久，不少企业级模板默认使用它；但 Prisma 在**查询边界的类型安全**上明显更深一层：`select`、`include`、`where` 的返回形状由 schema 编译生成，字段重命名会在编译期报错，而不是等到运行时才暴露。对于本项目这种内容类型多、关联关系频繁（文章 ↔ 分类 ↔ 标签、用户 ↔ 角色 ↔ 权限、视频 ↔ 作者）的场景，Prisma 生成的精确类型能显著降低 DTO 与数据库之间的漂移风险。Prisma 的迁移文件也是自动生成的，`prisma migrate dev` 会基于 schema 差异产出 SQL，对独立开发或小团队更为顺手。\n\n代价也需要提前知晓：Prisma 的嵌套过滤写法比 TypeORM 的 QueryBuilder 更啰嗦，`prisma generate` 在活跃开发中会增加一步操作感，极端高吞吐场景下 WASM 引擎仍略慢于调优后的查询构建器。对本项目的预期流量而言，这些代价可以接受。如果后续出现极复杂的报表查询，可以在 NestJS 中通过 `$queryRaw` 局部绕过，不必整体切换 ORM。\n\n### 1.2 为什么选独立 NestJS 后端而非 Nitro 全栈\n\nNuxt 3 的 Nitro 足以承载 MVP 级后端，但本项目预期包含**角色权限、内容审核、对象存储直传、多模块业务规则**，且未来可能扩展为多端消费（Web + 小程序 + 移动端）。将后端独立为 NestJS 服务，可以获得更强的架构约束、更清晰的模块边界和更成熟的 DI 生态；前后端通过 REST 通信，天然支持横向扩展与独立部署。代价是需要维护两个进程与一套 API 契约，这部分复杂度由 Prisma 的类型导出与轻量类型共享机制来缓解。\n\n---\n\n## 2. 项目结构\n\n采用**扁平双应用结构**，而不是 Nx monorepo。Nx 适合多应用、多团队共享库的大型工程，但会引入生成器、项目图和缓存层的心智负担。对于功能域明确、前后端各一的中型项目，两个独立目录配合同步的类型文件已经足够。\n\n```\nproject-root\u002F\n├── frontend\u002F                      # Nuxt 3 应用\n│   ├── components\u002F\n│   │   ├── content\u002F               # 文章卡片、视频卡片、轮播图等展示组件\n│   │   ├── navigation\u002F            # 动态导航渲染\n│   │   ├── account\u002F               # 个人资料、头像上传、密码修改\n│   │   └── ui\u002F\n│   ├── composables\u002F\n│   │   ├── useApi.ts\n│   │   ├── useContent.ts\n│   │   └── useAuth.ts             # 登录态、角色、权限、can()\n│   ├── layouts\u002F\n│   ├── middleware\u002F\n│   │   ├── auth.ts                # 登录校验\n│   │   └── admin.ts               # ADMIN 角色校验\n│   ├── pages\u002F\n│   │   ├── index.vue              # 首页（轮播图 + 最新内容聚合）\n│   │   ├── blog\u002F\n│   │   ├── news\u002F\n│   │   ├── videos\u002F\n│   │   ├── login.vue\n│   │   ├── account\u002F\n│   │   │   ├── profile.vue        # 个人资料与头像\n│   │   │   └── security.vue       # 修改密码、会话管理\n│   │   └── admin\u002F                 # 内容与用户管理（受保护）\n│   ├── server\u002F\n│   │   └── api\u002F\n│   │       └── [...path].ts       # catch-all 代理到 NestJS\n│   ├── nuxt.config.ts\n│   └── .env\n│\n├── backend\u002F                       # NestJS 应用\n│   ├── src\u002F\n│   │   ├── common\u002F                # 全局拦截器、过滤器、分页 DTO、装饰器\n│   │   ├── prisma\u002F                # PrismaModule + PrismaService（@Global）\n│   │   ├── auth\u002F                  # 登录、JWT、刷新令牌\n│   │   ├── users\u002F                 # 用户、个人资料、头像、角色管理\n│   │   ├── content\u002F               # 内容核心域\n│   │   │   ├── posts\u002F             # 博客 + 新闻（统一 Post 模型）\n│   │   │   ├── videos\u002F\n│   │   │   ├── categories\u002F\n│   │   │   └── tags\u002F\n│   │   ├── banners\u002F               # 轮播图\n│   │   ├── navigation\u002F            # 动态导航菜单\n│   │   └── app.module.ts\n│   ├── prisma\u002F\n│   │   ├── schema.prisma\n│   │   └── seed.ts                # 内置角色与权限种子\n│   ├── .env\n│   └── package.json\n│\n└── README.md\n```\n\n**共享类型的处理**：优先在 `frontend\u002Ftypes\u002F` 中通过 `import type` 引用后端导出的 DTO 类型定义。如果前后端不在同一 TypeScript 项目引用范围内，采用“后端导出 `.d.ts` → 前端复制为契约文件”的轻量方式，避免为此搭建完整的 monorepo 共享包体系。\n\n---\n\n## 3. 后端模块划分（NestJS）\n\nNestJS 的核心优势在于**按领域分 Module**，每个模块自带 controller \u002F service \u002F DTO，边界清晰。模块划分如下。\n\n### 3.1 Prisma 全局模块\n\n`PrismaService` 继承 `PrismaClient`，实现 `OnModuleInit` 与 `OnModuleDestroy`，在应用启动时 `$connect()`，关闭时 `$disconnect()`。使用 `@Global()` 装饰 `PrismaModule`，使所有业务模块无需重复 import 即可注入 PrismaService。\n\n```\nbackend\u002Fsrc\u002Fprisma\u002F\n├── prisma.module.ts      # @Global()\n└── prisma.service.ts     # extends PrismaClient, implements OnModuleInit\u002FOnModuleDestroy\n```\n\n### 3.2 `AuthModule` —— 认证\n\n负责注册、登录、刷新令牌与登出。登录成功后签发 Access Token（短期，如 15 分钟）与 Refresh Token（长期，如 7 天），Refresh Token 的哈希存入 `Session` 表，支持令牌轮换与服务端主动吊销。密码使用 `argon2` 或 `bcrypt` 哈希，**绝不存储明文**。\n\n`JwtAuthGuard` 注册为全局守卫，默认所有路由都需要登录，再用 `@Public()` 装饰器标记登录、注册等公开端点。\n\n### 3.3 `UsersModule` —— 用户、资料、头像、角色\n\n提供两类端点，权限要求截然不同：\n\n**个人资料端点（登录用户本人可操作）**\n\n| 方法 | 路径 | 说明 |\n|:---|:---|:---|\n| `GET` | `\u002Fusers\u002Fme` | 获取当前登录用户的完整资料（含 profile、roles、permissions） |\n| `PATCH` | `\u002Fusers\u002Fme` | 更新 `displayName`、`bio`、`website`、`timezone` |\n| `POST` | `\u002Fusers\u002Fme\u002Favatar\u002Fpresign` | 获取头像上传的预签名 URL |\n| `PATCH` | `\u002Fusers\u002Fme\u002Favatar` | 确认头像已上传，更新 `avatarUrl` 与 `avatarKey` |\n| `DELETE` | `\u002Fusers\u002Fme\u002Favatar` | 移除头像，回退到默认占位图 |\n| `PATCH` | `\u002Fusers\u002Fme\u002Fpassword` | 修改密码（需验证旧密码） |\n\n**管理端点（仅 `ADMIN` 可访问）**\n\n| 方法 | 路径 | 说明 |\n|:---|:---|:---|\n| `GET` | `\u002Fadmin\u002Fusers` | 分页用户列表，支持按邮箱、状态、角色筛选 |\n| `PATCH` | `\u002Fadmin\u002Fusers\u002F:id\u002Fstatus` | 启用 \u002F 禁用用户 |\n| `PUT` | `\u002Fadmin\u002Fusers\u002F:id\u002Froles` | 全量替换用户的角色集合 |\n| `GET` | `\u002Fadmin\u002Froles` | 角色列表（含每个角色的权限） |\n| `PATCH` | `\u002Fadmin\u002Froles\u002F:id\u002Fpermissions` | 调整角色的权限组合 |\n\n### 3.4 内容核心模块：`PostsModule`\n\n**博客与新闻共用一个 `Post` 模型，通过 `type` 字段区分**（`\"blog\"` 或 `\"news\"`）。两者在数据形态上高度重叠（标题、slug、正文、摘要、封面、作者、发布时间、状态），分表会带来大量重复的 controller 和 service；统一模型配合类型过滤，在 NestJS 中可以很自然地实现“同一个模块，两套查询入口”。\n\n`PostsController` 提供：\n\n- `GET \u002Fposts?type=blog&page=1&limit=10&category=...&tag=...&status=published`\n- `GET \u002Fposts\u002F:slug`\n- `POST \u002Fposts`（拥有 `post:create` 权限的角色）\n- `PATCH \u002Fposts\u002F:id`（拥有 `post:update` 权限的角色）\n- `DELETE \u002Fposts\u002F:id`（拥有 `post:delete` 权限的角色，软删除）\n\n分页通过通用的 `PaginationQueryDto`（`page`、`limit`）配合 Prisma 的 `skip` \u002F `take` 实现，返回结构统一为 `{ data: [], meta: { total, page, limit, totalPages } }`。\n\n**作者边界**：`AUTHOR` 角色在写操作时，service 层通过 `authorId` 过滤，确保其仅能修改\u002F删除自己创建的文章。这一约束可在后续引入 CASL 时升级为行级权限规则。\n\n### 3.5 `VideosModule`\n\n视频是独立模型，不复用 `Post`。原因在于视频的元数据维度不同：需要 `duration`、`coverUrl`、`videoUrl`、`provider`（自托管 \u002F 外链）、`viewCount` 等字段，且未来可能接入 HLS 分片播放。将视频与文章硬合并到一个模型中会导致大量字段为 `null`，得不偿失。\n\n视频播放本身不在 NestJS 中做流媒体转码或切片；后端只负责元数据 CRUD 与文件上传对接，前端通过 HLS 播放器消费视频地址。\n\n### 3.6 `BannersModule` —— 轮播图\n\n轻量但独立的模块，包含：\n\n- `imageUrl`、`title`、`subtitle`、`linkUrl`、`sortOrder`、`isActive`、`startAt`、`endAt`\n\n对外提供 `GET \u002Fbanners\u002Factive`，只返回当前在生效时间内且 `isActive = true` 的轮播项，按 `sortOrder` 升序。轮播图不直接关联到具体的 Post 或 Video，而是通过 `linkUrl` 自由指向任意前端路由或外部链接，保持模块的低耦合。\n\n### 3.7 `NavigationModule` —— 动态导航\n\n动态导航的复杂度取决于项目是否需要**基于角色的菜单可见性**。当前采用轻量模型：`Menu { id, label, path, icon, sortOrder, parentId, isExternal, isActive }`，返回树形结构供前端渲染。\n\n如果后续需要管理后台的**动态路由与权限菜单**，则扩展为 `User ↔ Role ↔ Menu` 多对多关联，后端根据当前用户角色返回过滤后的菜单树。初期先实现**无权限的前台导航**，将 RBAC 菜单作为后续迭代内容。\n\n---\n\n## 4. 数据模型（Prisma Schema 核心片段）\n\n以下为 `schema.prisma` 中与核心功能直接相关的模型设计。\n\n```prisma\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 用户与权限\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel User {\n  id           String    @id @default(cuid())\n  email        String    @unique\n  passwordHash String\n  isActive     Boolean   @default(true)\n  lastLoginAt  DateTime?\n  createdAt    DateTime  @default(now())\n  updatedAt    DateTime  @updatedAt\n\n  profile      UserProfile?\n  userRoles    UserRole[]\n  posts        Post[]\n  sessions     Session[]\n\n  @@index([email])\n}\n\nmodel UserProfile {\n  id          String   @id @default(cuid())\n  userId      String   @unique\n  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n  displayName String\n  bio         String?\n  avatarUrl   String?\n  avatarKey   String?\n  website     String?\n  timezone    String?  @default(\"Asia\u002FShanghai\")\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n}\n\nmodel Role {\n  id          String           @id @default(cuid())\n  name        String           @unique         \u002F\u002F ADMIN \u002F EDITOR \u002F AUTHOR \u002F MEMBER\n  description String?\n  isSystem    Boolean          @default(false) \u002F\u002F 系统内置角色，不允许删除\n  userRoles   UserRole[]\n  permissions RolePermission[]\n\n  @@index([name])\n}\n\nmodel Permission {\n  id          String           @id @default(cuid())\n  resource    String                           \u002F\u002F post \u002F video \u002F banner \u002F menu \u002F user\n  action      String                           \u002F\u002F create \u002F read \u002F update \u002F delete \u002F manage\n  description String?\n  rolePerms   RolePermission[]\n\n  @@unique([resource, action])\n}\n\nmodel UserRole {\n  userId String\n  roleId String\n  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)\n  role   Role @relation(fields: [roleId], references: [id], onDelete: Cascade)\n\n  @@id([userId, roleId])\n  @@index([roleId])\n}\n\nmodel RolePermission {\n  roleId       String\n  permissionId String\n  role         Role       @relation(fields: [roleId], references: [id], onDelete: Cascade)\n  permission   Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)\n\n  @@id([roleId, permissionId])\n  @@index([permissionId])\n}\n\nmodel Session {\n  id               String    @id @default(cuid())\n  userId           String\n  user             User      @relation(fields: [userId], references: [id], onDelete: Cascade)\n  refreshTokenHash String                    \u002F\u002F 存储刷新令牌的哈希，不存明文\n  userAgent        String?\n  ip               String?\n  expiresAt        DateTime\n  revokedAt        DateTime?\n  createdAt        DateTime  @default(now())\n\n  @@index([userId, expiresAt])\n}\n\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 内容核心：博客 + 新闻\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel Post {\n  id          String     @id @default(cuid())\n  type        PostType   @default(BLOG)          \u002F\u002F BLOG | NEWS\n  title       String\n  slug        String     @unique\n  excerpt     String?\n  body        String                              \u002F\u002F Markdown\n  coverUrl    String?\n  status      PostStatus @default(DRAFT)         \u002F\u002F DRAFT | PUBLISHED | ARCHIVED\n  isFeatured  Boolean    @default(false)\n  readTime    Int?\n  publishedAt DateTime?\n  deletedAt   DateTime?                           \u002F\u002F 软删除\n  authorId    String\n  author      User       @relation(fields: [authorId], references: [id])\n  categoryId  String?\n  category    Category?  @relation(fields: [categoryId], references: [id])\n  tags        PostTag[]\n  createdAt   DateTime   @default(now())\n  updatedAt   DateTime   @updatedAt\n\n  @@index([type, status, publishedAt])\n  @@index([slug])\n  @@index([deletedAt])\n}\n\nenum PostType   { BLOG NEWS }\nenum PostStatus { DRAFT PUBLISHED ARCHIVED }\n\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 分类与标签\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel Category {\n  id    String @id @default(cuid())\n  name  String @unique\n  slug  String @unique\n  posts Post[]\n}\n\nmodel Tag {\n  id    String    @id @default(cuid())\n  name  String    @unique @db.VarChar(50)        \u002F\u002F 存储小写\n  slug  String    @unique\n  posts PostTag[]\n}\n\nmodel PostTag {\n  postId String\n  tagId  String\n  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)\n  tag    Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)\n\n  @@id([postId, tagId])\n  @@index([tagId])\n}\n\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 视频\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel Video {\n  id          String        @id @default(cuid())\n  title       String\n  slug        String        @unique\n  description String?\n  coverUrl    String?\n  videoUrl    String                              \u002F\u002F 播放地址（MP4 \u002F HLS master）\n  provider    VideoProvider @default(SELF_HOSTED)\n  duration    Int?                                \u002F\u002F 秒\n  viewCount   Int           @default(0)\n  status      PostStatus    @default(DRAFT)\n  publishedAt DateTime?\n  deletedAt   DateTime?\n  createdAt   DateTime      @default(now())\n  updatedAt   DateTime      @updatedAt\n\n  @@index([status, publishedAt])\n}\n\nenum VideoProvider { SELF_HOSTED EXTERNAL HLS }\n\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 轮播图\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel Banner {\n  id        String    @id @default(cuid())\n  imageUrl  String\n  title     String?\n  subtitle  String?\n  linkUrl   String?\n  sortOrder Int       @default(0)\n  isActive  Boolean   @default(true)\n  startAt   DateTime?\n  endAt     DateTime?\n  createdAt DateTime  @default(now())\n  updatedAt DateTime  @updatedAt\n\n  @@index([isActive, sortOrder])\n}\n\n\u002F\u002F ═══════════════════════════════════════════════\n\u002F\u002F 动态导航（前台轻量版）\n\u002F\u002F ═══════════════════════════════════════════════\n\nmodel Menu {\n  id         String  @id @default(cuid())\n  label      String\n  path       String\n  icon       String?\n  sortOrder  Int     @default(0)\n  parentId   String?\n  parent     Menu?   @relation(\"MenuChildren\", fields: [parentId], references: [id])\n  children   Menu[]  @relation(\"MenuChildren\")\n  isExternal Boolean @default(false)\n  isActive   Boolean @default(true)\n\n  @@index([parentId, sortOrder])\n}\n```\n\n**设计取舍说明**\n\n- **用户资料拆分**：将 `displayName`、`bio`、`avatarUrl` 等公开资料从 `User` 拆分到 `UserProfile`。`User` 只承载认证与授权相关的敏感字段（`passwordHash`、`isActive`），未来对外提供“用户公开主页”或“作者信息”接口时，可以直接查询 `UserProfile` 而不必在 `select` 中小心排除密码字段，降低敏感字段泄露风险。代价是个人资料读取需要多一次 `include`，在 Prisma 中只是一行配置。\n- **软删除**：`Post` 与 `Video` 的 `deletedAt` 字段用于软删除。查询时在 service 层默认追加 `deletedAt: null` 条件，或在 Prisma Client Extension 中统一拦截。项目初期先通过 service 层约定实现，保持依赖精简，不引入第三方软删除扩展。\n- **权限二元组**：`Permission` 以 `(resource, action)` 唯一约束，`action` 使用 `create` \u002F `read` \u002F `update` \u002F `delete` \u002F `manage` 五种。`manage` 作为通配动作覆盖该资源所有具体动作，简化 `ADMIN` 的规则定义。\n\n---\n\n## 5. 用户管理与权限体系详解\n\n用户管理在本项目中承担两个职责：**前台用户的个人身份**（资料、头像、偏好）与**后台内容管理的操作权限**（谁可以发布文章、删除视频、管理轮播图）。两者共用同一套 `User` 实体，但权限边界必须清晰——前台注册用户默认**没有任何后台内容管理权限**，后台能力通过角色显式授予。\n\n### 5.1 认证与授权的分层\n\n认证（Authentication）回答“你是谁”，授权（Authorization）回答“你能做什么”。本项目采用 **JWT 认证 + RBAC 授权** 的分层结构：\n\n- **认证层**：`AuthModule` 负责登录、JWT 签发与校验。`JwtAuthGuard` 解析请求中的 JWT，将用户信息挂载到 `request.user`。这一层不关心角色，只确认身份有效。\n- **粗粒度授权层**：`RolesGuard` 配合 `@Roles()` 装饰器，在 controller 或路由方法级别检查用户是否拥有所需角色。适合“整个模块仅 Admin 可访问”这类简单场景，例如 `BannersController` 的写操作。\n- **细粒度授权层**：当权限需求超出“角色存在性检查”（例如“编辑只能改自己创建的文章”“作者不能删除已发布内容”），引入 **CASL + Prisma**，将权限规则定义为 Prisma `WhereInput`，实现**行级**和**字段级**的精细控制。\n\n推荐**先落地 RolesGuard，再按需引入 CASL**。过早引入 CASL 会增加 Ability 定义与调试成本；一旦出现“同一角色内部权限因资源归属不同而分化”的需求，CASL 几乎是 NestJS 生态中的标准答案。\n\n**守卫绑定方式**：`JwtAuthGuard` 注册为全局守卫，默认所有路由都需要登录，再用 `@Public()` 装饰器标记登录、注册等公开端点。`RolesGuard` 同样全局注册，但在没有 `@Roles()` 元数据时直接放行，只有声明了角色要求的路由才会实际校验。这种“默认拒绝 + 按需声明”的模式比在每个 controller 上手动挂守卫更不容易遗漏。\n\n### 5.2 内置角色与权限种子数据\n\n项目初始化时通过 Prisma seed 写入以下系统角色和权限组合。角色名称保持英文枚举风格，便于代码中引用。\n\n| 角色 | 权限范围 | 适用人员 |\n|:---|:---|:---|\n| `ADMIN` | 全部资源的 `manage`（含用户管理、角色分配） | 平台管理员 |\n| `EDITOR` | `post` \u002F `video` \u002F `banner` \u002F `menu` 的 `create`、`read`、`update`、`delete` | 内容运营 |\n| `AUTHOR` | `post` 的 `create`、`read`、`update`（仅自己的文章）；`video` 的 `read` | 投稿作者 |\n| `MEMBER` | 仅前台个人资料读写，无任何内容管理端点访问权 | 普通注册用户 |\n\n### 5.3 头像上传：预签名 URL 直传\n\n头像文件**不经过 NestJS 服务端内存**。流程如下：\n\n1. 前端调用 `POST \u002Fusers\u002Fme\u002Favatar\u002Fpresign`，后端校验用户身份，生成对象存储（S3 \u002F R2 \u002F MinIO 兼容）的预签名上传 URL，并返回该 URL 与最终的 `objectKey`。\n2. 前端直接将图片文件 `PUT` 到预签名 URL，**完全绕过 NestJS 服务器**。这是文件上传能够水平扩展的关键模式，避免了 Multer 内存存储在大文件或高并发下的内存压力。\n3. 上传成功后，前端调用 `PATCH \u002Fusers\u002Fme\u002Favatar`，将 `objectKey` 提交给后端。后端验证对象确实存在（通过存储服务的 HeadObject），更新 `UserProfile.avatarKey` 与 `avatarUrl`，并删除旧头像对象（如果存在且不同于新 key）。\n\n**安全要点**：预签名 URL 的过期时间建议设为 60–120 秒；后端在生成预签名时限定 `Content-Type` 为 `image\u002Fjpeg`、`image\u002Fpng`、`image\u002Fwebp`，并限制文件大小（如 2 MB）。**不要信任客户端提交的文件扩展名或 Content-Type**。如果图片需要进一步处理（压缩、裁剪），应在对象存储的 `onUpload` 事件或队列 worker 中异步完成，而不是在 HTTP 请求周期内同步处理。\n\n如果项目初期不想引入对象存储依赖，可以暂时使用 NestJS 的 `FileInterceptor` + 本地磁盘存储作为过渡，但需要将头像上传的 service 接口设计为可替换的抽象（如 `AvatarStorageService` 接口，本地实现与 S3 实现分别注入），避免未来迁移时牵动 controller 层。\n\n### 5.4 前端（Nuxt 3）的权限对接\n\n**用户状态管理**：使用 `useAuth()` composable 封装登录状态、当前用户、角色与权限集合。用户信息在 SSR 期间通过 `useFetch` 配合 Cookie 中的 JWT 获取，确保首屏渲染时权限状态已经就绪，避免“先渲染再重定向”的闪烁。建议暴露以下派生状态：\n\n```ts\n\u002F\u002F composables\u002FuseAuth.ts 的核心接口\nconst { user, roles, permissions, isLoggedIn } = useAuth()\n\u002F\u002F isLoggedIn: 是否有有效会话\n\u002F\u002F roles: string[]，如 ['EDITOR']\n\u002F\u002F permissions: string[]，如 ['post:create', 'post:update']\n\u002F\u002F can(perm: string): boolean\n```\n\n**路由中间件**：使用**命名中间件**，而不是在组件内做权限判断。`auth` 中间件检查登录状态，未登录时重定向到登录页并携带 `redirect` 参数；`admin` 中间件在 `auth` 之后运行，检查用户是否拥有 `ADMIN` 角色，不满足时调用 `abortNavigation()` 而非重定向，避免将用户推向另一个同样会被拒绝的页面。\n\n```ts\n\u002F\u002F middleware\u002Fadmin.ts\nexport default defineNuxtRouteMiddleware(() => {\n  const { user } = useAuth()\n  if (!user.value) return navigateTo('\u002Flogin')\n  if (!user.value.roles?.includes('ADMIN')) return abortNavigation()\n})\n```\n\n在页面中使用时按顺序声明：`definePageMeta({ middleware: ['auth', 'admin'] })`。中间件数组保证 `auth` 先建立用户上下文，`admin` 再进行角色判断。\n\n**按钮\u002F元素的权限控制**：对于页面内“是否显示删除按钮”“是否允许编辑”这类细粒度 UI 控制，使用 `v-if=\"can('post:delete')\"` 配合 `useAuth()` 中的 `can` 方法。需要明确的是，**前端的权限判断只影响用户体验，不构成安全边界**。所有真正的权限校验必须在 NestJS 端完成；前端隐藏按钮只是为了避免用户点击后才收到 403 的挫败感。\n\n**个人资料页面路由**：`\u002Faccount\u002Fprofile`（查看与编辑资料、更换头像）、`\u002Faccount\u002Fsecurity`（修改密码、查看登录会话）。这两个页面挂载 `auth` 中间件即可，无需角色限制。头像上传组件在获取预签名 URL 后直接 `fetch(presignedUrl, { method: 'PUT', body: file })`，然后在确认接口成功后刷新 `useAuth()` 中的用户信息。\n\n**JWT 存储**：Access Token 推荐存储在 **Cookie** 中（而非 `localStorage`），因为 Cookie 在 SSR 期间可被 `useCookie` 读取，且 Nuxt 的 `useCookie` 是 SSR-safe 的。NestJS 端签发 JWT 后通过 `Set-Cookie` 返回，前端在 `useFetch` \u002F `$fetch` 中依赖浏览器自动携带，无需在每个请求中手动拼接 Authorization 头。如果安全要求更高，可改用 `httpOnly` Cookie 并由 Nuxt 服务端路由代理所有 API 请求，但会牺牲部分客户端直接调用的便利性。\n\n---\n\n## 6. 前后端对接的关键配置\n\n### 6.1 Nuxt 3 侧的环境变量与 runtimeConfig\n\n后端地址**不能硬编码**。在 `nuxt.config.ts` 中使用 `runtimeConfig` 管理 API 基地址，并区分服务端与客户端可见性：\n\n```ts\n\u002F\u002F frontend\u002Fnuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    apiSecret: '',                    \u002F\u002F 仅服务端可见\n    public: {\n      apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http:\u002F\u002Flocalhost:3001\u002Fapi',\n    },\n  },\n})\n```\n\nNuxt 3 的 `runtimeConfig` 是推荐的运行时配置方式，构建后可通过环境变量覆盖，无需重新构建。对应的 `.env`：\n\n```\nNUXT_PUBLIC_API_BASE=http:\u002F\u002Flocalhost:3001\u002Fapi\n```\n\n### 6.2 API 调用的代理策略\n\n前后端分离下，Nuxt 端调用 NestJS API 有三种可选路径，建议**开发环境用 Nitro 代理，生产环境用同域反向代理（Nginx \u002F Caddy）**。\n\n开发环境下，`nitro.devProxy` 在涉及 SSR 的相对路径调用时存在已知局限：Nitro 会将 `\u002Fapi\u002F*` 识别为内部调用并尝试模拟，而不是真正转发到外部后端。更稳妥的开发代理方案是在 `server\u002Fapi\u002F[...path].ts` 中使用 `proxyRequest` 做 catch-all 转发：\n\n```ts\n\u002F\u002F frontend\u002Fserver\u002Fapi\u002F[...path].ts\nexport default defineEventHandler((event) => {\n  const config = useRuntimeConfig()\n  const target = `${config.public.apiBase}\u002F**`\n  return proxyRequest(event, target.replace('\u002F**', event.context.params.path))\n})\n```\n\n前端所有请求统一走 `\u002Fapi\u002F*`，SSR 与客户端行为一致，且 `apiBase` 可在运行时通过环境变量切换。\n\n### 6.3 认证的令牌存储\n\nJWT 的 Access Token 推荐存储在 Cookie 中（详见 5.4）。Refresh Token 建议使用 `httpOnly` Cookie，由 `\u002Fauth\u002Frefresh` 端点负责轮换；`Session` 表存储 Refresh Token 的哈希，支持“主动踢下线”与“查看登录设备”。\n\n---\n\n## 7. 功能模块与前端路由对应\n\n| 功能 | 后端模块 | 前端路由 | 权限要求 |\n|:---|:---|:---|:---|\n| 博客列表 \u002F 详情 | `PostsModule`（`type=blog`） | `\u002Fblog`、`\u002Fblog\u002F[slug]` | 公开 |\n| 新闻列表 \u002F 详情 | `PostsModule`（`type=news`） | `\u002Fnews`、`\u002Fnews\u002F[slug]` | 公开 |\n| 视频列表 \u002F 播放 | `VideosModule` | `\u002Fvideos`、`\u002Fvideos\u002F[slug]` | 公开 |\n| 首页轮播 | `BannersModule` | `\u002F`（`index.vue`） | 公开 |\n| 动态导航 | `NavigationModule` | 全局 layout | 公开 |\n| 登录 \u002F 注册 | `AuthModule` | `\u002Flogin`、`\u002Fregister` | 公开 |\n| 个人资料与头像 | `UsersModule` | `\u002Faccount\u002Fprofile` | 登录用户 |\n| 修改密码 \u002F 会话 | `AuthModule` | `\u002Faccount\u002Fsecurity` | 登录用户 |\n| 内容管理 | 各内容模块 Admin 端点 | `\u002Fadmin\u002Fcontent\u002F...` | `ADMIN`、`EDITOR`、`AUTHOR`（按资源） |\n| 用户管理 | `UsersModule`（Admin） | `\u002Fadmin\u002Fusers` | `ADMIN` |\n| 角色与权限管理 | `UsersModule`（Admin） | `\u002Fadmin\u002Froles` | `ADMIN` |\n\n**Admin 端点的粗粒度权限要求**\n\n| 端点前缀 | 最低角色 |\n|:---|:---|\n| `\u002Fposts`（写） | `AUTHOR`（仅自己的）\u002F `EDITOR` \u002F `ADMIN` |\n| `\u002Fvideos`（写） | `EDITOR` \u002F `ADMIN` |\n| `\u002Fbanners`（写） | `EDITOR` \u002F `ADMIN` |\n| `\u002Fmenus`（写） | `EDITOR` \u002F `ADMIN` |\n| `\u002Fadmin\u002Fusers`、`\u002Fadmin\u002Froles` | `ADMIN` |\n\n---\n\n## 8. 开发与部署基线\n\n**本地启动**：后端 `pnpm start:dev`（默认 `3001`），前端 `pnpm dev`（`3000`）。前端通过上述 catch-all 代理将 `\u002Fapi\u002F*` 转发至 `http:\u002F\u002Flocalhost:3001\u002Fapi`。\n\n**数据库迁移流程**：修改 `schema.prisma` → `npx prisma migrate dev --name \u003C描述>` → `npx prisma generate`。后端 service 中直接注入 `PrismaService`，无需额外维护 repository 层。\n\n**种子数据**：`npx prisma db seed` 执行 `prisma\u002Fseed.ts`，写入内置角色（`ADMIN` \u002F `EDITOR` \u002F `AUTHOR` \u002F `MEMBER`）、权限二元组、角色权限映射，以及一个初始管理员账号（凭据从环境变量读取，不硬编码）。\n\n**部署**：前后端作为两个独立服务部署。生产环境由 Nginx \u002F Caddy 将 `\u002Fapi\u002F*` 反向代理到 NestJS 服务，Nuxt 的 SSR 服务处理其余路由。这种部署方式天然规避 CORS，且前端构建产物与后端进程解耦，可以独立扩缩容。\n\n**安全基线**\n\n- `passwordHash` 使用 `argon2` 或 `bcrypt`（cost ≥ 12），任何接口响应中不得包含该字段。\n- JWT 签名密钥、对象存储凭证、数据库连接串全部来自环境变量，`.env` 不提交仓库。\n- 预签名 URL 有效期 60–120 秒，限定 Content-Type 与文件大小。\n- Admin 端点全部挂 `JwtAuthGuard` + `RolesGuard`，缺省拒绝。\n- 登录接口做速率限制（`@nestjs\u002Fthrottler`），防止暴力破解。\n\n---\n\n## 9. 建议的迭代顺序\n\n**Phase 1（基础内容骨架）**：`PrismaModule` + `PostsModule`（博客 + 新闻）+ Nuxt 博客\u002F新闻页面。先跑通“后端 CRUD → 前端 SSR 渲染 → 代理链路”的最短闭环。\n\n**Phase 2（多媒体与首页）**：`VideosModule` + `BannersModule` + 首页聚合。视频先以 MP4 直链或外链嵌入起步，HLS 适配作为后续优化。\n\n**Phase 2.5（用户与权限基础）**：`AuthModule` + `UsersModule` + `UserProfile` + `RolesGuard` + 角色\u002F权限种子数据 + 前端 `useAuth` 与 `auth`\u002F`admin` 中间件 + 头像预签名直传。这一步放在 Admin 管理界面之前，确保后续所有后台页面都建立在有效的登录与角色体系之上，而不是先做无保护的 Admin 页面再回头补权限。\n\n**Phase 3（动态导航与管理界面）**：`NavigationModule` + Admin 内容管理界面（文章、视频、轮播图）+ Admin 用户与角色管理界面。导航先做前台配置版；如果需要多角色后台，再引入 RBAC 菜单权限。\n\n**Phase 4（体验与工程化）**：搜索（`Post` 的全文索引）、SEO 增强（`useSeoMeta`、sitemap）、缓存策略（NestJS 端对轮播图和导航做短 TTL 缓存）、CASL 细粒度权限（当“作者只能改自己的文章”等规则复杂到 RolesGuard 难以表达时）。\n\n---\n\n## 附录：关键决策速查\n\n| 决策点 | 选择 | 主要理由 |\n|:---|:---|:---|\n| 后端形态 | 独立 NestJS 服务 | 预期多端消费、复杂业务规则、独立扩缩容 |\n| ORM | Prisma | 查询边界类型安全、迁移自动生成、与 TS 深度契合 |\n| 博客\u002F新闻 | 统一 `Post` + `type` 字段 | 数据形态高度重叠，避免重复 controller\u002Fservice |\n| 视频 | 独立 `Video` 模型 | 元数据维度不同，避免大量 null 字段 |\n| 用户资料 | 拆出 `UserProfile` | 敏感字段与公开字段隔离，降低泄露风险 |\n| 权限模型 | RBAC（Role ↔ Permission 多对多） | 覆盖粗粒度场景，为 CASL 行级权限预留扩展点 |\n| 授权实现 | RolesGuard 先行，CASL 按需引入 | 避免过早引入 Ability 定义成本 |\n| 头像上传 | 预签名 URL 直传对象存储 | 绕过服务端内存，水平扩展友好 |\n| 前端权限 | 中间件 + `can()` 控制 UI | UI 控制仅为体验，安全边界在后端 |\n| JWT 存储 | Cookie（SSR-safe） | Nuxt `useCookie` 原生支持，避免手动拼 Authorization |\n| 开发代理 | `server\u002Fapi\u002F[...path].ts` + `proxyRequest` | 规避 `devProxy` 在 SSR 相对路径上的局限 |\n| 部署 | Nginx\u002FCaddy 同域反代 `\u002Fapi\u002F*` | 天然规避 CORS，前后端独立扩缩容 |",33,false,"2026-10-29T10:15:39",{"list":93,"total":10,"page":20,"size":94},[],30]