Bladeren bron

fix: 支付宝授权通知状态NORMAL视同AUTHED - 归一授权状态值域

报文实证 alipay.fund.authorize.status.notify 签约通知 status=NORMAL(生效中)。
此前原样落库导致: 前端不识别显示生成按钮、点击被预检拦截报「存在未完成的授权申请」、
制单预检查不到AUTHED记录无法制单。修复: 通知侧NORMAL归一AUTHED(源头),
service 三处判据(重复授权/查询回写/制单预检)与前端展示兼容存量NORMAL。
alphaH 5 dagen geleden
bovenliggende
commit
5f1156ef84

+ 6 - 3
frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue

@@ -70,6 +70,8 @@ const qrcodeCanvas = ref<HTMLCanvasElement>();
 const AUTH_STATUS_TEXT: Record<string, string> = {
   AUTHING: "授权中",
   AUTHED: "已授权",
+  // 存量兼容: 通知归一前落库的 NORMAL(支付宝生效状态)视同已授权
+  NORMAL: "已授权",
   UNBIND: "已解绑",
 };
 
@@ -98,9 +100,10 @@ async function loadLatest() {
       status.value = "AUTHING";
       agreementNo.value = "";
       if (link.value) nextTick(() => drawQRCode());
-    } else if (latest.status === "AUTHED") {
+    } else if (latest.status === "AUTHED" || latest.status === "NORMAL") {
+      // NORMAL 为通知归一前落库的支付宝生效状态,视同已授权
       outBizNo.value = latest.out_biz_no;
-      status.value = "AUTHED";
+      status.value = latest.status;
       agreementNo.value = latest.agreement_no || "";
       isAuthed.value = true;
     }
@@ -163,7 +166,7 @@ async function handleQuery() {
     const res = await BatchPayAPI.queryAuthorize(enterpriseId.value, outBizNo.value);
     status.value = res.data.data.status;
     agreementNo.value = res.data.data.agreement_no || "";
-    if (status.value === "AUTHED") isAuthed.value = true;
+    if (status.value === "AUTHED" || status.value === "NORMAL") isAuthed.value = true;
   } finally {
     querying.value = false;
   }

+ 15 - 6
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -115,7 +115,7 @@ public class AlipayBatchPayService {
                         .orderByDesc(BatchAuthorizeEntity::getId)
                         .last("LIMIT 1"));
         if (existing != null) {
-            if ("AUTHED".equals(existing.getStatus()))
+            if (isAuthorizedStatus(existing.getStatus()))
                 throw new BusinessException(400, "该企业已存在生效授权,无需重新生成");
             existing.setStatus("UNBIND");
             batchAuthorizeMapper.updateById(existing);
@@ -141,7 +141,7 @@ public class AlipayBatchPayService {
                         .ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
         String outBizNo = SnowflakeIdGenerator.nextIdStr();
         if (existing != null) {
-            if ("AUTHED".equals(existing.getStatus()))
+            if (isAuthorizedStatus(existing.getStatus()))
                 throw new BusinessException(400, "该付款方已存在制单授权申请,无需重复授权");
             if (!isAuthorizeExpired(existing))
                 throw new BusinessException(400, "存在未完成的授权申请,请先完成授权或稍后重试");
@@ -224,8 +224,9 @@ public class AlipayBatchPayService {
                 throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
             }
 
-            // 仅 AUTHED 才回写: UNBIND 也返回协议号, 直接回写会破坏本地状态机(UNBIND 由异步通知回写)
-            if ("AUTHED".equals(response.getStatus())
+            // 仅生效授权才回写(AUTHED/NORMAL 均视同生效,通知侧 NORMAL 已归一 AUTHED);
+            // UNBIND 也返回协议号, 直接回写会破坏本地状态机(UNBIND 由异步通知回写)
+            if (isAuthorizedStatus(response.getStatus())
                     && response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
                 BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
@@ -306,13 +307,13 @@ public class AlipayBatchPayService {
             // 付款方 + 制单授权协议(Ruling 19/22: 不接受客户端指定,付款方身份遵循系统惯例 identity 优先回退 enterprise_id)
             EnterpriseEntity ent = requireEnterprise(dto.getEnterpriseId());
             String payerUid = payerIdentity(ent);
-            // 未完成授权不允许制单: 该付款方无 AUTHED 授权记录 → 本地预检拦截;
+            // 未完成授权不允许制单: 该付款方无生效授权记录(AUTHED/NORMAL)→ 本地预检拦截;
             // 支付宝侧 AUTH_INFO_NOT_EXISTS 兜底保留(防本地与支付宝状态不一致的竞态)
             Long authedCount = batchAuthorizeMapper.selectCount(
                     new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
                             .eq(BatchAuthorizeEntity::getEnterpriseId, dto.getEnterpriseId())
                             .eq(BatchAuthorizeEntity::getParticipantId, payerUid)
-                            .eq(BatchAuthorizeEntity::getStatus, "AUTHED"));
+                            .in(BatchAuthorizeEntity::getStatus, "AUTHED", "NORMAL"));
             if (authedCount == null || authedCount == 0)
                 throw new BusinessException(400, "该企业尚未完成制单授权,请先在「制单授权」中生成授权链接并完成授权");
             Participant payer = new Participant();
@@ -599,6 +600,14 @@ public class AlipayBatchPayService {
         return ent.getIdentityType() != null ? ent.getIdentityType() : "ALIPAY_USER_ID";
     }
 
+    /**
+     * 授权状态是否生效: 本地值域 AUTHED(已授权)与 NORMAL(生效中,支付宝授权签约通知回写的原始状态)均视同生效。
+     * 源头归一在 BatchPayHandler(通知 NORMAL → 落库 AUTHED),此处兼容存量 NORMAL 记录。
+     */
+    private static boolean isAuthorizedStatus(String status) {
+        return "AUTHED".equals(status) || "NORMAL".equals(status);
+    }
+
     /** 租户隔离: 企业 ID 是业务必需参数,为空直接拒绝(防御 Controller body 路径的零校验) */
     private static void requireEnterpriseId(String enterpriseId) {
         if (enterpriseId == null || enterpriseId.isBlank())

+ 4 - 2
java/src/main/java/com/payment/platform/module/payment/notification/handler/BatchPayHandler.java

@@ -74,8 +74,10 @@ public class BatchPayHandler extends BaseNotifyHandler {
             return;
         }
         if (params.get("agreement_no") != null) entity.setAgreementNo(params.get("agreement_no"));
-        // 支付宝通知的 status/action 字段值以接口文档为准(AUTHED/UNBIND 等),此处直接回写
-        if (params.get("status") != null) entity.setStatus(params.get("status"));
+        // 状态值域归一: 支付宝授权签约通知 status 实证为 NORMAL(生效中,operation_type=sign),
+        // 本地值域以 AUTHED 表示生效授权,NORMAL 归一为 AUTHED(否则前端/预检不识别)
+        String status = params.get("status");
+        if (status != null) entity.setStatus("NORMAL".equals(status) ? "AUTHED" : status);
         batchAuthorizeMapper.updateById(entity);
         log.info("制单授权状态更新: out_biz_no={}, status={}", outBizNo, entity.getStatus());
     }

+ 41 - 0
java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java

@@ -787,6 +787,22 @@ class AlipayBatchPayServiceTest {
         assertEquals("2088IDENTITY", entCap.getValue().getParticipantId());
     }
 
+    @Test
+    void authorizeApply_normalExisting_throws() throws AlipayApiException {
+        // NORMAL 为通知归一前落库的支付宝生效状态 — 视同 AUTHED,拒绝重复授权
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setEnterpriseId("E100");
+        existing.setParticipantId("2088123412341234");
+        existing.setStatus("NORMAL");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
+
+        assertEquals(400, ex.getCode());
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
     @Test
     void authorizeApply_enterpriseNotFound_throws() throws AlipayApiException {
         // requireEnterprise 防御: 企业不存在直接拒绝,不发起授权
@@ -931,6 +947,31 @@ class AlipayBatchPayServiceTest {
         verify(batchDetailMapper, never()).insert(any());
     }
 
+    @Test
+    void batchCreate_normalAuthorized_proceeds() throws AlipayApiException {
+        // NORMAL(生效中)记录应放行制单预检 — 该企业已完成授权
+        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
+        resp.setOutBatchNo("B1");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setEnterpriseId("E100");
+        dto.setOutBatchNo("B1");
+        dto.setOrderTitle("t");
+        BatchCreateDTO.BatchDetailDTO detail = new BatchCreateDTO.BatchDetailDTO();
+        detail.setOutBizNo("D1");
+        detail.setAmount(new BigDecimal("10"));
+        detail.setPayeeIdentity("a@b.com");
+        detail.setPayeeIdentityType("ALIPAY_LOGON_ID");
+        detail.setPayeeName("张三");
+        dto.setDetails(List.of(detail));
+
+        Map<String, Object> result = service.batchCreate(dto);
+
+        assertEquals("B1", result.get("out_batch_no"));
+        verify(alipayClient).certificateExecute(any(AlipayFundBatchCreateRequest.class));
+    }
+
     @Test
     void batchCreate_authorizedIdentity_proceedsWithIdentityAsPayer() throws AlipayApiException {
         // 反馈轮 4/5 协同: identity 有值时预检与付款方均用 identity(而非 enterprise_id)

+ 19 - 0
java/src/test/java/com/payment/platform/module/payment/notification/handler/BatchPayHandlerTest.java

@@ -61,6 +61,25 @@ class BatchPayHandlerTest {
         verify(batchAuthorizeMapper).updateById(entity);
     }
 
+    @Test
+    void authorizeNotify_normalStatus_normalizedToAuthed() {
+        // 支付宝授权签约通知 status 实证为 NORMAL(生效中)— 归一为本地值域 AUTHED,
+        // 否则前端不识别且制单预检/重复授权预检会拦截
+        BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+        entity.setId(1L);
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(entity);
+
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "A1");
+        params.put("agreement_no", "AGMT001");
+        params.put("status", "NORMAL");
+        handler.dispatch("alipay.fund.authorize.status.notify", params, ctx());
+
+        assertEquals("AGMT001", entity.getAgreementNo());
+        assertEquals("AUTHED", entity.getStatus());
+        verify(batchAuthorizeMapper).updateById(entity);
+    }
+
     @Test
     void batchNotify_updatesOrderStatus() {
         BatchOrderEntity order = new BatchOrderEntity();