微信生态接入与微信支付 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
目录
一、背景:为什么要做「双模式支付」
水务缴费场景在不同地区落地时,微信支付的商户资质是完全不一样的:
| 模式 | 商户形态 | 适用场景 |
|---|---|---|
| 代理商模式(服务商 + 子商户) | 服务商 spMchid 统一进件,各水司作为子商户 subMchid | 集团/平台统一接入微信支付,为多个水司提供缴费能力 |
| 直连模式(普通商户) | 水司自己申请微信支付商户号 | 水司自有资质,独立收款 |
两种模式的业务语义完全相同,差异只存在于微信支付 API 的报文结构:
- 代理商:
spAppid+spMchid+subAppid+subMchid,支付人用subOpenid,SDK 走partnerpayments包; - 直连:
appid+mchid,支付人用openid,SDK 走payments包。
如果不用设计模式,代码里会到处都是 if (payMode == 1) {...} else {...},难以维护。因此我们选择了 策略模式 + Spring 条件装配,用一行配置完成切换,业务代码零感知。
wx:
pay-mode: 1 # 1=代理商模式, 2=直连模式
二、整体架构
前端
微信小程序 / 公众号 H5
│ 下单 / 关单 / JSAPI 初始化
▼
wx-service ────────────────────────────────────────────────────────────
PayController /business/pay/*
│
▼
PayServiceImpl (业务编排)
│
├─► PayStrategy(策略接口)
│ ├─► PartnerPayStrategy 代理商 @ConditionalOnProperty=1
│ └─► DirectPayStrategy 直连 @ConditionalOnProperty=2
├─► WxServiceImpl token / ticket / openId ──► 微信公众平台
│ cgi-bin/token · jsapi ticket
├─► PostgreSQL
└─► Redis
OrderSyncSchedule (定时任务)
───────────────────────────────────────────────────────────────────────
│
├─► 微信支付 APIv3
│ · 异步回调 /business/pay/notify → PayController
│ · 8:00 自动对账
└─► 网关 / 身份认证服务 ──► 营收系统 IBCS
· 支付成功后核销
· 失败重试通知
分层职责:
PayController:只做参数接收与日志,不做业务;PayServiceImpl:编排(校验账单 → 落库订单 → 调用策略 → 更新状态 → 通知营收);PayStrategy:纯粹的微信支付 API 差异隔离层;WxServiceImpl:微信公众平台能力(token、ticket、openId、关注列表)。
三、微信支付 SDK 初始化:公钥模式
wechatpay-java 提供了两种 Config 实现:
RSAAutoCertificateConfig:自动下载并轮换微信支付平台证书(需请求平台证书接口);RSAPublicKeyConfig:使用微信支付公钥 + 公钥 ID,无需下载平台证书,适合生产环境网络受限或希望降低外部依赖的场景。
本项目采用公钥模式,在容器启动时构建单例 Config Bean:
@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();
}
}
对应的配置项(示例值均为占位符,实际部署请替换为真实值):
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 策略接口
把两种模式共有的支付动作抽象成接口:
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);
}
统一入参 / 出参对象,屏蔽两种模式的字段差异:
@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):
@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 包):
@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 条件装配原理
[Spring 启动扫描 @Component]
│
▼
{ 读取配置 wx.pay-mode }
│
├── 1 或未配置 ──► 注册 PartnerPayStrategy(matchIfMissing=true)
│
├── 2 ───────────► 注册 DirectPayStrategy
│
└── 其他 ────────► 两个 Bean 都不注册,启动报错(配置错误快速暴露)
│
▼(唯一存活的实现)
PayServiceImpl 构造器注入 private final PayStrategy payStrategy
│
▼
调用 payStrategy.prePay(param),运行时无 if/else
PayServiceImpl 用构造器注入单个 PayStrategy,Spring 只会装配到唯一存活的实现:
public PayServiceImpl(WxProperties wxProperties, PayStrategy payStrategy) {
this.wxProperties = wxProperties;
this.payStrategy = payStrategy;
}
注意
matchIfMissing = true让代理商模式成为默认兜底,避免老现场升级后因为漏配pay-mode而启动失败。
五、下单链路
1. 小程序/H5 ──GET /business/pay/prePay?amount&customerNo&phone──► PayController
2. PayController ──prePay(amount, customerNo, phone)────────────────► PayServiceImpl
3. PayServiceImpl ──SysUtil.getOpenId(phone)(内部用 ticket 拼 key 取 openId)─► Redis
4. PayServiceImpl ──查询用户账单 CustomerInfoVO─────────────────────► 网关(账单服务)
5. 网关(账单服务) ──应缴金额 / 余额 / billGuid──────────────────────► PayServiceImpl
6. PayServiceImpl ──校验:金额 > 0 且 ≥ 应缴金额(本地)
7. PayServiceImpl ──落本地订单(待支付)──────────────────────────► PostgreSQL
8. PayServiceImpl ──prePay(PrepayParam)───────────────────────────► PayStrategy
9. PayStrategy ──JSAPI 下单 prepayWithRequestPayment───────────► 微信支付 APIv3
10. 微信支付 APIv3 ──prepay_id + JSAPI 签名参数────────────────────► PayStrategy
11. PayStrategy ──拉起支付所需参数─────────────────────────────► PayServiceImpl
12. PayServiceImpl ──返回参数────────────────────────────────────► 小程序/H5
(前端 wx.requestPayment 拉起收银台)
核心编排代码(含金额二次校验,防止前端篡改):
@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 一次搞定:
@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 幂等与状态机
微信回调会重复推送(直到你返回成功),定时任务也会主动查单,两条路径可能并发命中同一订单。因此必须做幂等:
1. 微信支付 ──POST /business/pay/notify(密文)───────────► PayController
2. PayController ──payNotify(request)─────────────────────────► PayServiceImpl
3. PayServiceImpl ──parseNotify 验签解密(本地)
┌─ 若 tradeState = SUCCESS ─────────────────────────────────────────┐
│ 4. PayServiceImpl ──orderPaySuccess(outTradeNo, successTime)──► OrderServiceImpl
│ 5. OrderServiceImpl ──SETNX pay_lock:{wxOrderId} 5min──────────► Redis
│ ├─ 未拿到锁 ──► 已有线程在处理,直接返回
│ └─ 拿到锁 ──┬─► 条件更新 待支付→已支付(幂等)───────► PostgreSQL
│ ├─► 落 payment_notify 补偿记录───────────► PostgreSQL
│ ├─► 调用核销接口─────────────────────────► 网关→营收IBCS
│ │ ◄── 成功 / 异常
│ ├─► 更新补偿记录状态─────────────────────► PostgreSQL
│ └─► 事务提交后释放锁─────────────────────► Redis
└──────────────────────────────────────────────────────────────────┘
6. PayServiceImpl ──"SUCCESS"(必须原样返回)────────────────► 微信支付
订单更新采用带状态条件的更新,天然幂等——第二次执行时 where pay_state = 待支付 匹配不到记录:
@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 小时内」的订单,主动向微信查单补齐状态:
@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 只做状态收敛,复用同一套幂等更新逻辑:
@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:public static LocalDateTime parseWxTime(String successTime) { if (successTime == null || successTime.isEmpty()) return null; return OffsetDateTime.parse(successTime).toLocalDateTime(); }
七、对账单下载与自动对账
对账是资金安全的最后一道闸门:不信任回调,以微信账单为准。
[定时任务 每天 8:00]
│
▼
[downloadBill(昨日日期, GZIP)]
│
▼
[BillDownloadServiceExtension getTradeBill → DigestBillEntity]
│
▼
[落盘保存 CSV]
│
▼
[OpenCSV 解析:首行表头 → 列名索引映射]
│
▼
[逐行解析交易明细,从商户数据包还原 customerNo]
│
▼
[用商户订单号反查本地订单,补齐 billGuid]
│
▼
[汇总总金额 + 明细列表]
│
▼
[POST 网关 / IBCS_DZ 上传营收系统]
│
▼
{ 对账成功? }
│
┌────┴────┐
是│ │否
▼ ▼
[完成] [记录异常日志告警]
下载与解析:
@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 时有两个「微信特色」要注意:
// 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 定时任务跑完才能对,否则会拿到不完整数据。
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:
@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)使用:
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 分布式锁防止缓存过期瞬间的并发穿透:
@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 生成签名:
@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 全量同步一次关注状态:
@Scheduled(cron = "0 0 23 * * ?")
public void syncFlowStatus() {
iCustomerBindService.syncFollowStatus();
}
实现思路:先全部重置为未关注,再分页拉取微信「关注用户 openid 列表」,批量把命中的绑定关系置回已关注:
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 已经封装得很好),而在于回调不可靠性、时间格式、账单格式细节和多商户模式适配——把这几件事处理好,整套支付链路才算真正可用。