返回功能模块设计实现
功能模块设计实现 / 发布 2026-06-16 01:08 / 更新 2026-06-21 17:01

支付宝个人码支付接入

整理个人资质场景下支付宝开放平台应用准备、密钥配置、经营码获取,以及本地订单和账务流水轮询确认支付的实现思路。

支付宝支付码支付个人收款功能模块设计实现
支付宝个人码支付接入

个人网站接入支付解决方案

支付宝开放平台准备

打开开放平台并登录

进入 支付宝开放平台,先完成登录。

支付宝开放平台首页

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

支付宝开放平台扫码登录

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

填写手机号加入支付宝开发者平台

创建应用

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

选择网页移动应用入口

点击创建应用。

支付宝开放平台创建应用入口

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

填写支付宝应用基础信息

确认支付宝应用创建信息

确认支付宝应用配置

支付宝应用创建完成

下载密钥工具

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

进入支付宝应用开发配置

点击下载密钥工具。

下载支付宝开放平台密钥工具

安装密钥工具。

安装支付宝密钥工具

打开密钥工具。

打开支付宝密钥工具

点击生成密钥。

生成支付宝应用密钥对

上传应用公钥并下载支付宝公钥

复制“应用公钥”到支付宝开放平台,同时妥善保存“应用私钥”。

应用私钥 只能保存在服务端或安全配置中,不要提交到前端仓库,也不要写进公开文档。

复制支付宝应用公钥

点击确认上传应用公钥。

上传支付宝应用公钥

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

完成支付宝身份校验

下载并保存支付宝公钥。

下载支付宝公钥

提交应用审核。

提交支付宝应用审核

相关接口通常需要审核通过后才能使用。

获取配置参数

AppID

在应用详情页获取 AppID,后端配置时使用占位符 <ALIPAY_APP_ID>

支付宝 AppID 位置

收款支付宝用户 ID

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

收款支付宝用户 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_QRTRANSFER_QR
payment_memo 转账备注,转账码模式一般等于业务订单号
status WAITINGPAIDEXPIRED
created_atexpire_atpaid_at 创建、过期和支付完成时间
alipay_trade_no 匹配到的支付宝流水或交易标识

流水消费表建议至少保存:

字段 说明
bill_key 支付宝流水唯一键,建议加唯一索引
trade_no 被这条流水确认的本地支付单号
created_at 消费流水时间

创建支付单

创建支付单时,固定收款码和转账二维码对 paymentAmount 的处理不同:

  • FIXED_QR:支付宝侧没有订单号,建议对同金额待支付订单做金额偏移,例如 9.909.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/startapprender.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;
}

匹配成功后不能直接无脑更新订单。正式落地至少要保证两点:

  1. 同一条支付宝流水只能消费一次。
  2. 订单只能从 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();
}