设计一个商户侧支付系统,可以先把范围限定为:接入已有的支付服务商(PSP),记录支付请求和结果,计算内部应收应付,并与外部资金记录核对。实际资金转移由接入的银行、收单机构和支付网络完成。是否涉及牌照,要看具体业务活动与适用地区,不能只凭「支付系统」这个名称判断。
这个范围内最难处理的情况很具体:用户因网络卡顿连点付款,PSP 已经扣款但响应丢失,或者系统停止查询后才收到成功通知。系统需要解释每次请求发生了什么,并让支付状态、账务记录和外部事实最终对得上。
先把支付网络留在系统边界之外
一笔信用卡支付涉及多方,先区分它们的职责:
| 角色 | 职责 |
| Cardholder(持卡人) | 使用卡片支付的用户 |
| Issuer(发卡行) | 发行卡片、管理持卡人账户,参与授权决策 |
| Card Network(卡组织网络) | Visa、Mastercard 等提供的支付网络与规则体系,在参与方之间传递交易信息 |
| Acquirer(收单机构) | 为商户受理交易,并按安排参与清算与商户结算 |
| Merchant(商户) | 提供商品或服务的收款主体 |
| PSP(Payment Service Provider) | 向商户提供支付接入服务,具体可以包含支付方式集成、处理、风控和收单相关能力 |
用户可能持有多家银行的卡,商户也可能有多个收款账户。逐一对接这些机构,需要处理不同接口、合同和安全要求。接入 PSP 可以集中处理一部分差异,商户则按合同承担处理费、通道费等成本,不能套用一个跨地区、跨产品的固定费率。
PSP 完成外部支付处理后,商户系统仍然要回答:哪个订单在付款,是否已经付款,这个结果有没有重复处理,商户应收多少,以及外部账单能否证明这笔记录。
需求先保住安全与正确性
简化后的业务流程是:商户创建支付请求,用户完成付款操作,系统调用 PSP、记录结果并通知用户,再定期核对账单。功能需求先覆盖发起支付、完成付款和查询支付状态。
| 非功能需求 | 设计中需要落实的约束 |
| Security | 缩小敏感数据处理范围,保护凭据,验证请求与结果通知 |
| Reliability | 请求、响应、回调或内部处理失败后,能够恢复或进入明确的待处理流程 |
| Consistency | 同一支付操作的重试不重复产生扣款与记账效果,内部记录能与外部结果核对 |
| Correctness | 金额、币种、状态转换和账务分录满足定义好的规则,差异能够追踪 |
设计初期先落实这些约束,同时明确容量假设。外部 API 的延迟和限额是容量约束之一,本地数据库、队列或 webhook 处理也可能成为瓶颈,扩展方案需要结合各环节的实际负载评估。
这里的内部金额分配、应收应付计算,与银行或卡组织的清算网络有不同边界;内部账本写入成功,也不代表商户银行账户已经收到结算款。
四种集成方式,分别承担什么责任
支付集成可以按卡数据经过哪里、界面由谁控制来讨论。后端直连、SDK、iframe 和托管支付页是四个观察角度,其中 SDK 和 iframe 可以组合使用,不能把它们当作互斥的技术方案。
后端直接处理卡数据
商户自行提供输入界面,接收 PAN(主账号)、CVV 等卡数据,再由后端发送给支付处理方。后端直连 PSP API 与直接接入银行、卡网络,属于不同层次,不能统一称为「自建 PSP」。
直接处理卡数据会扩大 PCI DSS 范围,需要相应的安全控制与合规验证;具体采用哪种验证方式,应按业务范围和适用要求确定。技术上使用 Server-to-Server 接口,也不直接决定是否需要某种支付牌照。
这种方式可以增加对支付界面、错误处理和业务编排的控制,分期、分账等功能仍取决于处理方能力及合同。自主权增加的同时,开发、安全和持续维护成本也会增加,不能据此推导出通道费用消失,或认为只有某种规模的企业才能使用。
SDK 与 Tokenization
PSP 提供 JavaScript SDK,商户在网站中集成付款组件。卡数据通过组件提交给 PSP,商户获得 token 或支付方式标识,再由后端引用这个标识发起支付。Stripe Elements 就提供了让敏感支付信息不经过商户服务器的集成方式。
这能缩小商户直接处理卡数据的范围,PCI 合规仍是商户与服务商共同承担的责任。Stripe、PayPal 的卡支付组件可以用来讨论这种模式;微信支付、支付宝也提供支付集成能力,但不能把钱包支付的授权流程直接等同于银行卡输入与 Tokenization。
SDK 是结账链路上的依赖。如果 SDK 加载失败,依赖它的支付流程就可能无法继续。卡数据不经过商户后端,也不能证明商户页面是安全的:XSS 或被替换的脚本仍可能修改页面、诱导用户输入,或破坏支付流程。脚本通过 fetch 直连 PSP,只描述请求路径,没有说明页面隔离能力。
CSP 可以约束脚本、连接和 frame 的允许范围。SRI 适用于可以固定内容、校验哈希的资源;对于由 PSP 持续更新的托管 SDK,应遵循该 PSP 的加载要求,不能假定加一个固定哈希就能覆盖供应链风险。页面脚本管理与安全更新仍需单独落实。
iframe 隔离敏感输入
PSP 可以把敏感输入控件放在其控制的跨源 iframe 中,利用浏览器同源策略限制商户页面直接读取其中内容。商户通过 SDK 暴露的接口获得支付标识。这个设计可以与上一种 SDK 集成同时出现。
iframe、sandbox 属性、客户端加密(CSE)与 Tokenization 是不同机制,不代表所有 PSP 都使用「公钥加密卡号、PSP 解密、返回不可逆 token」的同一流程。token 是支付系统签发的引用,不宜泛称为一种不可逆加密结果;安全保证应以具体产品的设计为准。
代价是界面控制受限:商户无法任意用自己的 CSS 修改跨源 frame 内部元素,需要使用 PSP 支持的样式和布局接口。SDK 二次开发可以增加外围交互自由度,但不能绕过这些隔离边界,也会增加维护成本。
托管支付页
商户创建支付会话后,把用户导航到 PSP 托管的页面;用户完成操作后,再回到商户页面。具体跳转方式由集成决定,不限定为 HTTP 302。
托管页可以减少商户需要自建的支付界面与敏感数据处理环节,但品牌、布局和交互受产品配置限制。商户仍需处理支付会话、结果通知、订单状态和可用性,PSP 也不能替商户兜底所有故障。多一次跳转会改变结账体验,是否降低转化率要通过实际数据判断。
集成选型需要同时比较定制需求、开发投入、PCI 范围与安全运维责任。把更多环节交给 PSP,可以减少自建工作,也会增加对其组件、页面和接口的依赖。
订单、支付与账本分开建模
完成支付信息隔离后,商户仍要保存支付引用并发起交易。Braintree 把一种单次使用的支付方式引用称为 payment method nonce;如果需要复用,可按产品流程把支付方式保存到 Vault。不同 PSP 的 token 生命周期不同,不能把所有 token 都叫作一次性 nonce,也不能默认「保存支付信息」只是把原 token 改成可复用。
最直觉的建模方式,是用一张表记录买了什么、花了多少钱、支付是否成功、使用什么支付方式。问题在于,这些信息由不同流程产生,更新频率和历史保留要求也不同。
| 逻辑对象 | 回答的问题 | 读写特点 |
order | 买了什么,订单属于谁,业务如何履约 | 随业务流程变化 |
payment | 正在执行哪次支付,金额与币种是什么,PSP 返回了什么结果 | 随请求、查询和通知推进状态 |
ledger | 内部确认了哪些资金变化,金额如何归属 | 保留可审计的账务历史,以追加分录纠正错误 |
这三类职责适合分开建模,实际表数量由业务决定。支付操作状态可以更新;账务历史需要保留。不能把「账本不覆盖历史」扩大为「所有支付字段都不可修改」,也不能仅靠多建几张表就认定满足合规。
支付记录可以先包含以下字段:
| 字段 | 含义 |
payment_id | 支付单主键 |
order_id | 关联业务订单 |
payment_method | 支付方式及受控引用,避免把原始卡数据混入业务表 |
psp | 实际接入的支付服务商 |
status | 由状态转换规则维护的支付状态 |
amount | 金额,采用能精确表示约定货币单位的类型 |
currency | 币种 |
created_at / updated_at | 创建和最近更新时间 |
idempotency_key | 本次操作的幂等标识 |
状态机先承认结果可能未知
created → started → processing → success / failed 可以表达基本路径,但还需要表示外部结果未知的情况。假如 PSP 已扣款,响应在返回途中丢失,商户只知道请求超时,并不知道交易失败。
created:支付请求已建立并绑定幂等标识。started:服务端接受请求,开始执行。processing:已确认外部系统受理,等待最终结果。unknown:当前证据不足以确定结果,仍需查询、通知或对账确认。success/failed:获得足够证据后,确认本次支付操作成功或明确失败。
这里的 success 要绑定具体操作的含义,例如确认收款完成,而不是把授权通过、扣款完成和结算到账混为一谈。退款或争议可以产生后续业务与账务变化,不需要靠覆盖原支付历史来表达。
幂等要覆盖客户端、服务端与 PSP
内部一致性可以通过数据库约束与事务维护,但事务保证只覆盖参与该事务的记录。一次数据库提交无法自动把外部 PSP 调用也纳入同一个原子操作;外部一致性要沿着完整工作流处理。
用户在网络卡顿时连续点击两次付款,两个 HTTP 请求可以都到达服务器。设计目标是让同一支付操作的多次提交只产生一次预期业务效果。这个目标需要多层去重,不能仅凭「用了幂等键」就宣称端到端消息已经实现 exactly once。
一条可行的链路如下:
服务器为一次支付操作生成幂等键,在 checkout 页面下发给客户端。
客户端重复提交同一操作时,携带相同的键;不能每点击一次就换一个键。
Payment Service 通过持久化唯一约束或等价原子机制接纳操作,重复请求读取已有结果,避免并发请求同时越过去重判断。
调用 PSP 时使用稳定的外部幂等键,或保存内部键与外部键的稳定映射。
记录调用进度与 PSP 引用,按受理结果进入处理中;外部调用无法确认时,保留未知状态。
重试、查询和通知都回到同一支付记录,重复结果不再触发第二次记账。
幂等键有作用域、保存期限和参数匹配规则,需要逐个 PSP、逐个 API 核对。Stripe 的 API v1 与 API v2 就有不同的重放语义;不能把某个版本的缓存结果或过期规则当成所有通道的保证。重试同一操作时,也不应静默修改金额或币种。
重试要同时设计超时、退避、抖动和退出
PSP 的同步响应可能表示受理、成功,也可能表示业务失败。HTTP 200 只说明相应 HTTP 请求按接口语义得到成功响应,业务结果还要看响应对象,不能统一翻译为「支付处理中」。网络断开时,连这个响应也可能拿不到。
| 要素 | 设计方式 |
| Timeout | 设置连接与请求等待时限;到期表示停止本次等待,不证明外部操作未执行 |
| Backoff | 例如按 1 秒、2 秒、4 秒、8 秒延长重试间隔,并设置上限;这是示意配置,实际取值要结合通道要求 |
| Jitter | 给重试时间加入随机性,避免多台服务器同时重试、同时静默;±15% 可以作为一种示例,不能视为固定标准 |
| Dead Letter Queue(DLQ) | 超过自动重试预算后,把操作及上下文交给恢复任务或人工处理,并保留结果未知的事实 |
重试前先判断错误类型以及是否适合重试,再遵循 PSP 的幂等规则。参数错误不能靠重复发送自动修好;网络故障则可能需要使用原幂等键重试或先查询。PSP 侧的去重与本地去重需要同时存在。
超过重试次数,只能说明当前自动恢复流程已经停止,不能据此认定 PSP 宕机,更不能自动把支付判为失败。数据库回滚无法撤销一个已经发生的外部扣款;需要撤销或退款时,应走通道支持的独立操作。熔断可以暂时减少新调用,但已有未知结果仍要继续确认。
回调推进结果,查询处理漏通知
PSP 尚未给出最终结果时,可以先告诉用户「支付处理中,等待确认」。这个中间态与「已付款」有不同含义,订单侧也应据此决定是否允许履约。
异步结果通过 callback 或 webhook 到达商户,接收地址可以在平台预配置,也可以按接口要求提交。商户需要按 PSP 的要求验证通知真实性,去重处理,并处理乱序到达。Stripe 明确说明 webhook 可能重复,且不保证按事件生成顺序投递。
通知可能因为网络或接收端故障未送达。对于超过预期仍未确认的支付,可以用带退避、有限次数的查询补偿,具体频率受 PSP 接口和限流要求约束。Stripe 的支付状态文档也提醒,持续轮询的可靠性和限流风险需要单独考虑。
回调和查询最终都进入同一套状态转换与记账规则。确认成功或明确失败后,再通知业务与用户;仍无法确认的记录进入后续恢复或对账流程。对于同时支持通知和查询的 PSP,这两条路径可以互补,不必让用户浏览器一直等待。
账本记录资金,对账核对外部事实
确认支付结果后,账本需要记录内部的资金影响。账本涉及账户、交易和分录,不能只用 payment.status = success 替代。复式记账为每笔记账交易生成借贷分录,在同一币种的分录组内满足借方合计等于贷方合计,并对应至少两个账户。
账户还要区分资产、负债、收入、费用等属性,不能把所有方向简单理解为余额加减。借贷平衡能帮助检查记录是否守恒,但两边同时记错仍可能平衡,金额归属和外部核对也需要验证。
存储不等于账务规则。关系型数据库也可以通过访问控制、追加记录和审计设计保存账务历史;错误用新分录调整或冲正,保留原记录。专用账本产品可以提供部分存储与验证能力,但不会自动替系统定义账户、手续费和业务归属。
评估账本存储时,先确认服务当前是否可用,再核对事务语义、审计能力、历史保留和团队维护成本。产品名称中带有 Ledger,不代表它已经具备业务需要的记账、纠错和对账规则。
对账把内部支付与账务记录同银行或 PSP 的交易、结算账单核对。每日执行可以是一种业务安排,实际频率、文件格式和可得时间由通道与合同决定,不能假设所有银行每天发送相同的 settlement file。
发现差异后,先确定是内部漏记、外部仍在处理、金额不符还是重复记录。有充分证据的差异可以按既定规则自动补偿,其他情况转人工审核。补偿保留关联与处理历史,不能通过覆盖旧账来隐藏差异。
迟到成功暴露的是错误的失败定义
考虑一个具体场景:交易长时间处于审核中,回调未到,系统开始查询;达到最大查询次数后,本地写入 failed。过了一会儿,PSP 才确认成功并发送通知。此时出现的「失败又变成功」,首先说明本地曾把等待耗尽错误地当成外部失败。
三种控制手段分别处理不同问题:
状态转换约束处理业务语义。 结果未确认就保留
unknown或处理中;只有拿到明确失败证据才进入failed。终态规则不能成为拒绝真实资金事实的理由,矛盾结果应进入核查。版本号处理并发覆盖。 更新时比较版本可以避免两个处理者互相覆盖,但本地版本递增并不能证明哪个外部事件更新、更可信,仍需要核对事件身份与通道状态。
对账处理剩余差异。 用外部记录核对并修正内部遗漏,实际确认时点依赖账单可得性,不能承诺普遍的 T+0 日终修复。
用 UPDATE 或 UPSERT 维护实时支付状态是一种可选设计,配合独立账本和可追溯历史可以保持实现简洁。问题在于错误的状态定义、缺失历史或不受控的覆盖,不能把根因归结为 UPSERT 这个数据库操作,也不能声称采用某种表结构就天然合规。
需要完整历史时,再考虑 Event Sourcing
Event Sourcing 把状态变化保存为事件,再从事件生成当前视图。Martin Kleppmann 在 2015 年发表的《Turning the database inside-out with Apache Samza》可以帮助理解日志、数据流和派生视图之间的关系;这篇文章并不是 Event Sourcing 的起源定义。
一种设计路径是:
将被接受的业务变化追加到 Event Store,作为该模型的权威历史。
由消费者生成 projection,也就是查询视图或物化视图。
查询读取视图,需要恢复时从历史重建。
业务纠正写成新事件;账务错误通过对应的调整或冲正分录表达,原历史不被覆盖。
Kafka、Apache Pulsar 可以承担事件分发,但不能仅因它们保存消息,就直接当成完整的 Event Store。还需要核对历史保留、按业务实体读取、并发控制和恢复语义。Microsoft 的 Event Sourcing 文档也明确区分事件存储与 Kafka 这类消息代理:消息分发能力不能替代所需的存储语义。
Stripe 公开介绍过其 Ledger:用不可变、可审计的记录跟踪和验证资金流动,并以复式记账支撑数据质量检查。这支持「金融记录需要可追溯、可核对」的设计方向,不能据此推断所有支付公司都采用同一套端到端 Event Sourcing 架构。
| 收益 | 相应代价 |
| 保存完整变化历史,支持审计与追溯 | 要处理事件设计、版本演进和数据保留 |
| 通过重放重建查询视图 | 要处理重放成本、投影幂等与失败恢复 |
| 不同消费者构建不同视图,便于分别演进 | 增加一致性、运行维护和排障复杂度 |
队列前置可以缓冲突发流量,但接收成功前要明确数据已经可靠保存,入队也不代表支付完成。Payment Service 作为 producer 发布事件,消费者再把结果写入各类数据库,原有的一致性与失败处理也要相应调整。重放可以重新计算投影或辅助核对,不能再次触发外部扣款;仅重放内部事件,也无法替代与银行、PSP 账单的对账。
当历史重建、审计与多视图的收益足以承担这些成本时,再选择 Event Sourcing。只为解决一次超时误判就替换整个存储模型,往往超出了问题需要的范围。
风控作为支付前的一层决策
在支付服务调用 PSP 之前,可以增加 Risk Engine,根据交易和关联信息决定放行、拒绝或进一步审核。这里先讨论几类方法的能力与代价,不展开完整的风控平台。
| 方法 | 可以解决的问题 | 局限与成本 |
| 规则引擎 | 用规则树、黑白名单等表达明确条件,便于解释和审查 | 需要持续维护规则,对复杂组合模式的表达有限 |
| 统计方法与简单机器学习 | 从已有数据中学习风险特征 | 依赖数据、特征和标注质量,效果需实际评估,不能保证优于规则;面对新手法可能滞后 |
| 无监督学习 | 在标签较少时寻找异常模式 | 仍依赖数据质量与评估,异常不等于欺诈,误报需要人工处理 |
| 图模型与 GNN | 利用账户、设备、交易等关系识别关联模式 | 建模、训练、推理和维护成本较高,是否采用取决于数据与效果 |
实施时先处理重复提交、响应丢失和迟到成功这几种失败路径,再根据历史重建和风险识别的需求,决定是否引入事件存储或图风控。每增加一个组件,都应明确它处理哪一种具体失败,以及失败后由谁接手。