فهرست منبع

feat: 授权查询暴露 authUserInfo + openId

- BatchSubjectService.query 返回 Map<String,Object>, 扩展 open_id +
  auth_user_info(BankAuthUserInfo 5 字段判空, Map.of 不支持 null);
  未授权分支返回结构一致(open_id 空串 + 空对象), 前端无需分支防御
- 授权列表 AUTHED/NORMAL 行新增「授权详情」按钮, dialog 重点渲染
  OpenID 高亮块 + 授权用户信息(支付宝账号/昵称/姓名/身份证脱敏/外标卡号)
- queryAuthorize 前端类型 AuthorizeQueryResult 对齐后端契约
- 新增 query_returnsAuthUserInfoAndOpenId 用例, 3 个存量 query 用例改签名
alphaH 4 روز پیش
والد
کامیت
c556b25a6d

+ 21 - 2
frontend/src/api/module_payment/batch.ts

@@ -2,6 +2,25 @@ import request from "@/utils/request";
 
 const API_PATH = "/payment/batch";
 
+/**
+ * 授权查询结果(后端透传 alipay.fund.authorize.uni.query 响应,SNAKE_CASE 序列化)
+ * authUserInfo(授权人信息)仅在用户完成授权后回传;open_id 为授权账号在应用下的唯一标识
+ */
+export interface AuthorizeQueryResult {
+  agreement_no: string;
+  /** AUTHED/NORMAL 已授权 / AUTHING 授权中 / UNBIND 已解绑 */
+  status: string;
+  open_id?: string;
+  auth_user_info?: {
+    alipay_account_no?: string;
+    alipay_nick_name?: string;
+    alipay_out_card_no?: string;
+    /** 授权人身份证号(前端脱敏展示) */
+    user_cert_no?: string;
+    user_name?: string;
+  };
+}
+
 /** 制单授权记录(后端 BatchAuthorizeEntity,全局 SNAKE_CASE 序列化) */
 export interface BatchAuthorizeVO {
   /** 雪花ID序列化为字符串(后端 @JsonSerialize(ToStringSerializer)),防止 JS 精度丢失 */
@@ -104,9 +123,9 @@ export const BatchPayAPI = {
     });
   },
 
-  /** 查询制单授权状态(AUTHED 时回写协议号) */
+  /** 查询制单授权状态(AUTHED 时回写协议号;已授权返回 auth_user_info + open_id) */
   queryAuthorize(outBizNo: string) {
-    return request<ApiResponse<{ agreement_no: string; status: string }>>({
+    return request<ApiResponse<AuthorizeQueryResult>>({
       url: `${API_PATH}/authorize/query`,
       method: "get",
       params: { out_biz_no: outBizNo },

+ 98 - 4
frontend/src/views/module_payment/batch/components/AuthorizeList.vue

@@ -71,9 +71,16 @@
               刷新状态
             </el-button>
           </template>
-          <span v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'" class="authed-tip">
-            已生效(可直接制单)
-          </span>
+          <template v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'">
+            <el-button
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              @click="handleAuthDetail(row)"
+            >
+              授权详情
+            </el-button>
+            <span class="authed-tip">已生效(可直接制单)</span>
+          </template>
         </template>
       </el-table-column>
     </el-table>
@@ -134,6 +141,43 @@
       </template>
     </el-dialog>
 
+    <!-- 授权详情(已授权主体: 重点渲染 authUserInfo 与 openId) -->
+    <el-dialog v-model="authDetailVisible" title="授权详情" width="560px">
+      <template v-if="authDetail">
+        <div class="auth-openid-block">
+          <div class="auth-openid-label">OpenID(授权账号在应用下的唯一标识)</div>
+          <div class="auth-openid-value">{{ authDetail.open_id || "-" }}</div>
+        </div>
+        <el-descriptions title="授权用户信息" :column="1" border style="margin-top: 16px">
+          <el-descriptions-item label="支付宝账号">
+            {{ authUserField("alipay_account_no") }}
+          </el-descriptions-item>
+          <el-descriptions-item label="昵称">
+            {{ authUserField("alipay_nick_name") }}
+          </el-descriptions-item>
+          <el-descriptions-item label="姓名">
+            {{ authUserField("user_name") }}
+          </el-descriptions-item>
+          <el-descriptions-item label="身份证号">
+            {{ maskCertNo(authUserField("user_cert_no")) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="外标卡号">
+            {{ authUserField("alipay_out_card_no") }}
+          </el-descriptions-item>
+        </el-descriptions>
+        <el-descriptions :column="1" border style="margin-top: 16px">
+          <el-descriptions-item label="协议号">{{ authDetail.agreement_no || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            {{ STATUS_TEXT[authDetail.status] || authDetail.status || "-" }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </template>
+      <el-empty v-else-if="authDetailLoading" description="查询中..." />
+      <template #footer>
+        <el-button type="primary" @click="authDetailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
     <!-- 授权链接展示(复制 + 二维码 + 刷新状态) -->
     <el-dialog v-model="linkVisible" title="授权链接(PC 浏览器打开 / 手机支付宝扫码)" width="640px">
       <div style="display: flex; align-items: flex-start; gap: 16px">
@@ -166,7 +210,7 @@
 
 <script setup lang="ts">
 import { computed, nextTick, onMounted, reactive, ref } from "vue";
-import BatchPayAPI, { type BatchAuthorizeVO } from "@/api/module_payment/batch";
+import BatchPayAPI, { type AuthorizeQueryResult, type BatchAuthorizeVO } from "@/api/module_payment/batch";
 import ProviderAPI, { type ServiceProviderOption } from "@/api/module_system/service_provider";
 import { ElMessage, ElMessageBox } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
@@ -379,6 +423,36 @@ async function handleRefresh(row: BatchAuthorizeVO) {
     queryingNo.value = "";
   }
 }
+
+// ==================== 授权详情(AUTHED 行: authUserInfo + openId) ====================
+
+const authDetailVisible = ref(false);
+const authDetailLoading = ref(false);
+const authDetail = ref<AuthorizeQueryResult | null>(null);
+const authUserField = (k: keyof NonNullable<AuthorizeQueryResult["auth_user_info"]>) =>
+  authDetail.value?.auth_user_info?.[k] || "-";
+
+/** 身份证号脱敏: 前3后4(如 310101********1234),过短/空则原样返回 */
+function maskCertNo(certNo: string) {
+  if (certNo === "-" || certNo.length < 8) return certNo;
+  return certNo.replace(/^(.{3}).*(.{4})$/, "$1********$2");
+}
+
+/** AUTHED 行: 实时查询支付宝返回授权详情(重点展示 authUserInfo 与 openId) */
+async function handleAuthDetail(row: BatchAuthorizeVO) {
+  authDetailVisible.value = true;
+  authDetail.value = null;
+  authDetailLoading.value = true;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(row.out_biz_no);
+    authDetail.value = res.data.data;
+  } catch {
+    // 查询失败原因已由 request.ts 提示,关闭弹层
+    authDetailVisible.value = false;
+  } finally {
+    authDetailLoading.value = false;
+  }
+}
 </script>
 
 <style lang="scss" scoped>
@@ -418,4 +492,24 @@ async function handleRefresh(row: BatchAuthorizeVO) {
   font-size: 12px;
   color: #909399;
 }
+
+/* 授权详情: OpenID 重点高亮块 */
+.auth-openid-block {
+  padding: 12px 16px;
+  background: #f0f9eb;
+  border: 1px solid #e1f3d8;
+  border-radius: 4px;
+}
+
+.auth-openid-label {
+  font-size: 12px;
+  color: #909399;
+}
+
+.auth-openid-value {
+  margin-top: 4px;
+  font-size: 14px;
+  font-weight: 600;
+  word-break: break-all;
+}
 </style>

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java

@@ -42,7 +42,7 @@ public class BatchPayController {
 
     @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
     @GetMapping("/authorize/query")
-    public Result<Map<String, String>> queryAuthorize(
+    public Result<Map<String, Object>> queryAuthorize(
             @RequestParam("out_biz_no") String outBizNo) {
         return Result.ok(batchSubjectService.query(outBizNo));
     }

+ 27 - 5
java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java

@@ -5,6 +5,7 @@ import com.alipay.api.AlipayClient;
 import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AuthParticipantInfo;
+import com.alipay.api.domain.BankAuthUserInfo;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
@@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.OffsetDateTime;
+import java.util.HashMap;
 import java.util.Map;
 
 /**
@@ -165,9 +167,10 @@ public class BatchSubjectService {
     /**
      * alipay.fund.authorize.uni.query — 查询制单授权状态(单协议)
      * <p>
-     * 本地记录按 out_biz_no 反查(client 解析依据 = 记录冗余的服务商),不存在时回退默认客户端
+     * 本地记录按 out_biz_no 反查(client 解析依据 = 记录冗余的服务商),不存在时回退默认客户端。
+     * 用户完成授权后支付宝回传授权人信息(auth_user_info)与应用下 openId,透传前端渲染。
      */
-    public Map<String, String> query(String outBizNo) {
+    public Map<String, Object> query(String outBizNo) {
         BatchAuthorizeEntity record = batchAuthorizeMapper.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
                         .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
@@ -186,7 +189,8 @@ public class BatchSubjectService {
             if (!response.isSuccess()) {
                 // 未授权(协议不存在)是正常业务状态而非查询失败: 前端应展示「授权中」而不是报错
                 if ("AUTHORIZATION_NOT_EXIST".equals(response.getSubCode())) {
-                    return Map.of("agreement_no", "", "status", "AUTHING");
+                    return Map.of("agreement_no", "", "status", "AUTHING", "open_id", "",
+                            "auth_user_info", Map.of());
                 }
                 throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
             }
@@ -199,13 +203,31 @@ public class BatchSubjectService {
                 record.setStatus("AUTHED");
                 batchAuthorizeMapper.updateById(record);
             }
-            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
-                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
+            Map<String, Object> result = new HashMap<>();
+            result.put("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "");
+            result.put("status", response.getStatus() != null ? response.getStatus() : "AUTHING");
+            result.put("open_id", nvl(response.getOpenId()));
+            // 授权人信息仅在用户完成授权后回传(未授权为 null → 键不返回,前端判空兜底)
+            if (response.getAuthUserInfo() != null) {
+                BankAuthUserInfo info = response.getAuthUserInfo();
+                result.put("auth_user_info", Map.of(
+                        "alipay_account_no", nvl(info.getAlipayAccountNo()),
+                        "alipay_nick_name", nvl(info.getAlipayNickName()),
+                        "alipay_out_card_no", nvl(info.getAlipayOutCardNo()),
+                        "user_cert_no", nvl(info.getUserCertNo()),
+                        "user_name", nvl(info.getUserName())));
+            }
+            return result;
         } catch (AlipayApiException e) {
             throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
         }
     }
 
+    /** null → 空串(Map.of 不允许 null 值) */
+    private static String nvl(String s) {
+        return s != null ? s : "";
+    }
+
     /** 主体授权列表(分页,主体可选筛选)— 租户隔离由拦截器自动追加,不显式传 tenant_id */
     public PageResult<BatchAuthorizeEntity> list(String participantId, int pageNo, int pageSize) {
         var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()

+ 40 - 3
java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java

@@ -4,6 +4,7 @@ import com.alipay.api.AlipayApiException;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
+import com.alipay.api.domain.BankAuthUserInfo;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
@@ -288,10 +289,13 @@ class BatchSubjectServiceTest {
         resp.setStatus("AUTHED");
         when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.query("A1");
+        Map<String, Object> result = service.query("A1");
 
         assertEquals("AGMT001", result.get("agreement_no"));
         assertEquals("AUTHED", result.get("status"));
+        // 响应无 authUserInfo 时返回键不存在(前端判空兜底),open_id 恒为空串
+        assertEquals("", result.get("open_id"));
+        assertFalse(result.containsKey("auth_user_info"));
         // 生效授权回写本地: 协议号 + AUTHED
         ArgumentCaptor<BatchAuthorizeEntity> cap = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
         verify(batchAuthorizeMapper).updateById(cap.capture());
@@ -299,6 +303,36 @@ class BatchSubjectServiceTest {
         assertEquals("AUTHED", cap.getValue().getStatus());
     }
 
+    @Test
+    void query_returnsAuthUserInfoAndOpenId() throws AlipayApiException {
+        // 用户完成授权后支付宝回传授权人信息(BankAuthUserInfo)+ 应用下 openId,需透传给前端渲染
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setAgreementNo("AGMT001");
+        resp.setStatus("AUTHED");
+        resp.setOpenId("208800000000000000000001");
+        BankAuthUserInfo info = new BankAuthUserInfo();
+        info.setAlipayAccountNo("2088111122223333");
+        info.setAlipayNickName("张三");
+        info.setAlipayOutCardNo("6222000011112222");
+        info.setUserCertNo("310101199001011234");
+        info.setUserName("张三");
+        resp.setAuthUserInfo(info);
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, Object> result = service.query("A1");
+
+        assertEquals("208800000000000000000001", result.get("open_id"));
+        @SuppressWarnings("unchecked")
+        Map<String, String> authInfo = (Map<String, String>) result.get("auth_user_info");
+        assertNotNull(authInfo);
+        assertEquals("2088111122223333", authInfo.get("alipay_account_no"));
+        assertEquals("张三", authInfo.get("alipay_nick_name"));
+        assertEquals("6222000011112222", authInfo.get("alipay_out_card_no"));
+        assertEquals("310101199001011234", authInfo.get("user_cert_no"));
+        assertEquals("张三", authInfo.get("user_name"));
+    }
+
     @Test
     void query_unbind_doesNotOverwriteLocalStatus() throws AlipayApiException {
         mockLocalRecord();
@@ -307,7 +341,7 @@ class BatchSubjectServiceTest {
         resp.setStatus("UNBIND");
         when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.query("A1");
+        Map<String, Object> result = service.query("A1");
 
         assertEquals("AGMT001", result.get("agreement_no"));
         assertEquals("UNBIND", result.get("status"));
@@ -325,10 +359,13 @@ class BatchSubjectServiceTest {
         resp.setMsg("Business Failed");
         when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
 
-        Map<String, String> result = service.query("A1");
+        Map<String, Object> result = service.query("A1");
 
         assertEquals("", result.get("agreement_no"));
         assertEquals("AUTHING", result.get("status"));
+        // 未授权分支结构一致: open_id 空串 + auth_user_info 空对象,前端无需分支防御
+        assertEquals("", result.get("open_id"));
+        assertEquals(Map.of(), result.get("auth_user_info"));
         verify(batchAuthorizeMapper, never()).updateById(any());
     }