fix(payment): 微信支付改用公钥模式,修「无可用的平台证书」404

商户 1116982020 已在商户平台申请使用微信支付公钥,/v3/certificates 对这类
商户返回 RESOURCE_NOT_EXISTS(404)「无可用的平台证书」;而 WxPayConfigService
无条件使用 RSAAutoCertificateConfig(构造时即下载平台证书),因此创建支付
配置必然失败,Native/JSAPI 下单、查询、退款全部不可用。

- 数据库配置了 pub_key_id 时改用 RSAPublicKeyConfig(不请求平台证书):
  dev 取 classpath dev/wechat/{租户}/pub_key.pem,prod 取证书卷路径 pubKey
- 配了公钥ID但公钥文件缺失时抛出明确错误(含预期路径),
  不再退回平台证书自动下载后抛出难以定位的 404
- application-dev.yml 公钥文件名 wechatpay_cert.pem → pub_key.pem,
  与仓库内实际文件一致(shop 下单流程的 dev 公钥分支同受益)
- clearConfigCache 同时清 Redis Payment:1:{tenantId}:此前库中改了公钥ID
  也要等最长 24 小时缓存过期才生效
- 测试控制器 /api/test/clear-payment-cache 清的键不对(Payment:0:*),
  改为调用 clearConfigCache
- 新增 WxPayConfigServicePublicKeyTest 回归测试(3 个用例)

验证:/api/payment/query 由「创建微信支付配置对象失败: Wrong HttpStatusCode[404]
无可用的平台证书」变为微信业务应答 ORDER_NOT_EXIST;并用真实响应头验证
Wechatpay-Serial=PUB_KEY_ID_… 的公钥签名校验 PASS。
This commit is contained in:
2026-09-12 18:47:59 +08:00
parent da1f9a64d4
commit b2b1a1bdf9
4 changed files with 213 additions and 6 deletions
@@ -5,6 +5,7 @@ import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.common.system.service.PaymentService; import com.gxwebsoft.common.system.service.PaymentService;
import com.gxwebsoft.common.system.param.PaymentParam; import com.gxwebsoft.common.system.param.PaymentParam;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.payment.service.WxPayConfigService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -39,6 +40,9 @@ public class TestController extends BaseController {
@Autowired @Autowired
private PaymentService paymentService; private PaymentService paymentService;
@Autowired
private WxPayConfigService wxPayConfigService;
@Operation(summary = "测试LocalDateTime序列化") @Operation(summary = "测试LocalDateTime序列化")
@GetMapping("/datetime") @GetMapping("/datetime")
public ApiResult<Map<String, Object>> testDateTime() { public ApiResult<Map<String, Object>> testDateTime() {
@@ -233,6 +237,8 @@ public class TestController extends BaseController {
// 清理可能的缓存键 // 清理可能的缓存键
paymentCacheService.removePaymentConfig("0", tenantId); // 微信支付 paymentCacheService.removePaymentConfig("0", tenantId); // 微信支付
paymentCacheService.removePaymentConfig("wechat", tenantId); // 可能的其他格式 paymentCacheService.removePaymentConfig("wechat", tenantId); // 可能的其他格式
// 清理实际生效的缓存键:Payment:1:{tenantId} 与内存中的微信支付配置
wxPayConfigService.clearConfigCache(tenantId);
String result = "✅ 缓存已清理,租户ID: " + tenantId; String result = "✅ 缓存已清理,租户ID: " + tenantId;
System.out.println(result); System.out.println(result);
@@ -12,12 +12,15 @@ import com.gxwebsoft.payment.exception.PaymentException;
import com.gxwebsoft.payment.enums.PaymentType; import com.gxwebsoft.payment.enums.PaymentType;
import com.wechat.pay.java.core.Config; import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig; import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.core.RSAPublicKeyConfig;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -101,8 +104,11 @@ public class WxPayConfigService {
// 获取证书文件路径 // 获取证书文件路径
String certificatePath = getCertificatePath(tenantId, payment); String certificatePath = getCertificatePath(tenantId, payment);
// 获取微信支付公钥文件路径(未配置公钥ID时为null,表示使用平台证书自动下载模式)
String publicKeyPath = resolvePublicKeyPath(tenantId, payment);
// 创建微信支付配置对象 // 创建微信支付配置对象
return createWxPayConfig(payment, certificatePath); return createWxPayConfig(payment, certificatePath, publicKeyPath);
} catch (Exception e) { } catch (Exception e) {
if (e instanceof PaymentException) { if (e instanceof PaymentException) {
@@ -232,17 +238,96 @@ public class WxPayConfigService {
} }
} }
/**
* 解析微信支付公钥文件路径
*
* <p>商户平台已「申请使用微信支付公钥」的商户,调用 /v3/certificates 会返回
* RESOURCE_NOT_EXISTS(404),必须改用公钥模式(RSAPublicKeyConfig)。
* 是否启用公钥模式以数据库中的公钥ID(pubKeyId)为准:
* 配置了公钥ID却没有可用的公钥文件时直接报错,避免退回平台证书自动下载后
* 再次抛出难以定位的 404。</p>
*
* @param tenantId 租户ID
* @param payment 支付配置
* @return 公钥文件路径;未配置公钥ID时返回 null
* @throws PaymentException 启用了公钥模式但公钥文件不可用时抛出
*/
private String resolvePublicKeyPath(Integer tenantId, Payment payment) throws PaymentException {
if (payment == null || !StringUtils.hasText(payment.getPubKeyId())) {
return null;
}
if ("dev".equals(activeProfile)) {
return resolveDevPublicKeyPath(tenantId);
}
return resolveProdPublicKeyPath(payment);
}
/**
* 获取开发环境微信支付公钥路径
*/
private String resolveDevPublicKeyPath(Integer tenantId) throws PaymentException {
String certDir = certificateProperties.getDevCertPath() + "/"
+ certificateProperties.getWechatPay().getCertDir() + "/" + tenantId + "/";
// 优先使用配置的公钥文件名,并兼容证书目录下实际的 pub_key.pem
String[] candidates = {
certificateProperties.getWechatPay().getDev().getWechatpayCertFile(),
"pub_key.pem"
};
for (String fileName : candidates) {
if (!StringUtils.hasText(fileName)) {
continue;
}
ClassPathResource resource = new ClassPathResource(certDir + fileName);
if (resource.exists()) {
try {
String absolutePath = resource.getFile().getAbsolutePath();
log.debug("开发环境微信支付公钥路径: {}", absolutePath);
return absolutePath;
} catch (IOException e) {
throw PaymentException.systemError("获取开发环境微信支付公钥路径失败: " + e.getMessage(), e);
}
}
}
throw PaymentException.systemError(
"已配置微信支付公钥ID但缺少公钥文件,租户ID: " + tenantId
+ ",预期位置: classpath:" + certDir + "pub_key.pem"
+ "(请从商户平台-API安全下载微信支付公钥后放到该目录)", null);
}
/**
* 获取生产环境微信支付公钥路径
*/
private String resolveProdPublicKeyPath(Payment payment) throws PaymentException {
if (!StringUtils.hasText(payment.getPubKey())) {
throw PaymentException.systemError(
"已配置微信支付公钥ID但公钥文件(pubKey)未配置,商户号: " + payment.getMchId(), null);
}
String publicKeyPath = certificateService.getWechatPayCertPath(payment.getPubKey());
if (publicKeyPath == null || !new File(publicKeyPath).exists()) {
throw PaymentException.systemError(
"微信支付公钥文件不存在: " + publicKeyPath + ",商户号: " + payment.getMchId(), null);
}
log.debug("生产环境微信支付公钥路径: {}", publicKeyPath);
return publicKeyPath;
}
/** /**
* 创建微信支付配置对象 * 创建微信支付配置对象
*/ */
private Config createWxPayConfig(Payment payment, String certificatePath) throws PaymentException { private Config createWxPayConfig(Payment payment, String certificatePath, String publicKeyPath) throws PaymentException {
try { try {
if ("dev".equals(activeProfile) && payment == null) { if ("dev".equals(activeProfile) && payment == null) {
// 开发环境测试配置 // 开发环境测试配置
return createDevTestConfig(certificatePath); return createDevTestConfig(certificatePath);
} else if (payment != null) { } else if (payment != null) {
// 正常配置 // 正常配置
return createNormalConfig(payment, certificatePath); return createNormalConfig(payment, certificatePath, publicKeyPath);
} else { } else {
throw PaymentException.systemError("无法创建微信支付配置:配置信息不完整", null); throw PaymentException.systemError("无法创建微信支付配置:配置信息不完整", null);
} }
@@ -305,21 +390,57 @@ public class WxPayConfigService {
/** /**
* 创建正常配置 * 创建正常配置
*/ */
private Config createNormalConfig(Payment payment, String certificatePath) throws PaymentException { private Config createNormalConfig(Payment payment, String certificatePath, String publicKeyPath) throws PaymentException {
// 验证配置完整性 // 验证配置完整性
validatePaymentConfig(payment); validatePaymentConfig(payment);
log.info("使用数据库支付配置"); log.info("使用数据库支付配置");
log.debug("商户号: {}", payment.getMchId()); log.debug("商户号: {}", payment.getMchId());
return buildRsaConfig(payment, certificatePath, publicKeyPath);
}
/**
* 构建RSA配置
*
* <p>配置了微信支付公钥ID与公钥文件时使用公钥模式(RSAPublicKeyConfig),
* 否则使用平台证书自动下载模式(RSAAutoCertificateConfig)。
* 公钥模式下不会请求 /v3/certificates,因此不受「无可用的平台证书」404影响。</p>
*
* @param payment 支付配置
* @param privateKeyPath 商户私钥文件路径
* @param publicKeyPath 微信支付公钥文件路径,可为 null
* @return 微信支付配置
*/
static Config buildRsaConfig(Payment payment, String privateKeyPath, String publicKeyPath) {
if (usePublicKeyMode(payment.getPubKeyId(), publicKeyPath)) {
log.info("使用微信支付公钥模式,商户号: {}, 公钥ID: {}", payment.getMchId(), payment.getPubKeyId());
return new RSAPublicKeyConfig.Builder()
.merchantId(payment.getMchId())
.privateKeyFromPath(privateKeyPath)
.merchantSerialNumber(payment.getMerchantSerialNumber())
.publicKeyFromPath(publicKeyPath)
.publicKeyId(payment.getPubKeyId())
.apiV3Key(payment.getApiKey())
.build();
}
log.info("使用微信支付平台证书自动下载模式,商户号: {}", payment.getMchId());
return new RSAAutoCertificateConfig.Builder() return new RSAAutoCertificateConfig.Builder()
.merchantId(payment.getMchId()) .merchantId(payment.getMchId())
.privateKeyFromPath(certificatePath) .privateKeyFromPath(privateKeyPath)
.merchantSerialNumber(payment.getMerchantSerialNumber()) .merchantSerialNumber(payment.getMerchantSerialNumber())
.apiV3Key(payment.getApiKey()) .apiV3Key(payment.getApiKey())
.build(); .build();
} }
/**
* 是否使用微信支付公钥模式:配置了公钥ID且公钥文件可用
*/
static boolean usePublicKeyMode(String publicKeyId, String publicKeyPath) {
return StringUtils.hasText(publicKeyId) && StringUtils.hasText(publicKeyPath);
}
/** /**
* 验证支付配置完整性 * 验证支付配置完整性
*/ */
@@ -356,10 +477,14 @@ public class WxPayConfigService {
/** /**
* 清除指定租户的配置缓存 * 清除指定租户的配置缓存
* *
* <p>同时清除内存中的微信支付配置与Redis中的支付配置(Payment:1:{tenantId})
* 否则数据库中的支付配置(如公钥ID)变更后最长24小时才能生效。</p>
*
* @param tenantId 租户ID * @param tenantId 租户ID
*/ */
public void clearConfigCache(Integer tenantId) { public void clearConfigCache(Integer tenantId) {
WxNativeUtil.addConfig(tenantId, null); WxNativeUtil.addConfig(tenantId, null);
redisUtil.delete("Payment:1:" + tenantId);
log.info("清除微信支付配置缓存,租户ID: {}", tenantId); log.info("清除微信支付配置缓存,租户ID: {}", tenantId);
} }
} }
+1 -1
View File
@@ -55,4 +55,4 @@ certificate:
dev: dev:
private-key-file: "apiclient_key.pem" private-key-file: "apiclient_key.pem"
apiclient-cert-file: "apiclient_cert.pem" apiclient-cert-file: "apiclient_cert.pem"
wechatpay-cert-file: "wechatpay_cert.pem" wechatpay-cert-file: "pub_key.pem"
@@ -0,0 +1,76 @@
package com.gxwebsoft.payment.service;
import com.gxwebsoft.common.system.entity.Payment;
import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.core.RSAPublicKeyConfig;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.File;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 微信支付配置构建测试
*
* <p>回归场景:商户平台已「申请使用微信支付公钥」的商户,调用 /v3/certificates
* 会返回 RESOURCE_NOT_EXISTS(404)「无可用的平台证书」。数据库里配置了公钥ID时,
* 必须使用公钥模式(RSAPublicKeyConfig),不能再走平台证书自动下载。</p>
*
* @author 科技小王子
* @since 2026-09-12
*/
@DisplayName("微信支付公钥模式配置构建")
class WxPayConfigServicePublicKeyTest {
private static final String PRIVATE_KEY_PATH = "src/main/resources/dev/wechat/10626/apiclient_key.pem";
private static final String PUBLIC_KEY_PATH = "src/main/resources/dev/wechat/10626/pub_key.pem";
private static final String PUBLIC_KEY_ID = "PUB_KEY_ID_0111169820202026091100211540003401";
private Payment payment() {
Payment payment = new Payment();
payment.setTenantId(10626);
payment.setType(1);
payment.setMchId("1116982020");
payment.setAppId("wx5c60e74d6b6f997f");
payment.setMerchantSerialNumber("143F22047204DE942CA46F0EEA8A7AC32B4B1FCA");
payment.setApiKey("test-api-v3-key-32-bytes-length!");
payment.setPubKeyId(PUBLIC_KEY_ID);
payment.setPubKey("/20260912/1661c8232f4c409b831475a12fcd83e0.pem");
return payment;
}
@Test
@DisplayName("配置了公钥ID与公钥文件时使用公钥模式")
void shouldUsePublicKeyModeWhenPublicKeyConfigured() {
assertTrue(WxPayConfigService.usePublicKeyMode(PUBLIC_KEY_ID, PUBLIC_KEY_PATH));
}
@Test
@DisplayName("未配置公钥ID或公钥文件缺失时不使用公钥模式")
void shouldNotUsePublicKeyModeWithoutPublicKey() {
assertFalse(WxPayConfigService.usePublicKeyMode(null, PUBLIC_KEY_PATH), "公钥ID为空时不应使用公钥模式");
assertFalse(WxPayConfigService.usePublicKeyMode("", PUBLIC_KEY_PATH), "公钥ID为空串时不应使用公钥模式");
assertFalse(WxPayConfigService.usePublicKeyMode(PUBLIC_KEY_ID, null), "公钥文件缺失时不应使用公钥模式");
}
@Test
@DisplayName("配置了公钥ID时构建的是公钥配置而非平台证书自动下载配置")
void shouldBuildPublicKeyConfigInsteadOfAutoCertificateConfig() {
assertTrue(new File(PRIVATE_KEY_PATH).exists(), "缺少测试用商户私钥文件: " + PRIVATE_KEY_PATH);
assertTrue(new File(PUBLIC_KEY_PATH).exists(), "缺少测试用微信支付公钥文件: " + PUBLIC_KEY_PATH);
Payment payment = payment();
// 公钥模式:本地即可完成构建,不会请求 /v3/certificates
Config publicKeyConfig = WxPayConfigService.buildRsaConfig(payment, PRIVATE_KEY_PATH, PUBLIC_KEY_PATH);
assertNotNull(publicKeyConfig);
assertTrue(publicKeyConfig instanceof RSAPublicKeyConfig,
"配置了公钥ID时应构建 RSAPublicKeyConfig,实际: " + publicKeyConfig.getClass().getName());
assertFalse(publicKeyConfig instanceof RSAAutoCertificateConfig,
"配置了公钥ID时不应构建 RSAAutoCertificateConfig(会触发平台证书下载并返回404)");
}
}