Browse Source

fix: WAIT_PAY 批次可二次支付/可关闭 + 关闭失败回写真实状态

用户实测: ①本地被定时同步成 WAIT_PAY 后支付/关闭按钮消失(v-if 仅 INIT)
②关闭批次报 BATCH_ORDER_STATUS_INVALID(支付宝侧 INVALID=明细全部无效,SDK 注释实证)
- renderPay/batchClose 守卫放行 INIT+WAIT_PAY(未支付即可重新生成链接/可关闭)
- batchClose 失败时 detail.query 回写真实状态(避免本地残留 INIT 反复点关闭)
- 前端按钮 v-if 放行 INIT+WAIT_PAY,状态文案增 WAIT_PAY=等待支付 / INVALID=已失效
测试: 59/59 Service 全绿(+3 用例,M3 断言文案同步更新)
alphaH 4 ngày trước cách đây
mục cha
commit
c5431230e3

+ 6 - 2
frontend/src/views/module_payment/account/components/BatchPayDetail.vue

@@ -5,7 +5,7 @@
         <span>批次详情 {{ order.out_batch_no }}</span>
         <div>
           <el-button
-            v-if="order.status === 'INIT'"
+            v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
             v-hasPerm="['module_payment:account:transfer']"
             type="primary"
             :loading="payLoading"
@@ -14,7 +14,7 @@
             生成支付链接
           </el-button>
           <el-button
-            v-if="order.status === 'INIT'"
+            v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
             v-hasPerm="['module_payment:account:transfer']"
             type="danger"
             @click="handleClose"
@@ -113,15 +113,19 @@ const refreshing = ref(false);
 type TagType = "primary" | "success" | "warning" | "info" | "danger";
 const STATUS_TAG: Record<string, TagType> = {
   INIT: "warning",
+  WAIT_PAY: "warning",
   SUCCESS: "success",
   DISUSE: "info",
   FAIL: "danger",
+  INVALID: "danger",
 };
 const STATUS_TEXT: Record<string, string> = {
   INIT: "受理中",
+  WAIT_PAY: "等待支付",
   SUCCESS: "成功",
   DISUSE: "已关闭",
   FAIL: "失败",
+  INVALID: "已失效",
 };
 const DETAIL_STATUS_TAG: Record<string, TagType> = {
   INIT: "info",

+ 6 - 2
frontend/src/views/module_payment/account/components/BatchPayList.vue

@@ -83,7 +83,7 @@
       <el-table-column label="操作" width="210" fixed="right">
         <template #default="{ row }">
           <el-button
-            v-if="row.status === 'INIT'"
+            v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
             v-hasPerm="['module_payment:account:transfer']"
             size="small"
             type="primary"
@@ -100,7 +100,7 @@
             详情
           </el-button>
           <el-button
-            v-if="row.status === 'INIT'"
+            v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
             v-hasPerm="['module_payment:account:transfer']"
             size="small"
             type="danger"
@@ -158,15 +158,19 @@ const searchForm = reactive({
 type TagType = "primary" | "success" | "warning" | "info" | "danger";
 const STATUS_TAG: Record<string, TagType> = {
   INIT: "warning",
+  WAIT_PAY: "warning",
   SUCCESS: "success",
   DISUSE: "info",
   FAIL: "danger",
+  INVALID: "danger",
 };
 const STATUS_TEXT: Record<string, string> = {
   INIT: "受理中",
+  WAIT_PAY: "等待支付",
   SUCCESS: "成功",
   DISUSE: "已关闭",
   FAIL: "失败",
+  INVALID: "已失效",
 };
 
 async function load() {

+ 34 - 7
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -421,8 +421,10 @@ public class AlipayBatchPayService {
                         .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
                         .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
         if (order == null) throw new BusinessException(404, "批次不存在");
-        if (!"INIT".equals(order.getStatus()))
-            throw new BusinessException(400, "仅受理中的批次可支付,当前状态: " + order.getStatus());
+        // INIT=已受理 / WAIT_PAY=等待支付(render 过支付链接但未支付)均可生成(或重新生成)支付链接——
+        // 用户实测: 本地被定时同步成 WAIT_PAY 后无法二次发起支付
+        if (!"INIT".equals(order.getStatus()) && !"WAIT_PAY".equals(order.getStatus()))
+            throw new BusinessException(400, "仅受理中/等待支付的批次可支付,当前状态: " + order.getStatus());
         try {
             AlipayFundTransRenderPayModel model = new AlipayFundTransRenderPayModel();
             model.setProductCode(BATCH_PRODUCT_CODE);
@@ -518,9 +520,9 @@ public class AlipayBatchPayService {
                         .eq(BatchOrderEntity::getOutBatchNo, outBatchNo)
                         .eq(BatchOrderEntity::getEnterpriseId, enterpriseId));
         if (order == null) throw new BusinessException(404, "批次不存在");
-        // 与 renderPay 一致的前置守卫: 仅 INIT 状态可关闭(提前给友好提示而非透传支付宝原文案)
-        if (!"INIT".equals(order.getStatus()))
-            throw new BusinessException(400, "仅 INIT 状态的批次可关闭");
+        // INIT=已受理 / WAIT_PAY=等待支付(未支付)均可关闭——用户实测: 本地被定时同步成 WAIT_PAY 后关闭按钮直接消失
+        if (!"INIT".equals(order.getStatus()) && !"WAIT_PAY".equals(order.getStatus()))
+            throw new BusinessException(400, "仅受理中/等待支付的批次可关闭,当前状态: " + order.getStatus());
         try {
             AlipayFundBatchCloseModel model = new AlipayFundBatchCloseModel();
             model.setBatchTransId(order.getBatchTransId());
@@ -530,8 +532,12 @@ public class AlipayBatchPayService {
             request.setBizModel(model);
             AlipayFundBatchCloseResponse response =
                     alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
-            if (!response.isSuccess())
-                throw new BusinessException(400, "关闭批次失败: " + response.getMsg());
+            if (!response.isSuccess()) {
+                // 关闭失败多为支付宝侧批次已不可关闭(如 INVALID 明细全部无效)——回写真实状态,
+                // 避免本地残留 INIT 让用户反复点关闭(用户实测: BATCH_ORDER_STATUS_INVALID)
+                syncBatchStatusFromAlipay(order, enterpriseId);
+                throw new BusinessException(400, "关闭批次失败: " + response.getMsg() + " (" + response.getSubCode() + ")");
+            }
             order.setStatus("DISUSE");
             batchOrderMapper.updateById(order);
             return Map.of("status", "DISUSE");
@@ -540,6 +546,27 @@ public class AlipayBatchPayService {
         }
     }
 
+    /** 关闭失败后回写支付宝侧真实批次状态(detail.query),失败静默(不掩盖关闭错误本身) */
+    private void syncBatchStatusFromAlipay(BatchOrderEntity order, String enterpriseId) {
+        try {
+            AlipayFundBatchDetailQueryModel model = new AlipayFundBatchDetailQueryModel();
+            model.setOutBatchNo(order.getOutBatchNo());
+            model.setProductCode(BATCH_PRODUCT_CODE);
+            model.setBizScene(BATCH_BIZ_SCENE);
+            AlipayFundBatchDetailQueryRequest request = new AlipayFundBatchDetailQueryRequest();
+            request.setBizModel(model);
+            AlipayFundBatchDetailQueryResponse response =
+                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
+            if (response.isSuccess() && response.getBatchStatus() != null
+                    && !response.getBatchStatus().equals(order.getStatus())) {
+                order.setStatus(response.getBatchStatus());
+                batchOrderMapper.updateById(order);
+            }
+        } catch (AlipayApiException ignored) {
+            // 回写失败不掩盖关闭失败本身
+        }
+    }
+
     // ==================== 定时状态同步(batchQuery 兜底) ====================
 
     /**

+ 69 - 2
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -1084,7 +1084,7 @@ class AlipayBatchPayServiceTest {
         assertEquals("ALIPAY_OPEN_ID", m.getPayerInfo().getIdentityType());
     }
 
-    // ==================== M3: batchClose INIT 守卫 ====================
+    // ==================== M3: batchClose INIT/WAIT_PAY 守卫 ====================
 
     @Test
     void batchClose_nonInitStatus_throwsBusinessException() throws AlipayApiException {
@@ -1097,10 +1097,77 @@ class AlipayBatchPayServiceTest {
         BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E100", "B1"));
 
         assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("INIT"), ex.getMessage());
+        assertTrue(ex.getMessage().contains("可关闭"), ex.getMessage());
         verify(alipayClient, never()).certificateExecute(any());
     }
 
+    @Test
+    void batchClose_waitPayStatus_callsCloseApi() throws AlipayApiException {
+        // WAIT_PAY=等待支付(render 过链接未支付)应可关闭(用户实测: 本地被同步成 WAIT_PAY 后关闭按钮消失)
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("WAIT_PAY");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class)))
+                .thenReturn(new AlipayFundBatchCloseResponse());
+
+        Map<String, String> result = service.batchClose("E100", "B1");
+
+        assertEquals("DISUSE", result.get("status"));
+        verify(alipayClient).certificateExecute(any(AlipayFundBatchCloseRequest.class));
+    }
+
+    @Test
+    void batchClose_failureSyncsBackAlipayStatus() throws AlipayApiException {
+        // 关闭失败(支付宝侧批次已不可关闭,用户实测 BATCH_ORDER_STATUS_INVALID)→
+        // detail.query 回写真实状态到本地,避免残留 INIT 让用户反复点关闭
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("INIT");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+        AlipayFundBatchCloseResponse closeResp = new AlipayFundBatchCloseResponse();
+        closeResp.setCode("40004");
+        closeResp.setSubCode("BATCH_ORDER_STATUS_INVALID");
+        closeResp.setSubMsg("批次状态不合法");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCloseRequest.class))).thenReturn(closeResp);
+        AlipayFundBatchDetailQueryResponse queryResp = new AlipayFundBatchDetailQueryResponse();
+        queryResp.setCode("10000");
+        queryResp.setBatchStatus("INVALID");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchDetailQueryRequest.class))).thenReturn(queryResp);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchClose("E100", "B1"));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("BATCH_ORDER_STATUS_INVALID"), ex.getMessage());
+        // 回写: detail.query 返回 INVALID → 本地状态覆盖为 INVALID
+        verify(batchOrderMapper).updateById(order);
+        assertEquals("INVALID", order.getStatus());
+    }
+
+    @Test
+    void renderPay_waitPayStatus_regeneratesPayUrl() throws AlipayApiException {
+        // WAIT_PAY=等待支付(render 过链接未支付)应可重新生成支付链接(用户实测: 不能二次发起支付)
+        BatchOrderEntity order = new BatchOrderEntity();
+        order.setEnterpriseId("E100");
+        order.setOutBatchNo("B1");
+        order.setBatchTransId("BT1");
+        order.setStatus("WAIT_PAY");
+        when(batchOrderMapper.selectOne(any())).thenReturn(order);
+        AlipayFundTransRenderPayResponse resp = new AlipayFundTransRenderPayResponse();
+        resp.setCode("10000");
+        resp.setInitializeCode("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ");
+        when(alipayClient.certificateExecute(any(AlipayFundTransRenderPayRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.renderPay("E100", "B1");
+
+        assertEquals("https://p.tb.cn/_2PG4jfHMVvg9vqBUdtx1HZ", result.get("pay_url"));
+        verify(batchOrderMapper).updateById(order);
+    }
+
     // ==================== 二轮复核修复: 空 enterpriseId 拒绝(条件式 eq 的空值漏洞) ====================
 
     @Test