1. 支付模块整体设计与技术选型拆解
电商实战项目把支付功能跑通,和把支付功能做稳,中间隔着的不是一行代码,而是一整套状态管理、幂等控制、回调验签和对账兜底的工程体系。很多人第一次给商城项目接支付,脑子里想的都是“下单之后弹个收银台,用户付完钱把订单改成已支付”,真到联调时才发现问题全藏在细节里:回调来了两次怎么办、回调一直不来怎么办、用户付了钱但订单已经超时关闭怎么办、退款退一半失败又怎么办。支付功能不是订单模块的一个附属按钮,它更像一条独立的外挂链路,必须和业务订单保持“最终一致”,而不是“强一致”。这篇内容面向的是正在做电商实战项目、准备把支付环节补齐的开发者,不管你用的是单体架构还是微服务拆分,下面这些设计取舍和落地细节都能直接参考。
1.1 为什么支付不能当成“下单后加个按钮”的功能
支付链路的本质是“跨系统的一次状态协商”。用户在自己的客户端点击支付,请求先到我们的业务系统,再由业务系统向外部支付渠道发起预下单,渠道返回一个支付凭证,用户拿着凭证在渠道侧完成付款,最后渠道通过异步回调通知我们结果。注意,这中间有四五个环节,每一个环节都可能超时、重试、丢失或者乱序。业务系统唯一能确定的事情是:我发起过一笔支付请求,至于用户到底付没付,必须以渠道的回调或者主动查询为准。
这就是支付模块和普通业务模块最大的区别。普通业务查数据库就能拿到真相,支付模块的“真相”在别人手里。所以我们设计时要把“支付单”和“业务订单”拆开:业务订单负责记录用户买了什么、应付多少;支付单负责记录这一笔支付用哪个渠道、渠道单号是多少、当前处于什么状态。一个业务订单可能对应多笔支付单,比如第一次用渠道A超时关闭了,用户换渠道B重新付;也可能一笔支付单对应多次退款。把这两层拆清楚,后面所有状态流转才有地方落。
还有一个常见误区是把支付结果直接写进订单表的主状态字段。订单状态和支付状态是两个维度:订单可以是“待发货”,支付状态是“已支付”;订单也可以是“已关闭”,支付状态却是“已支付”,因为用户在关单之后才完成付款。这两个维度混在一个字段里,排查问题时根本说不清是业务动作还是资金动作。我的习惯是订单主表只保留业务状态,支付状态单独放在支付单上,两边通过订单号关联,展示层再做聚合。
1.2 支付链路的核心角色与状态流转设计
一条完整的支付链路通常包含这几个角色:业务订单、支付单、渠道交易流水、回调记录、退款单。业务订单是源头,支付单是每次支付的尝试,渠道交易流水是渠道侧返回的凭证,回调记录用于留痕和重放,退款单则独立于支付单存在。角色拆得越清楚,后面出问题时越容易定位到底是哪一层对不上。
支付单的状态我一般控制在五个左右:待支付、支付中、支付成功、支付失败、已关闭。状态之间的迁移必须单向且可审计,绝不允许从“支付成功”回退到“待支付”。每次迁移都记录时间戳和触发来源,比如“用户主动发起”“回调通知”“定时查询补偿”“人工干预”。这条记录在后期排查“订单为什么卡住”时价值极高,比翻应用日志快得多。
| 状态 | 触发动作 | 允许的下一状态 | 备注 |
|---|---|---|---|
| 待支付 | 创建支付单 | 支付中、已关闭 | 预下单成功后才进入支付中 |
| 支付中 | 渠道预下单成功 | 支付成功、支付失败、已关闭 | 超时未回调由补偿任务查询 |
| 支付成功 | 回调验签通过或主动查询确认 | 无(退款走退款单) | 终态,不可逆 |
| 支付失败 | 渠道明确返回失败 | 已关闭 | 允许用户重新发起新支付单 |
| 已关闭 | 超时关单或用户取消 | 无 | 若后续回调成功需走异常处理 |
这里有个容易忽略的细节:预下单成功不等于支付中。有些渠道的预下单只是生成了凭证,用户还没实际付款,这时候支付单应该是“支付中”而不是“待支付”。状态定义含糊,后面补偿任务的判断条件就会写错,进而出现“明明用户没付,系统却去查渠道”的无效请求。
1.3 技术选型:自研对接还是走聚合支付
实战项目里选择支付接入方式,通常有三条路:直连单一渠道、接聚合支付服务商、自己封装一层渠道适配层。直连单一渠道的优点是链路短、文档清晰、问题好排查,缺点是渠道覆盖有限,用户想换一种付款方式就得再加一套对接。聚合支付服务商的优点是接一次覆盖多种付款方式,结算和退款接口相对统一,缺点是多了中间层,出问题时需要两边排查,费率和到账周期也要提前算清楚。
我的建议是:实战项目阶段优先做“渠道适配层 + 单一渠道跑通”,不要把聚合服务商的 SDK 直接散落在业务代码里。定义一个统一的支付网关接口,把预下单、查询、退款、验签这几个能力抽象出来,具体渠道用实现类去填。这样后面加渠道只是新增一个实现,业务层几乎不用改。适配层的接口设计要尽量贴近业务语义,而不是照抄某个渠道的参数字段,否则换渠道时业务代码会被迫跟着改。
public interface PaymentGateway { PrepayResult prepay(PrepayCommand command); QueryResult query(String paymentNo); RefundResult refund(RefundCommand command); boolean verifyCallback(String rawBody, Map<String, String> headers); }适配层内部再做参数转换、签名、HTTP 调用和异常归一化。渠道返回的错误码五花八门,有的用字符串,有的用数字,有的成功码是“10000”,有的是“SUCCESS”。在适配层里统一转成我们自己的错误枚举,业务层只关心“可重试”“不可重试”“需要人工介入”这三类,代码会干净很多。这个设计不是过度工程,而是支付模块唯一值得提前投入抽象的地方,因为渠道变化是迟早的事。
2. 核心细节解析与实操要点
2.1 支付单与业务订单的拆分设计
支付单表的核心字段我通常这样设计:支付单号(系统生成,全局唯一)、业务订单号、渠道编码、渠道交易号、应付金额、实付金额、币种、状态、过期时间、创建时间、更新时间。业务订单号加渠道编码可以建普通索引,支付单号建唯一索引。渠道交易号一定要留可空字段,因为预下单阶段还拿不到,回调之后才回填。
金额字段统一用“分”作为单位的整数存储,这是支付领域最省心的做法。浮点数在金额计算里是灾难,0.1 加 0.2 不等于 0.3 这种事在退款分摊时会把账算乱。数据库用 BIGINT,Java 用 Long,前端展示时再除以 100。币种单独存三位编码,不要默认人民币就省略字段,后面做多币种或者对接海外渠道时会感谢自己当初留了这个口子。
支付单和业务订单的关联关系要允许“一对多”。用户第一次支付超时,我们关闭了支付单,用户重新发起时生成新的支付单,业务订单号不变。这样对账时按业务订单号能拉出所有支付尝试,按支付单号能精确到每一次渠道交互。千万别为了省事让业务订单表直接存一个“支付单号”字段,那等于把一对多硬压成一对一,后面补单和对账都会难受。
2.2 幂等、防重与回调验签的关键细节
支付模块里“幂等”两个字出现的频率最高,但真正做对的人不多。幂等至少有三个层面:接口层防重复提交、回调层防重复处理、状态迁移防并发覆盖。接口层靠令牌或者业务订单号加唯一约束;回调层靠渠道交易号加唯一索引,重复回调直接返回成功;状态迁移靠带条件的更新语句,比如update payment_order set status = 'SUCCESS' where payment_no = ? and status = 'PAYING',影响行数为零就说明已经被别的线程处理过了,直接放弃。
回调验签是另一个高频出问题的点。验签失败的原因通常不是算法写错,而是签名串拼接不对:参数排序规则、空值是否参与签名、编码是否统一、原始报文是否被框架改动过。我的做法是在网关层保留原始请求体,验签时用原始字节而不是反序列化之后再拼,避免 JSON 解析器把字段顺序或者空格改掉。签名算法优先选非对称的,公钥验签、私钥签名,密钥不要硬编码在代码里,放配置中心或者环境变量,并且区分沙箱和生产的密钥。
注意:回调接口必须做到“先验签、再落库、后处理业务”,三步顺序不能乱。先落库是为了留痕,哪怕后面业务处理失败,也能凭回调记录重放;后处理业务是为了把耗时操作移出回调线程,避免渠道因为超时反复重推。
回调处理还有一个反直觉的点:重复回调不是异常,是常态。渠道在网络抖动、我方响应超时、渠道自身重试策略下都会重复推送。所以回调接口的正确姿势是“幂等接收 + 快速应答”,先返回成功,再异步处理业务。如果业务处理失败,靠本地回调记录表加定时任务补偿,而不是靠渠道一直重推。把渠道的重推当成唯一的补偿手段,早晚会遇到渠道停止重推而业务还没处理完的情况。
2.3 金额、币种与精度的处理规范
金额处理的原则只有一条:全程整数,只在展示层做除法。下单时计算应付金额,优惠分摊、运费、税费都要用整数分参与运算,除不尽的部分按约定规则分摊到最后一个商品或者按比例最大余数法处理。退款时按支付单的实付金额做分摊,不能按订单原价退,否则用了优惠券的订单会多退钱。
举个具体的例子:订单原价 199.00 元,优惠 19.90 元,实付 179.10 元,对应 17910 分。用户申请退其中一个商品,该商品原价 99.50 元,按实付比例分摊退款金额 = 17910 × 9950 ÷ 19900,向下取整得到 8954 分,剩余 1 分留在最后一笔退款里补齐。这个分摊逻辑要写成一个独立工具类并配单元测试,不要散落在退款接口里临时算。
币种字段虽然实战项目里通常只有人民币,但字段类型和默认值要提前定好,支付渠道的金额单位也要核对清楚。有的渠道要求传“元”的字符串,有的要求传“分”的整数,适配层里必须做显式转换并加断言。我见过把 19900 分当成 19900 元传出去的案例,沙箱环境不校验金额上限,生产环境直接触发渠道风控。适配层里加一条assert amount > 0 && amount < 100000000这种边界断言,成本极低,收益极高。
2.4 敏感信息与密钥管理
支付模块会接触几类敏感信息:渠道密钥、证书、用户支付账号的脱敏信息、回调报文里的签名。密钥管理的底线是“不进代码库、不进日志、不进前端”。生产密钥放密钥管理服务或者加密配置中心,应用启动时拉取;沙箱密钥可以放本地配置文件,但要在.gitignore里排除。日志里打印请求参数时,对密钥、证书、用户标识做掩码,只保留前后各几位。
证书类渠道还要注意有效期。证书过期不会在代码里报编译错误,只会在某个凌晨突然全部支付失败。我的做法是把证书到期时间写进监控项,提前三十天告警,同时在应用启动时做一次加载校验,加载失败直接拒绝启动,而不是等到第一笔支付进来才暴露问题。这个策略听起来激进,但支付链路的故障成本远高于一次启动失败。
3. 实操过程与核心环节实现
3.1 数据库表结构与关键字段设计
先落表结构,后面所有代码都围绕它转。下面这三张表是我在实战项目里用得比较顺手的组合:支付单表、回调记录表、退款单表。字段没有追求大而全,但该有的唯一约束和索引都留了。
CREATE TABLE payment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, payment_no VARCHAR(64) NOT NULL COMMENT '支付单号', order_no VARCHAR(64) NOT NULL COMMENT '业务订单号', channel_code VARCHAR(32) NOT NULL COMMENT '渠道编码', channel_trade_no VARCHAR(128) DEFAULT NULL COMMENT '渠道交易号', amount BIGINT NOT NULL COMMENT '应付金额,单位分', paid_amount BIGINT DEFAULT NULL COMMENT '实付金额,单位分', currency CHAR(3) NOT NULL DEFAULT 'CNY', status VARCHAR(16) NOT NULL COMMENT 'WAITING/PAYING/SUCCESS/FAILED/CLOSED', expire_time DATETIME NOT NULL COMMENT '支付过期时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_payment_no (payment_no), UNIQUE KEY uk_channel_trade (channel_code, channel_trade_no), KEY idx_order_no (order_no), KEY idx_status_expire (status, expire_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE payment_callback_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, payment_no VARCHAR(64) DEFAULT NULL, channel_code VARCHAR(32) NOT NULL, raw_body TEXT NOT NULL, headers TEXT, verify_result TINYINT NOT NULL COMMENT '0失败 1成功', process_status VARCHAR(16) NOT NULL COMMENT 'PENDING/DONE/FAILED', retry_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_payment_no (payment_no), KEY idx_process_status (process_status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_channel_trade这个唯一索引是防重复回调的第一道闸门,渠道交易号加渠道编码必须唯一。注意渠道交易号允许为空,MySQL 的唯一索引对多个 NULL 是放行的,所以预下单阶段不会互相冲突。idx_status_expire是给超时关单任务用的,扫描“支付中且已过期”的记录时能命中索引,避免全表扫。
回调记录表里存原始报文和请求头,排查问题时不用再去翻应用日志。process_status和retry_count配合定时任务做补偿,重试次数到上限后转人工。这张表会持续增长,实战项目阶段按月归档就够,生产环境建议按时间分区或者定期清理,别让它拖慢主库。
3.2 下单支付接口的实现流程
支付接口的流程我一般拆成五步:校验订单、创建支付单、调用渠道预下单、保存渠道返回、返回支付凭证。校验订单时重点看三件事:订单是否存在且属于当前用户、订单是否处于待支付状态、订单金额与支付单金额是否一致。第三步调用渠道之前,先把支付单以“待支付”状态落库,拿到渠道返回后再更新为“支付中”并回填渠道交易号。这个顺序保证即使调用渠道超时,我们本地也有一条可查询的记录。
@Transactional(rollbackFor = Exception.class) public PrepayVO createPayment(CreatePaymentCommand cmd) { Order order = orderService.getByOrderNo(cmd.getOrderNo()); if (order == null || !order.getUserId().equals(cmd.getUserId())) { throw new BizException("订单不存在"); } if (!OrderStatus.WAIT_PAY.equals(order.getStatus())) { throw new BizException("订单状态不允许支付"); } long amount = order.getPayAmount(); PaymentOrder payment = new PaymentOrder(); payment.setPaymentNo(idGenerator.next("PAY")); payment.setOrderNo(order.getOrderNo()); payment.setChannelCode(cmd.getChannelCode()); payment.setAmount(amount); payment.setStatus(PaymentStatus.WAITING); payment.setExpireTime(LocalDateTime.now().plusMinutes(15)); paymentMapper.insert(payment); PaymentGateway gateway = gatewayFactory.get(cmd.getChannelCode()); PrepayResult result = gateway.prepay(buildPrepayCommand(payment, order)); if (!result.isSuccess()) { paymentMapper.updateStatus(payment.getPaymentNo(), PaymentStatus.FAILED); throw new BizException("渠道预下单失败:" + result.getErrorMsg()); } paymentMapper.markPaying(payment.getPaymentNo(), result.getChannelTradeNo()); return new PrepayVO(payment.getPaymentNo(), result.getPayUrl(), payment.getExpireTime()); }过期时间设 15 分钟不是拍脑袋。太短,用户还没来得及扫码就关单了,体验差;太长,库存和优惠券被长时间占用,超卖风险高。15 分钟是多数零售场景的折中值,配合前端倒计时展示。关单任务每分钟扫一次,单次扫描限制 500 条,避免一次拉太多把数据库压住。
操作意图说明:先落库再调渠道,是为了保证“调用超时也有据可查”;调用失败立刻置为 FAILED,是为了让用户可以重新发起新支付单,而不是卡在待支付状态反复重试同一个单号。
3.3 异步回调处理与订单状态推进
回调接口的实现要点前面提过,这里给一个可直接参考的骨架。核心是三步:验签、落回调记录、投递异步任务。同步线程里不做任何业务处理,只做最小必要的校验,保证能在渠道要求的超时时间内返回成功。
@PostMapping("/callback/{channel}") public String callback(@PathVariable String channel, @RequestBody String rawBody, HttpServletRequest request) { PaymentGateway gateway = gatewayFactory.get(channel); Map<String, String> headers = collectHeaders(request); boolean verified = gateway.verifyCallback(rawBody, headers); CallbackLog log = callbackLogService.save(channel, rawBody, headers, verified); if (!verified) { return gateway.failResponse(); } callbackTaskExecutor.execute(() -> callbackProcessor.process(log.getId())); return gateway.successResponse(); }异步处理器里做三件事:根据回调内容找到支付单、做幂等的状态迁移、推进业务订单。状态迁移用带条件的 UPDATE,业务订单推进用本地消息表或者事务消息,保证“支付成功”和“订单变为待发货”最终一致。如果业务订单已经关闭,不能简单丢弃,要把支付单标记为“成功但订单已关闭”,进入异常队列,由人工或者自动退款流程处理。这个分支一定要写,否则用户付了钱订单却没恢复,客诉会直接找上门。
补偿任务也在这条链路上:定时扫描“支付中”且超过过期时间的支付单,主动调渠道查询接口。查到成功就按回调逻辑处理,查到失败或者未支付就关闭支付单并释放库存。查询接口要限流,按支付单创建时间倒序处理,避免一次性把渠道查询配额打满。重试策略用指数退避,间隔按 1 分钟、2 分钟、4 分钟、8 分钟、16 分钟递增,累计三十分钟还没结果就转人工,不要无限重试。
3.4 超时关单与退款链路的落地
关单和退款是支付模块里最容易被低估的两块。关单不是把状态改成“已关闭”就完事,还要释放库存、回滚优惠券、通知订单模块。这几步要么放在同一个本地事务里,要么用本地消息表保证最终执行。我的做法是关单任务只负责“关闭支付单 + 发一条关单事件”,库存和优惠券由事件消费者处理,消费失败进重试队列。这样关单任务本身很轻,不会因为某个下游慢而拖垮整批扫描。
退款链路独立成单:退款单号、支付单号、退款金额、退款状态、渠道退款号、失败原因。退款请求同样要幂等,用“支付单号 + 退款业务号”做唯一约束。退款金额不能超过支付单实付金额减去已退金额,这个校验要在数据库事务里做,靠update ... where refunded_amount + ? <= paid_amount这种条件更新来防并发。退款回调也要验签、落记录、异步处理,和支付回调共用一套骨架,只是业务动作反过来。
部分退款的分摊前面讲过,这里补充一个实操细节:退款失败后不要自动无限重试。渠道返回“余额不足”“卡片状态异常”这类错误,重试多少次都不会成功,应该标记为失败并通知用户换方式。只有“网络超时”“系统繁忙”这类错误才值得重试。在适配层把渠道错误码映射成“可重试/不可重试”两类,退款任务只重试可重试的,能省掉大量无效调用。
3.5 联调、沙箱与上线前压测清单
联调阶段最值得投入的是“异常场景清单”,而不是只跑通正向流程。我通常会准备这样一组用例:支付成功但回调延迟十分钟、回调重复推送三次、回调验签失败、预下单超时、支付成功后立即退款、部分退款两次、关单后回调才到、渠道查询接口超时。每一条都要有预期结果和实际结果记录,跑完这一轮,上线后的信心会完全不一样。
上线前压测重点看三个指标:回调接口的 P99 响应时间、支付单表的写入压力、补偿任务的单次执行时长。回调接口要在 500 毫秒内返回,超过这个值渠道可能判定超时并重推。支付单表按日均订单量估算增长,实战项目阶段一天几千条没问题,但要提前想好归档策略。补偿任务单次执行不要超过一分钟,超过就拆批。
| 检查项 | 通过标准 | 常见问题 |
|---|---|---|
| 回调验签 | 沙箱与生产密钥分离,验签失败有告警 | 签名串拼接顺序不一致 |
| 幂等控制 | 同一回调重放三次结果一致 | 唯一索引缺失或状态更新无并发条件 |
| 超时关单 | 过期支付单在 2 分钟内关闭 | 扫描任务没有命中索引 |
| 退款金额 | 部分退款累计不超过实付 | 按原价而非实付分摊 |
| 日志脱敏 | 密钥、证书、用户标识不落明文 | 请求体整段打印 |
4. 常见问题与排查技巧实录
4.1 回调丢失与重复回调的排查
回调丢失的排查顺序我固定为四步:先看回调记录表有没有落库,再看验签是否通过,再看异步任务是否执行,最后看业务订单是否推进。如果记录表里根本没有,说明请求没到或者被网关拦了,去查接入层日志和渠道侧的回调地址配置。如果有记录但验签失败,对比原始报文和签名串,重点看参数排序和编码。如果验签通过但业务没推进,看异步任务的消费日志和重试次数。
重复回调相对好处理,前提是唯一索引和条件更新都到位。怕的是“重复回调 + 状态已终态”的组合,比如支付单已经成功,又来一条成功回调,这时候直接返回成功即可,不要重复推进订单。如果来的是失败回调而本地已经是成功状态,以本地成功为准,记录一条冲突日志,不要贸然改状态。支付领域的原则是“成功不可逆”,任何试图把成功改回失败的操作都要人工介入。
还有一种隐蔽情况:渠道先推了一条“支付中”或者“用户已付款待确认”的中间态通知,再推终态。很多实现只处理终态,中间态直接忽略,这本身没问题,但要注意中间态不能触发订单状态推进,否则会出现订单已发货但资金还没最终确认的情况。回调处理器里按事件类型分支,只对终态做业务动作。
4.2 金额不一致与订单状态卡死的定位
金额不一致的排查从三个地方对:业务订单应付金额、支付单金额、渠道返回实付金额。三者不一致时,先确认优惠分摊是否在支付单创建时已经算进去,再确认渠道是否存在手续费或者汇率转换。如果是跨境或者多币种场景,还要看渠道返回的结算币种和交易币种是否被混淆。实战项目里最常见的原因是分摊逻辑写了两份,下单算一次、支付单创建又算一次,两边取整规则不同。
订单状态卡死通常表现为“用户说付了钱,订单还是待支付”。定位时先查支付单状态,如果是“支付中”,说明回调没到或者补偿任务还没跑;如果是“成功”而订单没变,说明业务推进那一步失败了。看本地消息表或者事件表里有没有待消费记录,有就手动触发一次消费,没有就查事务提交和消息发送是否在同一个事务边界内。我踩过的一个坑是把消息发送放在了事务提交之前,事务回滚导致消息已发但数据没落,后来改成事务提交后发送加本地消息表兜底,这类问题就再没出现过。
4.3 常见问题速查表与避坑清单
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 回调验签一直失败 | 签名串排序或编码不一致 | 打印原始报文与待签名串对比 | 用原始字节验签,固定排序规则 |
| 用户已付款订单仍待支付 | 回调丢失或异步任务失败 | 查回调记录表与消息表 | 主动查询渠道并补单 |
| 重复退款 | 退款接口无幂等约束 | 查退款业务号是否唯一 | 加唯一索引与条件更新 |
| 关单后收到成功回调 | 回调延迟 | 查支付单与订单时间线 | 标记异常,自动或人工退款 |
| 金额差一分 | 分摊取整规则不一致 | 对比下单与支付单计算逻辑 | 统一工具类,补单元测试 |
| 证书过期导致全量失败 | 未做有效期监控 | 查应用启动日志与证书时间 | 提前三十天告警并轮换 |
最后分享几个实操里比较值钱的小习惯。第一,所有支付相关的接口都加一个“请求追踪号”,从下单到回调到退款全链路透传,排查时一个号串起来,比按时间翻日志快十倍。第二,沙箱环境也要跑一遍完整的退款和关单流程,很多人只在沙箱测支付成功,结果退款逻辑到生产才第一次执行。第三,把渠道文档里的错误码整理成一张映射表放进代码注释或者内部 wiki,下次再遇到“某某错误码是什么意思”就不用重新翻文档。第四,支付模块的日志级别默认开 INFO,但关键状态迁移单独打一条带业务含义的日志,比如“支付单已成功,订单推进完成”,这条日志在告警和复盘时比一堆 DEBUG 有用得多。
这套东西跑下来,支付功能才算真正接进了电商实战项目,而不是停留在“能付一次”的演示阶段。后面如果要加新渠道、做分账、接对账文件,适配层和状态机这两块设计能省掉大量返工。