Skip to content

后端工程规范 ​

适用于 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.tszod schema,请求与响应的形状
routes.tscreateRoute 定义:路径、方法、中间件、响应码
controller.ts取参 → 调 service → 组装响应。不写业务逻辑
index.ts把 routes 与 controller 接起来,一条链注册到底

加上 <名词>-service.ts 承载业务逻辑。

「一条链注册到底」不是风格问题:分步注册会丢 RPC 类型推导,前端的类型化客户端依赖于此。


2. src/services/ 的准入标准 ​

同时满足三条才能进:

  1. 不认识领域概念 —— 签名里不出现带业务语义的实体。拿 userId: string 当寻址键可以,查 user_profiles 表就不行
  2. 不含业务规则 —— 没有权限判定、状态机、字段校验这类「改需求就要改它」的逻辑
  3. 被 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用户 / 设备 id11111111…
fingerprint令牌、授权码、cookiea3f5c8d21b04
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 共享密钥,不接受用户令牌。三条缺一不可:

  1. 时间戳 ±60 秒
  2. nonce 只认一次(防重放)
  3. 签名等时比较

签名串是 [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 会留在镜像历史里。