用 AI 构建个人记账 App:服务端实践篇

作者:袖梨 2026-09-13

个人记账 App 从本地原型演进为可跨设备使用的产品时,首先要解决账号、数据同步和访问安全问题。这里以“念账”的服务端改造为背景,从 Node.js 后端选型与工程分层入手,逐步梳理认证、验证码、接口约束、数据隔离、错误日志及测试体系的具体设计。

一、背景

念账是一个 Flutter 记账客户端,第一版是快速原型,账号和账本数据都存在本地 SQLite 里。本次给它配上服务端,顺带将账号体系的功能一并完善了。

flowchart LR
  C[念账客户端] -->|HTTP API| D[后端服务]
  D --> E[(PostgreSQL)]
  C -.仅存登录态.-> F[SharedPreferences]

二、技术选型

选择职责为什么选它
Node.js后端开发语言选择一个我比较熟悉的技术栈。除此之外,Node.js 生态成熟,配合 TypeScript 获得类型安全
FastifyHTTP 服务器框架API 设计严格,开发者明确定义请求和响应的数据格式,减少开发调试的不确定性。另外,它相对于 Express 性能更好
PostgreSQL关系型数据库本项目用 MySQL 也能做,但我更倾向一款类型和约束更严格、扩展能力更强的数据库
pnpm包管理器,安装与管理依赖相比 npm / yarn 更快、更省磁盘;依赖隔离严格,只有显式声明的依赖才能引入,避免误用别人顺带装进来的包
JSON Web Token(JWT)+ Refresh Token认证方案,识别用户身份并维持登录态JWT 无状态、验证签名即可识别身份,适合移动端;配合可主动吊销的 Refresh Token 维持登录态
Vitest + Supertest测试工具,跑单元测试与接口集成测试Vitest 原生支持 ECMAScript Modules(ESM)/ TypeScript、快且兼容 Jest;Supertest 以真实 HTTP 请求验证接口行为

三、工程结构

项目采用经典的三层分层架构(Controller–Service–Repository),并按 feature-first 组织目录:每个业务模块自包含从路由到表结构的完整链路,与业务无关的基础设施放 core/,跨模块共享的放 shared/。模块之间不互相引用,需要共享就下沉。

flowchart TD
  R[routes 路由] --> C[controller 请求/响应]
  C --> S[service 业务逻辑]
  S --> P[repository 数据访问]
  P --> SC[schema 表结构]

三层各司其职:routes + controller表现层,负责 HTTP 输入输出;service应用/业务层,承载业务逻辑;repository + schema持久层,用 Repository 模式把 SQL 和表映射封在内部。这套 controller → service → repository 的组合,和 Spring Boot、NestJS 等框架的默认分层一脉相承,是最主流的 Web 应用架构。

这套结构强调依赖单向:上层依赖下层,反向不允许,业务逻辑与技术细节尽量分离。controller 只依赖 service、不直接碰数据库,各层职责单一,业务逻辑也能独立测试。

四、认证体系

JWT(JSON Web Token)是一种把用户信息和有效期打包并签名的令牌格式,服务端验证签名即可识别身份、无需查库。Access Token 就是短期的 JWT;Refresh Token 则是长期的不透明随机串,库里只存哈希,因此可主动吊销。刷新时做轮换:旧 Refresh 立即作废、签发新的一对,防止重放;登出吊销对应 Refresh(幂等)。

sequenceDiagram
  participant C as 客户端
  participant S as 服务端
  participant DB as 数据库
  C->>S: 登录(账号 + 密码)
  S->>DB: 校验密码
  S->>DB: 签发 Access + Refresh,存 Refresh 哈希
  S-->>C: Access(15m)+ Refresh(7d)
  C->>S: 业务请求 + Access
  Note over C,S: Access 过期
  C->>S: 刷新(Refresh)
  S->>DB: 校验哈希 → 吊销旧 → 签发新对并落库
  S-->>C: 新 Access + 新 Refresh

客户端把提前刷新、401 兜底重放、并发单飞全部封进拦截器,业务层无感知。安全默认值:密码 bcrypt 加盐;登录失败统一报"账号或密码错误",不泄露账号是否存在;所有令牌只存哈希。

五、邮箱验证码:注册与找回密码的统一机制

注册和找回密码要做的事其实是同一个套路:先往邮箱发一个验证码,用户提交后校验,通过就授予相应权限——注册是"创建账号",找回密码是"允许改密码"。既然是同一套路,就用一套机制承载,只用一个"用途(purpose)"字段区分这两种场景,不用维护两套代码。

验证码发到邮箱时是明文,但落库前会先做哈希,库里不存明文。哈希时额外掺入三样东西做盐:服务端密钥、邮箱、用途。加盐一是避免不同用户、不同场景的相同验证码在库里长成一样,二是让攻击者即使拿到数据库,也无法反推出验证码。

flowchart TD
  A[发送验证码] --> B{用途}
  B -->|注册| C{邮箱未被占用?}
  B -->|找回密码| D{邮箱已注册?}
  C -->|否| E[明确业务报错]
  D -->|否| E
  C -->|是| F{频控通过?}
  D -->|是| F
  F -->|否| G[提示稍后再试]
  F -->|是| H[生成码 => 哈希落库 => 作废旧码 => 发邮件]
  H --> I{校验最新记录<br/>未消费/未过期/未超限?}
  I -->|否| J[失败]
  I -->|是| K{哈希匹配?}
  K -->|否| L[失败次数+1]
  K -->|是| M[消费验证码]

规则围绕"短时效、一次性、可频控":10 分钟有效、用后作废、同邮箱发送有间隔与每日上限、失败到阈值作废。

六、RESTful API

接口采用常见的 RESTful 约定:统一 /api 前缀、资源用复数、多词用短横线,路径嵌套最多两层(如"用户 / 记账条目",不再往"条目下的分类"套),让调用方看路径就能猜到用途。

每个请求都经过同一条流水线:认证中间件认出用户 → Zod 校验参数 → controller → service → repository。认证和校验集中在入口,业务代码不用重复处理。

用户数据隔离:所有读写都强制带当前用户,按 ID 操作也一样,而且这条约束只放在仓储层这一个通道上执行,不会在业务分支里漏掉。越权访问统一返回"资源不存在"而非"无权限",避免泄露资源是否存在。

flowchart LR
  Req[请求] --> Auth[认证中间件<br/>解析 Access Token]
  Auth --> Val[Zod 边界校验]
  Val --> Ctrl[controller]
  Ctrl --> Svc[service]
  Svc --> Repo[repository<br/>强制 user_id 过滤]
  Repo --> DB[(PostgreSQL)]

金额用整数分、日期用 epoch 天,避免浮点误差和时区歧义;时间范围查询强制有界;汇总交给数据库;响应统一成 {code, message, data}

七、错误处理、日志与配置

错误的传递和处理:业务层抛携带业务码、文案、状态的领域错误,全局处理器接住后分类返回;代码异常统一给笼统文案,堆栈只进日志,不暴露内部细节。

flowchart TD
  A[抛出异常] --> B{是 AppError?}
  B -->|是| C[按业务码 + 状态返回]
  B -->|否| D[统一 500 文案]
  D --> E[完整堆栈写日志]

日志用 Pino,结构化并携带请求 ID;开发可读、生产 JSON、测试静默。密码、令牌、验证码一律不落日志。配置全部走环境变量,有默认值或启动校验,敏感项不硬编码、不进仓库。

八、测试策略

单元测试覆盖纯逻辑和可隔离的业务规则,比如密码哈希、令牌校验、验证码处理、service 分支。外部依赖用替身注入,跑得快、定位准。

集成测试在真实应用实例上通过 Supertest 发 HTTP 请求,覆盖注册、登录、刷新、记账增删改查等完整链路,验证各层拼起来是否真的通。

原则:测业务规则而不是测框架,隔离外部依赖。

  • 数据库用独立测试库,每个用例执行前清空数据、彼此独立;
  • 用例命名统一为"方法 + 场景 + 期望";
  • 核心逻辑覆盖率目标不低于 90%。

相关文章

精彩推荐