Spring Boot + Vue3 订单支付模块技术实现:支付单、回调验签、幂等处理与余额扣减 💰🚀
技术栈示例:Vue3 + Axios + Spring Boot + MyBatis + MySQL
适合学习方向:电商订单、会员系统、预约缴费、内容付费、账户余额、退款与对账等订单类业务。
更新日期:2026-07-08
发布标题推荐
Spring Boot + Vue3 订单支付模块技术实现:支付单、回调验签、幂等处理与余额扣减
256 字以内描述
本文从前后端项目开发角度,整理订单支付模块的核心实现逻辑,包括前端支付方式选择、后端订单创建、支付单生成、异步回调处理、签名校验、订单状态流转、幂等控制、数据库事务、余额扣减、退款和对账。示例采用 Vue3 + Spring Boot + MyBatis + MySQL,重点说明支付模块在真实业务中的数据表设计、接口设计和安全处理方式。
目录 📚
- [1. 订单支付模块整体理解](#1. 订单支付模块整体理解)
- [2. 开发前置配置](#2. 开发前置配置)
- [3. 支付方式与业务差异](#3. 支付方式与业务差异)
- [4. 推荐数据库表设计](#4. 推荐数据库表设计)
- [5. 前端实现逻辑 Vue3 示例](#5. 前端实现逻辑 Vue3 示例)
- [6. 后端统一支付接口设计](#6. 后端统一支付接口设计)
- [7. 微信支付技术实现](#7. 微信支付技术实现)
- [8. 支付宝支付技术实现](#8. 支付宝支付技术实现)
- [9. 银行卡/银联支付技术实现](#9. 银行卡/银联支付技术实现)
- [10. 余额支付技术实现](#10. 余额支付技术实现)
- [11. 回调验签、幂等与事务处理](#11. 回调验签、幂等与事务处理)
- [12. 退款、关闭订单、查询订单、对账](#12. 退款、关闭订单、查询订单、对账)
- [13. 前后台页面功能设计](#13. 前后台页面功能设计)
- [14. 安全注意事项](#14. 安全注意事项)
- [15. 常见问题和解决方案](#15. 常见问题和解决方案)
- [16. 官方技术与操作文档](#16. 官方技术与操作文档)
- [17. 项目落地开发顺序](#17. 项目落地开发顺序)
- [18. 给 Codex / AI 生成代码的提示词](#18. 给 Codex / AI 生成代码的提示词)
1. 订单支付模块整体理解
支付模块不要理解成"前端点击按钮后直接完成支付"。真实项目中,它通常包含 订单、支付单、支付渠道、回调通知、验签、幂等、钱包流水、退款、对账、后台管理 等多个部分。
| 模块 | 作用 |
|---|---|
| 订单模块 | 记录业务订单,例如商品订单、会员订单、预约订单 |
| 支付单模块 | 记录每一次支付请求,避免重复支付和状态混乱 |
| 支付渠道模块 | 按渠道封装微信、支付宝、银联、余额等支付逻辑 |
| 回调通知模块 | 接收外部支付渠道的异步通知 |
| 验签模块 | 判断通知来源和数据完整性 |
| 幂等模块 | 防止重复通知导致重复发货、重复开通权益 |
| 钱包模块 | 处理余额支付、余额流水、余额退款 |
| 退款模块 | 处理原路退款、余额退款、部分退款 |
| 对账模块 | 对比本地支付记录和渠道账单 |
| 后台管理 | 查询订单、支付单、退款单、流水和异常记录 |
一句话总结:
前端负责选择支付方式、展示二维码、跳转收银台、轮询支付状态;后端负责创建订单、创建支付单、调用支付接口、接收回调、验签、修改订单状态。 ✅
1.1 正确支付流程
#mermaid-svg-1wdkfUgixJxQl69Q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1wdkfUgixJxQl69Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1wdkfUgixJxQl69Q .error-icon{fill:#552222;}#mermaid-svg-1wdkfUgixJxQl69Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1wdkfUgixJxQl69Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1wdkfUgixJxQl69Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1wdkfUgixJxQl69Q .marker.cross{stroke:#333333;}#mermaid-svg-1wdkfUgixJxQl69Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1wdkfUgixJxQl69Q p{margin:0;}#mermaid-svg-1wdkfUgixJxQl69Q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1wdkfUgixJxQl69Q .cluster-label text{fill:#333;}#mermaid-svg-1wdkfUgixJxQl69Q .cluster-label span{color:#333;}#mermaid-svg-1wdkfUgixJxQl69Q .cluster-label span p{background-color:transparent;}#mermaid-svg-1wdkfUgixJxQl69Q .label text,#mermaid-svg-1wdkfUgixJxQl69Q span{fill:#333;color:#333;}#mermaid-svg-1wdkfUgixJxQl69Q .node rect,#mermaid-svg-1wdkfUgixJxQl69Q .node circle,#mermaid-svg-1wdkfUgixJxQl69Q .node ellipse,#mermaid-svg-1wdkfUgixJxQl69Q .node polygon,#mermaid-svg-1wdkfUgixJxQl69Q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1wdkfUgixJxQl69Q .rough-node .label text,#mermaid-svg-1wdkfUgixJxQl69Q .node .label text,#mermaid-svg-1wdkfUgixJxQl69Q .image-shape .label,#mermaid-svg-1wdkfUgixJxQl69Q .icon-shape .label{text-anchor:middle;}#mermaid-svg-1wdkfUgixJxQl69Q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1wdkfUgixJxQl69Q .rough-node .label,#mermaid-svg-1wdkfUgixJxQl69Q .node .label,#mermaid-svg-1wdkfUgixJxQl69Q .image-shape .label,#mermaid-svg-1wdkfUgixJxQl69Q .icon-shape .label{text-align:center;}#mermaid-svg-1wdkfUgixJxQl69Q .node.clickable{cursor:pointer;}#mermaid-svg-1wdkfUgixJxQl69Q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1wdkfUgixJxQl69Q .arrowheadPath{fill:#333333;}#mermaid-svg-1wdkfUgixJxQl69Q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1wdkfUgixJxQl69Q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1wdkfUgixJxQl69Q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1wdkfUgixJxQl69Q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1wdkfUgixJxQl69Q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1wdkfUgixJxQl69Q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1wdkfUgixJxQl69Q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1wdkfUgixJxQl69Q .cluster text{fill:#333;}#mermaid-svg-1wdkfUgixJxQl69Q .cluster span{color:#333;}#mermaid-svg-1wdkfUgixJxQl69Q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1wdkfUgixJxQl69Q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1wdkfUgixJxQl69Q rect.text{fill:none;stroke-width:0;}#mermaid-svg-1wdkfUgixJxQl69Q .icon-shape,#mermaid-svg-1wdkfUgixJxQl69Q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1wdkfUgixJxQl69Q .icon-shape p,#mermaid-svg-1wdkfUgixJxQl69Q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1wdkfUgixJxQl69Q .icon-shape .label rect,#mermaid-svg-1wdkfUgixJxQl69Q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1wdkfUgixJxQl69Q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1wdkfUgixJxQl69Q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1wdkfUgixJxQl69Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户提交订单
后端创建业务订单 orders
用户选择支付方式
前端请求后端创建支付
后端创建支付单 payments
支付方式
微信支付
支付宝支付
银行卡/银联支付
余额支付
外部支付渠道
系统内部钱包扣款
异步通知 notify_url
后端验签
核对订单号/金额/商户号
修改支付单和订单状态
发货/开通会员/增加权益
1.2 不建议的实现方式 ⚠️
text
前端直接调用支付渠道的敏感接口
前端保存商户私钥或证书信息
前端传入最终支付金额并由后端直接相信
用户跳到成功页面就直接改订单状态
银行卡号、CVV、安全码等敏感信息保存到业务数据库
推荐做法:
text
前端只请求自己的后端接口
敏感配置只放后端服务端
订单金额以后端数据库为准
支付成功以后以后端异步回调验签结果为准
银行卡支付使用官方或合规的托管/网关流程,避免自行保存卡敏感数据
2. 开发前置配置
为了让支付模块能在项目中运行,需要先准备业务系统和技术配置。这里重点从开发角度说明,不展开平台业务办理流程。
2.1 公共技术配置
| 配置项 | 说明 |
|---|---|
| 后端服务 | 用于创建订单、创建支付单、处理回调通知 |
| 数据库 | 保存订单、支付单、钱包、退款、回调日志 |
| HTTPS 域名 | 外部支付渠道回调通常要求公网可访问 |
| 异步通知地址 | 例如 {后端域名}/api/pay/wechat/notify |
| 同步跳转地址 | 例如 {前端域名}/pay/result |
| 日志记录 | 保存请求参数、回调原文、验签结果、异常堆栈 |
| 事务管理 | 保证支付单、订单、钱包流水状态一致 |
| 幂等控制 | 防止重复回调导致重复处理 |
2.2 微信支付技术配置 🟢
| 配置项 | 说明 |
|---|---|
appid |
公众号、小程序或 App 对应的应用标识 |
mch_id |
微信支付商户号 |
| 商户 API 私钥 | 后端请求签名使用 |
| 商户证书序列号 | APIv3 请求签名和验签相关配置 |
| APIv3 密钥 | 回调报文解密使用 |
notify_url |
微信支付异步通知地址 |
| 支付产品类型 | Native、JSAPI、H5、App、小程序支付等 |
2.3 支付宝支付技术配置 🔵
| 配置项 | 说明 |
|---|---|
app_id |
支付宝开放平台应用 ID |
| 应用私钥 | 后端请求加签使用 |
| 支付宝公钥/证书 | 后端验签使用 |
notify_url |
支付宝异步通知地址 |
return_url |
支付完成后的前端跳转地址 |
| 支付产品类型 | 电脑网站支付、手机网站支付、App 支付等 |
| 沙箱配置 | 本地开发和测试环境使用 |
2.4 银行卡/银联支付技术配置 💳
银行卡支付通常不建议业务系统自行采集或保存完整银行卡敏感信息。更常见的方式是:业务系统生成支付请求,用户跳转到官方/合规的支付页面完成输入和验证,支付完成后再由后端接收异步通知。
| 配置项 | 说明 |
|---|---|
| 商户配置 | 银联相关接口需要的商户编号和证书参数 |
| 签名证书 | 请求签名使用 |
| 验签证书 | 回调验签使用 |
frontUrl |
支付完成后的前端跳转地址 |
backUrl |
异步通知地址 |
| 金额单位 | 多数接口以"分"为单位,需要注意转换 |
2.5 余额支付技术配置 🪙
余额支付是系统内部的钱包能力,不依赖外部支付回调,但必须处理好事务、流水和并发。
| 配置项 | 说明 |
|---|---|
| 用户钱包表 | 记录可用余额、冻结余额 |
| 钱包流水表 | 记录充值、消费、退款、调整 |
| 支付密码 | 可选,用于余额支付二次校验 |
| 事务控制 | 防止扣款成功但订单状态未更新 |
| 幂等控制 | 防止重复扣款 |
| 行锁/乐观锁 | 防止多个请求同时扣余额 |
3. 支付方式与业务差异
| 支付方式 | 适合场景 | 前端行为 | 后端行为 | 是否依赖异步回调 |
|---|---|---|---|---|
| 微信 Native 支付 | PC 网站扫码 | 展示二维码 | 创建预支付订单 | 是 |
| 微信 JSAPI 支付 | 微信公众号网页 | 调起微信支付 | 结合 openid 下单 | 是 |
| 微信小程序支付 | 小程序商城 | wx.requestPayment |
返回支付参数 | 是 |
| 微信 H5 支付 | 手机浏览器 | 跳转 H5 支付页 | 返回 H5 地址 | 是 |
| 支付宝电脑网站支付 | PC 网站 | 提交 form 表单 | 返回支付表单 | 是 |
| 支付宝手机网站支付 | 手机浏览器 | 跳转支付宝页面 | 返回支付表单 | 是 |
| 支付宝 App 支付 | 原生 App | 调起支付宝 App | 返回 orderString | 是 |
| 银联在线支付 | PC/H5 银行卡支付 | 跳转支付页面 | 生成支付表单 | 是 |
| 余额支付 | 平台内部消费 | 点击确认支付 | 扣减钱包余额 | 否 |
4. 推荐数据库表设计
4.1 订单表 orders
sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '业务订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
title VARCHAR(255) NOT NULL COMMENT '订单标题',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款',
pay_time DATETIME NULL COMMENT '支付时间',
cancel_time DATETIME NULL COMMENT '取消时间',
remark VARCHAR(255) NULL COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='业务订单表';
4.2 支付单表 payments
一个订单可能产生多次支付尝试,例如第一次未完成,第二次换另一个渠道支付,所以需要单独的支付单表。
sql
CREATE TABLE payments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
pay_no VARCHAR(64) NOT NULL UNIQUE COMMENT '支付单号',
order_no VARCHAR(64) NOT NULL COMMENT '业务订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
channel VARCHAR(32) NOT NULL COMMENT 'WECHAT_NATIVE/WECHAT_JSAPI/ALIPAY_PC/UNIONPAY/BALANCE',
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1成功 2失败 3关闭 4退款',
third_trade_no VARCHAR(128) NULL COMMENT '外部交易号',
notify_raw TEXT NULL COMMENT '回调原文',
pay_time DATETIME NULL COMMENT '支付成功时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order_no (order_no),
INDEX idx_user_id (user_id),
INDEX idx_third_trade_no (third_trade_no)
) COMMENT='支付单表';
4.3 支付回调日志表 payment_notify_log
sql
CREATE TABLE payment_notify_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
channel VARCHAR(32) NOT NULL COMMENT '支付渠道',
pay_no VARCHAR(64) NULL COMMENT '支付单号',
order_no VARCHAR(64) NULL COMMENT '订单号',
notify_body TEXT NOT NULL COMMENT '回调原始内容',
verify_status TINYINT DEFAULT 0 COMMENT '0未验证 1验证成功 2验证失败',
error_message VARCHAR(500) NULL COMMENT '错误信息',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) COMMENT='支付回调日志表';
4.4 用户钱包表 user_wallet
sql
CREATE TABLE user_wallet (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL UNIQUE COMMENT '用户ID',
balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '可用余额',
frozen_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '冻结余额',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='用户钱包表';
4.5 钱包流水表 wallet_transaction
sql
CREATE TABLE wallet_transaction (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
order_no VARCHAR(64) NULL COMMENT '关联订单号',
trans_no VARCHAR(64) NOT NULL UNIQUE COMMENT '钱包流水号',
type VARCHAR(32) NOT NULL COMMENT 'RECHARGE充值/PAY支付/REFUND退款/ADJUST调整',
amount DECIMAL(10,2) NOT NULL COMMENT '金额,收入为正,支出为负',
balance_after DECIMAL(10,2) NOT NULL COMMENT '交易后余额',
remark VARCHAR(255) NULL COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_order_no (order_no)
) COMMENT='钱包流水表';
4.6 退款表 refunds
sql
CREATE TABLE refunds (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
refund_no VARCHAR(64) NOT NULL UNIQUE COMMENT '退款单号',
pay_no VARCHAR(64) NOT NULL COMMENT '支付单号',
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
channel VARCHAR(32) NOT NULL COMMENT '退款渠道',
refund_amount DECIMAL(10,2) NOT NULL COMMENT '退款金额',
reason VARCHAR(255) NULL COMMENT '退款原因',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0申请中 1退款成功 2退款失败',
third_refund_no VARCHAR(128) NULL COMMENT '外部退款单号',
refund_time DATETIME NULL COMMENT '退款成功时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='退款表';
5. 前端实现逻辑 Vue3 示例
前端主要负责:展示订单信息、展示支付方式、请求后端创建支付、根据返回结果生成二维码或跳转表单、轮询订单状态。
5.1 安装依赖
bash
npm install axios qrcode.vue
5.2 Vue3 支付页面示例
vue
<template>
<div class="pay-page">
<h2>确认支付 💰</h2>
<div class="order-card">
<p>订单号:{{ order.orderNo }}</p>
<p>商品名称:{{ order.title }}</p>
<p class="amount">应付金额:¥{{ order.amount }}</p>
</div>
<div class="pay-methods">
<button @click="pay('WECHAT_NATIVE')">微信扫码支付 🟢</button>
<button @click="pay('ALIPAY_PC')">支付宝支付 🔵</button>
<button @click="pay('UNIONPAY')">银行卡支付 💳</button>
<button @click="pay('BALANCE')">余额支付 🪙</button>
</div>
<div v-if="wechatQrCode" class="qr-box">
<p>请扫码完成支付 📱</p>
<qrcode-vue :value="wechatQrCode" :size="200" />
<p class="tip">支付完成后页面会自动跳转,请不要关闭页面。</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import QrcodeVue from 'qrcode.vue'
const order = ref({
orderNo: 'ORDER202607080001',
title: '测试商品',
amount: '99.00'
})
const wechatQrCode = ref('')
let pollingTimer = null
async function pay(channel) {
try {
const { data } = await axios.post('/api/pay/create', {
orderNo: order.value.orderNo,
channel
})
if (channel === 'WECHAT_NATIVE') {
wechatQrCode.value = data.codeUrl
startPolling(order.value.orderNo)
return
}
if (channel === 'ALIPAY_PC' || channel === 'UNIONPAY') {
submitPaymentForm(data.formHtml)
return
}
if (channel === 'BALANCE') {
alert('余额支付成功 ✅')
window.location.href = `/pay/success?orderNo=${order.value.orderNo}`
}
} catch (error) {
const message = error.response?.data?.message || '支付创建失败,请稍后重试'
alert(message)
}
}
function submitPaymentForm(formHtml) {
const div = document.createElement('div')
div.innerHTML = formHtml
document.body.appendChild(div)
document.forms[0].submit()
}
function startPolling(orderNo) {
clearInterval(pollingTimer)
pollingTimer = setInterval(async () => {
const { data } = await axios.get(`/api/pay/status/${orderNo}`)
if (data.status === 1) {
clearInterval(pollingTimer)
window.location.href = `/pay/success?orderNo=${orderNo}`
}
if (data.status === 2 || data.status === 3) {
clearInterval(pollingTimer)
alert('订单已关闭或支付失败')
}
}, 3000)
}
</script>
<style scoped>
.pay-page {
max-width: 680px;
margin: 40px auto;
padding: 24px;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.08);
}
.order-card {
padding: 20px;
border-radius: 12px;
background: #f8fafc;
margin-bottom: 20px;
}
.amount {
color: #ef4444;
font-size: 24px;
font-weight: bold;
}
.pay-methods {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.pay-methods button {
height: 48px;
border: none;
border-radius: 10px;
cursor: pointer;
background: #1677ff;
color: #fff;
font-size: 16px;
}
.qr-box {
margin-top: 24px;
text-align: center;
}
.tip {
color: #64748b;
font-size: 14px;
}
</style>
5.3 微信小程序支付前端示例
js
async function wxMiniPay(orderNo) {
const res = await uni.request({
url: '{后端域名}/api/pay/create',
method: 'POST',
data: {
orderNo,
channel: 'WECHAT_MINI'
}
})
const payData = res.data
uni.requestPayment({
provider: 'wxpay',
timeStamp: payData.timeStamp,
nonceStr: payData.nonceStr,
package: payData.packageVal,
signType: payData.signType,
paySign: payData.paySign,
success() {
uni.showToast({ title: '支付成功 ✅' })
uni.redirectTo({ url: `/pages/pay/success?orderNo=${orderNo}` })
},
fail() {
uni.showToast({ title: '用户取消支付', icon: 'none' })
}
})
}
6. 后端统一支付接口设计
6.1 推荐接口
| 接口 | 方法 | 作用 |
|---|---|---|
/api/pay/create |
POST | 创建支付 |
/api/pay/status/{orderNo} |
GET | 查询订单支付状态 |
/api/pay/wechat/notify |
POST | 微信支付回调 |
/api/pay/alipay/notify |
POST | 支付宝支付回调 |
/api/pay/unionpay/notify |
POST | 银联支付回调 |
/api/pay/refund |
POST | 发起退款 |
/api/pay/close/{payNo} |
POST | 关闭支付单 |
6.2 Maven 依赖
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.17</version>
</dependency>
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.39.208.ALL</version>
</dependency>
</dependencies>
6.3 application.yml 配置示例
yaml
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/pay_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 你的数据库密码
mybatis:
mapper-locations: classpath:/mapper/*.xml
type-aliases-package: com.example.pay.entity
wechat:
pay:
appId: wx你的appid
mchId: 你的商户号
mchSerialNo: 你的商户证书序列号
privateKeyPath: /cert/wechat/apiclient_key.pem
apiV3Key: 你的APIv3密钥
notifyUrl: {后端域名}/api/pay/wechat/notify
alipay:
appId: 你的支付宝appId
privateKey: 你的应用私钥
alipayPublicKey: 支付宝公钥
gatewayUrl: 支付宝网关地址
notifyUrl: {后端域名}/api/pay/alipay/notify
returnUrl: {前端域名}/pay/result
6.4 DTO 和 Controller
java
import lombok.Data;
@Data
public class PayCreateRequest {
private String orderNo;
private String channel;
}
java
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class PayStatusResponse {
private String orderNo;
private Integer status;
private String statusText;
}
java
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/pay")
public class PayController {
private final PayService payService;
public PayController(PayService payService) {
this.payService = payService;
}
@PostMapping("/create")
public Object createPay(@RequestBody PayCreateRequest request) {
return payService.createPay(request);
}
@GetMapping("/status/{orderNo}")
public PayStatusResponse getPayStatus(@PathVariable String orderNo) {
return payService.getPayStatus(orderNo);
}
}
6.5 统一支付分发 Service
java
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class PayService {
private final WechatPayService wechatPayService;
private final AlipayPayService alipayPayService;
private final UnionPayService unionPayService;
private final BalancePayService balancePayService;
private final OrderMapper orderMapper;
private final PaymentMapper paymentMapper;
public PayService(
WechatPayService wechatPayService,
AlipayPayService alipayPayService,
UnionPayService unionPayService,
BalancePayService balancePayService,
OrderMapper orderMapper,
PaymentMapper paymentMapper
) {
this.wechatPayService = wechatPayService;
this.alipayPayService = alipayPayService;
this.unionPayService = unionPayService;
this.balancePayService = balancePayService;
this.orderMapper = orderMapper;
this.paymentMapper = paymentMapper;
}
public Object createPay(PayCreateRequest request) {
Order order = orderMapper.selectByOrderNo(request.getOrderNo());
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (order.getStatus() == 1) {
throw new RuntimeException("订单已支付,请勿重复支付");
}
Payment payment = new Payment();
payment.setPayNo("PAY" + System.currentTimeMillis());
payment.setOrderNo(order.getOrderNo());
payment.setUserId(order.getUserId());
payment.setChannel(request.getChannel());
payment.setAmount(order.getAmount());
payment.setStatus(0);
payment.setCreateTime(LocalDateTime.now());
paymentMapper.insert(payment);
switch (request.getChannel()) {
case "WECHAT_NATIVE":
return wechatPayService.nativePay(order, payment);
case "ALIPAY_PC":
return alipayPayService.pagePay(order, payment);
case "UNIONPAY":
return unionPayService.gatewayPay(order, payment);
case "BALANCE":
return balancePayService.pay(order, payment);
default:
throw new RuntimeException("不支持的支付方式");
}
}
public PayStatusResponse getPayStatus(String orderNo) {
Order order = orderMapper.selectByOrderNo(orderNo);
if (order == null) {
throw new RuntimeException("订单不存在");
}
String statusText;
switch (order.getStatus()) {
case 0: statusText = "待支付"; break;
case 1: statusText = "已支付"; break;
case 2: statusText = "已取消"; break;
case 3: statusText = "已退款"; break;
default: statusText = "未知状态";
}
return new PayStatusResponse(order.getOrderNo(), order.getStatus(), statusText);
}
}
7. 微信支付技术实现
7.1 微信 Native 支付流程
微信支付 后端 前端 用户 微信支付 后端 前端 用户 #mermaid-svg-RsdIQODIK7YKur8B{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RsdIQODIK7YKur8B .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RsdIQODIK7YKur8B .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RsdIQODIK7YKur8B .error-icon{fill:#552222;}#mermaid-svg-RsdIQODIK7YKur8B .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RsdIQODIK7YKur8B .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RsdIQODIK7YKur8B .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RsdIQODIK7YKur8B .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RsdIQODIK7YKur8B .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RsdIQODIK7YKur8B .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RsdIQODIK7YKur8B .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RsdIQODIK7YKur8B .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RsdIQODIK7YKur8B .marker.cross{stroke:#333333;}#mermaid-svg-RsdIQODIK7YKur8B svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RsdIQODIK7YKur8B p{margin:0;}#mermaid-svg-RsdIQODIK7YKur8B .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RsdIQODIK7YKur8B text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-RsdIQODIK7YKur8B .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RsdIQODIK7YKur8B .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-RsdIQODIK7YKur8B .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-RsdIQODIK7YKur8B .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-RsdIQODIK7YKur8B #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-RsdIQODIK7YKur8B .sequenceNumber{fill:white;}#mermaid-svg-RsdIQODIK7YKur8B #sequencenumber{fill:#333;}#mermaid-svg-RsdIQODIK7YKur8B #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-RsdIQODIK7YKur8B .messageText{fill:#333;stroke:none;}#mermaid-svg-RsdIQODIK7YKur8B .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RsdIQODIK7YKur8B .labelText,#mermaid-svg-RsdIQODIK7YKur8B .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-RsdIQODIK7YKur8B .loopText,#mermaid-svg-RsdIQODIK7YKur8B .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-RsdIQODIK7YKur8B .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RsdIQODIK7YKur8B .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RsdIQODIK7YKur8B .noteText,#mermaid-svg-RsdIQODIK7YKur8B .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-RsdIQODIK7YKur8B .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RsdIQODIK7YKur8B .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RsdIQODIK7YKur8B .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RsdIQODIK7YKur8B .actorPopupMenu{position:absolute;}#mermaid-svg-RsdIQODIK7YKur8B .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-RsdIQODIK7YKur8B .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RsdIQODIK7YKur8B .actor-man circle,#mermaid-svg-RsdIQODIK7YKur8B line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-RsdIQODIK7YKur8B :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击微信支付 POST /api/pay/create 创建本地支付单 调用 Native 下单 返回 code_url 返回 code_url 展示二维码 扫码付款 异步通知 notify_url 验签/解密/核对金额 修改订单状态 轮询支付状态 已支付
7.2 微信支付 Service 示例
java
import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
@Service
public class WechatPayService {
@Value("${wechat.pay.appId}")
private String appId;
@Value("${wechat.pay.mchId}")
private String mchId;
@Value("${wechat.pay.mchSerialNo}")
private String mchSerialNo;
@Value("${wechat.pay.privateKeyPath}")
private String privateKeyPath;
@Value("${wechat.pay.apiV3Key}")
private String apiV3Key;
@Value("${wechat.pay.notifyUrl}")
private String notifyUrl;
public Map<String, Object> nativePay(Order order, Payment payment) {
Config config = new RSAAutoCertificateConfig.Builder()
.merchantId(mchId)
.privateKeyFromPath(privateKeyPath)
.merchantSerialNumber(mchSerialNo)
.apiV3Key(apiV3Key)
.build();
NativePayService service = new NativePayService.Builder()
.config(config)
.build();
PrepayRequest request = new PrepayRequest();
request.setAppid(appId);
request.setMchid(mchId);
request.setDescription(order.getTitle());
request.setOutTradeNo(payment.getPayNo());
request.setNotifyUrl(notifyUrl);
Amount amount = new Amount();
amount.setTotal(order.getAmount().multiply(new BigDecimal("100")).intValue());
request.setAmount(amount);
PrepayResponse response = service.prepay(request);
Map<String, Object> result = new HashMap<>();
result.put("payNo", payment.getPayNo());
result.put("codeUrl", response.getCodeUrl());
return result;
}
}
7.3 微信支付回调 Controller
java
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/pay/wechat")
public class WechatNotifyController {
private final WechatNotifyService wechatNotifyService;
public WechatNotifyController(WechatNotifyService wechatNotifyService) {
this.wechatNotifyService = wechatNotifyService;
}
@PostMapping("/notify")
public Map<String, String> notify(
@RequestHeader Map<String, String> headers,
@RequestBody String body
) {
wechatNotifyService.handleNotify(headers, body);
Map<String, String> result = new HashMap<>();
result.put("code", "SUCCESS");
result.put("message", "成功");
return result;
}
}
7.4 微信回调业务处理
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
@Service
public class WechatNotifyService {
private final PaymentMapper paymentMapper;
private final OrderMapper orderMapper;
private final PaymentNotifyLogMapper notifyLogMapper;
public WechatNotifyService(
PaymentMapper paymentMapper,
OrderMapper orderMapper,
PaymentNotifyLogMapper notifyLogMapper
) {
this.paymentMapper = paymentMapper;
this.orderMapper = orderMapper;
this.notifyLogMapper = notifyLogMapper;
}
@Transactional
public void handleNotify(Map<String, String> headers, String body) {
PaymentNotifyLog log = new PaymentNotifyLog();
log.setChannel("WECHAT");
log.setNotifyBody(body);
log.setVerifyStatus(0);
notifyLogMapper.insert(log);
// 正式项目需要使用微信 SDK 的通知解析能力完成验签和解密
String payNo = parseOutTradeNo(body);
String transactionId = parseTransactionId(body);
BigDecimal paidAmount = parseAmount(body);
Payment payment = paymentMapper.selectByPayNoForUpdate(payNo);
if (payment == null) {
throw new RuntimeException("支付单不存在");
}
if (payment.getStatus() == 1) {
return;
}
Order order = orderMapper.selectByOrderNoForUpdate(payment.getOrderNo());
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (payment.getAmount().compareTo(paidAmount) != 0) {
throw new RuntimeException("支付金额不一致");
}
payment.setStatus(1);
payment.setThirdTradeNo(transactionId);
payment.setNotifyRaw(body);
payment.setPayTime(LocalDateTime.now());
paymentMapper.update(payment);
order.setStatus(1);
order.setPayTime(LocalDateTime.now());
orderMapper.update(order);
}
private String parseOutTradeNo(String body) {
return "PAY202607080001";
}
private String parseTransactionId(String body) {
return "WX420000000000000000";
}
private BigDecimal parseAmount(String body) {
return new BigDecimal("99.00");
}
}
8. 支付宝支付技术实现
8.1 支付宝电脑网站支付流程
支付宝 后端 前端 用户 支付宝 后端 前端 用户 #mermaid-svg-dwPYJXueOUiojxG1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dwPYJXueOUiojxG1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dwPYJXueOUiojxG1 .error-icon{fill:#552222;}#mermaid-svg-dwPYJXueOUiojxG1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dwPYJXueOUiojxG1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dwPYJXueOUiojxG1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dwPYJXueOUiojxG1 .marker.cross{stroke:#333333;}#mermaid-svg-dwPYJXueOUiojxG1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dwPYJXueOUiojxG1 p{margin:0;}#mermaid-svg-dwPYJXueOUiojxG1 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dwPYJXueOUiojxG1 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-dwPYJXueOUiojxG1 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dwPYJXueOUiojxG1 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-dwPYJXueOUiojxG1 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-dwPYJXueOUiojxG1 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-dwPYJXueOUiojxG1 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-dwPYJXueOUiojxG1 .sequenceNumber{fill:white;}#mermaid-svg-dwPYJXueOUiojxG1 #sequencenumber{fill:#333;}#mermaid-svg-dwPYJXueOUiojxG1 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-dwPYJXueOUiojxG1 .messageText{fill:#333;stroke:none;}#mermaid-svg-dwPYJXueOUiojxG1 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dwPYJXueOUiojxG1 .labelText,#mermaid-svg-dwPYJXueOUiojxG1 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-dwPYJXueOUiojxG1 .loopText,#mermaid-svg-dwPYJXueOUiojxG1 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-dwPYJXueOUiojxG1 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-dwPYJXueOUiojxG1 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dwPYJXueOUiojxG1 .noteText,#mermaid-svg-dwPYJXueOUiojxG1 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-dwPYJXueOUiojxG1 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dwPYJXueOUiojxG1 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dwPYJXueOUiojxG1 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-dwPYJXueOUiojxG1 .actorPopupMenu{position:absolute;}#mermaid-svg-dwPYJXueOUiojxG1 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-dwPYJXueOUiojxG1 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-dwPYJXueOUiojxG1 .actor-man circle,#mermaid-svg-dwPYJXueOUiojxG1 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-dwPYJXueOUiojxG1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击支付宝支付 POST /api/pay/create 创建支付单 调用电脑网站支付接口 返回 form 表单 返回 formHtml 自动提交表单跳转 完成付款 异步通知 notify_url 验签/核对订单/核对金额 修改订单状态 同步跳转 return_url
8.2 支付宝支付 Service
java
import com.alibaba.fastjson2.JSONObject;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.request.AlipayTradePagePayRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class AlipayPayService {
@Value("${alipay.appId}")
private String appId;
@Value("${alipay.privateKey}")
private String privateKey;
@Value("${alipay.alipayPublicKey}")
private String alipayPublicKey;
@Value("${alipay.gatewayUrl}")
private String gatewayUrl;
@Value("${alipay.notifyUrl}")
private String notifyUrl;
@Value("${alipay.returnUrl}")
private String returnUrl;
public Map<String, Object> pagePay(Order order, Payment payment) {
try {
AlipayClient alipayClient = new DefaultAlipayClient(
gatewayUrl,
appId,
privateKey,
"json",
"UTF-8",
alipayPublicKey,
"RSA2"
);
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl(notifyUrl);
request.setReturnUrl(returnUrl);
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", payment.getPayNo());
bizContent.put("total_amount", order.getAmount().toPlainString());
bizContent.put("subject", order.getTitle());
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
request.setBizContent(bizContent.toJSONString());
String formHtml = alipayClient.pageExecute(request).getBody();
Map<String, Object> result = new HashMap<>();
result.put("payNo", payment.getPayNo());
result.put("formHtml", formHtml);
return result;
} catch (AlipayApiException e) {
throw new RuntimeException("支付宝支付表单生成失败", e);
}
}
}
8.3 支付宝异步通知 Controller
java
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/pay/alipay")
public class AlipayNotifyController {
private final AlipayNotifyService alipayNotifyService;
public AlipayNotifyController(AlipayNotifyService alipayNotifyService) {
this.alipayNotifyService = alipayNotifyService;
}
@PostMapping("/notify")
public String notify(HttpServletRequest request) {
boolean success = alipayNotifyService.handleNotify(request);
return success ? "success" : "failure";
}
}
8.4 支付宝回调验签和业务处理
java
import com.alipay.api.internal.util.AlipaySignature;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Service
public class AlipayNotifyService {
@Value("${alipay.alipayPublicKey}")
private String alipayPublicKey;
private final PaymentMapper paymentMapper;
private final OrderMapper orderMapper;
public AlipayNotifyService(PaymentMapper paymentMapper, OrderMapper orderMapper) {
this.paymentMapper = paymentMapper;
this.orderMapper = orderMapper;
}
@Transactional
public boolean handleNotify(HttpServletRequest request) {
try {
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
String[] values = requestParams.get(name);
params.put(name, String.join(",", values));
}
boolean signVerified = AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
"UTF-8",
"RSA2"
);
if (!signVerified) {
return false;
}
String tradeStatus = params.get("trade_status");
String payNo = params.get("out_trade_no");
String alipayTradeNo = params.get("trade_no");
BigDecimal paidAmount = new BigDecimal(params.get("total_amount"));
if (!"TRADE_SUCCESS".equals(tradeStatus) && !"TRADE_FINISHED".equals(tradeStatus)) {
return true;
}
Payment payment = paymentMapper.selectByPayNoForUpdate(payNo);
if (payment == null) {
return false;
}
if (payment.getStatus() == 1) {
return true;
}
Order order = orderMapper.selectByOrderNoForUpdate(payment.getOrderNo());
if (order == null) {
return false;
}
if (payment.getAmount().compareTo(paidAmount) != 0) {
return false;
}
payment.setStatus(1);
payment.setThirdTradeNo(alipayTradeNo);
payment.setPayTime(LocalDateTime.now());
paymentMapper.update(payment);
order.setStatus(1);
order.setPayTime(LocalDateTime.now());
orderMapper.update(order);
return true;
} catch (Exception e) {
return false;
}
}
}
9. 银行卡/银联支付技术实现
9.1 银行卡支付处理思路
银行卡支付涉及支付安全、交易验证、清算、争议处理和敏感数据保护。业务系统一般不直接采集完整银行卡敏感信息,而是采用官方或合规的网关/托管流程。
推荐技术链路:
text
用户选择银行卡支付
→ 后端生成银联支付请求参数
→ 前端提交支付表单或跳转支付页面
→ 用户在支付页面完成验证
→ 支付完成后触发异步通知
→ 后端验签、核对金额、更新订单状态
9.2 银联支付 Service 思路
java
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@Service
public class UnionPayService {
public Map<String, Object> gatewayPay(Order order, Payment payment) {
Map<String, String> data = new HashMap<>();
data.put("version", "5.1.0");
data.put("encoding", "UTF-8");
data.put("signMethod", "01");
data.put("txnType", "01");
data.put("txnSubType", "01");
data.put("bizType", "000201");
data.put("channelType", "07");
data.put("merId", "你的银联商户号");
data.put("accessType", "0");
data.put("orderId", payment.getPayNo());
data.put("txnTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
data.put("txnAmt", order.getAmount().multiply(new BigDecimal("100")).toBigInteger().toString());
data.put("currencyCode", "156");
data.put("frontUrl", "{前端域名}/pay/result");
data.put("backUrl", "{后端域名}/api/pay/unionpay/notify");
// 实际项目使用银联 SDK 完成签名和表单生成
String formHtml = "<form>这里是自动提交表单</form>";
Map<String, Object> result = new HashMap<>();
result.put("payNo", payment.getPayNo());
result.put("formHtml", formHtml);
return result;
}
}
9.3 银联回调处理
java
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@Service
public class UnionPayNotifyService {
private final PaymentMapper paymentMapper;
private final OrderMapper orderMapper;
public UnionPayNotifyService(PaymentMapper paymentMapper, OrderMapper orderMapper) {
this.paymentMapper = paymentMapper;
this.orderMapper = orderMapper;
}
@Transactional
public boolean handleNotify(HttpServletRequest request) {
try {
Map<String, String> params = getAllRequestParam(request);
// 正式项目使用银联 SDK 验签
boolean verifyResult = true;
if (!verifyResult) {
return false;
}
String respCode = params.get("respCode");
String payNo = params.get("orderId");
String queryId = params.get("queryId");
BigDecimal paidAmount = new BigDecimal(params.get("txnAmt"))
.divide(new BigDecimal("100"));
if (!"00".equals(respCode)) {
return true;
}
Payment payment = paymentMapper.selectByPayNoForUpdate(payNo);
if (payment == null) {
return false;
}
if (payment.getStatus() == 1) {
return true;
}
Order order = orderMapper.selectByOrderNoForUpdate(payment.getOrderNo());
if (order == null) {
return false;
}
if (payment.getAmount().compareTo(paidAmount) != 0) {
return false;
}
payment.setStatus(1);
payment.setThirdTradeNo(queryId);
payment.setPayTime(LocalDateTime.now());
paymentMapper.update(payment);
order.setStatus(1);
order.setPayTime(LocalDateTime.now());
orderMapper.update(order);
return true;
} catch (Exception e) {
return false;
}
}
private Map<String, String> getAllRequestParam(HttpServletRequest request) {
Map<String, String> res = new HashMap<>();
Enumeration<String> temp = request.getParameterNames();
while (temp.hasMoreElements()) {
String paramName = temp.nextElement();
String paramValue = request.getParameter(paramName);
res.put(paramName, paramValue);
}
return res;
}
}
10. 余额支付技术实现
10.1 余额支付流程
数据库 后端 前端 用户 数据库 后端 前端 用户 #mermaid-svg-iqq7j4SpAp74DdRK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iqq7j4SpAp74DdRK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iqq7j4SpAp74DdRK .error-icon{fill:#552222;}#mermaid-svg-iqq7j4SpAp74DdRK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iqq7j4SpAp74DdRK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iqq7j4SpAp74DdRK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iqq7j4SpAp74DdRK .marker.cross{stroke:#333333;}#mermaid-svg-iqq7j4SpAp74DdRK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iqq7j4SpAp74DdRK p{margin:0;}#mermaid-svg-iqq7j4SpAp74DdRK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iqq7j4SpAp74DdRK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iqq7j4SpAp74DdRK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iqq7j4SpAp74DdRK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iqq7j4SpAp74DdRK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iqq7j4SpAp74DdRK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iqq7j4SpAp74DdRK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iqq7j4SpAp74DdRK .sequenceNumber{fill:white;}#mermaid-svg-iqq7j4SpAp74DdRK #sequencenumber{fill:#333;}#mermaid-svg-iqq7j4SpAp74DdRK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iqq7j4SpAp74DdRK .messageText{fill:#333;stroke:none;}#mermaid-svg-iqq7j4SpAp74DdRK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iqq7j4SpAp74DdRK .labelText,#mermaid-svg-iqq7j4SpAp74DdRK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iqq7j4SpAp74DdRK .loopText,#mermaid-svg-iqq7j4SpAp74DdRK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iqq7j4SpAp74DdRK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iqq7j4SpAp74DdRK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iqq7j4SpAp74DdRK .noteText,#mermaid-svg-iqq7j4SpAp74DdRK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iqq7j4SpAp74DdRK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iqq7j4SpAp74DdRK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iqq7j4SpAp74DdRK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iqq7j4SpAp74DdRK .actorPopupMenu{position:absolute;}#mermaid-svg-iqq7j4SpAp74DdRK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-iqq7j4SpAp74DdRK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iqq7j4SpAp74DdRK .actor-man circle,#mermaid-svg-iqq7j4SpAp74DdRK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iqq7j4SpAp74DdRK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击余额支付 POST /api/pay/create channel=BALANCE 查询订单 锁定订单 锁定用户钱包 判断余额是否足够 扣减余额 写入钱包流水 修改支付单成功 修改订单已支付 支付成功
10.2 余额支付 Service
java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Service
public class BalancePayService {
private final WalletMapper walletMapper;
private final WalletTransactionMapper walletTransactionMapper;
private final OrderMapper orderMapper;
private final PaymentMapper paymentMapper;
public BalancePayService(
WalletMapper walletMapper,
WalletTransactionMapper walletTransactionMapper,
OrderMapper orderMapper,
PaymentMapper paymentMapper
) {
this.walletMapper = walletMapper;
this.walletTransactionMapper = walletTransactionMapper;
this.orderMapper = orderMapper;
this.paymentMapper = paymentMapper;
}
@Transactional
public Map<String, Object> pay(Order order, Payment payment) {
Order lockedOrder = orderMapper.selectByOrderNoForUpdate(order.getOrderNo());
if (lockedOrder == null) {
throw new RuntimeException("订单不存在");
}
if (lockedOrder.getStatus() == 1) {
throw new RuntimeException("订单已支付");
}
UserWallet wallet = walletMapper.selectByUserIdForUpdate(order.getUserId());
if (wallet == null) {
throw new RuntimeException("钱包不存在");
}
if (wallet.getBalance().compareTo(order.getAmount()) < 0) {
throw new RuntimeException("余额不足");
}
BigDecimal newBalance = wallet.getBalance().subtract(order.getAmount());
wallet.setBalance(newBalance);
walletMapper.update(wallet);
WalletTransaction trans = new WalletTransaction();
trans.setUserId(order.getUserId());
trans.setOrderNo(order.getOrderNo());
trans.setTransNo("WT" + System.currentTimeMillis());
trans.setType("PAY");
trans.setAmount(order.getAmount().negate());
trans.setBalanceAfter(newBalance);
trans.setRemark("余额支付订单:" + order.getOrderNo());
walletTransactionMapper.insert(trans);
payment.setStatus(1);
payment.setPayTime(LocalDateTime.now());
paymentMapper.update(payment);
lockedOrder.setStatus(1);
lockedOrder.setPayTime(LocalDateTime.now());
orderMapper.update(lockedOrder);
Map<String, Object> result = new HashMap<>();
result.put("payNo", payment.getPayNo());
result.put("status", "SUCCESS");
return result;
}
}
11. 回调验签、幂等与事务处理
支付回调是支付模块最重要的部分。前端成功页只能作为用户体验提示,业务状态必须以后端验签和订单状态更新结果为准。🚨
11.1 回调必须校验内容
| 校验项 | 说明 |
|---|---|
| 验签 | 判断通知来源和数据完整性 |
| 支付单是否存在 | 根据支付单号查询本地支付单 |
| 订单是否存在 | 根据订单号查询本地订单 |
| 金额是否一致 | 防止金额异常 |
| 商户号是否一致 | 防止通知串单 |
| appid 是否一致 | 微信/支付宝等渠道需要核对 |
| 支付状态是否成功 | 只处理成功状态 |
| 是否重复处理 | 已支付订单直接返回成功 |
| 是否在事务内处理 | 保证订单和支付单状态一致 |
11.2 幂等处理示例
java
Payment payment = paymentMapper.selectByPayNoForUpdate(payNo);
if (payment.getStatus() == 1) {
return true;
}
// 后续再执行修改支付状态、修改订单状态、开通权益等逻辑
11.3 为什么要用 FOR UPDATE
支付渠道可能重复推送回调,如果多个请求同时处理同一个支付单,可能出现重复开通权益、重复发货、重复改库存。
sql
SELECT * FROM payments WHERE pay_no = ? FOR UPDATE;
作用:
- 锁住当前支付单。
- 保证同一时间只有一个线程处理同一笔支付。
- 防止重复扣库存、重复开通会员、重复写流水。
12. 退款、关闭订单、查询订单、对账
完整支付模块不能只有支付成功,还需要考虑关闭订单、退款、主动查询和对账。
12.1 关闭订单
适合场景:
- 用户超时未支付。
- 用户主动取消订单。
- 库存不足自动关闭订单。
流程:
text
查询订单是否待支付
→ 关闭对应支付单
→ 修改本地支付单状态为已关闭
→ 修改订单状态为已取消
12.2 退款
退款方式:
| 方式 | 说明 |
|---|---|
| 原路退款 | 按原支付渠道退回 |
| 余额退款 | 退回用户账户余额 |
| 部分退款 | 只退订单部分金额 |
| 全额退款 | 退全部金额 |
退款流程:
text
用户申请退款
→ 后台审核
→ 创建退款单
→ 调用退款接口或执行余额退款
→ 等待退款结果
→ 修改退款单状态
→ 修改订单状态
12.3 查询支付结果
为什么需要主动查询?
- 回调可能延迟。
- 回调接口可能异常。
- 用户支付成功但前端没有跳转。
- 后台需要人工核对。
推荐接口:
http
GET /api/pay/query/{payNo}
12.4 对账
text
获取渠道账单
→ 和本地 payments 表核对
→ 找出金额不一致、状态不一致、缺失订单
→ 生成对账异常记录
→ 后台人工处理
13. 前后台页面功能设计
13.1 前台页面
| 页面 | 功能 |
|---|---|
| 商品详情页 | 点击购买、生成订单 |
| 确认订单页 | 展示商品、价格、优惠、应付金额 |
| 支付页面 | 选择微信、支付宝、银行卡、余额支付 |
| 扫码弹窗 | 展示二维码、倒计时、支付状态 |
| 支付成功页 | 展示订单号、支付金额、返回首页 |
| 支付失败页 | 重新支付、取消订单 |
| 钱包明细页 | 查看余额流水 |
| 退款申请页 | 填写退款原因、上传凭证 |
13.2 后台管理页面
| 页面 | 功能 |
|---|---|
| 订单管理 | 查询订单、查看详情、取消订单、退款 |
| 支付单管理 | 查询支付单、支付渠道、支付状态、交易号 |
| 退款管理 | 审核退款、查看退款结果 |
| 钱包管理 | 查看用户余额、钱包流水、人工调整 |
| 回调日志 | 查看微信/支付宝/银联回调内容 |
| 对账管理 | 账单核对、对账异常处理 |
| 支付配置 | 配置 appid、商户号、回调地址、证书路径 |
| 风控管理 | 大额支付、异常退款、频繁支付限制 |
13.3 支付配置后台字段
| 字段 | 说明 |
|---|---|
| 支付方式名称 | 微信支付、支付宝、银联、余额 |
| 支付渠道编码 | WECHAT、ALIPAY、UNIONPAY、BALANCE |
| 是否启用 | 启用/禁用 |
| appid | 微信/支付宝应用 ID |
| 商户号 | 微信/银联商户号 |
| 私钥路径 | 后端服务器证书路径 |
| 公钥路径 | 验签公钥路径 |
| APIv3 密钥 | 微信回调解密使用 |
| notify_url | 异步通知地址 |
| return_url | 同步跳转地址 |
| 沙箱模式 | 开启/关闭 |
| 排序 | 前端支付方式展示顺序 |
14. 安全注意事项
14.1 密钥不能放前端 🔐
这些内容不能出现在前端:
text
微信 API 私钥
微信 APIv3 密钥
支付宝应用私钥
支付宝公钥证书路径
银联证书密码
银行卡 CVV
完整银行卡号
支付平台证书
14.2 后端接口必须做权限校验
错误写法:
java
// 错误:直接相信前端传过来的金额
BigDecimal amount = request.getAmount();
正确写法:
java
// 正确:金额以后端订单表为准
Order order = orderMapper.selectByOrderNo(request.getOrderNo());
BigDecimal amount = order.getAmount();
14.3 回调地址必须公网可访问
开发阶段可使用内网穿透或测试环境域名验证回调链路。正式环境建议使用稳定的 HTTPS 域名和独立后端回调接口。
正式环境示例:
text
{后端域名}/api/pay/wechat/notify
{后端域名}/api/pay/alipay/notify
{后端域名}/api/pay/unionpay/notify
14.4 银行卡数据安全
如果业务系统存储、处理或传输银行卡敏感数据,就会涉及更严格的数据安全要求。一般项目建议使用网关或托管页面完成银行卡信息输入,业务系统只处理订单号、支付单号、状态和回调结果。
15. 常见问题和解决方案
15.1 支付成功了,但是订单还是未支付
可能原因:
- 回调地址公网访问不到。
- 回调接口报错。
- 验签失败。
- 支付单号和订单号对应错误。
- 金额校验失败。
- 数据库事务回滚。
- 支付渠道回调延迟。
解决方案:
text
检查服务器日志
检查 payment_notify_log 表
检查渠道通知记录
手动调用支付查询接口核实状态
修复后通过补单脚本同步订单状态
15.2 微信支付二维码生成了,但是扫码无法继续
可能原因:
- 支付产品类型配置不匹配。
- appid 和 mch_id 绑定关系不一致。
- 金额单位错误,微信常用单位是分。
- notify_url 不是 HTTPS 或无法访问。
- 签名参数错误。
- 订单号重复。
15.3 支付宝跳转失败
可能原因:
- 应用能力配置不匹配。
- 私钥、公钥配置错误。
product_code错误。- 网关地址与环境不一致。
- return_url 或 notify_url 配置错误。
15.4 余额支付出现重复扣款
可能原因:
- 没有事务。
- 没有行锁。
- 没有判断订单是否已支付。
- 前端重复点击支付按钮。
- 支付接口没有做幂等。
解决方案:
text
前端按钮点击后 loading 禁用
后端 select ... for update 锁订单和钱包
支付前判断订单状态
钱包扣款、流水、订单状态放在同一个事务中
16. 官方技术与操作文档
下面只保留官方技术文档和官方操作文档,方便开发时核对接口参数、签名验签、回调规则、沙箱配置和 SDK 使用方式。
16.1 支付相关官方文档
| 类型 | 名称 | 链接 |
|---|---|---|
| 微信支付 | 微信支付商户文档中心 | https://pay.wechatpay.cn/doc/v3/merchant/4012062520 |
| 微信支付 | 微信支付 APIv3 Java SDK | https://github.com/wechatpay-apiv3/wechatpay-java |
| 微信支付 | JSAPI 支付文档 | https://pay.wechatpay.cn/doc/v3/merchant/4012164514 |
| 微信支付 | 小程序支付文档 | https://pay.wechatpay.cn/doc/v3/merchant/4012791859 |
| 支付宝 | 支付宝开放平台文档中心 | https://opendocs.alipay.com/ |
| 支付宝 | 电脑网站支付 | https://opendocs.alipay.com/open/270/105899 |
| 支付宝 | 手机网站支付 | https://opendocs.alipay.com/open/203/105285 |
| 支付宝 | 支付宝 Java SDK | https://github.com/alipay/alipay-sdk-java-all |
| 支付宝 | 沙箱环境操作文档 | https://open.alipay.com/develop/sandbox/app |
| 银联 | 银联开放平台文档 | https://open.unionpay.com/ |
| 银联 | 在线网关支付接口 | https://open.unionpay.com/tjweb/api/dictionary?apiSvcId=448&catId=10&index=1 |
| 支付安全 | PCI SSC 标准文档 | https://www.pcisecuritystandards.org/standards/ |
16.2 前端官方文档
| 名称 | 链接 |
|---|---|
| Vue 官方文档 | https://cn.vuejs.org/ |
| Axios 官方文档 | https://axios-http.com/docs/intro |
| uni-app 支付 API | https://uniapp.dcloud.net.cn/api/plugins/payment.html |
| 微信小程序开发文档 | https://developers.weixin.qq.com/miniprogram/dev/framework/ |
16.3 后端官方文档
| 名称 | 链接 |
|---|---|
| Spring Boot 官方文档 | https://docs.spring.io/spring-boot/ |
| Spring 事务管理 | https://docs.spring.io/spring-framework/reference/data-access/transaction.html |
| MyBatis 官方文档 | https://mybatis.org/mybatis-3/ |
| MyBatis Spring Boot Starter | https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/ |
| MySQL 官方文档 | https://dev.mysql.com/doc/ |
17. 项目落地开发顺序
建议不要一开始就把所有渠道同时做完,可以按模块逐步落地。
第一阶段:订单基础能力 🧱
text
1. 创建商品/服务订单
2. 设计订单状态
3. 设计支付单表
4. 实现创建支付单接口
5. 实现查询支付状态接口
第二阶段:接入测试环境支付链路 🧪
text
1. 配置测试环境应用参数
2. 配置私钥、公钥或证书
3. 实现网页支付表单生成
4. 实现异步回调接口
5. 验签成功后修改订单状态
第三阶段:实现扫码支付链路 🟢
text
1. 准备应用标识和商户配置
2. 配置证书和密钥
3. 实现 Native 下单
4. 前端展示二维码
5. 实现回调验签
6. 前端轮询订单状态
第四阶段:实现小程序/公众号支付 📱
text
1. 获取用户 openid
2. 后端生成 JSAPI/小程序支付参数
3. 返回前端支付参数
4. 前端调起支付能力
5. 后端处理异步回调
第五阶段:实现余额支付 🪙
text
1. 用户钱包表
2. 钱包流水表
3. 余额变更能力
4. 余额支付能力
5. 退款到余额
6. 钱包明细页面
第六阶段:实现银行卡/银联支付 💳
text
1. 按官方文档完成网关参数配置
2. 配置签名和验签证书
3. 生成支付请求表单
4. 实现回调验签
5. 实现退款和对账
第七阶段:后台管理和对账 📊
text
1. 订单管理
2. 支付单管理
3. 退款管理
4. 钱包管理
5. 回调日志
6. 对账管理
7. 异常订单人工处理
18. 给 Codex / AI 生成代码的提示词
text
请使用 Vue3 + Spring Boot + MyBatis + MySQL 实现一个统一订单支付模块,要求包含微信支付、支付宝支付、银联银行卡支付、余额支付。
前端要求:
1. 支付确认页展示订单号、商品名称、金额。
2. 支持选择微信扫码支付、支付宝支付、银行卡支付、余额支付。
3. 微信 Native 支付返回 codeUrl 后生成二维码。
4. 支付宝和银联返回 formHtml 后自动提交跳转。
5. 余额支付成功后直接跳转支付成功页。
6. PC 扫码支付需要轮询 /api/pay/status/{orderNo} 查询订单状态。
7. 页面需要响应式适配 PC 和移动端。
后端要求:
1. 设计 orders、payments、payment_notify_log、user_wallet、wallet_transaction、refunds 表。
2. 提供 /api/pay/create 创建支付接口。
3. 提供 /api/pay/status/{orderNo} 查询支付状态接口。
4. 提供 /api/pay/wechat/notify 微信支付回调接口。
5. 提供 /api/pay/alipay/notify 支付宝支付回调接口。
6. 提供 /api/pay/unionpay/notify 银联支付回调接口。
7. 余额支付必须使用 @Transactional 和 select for update 防止重复扣款。
8. 微信、支付宝、银联回调必须验签、核对金额、做幂等处理。
9. 支付成功后修改 payments 和 orders 状态。
10. 预留退款、关闭订单、查询订单、对账接口。
11. 所有密钥和证书路径放 application.yml,不允许前端传入金额和密钥。
12. 代码结构清晰,Controller、Service、Mapper、Entity、DTO 分层。
13. 所有关键代码添加中文注释。
19. 总结
支付模块的核心不是单独调用某个接口,而是完整业务闭环:
text
创建订单
→ 创建支付单
→ 调用支付渠道
→ 用户完成支付
→ 异步回调
→ 后端验签
→ 核对金额
→ 幂等处理
→ 修改订单状态
→ 发货/开通权益
→ 支持退款和对账
重点记住:
- 前端不能保存密钥。 🔐
- 不能以前端成功页判断支付成功。 🚫
- 支付成功必须以后端异步回调验签为准。 ✅
- 余额支付必须使用事务和行锁。 🧱
- 银行卡支付不要自行保存卡敏感信息。 💳
- 正式项目要记录日志、支持退款、支持对账、支持异常补单。 📊
完。 🎉