这次的需求很具体:在一个已有小程序里增加话费充值入口,每次固定充值 19.8 元,同一个手机号每个自然月只能充值一次,用户直接通过微信支付。
由于拿不到原项目源码,我没有把它做成新的充值平台,而是将功能拆成一组可以嵌入现有工程的模块。前端提供 uni-app 页面,后端基于 JDK 11、Spring Boot 2.7.18、MyBatis 和 MySQL 8,宿主系统只需要接入登录身份、微信 OpenID、数据库和相关支付配置。
支付完成,不等于充值完成
这类业务最容易出问题的地方,是把小程序端的支付成功回调当成最终结果。
实际流程里,前端回调只负责提示用户“结果确认中”。后端收到经过验签的微信支付通知,或者主动查单确认付款后,才会向上游充值渠道提交订单。随后继续查询充值结果,明确成功才结束,明确失败则发起全额退款。
如果请求超时或者响应丢失,系统不会重新生成一笔充值流水,而是使用原订单继续查询。宁可进入人工复核,也不能出现重复充值,或者已经充值成功又把钱退给用户的情况。
“每月一次”放在数据库里保证
同一号码每月只能充值一次,不能只依赖前端按钮或普通业务判断。
模块会先标准化手机号,再生成不可逆的检索值,并与北京时间自然月组成唯一约束。即使同时收到多次请求,也只有一个请求能够占用当月资格。
创建订单、支付通知和渠道流水也分别设置了幂等标识。用户重复点击、微信重复通知或者后台任务重复执行,都不会产生第二笔充值。
手机号本身采用加密存储,页面和普通日志只显示脱敏号码。
把失败路径也当成正常流程
正常路径其实很短,真正花时间的是各种“不确定”:
- 用户打开支付后没有付款;
- 微信通知重复发送或发送失败;
- 充值请求已经到达渠道,但响应在途中丢失;
- 渠道长时间返回处理中;
- 退款请求超时;
- 服务重启后,后台任务被另一个实例接手。
这些情况最终都落进明确的订单状态。后台任务使用数据库租约执行,不依赖 Redis,也支持多实例抢占和过期恢复。长时间无法确认的订单会转入人工复核,不会无限重试。
最后的交付形态
最终交付的不是一个需要单独部署和维护的平台,而是一套边界清楚的业务模块:
- uni-app 充值页面;
- 独立的充值领域核心;
- 微信支付、充值渠道和 MySQL 适配层;
- Spring Boot 2 接入模块;
- 数据库初始化脚本;
- JAR 和源码交付。
宿主系统继续负责用户登录、OpenID、数据库连接和密钥管理,充值模块只处理自己范围内的订单、支付、充值与退款。
这次最大的收获,是再次确认:支付类功能不能只围绕“成功页面”设计。金额、幂等、状态查询、补偿和人工兜底,才是这类小模块真正的主体。
