
financial-services这个名字乍一看像是某个金融机构的官网入口实际上它是一个典型的聚合型后端服务项目。接手这个项目的时候需求方只给了一页纸核心诉求很直接把用户分散在不同数据源的账户余额、交易流水、支出分类和预算执行情况统一收拢到一个服务里对外输出干净一致的数据结果。听上去不复杂但真正落地时牵扯到的细节非常多账户怎么抽象、流水怎么去重、分类规则怎么定义、金额用什么类型存、时区怎么处理、接口怎么在高并发下保持稳定。这篇文章就把financial-services从设计到上线的完整过程拆开讲一遍包括踩过的坑和最终的取舍。这篇内容适合三类人看第一类是准备做个人财务类后端服务、但还没把数据模型理清楚的人第二类是团队里需要建设统一接口层、希望参考一个完整落地样例的开发者第三类是想了解金融服务类项目在数据标准化和安全方面到底有哪些隐性工作的人。我尽量用大白话讲清楚每一步为什么这么做也会给出可以直接落地的目录结构、核心代码和接口设计。1. 项目起源为什么我会做一个名叫 financial-services 的聚合服务1.1 业务场景与需求边界先说业务侧。当时产品想做一个个人财务健康看板用户授权后系统能聚合他名下多个账户的余额、最近几个月的交易流水给他算月度结余、固定支出占比、消费趋势。听上去很美好但产品拿到的真实数据非常原始每个数据源的字段叫法都不一样。有的把支出记为负数有的记成正数但用type字段区分有的交易时间是本地时间有的直接存 UTC有的有商户名有的只有一串参考编号。如果这些数据不经过任何处理直接丢给前端前端根本没法画图更别说算预算了。所以financial-services的第一任务不是“存数据”而是“把一堆乱七八糟的数据变成干净、口径一致的结构”。为了不让改造无穷无尽我把范围固定在三层接入层、标准化层、计算层。接入层负责和数据源方通信只做协议转换标准化层把不同来源的账户和交易映射到统一模型计算层只依赖标准化之后的数据负责分类、汇总、预算和风险评估。这样每一层都能独立测试替换数据源方时只影响接入层不会污染后面的计算逻辑。实际开发中这个分层救了我们很多次有一个数据源在下半年改了接口字段我们只需要动接入层的映射代码分类引擎和报表模块一行没改。1.2 技术栈选型的取舍技术栈上我最终选了 NestJS TypeORM PostgreSQL Redis。原因挺实际团队主栈是 TypeScript前后端共享类型定义可以省掉一大批沟通成本。NestJS 自带模块化、依赖注入和成熟生态适合搭一个多模块的服务。选型时有几个候选方案也被讨论过。Python FastAPI 性能好、学习成本低但和团队现有代码栈割裂光维护两套语言就会让后续扩展变得痛苦。Spring Boot 稳定性高但对这个体量的项目来说太重了。最后确定 NestJS还有一个隐藏原因NestJS 的模块边界和装饰器风格很适合做领域模块划分账户模块、交易模块、风控模块可以非常清晰地隔离。数据库选 PostgreSQL 而不是 MySQL主要是看中它的数值精度控制和 JSON 支持。金融计算里金额不能出现浮点误差PostgreSQL 的NUMERIC类型可以精确保存到小数点后若干位同时外部数据源传过来的原始字段经常带着很多不规则的扩展信息用JSONB存原始载荷既方便回溯也方便后续扩展字段。Redis 在这个项目里的角色很明确一是缓存用户聚合结果二是做幂等去重的临时存储三是作为异步任务的队列。像月度报表这种聚合计算如果每次都实时扫描几万条流水用户会明显感到卡顿所以第一次算完后把结果缓存一段时间后续直接读缓存。2. 核心模块拆解账户、交易与数据的标准化2.1 统一数据模型是整件事的地基统一模型我最后拆成了五张核心表account、transaction、category、budget、risk_event。每张表的字段都经过反复争论最后定下来的版本是够用且不冗余的。account表存账户信息关键字段包括userId、sourceType、sourceAccountId、accountType、balance、currency、status。这里有个很容易踩的坑sourceAccountId不能当主键因为不同数据源下同一个用户可能有相同的 ID必须用内部自增 ID 或雪花 ID 做主键再对(sourceType, sourceAccountId, userId)建立唯一索引。transaction表是流水的核心字段包含accountId、transTime统一转成 UTC 存TIMESTAMPTZ、amount用NUMERIC(20, 2)、currency、categoryId、rawData以及用于去重的fingerprint。金额在落库前就已经转成了统一方向正数表示支出负数表示收入。这样后续任何时候做汇总都不需要再判断方向直接对amount做正负求和就行。// transaction.entity.ts import { Entity, PrimaryGeneratedColumn, Column, Index } from typeorm; Entity(transaction) Index([accountId, transTime]) Index([accountId, categoryId, transTime]) export class Transaction { PrimaryGeneratedColumn(bigint) id: string; Column() userId: string; Column() accountId: string; Column(timestamptz) transTime: string; Column(numeric, { precision: 20, scale: 2 }) amount: string; Column({ length: 3 }) currency: string; Column({ nullable: true }) categoryId: string; Column({ unique: true }) fingerprint: string; Column(jsonb, { nullable: true }) rawData: Recordstring, any; }category表维护分类树根分类分为收入、支出、转账三类下面再挂餐饮、交通、购物、工资、理财等叶子分类。budget表保存用户每个月的预算额度按categoryId和月份做唯一约束。risk_event表则记录异常交易和风控触发事件后面安全部分会展开。2.2 交易分类引擎的设计交易分类是用户感知最强的功能。商户名“星巴克”可能被归到“餐饮”“滴滴出行”归到“交通”但分类规则如果只靠一条正则很快就会被各种变体打脸。我的做法是维护一个分类规则表规则按优先级排列每条规则包含匹配字段、关键字列表、匹配方式包含、前缀、正则命中后的分类 ID以及一个置信度。匹配时先对交易流水做cleanField去空格、转小写、去掉特殊符号然后用规则顺序匹配。为了效率把所有规则加载到内存并预编译成一条条正则处理大批量交易时不要每条流水都查数据库。核心逻辑大致是这样的// category-engine.ts interface CategoryRule { id: string; categoryId: string; field: merchantName | description; matchType: contains | prefix | regex; keyword: string; priority: number; confidence: number; } const rules: CategoryRule[] loadRulesFromCache(); export function matchCategory(transaction: CleanedTransaction) { const text transaction[rule.field].toLowerCase().trim(); for (const rule of rules.sort((a, b) a.priority - b.priority)) { let hit false; if (rule.matchType contains) { hit text.includes(rule.keyword.toLowerCase()); } else if (rule.matchType prefix) { hit text.startsWith(rule.keyword.toLowerCase()); } else if (rule.matchType regex) { hit new RegExp(rule.keyword, i).test(text); } if (hit) { return { categoryId: rule.categoryId, confidence: rule.confidence, }; } } return { categoryId: null, confidence: 0 }; }匹配完不是直接写死categoryId还要算置信度。比如“淘宝”匹配到的分类可能是“购物”也可能是“数码”置信度低于 0.8 就标记为pending由用户后续手动修正。每次修正都会作为反馈样本定期重放规则训练。这个机制上线一个月后自动分类准确率从最初的 76% 提升到了 92%效果非常明显。2.3 金额精度与币种处理金额处理是这个项目里最容易出事故的地方。我的原则是所有金额在存储和计算层面都以最小货币单位分的整数来流转对外展示时才转成元。虽然数据库NUMERIC能存小数但规模化计算时整数更可控也能避免各种语言里浮点数相加的隐式精度丢失。这里要特别提醒一点永远不要在 JavaScript 里直接用number类型存金额。0.1 0.2的问题大家都听过但在真实对账时差一分钱就够你排查半天。我用的是字符串类型配合decimal.js做计算数据库层再交回NUMERIC这样首尾都是精确的。汇率上由于是多币种聚合我加了一张exchange_rate表每天定时拉取并缓存汇率汇总时统一折算成用户设定的baseCurrency。这里有个细节汇率变化可能导致昨天的结余和今天看到的结余不一样所以报表要把“快照时点”一起存下来。否则用户会发现同一个月的支出总额在不同日期打开不一样财务产品最怕这种“数据自己会动”的观感。3. 实操过程从空目录到可消费的 financial-services API3.1 工程初始化与目录划分工程我直接用 Nest CLI 初始化命令很简单nest new financial-services然后安装 TypeORM、pg、ioredis 等依赖。目录划分上我坚持按领域模块划分不按技术类型划分。因为financial-services会逐渐长出账户、交易、分类、报表、风控多个领域如果所有 controller 堆在一个目录、所有 service 堆在另一个目录后期改一个功能可能要跨五六个文件夹才能找全。src/ modules/ account/ account.controller.ts account.service.ts account.repository.ts entities/ transaction/ transaction.controller.ts transaction.service.ts category-engine.ts entities/ category/ category.controller.ts category.service.ts entities/ budget/ budget.service.ts entities/ report/ report.controller.ts report.service.ts risk/ risk.service.ts entities/ common/ decorators/ filters/ interceptors/ utils/ config/模块内部再拆controller / service / repository三层公共部分放common。这种分法的好处是当你要单独扩展分类引擎时不会顺手改到交易模块的 repository。边界清楚代码 review 也会轻松很多。3.2 核心 API 设计与调用方式对外接口我按资源设计部分核心接口如下表方法路径用途关键参数GET/api/v1/accounts获取当前用户账户列表includeBalancetruePOST/api/v1/accounts/sync手动触发账户同步sourceTypePUT/api/v1/transactions/{id}/category修正流水分类categoryId, confidenceGET/api/v1/reports/monthly月度收支报表year, month, baseCurrencyPOST/api/v1/transactions/import批量导入流水idempotencyKey分页我统一用 cursor 分页不用页码。用户拉到上万条流水后深分页会越来越慢而 cursor 分页基于(accountId, transTime)索引定位不管翻到第几页都能保持接近常数级的查询时间。举个例子月度报表接口内部会做三件事先查当月所有transaction按category聚合再把收入、支出分别汇总最后和预算表对比计算剩余额度。返回体里我会把“计算时点generatedAt”和“币种”都带上方便前端做缓存和展示。接口响应体保持扁平化不嵌套太深否则前端和文档都要跟着遭殃。3.3 联调时最容易忽视的协作契约联调阶段最容易出事的就是接口字段语义不一致。比如createTime到底表示创建记录的时间还是交易发生的时间我要求所有接口在文档里写清楚字段语义并在测试用例里专门覆盖边界场景跨年交易、跨月交易、时区变化、金额为 0 的交易。另一个容易忽略的问题是幂等。同步任务可能会被前端重复触发也可能数据源方回调重复推送同一笔交易。我在transaction表上加了fingerprint唯一索引用sourceType sourceAccountId 交易时间戳 金额 原始参考号拼成一个哈希。重复插入直接命中唯一约束返回已有 id不会产生重复流水。前端拿到相同 id 就知道这是一次重复请求可以安心忽略。4. 安全与合规金融服务项目必须越过的那道坎4.1 密钥管理、鉴权与敏感数据脱敏金融服务类项目安全不是加分项而是必要条件。密钥管理上所有数据源的appKey / appSecret不入代码、不入环境变量明文而是放在配置中心或云加密存储里进程启动时通过密钥管理接口拉取内存中使用后及时释放。日志里禁止打印任何密钥这一条靠代码 review 和日志过滤双重保证。鉴权我用了 JWT Refresh Token短效的 access token 10 分钟过期refresh token 走 Redis 白名单机制用户注销后立刻失效。金融服务接口不建议用裸 JWT 做长期凭证否则一旦泄露就是长期的访问权限后续处理非常被动。脱敏方面凡是返回给前端的账户号、手机号、邮箱等字段统一脱敏比如账号显示成尾号 1234邮箱显示成f***example.com。数据库存储时敏感字段用 AES 加密但这里有个很关键的取舍不要把搜索键加密否则按账号反查用户会变成全表扫描。解决方案是加一个独立的hash字段用于查询密文只用于展示时解密。// sensitive-field.ts import { createCipheriv, createHash, randomBytes } from crypto; const algorithm aes-256-gcm; export function encryptField(plainText: string, key: Buffer) { const iv randomBytes(12); const cipher createCipheriv(algorithm, key, iv); const encrypted Buffer.concat([ cipher.update(plainText, utf8), cipher.final(), ]); const tag cipher.getAuthTag(); return ${iv.toString(hex)}:${tag.toString(hex)}:${encrypted.toString(hex)}; } export function hashField(plainText: string) { return createHash(sha256).update(plainText).digest(hex); }4.2 操作审计与风控规则user_ops_audit表记录所有关键操作谁在什么时间、对哪个账户、调了什么接口、改了哪个流水分类、结果如何。审计日志不是为了追责而是出了问题能快速定位是否由误操作触发这在财务场景里至关重要。风控规则我做了几类硬规则单账户单日同步超过 20 次自动告警并限制后续同步。批量导入接口必须有幂等键且单批不超过 5000 条。修改账户余额、调整历史交易等高风险动作强制二次校验。同一时间窗口内高频读取用户报表触发限流。不需要一开始就上机器学习模型简单的阈值规则就能拦住绝大部分误操作。等数据积累多了再逐步加入更细粒度的行为特征。4.3 数据使用边界不是一句口号再补一个很多人忽略的点即便用户授权了也不是所有字段都能拿到。数据源方会把返回字段分成核心字段、扩展字段和敏感字段接入层必须做字段级白名单不取业务不需要的数据。日志、缓存、测试账号三条链路都要做数据最小化。像账户凭证这类东西如果业务不需要一律不落库只在跟数据源通信前的内存中短暂存在。我们内部有一条铁律没有业务依据的数据一律不存宁可后续再加也不要一开始就囤一堆用不上的敏感字段。这个原则会在上线评审时帮团队省掉很多麻烦。5. 常见问题与排查技巧实录5.1 时间与金额精度踩坑实录第一个典型问题是时区。数据源 A 返回的交易时间带时区偏移数据源 B 返回的是 UTC 字符串数据源 C 干脆返回本地时间且没有时区描述。我在接入层就统一把字段转成 UTC ISO 8601 字符串入库前统一转 PostgreSQL 的timestamptz。查询时再按客户端时区显示。如果不这么做月度报表会在每月 1 号凌晨出现跨月和跨年的错位。第二个问题是精度。最开始我用number类型接收金额结果一次对账差了 0.01 元排查下来是浮点数相加导致的。后来全部改成以分为单位的整数存储和计算才彻底解决这类问题。以分为单位还有一个好处前端显示时可以按用户习惯做千分位不会受到后端语言精度差异的影响。场景错误做法正确做法金额存储JavaScript number 直接存分为单位整数或用 NUMERIC 字符串交易时间混用本地时间统一 UTC ISO 8601 入库月度汇总实时按当前汇率折算保存快照时点和当时汇率流水去重靠人工判断生成 fingerprint 唯一索引5.2 多数据源状态不统一的对账思路不同数据源返回的账户状态五花八门A 返回active / inactiveB 返回0 / 1C 干脆没有状态字段。我在接入层做了映射表把这些值归一到统一状态枚举。对账时要留意有些数据源是 T1 更新有些是准实时同一时刻的余额对不上是正常的。所以每个同步任务都要记录syncAt时间比对时按同步时间取快照而不是强行要求所有数据源实时一致。对账周期我设为每天凌晨跑一次生成差异报告差异超过阈值才触发人工复核。这样既不会漏掉数据源侧更新延迟导致的假差异也能及时发现真正的问题。5.3 性能排查慢查询、连接池与缓存穿透上线后遇到一个慢查询月度报表接口在数据量达到几十万条时耗时超过 3 秒。explain之后发现accountId和transTime的查询没有走到复合索引因为WHERE只用了accountId排序用了transTime优化器没有选到预期索引。解决方案是加一个(accountId, transTime DESC)复合索引再为分类聚合加一个(accountId, categoryId, transTime)复合索引。修改后 p95 从 3 秒降到了 120 毫秒。另一个坑是 Redis 连接数。缓存服务刚上线时每个请求都新建连接压测时直接把 Redis 打崩。改成连接池后连接复用同时在 service 层加了本地热点缓存同一用户的报表在 10 秒内重复请求不会打到数据库。缓存穿透也处理了一下如果用户本月没有流水我依然会缓存一个 30 秒的空结果避免大量空查询直接压到 DB。// report.service.ts import { Injectable } from nestjs/common; import Redis from ioredis; Injectable() export class ReportService { private readonly redis: Redis; async getMonthlyReport(userId: string, year: number, month: number) { const cacheKey report:${userId}:${year}:${month}; const cached await this.redis.get(cacheKey); if (cached) return JSON.parse(cached); const result await this.calculateMonthlyReport(userId, year, month); await this.redis.set(cacheKey, JSON.stringify(result), EX, 300); return result; } }6. 写在最后实际运维下来的几点体会这个项目跑到现在我自己最大的体会是金融服务项目里“正确”永远比“快”重要。你可以很快地搭出一套 CRUD但一旦涉及钱数据口径、边界情况、幂等、审计这四件事必须较真。每条规则、每个字段背后可能都对应一次用户投诉或一次财务对账故障。如果以后要继续扩展我会优先做三件事第一把交易分类引擎改成可学习的规则平台让运营能够在界面上调整规则并实时生效而不是每次改正则都要发版。第二把报表的多币种折算改成可选择历史快照版本让用户能回看某一天的口径结果而不是永远以当前汇率计算。第三接入更多数据源方的同时补一个独立对账服务定期比对本地流水和数据源侧汇总自动枚举差额。最后分享一个很实用的小技巧凡是对接外部金融服务数据源先把原始响应原样保存下来再去落业务字段。很多问题当场查不出来等对账发现异常时原始载荷就是最可靠的证据。这个习惯帮我们省去了很多扯皮的时间也让我在排查问题时不再靠猜。