Explorar o código

商品规格支持多件下单

zoujiajian hai 1 mes
pai
achega
4bd6a285b1
Modificáronse 45 ficheiros con 3254 adicións e 186 borrados
  1. 4 0
      netflix-dao/src/main/java/com/cyksj/mapper/GroupsRelationMapper.java
  2. 4 0
      netflix-dao/src/main/java/com/cyksj/mapper/OrderDonMapper.java
  3. 1 1
      netflix-dao/src/main/java/com/cyksj/mapper/UserMapper.java
  4. 79 0
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonBusinessEventMapper.java
  5. 37 0
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketQueryMapper.java
  6. 123 0
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketRecordMapper.java
  7. 14 0
      netflix-dao/src/main/java/com/cyksj/model/dto/MultiQuantitySkuConfig.java
  8. 15 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDetailView.java
  9. 43 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonBusinessEvent.java
  10. 46 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonTicketRecord.java
  11. 32 0
      netflix-dao/src/main/java/com/cyksj/model/views/MultiQuantityOrderTicketView.java
  12. 17 1
      netflix-service/pom.xml
  13. 18 1
      netflix-service/src/main/java/com/cyksj/redis/RedisService.java
  14. 2 0
      netflix-service/src/main/java/com/cyksj/service/coin/UserGalaxyCoinService.java
  15. 68 0
      netflix-service/src/main/java/com/cyksj/service/coin/impl/UserGalaxyCoinServiceImpl.java
  16. 9 0
      netflix-service/src/main/java/com/cyksj/service/exchange/ExchangeCodeService.java
  17. 38 21
      netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java
  18. 2 0
      netflix-service/src/main/java/com/cyksj/service/mange/CmsOrderDonService.java
  19. 30 6
      netflix-service/src/main/java/com/cyksj/service/mange/group/CmsGroupServiceImpl.java
  20. 651 43
      netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java
  21. 16 0
      netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderConfigService.java
  22. 22 0
      netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderService.java
  23. 26 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderDonBusinessEventService.java
  24. 54 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderDonTicketRecordService.java
  25. 1 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderRefundService.java
  26. 137 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderConfigServiceImpl.java
  27. 476 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java
  28. 6 1
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderCommonServiceImpl.java
  29. 99 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonBusinessEventServiceImpl.java
  30. 464 77
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java
  31. 337 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonTicketRecordServiceImpl.java
  32. 59 19
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderRefundServiceImpl.java
  33. 72 6
      netflix-service/src/main/java/com/cyksj/service/pay/impl/AliPayCommonServiceImpl.java
  34. 9 0
      netflix-service/src/main/java/com/cyksj/service/paypal/PayPalService.java
  35. 6 0
      netflix-service/src/main/java/com/cyksj/service/paypal/PaypalV2Service.java
  36. 55 2
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationClearServiceImpl.java
  37. 37 1
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java
  38. 24 2
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationChangeServiceImpl.java
  39. 12 2
      netflix-service/src/main/java/com/cyksj/service/scheduler/impl/SchedulerServiceImpl.java
  40. 9 0
      netflix-service/src/main/java/com/cyksj/service/stripe/StripeService.java
  41. 8 1
      netflix-service/src/main/java/com/cyksj/service/user/impl/UserBenefitsServiceImpl.java
  42. 3 0
      netflix-service/src/main/java/com/cyksj/service/wechat/WeChatService.java
  43. 57 1
      netflix-service/src/main/java/com/cyksj/service/wechat/impl/WeChatServiceImpl.java
  44. 31 0
      netflix-service/src/main/java/com/cyksj/task/MultiQuantityRefundRecoveryScheduler.java
  45. 1 1
      netflix-web/src/main/java/com/cyksj/config/SaTokenConfigure.java

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

@@ -13,6 +13,7 @@ import com.cyksj.model.views.RenewalView;
 import com.cyksj.model.views.UserRecommendGoodsFrontView;
 import com.cyksj.model.views.UserTicketView;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 import org.apache.ibatis.annotations.Update;
 
 import java.util.Date;
@@ -24,6 +25,9 @@ import java.util.List;
  */
 public interface GroupsRelationMapper extends BaseMapper<GroupsRelation> {
 
+    @Select("select * from groups_relation where id = #{relationId} for update")
+    GroupsRelation selectByIdForUpdate(@Param("relationId") Long relationId);
+
     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);

+ 4 - 0
netflix-dao/src/main/java/com/cyksj/mapper/OrderDonMapper.java

@@ -10,6 +10,7 @@ import com.cyksj.model.entity.RealGoodsInterestUser;
 import com.cyksj.model.excel.ExcelOrderDonInfo;
 import com.cyksj.model.views.*;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 import java.math.BigDecimal;
 import java.util.Date;
@@ -23,6 +24,9 @@ import java.util.Set;
  */
 public interface OrderDonMapper extends BaseMapper<OrderDon> {
 
+    @Select("select * from order_don where id = #{orderId} for update")
+    OrderDon selectByIdForUpdate(@Param("orderId") Long orderId);
+
 
     List<ExcelOrderDonInfo> exportOrderInfo(@Param("promotionId") Long promotionId, @Param("start") String start, @Param("end") String end);
 

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

@@ -46,7 +46,7 @@ public interface UserMapper extends BaseMapper<User> {
     @Update("update user set balance = balance + #{addBalance} where id = #{userId} and balance = #{userBalance}")
     Integer addUserBalance(@Param("userId") Long userId, @Param("userBalance") BigDecimal userBalance, @Param("addBalance") BigDecimal addBalance);
 
-    @Update("update user set balance = balance - #{subBalance} where id = #{userId} and balance = #{userBalance}")
+    @Update("update user set balance = balance - #{subBalance} where id = #{userId} and balance = #{userBalance} and balance >= #{subBalance}")
     Integer deductUserBalance(@Param("userId") Long userId, @Param("userBalance") BigDecimal userBalance, @Param("subBalance") BigDecimal subBalance);
 
     @Select("select id from user where show_id = #{showId} limit 1")

+ 79 - 0
netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonBusinessEventMapper.java

@@ -0,0 +1,79 @@
+package com.cyksj.mapper.order;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+
+public interface OrderDonBusinessEventMapper extends BaseMapper<OrderDonBusinessEvent> {
+
+    @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')")
+    int claim(@Param("orderId") Long orderId, @Param("eventType") String eventType,
+              @Param("workerToken") String workerToken);
+
+    @Select("select count(*) from order_don_business_event where order_id = #{orderId} and event_type != 'refund' and status = 'processing'")
+    int countProcessingNonRefund(@Param("orderId") Long orderId);
+
+    @Select("select status from order_don_business_event where order_id = #{orderId} and event_type = #{eventType} limit 1")
+    String selectStatus(@Param("orderId") Long orderId, @Param("eventType") String eventType);
+
+    @Select("select id from order_don_business_event where order_id = #{orderId} and event_type = 'refund' limit 1 for update")
+    Long lockRefundGuard(@Param("orderId") Long orderId);
+
+    @Update("update order_don_business_event set status = 'success', worker_token = null, processing_time = null, error_msg = null, update_time = now() where order_id = #{orderId} and event_type = #{eventType} and status = 'processing' and worker_token = #{workerToken}")
+    int markSuccess(@Param("orderId") Long orderId, @Param("eventType") String eventType,
+                    @Param("workerToken") String workerToken);
+
+    @Update("update order_don_business_event set status = 'failed', worker_token = null, processing_time = null, error_msg = #{errorMsg}, update_time = now() where order_id = #{orderId} and event_type = #{eventType} and status = 'processing' and worker_token = #{workerToken}")
+    int markFailed(@Param("orderId") Long orderId, @Param("eventType") String eventType,
+                   @Param("workerToken") String workerToken, @Param("errorMsg") String errorMsg);
+
+    @Update("update order_don_business_event set status = 'failed', worker_token = null, processing_time = null, error_msg = #{errorMsg}, update_time = now() where order_id = #{orderId} and event_type = #{eventType} and status = 'processing' and worker_token = #{workerToken}")
+    int markProcessingFailed(@Param("orderId") Long orderId,
+                             @Param("eventType") String eventType,
+                             @Param("workerToken") String workerToken,
+                             @Param("errorMsg") String errorMsg);
+
+    @Update("update order_don_business_event set status = 'failed', worker_token = null, processing_time = null, error_msg = 'business event lease expired', update_time = now() where status = 'processing' and event_type != 'refund' and processing_time < date_sub(now(), interval #{minutes} minute)")
+    int recoverExpired(@Param("minutes") int minutes);
+
+    @Select("select e.order_id from order_don_business_event e inner join order_don o on o.id = e.order_id " +
+            "where e.event_type = 'refund' and e.status = 'processing' and o.status in ('hasPayment', 'complete') " +
+            "and e.processing_time < date_sub(now(), interval #{minutes} minute) order by e.id limit #{limit}")
+    List<Long> selectExpiredRefundOrderIds(@Param("minutes") int minutes, @Param("limit") int limit);
+
+    @Update("update order_don_business_event set worker_token = #{newWorkerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = 'refund resumed after lease expiry', update_time = now() " +
+            "where order_id = #{orderId} and event_type = 'refund' and status = 'processing' and worker_token = #{oldWorkerToken} and processing_time < date_sub(now(), interval #{minutes} minute)")
+    int transferExpiredRefundWorker(@Param("orderId") Long orderId,
+                                    @Param("oldWorkerToken") String oldWorkerToken,
+                                    @Param("newWorkerToken") String newWorkerToken,
+                                    @Param("minutes") int minutes);
+
+
+    @Update("update order_don_business_event set processing_time = now(), error_msg = #{errorMsg}, update_time = now() " +
+            "where order_id = #{orderId} and event_type = 'refund' and status = 'processing' and worker_token = #{workerToken}")
+    int deferRefundCheck(@Param("orderId") Long orderId,
+                         @Param("workerToken") String workerToken,
+                         @Param("errorMsg") String errorMsg);
+
+    @Select("select * from order_don_business_event where order_id = #{orderId} and event_type = #{eventType} limit 1")
+    OrderDonBusinessEvent selectByOrderAndType(@Param("orderId") Long orderId,
+                                               @Param("eventType") String eventType);
+
+    @Select("select * from order_don_business_event where order_id = #{orderId} and event_type = #{eventType} and status = 'processing' and worker_token = #{workerToken} limit 1 for update")
+    OrderDonBusinessEvent lockClaim(@Param("orderId") Long orderId,
+                                    @Param("eventType") String eventType,
+                                    @Param("workerToken") String workerToken);
+
+    @Select("select e.order_id from order_don_business_event e inner join order_don o on o.id = e.order_id " +
+            "where e.event_type = #{eventType} and e.status in ('pending', 'failed') " +
+            "and o.status in ('hasPayment', 'complete') order by e.id limit #{limit}")
+    List<Long> selectPendingOrderIds(@Param("eventType") String eventType, @Param("limit") int limit);
+}

+ 37 - 0
netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketQueryMapper.java

@@ -0,0 +1,37 @@
+package com.cyksj.mapper.order;
+
+import com.cyksj.model.views.MultiQuantityOrderTicketView;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+public interface OrderDonTicketQueryMapper {
+
+    @Select({
+            "<script>",
+            "select r.item_index as itemIndex, r.relation_id as relationId,",
+            "r.status as deliveryStatus, gr.status as relationStatus,",
+            "gr.start_time as startTime, gr.expiry_time as expiryTime,",
+            "<choose>",
+            "<when test='userIds != null and userIds.size() > 0'>",
+            "case when gr.user_id in",
+            "<foreach collection='userIds' item='userId' open='(' separator=',' close=')'>#{userId}</foreach>",
+            "then coalesce(nullif(gr.account, ''), a.account) else null end as account,",
+            "case when gr.user_id in",
+            "<foreach collection='userIds' item='userId' open='(' separator=',' close=')'>#{userId}</foreach>",
+            "then coalesce(nullif(gr.password, ''), a.password) else null end as password",
+            "</when>",
+            "<otherwise>null as account, null as password</otherwise>",
+            "</choose>",
+            "from order_don_ticket_record r",
+            "left join groups_relation gr on gr.id = r.relation_id",
+            "left join groups_trips gt on gt.id = gr.groups_id",
+            "left join account a on a.id = gt.account_id",
+            "where r.order_id = #{orderId}",
+            "order by r.item_index",
+            "</script>"
+    })
+    List<MultiQuantityOrderTicketView> selectOrderTicketViews(@Param("orderId") Long orderId,
+                                                               @Param("userIds") List<Long> userIds);
+}

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

@@ -0,0 +1,123 @@
+package com.cyksj.mapper.order;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.entity.OrderDonTicketRecord;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+
+public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRecord> {
+
+    @Select("select * from order_don_ticket_record where order_id = #{orderId} order by item_index")
+    List<OrderDonTicketRecord> selectByOrderId(@Param("orderId") Long orderId);
+
+    @Insert({"<script>",
+            "insert into order_don_ticket_record(order_id, item_index, status, retry_num, created_time, update_time) values",
+            "<foreach collection='itemIndexes' item='itemIndex' separator=','>",
+            "(#{orderId}, #{itemIndex}, 'pending', 0, now(), now())",
+            "</foreach>",
+            "on duplicate key update id = id",
+            "</script>"})
+    int insertSlots(@Param("orderId") Long orderId,
+                    @Param("itemIndexes") List<Integer> itemIndexes);
+
+    @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}")
+    int claim(@Param("id") Long id, @Param("workerToken") String workerToken,
+              @Param("maxRetries") int maxRetries);
+
+    @Select("select * from order_don_ticket_record where id = #{id} and status = 'processing' and worker_token = #{workerToken} limit 1 for update")
+    OrderDonTicketRecord lockClaim(@Param("id") Long id,
+                                   @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set status = 'success', relation_id = #{relationId}, worker_token = null, processing_time = null, error_msg = null, update_time = now() where id = #{id} and status = 'processing' and worker_token = #{workerToken}")
+    int markSuccess(@Param("id") Long id, @Param("relationId") Long relationId, @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set status = 'failed', worker_token = null, processing_time = null, error_msg = #{errorMsg}, update_time = now() where id = #{id} and status = 'processing' and worker_token = #{workerToken}")
+    int markFailed(@Param("id") Long id, @Param("workerToken") String workerToken, @Param("errorMsg") String errorMsg);
+
+    @Update("update order_don_ticket_record set status = 'failed', worker_token = null, processing_time = null, error_msg = 'delivery lease expired', update_time = now() where status = 'processing' and processing_time < date_sub(now(), interval #{minutes} minute)")
+    int recoverExpired(@Param("minutes") int minutes);
+
+    @Select("select * from order_don_ticket_record where order_id = #{orderId} order by item_index for update")
+    List<OrderDonTicketRecord> lockByOrderId(@Param("orderId") Long orderId);
+
+    @Update("update order_don_ticket_record set status = 'refunding', worker_token = #{workerToken}, processing_time = now(), error_msg = null, update_time = now() where order_id = #{orderId} and status in ('pending', 'failed')")
+    int cancelUndeliveredForRefund(@Param("orderId") Long orderId,
+                                   @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set status = 'refunding', worker_token = #{workerToken}, processing_time = now(), error_msg = null, update_time = now() where order_id = #{orderId} and status = 'success'")
+    int startDeliveredRefund(@Param("orderId") Long orderId,
+                             @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set status = 'refunded', worker_token = null, processing_time = null, error_msg = null, update_time = now() where id = #{id} and status = 'refunding' and worker_token = #{workerToken} and (relation_id is null or not exists (select 1 from groups_relation gr where gr.id = order_don_ticket_record.relation_id and gr.user_id != 0))")
+    int markRefunded(@Param("id") Long id, @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set status = case when relation_id is null then 'pending' else 'success' end, retry_num = case when relation_id is null then 0 else retry_num end, worker_token = null, processing_time = null, error_msg = #{errorMsg}, update_time = now() where order_id = #{orderId} and status = 'refunding' and worker_token = #{workerToken}")
+    int restoreRefunding(@Param("orderId") Long orderId,
+                         @Param("workerToken") String workerToken,
+                         @Param("errorMsg") String errorMsg);
+
+    @Select("select count(*) from order_don_ticket_record where order_id = #{orderId} and status = 'refunding' and worker_token = #{workerToken}")
+    int countRefundingByWorker(@Param("orderId") Long orderId,
+                               @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record set worker_token = #{newWorkerToken}, processing_time = now(), error_msg = 'refund resumed after lease expiry', update_time = now() " +
+            "where order_id = #{orderId} and status = 'refunding' and worker_token = #{oldWorkerToken} and processing_time < date_sub(now(), interval #{minutes} minute)")
+    int transferRefundWorker(@Param("orderId") Long orderId,
+                             @Param("oldWorkerToken") String oldWorkerToken,
+                             @Param("newWorkerToken") String newWorkerToken,
+                             @Param("minutes") int minutes);
+
+    @Select("select distinct r.order_id from order_don_ticket_record r inner join order_don o on o.id = r.order_id " +
+            "where o.status in ('hasPayment', 'complete') and r.status in ('pending', 'failed') and not exists " +
+            "(select 1 from order_don_business_event e where e.order_id = r.order_id and e.event_type = 'refund' and e.status in ('processing', 'success')) " +
+            "and r.retry_num < #{maxRetries} order by r.order_id limit #{limit}")
+    List<Long> selectPendingOrderIds(@Param("limit") int limit,
+                                     @Param("maxRetries") int maxRetries);
+
+    @Select("select count(*) from order_don_ticket_record where order_id = #{orderId} and status = 'success'")
+    int countSuccess(@Param("orderId") Long orderId);
+
+    @Select("select count(*) from order_don_ticket_record " +
+            "where order_id = #{orderId} and status = 'success'")
+    int countAvailable(@Param("orderId") Long orderId);
+
+    @Update("update order_don_ticket_record r " +
+            "inner join groups_relation old_gr on old_gr.id = r.active_relation_id " +
+            "inner join groups_trips old_gt on old_gt.id = old_gr.groups_id " +
+            "inner join groups_relation new_gr on new_gr.id = #{newRelationId} " +
+            "inner join groups_trips new_gt on new_gt.id = new_gr.groups_id " +
+            "set r.relation_id = #{newRelationId}, r.update_time = now() " +
+            "where r.active_relation_id = #{oldRelationId} and r.status = 'success' " +
+            "and old_gt.sku_id = new_gt.sku_id")
+    int replaceActiveRelation(@Param("oldRelationId") Long oldRelationId,
+                              @Param("newRelationId") Long newRelationId);
+
+    @Select("select r.* from order_don_ticket_record r inner join groups_relation gr on gr.id = r.relation_id where gr.groups_id = #{groupsId} and r.status = 'success' order by r.item_index")
+    List<OrderDonTicketRecord> selectSuccessfulByGroupsId(@Param("groupsId") Long groupsId);
+
+    @Select("select distinct r.order_id from order_don_ticket_record r " +
+            "inner join order_don o on o.id = r.order_id " +
+            "inner join groups_relation gr on gr.id = r.relation_id " +
+            "where gr.groups_id = #{groupsId} and r.status = 'success' " +
+            "and o.status in ('hasPayment', 'complete') order by r.order_id")
+    List<Long> selectSuccessfulOrderIdsByGroupsId(@Param("groupsId") Long groupsId);
+
+    @Update("update order_don_ticket_record set status = 'cleared', worker_token = null, processing_time = null, error_msg = null, update_time = now() where active_relation_id = #{relationId} and status = 'success'")
+    int clearActiveRelation(@Param("relationId") Long relationId);
+
+    @Select("select * from order_don_ticket_record where active_relation_id = #{relationId} limit 1")
+    OrderDonTicketRecord selectActiveByRelationId(@Param("relationId") Long relationId);
+
+    @Select("select * from order_don_ticket_record where active_relation_id = #{relationId} limit 1 for update")
+    OrderDonTicketRecord selectActiveByRelationIdForUpdate(@Param("relationId") Long relationId);
+
+    @Select("select count(*) from order_don_ticket_record where order_id = #{orderId} and status = #{status}")
+    int countByOrderAndStatus(@Param("orderId") Long orderId, @Param("status") String status);
+}

+ 14 - 0
netflix-dao/src/main/java/com/cyksj/model/dto/MultiQuantitySkuConfig.java

@@ -0,0 +1,14 @@
+package com.cyksj.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/** Purchase limit for one SKU in the multi-quantity configuration. */
+@Getter
+@Setter
+public class MultiQuantitySkuConfig {
+
+    private Long skuId;
+
+    private Integer maxNum;
+}

+ 15 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/OrderDetailView.java

@@ -3,10 +3,13 @@ package com.cyksj.model.entity;
 import com.ejlchina.searcher.bean.DbField;
 import com.ejlchina.searcher.bean.DbIgnore;
 import com.ejlchina.searcher.bean.SearchBean;
+import com.cyksj.model.views.MultiQuantityOrderTicketView;
 import lombok.Getter;
 import lombok.Setter;
 
 import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
 
 /*
  *项目名: netflix
@@ -88,4 +91,16 @@ public class OrderDetailView {
 
 	@DbIgnore
 	private String password;
+
+	@DbIgnore
+	private Boolean multiQuantity = false;
+
+	@DbIgnore
+	private Integer ticketTotal = 0;
+
+	@DbIgnore
+	private Integer deliveredCount = 0;
+
+	@DbIgnore
+	private List<MultiQuantityOrderTicketView> tickets = Collections.emptyList();
 }

+ 43 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonBusinessEvent.java

@@ -0,0 +1,43 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+/** Durable outbox/guard for order-level side effects. */
+@Getter
+@Setter
+@TableName("order_don_business_event")
+public class OrderDonBusinessEvent extends BaseEntity {
+
+    private Long orderId;
+
+    private Type eventType;
+
+    private Status status;
+
+    private Integer retryNum;
+
+    private String errorMsg;
+
+    private String workerToken;
+
+    private Date processingTime;
+
+    public enum Type {
+        distribute,
+        order_side_effects,
+        balance_record,
+        galaxy_cashback,
+        refund
+    }
+
+    public enum Status {
+        pending,
+        processing,
+        success,
+        failed
+    }
+}

+ 46 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonTicketRecord.java

@@ -0,0 +1,46 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+/**
+ * One delivery slot for a multi-quantity order.
+ * The slot is durable so payment retries and service restarts are safe.
+ */
+@Getter
+@Setter
+@TableName("order_don_ticket_record")
+public class OrderDonTicketRecord extends BaseEntity {
+
+    private Long orderId;
+
+    private Integer itemIndex;
+
+    private Long relationId;
+
+    private Status status;
+
+    private Integer retryNum;
+
+    private String errorMsg;
+
+    /**
+     * Optional claim token. A stale worker cannot complete a slot reclaimed by another worker.
+     */
+    private String workerToken;
+
+    private Date processingTime;
+
+    public enum Status {
+        pending,
+        processing,
+        success,
+        failed,
+        refunding,
+        refunded,
+        cleared
+    }
+}

+ 32 - 0
netflix-dao/src/main/java/com/cyksj/model/views/MultiQuantityOrderTicketView.java

@@ -0,0 +1,32 @@
+package com.cyksj.model.views;
+
+import com.cyksj.model.entity.GroupsRelation;
+import com.cyksj.model.entity.OrderDonTicketRecord;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+/**
+ * Safe ticket details exposed for one delivery slot.
+ */
+@Getter
+@Setter
+public class MultiQuantityOrderTicketView {
+
+    private Integer itemIndex;
+
+    private Long relationId;
+
+    private OrderDonTicketRecord.Status deliveryStatus;
+
+    private GroupsRelation.Status relationStatus;
+
+    private Date startTime;
+
+    private Date expiryTime;
+
+    private String account;
+
+    private String password;
+}

+ 17 - 1
netflix-service/pom.xml

@@ -53,6 +53,22 @@
             <version>1.6.2</version>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
     </dependencies>
 
-</project>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>2.22.2</version>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 18 - 1
netflix-service/src/main/java/com/cyksj/redis/RedisService.java

@@ -5,10 +5,12 @@ import lombok.AllArgsConstructor;
 import lombok.Getter;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -24,6 +26,12 @@ import java.util.concurrent.TimeUnit;
 @SuppressWarnings("all")
 public class RedisService {
 
+    private static final DefaultRedisScript<Long> DELETE_IF_VALUE_EQUALS =
+            new DefaultRedisScript<>(
+                    "if redis.call('get', KEYS[1]) == ARGV[1] then "
+                            + "return redis.call('del', KEYS[1]) else return 0 end",
+                    Long.class);
+
     @Resource
     private RedisTemplate<String, Object> redisTemplate;
 
@@ -89,6 +97,15 @@ public class RedisService {
         }
     }
 
+    public boolean deleteIfValueEquals(String key, Object expectedValue) {
+        if (key == null || expectedValue == null) {
+            return false;
+        }
+        Long deleted = redisTemplate.execute(
+                DELETE_IF_VALUE_EQUALS, Collections.singletonList(key), expectedValue);
+        return Long.valueOf(1L).equals(deleted);
+    }
+
     /**
      * 普通缓存获取
      *
@@ -878,4 +895,4 @@ public class RedisService {
         }
 
     }
-}
+}

+ 2 - 0
netflix-service/src/main/java/com/cyksj/service/coin/UserGalaxyCoinService.java

@@ -25,4 +25,6 @@ public interface UserGalaxyCoinService {
 	void sendOrderCashback(Long orderId, Long userId, Integer galaxyCoin, String remark, OrderDonGalaxyCoinRecord.Source source);
 
 	void sendOrderGalaxyCloseOrder(Long orderId, Long userId, BigDecimal galaxyCoin, OrderDonGalaxyCoinRecord.Source source);
+
+	void reclaimOrderCloseGalaxyCoin(Long orderId, Long userId, OrderDonGalaxyCoinRecord.Source source);
 }

+ 68 - 0
netflix-service/src/main/java/com/cyksj/service/coin/impl/UserGalaxyCoinServiceImpl.java

@@ -15,6 +15,7 @@ import com.cyksj.service.coin.UserGalaxyCoinService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -118,9 +119,18 @@ public class UserGalaxyCoinServiceImpl implements UserGalaxyCoinService {
 	}
 
 	@Override
+	@Transactional(rollbackFor = Throwable.class)
 	public void recordOrderRefundGalaxyCoinIfValid(Long orderId, Long userId, BigDecimal galaxyCoin, OrderDonGalaxyCoinRecord.Source source) {
+		Integer existingRefund = userGalaxyCoinRecordMapper.selectCount(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+				.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER_REFUND));
+		if (existingRefund != null && existingRefund > 0) {
+			return;
+		}
 		UserGalaxyCoinRecord userGalaxyCoinRecord = userGalaxyCoinRecordMapper.selectOne(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
 				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
 				.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER)
 				.last("limit 1"));
 		if (userGalaxyCoinRecord == null) {
@@ -136,7 +146,15 @@ public class UserGalaxyCoinServiceImpl implements UserGalaxyCoinService {
 	}
 
 	@Override
+	@Transactional(rollbackFor = Throwable.class)
 	public void subOrderGenerateGalaxyCoin(Long orderId, OrderDonGalaxyCoinRecord.Source source) {
+		Integer deducted = userGalaxyCoinRecordMapper.selectCount(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+				.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER_REFUND_SUB));
+		if (deducted != null && deducted > 0) {
+			return;
+		}
 		UserGalaxyCoinRecord userGalaxyCoinRecord = userGalaxyCoinRecordMapper.selectOne(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
 				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
 				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
@@ -195,7 +213,15 @@ public class UserGalaxyCoinServiceImpl implements UserGalaxyCoinService {
 	 * 订单回调时,扣除已使用的银河币 并记录使用记录
 	 */
 	@Override
+	@Transactional(rollbackFor = Throwable.class)
 	public void handleUserGalaxyCoinOrderNotify(Long orderId, Long userId, String orderNo, OrderDonGalaxyCoinRecord.Source source) {
+		Integer existingUseRecord = userGalaxyCoinRecordMapper.selectCount(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+				.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER));
+		if (existingUseRecord != null && existingUseRecord > 0) {
+			return;
+		}
 		//银河币使用记录
 		OrderDonGalaxyCoinRecord orderDonGalaxyCoinRecord = orderDonGalaxyCoinRecordMapper.selectOne(Wrappers.lambdaQuery(OrderDonGalaxyCoinRecord.class)
 				.eq(OrderDonGalaxyCoinRecord::getOrderNo, orderNo)
@@ -214,14 +240,56 @@ public class UserGalaxyCoinServiceImpl implements UserGalaxyCoinService {
 	 * 订单支付成功 返银河币
 	 */
 	@Override
+	@Transactional(rollbackFor = Throwable.class)
 	public void sendOrderCashback(Long orderId, Long userId, Integer galaxyCoin, String remark, OrderDonGalaxyCoinRecord.Source source) {
+		Integer existing = userGalaxyCoinRecordMapper.selectCount(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+				.eq(UserGalaxyCoinRecord::getRemark, remark));
+		if (existing != null && existing > 0) {
+			return;
+		}
 		sendGalaxyCoinHandle(userId, orderId, galaxyCoin, remark, true, true, source);
 	}
 
+	@Transactional(rollbackFor = Throwable.class)
 	public void sendOrderGalaxyCloseOrder(Long orderId, Long userId, BigDecimal galaxyCoin, OrderDonGalaxyCoinRecord.Source source) {
+		Integer existing = userGalaxyCoinRecordMapper.selectCount(Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+				.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+				.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+				.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER_CLOSE));
+		if (existing != null && existing > 0) {
+			return;
+		}
 		sendGalaxyCoinHandle(userId, orderId, galaxyCoin.intValue(), PointsRecordConst.ORDER_CLOSE, true, true, source);
 	}
 
+	@Override
+	@Transactional(rollbackFor = Throwable.class)
+	public void reclaimOrderCloseGalaxyCoin(Long orderId, Long userId, OrderDonGalaxyCoinRecord.Source source) {
+		UserGalaxyCoinRecord closeRecord = userGalaxyCoinRecordMapper.selectOne(
+				Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+						.eq(UserGalaxyCoinRecord::getOrderId, orderId)
+						.eq(UserGalaxyCoinRecord::getSource, source.ordinal())
+						.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER_CLOSE)
+						.last("limit 1 for update"));
+		if (closeRecord == null) {
+			return;
+		}
+		BigDecimal amount = closeRecord.getGalaxyCoin().abs();
+		while (true) {
+			User user = userMapper.selectById(userId);
+			if (user == null || user.getGalaxyCoin().compareTo(amount) < 0
+					|| userMapper.subUserGalaxyCoin(userId, amount, user.getGalaxyCoin()) == 1) {
+				if (user == null || user.getGalaxyCoin().compareTo(amount) < 0) {
+					throw new IllegalStateException("galaxy coin returned for closed order is no longer available");
+				}
+				break;
+			}
+		}
+		userGalaxyCoinRecordMapper.deleteById(closeRecord.getId());
+	}
+
 	private Integer deductUserGalaxyCoin(Long galaxyCoinRecordId, BigDecimal deductGalaxyCoin, BigDecimal remainGalaxyCoin, boolean isUsed) {
 		Integer update = userGalaxyCoinRecordMapper.update(null, Wrappers.lambdaUpdate(UserGalaxyCoinRecord.class)
 				.set(UserGalaxyCoinRecord::getRemainGalaxyCoin, remainGalaxyCoin.subtract(deductGalaxyCoin))

+ 9 - 0
netflix-service/src/main/java/com/cyksj/service/exchange/ExchangeCodeService.java

@@ -43,4 +43,13 @@ public interface ExchangeCodeService extends IService<ExchangeCode> {
 	 * 获取车票车位
 	 */
 	GroupsRelation getRelationNoOrder(GoodsDonSku sku, Long userId, Long yhsId);
+
+	/**
+	 * Attempts one ticket allocation without swallowing the failure. Multi-quantity
+	 * fulfillment uses this entry so its per-slot transaction can roll back cleanly.
+	 */
+	GroupsRelation getRelationNoOrderOnce(GoodsDonSku sku, Long userId, Long yhsId);
+
+	/** Releases only the seat lock owned by the specified user. */
+	void releaseRelationLock(Long relationId, Long userId);
 }

+ 38 - 21
netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java

@@ -496,20 +496,45 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 				return relation;
 			}
 		}
-		Integer retryNum = 1;
-		List<Long> errorsRelationIds = new ArrayList<>();
-		GroupsRelation relation = getFinalRelation(sku, userId, retryNum, yhsId, errorsRelationIds);
-		return relation;
+		RuntimeException lastError = null;
+		for (int attempt = 1; attempt <= 15; attempt++) {
+			try {
+				return allocateRelation(sku, userId, yhsId);
+			} catch (RuntimeException e) {
+				lastError = e;
+			}
+		}
+		log.error("Failed to allocate ticket after 15 attempts, userId:{}, skuId:{}",
+				userId, sku.getId(), lastError);
+		throw BusinessRuntimeException.getInstance("获取车票异常,请联系客服");
+	}
+
+	@Override
+	public GroupsRelation getRelationNoOrderOnce(GoodsDonSku sku, Long userId, Long yhsId) {
+		return allocateRelation(sku, userId, yhsId);
+	}
+
+	@Override
+	public void releaseRelationLock(Long relationId, Long userId) {
+		if (relationId == null || userId == null) {
+			return;
+		}
+		try {
+			redisService.deleteIfValueEquals(RedisKey.GROUPS_RELATION_NUM_KEY + relationId, userId);
+		} catch (RuntimeException e) {
+			log.warn("Failed to release relation lock, relationId:{}, userId:{}",
+					relationId, userId, e);
+		}
 	}
 
-	public GroupsRelation getFinalRelation(GoodsDonSku sku, Long userId, Integer retryNum, Long yhsId, List<Long> errorsRelationIds) {
+	private GroupsRelation allocateRelation(GoodsDonSku sku, Long userId, Long yhsId) {
 		//获取车票车位
 		GroupsRelation relation = null;
 
 		try {
 			relation = groupRelationFrontService.getRelation(null, userId, sku, null);
 			//锁定座位
-			setRelation(relation);
+			setRelation(relation, userId);
 			relation.setUserId(userId);
 			GroupsTrips groupsTrips = groupsMapper.selectById(relation.getGroupsId());
 			if (GroupsTrips.Status.validity.equals(groupsTrips.getStatus())) {
@@ -548,30 +573,22 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 			if (relation != null) {
 				//删除座位缓存
 				delRelationKey(relation.getId(), userId);
-				errorsRelationIds.add(relation.getId());
 				log.error("用户:{}获取车票:{}异常", userId, relation.getId());
 			}
-			if (retryNum == 15) {
-				throw BusinessRuntimeException.getInstance("获取车票异常,请联系客服");
-			}
-			//失败重新给他分配车位
-			return getFinalRelation(sku, userId, ++retryNum, yhsId, errorsRelationIds);
+			throw e;
 		}
 		return relation;
 	}
 
-	public void setRelation(GroupsRelation relation) {
+	private void setRelation(GroupsRelation relation, Long targetUserId) {
 		Long relationId = relation.getId();
-		Long userId = relation.getUserId();
 		Long groupsId = relation.getGroupsId();
 		GroupsRelation.Status status = relation.getStatus();
 		String relation_num_key = RedisKey.GROUPS_RELATION_NUM_KEY + relationId;
-		if (!redisService.setNx(relation_num_key, userId, 60 * 5L)) {
-			GroupsRelation dbRelation = groupsRelationMapper.selectById(relationId);
-			if (dbRelation.getUserId() != 0) {
-				log.info("=====>用户:{}未抢到座位:{}", userId, relationId);
-				throw new BusinessRuntimeException("系统繁忙,请重试...");
-			}
+		if (targetUserId == null || targetUserId <= 0
+				|| !redisService.setNx(relation_num_key, targetUserId, 60 * 5L)) {
+			log.info("=====>用户:{}未抢到座位:{}", targetUserId, relationId);
+			throw new BusinessRuntimeException("系统繁忙,请重试...");
 		}
 		if (status == GroupsRelation.Status.outside) {
 			return;
@@ -581,7 +598,7 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 			log.error("车位异常 groupId:{}", groupsId);
 			groupsMapper.updateAvailableNum(groupsId);
 			//删除座位缓存
-			delRelationKey(relationId, userId);
+			delRelationKey(relationId, targetUserId);
 			throw new BusinessRuntimeException("车位异常");
 		}
 	}

+ 2 - 0
netflix-service/src/main/java/com/cyksj/service/mange/CmsOrderDonService.java

@@ -35,6 +35,8 @@ public interface CmsOrderDonService extends IService<OrderDon> {
 	 * 订单统一退款
 	 */
 	void unifiedRefund(OrderRefundReq refundReq) throws Exception;
+
+	int recoverMultiQuantityRefunds(int limit, int leaseMinutes);
 	/**
 	 * 套餐订单统一退款
 	 */

+ 30 - 6
netflix-service/src/main/java/com/cyksj/service/mange/group/CmsGroupServiceImpl.java

@@ -25,6 +25,8 @@ import com.cyksj.redis.RedisService;
 import com.cyksj.service.chatgpt.GptUserRechargeRecordService;
 import com.cyksj.service.mange.AccountCommonService;
 import com.cyksj.service.mange.CmsGroupService;
+import com.cyksj.service.order.MultiQuantityOrderService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.cyksj.service.relation.GroupsRelationNetflixService;
 import com.cyksj.service.sms.SmsService;
@@ -102,6 +104,10 @@ public class CmsGroupServiceImpl extends ServiceImpl<GroupsMapper,GroupsTrips> i
 
     private final SysLogMapper sysLogMapper;
 
+    private final OrderDonTicketRecordService orderDonTicketRecordService;
+
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
     private static final GlobalThreadPoolTaskExecutor TASK_EXECUTOR = GlobalThreadPoolTaskExecutor.getInstance();
 
 
@@ -236,10 +242,20 @@ public class CmsGroupServiceImpl extends ServiceImpl<GroupsMapper,GroupsTrips> i
             verifyCodeList = Arrays.stream(verifyCodesArray).filter(str -> StrUtil.isNotBlank(str)).map(str -> str.trim()).collect(Collectors.toList());
         }
         Boolean isPrdEnv = envCommonService.isPrdEnv();
+        java.util.Map<Long, OrderDonTicketRecord> multiTicketByRelationId =
+                orderDonTicketRecordService.listSuccessfulByGroupsId(groupsTrips.getId()).stream()
+                        .collect(Collectors.toMap(OrderDonTicketRecord::getRelationId, record -> record));
         for (int i = 0; i < groupsRelations.size(); i++) {
             GroupsRelation relation = groupsRelations.get(i);
             if (relation.getExpiryTime() == null) {
-                List<OrderDon> orderDonList = orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class).eq(OrderDon::getUserId, relation.getUserId()).eq(OrderDon::getRelationId, relation.getId()).eq(OrderDon::getStatus, OrderDon.Status.hasPayment));
+                OrderDonTicketRecord multiTicket = multiTicketByRelationId.get(relation.getId());
+                List<OrderDon> orderDonList = multiTicket == null
+                        ? orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class)
+                                .eq(OrderDon::getUserId, relation.getUserId())
+                                .eq(OrderDon::getRelationId, relation.getId())
+                                .eq(OrderDon::getStatus, OrderDon.Status.hasPayment))
+                        : Optional.ofNullable(orderDonMapper.selectById(multiTicket.getOrderId()))
+                                .map(List::of).orElseGet(List::of);
                 //加购车票
                 if (orderDonList.isEmpty()) {
                     GoodsDonSku donSku = skuMapper.selectById(groupsTrips.getSkuId());
@@ -266,17 +282,20 @@ public class CmsGroupServiceImpl extends ServiceImpl<GroupsMapper,GroupsTrips> i
                     log.info("设置账号,该用户 {} 初始时间:{} ", orderDon.getUserId(), DateUtil.format(expiryTime, "yyyy-MM-dd HH:mm:ss"));
                     GoodsDonSku donSku = skuMapper.selectById(orderDon.getSkuId());
                     Date newDate;
+                    int durationQuantity = multiTicket == null ? orderDon.getNum() : 1;
                     if (donSku.getDays() != null && donSku.getDays() > 0) {
-                        newDate = DateUtil.offsetDay(expiryTime, donSku.getDays() * orderDon.getNum());
+                        newDate = DateUtil.offsetDay(expiryTime, donSku.getDays() * durationQuantity);
                     } else {
-                        newDate = DateUtil.offset(expiryTime, DateField.MONTH, donSku.getMonths() * orderDon.getNum());
+                        newDate = DateUtil.offset(expiryTime, DateField.MONTH, donSku.getMonths() * durationQuantity);
                     }
                     relation.setExpiryTime(newDate);
                     log.info("设置账号,该用户 {} 过期时间:{} ", orderDon.getUserId(), DateUtil.format(newDate, "yyyy-MM-dd HH:mm:ss"));
                     relation.setStartTime(relation.getStartTime() == null ? new Date() : null);
                     relationMapper.updateById(relation);
-                    orderDon.setStatus(OrderDon.Status.complete);
-                    orderDonMapper.updateById(orderDon);
+                    if (multiTicket == null) {
+                        orderDon.setStatus(OrderDon.Status.complete);
+                        orderDonMapper.updateById(orderDon);
+                    }
                 });
             }
             if (relation.getUserId() != 0 && isPrdEnv && relation.getYhsId() == 0) {
@@ -328,7 +347,12 @@ public class CmsGroupServiceImpl extends ServiceImpl<GroupsMapper,GroupsTrips> i
 			    groupsTrips.setStatus(GroupsTrips.Status.down);
 		    }
 	    }
-       updateById(groupsTrips);
+        updateById(groupsTrips);
+
+        for (Long orderId : orderDonTicketRecordService
+                .listSuccessfulOrderIdsByGroupsId(groupsTrips.getId())) {
+            multiQuantityOrderService.completeIfAllTicketsAvailable(orderId);
+        }
 
         GoodsDon goodsDon = goodsDonMapper.selectById(account.getGoodsId());
         userSysMsgNotifyService.recordUserAccountSetSysMsg(account.getGoodsId(), groupsTrips.getSkuId(), groupsRelations, account.getAccount(), goodsDon.getTitle());

+ 651 - 43
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java

@@ -8,6 +8,7 @@ import cn.hutool.core.lang.Assert;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayTradeFastpayRefundQueryModel;
 import com.alipay.api.domain.AlipayTradeRefundModel;
 import com.alipay.api.request.AlipayTradeRefundRequest;
 import com.alipay.api.response.AlipayTradeRefundResponse;
@@ -63,6 +64,9 @@ import com.cyksj.service.mange.MirrorUserCleatService;
 import com.cyksj.service.mange.stock.GoodsDonStockService;
 import com.cyksj.service.market.MarketFrontService;
 import com.cyksj.service.order.OrderCommonService;
+import com.cyksj.service.order.OrderDonBusinessEventService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.order.OrderRefundService;
 import com.cyksj.service.order.UserRealOrderBenefitsService;
 import com.cyksj.service.relation.GroupRelationClearService;
@@ -81,6 +85,9 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
@@ -88,6 +95,7 @@ import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
@@ -238,6 +246,18 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
     private final AccountNetflixFrozenTicketReceivedRecordMapper receivedRecordMapper;
 
+    private final OrderDonTicketRecordService orderDonTicketRecordService;
+
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
+    private final OrderDonBusinessEventService orderDonBusinessEventService;
+
+    private final OrderDonGalaxyCoinRecordMapper orderDonGalaxyCoinRecordMapper;
+
+    private final UserBalanceSourceRecordMapper userBalanceSourceRecordMapper;
+
+    private final TransactionTemplate transactionTemplate;
+
     private static final GlobalThreadPoolTaskExecutor TASK_POOL = GlobalThreadPoolTaskExecutor.getInstance();
 
     @Override
@@ -349,6 +369,9 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
     public void kickOutRelationId(long adminId, ClickOutReq req) {
         Long orderId = req.getOrderId();
         OrderDon orderDon = getById(orderId);
+        if (orderDon != null && orderDonTicketRecordService.exists(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders cannot clear a single ticket");
+        }
         if (orderDon != null && !orderDon.getIsKickOut()) {
             try {
                 UserRelationKickOutRecord kickOutRecord = new UserRelationKickOutRecord();
@@ -381,6 +404,12 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
     @Override
     public void unifiedRefund(OrderRefundReq refundReq) throws Exception {
         OrderDon orderDon = orderDonMapper.selectById(refundReq.getOrderId());
+        boolean multiQuantityOrder = orderDon != null
+                && orderDonTicketRecordService.exists(orderDon.getId());
+        if (multiQuantityOrder) {
+            refundMultiQuantityOrder(refundReq, orderDon);
+            return;
+        }
         GoodsDonSku sku = null;
         if (orderDon.getSkuId() != null) {
             sku = goodsDonSkuMapper.selectById(orderDon.getSkuId());
@@ -410,7 +439,9 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
         orderDon.setRefundDesc(refundReq.getRefundDesc());
         //记录退款信息
-        commonRefund(orderDon, refundReq.getRefundMoney(), refundReq.getOperator(), goodsDon, sku, outNo, refundType, refundReq.getIsOnly(), refundReq.getRefundBalance(), refundReq.getChatImg(), refundReq.getDeductBalance(), refundReq.getDpRelationIds());
+        commonRefund(orderDon, refundReq.getRefundMoney(), refundReq.getOperator(), goodsDon, sku, outNo,
+                refundType, refundReq.getIsOnly(), refundReq.getRefundBalance(), refundReq.getChatImg(),
+                refundReq.getDeductBalance(), refundReq.getDpRelationIds(), multiQuantityOrder);
         //修改订单状态
         orderDon.setStatus(OrderDon.Status.refund);
         orderDonMapper.updateById(orderDon);
@@ -430,6 +461,476 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         groupRelationFrontService.setGroupsTripsParkingTimeEmpty(sku, orderDon.getRelationId());
     }
 
+    /**
+     * A multi-quantity order is one payment backed by independent ticket slots.
+     * Ticket reclaim, provider refund and order-level side effects are deliberately split so
+     * a provider call never participates in a database transaction.
+     */
+    private void refundMultiQuantityOrder(OrderRefundReq refundReq, OrderDon orderDon) throws Exception {
+        refundMultiQuantityOrder(refundReq, orderDon, 5);
+    }
+
+    private void refundMultiQuantityOrder(OrderRefundReq refundReq, OrderDon orderDon,
+                                          int leaseMinutes) throws Exception {
+        Long orderId = orderDon.getId();
+        OrderDonBusinessEvent existing = orderDonBusinessEventService.get(orderId, OrderDonBusinessEvent.Type.refund);
+        if (orderDon.getStatus() == OrderDon.Status.refund
+                && existing != null && existing.getStatus() == OrderDonBusinessEvent.Status.success) {
+            return;
+        }
+        validateMultiQuantityWholeRefund(refundReq, orderDon);
+        multiQuantityOrderService.prepareRefund(orderId);
+        if (orderDon.getType() == OrderDon.Type.PAYPAL || orderDon.getType() == OrderDon.Type.STRIPE) {
+            throw BusinessRuntimeException.getInstance("PayPal/Stripe批量订单请人工核对后退款");
+        }
+        OrderDonBusinessEvent refundEvent = orderDonBusinessEventService.get(
+                orderId, OrderDonBusinessEvent.Type.refund);
+        if (refundEvent != null && refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing) {
+            tryFinalizeProcessingRefund(orderDon, refundReq, refundEvent);
+            return;
+        }
+
+        String workerToken = UUID.randomUUID().toString().replace("-", "");
+        orderDonTicketRecordService.startRefund(orderId, workerToken);
+
+        GoodsDon goodsDon = goodsDonMapper.selectById(orderDon.getGoodsId());
+        GoodsDonSku sku = orderDon.getSkuId() == null ? null : goodsDonSkuMapper.selectById(orderDon.getSkuId());
+        String refundType = getMultiQuantityRefundType(orderDon);
+        String outRefundNo = buildMultiQuantityRefundNo(orderId);
+        if (requiresExternalRefund(orderDon)) {
+            try {
+                orderRefundService.centerRefund(refundReq, orderDon, outRefundNo);
+            } catch (Exception e) {
+                log.error("Multi-quantity refund submission failed, orderId:{}", orderId, e);
+                boolean restored = false;
+                try {
+                    ProviderRefundStatus status = queryProviderRefundStatus(orderDon, outRefundNo);
+                    if (status == ProviderRefundStatus.FAILED) {
+                        orderDonTicketRecordService.restoreRefund(orderId, workerToken,
+                                "provider refund submission failed");
+                        restored = true;
+                    }
+                } catch (Exception queryError) {
+                    log.warn("Unable to confirm refund submission result, orderId:{}", orderId, queryError);
+                }
+                if (restored) {
+                    throw BusinessRuntimeException.getInstance("退款申请失败,原车票已恢复");
+                }
+                throw BusinessRuntimeException.getInstance("退款申请状态确认中,请稍后查询");
+            }
+            if (orderDon.getType() == OrderDon.Type.WeChat) {
+                // WeChat's refund API only acknowledges the request. Finalize on its SUCCESS
+                // notification or a later query; tickets remain frozen meanwhile.
+                return;
+            }
+        }
+        final String finalWorkerToken = workerToken;
+        transactionTemplate.executeWithoutResult(status -> finishMultiQuantityRefund(
+                refundReq, orderDon, goodsDon, sku, outRefundNo, refundType, finalWorkerToken));
+    }
+
+    private void tryFinalizeProcessingRefund(OrderDon order, OrderRefundReq request,
+                                             OrderDonBusinessEvent event) throws Exception {
+        if (StrUtil.isBlank(event.getWorkerToken())) {
+            throw BusinessRuntimeException.getInstance("订单退款状态异常");
+        }
+        String outRefundNo = buildMultiQuantityRefundNo(order.getId());
+        ProviderRefundStatus status = queryProviderRefundStatus(order, outRefundNo);
+        if (status == ProviderRefundStatus.SUCCESS) {
+            GoodsDon goods = goodsDonMapper.selectById(order.getGoodsId());
+            GoodsDonSku sku = order.getSkuId() == null ? null : goodsDonSkuMapper.selectById(order.getSkuId());
+            transactionTemplate.executeWithoutResult(tx -> finishMultiQuantityRefund(
+                    request, order, goods, sku, outRefundNo,
+                    getMultiQuantityRefundType(order), event.getWorkerToken()));
+            return;
+        }
+        if (status == ProviderRefundStatus.FAILED) {
+            orderDonTicketRecordService.restoreRefund(order.getId(), event.getWorkerToken(),
+                    "provider refund failed or closed");
+            throw BusinessRuntimeException.getInstance("退款失败,原车票已恢复");
+        }
+        throw BusinessRuntimeException.getInstance("订单退款正在处理中,请稍后查询退款状态");
+    }
+
+    @Override
+    public int recoverMultiQuantityRefunds(int limit, int leaseMinutes) {
+        int boundedLimit = Math.max(1, Math.min(limit, 500));
+        int boundedLeaseMinutes = Math.max(1, Math.min(leaseMinutes, 120));
+        int processed = 0;
+        for (Long orderId : orderDonTicketRecordService.expiredRefundOrderIds(boundedLeaseMinutes, boundedLimit)) {
+            OrderDon order = orderDonMapper.selectById(orderId);
+            if (order == null || order.getStatus() == OrderDon.Status.refund) {
+                continue;
+            }
+            try {
+                recoverMultiQuantityRefund(order, boundedLeaseMinutes);
+            } catch (Exception e) {
+                log.error("Multi-quantity refund recovery failed, orderId:{}", orderId, e);
+            }
+            processed++;
+        }
+        return processed;
+    }
+
+    private void recoverMultiQuantityRefund(OrderDon order, int leaseMinutes) throws Exception {
+        OrderDonBusinessEvent refundEvent = orderDonBusinessEventService.get(
+                order.getId(), OrderDonBusinessEvent.Type.refund);
+        if (refundEvent == null || refundEvent.getStatus() != OrderDonBusinessEvent.Status.processing) {
+            return;
+        }
+        String outRefundNo = buildMultiQuantityRefundNo(order.getId());
+        ProviderRefundStatus providerStatus = queryProviderRefundStatus(order, outRefundNo);
+        if (providerStatus == ProviderRefundStatus.PROCESSING
+                || providerStatus == ProviderRefundStatus.UNKNOWN) {
+            if (StrUtil.isNotBlank(refundEvent.getWorkerToken())) {
+                orderDonTicketRecordService.deferRefundCheck(order.getId(), refundEvent.getWorkerToken(),
+                        "provider refund is not final");
+            }
+            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 (providerStatus == ProviderRefundStatus.FAILED) {
+            orderDonTicketRecordService.restoreRefund(order.getId(), workerToken,
+                    "provider refund failed or closed");
+            log.error("Multi-quantity provider refund failed, orderId:{}, outRefundNo:{}",
+                    order.getId(), outRefundNo);
+            return;
+        }
+        GoodsDon goodsDon = goodsDonMapper.selectById(order.getGoodsId());
+        GoodsDonSku sku = order.getSkuId() == null ? null : goodsDonSkuMapper.selectById(order.getSkuId());
+        OrderRefundReq request = buildRecoveryRefundRequest(order);
+        transactionTemplate.executeWithoutResult(status -> finishMultiQuantityRefund(
+                request, order, goodsDon, sku, outRefundNo,
+                getMultiQuantityRefundType(order), workerToken));
+    }
+
+    private OrderRefundReq buildRecoveryRefundRequest(OrderDon order) {
+        OrderRefundReq request = new OrderRefundReq();
+        request.setOrderId(order.getId());
+        request.setRefundMoney(Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO));
+        request.setRefundBalance(Optional.ofNullable(order.getBalance()).orElse(BigDecimal.ZERO));
+        request.setRefundDesc(StrUtil.blankToDefault(order.getRefundDesc(),
+                "multi-quantity whole-order refund recovery"));
+        request.setOperator("system-recovery");
+        request.setIsDiffer(false);
+        request.setRefundType(getMultiQuantityRefundType(order));
+        return request;
+    }
+
+    private boolean requiresExternalRefund(OrderDon order) {
+        return Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0
+                && order.getType() != OrderDon.Type.BALANCE;
+    }
+
+    private String refundShopId(OrderDon order) {
+        return StrUtil.blankToDefault(order.getShopId(),
+                order.getType() == OrderDon.Type.WeChat
+                        ? ShopConfig.WX_DEFAULT_APP_ID : ShopConfig.ZFB_DEFAULT_APP_ID);
+    }
+
+    private ProviderRefundStatus queryProviderRefundStatus(OrderDon order, String outRefundNo) throws Exception {
+        if (!requiresExternalRefund(order)) {
+            return ProviderRefundStatus.SUCCESS;
+        }
+        if (order.getType() == OrderDon.Type.WeChat) {
+            Map<String, String> result = weChatService.getWxRefundDetail(
+                    weChatConfig.getAppid(), refundShopId(order), outRefundNo);
+            return parseWxRefundStatus(result, order, outRefundNo);
+        }
+        if (order.getType() == OrderDon.Type.ALI_PAY) {
+            com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse response =
+                    queryAliRefund(order, outRefundNo);
+            if (response == null) {
+                return ProviderRefundStatus.UNKNOWN;
+            }
+            if (!StrUtil.equals(order.getOrderNo(), response.getOutTradeNo())
+                    || !StrUtil.equals(outRefundNo, response.getOutRequestNo())
+                    || !StrUtil.equals(order.getTransactionId(), response.getTradeNo())) {
+                throw BusinessRuntimeException.getInstance("支付宝退款查询订单信息不匹配");
+            }
+            BigDecimal expected = Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO);
+            BigDecimal actual = yuanToCent(response.getRefundAmount());
+            return actual.compareTo(expected) == 0
+                    ? ProviderRefundStatus.SUCCESS : ProviderRefundStatus.UNKNOWN;
+        }
+        return ProviderRefundStatus.UNKNOWN;
+    }
+
+    private com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse queryAliRefund(
+            OrderDon order, String outRefundNo) throws Exception {
+        AlipayClient alipayClient = AliPayClientFactory.getAlipayClient(
+                refundShopId(order));
+        com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest request =
+                new com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest();
+        AlipayTradeFastpayRefundQueryModel model = new AlipayTradeFastpayRefundQueryModel();
+        model.setTradeNo(order.getTransactionId());
+        model.setOutRequestNo(outRefundNo);
+        request.setBizModel(model);
+        ShopConfig shopConfig = shopConfigMapper.selectOne(Wrappers.lambdaQuery(ShopConfig.class)
+                .eq(ShopConfig::getAppId, order.getShopId()).last("limit 1"));
+        com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse response =
+                shopConfig != null && Boolean.TRUE.equals(shopConfig.getIsCert())
+                        ? alipayClient.certificateExecute(request) : alipayClient.execute(request);
+        return response != null && response.isSuccess() ? response : null;
+    }
+
+    private ProviderRefundStatus parseWxRefundStatus(Map<String, String> result,
+                                                      OrderDon order, String outRefundNo) {
+        if (result == null || !"SUCCESS".equals(result.get("result_code"))) {
+            return ProviderRefundStatus.UNKNOWN;
+        }
+        int index = findWxRefundIndex(result, outRefundNo);
+        if (index < 0) {
+            return ProviderRefundStatus.UNKNOWN;
+        }
+        if (!StrUtil.equals(order.getTransactionId(), result.get("transaction_id"))
+                || !StrUtil.equals(order.getOrderNo(), result.get("out_trade_no"))
+                || !StrUtil.equals(refundShopId(order), result.get("mch_id"))
+                || parseCent(result.get("total_fee"))
+                != Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO).longValueExact()
+                || parseCent(result.get("refund_fee_" + index))
+                != Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO).longValueExact()) {
+            throw BusinessRuntimeException.getInstance("微信退款查询订单信息不匹配");
+        }
+        return mapWxRefundStatus(result.get("refund_status_" + index));
+    }
+
+    private int findWxRefundIndex(Map<String, String> result, String outRefundNo) {
+        int count;
+        try {
+            count = Integer.parseInt(result.getOrDefault("refund_count", "0"));
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+        for (int index = 0; index < count; index++) {
+            if (StrUtil.equals(outRefundNo, result.get("out_refund_no_" + index))) {
+                return index;
+            }
+        }
+        return -1;
+    }
+
+    private ProviderRefundStatus mapWxRefundStatus(String status) {
+        if ("SUCCESS".equals(status)) {
+            return ProviderRefundStatus.SUCCESS;
+        }
+        if ("PROCESSING".equals(status)) {
+            return ProviderRefundStatus.PROCESSING;
+        }
+        if ("CHANGE".equals(status) || "REFUNDCLOSE".equals(status)) {
+            return ProviderRefundStatus.FAILED;
+        }
+        return ProviderRefundStatus.UNKNOWN;
+    }
+
+    private long parseCent(String value) {
+        try {
+            return Long.parseLong(value);
+        } catch (Exception e) {
+            throw BusinessRuntimeException.getInstance("退款金额格式异常");
+        }
+    }
+
+    private BigDecimal yuanToCent(String value) {
+        try {
+            return new BigDecimal(value).multiply(BigDecimal.valueOf(100))
+                    .setScale(0, RoundingMode.UNNECESSARY);
+        } catch (Exception e) {
+            throw BusinessRuntimeException.getInstance("退款金额格式异常");
+        }
+    }
+
+    private enum ProviderRefundStatus {
+        SUCCESS,
+        PROCESSING,
+        FAILED,
+        UNKNOWN
+    }
+
+    private void validateMultiQuantityWholeRefund(OrderRefundReq refundReq, OrderDon orderDon) {
+        if (orderDon.getStatus() == OrderDon.Status.noPayment || orderDon.getStatus() == OrderDon.Status.close) {
+            throw BusinessRuntimeException.getInstance("订单{0}状态不支持退款", orderDon.getStatus());
+        }
+        if (orderDon.getStatus() == OrderDon.Status.refund) {
+            throw BusinessRuntimeException.getInstance("该订单已退款");
+        }
+        if (orderDon.getCouponUserId() != null && orderDon.getCouponUserId() != 0) {
+            throw BusinessRuntimeException.getInstance("批量订单不支持优惠券退款");
+        }
+        BigDecimal money = Optional.ofNullable(orderDon.getMoney()).orElse(BigDecimal.ZERO);
+        BigDecimal balance = Optional.ofNullable(orderDon.getBalance()).orElse(BigDecimal.ZERO);
+        BigDecimal refundMoney = Optional.ofNullable(refundReq.getRefundMoney()).orElse(BigDecimal.ZERO);
+        BigDecimal requestedBalance = refundReq.getRefundBalance();
+        if (refundMoney.compareTo(money) != 0
+                || (requestedBalance != null && requestedBalance.compareTo(balance) != 0)
+                || Boolean.TRUE.equals(refundReq.getIsOnly()) || Boolean.TRUE.equals(refundReq.getIsDiffer())) {
+            throw BusinessRuntimeException.getInstance("批量订单仅支持整单退款,现金和余额必须全部退回");
+        }
+        refundReq.setRefundMoney(money);
+        refundReq.setRefundBalance(balance);
+        refundReq.setRefundType(getMultiQuantityRefundType(orderDon));
+    }
+
+    private void reclaimMultiQuantityTickets(OrderDon orderDon, String workerToken) {
+        List<OrderDonTicketRecord> records = orderDonTicketRecordService.listByOrderId(orderDon.getId());
+        int expected = Optional.ofNullable(orderDon.getNum()).orElse(records.size());
+        if (records.size() != expected) {
+            throw BusinessRuntimeException.getInstance("订单车票数量不匹配");
+        }
+        for (OrderDonTicketRecord record : records) {
+            if (isTicketAlreadyReclaimed(record)) {
+                continue;
+            }
+            if (record.getStatus() != OrderDonTicketRecord.Status.refunding) {
+                throw BusinessRuntimeException.getInstance("订单车票状态异常,序号: {0}", record.getItemIndex());
+            }
+            if (record.getRelationId() != null) {
+                clearMultiQuantityTicket(orderDon, record);
+            }
+            if (!orderDonTicketRecordService.markRefunded(record.getId(), workerToken)) {
+                throw BusinessRuntimeException.getInstance("车票退款状态更新冲突,序号: {0}", record.getItemIndex());
+            }
+        }
+        if (!areAllMultiQuantityTicketsReclaimed(orderDon,
+                orderDonTicketRecordService.listByOrderId(orderDon.getId()))) {
+            throw BusinessRuntimeException.getInstance("订单车票未全部回收");
+        }
+    }
+
+    static boolean areAllMultiQuantityTicketsReclaimed(OrderDon orderDon,
+                                                        List<OrderDonTicketRecord> records) {
+        int expected = Optional.ofNullable(orderDon.getNum()).orElse(records.size());
+        if (records.size() != expected) {
+            return false;
+        }
+        return records.stream().allMatch(CmsOrderDonServiceImpl::isTicketAlreadyReclaimed);
+    }
+
+    static boolean isTicketAlreadyReclaimed(OrderDonTicketRecord record) {
+        return record.getStatus() == OrderDonTicketRecord.Status.refunded
+                || record.getStatus() == OrderDonTicketRecord.Status.cleared;
+    }
+
+    private void clearMultiQuantityTicket(OrderDon orderDon, OrderDonTicketRecord record) {
+        OrderDonTicketRecord activeRecord = orderDonTicketRecordService.getActiveByRelationIdForUpdate(
+                record.getRelationId());
+        if (activeRecord == null || !activeRecord.getId().equals(record.getId())
+                || activeRecord.getStatus() != OrderDonTicketRecord.Status.refunding
+                || !orderDon.getId().equals(activeRecord.getOrderId())) {
+            throw BusinessRuntimeException.getInstance("车票分配记录已变更,relationId: {0}", record.getRelationId());
+        }
+        GroupsRelation relation = relationMapper.selectByIdForUpdate(record.getRelationId());
+        if (relation == null) {
+            throw BusinessRuntimeException.getInstance("退款车票不存在,relationId: {0}", record.getRelationId());
+        }
+        if (relation.getUserId() == null || relation.getUserId() == 0) {
+            return;
+        }
+        if (!isMultiQuantityTicketOwner(orderDon, relation)) {
+            throw BusinessRuntimeException.getInstance("车票归属异常,relationId: {0}", relation.getId());
+        }
+        relationClearService.clearTicket(relation, UserTicketClearedRecord.Source.refund);
+    }
+
+    static boolean isMultiQuantityTicketOwner(OrderDon orderDon, GroupsRelation relation) {
+        return orderDon != null && relation != null && orderDon.getUserId() != null
+                && orderDon.getUserId().equals(relation.getUserId());
+    }
+
+    private void finishMultiQuantityRefund(OrderRefundReq refundReq, OrderDon orderDon,
+                                           GoodsDon goodsDon, GoodsDonSku sku, String outRefundNo,
+                                           String refundType, String workerToken) {
+        OrderDon latest = orderDonMapper.selectByIdForUpdate(orderDon.getId());
+        if (latest == null) {
+            throw BusinessRuntimeException.getInstance("订单不存在");
+        }
+        OrderDonBusinessEvent refundEvent = orderDonBusinessEventService.get(
+                latest.getId(), OrderDonBusinessEvent.Type.refund);
+        if (refundEvent != null && refundEvent.getStatus() == OrderDonBusinessEvent.Status.success) {
+            return;
+        }
+        if (latest.getStatus() == OrderDon.Status.refund) {
+            throw BusinessRuntimeException.getInstance("订单退款状态与幂等事件不一致");
+        }
+        if (refundEvent == null || refundEvent.getStatus() != OrderDonBusinessEvent.Status.processing
+                || !StrUtil.equals(workerToken, refundEvent.getWorkerToken())) {
+            throw BusinessRuntimeException.getInstance("订单退款执行权已失效");
+        }
+        reclaimMultiQuantityTickets(latest, workerToken);
+        latest.setRefundDesc(refundReq.getRefundDesc());
+        latest.setRefundMoney(latest.getMoney());
+        latest.setRefundBalance(Optional.ofNullable(latest.getBalance()).orElse(BigDecimal.ZERO));
+
+        Integer returnedBalance = userBalanceSourceRecordMapper.selectCount(
+                Wrappers.lambdaQuery(UserBalanceSourceRecord.class)
+                        .eq(UserBalanceSourceRecord::getOrderId, latest.getId())
+                        .eq(UserBalanceSourceRecord::getSource, UserBalanceSourceRecord.Source.refund));
+        if ((OrderRefundReq.RefundType.balance.name().equals(refundType)
+                || OrderRefundReq.RefundType.bxj.name().equals(refundType))
+                && latest.getRefundBalance().compareTo(BigDecimal.ZERO) > 0
+                && returnedBalance != null && returnedBalance > 0) {
+            throw BusinessRuntimeException.getInstance("订单余额已退回,退款幂等状态异常");
+        }
+
+        commonRefund(latest, latest.getRefundMoney(), refundReq.getOperator(), goodsDon, sku,
+                outRefundNo, refundType, null, latest.getRefundBalance(), refundReq.getChatImg(),
+                refundReq.getDeductBalance(), null, true);
+        refundUsedGalaxyCoin(latest);
+        latest.setStatus(OrderDon.Status.refund);
+        if (orderDonMapper.update(latest, Wrappers.lambdaUpdate(OrderDon.class)
+                .ne(OrderDon::getStatus, OrderDon.Status.refund)
+                .eq(OrderDon::getId, latest.getId())) != 1) {
+            throw BusinessRuntimeException.getInstance("订单退款状态更新冲突");
+        }
+        if (!orderDonBusinessEventService.markSuccess(latest.getId(), OrderDonBusinessEvent.Type.refund, workerToken)) {
+            throw BusinessRuntimeException.getInstance("订单退款幂等状态更新冲突");
+        }
+    }
+
+    private void refundUsedGalaxyCoin(OrderDon orderDon) {
+        OrderDonGalaxyCoinRecord record = orderDonGalaxyCoinRecordMapper.selectOne(
+                Wrappers.lambdaQuery(OrderDonGalaxyCoinRecord.class)
+                        .eq(OrderDonGalaxyCoinRecord::getOrderId, orderDon.getId())
+                        .eq(OrderDonGalaxyCoinRecord::getSource, OrderDonGalaxyCoinRecord.Source.ordinary.ordinal())
+                        .last("limit 1"));
+        if (record == null || record.getGalaxyCoin() == null || record.getGalaxyCoin().compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        userGalaxyCoinService.recordOrderRefundGalaxyCoinIfValid(orderDon.getId(), record.getUserId(),
+                record.getGalaxyCoin(), OrderDonGalaxyCoinRecord.Source.ordinary);
+    }
+
+    private String getMultiQuantityRefundType(OrderDon orderDon) {
+        BigDecimal money = Optional.ofNullable(orderDon.getMoney()).orElse(BigDecimal.ZERO);
+        BigDecimal balance = Optional.ofNullable(orderDon.getBalance()).orElse(BigDecimal.ZERO);
+        if (money.compareTo(BigDecimal.ZERO) > 0 && balance.compareTo(BigDecimal.ZERO) > 0) {
+            return OrderRefundReq.RefundType.bxj.name();
+        }
+        if (money.compareTo(BigDecimal.ZERO) == 0 && balance.compareTo(BigDecimal.ZERO) > 0) {
+            return OrderRefundReq.RefundType.balance.name();
+        }
+        return OrderRefundReq.RefundType.money.name();
+    }
+
+    private String buildMultiQuantityRefundNo(Long orderId) throws Exception {
+        return Codec.DoDigest.custom()
+                .setAlgorithm(Codec.DoDigest.Algorithm.MD5)
+                .setStringData("multi-order-refund:" + orderId)
+                .toHexString();
+    }
+
     @Override
     @Transactional(rollbackFor = Throwable.class)
     public void unifiedUpgradeRefund(OrderRefundReq refundReq) throws Exception {
@@ -713,29 +1214,90 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
     @Override
     public void wxRefundNotify(Map<String, String> map) throws Exception {
-        // 对退款数据 解密
-        String md5Key = Codec.DoDigest.custom().setAlgorithm(Codec.DoDigest.Algorithm.MD5).setStringData(shopConfigMapper.getByAppId(map.get("mch_id")).getSecret()).toHexString();
-        String reqInfoXml = Codec.DoCipher.custom().setAlgorithm(Codec.DoCipher.Trans.AES_ECB_PKCS7Padding).setBase64Data(map.get("req_info")).setStringKey(md5Key).ofDecrypt().toText();
+        ShopConfig callbackShop = shopConfigMapper.getByAppId(map.get("mch_id"));
+        if (callbackShop == null || StrUtil.isBlank(callbackShop.getSecret())) {
+            throw BusinessRuntimeException.getInstance("退款回调商户不存在");
+        }
+        String md5Key = Codec.DoDigest.custom().setAlgorithm(Codec.DoDigest.Algorithm.MD5)
+                .setStringData(callbackShop.getSecret()).toHexString();
+        String reqInfoXml = Codec.DoCipher.custom()
+                .setAlgorithm(Codec.DoCipher.Trans.AES_ECB_PKCS7Padding)
+                .setBase64Data(map.get("req_info")).setStringKey(md5Key).ofDecrypt().toText();
         log.info("=== 退款数据解密: \n {}", reqInfoXml);
-        Map<String, String> reqInfoMap = Xmls.toMap(reqInfoXml);
-        // 退款失败
-        if (StrUtil.equals("CHANGE", reqInfoMap.get("refund_status"))) {
-            throw BusinessRuntimeException.getInstance("退款异常.");
-        } else if (StrUtil.equals("REFUNDCLOSE", reqInfoMap.get("refund_status"))) {
-            throw BusinessRuntimeException.getInstance("退款关闭.");
-        }
-        /* 此处处理业务逻辑 */
-        String outRefundNo = reqInfoMap.get("out_refund_no");
-        String transactionId = reqInfoMap.get("transaction_id");
-        String refundFee = reqInfoMap.get("refund_fee");
-        OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class).eq(OrderDon::getTransactionId, transactionId).last("limit 1"));
-        if (orderDon == null) {
-            log.error("退款回调,transactionId:{}订单不存在", transactionId);
+        Map<String, String> refund = Xmls.toMap(reqInfoXml);
+        String transactionId = refund.get("transaction_id");
+        OrderDon order = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+                .eq(OrderDon::getTransactionId, transactionId).last("limit 1"));
+        if (order == null) {
+            throw BusinessRuntimeException.getInstance("退款回调订单不存在");
+        }
+        String expectedRefundNo = buildMultiQuantityRefundNo(order.getId());
+        boolean multiQuantity = orderDonTicketRecordService.exists(order.getId());
+        if (!multiQuantity) {
+            log.info("微信普通订单退款回调, transactionId:{}, outRefundNo:{}",
+                    transactionId, refund.get("out_refund_no"));
+            return;
+        }
+        if (order.getType() != OrderDon.Type.WeChat) {
+            throw BusinessRuntimeException.getInstance("退款回调支付方式不匹配");
+        }
+        validateWxRefundCallback(refund, order, callbackShop, expectedRefundNo);
+        String status = refund.get("refund_status");
+        if ("SUCCESS".equals(status)) {
+            OrderDonBusinessEvent event = orderDonBusinessEventService.get(
+                    order.getId(), OrderDonBusinessEvent.Type.refund);
+            if (event != null && event.getStatus() == OrderDonBusinessEvent.Status.success) {
+                return;
+            }
+            if (event == null) {
+                log.error("WeChat refund succeeded without a local refund event, orderId:{}", order.getId());
+                throw BusinessRuntimeException.getInstance("退款回调缺少本地退款记录");
+            }
+            if (event.getStatus() == OrderDonBusinessEvent.Status.failed
+                    && StrUtil.isBlank(event.getWorkerToken())) {
+                String callbackToken = UUID.randomUUID().toString().replace("-", "");
+                orderDonTicketRecordService.startRefund(order.getId(), callbackToken);
+                event = orderDonBusinessEventService.get(order.getId(), OrderDonBusinessEvent.Type.refund);
+            }
+            if (event.getStatus() != OrderDonBusinessEvent.Status.processing
+                    || StrUtil.isBlank(event.getWorkerToken())) {
+                throw BusinessRuntimeException.getInstance("退款回调状态异常");
+            }
+            GoodsDon goods = goodsDonMapper.selectById(order.getGoodsId());
+            GoodsDonSku sku = order.getSkuId() == null ? null : goodsDonSkuMapper.selectById(order.getSkuId());
+            OrderRefundReq request = buildRecoveryRefundRequest(order);
+            String workerToken = event.getWorkerToken();
+            transactionTemplate.executeWithoutResult(tx -> finishMultiQuantityRefund(
+                    request, order, goods, sku, expectedRefundNo,
+                    getMultiQuantityRefundType(order), workerToken));
             return;
         }
-        log.info("微信退款: 退款单号[{}] 退款成功.", outRefundNo);
+        if ("CHANGE".equals(status) || "REFUNDCLOSE".equals(status)) {
+            OrderDonBusinessEvent event = orderDonBusinessEventService.get(
+                    order.getId(), OrderDonBusinessEvent.Type.refund);
+            if (event != null && event.getStatus() == OrderDonBusinessEvent.Status.processing
+                    && StrUtil.isNotBlank(event.getWorkerToken())) {
+                orderDonTicketRecordService.restoreRefund(order.getId(), event.getWorkerToken(),
+                        "WeChat refund " + status);
+            }
+            log.error("微信批量订单退款失败, orderId:{}, status:{}", order.getId(), status);
+        }
     }
 
+    private void validateWxRefundCallback(Map<String, String> refund, OrderDon order,
+                                          ShopConfig callbackShop, String expectedRefundNo) {
+        long expectedMoney = Optional.ofNullable(order.getMoney()).orElse(BigDecimal.ZERO).longValueExact();
+        if (!StrUtil.equals(callbackShop.getAppId(), refundShopId(order))
+                || !StrUtil.equals(callbackShop.getAppId(), refund.get("mch_id"))
+                || !StrUtil.equals(weChatConfig.getAppid(), refund.get("appid"))
+                || !StrUtil.equals(expectedRefundNo, refund.get("out_refund_no"))
+                || !StrUtil.equals(order.getTransactionId(), refund.get("transaction_id"))
+                || !StrUtil.equals(order.getOrderNo(), refund.get("out_trade_no"))
+                || parseCent(refund.get("total_fee")) != expectedMoney
+                || parseCent(refund.get("refund_fee")) != expectedMoney) {
+            throw BusinessRuntimeException.getInstance("微信退款回调订单信息不匹配");
+        }
+    }
     @Override
     @Transactional(rollbackFor = Throwable.class)
     public void changeRelation(ChangeRelationReq req) throws Exception {
@@ -744,7 +1306,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         UserTicketClearedRecord.Source source = req.getSource();
         if (source == null) source = UserTicketClearedRecord.Source.change_ticket;
         GroupsRelation relation = relationMapper.selectById(relationId);
-        Long yhsId = relation.getYhsId();
+        Long yhsId = relation == null ? null : relation.getYhsId();
         Assert.notNull(relation, "车票已不存在");
         if (relation.getRechargeStatus() != null) {
             throw BusinessRuntimeException.getInstance("代充账号车票无法更换车票");
@@ -760,14 +1322,21 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
         Long userId = relation.getUserId();
         List<Long> userIdList = userBindRelationService.getRelationUserIdList(userId, null);
+        OrderDonTicketRecord multiTicketRecord = orderDonTicketRecordService.getActiveByRelationIdForUpdate(relationId);
+        if (multiTicketRecord != null
+                && multiTicketRecord.getStatus() == OrderDonTicketRecord.Status.refunding) {
+            throw BusinessRuntimeException.getInstance("订单退款处理中,暂不支持更换车票");
+        }
         try {
             log.info("用户userId:{}更换车票relationId:{}", userId, relationId);
-            OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-                    .in(OrderDon::getUserId, userIdList)
-                    .eq(OrderDon::getRelationId, relationId)
-                    .notIn(OrderDon::getStatus, Constant.noOrderStatus)
-                    .orderByDesc(OrderDon::getId)
-                    .last("limit 1"));
+            OrderDon orderDon = multiTicketRecord == null
+                    ? orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+                            .in(OrderDon::getUserId, userIdList)
+                            .eq(OrderDon::getRelationId, relationId)
+                            .notIn(OrderDon::getStatus, Constant.noOrderStatus)
+                            .orderByDesc(OrderDon::getId)
+                            .last("limit 1"))
+                    : orderDonMapper.selectById(multiTicketRecord.getOrderId());
             OrderDiscountPlusPurchaseSkuRelation plusPurchaseSkuRelation = null;
             List<Long> plus_relationList = null;
             if (orderDon == null) {
@@ -865,7 +1434,9 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             relationClearService.clearTicket(relation, source);
 
             //兜底处理 多余车票
-            clearOtherChangeTicket(userId, relationId, newRelation, orderDon.getSkuId());
+            if (multiTicketRecord == null) {
+                clearOtherChangeTicket(userId, relationId, newRelation, orderDon.getSkuId());
+            }
 
             //奈飞车票
             if (orderDon.getGoodsId() == 1) {
@@ -904,6 +1475,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             }
 
             if ((newRelation.getStartTime() == null || newRelation.getExpiryTime() == null)) {
+                if (multiTicketRecord != null) {
+                    relationMapper.updateById(newRelation);
+                    return;
+                }
                 List<OrderDon> orderDonList = orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class).eq(OrderDon::getUserId, userId).eq(OrderDon::getRelationId, relationId).eq(OrderDon::getStatus, OrderDon.Status.hasPayment));
                 if (orderDonList.isEmpty()) {
                     orderDonList = orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class)
@@ -942,7 +1517,9 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                 return;
             }
 
-            List<OrderDon> orderDonList = orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class).eq(OrderDon::getUserId, userId).eq(OrderDon::getRelationId, relationId).eq(OrderDon::getStatus, OrderDon.Status.complete));
+            List<OrderDon> orderDonList = multiTicketRecord == null
+                    ? orderDonMapper.selectList(Wrappers.lambdaQuery(OrderDon.class).eq(OrderDon::getUserId, userId).eq(OrderDon::getRelationId, relationId).eq(OrderDon::getStatus, OrderDon.Status.complete))
+                    : java.util.Collections.emptyList();
             orderDonList.forEach(completeOrder->{
                 completeOrder.setRelationId(newRelation.getId());
                 orderDonMapper.updateById(completeOrder);
@@ -1156,12 +1733,16 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
     /**
      * 退款后 处理关联
      */
-    public void commonRefund(OrderDon orderDon, BigDecimal refundMoney, String operator, GoodsDon goodsDon, GoodsDonSku sku, String outNo, String refundType, Boolean isOnly, BigDecimal refundBalance, String chatImg, BigDecimal deductBalance, List<Long> dpRelationIds) {
+    public void commonRefund(OrderDon orderDon, BigDecimal refundMoney, String operator, GoodsDon goodsDon,
+                             GoodsDonSku sku, String outNo, String refundType, Boolean isOnly,
+                             BigDecimal refundBalance, String chatImg, BigDecimal deductBalance,
+                             List<Long> dpRelationIds, boolean multiQuantityOrder) {
         orderRefundService.refundRecord(orderDon, refundMoney, refundBalance, operator, goodsDon, sku, outNo, refundType, chatImg);
-        //退款一次
-        goodsDonStockService.upStockRelate(null, orderDon.getId(), "refund");
+        if (shouldRestoreStock(goodsDon, multiQuantityOrder)) {
+            goodsDonStockService.upStockRelate(null, orderDon.getId(), "refund");
+        }
         try {
-            if (!orderDon.getIsDp()) {
+            if (!multiQuantityOrder && !orderDon.getIsDp()) {
                 GroupsRelation relation = relationMapper.selectById(orderDon.getRelationId());
                 //冻结车票 清除
                 if (relation != null && relation.getIsFrozen()) {
@@ -1237,9 +1818,16 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         } catch (Exception e) {
             log.error("wx减少抽奖机会错误,orderId:{},error:{}", orderDon.getId(), e);
         }
-        if (OrderDon.Type.BALANCE.getType().equals(refundType)) {
-            if (isOnly == null && orderDon.getMoney().compareTo(BigDecimal.ZERO) > 0) {
-                userBenefitsService.addUserBalance(orderDon.getUserId(), refundMoney, UserBalanceSourceRecord.Source.refund, orderDon.getYhsId(), orderDon.getId(), goodsDon.getTitle() + UserBalanceSourceRecord.Source.refund.getDesc(), true);
+        if (OrderRefundReq.RefundType.balance.name().equals(refundType)) {
+            if (isOnly == null) {
+                BigDecimal balanceAmount = Optional.ofNullable(refundBalance)
+                        .orElse(Optional.ofNullable(refundMoney).orElse(BigDecimal.ZERO));
+                if (balanceAmount.compareTo(BigDecimal.ZERO) > 0) {
+                    userBenefitsService.addUserBalance(orderDon.getUserId(), balanceAmount,
+                            UserBalanceSourceRecord.Source.refund, orderDon.getYhsId(),
+                            orderDon.getId(), goodsDon.getTitle()
+                                    + UserBalanceSourceRecord.Source.refund.getDesc(), true);
+                }
             }
         } else {
             //余额退款不需要扣除积分
@@ -1313,7 +1901,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
 
         //同步车票退款 重购记录
-        TASK_POOL.execute(() -> {
+        Runnable syncRebuyRecords = () -> {
             GroupsRelationExpiryRecord groupsRelationExpiryRecord = groupsRelationExpiryRecordService.getOne(Wrappers.lambdaQuery(GroupsRelationExpiryRecord.class)
                     .in(GroupsRelationExpiryRecord::getReBuyOrderId, orderDon.getId())
                     .eq(GroupsRelationExpiryRecord::getRelationId, orderDon.getRelationId()).last("limit 1"));
@@ -1321,19 +1909,26 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                 log.info("订单orderId:{}退款,同步是否有重购记录不是直接续费车票", orderDon.getId());
                 List<GroupsRelationExpiryRecord> groupsRelationExpiryRecords = groupsRelationExpiryRecordService.list(Wrappers.lambdaQuery(GroupsRelationExpiryRecord.class)
                         .in(GroupsRelationExpiryRecord::getReBuyOrderId, orderDon.getId()));
-                log.info("订单orderId:{}退款,重购记录不是直接续费车票大小:{}", groupsRelationExpiryRecords.size());
+                log.info("订单orderId:{}退款,重购记录不是直接续费车票大小:{}", orderDon.getId(), groupsRelationExpiryRecords.size());
                 if (CollUtil.isNotEmpty(groupsRelationExpiryRecords)) {
-                    groupsRelationExpiryRecords.forEach(data -> {
-                        data.setIsReBuy(false);
-                    });
+                    groupsRelationExpiryRecords.forEach(data -> data.setIsReBuy(false));
                     groupsRelationExpiryRecordService.saveOrUpdateBatch(groupsRelationExpiryRecords);
                 }
-            }
-            if (groupsRelationExpiryRecord != null) {
+            } else {
                 groupsRelationExpiryRecord.setIsReBuy(false);
                 groupsRelationExpiryRecordService.saveOrUpdate(groupsRelationExpiryRecord);
             }
-        });
+        };
+        if (TransactionSynchronizationManager.isActualTransactionActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    TASK_POOL.execute(syncRebuyRecords);
+                }
+            });
+        } else {
+            TASK_POOL.execute(syncRebuyRecords);
+        }
 
         //是否是礼品卡 现金支付订单
         BigDecimal gcCash = orderDon.getGcCash();
@@ -1426,6 +2021,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
     }
 
     //处理下级渠道分销提成
+    static boolean shouldRestoreStock(GoodsDon goodsDon, boolean multiQuantityOrder) {
+        return !multiQuantityOrder || goodsDon == null || goodsDon.getType() != 1;
+    }
+
     public void handleSubRewards(OrderDon orderDon) {
         //是否是分销订单
         //处理下级渠道分销提成
@@ -1543,6 +2142,9 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         if (orderDon == null) {
             throw BusinessRuntimeException.getInstance("订单不存在");
         }
+        if (orderDonTicketRecordService.exists(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders only support whole-order refunds");
+        }
         if (orderDon.getMoney().compareTo(BigDecimal.ZERO) == 0) {
             throw BusinessRuntimeException.getInstance("该订单是0元订单,无法补差价");
         }
@@ -1607,6 +2209,12 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
         Long orderId = refundOrderDon.getOrderId();
         OrderDon orderDon = orderDonMapper.selectById(orderId);
+        if (orderDon == null) {
+            throw BusinessRuntimeException.getInstance("Order does not exist");
+        }
+        if (orderDonTicketRecordService.exists(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders only support whole-order refunds");
+        }
         if (orderDon.getMoney().compareTo(BigDecimal.ZERO) == 0) {
             throw BusinessRuntimeException.getInstance("该记录对应的订单无实际支付金额");
         }

+ 16 - 0
netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderConfigService.java

@@ -0,0 +1,16 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.dto.MultiQuantitySkuConfig;
+
+import java.util.Optional;
+
+public interface MultiQuantityOrderConfigService {
+
+    String CONFIG_KEY = "multi_quantity_order_goods";
+
+    Optional<MultiQuantitySkuConfig> findConfig(Long skuId);
+
+    boolean isMultiQuantity(Long skuId, Integer num);
+
+    MultiQuantitySkuConfig validateAndGet(Long skuId, Integer num);
+}

+ 22 - 0
netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderService.java

@@ -0,0 +1,22 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.entity.GoodsDon;
+import com.cyksj.model.entity.GoodsDonSku;
+import com.cyksj.model.entity.OrderDon;
+
+public interface MultiQuantityOrderService {
+
+    void initialize(OrderDon order);
+
+    boolean isMultiQuantityOrder(Long orderId);
+
+    void preparePrepay(OrderDon order, OrderDon.Type type, String shopId, Boolean webPay);
+
+    void prepareRefund(Long orderId);
+
+    void handlePaidOrder(OrderDon order, GoodsDon goods, GoodsDonSku sku);
+
+    int deliver(Long orderId);
+
+    boolean completeIfAllTicketsAvailable(Long orderId);
+}

+ 26 - 0
netflix-service/src/main/java/com/cyksj/service/order/OrderDonBusinessEventService.java

@@ -0,0 +1,26 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+
+import java.util.List;
+
+public interface OrderDonBusinessEventService {
+
+    boolean createIfAbsent(Long orderId, OrderDonBusinessEvent.Type eventType);
+
+    boolean claim(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken);
+
+    boolean markSuccess(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken);
+
+    boolean markFailed(Long orderId, OrderDonBusinessEvent.Type eventType,
+                       String workerToken, String errorMsg);
+
+    OrderDonBusinessEvent get(Long orderId, OrderDonBusinessEvent.Type eventType);
+
+    boolean lockClaim(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken);
+
+    List<Long> pendingOrderIds(OrderDonBusinessEvent.Type eventType, int limit);
+
+    int recoverExpired(int leaseMinutes);
+
+}

+ 54 - 0
netflix-service/src/main/java/com/cyksj/service/order/OrderDonTicketRecordService.java

@@ -0,0 +1,54 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.entity.OrderDonTicketRecord;
+
+import java.util.List;
+
+public interface OrderDonTicketRecordService {
+
+    void initSlots(Long orderId, int quantity);
+
+    boolean exists(Long orderId);
+
+    List<OrderDonTicketRecord> listByOrderId(Long orderId);
+
+    boolean claim(Long recordId, String workerToken, int maxRetries);
+
+    boolean lockClaim(Long recordId, String workerToken);
+
+    boolean markSuccess(Long recordId, Long relationId, String workerToken);
+
+    boolean markFailed(Long recordId, String workerToken, String errorMsg);
+
+    List<Long> pendingOrderIds(int limit, int maxRetries);
+
+    int recoverExpired(int leaseMinutes);
+
+    List<Long> expiredRefundOrderIds(int leaseMinutes, int limit);
+
+    String reclaimExpiredRefund(Long orderId, int leaseMinutes);
+
+    boolean deferRefundCheck(Long orderId, String workerToken, String message);
+
+    void startRefund(Long orderId, String workerToken);
+
+    boolean markRefunded(Long recordId, String workerToken);
+
+    void restoreRefund(Long orderId, String workerToken, String errorMsg);
+
+    int countSuccess(Long orderId);
+
+    int countAvailable(Long orderId);
+
+    OrderDonTicketRecord getActiveByRelationId(Long relationId);
+
+    OrderDonTicketRecord getActiveByRelationIdForUpdate(Long relationId);
+
+    boolean replaceActiveRelation(Long oldRelationId, Long newRelationId);
+
+    boolean clearActiveRelation(Long relationId);
+
+    List<OrderDonTicketRecord> listSuccessfulByGroupsId(Long groupsId);
+
+    List<Long> listSuccessfulOrderIdsByGroupsId(Long groupsId);
+}

+ 1 - 0
netflix-service/src/main/java/com/cyksj/service/order/OrderRefundService.java

@@ -14,6 +14,7 @@ import java.math.BigDecimal;
 public interface OrderRefundService {
 
 	String centerRefund(OrderRefundReq refundReq, OrderDon orderDon) throws Exception;
+	String centerRefund(OrderRefundReq refundReq, OrderDon orderDon, String outRefundNo) throws Exception;
 	String centerUpgradeRefund(OrderRefundReq refundReq, UpgradeOrderDon orderDon) throws Exception;
 
 	/**

+ 137 - 0
netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderConfigServiceImpl.java

@@ -0,0 +1,137 @@
+package com.cyksj.service.order.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.common.util.Jsons;
+import com.cyksj.model.dto.MultiQuantitySkuConfig;
+import com.cyksj.model.entity.SysConfig;
+import com.cyksj.redis.RedisService;
+import com.cyksj.service.order.MultiQuantityOrderConfigService;
+import com.cyksj.service.sys.SysConfigService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MultiQuantityOrderConfigServiceImpl implements MultiQuantityOrderConfigService {
+
+    private static final int ABSOLUTE_MAX_NUM = 100;
+
+    private static final long CONFIG_CACHE_SECONDS = 300L;
+
+    private volatile String cachedConfigValue;
+
+    private volatile Map<Long, MultiQuantitySkuConfig> cachedConfigMap = Collections.emptyMap();
+
+    private final SysConfigService sysConfigService;
+
+    private final RedisService redisService;
+
+    @Override
+    public Optional<MultiQuantitySkuConfig> findConfig(Long skuId) {
+        if (skuId == null || skuId <= 0) {
+            return Optional.empty();
+        }
+        return Optional.ofNullable(loadConfigMap().get(skuId));
+    }
+
+    @Override
+    public boolean isMultiQuantity(Long skuId, Integer num) {
+        return num != null && num > 1 && findConfig(skuId).isPresent();
+    }
+
+    @Override
+    public MultiQuantitySkuConfig validateAndGet(Long skuId, Integer num) {
+        if (num == null || num <= 1) {
+            throw BusinessRuntimeException.getInstance("多数量下单数量必须大于1");
+        }
+        MultiQuantitySkuConfig config = findConfig(skuId)
+                .orElseThrow(() -> BusinessRuntimeException.getInstance("该规格不支持一次购买多个"));
+        if (num > config.getMaxNum()) {
+            throw BusinessRuntimeException.getInstance("该规格单次最多购买{0}个", config.getMaxNum());
+        }
+        return config;
+    }
+
+    private Map<Long, MultiQuantitySkuConfig> loadConfigMap() {
+        SysConfig sysConfig = getSysConfig();
+        if (sysConfig == null || StrUtil.isBlank(sysConfig.getSysValue())) {
+            return Collections.emptyMap();
+        }
+        String configValue = sysConfig.getSysValue();
+        if (configValue.equals(cachedConfigValue)) {
+            return cachedConfigMap;
+        }
+        synchronized (this) {
+            if (configValue.equals(cachedConfigValue)) {
+                return cachedConfigMap;
+            }
+            Map<Long, MultiQuantitySkuConfig> parsedConfig = parseConfig(configValue);
+            cachedConfigMap = parsedConfig;
+            cachedConfigValue = configValue;
+            return parsedConfig;
+        }
+    }
+
+    private Map<Long, MultiQuantitySkuConfig> parseConfig(String configValue) {
+        try {
+            List<MultiQuantitySkuConfig> configs = Jsons.parseList(configValue, MultiQuantitySkuConfig.class);
+            if (CollUtil.isEmpty(configs)) {
+                return Collections.emptyMap();
+            }
+            Map<Long, MultiQuantitySkuConfig> configMap = new HashMap<>(configs.size());
+            for (MultiQuantitySkuConfig config : configs) {
+                validateConfigEntry(config);
+                if (configMap.put(config.getSkuId(), config) != null) {
+                    throw new IllegalArgumentException("duplicate skuId: " + config.getSkuId());
+                }
+            }
+            return Collections.unmodifiableMap(configMap);
+        } catch (Exception e) {
+            log.error("Invalid multi-quantity SKU config", e);
+            throw BusinessRuntimeException.getInstance("多数量规格配置异常,请联系客服");
+        }
+    }
+
+    private SysConfig getSysConfig() {
+        String cacheKey = RedisService.key.SYS_CONFIG_CACHE_KEY.getName() + CONFIG_KEY;
+        try {
+            Object cached = redisService.get(cacheKey);
+            if (cached instanceof SysConfig) {
+                return (SysConfig) cached;
+            }
+        } catch (Exception e) {
+            log.warn("Read multi-quantity config cache failed: {}", e.getMessage());
+        }
+
+        SysConfig sysConfig = sysConfigService.getOne(Wrappers.lambdaQuery(SysConfig.class)
+                .eq(SysConfig::getSysKey, CONFIG_KEY)
+                .last("limit 1"));
+        if (sysConfig != null) {
+            try {
+                redisService.set(cacheKey, sysConfig, CONFIG_CACHE_SECONDS);
+            } catch (Exception e) {
+                log.warn("Write multi-quantity config cache failed: {}", e.getMessage());
+            }
+        }
+        return sysConfig;
+    }
+
+    private void validateConfigEntry(MultiQuantitySkuConfig config) {
+        if (config == null || config.getSkuId() == null || config.getSkuId() <= 0
+                || config.getMaxNum() == null || config.getMaxNum() < 2
+                || config.getMaxNum() > ABSOLUTE_MAX_NUM) {
+            throw new IllegalArgumentException("skuId and maxNum must be valid");
+        }
+    }
+}

+ 476 - 0
netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java

@@ -0,0 +1,476 @@
+package com.cyksj.service.order.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.common.util.StringUtil;
+import com.cyksj.mapper.GoodsDonMapper;
+import com.cyksj.mapper.GoodsDonSkuMapper;
+import com.cyksj.mapper.OrderDonMapper;
+import com.cyksj.model.entity.GoodsDon;
+import com.cyksj.model.entity.GoodsDonSku;
+import com.cyksj.model.entity.GroupsRelation;
+import com.cyksj.model.entity.OrderDon;
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+import com.cyksj.model.entity.OrderDonTicketRecord;
+import com.cyksj.model.entity.TaskType;
+import com.cyksj.model.entity.UserBalanceSourceRecord;
+import com.cyksj.model.entity.SysConfig;
+import com.cyksj.model.dto.GalaxyCoinConsumeRateDto;
+import com.cyksj.mapper.UserBalanceSourceRecordMapper;
+import com.cyksj.mapper.market.task.TaskTypeMapper;
+import com.cyksj.common.constant.TaskTypeEnum;
+import com.cyksj.common.constant.Constant;
+import com.cyksj.common.constant.PointsRecordConst;
+import com.cyksj.service.coin.UserGalaxyCoinService;
+import com.cyksj.service.exchange.ExchangeCodeService;
+import com.cyksj.service.market.task.TaskService;
+import com.cyksj.service.order.MultiQuantityOrderService;
+import com.cyksj.service.order.OrderDonBusinessEventService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
+import com.cyksj.service.relation.GroupsRelationExpiryRecordService;
+import com.cyksj.service.shop.YhShopDistributeService;
+import com.cyksj.service.user.UserBenefitsService;
+import com.cyksj.service.sys.SysConfigService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService {
+
+    private static final int DELIVERY_MAX_RETRIES = 5;
+
+    private final OrderDonMapper orderDonMapper;
+    private final GoodsDonMapper goodsDonMapper;
+    private final GoodsDonSkuMapper goodsDonSkuMapper;
+    private final OrderDonTicketRecordService ticketRecordService;
+    private final OrderDonBusinessEventService businessEventService;
+    private final ExchangeCodeService exchangeCodeService;
+    private final GroupsRelationExpiryRecordService expiryRecordService;
+    private final UserBenefitsService userBenefitsService;
+    private final UserBalanceSourceRecordMapper userBalanceSourceRecordMapper;
+    private final UserGalaxyCoinService userGalaxyCoinService;
+    private final YhShopDistributeService yhShopDistributeService;
+    private final TaskTypeMapper taskTypeMapper;
+    private final TaskService taskService;
+    private final SysConfigService sysConfigService;
+
+    private final PlatformTransactionManager transactionManager;
+    private final ObjectProvider<OrderDonServiceImpl> orderServiceProvider;
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public void initialize(OrderDon order) {
+        if (order == null || order.getId() == null || order.getNum() == null || order.getNum() <= 1) {
+            throw new IllegalArgumentException("multi-quantity order is required");
+        }
+        ticketRecordService.initSlots(order.getId(), order.getNum());
+        businessEventService.createIfAbsent(order.getId(), OrderDonBusinessEvent.Type.distribute);
+        businessEventService.createIfAbsent(order.getId(), OrderDonBusinessEvent.Type.order_side_effects);
+        businessEventService.createIfAbsent(order.getId(), OrderDonBusinessEvent.Type.balance_record);
+        businessEventService.createIfAbsent(order.getId(), OrderDonBusinessEvent.Type.galaxy_cashback);
+    }
+
+    @Override
+    public boolean isMultiQuantityOrder(Long orderId) {
+        return orderId != null && orderId > 0 && ticketRecordService.exists(orderId);
+    }
+
+    @Override
+    public void preparePrepay(OrderDon order, OrderDon.Type type, String shopId, Boolean webPay) {
+        if (order == null || !isMultiQuantityOrder(order.getId())) {
+            return;
+        }
+        int updated = orderDonMapper.update(null, Wrappers.lambdaUpdate(OrderDon.class)
+                .set(OrderDon::getType, type)
+                .set(OrderDon::getShopId, shopId)
+                .set(webPay != null, OrderDon::getIsWebPay, webPay)
+                .eq(OrderDon::getId, order.getId())
+                .eq(OrderDon::getStatus, OrderDon.Status.noPayment)
+                .and(wrapper -> wrapper.isNull(OrderDon::getType).or().eq(OrderDon::getType, type))
+                .and(wrapper -> wrapper.isNull(OrderDon::getShopId).or().eq(OrderDon::getShopId, shopId)));
+        if (updated == 0) {
+            OrderDon current = orderDonMapper.selectById(order.getId());
+            boolean samePrepay = current != null
+                    && current.getStatus() == OrderDon.Status.noPayment
+                    && current.getType() == type
+                    && java.util.Objects.equals(current.getShopId(), shopId)
+                    && (webPay == null || java.util.Objects.equals(current.getIsWebPay(), webPay));
+            if (samePrepay) {
+                order.setType(current.getType());
+                order.setShopId(current.getShopId());
+                order.setIsWebPay(current.getIsWebPay());
+                return;
+            }
+            throw BusinessRuntimeException.getInstance("订单状态或支付方式已变更,请刷新后重试");
+        }
+        order.setType(type);
+        order.setShopId(shopId);
+        if (webPay != null) {
+            order.setIsWebPay(webPay);
+        }
+    }
+
+    @Override
+    public void prepareRefund(Long orderId) {
+        OrderDon order = orderDonMapper.selectById(orderId);
+        if (order == null || !isMultiQuantityOrder(orderId)) {
+            throw new IllegalArgumentException("invalid multi-quantity order");
+        }
+        runEvent(order, OrderDonBusinessEvent.Type.balance_record, () -> recordBalance(order));
+        if (order.getBalance() != null && order.getBalance().compareTo(BigDecimal.ZERO) > 0) {
+            OrderDonBusinessEvent event = businessEventService.get(
+                    orderId, OrderDonBusinessEvent.Type.balance_record);
+            if (event == null || event.getStatus() != OrderDonBusinessEvent.Status.success) {
+                throw BusinessRuntimeException.getInstance("订单余额支付流水尚未完成,请稍后重试退款");
+            }
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public void handlePaidOrder(OrderDon order, GoodsDon goods, GoodsDonSku sku) {
+        if (order == null || goods == null || sku == null || !isMultiQuantityOrder(order.getId())) {
+            throw new IllegalArgumentException("invalid multi-quantity paid order");
+        }
+        if (order.getType() == OrderDon.Type.PAYPAL || order.getType() == OrderDon.Type.STRIPE) {
+            throw new IllegalArgumentException("unsupported multi-quantity payment provider");
+        }
+        // A multi-quantity order is complete only after every durable ticket slot is usable.
+        OrderDon.Status paidStatus = OrderDon.Status.hasPayment;
+        OrderDon currentBeforePay = orderDonMapper.selectByIdForUpdate(order.getId());
+        if (currentBeforePay == null) {
+            throw BusinessRuntimeException.getInstance("order does not exist");
+        }
+        if (currentBeforePay.getStatus() != OrderDon.Status.noPayment
+                && currentBeforePay.getStatus() != OrderDon.Status.close
+                && !java.util.Objects.equals(currentBeforePay.getTransactionId(), order.getTransactionId())) {
+            throw BusinessRuntimeException.getInstance("订单支付交易号不匹配");
+        }
+        if (currentBeforePay.getStatus() == OrderDon.Status.close) {
+            reclaimClosedOrderBenefits(currentBeforePay);
+        }
+        int updated = orderDonMapper.update(null, Wrappers.lambdaUpdate(OrderDon.class)
+                .set(OrderDon::getStatus, paidStatus)
+                .set(order.getTransactionId() != null, OrderDon::getTransactionId, order.getTransactionId())
+                .set(order.getPayTime() != null, OrderDon::getPayTime, order.getPayTime())
+                .set(order.getShopId() != null, OrderDon::getShopId, order.getShopId())
+                .set(order.getBuyerId() != null, OrderDon::getBuyerId, order.getBuyerId())
+                .set(order.getOpenId() != null, OrderDon::getOpenId, order.getOpenId())
+                .set(order.getType() != null, OrderDon::getType, order.getType())
+                .eq(OrderDon::getId, order.getId())
+                .in(OrderDon::getStatus, OrderDon.Status.noPayment, OrderDon.Status.close));
+        if (updated == 0) {
+            OrderDon current = orderDonMapper.selectById(order.getId());
+            if (current == null || current.getStatus() == OrderDon.Status.noPayment
+                    || current.getStatus() == OrderDon.Status.close) {
+                throw BusinessRuntimeException.getInstance("订单支付状态更新失败");
+            }
+        }
+        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);
+            }
+        });
+    }
+
+    private void reclaimClosedOrderBenefits(OrderDon order) {
+        UserBalanceSourceRecord returnedBalance = userBalanceSourceRecordMapper.selectOne(
+                Wrappers.lambdaQuery(UserBalanceSourceRecord.class)
+                        .eq(UserBalanceSourceRecord::getOrderId, order.getId())
+                        .eq(UserBalanceSourceRecord::getSource, UserBalanceSourceRecord.Source.order_close)
+                        .last("limit 1 for update"));
+        if (returnedBalance != null) {
+            userBenefitsService.deductUserBalance(order.getUserId(), returnedBalance.getBalance());
+            userBalanceSourceRecordMapper.deleteById(returnedBalance.getId());
+        }
+        userGalaxyCoinService.reclaimOrderCloseGalaxyCoin(order.getId(), order.getUserId(),
+                com.cyksj.model.entity.OrderDonGalaxyCoinRecord.Source.ordinary);
+    }
+
+    @Override
+    public int deliver(Long orderId) {
+        OrderDon order = orderDonMapper.selectById(orderId);
+        if (order == null || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            return 0;
+        }
+        GoodsDon goods = goodsDonMapper.selectById(order.getGoodsId());
+        GoodsDonSku sku = goodsDonSkuMapper.selectById(order.getSkuId());
+        if (goods == null || sku == null) {
+            throw new IllegalStateException("goods or sku no longer exists");
+        }
+        int delivered = 0;
+        List<OrderDonTicketRecord> records = ticketRecordService.listByOrderId(orderId);
+        for (OrderDonTicketRecord record : records) {
+            if (record.getStatus() != OrderDonTicketRecord.Status.pending
+                    && record.getStatus() != OrderDonTicketRecord.Status.failed) {
+                continue;
+            }
+            String token = UUID.randomUUID().toString();
+            if (!requiresNewResult(() -> claimTicket(orderId, record.getId(), token))) {
+                continue;
+            }
+            try {
+                requiresNew(() -> deliverClaimedSlot(order, goods, sku, record.getId(), token));
+                delivered++;
+            } catch (Exception e) {
+                requiresNew(() -> ticketRecordService.markFailed(
+                        record.getId(), token, StringUtil.getErrorText(e)));
+                log.error("Multi-quantity ticket delivery failed, orderId:{}, slotId:{}", orderId,
+                        record.getId(), e);
+            }
+        }
+        requiresNewResult(() -> completeIfAllTicketsAvailable(orderId));
+        return delivered;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public boolean completeIfAllTicketsAvailable(Long orderId) {
+        if (orderId == null || orderId <= 0) {
+            return false;
+        }
+        OrderDon order = orderDonMapper.selectByIdForUpdate(orderId);
+        if (order == null || order.getNum() == null || order.getNum() <= 1
+                || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            return false;
+        }
+        if (ticketRecordService.countAvailable(orderId) != order.getNum()) {
+            return false;
+        }
+        if (order.getStatus() == OrderDon.Status.complete) {
+            return true;
+        }
+        return orderDonMapper.update(null, Wrappers.lambdaUpdate(OrderDon.class)
+                .set(OrderDon::getStatus, OrderDon.Status.complete)
+                .eq(OrderDon::getId, orderId)
+                .eq(OrderDon::getStatus, OrderDon.Status.hasPayment)) == 1;
+    }
+
+    private boolean claimTicket(Long orderId, Long recordId, String workerToken) {
+        OrderDon latest = orderDonMapper.selectByIdForUpdate(orderId);
+        if (latest == null || (latest.getStatus() != OrderDon.Status.hasPayment
+                && latest.getStatus() != OrderDon.Status.complete)) {
+            return false;
+        }
+        OrderDonBusinessEvent refundEvent = businessEventService.get(
+                orderId, OrderDonBusinessEvent.Type.refund);
+        if (refundEvent != null && (refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing
+                || refundEvent.getStatus() == OrderDonBusinessEvent.Status.success)) {
+            return false;
+        }
+        return ticketRecordService.claim(recordId, workerToken, DELIVERY_MAX_RETRIES);
+    }
+
+    private void deliverClaimedSlot(OrderDon order, GoodsDon goods, GoodsDonSku sku,
+                                    Long recordId, String workerToken) {
+        if (!ticketRecordService.lockClaim(recordId, workerToken)) {
+            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);
+		if (relation == null || relation.getId() == null) {
+			throw new IllegalStateException("no available ticket");
+		}
+		registerRelationLockCleanup(relation.getId(), order.getUserId());
+		if (!ticketRecordService.markSuccess(recordId, relation.getId(), workerToken)) {
+			throw new IllegalStateException("ticket slot claim expired");
+		}
+        OrderDon oneTicketOrder = copyForOneTicket(order, relation.getId());
+        try {
+            expiryRecordService.syncGroupsRelationExpiryRecord(oneTicketOrder, goods, sku);
+        } catch (DuplicateKeyException ignored) {
+            // The expiry record is ancillary; slot ownership is already durable.
+		}
+	}
+
+	private void registerRelationLockCleanup(Long relationId, Long userId) {
+		TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+			@Override
+			public void afterCompletion(int status) {
+				if (status != STATUS_ROLLED_BACK) {
+					return;
+				}
+				try {
+					exchangeCodeService.releaseRelationLock(relationId, userId);
+				} catch (RuntimeException e) {
+					log.warn("Failed to release rolled-back ticket lock, relationId:{}, userId:{}",
+							relationId, userId, e);
+				}
+			}
+		});
+	}
+
+	private void runOrderEvents(Long orderId) {
+        OrderDon order = orderDonMapper.selectById(orderId);
+        if (order == null || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            return;
+        }
+        runEvent(order, OrderDonBusinessEvent.Type.balance_record, () -> recordBalance(order));
+        runEvent(order, OrderDonBusinessEvent.Type.distribute, () -> distribute(order));
+        runEvent(order, OrderDonBusinessEvent.Type.order_side_effects, () -> completeOrderTask(order));
+        runEvent(order, OrderDonBusinessEvent.Type.galaxy_cashback,
+                () -> handleGalaxyCoin(order));
+    }
+
+    private void runEvent(OrderDon order, OrderDonBusinessEvent.Type type, Runnable action) {
+        String token = UUID.randomUUID().toString();
+        boolean claimed = requiresNewResult(() -> {
+            OrderDon latest = orderDonMapper.selectByIdForUpdate(order.getId());
+            if (latest == null || (latest.getStatus() != OrderDon.Status.hasPayment
+                    && latest.getStatus() != OrderDon.Status.complete)) {
+                return false;
+            }
+            businessEventService.createIfAbsent(order.getId(), type);
+            OrderDonBusinessEvent refundEvent = businessEventService.get(
+                    order.getId(), OrderDonBusinessEvent.Type.refund);
+            if (refundEvent != null && (refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing
+                    || refundEvent.getStatus() == OrderDonBusinessEvent.Status.success)) {
+                return false;
+            }
+            return businessEventService.claim(order.getId(), type, token);
+        });
+        if (!claimed) {
+            return;
+        }
+        try {
+            requiresNew(() -> {
+                if (!businessEventService.lockClaim(order.getId(), type, token)) {
+                    throw new IllegalStateException("business event claim expired");
+                }
+                action.run();
+                if (!businessEventService.markSuccess(order.getId(), type, token)) {
+                    throw new IllegalStateException("business event claim expired");
+                }
+            });
+        } catch (Exception e) {
+            requiresNew(() -> businessEventService.markFailed(
+                    order.getId(), type, token, StringUtil.getErrorText(e)));
+            log.error("Multi-quantity order event failed, orderId:{}, type:{}", order.getId(), type, e);
+        }
+    }
+
+    private void recordBalance(OrderDon order) {
+        BigDecimal balance = order.getBalance() == null ? BigDecimal.ZERO : order.getBalance();
+        if (balance.compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        // The event and balance record commit together, so a recovered event cannot duplicate the ledger entry.
+        userBenefitsService.recordBalanceBySource(order.getUserId(), balance.negate(),
+                UserBalanceSourceRecord.Source.payment, order.getId(),
+                order.getPayTitle() + UserBalanceSourceRecord.Source.payment.getDesc(), null, null);
+    }
+
+    private void distribute(OrderDon order) {
+        BigDecimal cash = order.getMoney() == null ? BigDecimal.ZERO : order.getMoney();
+        if (cash.compareTo(BigDecimal.ZERO) > 0 && Boolean.TRUE.equals(order.getIsDistribute())) {
+            // The order-level event serializes this legacy distribution method per order.
+            orderServiceProvider.getObject().distributeWaitingSendPointsRecord(order);
+        }
+        if (order.getYhsId() != null && order.getYhsId() > 0) {
+            yhShopDistributeService.distributeWaitingSendPointsRecord(order);
+        }
+        orderServiceProvider.getObject().sendSubDistributeRewards(order);
+    }
+
+    private void completeOrderTask(OrderDon order) {
+        TaskType taskType = taskTypeMapper.selectOne(Wrappers.lambdaQuery(TaskType.class)
+                .eq(TaskType::getName, TaskTypeEnum.yh_order.getDesc()).last("limit 1"));
+        if (taskType != null) {
+            taskService.completeTask(taskType, order.getUserId());
+        }
+    }
+
+    private void handleGalaxyCoin(OrderDon order) {
+        BigDecimal money = order.getMoney() == null ? BigDecimal.ZERO : order.getMoney();
+        if (money.compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        SysConfig config = sysConfigService.getOne(Wrappers.lambdaQuery(SysConfig.class)
+                .eq(SysConfig::getSysKey, Constant.GALAXY_COIN_CONSUME_RETURN_RATE_KEY)
+                .last("limit 1"));
+        if (config == null || config.getSysValue() == null || config.getSysValue().isBlank()) {
+            throw new IllegalStateException("galaxy coin cashback configuration is missing");
+        }
+        GalaxyCoinConsumeRateDto rate = com.cyksj.common.util.Jsons.parseObject(
+                (Object) config.getSysValue(), GalaxyCoinConsumeRateDto.class);
+        if (rate == null || rate.getMoney() == null || rate.getGalaxyCoin() == null
+                || rate.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
+            throw new IllegalStateException("invalid galaxy coin cashback configuration");
+        }
+        BigDecimal cashback = money.divide(rate.getMoney(), 0, RoundingMode.DOWN)
+                .multiply(rate.getGalaxyCoin());
+        userGalaxyCoinService.sendOrderCashback(order.getId(), order.getUserId(),
+                cashback.intValue(), PointsRecordConst.ORDER_CASHBACK,
+                com.cyksj.model.entity.OrderDonGalaxyCoinRecord.Source.ordinary);
+    }
+
+    private OrderDon copyForOneTicket(OrderDon source, Long relationId) {
+        OrderDon copy = new OrderDon();
+        copy.setId(source.getId());
+        copy.setOrderNo(source.getOrderNo());
+        copy.setGoodsId(source.getGoodsId());
+        copy.setSkuId(source.getSkuId());
+        copy.setUserId(source.getUserId());
+        copy.setPayTime(source.getPayTime());
+        copy.setCreatedTime(source.getCreatedTime());
+        copy.setRelationId(relationId);
+        copy.setNum(1);
+        copy.setOrderType(1);
+        copy.setYhsId(0L);
+        return copy;
+    }
+
+    private void afterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+
+    private void requiresNew(Runnable action) {
+        TransactionTemplate template = new TransactionTemplate(transactionManager);
+        template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+        template.executeWithoutResult(status -> action.run());
+    }
+
+    private <T> T requiresNewResult(java.util.function.Supplier<T> action) {
+        TransactionTemplate template = new TransactionTemplate(transactionManager);
+        template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+        return template.execute(status -> action.get());
+    }
+}

+ 6 - 1
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderCommonServiceImpl.java

@@ -10,6 +10,7 @@ import com.cyksj.mapper.*;
 import com.cyksj.model.entity.*;
 import com.cyksj.redis.RedisService;
 import com.cyksj.service.order.OrderCommonService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.cyksj.service.user.UserBindRelationService;
 import lombok.RequiredArgsConstructor;
@@ -46,6 +47,8 @@ public class OrderCommonServiceImpl implements OrderCommonService {
 
 	private final GroupRelationClearService clearService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
 	private final UserBindRelationService userBindRelationService;
 	@Override
 	public void clearUnrealGoodsDetail(OrderDon orderDon, Integer goodsType, GoodsDonSku sku) {
@@ -222,7 +225,9 @@ public class OrderCommonServiceImpl implements OrderCommonService {
 			relation.setIsHandle(false);
 			relation.setRenewStatus(false);
 			relation.setAccount(null);
-			groupsRelationMapper.updateExpiryTime(relation, expiryTime);
+			if (groupsRelationMapper.updateExpiryTime(relation, expiryTime) == 1) {
+				orderDonTicketRecordService.clearActiveRelation(relation.getId());
+			}
 			if (relation.getStatus() != GroupsRelation.Status.outside) {
 				delRelation(relation);
 			}

+ 99 - 0
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonBusinessEventServiceImpl.java

@@ -0,0 +1,99 @@
+package com.cyksj.service.order.impl;
+
+import com.cyksj.mapper.order.OrderDonBusinessEventMapper;
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+import com.cyksj.service.order.OrderDonBusinessEventService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class OrderDonBusinessEventServiceImpl implements OrderDonBusinessEventService {
+
+    private static final int MAX_BATCH_SIZE = 500;
+    private static final int MAX_LEASE_MINUTES = 120;
+
+    private final OrderDonBusinessEventMapper eventMapper;
+
+    @Override
+    public boolean createIfAbsent(Long orderId, OrderDonBusinessEvent.Type eventType) {
+        requireOrderAndType(orderId, eventType);
+        return eventMapper.insertIfAbsent(orderId, eventType.name()) == 1;
+    }
+
+    @Override
+    public boolean claim(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken) {
+        requireOrderAndType(orderId, eventType);
+        requireWorkerToken(workerToken);
+        return eventMapper.claim(orderId, eventType.name(), workerToken) == 1;
+    }
+
+    @Override
+    public boolean markSuccess(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken) {
+        requireOrderAndType(orderId, eventType);
+        requireWorkerToken(workerToken);
+        return eventMapper.markSuccess(orderId, eventType.name(), workerToken) == 1;
+    }
+
+    @Override
+    public boolean markFailed(Long orderId, OrderDonBusinessEvent.Type eventType,
+                              String workerToken, String errorMsg) {
+        requireOrderAndType(orderId, eventType);
+        requireWorkerToken(workerToken);
+        return eventMapper.markFailed(orderId, eventType.name(), workerToken,
+                truncate(errorMsg)) == 1;
+    }
+
+    @Override
+    public OrderDonBusinessEvent get(Long orderId, OrderDonBusinessEvent.Type eventType) {
+        requireOrderAndType(orderId, eventType);
+        return eventMapper.selectByOrderAndType(orderId, eventType.name());
+    }
+
+    @Override
+    public boolean lockClaim(Long orderId, OrderDonBusinessEvent.Type eventType, String workerToken) {
+        requireOrderAndType(orderId, eventType);
+        requireWorkerToken(workerToken);
+        return eventMapper.lockClaim(orderId, eventType.name(), workerToken) != null;
+    }
+
+    @Override
+    public List<Long> pendingOrderIds(OrderDonBusinessEvent.Type eventType, int limit) {
+        if (eventType == null) {
+            throw new IllegalArgumentException("eventType is required");
+        }
+        return eventMapper.selectPendingOrderIds(eventType.name(), boundedLimit(limit));
+    }
+
+    @Override
+    public int recoverExpired(int leaseMinutes) {
+        return eventMapper.recoverExpired(boundedLease(leaseMinutes));
+    }
+
+    private void requireOrderAndType(Long orderId, OrderDonBusinessEvent.Type eventType) {
+        if (orderId == null || orderId <= 0 || eventType == null) {
+            throw new IllegalArgumentException("orderId and eventType are required");
+        }
+    }
+
+    private void requireWorkerToken(String workerToken) {
+        if (workerToken == null || workerToken.isBlank() || workerToken.length() > 64) {
+            throw new IllegalArgumentException("invalid workerToken");
+        }
+    }
+
+    private int boundedLimit(int limit) {
+        return Math.max(1, Math.min(limit, MAX_BATCH_SIZE));
+    }
+
+    private int boundedLease(int leaseMinutes) {
+        return Math.max(1, Math.min(leaseMinutes, MAX_LEASE_MINUTES));
+    }
+
+    private String truncate(String message) {
+        String value = message == null ? "unknown error" : message;
+        return value.substring(0, Math.min(value.length(), 500));
+    }
+}

+ 464 - 77
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java

@@ -69,6 +69,7 @@ import com.cyksj.mapper.mirrorshop.UserMirrorShopDistributeWaitingSendPointsMapp
 import com.cyksj.mapper.mirrorshop.UserMirrorShopMapper;
 import com.cyksj.mapper.mirrorshop.UserMirrorShopOrderRelateMapper;
 import com.cyksj.mapper.mirrorshop.UserMirrorShopUserDistributeSharedMapper;
+import com.cyksj.mapper.order.OrderDonTicketQueryMapper;
 import com.cyksj.mapper.renew.RenewUpgradePackageMapper;
 import com.cyksj.mapper.shop.YhShopGoodsSkuMapper;
 import com.cyksj.mapper.sys.SysConfigMapper;
@@ -145,6 +146,9 @@ import java.util.stream.Collectors;
 public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> implements OrderDonService {
 
 	private static final Long EXPIRY_TIME = 1000 * 60 * 5L;
+	private static final long PAYMENT_QUERY_RETRY_DELAY = 181_000L;
+	private static final int PAYMENT_QUERY_MAX_RETRIES = 5;
+	private static final String PAYMENT_QUERY_RETRY_KEY = "payment:query:retry:";
 
 	private static final Sequence SEQUENCE = new Sequence(0);
 
@@ -182,6 +186,12 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 
 	private final OrderDonMapper orderDonMapper;
 
+	private final OrderDonTicketQueryMapper orderDonTicketQueryMapper;
+
+	private final MultiQuantityOrderConfigService multiQuantityOrderConfigService;
+
+	private final MultiQuantityOrderService multiQuantityOrderService;
+
 	private final EnvCommonService envCommonService;
 
 	private static final GlobalThreadPoolTaskExecutor TASK_POOL = GlobalThreadPoolTaskExecutor.getInstance();
@@ -425,6 +435,8 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	public OrderDon submit(OrderPayRequest payRequest) throws Exception {
 		GoodsDonSku sku = null;
 		Long goodsId;
+		int cmt = normalizeCmt(payRequest.getCmt());
+		payRequest.setCmt(cmt);
 		List<GiftCardPayReq> giftCards = null;
 		GiftCardDto gcPayInfo = payRequest.getGcPayInfo();
 		if (gcPayInfo != null && gcPayInfo.getGiftCards() != null) {
@@ -435,9 +447,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		String customId = payRequest.getCustomId();
 		UserMirrorShop userMirrorShop = null;
 		Long yhsId = 0l;
-		if (payRequest.getCmt() == 1) {
+		if (cmt == 1) {
 			yhsId = yhShopFrontService.checkShopCustomIdAndReturnShopId(customId);
-		} else if (payRequest.getCmt() == 2) {
+		} else if (cmt == 2) {
 			if (StrUtil.isNotEmpty(customId)) {
 				userMirrorShop = userMirrorShopMapper.selectOne(Wrappers.lambdaQuery(UserMirrorShop.class).eq(UserMirrorShop::getCustomId, customId).last("limit 1"));
 				if (userMirrorShop == null || !userMirrorShop.getStatus()) {
@@ -457,6 +469,26 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			goodsId = sku.getGoodsId();
 		}
 		GoodsDon goodsDon = goodsDonMapper.selectById(goodsId);
+		if (goodsDon == null) {
+			throw new BusinessRuntimeException("商品不存在");
+		}
+		Integer quantity = Optional.ofNullable(payRequest.getNum()).orElse(1);
+		if (quantity <= 0) {
+			throw BusinessRuntimeException.getInstance("购买数量必须大于0");
+		}
+		payRequest.setNum(quantity);
+		Long multiQuantitySkuId = sku == null ? null : sku.getId();
+		boolean multiQuantity = multiQuantityOrderConfigService.isMultiQuantity(multiQuantitySkuId, quantity);
+		if (quantity > 1 && !multiQuantity) {
+			throw BusinessRuntimeException.getInstance("该规格不支持一次购买多个");
+		}
+		if (multiQuantity) {
+			validateMultiQuantitySubmit(payRequest, goodsDon, sku, isGiftCardPay.get(), yhsId);
+			multiQuantityOrderConfigService.validateAndGet(multiQuantitySkuId, quantity);
+			if (!isSkuEnabled(sku)) {
+				throw BusinessRuntimeException.getInstance("SKU is disabled");
+			}
+		}
 		//是否是补差价订单
 		if (goodsDon.getType() != null && goodsDon.getType() == 5) {
 			return generateDifferOrderDon(payRequest, goodsDon, sku);
@@ -535,7 +567,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 
 		//校验奈飞限购信息
-		checkNfPayLimit(goodsId, userId);
+		if (!multiQuantity) {
+			checkNfPayLimit(goodsId, userId);
+		}
 		GroupsRelation relation = null;
 		String orderPhone = payRequest.getPhone();
 		if (StrUtil.isNotBlank(orderPhone)) {
@@ -553,11 +587,11 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			if (user == null) throw BusinessRuntimeException.getGatewayApiCode(GatewayApiCode.CMS_TOKEN_VALID);
 			if (user.getIsBlack()) throw BusinessRuntimeException.getInstance("系统检测操作异常,请稍后再试");
 			payOpenId = user.getOpenId();
-			if (sku != null) {
+			if (sku != null && !multiQuantity) {
 				//ChatGPT Plus账号限购校验
 				checkChatGPTPlusPurchaseLimit(user.getId(), goodsDon.getId(), goodsDon.getTitle(), sku.getId(), sku.getSpecVal());
 			}
-			if (goodsDon.getType() == 1 && isNeedTicket) {
+			if (!multiQuantity && goodsDon.getType() == 1 && isNeedTicket) {
 				//获取座位
 				relation = getRelation(payRequest, sku, yhsId);
 				relationId = relation.getId();
@@ -649,9 +683,15 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			orderDon.setDeposit(rentGoodsDonDeposit);
 		}
 
-		orderDon.setMoney(payRequest.getDonMoney());
-		orderDon.setRealMoney(payRequest.getDonMoney());
-		orderDon.setOriMoney(sku != null ? sku.getPrice() : orderDon.getMoney());
+		BigDecimal authoritativeTotal = multiQuantity
+				? sku.getPrice().multiply(BigDecimal.valueOf(quantity.longValue()))
+				: payRequest.getDonMoney();
+		if (multiQuantity) {
+			payRequest.setDonMoney(authoritativeTotal);
+		}
+		orderDon.setMoney(authoritativeTotal);
+		orderDon.setRealMoney(authoritativeTotal);
+		orderDon.setOriMoney(multiQuantity ? authoritativeTotal : (sku != null ? sku.getPrice() : orderDon.getMoney()));
 
 		List<Long> dpSkuIds = payRequest.getDpSkuIds();
 		if (CollUtil.isNotEmpty(dpSkuIds)) {
@@ -688,7 +728,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		orderDon.setGoodsId(goodsDon.getId());
 		orderDon.setStatus(OrderDon.Status.noPayment);
 		orderDon.setUserId(user.getId());
-		orderDon.setType(OrderDon.Type.getType(payRequest.getType()));
+		orderDon.setType(resolveSubmitPaymentType(multiQuantity, payRequest.getType()));
 		orderDon.setIsWebPay(payRequest.getIsWebPay());
 		orderDon.setSource(payRequest.getSource());
 		orderDon.setCouponExCode(payRequest.getCouponExCode());
@@ -715,7 +755,14 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 		if (sku != null) {
 			//扣除使用优惠券、余额的订单金额
-			deductOrderMoney(orderDon, couponUser, payRequest.getBalance(), user, sku.getPrice(), goodsDon, payRequest.getGcCash(), payRequest.getGcpIds(), payRequest.getIsAcReduce(), subsidyId, sku, payRequest.getGalaxyCoin(), null);
+			if (multiQuantity) {
+				deductGalaxyCoinPayOrderMoney(orderDon, authoritativeTotal, payRequest.getGalaxyCoin(), user);
+				deductBalancePayOrderMoney(orderDon, payRequest.getBalance(), user);
+			} else {
+				deductOrderMoney(orderDon, couponUser, payRequest.getBalance(), user, sku.getPrice(),
+						goodsDon, payRequest.getGcCash(), payRequest.getGcpIds(),
+						payRequest.getIsAcReduce(), subsidyId, sku, payRequest.getGalaxyCoin(), null);
+			}
 		}
 
 		if (isSpecificPrice && (orderDon.getMoney().compareTo(BigDecimal.ZERO) == 0 || orderDon.getMoney().compareTo(specificPrice) != 0)) {
@@ -734,6 +781,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			groupsRelationMapper.updateById(relation);
 		}
 		this.saveOrUpdate(orderDon);
+		if (multiQuantity) {
+			multiQuantityOrderService.initialize(orderDon);
+		}
 
 		Long orderId = orderDon.getId();
 		//使用银河币记录
@@ -828,6 +878,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		//余额支付订单
 		if (orderDon.getMoney().compareTo(BigDecimal.ZERO) == 0) {
 			orderDon = orderDonMapper.selectById(orderId);
+			orderDon.setPayTime(DateTime.now());
 			unifiedHandlerOrderNotify(orderDon, goodsDon, sku);
 		}
 		if (orderDon.getStatus() == OrderDon.Status.noPayment) {
@@ -958,12 +1009,66 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		return false;
 	}
 
+	private void validateMultiQuantitySubmit(OrderPayRequest request, GoodsDon goods,
+			GoodsDonSku sku, boolean giftCardOrder, Long shopId) {
+		if (goods.getType() == null || goods.getType() != 1 || sku == null) {
+			throw BusinessRuntimeException.getInstance("多数量下单仅支持已配置的虚拟商品");
+		}
+		if (request.getUserId() == null || Boolean.TRUE.equals(request.getIsNoLogin())
+				|| StrUtil.isNotBlank(request.getPhone())) {
+			throw BusinessRuntimeException.getInstance("多数量下单必须使用已登录账号");
+		}
+		if (giftCardOrder || goods.getSpecialType() != null || Boolean.TRUE.equals(sku.getIsMirror())
+				|| Boolean.TRUE.equals(sku.getIsDp()) || Boolean.TRUE.equals(request.getIsSpecificPrice())
+				|| Boolean.TRUE.equals(request.getIsAcReduce()) || request.getSubsidyId() != null
+				|| request.getPackageId() != null || request.getOriginalOrderId() != null
+				|| 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)) {
+			throw BusinessRuntimeException.getInstance("该下单方式不支持多数量购买");
+		}
+		if (goods.getId() == Constant.CLAUDE_CODE_GOODS_ID
+				|| goods.getId() == Constant.CODEX_GOODS_ID
+				|| goods.getId() == Constant.NANO_GOODS_ID) {
+			throw BusinessRuntimeException.getInstance("该商品存在续期或专属发放逻辑,暂不支持多数量购买");
+		}
+		if (request.getCouponId() != null || StrUtil.isNotBlank(request.getCouponExCode())) {
+			throw BusinessRuntimeException.getInstance("多数量下单不支持优惠券或优惠码");
+		}
+		if (request.getRelationId() != null && request.getRelationId() > 0) {
+			throw BusinessRuntimeException.getInstance("多数量下单不支持指定或续费车票");
+		}
+		OrderDon.Type payType = OrderDon.Type.getType(request.getType());
+		if (payType == OrderDon.Type.STRIPE || payType == OrderDon.Type.PAYPAL) {
+			throw BusinessRuntimeException.getInstance("Multi-quantity orders do not support PayPal or Stripe yet");
+		}
+	}
+
+	static int normalizeCmt(Integer cmt) {
+		return Optional.ofNullable(cmt).orElse(1);
+	}
+
+	static boolean isSkuEnabled(GoodsDonSku sku) {
+		return sku != null && Boolean.TRUE.equals(sku.getStatus());
+	}
+
+	static OrderDon.Type resolveSubmitPaymentType(boolean multiQuantity, String requestedType) {
+		if (multiQuantity && StrUtil.isBlank(requestedType)) {
+			return null;
+		}
+		return OrderDon.Type.getType(requestedType);
+	}
 	@Override
 	public H5JsPayParams pay(Long orderId, String tradeType) throws Exception {
 		log.info("调起微信支付[start] orderId:{}", orderId);
 
 		OrderDon orderDon = this.getById(orderId);
-
+		if (orderDon == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在");
+		}
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
+		validateMultiQuantityPrepay(orderDon, OrderDon.Type.WeChat, multiQuantity);
 		OrderAttach attach = new OrderAttach();
 		attach.setO(orderDon.getId());
 
@@ -976,10 +1081,14 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		if (shopConfig != null) {
 			shopId = shopConfig.getAppId();
 		}
-		orderDon.setType(OrderDon.Type.WeChat);
-		orderDon.setShopId(shopId);
-		orderDon.setIsWebPay(false);
-		this.updateById(orderDon);
+		if (multiQuantity) {
+			multiQuantityOrderService.preparePrepay(orderDon, OrderDon.Type.WeChat, shopId, false);
+		} else {
+			orderDon.setType(OrderDon.Type.WeChat);
+			orderDon.setShopId(shopId);
+			orderDon.setIsWebPay(false);
+			this.updateById(orderDon);
+		}
 		H5JsPayParams params = weChatService.getJsPayParams(
 				weChatConfig.getAppid(),
 				shopId,
@@ -1001,7 +1110,11 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	public H5JsPayParams wxPcWebPay(Long orderId, String tradeType) throws Exception {
 		log.info("调起微信支付[start] orderId:{}", orderId);
 		OrderDon orderDon = this.getById(orderId);
-
+		if (orderDon == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在");
+		}
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
+		validateMultiQuantityPrepay(orderDon, OrderDon.Type.WeChat, multiQuantity);
 		OrderAttach attach = new OrderAttach();
 		attach.setO(orderDon.getId());
 
@@ -1015,10 +1128,14 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		if (shopConfig != null) {
 			shopId = shopConfig.getAppId();
 		}
-		orderDon.setShopId(shopId);
-		orderDon.setType(OrderDon.Type.WeChat);
-		orderDon.setIsWebPay(true);
-		this.updateById(orderDon);
+		if (multiQuantity) {
+			multiQuantityOrderService.preparePrepay(orderDon, OrderDon.Type.WeChat, shopId, true);
+		} else {
+			orderDon.setShopId(shopId);
+			orderDon.setType(OrderDon.Type.WeChat);
+			orderDon.setIsWebPay(true);
+			this.updateById(orderDon);
+		}
 
 		H5JsPayParams params = weChatService.getJsPayParams(
 				weChatConfig.getAppid(),
@@ -1037,6 +1154,19 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		return params;
 	}
 
+	private void validateMultiQuantityPrepay(OrderDon orderDon, OrderDon.Type requestedType,
+			boolean multiQuantity) {
+		if (!multiQuantity) {
+			return;
+		}
+		if (orderDon.getStatus() != OrderDon.Status.noPayment) {
+			throw BusinessRuntimeException.getInstance("订单已处理,请勿重复支付");
+		}
+		if (orderDon.getType() != null && orderDon.getType() != requestedType) {
+			throw BusinessRuntimeException.getInstance("订单支付方式已确定,请勿切换支付方式");
+		}
+	}
+
 
 	@Transactional(rollbackFor = Throwable.class)
 	@Override
@@ -1060,13 +1190,29 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		log.info("微信商家订单号orderNo:{},status:{},进行回调", order.getOrderNo(), order.getStatus());
 
 		String appId = map.get("mch_id");
+		ShopConfig callbackShop = shopConfigMapper.getByAppId(appId);
+		if (callbackShop == null || !StrUtil.equals(appId, order.getShopId())) {
+			throw BusinessRuntimeException.getInstance("微信支付回调商户不匹配");
+		}
 
 		// 校验签名
-		boolean f = weChatService.checkSignWithMd5(map, shopConfigMapper.getByAppId(appId).getSecret());
+		boolean f = weChatService.checkSignWithMd5(map, callbackShop.getSecret());
 		if (!f) {
 			log.error("回调数据:{}", map);
 			throw BusinessRuntimeException.getInstance("支付成功回调, 校验签名失败.");
 		}
+		if (!StrUtil.equals(order.getOrderNo(), map.get("out_trade_no"))) {
+			throw BusinessRuntimeException.getInstance("微信支付回调订单号不匹配");
+		}
+		BigDecimal callbackMoney;
+		try {
+			callbackMoney = new BigDecimal(map.get("total_fee"));
+		} catch (Exception e) {
+			throw BusinessRuntimeException.getInstance("微信支付回调金额异常");
+		}
+		if (order.getMoney() == null || callbackMoney.compareTo(order.getMoney()) != 0) {
+			throw BusinessRuntimeException.getInstance("微信支付回调金额不匹配");
+		}
 		GoodsDonSku sku = null;
 		if (order.getSkuId() != null) {
 			sku = goodsDonSkuMapper.selectById(order.getSkuId());
@@ -1076,6 +1222,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		String transactionId = map.get("transaction_id");
 
 		if (!order.getStatus().equals(OrderDon.Status.noPayment) && !order.getStatus().equals(OrderDon.Status.close)) {
+			if (StrUtil.isNotBlank(order.getTransactionId())
+					&& !StrUtil.equals(order.getTransactionId(), transactionId)) {
+				throw BusinessRuntimeException.getInstance("微信支付交易号不匹配");
+			}
 			log.info("已回调,重复修改状态 id:{}", order.getId());
 			return;
 		}
@@ -1124,6 +1274,26 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		if (orderDon == null) {
 			return;
 		}
+		if (orderDon.getStatus() == OrderDon.Status.noPayment
+				&& multiQuantityOrderService.isMultiQuantityOrder(orderId)) {
+			PaymentQueryState queryState = queryAndSyncPaymentState(orderDon);
+			if (queryState == PaymentQueryState.UNKNOWN) {
+				schedulePaymentQueryRetry(orderDon);
+				return;
+			}
+			if (queryState != PaymentQueryState.NOT_PAID) {
+				return;
+			}
+			clearPaymentQueryRetry(orderDon.getId());
+			int closed = orderDonMapper.update(null, Wrappers.lambdaUpdate(OrderDon.class)
+					.set(OrderDon::getStatus, OrderDon.Status.close)
+					.eq(OrderDon::getId, orderId)
+					.eq(OrderDon::getStatus, OrderDon.Status.noPayment));
+			if (closed == 1) {
+				returnMultiQuantityPaymentBenefits(orderDon);
+			}
+			return;
+		}
 		if (userId != null) {
 			List<Long> relationUserIdList = userBindRelationService.getRelationUserIdList(userId, null);
 			if (!relationUserIdList.contains(orderDon.getUserId())) {
@@ -1133,8 +1303,13 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		log.info("关闭订单 orderId:{}", orderId);
 		if (OrderDon.Status.noPayment.equals(orderDon.getStatus())) {
 			//调用微信、支付宝第三方接口查询订单是否支付
-			OrderDon.Status status = isHasPaid(orderDon);
-			if (status == OrderDon.Status.noPayment) {
+			PaymentQueryState queryState = queryAndSyncPaymentState(orderDon);
+			if (queryState == PaymentQueryState.UNKNOWN) {
+				schedulePaymentQueryRetry(orderDon);
+				return;
+			}
+			if (queryState == PaymentQueryState.NOT_PAID) {
+				clearPaymentQueryRetry(orderDon.getId());
 				//查询微信支付是否已支付
 				orderDon.setStatus(OrderDon.Status.close);
 				updateById(orderDon);
@@ -1192,6 +1367,29 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 	}
 
+	private void returnMultiQuantityPaymentBenefits(OrderDon orderDon) {
+		if (Optional.ofNullable(orderDon.getBalance()).orElse(BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0) {
+			Integer returned = userBalanceSourceRecordMapper.selectCount(Wrappers.lambdaQuery(UserBalanceSourceRecord.class)
+					.eq(UserBalanceSourceRecord::getOrderId, orderDon.getId())
+					.eq(UserBalanceSourceRecord::getSource, UserBalanceSourceRecord.Source.order_close));
+			if (returned == null || returned == 0) {
+				userBenefitsService.addUserBalance(orderDon.getUserId(), orderDon.getBalance(),
+						UserBalanceSourceRecord.Source.order_close, null, orderDon.getId(),
+						UserBalanceSourceRecord.Source.order_close.getDesc(), true);
+			}
+		}
+		UserGalaxyCoinRecord useRecord = userGalaxyCoinRecordMapper.selectOne(
+				Wrappers.lambdaQuery(UserGalaxyCoinRecord.class)
+						.eq(UserGalaxyCoinRecord::getOrderId, orderDon.getId())
+						.eq(UserGalaxyCoinRecord::getSource, OrderDonGalaxyCoinRecord.Source.ordinary.ordinal())
+						.eq(UserGalaxyCoinRecord::getRemark, PointsRecordConst.ORDER)
+						.last("limit 1"));
+		if (useRecord != null) {
+			userGalaxyCoinService.sendOrderGalaxyCloseOrder(orderDon.getId(), orderDon.getUserId(),
+					useRecord.getGalaxyCoin().abs(), OrderDonGalaxyCoinRecord.Source.ordinary);
+		}
+	}
+
 	@Override
 	@Transactional(rollbackFor = Throwable.class)
 	public OrderDon renewal(OrderPayRequest payRequest) throws Exception {
@@ -1453,16 +1651,42 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	@Override
 	public OrderDetailView getDetail(Long orderId, Long userId) {
 		OrderDon orderDon = this.getById(orderId);
+		if (orderDon == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在");
+		}
 		MapBuilder builder = MapUtils.builder()
 				.field(OrderDonView::getId, orderId)
 				.orderBy(OrderDonView::getId).desc();
-		if (userId != null && orderDon.getOrderType() != 2) {
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderId);
+		if (multiQuantity) {
+			if (!isMultiQuantityOrderOwner(orderDon, userId)) {
+				throw BusinessRuntimeException.getInstance("订单不存在或无权查看");
+			}
+		} else if (userId != null && orderDon.getOrderType() != 2) {
 			builder.field(OrderDonView::getUserId, userId);
 		}
 		OrderDetailView detail = beanSearcher.searchFirst(OrderDetailView.class, builder.build());
+		if (detail == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在或无权查看");
+		}
+		List<Long> userIdList = userId == null
+				? Collections.emptyList()
+				: (multiQuantity ? List.of(orderDon.getUserId())
+				: userBindRelationService.getRelationUserIdList(userId, null));
+		if (multiQuantity) {
+			List<MultiQuantityOrderTicketView> tickets = orderDonTicketQueryMapper.selectOrderTicketViews(orderId, userIdList);
+			if (CollUtil.isNotEmpty(tickets)) {
+				detail.setMultiQuantity(true);
+				detail.setTicketTotal(detail.getNum());
+				detail.setDeliveredCount((int) tickets.stream()
+						.filter(ticket -> ticket.getDeliveryStatus() == OrderDonTicketRecord.Status.success)
+						.count());
+				detail.setTickets(tickets);
+				return detail;
+			}
+		}
 		//获取对应车票订单 账号密码
 		if (userId != null) {
-			List<Long> userIdList = userBindRelationService.getRelationUserIdList(userId, null);
 			RenewalView renewalView = beanSearcher.searchFirst(RenewalView.class, MapUtils.builder()
 					.field(RenewalView::getUserId, userIdList).op(Operator.InList)
 					.field(RenewalView::getRelationId, detail.getRelationId())
@@ -1481,6 +1705,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		return detail;
 	}
 
+	static boolean isMultiQuantityOrderOwner(OrderDon orderDon, Long userId) {
+		return orderDon != null && userId != null && userId.equals(orderDon.getUserId());
+	}
+
 	@Override
 	public List<OrderDon> getAppleOneOrderDons(List<Long> skuIds, List<String> noOrderStatus, Date yesZeroDate, Date thisZeroDate) {
 		return orderDonMapper.getAppleOneOrderDons(skuIds, noOrderStatus, yesZeroDate, thisZeroDate);
@@ -1505,21 +1733,39 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		if (null == orderDon) {
 			throw BusinessRuntimeException.getInstance("订单不存在.");
 		}
-		if (orderDon.getPopularizeId() == null) {
-			orderDon.setShopId(null);
-		}
 		String appId = params.get("app_id");
-		boolean signVerified = AlipaySignature.rsaCheckV1(params, shopConfigMapper.getByAppId(appId).getPublicKey(), BaseZfbConfig.charset, BaseZfbConfig.signType);
+		ShopConfig callbackShop = shopConfigMapper.getByAppId(appId);
+		if (callbackShop == null || !isCallbackShopMatched(orderDon.getShopId(), appId)) {
+			throw BusinessRuntimeException.getInstance("支付宝回调商户不匹配");
+		}
+		boolean signVerified = AlipaySignature.rsaCheckV1(params, callbackShop.getPublicKey(), BaseZfbConfig.charset, BaseZfbConfig.signType);
 		if (!signVerified) {
 			log.error("支付宝回调校验签名错误,自定义入参:{}", params.get("body"));
 			throw BusinessRuntimeException.getInstance("支付宝回调校验签名错误");
 		}
+		if (!StrUtil.equals(orderDon.getOrderNo(), orderNo) || !ObjectUtil.equal(orderDon.getId(), oderId)) {
+			throw BusinessRuntimeException.getInstance("支付宝回调订单号不匹配");
+		}
+		BigDecimal callbackMoney;
+		try {
+			callbackMoney = new BigDecimal(params.get("total_amount"))
+					.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.UNNECESSARY);
+		} catch (Exception e) {
+			throw BusinessRuntimeException.getInstance("支付宝回调金额异常");
+		}
+		if (orderDon.getMoney() == null || callbackMoney.compareTo(orderDon.getMoney()) != 0) {
+			throw BusinessRuntimeException.getInstance("支付宝回调金额不匹配");
+		}
 		String transactionId = params.get("trade_no");
 		String payTimeStr = params.get("gmt_payment");
 		//买家支付宝账号
 		String buyerId = Optional.ofNullable(params.get("buyer_id")).orElse(params.get("buyer_open_id"));
 		log.info("支付宝回调信息:{}", Jsons.toJson(params));
 		if (!orderDon.getStatus().equals(OrderDon.Status.noPayment) && !orderDon.getStatus().equals(OrderDon.Status.close)) {
+			if (StrUtil.isNotBlank(orderDon.getTransactionId())
+					&& !StrUtil.equals(orderDon.getTransactionId(), transactionId)) {
+				throw BusinessRuntimeException.getInstance("支付宝交易号不匹配");
+			}
 			log.info("支付宝已回调,重复修改状态 id:{}", orderDon.getId());
 			return;
 		}
@@ -1542,17 +1788,20 @@ 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 是否被后台拉黑
-		Integer count1 = userPayOrderBlockMapper.selectCount(Wrappers.lambdaQuery(UserPayOrderBlock.class)
-				.eq(UserPayOrderBlock::getBuyerId, buyerId));
-		if (count1 > 0) {
-			log.info("该支付账号id:{} 已被后台拉黑", buyerId);
-			notifyRefundOrder(orderDon, goodsDon, sku);
-			return;
+		if (!multiQuantity) {
+			Integer count1 = userPayOrderBlockMapper.selectCount(Wrappers.lambdaQuery(UserPayOrderBlock.class)
+					.eq(UserPayOrderBlock::getBuyerId, buyerId));
+			if (count1 > 0) {
+				log.info("该支付账号id:{} 已被后台拉黑", buyerId);
+				notifyRefundOrder(orderDon, goodsDon, sku);
+				return;
+			}
 		}
 
 		//是否是河南濮阳ip 直接退款mj
-		if (orderDon.getGoodsId() == 26) {
+		if (!multiQuantity && orderDon.getGoodsId() == 26) {
 			OrderDonLocationRelate orderDonLocationRelate = orderDonLocationRelateMapper.selectOne(Wrappers.lambdaQuery(OrderDonLocationRelate.class)
 					.eq(OrderDonLocationRelate::getOrderId, orderDon.getId())
 					.last("limit 1"));
@@ -1575,7 +1824,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 
 		//单个支付每月限购2单奈飞
-		if (orderDon.getGoodsId() == 1) {
+		if (!multiQuantity && orderDon.getGoodsId() == 1) {
 			Boolean isCheck = true;
 			SysConfig purchaseLimitConfig = sysConfigService.getOne(Wrappers.lambdaQuery(SysConfig.class)
 					.eq(SysConfig::getSysKey, Constant.NF_PURCHASE_LIMIT_KEY)
@@ -1607,7 +1856,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		}
 
 
-		if (orderDon.getOrderType() == 1 && StrUtil.isNotEmpty(buyerId)) {
+		if (!multiQuantity && orderDon.getOrderType() == 1 && StrUtil.isNotEmpty(buyerId)) {
 			//近一个月同一买家id 只能购买一次 多人 非镜像
 			if (orderDon.getGoodsId() == 26l && sku.getNum() != 1 && !sku.getIsMirror()) {
 				List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
@@ -1638,7 +1887,14 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		unifiedHandlerOrderNotify(orderDon, goodsDon, sku);
 	}
 
+	static boolean isCallbackShopMatched(String persistedShopId, String callbackAppId) {
+		return StrUtil.isNotBlank(persistedShopId) && StrUtil.equals(persistedShopId, callbackAppId);
+	}
+
     private void notifyRefundCodeOrderDon(OrderDon orderDon, GoodsDon goodsDon, GoodsDonSku sku) throws Exception {
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders only support the unified whole-order refund flow");
+        }
         //退款
         OrderRefundReq orderRefundReq = new OrderRefundReq();
         orderRefundReq.setOrderId(orderDon.getId());
@@ -1747,10 +2003,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		} else {
 			response = alipayClient.execute(request);
 		}
-		if (response.isSuccess()) {
-			return response;
-		}
-		return null;
+		return response;
 	}
 
 	/**
@@ -2266,16 +2519,20 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 					}
 					try {
 						distributeWaitingSendPointsMapper.insert(distributeWaitingSendPoints);
-						//暂定虚物 商务可首单提成
-						//普通订单
-						if (order.getOrderType() == 1 && businessId != null) {
-							//是否是绑定两个月内的渠道
-							Date businessBindTime = userDistribute.getBusinessBindTime();
-							if (businessBindTime != null && businessBindTime.after(DateUtil.offsetMonth(DateTime.now(), -2))) {
-								//给商务人员 分配提成权益
-								userBusinessFrontService.taskDistributeRateBenefits(userDistributeShared.getSharedId(), businessId, goodsDon.getType(), order, rate);
-							}
+					} catch (DuplicateKeyException e) {
+						log.info("订单id:{}分销待发记录已存在", order.getId());
+						return;
+					}
+					//暂定虚物 商务可首单提成
+					//普通订单
+					if (order.getOrderType() == 1 && businessId != null) {
+						//是否是绑定两个月内的渠道
+						Date businessBindTime = userDistribute.getBusinessBindTime();
+						if (businessBindTime != null && businessBindTime.after(DateUtil.offsetMonth(DateTime.now(), -2))) {
+							//给商务人员 分配提成权益
+							userBusinessFrontService.taskDistributeRateBenefits(userDistributeShared.getSharedId(), businessId, goodsDon.getType(), order, rate);
 						}
+					}
 						//是否是cc普通推广
 						//并且购买claude code作为首单
 //						if (userDistribute == null && Constant.CC_DISTRIBUTE_MARK.equals(userDistributeShared.getRemark()) && order.getGoodsId() == Constant.CLAUDE_CODE_GOODS_ID) {
@@ -2287,9 +2544,6 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 //								log.error("给用户{}发放claude code积分卡失败:{}", userDistributeShared.getSharedId(), e.getMessage());
 //							}
 //						}
-					} catch (DuplicateKeyException e) {
-
-					}
 				}
 			}
 		}
@@ -2625,44 +2879,168 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	 * 查询第三放接口订单是否已支付
 	 */
 	public OrderDon.Status isHasPaid(OrderDon orderDon) {
+		queryAndSyncPaymentState(orderDon);
+		return orderDon.getStatus();
+	}
+
+	private PaymentQueryState queryAndSyncPaymentState(OrderDon orderDon) {
+		if (orderDon == null) {
+			return PaymentQueryState.UNKNOWN;
+		}
 		GoodsDon goodsDon = goodsDonMapper.selectById(orderDon.getGoodsId());
 		if (orderDon.getType() == OrderDon.Type.WeChat) {
 			try {
-				Map<String, String> reMap = weChatService.getWxOrderDetail(orderDon.getShopId(), orderDon.getOrderNo());
-				if (reMap != null) {
-					String orderStatus = reMap.get("trade_state");
-					if ("SUCCESS".equals(orderStatus)) {
-						orderDon.setStatus(goodsDon.getType() == 1 && goodsDon.getSecondType() == 2 ? OrderDon.Status.complete : OrderDon.Status.hasPayment);
-						orderDon.setTransactionId(reMap.get("transaction_id"));
-						return updateNoPayment(orderDon, goodsDon);
+				Map<String, String> result = weChatService.getWxOrderDetail(
+						orderDon.getShopId(), orderDon.getOrderNo());
+				PaymentQueryState queryState = classifyWxPaymentQuery(result);
+				if (queryState == PaymentQueryState.PAID) {
+					validateWxActiveQuery(orderDon, result);
+					orderDon.setTransactionId(result.get("transaction_id"));
+					if (StrUtil.isNotBlank(result.get("time_end"))) {
+						orderDon.setPayTime(DateUtil.parse(result.get("time_end"), "yyyyMMddHHmmss"));
 					}
+					updateNoPayment(orderDon, goodsDon);
+					clearPaymentQueryRetry(orderDon.getId());
 				}
+				return queryState;
 			} catch (Exception e) {
-
+				log.error("微信主动查单校验失败, orderId:{}", orderDon.getId(), e);
+				return PaymentQueryState.UNKNOWN;
 			}
 		} else if (orderDon.getType() == OrderDon.Type.ALI_PAY) {
 			try {
-				AlipayTradeQueryResponse zfbOrderDetail = getZfbOrderDetail(orderDon.getShopId(), orderDon.getOrderNo());
-				if (zfbOrderDetail != null) {
-					String tradeStatus = zfbOrderDetail.getTradeStatus();
-					if ("TRADE_SUCCESS".equals(tradeStatus)) {
-						orderDon.setStatus(goodsDon.getType() == 1 && goodsDon.getSecondType() == 2 ? OrderDon.Status.complete : OrderDon.Status.hasPayment);
-						orderDon.setTransactionId(zfbOrderDetail.getTradeNo());
-						Date sendPayDate = zfbOrderDetail.getSendPayDate();
-						orderDon.setPayTime(sendPayDate);
-						//买家支付宝账号
-						String buyerId = Optional.ofNullable(zfbOrderDetail.getBuyerUserId()).orElse(zfbOrderDetail.getBuyerOpenId());
-						orderDon.setBuyerId(buyerId);
-						return updateNoPayment(orderDon, goodsDon);
-					}
-				}
+				AlipayTradeQueryResponse result = getZfbOrderDetail(
+						orderDon.getShopId(), orderDon.getOrderNo());
+				PaymentQueryState queryState = classifyAliPaymentQuery(result);
+				if (queryState == PaymentQueryState.PAID) {
+					validateAliActiveQuery(orderDon, result);
+					orderDon.setTransactionId(result.getTradeNo());
+					orderDon.setPayTime(result.getSendPayDate());
+					orderDon.setBuyerId(Optional.ofNullable(result.getBuyerUserId())
+							.orElse(result.getBuyerOpenId()));
+					updateNoPayment(orderDon, goodsDon);
+					clearPaymentQueryRetry(orderDon.getId());
+				}
+				return queryState;
 			} catch (Exception e) {
+				log.error("支付宝主动查单校验失败, orderId:{}", orderDon.getId(), e);
+				return PaymentQueryState.UNKNOWN;
+			}
+		}
+		return PaymentQueryState.NOT_PAID;
+	}
 
+	static PaymentQueryState classifyWxPaymentQuery(Map<String, String> result) {
+		if (result == null || !"SUCCESS".equals(result.get("return_code"))) {
+			return PaymentQueryState.UNKNOWN;
+		}
+		if (!"SUCCESS".equals(result.get("result_code"))) {
+			return "ORDERNOTEXIST".equals(result.get("err_code"))
+					? PaymentQueryState.NOT_PAID : PaymentQueryState.UNKNOWN;
+		}
+		String tradeState = result.get("trade_state");
+		if ("SUCCESS".equals(tradeState)) {
+			return PaymentQueryState.PAID;
+		}
+		if ("NOTPAY".equals(tradeState) || "CLOSED".equals(tradeState)
+				|| "REVOKED".equals(tradeState) || "PAYERROR".equals(tradeState)) {
+			return PaymentQueryState.NOT_PAID;
+		}
+		return PaymentQueryState.UNKNOWN;
+	}
+
+	static PaymentQueryState classifyAliPaymentQuery(AlipayTradeQueryResponse result) {
+		if (result == null) {
+			return PaymentQueryState.UNKNOWN;
+		}
+		return classifyAliPaymentQuery(result.isSuccess(), result.getSubCode(), result.getTradeStatus());
+	}
+
+	static PaymentQueryState classifyAliPaymentQuery(boolean success, String subCode, String tradeStatus) {
+		if (!success) {
+			return "ACQ.TRADE_NOT_EXIST".equals(subCode)
+					? PaymentQueryState.NOT_PAID : PaymentQueryState.UNKNOWN;
+		}
+		if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
+			return PaymentQueryState.PAID;
+		}
+		if ("WAIT_BUYER_PAY".equals(tradeStatus)) {
+			return PaymentQueryState.NOT_PAID;
+		}
+		return PaymentQueryState.UNKNOWN;
+	}
+
+	private void schedulePaymentQueryRetry(OrderDon orderDon) {
+		String retryKey = PAYMENT_QUERY_RETRY_KEY + orderDon.getId();
+		try {
+			Long retryNum = redisService.incr(retryKey, 1L);
+			if (retryNum != null && retryNum == 1L) {
+				redisService.expire(retryKey, 24 * 60 * 60L);
+			}
+			if (retryNum != null && retryNum <= PAYMENT_QUERY_MAX_RETRIES) {
+				jobManager.addJob(PAYMENT_QUERY_RETRY_DELAY,
+						() -> closeOrder(orderDon.getId(), null));
+				log.warn("支付状态查询未知,已安排重试, orderId:{}, retry:{}",
+						orderDon.getId(), retryNum);
+				return;
 			}
+			log.error("支付状态查询连续失败,停止主服务重试并等待外部任务兜底, orderId:{}",
+					orderDon.getId());
+		} catch (Exception e) {
+			log.error("安排支付状态查询重试失败, orderId:{}", orderDon.getId(), e);
 		}
-		return orderDon.getStatus();
 	}
 
+	private void clearPaymentQueryRetry(Long orderId) {
+		if (orderId != null) {
+			redisService.del(PAYMENT_QUERY_RETRY_KEY + orderId);
+		}
+	}
+
+	enum PaymentQueryState {
+		PAID,
+		NOT_PAID,
+		UNKNOWN
+	}
+
+	private void validateWxActiveQuery(OrderDon order, Map<String, String> result) {
+		if (!StrUtil.equals(order.getShopId(), result.get("mch_id"))
+				|| !StrUtil.equals(order.getOrderNo(), result.get("out_trade_no"))
+				|| !matchesPersistedTransaction(order.getTransactionId(), result.get("transaction_id"))
+				|| !matchesCentAmount(order.getMoney(), result.get("total_fee"))) {
+			throw BusinessRuntimeException.getInstance("微信主动查单信息不匹配");
+		}
+	}
+
+	private void validateAliActiveQuery(OrderDon order, AlipayTradeQueryResponse result) {
+		if (!StrUtil.equals(order.getOrderNo(), result.getOutTradeNo())
+				|| !matchesPersistedTransaction(order.getTransactionId(), result.getTradeNo())
+				|| !matchesYuanAmount(order.getMoney(), result.getTotalAmount())) {
+			throw BusinessRuntimeException.getInstance("支付宝主动查单信息不匹配");
+		}
+	}
+
+	static boolean matchesPersistedTransaction(String persisted, String queried) {
+		return StrUtil.isBlank(persisted) || StrUtil.equals(persisted, queried);
+	}
+
+	static boolean matchesCentAmount(BigDecimal expectedCent, String actualCent) {
+		try {
+			return expectedCent != null && new BigDecimal(actualCent).compareTo(expectedCent) == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	static boolean matchesYuanAmount(BigDecimal expectedCent, String actualYuan) {
+		try {
+			BigDecimal actualCent = new BigDecimal(actualYuan).multiply(BigDecimal.valueOf(100))
+					.setScale(0, RoundingMode.UNNECESSARY);
+			return expectedCent != null && actualCent.compareTo(expectedCent) == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
 	private OrderDon.Status updateNoPayment(OrderDon orderDon, GoodsDon goodsDon) {
 		GoodsDonSku sku = null;
 		if (orderDon.getSkuId() != null) {
@@ -3058,6 +3436,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 				orderDonGalaxyCoinRecord.setOrderNo(orderDon.getOrderNo());
 				orderDonGalaxyCoinRecord.setGalaxyCoin(subGalxyCoin);
 				orderDonGalaxyCoinRecord.setDeductMoney(thisDeductMoney);
+				orderDonGalaxyCoinRecord.setSource(OrderDonGalaxyCoinRecord.Source.ordinary.ordinal());
 				orderDonGalaxyCoinRecordMapper.insert(orderDonGalaxyCoinRecord);
 				orderDon.setMoney(orderMoney.subtract(thisDeductMoney));
 			}
@@ -3172,6 +3551,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	 * 统一订单信息回调
 	 */
 	public void unifiedHandlerOrderNotify(OrderDon orderDon, GoodsDon goodsDon, GoodsDonSku sku) {
+		if (multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
+			multiQuantityOrderService.handlePaidOrder(orderDon, goodsDon, sku);
+			return;
+		}
 		orderDon.setStatus(goodsDon.getType() == 1 && goodsDon.getSecondType() == 2 ? OrderDon.Status.complete : OrderDon.Status.hasPayment);
 		//补差价
 		if (goodsDon.getType() != null && goodsDon.getType() == 5) {
@@ -3937,6 +4320,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 	 * 回调后 直接退款
 	 */
 	public void notifyRefundOrder(OrderDon orderDon, GoodsDon goodsDon, GoodsDonSku sku) throws Exception {
+		if (multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
+			log.warn("Skip legacy relation-based auto refund for multi-quantity orderId:{}", orderDon.getId());
+			throw BusinessRuntimeException.getInstance("批量订单仅支持整单退款,请使用统一退款入口");
+		}
 		//续费订单过滤
 		if (orderDon.getOrderType() == 2) {
 			return;

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

@@ -0,0 +1,337 @@
+package com.cyksj.service.order.impl;
+
+import com.cyksj.mapper.order.OrderDonBusinessEventMapper;
+import com.cyksj.mapper.order.OrderDonTicketRecordMapper;
+import com.cyksj.mapper.OrderDonMapper;
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+import com.cyksj.model.entity.OrderDon;
+import com.cyksj.model.entity.OrderDonTicketRecord;
+import com.cyksj.service.order.OrderDonTicketRecordService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class OrderDonTicketRecordServiceImpl implements OrderDonTicketRecordService {
+
+    private static final int MAX_QUANTITY = 100;
+    private static final int MAX_BATCH_SIZE = 500;
+    private static final int MAX_LEASE_MINUTES = 120;
+
+    private final OrderDonTicketRecordMapper ticketRecordMapper;
+    private final OrderDonBusinessEventMapper businessEventMapper;
+    private final OrderDonMapper orderDonMapper;
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public void initSlots(Long orderId, int quantity) {
+        requireOrderId(orderId);
+        if (quantity <= 1 || quantity > MAX_QUANTITY) {
+            throw new IllegalArgumentException("quantity must be between 2 and " + MAX_QUANTITY);
+        }
+        List<Integer> indexes = new ArrayList<>(quantity);
+        for (int index = 1; index <= quantity; index++) {
+            indexes.add(index);
+        }
+        ticketRecordMapper.insertSlots(orderId, indexes);
+        int actual = ticketRecordMapper.countByOrderId(orderId);
+        if (actual != quantity) {
+            throw new IllegalStateException("ticket slot count mismatch, expected=" + quantity + ", actual=" + actual);
+        }
+    }
+
+    @Override
+    public boolean exists(Long orderId) {
+        requireOrderId(orderId);
+        return ticketRecordMapper.countByOrderId(orderId) > 0;
+    }
+
+    @Override
+    public List<OrderDonTicketRecord> listByOrderId(Long orderId) {
+        requireOrderId(orderId);
+        return ticketRecordMapper.selectByOrderId(orderId);
+    }
+
+    @Override
+    public boolean claim(Long recordId, String workerToken, int maxRetries) {
+        requireRecordId(recordId);
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.claim(recordId, workerToken, boundedRetries(maxRetries)) == 1;
+    }
+
+    @Override
+    public boolean lockClaim(Long recordId, String workerToken) {
+        requireRecordId(recordId);
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.lockClaim(recordId, workerToken) != null;
+    }
+
+    @Override
+    public boolean markSuccess(Long recordId, Long relationId, String workerToken) {
+        requireRecordId(recordId);
+        if (relationId == null || relationId <= 0) {
+            throw new IllegalArgumentException("relationId is required");
+        }
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.markSuccess(recordId, relationId, workerToken) == 1;
+    }
+
+    @Override
+    public boolean markFailed(Long recordId, String workerToken, String errorMsg) {
+        requireRecordId(recordId);
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.markFailed(recordId, workerToken, truncate(errorMsg)) == 1;
+    }
+
+    @Override
+    public List<Long> pendingOrderIds(int limit, int maxRetries) {
+        return ticketRecordMapper.selectPendingOrderIds(
+                boundedLimit(limit), boundedRetries(maxRetries));
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public int recoverExpired(int leaseMinutes) {
+        return ticketRecordMapper.recoverExpired(boundedLease(leaseMinutes));
+    }
+
+    @Override
+    public List<Long> expiredRefundOrderIds(int leaseMinutes, int limit) {
+        return businessEventMapper.selectExpiredRefundOrderIds(boundedLease(leaseMinutes), boundedLimit(limit));
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public String reclaimExpiredRefund(Long orderId, int leaseMinutes) {
+        requireOrderId(orderId);
+        OrderDon order = orderDonMapper.selectByIdForUpdate(orderId);
+        if (order == null || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            return null;
+        }
+        OrderDonBusinessEvent event = businessEventMapper.selectByOrderAndType(
+                orderId, OrderDonBusinessEvent.Type.refund.name());
+        if (event == null || event.getStatus() != OrderDonBusinessEvent.Status.processing
+                || event.getWorkerToken() == null) {
+            return null;
+        }
+        String newToken = UUID.randomUUID().toString().replace("-", "");
+        int boundedLeaseMinutes = boundedLease(leaseMinutes);
+        int expected = ticketRecordMapper.countRefundingByWorker(orderId, event.getWorkerToken());
+        int total = ticketRecordMapper.countByOrderAndStatus(
+                orderId, OrderDonTicketRecord.Status.refunding.name());
+        if (total != expected) {
+            throw new IllegalStateException("refund ticket ownership conflict");
+        }
+        if (expected > 0) {
+            int transferred = ticketRecordMapper.transferRefundWorker(
+                    orderId, event.getWorkerToken(), newToken, boundedLeaseMinutes);
+            if (transferred != expected) {
+                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;
+    }
+
+    @Override
+    public boolean deferRefundCheck(Long orderId, String workerToken, String message) {
+        requireOrderId(orderId);
+        requireWorkerToken(workerToken);
+        return businessEventMapper.deferRefundCheck(
+                orderId, workerToken, truncate(message)) == 1;
+    }
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public void startRefund(Long orderId, String workerToken) {
+        requireOrderId(orderId);
+        requireWorkerToken(workerToken);
+        OrderDon order = orderDonMapper.selectByIdForUpdate(orderId);
+        if (order == null || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            throw new IllegalStateException("order status does not support refund");
+        }
+
+        List<OrderDonTicketRecord> records = ticketRecordMapper.lockByOrderId(orderId);
+        if (records.isEmpty()) {
+            throw new IllegalStateException("ticket records do not exist");
+        }
+        if (order.getNum() == null || records.size() != order.getNum()) {
+            throw new IllegalStateException("ticket slot count does not match order quantity");
+        }
+        boolean processing = records.stream().anyMatch(record ->
+                record.getStatus() == OrderDonTicketRecord.Status.processing
+                        || record.getStatus() == OrderDonTicketRecord.Status.refunding);
+        boolean invalid = records.stream().anyMatch(record ->
+                record.getStatus() == OrderDonTicketRecord.Status.refunded);
+        if (processing) {
+            throw new IllegalStateException("ticket delivery or refund is processing");
+        }
+        if (invalid) {
+            throw new IllegalStateException("ticket state does not support refund");
+        }
+        businessEventMapper.insertIfAbsent(orderId, OrderDonBusinessEvent.Type.refund.name());
+        if (businessEventMapper.lockRefundGuard(orderId) == null) {
+            throw new IllegalStateException("refund guard does not exist");
+        }
+        OrderDonBusinessEvent refundEvent = businessEventMapper.selectByOrderAndType(
+                orderId, OrderDonBusinessEvent.Type.refund.name());
+        if (refundEvent == null || (refundEvent.getStatus() != OrderDonBusinessEvent.Status.pending
+                && refundEvent.getStatus() != OrderDonBusinessEvent.Status.failed)) {
+            throw new IllegalStateException("refund event has already been processed");
+        }
+        if (businessEventMapper.claim(orderId, OrderDonBusinessEvent.Type.refund.name(), workerToken) != 1) {
+            throw new IllegalStateException("refund event claim conflict");
+        }
+        if (businessEventMapper.countProcessingNonRefund(orderId) > 0) {
+            throw new IllegalStateException("order business event is processing");
+        }
+        ticketRecordMapper.cancelUndeliveredForRefund(orderId, workerToken);
+        ticketRecordMapper.startDeliveredRefund(orderId, workerToken);
+    }
+
+    @Override
+    public boolean markRefunded(Long recordId, String workerToken) {
+        requireRecordId(recordId);
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.markRefunded(recordId, workerToken) == 1;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public void restoreRefund(Long orderId, String workerToken, String errorMsg) {
+        requireOrderId(orderId);
+        requireWorkerToken(workerToken);
+        OrderDon order = orderDonMapper.selectByIdForUpdate(orderId);
+        if (order == null || (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete)) {
+            throw new IllegalStateException("order status does not support refund restore");
+        }
+        OrderDonBusinessEvent event = businessEventMapper.selectByOrderAndType(
+                orderId, OrderDonBusinessEvent.Type.refund.name());
+        if (event != null && event.getStatus() == OrderDonBusinessEvent.Status.failed) {
+            if (ticketRecordMapper.countByOrderAndStatus(
+                    orderId, OrderDonTicketRecord.Status.refunding.name()) > 0) {
+                throw new IllegalStateException("refund ticket restore conflict");
+            }
+            return;
+        }
+        if (event == null || event.getStatus() != OrderDonBusinessEvent.Status.processing
+                || !workerToken.equals(event.getWorkerToken())) {
+            throw new IllegalStateException("refund event restore ownership conflict");
+        }
+        String message = truncate(errorMsg);
+        ticketRecordMapper.restoreRefunding(orderId, workerToken, message);
+        if (ticketRecordMapper.countByOrderAndStatus(
+                orderId, OrderDonTicketRecord.Status.refunding.name()) > 0) {
+            throw new IllegalStateException("refund ticket restore conflict");
+        }
+        if (businessEventMapper.markProcessingFailed(orderId,
+                OrderDonBusinessEvent.Type.refund.name(), workerToken, message) != 1) {
+            throw new IllegalStateException("refund event restore conflict");
+        }
+    }
+
+    @Override
+    public int countSuccess(Long orderId) {
+        requireOrderId(orderId);
+        return ticketRecordMapper.countSuccess(orderId);
+    }
+
+    @Override
+    public int countAvailable(Long orderId) {
+        requireOrderId(orderId);
+        return ticketRecordMapper.countAvailable(orderId);
+    }
+
+    @Override
+    public OrderDonTicketRecord getActiveByRelationId(Long relationId) {
+        if (relationId == null || relationId <= 0) {
+            return null;
+        }
+        return ticketRecordMapper.selectActiveByRelationId(relationId);
+    }
+
+    @Override
+    public OrderDonTicketRecord getActiveByRelationIdForUpdate(Long relationId) {
+        if (relationId == null || relationId <= 0) {
+            return null;
+        }
+        return ticketRecordMapper.selectActiveByRelationIdForUpdate(relationId);
+    }
+
+    @Override
+    public boolean replaceActiveRelation(Long oldRelationId, Long newRelationId) {
+        if (oldRelationId == null || oldRelationId <= 0 || newRelationId == null || newRelationId <= 0) {
+            throw new IllegalArgumentException("oldRelationId and newRelationId are required");
+        }
+        return ticketRecordMapper.replaceActiveRelation(oldRelationId, newRelationId) == 1;
+    }
+
+    @Override
+    public boolean clearActiveRelation(Long relationId) {
+        if (relationId == null || relationId <= 0) {
+            return false;
+        }
+        return ticketRecordMapper.clearActiveRelation(relationId) == 1;
+    }
+
+    @Override
+    public List<OrderDonTicketRecord> listSuccessfulByGroupsId(Long groupsId) {
+        if (groupsId == null || groupsId <= 0) {
+            return List.of();
+        }
+        return ticketRecordMapper.selectSuccessfulByGroupsId(groupsId);
+    }
+
+    @Override
+    public List<Long> listSuccessfulOrderIdsByGroupsId(Long groupsId) {
+        if (groupsId == null || groupsId <= 0) {
+            return List.of();
+        }
+        return ticketRecordMapper.selectSuccessfulOrderIdsByGroupsId(groupsId);
+    }
+
+    private void requireOrderId(Long orderId) {
+        if (orderId == null || orderId <= 0) {
+            throw new IllegalArgumentException("orderId is required");
+        }
+    }
+
+    private void requireRecordId(Long recordId) {
+        if (recordId == null || recordId <= 0) {
+            throw new IllegalArgumentException("recordId is required");
+        }
+    }
+
+    private void requireWorkerToken(String workerToken) {
+        if (workerToken == null || workerToken.isBlank() || workerToken.length() > 64) {
+            throw new IllegalArgumentException("invalid workerToken");
+        }
+    }
+
+    private int boundedLimit(int limit) {
+        return Math.max(1, Math.min(limit, MAX_BATCH_SIZE));
+    }
+
+    private int boundedRetries(int maxRetries) {
+        return Math.max(1, Math.min(maxRetries, 20));
+    }
+
+    private int boundedLease(int leaseMinutes) {
+        return Math.max(1, Math.min(leaseMinutes, MAX_LEASE_MINUTES));
+    }
+
+    private String truncate(String message) {
+        String value = message == null ? "unknown error" : message;
+        return value.substring(0, Math.min(value.length(), 500));
+    }
+}

+ 59 - 19
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderRefundServiceImpl.java

@@ -13,6 +13,7 @@ import com.cyksj.config.zfb.AliPayClientFactory;
 import com.cyksj.mapper.*;
 import com.cyksj.mapper.channel.ShopConfigMapper;
 import com.cyksj.mapper.vip.VipOrderDonMapper;
+import com.cyksj.mapper.order.OrderDonTicketRecordMapper;
 import com.cyksj.model.entity.*;
 import com.cyksj.model.request.OrderRefundReq;
 import com.cyksj.service.order.OrderRefundService;
@@ -68,23 +69,39 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 
 	private final VipOrderDonMapper vipOrderDonMapper;
 
+	private final OrderDonTicketRecordMapper orderDonTicketRecordMapper;
+
 	@Override
 	public String centerRefund(OrderRefundReq refundReq, OrderDon orderDon) throws Exception {
+		return centerRefund(refundReq, orderDon, null);
+	}
+
+	@Override
+	public String centerRefund(OrderRefundReq refundReq, OrderDon orderDon, String outRefundNo) throws Exception {
+		if (orderDonTicketRecordMapper.countByOrderId(orderDon.getId()) > 0
+				&& StrUtil.isBlank(outRefundNo)) {
+			throw BusinessRuntimeException.getInstance("批量订单必须走整单退款流程");
+		}
 		OrderDon.Type type = orderDon.getType();
 		String refundType = refundReq.getRefundType();
 		if (type == OrderDon.Type.WeChat && !refundType.equals(OrderDon.Type.BALANCE.getType())) {
-			return wxRefund(refundReq, orderDon.getId(), orderDon.getShopId(), orderDon.getTransactionId(), orderDon.getMoney());
+			return wxRefund(refundReq, orderDon.getId(), orderDon.getShopId(),
+					orderDon.getTransactionId(), orderDon.getMoney(), outRefundNo);
 		}
 		if (type == OrderDon.Type.ALI_PAY && !refundType.equals(OrderDon.Type.BALANCE.getType())) {
-			return zfbRefund(refundReq, orderDon.getId(), orderDon.getShopId(), orderDon.getTransactionId(), orderDon.getMoney());
+			return zfbRefund(refundReq, orderDon.getId(), orderDon.getShopId(),
+					orderDon.getTransactionId(), orderDon.getMoney(), outRefundNo);
 		}
 		if (type == OrderDon.Type.PAYPAL && !refundType.equals(OrderDon.Type.BALANCE.getType())) {
+			if (StrUtil.isNotEmpty(outRefundNo)) {
+				throw BusinessRuntimeException.getInstance("批量订单暂不支持PayPal自动退款");
+			}
 			if (!paypalRefund(refundReq, orderDon)) {
 				throw BusinessRuntimeException.getInstance("paypal退款失败");
 			}
 		}
 		if (type == OrderDon.Type.STRIPE && !refundType.equals(OrderDon.Type.BALANCE.getType())) {
-			if (!stripeRefund(refundReq, orderDon)) {
+			if (!stripeRefund(refundReq, orderDon, outRefundNo)) {
 				throw BusinessRuntimeException.getInstance("stripe退款失败");
 			}
 		}
@@ -253,15 +270,22 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 	}
 
 	public String wxRefund(OrderRefundReq refundReq, Long orderId, String shopId, String transactionId, BigDecimal orderMoney) throws Exception {
+		return wxRefund(refundReq, orderId, shopId, transactionId, orderMoney, null);
+	}
+
+	public String wxRefund(OrderRefundReq refundReq, Long orderId, String shopId, String transactionId,
+							BigDecimal orderMoney, String fixedOutRefundNo) throws Exception {
 		if (orderMoney.compareTo(BigDecimal.ZERO) <= 0) {
 			return StrUtil.EMPTY;
 		}
-		long time = System.currentTimeMillis();
-		//退款订单号
-		String outRefundNo = Codec.DoDigest.custom()
-				.setAlgorithm(Codec.DoDigest.Algorithm.MD5)
-				.setStringData(time + String.valueOf(orderId))
-				.toHexString();
+		String outRefundNo = fixedOutRefundNo;
+		if (StrUtil.isEmpty(outRefundNo)) {
+			long time = System.currentTimeMillis();
+			outRefundNo = Codec.DoDigest.custom()
+					.setAlgorithm(Codec.DoDigest.Algorithm.MD5)
+					.setStringData(time + String.valueOf(orderId))
+					.toHexString();
+		}
 		weChatService.refundWxOrder(weChatConfig.getAppid(),
 				Optional.ofNullable(shopId).orElse(ShopConfig.WX_DEFAULT_APP_ID),
 				transactionId,
@@ -274,7 +298,12 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 	}
 
 	public String zfbRefund(OrderRefundReq refundReq,  Long orderId, String shopId, String transactionId, BigDecimal orderMoney) throws Exception {
-		if (orderMoney.compareTo(BigDecimal.ZERO) <=0){
+		return zfbRefund(refundReq, orderId, shopId, transactionId, orderMoney, null);
+	}
+
+	public String zfbRefund(OrderRefundReq refundReq, Long orderId, String shopId, String transactionId,
+							BigDecimal orderMoney, String fixedOutRefundNo) throws Exception {
+		if (orderMoney.compareTo(BigDecimal.ZERO) <= 0) {
 			return StrUtil.EMPTY;
 		}
 		AlipayTradeRefundResponse response;
@@ -286,12 +315,14 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 		model.setTradeNo(transactionId);
 		model.setRefundAmount(refundReq.getRefundMoney().divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_EVEN).toString());
 		model.setQueryOptions(List.of("gmt_refund_pay"));
-		long time = System.currentTimeMillis();
-		//退款订单号
-		String outRefundNo = Codec.DoDigest.custom()
-				.setAlgorithm(Codec.DoDigest.Algorithm.MD5)
-				.setStringData(time + String.valueOf(orderId))
-				.toHexString();
+		String outRefundNo = fixedOutRefundNo;
+		if (StrUtil.isEmpty(outRefundNo)) {
+			long time = System.currentTimeMillis();
+			outRefundNo = Codec.DoDigest.custom()
+					.setAlgorithm(Codec.DoDigest.Algorithm.MD5)
+					.setStringData(time + String.valueOf(orderId))
+					.toHexString();
+		}
 		//退款请求号
 		model.setOutRequestNo(outRefundNo);
 		request.setBizModel(model);
@@ -369,6 +400,10 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 	}
 
 	public Boolean stripeRefund(OrderRefundReq refundReq, OrderDon orderDon) throws StripeException {
+		return stripeRefund(refundReq, orderDon, null);
+	}
+
+	public Boolean stripeRefund(OrderRefundReq refundReq, OrderDon orderDon, String idempotencyKey) throws StripeException {
 		BigDecimal net = refundReq.getRefundMoney().divide(new BigDecimal(7), 2, RoundingMode.HALF_DOWN);
 		if (!refundReq.getIsDiffer()) {
 			net = net.subtract(orderDon.getAbroadTransactionFee());
@@ -381,11 +416,16 @@ public class OrderRefundServiceImpl implements OrderRefundService {
 						.setCharge(orderDon.getTransactionId())
 						.setAmount(net.longValue())
 						.build();
-		com.stripe.model.Refund refund = com.stripe.model.Refund.create(params);
+		com.stripe.model.Refund refund = StrUtil.isEmpty(idempotencyKey)
+				? com.stripe.model.Refund.create(params)
+				: com.stripe.model.Refund.create(params,
+						com.stripe.net.RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
 		log.info("stripe return refund:{}",refund);
 		if ("succeeded".equals(refund.getStatus())){
-			orderDon.setStatus(OrderDon.Status.refund);
-			orderDonMapper.updateById(orderDon);
+			if (StrUtil.isEmpty(idempotencyKey)) {
+				orderDon.setStatus(OrderDon.Status.refund);
+				orderDonMapper.updateById(orderDon);
+			}
 			return true;
 		}
 		return false;

+ 72 - 6
netflix-service/src/main/java/com/cyksj/service/pay/impl/AliPayCommonServiceImpl.java

@@ -35,17 +35,22 @@ import com.cyksj.model.dto.OrderPayBuilderDto;
 import com.cyksj.model.entity.*;
 import com.cyksj.model.request.AlipayOrderDonReq;
 import com.cyksj.model.request.OrderAttach;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.pay.AliPayCommonService;
 import com.cyksj.service.sys.SysConfigService;
 import com.google.common.collect.Lists;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.Date;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 /*
@@ -92,6 +97,10 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 
 	private final VipOrderDonMapper vipOrderDonMapper;
 
+	private final MultiQuantityOrderService multiQuantityOrderService;
+
+	private final PlatformTransactionManager transactionManager;
+
 	private static final Sequence SEQUENCE = new Sequence(0);
 
 	@Override
@@ -132,13 +141,24 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 		Long orderId = re.getOrderId();
 		String returnUrl = re.getReturnUrl();
 		OrderDon orderDon = orderDonMapper.selectById(orderId);
-		Boolean isWebPay = orderDon.getIsWebPay();
+		if (orderDon == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在");
+		}
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
+		validateMultiQuantityPrepay(orderDon, multiQuantity);
+		Boolean originalWebPay = orderDon.getIsWebPay();
 		if (ZfbProductCode.PC_WEB.getProductCode().equals(productCode)) {
 			orderDon.setIsWebPay(true);
 		}
 		if (ZfbProductCode.MOBILE_WEB.getProductCode().equals(productCode)) {
 			orderDon.setIsWebPay(false);
 		}
+		if (orderDon.getIsWebPay() == null) {
+			orderDon.setIsWebPay(false);
+		}
+		if (multiQuantity) {
+			orderDon = bindMultiQuantityAliPay(orderDon.getId(), orderDon.getIsWebPay());
+		}
 		//实物设备
 		GoodsDon goodsDon = goodsDonMapper.selectById(orderDon.getGoodsId());
 		if (orderDon.getGoodsId() == 15 || goodsDon.getType() == 2) {
@@ -150,11 +170,12 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 			orderDon.setShopId(Constant.CY_SHOP_ID);
 		}
 		String appId = orderDon.getShopId();
-		if (StrUtil.isEmpty(appId)) {
+		if (!multiQuantity && StrUtil.isEmpty(appId)) {
 			appId = getZfbDayLimitShopId(orderDon);
 		}
 		//若切换支付环境
-		if (!priceDifferGoods && StrUtil.isNotBlank(orderDon.getShopId()) && isWebPay != orderDon.getIsWebPay()) {
+		if (!multiQuantity && !priceDifferGoods && StrUtil.isNotBlank(orderDon.getShopId())
+				&& !java.util.Objects.equals(originalWebPay, orderDon.getIsWebPay())) {
 			//当前支付宝商户 不支持所有环境 使用默认支付宝
 			ZfbShopOrderDayLimit dayLimit = zfbShopOrderDayLimitMapper.selectOne(Wrappers.lambdaQuery(ZfbShopOrderDayLimit.class)
 					.eq(ZfbShopOrderDayLimit::getAppId, orderDon.getShopId())
@@ -167,11 +188,11 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 				zfbShopOrderDayLimitMapper.incrHourLimitRemainOrders(orderDon.getShopId());
 			}
 		}
-		if (OrderDon.Type.ALI_PAY != orderDon.getType()) {
+		if (!multiQuantity) {
 			orderDon.setType(OrderDon.Type.ALI_PAY);
+			orderDon.setShopId(appId);
+			orderDonMapper.updateById(orderDon);
 		}
-		orderDon.setShopId(appId);
-		orderDonMapper.updateById(orderDon);
 		String notifyPath = BaseZfbConfig.notifyUrl;
 		Boolean isUserBenefits = re.getIsUserBenefits();
 		String subject;
@@ -223,6 +244,51 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 		return aliPayParams;
 	}
 
+	private void validateMultiQuantityPrepay(OrderDon orderDon, boolean multiQuantity) {
+		if (!multiQuantity) {
+			return;
+		}
+		if (orderDon.getStatus() != OrderDon.Status.noPayment) {
+			throw BusinessRuntimeException.getInstance("订单已处理,请勿重复支付");
+		}
+		if (orderDon.getType() != null && orderDon.getType() != OrderDon.Type.ALI_PAY) {
+			throw BusinessRuntimeException.getInstance("订单支付方式已确定,请勿切换支付方式");
+		}
+	}
+
+	private OrderDon bindMultiQuantityAliPay(Long orderId, Boolean requestedWebPay) {
+		boolean webPay = Boolean.TRUE.equals(requestedWebPay);
+		TransactionTemplate transaction = new TransactionTemplate(transactionManager);
+		transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+		return Objects.requireNonNull(transaction.execute(status -> {
+			OrderDon locked = orderDonMapper.selectByIdForUpdate(orderId);
+			if (locked == null) {
+				throw BusinessRuntimeException.getInstance("order does not exist");
+			}
+			if (locked.getStatus() != OrderDon.Status.noPayment) {
+				throw BusinessRuntimeException.getInstance("order is no longer payable");
+			}
+			if (locked.getType() != null && locked.getType() != OrderDon.Type.ALI_PAY) {
+				throw BusinessRuntimeException.getInstance("payment method is already locked");
+			}
+			if (StrUtil.isNotBlank(locked.getShopId())) {
+				if (locked.getType() != OrderDon.Type.ALI_PAY
+						|| !Objects.equals(locked.getIsWebPay(), webPay)) {
+					throw BusinessRuntimeException.getInstance("payment scene is already locked");
+				}
+				return locked;
+			}
+			locked.setIsWebPay(webPay);
+			String shopId = getZfbDayLimitShopId(locked);
+			if (StrUtil.isBlank(shopId)) {
+				throw BusinessRuntimeException.getInstance("no Alipay merchant is currently available");
+			}
+			multiQuantityOrderService.preparePrepay(
+					locked, OrderDon.Type.ALI_PAY, shopId, webPay);
+			return locked;
+		}), "failed to bind Alipay prepayment");
+	}
+
 	private ZfbShopOrderDayLimit getZfbShopOrdersDayLimit(Long goodsId, List<Integer> scope, List<Integer> payCategorys) {
 		int hour = DateUtil.hour(DateTime.now(), true);
 		//如果是ai订单 不分配给善缘

+ 9 - 0
netflix-service/src/main/java/com/cyksj/service/paypal/PayPalService.java

@@ -12,6 +12,7 @@ import com.cyksj.model.entity.VipOrderDon;
 import com.cyksj.model.request.OrderRefundReq;
 import com.cyksj.redis.RedisService;
 import com.cyksj.service.order.OrderDonService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.order.VipOrderDonService;
 import com.paypal.api.payments.Currency;
 import com.paypal.api.payments.*;
@@ -52,6 +53,8 @@ public class PayPalService{
 
     private final VipOrderDonService vipOrderDonService;
 
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
     private static final String SANDBOX_URL = "https://api-m.sandbox.paypal.com";
     private static final String LIVE_URL = "https://api-m.paypal.com";
 
@@ -120,6 +123,9 @@ public class PayPalService{
 
     public Payment createPayment(Long orderId,String successUrl) throws PayPalRESTException {
         OrderDon orderDon = orderDonService.getById(orderId);
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders do not support PayPal");
+        }
         if (orderDon.getGoodsId() == 26) {
             GoodsDonSku sku = skuMapper.selectById(orderDon.getSkuId());
             if (!sku.getIsMirror()) {
@@ -206,6 +212,9 @@ public class PayPalService{
 
 
     public Boolean refund(OrderRefundReq refundReq, OrderDon orderDon) throws PayPalRESTException {
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders only support the unified whole-order refund flow");
+        }
         BigDecimal net = refundReq.getRefundMoney().divide(new BigDecimal(5),2,RoundingMode.HALF_DOWN).subtract(orderDon.getAbroadTransactionFee());;
         log.info("paypal return order:{},money:{}",orderDon.getId(),net);
         PaypalPayConfig paypalPayConfig = paypalPayConfigService.getById(1);

+ 6 - 0
netflix-service/src/main/java/com/cyksj/service/paypal/PaypalV2Service.java

@@ -11,6 +11,7 @@ import com.cyksj.model.entity.PaypalPayConfig;
 import com.cyksj.model.entity.VipOrderDon;
 import com.cyksj.redis.RedisService;
 import com.cyksj.service.order.OrderDonService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.order.VipOrderDonService;
 import com.paypal.base.rest.APIContext;
 import com.paypal.base.rest.OAuthTokenCredential;
@@ -52,6 +53,8 @@ public class PaypalV2Service {
 
     private final VipOrderDonService vipOrderDonService;
 
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
     private static final String SANDBOX_URL = "https://api-m.sandbox.paypal.com";
 
     private static final String LIVE_URL = "https://api-m.paypal.com";
@@ -73,6 +76,9 @@ public class PaypalV2Service {
 
     public String createPayment(Long orderId, String successUrl) {
         OrderDon orderDon = orderDonService.getById(orderId);
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders do not support PayPal");
+        }
         if (orderDon.getGoodsId() == 26) {
             GoodsDonSku sku = skuMapper.selectById(orderDon.getSkuId());
             if (!sku.getIsMirror()) {

+ 55 - 2
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationClearServiceImpl.java

@@ -10,12 +10,16 @@ import com.cyksj.model.manage.views.GroupsRelationView;
 import com.cyksj.service.claude.ClaudeCodeService;
 import com.cyksj.service.codex.CodexService;
 import com.cyksj.service.mange.MirrorUserCleatService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.ejlchina.searcher.BeanSearcher;
 import com.ejlchina.searcher.util.MapUtils;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.util.Date;
 
@@ -28,6 +32,7 @@ import java.util.Date;
 @Service
 @RequiredArgsConstructor
 @Slf4j
+@Transactional(rollbackFor = Throwable.class)
 public class GroupRelationClearServiceImpl implements GroupRelationClearService {
 	private final GroupsRelationMapper groupsRelationMapper;
 
@@ -53,6 +58,8 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 
 	private final CodexService codexService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
     private static final GlobalThreadPoolTaskExecutor TASK_POOL = GlobalThreadPoolTaskExecutor.getInstance();
 
 	@Override
@@ -61,6 +68,13 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 		Long newRelationId = relation.getNewRelationId();
 		Integer num = relation.getNum();
 		final GroupsRelation.Status relationStatus = relation.getStatus();
+		OrderDonTicketRecord activeTicket = orderDonTicketRecordService
+				.getActiveByRelationIdForUpdate(relationId);
+		if (activeTicket != null
+				&& activeTicket.getStatus() == OrderDonTicketRecord.Status.refunding
+				&& source != UserTicketClearedRecord.Source.refund) {
+			throw new IllegalStateException("ticket refund is processing");
+		}
 		//记录用户被清除车票历史记录
 		GroupsRelationView groupsRelation = beanSearcher.searchFirst(GroupsRelationView.class,
 				MapUtils.builder().field(GroupsRelationView::getId, relationId).build());
@@ -85,6 +99,20 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 			relation.setAccount(null);
 			Integer success = groupsRelationMapper.updateExpiryTime(relation, expireTime);
 			if (success == 1) {
+				if (newRelationId == null) {
+					boolean cleared = orderDonTicketRecordService.clearActiveRelation(relationId);
+					if (activeTicket != null
+							&& activeTicket.getStatus() == OrderDonTicketRecord.Status.success
+							&& !cleared) {
+						throw new IllegalStateException("active ticket clear conflict");
+					}
+				} else {
+					boolean replaced = orderDonTicketRecordService
+							.replaceActiveRelation(relationId, newRelationId);
+					if (activeTicket != null && !replaced) {
+						throw new IllegalStateException("active ticket replacement conflict");
+					}
+				}
 				//2023-06-07 GPT账号座位大于20
 				if (relationStatus != GroupsRelation.Status.outside) {
 					log.info("开始清除过期子账号,座位号:{}", relation.getId());
@@ -146,7 +174,7 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 				}
 				//claude code
 				if (groupsRelation.getGoodsId() != null && groupsRelation.getGoodsId() == Constant.CLAUDE_CODE_GOODS_ID && source != UserTicketClearedRecord.Source.cc_deduct) {
-                    TASK_POOL.execute(()->{
+                    runAfterCommit(() -> {
                         //用户失效 车票用户id
                         claudeCodeService.invalidateUserCache(groupsRelation.getUserId());
                         //删除用户套餐
@@ -171,14 +199,33 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
                         codexService.delUserPackages(groupsRelation.getUserId());
                     }
                     //清除codex
-                    TASK_POOL.execute(() -> codexUserMapper.delete(Wrappers.lambdaQuery(CodexUser.class).eq(CodexUser::getRelationId, relationId)));
+                    runAfterCommit(() -> codexUserMapper.delete(Wrappers.lambdaQuery(CodexUser.class).eq(CodexUser::getRelationId, relationId)));
                 }
 			}
 		}
 	}
 
+	private void runAfterCommit(Runnable action) {
+		if (!TransactionSynchronizationManager.isActualTransactionActive()) {
+			TASK_POOL.execute(action);
+			return;
+		}
+		TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+			@Override
+			public void afterCommit() {
+				TASK_POOL.execute(action);
+			}
+		});
+	}
+
 	@Override
 	public void clearRenewGroupRelation(GroupsRelation relation) {
+		OrderDonTicketRecord activeTicket = orderDonTicketRecordService
+				.getActiveByRelationIdForUpdate(relation.getId());
+		if (activeTicket != null
+				&& activeTicket.getStatus() == OrderDonTicketRecord.Status.refunding) {
+			throw new IllegalStateException("ticket refund is processing");
+		}
 		Date expireTime = relation.getExpiryTime();
 		relation.setStartTime(null);
 		relation.setExpiryTime(null);
@@ -189,6 +236,12 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 		relation.setAccount(null);
 		Integer success = groupsRelationMapper.updateExpiryTime(relation, expireTime);
 		if (success == 1) {
+			boolean cleared = orderDonTicketRecordService.clearActiveRelation(relation.getId());
+			if (activeTicket != null
+					&& activeTicket.getStatus() == OrderDonTicketRecord.Status.success
+					&& !cleared) {
+				throw new IllegalStateException("active ticket clear conflict");
+			}
 			log.info("续费后更换车票,座位号:{}", relation.getId());
 			int count = groupsMapper.incrAvailableNum(relation.getGroupsId());
 			if (count == 0) {

+ 37 - 1
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java

@@ -61,6 +61,7 @@ import com.cyksj.service.mail.MailService;
 import com.cyksj.service.mange.coupon.CouponCommonService;
 import com.cyksj.service.midjourney.MidjourneyAccountService;
 import com.cyksj.service.order.OrderRefundService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.cyksj.service.relation.GroupRelationFrontService;
 import com.cyksj.service.relation.GroupsRelationChangeService;
@@ -210,6 +211,8 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	private final GroupRelationClearService clearService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
 	private final UserBenefitsService userBenefitsService;
 
 	private final ChatgptUserMapper chatgptUserMapper;
@@ -289,7 +292,10 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 					.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
 					.last("limit 1"));
 			Long orderId = orderDon != null ? orderDon.getId() : 0l;
-			conditionSql += String.format(" and exists(select 1 from order_don o where o.relation_id = gr.id and o.user_id = gr.user_id and o.id = %s)", orderId);
+			conditionSql += String.format(" and (exists(select 1 from order_don o where o.relation_id = gr.id and o.user_id = gr.user_id and o.id = %s)" +
+					" or exists(select 1 from order_don_ticket_record r inner join order_don o on o.id = r.order_id" +
+					" where r.relation_id = gr.id and r.status = 'success' and o.user_id = gr.user_id and o.id = %s))",
+					orderId, orderId);
 		}
 		SearchResult<RenewalView> list = beanSearcher.search(RenewalView.class, builder
 				.field(RenewalView::getUserId, 0).op(Operator.GreaterThan)
@@ -399,6 +405,16 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 						.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
 						.orderByDesc(OrderDon::getId)
 						.last("limit 1"));
+				if (orderDon == null) {
+					OrderDonTicketRecord multiTicket = orderDonTicketRecordService.getActiveByRelationId(ticket.getRelationId());
+					if (multiTicket != null) {
+						orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+								.eq(OrderDon::getId, multiTicket.getOrderId())
+								.in(OrderDon::getUserId, userIds)
+								.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+								.last("limit 1"));
+					}
+				}
 				Long zhSkuId = ticket.getSkuId();
 				if (orderDon == null) {
 					if (ticket.getGoodsId() == Constant.NETFLIX_GID) {
@@ -1779,6 +1795,9 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 	public void errorNetflixSubmitChangeOtherTicket(ErrorNetflixChangeReq req) throws Exception {
 		Long relationId = req.getRelationId();
 		Long userId = req.getUserId();
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			throw BusinessRuntimeException.getInstance("Multi-quantity tickets do not support cross-SKU replacement");
+		}
 		//校验奈飞账号状态
 		GroupsRelationView netflixRelationView = checkNetflixErrorStatus(relationId, userId);
 		Date expiryTime = netflixRelationView.getExpiryTime();
@@ -1926,6 +1945,11 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	@Override
 	public NetflixRefundInfoResp refundNetflixInfo(Long userId, Long relationId) {
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			NetflixRefundInfoResp response = new NetflixRefundInfoResp();
+			response.setIsRefund(false);
+			return response;
+		}
 		//校验奈飞账号状态
 		GroupsRelationView netflixRelationView = checkNetflixErrorStatus(relationId, userId);
 		Date expiryTime = netflixRelationView.getExpiryTime();
@@ -1978,6 +2002,9 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 	public Boolean refundNetflix(ErrorNetflixChangeReq req) throws Exception {
 		Long relationId = req.getRelationId();
 		Long userId = req.getUserId();
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			throw BusinessRuntimeException.getInstance("Multi-quantity tickets only support whole-order refunds");
+		}
 		//校验奈飞账号状态
 		GroupsRelationView netflixRelationView = checkNetflixErrorStatus(relationId, userId);
 		Date expiryTime = netflixRelationView.getExpiryTime();
@@ -2488,6 +2515,9 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 	public void errorYoutubeChangeOtherTicket(ErrorNetflixChangeReq req) throws Exception {
 		Long relationId = req.getRelationId();
 		Long userId = req.getUserId();
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			throw BusinessRuntimeException.getInstance("Multi-quantity tickets do not support cross-SKU replacement");
+		}
 		//校验账号
 		GroupsRelationView relationView = checkYoutubeTime(relationId, userId);
 		String key = RedisService.key.NETFLIX_CHANGE_TICKET_KEY.getName() + relationId;
@@ -2540,6 +2570,9 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 	public void errorYoutubeRefund(ErrorNetflixChangeReq req) throws Exception {
 		Long relationId = req.getRelationId();
 		Long userId = req.getUserId();
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			throw BusinessRuntimeException.getInstance("Multi-quantity tickets only support whole-order refunds");
+		}
 		//校验状态
 		GroupsRelationView relationView = checkYoutubeTime(relationId, userId);
 		Long ticketUserId = relationView.getUserId();
@@ -2705,6 +2738,9 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 
 	@Override
 	public OrderRefundDto getYoutubeRefundInfo(long userId, Long relationId) {
+		if (orderDonTicketRecordService.getActiveByRelationId(relationId) != null) {
+			return null;
+		}
 		//校验状态
 		List<Long> userIdList = userBindRelationService.getRelationUserIdList(userId, null);
 		GroupsRelationView relationView = beanSearcher.searchFirst(GroupsRelationView.class, MapUtils.builder()

+ 24 - 2
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationChangeServiceImpl.java

@@ -2,6 +2,7 @@ package com.cyksj.service.relation.impl;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.cyksj.common.constant.Constant;
+import com.cyksj.common.exception.BusinessRuntimeException;
 import com.cyksj.dto.RedisKey;
 import com.cyksj.mapper.GoodsDonSkuMapper;
 import com.cyksj.mapper.GroupsMapper;
@@ -10,6 +11,7 @@ import com.cyksj.mapper.OrderDonMapper;
 import com.cyksj.model.entity.*;
 import com.cyksj.redis.RedisService;
 import com.cyksj.service.groups.GroupsFuncService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.cyksj.service.relation.GroupsRelationChangeService;
 import lombok.RequiredArgsConstructor;
@@ -41,13 +43,17 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 
 	private final GroupRelationClearService relationClearService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
 	@Override
 	@Transactional(rollbackFor = Throwable.class)
 	public GroupsRelation changeTicket(Long userId, Long oriRelationId, Long itemGroupsId, Long skuId) {
+		validateSameSku(oriRelationId, skuId);
 		GroupsRelation relation = getRelationThisTrips(userId, itemGroupsId, 1);
 		if (relation == null) {
 			return relation;
 		}
+		validateSameSku(relation.getId(), skuId);
 		syncGroupsRelation(oriRelationId, relation);
 		return relation;
 	}
@@ -55,6 +61,7 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 	@Override
 	@Transactional(rollbackFor = Throwable.class)
 	public GroupsRelation getEmptyGroupsRelation(Long userId, Long oriRelationId, Long skuId) {
+		validateSameSku(oriRelationId, skuId);
 		return getEmptyGroupsRelationIfRetry(userId, oriRelationId, skuId, 1);
 	}
 
@@ -89,7 +96,7 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 		if (count == 0) {
 			log.error("车位异常 groupId:{}", groupsId);
 			groupsMapper.updateAvailableNum(groupsId);
-			log.error("车位异常");
+			throw BusinessRuntimeException.getInstance("车位异常,请重试");
 		}
 		syncGroupsRelation(oriRelationId, relation);
 		return relation;
@@ -126,14 +133,22 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 	}
 
 	public void syncGroupsRelation(Long oriRelationId, GroupsRelation newRelation) {
-		GroupsRelation oriRelation = relationMapper.selectById(oriRelationId);
+		OrderDonTicketRecord multiTicket = orderDonTicketRecordService.getActiveByRelationIdForUpdate(oriRelationId);
+		if (multiTicket != null && multiTicket.getStatus() == OrderDonTicketRecord.Status.refunding) {
+			throw BusinessRuntimeException.getInstance("订单退款处理中,暂不支持更换车票");
+		}
+		GroupsRelation oriRelation = relationMapper.selectByIdForUpdate(oriRelationId);
 		newRelation.setStartTime(oriRelation.getStartTime());
 		newRelation.setExpiryTime(oriRelation.getExpiryTime());
 		newRelation.setStatus(oriRelation.getStatus());
 		newRelation.setUserId(oriRelation.getUserId());
 		newRelation.setYhsId(oriRelation.getYhsId());
 		relationMapper.updateById(newRelation);
+		oriRelation.setNewRelationId(newRelation.getId());
 		relationClearService.clearTicket(oriRelation, UserTicketClearedRecord.Source.change_ticket);
+		if (multiTicket != null) {
+			return;
+		}
 		OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
 				.eq(OrderDon::getRelationId, oriRelationId)
 				.eq(OrderDon::getUserId, newRelation.getUserId())
@@ -145,4 +160,11 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 			orderDonMapper.updateById(orderDon);
 		}
 	}
+
+	private void validateSameSku(Long relationId, Long skuId) {
+		Long relationSkuId = relationMapper.getGroupTripsSkuIdByRelationId(relationId);
+		if (skuId == null || relationSkuId == null || !skuId.equals(relationSkuId)) {
+			throw BusinessRuntimeException.getInstance("仅支持更换同规格车票");
+		}
+	}
 }

+ 12 - 2
netflix-service/src/main/java/com/cyksj/service/scheduler/impl/SchedulerServiceImpl.java

@@ -17,6 +17,7 @@ import com.cyksj.service.error.UserRelationChangeErrorRecordService;
 import com.cyksj.service.groups.GroupsFuncService;
 import com.cyksj.service.mange.CmsOrderDonService;
 import com.cyksj.service.order.OrderRefundService;
+import com.cyksj.service.order.OrderDonTicketRecordService;
 import com.cyksj.service.relation.GroupRelationClearService;
 import com.cyksj.service.scheduler.SchedulerService;
 import com.cyksj.service.user.UserBenefitsService;
@@ -63,6 +64,8 @@ public class SchedulerServiceImpl implements SchedulerService {
 
 	private final OrderRefundService orderRefundService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
 	private final CmsOrderDonService cmsOrderDonService;
 
 	private final UserRelationChangeErrorRecordService changeErrorRecordService;
@@ -158,6 +161,13 @@ public class SchedulerServiceImpl implements SchedulerService {
 					if (item_groupsId == null) {
 						//无对应车队 退款
 						//退款至余额
+						OrderDonTicketRecord multiTicketRecord = orderDonTicketRecordService
+								.getActiveByRelationId(change_relationId);
+						if (multiTicketRecord != null) {
+							log.warn("Skip legacy single-ticket expiry refund for multi-quantity orderId:{}, relationId:{}",
+									multiTicketRecord.getOrderId(), change_relationId);
+							return;
+						}
 						OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
 								.eq(OrderDon::getRelationId, change_relationId)
 								.eq(OrderDon::getUserId, userId)
@@ -165,7 +175,7 @@ public class SchedulerServiceImpl implements SchedulerService {
 								.orderByDesc(OrderDon::getId)
 								.last("limit 1"));
 						if (orderDon == null) {
-							//更换过车票
+							// The ticket may have been replaced
 							orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
 									.eq(OrderDon::getSkuId, skuId)
 									.eq(OrderDon::getUserId, userId)
@@ -174,7 +184,7 @@ public class SchedulerServiceImpl implements SchedulerService {
 									.last("limit 1"));
 						}
 						if (orderDon == null) {
-							log.error("用户userId:{}的车票relationId:{}账号过期后转移错误", userId, change_relationId);
+							log.error("No refundable order found after account expiry, userId:{}, relationId:{}", userId, change_relationId);
 							changeErrorRecordService.changeTicketErrorRecord(relationView, UserRelationChangeErrorRecord.Source.change);
 							return;
 						}

+ 9 - 0
netflix-service/src/main/java/com/cyksj/service/stripe/StripeService.java

@@ -12,6 +12,7 @@ import com.cyksj.model.entity.StripePayConfig;
 import com.cyksj.model.entity.VipOrderDon;
 import com.cyksj.model.request.OrderRefundReq;
 import com.cyksj.service.order.OrderDonService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.order.VipOrderDonService;
 import com.stripe.Stripe;
 import com.stripe.exception.StripeException;
@@ -49,10 +50,15 @@ public class StripeService {
 
     private final VipOrderDonService vipOrderDonService;
 
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
 
 
     public String checkOutStripe(Long orderId,String successUrl) throws Exception{
         OrderDon orderDon = orderDonService.getById(orderId);
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderId)) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders do not support Stripe");
+        }
         if (orderDon.getGoodsId() == 26) {
             GoodsDonSku sku = skuMapper.selectById(orderDon.getSkuId());
             if (!sku.getIsMirror()) {
@@ -132,6 +138,9 @@ public class StripeService {
 
 
     public Boolean refund(OrderRefundReq refundReq, OrderDon orderDon) throws StripeException {
+        if (multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
+            throw BusinessRuntimeException.getInstance("Multi-quantity orders only support the unified whole-order refund flow");
+        }
         BigDecimal net = refundReq.getRefundMoney().divide(new BigDecimal(5),2, RoundingMode.HALF_DOWN).subtract(orderDon.getAbroadTransactionFee());
         log.info("stripe return order:{},money:{}",orderDon.getId(),net);
         StripePayConfig stripePayConfig = stripePayConfigMapper.selectById(1);

+ 8 - 1
netflix-service/src/main/java/com/cyksj/service/user/impl/UserBenefitsServiceImpl.java

@@ -15,6 +15,7 @@ import com.cyksj.service.user.UserBenefitsService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
 
@@ -72,6 +73,7 @@ public class UserBenefitsServiceImpl implements UserBenefitsService {
 	}
 
 	@Override
+	@Transactional(rollbackFor = Throwable.class)
 	public void addUserBalance(Long userId, BigDecimal addBalance, UserBalanceSourceRecord.Source source, Long yhsId, Long orderId, String detail, Boolean isRecord) {
 		if (addBalance == null || addBalance.compareTo(BigDecimal.ZERO) <= 0) {
 			return;
@@ -105,8 +107,13 @@ public class UserBenefitsServiceImpl implements UserBenefitsService {
 		}
 		while (true) {
 			User user = userMapper.selectById(userId);
-			if (user == null) break;
+			if (user == null) {
+				throw new IllegalStateException("user does not exist");
+			}
 			BigDecimal balance = user.getBalance();
+			if (balance.compareTo(subBalance) < 0) {
+				throw new IllegalStateException("user balance is insufficient");
+			}
 			Integer success = userMapper.deductUserBalance(user.getId(), balance, subBalance);
 			if (success == 1) break;
 		}

+ 3 - 0
netflix-service/src/main/java/com/cyksj/service/wechat/WeChatService.java

@@ -75,6 +75,9 @@ public interface WeChatService {
      */
     void refundWxOrder(String appId, String shopId, String transactionId, String outRefundNo, BigDecimal totalFee, BigDecimal refundMoney, String notifyUrl) throws Exception;
 
+    /** Query a refund by the deterministic merchant refund number. */
+    Map<String, String> getWxRefundDetail(String appId, String shopId, String outRefundNo) throws Exception;
+
     /**
      * 获取公众号带参数的临时二维码
      * @return

+ 57 - 1
netflix-service/src/main/java/com/cyksj/service/wechat/impl/WeChatServiceImpl.java

@@ -486,6 +486,57 @@ public class WeChatServiceImpl implements WeChatService {
         log.info("------------------- 调用微信申请退款 结束 -------------------");
     }
 
+    @Override
+    public Map<String, String> getWxRefundDetail(String appId, String shopId, String outRefundNo) throws Exception {
+        ShopConfig shopConfig = shopConfigMapper.selectOne(Wrappers.lambdaQuery(ShopConfig.class)
+                .eq(ShopConfig::getAppId, shopId).last("limit 1"));
+        if (shopConfig == null || StrUtil.isBlank(shopConfig.getSecret())) {
+            throw BusinessRuntimeException.getInstance("微信退款查询商户不存在");
+        }
+        Map<String, String> requestData = new TreeMap<>();
+        requestData.put("appid", appId);
+        requestData.put("mch_id", shopId);
+        requestData.put("out_refund_no", outRefundNo);
+        requestData.put("nonce_str", UUID.randomUUID().toString().replace("-", ""));
+        requestData.put("sign", signWithMd5(requestData, shopConfig.getSecret()));
+        HttpResponse<String> response = J11HttpC.custom()
+                .ofPost()
+                .url("https://api.mch.weixin.qq.com/pay/refundquery")
+                .headers(J11HttpC.ReqType.raw_xml)
+                .body(HttpRequest.BodyPublishers.ofString(Xmls.toXml(requestData), IoKit.Charsets.UTF_8.getCharset()))
+                .send(HttpResponse.BodyHandlers.ofString());
+        if (response.statusCode() != 200) {
+            throw BusinessRuntimeException.getInstance("微信退款查询失败: " + response.body());
+        }
+        Map<String, String> result = Xmls.toMap(response.body());
+        if (!"SUCCESS".equals(result.get("return_code"))) {
+            throw BusinessRuntimeException.getInstance("微信退款查询通信失败: " + result);
+        }
+        if (StrUtil.isNotBlank(result.get("mch_id")) && !shopId.equals(result.get("mch_id"))) {
+            throw BusinessRuntimeException.getInstance("微信退款查询商户不匹配");
+        }
+        if (StrUtil.isNotBlank(result.get("appid")) && !appId.equals(result.get("appid"))) {
+            throw BusinessRuntimeException.getInstance("微信退款查询应用不匹配");
+        }
+        if (!"SUCCESS".equals(result.get("result_code"))) {
+            return result;
+        }
+        if (!checkSignWithMd5(result, shopConfig.getSecret())) {
+            throw BusinessRuntimeException.getInstance("微信退款查询签名校验失败");
+        }
+        return result;
+    }
+
+    private String signWithMd5(Map<String, String> values, String secret) throws Exception {
+        StringBuilder data = new StringBuilder();
+        values.entrySet().stream()
+                .filter(entry -> !"sign".equals(entry.getKey()) && StrUtil.isNotBlank(entry.getValue()))
+                .sorted(Map.Entry.comparingByKey())
+                .forEach(entry -> data.append(entry.getKey()).append('=').append(entry.getValue()).append('&'));
+        data.append("key=").append(secret);
+        return Codec.DoDigest.custom().setAlgorithm(Codec.DoDigest.Algorithm.MD5)
+                .setStringData(data.toString()).toHexString().toUpperCase();
+    }
     @Override
     public WxGzhQrCodeTicketRep getWxGzhQrCode(GzhBodyDto gzhBodyDto) throws Exception {
         SysConfig sysConfig = sysConfigService.getOne(Wrappers.lambdaQuery(SysConfig.class)
@@ -571,10 +622,15 @@ public class WeChatServiceImpl implements WeChatService {
                 .body(HttpRequest.BodyPublishers.ofString(xml, IoKit.Charsets.UTF_8.getCharset()))
                 .send(HttpResponse.BodyHandlers.ofString());
         Map<String, String> orderSearch = Xmls.toMap(send.body());
+        ShopConfig shopConfig = shopConfigMapper.getByAppId(shopId);
+        if (shopConfig == null || !checkSignWithMd5(orderSearch, shopConfig.getSecret())) {
+            throw BusinessRuntimeException.getInstance("微信订单查询签名校验失败");
+        }
         log.info("微信返回值 xml转map: \n{}", orderSearch);
         if (!StringUtils.equals(orderSearch.get("result_code"), "SUCCESS")) {
             log.error("调用微信h5查询订单详情接口失败. 微信返回: " + orderSearch);
-            return null;
+            // Preserve verified failure codes such as ORDERNOTEXIST for tri-state handling.
+            return orderSearch;
         }
         return orderSearch;
     }

+ 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;
+
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class MultiQuantityRefundRecoveryScheduler {
+
+    private static final int RECOVERY_BATCH_SIZE = 50;
+    private static final int REFUND_LEASE_MINUTES = 5;
+
+    private final CmsOrderDonService cmsOrderDonService;
+
+    @Scheduled(fixedDelay = 60_000L, initialDelay = 60_000L)
+    public void recoverExpiredRefunds() {
+        try {
+            int recovered = cmsOrderDonService.recoverMultiQuantityRefunds(
+                    RECOVERY_BATCH_SIZE, REFUND_LEASE_MINUTES);
+            if (recovered > 0) {
+                log.info("Recovered {} expired multi-quantity refunds", recovered);
+            }
+        } catch (Exception e) {
+            log.error("Failed to recover expired multi-quantity refunds", e);
+        }
+    }
+}

+ 1 - 1
netflix-web/src/main/java/com/cyksj/config/SaTokenConfigure.java

@@ -41,7 +41,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
 						"/manage/account/put/account",
 						"/manage/coupon/get/classificationList",
 						"/manage/coupon/get/couponList",
-						"manage/order/wx/refund/notify",
+						"/manage/order/wx/refund/notify",
 						"/manage/goods/whole/sale/deposit/refund/notify",
 						"/manage/admin/get/yh/user/token",
 						"/manage/user/wx/authLogin",