
简介围绕支付系统中承上启下的收银核心展开属于《百图解码支付系统设计与实现》系列的第11篇适合支付研发、架构设计人员及希望入门支付领域的初学者阅读。内容以实战为导向从收银核心与支付引擎的协同关系切入依次拆解支付咨询阶段的订单与商户校验、绑卡余额等资产查询、渠道与限额判断、支付方式组装与排序再到支付受理中的重复校验、风控、渠道路由与结果轮询并给出收银核心的系统架构、领域模型以及快捷支付、代扣、卡支付、网银、VA、OTC、第三方钱包等常见方式的适用场景。书中大量手绘风格图示把抽象概念转化为直观流程便于对照自身系统查漏补缺。资源为单个PDF文件约5.2MB已有102人学习可作为架构评审、方案设计与新人培训时的案头参考。1. 收银台不是页面是支付系统里最容易背锅的那一层打开一份讲支付架构的 PDF很多人第一反应是找“支付引擎怎么扣款”。但真正在生产上出问题的八成不是引擎而是收银台这一层用户明明看到余额支付可选点下去提示“余额不足”订单已经关闭了收银台还在渲染支付方式渠道明明关了前端还挂着一个灰色的图标。这份《图解收银台支付系统承上启下的关键应用》就是把这一层拆开讲的材料属于“百图解码支付系统设计与实现”系列的第 11 篇全篇用手绘风格图示配合文字讲清收银核心的定位、支付咨询与支付受理两段流程、系统架构、领域模型和常见支付方式。它适合两类人一是刚接手收银台需求、只会照着接口文档改字段的研发二是做支付架构设计、需要把“可用支付方式怎么算出来”这件事讲清楚的技术负责人。这篇材料不含具体公司的实现讲的是通用设计思路能直接映射到你手上的项目里。下面我按“这是什么 → 怎么落地 → 坑在哪”的顺序拆。2. 收银核心的职责边界为什么它必须挡在支付引擎前面很多团队一开始会把收银台做成一个纯前端页面选完支付方式直接把参数丢给支付引擎。短时间能跑订单一多就开始出乱子。原因很简单支付引擎只认“扣款指令”它不关心这个用户能不能用这张卡、这个商户有没有开这个渠道、这笔金额有没有超日限额。这些判断如果不在收银核心做就只能散落在各个业务方最后变成谁都能改、谁都不敢改的一坨逻辑。2.1 承上启下的两个核心能力材料里把收银核心的能力收敛成两条我认为这是整篇最值得记住的一句支付方式咨询告诉用户本次可以用哪些支付方式。提交支付后的各类校验订单是否有效、商户权限、用户身份、风控等。第一条决定了收银台长什么样第二条决定了钱能不能安全地出去。两条都做完才把请求交给支付引擎做真实扣款。换句话说收银核心是“决策层”支付引擎是“执行层”。决策层做错执行层再稳也没用——它只会忠实执行一个错误的扣款。这里有个容易被忽略的细节支付引擎返回成功不等于用户看到支付成功。材料里明确说最终结果要从收单平台轮询而不是从支付引擎取。因为收单侧可能因为订单过期关闭而发起资金退回此时引擎是成功的、收单是失败的。用户看到的“订单已关闭如已扣款将于 N 个工作日原路退回”就是这条链路真实的产物。凡是把引擎结果直接当最终态返给前端的实现早晚要在这个点上翻车。2.2 支付咨询的六步拆解支付咨询不是查一次数据库就完事。材料给出的顺序是基础检查 → 资产咨询 → 渠道咨询 → 额度咨询 → 支付方式组装 → 支付方式排序。这个顺序有内在依赖不能随意调换。步骤要做的事依赖的数据失败后的表现基础检查订单是否已支付、用户是否有效、商户是否正常订单中心、用户中心、商户服务直接拦截不进入后续步骤资产咨询绑卡列表、账户余额、营销优惠满减、红包卡中心、账户、营销系统对应支付方式不出现渠道咨询按币种、金额、渠道开关筛可用渠道渠道网关配置渠道下线即不可见额度咨询单笔、日累计、月累计限额限额配置、当日累计流水超限的方式置灰或剔除支付方式组装把资产与渠道组合成用户能理解的方式上一步结果展示形态错误支付方式排序按体验与营销策略推荐排序策略配置用户找不到想用的方式这张表我建议直接拿去对照自己的代码。绝大多数“可选支付方式不对”的问题都能定位到某一步的输入数据错了而不是组装逻辑写错了。2.3 把咨询逻辑写成可复用的检查链落地时我一般不会写一个巨大的 if-else而是把每一步做成独立的检查器串成链。下面是简化后的骨架重点是结构而不是具体字段from dataclasses import dataclass, field from typing import List dataclass class PayContext: order_no: str user_id: str merchant_id: str amount: int # 单位分避免浮点误差 currency: str pay_methods: List[dict] field(default_factorylist) class Checker: def check(self, ctx: PayContext) - bool: raise NotImplementedError class OrderValidChecker(Checker): 基础检查订单存在、未支付、未过期 def check(self, ctx): order load_order(ctx.order_no) # 查订单中心 if order is None or order.status ! INIT: return False if order.expire_at now(): return False return True class BalanceChecker(Checker): 资产咨询余额不足则不挂余额支付方式 def check(self, ctx): balance load_balance(ctx.user_id) if balance ctx.amount: ctx.pay_methods.append({type: BALANCE, priority: 10}) return True class LimitChecker(Checker): 额度咨询超过单笔限额则剔除该方式 def check(self, ctx): kept [] for m in ctx.pay_methods: if ctx.amount load_single_limit(m[type], ctx.merchant_id): kept.append(m) ctx.pay_methods kept return True def query_pay_methods(ctx: PayContext) - List[dict]: chain [OrderValidChecker(), BalanceChecker(), LimitChecker()] for c in chain: if not c.check(ctx): return [] # 任一硬性检查失败直接返回空 return sorted(ctx.pay_methods, keylambda x: -x[priority])逻辑说明PayContext是全流程的上下文载体金额统一用分存储避免浮点比较带来的“余额 99.99 却显示可用”这类玄学问题。检查链里区分两类失败像订单无效这种硬性检查返回空表示不可支付像额度这种软性检查只是把某个支付方式从列表里剔掉不影响其他方式。参数说明priority是排序权重营销侧想推某种方式时调大它即可不需要改检查逻辑。load_single_limit这类函数在真实项目里通常带本地缓存加短 TTL因为收银台是高频读、低频写的场景直接每次打限额服务会把下游压垮。3. 支付受理为什么咨询阶段的工作要再做一遍用户点了“确认支付”之后进入受理阶段。材料里有一句话很关键支付咨询阶段的工作要全部再做一遍。很多新手觉得这是浪费性能想加缓存跳过。血泪经验是——不能跳。用户从看到支付方式到真正点击中间可能隔了几分钟甚至几小时余额被别的订单花掉了、订单过期关闭了、商户渠道被运营下线了这些变化全在这段时间里发生。3.1 受理阶段的执行顺序材料的顺序是重复咨询校验 → 风控判断 → 渠道路由选最优渠道 → 提交支付引擎扣款 → 从收单平台轮询结果。这个顺序不能乱尤其是风控必须在路由之前因为风控的结论会直接影响允许走哪些渠道。def accept_payment(ctx: PayContext, chosen_method: dict) - dict: # 1. 复检复用咨询阶段的检查链不允许跳过 methods query_pay_methods(ctx) if not any(m[type] chosen_method[type] for m in methods): return {code: PAY_METHOD_UNAVAILABLE, msg: 支付方式已不可用} # 2. 风控必须在路由前风控结论会影响可用渠道 risk risk_check(ctx.user_id, ctx.amount, chosen_method) if risk.blocked: return {code: RISK_REJECT, msg: risk.reason} # 3. 渠道路由卡支付等外部渠道需要选出最优通道 route None if chosen_method[type] in (CARD, BANK): route channel_route(chosen_method, ctx.amount, ctx.currency) if route is None: return {code: NO_ROUTE, msg: 无可用渠道} # 4. 提交支付引擎真正发起扣款 pay_no submit_to_engine(ctx, chosen_method, route) # 5. 从收单平台轮询最终结果而不是从引擎取 return poll_acquire_result(ctx.order_no, pay_no)逻辑说明第 1 步直接复用query_pay_methods保证咨询与受理用的是同一套规则避免两处逻辑漂移。第 3 步只对外部渠道做路由余额、内部账户这类不经过外部通道的方式可以跳过。第 5 步是整段最容易写错的地方poll_acquire_result查的是收单侧订单状态不是引擎流水状态。参数说明poll_acquire_result要有明确的最大轮询次数和间隔一般前几次间隔短、后面逐步拉长超时后返回“处理中”而不是直接判失败把结果交给异步通知兜底。用同步死循环等结果的写法在渠道抖动时会把线程池打满。3.2 收银核心的系统架构与外部依赖材料把收银核心的外部依赖列得很清楚会员、商服、卡中心、风控、渠道网关、支付引擎。这个清单本身就是一张排障地图——收银台报错先按依赖顺序从下游往上查基本不会跑偏。从产品形态看收单侧通常包括标准收银台、前置收银台、扫码付三类。标准收银台由支付平台提供、需要跳转前置收银台直接嵌在商户收银台里完成支付扫码付则分正扫商户出码、用户扫和反扫用户出码、商户扫。这三类在咨询逻辑上是共用的差别主要在交互与安全边界前置收银台因为嵌在商户页面里对参数防篡改和来源校验的要求更高。3.3 领域模型几个容易画错的实体关系收银核心的领域模型不需要画得多复杂但有几个关系必须理清否则代码里会出现循环依赖。第一支付方式不是配置表里的一行它是“资产 渠道 额度”三者组合出来的运行时对象。把它做成数据库实体是常见误用结果就是运营改一条配置、所有用户的展示都变没法做个性化。第二收银单与支付单要分开。收银单记录“用户这次在收银台选了什么”支付单记录“引擎实际执行了什么”。两者一一对应但生命周期不同收银单可以因为超时关闭支付单可能已经成功——这正是前面说的“订单已关闭但钱扣了”的场景来源。第三渠道配置与渠道路由策略解耦。配置回答“这个渠道开没开”路由回答“这笔交易走哪条通道”。混在一起写后面加一条路由规则就要动配置表结构。4. 常见支付方式的落地差异别用一套代码套所有方式材料列了快捷支付、代扣/协议支付、卡支付、网银支付、VA 支付、OTC 支付、第三方钱包、余额支付、正扫反扫。名字多但从收银核心的视角看它们只在三个维度上有差别是否需要跳转、结果是否同步、失败后能否重试。支付方式是否跳转结果获取方式典型坑余额支付否同步并发扣减需幂等与锁快捷支付视渠道同步 异步通知绑卡关系失效未及时清理代扣/协议支付否异步协议过期、授权范围超限卡支付是异步 轮询3DS 挑战中断后状态悬挂网银支付是异步 回跳回跳参数被篡改VA 支付是线下转账银行通知VA 号过期用户超时未转OTC 支付是线下柜台服务商通知用户拿到码但未付款第三方钱包是异步 轮询重复回调VA 和 OTC 这两类在东南亚和国际收款场景里很常见流程几乎一样调用渠道接口为订单生成一个 VA 号或 OTC 码用户拿着它去 ATM 或柜台付款渠道收到钱后通知支付系统支付系统再通知商户发货。区别只在于前者由银行提供、后者由线下零售商提供。设计时把它们抽象成同一类“离线凭证支付”能省掉大量重复代码。代扣/协议支付要注意的是授权边界用户授权的是“某个商户在某个范围内可以扣款”超出范围必须重新授权不能靠一句“用户同意过”就放行。5. 避坑与排查收银台最常见的五个翻车点5.1 现象用户看到余额可选点下去提示余额不足原因咨询阶段查的是缓存余额受理阶段查的是实时余额两者不一致或者两处用了不同的账户口径可用余额 vs 账户总额。解决咨询与受理复用同一个余额服务方法缓存 TTL 控制在秒级余额展示文案统一为“可用余额”并在受理失败时给出明确提示而不是笼统的“支付失败”。5.2 现象订单已关闭收银台仍在渲染支付方式原因基础检查只校验了订单存在没校验状态和过期时间或者校验放在前端做。解决把订单状态与过期时间纳入服务端硬性检查任何一步不通过直接返回空列表前端拿到空列表就展示“订单已关闭”不要自己兜底造一个默认支付方式。5.3 现象渠道已下线前端还挂着灰色图标原因渠道开关的配置有缓存运营改完没失效或者前端把支付方式列表做了本地持久化。解决渠道开关变更时主动失效缓存前端支付方式列表不落地存储每次进入收银台重新拉取。5.4 现象支付引擎成功用户却看到“订单已关闭”原因把引擎结果当成最终态返回没走收单平台轮询。解决受理后统一从收单平台取最终结果对“引擎成功、收单失败”的场景预置资金退回话术避免用户反复来问。5.5 现象用户连点两次扣了两笔原因受理接口没有幂等控制或者幂等键生成规则里带了时间戳导致每次都不一样。解决以订单号 支付方式作为幂等键服务端加唯一约束前端按钮点击后立即置灰只是辅助手段不能当作幂等方案。6. 用一张检查清单把收银核心验收一遍材料里手绘图示的价值在于它把抽象流程画成了可以顺着走一遍的路径。我自己的习惯是每次改动收银核心都拿一张清单从头对一遍比读代码快得多。第一项咨询与受理是否共用同一套检查逻辑。如果答案是“两份代码”那迟早会漂移直接重构成检查链。第二项硬性检查与软性检查是否分清。订单无效、商户冻结属于硬性直接返回空额度超限、余额不足属于软性只剔除对应方式。混在一起写就会出现“余额不够导致整个收银台空白”的荒唐情况。第三项所有对外部渠道的调用是否都有超时与降级。渠道网关抖动是常态收银台不能跟着一起挂。常见做法是给每个渠道调用设独立超时失败后按降级策略决定是剔除该方式还是整体报错。第四项最终态是否只从收单平台取。这条前面说过多次但每次代码评审我都能看到有人从引擎流水直接读状态。第五项支付方式排序策略是否可配置。排序既影响体验又影响营销硬编码在代码里运营提一次需求就要发一次版。# 上线前用脚本核对咨询与受理的支付方式集合是否一致 # 思路同一订单分别调咨询接口与受理预检接口比对结果 curl -s https://api.internal/pay/query?order_no$ORDER | jq .methods[].type | sort consult.txt curl -s -X POST https://api.internal/pay/precheck \ -d {\order_no\:\$ORDER\} | jq .methods[].type | sort accept.txt diff consult.txt accept.txt echo 一致 || echo 存在漂移需排查这段脚本不是银弹但它能在联调阶段抓出大部分“两处逻辑不一致”的问题。参数上注意order_no要用真实处于待支付状态的订单否则两边都返回空比对结果没有意义。最后说个我自己的教训。早年做收银台我总觉得“支付方式列表”是个展示问题重点应该放在扣款链路上。直到有一次线上出现用户反复点击、扣了两笔排查下来发现受理接口的幂等键里带了毫秒时间戳而咨询与受理的检查逻辑又是两份代码——两个小问题叠在一起变成了一个需要人工退款的故障。从那以后我每次动收银核心都强制先把“咨询与受理是否同源”和“幂等键是否稳定”这两条走一遍再谈其他优化。希望帮到你。本文还有配套的精品资源点击获取