微信生态接入与微信支付 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=直连模式
二、整体架构
分层职责:
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 条件装配原理
PayServiceImpl 用构造器注入 单个 PayStrategy,Spring 只会装配到唯一存活的实现:
java
public PayServiceImpl(WxProperties wxProperties, PayStrategy payStrategy) {
this.wxProperties = wxProperties;
this.payStrategy = payStrategy;
}
注意
matchIfMissing = true让代理商模式成为默认兜底 ,避免老现场升级后因为漏配pay-mode而启动失败。
五、下单链路
核心编排代码(含金额二次校验,防止前端篡改):
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("出错了!请稍候尝试");
}
}
两个关键设计:
- 金额单位换算只发生一次 :业务层用「元」,进入策略层立刻
× 100转「分」,与微信 API 对齐; 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 幂等与状态机
微信回调会重复推送(直到你返回成功),定时任务也会主动查单,两条路径可能并发命中同一订单。因此必须做幂等:
订单更新采用带状态条件的更新 ,天然幂等------第二次执行时 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:
javapublic static LocalDateTime parseWxTime(String successTime) { if (successTime == null || successTime.isEmpty()) return null; return OffsetDateTime.parse(successTime).toLocalDateTime(); }
七、对账单下载与自动对账
对账是资金安全的最后一道闸门:不信任回调,以微信账单为准。
下载与解析:
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×tamp=%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));
九、小结
这套方案的核心思想可以概括为三点:
- 隔离差异 :用
策略模式 + @ConditionalOnProperty把代理商/直连的报文差异关进两个实现类,业务代码只面向PayStrategy接口,配置一行切换。 - 以幂等对抗不确定性:支付回调天然会重复、会丢失,因此「状态条件更新」保证重复调用无害,「Redis 锁」防并发,「定时查单」补丢失,「补偿表」保通知。
- 以账单为准:回调只是通知,资金安全最终由每日对账兜底,对账失败即告警。
微信支付 APIv3 的接入难点其实不在加密/签名本身(SDK 已经封装得很好),而在于回调不可靠性、时间格式、账单格式细节和多商户模式适配------把这几件事处理好,整套支付链路才算真正可用。