外观
后端工程规范
适用于 Nexo 的所有后端服务(当前:
nexo-auth、nexo-im-api)。新起一个后端服务时照这份做,不要另起一套。各仓库
.trellis/spec/backend/里有更细的、跟着代码走的版本;这里只写跨服务必须一致的部分。
1. 目录结构
src/
├── index.ts Node 启动入口
├── entry.ts app 聚合导出 + 后台任务装配(dev 与生产共用这一处)
├── config/
│ ├── index.ts 客户端安全的常量(PORT 等)
│ └── env.ts 环境变量,zod 校验,**仅服务端可 import**
├── db/
│ ├── index.ts 连接池 + drizzle 实例,数据库访问唯一入口
│ ├── migrate.ts 程序化迁移入口(构建进镜像,node dist/migrate.js)
│ ├── seed.ts 种子入口(**也要构建进镜像**,理由见 §5)
│ └── schema/ 一表一文件,index.ts 汇总导出
├── routes/ 业务模块,一个模块的全部代码在同一目录
│ ├── global-route.ts 根实例 + 全局中间件
│ ├── index.ts 路由组装
│ └── <模块>/ 四件套 + 该模块的 service
├── services/ **只放通用基础设施**,判定标准见 §2
├── types/ 跨模块共享类型
└── utils/ 通用工具模块四件套
每个 HTTP 业务模块固定四个文件,职责不交叉:
| 文件 | 放什么 |
|---|---|
schema.ts | zod schema,请求与响应的形状 |
routes.ts | createRoute 定义:路径、方法、中间件、响应码 |
controller.ts | 取参 → 调 service → 组装响应。不写业务逻辑 |
index.ts | 把 routes 与 controller 接起来,一条链注册到底 |
加上 <名词>-service.ts 承载业务逻辑。
「一条链注册到底」不是风格问题:分步注册会丢 RPC 类型推导,前端的类型化客户端依赖于此。
2. src/services/ 的准入标准
同时满足三条才能进:
- 不认识领域概念 —— 签名里不出现带业务语义的实体。拿
userId: string当寻址键可以,查user_profiles表就不行 - 不含业务规则 —— 没有权限判定、状态机、字段校验这类「改需求就要改它」的逻辑
- 被 2 个以上业务模块共用
「被复用」不构成上浮理由。 只看第 3 条会让「通用」退化成「凡是被两处 import 的都算」,
src/services/就会重新长回业务与基础设施混放的样子。nexo-im-api的 MVP1 正是这么长歪的,归位时搬了 6 个文件、改了 26 处 import 与 8 处vi.mock()字符串。
业务 service 一律跟着路由模块走,放 routes/<模块>/。
3. 共享代码走 @lamolabs/nexo-backend-sdk
日志与脱敏、错误响应结构、服务间 HMAC 鉴权、JWKS 本地验签——这四样在包里,不要在服务里重写。
判断标准:它是「接入这套认证体系的后端服务」都需要的,还是某个服务自己的业务?
错误码是个容易搞错的例子:
- 分段约定是共享的:
1xxx请求本身有问题 /2xxx认证授权 /3xxx业务 /5xxx系统 - 具体码值不是。各服务在自己的
utils/errors.ts里按段取值
把 INVALID_REFRESH_TOKEN 和 CONVERSATION_NOT_FOUND 放进同一张表,只会让两边都看到一堆与自己无关的码,并诱使人复用不属于本服务的语义。
日志
调用约定 logger.<级别>(模块, 消息, ...片段),模块名点分层,片段一律 key=value(用 field() 拼)。
身份 id 必须脱敏后再打:
| 用 | 场合 | 输出 |
|---|---|---|
maskId | 用户 / 设备 id | 11111111… |
fingerprint | 令牌、授权码、cookie | a3f5c8d21b04 |
maskUsername | 用户名 | m***(7) |
日志会被贴进工单和聊天窗口,完整 id 在那里等于一份可直接查库的清单。
nexo-auth 有一个 utils/log-hygiene.test.ts 扫源码守这条线,新服务照抄即可——它在写完当天就抓到了两处人工改漏的地方。
4. 数据访问:操作原子化
每次数据访问都应该是一个单一目的、可独立执行的最小操作。
说的是操作粒度,不是事务 ACID 里那个原子性。一次访问只干一件事、只碰一张表,而不是把多张表的取数捆成一条复合语句。
两条硬约束:
- 读 —— 跨表取数拆成多次查询、在应用层组装。不写 JOIN,不论是否同库
- 写 —— 不依赖数据库的级联删除 / 更新,该级联的关系在应用层显式处理
为什么:复合语句锁死的正是分布式下最需要的几种能力。
| 原子化带来的 | 复合语句锁死的 |
|---|---|
| 可路由:单表操作能落到确定的分片或实例 | 分库之后「该关联哪些表」本身不可知,SQL 层表达不了 |
| 可替换:某张表挪到另一个服务后面时,原地把查询换成一次 RPC 即可 | 表间耦合被固化进 SQL,拆库时每一条都得重写;而拆库通常是被容量逼着做的,那时没有从容重写的余地 |
| 可降级:某一步失败能单独重试,或返回占位(用户资料取不到就显示默认头像,列表照常出) | 全有或全无——一张表出问题整个查询失败,页面直接白掉 |
| 可缓存:单一目的的结果能独立缓存、独立失效 | 复合结果的缓存粒度粗、失效条件复杂,基本等于不能缓存 |
| 可观测:每一步的耗时与失败率能单独度量 | 只看得到一条笼统的慢查询,不知道慢在哪张表 |
外加一条最现实的:即使表还在同一个实例上,跨节点关联的开销也远大于几次点查。
拆成多次查询 ≠ 逐条查询。 正确姿势是分层批量:① 查主表拿到一批 id → ② 用 IN (...) 一次批量查关联表 → ③ 应用层按 id 组装。
在循环里查库是把 JOIN 换成了 N+1,比 JOIN 更糟。code review 见到就打回。
数据归属:跨服务的数据只有一个权威方。用户、设备、登录会话在 nexo-auth;其他服务经 /internal/* 按 id 批量取,需要常驻就落本地只读投影(如 nexo_im.user_profiles),不反向依赖对方的表结构。
外键:单库阶段可以留着挡脏数据,代价接近零。但外键在分库之后同样失效(跨库建不了),所以业务行为不能依赖它——这样将来去掉外键时,只是少了一层校验,而不是行为改变。
存量:nexo-im-api 的 conversation-service.ts 有一处 innerJoin(userProfiles)(会话列表关联对方资料,同库)。登记为待收敛项;新增查询一律不得再产生 JOIN。
5. 构建入口
vite.config.ts 的 input 至少三个:index、migrate、seed。
seed 不是可选项:oauth_clients 表为空时 /authorize 对任何 client_id 都报「未注册的应用」,所有前端都登不进来。nexo-im-api 曾漏了这个入口,部署文档里不得不写一整套「起临时 Node 容器挂载源码执行」的绕法。
6. 密钥
一律不给默认值。给了默认值就等于所有部署共用同一把,任何人都能伪造。缺失时让 zod 在启动阶段直接抛错——服务起不来正是想要的行为。
| 密钥 | 分开的理由 |
|---|---|
AUTH_SIGNING_KEY | 令牌签名 |
AUTH_SESSION_SECRET | 会话 cookie 签名 |
AUTH_INTERNAL_SHARED_SECRET | 服务间 HMAC |
三把分开,一把泄漏不至于让其余失守,且轮换互不影响。
AUTH_INTERNAL_SHARED_SECRET两侧必须同值。不一致时两边都不直接报错,各留一条 warn——界面上表现为「所有人都没有昵称头像」「被踢的设备要等 60 秒才下线」,极难往密钥上想。
7. /internal/*
服务间接口走 HMAC 共享密钥,不接受用户令牌。三条缺一不可:
- 时间戳 ±60 秒
- nonce 只认一次(防重放)
- 签名等时比较
签名串是 [METHOD, path+query, timestamp, nonce, sha256(body)] 换行拼接。三个容易漏的点,漏了都表现为「对方 401 但本地看不出哪里错」:query 必须进签名、每次换新 nonce、path 要带 search。
反向代理必须拒绝 /internal/ 前缀,返回 404 而非 403——403 等于承认「这个路径存在但你没权限」。上线后要从公网实测一次,见各仓 docs/deploy.md。
8. 部署
统一形态:服务器上 git clone 源码 → 本地 docker build → 上线。不推跨境镜像(服务器在广州,从 ghcr.io 拉会超时)。
每个服务需要:docker-compose.deploy.yml、scripts/deploy.sh、.github/workflows/cd.yml、docs/deploy.md。照现成的抄,改差异部分。
两个后端要挂在同一个外部 docker 网络上(docker network create nexo),否则跨服务调用全部不通。
私有依赖 @lamolabs/nexo-backend-sdk 需要构建期鉴权,用 build secret 而非 ARG——ARG 会留在镜像历史里。