Преглед изворни кода

fix(backend): 修复腾讯云TTS签名错误 + 虚拟支付超时任务误杀普通支付订单

TTS:buildAuthorization 日期错误(Unix秒截前8位而非UTC yyyy-MM-dd)导致
AuthFailure.InvalidAuthorization;kDate 派生漏 TC3 前缀导致 SignatureFailure。
均已修复并新增签名单测(grep 验证 RED/GREEN)。

超时任务:仅虚拟支付通道订单(开关开启+道具配置存在)才允许 xpay 查单,
普通支付降级订单(v3-notify 通道)跳过,避免 STATUS_ORDER_NOT_FOUND 误置 cancelled
(2026-10-22 订单48 事件)。新增 VirtualPayTimeoutTaskTest 5 用例。
Xiaogang Liao пре 3 недеља
родитељ
комит
a3cd2d3c12

+ 24 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/TencentTtsService.java

@@ -16,6 +16,9 @@ import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
 import java.util.Base64;
 import java.util.Map;
 import java.util.TreeMap;
@@ -141,10 +144,12 @@ public class TencentTtsService {
 
     /**
      * 计算 TC3-HMAC-SHA256 签名头。
+     * package-private:单测直接验证签名(TencentTtsServiceAuthorizationTest)。
      */
-    private String buildAuthorization(String body, String timestamp) throws Exception {
-        String date = timestamp == null || timestamp.length() < 8
-                ? "" : timestamp.substring(0, 8); // 取前8位 yyyyMMdd
+    String buildAuthorization(String body, String timestamp) throws Exception {
+        // 腾讯云 TC3 签名要求 Credential 中的日期为 UTC 的 yyyy-MM-dd
+        // (timestamp 为 Unix 秒;不能像旧实现那样截取 Unix 秒字符串前 8 位)
+        String date = toUtcDate(timestamp);
         String service = SERVICE;
         String version = "tc3_request";
 
@@ -163,8 +168,8 @@ public class TencentTtsService {
         String stringToSign = ALGORITHM + "\n" + timestamp + "\n" + credentialScope + "\n"
                 + hashedCanonicalRequest;
 
-        // 3. Signature
-        byte[] secretDate = hmacSha256((secretKey).getBytes(StandardCharsets.UTF_8), date);
+        // 3. Signature:kDate = HMAC_SHA256("TC3"+SecretKey, date),腾讯云要求 key 带 "TC3" 前缀
+        byte[] secretDate = hmacSha256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date);
         byte[] secretService = hmacSha256(secretDate, service);
         byte[] secretSigning = hmacSha256(secretService, version);
         byte[] signatureBytes = hmacSha256(secretSigning, stringToSign);
@@ -193,4 +198,18 @@ public class TencentTtsService {
         }
         return sb.toString();
     }
+
+    /**
+     * 将 Unix 秒时间戳转换为 UTC 日期(yyyy-MM-dd),用于 TC3 签名 Credential。
+     * 旧实现直接截取 Unix 秒字符串前 8 位(如 1792678570 → "17926785"),不符合腾讯云要求。
+     */
+    private static String toUtcDate(String timestamp) {
+        if (timestamp == null || timestamp.trim().isEmpty()) {
+            throw new IllegalArgumentException("timestamp 为空,无法计算 TC3 签名日期");
+        }
+        long epochSecond = Long.parseLong(timestamp.trim());
+        return Instant.ofEpochSecond(epochSecond)
+                .atZone(ZoneOffset.UTC)
+                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+    }
 }

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/VirtualPayTimeoutTask.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.MemberSubscription;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
@@ -15,6 +16,8 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.AssessmentOrderService;
 import com.etotem.cfc.service.MemberSubscriptionService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.SysConfigService;
+import com.etotem.cfc.service.VirtualGoodsConfigService;
 import com.etotem.cfc.service.VirtualPayService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -68,6 +71,12 @@ public class VirtualPayTimeoutTask {
     @Resource
     private FamilyMapper familyMapper;
 
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
     @Value("${virtual-pay.payment-timeout-minutes:30}")
     private int paymentTimeoutMinutes;
 
@@ -161,8 +170,36 @@ public class VirtualPayTimeoutTask {
         return false;
     }
 
+    /**
+     * 判定订单是否属于"虚拟支付通道"(即下单时走 xpay,后续需要 xpay query_order 兜底)。
+     * 判据与 MembershipService.createOrder 降级逻辑同源:开关开启 且 该 levelCode
+     * 在 virtual_goods_config 中有启用的 MEMBERSHIP 道具。不满足则必然走了普通支付
+     * (v3-notify 通道),不应被 xpay 查单任务处理。
+     */
+    boolean isVirtualPaymentOrder(PaymentOrder order) {
+        if (order == null || order.getLevelCode() == null) {
+            return false;
+        }
+        String switchVal = sysConfigService.getValue("member_pay_virtual_enabled");
+        boolean virtualEnabled = !"0".equals(switchVal);
+        if (!virtualEnabled) {
+            return false;
+        }
+        VirtualGoodsConfig cfg = virtualGoodsConfigService.getEnabled("MEMBERSHIP", order.getLevelCode());
+        return cfg != null;
+    }
+
     /** ORD 前缀(会员订单)单笔处理,返回是否处理(解锁/关单) */
     private boolean handlePaymentOrder(PaymentOrder order) {
+        // 误杀防护(2026-10-22 订单48 事件):普通支付降级订单在 xpay 侧不存在,
+        // queryOrder 必然返回 STATUS_ORDER_NOT_FOUND 并会被误置 cancelled。
+        // 仅虚拟支付通道订单(开关开启 + 有道具配置)才允许 xpay 查单,与普通支付通道
+        // 订单(走 v3-notify)的查询/关单路径解耦。
+        if (!isVirtualPaymentOrder(order)) {
+            log.info("虚拟支付超时兜底: 非虚拟支付渠道会员订单, 跳过不查单不关单: orderNo={}, levelCode={}",
+                    order.getOrderNo(), order.getLevelCode());
+            return false;
+        }
         // family_id=NULL 的订单(下单接口未校验历史脏数据):无法向微信查单,直接置 cancelled
         if (order.getFamilyId() == null) {
             order.setStatus("cancelled");

+ 48 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TencentTtsServiceAuthorizationTest.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * TencentTtsService TC3-HMAC-SHA256 签名回归测试。
+ *
+ * 期望值由独立 Python 实现计算,且该实现已用真实腾讯云 TTS API 验证通过
+ * (腾讯云返回 PkgExhausted 而非 SignatureFailure,证明签名正确)。
+ * 两个关键点:
+ *   1. Credential 中的日期必须是 UTC 的 yyyy-MM-dd(不能是 Unix 秒截断)
+ *   2. kDate 派生密钥必须带 "TC3" 前缀
+ */
+public class TencentTtsServiceAuthorizationTest {
+
+    private TencentTtsService service;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        service = new TencentTtsService();
+        ReflectionTestUtils.setField(service, "secretId", "AKIDtest");
+        ReflectionTestUtils.setField(service, "secretKey", "testkey");
+    }
+
+    /** 固定输入与 Python 验证实现一致:body={"Text":"hi"}, timestamp=1792678570 */
+    @Test
+    public void buildAuthorization_matchesVerifiedTc3Signature() throws Exception {
+        String body = "{\"Text\":\"hi\"}";
+        String timestamp = "1792678570";
+        String expected = "TC3-HMAC-SHA256 Credential=AKIDtest/2026-10-22/tts/tc3_request, "
+                + "SignedHeaders=content-type;host, "
+                + "Signature=3357cea9daa02de1d6a9541c5bcca55cf1152c1a50fc5a3dc2ee0874ebb1fff4";
+        assertEquals(expected, service.buildAuthorization(body, timestamp));
+    }
+
+    /** Credential 中日期必须是 UTC yyyy-MM-dd 格式(timestamp 1792678570 → 2026-10-22 UTC) */
+    @Test
+    public void buildAuthorization_usesUtcDateInCredentialScope() throws Exception {
+        String auth = service.buildAuthorization("{}", "1792678570");
+        assertTrue(auth.contains("Credential=AKIDtest/2026-10-22/tts/tc3_request"),
+                "Credential 应含 UTC 日期 2026-10-22,实际: " + auth);
+    }
+}

+ 89 - 0
cfc-backend/src/test/java/com/etotem/cfc/task/VirtualPayTimeoutTaskTest.java

@@ -0,0 +1,89 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+import com.etotem.cfc.service.SysConfigService;
+import com.etotem.cfc.service.VirtualGoodsConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * VirtualPayTimeoutTask 误杀防护单测。
+ *
+ * 背景:createOrder 在"虚拟道具未配置"时降级普通微信支付(v3 JSAPI 通道),
+ * 这类订单在 xpay 侧不存在。旧逻辑对全部 pending 会员订单统一 xpay 查单,
+ * 查不到即 STATUS_ORDER_NOT_FOUND 误置 cancelled(2026-10-22 订单48 事件)。
+ *
+ * 修复判据与 MembershipService.createOrder 降级逻辑同源:
+ * 仅当 系统开关 member_pay_virtual_enabled != "0" 且 virtual_goods_config
+ * 中存在启用的 MEMBERSHIP 道具配置时,订单才属于虚拟支付通道、才允许 xpay 查单。
+ */
+public class VirtualPayTimeoutTaskTest {
+
+    private VirtualPayTimeoutTask task;
+    private SysConfigService sysConfigService;
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
+    @BeforeEach
+    public void setUp() {
+        task = new VirtualPayTimeoutTask();
+        sysConfigService = mock(SysConfigService.class);
+        virtualGoodsConfigService = mock(VirtualGoodsConfigService.class);
+        ReflectionTestUtils.setField(task, "sysConfigService", sysConfigService);
+        ReflectionTestUtils.setField(task, "virtualGoodsConfigService", virtualGoodsConfigService);
+    }
+
+    private PaymentOrder basicOrder() {
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo("ORD-TEST-BASIC");
+        order.setFamilyId(58L);
+        order.setLevelCode("BASIC");
+        order.setStatus("pending");
+        return order;
+    }
+
+    /** 开关开启 + 有道具配置 → 虚拟支付通道,允许查单 */
+    @Test
+    public void isVirtualPaymentOrder_whenSwitchOnAndGoodsConfigured() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "BASIC")).thenReturn(new VirtualGoodsConfig());
+        assertTrue(task.isVirtualPaymentOrder(basicOrder()));
+    }
+
+    /** 开关开启 + 无道具配置(订单48 场景:BASIC 无配置)→ 普通支付通道,跳过 */
+    @Test
+    public void notVirtualPaymentOrder_whenNoGoodsConfig() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "BASIC")).thenReturn(null);
+        assertFalse(task.isVirtualPaymentOrder(basicOrder()));
+    }
+
+    /** 开关关闭(=0)→ 全部走普通支付,跳过 */
+    @Test
+    public void notVirtualPaymentOrder_whenSwitchOff() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("0");
+        assertFalse(task.isVirtualPaymentOrder(basicOrder()));
+    }
+
+    /** 开关未设置(null) → 按 createOrder 缺省逻辑视为开启,但有配置才算虚拟 */
+    @Test
+    public void defaultSwitchOn_whenConfigMissing() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn(null);
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "BASIC")).thenReturn(new VirtualGoodsConfig());
+        assertTrue(task.isVirtualPaymentOrder(basicOrder()));
+    }
+
+    /** 开关未设置(null) + 无配置 → 普通支付 */
+    @Test
+    public void defaultSwitchOnButNoGoods() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn(null);
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "BASIC")).thenReturn(null);
+        assertFalse(task.isVirtualPaymentOrder(basicOrder()));
+    }
+}