资讯详情

收银核心架构拆解:支付咨询与受理链路设计

📅 2026/11/2 5:13:43 | 华诺云谱 👁 阅读
收银核心架构拆解:支付咨询与受理链路设计
简介这份PDF面向支付系统初学者与希望提升架构设计能力的研发人员聚焦收银核心在支付链路中承上启下的定位讲清其与支付引擎的协同关系、系统架构与领域模型。内容围绕支付咨询、支付受理两条主线展开覆盖基础检查、资产与渠道咨询、额度校验、支付方式组装排序、风控挑战、渠道路由及交易结果轮询并梳理快捷支付、代扣/协议支付、卡支付、网银、VA、OTC、第三方钱包与余额支付等常见方式配有大量手绘风格图示帮助理解抽象设计。包内共1个PDF文件大小约5.2MB便于集中阅读与查阅。已有102人学习适合作为支付系统入门、架构梳理和日常研发设计的实战参考。1. 收银台那排灰掉的支付方式背后压着六层过滤收银台的那一屏支付方式按钮是支付系统里最容易背锅的地方。用户只看到四个图标其中一个还是灰的就有人来问为什么我的余额用不了顺着请求往下翻才发现收银核心要对订单、商户、用户、资产、渠道、额度各判一遍任何一层返回不可用最终就只剩下一行提示。这份图解收银台的资料把支付系统里这个承上启下的应用拆成了架构、领域模型、支付咨询、支付受理几条线用手绘图把每一步在算什么讲清楚而不是甩一堆名词。它适合两类人一类是刚接手收银台或支付咨询模块、需要先把整体链路在脑子里搭起来的后端另一类是要做支付中台架构设计、想看看别人怎么划分边界的产品和架构。它不讲具体某家公司的实现讲的是通用套路这正是它能直接拿来当自己系统设计底稿的原因。2. 收银核心的定位与系统架构它在支付系统里到底卡在哪个位置2.1 一句话定位它是门面不是账本先把最容易混淆的两个角色分开。收银核心负责的是「用户能看到什么、点下去之后能不能过」支付引擎负责的是「真正把钱从一个账户挪到另一个账户」。前者是门面后者是账房。门面可以重来账房不能出错。这个分工带来的直接结果是收银核心的所有判断都是可重复、可回滚、不产生资金副作用的。它查余额、查券、查限额、查渠道开关全是读操作加上一些缓存与拼装真正写资金的只有最后一步「提交支付引擎」。所以收银核心的接口可以相对随意地重试、并行、加缓存而支付引擎那一侧必须严格幂等。资料里用咖啡店做比喻收银核心是那位侍者递菜单、核身份、查预存款、做背影调查但他不碰收银机里的现金。这个比喻听着轻实际点出了收银核心的两个核心能力——支付方式咨询和支付受理校验前一个回答「你能用什么付」后一个回答「你这次到底能不能付成」。常见做法是把这两个能力做成两个独立接口而不是一个接口带个 action 参数。原因很实际咨询是高频、可缓存、允许降级的受理是低频、强一致、不允许降级的。两者混在一个接口里超时策略和缓存策略没法分开配最后就是咨询被受理拖慢或者受理被咨询的缓存污染。2.2 系统架构收银核心到底依赖谁把收银核心放在中间它上游是各种收银产品形态下游是一串外部服务。资料里列的外部依赖基本是这个行业的标配我按「向它要什么」整理一遍依赖服务收银核心向它要什么拿不到时的降级会员服务用户身份、实名状态、账户状态一般不可降级直接拒绝商户服务商户开通的支付产品、结算配置、权限不可降级返回商户未开通卡中心用户已绑卡列表、卡类型、卡 BIN 信息可降级隐藏卡支付方式风控服务受理阶段的实时风险决策视策略多数走人工或拒绝渠道网关渠道开关、渠道容量、渠道路由结果可降级过滤掉该渠道支付引擎真实扣款与交易状态不可降级直接失败营销/资产余额、红包、满减券可降级不带营销方式这张表的价值在于它把「哪些失败可以让用户继续付、哪些失败必须拦住」提前定了。很多收银台翻车不是逻辑写错是降级策略没定一个非核心的券服务超时把整屏支付方式干没了。上游的收银产品形态也要分清资料里提到三种差异点很实在标准收银台由支付平台提供页面商户把用户跳转过来。好处是支付方式统一维护坏处是跳出商户站点转化率受损。前置收银台页面嵌在商户自己的收银台里通过 SDK 或 iframe 调支付平台的咨询与受理接口。体验好但商户侧要处理样式和兼容。扫码付拆成正扫和反扫。正扫是商户出码、用户扫反扫是用户出码、商户扫。反扫对收银核心的压力最大因为它要求毫秒级返回扣款结果链路里能省的查询都得省。2.3 领域模型把「一次收银」拆成哪些实体资料里有一张领域模型图落到代码上大概是这样一组对象。这里给的是骨架重点是字段之间的关系不是完整实现// 收银请求一次「打开收银台」或「确认支付」的上下文 public class CashierRequest { private String cashierNo; // 收银请求号全局唯一幂等键的基础 private String bizOrderNo; // 业务订单号商户侧订单 private String merchantId; // 商户 private String userId; // 付款用户 private Long amount; // 金额最小货币单位 private String currency; // 币种决定可用渠道集合 private String scene; // 场景PC / H5 / APP / 小程序 / 线下扫码 private String payMethodCode; // 受理阶段才有值咨询阶段为空 } // 支付方式项最终渲染给用户的一个按钮 public class PayMethodItem { private String payMethodCode; // 支付方式编码如 BALANCE / QUICK_PAY / VA private String payMethodType; // 大类账户类 / 卡类 / 渠道类 / 线下类 private String channelCode; // 具体渠道如某银行借记卡 private Boolean available; // 是否可用 private String unavailableReason; // 不可用原因用于置灰文案 private Integer sortWeight; // 排序权重 private ListAssetInfo assets; // 该方式下可选的资产如多张卡、余额 private ListPromoInfo promos; // 可用营销权益 private QuotaInfo quota; // 限额信息用于前端提示 }cashierNo这个字段很少有人重视但它是后面所有幂等、重试、对账的主键。很多团队图省事直接拿bizOrderNo当幂等键结果用户换个支付方式重付一次直接被判成重复请求血泪经验。正确的做法是一次「用户点击确认支付」生成一个cashierNo同一个cashierNo重试才幂等换支付方式必须换新号。PayMethodItem里available和unavailableReason要成对出现。只返回 availablefalse 不返回原因的前端只能写「暂不可用」产品和客服都会被问爆。资料里强调的「支付方式组装」本质就是把渠道、资产、营销、限额四路信息归一化到这个结构上。3. 支付咨询链路拆解六步算出用户能看见的支付方式3.1 六步链路每一步的输入输出资料把支付咨询拆成六步顺序不能乱因为后一步依赖前一步的产出基础检查。校验订单是否存在、是否已支付、是否已关闭、是否超时再校验用户状态和商户是否开通对应支付产品。这一步是硬门槛任何一条不过就直接返回空列表不用往下走。资产咨询。查绑卡列表、账户余额、可用红包和满减券。注意这里查的是「候选资产」不是「最终使用的资产」所以允许宽松一点比如余额不够本次金额也先返回让用户看到「余额 ¥12.00不足」比直接隐藏更友好。渠道咨询。按币种、金额、渠道开关、渠道容量筛出可用渠道。跨境场景里币种是第一过滤条件境内场景里金额和渠道限额是第一过滤条件。额度咨询。单笔限额、日累计限额、月累计限额。这里最容易出错的是「累计」的口径——是按用户维度累计、按卡维度累计还是按商户维度累计各家不一样必须提前定死并写进接口文档。支付方式组装。把前四步的结果归一化成PayMethodItem。一个支付方式可能对应多个渠道某张卡可以走 A 通道也可以走 B 通道组装时要决定是合并成一个按钮还是拆成两个。支付方式排序。可用性优先然后才是体验和营销。营销加权必须排在可用性之后否则就会出现「推了一张用户根本付不了的方式在第一位」这种翻车。这六步里第 1 步和第 4 步是必须串行的第 2、3 步和第 4 步之间可以并行。工程上我一般这么排// 咨询主流程并行发起三路查询再统一组装 public PayConsultResult consult(CashierRequest req) { // 1. 基础检查串行失败直接短路 checkOrderAndMerchant(req); // 2. 资产、渠道、额度三路并行各自超时 200ms CompletableFutureAssetResult assetFuture CompletableFuture.supplyAsync(() - assetService.query(req), pool); CompletableFutureChannelResult channelFuture CompletableFuture.supplyAsync(() - channelService.query(req), pool); CompletableFutureQuotaResult quotaFuture CompletableFuture.supplyAsync(() - quotaService.query(req), pool); // 3. 任一路超时按降级处理不阻塞整体 AssetResult assets getOrDefault(assetFuture, AssetResult.empty()); ChannelResult chans getOrDefault(channelFuture, ChannelResult.empty()); QuotaResult quotas getOrDefault(quotaFuture, QuotaResult.empty()); // 4. 组装 排序 ListPayMethodItem items assemble(req, assets, chans, quotas); return new PayConsultResult(sort(items), buildCacheKey(req)); }getOrDefault这里是关键它把「某一非核心依赖挂了」变成「少几种支付方式」而不是「整个收银台白屏」。但要注意资产和渠道同时降级成空返回的列表也是空前端依然白屏所以降级要分级——卡中心挂了只隐藏卡方式渠道网关挂了才降级到只用余额。3.2 组装结果长什么样给前端的返回结构资料里没有贴报文但按这套模型返回结构大概是这样。字段名不重要重要的是每个字段回答前端的一个具体问题{ cashierNo: CSH20240124000001, bizOrderNo: ORD90001, amount: 12800, currency: CNY, expireTime: 2024-01-24T23:59:5908:00, payMethods: [ { payMethodCode: BALANCE, payMethodType: ACCOUNT, available: true, sortWeight: 100, assets: [{ assetType: BALANCE, balance: 50000 }], promos: [{ promoType: COUPON, promoId: C001, discount: 500 }] }, { payMethodCode: QUICK_PAY, payMethodType: CARD, available: false, unavailableReason: 该卡单笔限额 100 元当前订单 128 元, sortWeight: 90, assets: [{ assetType: BANK_CARD, cardId: CARD001, cardType: DEBIT }] } ] }expireTime这个字段一定要给。收银台页面放着不动用户半小时后才点确认这时候固定不变的支付方式列表就已经过期了。返回过期时间前端可以主动刷新比让用户在受理阶段吃一个「订单已过期」的报错体验好得多。unavailableReason里带上具体数字是产品体验的分水岭。「暂不可用」和「该卡单笔限额 100 元当前订单 128 元」是完全两种感受后者用户会自己去换卡前者用户会来找客服。3.3 咨询阶段的性能账能缓存什么、不能缓存什么咨询是收银台的首屏接口通常要求 200ms 内返回P99 不超过 500ms。这个指标下不可能每次都把六步全查一遍所以缓存是必须的但缓存粒度要想清楚可缓存支付方式的基础配置有哪些支付方式、各自图标文案、渠道开关这类数据变更低频本地缓存 15 分钟没问题。可缓存但要有短 TTL卡列表、余额TTL 控制在秒级或者干脆不缓存只并行。不可缓存订单状态、限额累计值、营销券可用性。这三个变了用户点下去就会失败缓存等于制造故障。一个实用的折中是把「支付方式配置」和「用户资产」拆成两个接口前者走 CDN 或本地缓存后者实时查。前端先渲染出骨架资产到了再填数字。这样首屏时间能压到 100ms 以内用户感知不到后面的查询。4. 支付受理链路拆解从点击确认支付到拿到最终结果4.1 为什么咨询做过的事要再做一遍用户从看到支付方式到点击确认中间可能隔了几十秒也可能隔了几十分钟。这段时间里后台数据会变余额被别的订单扣了、券过期了、订单超时关闭了、商户被风控限额了。所以受理阶段必须把咨询阶段的六步完整再跑一次而且这次不能降级、不能读缓存。资料里专门提了这一点我觉得这是整份资料最实在的地方之一。很多团队觉得「咨询都过了受理直接扣款就行」上线后就会出现「余额不足」的报错出现在用户明明看到余额充足的情况下——因为咨询读的是缓存受理读的是实时值。工程上的写法是把咨询逻辑抽成一个doConsult(req, strictMode)方法咨询接口用strictModefalse允许降级、读缓存受理接口用strictModetrue不允许降级、强制实时。同一份代码两种模式避免两份逻辑漂移。4.2 五道关卡与主流程受理阶段按资料的说法有五件事重跑咨询、风控判断、渠道路由、提交支付引擎、轮询结果。落到代码// 受理主流程严格模式全程不允许降级 public PayAcceptResult accept(CashierRequest req) { // 0. 幂等同一 cashierNo 重复请求直接返回上次结果 PayAcceptResult exist idempotentRepo.get(req.getCashierNo()); if (exist ! null) return exist; // 1. 严格模式重跑咨询本次必须拿到指定支付方式 PayConsultResult consult doConsult(req, true); PayMethodItem chosen pick(consult, req.getPayMethodCode()); if (chosen null || !chosen.getAvailable()) { return PayAcceptResult.fail(PAY_METHOD_UNAVAILABLE, chosen.getUnavailableReason()); } // 2. 风控同步调用决策为拒绝则直接返回 RiskDecision risk riskService.decide(req, chosen); if (risk.isReject()) return PayAcceptResult.fail(RISK_REJECT, risk.getReason()); // 3. 渠道路由外部渠道才需要余额类内部方式跳过 String routeChannel chosen.getChannelCode(); if (chosen.isExternalChannel()) { routeChannel routeService.route(req, chosen); // 选一条最优通道 if (routeChannel null) return PayAcceptResult.fail(NO_AVAILABLE_CHANNEL, null); } // 4. 落收银单状态置为 PAYING再提交支付引擎 CashierOrder order cashierOrderRepo.create(req, chosen, routeChannel); PayEngineResult engine payEngine.submit(order); // 5. 异步轮询收单平台不在本次请求里等最终结果 pollingScheduler.schedule(order.getCashierNo()); return PayAcceptResult.processing(order.getCashierNo(), engine.getEngineOrderNo()); }几个参数值得说清楚cashierNo的幂等落库必须在第 1 步之前且用唯一索引兜底。并发重复提交靠先查后插是挡不住的索引冲突才是最后一道防线。routeChannel必须在落收银单时持久化。渠道路由如果在下单时选一次、扣款时再选一次两次结果不一致对账时就会对不上账这是典型踩坑。payEngine.submit返回的应该是「已受理」而不是「已成功」。支付引擎的返回代表它收到了请求不代表银行那边扣款成功。最终的成败由轮询决定。4.3 结果为什么以收单平台为准资料里对这点讲得很清楚值得单独拎出来用户看到的支付结果必须以收单平台的订单状态为准而不是支付引擎的状态。原因是链路末端可能出现「引擎成功、收单失败」的情况。比如用户付款回来支付引擎那边确实扣款成功了但收单平台因为订单已经超时关闭会触发资金退回。这时候收单订单的实际状态是未支付成功用户看到的应该是「订单已关闭如已扣款将原路退回」而不是「支付成功」。这个差异在技术上体现为状态机两个层级层级状态谁维护是否对用户可见支付引擎层INIT / PROCESSING / SUCCESS / FAIL支付引擎否收单平台层WAIT_PAY / PAYING / PAID / CLOSED / REFUNDING / REFUNDED收银核心是收银核心要做的是把引擎状态映射到收单状态并在映射时处理冲突引擎 SUCCESS 但收单 CLOSED映射结果必须是 CLOSED 触发退款而不是 PAID。轮询本身也有讲究。固定间隔 1 秒轮询 30 次是最粗暴的做法实际一般用退避前 3 次 1 秒之后 2 秒、3 秒、5 秒总时长控制在支付渠道的最大超时时间内。超过还没结果就置为「处理中」交给对账兜底不要无限轮询把渠道打挂。4.4 支付方式矩阵每种方式的差异点在哪资料里列了常见的支付方式我把它们按「收银核心要额外处理什么」重新整理了一遍这比按名字罗列有用支付方式触发条件收银核心要额外处理的点余额支付用户有账户余额余额冻结与解冻并发扣减快捷支付用户已绑卡卡状态、卡限额、短信验证码卡支付用户输入卡信息卡 BIN 识别、3DS 挑战网银支付用户选银行跳转地址生成、回跳验签代扣/协议支付用户已签约协议号校验、扣款频次限制VA 支付东南亚等场景临时账户生成、到账通知、超时失效OTC 支付线下连锁网点支付码生成、码有效期、到账回调第三方钱包用户选钱包跳转授权、回跳验签、退款链路正扫/反扫线下扫码反扫要求同步返回链路要极短VA 和 OTC 这两种在国内系统里少见但流程值得记一下都是「先生成一个凭证码或虚拟账号用户拿去线下或另一个通道完成付款通道方回调通知支付系统支付系统再通知商户发货」。收银核心在这里的职责是生成凭证、维护凭证有效期、处理回调的幂等而不是自己去查账。5. 避坑与排查收银核心设计里最容易翻车的五处5.1 现象咨询显示可用受理返回「支付方式不可用」原因咨询走了缓存受理读的是实时值或者咨询阶段某一路降级成空、受理阶段严格模式拿到真实数据。解决咨询和受理共用同一个doConsult靠strictMode参数区分咨询结果里带上expireTime超时后前端强制刷新。另外在受理失败时把咨询时的快照打到日志里方便对比是哪一路数据变了。5.2 现象推荐排序把可用的支付方式排到了后面原因排序权重只考虑了营销加权没把available作为第一优先级或者前端拿到列表后自己又排了一次序。解决服务端排序时先按available分组可用组内再按权重排不可用的一律置底或按产品要求折叠。接口文档里明确写「返回顺序即展示顺序前端不得重排」避免两端各排一次。5.3 现象用户看到「支付成功」几分钟后钱被退回原因结果判断取自支付引擎状态而收单订单已超时关闭触发了资金退回。解决用户侧结果一律以收单平台订单状态为准引擎状态只用于内部映射。映射函数要单独写单元测试把「引擎 SUCCESS 收单 CLOSED」这类组合全部覆盖。5.4 现象用户换一种支付方式重付被判定为重复请求原因幂等键用了业务订单号第二次请求直接命中第一次的幂等记录。解决幂等键用cashierNo一次「点击确认支付」生成一个新号业务订单号只用于关联和对账不做幂等。// 正确做法幂等键由「收银请求号」构成而不是业务订单号 String idempotentKey CASHIER:ACCEPT: req.getCashierNo(); // 同一 cashierNo 的重试命中幂等返回首次结果 // 业务订单号相同但 cashierNo 不同视为一次新的支付尝试这里还有一层同一个cashierNo换了支付方式再提交应该直接拒绝因为cashierNo绑定了一次支付尝试。想换方式必须重新走咨询拿新号。5.5 现象额度咨询通过扣款时被渠道拒绝「超出日累计限额」原因额度咨询只算了单笔限额没算日/月累计或者累计口径和渠道侧不一致比如自己按自然日算、渠道按 T0 结算日算。解决额度查询要么直接问渠道实时但不稳定要么本地维护累计值并定期和渠道对账校正。跨天的时间点要跟渠道确认清楚别自己拍脑袋用零点。6. 二次开发把这份图解改造成自己系统的设计底稿拿到这类图解资料最容易犯的错是看完觉得懂了、动手时还是从零开始设计。我的习惯是把它当成一份待填写的模板按四个动作落到自己的项目里。第一步先画边界图再画流程图。边界图上只放三类东西上游收银产品形态、收银核心自己、下游外部依赖。每个依赖旁边写一句「拿不到时怎么办」这句话就是后面的降级策略。图画完了才知道哪些查询能并行、哪些必须串行。第二步把支付咨询定义成一个纯函数。输入是订单、用户、商户、场景输出是支付方式列表中间不产生任何写操作。定成纯函数的好处是可以直接拿去跑单元测试把「余额为 0」「所有渠道关闭」「券全部过期」这些边界条件一次性覆盖。下面这个签名我在几个项目里都用过改的只是内部实现// 咨询无副作用可缓存可并行可重试 PayConsultResult consult(CashierRequest req, ConsultOptions options); // options 里放 strictMode、超时预算、是否允许降级、是否带营销 // 受理有副作用强幂等不缓存不降级 PayAcceptResult accept(CashierRequest req);第三步先把状态机写在代码之前。收单订单的状态、每个状态的进入条件、允许的迁移、迁移时触发的动作发起退款、关闭订单、通知商户这些用一张表列清楚比直接写 if-else 强得多。状态机定完了轮询逻辑和对账逻辑基本就自动出来了。第四步定对账口径。收银单、支付引擎单、渠道单三方对账字段至少要能对上收银请求号、引擎交易号、渠道流水号、金额、币种、时间。这三个号在受理阶段的落库时就都要存下来事后再补是补不回来的。改造清单我一般按这个顺序推进顺序动作验收标准1抽出支付咨询纯函数能用固定入参跑出稳定出参有单测覆盖2定义收银单与支付方式领域对象字段能支撑对账幂等键唯一索引建好3画状态机表所有迁移路径有对应处理无孤立状态4接入渠道路由并持久化结果下单与扣款使用同一路由结果5补轮询退避与对账兜底超时订单能被对账任务收敛有一件事我踩过照着别人的架构图画完觉得结构很干净结果上线后发现咨询接口的 P99 到了 800ms因为图里没画并行。后来我在设计文档里加了一条硬规定——凡是咨询链路里三个以上的外部查询必须标明是否并行、超时多少、降级成什么。从那以后每次评审收银台相关的设计我都会强制把这条过一遍哪怕评审只剩五分钟。这份图解资料的价值不在它给了多少结论而在于它把收银核心这条从「用户看到」到「钱真正动」之间的链路完整摊开了你可以对着它一条条核对自己的系统缺了哪一环。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑