Procházet zdrojové kódy

feat: 授权管理拆为 BatchSubjectService - 表单主体参数/服务商client/租户隔离

alphaH před 5 dny
rodič
revize
653b494e75

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

@@ -6,6 +6,7 @@ import com.payment.platform.module.payment.batch.dto.BatchCreateDTO;
 import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
 import com.payment.platform.module.payment.batch.entity.BatchOrderEntity;
 import com.payment.platform.module.payment.batch.service.AlipayBatchPayService;
+import com.payment.platform.module.payment.batch.service.BatchSubjectService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -21,26 +22,31 @@ import java.util.Map;
 public class BatchPayController {
 
     private final AlipayBatchPayService batchPayService;
+    private final BatchSubjectService batchSubjectService;
 
     @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
     @PostMapping("/authorize/apply")
     public Result<Map<String, String>> authorizeApply(@RequestBody Map<String, Object> b) {
-        // 付款方恒为企业自身 UID(enterprise_id),不接受客户端指定 participant_id
-        return Result.ok(batchPayService.authorizeApply((String) b.get("enterprise_id")));
+        // 账号级: 主体参数来自表单(名称/uid/服务商),不再从企业解析
+        return Result.ok(batchSubjectService.apply(
+                (String) b.get("participant_name"),
+                (String) b.get("participant_id"),
+                b.get("service_provider_id") != null
+                        ? Long.valueOf(b.get("service_provider_id").toString()) : null));
     }
 
     @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
     @PostMapping("/authorize/rebind")
     public Result<Map<String, String>> authorizeRebind(@RequestBody Map<String, Object> b) {
-        return Result.ok(batchPayService.authorizeRebind((String) b.get("enterprise_id")));
+        return Result.ok(batchSubjectService.rebind(
+                b.get("id") != null ? Long.valueOf(b.get("id").toString()) : null));
     }
 
     @PreAuthorize("@perm.hasAny('module_payment:account:authorize')")
     @GetMapping("/authorize/query")
     public Result<Map<String, String>> queryAuthorize(
-            @RequestParam("enterprise_id") String enterpriseId,
             @RequestParam("out_biz_no") String outBizNo) {
-        return Result.ok(batchPayService.queryAuthorize(enterpriseId, outBizNo));
+        return Result.ok(batchSubjectService.query(outBizNo));
     }
 
     @PreAuthorize("@perm.hasAny('module_payment:account:transfer')")
@@ -98,8 +104,8 @@ public class BatchPayController {
     public Result<PageResult<BatchAuthorizeEntity>> authorizeList(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
             @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
-            @RequestParam(name = "enterprise_id", required = false) String enterpriseId) {
-        return Result.ok(batchPayService.authorizeList(enterpriseId, pageNo, pageSize));
+            @RequestParam(name = "participant_id", required = false) String participantId) {
+        return Result.ok(batchSubjectService.list(participantId, pageNo, pageSize));
     }
 
     @PreAuthorize("@perm.hasAny('module_payment:account:transfer:list')")

+ 0 - 187
java/src/main/java/com/payment/platform/module/payment/batch/service/AlipayBatchPayService.java

@@ -3,24 +3,17 @@ package com.payment.platform.module.payment.batch.service;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.domain.AccDetailModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AlipayFundBatchCloseModel;
 import com.alipay.api.domain.AlipayFundBatchCreateModel;
 import com.alipay.api.domain.AlipayFundBatchDetailQueryModel;
 import com.alipay.api.domain.AlipayFundTransRenderPayModel;
-import com.alipay.api.domain.AuthParticipantInfo;
 import com.alipay.api.domain.Participant;
 import com.alipay.api.domain.TransOrderDetail;
 import com.alipay.api.domain.TransferSceneReportInfo;
-import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
-import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.request.AlipayFundBatchCloseRequest;
 import com.alipay.api.request.AlipayFundBatchCreateRequest;
 import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
 import com.alipay.api.request.AlipayFundTransRenderPayRequest;
-import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
-import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
 import com.alipay.api.response.AlipayFundBatchCloseResponse;
 import com.alipay.api.response.AlipayFundBatchCreateResponse;
 import com.alipay.api.response.AlipayFundBatchDetailQueryResponse;
@@ -73,9 +66,6 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class AlipayBatchPayService {
 
-    private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
-    private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
-    private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
     private static final String BIZ_TYPE = "BATCH_PAY";
 
     /** 静态 ObjectMapper(同 NotificationService 第 55 行模式),用于 ext_info / 报备 JSON 序列化 */
@@ -87,166 +77,6 @@ public class AlipayBatchPayService {
     private final BatchDetailMapper batchDetailMapper;
     private final EnterpriseMapper enterpriseMapper;
 
-    /**
-     * alipay.fund.authorize.uni.apply — 生成制单授权短链接(PC 渠道)
-     * <p>
-     * Ruling 19/22: 付款方即企业自己,不接收客户端传入的 participant_id(防篡改指向他人账号)。
-     * 付款方身份遵循系统惯例(同 AlipayTransferService.createOnboard): 企业入驻身份
-     * pay_enterprise.identity 优先(配套 identityType),为空回退 enterprise_id。
-     */
-    @Transactional
-    public Map<String, String> authorizeApply(String enterpriseId) {
-        EnterpriseEntity ent = requireEnterprise(enterpriseId);
-        return doAuthorizeApply(enterpriseId, payerIdentity(ent), payerIdentityType(ent));
-    }
-
-    /**
-     * 制单授权重新生成: 作废最新一条非终态授权申请(AUTHING → UNBIND,旧链接失效),
-     * 换新 out_biz_no 重新申请。AUTHED 为终态(永久生效授权)拒绝重新生成。
-     * 无既有记录时等价于直接申请。
-     */
-    @Transactional
-    public Map<String, String> authorizeRebind(String enterpriseId) {
-        EnterpriseEntity ent = requireEnterprise(enterpriseId);
-        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                        .eq(BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                        .ne(BatchAuthorizeEntity::getStatus, "UNBIND")
-                        .orderByDesc(BatchAuthorizeEntity::getId)
-                        .last("LIMIT 1"));
-        if (existing != null) {
-            if (isAuthorizedStatus(existing.getStatus()))
-                throw new BusinessException(400, "该企业已存在生效授权,无需重新生成");
-            existing.setStatus("UNBIND");
-            batchAuthorizeMapper.updateById(existing);
-            log.info("制单授权重新生成,作废旧申请: old_out_biz_no={}, enterprise_id={}",
-                    existing.getOutBizNo(), enterpriseId);
-        }
-        return doAuthorizeApply(enterpriseId, payerIdentity(ent), payerIdentityType(ent));
-    }
-
-    /** 授权申请公共逻辑(三态预检 + 调支付宝 + 落库),apply 与 rebind 共用 */
-    private Map<String, String> doAuthorizeApply(String enterpriseId, String participantId, String participantIdType) {
-        if (participantId == null || participantId.isBlank())
-            throw new BusinessException(400, "付款方支付宝账号不能为空");
-        // 重复新增防护三态(设计文档 2.4/7):
-        //   AUTHED            → 拒绝(已有生效授权)
-        //   AUTHING 未过期    → 拒绝(授权链接一次有效,提示先完成授权)
-        //   AUTHING 已过期    → 旧记录置 UNBIND,换新 out_biz_no 重新申请
-        // DB 兜底: uk_batch_authorize_active partial unique 索引(enterprise_id, participant_id)WHERE status <> 'UNBIND'
-        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                        .eq(BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                        .eq(BatchAuthorizeEntity::getParticipantId, participantId)
-                        .ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
-        String outBizNo = SnowflakeIdGenerator.nextIdStr();
-        if (existing != null) {
-            if (isAuthorizedStatus(existing.getStatus()))
-                throw new BusinessException(400, "该付款方已存在制单授权申请,无需重复授权");
-            if (!isAuthorizeExpired(existing))
-                throw new BusinessException(400, "存在未完成的授权申请,请先完成授权或稍后重试");
-            existing.setStatus("UNBIND");
-            batchAuthorizeMapper.updateById(existing);
-            log.info("制单授权申请已过期,作废旧记录并重新申请: old_out_biz_no={}, participant_id={}",
-                    existing.getOutBizNo(), participantId);
-        }
-        try {
-            AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
-            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
-            model.setBizScene(AUTHORIZE_BIZ_SCENE);
-            model.setOutBizNo(outBizNo);
-            model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
-            model.setChannel("pc");
-            AuthParticipantInfo principal = new AuthParticipantInfo();
-            principal.setParticipantId(participantId);
-            principal.setParticipantIdType(participantIdType);
-            model.setPrincipalInfo(principal);
-
-            AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
-            request.setBizModel(model);
-            AlipayClient client = alipayClientFactory.getClient(enterpriseId, BIZ_TYPE);
-            AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
-            if (!response.isSuccess())
-                throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
-
-            BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
-            entity.setEnterpriseId(enterpriseId);
-            entity.setOutBizNo(outBizNo);
-            entity.setParticipantId(participantId);
-            entity.setParticipantIdType("ALIPAY_USER_ID");
-            entity.setStatus("AUTHING");
-            entity.setAuthorizeLink(response.getAuthorizeLink());
-            try {
-                batchAuthorizeMapper.insert(entity);
-            } catch (DuplicateKeyException e) {
-                // 并发双击兜底: 同企业同付款方已有非 UNBIND 授权记录,命中 uk_batch_authorize_active
-                throw new BusinessException(400, "该付款方已存在制单授权申请,请勿重复操作");
-            }
-
-            return Map.of("authorize_link",
-                    response.getAuthorizeLink() != null ? response.getAuthorizeLink() : "",
-                    "out_biz_no", outBizNo, "status", "AUTHING");
-        } catch (AlipayApiException e) {
-            throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 授权申请是否已过期: authorize_expire_time 非空且 > now 为未过期;
-     * 为空时按 created_time + 24h 判定(授权链接一次有效,逾期视为过期可重新申请)
-     */
-    private boolean isAuthorizeExpired(BatchAuthorizeEntity entity) {
-        OffsetDateTime now = OffsetDateTime.now();
-        if (entity.getAuthorizeExpireTime() != null)
-            return !entity.getAuthorizeExpireTime().isAfter(now);
-        if (entity.getCreatedTime() != null)
-            return entity.getCreatedTime().plusHours(24).isBefore(now);
-        return true;
-    }
-
-    /** alipay.fund.authorize.uni.query — 查询制单授权状态(单协议) */
-    public Map<String, String> queryAuthorize(String enterpriseId, String outBizNo) {
-        try {
-            AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
-            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
-            model.setBizScene(AUTHORIZE_BIZ_SCENE);
-            model.setOutBizNo(outBizNo);
-
-            AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
-            request.setBizModel(model);
-            AlipayFundAuthorizeUniQueryResponse response =
-                    alipayClientFactory.getClient(enterpriseId, BIZ_TYPE).certificateExecute(request);
-            if (!response.isSuccess()) {
-                // 未授权(协议不存在)是正常业务状态而非查询失败: 前端应展示「授权中」而不是报错
-                if ("AUTHORIZATION_NOT_EXIST".equals(response.getSubCode())) {
-                    return Map.of("agreement_no", "", "status", "AUTHING");
-                }
-                throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
-            }
-
-            // 仅生效授权才回写(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>()
-                                .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
-                if (entity != null) {
-                    entity.setAgreementNo(response.getAgreementNo());
-                    entity.setStatus("AUTHED");
-                    batchAuthorizeMapper.updateById(entity);
-                } else {
-                    log.warn("制单授权查询成功但本地记录不存在, enterpriseId={}, outBizNo={}, agreementNo={}",
-                            enterpriseId, outBizNo, response.getAgreementNo());
-                }
-            }
-            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
-                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
-        } catch (AlipayApiException e) {
-            throw new BusinessException(400, "查询授权状态失败: " + e.getMessage());
-        }
-    }
-
     // ==================== 批次 ====================
 
     private static final String BATCH_PRODUCT_CODE = "BATCH_API_TO_ACC";
@@ -585,15 +415,6 @@ public class AlipayBatchPayService {
 
     // ==================== 列表 / 详情 / 导出 ====================
 
-    /** 授权列表(分页) */
-    public PageResult<BatchAuthorizeEntity> authorizeList(String enterpriseId, int pageNo, int pageSize) {
-        var w = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
-                .eq(enterpriseId != null && !enterpriseId.isBlank(), BatchAuthorizeEntity::getEnterpriseId, enterpriseId)
-                .orderByDesc(BatchAuthorizeEntity::getId);
-        var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
-        return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
-    }
-
     /** 批次列表(分页,状态/时间筛选)— 时间解析照抄 AccountService.parseDateTime 模式 */
     public PageResult<BatchOrderEntity> batchList(String enterpriseId, String status,
             String startTime, String endTime, int pageNo, int pageSize) {
@@ -647,14 +468,6 @@ 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())

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

@@ -0,0 +1,224 @@
+package com.payment.platform.module.payment.batch.service;
+
+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.AuthParticipantInfo;
+import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
+import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
+import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.utils.SnowflakeIdGenerator;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.Map;
+
+/**
+ * 账号级制单授权 — 授权主体(Subject)管理
+ * <p>
+ * 与企业管理功能隔离(spec D1/D6): 一个租户可维护多个授权主体(个人/企业支付宝账号均可签约),
+ * 主体参数全部来自表单(participant_name / participant_id / service_provider_id),不再从企业解析;
+ * client 按服务商直取({@link AlipayClientFactory#getClientByProvider(Long, String)});
+ * 租户隔离依赖 TenantInnerInterceptor 自动过滤(insert 自动填充 tenant_id,select 自动追加条件)。
+ * <p>
+ * 授权链路: apply 生成短链(PC) → 付款方端内授权(永久生效) →
+ * fund.authorize.status.notify 异步通知回写 agreement_no(BatchPayHandler 处理)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BatchSubjectService {
+
+    private static final String AUTHORIZE_PRODUCT_CODE = "TRANSFER_API_STANDARD_AUTHORIZATION";
+    private static final String AUTHORIZE_BIZ_SCENE = "STANDARD_CREATE_FUND_ORDER";
+    private static final String AUTHORIZE_LINK_TYPE = "SHORT_URL";
+    private static final String BIZ_TYPE = "BATCH_PAY";
+
+    private final AlipayClientFactory alipayClientFactory;
+    private final BatchAuthorizeMapper batchAuthorizeMapper;
+
+    /**
+     * 主体授权申请 — 表单主体参数(名称/uid/服务商),不再从企业解析(spec 5.1/D8)
+     * <p>
+     * 重复新增防护三态(迁移自原 doAuthorizeApply):
+     *   AUTHED            → 拒绝(已签约,对应支付宝 USER_AUTHORIZATION_EXIST: 前端提示直接选择使用)
+     *   AUTHING 未过期    → 拒绝(授权链接一次有效,提示先完成授权)
+     *   AUTHING 已过期    → 旧记录置 UNBIND,换新 out_biz_no 重新申请(对应 EXISTS_STOPPED_AUTHORIZE 语义)
+     * DB 兜底: uk_batch_authorize_active partial unique 索引 (tenant_id, participant_id) WHERE status <> 'UNBIND'
+     */
+    @Transactional
+    public Map<String, String> apply(String participantName, String participantId, Long serviceProviderId) {
+        if (participantId == null || participantId.isBlank())
+            throw new BusinessException(400, "支付宝账号不能为空");
+        if (participantName == null || participantName.isBlank())
+            throw new BusinessException(400, "主体名称不能为空");
+        if (serviceProviderId == null)
+            throw new BusinessException(400, "请选择服务商");
+        return doApply(participantName, participantId, serviceProviderId);
+    }
+
+    /**
+     * 重新生成授权链接: 作废该主体非终态授权申请(AUTHING → UNBIND,旧链接失效),
+     * 换新 out_biz_no 重新申请(EXISTS_STOPPED_AUTHORIZE 语义)。AUTHED 为终态(永久生效授权)拒绝重新生成。
+     */
+    @Transactional
+    public Map<String, String> rebind(Long id) {
+        if (id == null) throw new BusinessException(400, "缺少授权记录ID");
+        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectById(id);
+        if (existing == null) throw new BusinessException(404, "授权记录不存在");
+        if (isAuthorizedStatus(existing.getStatus()))
+            throw new BusinessException(400, "该主体已存在生效授权,无需重新生成");
+        existing.setStatus("UNBIND");
+        batchAuthorizeMapper.updateById(existing);
+        log.info("制单授权重新生成,作废旧申请: old_out_biz_no={}, participant_id={}",
+                existing.getOutBizNo(), existing.getParticipantId());
+        return doApply(existing.getParticipantName(), existing.getParticipantId(), existing.getServiceProviderId());
+    }
+
+    /** 授权申请公共逻辑(三态预检 + 调支付宝 + 落库),apply 与 rebind 共用 */
+    private Map<String, String> doApply(String participantName, String participantId, Long serviceProviderId) {
+        // 重复预检: 同租户同主体非 UNBIND 记录(租户隔离由拦截器自动追加)
+        BatchAuthorizeEntity existing = batchAuthorizeMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getParticipantId, participantId)
+                        .ne(BatchAuthorizeEntity::getStatus, "UNBIND"));
+        String outBizNo = SnowflakeIdGenerator.nextIdStr();
+        if (existing != null) {
+            if (isAuthorizedStatus(existing.getStatus()))
+                throw new BusinessException(400, "该支付宝账号已签约,请直接在制单时选择使用(USER_AUTHORIZATION_EXIST)");
+            if (!isAuthorizeExpired(existing))
+                throw new BusinessException(400, "该主体存在未完成的授权申请,请先完成授权或稍后重试");
+            existing.setStatus("UNBIND");
+            batchAuthorizeMapper.updateById(existing);
+            log.info("授权申请已过期,作废旧记录并重新申请: old_out_biz_no={}, participant_id={}",
+                    existing.getOutBizNo(), participantId);
+        }
+        try {
+            AlipayFundAuthorizeUniApplyModel model = new AlipayFundAuthorizeUniApplyModel();
+            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
+            model.setBizScene(AUTHORIZE_BIZ_SCENE);
+            model.setOutBizNo(outBizNo);
+            model.setAuthorizeLinkType(AUTHORIZE_LINK_TYPE);
+            model.setChannel("pc");
+            AuthParticipantInfo principal = new AuthParticipantInfo();
+            principal.setParticipantId(participantId);
+            principal.setParticipantIdType("ALIPAY_USER_ID");
+            principal.setName(participantName);
+            model.setPrincipalInfo(principal);
+
+            AlipayFundAuthorizeUniApplyRequest request = new AlipayFundAuthorizeUniApplyRequest();
+            request.setBizModel(model);
+            AlipayClient client = alipayClientFactory.getClientByProvider(serviceProviderId, BIZ_TYPE);
+            AlipayFundAuthorizeUniApplyResponse response = client.certificateExecute(request);
+            if (!response.isSuccess())
+                throw new BusinessException(400, "生成授权链接失败: " + response.getMsg());
+
+            BatchAuthorizeEntity entity = new BatchAuthorizeEntity();
+            entity.setOutBizNo(outBizNo);
+            entity.setParticipantId(participantId);
+            entity.setParticipantIdType("ALIPAY_USER_ID");
+            entity.setParticipantName(participantName);
+            entity.setServiceProviderId(serviceProviderId);
+            entity.setStatus("AUTHING");
+            entity.setAuthorizeLink(response.getAuthorizeLink());
+            try {
+                batchAuthorizeMapper.insert(entity);
+            } catch (DuplicateKeyException e) {
+                // 并发双击兜底: 同租户同主体已有非 UNBIND 授权记录,命中 uk_batch_authorize_active
+                throw new BusinessException(400, "该支付宝账号已存在授权申请,请勿重复操作");
+            }
+
+            return Map.of("authorize_link",
+                    response.getAuthorizeLink() != null ? response.getAuthorizeLink() : "",
+                    "out_biz_no", outBizNo, "status", "AUTHING");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "生成授权链接失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * alipay.fund.authorize.uni.query — 查询制单授权状态(单协议)
+     * <p>
+     * 本地记录按 out_biz_no 反查(client 解析依据 = 记录冗余的服务商),不存在时回退默认客户端
+     */
+    public Map<String, String> query(String outBizNo) {
+        BatchAuthorizeEntity record = batchAuthorizeMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<BatchAuthorizeEntity>()
+                        .eq(BatchAuthorizeEntity::getOutBizNo, outBizNo));
+        AlipayClient client = record != null
+                ? alipayClientFactory.getClientByProvider(record.getServiceProviderId(), BIZ_TYPE)
+                : alipayClientFactory.getClient();
+        try {
+            AlipayFundAuthorizeUniQueryModel model = new AlipayFundAuthorizeUniQueryModel();
+            model.setProductCode(AUTHORIZE_PRODUCT_CODE);
+            model.setBizScene(AUTHORIZE_BIZ_SCENE);
+            model.setOutBizNo(outBizNo);
+
+            AlipayFundAuthorizeUniQueryRequest request = new AlipayFundAuthorizeUniQueryRequest();
+            request.setBizModel(model);
+            AlipayFundAuthorizeUniQueryResponse response = client.certificateExecute(request);
+            if (!response.isSuccess()) {
+                // 未授权(协议不存在)是正常业务状态而非查询失败: 前端应展示「授权中」而不是报错
+                if ("AUTHORIZATION_NOT_EXIST".equals(response.getSubCode())) {
+                    return Map.of("agreement_no", "", "status", "AUTHING");
+                }
+                throw new BusinessException(400, "查询授权状态失败: " + response.getMsg());
+            }
+
+            // 仅生效授权才回写(AUTHED/NORMAL 均视同生效,通知侧 NORMAL 已归一 AUTHED);
+            // UNBIND 也返回协议号, 直接回写会破坏本地状态机(UNBIND 由异步通知回写)
+            if (record != null && isAuthorizedStatus(response.getStatus())
+                    && response.getAgreementNo() != null && !response.getAgreementNo().isBlank()) {
+                record.setAgreementNo(response.getAgreementNo());
+                record.setStatus("AUTHED");
+                batchAuthorizeMapper.updateById(record);
+            }
+            return Map.of("agreement_no", response.getAgreementNo() != null ? response.getAgreementNo() : "",
+                    "status", response.getStatus() != null ? response.getStatus() : "AUTHING");
+        } catch (AlipayApiException e) {
+            throw new BusinessException(400, "查询授权状态失败: " + 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>()
+                .eq(participantId != null && !participantId.isBlank(), BatchAuthorizeEntity::getParticipantId, participantId)
+                .orderByDesc(BatchAuthorizeEntity::getId);
+        var r = batchAuthorizeMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
+    }
+
+    /**
+     * 授权申请是否已过期: authorize_expire_time 非空且 > now 为未过期;
+     * 为空时按 created_time + 24h 判定(授权链接一次有效,逾期视为过期可重新申请)
+     */
+    private boolean isAuthorizeExpired(BatchAuthorizeEntity entity) {
+        OffsetDateTime now = OffsetDateTime.now();
+        if (entity.getAuthorizeExpireTime() != null)
+            return !entity.getAuthorizeExpireTime().isAfter(now);
+        if (entity.getCreatedTime() != null)
+            return entity.getCreatedTime().plusHours(24).isBefore(now);
+        return true;
+    }
+
+    /**
+     * 授权状态是否生效: 本地值域 AUTHED(已授权)与 NORMAL(生效中,支付宝授权签约通知回写的原始状态)均视同生效。
+     * 源头归一在 BatchPayHandler(通知 NORMAL → 落库 AUTHED),此处兼容存量 NORMAL 记录。
+     */
+    private static boolean isAuthorizedStatus(String status) {
+        return "AUTHED".equals(status) || "NORMAL".equals(status);
+    }
+}

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

@@ -3,18 +3,12 @@ package com.payment.platform.module.payment.batch.service;
 import com.alipay.api.AlipayClient;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.domain.AccDetailModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniApplyModel;
-import com.alipay.api.domain.AlipayFundAuthorizeUniQueryModel;
 import com.alipay.api.domain.AlipayFundBatchCreateModel;
 import com.alipay.api.domain.AlipayFundTransRenderPayModel;
-import com.alipay.api.request.AlipayFundAuthorizeUniApplyRequest;
-import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
 import com.alipay.api.request.AlipayFundBatchCloseRequest;
 import com.alipay.api.request.AlipayFundBatchCreateRequest;
 import com.alipay.api.request.AlipayFundBatchDetailQueryRequest;
 import com.alipay.api.request.AlipayFundTransRenderPayRequest;
-import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
-import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
 import com.alipay.api.response.AlipayFundBatchCloseResponse;
 import com.alipay.api.response.AlipayFundBatchCreateResponse;
 import com.alipay.api.response.AlipayFundBatchDetailQueryResponse;
@@ -85,96 +79,6 @@ class AlipayBatchPayServiceTest {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
     }
 
-    @Test
-    void authorizeApply_returnsShortLinkAndPersists() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        resp.setOutBizNo("A1");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
-        assertEquals("AUTHING", result.get("status"));
-
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
-        assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
-        assertEquals("SHORT_URL", m.getAuthorizeLinkType());
-        assertEquals("pc", m.getChannel());
-        // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),不接受客户端指定
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("E100", ent.getValue().getEnterpriseId());
-        assertEquals("AUTHING", ent.getValue().getStatus());
-    }
-
-    @Test
-    void authorizeApply_failure_throwsBusinessException() throws AlipayApiException {
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
-                .thenThrow(new AlipayApiException("network error"));
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-    }
-
-    @Test
-    void queryAuthorize_returnsAgreementNo() throws AlipayApiException {
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setAgreementNo("AGMT001");
-        resp.setStatus("AUTHED");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("AGMT001", result.get("agreement_no"));
-        assertEquals("AUTHED", result.get("status"));
-    }
-
-    @Test
-    void authorizeApply_existingAuthorization_throwsBusinessException() throws AlipayApiException {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHED");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-
-        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeApply_nullAuthorizeLink_returnsEmptyString() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("", result.get("authorize_link"));
-        assertEquals("AUTHING", result.get("status"));
-    }
-
-    @Test
-    void queryAuthorize_unbind_doesNotOverwriteLocalStatus() throws AlipayApiException {
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setAgreementNo("AGMT001");
-        resp.setStatus("UNBIND");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("AGMT001", result.get("agreement_no"));
-        assertEquals("UNBIND", result.get("status"));
-        // UNBIND 下协议号非空也不得回写本地状态
-        verify(batchAuthorizeMapper, never()).selectOne(any());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
     @Test
     void batchCreate_persistsOrderAndDetails() throws AlipayApiException {
         AlipayFundBatchCreateResponse resp = new AlipayFundBatchCreateResponse();
@@ -450,22 +354,6 @@ class AlipayBatchPayServiceTest {
         assertEquals(400, ex.getCode());
     }
 
-    @Test
-    void queryAuthorize_notExist_returnsAUTHINGInsteadOfError() throws AlipayApiException {
-        // Task 8 全链路验证: 支付宝 40004 AUTHORIZATION_NOT_EXIST(未授权)是正常业务状态而非查询失败
-        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
-        resp.setCode("40004");
-        resp.setSubCode("AUTHORIZATION_NOT_EXIST");
-        resp.setMsg("Business Failed");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.queryAuthorize("E100", "A1");
-
-        assertEquals("", result.get("agreement_no"));
-        assertEquals("AUTHING", result.get("status"));
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
     @Test
     void batchCreate_authInfoNotExists_friendlyMessage() throws AlipayApiException {
         // Task 8 全链路验证: AUTH_INFO_NOT_EXISTS(付款方未授权)应提示明确动作而非透传支付宝原文案
@@ -700,261 +588,6 @@ class AlipayBatchPayServiceTest {
         verify(batchOrderMapper).updateById(order);
     }
 
-    // ==================== M1: 授权并发 DB 兜底 ====================
-
-    @Test
-    void authorizeApply_insertDuplicateKey_throwsFriendlyMessage() throws AlipayApiException {
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-        // 并发双击: 预检通过后 insert 命中 uk_batch_authorize_active 唯一索引
-        doThrow(new DuplicateKeyException("duplicate key")).when(batchAuthorizeMapper).insert(any());
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("请勿重复操作"), ex.getMessage());
-    }
-
-    // ==================== M2: AUTHING 过期重申请 ====================
-
-    @Test
-    void authorizeApply_authingNotExpired_throws() {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHING");
-        existing.setAuthorizeExpireTime(OffsetDateTime.now().plusHours(1));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        BusinessException ex = assertThrows(BusinessException.class,
-                () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("未完成"), ex.getMessage());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeApply_authingFreshCreatedTime_throws() {
-        // authorize_expire_time 为空时按 created_time + 24h 判定: 刚创建的 AUTHING 未过期 → 拒绝
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setStatus("AUTHING");
-        existing.setCreatedTime(OffsetDateTime.now());
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-    }
-
-    @Test
-    void authorizeApply_authingExpired_rebindWithNewOutBizNo() throws AlipayApiException {
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        existing.setAuthorizeExpireTime(OffsetDateTime.now().minusHours(1));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        // 旧记录作废置 UNBIND(与 uk_batch_authorize_active 的 WHERE status <> 'UNBIND' 协同)
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
-        // 新记录插入 + 新 out_biz_no
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("AUTHING", ent.getValue().getStatus());
-        assertFalse(ent.getValue().getOutBizNo().equals("OLD1"), "应换新 out_biz_no 重新申请");
-        assertEquals("E100", ent.getValue().getEnterpriseId());
-        // 支付宝侧使用新 out_biz_no
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals(ent.getValue().getOutBizNo(), m.getOutBizNo());
-    }
-
-    @Test
-    void authorizeApply_authingExpiredByCreatedTimeFallback_allowsReapply() throws AlipayApiException {
-        // expire 为空 + created_time 超过 24h → 视为过期,允许重新申请
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("2088123412341234");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        existing.setCreatedTime(OffsetDateTime.now().minusDays(2));
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertNotNull(result.get("authorize_link"));
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
-        verify(batchAuthorizeMapper).insert(any());
-    }
-
-    // ==================== 用户反馈轮 2: 付款方 UID 自动带出 + 授权可重新生成 ====================
-
-    @Test
-    void authorizeApply_alwaysUsesEnterpriseAsPayer() throws AlipayApiException {
-        // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),方法不接受客户端传入的 participant_id(防篡改指向他人账号)
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertEquals("E100", ent.getValue().getParticipantId());
-    }
-
-    @Test
-    void authorizeApply_usesEnterpriseIdentityWhenPresent() throws AlipayApiException {
-        // Ruling 23: 企业入驻身份 identity 优先(配套 identityType),与 AlipayTransferService.createOnboard 惯例一致
-        EnterpriseEntity ent = new EnterpriseEntity();
-        ent.setEnterpriseId("E100");
-        ent.setIdentity("2088IDENTITY");
-        ent.setIdentityType("ALIPAY_OPEN_ID");
-        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(ent);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeApply("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("2088IDENTITY", m.getPrincipalInfo().getParticipantId());
-        assertEquals("ALIPAY_OPEN_ID", m.getPrincipalInfo().getParticipantIdType());
-        ArgumentCaptor<BatchAuthorizeEntity> entCap = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(entCap.capture());
-        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 防御: 企业不存在直接拒绝,不发起授权
-        when(enterpriseMapper.selectByEnterpriseIdIgnoreTenant("E100")).thenReturn(null);
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeApply("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("企业不存在"), ex.getMessage());
-        verify(alipayClient, never()).certificateExecute(any());
-    }
-
-    @Test
-    void authorizeApply_nullEnterpriseId_throws() {
-        // requireEnterpriseId 防御: 企业都不存在时直接拒绝而非默认空串申请
-        assertThrows(BusinessException.class, () -> service.authorizeApply(null));
-    }
-
-    @Test
-    void authorizeRebind_noExisting_appliesFresh() throws AlipayApiException {
-        // 无既有授权记录 → 直接申请(付款方 = 企业自己)
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeRebind("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
-        verify(alipayClient).certificateExecute(cap.capture());
-        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
-        assertEquals("E100", m.getPrincipalInfo().getParticipantId());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-    }
-
-    @Test
-    void authorizeRebind_authingExisting_unbindsAndReapplies() throws AlipayApiException {
-        // 最新 AUTHING 记录 → 置 UNBIND(旧链接作废)→ 换新 out_biz_no 重新申请
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("E100");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHING");
-        // 序列 stub: rebind 预检命中旧记录 → 作废后 doAuthorizeApply 的二次预检(ne UNBIND)应返回 null
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing, null);
-
-        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
-        resp.setAuthorizeLink("https://ur.alipay.com/abc");
-        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
-
-        Map<String, String> result = service.authorizeRebind("E100");
-
-        assertEquals("AUTHING", result.get("status"));
-        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus()) && "OLD1".equals(e.getOutBizNo())));
-        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
-        verify(batchAuthorizeMapper).insert(ent.capture());
-        assertFalse(ent.getValue().getOutBizNo().equals("OLD1"), "应换新 out_biz_no 重新申请");
-        assertEquals("E100", ent.getValue().getParticipantId());
-    }
-
-    @Test
-    void authorizeRebind_authedExisting_throws() throws AlipayApiException {
-        // AUTHED 是终态(永久生效授权),重新生成无意义 → 拒绝
-        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
-        existing.setId(1L);
-        existing.setEnterpriseId("E100");
-        existing.setParticipantId("E100");
-        existing.setOutBizNo("OLD1");
-        existing.setStatus("AUTHED");
-        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
-
-        BusinessException ex = assertThrows(BusinessException.class, () -> service.authorizeRebind("E100"));
-
-        assertEquals(400, ex.getCode());
-        assertTrue(ex.getMessage().contains("生效授权"), ex.getMessage());
-        verify(alipayClient, never()).certificateExecute(any());
-        verify(batchAuthorizeMapper, never()).updateById(any());
-        verify(batchAuthorizeMapper, never()).insert(any());
-    }
-
-    @Test
-    void authorizeRebind_blankEnterpriseId_throws() {
-        assertThrows(BusinessException.class, () -> service.authorizeRebind(""));
-    }
-
     @Test
     void batchCreate_alwaysUsesEnterpriseAsPayer() throws AlipayApiException {
         // Ruling 19/22: 付款方恒为企业自身 UID(enterprise_id),DTO 已移除 payer_uid 不接受客户端指定

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

@@ -0,0 +1,342 @@
+package com.payment.platform.module.payment.batch.service;
+
+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.request.AlipayFundAuthorizeUniApplyRequest;
+import com.alipay.api.request.AlipayFundAuthorizeUniQueryRequest;
+import com.alipay.api.response.AlipayFundAuthorizeUniApplyResponse;
+import com.alipay.api.response.AlipayFundAuthorizeUniQueryResponse;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.batch.entity.BatchAuthorizeEntity;
+import com.payment.platform.module.payment.batch.mapper.BatchAuthorizeMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 账号级制单授权 — BatchSubjectService
+ * <p>
+ * 与 AlipayBatchPayService 授权用例同构(迁移自 AlipayBatchPayServiceTest),差异:
+ * 主体来自表单(participant_name/participant_id/service_provider_id),不再从企业解析;
+ * client 按服务商直取(getClientByProvider),租户隔离依赖 TenantInnerInterceptor(测试不 mock)。
+ */
+@ExtendWith(MockitoExtension.class)
+class BatchSubjectServiceTest {
+
+    @Mock private AlipayClientFactory alipayClientFactory;
+    @Mock private AlipayClient alipayClient;
+    @Mock private BatchAuthorizeMapper batchAuthorizeMapper;
+    private BatchSubjectService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new BatchSubjectService(alipayClientFactory, batchAuthorizeMapper);
+        // lenient: 预检拦截用例在到达 getClient 前即抛异常,该 stub 不会被使用
+        lenient().when(alipayClientFactory.getClientByProvider(1L, "BATCH_PAY")).thenReturn(alipayClient);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), BatchAuthorizeEntity.class);
+    }
+
+    /** 查询用例公共: 本地授权记录(含服务商,client 解析依据) */
+    private void mockLocalRecord() {
+        BatchAuthorizeEntity rec = new BatchAuthorizeEntity();
+        rec.setId(5L);
+        rec.setOutBizNo("A1");
+        rec.setServiceProviderId(1L);
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(rec);
+    }
+
+    @Test
+    void apply_usesFormSubjectAndProvider() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.apply("张三公司", "2088111122223333", 1L);
+
+        assertEquals("https://ur.alipay.com/abc", result.get("authorize_link"));
+        assertEquals("AUTHING", result.get("status"));
+
+        ArgumentCaptor<AlipayFundAuthorizeUniApplyRequest> cap = ArgumentCaptor.forClass(AlipayFundAuthorizeUniApplyRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        AlipayFundAuthorizeUniApplyModel m = (AlipayFundAuthorizeUniApplyModel) cap.getValue().getBizModel();
+        assertEquals("TRANSFER_API_STANDARD_AUTHORIZATION", m.getProductCode());
+        assertEquals("STANDARD_CREATE_FUND_ORDER", m.getBizScene());
+        assertEquals("SHORT_URL", m.getAuthorizeLinkType());
+        assertEquals("pc", m.getChannel());
+        // 账号级: 主体来自表单,非企业身份
+        assertEquals("2088111122223333", m.getPrincipalInfo().getParticipantId());
+        assertEquals("ALIPAY_USER_ID", m.getPrincipalInfo().getParticipantIdType());
+        assertEquals("张三公司", m.getPrincipalInfo().getName());
+
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertEquals("张三公司", ent.getValue().getParticipantName());
+        assertEquals(1L, ent.getValue().getServiceProviderId());
+        assertEquals("2088111122223333", ent.getValue().getParticipantId());
+        assertEquals("AUTHING", ent.getValue().getStatus());
+        verify(alipayClientFactory).getClientByProvider(1L, "BATCH_PAY");
+    }
+
+    @Test
+    void apply_failure_throwsBusinessException() throws AlipayApiException {
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class)))
+                .thenThrow(new AlipayApiException("network error"));
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", 1L));
+    }
+
+    @Test
+    void apply_missingParticipantId_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", " ", 1L));
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", null, 1L));
+    }
+
+    @Test
+    void apply_missingParticipantName_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply(" ", "2088111122223333", 1L));
+    }
+
+    @Test
+    void apply_missingServiceProvider_throwsBusinessException() {
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", null));
+    }
+
+    @Test
+    void apply_existingAuthedSubject_throwsBusinessException() throws AlipayApiException {
+        // USER_AUTHORIZATION_EXIST: 同租户同主体已签约 → 拒绝重复发起
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", 1L));
+
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_expiredAuthing_revokesAndReapplies() throws AlipayApiException {
+        // AUTHING 已过期(created + 24h)→ 旧记录置 UNBIND,换新 out_biz_no 重新申请(EXISTS_STOPPED_AUTHORIZE 语义)
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(3L);
+        existing.setOutBizNo("OLD-1");
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(java.time.OffsetDateTime.now().minusHours(25));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/new");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        service.apply("张三公司", "2088111122223333", 1L);
+
+        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus()) && "OLD-1".equals(e.getOutBizNo())));
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertNotEquals("OLD-1", ent.getValue().getOutBizNo());
+    }
+
+    @Test
+    void apply_nullAuthorizeLink_returnsEmptyString() throws AlipayApiException {
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.apply("张三公司", "2088111122223333", 1L);
+
+        assertEquals("", result.get("authorize_link"));
+        assertEquals("AUTHING", result.get("status"));
+    }
+
+    @Test
+    void rebind_invalidatesOldAuthingAndReapplies() throws AlipayApiException {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(9L);
+        existing.setOutBizNo("OLD-2");
+        existing.setParticipantName("张三公司");
+        existing.setParticipantId("2088111122223333");
+        existing.setServiceProviderId(1L);
+        existing.setStatus("AUTHING");
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(existing);
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/new");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+
+        service.rebind(9L);
+
+        verify(batchAuthorizeMapper).updateById(argThat(e -> "UNBIND".equals(e.getStatus())));
+        ArgumentCaptor<BatchAuthorizeEntity> ent = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).insert(ent.capture());
+        assertNotEquals("OLD-2", ent.getValue().getOutBizNo());
+        assertEquals("张三公司", ent.getValue().getParticipantName());
+    }
+
+    @Test
+    void rebind_authedRecord_throwsBusinessException() {
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setId(9L);
+        existing.setStatus("AUTHED");
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.rebind(9L));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void rebind_notFound_throwsBusinessException() {
+        when(batchAuthorizeMapper.selectById(9L)).thenReturn(null);
+
+        assertThrows(BusinessException.class, () -> service.rebind(9L));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void query_returnsAgreementNo() throws AlipayApiException {
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setAgreementNo("AGMT001");
+        resp.setStatus("AUTHED");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("AGMT001", result.get("agreement_no"));
+        assertEquals("AUTHED", result.get("status"));
+        // 生效授权回写本地: 协议号 + AUTHED
+        ArgumentCaptor<BatchAuthorizeEntity> cap = ArgumentCaptor.forClass(BatchAuthorizeEntity.class);
+        verify(batchAuthorizeMapper).updateById(cap.capture());
+        assertEquals("AGMT001", cap.getValue().getAgreementNo());
+        assertEquals("AUTHED", cap.getValue().getStatus());
+    }
+
+    @Test
+    void query_unbind_doesNotOverwriteLocalStatus() throws AlipayApiException {
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setAgreementNo("AGMT001");
+        resp.setStatus("UNBIND");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("AGMT001", result.get("agreement_no"));
+        assertEquals("UNBIND", result.get("status"));
+        // UNBIND 下协议号非空也不得回写本地状态(UNBIND 由异步通知回写,防破坏状态机)
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void query_notExist_returnsAUTHINGInsteadOfError() throws AlipayApiException {
+        // 支付宝 40004 AUTHORIZATION_NOT_EXIST(未授权)是正常业务状态而非查询失败
+        mockLocalRecord();
+        AlipayFundAuthorizeUniQueryResponse resp = new AlipayFundAuthorizeUniQueryResponse();
+        resp.setCode("40004");
+        resp.setSubCode("AUTHORIZATION_NOT_EXIST");
+        resp.setMsg("Business Failed");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniQueryRequest.class))).thenReturn(resp);
+
+        Map<String, String> result = service.query("A1");
+
+        assertEquals("", result.get("agreement_no"));
+        assertEquals("AUTHING", result.get("status"));
+        verify(batchAuthorizeMapper, never()).updateById(any());
+    }
+
+    @Test
+    void apply_insertDuplicateKey_throwsFriendlyMessage() throws AlipayApiException {
+        // M1 并发兜底: 预检通过后 insert 命中 uk_batch_authorize_active 唯一索引
+        AlipayFundAuthorizeUniApplyResponse resp = new AlipayFundAuthorizeUniApplyResponse();
+        resp.setAuthorizeLink("https://ur.alipay.com/abc");
+        when(alipayClient.certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class))).thenReturn(resp);
+        doThrow(new org.springframework.dao.DuplicateKeyException("duplicate key")).when(batchAuthorizeMapper).insert(any());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", 1L));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("请勿重复操作"), ex.getMessage());
+    }
+
+    @Test
+    void apply_authingNotExpired_throws() throws AlipayApiException {
+        // M2: AUTHING 未过期(authorize_expire_time 在未来)→ 拒绝
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setAuthorizeExpireTime(java.time.OffsetDateTime.now().plusHours(1));
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", 1L));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("未完成"), ex.getMessage());
+        verify(batchAuthorizeMapper, never()).updateById(any());
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_authingFreshCreatedTime_throws() {
+        // authorize_expire_time 为空时按 created_time + 24h 判定: 刚创建的 AUTHING 未过期 → 拒绝
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("AUTHING");
+        existing.setCreatedTime(java.time.OffsetDateTime.now());
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.apply("张三公司", "2088111122223333", 1L));
+    }
+
+    @Test
+    void apply_normalExisting_throws() throws AlipayApiException {
+        // NORMAL 为通知归一前落库的支付宝生效状态 — 视同 AUTHED,拒绝重复授权
+        BatchAuthorizeEntity existing = new BatchAuthorizeEntity();
+        existing.setParticipantId("2088111122223333");
+        existing.setStatus("NORMAL");
+        when(batchAuthorizeMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.apply("张三公司", "2088111122223333", 1L));
+
+        assertEquals(400, ex.getCode());
+        verify(alipayClient, never()).certificateExecute(any(AlipayFundAuthorizeUniApplyRequest.class));
+        verify(batchAuthorizeMapper, never()).insert(any());
+    }
+
+    @Test
+    void list_filtersByParticipantId() {
+        BatchAuthorizeEntity r1 = new BatchAuthorizeEntity();
+        r1.setId(1L);
+        r1.setParticipantId("2088111122223333");
+        Page<BatchAuthorizeEntity> page = new Page<>(1, 10);
+        page.setRecords(List.of(r1));
+        page.setTotal(1);
+        when(batchAuthorizeMapper.selectPage(any(), any())).thenReturn(page);
+
+        PageResult<BatchAuthorizeEntity> result = service.list("2088111122223333", 1, 10);
+
+        assertEquals(1, result.getTotal());
+        assertEquals(1, result.getList().size());
+        assertEquals("2088111122223333", result.getList().get(0).getParticipantId());
+    }
+}