feat(core): 新增阿里云推送、证书加载器和支付缓存服务
- 添加 AliYunSender 工具类实现阿里云推送功能 - 添加 CertificateLoader 证书加载工具类支持多种证书加载方式 - 添加 PaymentCacheService 支付配置缓存服务优化性能 - 添加 TenantContext 租户上下文管理器用于临时禁用租户隔离 - 添加 WechatPayCertificateDiagnostic 微信支付证书诊断工具 - 添加 WechatPayConfigValidator 微信支付配置验证工具 - 添加 MqttProperties MQTT配置属性类 - 更新 MainController 中的用户信息修改功能增强安全性 - 优化 application-glt.yml 生产环境配置文件
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
package com.gxwebsoft.auto.dto;
|
package com.gxwebsoft.auto.dto;
|
||||||
|
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import io.swagger.models.auth.In;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.gxwebsoft.common.core.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQTT配置属性
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-02
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "mqtt")
|
||||||
|
public class MqttProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用MQTT服务
|
||||||
|
*/
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQTT服务器地址
|
||||||
|
*/
|
||||||
|
private String host = "tcp://127.0.0.1:1883";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
private String password = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端ID前缀
|
||||||
|
*/
|
||||||
|
private String clientIdPrefix = "mqtt_client_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅主题
|
||||||
|
*/
|
||||||
|
private String topic = "/SW_GPS/#";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QoS等级
|
||||||
|
*/
|
||||||
|
private int qos = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接超时时间(秒)
|
||||||
|
*/
|
||||||
|
private int connectionTimeout = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心跳间隔(秒)
|
||||||
|
*/
|
||||||
|
private int keepAliveInterval = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否自动重连
|
||||||
|
*/
|
||||||
|
private boolean autoReconnect = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否清除会话
|
||||||
|
*/
|
||||||
|
private boolean cleanSession = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.gxwebsoft.common.core.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户上下文管理器
|
||||||
|
*
|
||||||
|
* 用于在特定场景下临时禁用租户隔离
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
* @since 2025-01-26
|
||||||
|
*/
|
||||||
|
public class TenantContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Boolean> IGNORE_TENANT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置忽略租户隔离
|
||||||
|
*/
|
||||||
|
public static void setIgnoreTenant(boolean ignore) {
|
||||||
|
IGNORE_TENANT.set(ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否忽略租户隔离
|
||||||
|
*/
|
||||||
|
public static boolean isIgnoreTenant() {
|
||||||
|
Boolean ignore = IGNORE_TENANT.get();
|
||||||
|
return ignore != null && ignore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除租户上下文
|
||||||
|
*/
|
||||||
|
public static void clear() {
|
||||||
|
IGNORE_TENANT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在忽略租户隔离的上下文中执行操作
|
||||||
|
*
|
||||||
|
* @param runnable 要执行的操作
|
||||||
|
*/
|
||||||
|
public static void runIgnoreTenant(Runnable runnable) {
|
||||||
|
boolean originalIgnore = isIgnoreTenant();
|
||||||
|
try {
|
||||||
|
setIgnoreTenant(true);
|
||||||
|
runnable.run();
|
||||||
|
} finally {
|
||||||
|
setIgnoreTenant(originalIgnore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在忽略租户隔离的上下文中执行操作并返回结果
|
||||||
|
*
|
||||||
|
* @param supplier 要执行的操作
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
public static <T> T callIgnoreTenant(java.util.function.Supplier<T> supplier) {
|
||||||
|
boolean originalIgnore = isIgnoreTenant();
|
||||||
|
try {
|
||||||
|
setIgnoreTenant(true);
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
setIgnoreTenant(originalIgnore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -135,7 +134,7 @@ public class CertificateService {
|
|||||||
log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage());
|
log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
java.io.File file = new java.io.File(certPath);
|
File file = new File(certPath);
|
||||||
String absolutePath = file.getAbsolutePath();
|
String absolutePath = file.getAbsolutePath();
|
||||||
log.info("微信支付证书路径模式: FILESYSTEM");
|
log.info("微信支付证书路径模式: FILESYSTEM");
|
||||||
log.info("微信支付证书完整绝对路径: {}", absolutePath);
|
log.info("微信支付证书完整绝对路径: {}", absolutePath);
|
||||||
@@ -171,7 +170,7 @@ public class CertificateService {
|
|||||||
log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage());
|
log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
java.io.File file = new java.io.File(certPath);
|
File file = new File(certPath);
|
||||||
String absolutePath = file.getAbsolutePath();
|
String absolutePath = file.getAbsolutePath();
|
||||||
log.info("支付宝证书路径模式: FILESYSTEM");
|
log.info("支付宝证书路径模式: FILESYSTEM");
|
||||||
log.info("支付宝证书完整绝对路径: {}", absolutePath);
|
log.info("支付宝证书完整绝对路径: {}", absolutePath);
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.gxwebsoft.common.core.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import com.gxwebsoft.common.system.param.PaymentParam;
|
||||||
|
import com.gxwebsoft.common.system.service.PaymentService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付配置缓存服务
|
||||||
|
* 统一管理支付配置的缓存读取,支持 Payment:1* 格式
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class PaymentCacheService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisUtil redisUtil;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PaymentService paymentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据支付类型获取支付配置
|
||||||
|
* 优先从 Payment:1{payType} 格式的缓存读取
|
||||||
|
*
|
||||||
|
* @param payType 支付类型 (0=微信支付, 1=支付宝, 2=其他)
|
||||||
|
* @param tenantId 租户ID (用于兜底查询)
|
||||||
|
* @return Payment 支付配置
|
||||||
|
*/
|
||||||
|
public Payment getPaymentConfig(Integer payType, Integer tenantId) {
|
||||||
|
// 1. 优先使用 Payment:1{payType} 格式的缓存键
|
||||||
|
String primaryKey = "Payment:1:" + tenantId;
|
||||||
|
Payment payment = redisUtil.get(primaryKey, Payment.class);
|
||||||
|
|
||||||
|
if (ObjectUtil.isNotEmpty(payment)) {
|
||||||
|
log.debug("从缓存获取支付配置成功: {}", primaryKey);
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果 Payment:1* 格式不存在,尝试原有格式
|
||||||
|
String fallbackKey = "Payment:" + payType + ":" + tenantId;
|
||||||
|
payment = redisUtil.get(fallbackKey, Payment.class);
|
||||||
|
|
||||||
|
if (ObjectUtil.isNotEmpty(payment)) {
|
||||||
|
log.debug("从兜底缓存获取支付配置成功: {}", fallbackKey);
|
||||||
|
// 将查询结果缓存到 Payment:1* 格式
|
||||||
|
redisUtil.set(primaryKey, payment);
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最后从数据库查询
|
||||||
|
log.debug("从数据库查询支付配置, payType: {}, tenantId: {}", payType, tenantId);
|
||||||
|
PaymentParam paymentParam = new PaymentParam();
|
||||||
|
paymentParam.setType(payType);
|
||||||
|
paymentParam.setTenantId(tenantId); // 设置租户ID进行过滤
|
||||||
|
List<Payment> payments = paymentService.listRel(paymentParam);
|
||||||
|
|
||||||
|
if (payments.isEmpty()) {
|
||||||
|
throw new BusinessException("请完成支付配置,支付类型: " + payType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment dbPayment = payments.get(0);
|
||||||
|
|
||||||
|
// 清理时间字段,避免序列化问题
|
||||||
|
Payment cachePayment = cleanPaymentForCache(dbPayment);
|
||||||
|
|
||||||
|
// 将查询结果缓存到 Payment:1* 格式
|
||||||
|
redisUtil.set(primaryKey, cachePayment);
|
||||||
|
log.debug("支付配置已缓存到: {}", primaryKey);
|
||||||
|
|
||||||
|
return dbPayment; // 返回原始对象,不影响业务逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存支付配置
|
||||||
|
* 同时缓存到 Payment:1{payType} 和原有格式
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
*/
|
||||||
|
public void cachePaymentConfig(Payment payment, Integer tenantId) {
|
||||||
|
// 缓存到 Payment:1* 格式
|
||||||
|
String primaryKey = "Payment:1" + payment.getCode();
|
||||||
|
redisUtil.set(primaryKey, payment);
|
||||||
|
log.debug("支付配置已缓存到: {}", primaryKey);
|
||||||
|
|
||||||
|
// 兼容原有格式
|
||||||
|
String legacyKey = "Payment:" + payment.getCode() + ":" + tenantId;
|
||||||
|
redisUtil.set(legacyKey, payment);
|
||||||
|
log.debug("支付配置已缓存到兼容格式: {}", legacyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除支付配置缓存
|
||||||
|
* 同时删除 Payment:1{payType} 和原有格式
|
||||||
|
*
|
||||||
|
* @param paymentCode 支付代码 (可以是String或Integer)
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
*/
|
||||||
|
public void removePaymentConfig(String paymentCode, Integer tenantId) {
|
||||||
|
// 删除 Payment:1* 格式缓存
|
||||||
|
String primaryKey = "Payment:1" + paymentCode;
|
||||||
|
redisUtil.delete(primaryKey);
|
||||||
|
log.debug("已删除支付配置缓存: {}", primaryKey);
|
||||||
|
|
||||||
|
// 删除原有格式缓存
|
||||||
|
String legacyKey = "Payment:" + paymentCode + ":" + tenantId;
|
||||||
|
redisUtil.delete(legacyKey);
|
||||||
|
log.debug("已删除兼容格式缓存: {}", legacyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信支付配置 (payType = 0)
|
||||||
|
*/
|
||||||
|
public Payment getWechatPayConfig(Integer tenantId) {
|
||||||
|
return getPaymentConfig(0, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支付宝配置 (payType = 1)
|
||||||
|
*/
|
||||||
|
public Payment getAlipayConfig(Integer tenantId) {
|
||||||
|
return getPaymentConfig(1, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理Payment对象用于缓存
|
||||||
|
* 移除可能导致序列化问题的时间字段
|
||||||
|
*/
|
||||||
|
private Payment cleanPaymentForCache(Payment original) {
|
||||||
|
if (original == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment cleaned = new Payment();
|
||||||
|
// 复制所有业务相关字段
|
||||||
|
cleaned.setId(original.getId());
|
||||||
|
cleaned.setName(original.getName());
|
||||||
|
cleaned.setType(original.getType());
|
||||||
|
cleaned.setCode(original.getCode());
|
||||||
|
cleaned.setImage(original.getImage());
|
||||||
|
cleaned.setWechatType(original.getWechatType());
|
||||||
|
cleaned.setAppId(original.getAppId());
|
||||||
|
cleaned.setMchId(original.getMchId());
|
||||||
|
cleaned.setApiKey(original.getApiKey());
|
||||||
|
cleaned.setApiclientCert(original.getApiclientCert());
|
||||||
|
cleaned.setApiclientKey(original.getApiclientKey());
|
||||||
|
cleaned.setPubKey(original.getPubKey());
|
||||||
|
cleaned.setPubKeyId(original.getPubKeyId());
|
||||||
|
cleaned.setMerchantSerialNumber(original.getMerchantSerialNumber());
|
||||||
|
cleaned.setNotifyUrl(original.getNotifyUrl());
|
||||||
|
cleaned.setComments(original.getComments());
|
||||||
|
cleaned.setSortNumber(original.getSortNumber());
|
||||||
|
cleaned.setStatus(original.getStatus());
|
||||||
|
cleaned.setDeleted(original.getDeleted());
|
||||||
|
cleaned.setTenantId(original.getTenantId());
|
||||||
|
|
||||||
|
// 不设置时间字段,避免序列化问题
|
||||||
|
// cleaned.setCreateTime(null);
|
||||||
|
// cleaned.setUpdateTime(null);
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java
Normal file
145
src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
import cn.hutool.core.codec.Base64;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLConnection;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AliYunSender {
|
||||||
|
/*
|
||||||
|
* 计算MD5+BASE64
|
||||||
|
*/
|
||||||
|
public static String MD5Base64(String s) {
|
||||||
|
if (s == null)
|
||||||
|
return null;
|
||||||
|
String encodeStr = "";
|
||||||
|
byte[] utfBytes = s.getBytes();
|
||||||
|
MessageDigest mdTemp;
|
||||||
|
try {
|
||||||
|
mdTemp = MessageDigest.getInstance("MD5");
|
||||||
|
mdTemp.update(utfBytes);
|
||||||
|
byte[] md5Bytes = mdTemp.digest();
|
||||||
|
encodeStr = Base64.encode(md5Bytes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new Error("Failed to generate MD5 : " + e.getMessage());
|
||||||
|
}
|
||||||
|
return encodeStr;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 计算 HMAC-SHA1
|
||||||
|
*/
|
||||||
|
public static String HMACSha1(String data, String key) {
|
||||||
|
String result;
|
||||||
|
try {
|
||||||
|
SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(), "HmacSHA1");
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA1");
|
||||||
|
mac.init(signingKey);
|
||||||
|
byte[] rawHmac = mac.doFinal(data.getBytes());
|
||||||
|
result = Base64.encode(rawHmac);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new Error("Failed to generate HMAC : " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 获取时间
|
||||||
|
*/
|
||||||
|
public static String toGMTString(Date date) {
|
||||||
|
SimpleDateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.UK);
|
||||||
|
df.setTimeZone(new java.util.SimpleTimeZone(0, "GMT"));
|
||||||
|
return df.format(date);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 发送POST请求
|
||||||
|
*/
|
||||||
|
public static String sendPost(String url, String body, String ak_id, String ak_secret) {
|
||||||
|
PrintWriter out = null;
|
||||||
|
BufferedReader in = null;
|
||||||
|
String result = "";
|
||||||
|
try {
|
||||||
|
URL realUrl = new URL(url);
|
||||||
|
/*
|
||||||
|
* http header 参数
|
||||||
|
*/
|
||||||
|
String method = "POST";
|
||||||
|
String accept = "application/json";
|
||||||
|
String content_type = "application/json;chrset=utf-8";
|
||||||
|
String path = realUrl.getFile();
|
||||||
|
String date = toGMTString(new Date());
|
||||||
|
String host = realUrl.getHost();
|
||||||
|
// 1.对body做MD5+BASE64加密
|
||||||
|
String bodyMd5 = MD5Base64(body);
|
||||||
|
String uuid = UUID.randomUUID().toString();
|
||||||
|
String stringToSign = method + "\n" + accept + "\n" + bodyMd5 + "\n" + content_type + "\n" + date + "\n"
|
||||||
|
+ "x-acs-signature-method:HMAC-SHA1\n"
|
||||||
|
+ "x-acs-signature-nonce:" + uuid + "\n"
|
||||||
|
+ "x-acs-version:2019-01-02\n"
|
||||||
|
+ path;
|
||||||
|
// 2.计算 HMAC-SHA1
|
||||||
|
String signature = HMACSha1(stringToSign, ak_secret);
|
||||||
|
// 3.得到 authorization header
|
||||||
|
String authHeader = "acs " + ak_id + ":" + signature;
|
||||||
|
// 打开和URL之间的连接
|
||||||
|
URLConnection conn = realUrl.openConnection();
|
||||||
|
// 设置通用的请求属性
|
||||||
|
conn.setRequestProperty("Accept", accept);
|
||||||
|
conn.setRequestProperty("Content-Type", content_type);
|
||||||
|
conn.setRequestProperty("Content-MD5", bodyMd5);
|
||||||
|
conn.setRequestProperty("Date", date);
|
||||||
|
conn.setRequestProperty("Host", host);
|
||||||
|
conn.setRequestProperty("Authorization", authHeader);
|
||||||
|
conn.setRequestProperty("x-acs-signature-nonce", uuid);
|
||||||
|
conn.setRequestProperty("x-acs-signature-method", "HMAC-SHA1");
|
||||||
|
conn.setRequestProperty("x-acs-version", "2019-01-02"); // 版本可选
|
||||||
|
// 发送POST请求必须设置如下两行
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setDoInput(true);
|
||||||
|
// 获取URLConnection对象对应的输出流
|
||||||
|
out = new PrintWriter(conn.getOutputStream());
|
||||||
|
// 发送请求参数
|
||||||
|
out.print(body);
|
||||||
|
// flush输出流的缓冲
|
||||||
|
out.flush();
|
||||||
|
// 定义BufferedReader输入流来读取URL的响应
|
||||||
|
InputStream is;
|
||||||
|
HttpURLConnection httpconn = (HttpURLConnection) conn;
|
||||||
|
if (httpconn.getResponseCode() == 200) {
|
||||||
|
is = httpconn.getInputStream();
|
||||||
|
} else {
|
||||||
|
is = httpconn.getErrorStream();
|
||||||
|
}
|
||||||
|
in = new BufferedReader(new InputStreamReader(is));
|
||||||
|
String line;
|
||||||
|
while ((line = in.readLine()) != null) {
|
||||||
|
result += line;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("发送 POST 请求出现异常!" + e);
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
// 使用finally块来关闭输出流、输入流
|
||||||
|
finally {
|
||||||
|
try {
|
||||||
|
if (out != null) {
|
||||||
|
out.close();
|
||||||
|
}
|
||||||
|
if (in != null) {
|
||||||
|
in.close();
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
ex.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证书加载工具类
|
||||||
|
* 支持多种证书加载方式,适配Docker容器化部署
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-01-26
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class CertificateLoader {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
|
||||||
|
public CertificateLoader(CertificateProperties certConfig) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
log.info("证书加载器初始化,加载模式:{}", certConfig.getLoadMode());
|
||||||
|
if (certConfig.getLoadMode() == CertificateProperties.LoadMode.VOLUME) {
|
||||||
|
log.info("Docker挂载卷证书路径:{}", certConfig.getCertRootPath());
|
||||||
|
validateCertDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书目录是否存在
|
||||||
|
*/
|
||||||
|
private void validateCertDirectory() {
|
||||||
|
File certDir = new File(certConfig.getCertRootPath());
|
||||||
|
if (!certDir.exists()) {
|
||||||
|
log.warn("证书目录不存在:{},将尝试创建", certConfig.getCertRootPath());
|
||||||
|
if (!certDir.mkdirs()) {
|
||||||
|
log.error("无法创建证书目录:{}", certConfig.getCertRootPath());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("证书目录验证成功:{}", certConfig.getCertRootPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载证书文件路径
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径(可能是相对路径、绝对路径或classpath路径)
|
||||||
|
* @return 实际的证书文件路径
|
||||||
|
*/
|
||||||
|
public String loadCertificatePath(String certPath) {
|
||||||
|
if (!StringUtils.hasText(certPath)) {
|
||||||
|
throw new IllegalArgumentException("证书路径不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
return loadFromClasspath(certPath);
|
||||||
|
case VOLUME:
|
||||||
|
return loadFromVolume(certPath);
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
return loadFromFileSystem(certPath);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("加载证书失败,路径:{}", certPath, e);
|
||||||
|
throw new RuntimeException("证书加载失败:" + certPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从classpath加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromClasspath(String certPath) throws IOException {
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
if (!resource.exists()) {
|
||||||
|
throw new IOException("Classpath中找不到证书文件:" + resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将classpath中的文件复制到临时目录
|
||||||
|
Path tempFile = Files.createTempFile("cert_", ".pem");
|
||||||
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
|
Files.copy(inputStream, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
String tempPath = tempFile.toAbsolutePath().toString();
|
||||||
|
log.debug("从classpath加载证书:{} -> {}", resourcePath, tempPath);
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从Docker挂载卷加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromVolume(String certPath) {
|
||||||
|
log.debug("尝试从Docker挂载卷加载证书:{}", certPath);
|
||||||
|
|
||||||
|
// 如果是完整路径,直接使用
|
||||||
|
if (certPath.startsWith("/") || certPath.contains(":")) {
|
||||||
|
File file = new File(certPath);
|
||||||
|
log.debug("检查完整路径文件是否存在:{}", certPath);
|
||||||
|
if (file.exists()) {
|
||||||
|
log.debug("使用完整路径加载证书:{}", certPath);
|
||||||
|
return certPath;
|
||||||
|
} else {
|
||||||
|
log.error("完整路径文件不存在:{}", certPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则拼接挂载卷路径
|
||||||
|
String fullPath = Paths.get(certConfig.getCertRootPath(), certPath).toString();
|
||||||
|
File file = new File(fullPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new RuntimeException("Docker挂载卷中找不到证书文件:" + fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("从Docker挂载卷加载证书:{}", fullPath);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件系统加载证书
|
||||||
|
*/
|
||||||
|
private String loadFromFileSystem(String certPath) {
|
||||||
|
File file = new File(certPath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new RuntimeException("文件系统中找不到证书文件:" + certPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("从文件系统加载证书:{}", certPath);
|
||||||
|
return certPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件是否存在
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径
|
||||||
|
* @return 是否存在
|
||||||
|
*/
|
||||||
|
public boolean certificateExists(String certPath) {
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
return resource.exists();
|
||||||
|
case VOLUME:
|
||||||
|
String fullPath = certPath.startsWith("/") ? certPath :
|
||||||
|
Paths.get(certConfig.getCertRootPath(), certPath).toString();
|
||||||
|
return new File(fullPath).exists();
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
return new File(certPath).exists();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("检查证书文件存在性时出错:{}", certPath, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取证书文件的输入流
|
||||||
|
*
|
||||||
|
* @param certPath 证书路径
|
||||||
|
* @return 输入流
|
||||||
|
*/
|
||||||
|
public InputStream getCertificateInputStream(String certPath) throws IOException {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case CLASSPATH:
|
||||||
|
String resourcePath = certPath.startsWith("classpath:") ?
|
||||||
|
certPath.substring("classpath:".length()) : certPath;
|
||||||
|
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||||
|
return resource.getInputStream();
|
||||||
|
case VOLUME:
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
String actualPath = loadCertificatePath(certPath);
|
||||||
|
return Files.newInputStream(Paths.get(actualPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出证书目录中的所有文件
|
||||||
|
*
|
||||||
|
* @return 证书文件列表
|
||||||
|
*/
|
||||||
|
public String[] listCertificateFiles() {
|
||||||
|
try {
|
||||||
|
switch (certConfig.getLoadMode()) {
|
||||||
|
case VOLUME:
|
||||||
|
File certDir = new File(certConfig.getCertRootPath());
|
||||||
|
if (certDir.exists() && certDir.isDirectory()) {
|
||||||
|
return certDir.list();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CLASSPATH:
|
||||||
|
// classpath模式下不支持列出文件
|
||||||
|
log.warn("Classpath模式下不支持列出证书文件");
|
||||||
|
break;
|
||||||
|
case FILESYSTEM:
|
||||||
|
default:
|
||||||
|
// 文件系统模式下证书可能分散在不同目录,不支持统一列出
|
||||||
|
log.warn("文件系统模式下不支持列出证书文件");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("列出证书文件时出错", e);
|
||||||
|
}
|
||||||
|
return new String[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付证书诊断工具
|
||||||
|
* 专门用于诊断和解决证书相关问题
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-29
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayCertificateDiagnostic {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
private final CertificateLoader certificateLoader;
|
||||||
|
|
||||||
|
public WechatPayCertificateDiagnostic(CertificateProperties certConfig, CertificateLoader certificateLoader) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
this.certificateLoader = certificateLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全面诊断微信支付证书配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @param environment 环境(dev/prod)
|
||||||
|
* @return 诊断结果
|
||||||
|
*/
|
||||||
|
public DiagnosticResult diagnoseCertificateConfig(Payment payment, Integer tenantId, String environment) {
|
||||||
|
DiagnosticResult result = new DiagnosticResult();
|
||||||
|
|
||||||
|
log.info("=== 开始微信支付证书诊断 ===");
|
||||||
|
log.info("租户ID: {}, 环境: {}", tenantId, environment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 检查基本配置
|
||||||
|
checkBasicConfig(payment, result);
|
||||||
|
|
||||||
|
// 2. 检查证书文件
|
||||||
|
checkCertificateFiles(payment, tenantId, environment, result);
|
||||||
|
|
||||||
|
// 3. 检查证书内容
|
||||||
|
validateCertificateContent(payment, tenantId, environment, result);
|
||||||
|
|
||||||
|
// 4. 生成建议
|
||||||
|
generateRecommendations(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("诊断过程中发生异常: " + e.getMessage());
|
||||||
|
log.error("证书诊断异常", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("=== 证书诊断完成 ===");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查基本配置
|
||||||
|
*/
|
||||||
|
private void checkBasicConfig(Payment payment, DiagnosticResult result) {
|
||||||
|
if (payment == null) {
|
||||||
|
result.addError("支付配置为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getMchId() == null || payment.getMchId().trim().isEmpty()) {
|
||||||
|
result.addError("商户号未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("商户号: " + payment.getMchId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getAppId() == null || payment.getAppId().trim().isEmpty()) {
|
||||||
|
result.addError("应用ID未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("应用ID: " + payment.getAppId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getMerchantSerialNumber() == null || payment.getMerchantSerialNumber().trim().isEmpty()) {
|
||||||
|
result.addError("商户证书序列号未配置");
|
||||||
|
} else {
|
||||||
|
result.addInfo("商户证书序列号: " + payment.getMerchantSerialNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getApiKey() == null || payment.getApiKey().trim().isEmpty()) {
|
||||||
|
result.addWarning("数据库中APIv3密钥未配置,将使用配置文件默认值");
|
||||||
|
} else {
|
||||||
|
result.addInfo("APIv3密钥: 已配置(" + payment.getApiKey().length() + "位)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件
|
||||||
|
*/
|
||||||
|
private void checkCertificateFiles(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
|
||||||
|
if ("dev".equals(environment)) {
|
||||||
|
// 开发环境证书检查
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
|
||||||
|
|
||||||
|
// 检查私钥文件
|
||||||
|
if (certificateLoader.certificateExists(privateKeyPath)) {
|
||||||
|
result.addInfo("✅ 私钥文件存在: " + privateKeyPath);
|
||||||
|
try {
|
||||||
|
String privateKeyFile = certificateLoader.loadCertificatePath(privateKeyPath);
|
||||||
|
result.addInfo("私钥文件路径: " + privateKeyFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("私钥文件加载失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.addError("❌ 私钥文件不存在: " + privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查商户证书文件
|
||||||
|
if (certificateLoader.certificateExists(apiclientCertPath)) {
|
||||||
|
result.addInfo("✅ 商户证书文件存在: " + apiclientCertPath);
|
||||||
|
} else {
|
||||||
|
result.addWarning("⚠️ 商户证书文件不存在: " + apiclientCertPath + " (自动证书配置不需要此文件)");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 生产环境证书检查
|
||||||
|
if (payment.getApiclientKey() != null) {
|
||||||
|
result.addInfo("私钥文件配置: " + payment.getApiclientKey());
|
||||||
|
} else {
|
||||||
|
result.addError("生产环境私钥文件路径未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getApiclientCert() != null) {
|
||||||
|
result.addInfo("商户证书文件配置: " + payment.getApiclientCert());
|
||||||
|
} else {
|
||||||
|
result.addWarning("生产环境商户证书文件路径未配置 (自动证书配置不需要此文件)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书内容
|
||||||
|
*/
|
||||||
|
private void validateCertificateContent(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
|
||||||
|
try {
|
||||||
|
if ("dev".equals(environment)) {
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
|
||||||
|
|
||||||
|
if (certificateLoader.certificateExists(apiclientCertPath)) {
|
||||||
|
validateX509Certificate(apiclientCertPath, payment.getMerchantSerialNumber(), result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addWarning("证书内容验证失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证X509证书
|
||||||
|
*/
|
||||||
|
private void validateX509Certificate(String certPath, String expectedSerialNumber, DiagnosticResult result) {
|
||||||
|
try {
|
||||||
|
String actualCertPath = certificateLoader.loadCertificatePath(certPath);
|
||||||
|
|
||||||
|
try (InputStream inputStream = new FileInputStream(new File(actualCertPath))) {
|
||||||
|
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||||
|
X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream);
|
||||||
|
|
||||||
|
if (cert != null) {
|
||||||
|
String actualSerialNumber = cert.getSerialNumber().toString(16).toUpperCase();
|
||||||
|
result.addInfo("证书序列号: " + actualSerialNumber);
|
||||||
|
result.addInfo("证书有效期: " + cert.getNotBefore() + " 至 " + cert.getNotAfter());
|
||||||
|
result.addInfo("证书主体: " + cert.getSubjectX500Principal().toString());
|
||||||
|
|
||||||
|
// 检查序列号是否匹配
|
||||||
|
if (expectedSerialNumber != null && !expectedSerialNumber.equalsIgnoreCase(actualSerialNumber)) {
|
||||||
|
result.addError("证书序列号不匹配! 配置: " + expectedSerialNumber + ", 实际: " + actualSerialNumber);
|
||||||
|
} else {
|
||||||
|
result.addInfo("✅ 证书序列号匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查证书是否过期
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now < cert.getNotBefore().getTime()) {
|
||||||
|
result.addError("证书尚未生效");
|
||||||
|
} else if (now > cert.getNotAfter().getTime()) {
|
||||||
|
result.addError("证书已过期");
|
||||||
|
} else {
|
||||||
|
result.addInfo("✅ 证书在有效期内");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.addError("无法解析证书文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("证书验证失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成建议
|
||||||
|
*/
|
||||||
|
private void generateRecommendations(DiagnosticResult result) {
|
||||||
|
if (result.hasErrors()) {
|
||||||
|
result.addRecommendation("🔧 修复建议:");
|
||||||
|
|
||||||
|
String errorText = result.getErrors();
|
||||||
|
if (errorText.contains("商户号")) {
|
||||||
|
result.addRecommendation("1. 请在支付配置中设置正确的商户号");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("序列号")) {
|
||||||
|
result.addRecommendation("2. 请检查商户证书序列号是否正确,可在微信商户平台查看");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("证书文件")) {
|
||||||
|
result.addRecommendation("3. 请确保证书文件已正确放置在指定目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorText.contains("过期")) {
|
||||||
|
result.addRecommendation("4. 请更新过期的证书文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addRecommendation("5. 建议使用RSAAutoCertificateConfig自动证书配置,可避免手动管理证书");
|
||||||
|
result.addRecommendation("6. 确保在微信商户平台开启API安全功能并申请使用微信支付公钥");
|
||||||
|
} else {
|
||||||
|
result.addRecommendation("✅ 证书配置正常,建议使用自动证书配置以获得最佳体验");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断结果类
|
||||||
|
*/
|
||||||
|
public static class DiagnosticResult {
|
||||||
|
private final StringBuilder errors = new StringBuilder();
|
||||||
|
private final StringBuilder warnings = new StringBuilder();
|
||||||
|
private final StringBuilder info = new StringBuilder();
|
||||||
|
private final StringBuilder recommendations = new StringBuilder();
|
||||||
|
|
||||||
|
public void addError(String error) {
|
||||||
|
if (errors.length() > 0) errors.append("\n");
|
||||||
|
errors.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addWarning(String warning) {
|
||||||
|
if (warnings.length() > 0) warnings.append("\n");
|
||||||
|
warnings.append(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addInfo(String information) {
|
||||||
|
if (info.length() > 0) info.append("\n");
|
||||||
|
info.append(information);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addRecommendation(String recommendation) {
|
||||||
|
if (recommendations.length() > 0) recommendations.append("\n");
|
||||||
|
recommendations.append(recommendation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasErrors() {
|
||||||
|
return errors.length() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrors() {
|
||||||
|
return errors.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWarnings() {
|
||||||
|
return warnings.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getInfo() {
|
||||||
|
return info.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecommendations() {
|
||||||
|
return recommendations.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFullReport() {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("=== 微信支付证书诊断报告 ===\n\n");
|
||||||
|
|
||||||
|
if (info.length() > 0) {
|
||||||
|
report.append("📋 基本信息:\n").append(info).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length() > 0) {
|
||||||
|
report.append("⚠️ 警告:\n").append(warnings).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length() > 0) {
|
||||||
|
report.append("❌ 错误:\n").append(errors).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.length() > 0) {
|
||||||
|
report.append("💡 建议:\n").append(recommendations).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
report.append("=== 诊断报告结束 ===");
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.config.CertificateProperties;
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付配置验证工具
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayConfigValidator {
|
||||||
|
|
||||||
|
private final CertificateProperties certConfig;
|
||||||
|
private final CertificateLoader certificateLoader;
|
||||||
|
|
||||||
|
@Value("${spring.profiles.active}")
|
||||||
|
private String activeProfile;
|
||||||
|
|
||||||
|
public WechatPayConfigValidator(CertificateProperties certConfig, CertificateLoader certificateLoader) {
|
||||||
|
this.certConfig = certConfig;
|
||||||
|
this.certificateLoader = certificateLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证微信支付配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 验证结果
|
||||||
|
*/
|
||||||
|
public ValidationResult validateWechatPayConfig(Payment payment, Integer tenantId) {
|
||||||
|
ValidationResult result = new ValidationResult();
|
||||||
|
|
||||||
|
log.info("开始验证微信支付配置 - 租户ID: {}", tenantId);
|
||||||
|
|
||||||
|
// 1. 验证基本配置
|
||||||
|
if (payment == null) {
|
||||||
|
result.addError("支付配置为空");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getMchId())) {
|
||||||
|
result.addError("商户号未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getAppId())) {
|
||||||
|
result.addError("应用ID未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(payment.getMerchantSerialNumber())) {
|
||||||
|
result.addError("商户证书序列号未配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证 APIv3 密钥
|
||||||
|
String apiV3Key = getValidApiV3Key(payment);
|
||||||
|
if (!StringUtils.hasText(apiV3Key)) {
|
||||||
|
result.addError("APIv3密钥未配置");
|
||||||
|
} else {
|
||||||
|
validateApiV3Key(apiV3Key, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证证书文件
|
||||||
|
validateCertificateFiles(tenantId, result);
|
||||||
|
|
||||||
|
// 4. 记录验证结果
|
||||||
|
if (result.isValid()) {
|
||||||
|
log.info("✅ 微信支付配置验证通过 - 租户ID: {}", tenantId);
|
||||||
|
} else {
|
||||||
|
log.error("❌ 微信支付配置验证失败 - 租户ID: {}, 错误: {}", tenantId, result.getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取有效的 APIv3 密钥
|
||||||
|
* 优先使用数据库配置,如果为空则使用配置文件默认值
|
||||||
|
*/
|
||||||
|
public String getValidApiV3Key(Payment payment) {
|
||||||
|
String apiV3Key = payment.getApiKey();
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(apiV3Key)) {
|
||||||
|
apiV3Key = certConfig.getWechatPay().getDev().getApiV3Key();
|
||||||
|
log.warn("数据库中APIv3密钥为空,使用配置文件默认值");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiV3Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 APIv3 密钥格式
|
||||||
|
*/
|
||||||
|
private void validateApiV3Key(String apiV3Key, ValidationResult result) {
|
||||||
|
if (apiV3Key.length() != 32) {
|
||||||
|
result.addError("APIv3密钥长度错误,应为32位,实际为: " + apiV3Key.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiV3Key.matches("^[a-zA-Z0-9]+$")) {
|
||||||
|
result.addError("APIv3密钥格式错误,应仅包含字母和数字");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("APIv3密钥验证 - 长度: {}, 格式: {}",
|
||||||
|
apiV3Key.length(),
|
||||||
|
apiV3Key.matches("^[a-zA-Z0-9]+$") ? "正确" : "错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证证书文件
|
||||||
|
*/
|
||||||
|
private void validateCertificateFiles(Integer tenantId, ValidationResult result) {
|
||||||
|
if ("dev".equals(activeProfile)) {
|
||||||
|
// 开发环境证书验证
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
|
||||||
|
if (!certificateLoader.certificateExists(privateKeyPath)) {
|
||||||
|
result.addError("证书文件不存在: " + privateKeyPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
certificateLoader.loadCertificatePath(privateKeyPath);
|
||||||
|
log.info("✅ 开发环境证书文件验证通过: {}", privateKeyPath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.addError("证书文件加载失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 生产环境证书验证 - 跳过文件存在性检查,因为证书路径来自数据库
|
||||||
|
log.info("✅ 生产环境跳过证书文件存在性验证,使用数据库配置的证书路径");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证结果类
|
||||||
|
*/
|
||||||
|
public static class ValidationResult {
|
||||||
|
private boolean valid = true;
|
||||||
|
private StringBuilder errors = new StringBuilder();
|
||||||
|
|
||||||
|
public void addError(String error) {
|
||||||
|
this.valid = false;
|
||||||
|
if (errors.length() > 0) {
|
||||||
|
errors.append("; ");
|
||||||
|
}
|
||||||
|
errors.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrors() {
|
||||||
|
return errors.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logErrors() {
|
||||||
|
if (!valid) {
|
||||||
|
log.error("配置验证失败: {}", errors.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成配置诊断报告
|
||||||
|
*/
|
||||||
|
public String generateDiagnosticReport(Payment payment, Integer tenantId) {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("=== 微信支付配置诊断报告 ===\n");
|
||||||
|
report.append("租户ID: ").append(tenantId).append("\n");
|
||||||
|
|
||||||
|
if (payment != null) {
|
||||||
|
report.append("商户号: ").append(payment.getMchId()).append("\n");
|
||||||
|
report.append("应用ID: ").append(payment.getAppId()).append("\n");
|
||||||
|
report.append("商户证书序列号: ").append(payment.getMerchantSerialNumber()).append("\n");
|
||||||
|
|
||||||
|
String dbApiKey = payment.getApiKey();
|
||||||
|
String configApiKey = certConfig.getWechatPay().getDev().getApiV3Key();
|
||||||
|
|
||||||
|
report.append("数据库APIv3密钥: ").append(dbApiKey != null ? "已配置(" + dbApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
report.append("配置文件APIv3密钥: ").append(configApiKey != null ? "已配置(" + configApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
|
||||||
|
String finalApiKey = getValidApiV3Key(payment);
|
||||||
|
report.append("最终使用APIv3密钥: ").append(finalApiKey != null ? "已配置(" + finalApiKey.length() + "位)" : "未配置").append("\n");
|
||||||
|
|
||||||
|
} else {
|
||||||
|
report.append("❌ 支付配置为空\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 证书文件检查
|
||||||
|
report.append("当前环境: ").append(activeProfile).append("\n");
|
||||||
|
if ("dev".equals(activeProfile)) {
|
||||||
|
String tenantCertPath = "dev/wechat/" + tenantId;
|
||||||
|
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
|
||||||
|
boolean certExists = certificateLoader.certificateExists(privateKeyPath);
|
||||||
|
|
||||||
|
report.append("开发环境证书文件路径: ").append(privateKeyPath).append("\n");
|
||||||
|
report.append("证书文件存在: ").append(certExists ? "是" : "否").append("\n");
|
||||||
|
} else {
|
||||||
|
report.append("生产环境证书路径: 从数据库配置获取\n");
|
||||||
|
if (payment != null) {
|
||||||
|
report.append("私钥文件: ").append(payment.getApiclientKey()).append("\n");
|
||||||
|
report.append("证书文件: ").append(payment.getApiclientCert()).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult validation = validateWechatPayConfig(payment, tenantId);
|
||||||
|
report.append("配置验证结果: ").append(validation.isValid() ? "通过" : "失败").append("\n");
|
||||||
|
if (!validation.isValid()) {
|
||||||
|
report.append("验证错误: ").append(validation.getErrors()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
report.append("=== 诊断报告结束 ===");
|
||||||
|
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付配置诊断工具
|
||||||
|
* 用于排查微信支付签名验证失败等问题
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-07-27
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WechatPayDiagnostic {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断微信支付配置
|
||||||
|
*
|
||||||
|
* @param payment 支付配置
|
||||||
|
* @param privateKeyPath 私钥路径
|
||||||
|
* @param environment 环境标识
|
||||||
|
*/
|
||||||
|
public void diagnosePaymentConfig(Payment payment, String privateKeyPath, String environment) {
|
||||||
|
log.info("=== 微信支付配置诊断开始 ===");
|
||||||
|
log.info("环境: {}", environment);
|
||||||
|
|
||||||
|
// 1. 检查支付配置基本信息
|
||||||
|
checkBasicConfig(payment);
|
||||||
|
|
||||||
|
// 2. 检查证书文件
|
||||||
|
checkCertificateFiles(payment, privateKeyPath, environment);
|
||||||
|
|
||||||
|
// 3. 检查配置完整性
|
||||||
|
checkConfigCompleteness(payment);
|
||||||
|
|
||||||
|
log.info("=== 微信支付配置诊断结束 ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查基本配置信息
|
||||||
|
*/
|
||||||
|
private void checkBasicConfig(Payment payment) {
|
||||||
|
log.info("--- 基本配置检查 ---");
|
||||||
|
|
||||||
|
if (payment == null) {
|
||||||
|
log.error("❌ 支付配置为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("支付配置ID: {}", payment.getId());
|
||||||
|
log.info("支付方式名称: {}", payment.getName());
|
||||||
|
log.info("支付类型: {}", payment.getType());
|
||||||
|
log.info("支付代码: {}", payment.getCode());
|
||||||
|
log.info("状态: {}", payment.getStatus());
|
||||||
|
|
||||||
|
// 检查关键字段
|
||||||
|
checkField("应用ID", payment.getAppId());
|
||||||
|
checkField("商户号", payment.getMchId());
|
||||||
|
checkField("商户证书序列号", payment.getMerchantSerialNumber());
|
||||||
|
checkField("API密钥", payment.getApiKey(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查证书文件
|
||||||
|
*/
|
||||||
|
private void checkCertificateFiles(Payment payment, String privateKeyPath, String environment) {
|
||||||
|
log.info("--- 证书文件检查 ---");
|
||||||
|
|
||||||
|
// 检查私钥文件
|
||||||
|
if (privateKeyPath != null) {
|
||||||
|
checkFileExists("私钥文件", privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境检查证书文件
|
||||||
|
if (!"dev".equals(environment)) {
|
||||||
|
if (payment.getApiclientCert() != null) {
|
||||||
|
log.info("商户证书文件配置: {}", payment.getApiclientCert());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.getPubKey() != null) {
|
||||||
|
log.info("公钥文件配置: {}", payment.getPubKey());
|
||||||
|
log.info("公钥ID: {}", payment.getPubKeyId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配置完整性
|
||||||
|
*/
|
||||||
|
private void checkConfigCompleteness(Payment payment) {
|
||||||
|
log.info("--- 配置完整性检查 ---");
|
||||||
|
|
||||||
|
boolean isComplete = true;
|
||||||
|
|
||||||
|
if (isEmpty(payment.getMchId())) {
|
||||||
|
log.error("❌ 商户号未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getMerchantSerialNumber())) {
|
||||||
|
log.error("❌ 商户证书序列号未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getApiKey())) {
|
||||||
|
log.error("❌ API密钥未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(payment.getAppId())) {
|
||||||
|
log.error("❌ 应用ID未配置");
|
||||||
|
isComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
log.info("✅ 配置完整性检查通过");
|
||||||
|
} else {
|
||||||
|
log.error("❌ 配置不完整,请补充缺失的配置项");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字段是否为空
|
||||||
|
*/
|
||||||
|
private void checkField(String fieldName, String value) {
|
||||||
|
checkField(fieldName, value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字段是否为空
|
||||||
|
*/
|
||||||
|
private void checkField(String fieldName, String value, boolean isSensitive) {
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
log.warn("⚠️ {}: 未配置", fieldName);
|
||||||
|
} else {
|
||||||
|
if (isSensitive) {
|
||||||
|
log.info("✅ {}: 已配置(长度:{})", fieldName, value.length());
|
||||||
|
} else {
|
||||||
|
log.info("✅ {}: {}", fieldName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
*/
|
||||||
|
private void checkFileExists(String fileName, String filePath) {
|
||||||
|
try {
|
||||||
|
File file = new File(filePath);
|
||||||
|
if (file.exists() && file.isFile()) {
|
||||||
|
log.info("✅ {}: 文件存在 - {}", fileName, filePath);
|
||||||
|
log.info(" 文件大小: {} bytes", file.length());
|
||||||
|
|
||||||
|
// 检查文件内容格式
|
||||||
|
if (filePath.endsWith(".pem")) {
|
||||||
|
checkPemFileFormat(fileName, filePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("❌ {}: 文件不存在 - {}", fileName, filePath);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("❌ {}: 检查文件时出错 - {} ({})", fileName, filePath, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查PEM文件格式
|
||||||
|
*/
|
||||||
|
private void checkPemFileFormat(String fileName, String filePath) {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(Paths.get(filePath));
|
||||||
|
if (content.contains("-----BEGIN") && content.contains("-----END")) {
|
||||||
|
log.info("✅ {}: PEM格式正确", fileName);
|
||||||
|
} else {
|
||||||
|
log.warn("⚠️ {}: PEM格式可能有问题", fileName);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("⚠️ {}: 无法读取文件内容进行格式检查 ({})", fileName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字符串是否为空
|
||||||
|
*/
|
||||||
|
private boolean isEmpty(String str) {
|
||||||
|
return str == null || str.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成诊断报告
|
||||||
|
*/
|
||||||
|
public String generateDiagnosticReport(Payment payment, String environment) {
|
||||||
|
StringBuilder report = new StringBuilder();
|
||||||
|
report.append("🔍 微信支付配置诊断报告\n");
|
||||||
|
report.append("========================\n\n");
|
||||||
|
|
||||||
|
report.append("环境: ").append(environment).append("\n");
|
||||||
|
report.append("租户ID: ").append(payment != null ? payment.getTenantId() : "未知").append("\n");
|
||||||
|
report.append("商户号: ").append(payment != null ? payment.getMchId() : "未配置").append("\n");
|
||||||
|
report.append("应用ID: ").append(payment != null ? payment.getAppId() : "未配置").append("\n\n");
|
||||||
|
|
||||||
|
report.append("🚨 常见问题排查:\n");
|
||||||
|
report.append("1. 商户证书序列号是否正确\n");
|
||||||
|
report.append("2. APIv3密钥是否正确\n");
|
||||||
|
report.append("3. 私钥文件是否正确\n");
|
||||||
|
report.append("4. 微信支付平台证书是否过期\n");
|
||||||
|
report.append("5. 网络连接是否正常\n\n");
|
||||||
|
|
||||||
|
report.append("💡 建议解决方案:\n");
|
||||||
|
report.append("1. 使用自动证书配置(RSAAutoCertificateConfig)\n");
|
||||||
|
report.append("2. 在微信商户平台重新下载证书\n");
|
||||||
|
report.append("3. 检查商户平台API安全设置\n");
|
||||||
|
|
||||||
|
return report.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.gxwebsoft.common.core.utils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付工具类
|
||||||
|
* 处理微信支付API的字段限制和格式要求
|
||||||
|
*
|
||||||
|
* @author 科技小王子
|
||||||
|
* @since 2025-01-11
|
||||||
|
*/
|
||||||
|
public class WechatPayUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付description字段的最大字节数限制
|
||||||
|
*/
|
||||||
|
public static final int DESCRIPTION_MAX_BYTES = 127;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信支付attach字段的最大字节数限制
|
||||||
|
*/
|
||||||
|
public static final int ATTACH_MAX_BYTES = 127;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断字符串以确保字节数不超过指定限制
|
||||||
|
* 主要用于微信支付API的字段限制处理
|
||||||
|
*
|
||||||
|
* @param text 原始文本
|
||||||
|
* @param maxBytes 最大字节数
|
||||||
|
* @return 截断后的文本,确保UTF-8字符完整性
|
||||||
|
*/
|
||||||
|
public static String truncateToByteLimit(String text, int maxBytes) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length <= maxBytes) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断字节数组,但要确保不会截断UTF-8字符的中间
|
||||||
|
int truncateLength = maxBytes;
|
||||||
|
while (truncateLength > 0) {
|
||||||
|
byte[] truncated = new byte[truncateLength];
|
||||||
|
System.arraycopy(bytes, 0, truncated, 0, truncateLength);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String result = new String(truncated, StandardCharsets.UTF_8);
|
||||||
|
// 检查是否有无效字符(被截断的UTF-8字符)
|
||||||
|
if (!result.contains("\uFFFD")) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 继续尝试更短的长度
|
||||||
|
}
|
||||||
|
truncateLength--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""; // 如果无法安全截断,返回空字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信支付商品描述字段
|
||||||
|
* 确保字节数不超过127字节
|
||||||
|
*
|
||||||
|
* @param description 商品描述
|
||||||
|
* @return 处理后的描述,符合微信支付要求
|
||||||
|
*/
|
||||||
|
public static String processDescription(String description) {
|
||||||
|
return truncateToByteLimit(description, DESCRIPTION_MAX_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理微信支付附加数据字段
|
||||||
|
* 确保字节数不超过127字节
|
||||||
|
*
|
||||||
|
* @param attach 附加数据
|
||||||
|
* @return 处理后的附加数据,符合微信支付要求
|
||||||
|
*/
|
||||||
|
public static String processAttach(String attach) {
|
||||||
|
return truncateToByteLimit(attach, ATTACH_MAX_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证字符串是否符合微信支付字段的字节限制
|
||||||
|
*
|
||||||
|
* @param text 待验证的文本
|
||||||
|
* @param maxBytes 最大字节数限制
|
||||||
|
* @return true如果符合限制,false如果超出限制
|
||||||
|
*/
|
||||||
|
public static boolean isWithinByteLimit(String text, int maxBytes) {
|
||||||
|
if (text == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return text.getBytes(StandardCharsets.UTF_8).length <= maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字符串的UTF-8字节数
|
||||||
|
*
|
||||||
|
* @param text 文本
|
||||||
|
* @return 字节数
|
||||||
|
*/
|
||||||
|
public static int getByteLength(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return text.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ import java.io.UnsupportedEncodingException;
|
|||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.text.MessageFormat;
|
import java.text.MessageFormat;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
@@ -304,15 +305,46 @@ public class MainController extends BaseController {
|
|||||||
@Operation(summary = "修改个人信息")
|
@Operation(summary = "修改个人信息")
|
||||||
@PutMapping("/auth/user")
|
@PutMapping("/auth/user")
|
||||||
public ApiResult<User> updateInfo(@RequestBody User user) {
|
public ApiResult<User> updateInfo(@RequestBody User user) {
|
||||||
user.setUserId(getLoginUserId());
|
if (getLoginUserId() == null) {
|
||||||
// 不能修改的字段
|
return fail("未登录", null);
|
||||||
user.setUsername(null);
|
}
|
||||||
user.setPassword(null);
|
|
||||||
user.setEmailVerified(null);
|
// 仅允许修改个人资料字段;避免客户端透传修改敏感字段(余额/角色/租户等)
|
||||||
user.setOrganizationId(null);
|
User update = new User();
|
||||||
user.setStatus(null);
|
update.setUserId(getLoginUserId());
|
||||||
if (userService.updateById(user)) {
|
update.setNickname(user.getNickname());
|
||||||
return success(userService.getByIdRel(user.getUserId()));
|
update.setAvatar(user.getAvatar());
|
||||||
|
update.setBgImage(user.getBgImage());
|
||||||
|
update.setSex(user.getSex());
|
||||||
|
update.setPhone(user.getPhone());
|
||||||
|
update.setEmail(user.getEmail());
|
||||||
|
update.setProvince(user.getProvince());
|
||||||
|
update.setCity(user.getCity());
|
||||||
|
update.setRegion(user.getRegion());
|
||||||
|
update.setAddress(user.getAddress());
|
||||||
|
update.setIntroduction(user.getIntroduction());
|
||||||
|
|
||||||
|
// MyBatis-Plus: 如果没有任何可更新字段,会生成 `UPDATE ... WHERE ...`(没有 SET)导致 SQL 报错
|
||||||
|
// 这里检测一下“确实有字段需要更新”,否则直接返回当前用户信息。
|
||||||
|
if (ObjectUtil.isAllEmpty(
|
||||||
|
update.getNickname(),
|
||||||
|
update.getAvatar(),
|
||||||
|
update.getBgImage(),
|
||||||
|
update.getSex(),
|
||||||
|
update.getPhone(),
|
||||||
|
update.getEmail(),
|
||||||
|
update.getProvince(),
|
||||||
|
update.getCity(),
|
||||||
|
update.getRegion(),
|
||||||
|
update.getAddress(),
|
||||||
|
update.getIntroduction()
|
||||||
|
)) {
|
||||||
|
return success("没有需要更新的字段", userService.getByIdRel(update.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
update.setUpdateTime(LocalDateTime.now());
|
||||||
|
if (userService.updateById(update)) {
|
||||||
|
return success(userService.getByIdRel(update.getUserId()));
|
||||||
}
|
}
|
||||||
return fail("保存失败", null);
|
return fail("保存失败", null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.gxwebsoft.common.system.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序 access_token 获取服务(按租户)。
|
||||||
|
*
|
||||||
|
* <p>用于调用微信小程序开放接口(例如:上传发货信息)。</p>
|
||||||
|
*/
|
||||||
|
public interface WxMiniappAccessTokenService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定租户的小程序 access_token(内部带缓存)。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return access_token
|
||||||
|
*/
|
||||||
|
String getAccessToken(Integer tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
53
src/main/resources/application-glt.yml
Normal file
53
src/main/resources/application-glt.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://1Panel-mysql-XsWW:3306/gxwebsoft_core?useSSL=false&serverTimezone=UTC
|
||||||
|
username: gxwebsoft_core
|
||||||
|
password: ZXT5FkBREBJQPiAs
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
redis:
|
||||||
|
database: 0
|
||||||
|
host: 1Panel-redis-GmNr
|
||||||
|
port: 6379
|
||||||
|
password: redis_t74P8C
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: websoft-core.log
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.gxwebsoft: ERROR
|
||||||
|
com.baomidou.mybatisplus: ERROR
|
||||||
|
|
||||||
|
socketio:
|
||||||
|
host: 0.0.0.0 #IP地址
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
# 开启knife4j增强
|
||||||
|
enable: true
|
||||||
|
# 开启生产环境屏蔽,一定要先开启knife4j增强才会生效
|
||||||
|
production: false
|
||||||
|
|
||||||
|
# 框架配置
|
||||||
|
config:
|
||||||
|
# 生产环境接口
|
||||||
|
server-url: https://server.guiletao.com/api
|
||||||
|
upload-path: /www/wwwroot/file.ws
|
||||||
|
|
||||||
|
# 阿里云OSS云存储
|
||||||
|
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
||||||
|
accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP
|
||||||
|
accessKeySecret: BiDkpS7UXj72HWwDWaFZxiXjNFBNCM
|
||||||
|
bucketName: oss-gxwebsoft
|
||||||
|
bucketDomain: https://oss.wsdns.cn
|
||||||
|
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
||||||
|
|
||||||
|
# 生产环境证书配置
|
||||||
|
certificate:
|
||||||
|
# 生产环境使用挂载卷模式
|
||||||
|
load-mode: VOLUME
|
||||||
|
cert-root-path: /app/certs
|
||||||
Reference in New Issue
Block a user