个人网站接入支付解决方案
支付宝开放平台准备
打开开放平台并登录
进入 支付宝开放平台,先完成登录。

点击登录后使用支付宝扫码登录。

按页面提示填写手机号并获取验证码,加入开发者平台。

创建应用
进入控制台后,选择 网页/移动应用。

点击创建应用。

按实际项目填写应用信息,然后点击创建。




下载密钥工具
进入应用开发配置,找到密钥相关配置入口。

点击下载密钥工具。

安装密钥工具。

打开密钥工具。

点击生成密钥。

上传应用公钥并下载支付宝公钥
复制“应用公钥”到支付宝开放平台,同时妥善保存“应用私钥”。
应用私钥只能保存在服务端或安全配置中,不要提交到前端仓库,也不要写进公开文档。

点击确认上传应用公钥。

按页面要求完成身份校验。

下载并保存支付宝公钥。

提交应用审核。

相关接口通常需要审核通过后才能使用。
获取配置参数
AppID
在应用详情页获取 AppID,后端配置时使用占位符 <ALIPAY_APP_ID>。

收款支付宝用户 ID
这里获取到的是收款支付宝用户自己的 ID。转账二维码模式会用这个 ID 构建“转给谁付款”的二维码,后端配置时使用占位符 <PAYEE_USER_ID>。


经营码
如果使用个人经营码,可在支付宝 App 中按以下路径开通:
我的 -> 商家服务 -> 经营码 -> 开通经营码
推荐选择“餐饮 / 流动摊位”,更容易通过开通校验。营业执照是选填,不用管。开通后下载电子经营码,用作固定收款码图片。

配置示例
alipay:
app-id: ${ALIPAY_APP_ID:<ALIPAY_APP_ID>}
private-key: ${ALIPAY_PRIVATE_KEY:<ALIPAY_PRIVATE_KEY>} # 应用密钥
alipay-public-key: ${ALIPAY_PUBLIC_KEY:<ALIPAY_PUBLIC_KEY>} # 支付宝公钥
payee-user-id: ${ALIPAY_PAYEE_USER_ID:<PAYEE_USER_ID>}
payment-mode: ${ALIPAY_PAYMENT_MODE:FIXED_QR}
collect-qr-path: ${ALIPAY_COLLECT_QR_PATH:<COLLECT_QR_PATH>}
bill-query-minutes-back: ${ALIPAY_BILL_QUERY_MINUTES_BACK:10}
poll-interval-ms: ${ALIPAY_POLL_INTERVAL_MS:5000}
码支付实现流程
这套方案不调用支付宝官方下单接口,也没有官方异步通知;支付结果以后端账务流水匹配为准。
支付闭环流程
1. 业务系统创建本地支付单,订单状态为 WAITING
2. 后端根据支付模式返回二维码
- FIXED_QR:返回配置好的固定收款码图片
- TRANSFER_QR:按订单金额和业务订单号备注生成转账二维码
3. 用户扫码付款
4. 后端定时查询支付宝账务流水
5. 后端按支付模式匹配流水和本地订单
6. 匹配成功后,在事务里把订单从 WAITING 更新为 PAID
7. 前端轮询本地订单状态,看到 PAID 后展示支付完成
两种模式差异:
| 对比项 | 固定收款码 FIXED_QR |
转账二维码 TRANSFER_QR |
|---|---|---|
| 二维码来源 | 一张固定收款码图片 | 每笔订单动态生成转账链接二维码 |
| 金额 | 用户扫码后手动输入 | 链接里尝试预填金额 |
| 业务订单号 | 支付宝流水里没有业务订单号 | 作为转账备注写入 memo |
| 到账确认 | 收入方向 + 金额 + 时间窗口 | 收入方向 + 备注订单号 + 金额 + 时间窗口 |
| 误匹配风险 | 较高,同金额同时间容易撞 | 较低,因为多了备注订单号 |
| 用户侧稳定性 | 展示固定码较稳定 | 拉起支付宝转账页可能触发风控、风险提示或拦截 |
| 推荐定位 | 低频、简单、可人工核对 | 验证“备注识别订单”的实验方案 |
数据字段设计
支付单建议至少保存这些字段:
| 字段 | 说明 |
|---|---|
trade_no |
本系统支付单号 |
out_trade_no |
业务订单号 |
subject |
商品或订单标题 |
amount |
原始订单金额 |
payment_amount |
用户实际应付金额 |
payment_mode |
FIXED_QR 或 TRANSFER_QR |
payment_memo |
转账备注,转账码模式一般等于业务订单号 |
status |
WAITING、PAID、EXPIRED |
created_at、expire_at、paid_at |
创建、过期和支付完成时间 |
alipay_trade_no |
匹配到的支付宝流水或交易标识 |
流水消费表建议至少保存:
| 字段 | 说明 |
|---|---|
bill_key |
支付宝流水唯一键,建议加唯一索引 |
trade_no |
被这条流水确认的本地支付单号 |
created_at |
消费流水时间 |
创建支付单
创建支付单时,固定收款码和转账二维码对 paymentAmount 的处理不同:
FIXED_QR:支付宝侧没有订单号,建议对同金额待支付订单做金额偏移,例如9.90、9.91,降低误匹配概率。TRANSFER_QR:备注里能带业务订单号,一般保持原始订单金额,不额外偏移。
/**
* 创建本地支付单,并按“标准化金额、确定支付模式、生成实付金额和备注、保存 WAITING 订单”的顺序完成码支付建单。
*
* @param request 创建支付单请求
* @return 本地支付单创建结果
*/
@Transactional(rollbackFor = Exception.class)
public PayOrderCreateResp createPayOrder(PayOrderCreateReq request)
{
// 1. 金额统一保留两位小数,后续二维码展示和流水匹配都以 paymentAmount 为准
BigDecimal amount = request.getAmount().setScale(2, RoundingMode.HALF_UP);
String paymentMode = payDemoProperties.getPaymentMode();
// 2. 固定码需要尽量避开同金额待支付订单,转账码用备注识别订单所以保持原金额
BigDecimal paymentAmount = resolvePaymentAmount(amount, paymentMode);
// 3. 创建本地支付单,业务订单号同时作为转账备注,方便后续从支付宝流水里反查订单
PayOrder order = new PayOrder();
order.setTradeNo(generateTradeNo());
order.setOutTradeNo(request.getOutTradeNo());
order.setSubject(request.getSubject());
order.setAmount(amount);
order.setPaymentAmount(paymentAmount);
order.setPaymentMode(paymentMode);
order.setPaymentMemo(request.getOutTradeNo());
order.setStatus("WAITING");
order.setCreatedAt(LocalDateTime.now());
order.setExpireAt(order.getCreatedAt().plusMinutes(5));
payOrderRepository.insert(order);
// 4. 返回本地支付单号和二维码地址,前端后续用 tradeNo 请求二维码和轮询状态
PayOrderCreateResp resp = new PayOrderCreateResp();
resp.setTradeNo(order.getTradeNo());
resp.setOutTradeNo(order.getOutTradeNo());
resp.setPaymentAmount(order.getPaymentAmount());
resp.setQrCodeUrl("/pay/qrcode/" + order.getTradeNo());
resp.setStatus(order.getStatus());
return resp;
}
/**
* 计算实际应付金额,并按“转账码保持原金额、固定码避开同金额待支付订单”的顺序降低误匹配概率。
*
* @param amount 原始订单金额
* @param paymentMode 支付模式
* @return 用户实际应付金额
*/
private BigDecimal resolvePaymentAmount(BigDecimal amount, String paymentMode)
{
// 1. 转账码可以靠备注识别订单,不建议改动用户看到的订单金额
if ("TRANSFER_QR".equals(paymentMode)) {
return amount;
}
// 2. 固定码没有订单号,只能通过金额和时间识别,尽量给并发同金额订单分配不同分位
BigDecimal offset = new BigDecimal("0.01");
for (int i = 0; i < 100; i++) {
BigDecimal candidate = amount.add(offset.multiply(BigDecimal.valueOf(i))).setScale(2, RoundingMode.HALF_UP);
if (!payOrderRepository.existsWaitingPaymentAmount(candidate)) {
return candidate;
}
}
// 3. 极端情况下兜底返回加 1 元后的金额,避免多个 WAITING 订单继续共用同一金额
return amount.add(new BigDecimal("1.00")).setScale(2, RoundingMode.HALF_UP);
}
返回支付二维码
二维码接口只做三件事:查订单、判断模式、返回图片。
/**
* 返回支付二维码,并按“查询待支付订单、判断支付模式、固定码返回图片、转账码动态生成图片”的顺序输出扫码入口。
*
* @param tradeNo 本系统支付单号
* @return 支付二维码图片
* @throws IOException 读取固定收款码失败时抛出
*/
@GetMapping("/pay/qrcode/{tradeNo}")
public ResponseEntity<byte[]> qrcode(@PathVariable String tradeNo) throws IOException
{
// 1. 从数据库读取待支付订单,只有 WAITING 状态才允许继续展示二维码
PayOrder order = payOrderRepository.findByTradeNo(tradeNo);
if (order == null || !"WAITING".equals(order.getStatus())) {
return ResponseEntity.notFound().build();
}
// 2. 转账二维码模式:按当前订单动态生成二维码,金额和备注会写进转账链接
if ("TRANSFER_QR".equals(order.getPaymentMode())) {
String transferUrl = buildTransferUrl(
payDemoProperties.getPayeeUserId(),
order.getPaymentAmount(),
order.getPaymentMemo());
byte[] png = QrCodeUtil.generatePng(transferUrl, 300, 300);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.noStore())
.body(png);
}
// 3. 固定收款码模式:直接返回配置好的收款码图片,用户必须按页面显示金额手动付款
Path qrPath = Paths.get(payDemoProperties.getCollectQrPath()).toAbsolutePath().normalize();
if (!Files.exists(qrPath) || !Files.isRegularFile(qrPath)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.noStore())
.body(Files.readAllBytes(qrPath));
}
生成转账二维码链接
转账二维码的关键是把收款用户、金额、业务订单号备注写入链接。后面匹配流水时,就靠备注把支付宝流水和本地订单关联起来。
但这个模式一定要写清风险:它不是支付宝官方收银台,本质上是用 alipays://platformapi/startapp 或 render.alipay.com 包装后拉起支付宝转账页。用户扫码后可能出现风险提示、拦截、金额或备注没有稳定带入、要求用户手动确认等情况。这些表现和账号、金额、频率、备注内容、设备环境、支付宝客户端策略有关,不能承诺稳定。
/**
* 构造支付宝转账二维码内容,并按“组装转账 Scheme、编码 Scheme、套入 render 链接”的顺序生成二维码 URL。
*
* @param userId 收款支付宝用户 ID
* @param amount 转账金额
* @param memo 转账备注,建议使用业务订单号
* @return 可直接生成二维码的转账链接
*/
private String buildTransferUrl(String userId, BigDecimal amount, String memo)
{
// 1. 组装支付宝转账页参数,memo 是后续到账识别的关键字段
Map<String, String> params = new LinkedHashMap<>();
params.put("appId", "09999988");
params.put("actionType", "toAccount");
params.put("goBack", "NO");
params.put("amount", amount.setScale(2, RoundingMode.HALF_UP).toPlainString());
params.put("userId", userId);
params.put("memo", memo);
// 2. 把 alipays Scheme 包装成 render 链接,再把这个链接转成二维码
String scheme = "alipays://platformapi/startapp?" + toQueryString(params);
return "https://render.alipay.com/p/s/i?scheme=" + urlEncode(scheme);
}
/**
* 拼接 URL 查询参数,并按“保留参数顺序、逐个编码、使用 & 连接”的顺序避免特殊字符破坏链接。
*
* @param params 参数集合
* @return 查询字符串
*/
private String toQueryString(Map<String, String> params)
{
// 1. 支付宝 Scheme 中的金额、用户和备注都需要编码后再拼接
return params.entrySet().stream()
.map(item -> urlEncode(item.getKey()) + "=" + urlEncode(item.getValue()))
.collect(Collectors.joining("&"));
}
/**
* 对 URL 参数进行 UTF-8 编码,并把编码异常统一转换为运行时异常。
*
* @param value 原始参数
* @return 编码后的参数
*/
private String urlEncode(String value)
{
// 1. 备注里可能包含中文或特殊字符,必须编码后才能稳定放进二维码
try {
return URLEncoder.encode(value == null ? "" : value, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("当前运行环境不支持 UTF-8 编码", e);
}
}
确认支付
支付确认不是前端说“我扫完了”就算成功,而是后端从支付宝账务流水里找到一笔能对应本地订单的收入。
固定收款码确认逻辑
固定收款码是同一张图片,支付宝流水里通常没有业务订单号,所以后端只能这样判断:
这是一笔收入
并且流水金额 == 本地订单 paymentAmount
并且流水发生时间在订单创建和过期时间附近
固定码确认支付靠的是金额和时间窗口。如果同一时间有两笔相同金额收入,就有误匹配风险。
转账二维码确认逻辑
转账二维码是每笔订单动态生成的,链接里带了备注:
memo = 业务订单号
amount = 本地订单 paymentAmount
所以后端确认时可以多校验一个条件:
这是一笔收入
并且流水备注 == 本地订单 paymentMemo
并且流水金额 == 本地订单 paymentAmount
并且流水发生时间在订单创建和过期时间附近
转账二维码比固定收款码更好匹配,是因为它能把业务订单号塞进支付宝流水备注里。但“更好匹配”不等于“更稳定可用”,用户侧拉起转账页可能触发支付宝风控,所以只能把它当成可识别订单的实验方案,不能写成稳定替代官方支付的方案。
轮询支付宝流水
轮询任务只做四件事:查待支付订单、查支付宝流水、匹配流水、幂等更新订单。
/**
* 轮询支付宝账务流水确认支付结果,并按“查询待支付订单、拉取近期流水、按模式匹配、幂等更新状态”的顺序推进支付单。
*/
@Scheduled(fixedDelayString = "${alipay.poll-interval-ms:5000}")
public void pollAndConfirmPaidOrders()
{
// 1. 查询仍在等待支付且未过期的订单,已经支付或过期的订单不再参与匹配
LocalDateTime now = LocalDateTime.now();
List<PayOrder> waitingOrders = payOrderRepository.findWaitingOrders(now);
if (waitingOrders.isEmpty()) {
return;
}
// 2. 查询最近一段时间内的支付宝账务流水,只需要归一化出金额、时间、方向、备注和流水号
LocalDateTime startTime = now.minusMinutes(payDemoProperties.getBillQueryMinutesBack());
List<AlipayBillRecord> bills = alipayBillClient.queryBills(startTime, now);
if (bills.isEmpty()) {
return;
}
// 3. 按订单逐条匹配流水,命中后进入事务内确认支付
for (PayOrder order : waitingOrders) {
for (AlipayBillRecord bill : bills) {
if (!isBillMatchOrder(order, bill)) {
continue;
}
confirmOrderPaid(order, bill);
break;
}
}
}
流水匹配和幂等确认
匹配代码要把两种模式分开写清楚,别混成一个模糊判断。
/**
* 判断支付宝流水是否匹配本地支付单,并按“基础条件、固定码规则、转账码规则”的顺序区分两种确认方式。
*
* @param order 待支付订单
* @param bill 支付宝账务流水
* @return 流水可以确认该订单时返回 true
*/
private boolean isBillMatchOrder(PayOrder order, AlipayBillRecord bill)
{
// 1. 两种模式都必须先满足收入方向、金额一致和时间窗口这三个基础条件
if (!isBaseBillMatch(order, bill)) {
return false;
}
// 2. 固定码模式没有业务订单号,只能使用基础条件确认
if ("FIXED_QR".equals(order.getPaymentMode())) {
return true;
}
// 3. 转账码模式必须额外校验备注订单号,避免同金额转账误确认
if ("TRANSFER_QR".equals(order.getPaymentMode())) {
return StringUtils.hasText(bill.getMemo())
&& bill.getMemo().trim().equals(order.getPaymentMemo());
}
// 4. 未知支付模式一律不确认,避免错误配置导致误入账
return false;
}
/**
* 校验两种支付模式共用的基础条件,并按“收入方向、时间窗口、金额一致”的顺序过滤明显不匹配的流水。
*
* @param order 待支付订单
* @param bill 支付宝账务流水
* @return 基础条件全部满足时返回 true
*/
private boolean isBaseBillMatch(PayOrder order, AlipayBillRecord bill)
{
// 1. 只接受收入流水;方向字段为空时保守继续交给金额和时间判断
String direction = bill.getDirection();
if (StringUtils.hasText(direction)
&& !direction.contains("收入")
&& !"IN".equalsIgnoreCase(direction)
&& !"1".equals(direction)) {
return false;
}
// 2. 流水时间必须落在订单创建到过期后的短兜底窗口内,避免历史收入误匹配
LocalDateTime transTime = bill.getTransTime();
if (transTime != null
&& (transTime.isBefore(order.getCreatedAt().minusSeconds(10))
|| transTime.isAfter(order.getExpireAt().plusMinutes(1)))) {
return false;
}
// 3. 金额必须精确到分一致,付款金额填错不能自动确认
return bill.getAmount() != null
&& bill.getAmount().compareTo(order.getPaymentAmount()) == 0;
}
匹配成功后不能直接无脑更新订单。正式落地至少要保证两点:
- 同一条支付宝流水只能消费一次。
- 订单只能从
WAITING更新到PAID一次。
/**
* 幂等确认订单已支付,并按“消费流水唯一键、条件更新订单状态、提交事务”的顺序防止重复确认和重复入账。
*
* @param order 待支付订单
* @param bill 匹配到的支付宝流水
*/
@Transactional(rollbackFor = Exception.class)
public void confirmOrderPaid(PayOrder order, AlipayBillRecord bill)
{
// 1. 先插入流水消费记录,唯一键冲突说明这条流水已经确认过其他订单
String billKey = buildBillKey(bill);
boolean consumed = billConsumeRepository.insertIgnore(billKey, order.getTradeNo());
if (!consumed) {
return;
}
// 2. 只允许 WAITING 订单更新为 PAID,状态已变化则回滚本次流水消费
int updated = payOrderRepository.markPaidIfWaiting(
order.getTradeNo(),
bill.getAlipayTradeNo(),
bill.getTransTime() == null ? LocalDateTime.now() : bill.getTransTime());
if (updated != 1) {
throw new IllegalStateException("支付单状态已变化,本次确认回滚");
}
}
/**
* 构造支付宝流水去重键,并按“支付宝交易号、账务流水号、金额时间兜底”的顺序生成唯一标识。
*
* @param bill 支付宝账务流水
* @return 流水唯一键
*/
private String buildBillKey(AlipayBillRecord bill)
{
// 1. 优先使用支付宝侧稳定编号,缺失时才用金额和时间兜底
if (StringUtils.hasText(bill.getAlipayTradeNo())) {
return bill.getAlipayTradeNo();
}
if (StringUtils.hasText(bill.getAccountLogId())) {
return bill.getAccountLogId();
}
return bill.getAmount() + "@" + bill.getTransTime();
}
