金融科技工程手册
面向软件工程师的货币系统模式指南——当资金是系统的核心关注点时, 如何构建可信赖、可审计、抗故障的金融软件。
序言
欢迎阅读《金融科技工程手册》。这份资料旨在描述软件工程中当资金是系统核心关注点时最重要的模式。 你可以通篇阅读以获得全面理解,也可以按需查阅以应对特定问题。
面向哪些人?
这是一份活文档,欢迎贡献。
核心原则
下文你将读到的所有内容,都是对三条核心原则的实践:
表示货币
在你能够移动或记录资金之前,你必须先表示它。这些是关于货币值如何建模、存储、计算和转换的决策。把它们做错了,上面的每一层都会继承错误。
精度处理
货币表示是金融系统中最基础的决策之一。主要有四种方式:
- 浮点数。内置的 float 或 double 类型。这可能造成不可预测的精度损失,几乎永远不是一个好主意。但它最快、最省内存,且不需要额外的库或数据结构。
- 任意精度。如 Java 的
BigDecimal这样的类型让你精确控制计算精度。代码可预测,我们可以决定在何处以及如何进行舍入。适用于外汇或定价等中间计算,因为那里有许多操作链接在一起。 - 最小单位精度。对于大多数法币来说,只保留固定精度即可,与连接的中央银行系统使用的精度相同。位数由 ISO 4217 描述(不要假设总是 2 位,并不是!)。在实践中,这意味着将金额作为其最小单位的整数存储——€12.34 变成
1234。加密货币使用相同的整数最小单位思路(BTC 的 satoshi,ETH 的 wei),但有两个不同之处:精度是每个资产定义的(例如 ERC-20 的decimals),通常是 18 位;由此产生的大小会溢出 64 位整数,因此需要用任意宽度整数来存储。 - 有理数。当不接受任何精度损失时使用。这是最强大的方法,但也有其注意事项。首先,它比其他方案更慢。其次,它不能在不损失精度的情况下转换为其他格式。第三,它通常需要自定义数据类型或库。
选择哪种方式取决于系统类别及其职责。除了不使用浮点数之外,这里没有通用的经验法则。这些表示方法也不是互斥的——如何存储金额和如何计算是分开的决策,系统通常会组合它们,例如用整数存储,用 BigDecimal 进行中间计算。
同样的谨慎也适用于金额的序列化。裸 JSON 数字在大多数解析器中是 IEEE-754 double,因此将货币序列化为数字会在边界重新引入浮点问题,无论你内部多么小心。请将货币作为字符串("12.34")或最小单位的整数发送。
触及的原则:不丢失数据。错误的表示会悄悄丢弃永远无法恢复的精度。
舍入策略
- 舍入不可避免。应该显式地执行:任何除法、货币转换、手续费、利息或费率应用,或在精度之间移动,都可能需要舍入。
- 这是业务决策。不同的舍入策略有不同的含义。有时你必须保守(例如,不能花费你没有的钱)并向下舍入;有时你关心统计效应而使用奇进偶舍。决定谁得到余数可能有法律/税务影响。
- 尽可能少舍入。你保持全精度的时间越长,在正确语境中做出正确决策的选择就越多。舍入通常应该发生在边界上,例如在数字被持久化或展示给用户之前。
- 舍入会破坏求和。如果一个数字被拆分为若干部分并应用了舍入,各部分之和可能不再等于原始数字。根据上下文,这可能需要显式处理——例如一个专门的舍入差异科目。
触及的原则:不丢失数据。残余项必须被跟踪,不能丢弃。
不虚构数据。舍入绝不能创造本来不存在的钱。
货币处理
货币不能单独作为数字表示——它与币种配对。在处理货币时有几个细微之处。
- 将金额和币种打包在一起。
Money新类型(结构体、类、记录等)最小化出错概率。 - 不允许跨币种算术。你的系统应该禁止将两种不同币种的金额相加。转换应该以严格控制的汇率非常明确地发生。
- 使用受控的币种集合。自定义配置项、JDK 数据库、专用服务。绝不接受任意币种代码;在系统边界进行验证。
- 代码仅标识法币。币种代码仅对法币是唯一且可用作标识符的。对于加密货币,你需要使用更复杂的方法,如
(网络, 合约地址)或类似方式。 - 币种携带元数据。符号、精度、名称等。你通常需要这些详情用于展示目的,但很少用于业务逻辑。
- 锚定不等于底层资产。锚定、桥接和包装的加密货币并不等同于底层资产。
触及的原则:不信任任何人。在边界验证币种是否在受控集合中。
不虚构数据。将不同的币种/资产视为可互换等同于凭空创造价值。
汇率
汇率(FX,外汇市场汇率)允许我们在不同币种之间转换资金。
- 汇率始终有方向。EUR/USD 汇率与反转的 USD/EUR 汇率不是同一回事。在交易所,买卖是价格不同的两个订单(买卖价差),因此两个方向不能简单互为倒数。
- 汇率的时间至关重要。虽然技术上可以使用任意时间点的汇率,但最常用的是:
- 当前时间汇率。用于计算当前持仓或一笔交易如同现在发生的价值。
- 起息日汇率。用于计算价值变动或税额。
- 两种汇率对转换至关重要:
- 交易汇率。实际转换发生的汇率。你不直接存储它——它从原始金额和结果金额中得出。
- 参考汇率(中间市场或中央银行)。用于估值和等价计算(当前持仓价值,或起息日的税基),而不是任何实际交易的价格。
- 不存在规范汇率。汇率来自市场,不同场所或计算方法会产生差异。最接近规范的是央行汇率,只能用作参考汇率,且即使在那里也可以有同样有效的替代来源。
触及的原则:不丢失数据。保留金额(对于参考汇率,还要保留追溯到来源的途径)。
不信任任何人。不存在规范汇率,因此来源应是数据的一部分。
记录货币:账本
货币被表示之后,资金流动必须以能够平衡、经得起审计且多年后可以重建的方式记录。这里是账本、它们的时间戳和历史所在之处。
复式记账
复式记账是一种广泛使用的方法,将财务交易存储为 (贷记科目, 借记科目, 金额) 形式的条目列表(这是紧凑形式;经典表示每条移动使用单独的一行借记和一行贷记)。因为每条条目将相同金额从一个科目转出并转入另一个科目,账本始终保持平衡——资金只被移动,从不被创造或销毁。
- 资金总有来源和目的地。外部提供商也有专门的科目,因此进入/离开系统的资金仍然被追踪。
- 余额从不存储。它从资金变动中推导得出。
- 科目有类型。资产、负债或权益,使得会计等式(
资产 = 负债 + 权益)成立,每个科目有定义好的增加方向。在实践中,你还需要收入(营收)和费用科目——例如将手续费记为收入,或将核销记为损失(资产 = 负债 + 权益 + 收入 - 费用)。 - 一笔交易,多次移动。一笔交易通常会产生多次移动,例如一次为净额,另一次为手续费。
- 已记账条目不可变。按照惯例,更正通过添加新的、抵消原始条目的补偿条目来进行。
触及的原则:不虚构数据。资金只在科目之间移动;总量守恒。
起息时间 · 记账时间 · 结算时间
交易通常关联至少两个、有时三个时间戳:
- 起息时间。交易何时发生。
- 记账时间。交易何时记录到系统中。
- 结算时间。资金实际转移或物化。并非每笔交易都有。通常表示为 T+X,其中 X 为起息后结算的天数(例如 T+2 表示起息后 2 天)。
前两个几乎总是有偏差:
- 回溯记账(记账 > 起息)。技术上几乎所有交易都是回溯的,但该术语在记账和起息时间属于不同的报告期(例如日、月、年)时影响最大。
- 预先记账(记账 < 起息)。不太常见,但发生在例如计划或远期付款中——今天记录的定期支付但下周生效。
示例:一笔卡支付在 T1 发生(起息时间),你在 T2 记录了它(记账时间),但支付提供商在 T3 将资金转入你的账户(结算时间)。
触及的原则:不丢失数据。记录每个相关时间戳;将它们合并为单一 created_at 会丢失以后无法重建的信息。
审计与审计追踪
金融系统受到各种形式的监管审查。审计中可能会被验证的一些事项:
- 公司资金是否没有与用户资金混用,或用于公司开支?
- 所有收入是否已登记、报告且可解释?例如,你能否精准指出哪些交易在特定期间贡献了特定的收入流?
- 提供给外部世界的信息(例如用户或税务机关)是否与现实一致?例如,公司持有的资产是否与欠用户的相当?
- 资金是否受到保护免受外部威胁?(例如,谁能接触到资金以及如何接触)
为回答这些问题,金融系统必须不仅追踪当前状态,还要追踪该状态如何形成的完整历史。这段历史就是审计追踪:一切发生的事情的记录,详细到任何余额、报告或决策都可以从中解释和复现。
有用的审计追踪捕获每次变更的以下信息:
- 什么发生了。
- 何时发生(见起息时间 vs 记账时间)。
- 谁或什么触发了它——用户、操作员、自动化任务。
- 为什么发生——引用导致它的订单、指令或事件。
事件溯源
事件溯源可能是构建审计追踪的最原则性和系统性的方法。在事件溯源中,不将当前状态与旁边的日志一起存储,而是仅存储事件并从中派生状态。复式账本就是这种模式应用于货币的一个例子——余额永远不存储,它从存储的条目中计算。通过这种方法,追踪成为主要产物,不可能与现实漂移。
几点实践建议:
- 你不需要到处使用它。账本已经覆盖了货币;对于周围的领域,传统模型加上可靠变更日志可能就足够了。
- 派生状态可以缓存。余额和投影可以被缓存或快照以提高性能。
- 投影工作量大。你可能需要很多投影,而且你无法对主要数据集(事件)进行有效查询,所以你需要构建专用或通用的投影来查看数据。
- 为模式演进做计划。事件存续多年,因此今天的代码必须仍然能读取很久以前写入的事件。
触及的原则:不丢失数据。当状态从事件派生时,追踪是真相的来源,无法与现实不同步。
不可变性
可以被编辑的审计追踪什么也证明不了,因此记录永不能被更新或删除。我们的日志必须是追加的,每次更正都应是新记录(见下文)。
不可变性是一个不变量,常规工具箱适用:
- 通过建构保证。追加表,在数据库权限层面撤销
UPDATE/DELETE。 - 运行时检查。应用层不对已记账记录暴露可变操作。
- 事后。防篡改证据:记录上的校验和或哈希链,定期验证,使任何事后修改都可被检测到。
冲销与更正
错误仍然会发生,例如错误的金额被记账,或交易进入了错误的科目。不可变性意味着向前修正——记账一条新的补偿条目,并将其链接到它更正的记录,双向链接。
- 冲销。全额否定原始记录,经济上如同它从未发生过——但它在历史中仍然可见,与原始记录一起。
- 更正(调整)。记录已记账与应记账之间的差额,或冲销并用正确值重新记账。
- 注意报告期。更正往往落入不同于原始的另一个报告期(见起息时间 vs 记账时间);链接使得报告能够正确归因并区分真实活动与清理工作。
触及的原则:不虚构数据。错误通过记账链接的、抵消原始记录的补偿条目来修正。
不可变性与 GDPR
GDPR 的删除权表面上与不可变账本相矛盾。在实践中很容易使其不成问题:
- 财务记录很大程度上是豁免的。法定留存义务(会计法、反洗钱,通常 5-10 年)优先于对交易数据的删除请求。你不在该时限内删除记账。
- 分离 PII 与财务数据。豁免覆盖了你必须保留的部分,而非你想要保留的全部。不可变账本仅通过不透明的内部标识符引用用户,而 PII(姓名、地址、文档)存在于一个独立的、可变的数据存储中,可以独立地编辑或删除。
- 对嵌入的 PII 进行加密粉碎。当个人数据必须嵌入不可变记录(例如事件负载)时,用每用户密钥加密用户的个人字段,通过删除密钥来擦除。删除变成密钥的删除,而不是重写历史。
触及的原则:不丢失数据。将 PII 与财务数据分开,使你能够履行删除义务而不丢失你有义务保留的财务历史。
执行资金流
一次资金操作很少是单一写入。它跨越多个步骤、并发和故障,并且必须在全程保持正确——不虚构也不丢失资金。以下是在此过程中保持单次流正确的模式。
不变量
在任何系统中都存在必须始终成立的特殊属性——我们称之为不变量。上文提到的会计等式就是一个不变量。你的业务相关方可能定义许多这样的条件,然后必须被强制执行。
有三种主要方式来强制执行不变量:
- 通过建构保证。确保系统只允许创建有效对象,使无效状态无法表达。可以通过多种技术实现:工厂方法(智能构造函数)、类型级编程(例如精炼类型)、数据库约束。
- 运行时检查。在执行逻辑时检查不变量是否成立。这可以是生产代码中的断言或测试——基于属性的测试在这里大放异彩(例如"对于任意记账序列,账本保持平衡")。
- 事后。分析系统持久化的数据,寻找任何违规,例如对账任务或夜间检查确认账本余额仍然满足会计等式。
触及的原则:不信任任何人。不变量是被验证的,不是被假定的;甚至你自己代码的输出也要被检查。
资金预留
在大多数情况下,你的交易需要与外部世界交互。例如,你可能需要在允许用户提取资金之前进行合规检查,或者你需要将提取操作注册到外部系统。在这些情况下,你还需要避免竞态条件——将同一笔钱花费两次,或者在与外部世界交互之后才发现"余额不足"。
为解决这个问题,系统实现资金预留(也称为冻结与释放),即资金首先为特定交易预留,然后才开始外部交互。一旦完成,预留被结算,交易继续;如果任何环节出错,预留被释放,资金回到可用余额。
这种模式引入了两种余额的区分:总余额(用户拥有的全部资金,包括预留的)和可用余额(可用 = 总 - 预留)。余额检查和新的预留都针对可用余额进行,这正是阻止同一笔资金支撑两笔交易的原因。
触及的原则:不虚构数据。同一笔资金不能支撑两笔交易;预留显式地确保这一点,而不是依赖竞态的余额检查。
处理透支
透支发生在科目余额变为负值时。透支有两种类型:
- 有意的。透支是业务明确提供的信贷产品,有额度和利息。这是业务特性,不是异常。
- 无意的。即使策略禁止,余额仍变为负值。
禁止不等于不可表示。将"余额永不为负"在类型或存储层面编码为无符号整数或 CHECK (balance >= 0) 约束很有诱惑力。但当我们被迫接受负余额时,无法表示它的系统要么中途崩溃、悄悄将余额钳制为零(虚构了钱),或做出类似的事。
触及的原则:不虚构数据。将负余额钳制为零就是凭空创造钱。
不信任任何人。外部世界可以造成透支,无论你的检查怎么说。
幂等性
在分布式系统中,不可能保证精确一次投递——任何调用都可能被中断,我们不知道它是否到达了另一方。为确保消息被投递,我们必须重试每一次调用。但这样做就有超过一次投递的风险,因此对它的处理必须是幂等的——同一条消息投递两次必须只触发处理一次。
- 优先使用显式幂等键。幂等键 vs 从业务派生的幂等(例如对载荷去重)。显式幂等键通常是更简单且更好的解决方案。
- 决定错误如何重放。当调用第一次失败时,重试应该重新抛出存储的错误还是重新触发处理?将错误作为幂等结果并重放它通常更简单更容易推理。
- 验证重复的载荷。确保重复调用携带与原始相同的载荷是好的实践,但在实践中成本高且只换来很少的额外信心。
- 大规模下很难。构建可靠的幂等性可能是复杂的工作,投入足够的精力。不仅需要去重数十亿请求,还要在并发访问下使行为正确。
- 警惕时间窗口。依赖幂等时间窗口(例如仅在 24h 内去重)显著简化了实现,但以牺牲正确性为代价。
- 为重试而测试。较好的方法之一是将通用的中间件嵌入集成测试或系统测试,自动重复每次调用。
- 处理乱序重试。你的系统需要保持幂等,即使已经迁移到新状态——例如保持资金的冻结幂等,即使它们已被释放。
触及的原则:不虚构数据。重试不可避免,因此处理必须将重复投递折叠为单一效果,而不是移动资金两次。
完全可恢复性
资金流很少在单步中发生。一笔提取可能需要预留资金、运行合规检查、在外部系统中注册操作、最后结算。这样的序列跨越时间,可能在任意两步之间中断,因此安全的假设是它会——假设每两步就发生一次故障。因此,一次流程永远不能假设它一次性运行到完成,半完成的状态必须始终落在可恢复的状态,而不是不一致的状态。
- 持久化进度,不要存在内存中。将流程建模为显式的状态机,其状态被持久化存储,在开始下一步之前提交每步的完成。
- 必须有东西恢复停滞的流程。一个独立的驱动(调度器、worker 或轮询器)必须拾取未完成的流程并将其推进。
- 每步都必须可以安全重跑。恢复时你可能重新执行一个已经部分发生的步骤,因此每一步都必须是幂等的。
- 向前滚动或补偿。外部效应无法被回滚。一旦你调用了外部世界就不能取消调用,数据库回滚也不会撤销它。因此你要么不断重试直到流程完成,要么在后期步骤永久失败时,发布补偿操作来撤销先前的步骤(Saga 模式)。
触及的原则:不丢失数据。流程中发生崩溃绝不能丢失对在途资金的追踪;持久化的进度使流程得以被重新拾取并完成。
不虚构数据。恢复时重跑步骤,因此它们必须重新应用而不重复计算——流程恰好完成一次。
外部世界
与外部世界交互——无论是第三方提供商(支付、KYC、反洗钱、银行、托管人等)还是内部服务——都是不可避免的。我们的工作是构建一个无论这些依赖变得多不可靠都能保持正确的系统。
消费 API
迟早你会需要调用别人的 API,例如支付提供商、托管方、区块链节点或 KYC 厂商。你不控制它的代码、质量或可用性,因此安全的默认假设是它会行为异常,你需要防御性地构建。
- 不要信任模式。响应不会始终符合你得到的协议:字段可能消失、类型可能变化、null 可能出现在本不应有的地方。在边界验证重要内容,对任何你未预期的事物大声失败,这样错误数据不会泄漏到系统内。
- 预期不完美的工程。你会看到令牌放在 URL 中、精度丢失、HTTP 代码含义不符(
200携带错误响应体)、不一致的分页、自定义日期格式。不要为此沮丧;把它当作工作的常态。 - 所有调用都会失败。设计系统使其能处理响应的缺失。重试和超时是必要的保护。
- 断路器通常可选。它们主要是对过载服务器的善意,代价是客户端增加的复杂性。期待服务器自己处理负载并丢弃无法服务的请求是合理的。
- 注意配额。限流和使用配额容易被遗忘,但可能是糟糕的周末故障的来源。
- 存储每一次请求和响应。这听起来可能过分,但在调查期间,当外部 API 开始返回它不应该返回的内容时,这可能救命。
- 争取提供商冗余。对于最关键的部分,考虑为同一目的使用不止一个提供商。你永远不能完全信任一个提供商。
- 不要信任沙盒。如果提供商给你测试/沙盒访问,这已经是一个好迹象。这些环境对于基本场景是好的,但通常会与生产设置严重偏离。
触及的原则:不信任任何人。提供商的代码、模式和可用性都超出你的控制,因此需要针对独立来源验证事实并在边界验证一切。
不丢失数据。持久化每次请求和响应,保留可以据以对账和重新处理的记录。
处理 Webhook
Webhook 是从外部系统接收信号的最常见方式,但安全地处理它们并不简单。
- 不要假设有序。消息可能乱序到达或携带过时数据,因此最后收到的 webhook 不一定代表最新真相。
- 不要假设有效。Webhook 可能来自发送方系统的次要部分并携带过时或不当转换的数据。好的实践是忽略 webhook 的内容,仅将其作为触发去查询 API 获取权威状态。
- 不要假设已投递。Webhook 迟早会丢失,无论发送方承诺了多强的重投策略。你必须准备好处理缺失的 webhook,这通常意味着一个独立的过程来修复数据的完整性。详见对账。
- 不要假设单次投递。同一条 webhook 会被投递多次。处理必须是幂等的。详见幂等性。
- 快速确认,异步处理。在持久化了原始事件后尽快返回 2xx,把真正的工作异步完成。
- 持久化原始载荷。在处理之前存储你收到的原始内容。
- 验证调用者。通常由发送方附上载荷的签名,使你能验证消息确实来自他们。最常见的是用共享密钥计算的 HMAC。
触及的原则:不信任任何人。Webhook 是一个未经认证、无序、可能丢失、可能重复的提示;验证来源,并针对 API 确认实际状态。
不丢失数据。持久化原始事件,并用对账作为投递的备份,使一个丢失的 webhook 不代表一个丢失的事实。
可靠通知(发件箱与 CDC)
让外部世界可靠地了解我们系统中的变更是很常见的需求——通过发布 Kafka 事件、调度 webhook 调用或通过众多其他方式。问题的关键在于可靠地:我们必须确保至少一次投递,而这些通道不适合我们通常依赖的事务模型。
实践选项:
- 发件箱模式。将"发布"事件与状态变更一起事务性地写入专用存储,然后可靠地处理它。
- 变更数据捕获(CDC)。自动化机制检测已提交到数据库的变更并将其转换为事件流。像 Debezium 或 AWS DMS 这样的工具实现了现成的解决方案。
- 监听自己。反转顺序,先发布事件(例如到 Kafka),然后从中重建自己的状态。
- 事件溯源。事件日志已经在数据库中,发布只是从中读取的问题。
触及的原则:不丢失数据。已提交的变更必须可靠地到达其消费者;发件箱(或日志)保证通知不会因为单独的发布步骤失败而被丢弃。
不虚构数据。我们绝不为未提交的变更发布通知,重复投递折叠为单一效果。
对账
任何依赖外部数据的系统都容易出现数据漂移——一个系统与另一个不匹配的情况。在所有这些情况下,我们需要对账:一个使两个系统对齐的过程。
- 频率。取决于具体上下文和约束,对账可以每小时、每日、每月甚至每年进行。
- 漂移的本质。数据可能缺失(这比较简单)或不同(例如同一笔交易金额不同,解决起来复杂得多)。
- 匹配算法。知道在两个系统之间比较什么是最难的部分。通常你想在自己系统内持久化外部提供商 ID 以便匹配是直接的。
- 一对多。在某些情况下,你需要将一边的多条记录与另一边的一条对账。
- 对齐并不简单。我们不能简单地覆盖数据使对账满意。发现的每个差异都应该通过一流的支持来理解和修复。
触及的原则:不信任任何人。对账是我们如何交叉验证独立来源,而不是相信任何一个来源是正确的。
不丢失数据。它是捕获丢失事实的安全网——缺失的 webhook、未结算的转账——在它们永远消失之前。
控制与访问
前面的模式保证了数据的正确。但一个货币系统还必须约束谁被允许操作它,并在事后证明流程被遵循。这就是不信任任何人原则向内转向的地方——你自己的操作员和工程师也是信任边界,就像外部提供商和内部组件一样。审计师和审查账本一样审查这些控制。
职责分离与四眼原则
有些操作太敏感了,不能留给单个人,无论他们多么受信任。拆分它们是金融中最古老的控制手段,有两种相关形式:职责分离(没有人拥有整个流程)和四眼/制作者-检查者(特定操作需要第二个人审批才能生效,也称双重控制)。
- 适用于资金操作。大额或手动提取、手动账本更正、资金库和冷钱包移动、更改费率表或限额——任何可以移动或错误陈述资金的操作都应需要第二个审批人。
- 也适用于工程。合并代码、部署到生产以及变更基础设施是货币系统中的敏感操作。因此我们通常要求审查和批准。
- 审批属于追踪的一部分。记录谁请求、谁批准,以及两人是不同的——否则控制不可证明。
- 紧急情况需要路径。紧急情况会发生,僵化的控制会诱使人们绕过它。提供一个明确的、经过严格审计的覆盖通道,而不是迫使走后门。
触及的原则:不信任任何人。单个内部行动者——即使是受信任的——不足以成为敏感或不可逆操作的权威。
访问控制
谁能做什么本身就是系统状态的一部分,并且随着人员加入、调动和离开而变化。不仅要知道今天谁能接触资金;审计师也会问他们是如何获得该访问权限的。
- 最小权限。授予每个行动者——人类或服务——所需的最小权限,优先使用角色(RBAC)而非按人授予,使访问保持可审查。
- 授权变更需要追踪。授予或撤销能力是一个敏感事件,就像资金移动一样:记录什么变了、谁变以及为什么。
- 定期审查访问。权限变得陈旧或不准确。定期的访问审查(重新认证)是应用于访问的事后检查,以便我们捕获漂移。
触及的原则:不信任任何人。静态访问在悄悄累积;最小权限和定期审查是使其受控的手段。
变更追踪(SDLC)
在受监管环境中,我们通常必须审计代码如何到达生产,从而知道谁审查了变更、谁批准了、何时发布等等。你的版本控制和 CI/CD 系统如果做对了,会是很好的辅助。
- 源代码控制就是记录。提交历史将每次变更归属于作者,并通过审查和关联工单将其与变更原因绑定(这就是审计追踪要求的什么/谁/为什么)。
- 审查和流水线必须强制执行。必需的(非可选)审查、状态检查和"不能直接推送到 main"至关重要,因为自律在审计中不成立。
- 部署可追踪。运行中的版本、谁发布的、何时发布,都应该可以重建——这是让事件能与造成它的变更关联起来的关键。
触及的原则:不丢失数据。系统如何形成的历史,与它持有的资金历史一样,是追踪的一部分。
不信任任何人。系统强制执行交付控制——不依赖人们记住去遵守。
测试
测试在任何地方都很重要,但在一个货币系统中更加重要。难点在于你通常不能枚举预期输出——操作序列的空间太大,有趣的失败藏在其组合中。以下方法是在系统正确性方面获得信心的途径。将其视为一份餐厅菜单,你可以从中选择对你的系统影响最大的技术。
- 基于属性的测试。与其断言特定输出,不如断言对于任何生成的输入,某个属性都成立。这对于不变量或货币数学来说是天然的契合。
- 步骤间的不变量检查。当你生成一个操作序列时,不要只在结束时断言不变量——在每一步之后都断言。
- 生成式幂等性测试。因为每个接触外部世界的操作都必须是幂等的,你可以将其作为系统的一个属性。自动重复所有已声明的操作并断言第二次调用对系统没有影响。
- 崩溃与恢复注入。长流程必须能在任意两步之间中断后存活(见完全可恢复性),我们可以通过在每个步骤注入故障来测试。
- 往返测试。编码再解码,序列化再反序列化,转换再转换回来——断言你回到了起点(或在已知容差内)。
- Golden 测试。将一个计算或投影的输出(费用分解、报表、报告)钉到一个存储的预期结果上,使任何非预期变更表现为一个 diff。
- 向后兼容性测试。事件和存储记录存续多年,今天的代码必须仍然能读取旧代码写入的内容。保持一个真实、旧格式的负载语料库,并断言当前代码仍能正确反序列化和投影它们。
- 生产环境测试。有些信心只能针对真实事物获得。提供商沙盒与生产环境严重偏离(见消费 API),因此验证集成工作正常的最终证明往往必须实时发生——通过金丝雀发布、受控的小范围推出,或合成交易,持续地向系统推送小量真实资金作为健康检查。
触及的原则:不信任任何人。测试是你验证模式确实成立而不是假设它们成立的方式;不变量是 oracle,而不是你恰好预期的值。
不虚构数据。重放操作和注入故障证明了重试和恢复不会重复计数或创造资金。
不丢失数据。往返和向后兼容性测试证明了精度和历史在边界和时间的流逝中存活下来。
附录 A:领域术语
加入金融科技最困难的部分往往不是代码,而是它背后的词汇和概念。这个领域充满了听起来普通但有精确含义的词语,以及你周围每个人使用却从不展开的首字母缩写。
会计与账本
- 账本
- 资金移动的记录系统;衍生余额的真相来源。见复式记账。
- 总账 vs 子账
- 单一合并账簿 vs 一个领域的详细账簿(例如每个用户或产品),汇总到总账。
- 借记 / 贷记
- 每条条目的两方。哪一方增加科目取决于科目类型,而非"钱进 vs 钱出"。
- 记账
- 将一条条目提交到账本;"已记账"意味着已记录且按惯例不可变。
- 科目表
- 可以记账的目的科目目录;一个系统可以有多个(例如按法律实体、账簿或报告标准)。
- 科目类型
- 资产 / 负债 / 权益(加收入 / 费用),使会计等式成立且每个科目有定义好的增加方向。
- 应收 / 应付
- 欠你的钱 / 你欠的钱。
- IOU
- 非正式地,一笔负债:你欠某人钱的记录。用户在托管平台上的余额是平台对用户的 IOU,这就是为什么它在账簿的负债方。
- 权责发生制 vs 现金制
- 在钱被赚得或欠下时确认 vs 当钱实际移动时确认。
- 试算平衡
- 检查总借记等于总贷记是否在整个账簿中成立。
- 暂记 / 清算科目
- 用于在途或尚未归属的资金的临时持有科目。
- 核销
- 将不再预期收回的余额记为损失。见处理透支。
- 混用
- 将公司资金与用户资金混合;监管红线。见审计与审计追踪。
- 对账差异
- 对账(见对账)发现的单个未匹配差异。
货币与外汇
- 货币(作为类型)
- 与币种配对的金额。见货币处理。
- 最小单位
- 币种的最小不可分单位;金额通常作为这些单位的整数存储(€12.34 →
1234)。见精度处理。 - 基点(bp)
- 百分之一的百分之一(0.01%);费用和利率通常以基点报价。
- 名义金额
- 计算所基于的面值,可能远大于实际易手的现金。
- 法币 vs 加密货币
- 国家发行货币 vs 区块链原生资产。
- 稳定币
- 与参考资产(通常是类似 USD 的法币)锚定的代币。
- 锚定 / 包装 / 桥接
- 与标的资产关联但不等同的表征。见货币处理。
- 买入价 / 卖出价 / 价差
- 买入价格、卖出价格以及它们之间的差距。
- 中间市场汇率
- 买卖价差的中点;一个参考点,不是你实际可以交易的价格。见汇率。
- 参考汇率
- 用于估值和等价计算(持仓价值、税基)的汇率,不是实际交易价格。见汇率。
- 逐市计价
- 以当前市场汇率而非获取时的价格重新估值持仓。
交易、时间与结算
- 起息日 / 记账日 / 结算日
- 何时发生 / 何时记录 / 资金实际何时移动。见三种时间。
- T+X
- 某物(例如结算)在起息日之后 X 个工作日发生(例如 T+2)。
- 清算 vs 结算
- 约定谁欠多少 vs 实际转移资金。
- 截止时间
- 每日截止时间,之后交易落入下一个结算窗口。
- 在途资金
- 在转移过程中,似乎同时存在于两个系统(或两个都不在)中的资金。
- 净额结算
- 将多个义务抵消为单一净额转移,而不是逐一总额结算。
- 回溯
- 分配一个早于记账日的起息日。见三种时间。
- 冲销 / 更正
- 全额否定一笔记账如同它经济上从未发生 vs 记录已记录与应记录之间的差额。见冲销与更正。
支付、通道与卡
- 支付通道
- 支付所经过的底层网络(SEPA、SWIFT、ACH、卡网络、区块链)。
- PSP(支付服务提供商)
- 将你连接到一个或多个通道的厂商。
- Nostro / Vostro 账户
- "我们在他行的钱" / "他在我行的钱";使跨行转账运作的账户。
- 综合账户
- 一个池化账户持有多个用户的资金,内部追踪每用户余额。
- FBO 账户("为受益")
- 公司代表其用户持有的账户。
- 归集
- 按计划自动在不同账户间移动余额。
- 拒付
- 由持卡人银行发起的卡支付强制冲销。
- 交换费
- 收单行在每笔卡交易中支付给发卡行的费用;卡处理成本的大头。
- 授权 vs 捕获
- 冻结资金 vs 实际扣款;卡世界中资金预留的版本。
- 催款
- 对失败定期付款的重试和通知流程。
交易与市场
- 订单簿
- 每个价位上的未成交买单(出价)和卖单(要价)的实时列表。
- 市价 vs 限价单
- 现在以最优可用价格成交(吃掉流动性)vs 仅在设定价格或更好时成交(挂在簿上,提供流动性)。
- Maker / Taker
- Maker 向簿上增加挂单,Taker 穿过价差吃掉一个;费用通常不同。
- 滑点
- 预期价格与实际成交价格之间的差异。
- 流动性 / 深度
- 在价格移动之前可以交易多少;薄簿移动更多。
- 现货
- 即刻交割买卖资产本身。
- 衍生品
- 价值从标的资产而非资产本身衍生的合约。
- 永续合约
- 无到期日的合约,通过资金费率保持接近现货价格。
- 资金费率
- 多头和空头之间的定期支付,将永续合约的价格锚定到指数。
- 杠杆 / 保证金
- 控制大于你资本的仓位 / 为开仓和维持它而抵押的担保。
- 强平
- 当仓位保证金低于维持要求时强制平仓。
- 扣减率
- 对抵押品价值应用的折扣,以缓冲价格波动。
- 对手方
- 交易或合约的另一方;"对手方风险"是他们无法交付的风险。
- AUM / AUC
- 管理资产(平台主动管理,通常在收费授权下)vs 托管资产(仅保管)。同一持仓可以是其中一个、另一个,或两者兼有。
托管与加密货币
- 托管
- 谁控制资产:自托管(用户持有密钥)vs 托管(平台或托管人持有)。
- 热 / 冷钱包
- 密钥在线以便快速访问 vs 离线以保证安全。
- 私钥 / 公钥 / 地址
- 授权支出的秘密 / 其派生的公开标识符 / 资金接收地址。
- 助记词
- 重建钱包密钥的人类可读备份。
- 多签 / MPC
- 需要多个密钥(或密钥份额)来授权一次转账,使单个设备无法独自移动资金。
- Gas / 网络费
- 令交易被纳入区块链所支付的费用。
- 确认 / 最终性
- 一个区块建立在包含你的交易的区块之上 / 无法再被逆转的点。
- 重组
- 可以撤销最近已确认交易的区块链重组;最终性不是即时的原因。
- UTXO vs 账户模型
- 比特币式的"花费整币,接收找零"vs 以太坊式的运行余额;两者要求不同的记账方式。
- 粉尘
- 金额小到移动它的网络费用超过其本身价值。
- 地址白名单
- 将提取限制在一组预先批准的地址。
合规与监管
- KYC
- 了解你的客户(Know Your Customer);验证用户身份。
- AML / CFT
- 反洗钱(Anti-Money-Laundering)/ 反恐怖融资(Countering the Financing of Terrorism);检测和防止非法资金的控制。
- 制裁筛查
- 检查交易方是否在被制裁实体名单中。
- PEP
- 政治人物(Politically Exposed Person);需要额外尽调的高风险客户类别。
- 资金 / 财富来源
- 客户的钱从哪里来的证据。
- 旅行规则
- 在超过阈值的转账上共享发起方和受益人信息的要求。
- VASP
- 虚拟资产服务提供商(Virtual Asset Service Provider);加密货币业务(如交易所或托管人)的监管标签。
- MiCA
- 欧盟加密资产市场监管(Markets in Crypto-Assets regulation)。
- 职责分离
- 将一项敏感操作拆分到多人,使单人无法独自完成。见职责分离与四眼原则。
附录 B:端到端示例
本手册正文将每个模式单独讲解,这不利于建立整体理解和直觉。 本附录通过三个常见流程,覆盖资金移动的三个方向:出系统(提现)、入系统(卡充值)以及在系统内移动(转换)。
流程一:加密货币提现
用户请求提取 0.5 ETH 到一个外部地址。这是三个流程中最丰富的,因为资金通过一个不可逆的外部效应离开系统。
-
请求附带幂等键到达。客户端可能重试提交(网络吃掉了响应、用户双击),因此首要关切是同一次请求的两次投递只产生一次提现,而非两次。键的作用域限定在此用户与此操作。
-
在不可逆操作之前预留资金。你预留 0.5 ETH 加预估的网络费用,针对用户可用余额。余额检查和预留是单一的线性化步骤——否则两个并发的提现可能双双通过检查并用同一笔钱支撑它们的花费。
-
合规门运行——流程可能在此睡眠数天。广播之前,你筛查交易(制裁、反洗钱、目标地址)。这是一次外部调用,因此它可能慢、失败或说谎。可能升级到人工审查,持续数小时或数天,因此流程必须能在步骤间中断后存活:其状态被持久化,独立的驱动器恢复它,在此期间预留简单地保持。
-
交易在链上广播。通过外部调用签名并广播。此步骤必须幂等:崩溃后恢复必须重新检查链,而不是盲目地第二次广播。
-
等待最终性,然后记账到账本。一次确认不是终点——重组可以撤销你过早声明为"完成"的发送,所以要等待足够的确认。然后才记账复式移动:借记用户科目,贷记外部链上科目(外部世界也有科目),将网络费用记入费用科目,将你的服务费记入收入科目。
-
夜间作业对账链上数据。独立于上述流程,一个作业将账本与链上现实和节点对你交易的视图进行比较。它是捕获未确认的广播或与你记账不同的费用的安全网。
流程二:卡充值
用户通过支付服务提供商(PSP)用卡充值账户。资金在进来,难点从"不要发送两次"转变为"不要信任外部世界告诉你的,也不要计入尚未真正到达的钱"。
- 用户发起充值。输入金额并提交卡详情,你向 PSP 开启一笔充值交易——同样附带幂等键。
- 授权冻结一笔资金。PSP 授权卡片,冻结资金但尚未捕获。这是卡世界中资金预留的版本(授权 vs 捕获)。你还不计入用户余额——钱还不是你的。
- Webhook 说"已捕获"——你什么都不信。验证原始字节上的签名,持久化原始载荷,快速确认 2xx,然后才异步处理。将 webhook 视为某件事发生了的提示而非真相:你查询 PSP 的 API 获取权威状态。
- 入账通过清算科目。资金在途(在途资金)——已被 PSP 捕获但尚未结算到你的银行——因此你通过一个暂记/清算科目记账,而不是假装它已经到达。
- 结算批量到达,对账是一对多的。数天后 PSP 结算一笔覆盖多笔充值的单次转账到你的银行。你将这批与清算科目对账,将一次结算与多笔交易匹配。
- 数周后,一笔拒付。持卡人对付款有争议,银行强制冲销。你不编辑原始记账——你记账一条链接的补偿条目。
流程三:应用内转换与返现
用户将 1,000 EUR 转换为 USDC 并获得一笔小额促销返现。资金完全在系统内移动,因此没有不可靠的外部通道——取而代之,此流程强调了表示层(精度、舍入、币种、汇率)以及不虚构数据原则的最尖锐形式。
- 定向报价与预留。你为 EUR→USDC 报价。该汇率是其自身的价格,不是 USDC→EUR 的反转——买卖位于买卖价差的两端。预留 1,000 EUR。
- 两边永远不相加。EUR 和 USDC 是不同的币种——USDC 通过
(网络, 合约地址)标识,与它锚定的法币不可互换。系统禁止跨币种运算。 - 全程全精度计算,仅在边界舍入。保持全精度进行计算,在边界精确舍入一次,使用深思熟虑选择的策略。你赚取的价差是收入——必须通过复式记账显式地记入收入科目,不允许消失在舍入残余中。
- 返现是对"不虚构数据"的最严峻考验。诱惑是将奖励视为免费的余额增加,但那样会凭空造出钱。返现是真实的钱:它必须有资金来源——从公司促销/费用科目通过适当的复式记账转移到用户余额——定义它的百分比需要与一切相同的显式舍入决策。
- 结算,然后可靠地通知。结算预留,记录所有移动及其时间戳和审计追踪,并发布结果使得系统的其余部分——报表、通知、分析——知悉。该发布必须可靠,这正是发件箱(或 CDC、事件日志)的用途;下游消费者根据稳定的 event id 去重,因为投递是至少一次的。