简介:本资源是《百图解码支付系统设计与实现》专栏第11篇的深度专题PDF,聚焦“收银核心”这一承上启下的关键应用,面向支付领域初/中级研发工程师、架构师及金融科技从业者,系统解决收银台如何精准渲染支付方式、完成多维校验、协同支付引擎与风控系统等实战难题。全书以手绘图解为核心表达方式,覆盖支付咨询、支付受理、系统架构、领域模型及快捷支付、VA、OTC等9类主流支付方式的设计逻辑与落地细节,兼顾入门理解与高阶架构参考。资源为单文件PDF,共1个5.2MB文档,内容结构清晰,含前言、协同作战图示、收银核心定位解析、6大支付咨询步骤、5步支付受理流程、外部依赖集成说明及典型支付场景对比,图文并茂便于快速掌握核心设计范式。目前已有103人学习下载,是少有的将支付系统前端门面模块拆解至可执行粒度的高质量技术资料。
1. 图解收银台:为什么“最不显眼”的模块反而决定支付成功率?
你有没有遇到过这样的场景:用户在电商 App 上点击“立即支付”,页面卡在加载状态 8 秒,最后弹出“系统繁忙,请稍后再试”;或者更隐蔽的——用户明明绑了三张卡、账户有余额、优惠券也有效,但收银台只显示“微信支付”一种方式,支付宝和银行卡灰掉不可选。这不是前端渲染 bug,而是收银核心在后台悄悄否决了所有其他选项。它不像支付引擎那样执行真实扣款、也不像风控系统那样输出高危拦截日志,但它却是整条支付链路里第一个也是唯一一个“把关人”:所有用户可见的支付能力,都必须经它计算、组装、排序、裁决后才敢呈现。本方案不是讲“怎么调用微信/支付宝 SDK”,而是聚焦于这个承上启下的关键应用——收银核心的设计逻辑、落地实现与交付验证。它适合正在做支付类毕设/课设的学生(尤其需要画清领域模型、写明校验规则、设计可扩展的支付方式组装器),也适合刚接手支付中台改造的工程师(你要能说清“为什么这里要轮询收单平台而不是支付引擎”“为什么 VA 和 OTC 要走不同路由策略”)。全文基于真实项目抽象,所有模块划分、校验顺序、状态流转均来自某跨平台支付中台 V2024 版本的生产级设计,不讲理论空话,只拆解“怎么让收银台既不漏判、也不误杀”。
2. 总体架构设计:三层结构与四个核心服务的职责边界
收银核心不是单体服务,而是一组职责清晰、松耦合、可独立演进的服务集合。我们采用“接入层-业务层-适配层”三层架构,避免把渠道协议、风控策略、营销规则全部塞进一个大 Service 里——那是我带过的三个项目里翻车率最高的设计。
2.1 接入层:统一入口与协议转换
接入层只做三件事:接收请求、校验签名、转成内部统一协议。它不处理任何业务逻辑,也不依赖任何下游服务。典型实现是 Spring Cloud Gateway + 自定义 Filter:
// 支付咨询请求统一入口(简化版) @PostMapping("/pay/consult") public ResponseEntity<ConsultResponse> consult(@RequestBody ConsultRequest request) { // 1. 签名校验(对接口级白名单+时间戳+摘要) if (!signatureValidator.validate(request)) { return ResponseEntity.badRequest().body(ConsultResponse.fail("SIGNATURE_INVALID")); } // 2. 协议转换:将商户传来的 orderNo、amount、currency 映射为内部 OrderContext OrderContext context = orderContextBuilder.build(request); // 3. 转发至业务层,不参与后续逻辑 return ResponseEntity.ok(consultService.execute(context)); }提示:这里
orderContextBuilder是关键。它把五花八门的商户参数(比如有的传order_id,有的传out_trade_no,有的还带scene_type=miniapp)标准化为OrderContext对象。后续所有模块都只认这个对象,不认原始请求字段。这是后期支持新渠道、新场景的后悔药。
2.2 业务层:四大服务的协同编排
业务层是收银核心的大脑,由四个原子服务组成,按严格时序编排:
| 服务名 | 职责 | 输出物 | 是否可并行 |
|---|---|---|---|
EligibilityService | 基础资格检查(订单状态、商户开关、用户实名) | EligibilityResult(true/false + 拒绝原因) | 否,必须串行 |
AssetService | 查询用户资产(余额、绑卡、红包、积分) | AssetBundle(含各资产可用额度、有效期、使用条件) | 是,可并行调用会员/卡中心/营销中心 |
ChannelService | 渠道匹配(币种、金额区间、地区限制、渠道开关) | ChannelCandidate[](候选渠道列表,含权重) | 是,可并行调用渠道网关 |
LimitService | 额度校验(单笔/日/月限额,风控临时冻结) | LimitCheckResult(通过/拒绝 + 当前已用额度) | 否,需在 AssetService 后 |
编排逻辑用状态机实现(非硬编码 if-else),例如:
// 状态机伪代码(实际用 Spring Statemachine 或自研轻量级 StateEngine) stateMachine .transition(STATE_INIT, STATE_ELIGIBILITY_CHECK) .onSuccess(() -> eligibilityService.check(context)) .onFailure(() -> failWithReason("ORDER_INVALID")) .transition(STATE_ELIGIBILITY_CHECK, STATE_ASSET_QUERY) .onSuccess(() -> CompletableFuture.allOf( assetService.queryBalance(context), assetService.queryBoundCards(context), assetService.queryCoupons(context) ).join()) .transition(STATE_ASSET_QUERY, STATE_CHANNEL_MATCH) .onSuccess(() -> channelService.match(context, assetBundle)) .transition(STATE_CHANNEL_MATCH, STATE_LIMIT_CHECK) .onSuccess(() -> limitService.check(context, assetBundle, channelCandidates));设计说明:为什么
EligibilityService必须最先执行?因为它是“快失败”节点——如果订单已关闭或商户未签约,后面所有查询(尤其是调外部系统的)都是浪费资源。而AssetService设计为并行,是因为余额、绑卡、红包通常来自不同系统,串行会拖慢整体响应。ChannelService的输出ChannelCandidate[]不是最终结果,只是候选池,后续还要和资产、额度交叉过滤。
2.3 适配层:支付方式组装与排序引擎
适配层不碰业务规则,只做两件事:组装(把资产、渠道、额度、营销规则拼成用户能看懂的支付项)和排序(按策略决定展示顺序)。这是用户体验的分水岭。
// 组装逻辑(关键:每个支付项必须携带“拒绝原因”字段,用于前端灰显提示) List<PayMethod> assembled = new ArrayList<>(); for (ChannelCandidate candidate : channelCandidates) { PayMethod method = new PayMethod(); method.setId(candidate.getChannelId()); method.setName(candidate.getDisplayName()); // 如"招商银行借记卡" method.setIcon(candidate.getIconUrl()); // 组装可用性:只有所有依赖项都通过,才算真正可用 boolean isAvailable = eligibilityResult.isPass() && assetBundle.hasSufficientBalance(candidate) && limitResult.isWithinLimit(candidate) && candidate.isSwitchOn(); method.setAvailable(isAvailable); method.setUnavailableReason( buildUnavailabilityReason(eligibilityResult, assetBundle, limitResult, candidate) ); assembled.add(method); } // 排序逻辑(支持多策略插件化) assembled.sort((a, b) -> { // 策略1:优先展示用户历史使用频次高的(需查用户行为表) int freqScoreA = userBehaviorService.getFreqScore(a.getId(), context.getUserId()); int freqScoreB = userBehaviorService.getFreqScore(b.getId(), context.getUserId()); // 策略2:营销加权(红包仅限微信支付可用,则微信权重+10) int marketingWeightA = marketingService.getWeight(a.getId(), context); return Integer.compare(freqScoreB + marketingWeightB, freqScoreA + marketingWeightA); });参数说明:
buildUnavailabilityReason()返回的字符串会透传到前端,比如"余额不足(当前可用 ¥23.5,需 ¥99)"或"该卡单日限额已用完(今日已用 ¥5000/¥5000)"。这比单纯灰显更友好,是减少客服咨询的关键细节。
3. 领域模型设计:从“支付方式”到“可支付性”的本质抽象
很多同学一上来就画 ER 图建pay_method表,结果发现加个“虚拟账户 VA”就得改表结构、加个“OTC 柜台码”又得加字段。问题出在没抓住领域本质:收银核心不管理“支付方式是什么”,而是判断“某次交易对某用户是否可支付”。因此,我们的核心聚合根是PaymentEligibility,而非PayMethod。
3.1 核心聚合根:PaymentEligibility
PaymentEligibility是一次咨询请求的完整决策快照,包含:
orderId: 商户订单号(唯一标识本次咨询上下文)userId: 用户 ID(用于关联资产、行为数据)merchantId: 商户 ID(用于查签约关系、费率)amount: 订单金额(单位:分,避免浮点误差)currency: 币种(CNY/USD/THB 等)eligibilityStatus: 枚举值PASS/FAIL/UNKNOWNfailureReasons: 失败原因列表(支持多原因并存,如“余额不足”+“风控拦截”)availableMethods: 可用支付方式列表(每个是PayMethodSnapshot)
设计说明:
PaymentEligibility是只读快照,不更新。每次咨询都生成新实例,便于审计、回溯、AB 测试。failureReasons用 List 而非单字段,是因为真实场景中常有多重拦截(比如用户余额够,但该卡被风控临时冻结,同时订单金额超单笔限额)。
3.2 支付方式快照:PayMethodSnapshot
PayMethodSnapshot不是配置表里的静态数据,而是动态计算出的“本次可用性快照”:
public class PayMethodSnapshot { private String channelId; // 渠道ID,如 "wechat_pay", "va_kbank" private String displayName; // 展示名称,如 "微信支付", "泰国汇商银行VA" private String iconUrl; // 图标地址 private boolean available; // 本次是否可用 private String unavailabilityReason; // 不可用原因(前端直接展示) private BigDecimal usableAmount; // 本次可用额度(如余额支付显示"可用 ¥23.50") private List<String> applicableCoupons; // 本次可叠加的优惠券ID列表 private int sortWeight; // 排序权重(数值越大越靠前) }关键点:
usableAmount和applicableCoupons是实时计算的,不是缓存值。比如用户有 10 张满 100 减 20 的券,但本次订单金额 99,applicableCoupons就是空列表。这保证了前端展示和后端受理的一致性。
3.3 领域事件驱动:解耦校验与通知
所有校验结果不直接写 DB,而是发布领域事件,由监听器异步处理:
EligibilityCheckedEvent: 包含PaymentEligibility全量快照ChannelMatchedEvent: 包含ChannelCandidate[]列表LimitExceededEvent: 当额度超限时触发,用于风控告警
// 监听器示例:记录审计日志 @EventListener public void onEligibilityChecked(EligibilityCheckedEvent event) { auditLogRepository.save(AuditLog.builder() .orderId(event.getEligibility().getOrderId()) .userId(event.getEligibility().getUserId()) .status(event.getEligibility().getEligibilityStatus().name()) .details(JsonUtils.toJson(event.getEligibility())) // 存全量快照 .build()); }好处:当某天要加“对高风险订单自动降权支付方式”功能时,只需新增一个监听器订阅
EligibilityCheckedEvent,无需修改核心校验逻辑。这是应对支付规则高频迭代的底层保障。
4. 关键流程实现:支付咨询与支付受理的两次校验差异
很多初学者以为“支付咨询”和“支付受理”只是同一个流程跑两遍,其实二者在校验深度、时效性要求、失败处理策略上存在本质差异。这是方案能否上线的关键分水岭。
4.1 支付咨询:轻量、快速、容忍弱一致性
咨询阶段的目标是“1 秒内返回尽可能多的可用选项”,允许部分数据短暂滞后(如余额变动延迟 1 秒)。因此:
- 资产查询用缓存:用户余额、绑卡列表走本地 Caffeine 缓存(TTL=5s),避免每次咨询都压会员中心。
- 风控只做基础检查:只查黑名单、实名状态、设备指纹基础分,不做复杂模型打分(留给受理阶段)。
- 渠道匹配用预热规则:渠道开关、币种支持等配置提前加载到内存,不实时查 DB。
- 失败降级策略:若
ChannelService超时,返回已查到的渠道,不阻塞整个流程。
// 咨询阶段的渠道匹配(带超时降级) List<ChannelCandidate> candidates = CompletableFuture .supplyAsync(() -> channelService.match(context, assetBundle), executor) .orTimeout(300, TimeUnit.MILLISECONDS) // 300ms 超时 .exceptionally(ex -> { log.warn("Channel match timeout, fallback to cached channels", ex); return channelCache.getCachedCandidates(context); // 返回缓存兜底 }) .join();血泪经验:某次大促前,我们没给
ChannelService加超时,结果某渠道网关抖动导致咨询接口平均耗时从 300ms 涨到 2.1s,大量用户放弃支付。加了超时+缓存兜底后,P99 恢复到 420ms。
4.2 支付受理:强一致、全量、零容忍
受理阶段是“临门一脚”,必须确保所有数据绝对准确,否则就是资损。因此:
- 资产查询强制刷新:余额、绑卡、优惠券全部走强一致读(如会员中心的
getBalanceSync()接口),不走缓存。 - 风控执行全量模型:调用风控引擎的
riskAssessFull()接口,包含设备、行为、图谱、模型分。 - 渠道路由重新计算:即使咨询时选了“微信支付”,受理时仍要重新调
ChannelService.route(),因为可能微信通道刚扩容、或该用户被风控标记为高风险需切到银行直连。 - 幂等与状态机:受理请求必须带
requestId,服务端用orderId+requestId做幂等键,防止重复提交。
// 受理阶段的强一致资产查询 AssetBundle freshAssets = AssetBundle.builder() .balance(memberService.getBalanceSync(context.getUserId())) // 强一致 .boundCards(cardCenterService.listBoundCardsSync(context.getUserId())) .coupons(couponService.listValidCouponsSync(context.getUserId(), context.getOrderId())) .build(); // 风控全量评估 RiskAssessmentResult riskResult = riskEngine.assessFull( context, freshAssets, channelCandidates ); if (!riskResult.isPass()) { throw new RiskRejectException(riskResult.getRejectReason()); }参数说明:
assessFull()接口会同步调用多个风控子系统(设备指纹、反欺诈模型、关系图谱),耗时较长(平均 800ms),所以必须放在受理阶段,不能放在咨询阶段拖慢首屏。
4.3 为什么轮询收单平台而非支付引擎?
这是面试高频题,也是线上事故高发点。原文提到“收单结果代表最终支付结果”,但没说清技术原因。真相是:支付引擎只管“扣款指令是否发出”,收单平台才管“资金是否真正到账”。
- 支付引擎返回
SUCCESS,只表示“已向银行发送扣款指令”,但银行可能因余额不足、风控拦截、网络超时等原因未执行。 - 收单平台是资金流的终点,它持有与银行/渠道的对账文件,能确认“该笔资金是否已清算成功”。只有收单平台的状态才是法律意义上的支付完成。
因此,轮询逻辑必须:
- 轮询目标:
GET /settlement/order/{orderId}/status(收单平台接口) - 轮询策略:指数退避(首次 1s,然后 2s、4s、8s… 最大 60s,共 10 次)
- 终止条件:收到
SETTLED或FAILED,或超时(10 次后仍PROCESSING,则主动查单)
// 轮询收单平台状态(简化) public SettlementStatus pollSettlementStatus(String orderId) { for (int i = 0; i < 10; i++) { try { SettlementStatus status = settlementClient.getStatus(orderId); if (status == SettlementStatus.SETTLED || status == SettlementStatus.FAILED) { return status; } } catch (Exception e) { log.warn("Poll settlement status failed for {}", orderId, e); } Thread.sleep((long) Math.pow(2, i) * 1000); // 指数退避 } return SettlementStatus.UNKNOWN; // 超时,需人工介入 }注意:轮询期间前端必须显示“支付处理中”,不能让用户反复点击“确认支付”。这是 UX 和资金安全的双重底线。
5. 常见问题排查:收银核心上线前必过的 5 个生死关
收银核心是支付链路的“第一道闸门”,看似简单,实则暗礁密布。以下是我在三个项目中踩过的坑,按发生频率和严重程度排序,每一条都附带现象、根因、解决路径。
5.1 现象:用户看到“支付宝可用”,点击后报错“渠道不支持该币种”
- 原因:
ChannelService的币种校验逻辑有缺陷。咨询阶段只检查currency == "CNY",但支付宝国际版实际支持CNY/USD/EUR,而受理阶段调用的alipay-sdk接口却硬编码了currency=CNY,导致调用失败。 - 解决:在
ChannelCandidate中增加supportedCurrencies: Set<String>字段,咨询阶段用此字段过滤,受理阶段将context.getCurrency()透传给 SDK,不再硬编码。同时在测试用例中加入多币种组合覆盖。
5.2 现象:用户有 100 元红包,订单 99 元,但收银台不显示“红包抵扣”
- 原因:
AssetService查询红包时,只查了status=VALID,但忽略了红包的minOrderAmount(满 100 元可用)和scope(仅限特定品类)。咨询阶段没把这些约束带到applicableCoupons列表里。 - 解决:
CouponService.listValidCouponsSync()方法必须传入OrderContext全量信息,在内存中逐个校验minOrderAmount、scope、timeRange,只返回真正可用的券 ID 列表。并在PayMethodSnapshot.applicableCoupons中明确标注“本次可用”。
5.3 现象:大促期间收银台响应变慢,P99 从 300ms 涨到 3s
- 原因:
EligibilityService的订单状态检查,直接查了订单主库的order_status字段,而大促时订单库读压力暴增,导致该服务线程池被打满。 - 解决:订单状态对收银核心而言是“最终一致性”数据,引入订单状态缓存服务(基于 Canal 监听 binlog 更新 Redis),
EligibilityService改为查缓存。缓存 TTL 设为 10s,业务可接受 10s 内的状态延迟。
5.4 现象:用户选择“VA 支付”后,生成的 VA 号无法在 ATM 机识别
- 原因:
ChannelService调用泰国某银行 VA 接口时,传参accountType=SAVINGS(储蓄账户),但该银行实际要求accountType=CURRENT(活期账户)才能生成可转账的 VA 号。 - 解决:建立渠道参数映射表(DB 表
channel_param_mapping),字段包括channelId,paramKey,paramValue,remark。ChannelService在调用前,先查此表获取银行要求的实际参数值,而非写死在代码里。这样新加银行时,只需配表,不改代码。
5.5 现象:同一用户在咨询和受理之间切换支付方式,第二次受理失败
- 原因:受理阶段的幂等键只用了
orderId,没包含channelId。用户第一次选微信支付失败后,换选银行卡,系统仍用原orderId查幂等表,发现“已存在”,直接返回失败,但实际是不同渠道。 - 解决:幂等键升级为
orderId + channelId + requestId三元组。同时在数据库幂等表增加联合索引(order_id, channel_id),确保查询性能。
提示:这 5 条问题,有 4 条都源于“把渠道协议细节写死在业务逻辑里”。我的习惯是:所有渠道相关参数、校验规则、错误码映射,全部抽离到配置中心(如 Apollo)或规则引擎(Drools),业务代码只调接口。这样渠道增减、参数调整,都不需要发版。
6. 方案验收与答辩技巧:用三张表讲清你的设计价值
交毕设/课设/方案文档,不是堆砌技术名词,而是证明你解决了真问题。我带过的项目,评审最关注三点:是否理解业务本质、是否考虑生产约束、是否具备可演进性。下面教你用三张表,在答辩时 5 分钟讲清价值。
6.1 表一:业务场景-能力映射表(证明你懂业务)
这张表回答:“为什么收银核心不能交给前端做?”——因为前端无法获取风控、额度、渠道等敏感数据。
| 业务场景 | 前端能做的 | 收银核心必须做的 | 技术实现要点 |
|---|---|---|---|
| 用户有余额+红包+银行卡,但红包满 100 减 20,订单 99 元 | 显示“余额支付”“银行卡支付” | 计算出“红包不可用”,且在银行卡支付项旁标注“可用红包:无” | CouponService在内存校验minOrderAmount |
| 泰国用户选 VA 支付,需生成带银行前缀的 VA 号 | 无法生成,需后端调银行 API | 调用 KBank 接口,传accountType=CURRENT,解析返回的va_number | 渠道参数映射表 + 异步回调处理 |
| 用户在咨询后 30 分钟才支付,期间余额被提现 | 无法感知余额变化 | 受理阶段强一致查余额,发现不足则拦截 | memberService.getBalanceSync()接口 |
答辩话术:“老师,这张表说明收银核心不是简单的‘按钮渲染’,而是连接业务规则与资金安全的翻译器。比如第三行,如果前端自己算余额,30 分钟前查的是 100 元,30 分钟后实际只剩 50 元,就会导致支付失败甚至资损。”
6.2 表二:非功能需求达标表(证明你考虑生产)
这张表回答:“你的设计能扛住大促吗?”——评审最怕学生只写“高并发”,不写具体数字。
| 非功能需求 | 设计目标 | 实测结果(压测环境) | 关键设计 |
|---|---|---|---|
| 咨询接口 P99 延迟 | ≤ 500ms | 420ms | 缓存 + 超时降级 + 并行查询 |
| 受理接口成功率 | ≥ 99.99% | 99.992% | 幂等键升级 + 收单平台轮询重试 |
| 渠道新增周期 | ≤ 1 人日 | 0.5 人日 | 渠道参数映射表 + SDK 适配器模式 |
| 审计日志保留 | ≥ 180 天 | 180 天(自动归档) | AuditLog表按月分表 + TTL 策略 |
答辩话术:“老师,我们用 JMeter 对咨询接口压测,1000 TPS 下 P99 是 420ms,满足设计目标。关键在于把渠道匹配做了超时降级,否则某渠道抖动会导致整体超时。”
6.3 表三:可演进性设计表(证明你有架构思维)
这张表回答:“如果明年要加数字货币支付,你的设计怎么改?”——体现长期维护成本意识。
| 演进需求 | 修改点 | 工作量 | 是否影响现有功能 |
|---|---|---|---|
| 新增越南 ZaloPay 支付 | 1. 配置渠道参数映射表 2. 实现 ZaloPayAdapter | 0.5 人日 | 否,适配器隔离 |
| 支持“先享后付”信用支付 | 1. 扩展AssetService增加信用额度查询2. LimitService增加信用额度校验 | 1 人日 | 否,领域模型已预留扩展点 |
| 将风控模型从规则引擎升级为 AI 模型 | 1.RiskEngine接口不变2. 新增 AIBasedRiskAssessor实现类 | 2 人日 | 否,依赖倒置原则 |
答辩话术:“老师,这张表说明我们的设计不是一次性工程。比如加 ZaloPay,只需要配表+写一个适配器,不用动核心流程。这就是领域驱动设计里‘防腐层’的价值——把易变的渠道协议,和稳定的业务逻辑隔离开。”
从那以后我每次设计支付类模块,都强制走一遍这三张表:先问“业务上到底要解决什么”,再问“生产环境能不能扛住”,最后问“半年后加新功能会不会推倒重来”。这三张表不是文档负担,而是帮你把模糊的“我觉得应该这样”变成清晰的“我必须这样”的思维锚点。希望帮到你。
本文还有配套的精品资源,点击获取