NestJS + Prisma 配装广场后端设计
配装器最初是纯本地应用,数据存在微信 Storage 里。MVP 后端的目标是让配装数据可以云端持久化、公开分享和社交互动。这篇文章记录核心的架构决策。
技术选型
| 层 | 选型 | 理由 |
|---|---|---|
| 框架 | NestJS 11 | 模块化、装饰器风格、TypeScript 原生 |
| 数据库 | PostgreSQL 16 | 关系完整性好,全文搜索有 pg_trgm 扩展可用 |
| ORM | Prisma 6 | 类型安全、迁移工具完善 |
| 认证 | JWT + Passport | 无状态,适合横向扩展 |
| 限流 | @nestjs/throttler | 内存存储,MVP 单机足够 |
模块划分
src/
├── infrastructure/
│ ├── auth/ # 微信登录、JWT 签发
│ └── users/ # 用户信息管理
└── gearing/ # 配装器子应用
├── gearsets/ # 私有配装 CRUD(写侧)
├── square/ # 配装广场(读侧聚合)
├── likes/ # 点赞领域
└── favorites/ # 收藏领域gearing 下的四个模块对应四个不同的职责,infrastructure 层提供登录和用户管理,两层之间没有循环依赖。
读写分离:SquareQueryService
广场(square)模块是整个后端里最有意思的设计点。
广场列表需要聚合多个领域的数据:配装基本信息、作者信息、当前用户是否点赞/收藏。一开始的直觉是把这些查询分散到各自的 Service 里,但这样会导致 Controller 需要协调多个 Service,逻辑分散。
最终的方案是专门抽出一个 SquareQueryService,只负责读侧聚合,内部编排 LikeService、FavoriteService 和 User 数据:
SquareController
└── SquareQueryService(读侧,唯一出口)
├── GearsetRepository(直接查询)
├── LikeService(查询当前用户是否点赞)
└── FavoriteService(查询当前用户是否收藏)SquareController 只依赖 SquareQueryService,不直接调用 LikeService 或 FavoriteService。
这样做的好处:读模型和写模型保持清晰边界。点赞、收藏的写操作走各自的 Service(各自维护计数),广场的读视图走 SquareQueryService 统一组装。将来若需要把广场查询提升为独立投影层或引入缓存,改动范围可控。
ShareCode 存储策略
配装内容以客户端已有的 ShareCode 字符串存储,这是一个 Base62 编码的 BigInt,完整包含职业、等级、所有装备槽的装备 ID 与魔晶石、食物信息。
服务端不解码、不校验 ShareCode 内容,只做透传存储。这个决策省掉了服务端维护一套与客户端一致的解码逻辑的麻烦,同时意味着:
- 服务端无法基于装备内容做筛选(但可以基于客户端上报的元数据,如职业、平均装等)
- 版本升级时旧 ShareCode 依然有效,不需要迁移
同时记录 shareCodeVersion(当前为 v5),便于将来新版格式上线时快速定位旧数据。
索引策略
Prisma 不支持 Partial Index 和 GIN Index,所以索引都通过初始迁移脚本的 raw SQL 来管理。
Partial Index 减少索引体积
广场和私有列表的主查询条件都含 deleted_at IS NULL,用 Partial Index 让索引只覆盖活跃数据:
-- 私有配装列表:按用户 + 时间排序
CREATE INDEX gearset_user_active_idx ON "Gearset" (user_id, created_at DESC)
WHERE deleted_at IS NULL;
-- 广场列表:最新排序
CREATE INDEX gearset_public_newest_idx ON "Gearset" (created_at DESC)
WHERE is_public = true AND deleted_at IS NULL;
-- 广场列表:点赞排序
CREATE INDEX gearset_public_likes_idx ON "Gearset" (like_count DESC)
WHERE is_public = true AND deleted_at IS NULL;随着软删除数据积累,Partial Index 的体积不会膨胀,全表索引会。
GIN Index 支持模糊搜索
广场支持按名称 + 简介模糊搜索,ILIKE '%q%' 前置通配符无法走 B-tree,必须用 trigram GIN 索引:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX gearset_search_trgm_idx ON "Gearset"
USING GIN ((name || ' ' || COALESCE(description, '')) gin_trgm_ops);软删除
删除配装用软删除(设置 deletedAt 时间戳),MVP 阶段不提供恢复入口。软删除后配装立即从广场下架,但已有的 Like / Favorite 记录保留。
收藏夹的查询逻辑特殊:过滤掉已软删除的配装(它们不应出现在收藏列表里),但已设为私有(isPublic=false)的配装继续展示——已收藏的东西被作者下架不应直接消失,客户端根据 isPublic 字段显示"已下架"提示即可。
微信登录流程
客户端 wx.login() → 拿到 code
↓
POST /api/auth/wx-login { code }
↓
服务端调用 wx.jscode2session → 换取 openId
↓
按 openId 查找或创建 User
↓
签发 JWT(payload: { sub: userId },有效期 7 天)
↓
返回 accessToken + 用户基本信息客户端在请求头附带 Authorization: Bearer <token>,遇到 401 时自动调用微信静默登录重新换取 token,对用户无感知。
小结
这个后端整体不复杂,但几个决策值得记录:
- SquareQueryService 把读侧聚合逻辑集中,避免 Controller 变成协调中心
- ShareCode 透传存储,客户端和服务端各管各的解码,边界清晰
- Partial Index + GIN Index 让查询性能在数据量增长后仍可预期
- 软删除配合收藏夹的"已下架"逻辑,保留了用户体验的连贯性