From 853651e80d33aa510bc54394e106faf45b459ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Fri, 13 Feb 2026 21:41:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=E9=98=BF?= =?UTF-8?q?=E9=87=8C=E4=BA=91=E6=8E=A8=E9=80=81=E3=80=81=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=92=8C=E6=94=AF=E4=BB=98=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 AliYunSender 工具类实现阿里云推送功能 - 添加 CertificateLoader 证书加载工具类支持多种证书加载方式 - 添加 PaymentCacheService 支付配置缓存服务优化性能 - 添加 TenantContext 租户上下文管理器用于临时禁用租户隔离 - 添加 WechatPayCertificateDiagnostic 微信支付证书诊断工具 - 添加 WechatPayConfigValidator 微信支付配置验证工具 - 添加 MqttProperties MQTT配置属性类 - 更新 MainController 中的用户信息修改功能增强安全性 - 优化 application-glt.yml 生产环境配置文件 --- .../auto/dto/QrLoginStatusResponse.java | 1 - .../common/core/config/MqttProperties.java | 72 ++++ .../common/core/context/TenantContext.java | 67 ++++ .../core/service/CertificateService.java | 5 +- .../core/service/PaymentCacheService.java | 174 ++++++++++ .../common/core/utils/AliYunSender.java | 145 ++++++++ .../common/core/utils/CertificateLoader.java | 228 +++++++++++++ .../utils/WechatPayCertificateDiagnostic.java | 314 ++++++++++++++++++ .../core/utils/WechatPayConfigValidator.java | 223 +++++++++++++ .../core/utils/WechatPayDiagnostic.java | 222 +++++++++++++ .../common/core/utils/WechatPayUtils.java | 111 +++++++ .../system/controller/MainController.java | 50 ++- .../service/WxMiniappAccessTokenService.java | 18 + src/main/resources/application-glt.yml | 53 +++ 14 files changed, 1670 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/common/core/config/MqttProperties.java create mode 100644 src/main/java/com/gxwebsoft/common/core/context/TenantContext.java create mode 100644 src/main/java/com/gxwebsoft/common/core/service/PaymentCacheService.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/CertificateLoader.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/WechatPayCertificateDiagnostic.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/WechatPayConfigValidator.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/WechatPayDiagnostic.java create mode 100644 src/main/java/com/gxwebsoft/common/core/utils/WechatPayUtils.java create mode 100644 src/main/java/com/gxwebsoft/common/system/service/WxMiniappAccessTokenService.java create mode 100644 src/main/resources/application-glt.yml diff --git a/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java b/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java index 4c9a6f7..fc37407 100644 --- a/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java +++ b/src/main/java/com/gxwebsoft/auto/dto/QrLoginStatusResponse.java @@ -1,7 +1,6 @@ package com.gxwebsoft.auto.dto; import com.gxwebsoft.common.system.entity.User; -import io.swagger.models.auth.In; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/gxwebsoft/common/core/config/MqttProperties.java b/src/main/java/com/gxwebsoft/common/core/config/MqttProperties.java new file mode 100644 index 0000000..cfc882d --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/config/MqttProperties.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java b/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java new file mode 100644 index 0000000..42adb46 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/context/TenantContext.java @@ -0,0 +1,67 @@ +package com.gxwebsoft.common.core.context; + +/** + * 租户上下文管理器 + * + * 用于在特定场景下临时禁用租户隔离 + * + * @author WebSoft + * @since 2025-01-26 + */ +public class TenantContext { + + private static final ThreadLocal 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 callIgnoreTenant(java.util.function.Supplier supplier) { + boolean originalIgnore = isIgnoreTenant(); + try { + setIgnoreTenant(true); + return supplier.get(); + } finally { + setIgnoreTenant(originalIgnore); + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/service/CertificateService.java b/src/main/java/com/gxwebsoft/common/core/service/CertificateService.java index 321020f..6d013ea 100644 --- a/src/main/java/com/gxwebsoft/common/core/service/CertificateService.java +++ b/src/main/java/com/gxwebsoft/common/core/service/CertificateService.java @@ -11,7 +11,6 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; - import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Date; @@ -135,7 +134,7 @@ public class CertificateService { log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage()); } } else { - java.io.File file = new java.io.File(certPath); + File file = new File(certPath); String absolutePath = file.getAbsolutePath(); log.info("微信支付证书路径模式: FILESYSTEM"); log.info("微信支付证书完整绝对路径: {}", absolutePath); @@ -171,7 +170,7 @@ public class CertificateService { log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage()); } } else { - java.io.File file = new java.io.File(certPath); + File file = new File(certPath); String absolutePath = file.getAbsolutePath(); log.info("支付宝证书路径模式: FILESYSTEM"); log.info("支付宝证书完整绝对路径: {}", absolutePath); diff --git a/src/main/java/com/gxwebsoft/common/core/service/PaymentCacheService.java b/src/main/java/com/gxwebsoft/common/core/service/PaymentCacheService.java new file mode 100644 index 0000000..17a8e32 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/service/PaymentCacheService.java @@ -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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java b/src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java new file mode 100644 index 0000000..80fe4b1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/AliYunSender.java @@ -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; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/CertificateLoader.java b/src/main/java/com/gxwebsoft/common/core/utils/CertificateLoader.java new file mode 100644 index 0000000..37f35d1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/CertificateLoader.java @@ -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]; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/WechatPayCertificateDiagnostic.java b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayCertificateDiagnostic.java new file mode 100644 index 0000000..ebe8189 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayCertificateDiagnostic.java @@ -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(); + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/WechatPayConfigValidator.java b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayConfigValidator.java new file mode 100644 index 0000000..23326d2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayConfigValidator.java @@ -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(); + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/WechatPayDiagnostic.java b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayDiagnostic.java new file mode 100644 index 0000000..620d506 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayDiagnostic.java @@ -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(); + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/utils/WechatPayUtils.java b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayUtils.java new file mode 100644 index 0000000..14a31e9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/utils/WechatPayUtils.java @@ -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; + } +} diff --git a/src/main/java/com/gxwebsoft/common/system/controller/MainController.java b/src/main/java/com/gxwebsoft/common/system/controller/MainController.java index 3dcbc71..0b11e4d 100644 --- a/src/main/java/com/gxwebsoft/common/system/controller/MainController.java +++ b/src/main/java/com/gxwebsoft/common/system/controller/MainController.java @@ -58,6 +58,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.text.MessageFormat; import java.time.Instant; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Random; @@ -304,15 +305,46 @@ public class MainController extends BaseController { @Operation(summary = "修改个人信息") @PutMapping("/auth/user") public ApiResult updateInfo(@RequestBody User user) { - user.setUserId(getLoginUserId()); - // 不能修改的字段 - user.setUsername(null); - user.setPassword(null); - user.setEmailVerified(null); - user.setOrganizationId(null); - user.setStatus(null); - if (userService.updateById(user)) { - return success(userService.getByIdRel(user.getUserId())); + if (getLoginUserId() == null) { + return fail("未登录", null); + } + + // 仅允许修改个人资料字段;避免客户端透传修改敏感字段(余额/角色/租户等) + User update = new User(); + update.setUserId(getLoginUserId()); + update.setNickname(user.getNickname()); + 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); } diff --git a/src/main/java/com/gxwebsoft/common/system/service/WxMiniappAccessTokenService.java b/src/main/java/com/gxwebsoft/common/system/service/WxMiniappAccessTokenService.java new file mode 100644 index 0000000..3a3a44b --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/system/service/WxMiniappAccessTokenService.java @@ -0,0 +1,18 @@ +package com.gxwebsoft.common.system.service; + +/** + * 微信小程序 access_token 获取服务(按租户)。 + * + *

用于调用微信小程序开放接口(例如:上传发货信息)。

+ */ +public interface WxMiniappAccessTokenService { + + /** + * 获取指定租户的小程序 access_token(内部带缓存)。 + * + * @param tenantId 租户ID + * @return access_token + */ + String getAccessToken(Integer tenantId); +} + diff --git a/src/main/resources/application-glt.yml b/src/main/resources/application-glt.yml new file mode 100644 index 0000000..9afd23b --- /dev/null +++ b/src/main/resources/application-glt.yml @@ -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