Browse Source

fix: 当面付代开通模块6项修复 — 竞态/空值覆盖/丢失更新/死代码/无LIMIT/未知状态卡死

alphah 1 day ago
parent
commit
38a8b2fb36

+ 2 - 0
java/sql/008_facetoface_order_version.sql

@@ -0,0 +1,2 @@
+-- 当面付申请单新增乐观锁版本号
+ALTER TABLE pay_facetoface_order ADD COLUMN IF NOT EXISTS version INT DEFAULT 0;

+ 8 - 0
java/src/main/java/com/payment/platform/module/payment/facetoface/controller/FacetofaceController.java

@@ -4,6 +4,7 @@ import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.facetoface.dto.F2fOrderCreateDTO;
 import com.payment.platform.module.payment.facetoface.dto.F2fOrderQueryDTO;
+import com.payment.platform.module.payment.facetoface.dto.F2fOrderUpdateDTO;
 import com.payment.platform.module.payment.facetoface.dto.F2fOrderVO;
 import com.payment.platform.module.payment.facetoface.service.FacetofaceService;
 import jakarta.validation.Valid;
@@ -56,4 +57,11 @@ public class FacetofaceController {
     public Result<F2fOrderVO> queryStatus(@PathVariable(name = "order_id") Long orderId) {
         return Result.ok(service.queryStatus(orderId));
     }
+
+    @PutMapping("/{order_id}")
+    public Result<F2fOrderVO> update(@PathVariable(name = "order_id") Long orderId,
+                                     @Valid @RequestBody F2fOrderUpdateDTO dto) {
+        dto.setId(orderId);
+        return Result.ok(service.update(dto));
+    }
 }

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/facetoface/entity/FacetofaceOrderEntity.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.facetoface.entity;
 
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
 import com.payment.platform.common.base.PaymentTenantBaseEntity;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -37,4 +38,8 @@ public class FacetofaceOrderEntity extends PaymentTenantBaseEntity {
     private OffsetDateTime lastQueryTime;
     private OffsetDateTime nextQueryTime;
     private Integer queryCount;
+
+    /** 乐观锁版本号 — MyBatis-Plus updateById 自动校验 */
+    @Version
+    private Integer version;
 }

+ 153 - 114
java/src/main/java/com/payment/platform/module/payment/facetoface/service/FacetofaceService.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.facetoface.service;
 
 import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.bean.copier.CopyOptions;
 import cn.hutool.core.util.StrUtil;
 import com.alipay.api.AlipayApiException;
 import com.alipay.api.domain.*;
@@ -10,6 +11,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 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.RedisLockUtil;
 import com.payment.platform.core.alipay.AlipayClientFactory;
 import com.payment.platform.module.payment.facetoface.dto.*;
 import com.payment.platform.module.payment.facetoface.entity.FacetofaceOrderEntity;
@@ -35,6 +37,10 @@ public class FacetofaceService {
 
     private final FacetofaceOrderMapper mapper;
     private final AlipayClientFactory alipayClientFactory;
+    private final RedisLockUtil redisLockUtil;
+
+    /** 最大轮询次数 — 超过此值未到达终态则自动关闭 */
+    private static final int MAX_QUERY_COUNT = 50;
 
     // ==================== 查询 ====================
 
@@ -83,133 +89,143 @@ public class FacetofaceService {
      */
     @Transactional
     public F2fOrderVO apply(F2fOrderCreateDTO dto) {
-        // 检查同一企业是否有进行中的申请
-        FacetofaceOrderEntity active = mapper.selectOne(
-                new LambdaQueryWrapper<FacetofaceOrderEntity>()
-                        .eq(FacetofaceOrderEntity::getEnterpriseId, dto.getEnterpriseId())
-                        .ne(FacetofaceOrderEntity::getOrderStatus, FacetofaceOrderStatus.CLOSED.getValue())
-                        .orderByDesc(FacetofaceOrderEntity::getId)
-                        .last("LIMIT 1"));
-        if (active != null) {
-            throw new BusinessException(400, "该企业已有进行中的当面付申请");
+        String lockKey = "f2f:apply:" + dto.getEnterpriseId();
+        String lockValue = redisLockUtil.lock(lockKey, 30);
+        if (lockValue == null) {
+            throw new BusinessException(400, "该企业正在处理中,请稍后再试");
         }
-
-        // 查询同一企业是否有历史 CLOSED 申请单(用于复用更新而非插新行)
-        FacetofaceOrderEntity closedOrder = mapper.selectOne(
-                new LambdaQueryWrapper<FacetofaceOrderEntity>()
-                        .eq(FacetofaceOrderEntity::getEnterpriseId, dto.getEnterpriseId())
-                        .eq(FacetofaceOrderEntity::getOrderStatus, FacetofaceOrderStatus.CLOSED.getValue())
-                        .orderByDesc(FacetofaceOrderEntity::getId)
-                        .last("LIMIT 1"));
-
         try {
-            // Step 1: 创建应用事务
-            AlipayOpenAgentCreateModel createModel = new AlipayOpenAgentCreateModel();
-            createModel.setAccount(dto.getAccount());
-            ContactModel contactInfo = new ContactModel();
-            contactInfo.setContactName(dto.getContactName());
-            contactInfo.setContactMobile(dto.getContactMobile());
-            if (StrUtil.isNotBlank(dto.getContactEmail())) {
-                contactInfo.setContactEmail(dto.getContactEmail());
-            }
-            createModel.setContactInfo(contactInfo);
-            if (StrUtil.isNotBlank(dto.getOrderTicket())) {
-                createModel.setOrderTicket(dto.getOrderTicket());
+            // 检查同一企业是否有进行中的申请
+            FacetofaceOrderEntity active = mapper.selectOne(
+                    new LambdaQueryWrapper<FacetofaceOrderEntity>()
+                            .eq(FacetofaceOrderEntity::getEnterpriseId, dto.getEnterpriseId())
+                            .ne(FacetofaceOrderEntity::getOrderStatus, FacetofaceOrderStatus.CLOSED.getValue())
+                            .orderByDesc(FacetofaceOrderEntity::getId)
+                            .last("LIMIT 1"));
+            if (active != null) {
+                throw new BusinessException(400, "该企业已有进行中的当面付申请");
             }
 
-            AlipayOpenAgentCreateRequest createRequest = new AlipayOpenAgentCreateRequest();
-            createRequest.setBizModel(createModel);
+            // 查询同一企业是否有历史 CLOSED 申请单(用于复用更新而非插新行)
+            FacetofaceOrderEntity closedOrder = mapper.selectOne(
+                    new LambdaQueryWrapper<FacetofaceOrderEntity>()
+                            .eq(FacetofaceOrderEntity::getEnterpriseId, dto.getEnterpriseId())
+                            .eq(FacetofaceOrderEntity::getOrderStatus, FacetofaceOrderStatus.CLOSED.getValue())
+                            .orderByDesc(FacetofaceOrderEntity::getId)
+                            .last("LIMIT 1"));
 
-            AlipayOpenAgentCreateResponse createResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(createRequest);
-            if (!createResponse.isSuccess())
-                throw new BusinessException(400, "创建应用事务失败: " +
-                        (createResponse.getSubMsg() != null ? createResponse.getSubMsg() : createResponse.getMsg()));
+            try {
+                // Step 1: 创建应用事务
+                AlipayOpenAgentCreateModel createModel = new AlipayOpenAgentCreateModel();
+                createModel.setAccount(dto.getAccount());
+                ContactModel contactInfo = new ContactModel();
+                contactInfo.setContactName(dto.getContactName());
+                contactInfo.setContactMobile(dto.getContactMobile());
+                if (StrUtil.isNotBlank(dto.getContactEmail())) {
+                    contactInfo.setContactEmail(dto.getContactEmail());
+                }
+                createModel.setContactInfo(contactInfo);
+                if (StrUtil.isNotBlank(dto.getOrderTicket())) {
+                    createModel.setOrderTicket(dto.getOrderTicket());
+                }
 
-            String batchNo = createResponse.getBatchNo();
-            if (StrUtil.isBlank(batchNo))
-                throw new BusinessException(400, "创建应用事务失败: 未返回 batch_no");
+                AlipayOpenAgentCreateRequest createRequest = new AlipayOpenAgentCreateRequest();
+                createRequest.setBizModel(createModel);
 
-            log.info("当面付代开通 - Step1 创建事务成功: batch_no={}, account={}", batchNo, dto.getAccount());
+                AlipayOpenAgentCreateResponse createResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(createRequest);
+                if (!createResponse.isSuccess())
+                    throw new BusinessException(400, "创建应用事务失败: " +
+                            (createResponse.getSubMsg() != null ? createResponse.getSubMsg() : createResponse.getMsg()));
 
-            // Step 2: 提交当面付签约申请
-            AlipayOpenAgentFacetofaceSignRequest signRequest = new AlipayOpenAgentFacetofaceSignRequest();
-            signRequest.setBatchNo(batchNo);
-            if (dto.getSignAndAuth() != null) {
-                signRequest.setSignAndAuth(dto.getSignAndAuth());
-            }
-            if (StrUtil.isNotBlank(dto.getRate())) {
-                signRequest.setRate(dto.getRate());
-            }
+                String batchNo = createResponse.getBatchNo();
+                if (StrUtil.isBlank(batchNo))
+                    throw new BusinessException(400, "创建应用事务失败: 未返回 batch_no");
 
-            AlipayOpenAgentFacetofaceSignResponse signResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(signRequest);
-            if (!signResponse.isSuccess())
-                throw new BusinessException(400, "提交当面付签约失败: " +
-                        (signResponse.getSubMsg() != null ? signResponse.getSubMsg() : signResponse.getMsg()));
-
-            log.info("当面付代开通 - Step2 签约成功: batch_no={}", batchNo);
-
-            // Step 3: 确认提交事务
-            AlipayOpenAgentConfirmModel confirmModel = new AlipayOpenAgentConfirmModel();
-            confirmModel.setBatchNo(batchNo);
-            AlipayOpenAgentConfirmRequest confirmRequest = new AlipayOpenAgentConfirmRequest();
-            confirmRequest.setBizModel(confirmModel);
-
-            AlipayOpenAgentConfirmResponse confirmResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(confirmRequest);
-            if (!confirmResponse.isSuccess())
-                throw new BusinessException(400, "确认提交事务失败: " +
-                        (confirmResponse.getSubMsg() != null ? confirmResponse.getSubMsg() : confirmResponse.getMsg()));
-
-            log.info("当面付代开通 - Step3 确认事务成功: batch_no={}, order_no={}", batchNo, confirmResponse.getOrderNo());
-
-            // 保存/更新申请单(对应 Python: 若存在已关闭旧单则复用更新)
-            OffsetDateTime now = OffsetDateTime.now();
-            if (closedOrder != null) {
-                // 复用已关闭的旧单:设置字段后 update(避免 enterprise_id 唯一键冲突)
-                BeanUtil.copyProperties(dto, closedOrder, "id");
-                closedOrder.setOrderStatus(FacetofaceOrderStatus.SUBMITTED.getValue());
-                closedOrder.setBatchNo(batchNo);
-                closedOrder.setOrderNo(confirmResponse.getOrderNo());
-                closedOrder.setAppAuthToken(confirmResponse.getAppAuthToken());
-                closedOrder.setAppRefreshToken(confirmResponse.getAppRefreshToken());
-                closedOrder.setAuthAppId(confirmResponse.getAuthAppId());
-                closedOrder.setUserId(confirmResponse.getUserId());
-                closedOrder.setOpenId(confirmResponse.getOpenId());
-                closedOrder.setExpiresIn(confirmResponse.getExpiresIn());
-                closedOrder.setReExpiresIn(confirmResponse.getReExpiresIn());
-                closedOrder.setNextQueryTime(now.plusMinutes(5));
-                closedOrder.setQueryCount(0);
-                closedOrder.setLastQueryTime(null);
-                closedOrder.setRejectReason(null);
-                mapper.updateById(closedOrder);
-                return BeanUtil.copyProperties(mapper.selectById(closedOrder.getId()), F2fOrderVO.class);
-            }
+                log.info("当面付代开通 - Step1 创建事务成功: batch_no={}, account={}", batchNo, dto.getAccount());
 
-            FacetofaceOrderEntity e = BeanUtil.copyProperties(dto, FacetofaceOrderEntity.class);
-            e.setOrderStatus(FacetofaceOrderStatus.SUBMITTED.getValue());
-            e.setBatchNo(batchNo);
-            e.setOrderNo(confirmResponse.getOrderNo());
-            e.setAppAuthToken(confirmResponse.getAppAuthToken());
-            e.setAppRefreshToken(confirmResponse.getAppRefreshToken());
-            e.setAuthAppId(confirmResponse.getAuthAppId());
-            e.setUserId(confirmResponse.getUserId());
-            e.setOpenId(confirmResponse.getOpenId());
-            e.setExpiresIn(confirmResponse.getExpiresIn());
-            e.setReExpiresIn(confirmResponse.getReExpiresIn());
-            e.setNextQueryTime(now.plusMinutes(5));
-            e.setQueryCount(0);
-            mapper.insert(e);
+                // Step 2: 提交当面付签约申请
+                AlipayOpenAgentFacetofaceSignRequest signRequest = new AlipayOpenAgentFacetofaceSignRequest();
+                signRequest.setBatchNo(batchNo);
+                if (dto.getSignAndAuth() != null) {
+                    signRequest.setSignAndAuth(dto.getSignAndAuth());
+                }
+                if (StrUtil.isNotBlank(dto.getRate())) {
+                    signRequest.setRate(dto.getRate());
+                }
 
-            return BeanUtil.copyProperties(mapper.selectById(e.getId()), F2fOrderVO.class);
+                AlipayOpenAgentFacetofaceSignResponse signResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(signRequest);
+                if (!signResponse.isSuccess())
+                    throw new BusinessException(400, "提交当面付签约失败: " +
+                            (signResponse.getSubMsg() != null ? signResponse.getSubMsg() : signResponse.getMsg()));
 
-        } catch (AlipayApiException ex) {
-            throw new BusinessException(400, "当面付代开通失败: " + ex.getMessage());
+                log.info("当面付代开通 - Step2 签约成功: batch_no={}", batchNo);
+
+                // Step 3: 确认提交事务
+                AlipayOpenAgentConfirmModel confirmModel = new AlipayOpenAgentConfirmModel();
+                confirmModel.setBatchNo(batchNo);
+                AlipayOpenAgentConfirmRequest confirmRequest = new AlipayOpenAgentConfirmRequest();
+                confirmRequest.setBizModel(confirmModel);
+
+                AlipayOpenAgentConfirmResponse confirmResponse = alipayClientFactory.getClient(dto.getEnterpriseId()).execute(confirmRequest);
+                if (!confirmResponse.isSuccess())
+                    throw new BusinessException(400, "确认提交事务失败: " +
+                            (confirmResponse.getSubMsg() != null ? confirmResponse.getSubMsg() : confirmResponse.getMsg()));
+
+                log.info("当面付代开通 - Step3 确认事务成功: batch_no={}, order_no={}", batchNo, confirmResponse.getOrderNo());
+
+                // 保存/更新申请单(对应 Python: 若存在已关闭旧单则复用更新)
+                OffsetDateTime now = OffsetDateTime.now();
+                if (closedOrder != null) {
+                    // 复用已关闭的旧单:忽略 null 值避免覆盖已有数据
+                    BeanUtil.copyProperties(dto, closedOrder, CopyOptions.create().setIgnoreNullValue(true));
+                    closedOrder.setOrderStatus(FacetofaceOrderStatus.SUBMITTED.getValue());
+                    closedOrder.setBatchNo(batchNo);
+                    closedOrder.setOrderNo(confirmResponse.getOrderNo());
+                    closedOrder.setAppAuthToken(confirmResponse.getAppAuthToken());
+                    closedOrder.setAppRefreshToken(confirmResponse.getAppRefreshToken());
+                    closedOrder.setAuthAppId(confirmResponse.getAuthAppId());
+                    closedOrder.setUserId(confirmResponse.getUserId());
+                    closedOrder.setOpenId(confirmResponse.getOpenId());
+                    closedOrder.setExpiresIn(confirmResponse.getExpiresIn());
+                    closedOrder.setReExpiresIn(confirmResponse.getReExpiresIn());
+                    closedOrder.setNextQueryTime(now.plusMinutes(5));
+                    closedOrder.setQueryCount(0);
+                    closedOrder.setLastQueryTime(null);
+                    closedOrder.setRejectReason(null);
+                    mapper.updateById(closedOrder);
+                    return BeanUtil.copyProperties(mapper.selectById(closedOrder.getId()), F2fOrderVO.class);
+                }
+
+                FacetofaceOrderEntity e = BeanUtil.copyProperties(dto, FacetofaceOrderEntity.class);
+                e.setOrderStatus(FacetofaceOrderStatus.SUBMITTED.getValue());
+                e.setBatchNo(batchNo);
+                e.setOrderNo(confirmResponse.getOrderNo());
+                e.setAppAuthToken(confirmResponse.getAppAuthToken());
+                e.setAppRefreshToken(confirmResponse.getAppRefreshToken());
+                e.setAuthAppId(confirmResponse.getAuthAppId());
+                e.setUserId(confirmResponse.getUserId());
+                e.setOpenId(confirmResponse.getOpenId());
+                e.setExpiresIn(confirmResponse.getExpiresIn());
+                e.setReExpiresIn(confirmResponse.getReExpiresIn());
+                e.setNextQueryTime(now.plusMinutes(5));
+                e.setQueryCount(0);
+                mapper.insert(e);
+
+                return BeanUtil.copyProperties(mapper.selectById(e.getId()), F2fOrderVO.class);
+
+            } catch (AlipayApiException ex) {
+                throw new BusinessException(400, "当面付代开通失败: " + ex.getMessage());
+            }
+        } finally {
+            redisLockUtil.unlock(lockKey, lockValue);
         }
     }
 
     /**
      * 查询申请单状态 — 调用支付宝接口查询最新状态(对应 Python query_order_service)
+     * <p>
+     * 注意:不标注 @Transactional,仅在 DB 写入时使用事务,避免 HTTP 调用期间占用连接。
      */
-    @Transactional
     public F2fOrderVO queryStatus(Long orderId) {
         FacetofaceOrderEntity e = requireOrder(orderId);
 
@@ -217,6 +233,18 @@ public class FacetofaceService {
             return BeanUtil.copyProperties(e, F2fOrderVO.class);
         }
 
+        // 超过最大轮询次数自动关闭,防止未知状态永久卡住
+        int currentCount = e.getQueryCount() != null ? e.getQueryCount() : 0;
+        if (currentCount >= MAX_QUERY_COUNT) {
+            log.warn("当面付申请单超过最大轮询次数({}),自动关闭: batch_no={}, order_status={}",
+                    MAX_QUERY_COUNT, e.getBatchNo(), e.getOrderStatus());
+            e.setOrderStatus(FacetofaceOrderStatus.CLOSED.getValue());
+            e.setNextQueryTime(null);
+            e.setRejectReason("超过最大轮询次数自动关闭");
+            doUpdateStatus(e);
+            return BeanUtil.copyProperties(mapper.selectById(e.getId()), F2fOrderVO.class);
+        }
+
         if (StrUtil.isBlank(e.getBatchNo())) {
             throw new BusinessException(400, "申请单无事务编号,无法查询");
         }
@@ -267,8 +295,10 @@ public class FacetofaceService {
                             e.setOrderStatus(FacetofaceOrderStatus.CLOSED.getValue());
                             e.setNextQueryTime(null);
                             break;
-                        case "MERCHANT_INFO_HOLD":
                         default:
+                            // 未知状态:不更新 orderStatus,仅设置下次轮询时间
+                            log.warn("当面付申请单遇到未知支付宝状态: batch_no={}, alipay_status={}, query_count={}",
+                                    e.getBatchNo(), alipayStatus, currentCount + 1);
                             e.setNextQueryTime(now.plusHours(4));
                             break;
                     }
@@ -277,26 +307,33 @@ public class FacetofaceService {
                 }
 
                 e.setLastQueryTime(now);
-                e.setQueryCount(e.getQueryCount() != null ? e.getQueryCount() + 1 : 1);
-                mapper.updateById(e);
+                e.setQueryCount(currentCount + 1);
+                doUpdateStatus(e);
             } else {
                 log.warn("查询当面付申请单失败: batch_no={}, code={}, msg={}",
                         e.getBatchNo(), queryResponse.getCode(), queryResponse.getSubMsg());
             }
         } catch (AlipayApiException ex) {
             log.error("查询当面付申请单异常: batch_no={}, error={}", e.getBatchNo(), ex.getMessage());
+            // 手动查询失败时抛异常,让调用方感知
+            throw new BusinessException(400, "查询当面付申请单失败: " + ex.getMessage());
         }
 
         return BeanUtil.copyProperties(mapper.selectById(e.getId()), F2fOrderVO.class);
     }
 
+    @Transactional
+    void doUpdateStatus(FacetofaceOrderEntity e) {
+        mapper.updateById(e);
+    }
+
     @Transactional
     public F2fOrderVO update(F2fOrderUpdateDTO dto) {
         FacetofaceOrderEntity exist = requireOrder(dto.getId());
         if (dto.getOrderStatus() != null && !dto.getOrderStatus().equals(exist.getOrderStatus())) {
             validateStatusTransition(exist.getOrderStatus(), dto.getOrderStatus());
         }
-        BeanUtil.copyProperties(dto, exist, "id");
+        BeanUtil.copyProperties(dto, exist, CopyOptions.create().setIgnoreNullValue(true));
         mapper.updateById(exist);
         return BeanUtil.copyProperties(mapper.selectById(dto.getId()), F2fOrderVO.class);
     }
@@ -312,13 +349,15 @@ public class FacetofaceService {
     public List<FacetofaceOrderEntity> getPendingOrders() {
         // 对应 Python: filter order_status IN (SUBMITTED, MERCHANT_AUDITING, MERCHANT_CONFIRM)
         //              AND next_query_time <= now
+        // LIMIT 100 防止待轮询订单过多时 OOM
         return mapper.selectList(
                 new LambdaQueryWrapper<FacetofaceOrderEntity>()
                         .le(FacetofaceOrderEntity::getNextQueryTime, OffsetDateTime.now())
                         .in(FacetofaceOrderEntity::getOrderStatus,
                                 FacetofaceOrderStatus.SUBMITTED.getValue(),
                                 FacetofaceOrderStatus.MERCHANT_AUDITING.getValue(),
-                                FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue()));
+                                FacetofaceOrderStatus.MERCHANT_CONFIRM.getValue())
+                        .last("LIMIT 100"));
     }
 
     // ========== private ==========