ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot的支付系统设计与支付宝微信支付对接实践

基于SpringBoot的支付系统设计与支付宝微信支付对接实践 简介基于 Spring Boot 开发的支付系统项目压缩包包含支付宝支付、微信支付与订单管理模块适合 Java 后端初学者、毕业设计或课程设计参考。项目已经过运行验证可导入开发工具直接查看整体结构配合项目说明即可快速了解启动流程。压缩包共有六十六个文件大小约六点二七兆其中以四十四个 Java 源码文件为主体配合 JSP 页面、JAR 依赖、properties 与 XML 配置以及数据库 DDL 脚本、ERD 关系图和项目说明文档便于从数据建模、接口调用到部署运行形成完整认知。目前已有 101 人学习。借助这一项目可以熟悉 Spring Boot 多模块组织方式理解 starter-web、starter-websocket 等组件在真实支付业务中的用法订单系统、支付回调等代码对处理第三方支付对接和构建可扩展后端服务也有直接参考价值适合二次开发与功能拓展。1. 支付系统不是把两个支付SDK塞进SpringBoot就完事了很多开发者在面对“基于SpringBoot开发的支付系统”这个需求时第一反应是把支付宝和微信支付的SDK引进来调一下下单接口再把回调地址填上去就收工了。真这么干的系统大概率会在上线第一周收到“订单已扣款但系统显示未支付”的工单。支付系统的复杂度不在支付接口本身而在订单与资金流的对账、回调的幂等、超时关单和异常补偿。SpringBoot在这里承担的不是“能跑”的骨架而是用自动配置、事件监听、任务调度让这套流程可拆解、可排查、可补充。这一章想解决的问题是当你拿到一个标题为“基于SpringBoot开发的支付系统包括支付宝支付微信支付订单系统”的压缩包或者自己接到类似需求时第一件该做的事情不是写Controller而是先把订单系统和支付模块的边界画清楚。本文会按“先立模型、再对接支付、最后做联调和排错”的顺序展开代码以Java SpringBoot常见写法为例不绑定某个具体商业项目。适合正在做毕设、接手老项目、或者准备把单体系统里硬编码的支付逻辑拆出来的后端工程师。你会得到可直接落地的表结构、支付配置、回调处理和一套验证方法。2. SpringBoot支付系统的订单与支付单模型设计2.1 为什么订单表不能直接存支付状态支付系统的第一个坑就是把支付状态直接写在订单表里。订单表负责业务履约下单、库存占用、物流、售后。支付单表负责资金流调起支付、等待回调、支付成功、已退款。两者如果混在一张表会出现两个问题。第一订单状态和支付状态更新时机不同业务上“已发货”和“已支付”并不是强一致关系第二一个订单可能被拆成多次支付或者一个订单的退款操作要关联多笔支付流水一张表无法表达这种一对多关系。我一般会拆分出ord_order和pay_payment两张核心表必要时再加pay_refund退款表。pay_payment保存支付宝或微信支付返回的transaction_id、渠道、金额、支付状态ord_order只保存业务状态和总应支付金额。做这个分离还有一个好处支付系统可以脱离订单系统独立测试。在SpringBoot工程里可以把订单模块和支付模块分到不同package通过Service接口相互调用避免循环依赖。2.2 支付单表结构设计与状态机定义下面这组SQL结构是支付系统里最常见也最稳的形态金额字段全部以“分”为单位存储避免浮点误差。建表时还可以加tenant_id字段原项目是单商户还是多商户这个字段能决定后续扩展成本。CREATE TABLE ord_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint(20) NOT NULL, total_fee bigint(20) NOT NULL COMMENT 总金额单位分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付1已支付2已取消3已完成4退款中, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at datetime DEFAULT NULL COMMENT 支付完成时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pay_payment ( id bigint(20) NOT NULL AUTO_INCREMENT, payment_no varchar(32) NOT NULL COMMENT 支付单号, order_no varchar(32) NOT NULL COMMENT 业务订单号, channel varchar(16) NOT NULL COMMENT alipay/wxpay, amount_fee bigint(20) NOT NULL COMMENT 本次支付金额单位分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付1支付中2成功3失败4已关闭, transaction_id varchar(64) DEFAULT NULL COMMENT 支付宝/微信交易号, notify_data text COMMENT 回调原始报文, expire_time datetime NOT NULL COMMENT 订单过期时间, paid_time datetime DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_payment_no (payment_no), KEY idx_order_no (order_no), KEY idx_transaction_id (transaction_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;支付状态机的核心状态是待支付、支付中、成功、失败、已关闭。注意“支付中”这个状态因为支付宝和微信支付在下单之后回调到达之前系统并不知道用户是否已经付款。这里的关键参数是expire_time微信支付和支付宝都要求订单有时效通常15分钟到2小时过期之后渠道方不再受理支付。数据库里的status和渠道状态要保持对应但不要直接依赖渠道状态作为订单状态回调才是唯一权威来源。表结构设计之后SpringBoot代码里最需要的是一套不会写错状态映射的方案。用枚举配合MyBatis-Plus的EnumValue注解或者在VARCHAR字段存英文状态都是常见做法。不建议直接存0/1/2这类数字因为排错时完全看不出状态含义。用枚举既有代码提示又能和数据库数字值一一对应。2.3 订单状态变更的SpringBoot实体与状态检查实体类设计上订单和支付单分两个领域对象订单聚合根不直接暴露支付状态的setter。下面这段代码演示了创建支付单时的核心逻辑重点是状态检查和重复支付拦截。Service public class PaymentDomainService { private final OrderMapper orderMapper; private final PaymentMapper paymentMapper; Transactional(rollbackFor Exception.class) public Payment createPayment(String orderNo, String channel, long amountFee, int expireMinutes) { Order order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在); } if (order.getStatus() OrderStatus.PAID.getCode()) { throw new BizException(订单已支付); } // 防止同一订单同时发起多个支付单 ListPayment pending paymentMapper.selectPendingByOrderNo(orderNo); if (!pending.isEmpty()) { throw new BizException(订单已存在待支付支付单); } Payment payment new Payment(); payment.setPaymentNo(generatePaymentNo()); payment.setOrderNo(orderNo); payment.setChannel(channel); payment.setAmountFee(amountFee); payment.setStatus(PaymentStatus.WAITING.getCode()); payment.setExpireTime(LocalDateTime.now().plusMinutes(expireMinutes)); paymentMapper.insert(payment); return payment; } }这段代码说明了两个容易被忽略的点。第一重复支付拦截放在下发支付单之前而不是回调进来之后。第二支付单的payment_no要用独立的、不可预测的编号规则不能直接用订单号。如果直接用订单号支付宝和微信支付的沙箱环境会缓存同一商户订单号导致第二次测试时拿到上一次的支付结果。常见做法是前缀 时间戳 随机数长度控制在32位以内。2.3.1 SpringBoot配置类注入支付参数支付参数不要写死在代码里。SpringBoot的ConfigurationProperties绑定application.yml里的pay.alipay和pay.wxpay这两段配置即用即取。这背后是SpringBoot自动装配原理在起作用配置绑定器和自动配置会帮你把pay.alipay.app-id这类带横杠的键自动映射成骆驼峰字段。常见的坑是SpringBoot版本太高比如2.7.x升级到3.x之后ConfigurationProperties的包路径变了旧项目直接复制代码会出现编译失败。如果是从旧项目升级优先看Spring Boot 3.x里org.springframework.boot.context.properties包是否还在启动类扫描范围内。3. 支付宝支付接口接入下单、验签、异步通知3.1 支付宝开放平台的接入参数和初始化支付宝支付的常规接入方式是使用开放平台提供的alipay-sdk-java项目里引入依赖后配置几个核心参数就能构造客户端。标题里提到“支付宝授权登录”那是另一个独立接口但底层使用的AlipayClient是同一个。初始化客户端时需要四样东西appId、应用私钥、支付宝公钥、签名方式。如果用的是SpringBoot多环境配置可以把沙箱和正式环境放在不同profile下通过Profile注解加载不同的AlipayClient实例。Configuration public class AlipayConfig { Bean public AlipayClient alipayClient(Value(${pay.alipay.app-id}) String appId, Value(${pay.alipay.private-key}) String privateKey, Value(${pay.alipay.public-key}) String alipayPublicKey, Value(${pay.alipay.gateway}) String gateway) { AlipayConfig config new AlipayConfig(); config.setAppId(appId); config.setPrivateKey(privateKey); config.setAlipayPublicKey(alipayPublicKey); config.setSignType(RSA2); config.setServerUrl(gateway); config.setFormat(json); config.setCharset(UTF-8); return new DefaultAlipayClient(config); } }这段代码是典型的SpringBoot配置类写法。Value注入虽然简单但参数一多建议改成ConfigurationProperties绑定一个AlipayProperties对象。需要注意的边界条件是支付宝公钥和应用公钥不同配错之后下单接口会返回“验签失败”而不是权限报错新手经常在这里浪费半天。RSA2对应SHA256WithRSA签名比老版RSA更安全新应用默认使用RSA2。3.2 创建支付宝PC端或WAP端预支付订单接入支付宝支付接口时根据终端形态不同接口名称也不同电脑网站支付用alipay.trade.page.pay手机网站支付用alipay.trade.wap.pay当面付扫码用alipay.trade.precreate。下面以电脑网站支付为例封装一个创建支付单的方法。注意金额单位是元所以必须把数据库里的“分”除以100后再传。Service public class AlipayServiceImpl implements AlipayService { private final AlipayClient alipayClient; private final PaymentMapper paymentMapper; public String createPagePay(String paymentNo, String orderNo, long amountFee, String subject) { AlipayTradePagePayRequest request new AlipayTradePagePayRequest(); request.setNotifyUrl(alipayConfig.getNotifyUrl()); request.setReturnUrl(alipayConfig.getReturnUrl()); JSONObject bizContent new JSONObject(); bizContent.put(out_trade_no, paymentNo); bizContent.put(total_amount, amountFee / 100.0); bizContent.put(subject, subject); bizContent.put(product_code, FAST_INSTANT_TRADE_PAY); request.setBizContent(bizContent.toString()); try { AlipayTradePagePayResponse response alipayClient.pageExecute(request); if (response.isSuccess()) { return response.getBody(); } log.error(支付宝下单失败: {}, response.getMsg()); } catch (AlipayApiException e) { log.error(支付宝下单异常, e); } return null; } }bizContent里的三个参数是必须的。out_trade_no对应数据库里的payment_nototal_amount是实付金额subject是商品摘要。很多人会在这里把out_trade_no传成订单号这会让支付回调关联不上支付单。product_code参数在不同支付产品下必须对应PC网页支付固定为FAST_INSTANT_TRADE_PAY预付创建用其他枚举抄错会导致“系统繁忙”。pageExecute返回的是HTML表单字符串直接写入Controller的响应体即可跳转到收银台。如果你的诉求是实现APP支付对应的接口是AlipayTradeAppPayRequest服务端只需要把订单字符串返回给客户端由客户端SDK调起支付宝App服务端不直接参与跳转页面。3.3 支付宝异步通知的安全验签和订单回写支付成功后支付宝会向notify_url发送同步HTTP请求。这个接口必须处理好“验签、幂等、回写顺序”三件事。验签用AlipaySignature.rsaCheckV1这一步直接决定你是不是被别人伪造回调骗订单状态。以下是一个可落地的回调处理Controller。RestController public class AlipayNotifyController { private final PaymentNotifyService notifyService; private final AlipayConfig alipayConfig; PostMapping(/notify/alipay) public String alipayNotify(HttpServletRequest request) throws AlipayApiException { MapString, String params ParametersToMapUtil.convert(request.getParameterMap()); boolean signVerified AlipaySignature.rsaCheckV1( params, alipayConfig.getAlipayPublicKey(), alipayConfig.getCharset(), alipayConfig.getSignType()); if (!signVerified) { return failure; } String tradeStatus params.get(trade_status); String paymentNo params.get(out_trade_no); String transactionId params.get(trade_no); String totalAmount params.get(total_amount); if (TRADE_SUCCESS.equals(tradeStatus)) { notifyService.handlePaid(paymentNo, transactionId, totalAmount, JSON.toJSONString(params)); } return success; } }回来返回字符串必须是success或failure支付宝会识别这两个固定值。如果返回其他内容支付宝会认为通知失败并不断重试。handlePaid方法内部需要先查支付单判断当前状态是否为“待支付”只有在待支付状态才执行订单回写和状态更新。用数据库的状态判断做幂等比单纯用Redis锁要可靠得多。回调逻辑中不要直接信任total_amount要以本地业务订单的应付金额为准金额不一致立即记录异常并人工排查。这背后是一个很现实的场景用户用支付宝付款成功后因为回调里带了金额开发直接拿回调金额覆盖本地订单金额结果被修改订单金额的漏洞利用。正确做法是仅将回调金额用于对账校验。4. 微信支付V3接口接入与投诉回调处理4.1 微信支付V3的证书、密钥和回调解密基础微信支付V3和三年前流行的V2接口有本质区别。V3使用RSA签名和AEAD_AES_256_GCM对称加密平台证书或公钥ID、商户私钥、APIv3密钥这三样东西缺一不可。很多开发者在微信商户平台配置时会把“AppSecret”和“APIv3密钥”混在一起导致后续解密回调时一直报AEAD解密失败。需要在SpringBoot里维护的配置项如下配置项说明获取位置mchid商户号微信支付商户平台merchant-serial-no商户证书序列号API证书管理merchant-private-key商户API私钥申请API证书时生成apiv3-key用于回调数据解密的32字节对称密钥账户中心自主设置wechatpay-serial-no平台证书序列号证书管理初始化微信支付客户端的常见方式是使用wechatpay-java官方SDK如果项目里不想引入额外SDK也可以直接用HttpClient构造请求但签名头Authorization的格式很容易出错。我一般会引入SDK因为V3回调验签和AES-GCM解密已经封装好出问题的概率低很多。4.2 微信支付V3 JSAPI下单与Native下单微信支付的场景分为JSAPI公众号/小程序、APP、Native扫码、H5。JSAPI和Native统一下单接口都是POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapiNative对应.../native区别只在body里是否带openid。下面以JSAPI为例展示用SDK构造下单请求的过程。Slf4j Service public class WxPayServiceImpl { private final RSAAutoCertificateConfig config; public String createJsapiOrder(String paymentNo, String openid, long amountFee, String description, String notifyUrl) throws Exception { RequestParam requestParam new RequestParam() .setAppid(wxPayProperties.getAppId()) .setMchid(wxPayProperties.getMchId()) .setDescription(description) .setOutTradeNo(paymentNo) .setNotifyUrl(notifyUrl) .setAmount(new Amount().setTotal(Math.toIntExact(amountFee))) .setPayer(new Payer().setOpenid(openid)); TransactionsJsapiService service new TransactionsJsapiService.Builder().config(config).build(); Transactions result service.pay(requestParam); return result.getPrepayId(); } }这里有两个参数值得细看。第一是amountFee单位是“分”支付宝单位是“元”微信是“分”对接两个渠道时最好在Service层就统一转成“分”别在Controller层临时做除法容易漏转换。第二是RSAAutoCertificateConfig它会自动下载平台证书并定时更新省去手动维护平台证书的流程。但要留意老系统如果依赖的SDK版本低这个类并不存在会出现“springboot版本太高”和“微信SDK版本不匹配”同时报错的情况解决办法是把微信SDK升级到适配SpringBoot生命周期的版本而不是盲目调SpringBoot版本。拿到prepay_id之后JSAPI还需要用它生成小程序端调起支付的签名参数包括timeStamp、nonceStr、package、signType和paySign。这个paySign的签名串拼接顺序非常严格少了字段就是wechatpaySignature错误。小程序端容易踩的坑是把package参数直接传成prepay_id忘了前面加prepay_id前缀。4.3 微信支付V3回调通知解密与投诉回调微信支付V3回调通知并不是明文JSON而是一个加密结构resource.type为transaction时ciphertext是用APIv3密钥保护的AES-GCM密文。收到回调时先用签名头Wechatpay-Signature验签再解密得到支付结果。SDK封装后的写法很简洁。PostMapping(/notify/wxpay) public String wxpayNotify(HttpServletRequest request) throws Exception { String body StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); RequestParam requestParam new RequestParam() .setHeaders(getHeaders(request)) .setBody(body); NotificationParser parser new NotificationParser(config); Transaction transaction parser.parse(requestParam, Transaction.class); String paymentNo transaction.getOutTradeNo(); String transactionId transaction.getTransactionId(); String tradeState transaction.getTradeState(); if (SUCCESS.equals(tradeState)) { notifyService.handleWxPaid(paymentNo, transactionId, transaction.getAmount().getTotal(), body); } return {\code\:\SUCCESS\,\message\:\成功\}; }这段接口和支付宝回调的核心差异在于响应体格式不同。微信支付要求HTTP状态码是200且返回{code:SUCCESS}否则会认为回调失败。另外微信支付的tradeState除了SUCCESS还有REFUND、NOTPAY等下单成功和退款成功走同一个notify_url判断类型后分别走支付处理和退款处理。标题里的“微信支付投诉回调”是商户平台针对用户投诉的通知和交易回调完全独立。投诉回调的配置入口是商户平台-账户中心-消费者投诉配置的URL需要返回success字符串。收到投诉回调后微信要求商户在48小时内通过消费者投诉详情接口查询投诉内容并提交处理结果。很多人只配置了通知却没实现回应逻辑导致投诉率上升。建议用独立的Controller接收投诉回调日志里记录complaint_id和openid再挂一个定时任务把未处理的投诉同步到内部工单表。4.3.1 回调失败的补偿策略支付宝和微信都内置了回调重试机制但重试时间并不固定最长可能延迟到当天深夜。所以业务层不能只在回调里更新订单还要在订单系统里加一个“支付状态主动查询”的兜底方案。常见做法是定时任务每分钟扫描pay_payment表里状态为“待支付”且已超过3分钟的记录调用支付宝alipay.trade.query或微信GET /v3/pay/transactions/out-trade-no/{out_trade_no}查询结果。查询接口返回TRADE_SUCCESS或SUCCESS时手动走一遍和收到回调一样的处理逻辑。这样即使异步通知在网络抖动时丢了订单也不会一直卡在“待支付”。5. 用订单系统和支付模块联调把订单不超时、0错漏做实联调支付业务时最有效的不是看日志而是直接操作数据库里的pay_payment和ord_order两张表模拟出用户、支付渠道、回调服务三方之间的状态切换。按下面的三步走可以验证绝大部分边界问题。第一步验证支付超时关单。在pay_payment表里手动把某条记录的expire_time改成当前时间前一分钟然后等定时任务执行。关单逻辑要同时处理本地订单状态和请求渠道关单接口不要只改本地状态。微信支付有单独关单接口支付宝则没有强制关单但可以通过查询确认交易状态。第二步验证重复回调幂等。把支付宝回调接口的请求体保存下来用Postman重放同一份trade_statusTRADE_SUCCESS参数连续请求两次。正确结果应该是第一次返回success且订单状态变为已支付第二次也返回success但订单状态不更新且在日志中记录一条“重复回调已忽略”。第三步验证金额一致性。把回调里的total_amount临时改掉一毛钱触发接口后系统应该进入异常对账流程而不是直接修改订单金额。联调最后建议再补一个“无回调场景”的验证把notify_url指向一个错误地址正常完成付款这时订单应该通过定时查询任务在几分钟内自动变成已支付。这才是“订单不超时0错漏”的真正底线因为线上实际环境中回调延迟甚至丢失是大概率事件而不是小概率事件。这一章不需要再引入新框架重点是理解SpringBoot定时任务Scheduled在这个业务里的真实价值。用固定延迟每60秒跑一次扫描SQL里以expire_time和status作为过滤条件把超时订单和待确认订单分开处理。不要用用户请求触发关单因为用户关闭页面后请求就不存在了必须由服务端主动补偿。配合支付查询接口支付系统的可靠性就能做到不依赖上游回调永远准时到达。本文还有配套的精品资源点击获取
返回列表