Procházet zdrojové kódy

Fix: 微信支付真实对接API v3+回调AES解密+退款;修复CertService seq类型;trial/release改https

liaoxg před 1 týdnem
rodič
revize
feccca5471

+ 10 - 4
train-backend/src/main/java/com/train/controller/OrderController.java

@@ -74,10 +74,16 @@ public class OrderController {
         }
     }
 
-    @Operation(summary = "微信支付回调(公网)")
+    @Operation(summary = "微信支付回调(公网,API v3 JSON)")
     @PostMapping("/notify")
-    public String notify(@RequestBody(required = false) String xmlBody) {
-        // 微信推送 XML,test-mode 下单即入账;此处兜底幂等,生产需按微信文档验签
-        return payService.handleNotify(xmlBody);
+    public String notify(@RequestBody(required = false) String body,
+                         @org.springframework.web.bind.annotation.RequestHeader(value = "Wechatpay-Signature", required = false) String signature,
+                         @org.springframework.web.bind.annotation.RequestHeader(value = "Wechatpay-Serial", required = false) String serial,
+                         @org.springframework.web.bind.annotation.RequestHeader(value = "Wechatpay-Timestamp", required = false) String timestamp,
+                         @org.springframework.web.bind.annotation.RequestHeader(value = "Wechatpay-Nonce", required = false) String nonce) {
+        // 微信支付 API v3 回调:JSON body 经 AES-256-GCM 解密后幂等入账。
+        // test-mode 下单即入账,此处兜底;生产回调解密见 PayService.handleNotify。
+        // 验签需商户平台证书(Wechatpay-Serial 对应证书公钥验签),商户接入后补充。
+        return payService.handleNotify(body);
     }
 }

+ 2 - 2
train-backend/src/main/java/com/train/service/CertService.java

@@ -77,11 +77,11 @@ public class CertService {
             } else {
                 // 发号:当年该 level 最大 seq + 1
                 int year = Calendar.getInstance().get(Calendar.YEAR);
-                int maxSeq = trainCertificateMapper.selectCount(
+                Long maxSeq = trainCertificateMapper.selectCount(
                         new LambdaQueryWrapper<TrainCertificate>()
                                 .eq(TrainCertificate::getLevel, level)
                                 .eq(TrainCertificate::getYear, year));
-                int seq = maxSeq + 1;
+                int seq = maxSeq.intValue() + 1;
                 String certNo = String.format("CM-%s-%d-%03d", level, year, seq);
 
                 Date now = new Date();

+ 61 - 31
train-backend/src/main/java/com/train/service/PayService.java

@@ -39,10 +39,11 @@ import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
 
 /**
- * 支付服务:订单创建、微信支付(test-mode 模拟)、回调幂等入账、取消、退款。
- * <p>对齐 cfc(E:\cfc)PaymentService/VirtualPayService 的 test-mode 约定:
- * test-mode=true 时跳过微信 API 调用——create 直接置订单 paid(模拟支付成功),
- * notify 仅做幂等入账。金额一律以「分」为单位的整数存储。
+ * 支付服务:订单创建、微信支付(test-mode 模拟,生产走 API v3 客户端)、回调幂等入账、取消、退款。
+ * <p>对齐 cfc(E:\cfc)PaymentService 的 test-mode 约定与 API v3 接入方式:
+ * test-mode=true 时跳过微信 API 调用——create 直接置订单 paid(模拟支付成功);
+ * test-mode=false 时调用 WechatPayClient 统一下单(返回小程序 prepayParams),
+ * 回调经 AES-256-GCM 解密后幂等入账。金额一律以「分」为单位的整数存储。
  */
 @Slf4j
 @Service
@@ -72,6 +73,8 @@ public class PayService {
     private CfcActivityRegistrationMapper cfcActivityRegistrationMapper;
     @Resource
     private PlanController planController;
+    @Resource
+    private WechatPayClient wechatPayClient;
 
     @Value("${wechat.test-mode}")
     private boolean testMode;
@@ -110,7 +113,7 @@ public class PayService {
                         .eq(TrainOrder::getStatus, "unpaid")
                         .last("LIMIT 1"));
         if (exist != null) {
-            return buildOrderResult(exist);
+            return buildOrderResult(exist, createPrepayParams(exist, userId, "爱伴·AI之旅课程报名"));
         }
 
         // 金额:优先会员价(memberPrice),否则原价(price);单位为分
@@ -171,38 +174,37 @@ public class PayService {
         }
 
         TrainOrder saved = trainOrderMapper.selectById(order.getId());
-        return buildOrderResult(saved);
+        Map<String, Object> prepayParams = testMode ? null
+                : createPrepayParams(saved, userId, "爱伴·AI之旅课程报名");
+        return buildOrderResult(saved, prepayParams);
     }
 
     /**
-     * 微信支付回调处理(test-mode 下 create 已直接入账,此方法兜底幂等)。
-     * 生产环境需在此接入微信验签(商户接入后补充)。
+     * 微信支付回调处理(API v3:JSON body,AES-256-GCM 解密后幂等入账)。
+     * test-mode 下 create 已直接入账,此方法兜底幂等。
+     * 生产环境需在此接入微信验签(商户平台证书验签,商户接入后补充)。
      *
-     * @param xmlBody 微信回调 XML
-     * @return 微信约定返回(SUCCESS/FAIL)
+     * @param requestBody 微信回调 JSON body
+     * @return 微信 V3 约定的 JSON 返回({"code":"SUCCESS"...} / {"code":"FAIL"...}
      */
     @Transactional
-    public String handleNotify(String xmlBody) {
-        if (xmlBody == null || xmlBody.trim().isEmpty()) {
+    public String handleNotify(String requestBody) {
+        if (requestBody == null || requestBody.trim().isEmpty()) {
             log.warn("微信支付回调空报文");
-            return "FAIL";
+            return notifyResp("FAIL", "空报文");
         }
-        String outTradeNo = extractXmlValue(xmlBody, "out_trade_no");
-        if (outTradeNo == null) {
-            log.warn("微信支付回调缺少 out_trade_no: {}", xmlBody);
-            return "FAIL";
+        Map<String, String> payResult = wechatPayClient.handleNotify(requestBody);
+        if (payResult == null || payResult.get("outTradeNo") == null) {
+            log.warn("微信支付回调解密失败: {}", requestBody);
+            return notifyResp("FAIL", "解密失败");
         }
-        String txnId = extractXmlValue(xmlBody, "transaction_id");
-        confirmPaid(outTradeNo, txnId);
-        return "SUCCESS";
+        confirmPaid(payResult.get("outTradeNo"), payResult.get("transactionId"));
+        return notifyResp("SUCCESS", "成功");
     }
 
-    /** 简化 XML 取值(支持 CDATA),供回调解析使用 */
-    private String extractXmlValue(String xml, String tag) {
-        java.util.regex.Pattern p = java.util.regex.Pattern.compile(
-                "<" + tag + ">(?:<!\\[CDATA\\[)?(.*?)(?:\\]\\]>)?</" + tag + ">");
-        java.util.regex.Matcher m = p.matcher(xml);
-        return m.find() ? m.group(1).trim() : null;
+    /** 微信支付 V3 回调应答体 */
+    private String notifyResp(String code, String message) {
+        return "{\"code\":\"" + code + "\",\"message\":\"" + message + "\"}";
     }
 
     /**
@@ -406,6 +408,10 @@ public class PayService {
         if (refundAmount == null || refundAmount <= 0 || refundAmount > order.getAmount()) {
             throw new RuntimeException("退款金额不合法");
         }
+        // 生产模式:先调微信退款(V3),成功后落库;test-mode 直接置 refunded
+        if (!testMode) {
+            doWechatRefund(order, refundAmount);
+        }
         order.setRefundAmount(refundAmount);
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
@@ -503,7 +509,7 @@ public class PayService {
         if (go.getOrderId() != null) {
             TrainOrder exist = trainOrderMapper.selectById(go.getOrderId());
             if (exist != null && "unpaid".equals(exist.getStatus())) {
-                return buildOrderResult(exist);
+                return buildOrderResult(exist, createPrepayParams(exist, userId, "爱伴·AI之旅团报"));
             }
         }
         TrainOrder order = new TrainOrder();
@@ -531,7 +537,9 @@ public class PayService {
             completeGroupPaid(go, order);
         }
         TrainOrder saved = trainOrderMapper.selectById(order.getId());
-        return buildOrderResult(saved);
+        Map<String, Object> prepayParams = testMode ? null
+                : createPrepayParams(saved, userId, "爱伴·AI之旅团报");
+        return buildOrderResult(saved, prepayParams);
     }
 
     /**
@@ -593,17 +601,39 @@ public class PayService {
         trainGroupOrderMapper.updateById(go);
     }
 
-    private Map<String, Object> buildOrderResult(TrainOrder order) {
+    private Map<String, Object> buildOrderResult(TrainOrder order, Map<String, Object> prepayParams) {
         Map<String, Object> row = new HashMap<>();
         row.put("orderNo", order.getOrderNo());
         row.put("amount", order.getAmount());
         row.put("status", order.getStatus());
         row.put("payTime", order.getPayTime());
-        // prepayParams 为 null:test-mode 直接入账;生产接入微信统一下单后回填
-        row.put("prepayParams", null);
+        // prepayParams:null = test-mode 直接入账;生产环境为微信统一下单返回的小程序支付参数
+        row.put("prepayParams", prepayParams);
         return row;
     }
 
+    /**
+     * 生产模式:调用微信统一下单,返回小程序拉起支付的 prepayParams。
+     * openid 缺失时返回 null(前端按 test-mode 兜底提示),不阻塞下单。
+     */
+    private Map<String, Object> createPrepayParams(TrainOrder order, Long userId, String description) {
+        TrainUser user = userId == null ? null : trainUserMapper.selectById(userId);
+        String openid = user == null ? null : user.getOpenid();
+        if (openid == null || openid.isEmpty()) {
+            log.warn("学员 openid 缺失,跳过微信统一下单: orderNo={}, uid={}", order.getOrderNo(), userId);
+            return null;
+        }
+        return wechatPayClient.createJsapiPrepay(order.getOrderNo(), description, order.getAmount(), openid);
+    }
+
+    /**
+     * 生产模式:调用微信退款(V3)。退款单号:REF + 时间戳。
+     */
+    private void doWechatRefund(TrainOrder order, Integer refundAmount) {
+        String outRefundNo = "REF" + System.currentTimeMillis();
+        wechatPayClient.refund(order.getOrderNo(), outRefundNo, refundAmount, order.getAmount());
+    }
+
     /** 生成订单号:yyyyMMddHHmmss + 6位随机 */
     private String genOrderNo() {
         String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());

+ 382 - 0
train-backend/src/main/java/com/train/service/WechatPayClient.java

@@ -0,0 +1,382 @@
+package com.train.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 微信支付 V3 客户端(对齐 cfc E:\cfc 的 PaymentService 约定)。
+ * <p>API 版本:微信支付 API v3(JSON + RSA 签名 + AES-256-GCM 回调解密)。
+ * <p>test-mode=true 时跳过真实微信 API 调用(doPost 返回 mock prepay_id),
+ * 生产对接只需在 application.yml 配置商户号/密钥/证书后关闭 test-mode。
+ */
+@Slf4j
+@Component
+public class WechatPayClient {
+
+    private static final String WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
+    private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
+
+    @Value("${wechat.appid}")
+    private String appid;
+
+    @Value("${wechat.mch-id:}")
+    private String mchId;
+
+    @Value("${wechat.api-v3-key:}")
+    private String apiV3Key;
+
+    @Value("${wechat.mch-serial-no:}")
+    private String mchSerialNo;
+
+    @Value("${wechat.private-key-path:}")
+    private String privateKeyPath;
+
+    @Value("${wechat.notify-url:}")
+    private String notifyUrl;
+
+    @Value("${wechat.test-mode:true}")
+    private boolean testMode;
+
+    private PrivateKey privateKey;
+    private final OkHttpClient httpClient;
+
+    public WechatPayClient() {
+        httpClient = new OkHttpClient.Builder()
+                .connectTimeout(10, TimeUnit.SECONDS)
+                .readTimeout(10, TimeUnit.SECONDS)
+                .writeTimeout(10, TimeUnit.SECONDS)
+                .build();
+    }
+
+    @PostConstruct
+    public void init() {
+        if (testMode) {
+            log.info("微信支付使用测试模式,跳过商户私钥加载");
+            return;
+        }
+        if (privateKeyPath == null || privateKeyPath.isEmpty()) {
+            log.warn("微信支付私钥路径未配置(wechat.private-key-path),仅 test-mode 可用");
+            return;
+        }
+        try (InputStream is = loadPrivateKeyStream()) {
+            if (is == null) {
+                log.error("微信支付私钥文件未找到: {}", privateKeyPath);
+                return;
+            }
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
+                StringBuilder sb = new StringBuilder();
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    if (!line.startsWith("-----")) {
+                        sb.append(line.trim());
+                    }
+                }
+                byte[] keyBytes = Base64.getDecoder().decode(sb.toString());
+                PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+                KeyFactory kf = KeyFactory.getInstance("RSA");
+                privateKey = kf.generatePrivate(spec);
+                log.info("微信支付商户私钥加载成功");
+            }
+        } catch (Exception e) {
+            log.error("加载微信支付私钥失败: {}", e.getMessage());
+        }
+    }
+
+    /** 私钥加载:优先 classpath,其次绝对路径文件 */
+    private InputStream loadPrivateKeyStream() throws Exception {
+        String normal = privateKeyPath.replace("\\", "/");
+        InputStream is = getClass().getClassLoader().getResourceAsStream(normal);
+        if (is != null) {
+            return is;
+        }
+        File f = new File(privateKeyPath);
+        if (f.exists()) {
+            return new FileInputStream(f);
+        }
+        return null;
+    }
+
+    /**
+     * 微信 JSAPI 下单(v3 /v3/pay/transactions/jsapi)。
+     *
+     * @param orderNo     商户订单号
+     * @param description 商品描述
+     * @param totalFee    金额(分)
+     * @param openid      支付用户 openid(trade_type=JSAPI 必填)
+     * @return 小程序发起支付所需参数:appId/timeStamp/nonceStr/package/signType/paySign
+     */
+    public Map<String, Object> createJsapiPrepay(String orderNo, String description, Integer totalFee, String openid) {
+        JSONObject body = new JSONObject();
+        body.put("appid", appid);
+        body.put("mchid", mchId);
+        body.put("description", description);
+        body.put("out_trade_no", orderNo);
+        body.put("notify_url", notifyUrl);
+        JSONObject payer = new JSONObject();
+        payer.put("openid", openid);
+        body.put("payer", payer);
+        JSONObject amount = new JSONObject();
+        amount.put("total", totalFee);
+        amount.put("currency", "CNY");
+        body.put("amount", amount);
+
+        String respBody;
+        try {
+            respBody = doPost("/v3/pay/transactions/jsapi", body.toJSONString());
+        } catch (Exception e) {
+            log.error("微信统一下单请求异常: orderNo={}, err={}", orderNo, e.getMessage());
+            throw new RuntimeException("微信统一下单请求异常: " + e.getMessage());
+        }
+        JSONObject resp = JSON.parseObject(respBody);
+        String prepayId = resp.getString("prepay_id");
+        if (prepayId == null) {
+            log.error("微信统一下单失败: {}", respBody);
+            throw new RuntimeException("微信统一下单失败: "
+                    + resp.getString("code") + " " + resp.getString("message"));
+        }
+        String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
+        String nonceStr = UUID.randomUUID().toString().replace("-", "");
+        String packageStr = "prepay_id=" + prepayId;
+        String paySign = generatePaySign(appid, timeStamp, nonceStr, packageStr);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("appId", appid);
+        result.put("timeStamp", timeStamp);
+        result.put("nonceStr", nonceStr);
+        result.put("package", packageStr);
+        result.put("signType", "RSA");
+        result.put("paySign", paySign);
+        log.info("微信预支付订单创建成功: orderNo={}, prepayId={}", orderNo, prepayId);
+        return result;
+    }
+
+    /**
+     * 微信支付回调解析(V3:AES-256-GCM 解密 resource)。
+     *
+     * @param requestBody 回调 JSON body
+     * @return {outTradeNo, transactionId};解析失败返回 null
+     */
+    public Map<String, String> handleNotify(String requestBody) {
+        try {
+            JSONObject body = JSON.parseObject(requestBody);
+            JSONObject resource = body == null ? null : body.getJSONObject("resource");
+            if (resource == null) {
+                log.error("微信通知数据缺少resource字段");
+                return null;
+            }
+            String ciphertext = resource.getString("ciphertext");
+            String associatedData = resource.getString("associated_data");
+            String nonce = resource.getString("nonce");
+            String plaintext = decryptAes256Gcm(ciphertext, associatedData, nonce);
+            JSONObject payResult = JSON.parseObject(plaintext);
+            Map<String, String> result = new LinkedHashMap<>();
+            result.put("outTradeNo", payResult.getString("out_trade_no"));
+            result.put("transactionId", payResult.getString("transaction_id"));
+            log.info("微信支付通知解析: orderNo={}, transactionId={}",
+                    result.get("outTradeNo"), result.get("transactionId"));
+            return result;
+        } catch (Exception e) {
+            log.error("处理微信支付通知异常: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 查询订单支付状态(v3 /v3/pay/transactions/out-trade-no/{no})。
+     *
+     * @return trade_state:SUCCESS / NOTPAY / CLOSED / REFUND 等;查询失败返回 null
+     */
+    public String queryTradeState(String outTradeNo) {
+        if (testMode) {
+            return null;
+        }
+        try {
+            String path = "/v3/pay/transactions/out-trade-no/" + outTradeNo + "?mchid=" + mchId;
+            String respBody = doGet(path);
+            if (respBody == null || respBody.isEmpty()) {
+                return null;
+            }
+            JSONObject resp = JSON.parseObject(respBody);
+            return resp.getString("trade_state");
+        } catch (Exception e) {
+            log.error("查询微信支付状态异常: orderNo={}, err={}", outTradeNo, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 微信退款(v3 /v3/refund/domestic/refunds)。
+     *
+     * @param orderNo      原商户订单号
+     * @param outRefundNo  商户退款单号
+     * @param refundAmount 退款金额(分)
+     * @param totalAmount  原订单金额(分)
+     * @return 退款状态 SUCCESS/PROCESSING/CLOSED;失败抛异常
+     */
+    public String refund(String orderNo, String outRefundNo, Integer refundAmount, Integer totalAmount) {
+        JSONObject body = new JSONObject();
+        body.put("out_trade_no", orderNo);
+        body.put("out_refund_no", outRefundNo);
+        JSONObject amount = new JSONObject();
+        amount.put("refund", refundAmount);
+        amount.put("total", totalAmount);
+        amount.put("currency", "CNY");
+        body.put("amount", amount);
+        String respBody;
+        try {
+            respBody = doPost("/v3/refund/domestic/refunds", body.toJSONString());
+        } catch (Exception e) {
+            log.error("微信退款请求异常: orderNo={}, err={}", orderNo, e.getMessage());
+            throw new RuntimeException("微信退款请求异常: " + e.getMessage());
+        }
+        JSONObject resp = JSON.parseObject(respBody);
+        String status = resp.getString("status");
+        if (status == null) {
+            log.error("微信退款失败: orderNo={}, response={}", orderNo, respBody);
+            throw new RuntimeException("微信退款失败: " + resp.getString("code") + " " + resp.getString("message"));
+        }
+        log.info("微信退款结果: orderNo={}, outRefundNo={}, status={}", orderNo, outRefundNo, status);
+        return status;
+    }
+
+    public boolean isTestMode() {
+        return testMode;
+    }
+
+    public boolean isConfigured() {
+        return !testMode && mchId != null && !mchId.isEmpty();
+    }
+
+    private String doGet(String path) throws Exception {
+        if (testMode) {
+            log.info("【测试模式】跳过微信API请求: GET {}", path);
+            return "{\"code\":\"TEST\",\"message\":\"mock\"}";
+        }
+        String url = WECHAT_API_BASE + path;
+        String auth = buildAuthHeader("GET", path, "");
+        Request request = new Request.Builder()
+                .url(url)
+                .header("Authorization", auth)
+                .header("Accept", "application/json")
+                .header("User-Agent", "train-backend")
+                .get()
+                .build();
+        try (Response response = httpClient.newCall(request).execute()) {
+            String respBody = response.body() != null ? response.body().string() : "";
+            if (!response.isSuccessful()) {
+                log.error("微信API GET请求失败: status={}, path={}, body={}", response.code(), path, respBody);
+            }
+            return respBody;
+        }
+    }
+
+    private String doPost(String path, String body) throws Exception {
+        if (testMode) {
+            log.info("【测试模式】跳过微信API请求: POST {}", path);
+            return "{\"code\":\"TEST\",\"message\":\"mock\",\"prepay_id\":\"mock_prepay_id_" + System.currentTimeMillis() + "\"}";
+        }
+        String url = WECHAT_API_BASE + path;
+        String auth = buildAuthHeader("POST", path, body);
+        Request request = new Request.Builder()
+                .url(url)
+                .header("Authorization", auth)
+                .header("Accept", "application/json")
+                .header("Content-Type", "application/json")
+                .header("User-Agent", "train-backend")
+                .post(RequestBody.create(JSON_MEDIA, body))
+                .build();
+        try (Response response = httpClient.newCall(request).execute()) {
+            String respBody = response.body() != null ? response.body().string() : "";
+            if (!response.isSuccessful()) {
+                log.error("微信API POST请求失败: status={}, path={}, body={}", response.code(), path, respBody);
+            }
+            return respBody;
+        }
+    }
+
+    private String buildAuthHeader(String method, String path, String body) throws Exception {
+        if (privateKey == null) {
+            log.warn("微信支付私钥未配置,使用模拟认证头");
+            return "WECHATPAY2-SHA256-RSA2048 mchid=\"" + mchId
+                    + "\",nonce_str=\"mock\",timestamp=\"0\",serial_no=\"mock\",signature=\"mock\"";
+        }
+        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        String message = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n";
+        Signature sign = Signature.getInstance("SHA256withRSA");
+        sign.initSign(privateKey);
+        sign.update(message.getBytes(StandardCharsets.UTF_8));
+        String signature = Base64.getEncoder().encodeToString(sign.sign());
+        return "WECHATPAY2-SHA256-RSA2048 "
+                + "mchid=\"" + mchId + "\","
+                + "nonce_str=\"" + nonce + "\","
+                + "timestamp=\"" + timestamp + "\","
+                + "serial_no=\"" + mchSerialNo + "\","
+                + "signature=\"" + signature + "\"";
+    }
+
+    private String generatePaySign(String appId, String timeStamp, String nonceStr, String packageStr) {
+        if (privateKey == null) {
+            return "mock_pay_sign_" + System.currentTimeMillis();
+        }
+        try {
+            String message = appId + "\n" + timeStamp + "\n" + nonceStr + "\n" + packageStr + "\n";
+            Signature sign = Signature.getInstance("SHA256withRSA");
+            sign.initSign(privateKey);
+            sign.update(message.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(sign.sign());
+        } catch (Exception e) {
+            log.error("生成支付签名失败", e);
+            throw new RuntimeException("生成支付签名失败", e);
+        }
+    }
+
+    /** 微信支付 API v3 回调解密(APIv3 密钥作为 AES-256 key,需恰好 32 字节) */
+    public String decryptAes256Gcm(String ciphertext, String associatedData, String nonce) throws Exception {
+        if (apiV3Key == null || apiV3Key.isEmpty()) {
+            log.warn("API V3密钥未配置,使用模拟解密");
+            return "{\"out_trade_no\":\"mock\",\"transaction_id\":\"mock\",\"trade_state\":\"SUCCESS\"}";
+        }
+        byte[] aesKey = apiV3Key.getBytes(StandardCharsets.UTF_8);
+        if (aesKey.length != 32) {
+            log.warn("API V3密钥长度不为32字节,实际长度={}", aesKey.length);
+        }
+        SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+        GCMParameterSpec gcmSpec = new GCMParameterSpec(128, Base64.getDecoder().decode(nonce));
+        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
+        if (associatedData != null && !associatedData.isEmpty()) {
+            cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
+        }
+        byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
+        return new String(plaintext, StandardCharsets.UTF_8);
+    }
+}

+ 6 - 0
train-backend/src/main/resources/application.yml

@@ -51,6 +51,12 @@ wechat:
   test-phone: "13800138000"  # 测试模式下 getPhoneNumber 的模拟手机号
   subscribe-url: https://api.weixin.qq.com/cgi-bin/message/subscribe/send
   qrcode-url: https://api.weixin.qq.com/wxa/getwxacodeunlimit
+  # 微信支付 API v3(对齐 cfc PaymentService;test-mode=true 时全部走 mock,无需配置)
+  mch-id: ${WECHAT_MCH_ID:}                # 微信支付商户号
+  api-v3-key: ${WECHAT_API_V3_KEY:}        # API v3 密钥(32 字节,回调 AES-GCM 解密)
+  mch-serial-no: ${WECHAT_MCH_SERIAL_NO:}  # 商户证书序列号(Authorization 头)
+  private-key-path: ${WECHAT_PRIVATE_KEY_PATH:}  # 商户 API 私钥文件路径(classpath 或绝对路径)
+  notify-url: ${WECHAT_NOTIFY_URL:https://cmai.etotem.com.cn/api/pay/notify}  # 支付回调公网地址
   # 订阅消息模板 ID(生产填写:微信公众平台「订阅消息」申请后回填)
   # key 与定时任务 action 对应:followup_t1..30 / preclass_t7..t1
   message-template:

+ 2 - 2
train-frontend/config.js

@@ -13,10 +13,10 @@ try {
         API_BASE_URL = 'http://localhost:9083'
         break
       case 'trial':
-        API_BASE_URL = 'http://cmai.etotem.com.cn'
+        API_BASE_URL = 'https://cmai.etotem.com.cn'
         break
       case 'release':
-        API_BASE_URL = 'http://cmai.etotem.com.cn'
+        API_BASE_URL = 'https://cmai.etotem.com.cn'
         break
     }
   }