1. 从 "financial-services" 这个标题说起:一个被低估的工程化命题
第一次看到financial-services这个项目标题,很多人第一反应是"这不就是个行业分类吗"。但如果你真的在金融科技一线待过,就会明白这四个字背后压着的东西有多重——它不是某个具体产品,而是一整套围绕资金、账务、交易、风控、合规构建的软件工程体系。我做过几个跟支付清结算、账务中台相关的项目,每次立项时最头疼的从来不是"能不能写出来",而是"写出来之后账对不对、钱错没错、审计能不能过"。
这个标题之所以值得单独拆一篇,是因为它天然带着几个硬约束:数据不能丢、金额不能错、操作要留痕、状态要可追溯。普通业务系统里一个字段写错了,改一下就行;金融系统里一个金额字段精度丢了,可能就是几百万的对账差异。所以financial-services这类项目的核心难点,从来不在业务逻辑本身有多复杂,而在于如何用工程手段把"正确性"变成一种可验证、可复现、可审计的默认属性。
这篇文章适合三类人看:一是刚接触金融业务系统、想搞清楚"和普通 CRUD 到底差在哪"的后端同学;二是正在做账务、支付、清结算模块,想找一套可落地工程规范的开发者;三是对Claude、Cowork、Managed Agents API、plugin这些近期热词感兴趣,想知道它们能不能用在金融场景里的技术选型者。我会把标题背后的领域拆开,讲清楚核心设计思路、关键实现细节、实操步骤,以及我自己踩过的坑。
需要先说明一点:金融领域的具体业务规则(比如某些监管口径、特定清算规则)因地区和机构而异,本文讲的是通用的工程方法论和架构思路,具体参数和规则请以你所在机构的实际规范为准。下面所有内容都是基于常见工程实践做的合理推演,不是某个特定机构的内部文档。
2. 核心领域拆解:financial-services 到底在解决什么问题
2.1 金融系统的四个本质特征
要理解financial-services这个领域,先得抓住它和普通业务系统的本质区别。我把它归纳成四条,这四条决定了后面所有的架构选择。
第一是金额的精确性。普通系统用float存价格,误差在小数点后十几位,用户根本感知不到。但金融系统里,0.1 + 0.2 != 0.3这种浮点误差是致命的。所以金融系统一律用定点数表示金额,通常以"分"或更小的单位作为整数存储,或者用decimal类型。这个选择不是偏好问题,是正确性问题。
第二是状态的可追溯。一笔交易从发起到最终入账,中间可能经历"待处理、处理中、成功、失败、冲正、退款"等多个状态。普通系统可能只存一个当前状态字段,但金融系统需要完整的状态流转历史,因为出了纠纷要能还原"这笔钱在什么时间点处于什么状态、被谁改的、依据是什么"。
第三是操作的幂等性。网络会抖动、客户端会重试、消息会重复投递。如果一笔扣款请求被重复执行两次,用户就被扣了两次钱。所以金融系统的每一个写操作都必须幂等——同一个请求 ID 执行多次,结果和执行一次完全一样。
第四是账务的平衡性。会计上有个铁律叫"有借必有贷,借贷必相等"。任何一笔资金变动,都要在至少两个账户上同时记录,且总额守恒。这不是形式主义,而是用数学约束来发现错误——如果借贷不平,说明系统出 bug 了,能第一时间报警。
2.2 典型模块划分与职责边界
一个完整的financial-services体系,通常包含下面这些模块。我用表格把职责和关键点列清楚,方便你对照自己项目做裁剪。
| 模块 | 核心职责 | 关键工程点 |
|---|---|---|
| 账户服务 | 管理用户/商户账户、余额、冻结 | 余额计算、并发扣减、账户状态机 |
| 交易服务 | 受理交易请求、编排流程 | 幂等、状态机、超时补偿 |
| 账务服务 | 记账、复式记账、日终结算 | 借贷平衡、分录、对账 |
| 支付网关 | 对接外部渠道、路由 | 渠道路由、重试、降级 |
| 风控服务 | 实时规则、额度、黑名单 | 低延迟、规则引擎、可解释 |
| 对账服务 | 内部账 vs 外部账核对 | 差异识别、自动/人工处理 |
| 审计服务 | 操作留痕、合规查询 | 不可篡改、可检索 |
这里要特别强调账户服务和账务服务的分离。很多新手会把"余额"和"账务分录"混在一起,觉得余额就是分录的汇总,没必要分开。但实际工程中,余额是高频读、低频写的,需要缓存和快速查询;而分录是只增不改的流水,需要完整性和可审计。两者性能特征和一致性要求完全不同,混在一起会导致要么余额查询慢,要么分录被意外修改。我见过一个项目就是因为把余额直接存在分录表里做sum,结果账户一多查询就崩了。
2.3 为什么这个领域对工程规范要求极高
金融系统的 bug 成本和其他系统不是一个量级。一个电商系统挂了,用户骂两句,修好就行;一个支付系统挂了,可能直接造成资金损失,还要面对监管问询和用户索赔。这种成本差异,倒逼出一套非常严格的工程规范。
具体体现在几个方面:代码评审必须双人以上,涉及资金计算的代码更是要逐行看;测试覆盖率要求极高,尤其是边界条件,比如金额为 0、金额为负、金额超过上限、并发扣减同一账户;上线必须灰度,先小流量验证,再逐步放量;任何变更都要有回滚方案,而且回滚方案本身要经过验证。这些规范听起来繁琐,但每一条背后都是真实事故换来的。
3. 架构设计思路:为什么这样选,而不是那样选
3.1 分层架构与领域边界
做financial-services,我强烈建议用清晰的分层 + 领域边界。常见的分层是:接入层(API 网关)、应用层(编排)、领域层(核心业务)、基础设施层(存储、消息、外部渠道)。这个分层不新鲜,但金融场景下每一层的职责要卡得非常死。
接入层只做协议转换、鉴权、限流,绝对不写业务逻辑。我见过有人在网关里做金额校验,结果网关升级时校验规则丢了,直接放了一批非法请求进来。应用层负责编排——比如一笔支付要依次调用风控、账户冻结、渠道扣款、账务记账,这些步骤的先后顺序和补偿逻辑放在应用层。领域层是核心,账户、交易、账务的模型和规则都在这里,这一层不依赖任何框架和外部服务,保证可测试性。基础设施层封装数据库、消息队列、外部渠道的调用细节。
为什么这么强调边界?因为金融系统的变更频率和风险等级不同。业务规则可能经常调整,但账务的核心算法应该极其稳定。如果边界不清,改一个业务规则可能不小心动到账务逻辑,风险就失控了。
3.2 一致性方案选型:强一致还是最终一致
这是金融系统设计里最纠结的问题之一。我的经验是:同一笔交易内部用强一致(本地事务),跨服务之间用最终一致(可靠消息 + 补偿)。
举个例子,用户下单支付,涉及"扣余额"和"记流水"两个操作。如果这两个在同一个数据库、同一个事务里,那就用本地事务保证强一致,简单可靠。但如果"扣余额"在账户服务、"记流水"在账务服务,跨了服务,就不能用分布式事务硬扛——性能差、复杂度高、还容易死锁。这时候用可靠消息:账户服务扣款成功后,发一条消息到消息队列,账务服务消费消息记账。消息要保证"至少一次投递",账务服务消费时要幂等。
那如果账务服务消费失败怎么办?这就需要补偿机制。常见做法是记录一个"待处理"的中间状态,定时任务扫描超时未完成的交易,触发查询或冲正。这里的关键是每一步都要有明确的、可执行的补偿动作,而不是简单重试。比如渠道扣款超时,不能盲目重试(可能已经扣了),而要先查询渠道状态,确认后再决定是继续还是冲正。
3.3 幂等设计的三种落地方式
幂等是金融系统的生命线,我总结过三种落地方式,各有适用场景。
第一种是唯一索引。给请求 ID 建唯一索引,重复插入直接报错。这种方式最简单,适合"创建类"操作,比如创建一笔交易。缺点是只能防重复创建,不能防重复执行。
第二种是状态机 + 版本号。每个业务对象有状态和版本号,更新时带上版本号做乐观锁。比如交易从"待处理"到"处理中",只有当前状态是"待处理"时才能更新。这种方式适合"状态流转类"操作,能防止重复流转。
第三种是幂等表。单独建一张表记录"请求 ID -> 处理结果",每次请求先查表,有记录就直接返回结果。这种方式最通用,适合"执行类"操作,比如扣款。缺点是每次都要多一次查询,但换来的是绝对的幂等保证。
实际项目中,我通常三种结合使用:入口用幂等表挡一层,核心对象用状态机 + 版本号,创建操作用唯一索引兜底。多层防护,任何一层失效都还有下一层。
4. 核心细节解析与实操要点
4.1 金额处理:从存储到计算的完整规范
金额处理是金融系统最容易出问题的地方,我把完整规范列一下。
存储层:金额一律用整数存储,单位是"分"或更小。比如 100.50 元存成 10050 分。数据库字段用BIGINT,不用DECIMAL也行,但整数运算更快、更不容易出错。如果业务需要更小单位(比如某些场景要精确到厘),就统一放大倍数,全系统保持一致。
计算层:所有金额运算用整数运算,或者用BigDecimal(Java)/Decimal(Python)这类定点数类型。绝对禁止用 float/double。我见过一个项目用 double 算利息,结果累计误差导致对账差了几十块,查了三天才定位到。
传输层:API 传输金额时,用字符串而不是数字。因为 JSON 的数字类型在不同语言里精度处理不一样,JavaScript 的Number只有 53 位精度,大金额会丢精度。用字符串传输,接收方自己解析成定点数,最安全。
展示层:只在最后展示给用户时才转成"元",且要统一格式化。这里要注意四舍五入规则,金融场景通常用"四舍六入五成双"(银行家舍入),而不是简单的四舍五入,避免系统性偏差。
from decimal import Decimal, ROUND_HALF_EVEN def fen_to_yuan(fen: int) -> str: # 分转元,保留两位,银行家舍入 yuan = Decimal(fen) / Decimal(100) return str(yuan.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)) def yuan_to_fen(yuan_str: str) -> int: # 元转分,字符串解析避免浮点误差 yuan = Decimal(yuan_str) return int((yuan * Decimal(100)).quantize(Decimal('1'), rounding=ROUND_HALF_EVEN))注意:
Decimal的构造一定要用字符串,用 float 构造会先把 float 的误差带进来,等于白用。
4.2 账户余额的并发扣减
账户余额扣减是典型的并发场景,多个请求同时扣同一个账户,处理不好就会超扣。常见方案有三种,我按推荐度排序。
方案一:数据库行锁 + 条件更新。用UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?,靠数据库的行锁和balance >= ?条件保证不超扣。这种方式简单可靠,适合并发量不极端的场景。关键是WHERE里必须带balance >= ?,否则会扣成负数。
方案二:乐观锁 + 重试。读余额和版本号,计算新余额,更新时带版本号条件。失败就重试。适合读多写少、冲突不激烈的场景。缺点是冲突多时重试次数爆炸。
方案三:预扣 + 确认。先冻结(预扣)一部分额度,实际扣款时从冻结额度里扣。这种方式把"扣减"拆成两步,降低了单次操作的冲突概率,适合高并发场景。但要注意冻结额度的超时释放,否则会一直占着。
我实际项目里,中等并发用方案一,高并发用方案三。方案二用得少,因为金融场景冲突往往比较集中(比如热门商户),乐观锁重试成本太高。
4.3 状态机设计:让交易流转可控可查
交易状态机是金融系统的骨架。设计要点有三个:状态要完备、流转要受限、历史要留痕。
状态要完备,意思是所有可能的情况都要有对应状态,不能有"未知状态"。比如一笔支付,至少要有:INIT(已创建)、PROCESSING(处理中)、SUCCESS(成功)、FAILED(失败)、REVERSED(已冲正)、REFUNDED(已退款)。缺了哪个,出问题时就没法准确描述。
流转要受限,意思是每个状态只能转到特定的下一个状态。比如SUCCESS不能直接转PROCESSING,只能转REFUNDED。这个约束要在代码里硬编码,不能靠约定。我通常用一个状态转移表来定义:
| 当前状态 | 允许的下一状态 |
|---|---|
| INIT | PROCESSING, FAILED |
| PROCESSING | SUCCESS, FAILED |
| SUCCESS | REFUNDED, REVERSED |
| FAILED | INIT(重试), REVERSED |
历史要留痕,意思是每次状态变更都要记录一条流水,包含时间、操作人、原因。这张流水表只增不改,是审计和对账的重要依据。
4.4 对账:发现差异比避免差异更重要
很多人以为对账是"确认没问题",其实对账的核心价值是发现差异。再好的系统也会有差异,关键是要能及时发现、快速定位。
对账的基本流程是:拉取内部账务流水和外部渠道流水,按交易号匹配,比对金额和状态。匹配上的标记为"已核对",匹配不上的进入差异池。差异分几类:内部有外部无(可能渠道还没返回)、外部有内部无(可能内部漏记)、金额不一致(可能计算错误)、状态不一致(可能状态同步延迟)。
处理差异的原则是:先自动、后人工,先查询、后冲正。能通过查询渠道状态自动解决的,自动解决;解决不了的,进人工队列。人工处理时,绝对不能直接改数据,而要通过发起一笔冲正或补记交易来修正,保证所有变动都有流水。
实操心得:对账任务一定要有幂等和断点续跑能力。我见过对账任务跑到一半挂了,重跑时把已核对的数据又核对了一遍,导致重复处理。正确做法是记录对账批次和进度,重跑时从断点继续。
5. 实操过程:从零搭建一个最小可用的账务核心
5.1 环境与依赖准备
假设我们用 Python + PostgreSQL 来搭一个最小账务核心,验证核心概念。选 Python 是因为写起来快,适合验证;选 PostgreSQL 是因为它的事务和约束能力强,适合金融场景。
# 建库建表 psql -U postgres -c "CREATE DATABASE fin_core;"核心表设计如下。账户表存余额,分录表存流水,幂等表防重复。
CREATE TABLE account ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, balance BIGINT NOT NULL DEFAULT 0, -- 单位:分 frozen BIGINT NOT NULL DEFAULT 0, -- 冻结金额 version INT NOT NULL DEFAULT 0, status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMP NOT NULL DEFAULT now(), UNIQUE(user_id) ); CREATE TABLE entry ( id BIGSERIAL PRIMARY KEY, txn_id VARCHAR(64) NOT NULL, account_id BIGINT NOT NULL, direction VARCHAR(8) NOT NULL, -- DEBIT / CREDIT amount BIGINT NOT NULL, balance_after BIGINT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now(), UNIQUE(txn_id, account_id, direction) ); CREATE TABLE idempotent ( request_id VARCHAR(64) PRIMARY KEY, result TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() );这里几个设计点值得说:balance和frozen分开存,方便做预扣;entry表用(txn_id, account_id, direction)做唯一索引,天然防重复记账;idempotent表用request_id做主键,防重复请求。
5.2 转账核心逻辑实现
转账是最典型的金融操作,涉及两个账户、两笔分录、一个事务。下面是最小实现。
import psycopg2 from decimal import Decimal def transfer(conn, request_id, from_user, to_user, amount_fen): with conn.cursor() as cur: # 1. 幂等检查 cur.execute("SELECT result FROM idempotent WHERE request_id = %s", (request_id,)) row = cur.fetchone() if row: return row[0] # 2. 锁定两个账户(按 id 排序避免死锁) cur.execute(""" SELECT id, balance FROM account WHERE user_id IN (%s, %s) AND status = 'ACTIVE' ORDER BY id FOR UPDATE """, (from_user, to_user)) accounts = {r[0]: r[1] for r in cur.fetchall()} # ... 校验账户存在、余额充足 # 3. 扣减和增加 cur.execute("UPDATE account SET balance = balance - %s WHERE user_id = %s", (amount_fen, from_user)) cur.execute("UPDATE account SET balance = balance + %s WHERE user_id = %s", (amount_fen, to_user)) # 4. 记两笔分录 txn_id = request_id cur.execute("""INSERT INTO entry(txn_id, account_id, direction, amount, balance_after) VALUES (%s, %s, 'DEBIT', %s, %s)""", (txn_id, from_id, amount_fen, from_balance_after)) cur.execute("""INSERT INTO entry(txn_id, account_id, direction, amount, balance_after) VALUES (%s, %s, 'CREDIT', %s, %s)""", (txn_id, to_id, amount_fen, to_balance_after)) # 5. 记录幂等结果 cur.execute("INSERT INTO idempotent(request_id, result) VALUES (%s, %s)", (request_id, 'SUCCESS')) conn.commit() return 'SUCCESS'这段代码有几个关键点。第一,锁账户时按 id 排序,避免两个并发转账互相等待造成死锁。第二,扣减和增加在同一个事务里,要么都成功要么都失败。第三,分录记录balance_after,这样任何时候都能还原账户余额,也方便对账。第四,幂等检查在最前面,重复请求直接返回。
5.3 参数计算:手续费与利息的处理
金融系统里手续费和利息的计算,是另一个容易出错的点。核心原则是计算过程用高精度,结果按规则舍入。
以手续费为例,假设费率是 0.6%,最低 1 分,最高 50 元。计算过程:
def calc_fee(amount_fen: int, rate: Decimal = Decimal('0.006')) -> int: # 用 Decimal 计算,避免浮点误差 fee = (Decimal(amount_fen) * rate).quantize(Decimal('1'), rounding=ROUND_HALF_EVEN) fee = int(fee) # 应用上下限 fee = max(fee, 1) # 最低 1 分 fee = min(fee, 5000) # 最高 50 元 = 5000 分 return fee利息计算更复杂,涉及计息天数、计息基数(通常一年按 360 天或 365 天)。这里的关键是计息规则要写进配置,不能硬编码,因为不同产品规则不同。我通常把计息规则抽象成一个策略接口,不同产品实现不同策略。
注意:手续费和利息的舍入方向要明确。是向上取整、向下取整还是四舍五入,直接影响用户感知和机构收入。这个规则必须和业务方确认清楚,写进文档,不能想当然。
5.4 日终结算与对账任务
日终结算是对账的基础。每天固定时间(通常是凌晨),跑一个结算任务,做几件事:汇总当日交易、核对借贷平衡、生成对账文件、清理过期数据。
借贷平衡校验很简单:SELECT SUM(CASE WHEN direction='DEBIT' THEN amount ELSE -amount END) FROM entry WHERE created_at::date = ?,结果应该是 0。如果不是 0,说明有 bug,立即报警。
对账任务要和外部渠道对。流程是:拉取渠道对账单,和内部流水按交易号匹配,生成差异报告。差异报告要包含:差异类型、交易号、内部金额、外部金额、建议处理方式。
def reconcile(internal_entries, channel_entries): internal_map = {e['txn_id']: e for e in internal_entries} channel_map = {e['txn_id']: e for e in channel_entries} diffs = [] for txn_id in set(internal_map) | set(channel_map): i = internal_map.get(txn_id) c = channel_map.get(txn_id) if i and not c: diffs.append({'txn_id': txn_id, 'type': 'INTERNAL_ONLY'}) elif c and not i: diffs.append({'txn_id': txn_id, 'type': 'CHANNEL_ONLY'}) elif i['amount'] != c['amount']: diffs.append({'txn_id': txn_id, 'type': 'AMOUNT_MISMATCH', 'internal': i['amount'], 'channel': c['amount']}) return diffs对账任务一定要幂等,重跑不会重复处理。做法是记录对账批次,每个批次处理固定范围的数据,重跑时跳过已完成的批次。
6. 常见问题与排查技巧实录
6.1 金额相关问题的排查思路
金额问题是最难查的,因为往往涉及多个环节。我总结了一套排查顺序:先看流水,再看余额,最后看计算。
看流水,就是查entry表,看这笔交易的借贷分录是否平衡、金额是否正确、时间是否合理。如果流水本身就不对,那问题在记账环节。看余额,就是查account表的balance和entry表汇总是否一致,不一致说明有直接改余额的操作绕过了记账。看计算,就是复现手续费、利息的计算过程,看舍入规则是否一致。
常见问题速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 余额和流水汇总不一致 | 有直接改余额的操作 | 查是否有绕过记账的 UPDATE |
| 同一笔交易重复扣款 | 幂等失效 | 查幂等表是否有记录、请求 ID 是否唯一 |
| 金额差几分钱 | 舍入规则不一致 | 对比各环节的舍入方式 |
| 对账大量差异 | 时区或批次问题 | 检查对账时间范围和时区设置 |
| 并发扣款超扣 | 锁粒度或条件不对 | 检查 UPDATE 是否带 balance >= ? |
6.2 幂等失效的典型场景
幂等失效是金融系统的高频问题,我遇到过几种典型场景。
场景一:请求 ID 由客户端生成,但客户端每次重试都生成新 ID。这样服务端看到的是不同请求,幂等表挡不住。解决办法是请求 ID 由客户端在首次发起时生成并复用,重试时带同一个 ID。
场景二:幂等表写入和业务操作不在同一事务。业务操作成功了,但幂等表写入失败,下次重试时幂等表没记录,又执行了一遍。解决办法是幂等表写入和业务操作放在同一事务。
场景三:幂等表清理策略不当。幂等表数据太多,定期清理,但清理窗口太短,导致重试请求在清理后进来,幂等失效。解决办法是幂等记录的保留时间要覆盖最大重试窗口,通常至少 24 小时。
实操心得:幂等表的主键用请求 ID,但请求 ID 的生成规则要包含业务维度,比如
业务类型 + 用户ID + 时间戳 + 随机数,避免不同业务之间 ID 冲突。
6.3 状态机卡死的处理
状态机卡死,指的是一笔交易长时间停在中间状态(比如PROCESSING),既不成功也不失败。这种情况通常是因为某个环节超时了,但没有触发补偿。
处理办法是定时扫描 + 主动查询。定时任务扫描超过阈值时间还在中间状态的交易,主动去查询外部渠道的真实状态,然后根据查询结果推进状态机。如果查询也失败,就标记为"需人工处理",进人工队列。
这里的关键是阈值要合理。太短会误判(渠道正常但慢),太长会影响用户体验。我的经验是设为渠道平均响应时间的 3-5 倍。同时,补偿任务本身要幂等,多次执行结果一致。
6.4 对账差异的自动处理策略
对账差异不能全靠人工,要尽量自动化。我通常按差异类型配置自动处理策略:
- 内部有外部无,且内部状态是处理中:说明渠道还没返回,等下一轮对账。
- 内部有外部无,且内部状态是成功:可能渠道漏记,发起查询,确认后补记或冲正。
- 外部有内部无:可能内部漏记,查询内部日志,确认后补记。
- 金额不一致:通常是手续费或舍入问题,核对规则后调整。
- 状态不一致:以渠道为准,同步内部状态。
自动处理不了的,才进人工。人工处理界面要展示完整上下文:内部流水、外部流水、历史操作记录,让处理人一眼看清。
7. 关于 Claude、Cowork、Managed Agents API 与 plugin 在金融场景的思考
最近Claude、Claude Code、Cowork、Managed Agents API、plugin这些词很热,不少同行在问能不能用在金融系统里。我的看法是:辅助可以,核心不行。
Claude Code这类工具在写代码、查文档、生成测试用例上确实能提效。比如让它帮你生成边界条件的测试用例,或者解释一段复杂的账务逻辑,都挺有用。plugin机制也让工具能接入更多能力。但涉及资金计算、状态流转、对账逻辑的核心代码,我坚持人工评审,不放心让 AI 直接生成。原因很简单:AI 生成的代码可能"看起来对",但边界条件处理不一定严谨,而金融系统的边界条件恰恰是最要命的。
Managed Agents API这类托管代理能力,在客服、工单分类、文档检索等场景有想象空间。比如用户问"我的退款到哪了",代理可以查交易状态并回复。但代理绝对不能直接操作资金,只能查询和转人工。这是红线。
至于Cowork这类协作工具,用在团队知识管理、需求文档协作上不错,但金融系统的需求变更要走严格的评审流程,不能靠协作工具随意改。
一句话总结:AI 工具是杠杆,能放大效率,但金融系统的正确性底线,必须靠工程规范和人工把关来守。工具再强,也不能替代对资金安全的敬畏。
8. 我踩过的几个坑和一点个人体会
说几个我实际踩过的坑,都是文档里不会写的。
第一个坑:以为用了Decimal就万事大吉。早期我用Decimal存金额,但有一次从数据库读出来是float,直接构造Decimal,误差就带进来了。后来统一规定:金额从数据库到内存、从内存到 API,全程用字符串或整数,绝不用 float 中转。
第二个坑:对账任务没做断点续跑。有一次对账跑到 80% 挂了,重跑时从头开始,把已处理的又处理了一遍,产生了大量重复差异记录。后来改成按批次记录进度,重跑从断点继续。
第三个坑:状态机没考虑"重试"路径。一开始设计状态机,FAILED是终态,结果发现有些失败是可以重试的,只能手动改状态。后来在状态机里加了FAILED -> INIT的重试路径,但要限制重试次数,避免无限重试。
第四个坑:日志打太多,把敏感信息打进去了。排查问题时打了完整请求日志,结果日志里带了用户身份证号和银行卡号。后来做了日志脱敏,敏感字段统一掩码。
我个人在实际操作中的体会是:金融系统的复杂度,80% 来自对"正确性"的追求,20% 才是业务逻辑本身。把幂等、状态机、对账、金额处理这几个基础打牢,业务逻辑反而是最简单的部分。另外,不要迷信任何框架和工具,再好的框架也替代不了对业务的理解和对边界的敬畏。每次上线前,我都会问自己三个问题:这笔钱算对了吗?重复执行会怎样?出错了怎么查?这三个问题答不上来,就不上。
最后分享一个小技巧:给核心账务逻辑写"不变量测试"。比如"任何时刻,所有账户余额之和等于所有分录的净额",把这个不变量写成测试,每次改动都跑一遍。这个测试帮我抓到过好几次隐蔽的 bug,比看代码管用多了。