Przeglądaj źródła

feat: 批量付款LOGON_ID主体扫码授权制单 - user.info.share取2088 userId(应用未开通open_id实证改用user_id)+ 授权回调复用白名单aplipay/auth按参数分流

alphaH 4 dni temu
rodzic
commit
8c6e6f487d
16 zmienionych plików z 708 dodań i 15 usunięć
  1. 15 1
      frontend/src/api/module_payment/batch.ts
  2. 55 2
      frontend/src/views/module_payment/batch/components/AuthorizeList.vue
  3. 11 2
      frontend/src/views/module_payment/batch/components/BatchPayCreate.vue
  4. 22 0
      java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java
  5. 13 0
      java/src/main/java/com/payment/platform/core/alipay/AlipayConfig.java
  6. 2 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  7. 5 1
      java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java
  8. 13 0
      java/src/main/java/com/payment/platform/module/payment/batch/controller/BatchPayController.java
  9. 6 0
      java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java
  10. 27 2
      java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java
  11. 102 0
      java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java
  12. 40 7
      java/src/main/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthController.java
  13. 4 0
      java/src/main/resources/application.yml
  14. 121 0
      java/src/test/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayServiceTest.java
  15. 190 0
      java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java
  16. 82 0
      java/src/test/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthControllerTest.java

+ 15 - 1
frontend/src/api/module_payment/batch.ts

@@ -12,6 +12,11 @@ export interface BatchAuthorizeVO {
   authorize_link?: string;
   participant_id: string;
   participant_id_type?: string;
+  /**
+   * 个人账号(ALIPAY_LOGON_ID)主体扫码授权获取的支付宝账号ID(2088 开头)— 制单时付款方 identity 用(ALIPAY_USER_ID)。
+   * 注: 本应用未开通支付宝 open_id 能力(user.info.share 响应实证),故不用 open_id 体系
+   */
+  alipay_user_id?: string;
   /** 主体名称(表单录入,同时作为授权申请 principal_info.name) */
   participant_name?: string;
   /** 服务商(表单下拉选择,授权申请 client 解析依据) */
@@ -69,7 +74,7 @@ export interface BatchDetailItem {
 
 /** 创建批次请求体(BatchCreateDTO,snake_case;付款方恒为已授权主体、协议号自动带出,均不接受客户端指定) */
 export interface BatchCreateParams {
-  /** 付款主体(授权主体支付宝 uid,制单时从已授权主体选择) */
+  /** 付款主体(授权主体支付宝账号/uid/openid,制单时从已授权主体选择;后端按主体类型映射制单 identity) */
   participant_id: string;
   order_title: string;
   transfer_scene_name?: string;
@@ -113,6 +118,15 @@ export const BatchPayAPI = {
     });
   },
 
+  /** 生成 user.info.share OAuth 授权链接(LOGON_ID 主体扫码获取支付宝ID 制单用)— 前端新窗口打开 */
+  openidAuthorizeUrl(id: string) {
+    return request<ApiResponse<string>>({
+      url: `${API_PATH}/authorize/openid/url`,
+      method: "get",
+      params: { id },
+    });
+  },
+
   /** 制单授权记录(分页,可按主体筛选) */
   authorizeList(params: { participant_id?: string; page_no?: number; page_size?: number }) {
     return request<ApiResponse<BatchPageResult<BatchAuthorizeVO[]>>>({

+ 55 - 2
frontend/src/views/module_payment/batch/components/AuthorizeList.vue

@@ -27,6 +27,7 @@
           <div v-if="row.participant_id_type" class="sub-text">
             {{ participantTypeText(row.participant_id_type) }}
           </div>
+          <div v-if="row.alipay_user_id" class="sub-text">支付宝ID: {{ row.alipay_user_id }}</div>
         </template>
       </el-table-column>
       <el-table-column label="服务商" min-width="140">
@@ -71,8 +72,19 @@
               刷新状态
             </el-button>
           </template>
-          <span v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'" class="authed-tip">
-            已生效(可直接制单)
+          <span v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'" style="display: flex; align-items: center; gap: 8px">
+            <!-- LOGON_ID 主体扫码授权获取支付宝ID:授权成功(alipay_user_id 落库)后按钮隐藏,无需再次获取 -->
+            <el-button
+              v-if="row.participant_id_type === 'ALIPAY_LOGON_ID' && !row.alipay_user_id"
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              type="primary"
+              link
+              @click="handleGetOpenId(row)"
+            >
+              授权
+            </el-button>
+            <span class="authed-tip">已生效(可直接制单)</span>
           </span>
         </template>
       </el-table-column>
@@ -212,6 +224,7 @@ async function load() {
 }
 
 onMounted(async () => {
+  handleOpenIdCallbackResult();
   load();
   try {
     const res = await ProviderAPI.options();
@@ -219,6 +232,26 @@ onMounted(async () => {
   } catch { /* 服务商下拉加载失败不阻塞列表 */ }
 });
 
+/**
+ * user.info.share OAuth 回调完成(后端 302 回跳 #/payment/batch?openid=success|fail)→
+ * 提示结果并清掉 query,避免刷新页面重复提示
+ */
+function handleOpenIdCallbackResult() {
+  const hash = location.hash || "";
+  const qIndex = hash.indexOf("?");
+  if (qIndex < 0) return;
+  const hashQuery = new URLSearchParams(hash.slice(qIndex + 1));
+  const result = hashQuery.get("openid");
+  if (result === "success") {
+    ElMessage.success("支付宝ID获取成功,可直接制单");
+  } else if (result === "fail") {
+    ElMessage.error(`支付宝ID获取失败:${decodeURIComponent(hashQuery.get("msg") || "未知原因")}`);
+  } else {
+    return;
+  }
+  history.replaceState(null, "", location.pathname + location.search + hash.slice(0, qIndex));
+}
+
 // ==================== 新增授权 ====================
 
 const applyVisible = ref(false);
@@ -364,6 +397,26 @@ async function handleRebind(row: BatchAuthorizeVO) {
   }
 }
 
+/**
+ * AUTHED + LOGON_ID 行: 当前窗口直接跳转 user.info.share OAuth 授权页(主体本人支付宝扫码授权)—
+ * 授权完成 → 支付宝回跳后端 callback 换支付宝ID(2088)落库 → 302 回本页(openid=success 提示)。
+ * 授权页跳回平台由 redirect_uri 保证,无需新窗口(避免多开窗口)
+ */
+async function handleGetOpenId(row: BatchAuthorizeVO) {
+  if (row.id == null) {
+    ElMessage.warning("记录缺少 id,无法生成授权链接");
+    return;
+  }
+  const res = await BatchPayAPI.openidAuthorizeUrl(row.id);
+  const url = res.data.data;
+  if (!url) {
+    ElMessage.warning("未返回授权链接");
+    return;
+  }
+  // 当前窗口跳转: 授权完成后支付宝自动回跳平台(登录态存 localStorage,同域跳回不丢失)
+  window.location.href = url;
+}
+
 /** AUTHING 行: 刷新状态(AUTHED 后协议号回写本地) */
 async function handleRefresh(row: BatchAuthorizeVO) {
   queryingNo.value = row.out_biz_no;

+ 11 - 2
frontend/src/views/module_payment/batch/components/BatchPayCreate.vue

@@ -16,12 +16,13 @@
           <el-option
             v-for="s in authedSubjects"
             :key="s.participant_id"
-            :label="`${s.participant_name || '未命名主体'}(${s.participant_id})`"
+            :label="subjectOptionLabel(s)"
             :value="s.participant_id"
+            :disabled="needOpenId(s)"
           />
         </el-select>
         <div class="form-item-tip">
-          仅展示已签约(AUTHED)主体;未签约请先到「制单授权」完成授权
+          仅展示已签约(AUTHED)主体;账号类型(手机号/邮箱)主体需先在「制单授权」中扫码获取支付宝ID
         </div>
       </el-form-item>
       <el-form-item label="批次标题" prop="order_title">
@@ -250,6 +251,14 @@ const rules: FormRules = {
 /** 已签约(AUTHED)主体 — 制单付款方候选 */
 const authedSubjects = ref<BatchAuthorizeVO[]>([]);
 
+/** LOGON_ID 主体且未扫码获取支付宝ID → 制单不可选(后端制单也会拦截,前端先行禁用提示) */
+const needOpenId = (s: BatchAuthorizeVO) =>
+  s.participant_id_type === "ALIPAY_LOGON_ID" && !s.alipay_user_id;
+
+/** 主体下拉文案: 账号类型未获取支付宝ID 时追加提示,避免静默禁用 */
+const subjectOptionLabel = (s: BatchAuthorizeVO) =>
+  `${s.participant_name || "未命名主体"}(${s.participant_id})${needOpenId(s) ? "(未获取支付宝ID,禁选)" : ""}`;
+
 /** 重新拉取已授权主体(每次切换到本 tab 时调用,保证授权完成后立即可选) */
 async function reloadSubjects() {
   // 主体量小,page_size=100 一次拉全(后端 list 按 id 倒序返回)

+ 22 - 0
java/src/main/java/com/payment/platform/core/alipay/AlipayClientFactory.java

@@ -167,6 +167,28 @@ public class AlipayClientFactory {
         return paymentAlipayConfig;
     }
 
+    /**
+     * 按服务商 + 业务类型解析 app_id(与 {@link #getClientByProvider} 同一条解析链,无客户端时也有值)
+     * <p>
+     * 用于 OAuth 授权链接(openauth.alipay.com)等需要 app_id 但不需要完整客户端的场景:
+     *   profile(appId) → 服务商默认(appId) → yml 配置
+     */
+    public String getAppIdByProvider(Long providerId, String bizType) {
+        if (providerId != null) {
+            if (bizType != null) {
+                ServiceProviderProfileEntity profile = getProfileEntity(providerId, bizType);
+                if (profile != null && profile.getAppId() != null) {
+                    return profile.getAppId();
+                }
+            }
+            ServiceProviderEntity sp = serviceProviderMapper.selectById(providerId);
+            if (sp != null && sp.getAppId() != null) {
+                return sp.getAppId();
+            }
+        }
+        return paymentAlipayConfig.getAppId();
+    }
+
     /**
      * 按企业 + 业务类型解析 app_id(与 {@link #getClient} 同一条解析链)
      */

+ 13 - 0
java/src/main/java/com/payment/platform/core/alipay/AlipayConfig.java

@@ -53,6 +53,19 @@ public class AlipayConfig {
     /** 同步跳转地址 */
     private String returnUrl;
 
+    /**
+     * 前端页面地址(如 http://localhost:5180)— OAuth 授权回调完成后 302 跳回的前端入口,
+     * openid 授权 URL 生成时校验非空(callback 无登录态,无法反向解析前端地址)
+     */
+    private String oauthFrontUrl;
+
+    /**
+     * OAuth 授权回调地址(完整 URL)— 支付宝开放平台「授权回调地址」白名单只能配一个,
+     * 与当面付 app_auth_code 授权共用(aplipay/auth,按参数分流),须与白名单配置完全一致;
+     * openid 授权 URL 生成时校验非空(redirect_uri 固定配置,避免反代下动态 Host 与白名单不一致)
+     */
+    private String oauthRedirectUri;
+
     /** 最大重试次数 */
     private int maxRetries = 3;
 

+ 2 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -57,6 +57,8 @@ public class SecurityConfig {
             "/system/notice/available",
             "/payment/notify/health",
             "/payment/notify/alipay",
+            // 支付宝授权回调白名单地址(当面付 app_auth_code + openid auth_code 共用,按参数分流;
+            // 无登录态,租户隔离由 TenantInnerInterceptor 条件表放行)
             "/payment/aplipay/auth",
             "/v3/api-docs/**",
             "/swagger-ui/**",

+ 5 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -68,7 +68,11 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "sys_tenant_api_key",    // 开放API Key(TenantApiKeyAuthFilter 认证阶段无租户上下文,
                                      //   API Key 全局唯一,须跨租户查询;管理端登录后仍按租户隔离)
             "open_transfer",         // 开放转账映射(支付宝通知回调 notifyTransferResult 无认证上下文)
-            "open_conf"              // 开放配置(回调通知 resolveReturnUrl 无认证上下文)
+            "open_conf",             // 开放配置(回调通知 resolveReturnUrl 无认证上下文)
+            "pay_batch_authorize"    // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文;
+                                     //   无认证时按 out_biz_no(雪花ID全局唯一)反查,无跨租户风险;
+                                     //   此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效,
+                                     //   实际靠前端 queryAuthorize 兜底 — 加入后通知回写真正生效)
     );
 
     public TenantInnerInterceptor() {

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

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.batch.controller;
 
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.alipay.AlipayConfig;
 import com.payment.platform.module.payment.batch.dto.AuthorizeApplyDTO;
 import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
 import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
@@ -24,6 +25,7 @@ public class BatchPayController {
 
     private final AlipayBatchPayService batchPayService;
     private final BatchSubjectService batchSubjectService;
+    private final AlipayConfig alipayConfig;
 
     @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
     @PostMapping("/authorize/apply")
@@ -47,6 +49,17 @@ public class BatchPayController {
         return Result.ok(batchSubjectService.query(outBizNo));
     }
 
+    /**
+     * 生成 user.info.share 授权链接(LOGON_ID 主体获取 open_id 制单用)— 前端新窗口打开。
+     * redirect_uri 为支付宝白名单固定地址(alipay.oauth-redirect-uri,与当面付 aplipay/auth 共用,按参数分流)
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:batch:authorize')")
+    @GetMapping("/authorize/openid/url")
+    public Result<String> openIdAuthorizeUrl(@RequestParam("id") Long id) {
+        return Result.ok(batchSubjectService.openIdAuthorizeUrl(
+                id, alipayConfig.getOauthRedirectUri(), alipayConfig.getOauthFrontUrl()));
+    }
+
     @PreAuthorize("@perm.hasAny('module_payment:batch:create')")
     @PostMapping("/create")
     public Result<Map<String, Object>> batchCreate(@Valid @RequestBody BatchCreateDTO dto) {

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/batch/entity/BatchAuthorizeEntity.java

@@ -31,6 +31,12 @@ public class BatchAuthorizeEntity extends PaymentTenantBaseEntity {
     /** 服务商(账号级表单下拉选择,授权申请 client 解析依据) */
     private Long serviceProviderId;
     private String agreementNo;
+    /**
+     * 个人账号(ALIPAY_LOGON_ID 主体)扫码授权后获取的支付宝账号 ID(2088 开头,user.info.share 返回)—
+     * 制单时付款方 identity 用(ALIPAY_USER_ID)。
+     * 注: 本应用未开通支付宝 open_id 能力(user.info.share 响应无 open_id 字段,实证),故直接使用 user_id
+     */
+    private String alipayUserId;
     /** AUTHING / AUTHED / UNBIND */
     private String status;
     private String authorizeLink;

+ 27 - 2
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -150,9 +150,12 @@ public class AlipayBatchPayService {
                 throw new BusinessException(400, "该主体尚未完成制单授权,请先在「制单授权」中生成授权链接并完成授权");
             // 协议号自动带出: 取该笔生效授权的 agreement_no(支付宝生成,不接受客户端指定)
             String agreementNo = authed.getAgreementNo();
+            // 付款方 identity 映射: 制单接口仅接受 ALIPAY_USER_ID / ALIPAY_OPEN_ID(用户实证),
+            // LOGON_ID 主体必须先用 OAuth 扫码获取支付宝ID(resolvePayerIdentity 内拦截)
+            Map<String, String> payerIdentity = resolvePayerIdentity(authed, payerUid);
             Participant payer = new Participant();
-            payer.setIdentity(payerUid);
-            payer.setIdentityType("ALIPAY_USER_ID");
+            payer.setIdentity(payerIdentity.get("identity"));
+            payer.setIdentityType(payerIdentity.get("identity_type"));
             if (agreementNo != null && !agreementNo.isBlank()) {
                 Map<String, String> ext = new LinkedHashMap<>();
                 ext.put("agreement_no", agreementNo);
@@ -231,6 +234,28 @@ public class AlipayBatchPayService {
         }
     }
 
+    /**
+     * 制单付款方 identity 解析(制单接口仅接受 ALIPAY_USER_ID / ALIPAY_OPEN_ID,用户实证):
+     *   LOGON_ID(手机号/邮箱)→ 需先扫码授权获取支付宝ID → identity=2088 user_id + ALIPAY_USER_ID(未获取拦截)
+     *     (本应用未开通 open_id 能力,响应实证无 open_id 字段,故不用 open_id 体系)
+     *   USER_ID(2088 uid)→ identity=participant_id + ALIPAY_USER_ID
+     *   OPEN_ID → identity=participant_id + ALIPAY_OPEN_ID
+     *   participant_id_type 为空(存量记录)→ 按支付宝账号ID处理
+     */
+    static Map<String, String> resolvePayerIdentity(BatchAuthorizeEntity authed, String participantId) {
+        String idType = authed.getParticipantIdType();
+        if ("ALIPAY_LOGON_ID".equals(idType)) {
+            if (authed.getAlipayUserId() == null || authed.getAlipayUserId().isBlank())
+                throw new BusinessException(400, "该主体为账号类型(手机号/邮箱),请先在「制单授权」中扫码获取支付宝ID后再制单");
+            return Map.of("identity", authed.getAlipayUserId(), "identity_type", "ALIPAY_USER_ID");
+        }
+        if ("ALIPAY_OPEN_ID".equals(idType)) {
+            return Map.of("identity", participantId, "identity_type", "ALIPAY_OPEN_ID");
+        }
+        // 默认(ALIPAY_USER_ID 与 null 存量): 支付宝账号ID
+        return Map.of("identity", participantId, "identity_type", "ALIPAY_USER_ID");
+    }
+
     /** 幂等返回体: 已受理批次信息(本地 selectCount 命中与 UNIQUE_VIOLATION 兜底共用) */
     private Map<String, Object> acceptedBatchResult(BatchOrderEntity existing) {
         return Map.of("out_batch_no", existing.getOutBatchNo(),

+ 102 - 0
java/src/main/java/com/payment/platform/module/payment/batch/service/BatchSubjectService.java

@@ -7,8 +7,12 @@ import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AuthParticipantInfo;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipaySystemOauthTokenRequest;
+import com.alipay.api.request.AlipayUserInfoShareRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
 import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.alipay.api.response.AlipaySystemOauthTokenResponse;
+import com.alipay.api.response.AlipayUserInfoShareResponse;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
@@ -22,6 +26,8 @@ import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.time.OffsetDateTime;
 import java.util.Map;
 
@@ -50,6 +56,11 @@ public class BatchSubjectService {
     private static final java.util.Set<String> PARTICIPANT_ID_TYPES = java.util.Set.of(
             "ALIPAY_LOGON_ID", "ALIPAY_OPEN_ID", "ALIPAY_USER_ID");
 
+    /** openauth.alipay.com OAuth 授权(获取支付宝用户信息)— 个人账号(LOGON_ID)制单换 open_id 用 */
+    private static final String OAUTH_AUTHORIZE_URL = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm";
+    /** user.info.share 需 scope=auth_user(用户基本信息+身份核验信息) */
+    private static final String OAUTH_SCOPE = "auth_user";
+
     private final AlipayClientFactory alipayClientFactory;
     private final BatchAuthorizeMapper batchAuthorizeMapper;
 
@@ -206,6 +217,97 @@ public class BatchSubjectService {
         }
     }
 
+    /**
+     * 生成 user.info.share OAuth 授权链接(ALIPAY_LOGON_ID 主体扫码获取支付宝账号ID 制单用)
+     * <p>
+     * 链路: 主体本人支付宝授权 → 回跳支付宝白名单回调地址(aplipay/auth,redirect_uri 携带 out_biz_no,无登录态)→
+     * oauth.token 换 access_token → user.info.share 取支付宝账号ID(2088)→ 落库 → 302 回前端。
+     * 制单接口不接受 LOGON_ID(用户实证),LOGON_ID 主体必须先扫码获取支付宝ID;
+     * 本应用未开通 open_id 能力(响应实证),故不使用 open_id 体系。
+     * <p>
+     * oauthRedirectUri 为支付宝开放平台「授权回调地址」白名单配置(当面付已占用,只能配一个,
+     * openid 与 app_auth_code 授权共用该地址按参数分流)— 白名单前缀匹配,redirect_uri 可带 out_biz_no query。
+     */
+    public String openIdAuthorizeUrl(Long id, String oauthRedirectUri, String frontUrl) {
+        if (frontUrl == null || frontUrl.isBlank())
+            throw new BusinessException(400, "未配置前端跳转地址(alipay.oauth-front-url),无法生成授权链接");
+        if (oauthRedirectUri == null || oauthRedirectUri.isBlank())
+            throw new BusinessException(400, "未配置回调地址(alipay.oauth-redirect-uri),无法生成授权链接");
+        if (id == null) throw new BusinessException(400, "缺少授权记录ID");
+        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectById(id);
+        if (entity == null) throw new BusinessException(404, "授权记录不存在");
+        if (!isAuthorizedStatus(entity.getStatus()))
+            throw new BusinessException(400, "仅已生效授权的主体可获取支付宝ID");
+        if (!"ALIPAY_LOGON_ID".equals(entity.getParticipantIdType()))
+            throw new BusinessException(400, "仅账号类型(手机号/邮箱)主体需要扫码获取支付宝ID,其他类型可直接制单");
+        String appId = alipayClientFactory.getAppIdByProvider(entity.getServiceProviderId(), BIZ_TYPE);
+        if (appId == null || appId.isBlank())
+            throw new BusinessException(400, "服务商未配置 app_id,无法生成授权链接");
+        String redirectUri = URLEncoder.encode(
+                oauthRedirectUri + "?out_biz_no=" + entity.getOutBizNo(),
+                StandardCharsets.UTF_8);
+        return OAUTH_AUTHORIZE_URL + "?app_id=" + appId + "&scope=" + OAUTH_SCOPE + "&redirect_uri=" + redirectUri;
+    }
+
+    /**
+     * OAuth 授权回调: auth_code 换 access_token → user.info.share 取支付宝账号ID(2088 user_id)→ 落库。
+     * <p>
+     * 注: 本应用未开通支付宝 open_id 能力(info.share 响应实证无 open_id 字段),
+     * 制单身份直接使用 user_id(ALIPAY_USER_ID 为制单接口标准支持)。
+     * 浏览器跳转无登录态,租户隔离由 TenantInnerInterceptor 条件表放行
+     * (out_biz_no 为雪花ID全局唯一,无跨租户风险,与授权签约通知同模式)。
+     */
+    @Transactional
+    public String openIdCallback(String authCode, String outBizNo) {
+        if (authCode == null || authCode.isBlank())
+            throw new BusinessException(400, "缺少授权码 auth_code");
+        if (outBizNo == null || outBizNo.isBlank())
+            throw new BusinessException(400, "缺少授权单号 out_biz_no");
+        BatchAuthorizeEntity entity = batchAuthorizeMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
+        if (entity == null) throw new BusinessException(404, "授权记录不存在");
+        if (!"ALIPAY_LOGON_ID".equals(entity.getParticipantIdType()))
+            throw new BusinessException(400, "仅账号类型主体需获取支付宝ID");
+        try {
+            AlipayClient client = alipayClientFactory.getClientByProvider(entity.getServiceProviderId(), BIZ_TYPE);
+            // 1. oauth.token: 授权码换用户授权令牌(SDK 实证: code + grant_type=authorization_code)
+            AlipaySystemOauthTokenRequest tokenRequest = new AlipaySystemOauthTokenRequest();
+            tokenRequest.setCode(authCode);
+            tokenRequest.setGrantType("authorization_code");
+            AlipaySystemOauthTokenResponse tokenResponse = client.certificateExecute(tokenRequest);
+            if (!tokenResponse.isSuccess())
+                throw new BusinessException(400, "换取授权令牌失败: " + tokenResponse.getMsg());
+            String accessToken = tokenResponse.getAccessToken();
+            if (accessToken == null || accessToken.isBlank())
+                throw new BusinessException(400, "换取授权令牌失败: 未返回 access_token");
+            // 2. user.info.share: 令牌换用户信息取 user_id(2088;access_token 走 execute 参数,非 bizModel)
+            AlipayUserInfoShareRequest shareRequest = new AlipayUserInfoShareRequest();
+            AlipayUserInfoShareResponse shareResponse = client.certificateExecute(shareRequest, accessToken);
+            if (!shareResponse.isSuccess())
+                throw new BusinessException(400, "获取用户信息失败: " + shareResponse.getMsg());
+            String userId = shareResponse.getUserId();
+            // 兜底: oauth.token 响应同样返回 user_id(SDK 字段实证: user_id/alipay_user_id),info.share 未返回时回退
+            if (userId == null || userId.isBlank()) {
+                if (tokenResponse.getAlipayUserId() != null && !tokenResponse.getAlipayUserId().isBlank())
+                    userId = tokenResponse.getAlipayUserId();
+                else if (tokenResponse.getUserId() != null && !tokenResponse.getUserId().isBlank())
+                    userId = tokenResponse.getUserId();
+            }
+            if (userId == null || userId.isBlank()) {
+                // 实证日志: info.share 原始响应体(无敏感字段,排查 user_id 缺失)
+                log.info("授权回调响应排查: shareBody={}", shareResponse.getBody());
+                throw new BusinessException(400, "获取用户信息失败: 未返回支付宝账号ID");
+            }
+            entity.setAlipayUserId(userId);
+            batchAuthorizeMapper.updateById(entity);
+            log.info("获取支付宝ID成功: out_biz_no={}, user_id={}", outBizNo, userId);
+            return userId;
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "获取支付宝ID失败: " + e.getMessage());
+        }
+    }
+
     /** 主体授权列表(分页,主体可选筛选)— 租户隔离由拦截器自动追加,不显式传 tenant_id */
     public PageResult<BatchAuthorizeEntity> list(String participantId, int pageNo, int pageSize) {
         var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()

+ 40 - 7
java/src/main/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthController.java

@@ -1,6 +1,9 @@
 package com.payment.platform.module.payment.facetoface.controller;
 
+import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.alipay.AlipayConfig;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
 import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
@@ -9,10 +12,15 @@ import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
+import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 
 /**
  * 支付宝授权相关接口 — @Controller 统一用 sendRedirect
+ * <p>
+ * 该地址同时是支付宝开放平台「授权回调地址」白名单(只能配一个),openid 用户授权
+ * (publicAppAuthorize, scope=auth_user)与当面付 app_auth_code 授权共用,按参数互斥分流:
+ * auth_code 仅 openid 授权回跳携带,app_auth_code 仅当面付 ISV 授权携带。
  */
 @Slf4j
 @Controller
@@ -21,37 +29,62 @@ import java.nio.charset.StandardCharsets;
 public class AlipayAuthController {
 
     private final FacetofaceService service;
+    private final BatchSubjectService batchSubjectService;
+    private final AlipayConfig alipayConfig;
 
     private static final String FRONTEND_URL = "https://qcsj88888.com/#/payment/enterprise";
 
     /**
-     * 授权回调 — 支付宝 302 回跳,用 app_auth_code 换 token,完成后重定向回前端
+     * 授权回调 — 支付宝 302 回跳(无登录态,SecurityConfig 白名单放行):
+     * 携带 auth_code(+ redirect_uri 的 out_biz_no)→ openid 业务(batch 制单主体获取 OpenID);
+     * 携带 app_auth_code → 当面付服务商授权原流程。均完成后重定向回前端。
      */
     @GetMapping("/auth")
     public void auth(
-            @RequestParam("app_id") String appId,
-            @RequestParam("source") String source,
-            @RequestParam("state") String state,
-            @RequestParam("app_auth_code") String appAuthCode,
+            @RequestParam(value = "app_id", required = false) String appId,
+            @RequestParam(value = "source", required = false) String source,
+            @RequestParam(value = "state", required = false) String state,
+            @RequestParam(value = "app_auth_code", required = false) String appAuthCode,
+            @RequestParam(value = "auth_code", required = false) String authCode,
+            @RequestParam(value = "out_biz_no", required = false) String outBizNo,
             HttpServletResponse response) throws IOException {
 
+        // openid 业务: auth_code 仅用户授权(publicAppAuthorize)回跳携带,与 app_auth_code 互斥
+        if (authCode != null && !authCode.isBlank()) {
+            handleOpenIdCallback(authCode, outBizNo, response);
+            return;
+        }
+
         String redirect;
 
         log.info("收到支付宝授权回调: app_id={}, source={}, enterprise_id={}, code_prefix={}",
                 appId, source, state,
-                appAuthCode.length() > 10 ? appAuthCode.substring(0, 10) : appAuthCode);
+                appAuthCode != null && appAuthCode.length() > 10 ? appAuthCode.substring(0, 10) : appAuthCode);
 
         try {
             service.exchangeAppAuthCode(state, appId, appAuthCode);
             redirect = FRONTEND_URL + "?auth=success";
         } catch (Exception e) {
             log.error("授权回调处理失败: enterprise_id={}, error={}", state, e.getMessage());
-            redirect = FRONTEND_URL + "?auth=fail&msg=" + java.net.URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
+            redirect = FRONTEND_URL + "?auth=fail&msg=" + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
         }
 
         response.sendRedirect(redirect);
     }
 
+    /** openid 授权回跳: auth_code 换 open_id 落库后 302 回前端(openid=success/fail 提示) */
+    private void handleOpenIdCallback(String authCode, String outBizNo, HttpServletResponse response) throws IOException {
+        String frontBase = alipayConfig.getOauthFrontUrl();
+        try {
+            batchSubjectService.openIdCallback(authCode, outBizNo);
+            response.sendRedirect(frontBase + "/#/payment/batch?openid=success");
+        } catch (BusinessException e) {
+            log.error("OpenID授权回调处理失败: out_biz_no={}, error={}", outBizNo, e.getMessage());
+            response.sendRedirect(frontBase + "/#/payment/batch?openid=fail&msg="
+                    + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8));
+        }
+    }
+
     /**
      * 生成授权链接 — 返回支付宝授权 URL,由前端完成重定向
      */

+ 4 - 0
java/src/main/resources/application.yml

@@ -94,6 +94,10 @@ alipay:
   sandbox: false
   notify-url: ""
   return-url: ""
+  # OAuth 授权回调完成后的前端跳转地址(如 http://localhost:5180)— openid 授权 URL 生成时校验非空
+  oauth-front-url: "https://qcsj88888.com"
+  # OAuth 授权回调地址(完整 URL)— 支付宝开放平台「授权回调地址」白名单只能配一个,与当面付 app_auth_code 授权共用(按参数分流),须与白名单完全一致
+  oauth-redirect-uri: "https://qcsj88888.com/api/v1/payment/aplipay/auth"
   max-retries: 3
   request-timeout: 30
   rate-limit: 100

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

@@ -788,4 +788,125 @@ class AlipayBatchPayServiceTest {
         assertEquals(OffsetDateTime.parse("2026-08-25T10:30:00+08:00"),
                 AlipayBatchPayService.parseTimeFilter("2026-08-25 10:30", true));
     }
+
+    // ==================== 个人账号(LOGON_ID): 制单付款方映射(扫码授权获取 2088 userId) ====================
+
+    private static BatchAuthorizeEntity authed(String idType, String alipayUserId) {
+        BatchAuthorizeEntity e = new BatchAuthorizeEntity();
+        e.setParticipantIdType(idType);
+        e.setAlipayUserId(alipayUserId);
+        return e;
+    }
+
+    @Test
+    void resolvePayerIdentity_logonId_withUserId_mapsToUserId() {
+        // LOGON_ID(手机号/邮箱)→ 制单时用扫码授权获取的 2088 user_id + ALIPAY_USER_ID
+        // (制单接口不接受 LOGON_ID,用户实证;本应用未开通 open_id 能力,故用 user_id)
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", "2088122583917741"), "18812345678");
+        assertEquals("2088122583917741", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_logonId_withoutUserId_rejected() {
+        // 未获取支付宝ID → 本地拦截(提示先获取),空串存量脏数据同样拦截
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", null), "18812345678"));
+        assertEquals(400, e.getCode());
+        assertTrue(e.getMessage().contains("支付宝ID"), e.getMessage());
+        assertThrows(BusinessException.class,
+                () -> AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_LOGON_ID", "  "), "18812345678"));
+    }
+
+    @Test
+    void resolvePayerIdentity_userId_mapsToUid() {
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_USER_ID", null), "2088111122223333");
+        assertEquals("2088111122223333", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_openIdType_mapsToParticipantId() {
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed("ALIPAY_OPEN_ID", null), "openid-abc");
+        assertEquals("openid-abc", r.get("identity"));
+        assertEquals("ALIPAY_OPEN_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void resolvePayerIdentity_nullIdType_defaultsToUserId() {
+        // 存量记录 participant_id_type 为空 → 按支付宝账号ID处理(与旧行为一致)
+        Map<String, String> r = AlipayBatchPayService.resolvePayerIdentity(authed(null, null), "2088111122223333");
+        assertEquals("2088111122223333", r.get("identity"));
+        assertEquals("ALIPAY_USER_ID", r.get("identity_type"));
+    }
+
+    @Test
+    void batchCreate_logonIdWithoutUserId_throwsFriendlyMessage() throws AlipayApiException {
+        // 全链路: LOGON_ID 主体未获取支付宝ID → 制单被本地拦截,不调支付宝
+        BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
+        authed.setStatus("AUTHED");
+        authed.setParticipantIdType("ALIPAY_LOGON_ID");
+        authed.setServiceProviderId(1L);
+        authed.setAgreementNo("AGMT001");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setParticipantId("18812345678");
+        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));
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.batchCreate(dto));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("支付宝ID"), ex.getMessage());
+        verify(alipayClient, never()).certificateExecute(any());
+        verify(batchOrderMapper, never()).insert(any());
+    }
+
+    @Test
+    void batchCreate_logonIdWithUserId_usesUserIdIdentity() throws AlipayApiException {
+        // 全链路: LOGON_ID 主体已获取支付宝ID → 付款方 identity=2088 user_id + ALIPAY_USER_ID
+        AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
+        resp.setOutBatchNo("B1");
+        when(alipayClient.certificateExecute(any(AlipayFundBatchCreateRequest.class))).thenReturn(resp);
+        BatchAuthorizeEntity authed = new BatchAuthorizeEntity();
+        authed.setStatus("AUTHED");
+        authed.setParticipantIdType("ALIPAY_LOGON_ID");
+        authed.setAlipayUserId("2088122583917741");
+        authed.setServiceProviderId(1L);
+        authed.setAgreementNo("AGMT001");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(authed);
+
+        BatchCreateDTO dto = new BatchCreateDTO();
+        dto.setParticipantId("18812345678");
+        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"));
+        ArgumentCaptor<AlipayFundBatchCreateRequest> cap = ArgumentCaptor.forClass(AlipayFundBatchCreateRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundBatchCreateModel m = (AlipayFundBatchCreateModel) cap.getValue().getBizModel();
+        assertEquals("2088122583917741", m.getPayerInfo().getIdentity());
+        assertEquals("ALIPAY_USER_ID", m.getPayerInfo().getIdentityType());
+        // DB 回写 payer_uid 恒为表单选择的主体账号(非 2088,与列表筛选口径一致)
+        ArgumentCaptor<BatchOrderEntity> order = ArgumentCaptor.forClass(BatchOrderEntity.class);
+        verify(batchOrderMapper).insert(order.capture());
+        assertEquals("18812345678", order.getValue().getPayerUid());
+    }
 }

+ 190 - 0
java/src/test/java/com/payment/platform/module/payment/batch/service/BatchSubjectServiceTest.java

@@ -6,8 +6,12 @@ import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
 import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
 import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.request.AlipaySystemOauthTokenRequest;
+import com.alipay.api.request.AlipayUserInfoShareRequest;
 import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
 import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.alipay.api.response.AlipaySystemOauthTokenResponse;
+import com.alipay.api.response.AlipayUserInfoShareResponse;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -409,4 +413,190 @@ class BatchSubjectServiceTest {
         assertEquals(1, result.getList().size());
         assertEquals("2088111122223333", result.getList().get(0).getParticipantId());
     }
+
+    // ==================== openid 授权(个人账号获取 OpenID 制单) ====================
+
+    /** openid 用例公共: AUTHED + LOGON_ID 本地授权记录(selectById 与 selectOne 均返回) */
+    private void mockLogonAuthorizedRecord() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(6L);
+        rec.setOutBizNo("A2");
+        rec.setServiceProviderId(1L);
+        rec.setParticipantId("18812345678");
+        rec.setParticipantIdType("ALIPAY_LOGON_ID");
+        rec.setStatus("AUTHED");
+        // lenient: 各用例只触发其一(openIdAuthorizeUrl 用 selectById,openIdCallback 用 selectOne)
+        lenient().when(batchAuthorizeMapper.selectById(6L)).thenReturn(rec);
+        lenient().when(batchAuthorizeMapper.selectOne(any())).thenReturn(rec);
+    }
+
+    @Test
+    void openIdAuthorizeUrl_logonId_returnsOAuthUrl() {
+        mockLogonAuthorizedRecord();
+        when(alipayClientFactory.getAppIdByProvider(1L, "BATCH_PAY")).thenReturn("2021000000000001");
+
+        // oauthRedirectUri = 支付宝授权回调白名单地址(aplipay/auth 复用),拼 out_biz_no 后整段编码
+        String url = service.openIdAuthorizeUrl(6L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180");
+
+        assertTrue(url.startsWith("https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?app_id=2021000000000001"));
+        assertTrue(url.contains("scope=auth_user"));
+        assertTrue(url.contains("redirect_uri=" + java.net.URLEncoder.encode(
+                "https://qcsj88888.com/api/v1/payment/aplipay/auth?out_biz_no=A2",
+                java.nio.charset.StandardCharsets.UTF_8)));
+        verify(alipayClientFactory).getAppIdByProvider(1L, "BATCH_PAY");
+    }
+
+    @Test
+    void openIdAuthorizeUrl_frontUrlBlank_rejected() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(6L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", ""));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_redirectUriBlank_rejected() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(6L, "", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_nonLogonId_rejected() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(7L);
+        rec.setParticipantIdType("ALIPAY_USER_ID");
+        rec.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectById(7L)).thenReturn(rec);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(7L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdAuthorizeUrl_notAuthed_rejected() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(8L);
+        rec.setParticipantIdType("ALIPAY_LOGON_ID");
+        rec.setStatus("AUTHING");
+        when(batchAuthorizeMapper.selectById(8L)).thenReturn(rec);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdAuthorizeUrl(8L, "https://qcsj88888.com/api/v1/payment/aplipay/auth", "http://localhost:5180"));
+        assertEquals(400, e.getCode());
+    }
+
+    @Test
+    void openIdCallback_tokenAndInfoShare_persistsUserId() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        AlipayUserInfoShareResponse shareResp = new AlipayUserInfoShareResponse();
+        shareResp.setUserId("2088122583917741");
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(shareResp);
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088122583917741", userId);
+        // oauth.token: auth_code + grant_type=authorization_code 换令牌
+        ArgumentCaptor<AlipaySystemOauthTokenRequest> cap = ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        assertEquals("auth-code-1", cap.getValue().getCode());
+        assertEquals("authorization_code", cap.getValue().getGrantType());
+        // user.info.share: access_token 走 execute 第二参数(非 bizModel)
+        verify(alipayClient).certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"));
+        // 支付宝账号ID(2088)落库 — 本应用未开通 open_id 能力,制单身份用 user_id(实证)
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088122583917741", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_tokenFailed_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setCode("40004");
+        tokenResp.setMsg("无效授权码");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("bad-code", "A2"));
+        assertEquals(400, e.getCode());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_infoShareFailed_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        AlipayUserInfoShareResponse shareResp = new AlipayUserInfoShareResponse();
+        shareResp.setCode("40004");
+        shareResp.setMsg("授权令牌失效");
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(shareResp);
+
+        assertThrows(BusinessException.class, () -> service.openIdCallback("auth-code-1", "A2"));
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_userIdFallsBackToTokenResponse() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        tokenResp.setUserId("2088-token-uid");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        // info.share 成功但未返回 user_id → 回退 oauth.token 响应中的 user_id
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088-token-uid", userId);
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088-token-uid", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_userIdFallsBackToTokenAlipayUserId() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        tokenResp.setAlipayUserId("2088-alipay-uid");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        String userId = service.openIdCallback("auth-code-1", "A2");
+
+        assertEquals("2088-alipay-uid", userId);
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(ent.capture());
+        assertEquals("2088-alipay-uid", ent.getValue().getAlipayUserId());
+    }
+
+    @Test
+    void openIdCallback_noUserIdAnywhere_rejected() throws AlipayApiException {
+        mockLogonAuthorizedRecord();
+        AlipaySystemOauthTokenResponse tokenResp = new AlipaySystemOauthTokenResponse();
+        tokenResp.setAccessToken("at-123");
+        when(alipayClient.certificateExecute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(tokenResp);
+        // info.share 与 oauth.token 均未返回 user_id → 明确报错
+        when(alipayClient.certificateExecute(any(AlipayUserInfoShareRequest.class), eq("at-123"))).thenReturn(new AlipayUserInfoShareResponse());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("auth-code-1", "A2"));
+        assertEquals(400, e.getCode());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void openIdCallback_recordNotFound_rejected() {
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openIdCallback("auth-code-1", "NOPE"));
+        assertEquals(404, e.getCode());
+    }
 }

+ 82 - 0
java/src/test/java/com/payment/platform/module/payment/facetoface/controller/AlipayAuthControllerTest.java

@@ -0,0 +1,82 @@
+package com.payment.platform.module.payment.facetoface.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.alipay.AlipayConfig;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
+import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import static org.mockito.Mockito.*;
+
+/**
+ * 白名单地址复用分流: 支付宝「授权回调地址」只能配一个(aplipay/auth),
+ * openid 授权与当面付 app_auth_code 授权共用该地址,按参数互斥分流
+ * (auth_code 仅 openid 授权回跳携带,app_auth_code 仅当面付 ISV 授权携带)。
+ */
+class AlipayAuthControllerTest {
+
+    private FacetofaceService facetofaceService;
+    private BatchSubjectService batchSubjectService;
+    private AlipayConfig alipayConfig;
+    private AlipayAuthController controller;
+    private HttpServletResponse response;
+
+    @BeforeEach
+    void setUp() {
+        facetofaceService = mock(FacetofaceService.class);
+        batchSubjectService = mock(BatchSubjectService.class);
+        alipayConfig = mock(AlipayConfig.class);
+        when(alipayConfig.getOauthFrontUrl()).thenReturn("https://qcsj88888.com");
+        controller = new AlipayAuthController(facetofaceService, batchSubjectService, alipayConfig);
+        response = mock(HttpServletResponse.class);
+    }
+
+    // ==================== openid 分流(auth_code 参数存在 → openid 业务) ====================
+
+    @Test
+    void openidCallback_success_dispatchesOpenIdFlow() throws Exception {
+        controller.auth(null, null, null, null, "auth-code-1", "A2", response);
+
+        verify(batchSubjectService).openIdCallback("auth-code-1", "A2");
+        verify(facetofaceService, never()).exchangeAppAuthCode(anyString(), anyString(), anyString());
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/batch?openid=success");
+    }
+
+    @Test
+    void openidCallback_bizError_redirectsFailWithMsg() throws Exception {
+        doThrow(new BusinessException(400, "获取OpenID失败: xxx"))
+                .when(batchSubjectService).openIdCallback("auth-code-1", "A2");
+
+        controller.auth(null, null, null, null, "auth-code-1", "A2", response);
+
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/batch?openid=fail&msg="
+                + URLEncoder.encode("获取OpenID失败: xxx", StandardCharsets.UTF_8));
+    }
+
+    // ==================== 当面付原流程(app_auth_code 参数存在,行为不变) ====================
+
+    @Test
+    void facetofaceCallback_success_keepsOriginalFlow() throws Exception {
+        controller.auth("app-id-1", "source-1", "ent-1", "app-auth-code-1", null, null, response);
+
+        verify(facetofaceService).exchangeAppAuthCode("ent-1", "app-id-1", "app-auth-code-1");
+        verify(batchSubjectService, never()).openIdCallback(anyString(), anyString());
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/enterprise?auth=success");
+    }
+
+    @Test
+    void facetofaceCallback_error_redirectsFail() throws Exception {
+        doThrow(new RuntimeException("boom")).when(facetofaceService)
+                .exchangeAppAuthCode("ent-1", "app-id-1", "app-auth-code-1");
+
+        controller.auth("app-id-1", "source-1", "ent-1", "app-auth-code-1", null, null, response);
+
+        verify(response).sendRedirect("https://qcsj88888.com/#/payment/enterprise?auth=fail&msg="
+                + URLEncoder.encode("boom", StandardCharsets.UTF_8));
+    }
+}