微信生态接入与微信支付 APIv3 实战:从公众号授权到代理商/直连双模式支付

微信生态接入与微信支付 APIv3 实战:从公众号授权到代理商/直连双模式支付

本文基于 wx-service(线上营业厅)真实项目整理,覆盖 公众号网页授权 / openId 管理 / JS-SDK 签名 / 微信支付 APIv3 双模式下单 / 支付回调幂等 / 对账单下载解析与自动对账 全链路。

技术栈:Spring Boot 3.2.5 · Java 17 · MyBatis-Plus 3.5.5 · Redis(Jedis) · PostgreSQL · wechatpay-java 0.2.17

目录

  • [微信生态接入与微信支付 APIv3 实战:从公众号授权到代理商/直连双模式支付](#微信生态接入与微信支付 APIv3 实战:从公众号授权到代理商/直连双模式支付 "#%E5%BE%AE%E4%BF%A1%E7%94%9F%E6%80%81%E6%8E%A5%E5%85%A5%E4%B8%8E%E5%BE%AE%E4%BF%A1%E6%94%AF%E4%BB%98-apiv3-%E5%AE%9E%E6%88%98%E4%BB%8E%E5%85%AC%E4%BC%97%E5%8F%B7%E6%8E%88%E6%9D%83%E5%88%B0%E4%BB%A3%E7%90%86%E5%95%86%E7%9B%B4%E8%BF%9E%E5%8F%8C%E6%A8%A1%E5%BC%8F%E6%94%AF%E4%BB%98")
    • 目录
    • 一、背景:为什么要做「双模式支付」
    • 二、整体架构
    • [三、微信支付 SDK 初始化:公钥模式](#三、微信支付 SDK 初始化:公钥模式 "#%E4%B8%89%E5%BE%AE%E4%BF%A1%E6%94%AF%E4%BB%98-sdk-%E5%88%9D%E5%A7%8B%E5%8C%96%E5%85%AC%E9%92%A5%E6%A8%A1%E5%BC%8F")
    • [四、策略模式:一行配置切换代理商 / 直连](#四、策略模式:一行配置切换代理商 / 直连 "#%E5%9B%9B%E7%AD%96%E7%95%A5%E6%A8%A1%E5%BC%8F%E4%B8%80%E8%A1%8C%E9%85%8D%E7%BD%AE%E5%88%87%E6%8D%A2%E4%BB%A3%E7%90%86%E5%95%86--%E7%9B%B4%E8%BF%9E")
      • [4.1 策略接口](#4.1 策略接口 "#41-%E7%AD%96%E7%95%A5%E6%8E%A5%E5%8F%A3")
      • [4.2 两个实现类:差异只在报文](#4.2 两个实现类:差异只在报文 "#42-%E4%B8%A4%E4%B8%AA%E5%AE%9E%E7%8E%B0%E7%B1%BB%E5%B7%AE%E5%BC%82%E5%8F%AA%E5%9C%A8%E6%8A%A5%E6%96%87")
      • [4.3 条件装配原理](#4.3 条件装配原理 "#43-%E6%9D%A1%E4%BB%B6%E8%A3%85%E9%85%8D%E5%8E%9F%E7%90%86")
    • 五、下单链路
    • 六、支付回调:验签、解密、幂等
      • [6.1 回调处理](#6.1 回调处理 "#61-%E5%9B%9E%E8%B0%83%E5%A4%84%E7%90%86")
      • [6.2 幂等与状态机](#6.2 幂等与状态机 "#62-%E5%B9%82%E7%AD%89%E4%B8%8E%E7%8A%B6%E6%80%81%E6%9C%BA")
      • [6.3 兜底:定时查单](#6.3 兜底:定时查单 "#63-%E5%85%9C%E5%BA%95%E5%AE%9A%E6%97%B6%E6%9F%A5%E5%8D%95")
    • 七、对账单下载与自动对账
    • 八、公众号侧能力接入
      • [8.1 网页授权换 openId](#8.1 网页授权换 openId "#81-%E7%BD%91%E9%A1%B5%E6%8E%88%E6%9D%83%E6%8D%A2-openid")
      • [8.2 稳定版 access_token:缓存 + 分布式锁防击穿](#8.2 稳定版 access_token:缓存 + 分布式锁防击穿 "#82-%E7%A8%B3%E5%AE%9A%E7%89%88-access_token%E7%BC%93%E5%AD%98--%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E9%98%B2%E5%87%BB%E7%A9%BF")
      • [8.3 JS-SDK 签名](#8.3 JS-SDK 签名 "#83-js-sdk-%E7%AD%BE%E5%90%8D")
      • [8.4 关注状态同步](#8.4 关注状态同步 "#84-%E5%85%B3%E6%B3%A8%E7%8A%B6%E6%80%81%E5%90%8C%E6%AD%A5")
    • 九、踩坑记录
    • 十、小结

一、背景:为什么要做「双模式支付」

水务缴费场景在不同地区落地时,微信支付的商户资质是完全不一样的:

模式 商户形态 适用场景
代理商模式(服务商 + 子商户) 服务商 spMchid 统一进件,各水司作为子商户 subMchid 集团/平台统一接入微信支付,为多个水司提供缴费能力
直连模式(普通商户) 水司自己申请微信支付商户号 水司自有资质,独立收款

两种模式的业务语义完全相同 ,差异只存在于微信支付 API 的报文结构:

  • 代理商:spAppid + spMchid + subAppid + subMchid,支付人用 subOpenid,SDK 走 partnerpayments 包;
  • 直连:appid + mchid,支付人用 openid,SDK 走 payments 包。

如果不用设计模式,代码里会到处都是 if (payMode == 1) {...} else {...},难以维护。因此我们选择了 策略模式 + Spring 条件装配,用一行配置完成切换,业务代码零感知。

yaml 复制代码
wx:
  pay-mode: 1   # 1=代理商模式, 2=直连模式

二、整体架构

flowchart TB subgraph FE[&#34;前端&#34;] MINI[&#34;微信小程序 / 公众号 H5&#34;] end subgraph APP[&#34;wx-service&#34;] PC[&#34;PayController<br/>/business/pay/*&#34;] PS[&#34;PayServiceImpl<br/>业务编排&#34;] STRAT{&#34;PayStrategy<br/>策略接口&#34;} P[&#34;PartnerPayStrategy<br/>代理商 @ConditionalOnProperty=1&#34;] D[&#34;DirectPayStrategy<br/>直连 @ConditionalOnProperty=2&#34;] WXS[&#34;WxServiceImpl<br/>token / ticket / openId&#34;] SCH[&#34;OrderSyncSchedule<br/>定时任务&#34;] end WXA[&#34;微信公众平台<br/>cgi-bin/token · jsapi ticket&#34;] WXP[微信支付 APIv3] GW[网关 / 身份认证服务] IBCS[营收系统 IBCS] DB[(PostgreSQL)] RD[(Redis)] MINI -->|下单/关单/JSAPI初始化| PC PC --> PS PS --> STRAT STRAT --> P STRAT --> D P --> WXP D --> WXP PS --> WXS WXS --> WXA PS --> DB PS --> RD SCH --> PS PS -->|支付成功后核销| GW SCH -->|失败重试通知| GW GW --> IBCS WXP -->|异步回调 /business/pay/notify| PC SCH -->|8:00 自动对账| WXP

分层职责:

  • PayController:只做参数接收与日志,不做业务;
  • PayServiceImpl:编排(校验账单 → 落库订单 → 调用策略 → 更新状态 → 通知营收);
  • PayStrategy:纯粹的微信支付 API 差异隔离层;
  • WxServiceImpl:微信公众平台能力(token、ticket、openId、关注列表)。

三、微信支付 SDK 初始化:公钥模式

wechatpay-java 提供了两种 Config 实现:

  • RSAAutoCertificateConfig:自动下载并轮换微信支付平台证书(需请求平台证书接口);
  • RSAPublicKeyConfig:使用微信支付公钥 + 公钥 ID,无需下载平台证书,适合生产环境网络受限或希望降低外部依赖的场景。

本项目采用公钥模式 ,在容器启动时构建单例 Config Bean:

java 复制代码
@Configuration
@RequiredArgsConstructor
public class WechatPayConfig {

    private final WxProperties wxProperties;

    @Bean
    public Config config() {
        return new RSAPublicKeyConfig.Builder()
                .merchantId(wxProperties.getMerchantId())
                .privateKeyFromPath(wxProperties.getPrivateKeyPath())   // 商户 API 私钥 apiclient_key.pem
                .publicKeyFromPath(wxProperties.getPublicKeyPath())     // 微信支付公钥 pub_key.pem
                .publicKeyId(wxProperties.getPublicKeyId())             // 公钥 ID:PUB_KEY_ID_xxx
                .merchantSerialNumber(wxProperties.getMerchantSerialNumber())
                .apiV3Key(wxProperties.getApiV3Key())                   // APIv3 密钥,用于解密回调
                .build();
    }
}

对应的配置项(示例值均为占位符,实际部署请替换为真实值):

yaml 复制代码
wx:
  spAppId: wx0000000000000001            # 服务商 AppId
  appId: wx0000000000000002              # 公众 / 子商户 AppId
  appSecret: <your-app-secret>
  merchantId: "1900000001"               # 商户号(代理商模式 = 服务商号)
  merchantSubId: "1900000002"            # 子商户号(直连模式不使用)
  merchantSerialNumber: <商户证书序列号>
  privateKeyPath: /data/wechat/apiclient_key.pem   # 商户 API 私钥
  publicKeyPath: /data/wechat/pub_key.pem          # 微信支付公钥
  publicKeyId: PUB_KEY_ID_<公钥ID>
  apiV3Key: <your-apiv3-key>
  notifyUrl: https://<your-domain>/business/pay/notify
  billFilePath: /data/bill                          # 对账单落盘目录

以上证书、密钥、序列号等全部属于敏感信息,生产环境建议通过配置中心或环境变量注入,不要硬编码进代码仓库。


四、策略模式:一行配置切换代理商 / 直连

4.1 策略接口

把两种模式共有的支付动作抽象成接口:

java 复制代码
public interface PayStrategy {

    /**
     * 预支付下单
     * 返回 SDK 的 PrepayWithRequestPaymentResponse(含 prepay_id 与 JSAPI 签名参数)
     * 两种模式的返回类型一致,因此统一声明为 Object,由调用方直接透传给前端
     */
    Object prePay(PrepayParam param);

    /** 关闭订单 */
    boolean closeOrder(String wxOrderId);

    /** 查询订单状态(交易状态 + 支付完成时间) */
    PayNotifyResult queryOrderState(String wxOrderId);

    /** 下载对账单,返回 GZIP 输入流 */
    InputStream downloadBill(String billDate);

    /** 解析并验签支付回调 */
    PayNotifyResult parseNotify(HttpServletRequest request);
}

统一入参 / 出参对象,屏蔽两种模式的字段差异:

java 复制代码
@Data
public class PrepayParam {
    private BigDecimal amount;    // 支付金额(元)
    private String wxOrderId;     // 微信订单号(本地生成,全局唯一)
    private String openId;        // 用户 openId
    private String customerNo;    // 用户编号
}

@Data
public class PayNotifyResult {
    private String outTradeNo;    // 商户订单号
    private String tradeState;    // 交易状态
    private String successTime;   // 支付完成时间(RFC3339)

    public boolean isSuccess() {
        return "SUCCESS".equals(tradeState);
    }
}

4.2 两个实现类:差异只在报文

代理商模式 (注意:服务商参数 + SubOpenid):

java 复制代码
@Slf4j
@Component
@ConditionalOnProperty(name = "wx.pay-mode", havingValue = "1", matchIfMissing = true)
public class PartnerPayStrategy implements PayStrategy {

    private final JsapiServiceExtension service;          // partnerpayments.jsapi 包
    private final BillDownloadServiceExtension billDownService;
    private final NotificationParser parser;

    public PartnerPayStrategy(Config config, WxProperties wxProperties) {
        this.wxProperties = wxProperties;
        this.service = new JsapiServiceExtension.Builder().config(config).build();
        this.billDownService = new BillDownloadServiceExtension.Builder().config(config).build();
        this.parser = new NotificationParser((NotificationConfig) config);
    }

    @Override
    public Object prePay(PrepayParam param) {
        PrepayRequest request = new PrepayRequest();
        request.setSpAppid(wxProperties.getSpAppId());      // 服务商 AppId
        request.setSpMchid(wxProperties.getMerchantId());   // 服务商商户号
        request.setSubAppid(wxProperties.getAppId());       // 子商户 AppId
        request.setSubMchid(wxProperties.getMerchantSubId());// 子商户商户号
        request.setDescription("【" + param.getCustomerNo() + "水费】");
        request.setAttach(BusinessConstant.wxPayKey + param.getCustomerNo()); // 回填用户编号
        request.setOutTradeNo(param.getWxOrderId());

        Amount amount = new Amount();
        amount.setTotal(param.getAmount().multiply(new BigDecimal("100")).intValue()); // 元 → 分
        amount.setCurrency("CNY");
        request.setAmount(amount);

        Payer payer = new Payer();
        payer.setSubOpenid(param.getOpenId());              // 子商户下的 openId
        request.setPayer(payer);
        request.setNotifyUrl(wxProperties.getNotifyUrl());

        // 直接返回带签名的 JSAPI 拉起参数
        return service.prepayWithRequestPayment(request, wxProperties.getAppId());
    }
    // closeOrder / queryOrderState / downloadBill / parseNotify 略
}

直连模式 (普通商户参数 + Openid,SDK 走 payments.jsapi 包):

java 复制代码
@Slf4j
@Component
@ConditionalOnProperty(name = "wx.pay-mode", havingValue = "2")
public class DirectPayStrategy implements PayStrategy {

    private final WxProperties wxProperties;
    private final JsapiServiceExtension service;          // payments.jsapi 包
    private final BillDownloadServiceExtension billDownService;
    private final NotificationParser parser;

    public DirectPayStrategy(Config config, WxProperties wxProperties) {
        this.wxProperties = wxProperties;
        this.service = new JsapiServiceExtension.Builder().config(config).build();
        this.billDownService = new BillDownloadServiceExtension.Builder().config(config).build();
        this.parser = new NotificationParser((NotificationConfig) config);
    }

    @Override
    public Object prePay(PrepayParam param) {
        PrepayRequest request = new PrepayRequest();
        request.setAppid(wxProperties.getAppId());
        request.setMchid(wxProperties.getMerchantId());
        request.setDescription("【" + param.getCustomerNo() + "水费】");
        request.setAttach(BusinessConstant.wxPayKey + param.getCustomerNo());
        request.setOutTradeNo(param.getWxOrderId());

        Amount amount = new Amount();
        amount.setTotal(param.getAmount().multiply(new BigDecimal("100")).intValue());
        amount.setCurrency("CNY");
        request.setAmount(amount);

        Payer payer = new Payer();
        payer.setOpenid(param.getOpenId());        // 直连用 openid
        request.setPayer(payer);
        request.setNotifyUrl(wxProperties.getNotifyUrl());

        return service.prepayWithRequestPayment(request);
    }
}

4.3 条件装配原理

flowchart TD A[&#34;Spring 启动扫描 @Component&#34;] --> B{&#34;读取配置 wx.pay-mode&#34;} B -->|&#34;1 或未配置&#34;| C[&#34;注册 PartnerPayStrategy<br/>matchIfMissing=true&#34;] B -->|2| D[&#34;注册 DirectPayStrategy&#34;] B -->|其他| E[&#34;两个 Bean 都不注册<br/>启动报错,配置错误快速暴露&#34;] C --> F[&#34;PayServiceImpl 构造器注入<br/>private final PayStrategy payStrategy&#34;] D --> F F --> G[&#34;调用 payStrategy.prePay(param)<br/>运行时无 if/else&#34;]

PayServiceImpl 用构造器注入 单个 PayStrategy,Spring 只会装配到唯一存活的实现:

java 复制代码
public PayServiceImpl(WxProperties wxProperties, PayStrategy payStrategy) {
    this.wxProperties = wxProperties;
    this.payStrategy = payStrategy;
}

注意 matchIfMissing = true 让代理商模式成为默认兜底 ,避免老现场升级后因为漏配 pay-mode 而启动失败。


五、下单链路

sequenceDiagram autonumber participant U as 小程序/H5 participant C as PayController participant S as PayServiceImpl participant R as Redis participant G as 网关(账单服务) participant DB as PostgreSQL participant ST as PayStrategy participant WX as 微信支付 APIv3 U->>C: GET /business/pay/prePay?amount&customerNo&phone C->>S: prePay(amount, customerNo, phone) S->>R: SysUtil.getOpenId(phone)<br/>(内部用 ticket 拼 key 取 openId) S->>G: 查询用户账单 CustomerInfoVO G-->>S: 应缴金额 / 余额 / billGuid S->>S: 校验:金额 > 0 且 ≥ 应缴金额 S->>DB: 落本地订单(待支付) S->>ST: prePay(PrepayParam) ST->>WX: JSAPI 下单 prepayWithRequestPayment WX-->>ST: prepay_id + JSAPI 签名参数 ST-->>S: 拉起支付所需参数 S-->>U: 返回参数,前端 wx.requestPayment 拉起收银台

核心编排代码(含金额二次校验,防止前端篡改):

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public Object prePay(String amount, String customerNo, String phone) {
    if (StringUtils.isBlank(amount)) {
        throw new BusinessException("缴费金额不能为空");
    }
    String openId = SysUtil.getOpenId(phone);

    // 1. 校验账单及金额
    CustomerInfoVO customerInfoVO = iCustomerBindService.getCustomerInfo(customerNo);
    if (customerInfoVO == null) {
        throw new BusinessException("未找到该用户账单");
    }
    if (StringUtils.isNotBlank(customerInfoVO.getCurrentBillAmount())) {
        BigDecimal need = new BigDecimal(customerInfoVO.getCurrentBillAmount())
                .subtract(new BigDecimal(customerInfoVO.getCurrentBalance()));
        if (new BigDecimal(amount).compareTo(need) < 0 || new BigDecimal(amount).compareTo(BigDecimal.ZERO) <= 0) {
            throw new BusinessException("缴费金额不能为零且不允许小于账单金额");
        }
    }

    // 2. 创建本地订单(wxOrderId 即 outTradeNo,本地生成保证唯一)
    Order order = createOrder(customerInfoVO.getBillGuid(), amount, customerNo, phone, openId);
    orderService.save(order);

    // 3. 委托策略预下单
    try {
        PrepayParam param = new PrepayParam();
        param.setAmount(new BigDecimal(amount));
        param.setWxOrderId(order.getWxOrderId());
        param.setOpenId(openId);
        param.setCustomerNo(customerNo);
        return payStrategy.prePay(param);
    } catch (Exception e) {
        log.error("微信下单失败: {}", e.getMessage(), e);
        throw new BusinessException("出错了!请稍候尝试");
    }
}

两个关键设计:

  1. 金额单位换算只发生一次 :业务层用「元」,进入策略层立刻 × 100 转「分」,与微信 API 对齐;
  2. attach 回填用户编号 :格式为 <业务前缀>{customerNo},在对账单解析时可以直接从「商户数据包」字段还原用户编号,无需再查库。

六、支付回调:验签、解密、幂等

6.1 回调处理

微信支付 APIv3 回调是密文 ,必须用 APIv3 密钥解密,并用请求头中的签名验证来源。SDK 的 NotificationParser 一次搞定:

java 复制代码
@Override
public PayNotifyResult parseNotify(HttpServletRequest request) {
    // 1. 收集微信回调的四个签名头
    RequestParam requestParam = new RequestParam.Builder()
            .serialNumber(request.getHeader("Wechatpay-Serial"))
            .nonce(request.getHeader("Wechatpay-Nonce"))
            .signature(request.getHeader("Wechatpay-Signature"))
            .timestamp(request.getHeader("Wechatpay-Timestamp"))
            .body(getRequestBody(request))     // 原始报文,不能先被框架解析消费
            .build();

    // 2. 验签 + AES-GCM 解密,一步到位
    Transaction transaction = parser.parse(requestParam, Transaction.class);

    PayNotifyResult result = new PayNotifyResult();
    result.setOutTradeNo(transaction.getOutTradeNo());
    result.setTradeState(transaction.getTradeState().name());
    result.setSuccessTime(transaction.getSuccessTime());
    return result;
}

private String getRequestBody(HttpServletRequest request) {
    try (BufferedReader reader = request.getReader()) {
        return reader.lines().collect(Collectors.joining(System.lineSeparator()));
    } catch (Exception e) {
        log.error("读取请求体失败", e);
        return null;
    }
}

回调接口无需 ticket:AuthFilter 的语义是「有 ticket 则填充登录上下文,无 ticket 也放行」,因此 /business/pay/notify 不会被拦截;而 /business/pay/dz、/business/user/register 等则显式加入 UrlWhiteList 跳过该逻辑。另外 notifyUrl 必须是公网可达的地址。

6.2 幂等与状态机

微信回调会重复推送(直到你返回成功),定时任务也会主动查单,两条路径可能并发命中同一订单。因此必须做幂等:

sequenceDiagram autonumber participant WX as 微信支付 participant C as PayController participant S as PayServiceImpl participant O as OrderServiceImpl participant R as Redis participant DB as PostgreSQL participant G as 网关→营收IBCS WX->>C: POST /business/pay/notify (密文) C->>S: payNotify(request) S->>S: parseNotify 验签解密 alt tradeState = SUCCESS S->>O: orderPaySuccess(outTradeNo, successTime) O->>R: SETNX pay_lock:{wxOrderId} 5min alt 未拿到锁 O-->>O: 已有线程在处理,直接返回 else 拿到锁 O->>DB: 条件更新 待支付→已支付(幂等) O->>DB: 落 payment_notify 补偿记录 O->>G: 调用核销接口 G-->>O: 成功 / 异常 O->>DB: 更新补偿记录状态 O->>R: 事务提交后释放锁 end end S-->>WX: &#34;SUCCESS&#34;(必须原样返回)

订单更新采用带状态条件的更新 ,天然幂等------第二次执行时 where pay_state = 待支付 匹配不到记录:

java 复制代码
@Async("payNotifyExecutor")
@Transactional(rollbackFor = Exception.class)
@Override
public void orderPaySuccess(String wxOrderId, LocalDateTime payTime) {
    String lockKey = "pay_lock:" + wxOrderId;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(5));
    if (Boolean.FALSE.equals(locked)) {
        log.info("订单:{} 异步核销正在处理中,跳过", wxOrderId);
        return;
    }
    // 关键:把锁的释放延后到事务提交之后,避免「锁已释放但事务未提交」导致重复核销
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            redisTemplate.delete(lockKey);
        }
    });

    PaymentNotify paymentNotify = new PaymentNotify();
    try {
        Order order = getOne(new LambdaQueryWrapper<Order>()
                .eq(Order::getWxOrderId, wxOrderId)
                .eq(Order::getOrderState, OrderEnum.ORDER_STATE_NORMAL.getCode())
                .eq(Order::getPayState, OrderEnum.ORDER_PAY_STATE_OPEN.getCode())); // 幂等条件
        if (order == null) {
            return;   // 已处理过,直接返回
        }
        order.setPayState(OrderEnum.ORDER_PAY_STATE_SUCCESS.getCode());
        order.setPayTime(payTime);
        updateById(order);

        // 组装核销参数:以本地订单为准,金额用元、日期用 yyyyMMdd
        Map<String, Object> param = new HashMap<>();
        param.put("customerNo", order.getCustomerNo());
        param.put("billId", order.getBillGuid());
        param.put("payMoney", order.getBillAmount());
        param.put("thirdBillNo", order.getWxOrderId());
        param.put("chargeMode", "6");
        param.put("username", wxProperties.getUserName());
        param.put("userguid", wxProperties.getUserGuid());
        param.put("tenementguid", wxProperties.getTenementId());
        param.put("bankDate", payTime.format(DateTimeFormatter.ofPattern("yyyyMMdd")));

        // 落补偿记录后再通知营收,失败可被定时任务重试
        paymentNotify.setId(IdUtil.simpleUUID());
        paymentNotify.setOrderId(order.getId());
        paymentNotify.setNotifyParam(JSONObject.toJSONString(param));
        paymentNotify.setNotifyIbcsState(PaymentNotifyEnum.NOTIFY_INIT.getCode());
        paymentNotifyService.save(paymentNotify);

        gatewayService.postIBCSNotify(null, BusinessEnum.IBCS_REAL_PAY.getApiSecret(), param, String.class);
        paymentNotify.setNotifyIbcsState(PaymentNotifyEnum.NOTIFY_SUCCESS.getCode());
        paymentNotifyService.updateById(paymentNotify);
    } catch (Exception e) {
        log.error("订单:{} 异步核销失败!", wxOrderId, e);
        paymentNotify.setNotifyIbcsState(PaymentNotifyEnum.NOTIFY_WAIT.getCode()); // 交由定时任务重试
        paymentNotifyService.updateById(paymentNotify);
    }
}

三重幂等保障:

层 手段 作用
分布式锁 SETNX pay_lock:{orderId} 5 分钟 防止回调与定时任务并发处理同一订单
状态条件更新 where pay_state = 待支付 已处理的订单查不到,天然幂等
补偿记录表 payment_notify + NOTIFY_WAIT 通知营收失败可重试,不丢单

6.3 兜底:定时查单

微信回调可能因网络原因根本没到达。因此每 5 分钟扫描一次「待支付且已下单超过 2 分钟、24 小时内」的订单,主动向微信查单补齐状态:

java 复制代码
@Scheduled(cron = "0 0/5 * * * ?")
public void execute() {
    LocalDateTime now = LocalDateTime.now();
    List<Order> list = orderService.list(new LambdaQueryWrapper<Order>()
            .eq(Order::getPayState, OrderEnum.ORDER_PAY_STATE_OPEN.getCode())
            .eq(Order::getOrderState, OrderEnum.ORDER_STATE_NORMAL.getCode())
            .gt(Order::getOpenTime, now.minusHours(24))
            .lt(Order::getOpenTime, now.minusMinutes(2))
            .last("LIMIT 1000"));   // 分批处理
    for (Order order : list) {
        payService.syncOrderState(order);
    }
}

syncOrderState 只做状态收敛,复用同一套幂等更新逻辑:

java 复制代码
@Override
public void syncOrderState(Order order) {
    PayNotifyResult result = payStrategy.queryOrderState(order.getWxOrderId());
    if (Transaction.TradeStateEnum.SUCCESS.name().equals(result.getTradeState())) {
        orderService.orderPaySuccess(order.getWxOrderId(), WxTimeUtil.parseWxTime(result.getSuccessTime()));
    } else if (Transaction.TradeStateEnum.CLOSED.name().equals(result.getTradeState())) {
        orderService.orderClose(order.getWxOrderId());
    }
}

时间字段解析踩坑:微信返回 successTime 是 RFC3339 (如 2026-09-29T15:20:31+08:00),直接 LocalDateTime.parse 会抛异常。统一用 OffsetDateTime 解析后再转 LocalDateTime:

java 复制代码
public static LocalDateTime parseWxTime(String successTime) {
    if (successTime == null || successTime.isEmpty()) return null;
    return OffsetDateTime.parse(successTime).toLocalDateTime();
}

七、对账单下载与自动对账

对账是资金安全的最后一道闸门:不信任回调,以微信账单为准。

flowchart TD A[&#34;定时任务 每天 8:00&#34;] --> B[&#34;downloadBill(昨日日期, GZIP)&#34;] B --> C[&#34;BillDownloadServiceExtension<br/>getTradeBill → DigestBillEntity&#34;] C --> D[&#34;落盘保存 CSV&#34;] D --> E[&#34;OpenCSV 解析<br/>首行表头 → 列名索引映射&#34;] E --> F[&#34;逐行解析交易明细<br/>从商户数据包还原 customerNo&#34;] F --> G[&#34;用商户订单号反查本地订单<br/>补齐 billGuid&#34;] G --> H[汇总总金额 + 明细列表] H --> I[POST 网关 / IBCS_DZ 上传营收系统] I --> J{对账成功?} J -->|是| K[完成] J -->|否| L[记录异常日志告警]

下载与解析:

java 复制代码
@Override
public InputStream downloadBill(String billDate) {
    GetTradeBillRequest request = new GetTradeBillRequest();
    request.setBillDate(billDate);            // yyyy-MM-dd
    request.setBillType(BillType.SUCCESS);    // 只取成功账单
    request.setTarType(TarType.GZIP);         // GZIP 压缩,省流量
    request.setSubMchid(wxProperties.getMerchantSubId());  // 代理商模式必填;直连模式不设置
    DigestBillEntity billEntity = billDownService.getTradeBill(request);
    return billEntity.getInputStream();
}

解析 CSV 时有两个「微信特色」要注意:

java 复制代码
// 1) 表头带 BOM/不换行空格,必须清洗后再建索引
private static final Pattern SPECIAL_SPACE_PATTERN = Pattern.compile("[\\uFEFF\\u00A0\\u200B\\s]+");

private Map<String, Integer> buildHeaderIndexMap(String[] headerLine) {
    Map<String, Integer> indexMap = new HashMap<>(headerLine.length);
    for (int i = 0; i < headerLine.length; i++) {
        String cleaned = SPECIAL_SPACE_PATTERN.matcher(headerLine[i]).replaceAll("").toLowerCase().trim();
        if (!cleaned.isEmpty()) indexMap.put(cleaned, i);
    }
    return indexMap;   // key = 列名(去空白、小写),value = 列下标
}

// 2) 每个字段值用反引号 ` 包裹,需去掉;商户数据包形如:`<业务前缀>1000123`
String attch = getValue(line, headerIndexMap, "商户数据包");
detail.setCustomer_no(attch.split("\\|")[0].replaceAll(BusinessConstant.wxPayKey, ""));

用列名索引映射而不是硬编码下标,可以避免微信账单列顺序调整时解析错位。

自动对账还处理了现实约束:当天账单不能对、昨日账单要等 8:10 定时任务跑完才能对,否则会拿到不完整数据。

java 复制代码
if (date1.isEqual(LocalDate.now()) || date1.isAfter(LocalDate.now())) {
    return "请选择历史账单对账";
}
if (date1.isEqual(LocalDate.now().minusDays(1))
        && LocalDateTime.now().isBefore(LocalDateTime.now().withHour(8).withMinute(10).withSecond(0))) {
    return "前一天账单,请等待8:10定时任务结束后再进行对账!";
}

八、公众号侧能力接入

支付之外,营业厅还需要公众号的网页授权、JS-SDK、关注状态 能力,统一收敛在 WxServiceImpl。

8.1 网页授权换 openId

前端拿到 code 后回调后端,换取 openid:

java 复制代码
@Override
public String getOpenId(String code) {
    String url = "https://api.weixin.qq.com/sns/oauth2/access_token"
            + "?grant_type=authorization_code"
            + "&appid=" + wxProperties.getAppId()
            + "&secret=" + wxProperties.getAppSecret()
            + "&code=" + code;
    ResponseEntity<String> res = restTemplate.getForEntity(url, String.class);
    if (res.getStatusCode().is2xxSuccessful()) {
        return JSONObject.parseObject(res.getBody(), WxAccessTokenVO.class).getOpenid();
    }
    return null;
}

拿到 openId 后,通过 Dubbo 换取平台 ticket,并以 openId:{ticket}:{phone} 为 key 存入 Redis,供后续业务(下单取 openId)使用:

java 复制代码
public String getTicket(String openId, String phone, String tenementId) {
    ResultDTO<?> res = oauthSocialServiceDubbo.getTokenByOpenid(openId, phone, tenementId);
    GatewayLoginVO loginVO = JSONObject.parseObject(JSONObject.toJSONString(res.getResult()), GatewayLoginVO.class);
    RedisUtil.set(String.format(BusinessConstant.openIdKey, loginVO.getTicket(), phone),
                  openId, loginVO.getExpiresIn() / 1000);
    return loginVO.getTicket();
}

缓存 key 里带上 phone 维度,解决「同一浏览器 / 同一 ticket 切换手机号登录」时 openId 串号的问题。

8.2 稳定版 access_token:缓存 + 分布式锁防击穿

access_token 全局唯一且有配额限制,不能用普通 token 接口频繁刷新 (会互相踢掉)。这里用微信的 stable_token 接口,并叠加 Redis 分布式锁防止缓存过期瞬间的并发穿透:

java 复制代码
@Override
public String getStableAccessToken() {
    String cacheKey = BusinessConstant.wxStableKey + wxProperties.getAppId();
    String lockKey = TOKEN_LOCK_PREFIX + wxProperties.getAppId();

    // 1. 缓存命中直接返回
    if (RedisUtil.hasKey(cacheKey)) return (String) RedisUtil.get(cacheKey);

    // 2. 竞争分布式锁(最多重试 5 次,每次 100ms)
    boolean locked = false;
    for (int i = 0; i < LOCK_MAX_RETRIES && !locked; i++) {
        locked = RedisUtil.setIfAbsent(lockKey, "1", LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
        if (!locked) {
            if (RedisUtil.hasKey(cacheKey)) return (String) RedisUtil.get(cacheKey); // 别人已刷新
            Thread.sleep(LOCK_RETRY_INTERVAL_MS);
        }
    }
    if (!locked) throw new BusinessException("系统繁忙,请稍候尝试");

    try {
        // 3. 双重检查
        if (RedisUtil.hasKey(cacheKey)) return (String) RedisUtil.get(cacheKey);

        // 4. 调微信 stable_token
        JSONObject body = new JSONObject();
        body.put("grant_type", "client_credential");
        body.put("appid", wxProperties.getAppId());
        body.put("secret", wxProperties.getAppSecret());
        // ... POST 请求 ...
        RedisUtil.set(cacheKey, newToken, BusinessConstant.wxTokenExpiresIn); // 7200-60 秒,留缓冲
        return newToken;
    } finally {
        if (locked) RedisUtil.del(lockKey);
    }
}

8.3 JS-SDK 签名

H5 要调用微信 JS-SDK(如分享、拍照),需后端用 jsapi_ticket 生成签名:

java 复制代码
@Override
public WxJsApiInitVO jsApiInit(WxJsApiDTO dto) {
    String accessToken = wxService.getStableAccessToken();
    String ticket = wxService.getWxTicket(accessToken);

    String nonceStr = UUID.randomUUID().toString().replace("-", "");
    String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
    String url = dto.getUrl().split("#")[0];        // 必须去掉 URL 的 # 锚点部分
    String sign = generateJsApiSign(ticket, nonceStr, timestamp, url);

    return new WxJsApiInitVO()
            .setNonceStr(nonceStr).setTimestamp(timestamp).setSign(sign)
            .setUrl(url).setJsapiTicket(ticket).setAppId(wxProperties.getAppId());
}

private String generateJsApiSign(String jsapiTicket, String nonceStr, String timestamp, String url) {
    // 拼接待签名字符串(字段顺序固定,不可调整)
    String string1 = String.format("jsapi_ticket=%s&noncestr=%s&timestamp=%s&url=%s",
            jsapiTicket, nonceStr, timestamp, url);
    MessageDigest md = MessageDigest.getInstance("SHA-1");
    byte[] digest = md.digest(string1.getBytes("UTF-8"));
    StringBuilder sb = new StringBuilder();
    for (byte b : digest) sb.append(String.format("%02x", b));   // 转小写十六进制
    return sb.toString();
}

ticket 同样走 Redis 缓存(wx:ticket:{appId},7200-60 秒),避免每次请求都打微信接口。

8.4 关注状态同步

未关注公众号的用户收不到模板消息,因此每天 23:00 全量同步一次关注状态:

java 复制代码
@Scheduled(cron = "0 0 23 * * ?")
public void syncFlowStatus() {
    iCustomerBindService.syncFollowStatus();
}

实现思路:先全部重置为未关注 ,再分页拉取微信「关注用户 openid 列表」,批量把命中的绑定关系置回已关注:

java 复制代码
this.lambdaUpdate().set(CustomerBind::getFollow, false).update();   // 1. 全量置 false
String nextOpenId = null;
do {
    WxUserListResponse userList = wxService.getWxUserList(accessToken, nextOpenId);
    List<String> openidList = userList.getData().getOpenid();
    // 2. 命中的批量置 true(BATCH_SIZE 分批 update,规避 IN 过长)
    ...
    nextOpenId = userList.getNext_openid();
} while (StringUtils.isNotBlank(nextOpenId));

九、小结

这套方案的核心思想可以概括为三点:

  1. 隔离差异 :用策略模式 + @ConditionalOnProperty把代理商/直连的报文差异关进两个实现类,业务代码只面向 PayStrategy 接口,配置一行切换。
  2. 以幂等对抗不确定性:支付回调天然会重复、会丢失,因此「状态条件更新」保证重复调用无害,「Redis 锁」防并发,「定时查单」补丢失,「补偿表」保通知。
  3. 以账单为准:回调只是通知,资金安全最终由每日对账兜底,对账失败即告警。

微信支付 APIv3 的接入难点其实不在加密/签名本身(SDK 已经封装得很好),而在于回调不可靠性、时间格式、账单格式细节和多商户模式适配------把这几件事处理好,整套支付链路才算真正可用。

相关推荐
suhan1 小时前
【数据结构】三、栈和队列:2.顺序栈&共享栈(顺序栈的初始化,判空,进栈,出栈,读取栈顶,顺序栈实例)
数据结构·后端·算法
neoooo1 小时前
🚀Claude Code 小技巧(一):一键进入危险模式
前端·人工智能·后端
中趴菜1 小时前
工具调用 3|工具拿到参数以后,程序还必须检查什么?
后端
AI语宙漫游指南1 小时前
RAG 向量检索(上):从 FLAT 到 IVF,查得快为何可能漏?
后端·算法
java资料站2 小时前
七、Spring AI Alibaba · Skills
java·后端·spring
殷紫川2 小时前
Arthas 4 深度实战:从字节码原理到生产故障秒解
后端
JuiceFS2 小时前
地平线 EB 级存储工程实践
人工智能·后端·自动驾驶
霸道流氓气质3 小时前
LLM API密钥管理与轮换策略:Spring Vault动态密钥、熔断限流协同与多Provider路由的 Java 生产级实现
java·后端·spring
凤山老林3 小时前
Spring Boot 集成 Redis Stream 做可靠消息队列:持久化、消费组重组与流式处理实战
spring boot·redis·后端