Skip to content

NestJS + Prisma 配装广场后端设计

配装器最初是纯本地应用,数据存在微信 Storage 里。MVP 后端的目标是让配装数据可以云端持久化、公开分享和社交互动。这篇文章记录核心的架构决策。


技术选型

选型理由
框架NestJS 11模块化、装饰器风格、TypeScript 原生
数据库PostgreSQL 16关系完整性好,全文搜索有 pg_trgm 扩展可用
ORMPrisma 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,只负责读侧聚合,内部编排 LikeServiceFavoriteService 和 User 数据:

SquareController
    └── SquareQueryService(读侧,唯一出口)
            ├── GearsetRepository(直接查询)
            ├── LikeService(查询当前用户是否点赞)
            └── FavoriteService(查询当前用户是否收藏)

SquareController 只依赖 SquareQueryService,不直接调用 LikeServiceFavoriteService

这样做的好处:读模型和写模型保持清晰边界。点赞、收藏的写操作走各自的 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 让索引只覆盖活跃数据:

sql
-- 私有配装列表:按用户 + 时间排序
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 索引:

sql
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 让查询性能在数据量增长后仍可预期
  • 软删除配合收藏夹的"已下架"逻辑,保留了用户体验的连贯性