Sfoglia il codice sorgente

fix 多件商品下单

zoujiajian 2 settimane fa
parent
commit
8a24e16c8d

+ 30 - 0
netflix-dao/src/main/java/com/cyksj/mapper/GroupsRelationMapper.java

@@ -28,6 +28,36 @@ public interface GroupsRelationMapper extends BaseMapper<GroupsRelation> {
     @Select("select * from groups_relation where id = #{relationId} for update")
     GroupsRelation selectByIdForUpdate(@Param("relationId") Long relationId);
 
+    /**
+     * Database ownership fence for assigning an available seat. Redis only
+     * reduces contention; this CAS is the cross-process source of truth.
+     */
+    @Update("update groups_relation set user_id = #{userId}, status = #{status}, " +
+            "start_time = #{startTime}, expiry_time = #{expiryTime}, yhs_id = #{yhsId}, " +
+            "cmt = #{cmt} where id = #{id} and user_id = 0 and status = 'none'")
+    int claimAvailableRelation(@Param("id") Long id,
+                               @Param("userId") Long userId,
+                               @Param("status") GroupsRelation.Status status,
+                               @Param("startTime") Date startTime,
+                               @Param("expiryTime") Date expiryTime,
+                               @Param("yhsId") Long yhsId,
+                               @Param("cmt") Integer cmt);
+
+    /**
+     * Claims a dynamically allocated outside seat without changing its outside
+     * status. The ownership predicates prevent the main service and recovery
+     * job from assigning the same relation concurrently.
+     */
+    @Update("update groups_relation set user_id = #{userId}, " +
+            "start_time = #{startTime}, expiry_time = #{expiryTime}, yhs_id = #{yhsId}, " +
+            "cmt = #{cmt} where id = #{id} and user_id = 0 and status = 'outside'")
+    int claimOutsideRelation(@Param("id") Long id,
+                             @Param("userId") Long userId,
+                             @Param("startTime") Date startTime,
+                             @Param("expiryTime") Date expiryTime,
+                             @Param("yhsId") Long yhsId,
+                             @Param("cmt") Integer cmt);
+
     List<Long> getAccountIdsByUserIdOrNickNameOrSubmitAccount(@Param("userId") Long userId, @Param("nickName") String nickName, @Param("submitAccount") String submitAccount);
 
     Integer updateExpiryTime(@Param("entity") GroupsRelation relation, @Param("expireTime") Date expireTime);

+ 5 - 1
netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonBusinessEventMapper.java

@@ -14,7 +14,11 @@ public interface OrderDonBusinessEventMapper extends BaseMapper<OrderDonBusiness
     @Insert("insert into order_don_business_event(order_id, event_type, status, retry_num, created_time, update_time) values(#{orderId}, #{eventType}, 'pending', 0, now(), now()) on duplicate key update id = id")
     int insertIfAbsent(@Param("orderId") Long orderId, @Param("eventType") String eventType);
 
-    @Update("update order_don_business_event set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() where order_id = #{orderId} and event_type = #{eventType} and status in ('pending', 'failed')")
+    @Update("update order_don_business_event set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() " +
+            "where order_id = #{orderId} and event_type = #{eventType} " +
+            "and ((event_type = 'refund' and status in ('pending', 'failed')) " +
+            "or (event_type != 'refund' and retry_num < 5 " +
+            "and (status = 'pending' or (status = 'failed' and update_time <= date_sub(now(), interval 1 minute)))))")
     int claim(@Param("orderId") Long orderId, @Param("eventType") String eventType,
               @Param("workerToken") String workerToken);
 

+ 1 - 1
netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketRecordMapper.java

@@ -27,7 +27,7 @@ public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRec
     @Select("select count(*) from order_don_ticket_record where order_id = #{orderId}")
     int countByOrderId(@Param("orderId") Long orderId);
 
-    @Update("update order_don_ticket_record set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() where id = #{id} and status in ('pending', 'failed') and retry_num < #{maxRetries}")
+    @Update("update order_don_ticket_record set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() where id = #{id} and retry_num < #{maxRetries} and (status = 'pending' or (status = 'failed' and update_time <= date_sub(now(), interval 1 minute)))")
     int claim(@Param("id") Long id, @Param("workerToken") String workerToken,
               @Param("maxRetries") int maxRetries);
 

+ 12 - 1
netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java

@@ -561,7 +561,18 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 			if (yhsId != null && yhsId != 0) {
 				relation.setCmt(1);
 			}
-			groupsRelationMapper.updateById(relation);
+			int claimed;
+			if (relation.getStatus() == GroupsRelation.Status.outside) {
+				claimed = groupsRelationMapper.claimOutsideRelation(relation.getId(), userId,
+						relation.getStartTime(), relation.getExpiryTime(), relation.getYhsId(), relation.getCmt());
+			} else {
+				claimed = groupsRelationMapper.claimAvailableRelation(relation.getId(), userId,
+						relation.getStatus(), relation.getStartTime(), relation.getExpiryTime(),
+						relation.getYhsId(), relation.getCmt());
+			}
+			if (claimed != 1) {
+				throw new BusinessRuntimeException("seat was concurrently allocated");
+			}
 
 			Long skuId = sku.getId();
 			Long groupsId = relation.getGroupsId();

+ 50 - 37
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java

@@ -94,6 +94,7 @@ import java.math.RoundingMode;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -479,10 +480,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             return;
         }
         validateMultiQuantityWholeRefund(refundReq, orderDon);
-        multiQuantityOrderService.prepareRefund(orderId);
-        if (orderDon.getType() == OrderDon.Type.PAYPAL || orderDon.getType() == OrderDon.Type.STRIPE) {
+        if (requiresManualMultiQuantityRefund(orderDon)) {
             throw BusinessRuntimeException.getInstance("PayPal/Stripe批量订单请人工核对后退款");
         }
+        multiQuantityOrderService.prepareRefund(orderId);
         OrderDonBusinessEvent refundEvent = orderDonBusinessEventService.get(
                 orderId, OrderDonBusinessEvent.Type.refund);
         if (refundEvent != null && refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing) {
@@ -588,17 +589,13 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             }
             return;
         }
-        String workerToken;
-        if (providerStatus == ProviderRefundStatus.SUCCESS) {
-            workerToken = refundEvent.getWorkerToken();
-            if (StrUtil.isBlank(workerToken)) {
-                throw new IllegalStateException("refund worker token is missing");
-            }
-        } else {
-            workerToken = orderDonTicketRecordService.reclaimExpiredRefund(order.getId(), leaseMinutes);
-            if (workerToken == null) {
-                return;
-            }
+        if (!requiresRefundLeaseReclaim(providerStatus)) {
+            return;
+        }
+        String workerToken = orderDonTicketRecordService.reclaimExpiredRefund(
+                order.getId(), leaseMinutes);
+        if (workerToken == null) {
+            return;
         }
 
         if (providerStatus == ProviderRefundStatus.FAILED) {
@@ -752,7 +749,16 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
     }
 
-    private enum ProviderRefundStatus {
+    static boolean requiresManualMultiQuantityRefund(OrderDon order) {
+        return order != null && (order.getType() == OrderDon.Type.PAYPAL
+                || order.getType() == OrderDon.Type.STRIPE);
+    }
+
+    static boolean requiresRefundLeaseReclaim(ProviderRefundStatus status) {
+        return status == ProviderRefundStatus.SUCCESS || status == ProviderRefundStatus.FAILED;
+    }
+
+    enum ProviderRefundStatus {
         SUCCESS,
         PROCESSING,
         FAILED,
@@ -1742,10 +1748,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             goodsDonStockService.upStockRelate(null, orderDon.getId(), "refund");
         }
         try {
-            if (!multiQuantityOrder && !orderDon.getIsDp()) {
+            if (!multiQuantityOrder && !Boolean.TRUE.equals(orderDon.getIsDp())) {
                 GroupsRelation relation = relationMapper.selectById(orderDon.getRelationId());
                 //冻结车票 清除
-                if (relation != null && relation.getIsFrozen()) {
+                if (relation != null && Boolean.TRUE.equals(relation.getIsFrozen())) {
                     Optional.ofNullable(receivedRecordMapper.selectOne(Wrappers.lambdaQuery(AccountNetflixFrozenTicketReceivedRecord.class).eq(AccountNetflixFrozenTicketReceivedRecord::getRelationId, relation.getId()).last("limit 1")))
                             .ifPresent(rr -> {
                                 Long sendRelationId = rr.getSendRelationId();
@@ -1763,7 +1769,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             log.error("清除车票错误:{}", StringUtil.getErrorText(e));
         }
         //若存在优惠券使用,退还优惠券
-        if (orderDon.getCouponUserId() != 0) {
+        if (orderDon.getCouponUserId() != null && orderDon.getCouponUserId() != 0L) {
             couponFontService.updateOrderDonCouponStatus(orderDon);
         }
         //退等比例
@@ -1799,7 +1805,8 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 //        }
 
 	    //扣除 该笔订单产生的返现银河币
-	    if (orderDon.getMoney().compareTo(BigDecimal.ZERO) > 0) {
+	    if (Optional.ofNullable(orderDon.getMoney()).orElse(BigDecimal.ZERO)
+			    .compareTo(BigDecimal.ZERO) > 0) {
 		    userGalaxyCoinService.subOrderGenerateGalaxyCoin(orderDon.getId(), OrderDonGalaxyCoinRecord.Source.ordinary);
 	    }
 
@@ -1810,7 +1817,8 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
         try {
             marketFrontService.subSpecificChanceNum(orderDon.getUserId(), orderDon.getId(), orderDon.getGoodsId(), orderDon.getUpdateTime());
-            if (goodsDon.getType() == 2 && !orderDon.getIsDistribute()) {
+            if (goodsDon != null && Objects.equals(goodsDon.getType(), 2)
+                    && !Boolean.TRUE.equals(orderDon.getIsDistribute())) {
                 equipmentDistributeService.subDistributeBenefitsChance(orderDon.getUserId(), orderDon.getId());
                 //扣除用户自身实物权益
                 userRealOrderBenefitsService.deductUserRealGoodsBenefits(orderDon.getId());
@@ -1839,7 +1847,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             deductUserMirrorShopMoney(orderDon);
         }
 
-        if (orderDon.getIsFixedDistribute()) {
+        if (Boolean.TRUE.equals(orderDon.getIsFixedDistribute())) {
             //商务提成 因订单退款去掉
             UserDistributeBusinessOrderDonRecord record = businessOrderDonRecordMapper.selectOne(Wrappers.lambdaQuery(UserDistributeBusinessOrderDonRecord.class)
                     .eq(UserDistributeBusinessOrderDonRecord::getOrderId, orderDon.getId())
@@ -1863,7 +1871,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             }
         }
         //是否是优惠加购关联订单
-        if (orderDon.getIsDp()) {
+        if (Boolean.TRUE.equals(orderDon.getIsDp())) {
             OrderDiscountPlusPurchaseSkuRelation discountPlusPurchaseSkuRelation = orderDiscountPlusPurchaseSkuRelationMapper.selectOne(Wrappers.lambdaQuery(OrderDiscountPlusPurchaseSkuRelation.class)
                     .eq(OrderDiscountPlusPurchaseSkuRelation::getOrderId, orderDon.getId()).last("limit 1"));
             if (discountPlusPurchaseSkuRelation != null) {
@@ -1946,7 +1954,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         handleSubRewards(orderDon);
 
         //是否是用户套餐优惠权益订单
-        if (orderDon.getGoodsId() == 15) {
+        if (Objects.equals(orderDon.getGoodsId(), 15L)) {
             UserBenefitsComboRelationRecord userBenefitsComboRelationRecord = userBenefitsComboRelationRecordMapper.selectOne(Wrappers.lambdaQuery(UserBenefitsComboRelationRecord.class)
                     .eq(UserBenefitsComboRelationRecord::getOrderId, orderDon.getId()).last("limit 1"));
             if (userBenefitsComboRelationRecord != null) {
@@ -1965,7 +1973,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         //HBO GO退款,车队上架
         setUpIfEmpty(orderDon.getGoodsId(), orderDon.getRelationId());
 
-        if (orderDon.getGoodsId() == Constant.NETFLIX_GID) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.NETFLIX_GID)) {
             List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
             NetflixUserAccountSubmitRecoverRecord recoverRecord = netflixUserAccountSubmitRecoverRecordMapper.selectOne(Wrappers.lambdaQuery(NetflixUserAccountSubmitRecoverRecord.class)
                     .eq(NetflixUserAccountSubmitRecoverRecord::getRelationId, orderDon.getRelationId())
@@ -1979,12 +1987,15 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
 
         //GPT独立会员
-        if (orderDon.getGoodsId() == Constant.GPT_PLUS_GID && sku.getNum() == 1) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.GPT_PLUS_GID)
+                && sku != null && Objects.equals(sku.getNum(), 1)) {
             subRechargeNumOrder(orderDon.getUserId(), orderDon.getRelationId(), sku.getMonths(), operator);
         }
 
         //GPT独立会员退款 或代充退款 退还平台发券
-        if ((orderDon.getGoodsId() == Constant.GPT_PLUS_GID && sku.getNum() == 1) || orderDon.getGoodsId() == Constant.GPT_RECHARGE_GOODS_ID) {
+        if ((Objects.equals(orderDon.getGoodsId(), Constant.GPT_PLUS_GID)
+                && sku != null && Objects.equals(sku.getNum(), 1))
+                || Objects.equals(orderDon.getGoodsId(), Constant.GPT_RECHARGE_GOODS_ID)) {
             //若不存在对应的车票 则清除优惠券
             List<Long> skuIds = goodsDonSkuMapper.selectIdpGptAndRechargeSkuIds(Constant.GPT_PLUS_GID, Constant.GPT_RECHARGE_GOODS_ID);
             List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
@@ -2005,7 +2016,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
 
         //claude code 退款
-        if (orderDon.getGoodsId() == Constant.CLAUDE_CODE_GOODS_ID) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.CLAUDE_CODE_GOODS_ID)) {
             DistributeWaitingSendPoints distributeWaitingSendPoints = distributeWaitingSendPointsMapper.selectOne(Wrappers.lambdaQuery(DistributeWaitingSendPoints.class).eq(DistributeWaitingSendPoints::getOrderId, orderDon.getId()).last("limit 1"));
             if (distributeWaitingSendPoints != null) {
                 if (distributeWaitingSendPoints.getSendStatus() == DistributeWaitingSendPoints.Status.close) {
@@ -2022,13 +2033,13 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
     //处理下级渠道分销提成
     static boolean shouldRestoreStock(GoodsDon goodsDon, boolean multiQuantityOrder) {
-        return !multiQuantityOrder || goodsDon == null || goodsDon.getType() != 1;
+        return !multiQuantityOrder || goodsDon == null || !Objects.equals(goodsDon.getType(), 1);
     }
 
     public void handleSubRewards(OrderDon orderDon) {
         //是否是分销订单
         //处理下级渠道分销提成
-        if (orderDon.getIsDistribute()) {
+        if (orderDon != null && Boolean.TRUE.equals(orderDon.getIsDistribute())) {
             UserDistributeSub subRelate = userDistributeSubMapper.getSubRelate(orderDon.getUserId());
             if (subRelate != null) {
                 List<UserDistributeSubWaiting> subWaitings = subWaitingMapper.selectList(Wrappers.lambdaQuery(UserDistributeSubWaiting.class)
@@ -2061,7 +2072,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
      * 退款 分销提成积分扣除
      */
     public void deductDsPoints(OrderDon orderDon) {
-        if (orderDon.getIsDistribute()) {
+        if (orderDon != null && Boolean.TRUE.equals(orderDon.getIsDistribute())) {
             DistributeWaitingSendPoints distributeWaitingSendPoints = distributeWaitingSendPointsMapper.selectOne(Wrappers.lambdaQuery(DistributeWaitingSendPoints.class).eq(DistributeWaitingSendPoints::getOrderId, orderDon.getId()).eq(DistributeWaitingSendPoints::getIsVip,Boolean.FALSE).last("limit 1"));
             if (distributeWaitingSendPoints != null) {
                 BigDecimal money = orderDon.getMoney();
@@ -2071,7 +2082,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                 BigDecimal points = distributeWaitingSendPoints.getPoints();
                 if (sendStatus == DistributeWaitingSendPoints.Status.waiting) {
                     //实物设备退款就清0
-                    if (refundMoney.compareTo(money) == 0 || orderDon.getGoodsId() == 15) {
+                    if (refundMoney.compareTo(money) == 0 || Objects.equals(orderDon.getGoodsId(), 15L)) {
                         //待发送状态 全额退款
                         distributeWaitingSendPoints.setSendStatus(DistributeWaitingSendPoints.Status.close);
                         distributeWaitingSendPoints.setRefundPoints(points);
@@ -2092,9 +2103,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                         BigDecimal remainPoints = points.subtract(needSubPoints);
                         distributeWaitingSendPoints.setPoints(remainPoints);
                         //原先获取积分也扣除
-                        if (distributeWaitingSendPoints.getOriPoints().compareTo(BigDecimal.ZERO) > 0) {
-                            BigDecimal oriNeedSubPoints = distributeWaitingSendPoints.getOriPoints().multiply(refundRate);
-                            distributeWaitingSendPoints.setOriPoints(distributeWaitingSendPoints.getOriPoints().subtract(oriNeedSubPoints));
+                        BigDecimal oriPoints = distributeWaitingSendPoints.getOriPoints();
+                        if (oriPoints != null && oriPoints.compareTo(BigDecimal.ZERO) > 0) {
+                            BigDecimal oriNeedSubPoints = oriPoints.multiply(refundRate);
+                            distributeWaitingSendPoints.setOriPoints(oriPoints.subtract(oriNeedSubPoints));
                         }
                         distributeWaitingSendPointsMapper.updateById(distributeWaitingSendPoints);
                     }
@@ -2102,7 +2114,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                     //已发送的提成 部分退款 全额退款 需要扣除用户积分
                     //扣除固定推广者积分
                     //实物设备退款就清0
-                    if (refundMoney.compareTo(money) == 0 || orderDon.getGoodsId() == 15) {
+                    if (refundMoney.compareTo(money) == 0 || Objects.equals(orderDon.getGoodsId(), 15L)) {
                         userBenefitsService.deductDistributePoints(orderDon.getId(), distributeWaitingSendPoints.getSharedId(), points);
                         distributeWaitingSendPoints.setRefundPoints(points);
                         distributeWaitingSendPoints.setPoints(BigDecimal.ZERO);
@@ -2123,9 +2135,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                         distributeWaitingSendPoints.setRefundPoints(needSubPoints);
                         distributeWaitingSendPoints.setPoints(points.subtract(needSubPoints));
                         //原先获取积分也扣除
-                        if (distributeWaitingSendPoints.getOriPoints().compareTo(BigDecimal.ZERO) > 0) {
-                            BigDecimal oriNeedSubPoints = distributeWaitingSendPoints.getOriPoints().multiply(refundRate);
-                            distributeWaitingSendPoints.setOriPoints(distributeWaitingSendPoints.getOriPoints().subtract(oriNeedSubPoints));
+                        BigDecimal oriPoints = distributeWaitingSendPoints.getOriPoints();
+                        if (oriPoints != null && oriPoints.compareTo(BigDecimal.ZERO) > 0) {
+                            BigDecimal oriNeedSubPoints = oriPoints.multiply(refundRate);
+                            distributeWaitingSendPoints.setOriPoints(oriPoints.subtract(oriNeedSubPoints));
                         }
                         distributeWaitingSendPointsMapper.updateById(distributeWaitingSendPoints);
                     }

+ 9 - 8
netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java

@@ -184,18 +184,18 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
         }
         order.setStatus(paidStatus);
         afterCommit(() -> {
-            try {
-                runOrderEvents(order.getId());
-            } catch (Exception e) {
-                // Ticket delivery must not be blocked by a failed order-level side effect.
-                log.error("Immediate multi-quantity order events failed, orderId:{}", order.getId(), e);
-            }
             try {
                 deliver(order.getId());
             } catch (Exception e) {
                 // Payment is already committed. Durable ticket slots let the recovery job retry safely.
                 log.error("Immediate multi-quantity ticket delivery failed, orderId:{}", order.getId(), e);
             }
+            try {
+                runOrderEvents(order.getId());
+            } catch (Exception e) {
+                // Distribution and other order-level side effects never gate ticket delivery.
+                log.error("Immediate multi-quantity order events failed, orderId:{}", order.getId(), e);
+            }
         });
     }
 
@@ -295,7 +295,8 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
             throw new IllegalStateException("ticket slot claim expired");
         }
         // One slot attempt runs in one transaction; any allocation retry must start from a clean state.
-		GroupsRelation relation = exchangeCodeService.getRelationNoOrderOnce(sku, order.getUserId(), 0L);
+		Long yhsId = order.getYhsId() == null ? 0L : order.getYhsId();
+		GroupsRelation relation = exchangeCodeService.getRelationNoOrderOnce(sku, order.getUserId(), yhsId);
 		if (relation == null || relation.getId() == null) {
 			throw new IllegalStateException("no available ticket");
 		}
@@ -445,7 +446,7 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
         copy.setRelationId(relationId);
         copy.setNum(1);
         copy.setOrderType(1);
-        copy.setYhsId(0L);
+		copy.setYhsId(source.getYhsId() == null ? 0L : source.getYhsId());
         return copy;
     }
 

+ 25 - 9
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java

@@ -617,7 +617,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		Boolean isSpecificPrice = Optional.ofNullable(payRequest.getIsSpecificPrice()).orElse(false);
 		String specificGoodsIds = null;
 		BigDecimal specificPrice = BigDecimal.ZERO;
-		if (isSpecificPrice) {
+		// 多件订单已在 validateMultiQuantitySubmit 中禁止专属价;这里再次门禁,
+		// 避免恶意请求在权威金额计算前读取客户端 donMoney。
+		if (!multiQuantity && isSpecificPrice) {
 			specificGoodsIds = sku.getSpecificGoodsIds();
 			if (StrUtil.isEmpty(specificGoodsIds)) {
 				throw BusinessRuntimeException.getInstance("系统异常,请刷新页面重试");
@@ -640,7 +642,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			specificPrice = sku.getSpecificPrice();
 		}
 
-		if (sku != null && payRequest.getDonMoney().compareTo(sku.getPrice()) < 0 && !isSpecificPrice) {
+		if (!multiQuantity && sku != null && payRequest.getDonMoney().compareTo(sku.getPrice()) < 0 && !isSpecificPrice) {
 			log.info("规格:{}支付金额:{}异常,调整用户userId:{}支付金额为:{}", sku.getSpecVal(), payRequest.getDonMoney(), user.getId(), sku.getPrice());
 			payRequest.setDonMoney(sku.getPrice());
 		}
@@ -662,7 +664,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 //		}
 
 		//租赁商品
-		if (goodsDon.getType() == 4) {
+		if (!multiQuantity && goodsDon.getType() == 4) {
 			if (userIdList == null) {
 				throw BusinessRuntimeException.getGatewayApiCode(GatewayApiCode.CMS_TOKEN_VALID);
 			}
@@ -681,7 +683,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 
 		BigDecimal authoritativeTotal = multiQuantity
-				? sku.getPrice().multiply(BigDecimal.valueOf(quantity.longValue()))
+				? calculateMultiQuantityTotal(sku, quantity)
 				: payRequest.getDonMoney();
 		if (multiQuantity) {
 			payRequest.setDonMoney(authoritativeTotal);
@@ -755,6 +757,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			if (multiQuantity) {
 				deductGalaxyCoinPayOrderMoney(orderDon, authoritativeTotal, payRequest.getGalaxyCoin(), user);
 				deductBalancePayOrderMoney(orderDon, payRequest.getBalance(), user);
+				if (orderDon.getMoney() == null || orderDon.getMoney().signum() < 0) {
+					throw BusinessRuntimeException.getInstance("订单应付金额不能为负数");
+				}
 			} else {
 				deductOrderMoney(orderDon, couponUser, payRequest.getBalance(), user, sku.getPrice(),
 						goodsDon, payRequest.getGcCash(), payRequest.getGcpIds(),
@@ -1017,7 +1022,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	}
 
 	private void validateMultiQuantitySubmit(OrderPayRequest request, GoodsDon goods,
-			GoodsDonSku sku, boolean giftCardOrder, Long shopId) {
+			GoodsDonSku sku, boolean giftCardOrder, Long ignoredShopId) {
 		if (goods.getType() == null || goods.getType() != 1 || sku == null) {
 			throw BusinessRuntimeException.getInstance("多数量下单仅支持已配置的虚拟商品");
 		}
@@ -1032,7 +1037,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 				|| request.getBenefitsId() != null || request.getRenewSource() != null
 				|| request.getGcCash() != null || CollUtil.isNotEmpty(request.getGcpIds())
 				|| CollUtil.isNotEmpty(request.getDpSkuIds()) || request.getCmt() == null
-				|| request.getCmt() != 1 || (shopId != null && shopId > 0)) {
+				|| request.getCmt() != 1) {
 			throw BusinessRuntimeException.getInstance("该下单方式不支持多数量购买");
 		}
 		if (goods.getId() == Constant.CLAUDE_CODE_GOODS_ID
@@ -1060,6 +1065,13 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		return sku != null && Boolean.TRUE.equals(sku.getStatus());
 	}
 
+	static BigDecimal calculateMultiQuantityTotal(GoodsDonSku sku, int quantity) {
+		if (sku == null || sku.getPrice() == null || quantity <= 1) {
+			throw new IllegalArgumentException("invalid multi-quantity amount input");
+		}
+		return sku.getPrice().multiply(BigDecimal.valueOf(quantity));
+	}
+
 	static OrderDon.Type resolveSubmitPaymentType(boolean multiQuantity, String requestedType) {
 		if (multiQuantity && StrUtil.isBlank(requestedType)) {
 			return null;
@@ -1198,7 +1210,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 
 		String appId = map.get("mch_id");
 		ShopConfig callbackShop = shopConfigMapper.getByAppId(appId);
-		if (callbackShop == null || !StrUtil.equals(appId, order.getShopId())) {
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(order.getId());
+		// 历史单件订单可能在回调前未持久化 shopId,保持旧商户选择逻辑;
+		// 多件订单必须匹配预支付阶段锁定的商户,防止跨商户回调。
+		if (callbackShop == null || (multiQuantity && !StrUtil.equals(appId, order.getShopId()))) {
 			throw BusinessRuntimeException.getInstance("微信支付回调商户不匹配");
 		}
 
@@ -1742,7 +1757,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 		String appId = params.get("app_id");
 		ShopConfig callbackShop = shopConfigMapper.getByAppId(appId);
-		if (callbackShop == null || !isCallbackShopMatched(orderDon.getShopId(), appId)) {
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
+		// 普通历史订单沿用旧逻辑,仅按回调商户验签;多件订单严格绑定商户。
+		if (callbackShop == null || (multiQuantity && !isCallbackShopMatched(orderDon.getShopId(), appId))) {
 			throw BusinessRuntimeException.getInstance("支付宝回调商户不匹配");
 		}
 		boolean signVerified = AlipaySignature.rsaCheckV1(params, callbackShop.getPublicKey(), BaseZfbConfig.charset, BaseZfbConfig.signType);
@@ -1795,7 +1812,6 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		orderDon.setShopId(appId);
 		orderDon.setType(OrderDon.Type.ALI_PAY);
 		orderDon.setBuyerId(buyerId);
-		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
 		//该支付账号id 是否被后台拉黑
 		if (!multiQuantity) {
 			Integer count1 = userPayOrderBlockMapper.selectCount(Wrappers.lambdaQuery(UserPayOrderBlock.class)

+ 4 - 4
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonTicketRecordServiceImpl.java

@@ -110,6 +110,10 @@ public class OrderDonTicketRecordServiceImpl implements OrderDonTicketRecordServ
         }
         String newToken = UUID.randomUUID().toString().replace("-", "");
         int boundedLeaseMinutes = boundedLease(leaseMinutes);
+        if (businessEventMapper.transferExpiredRefundWorker(orderId, event.getWorkerToken(),
+                newToken, boundedLeaseMinutes) != 1) {
+            return null;
+        }
         int expected = ticketRecordMapper.countRefundingByWorker(orderId, event.getWorkerToken());
         int total = ticketRecordMapper.countByOrderAndStatus(
                 orderId, OrderDonTicketRecord.Status.refunding.name());
@@ -123,10 +127,6 @@ public class OrderDonTicketRecordServiceImpl implements OrderDonTicketRecordServ
                 throw new IllegalStateException("refund ticket lease transfer conflict");
             }
         }
-        if (businessEventMapper.transferExpiredRefundWorker(orderId, event.getWorkerToken(),
-                newToken, boundedLeaseMinutes) != 1) {
-            throw new IllegalStateException("refund event lease transfer conflict");
-        }
         return newToken;
     }
 

+ 31 - 0
netflix-service/src/main/java/com/cyksj/task/MultiQuantityRefundRecoveryScheduler.java

@@ -0,0 +1,31 @@
+package com.cyksj.task;
+
+import com.cyksj.service.mange.CmsOrderDonService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * Only recovers provider-refund results that remained unknown after submission.
+ * Ticket delivery recovery stays exclusively in netflix-job.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MultiQuantityRefundRecoveryScheduler {
+
+    private static final int BATCH_SIZE = 100;
+    private static final int REFUND_LEASE_MINUTES = 10;
+
+    private final CmsOrderDonService cmsOrderDonService;
+
+    @Scheduled(fixedDelay = 60_000L, initialDelay = 60_000L)
+    public void recoverRefunds() {
+        int processed = cmsOrderDonService.recoverMultiQuantityRefunds(
+                BATCH_SIZE, REFUND_LEASE_MINUTES);
+        if (processed > 0) {
+            log.info("Recovered multi-quantity refund states, processed:{}", processed);
+        }
+    }
+}