Ver Fonte

Merge branch 'codex/multi-quantity-orders'

zoujiajian há 1 semana atrás
pai
commit
36b6685f75
61 ficheiros alterados com 3861 adições e 281 exclusões
  1. 34 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. 76 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. 149 0
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketRecordMapper.java
  7. 32 0
      netflix-dao/src/main/java/com/cyksj/model/dto/MultiQuantityTicketCountDto.java
  8. 4 0
      netflix-dao/src/main/java/com/cyksj/model/entity/GoodsDonSku.java
  9. 15 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDetailView.java
  10. 43 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonBusinessEvent.java
  11. 55 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonTicketRecord.java
  12. 4 0
      netflix-dao/src/main/java/com/cyksj/model/manage/request/ReqGoodsSkuInsert.java
  13. 20 1
      netflix-dao/src/main/java/com/cyksj/model/manage/views/OrderDonBackView.java
  14. 22 0
      netflix-dao/src/main/java/com/cyksj/model/response/MultiQuantityRedeliveryResult.java
  15. 4 0
      netflix-dao/src/main/java/com/cyksj/model/views/GoodsDonSkuFrontView.java
  16. 32 0
      netflix-dao/src/main/java/com/cyksj/model/views/MultiQuantityOrderTicketView.java
  17. 10 2
      netflix-dao/src/main/resources/mapper/GroupRelationMapper.xml
  18. 17 1
      netflix-service/pom.xml
  19. 18 1
      netflix-service/src/main/java/com/cyksj/redis/RedisService.java
  20. 15 6
      netflix-service/src/main/java/com/cyksj/server/recharge/impl/GptProxyRechargeServiceImpl.java
  21. 2 0
      netflix-service/src/main/java/com/cyksj/service/coin/UserGalaxyCoinService.java
  22. 68 0
      netflix-service/src/main/java/com/cyksj/service/coin/impl/UserGalaxyCoinServiceImpl.java
  23. 10 0
      netflix-service/src/main/java/com/cyksj/service/exchange/ExchangeCodeService.java
  24. 60 23
      netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java
  25. 2 0
      netflix-service/src/main/java/com/cyksj/service/mange/CmsOrderDonService.java
  26. 13 0
      netflix-service/src/main/java/com/cyksj/service/mange/goods/CmsGoodsDonSpecServiceImpl.java
  27. 15 6
      netflix-service/src/main/java/com/cyksj/service/mange/gpt/impl/GptRechargeServiceImpl.java
  28. 30 6
      netflix-service/src/main/java/com/cyksj/service/mange/group/CmsGroupServiceImpl.java
  29. 709 56
      netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java
  30. 27 0
      netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderService.java
  31. 20 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderDonBusinessEventService.java
  32. 54 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderDonTicketRecordService.java
  33. 1 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderRefundService.java
  34. 668 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java
  35. 6 1
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderCommonServiceImpl.java
  36. 86 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonBusinessEventServiceImpl.java
  37. 500 81
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java
  38. 340 0
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonTicketRecordServiceImpl.java
  39. 59 19
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderRefundServiceImpl.java
  40. 3 1
      netflix-service/src/main/java/com/cyksj/service/order/impl/UserRealOrderBenefitsImpl.java
  41. 72 6
      netflix-service/src/main/java/com/cyksj/service/pay/impl/AliPayCommonServiceImpl.java
  42. 9 0
      netflix-service/src/main/java/com/cyksj/service/paypal/PayPalService.java
  43. 6 0
      netflix-service/src/main/java/com/cyksj/service/paypal/PaypalV2Service.java
  44. 9 0
      netflix-service/src/main/java/com/cyksj/service/relation/GroupRelationClearService.java
  45. 8 0
      netflix-service/src/main/java/com/cyksj/service/relation/GroupsRelationExpiryRecordService.java
  46. 1 0
      netflix-service/src/main/java/com/cyksj/service/relation/impl/CmsGroupsRelationServiceImpl.java
  47. 72 2
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationClearServiceImpl.java
  48. 104 38
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java
  49. 26 2
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationChangeServiceImpl.java
  50. 7 1
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationExpiryRecordServiceImpl.java
  51. 12 2
      netflix-service/src/main/java/com/cyksj/service/scheduler/impl/SchedulerServiceImpl.java
  52. 9 0
      netflix-service/src/main/java/com/cyksj/service/stripe/StripeService.java
  53. 8 1
      netflix-service/src/main/java/com/cyksj/service/user/impl/UserBenefitsServiceImpl.java
  54. 3 0
      netflix-service/src/main/java/com/cyksj/service/wechat/WeChatService.java
  55. 57 1
      netflix-service/src/main/java/com/cyksj/service/wechat/impl/WeChatServiceImpl.java
  56. 31 0
      netflix-service/src/main/java/com/cyksj/task/MultiQuantityRefundRecoveryScheduler.java
  57. 1 1
      netflix-web/src/main/java/com/cyksj/config/SaTokenConfigure.java
  58. 53 1
      netflix-web/src/main/java/com/cyksj/web/controller/manage/CmsOrderController.java
  59. 14 1
      netflix-web/src/main/java/com/cyksj/web/controller/manage/goods/CmsGoodsDonSkuController.java
  60. 91 19
      netflix-web/src/main/java/com/cyksj/web/controller/manage/group/CmsGroupRelationController.java
  61. 3 1
      netflix-web/src/main/java/com/cyksj/web/controller/user/UserController.java

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

+ 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")

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

@@ -0,0 +1,76 @@
+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 ((event_type = 'refund' and status in ('pending', 'failed')) " +
+            "or (event_type != 'refund' and retry_num < 5 " +
+            "and (status = 'pending' or (status = 'failed' and update_time <= date_sub(now(), interval 1 minute)))))")
+    int claim(@Param("orderId") Long orderId, @Param("eventType") String eventType,
+              @Param("workerToken") String workerToken);
+
+    @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);
+
+    @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);
+
+}

+ 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.active_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.active_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);
+}

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

@@ -0,0 +1,149 @@
+package com.cyksj.mapper.order;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.dto.MultiQuantityTicketCountDto;
+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);
+
+    @Select({"<script>",
+            "select order_id as orderId, count(*) as ticketTotalNum,",
+            "sum(case when status = 'success' then 1 else 0 end) as ticketSuccessNum,",
+            "sum(case when status = 'processing' then 1 else 0 end) as ticketProcessingNum,",
+            "sum(case when status in ('pending', 'failed') or (status = 'processing' and processing_time &lt; date_sub(now(), interval 5 minute)) then 1 else 0 end) as ticketRedeliverableNum,",
+            "sum(case when status = 'pending' then 1 else 0 end) as ticketPendingNum,",
+            "sum(case when status = 'failed' then 1 else 0 end) as ticketFailedNum,",
+            "sum(case when status = 'refunding' then 1 else 0 end) as ticketRefundingNum,",
+            "sum(case when status = 'refunded' then 1 else 0 end) as ticketRefundedNum,",
+            "sum(case when status = 'cleared' then 1 else 0 end) as ticketClearedNum",
+            "from order_don_ticket_record where order_id in",
+            "<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
+            "#{orderId}",
+            "</foreach>",
+            "group by order_id",
+            "</script>"})
+    List<MultiQuantityTicketCountDto> selectTicketCounts(@Param("orderIds") List<Long> orderIds);
+
+    @Update("update order_don_ticket_record set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() where id = #{id} and retry_num < #{maxRetries} and (status = 'pending' or (status = 'failed' and update_time <= date_sub(now(), interval 1 minute)))")
+    int claim(@Param("id") Long id, @Param("workerToken") String workerToken,
+              @Param("maxRetries") int maxRetries);
+
+    @Update("update order_don_ticket_record r inner join order_don o on o.id = r.order_id set r.status = 'processing', r.worker_token = #{workerToken}, r.processing_time = now(), r.retry_num = r.retry_num + 1, r.error_msg = null, r.update_time = now() where r.id = #{id} and r.order_id = #{orderId} and 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'))")
+    int claimManually(@Param("orderId") Long orderId,
+                      @Param("id") Long id,
+                      @Param("workerToken") String workerToken);
+
+    @Update("update order_don_ticket_record r set r.status = 'failed', r.worker_token = null, r.processing_time = null, r.error_msg = 'delivery lease expired; waiting for manual redelivery', r.update_time = now() where r.order_id = #{orderId} and r.status = 'processing' and r.processing_time < date_sub(now(), interval #{minutes} minute) 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'))")
+    int recoverExpiredProcessingForOrder(@Param("orderId") Long orderId,
+                                         @Param("minutes") int minutes);
+
+    @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);
+
+    @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 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.active_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.active_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} " +
+            "and status in ('success', 'refunding')")
+    OrderDonTicketRecord selectActiveByRelationId(@Param("relationId") Long relationId);
+
+    @Select("select * from order_don_ticket_record where active_relation_id = #{relationId} " +
+            "and status in ('success', 'refunding') 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);
+}

+ 32 - 0
netflix-dao/src/main/java/com/cyksj/model/dto/MultiQuantityTicketCountDto.java

@@ -0,0 +1,32 @@
+package com.cyksj.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Aggregated ticket-slot counts for one multi-quantity order.
+ */
+@Getter
+@Setter
+public class MultiQuantityTicketCountDto {
+
+    private Long orderId;
+
+    private Integer ticketTotalNum;
+
+    private Integer ticketSuccessNum;
+
+    private Integer ticketProcessingNum;
+
+    private Integer ticketRedeliverableNum;
+
+    private Integer ticketPendingNum;
+
+    private Integer ticketFailedNum;
+
+    private Integer ticketRefundingNum;
+
+    private Integer ticketRefundedNum;
+
+    private Integer ticketClearedNum;
+}

+ 4 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/GoodsDonSku.java

@@ -102,6 +102,10 @@ public class GoodsDonSku extends BaseEntity {
      */
     private Integer num;
 
+    private Boolean isMultiQuantity;
+
+    private Integer multiQuantityMaxNum;
+
     /**
      * 价格
      */

+ 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
+    }
+}

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

@@ -0,0 +1,55 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
+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;
+
+    /**
+     * Database-generated active relation used by the unique active-ticket constraint.
+     * It must never be inserted or updated by the application.
+     */
+    @TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
+    private Long activeRelationId;
+
+    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
+    }
+}

+ 4 - 0
netflix-dao/src/main/java/com/cyksj/model/manage/request/ReqGoodsSkuInsert.java

@@ -78,6 +78,10 @@ public class ReqGoodsSkuInsert {
          */
         private Integer num;
 
+        private Boolean isMultiQuantity;
+
+        private Integer multiQuantityMaxNum;
+
         /**
          * 有效天数
          */

+ 20 - 1
netflix-dao/src/main/java/com/cyksj/model/manage/views/OrderDonBackView.java

@@ -192,6 +192,25 @@ public class OrderDonBackView {
 	@DbField("o.num")
 	private Integer num;
 
+	/**
+	 * 是否为按票槽发放的多件虚拟商品订单。
+	 * 非 order_don 表字段,由管理端列表接口按票槽记录计算。
+	 */
+	@DbIgnore
+	private Boolean multiQuantityOrder;
+
+	/** 当前已成功发放的车票数量。 */
+	@DbIgnore
+	private Integer ticketSuccessNum;
+
+	/** 当前尚未成功发放的车票数量。 */
+	@DbIgnore
+	private Integer ticketUndeliveredNum;
+
+	/** 是否允许客服手工补发缺失车票。 */
+	@DbIgnore
+	private Boolean canRedeliver;
+
 	@DbField("o.created_time")
 	private Date createdTime;
 
@@ -353,4 +372,4 @@ public class OrderDonBackView {
 	 */
 	@DbIgnore
 	private Boolean isTp;
-}
+}

+ 22 - 0
netflix-dao/src/main/java/com/cyksj/model/response/MultiQuantityRedeliveryResult.java

@@ -0,0 +1,22 @@
+package com.cyksj.model.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * Result of a customer-service initiated ticket redelivery attempt.
+ */
+@Getter
+@AllArgsConstructor
+public class MultiQuantityRedeliveryResult {
+
+    private final Integer orderedCount;
+
+    private final Integer deliveredBefore;
+
+    private final Integer deliveredNow;
+
+    private final Integer deliveredTotal;
+
+    private final Integer missingCount;
+}

+ 4 - 0
netflix-dao/src/main/java/com/cyksj/model/views/GoodsDonSkuFrontView.java

@@ -87,6 +87,10 @@ public class GoodsDonSkuFrontView {
 	 */
 	private Boolean status;
 
+	private Boolean isMultiQuantity;
+
+	private Integer multiQuantityMaxNum;
+
 	/**
 	 * 价格
 	 */

+ 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;
+}

+ 10 - 2
netflix-dao/src/main/resources/mapper/GroupRelationMapper.xml

@@ -156,6 +156,10 @@
         where groups_id = #{groupsId}
           and user_id = 0
           and status = 'none'
+          and not exists (
+              select 1 from order_don_ticket_record ticket
+              where ticket.active_relation_id = groups_relation.id
+          )
         order by rand() limit 1
     </select>
 
@@ -177,6 +181,10 @@
           and user_id = 0
           and num > #{relationNum}
           and status = 'outside'
+          and not exists (
+              select 1 from order_don_ticket_record ticket
+              where ticket.active_relation_id = groups_relation.id
+          )
         order by rand() limit 1
     </select>
 
@@ -391,7 +399,7 @@
         select g.id as goods_id,g.title,g.logo from groups_relation gr inner join groups_trips gt on gt.id = gr.groups_id
         inner join goods_don_sku sku on sku.id = gt.sku_id inner join goods_don g on g.id = sku.goods_id
         where gr.status in ('validity', 'outside')
-        and gr.cmt != 2
+        and (gr.cmt is null or gr.cmt != 2)
         and (gr.expiry_time is null or gr.expiry_time > #{now} or (sku.is_mirror and gr.expiry_time > DATE_ADD(#{now},interval -7 day)))
         and gr.user_id != 0
         and gr.user_id in
@@ -464,7 +472,7 @@
             #{item}
         </foreach>
         and gr.status in ('validity','outside')
-        and gr.cmt != 2
+        and (gr.cmt is null or gr.cmt != 2)
         and gr.is_vip = 0
         and (gr.expiry_time is null or gr.expiry_time > now() or (gdk.is_mirror and gr.expiry_time >
         DATE_ADD(now(),interval -7 day)))

+ 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 {
         }
 
     }
-}
+}

+ 15 - 6
netflix-service/src/main/java/com/cyksj/server/recharge/impl/GptProxyRechargeServiceImpl.java

@@ -23,6 +23,7 @@ import com.cyksj.server.recharge.dto.TaskResult;
 import com.cyksj.server.recharge.dto.TaskSubmitResult;
 import com.cyksj.server.recharge.dto.TokenParseResult;
 import com.cyksj.service.chatgpt.GptUserRechargeRecordService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.recharge.*;
 import com.cyksj.service.user.UserBindRelationService;
 import lombok.RequiredArgsConstructor;
@@ -55,6 +56,8 @@ public class GptProxyRechargeServiceImpl implements GptProxyRechargeService {
 
     private final OrderDonMapper orderDonMapper;
 
+    private final MultiQuantityOrderService multiQuantityOrderService;
+
 	private final GptUserRechargeRecordService gptUserRechargeRecordService;
 
 	private final RedisService redisService;
@@ -347,12 +350,18 @@ public class GptProxyRechargeServiceImpl implements GptProxyRechargeService {
 			if (updateRemainNum == 0) {
 				throw BusinessRuntimeException.getInstance("代充异常,请重试");
 			}
-			OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-					.in(OrderDon::getUserId, userIdList)
-					.eq(OrderDon::getRelationId, relationId)
-					.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-					.orderByDesc(OrderDon::getId)
-					.last("limit 1"));
+			OrderDon orderDon = multiQuantityOrderService.findOrderByRelationId(relationId);
+			if (orderDon != null && !userIdList.contains(orderDon.getUserId())) {
+				orderDon = null;
+			}
+			if (orderDon == null) {
+				orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+						.in(OrderDon::getUserId, userIdList)
+						.eq(OrderDon::getRelationId, relationId)
+						.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+						.orderByDesc(OrderDon::getId)
+						.last("limit 1"));
+			}
 			if (orderDon == null) {
 				throw BusinessRuntimeException.getInstance("代充订单不存在或已退款");
 			}

+ 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))

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

@@ -3,6 +3,7 @@ package com.cyksj.service.exchange;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.cyksj.dto.Result;
 import com.cyksj.model.entity.ExchangeCode;
+import com.cyksj.model.entity.GoodsDon;
 import com.cyksj.model.entity.GoodsDonSku;
 import com.cyksj.model.entity.GroupsRelation;
 import com.cyksj.model.entity.OrderDon;
@@ -43,4 +44,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(GoodsDon goods, GoodsDonSku sku, Long userId, Long yhsId);
+
+	/** Releases only the seat lock owned by the specified user. */
+	void releaseRelationLock(Long relationId, Long userId);
 }

+ 60 - 23
netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java

@@ -496,20 +496,49 @@ 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);
+		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(GoodsDon goods, GoodsDonSku sku, Long userId, Long yhsId) {
+		GroupsRelation relation = allocateRelation(sku, userId, yhsId);
+		// Multi-quantity fulfillment bypasses the legacy order-creation method which
+		// normally initializes recharge tickets. Initialize every allocated slot here.
+		setRelationInfoIfRecharge(relation, goods, sku);
 		return relation;
 	}
 
-	public GroupsRelation getFinalRelation(GoodsDonSku sku, Long userId, Integer retryNum, Long yhsId, List<Long> errorsRelationIds) {
+	@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);
+		}
+	}
+
+	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())) {
@@ -533,10 +562,26 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 				relation.setStatus(GroupsRelation.Status.validity);
 			}
 			relation.setYhsId(yhsId);
-			if (yhsId != null && yhsId != 0) {
-				relation.setCmt(1);
+			// Persist the shop type for every newly claimed relation.  New seats are
+			// created with a nullable cmt, while the user-ticket query intentionally
+			// excludes mirror-shop seats (cmt = 2).  Leaving a platform seat as NULL
+			// therefore makes it disappear from the user's ticket list because SQL
+			// `cmt <> 2` does not match NULL.  A normal platform seat is explicitly 0;
+			// a regular shop seat remains 1 and mirror-shop orders can still promote it
+			// to 2 in OrderDonServiceImpl after the order is created.
+			relation.setCmt(yhsId != null && yhsId > 0 ? 1 : 0);
+			int claimed;
+			if (relation.getStatus() == GroupsRelation.Status.outside) {
+				claimed = groupsRelationMapper.claimOutsideRelation(relation.getId(), userId,
+						relation.getStartTime(), relation.getExpiryTime(), relation.getYhsId(), relation.getCmt());
+			} else {
+				claimed = groupsRelationMapper.claimAvailableRelation(relation.getId(), userId,
+						relation.getStatus(), relation.getStartTime(), relation.getExpiryTime(),
+						relation.getYhsId(), relation.getCmt());
+			}
+			if (claimed != 1) {
+				throw new BusinessRuntimeException("seat was concurrently allocated");
 			}
-			groupsRelationMapper.updateById(relation);
 
 			Long skuId = sku.getId();
 			Long groupsId = relation.getGroupsId();
@@ -548,30 +593,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 +618,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);
 	/**
 	 * 套餐订单统一退款
 	 */

+ 13 - 0
netflix-service/src/main/java/com/cyksj/service/mange/goods/CmsGoodsDonSpecServiceImpl.java

@@ -185,6 +185,7 @@ public class CmsGoodsDonSpecServiceImpl extends ServiceImpl<GoodsDonSpecMapper,
 		Set<String> set = null;
 
 		for (ReqGoodsSkuInsert.Sku sku : insert.getSkus()) {
+			normalizeMultiQuantityConfig(sku);
 			// 跳过空值的sku
 			// 解析sku的规格属性
 			final String specIds = sku.getSpecIds();
@@ -295,6 +296,18 @@ public class CmsGoodsDonSpecServiceImpl extends ServiceImpl<GoodsDonSpecMapper,
 		}
 	}
 
+	private void normalizeMultiQuantityConfig(ReqGoodsSkuInsert.Sku sku) {
+		if (!Boolean.TRUE.equals(sku.getIsMultiQuantity())) {
+			sku.setIsMultiQuantity(false);
+			sku.setMultiQuantityMaxNum(1);
+			return;
+		}
+		Integer maxNum = sku.getMultiQuantityMaxNum();
+		if (maxNum == null || maxNum < 2 || maxNum > 100) {
+			throw BusinessRuntimeException.getInstance("多数量单次购买上限必须在2到100之间");
+		}
+	}
+
 	@Override
 	public void combinationSku(Long goodsId, List<CombinationSkuReq> combinations) throws Exception {
 		GoodsDon goodsDon = goodsDonMapper.selectById(goodsId);

+ 15 - 6
netflix-service/src/main/java/com/cyksj/service/mange/gpt/impl/GptRechargeServiceImpl.java

@@ -29,6 +29,7 @@ import com.cyksj.server.recharge.dto.TaskSubmitResult;
 import com.cyksj.server.recharge.dto.TokenParseResult;
 import com.cyksj.service.chatgpt.GptUserRechargeRecordService;
 import com.cyksj.service.mange.gpt.GptRechargeService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.recharge.*;
 import com.cyksj.service.user.UserBindRelationService;
 import lombok.RequiredArgsConstructor;
@@ -64,6 +65,8 @@ public class GptRechargeServiceImpl implements GptRechargeService {
 
 	private final OrderDonMapper orderDonMapper;
 
+	private final MultiQuantityOrderService multiQuantityOrderService;
+
 	private final GptUserRechargeRecordService gptUserRechargeRecordService;
 
 	private final TransactionTemplate transactionTemplate;
@@ -190,12 +193,18 @@ public class GptRechargeServiceImpl implements GptRechargeService {
 			if (updateRemainNum == 0) {
 				throw BusinessRuntimeException.getInstance("剩余可代充次数为0");
 			}
-			OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-					.eq(OrderDon::getUserId, relation.getUserId())
-					.eq(OrderDon::getRelationId, relationId)
-					.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-					.orderByDesc(OrderDon::getId)
-					.last("limit 1"));
+			OrderDon orderDon = multiQuantityOrderService.findOrderByRelationId(relationId);
+			if (orderDon != null && !relation.getUserId().equals(orderDon.getUserId())) {
+				orderDon = null;
+			}
+			if (orderDon == null) {
+				orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+						.eq(OrderDon::getUserId, relation.getUserId())
+						.eq(OrderDon::getRelationId, relationId)
+						.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+						.orderByDesc(OrderDon::getId)
+						.last("limit 1"));
+			}
 			if (orderDon == null) {
 				throw BusinessRuntimeException.getInstance("代充订单不存在或已退款");
 			}

+ 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());

Diff do ficheiro suprimidas por serem muito extensas
+ 709 - 56
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java


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

@@ -0,0 +1,27 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.entity.GoodsDon;
+import com.cyksj.model.entity.GoodsDonSku;
+import com.cyksj.model.entity.OrderDon;
+import com.cyksj.model.response.MultiQuantityRedeliveryResult;
+
+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);
+
+    MultiQuantityRedeliveryResult redeliverManually(Long orderId);
+
+    boolean completeIfAllTicketsAvailable(Long orderId);
+
+    OrderDon findOrderByRelationId(Long relationId);
+}

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

@@ -0,0 +1,20 @@
+package com.cyksj.service.order;
+
+import com.cyksj.model.entity.OrderDonBusinessEvent;
+
+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);
+
+}

+ 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 claimManually(Long orderId, Long recordId, String workerToken);
+
+    int recoverExpiredProcessingForOrder(Long orderId, int leaseMinutes);
+
+    boolean lockClaim(Long recordId, String workerToken);
+
+    boolean markSuccess(Long recordId, Long relationId, String workerToken);
+
+    boolean markFailed(Long recordId, String workerToken, String errorMsg);
+
+    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;
 
 	/**

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

@@ -0,0 +1,668 @@
+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.GroupsRelationMapper;
+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.model.response.MultiQuantityRedeliveryResult;
+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.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService {
+
+    private static final int DELIVERY_MAX_RETRIES = 5;
+    private static final int MANUAL_DELIVERY_LEASE_MINUTES = 5;
+
+    @Override
+    public OrderDon findOrderByRelationId(Long relationId) {
+        if (relationId == null || relationId <= 0) {
+            return null;
+        }
+        OrderDonTicketRecord record = ticketRecordService.getActiveByRelationId(relationId);
+        if (record == null || record.getOrderId() == null) {
+            return null;
+        }
+        OrderDon order = orderDonMapper.selectById(record.getOrderId());
+        return order != null && order.getNum() != null && order.getNum() > 1 ? order : null;
+    }
+
+    private final OrderDonMapper orderDonMapper;
+    private final GoodsDonMapper goodsDonMapper;
+    private final GoodsDonSkuMapper goodsDonSkuMapper;
+    private final GroupsRelationMapper groupsRelationMapper;
+    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 {
+                deliver(order.getId());
+            } catch (Exception e) {
+                // Payment is already committed. Durable ticket slots allow safe manual redelivery.
+                log.error("Immediate multi-quantity ticket delivery failed, orderId:{}", order.getId(), e);
+            }
+            try {
+                runOrderEvents(order.getId());
+            } catch (Exception e) {
+                // Distribution and other order-level side effects never gate ticket delivery.
+                log.error("Immediate multi-quantity order events failed, orderId:{}", order.getId(), e);
+            }
+        });
+    }
+
+    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(),
+                        record.getRelationId(), 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
+    public MultiQuantityRedeliveryResult redeliverManually(Long orderId) {
+        if (orderId == null || orderId <= 0) {
+            throw BusinessRuntimeException.getInstance("订单ID不能为空");
+        }
+        OrderDon order = requiresNewResult(() -> lockOrderForManualRedelivery(orderId));
+
+        List<OrderDonTicketRecord> beforeRecords = ticketRecordService.listByOrderId(orderId);
+        validateManualRedeliveryRecords(order, beforeRecords);
+        int deliveredBefore = countSuccessfulTickets(beforeRecords);
+        if (deliveredBefore >= order.getNum()) {
+            return new MultiQuantityRedeliveryResult(order.getNum(), deliveredBefore,
+                    0, deliveredBefore, 0);
+        }
+
+        // A restarted service may leave an owned slot behind. Only an expired lease is reclaimed.
+        ticketRecordService.recoverExpiredProcessingForOrder(
+                orderId, MANUAL_DELIVERY_LEASE_MINUTES);
+
+        GoodsDon goods = goodsDonMapper.selectById(order.getGoodsId());
+        GoodsDonSku sku = goodsDonSkuMapper.selectById(order.getSkuId());
+        if (goods == null || sku == null) {
+            throw BusinessRuntimeException.getInstance("商品或规格不存在,无法补发车票");
+        }
+
+        int deliveredNow = 0;
+        // This is a snapshot: one customer-service click attempts every eligible slot at most once.
+        List<OrderDonTicketRecord> candidates = ticketRecordService.listByOrderId(orderId);
+        validateManualRedeliveryRecords(order, candidates);
+        boolean hasProcessingSlot = candidates.stream().anyMatch(record ->
+                record.getStatus() == OrderDonTicketRecord.Status.processing);
+        boolean hasEligibleSlot = candidates.stream().anyMatch(record ->
+                record.getStatus() == OrderDonTicketRecord.Status.pending
+                        || record.getStatus() == OrderDonTicketRecord.Status.failed);
+        if (!hasEligibleSlot && hasProcessingSlot) {
+            throw BusinessRuntimeException.getInstance("车票正在发放中,请五分钟后再补发");
+        }
+        for (OrderDonTicketRecord record : candidates) {
+            if (record.getStatus() != OrderDonTicketRecord.Status.pending
+                    && record.getStatus() != OrderDonTicketRecord.Status.failed) {
+                continue;
+            }
+            String token = UUID.randomUUID().toString();
+            if (!requiresNewResult(() -> claimTicketManually(orderId, record.getId(), token))) {
+                continue;
+            }
+            try {
+                requiresNew(() -> deliverClaimedSlot(order, goods, sku, record.getId(),
+                        record.getRelationId(), token));
+                deliveredNow++;
+            } catch (Exception e) {
+                requiresNew(() -> ticketRecordService.markFailed(
+                        record.getId(), token, StringUtil.getErrorText(e)));
+                log.error("Manual multi-quantity ticket redelivery failed, orderId:{}, slotId:{}",
+                        orderId, record.getId(), e);
+            }
+        }
+        requiresNewResult(() -> completeIfAllTicketsAvailable(orderId));
+        int deliveredTotal = ticketRecordService.countSuccess(orderId);
+        return new MultiQuantityRedeliveryResult(order.getNum(), deliveredBefore,
+                deliveredNow, deliveredTotal, Math.max(0, order.getNum() - deliveredTotal));
+    }
+
+    private OrderDon lockOrderForManualRedelivery(Long orderId) {
+        OrderDon order = orderDonMapper.selectByIdForUpdate(orderId);
+        if (order == null) {
+            throw BusinessRuntimeException.getInstance("订单不存在");
+        }
+        if (order.getNum() == null || order.getNum() <= 1
+                || ticketRecordService.listByOrderId(orderId).size() != order.getNum()) {
+            throw BusinessRuntimeException.getInstance("仅支持多件商品订单补发车票");
+        }
+        if (order.getStatus() != OrderDon.Status.hasPayment
+                && order.getStatus() != OrderDon.Status.complete) {
+            throw BusinessRuntimeException.getInstance("当前订单状态不允许补发车票");
+        }
+        OrderDonBusinessEvent refundEvent = businessEventService.get(
+                orderId, OrderDonBusinessEvent.Type.refund);
+        if (refundEvent != null && (refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing
+                || refundEvent.getStatus() == OrderDonBusinessEvent.Status.success)) {
+            throw BusinessRuntimeException.getInstance("订单正在退款或已退款,禁止补发车票");
+        }
+        return order;
+    }
+
+    private void validateManualRedeliveryRecords(OrderDon order,
+                                                 List<OrderDonTicketRecord> records) {
+        if (records.size() != order.getNum()) {
+            throw BusinessRuntimeException.getInstance("订单车票槽数量异常,禁止补发");
+        }
+        boolean refundOrCleared = records.stream().anyMatch(record ->
+                record.getStatus() == OrderDonTicketRecord.Status.refunding
+                        || record.getStatus() == OrderDonTicketRecord.Status.refunded
+                        || record.getStatus() == OrderDonTicketRecord.Status.cleared);
+        if (refundOrCleared) {
+            throw BusinessRuntimeException.getInstance("订单正在退款、已退款或车票已清除,禁止补发");
+        }
+    }
+
+    private int countSuccessfulTickets(List<OrderDonTicketRecord> records) {
+        return (int) records.stream()
+                .filter(record -> record.getStatus() == OrderDonTicketRecord.Status.success)
+                .count();
+    }
+
+    private boolean claimTicketManually(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)) {
+            throw BusinessRuntimeException.getInstance("订单正在退款或已退款,补发已停止");
+        }
+        return ticketRecordService.claimManually(orderId, recordId, workerToken);
+    }
+
+    @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, Long previousRelationId, String workerToken) {
+        if (!ticketRecordService.lockClaim(recordId, workerToken)) {
+            throw new IllegalStateException("ticket slot claim expired");
+        }
+
+        /*
+         * A failed slot may still retain the relation_id of its previous ticket.
+         * Reallocating in that case creates an untracked/orphan seat. Reuse the
+         * old relation while it is still owned by this user and has the same SKU.
+         * A relation already reused by somebody else is never touched; allocation
+         * then follows the normal path below.
+         */
+        GroupsRelation previous = findReusablePreviousRelation(order, sku, recordId,
+                previousRelationId);
+        if (previous != null) {
+            if (!ticketRecordService.markSuccess(recordId, previous.getId(), workerToken)) {
+                throw new IllegalStateException("ticket slot claim expired");
+            }
+            try {
+                expiryRecordService.syncGroupsRelationExpiryRecord(order, previous.getId(), goods, sku);
+            } catch (DuplicateKeyException ignored) {
+                // Ancillary record; durable slot ownership is already committed.
+            }
+            return;
+        }
+
+        // One slot attempt runs in one transaction; any allocation retry must start from a clean state.
+		Long yhsId = order.getYhsId() == null ? 0L : order.getYhsId();
+		GroupsRelation relation = exchangeCodeService.getRelationNoOrderOnce(goods, sku, order.getUserId(), yhsId);
+		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");
+		}
+        try {
+			expiryRecordService.syncGroupsRelationExpiryRecord(order, relation.getId(), goods, sku);
+        } catch (DuplicateKeyException ignored) {
+            // The expiry record is ancillary; slot ownership is already durable.
+		}
+	}
+
+    /**
+     * Return a previous relation only if it is still a usable ticket for this
+     * order user and the same SKU. A same-user stale relation is rejected rather
+     * than silently allocating a second seat; a relation owned by another user
+     * is treated as already reused and is left untouched.
+     */
+    private GroupsRelation findReusablePreviousRelation(OrderDon order, GoodsDonSku sku,
+                                                        Long recordId, Long previousRelationId) {
+        if (previousRelationId == null || previousRelationId <= 0) {
+            return null;
+        }
+        // Lock the relation while deciding whether it can be reused. Clear/change
+        // operations acquire the ticket-slot lock first, so this preserves the
+        // same lock order and prevents a stale ownership decision.
+        GroupsRelation previous = groupsRelationMapper.selectByIdForUpdate(previousRelationId);
+        if (previous == null || !Objects.equals(previous.getUserId(), order.getUserId())) {
+            return null;
+        }
+        Long previousSkuId = groupsRelationMapper.getGroupTripsSkuIdByRelationId(previousRelationId);
+        if (!Objects.equals(previousSkuId, sku.getId())) {
+            throw new IllegalStateException("previous ticket SKU does not match; clear it before redelivery");
+        }
+        OrderDonTicketRecord active = ticketRecordService.getActiveByRelationId(previousRelationId);
+        if (active != null && !Objects.equals(active.getId(), recordId)) {
+            throw new IllegalStateException("previous ticket is already bound to another order slot; manual reconciliation required");
+        }
+        if (!isUsablePreviousRelation(previous)) {
+            throw new IllegalStateException("previous ticket is still bound but no longer usable; clear it before redelivery");
+        }
+        return previous;
+    }
+
+    private boolean isUsablePreviousRelation(GroupsRelation relation) {
+        GroupsRelation.Status status = relation.getStatus();
+        if (status != GroupsRelation.Status.validity && status != GroupsRelation.Status.outside) {
+            return false;
+        }
+        Date expiryTime = relation.getExpiryTime();
+        return expiryTime == null || expiryTime.after(new Date());
+    }
+
+	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;
+        try {
+            rate = com.cyksj.common.util.Jsons.parseObject(
+                    config.getSysValue(), GalaxyCoinConsumeRateDto.class);
+        } catch (Exception e) {
+            throw new IllegalStateException("invalid galaxy coin cashback configuration", e);
+        }
+        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 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);
 			}

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

@@ -0,0 +1,86 @@
+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;
+    }
+
+    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));
+    }
+}

+ 500 - 81
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,10 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 
 	private final OrderDonMapper orderDonMapper;
 
+	private final OrderDonTicketQueryMapper orderDonTicketQueryMapper;
+
+	private final MultiQuantityOrderService multiQuantityOrderService;
+
 	private final EnvCommonService envCommonService;
 
 	private static final GlobalThreadPoolTaskExecutor TASK_POOL = GlobalThreadPoolTaskExecutor.getInstance();
@@ -425,6 +433,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 +445,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 +467,25 @@ 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);
+		boolean multiQuantity = quantity > 1 && sku != null && Boolean.TRUE.equals(sku.getIsMultiQuantity());
+		if (quantity > 1 && !multiQuantity) {
+			throw BusinessRuntimeException.getInstance("该规格不支持一次购买多个");
+		}
+		if (multiQuantity) {
+			validateMultiQuantitySubmit(payRequest, goodsDon, sku, isGiftCardPay.get(), yhsId);
+			validateMultiQuantitySkuLimit(sku, quantity);
+			if (!isSkuEnabled(sku)) {
+				throw BusinessRuntimeException.getInstance("SKU is disabled");
+			}
+		}
 		//是否是补差价订单
 		if (goodsDon.getType() != null && goodsDon.getType() == 5) {
 			return generateDifferOrderDon(payRequest, goodsDon, sku);
@@ -535,7 +564,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 +584,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();
@@ -586,7 +617,9 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		Boolean isSpecificPrice = Optional.ofNullable(payRequest.getIsSpecificPrice()).orElse(false);
 		String specificGoodsIds = null;
 		BigDecimal specificPrice = BigDecimal.ZERO;
-		if (isSpecificPrice) {
+		// 多件订单已在 validateMultiQuantitySubmit 中禁止专属价;这里再次门禁,
+		// 避免恶意请求在权威金额计算前读取客户端 donMoney。
+		if (!multiQuantity && isSpecificPrice) {
 			specificGoodsIds = sku.getSpecificGoodsIds();
 			if (StrUtil.isEmpty(specificGoodsIds)) {
 				throw BusinessRuntimeException.getInstance("系统异常,请刷新页面重试");
@@ -609,7 +642,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 			specificPrice = sku.getSpecificPrice();
 		}
 
-		if (sku != null && payRequest.getDonMoney().compareTo(sku.getPrice()) < 0 && !isSpecificPrice) {
+		if (!multiQuantity && sku != null && payRequest.getDonMoney().compareTo(sku.getPrice()) < 0 && !isSpecificPrice) {
 			log.info("规格:{}支付金额:{}异常,调整用户userId:{}支付金额为:{}", sku.getSpecVal(), payRequest.getDonMoney(), user.getId(), sku.getPrice());
 			payRequest.setDonMoney(sku.getPrice());
 		}
@@ -631,7 +664,7 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 //		}
 
 		//租赁商品
-		if (goodsDon.getType() == 4) {
+		if (!multiQuantity && goodsDon.getType() == 4) {
 			if (userIdList == null) {
 				throw BusinessRuntimeException.getGatewayApiCode(GatewayApiCode.CMS_TOKEN_VALID);
 			}
@@ -649,9 +682,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
+				? calculateMultiQuantityTotal(sku, quantity)
+				: 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 +727,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 +754,17 @@ 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);
+				if (orderDon.getMoney() == null || orderDon.getMoney().signum() < 0) {
+					throw BusinessRuntimeException.getInstance("订单应付金额不能为负数");
+				}
+			} else {
+				deductOrderMoney(orderDon, couponUser, payRequest.getBalance(), user, sku.getPrice(),
+						goodsDon, payRequest.getGcCash(), payRequest.getGcpIds(),
+						payRequest.getIsAcReduce(), subsidyId, sku, payRequest.getGalaxyCoin(), null);
+			}
 		}
 
 		if (isSpecificPrice && (orderDon.getMoney().compareTo(BigDecimal.ZERO) == 0 || orderDon.getMoney().compareTo(specificPrice) != 0)) {
@@ -734,6 +783,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 +880,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 +1011,85 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		return false;
 	}
 
+	static void validateMultiQuantitySkuLimit(GoodsDonSku sku, int quantity) {
+		Integer maxNum = sku.getMultiQuantityMaxNum();
+		if (maxNum == null || maxNum < 2 || maxNum > 100) {
+			throw BusinessRuntimeException.getInstance("该规格多数量配置异常");
+		}
+		if (quantity > maxNum) {
+			throw BusinessRuntimeException.getInstance("该规格单次最多购买{0}个", maxNum);
+		}
+	}
+
+	private void validateMultiQuantitySubmit(OrderPayRequest request, GoodsDon goods,
+			GoodsDonSku sku, boolean giftCardOrder, Long ignoredShopId) {
+		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("多数量下单必须使用已登录账号");
+		}
+		boolean unsupportedSpecialType = goods.getSpecialType() != null
+				&& goods.getSpecialType() != GoodsDon.SpecialType.recharge;
+		if (giftCardOrder || unsupportedSpecialType || 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) {
+			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 BigDecimal calculateMultiQuantityTotal(GoodsDonSku sku, int quantity) {
+		if (sku == null || sku.getPrice() == null || quantity <= 1) {
+			throw new IllegalArgumentException("invalid multi-quantity amount input");
+		}
+		return sku.getPrice().multiply(BigDecimal.valueOf(quantity));
+	}
+
+	static OrderDon.Type resolveSubmitPaymentType(boolean multiQuantity, String requestedType) {
+		if (multiQuantity && StrUtil.isBlank(requestedType)) {
+			return null;
+		}
+		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 +1102,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 +1131,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 +1149,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 +1175,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 +1211,32 @@ 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);
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(order.getId());
+		// 历史单件订单可能在回调前未持久化 shopId,保持旧商户选择逻辑;
+		// 多件订单必须匹配预支付阶段锁定的商户,防止跨商户回调。
+		if (callbackShop == null || (multiQuantity && !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 +1246,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 +1298,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 +1327,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 +1391,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 +1675,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 +1729,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 +1757,41 @@ 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);
+		boolean multiQuantity = multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId());
+		// 普通历史订单沿用旧逻辑,仅按回调商户验签;多件订单严格绑定商户。
+		if (callbackShop == null || (multiQuantity && !isCallbackShopMatched(orderDon.getShopId(), appId))) {
+			throw BusinessRuntimeException.getInstance("支付宝回调商户不匹配");
+		}
+		boolean signVerified = AlipaySignature.rsaCheckV1(params, callbackShop.getPublicKey(), BaseZfbConfig.charset, BaseZfbConfig.signType);
 		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;
 		}
@@ -1543,16 +1815,18 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		orderDon.setType(OrderDon.Type.ALI_PAY);
 		orderDon.setBuyerId(buyerId);
 		//该支付账号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 +1849,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 +1881,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 +1912,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 +2028,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 +2544,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 +2569,6 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 //								log.error("给用户{}发放claude code积分卡失败:{}", userDistributeShared.getSharedId(), e.getMessage());
 //							}
 //						}
-					} catch (DuplicateKeyException e) {
-
-					}
 				}
 			}
 		}
@@ -2625,44 +2904,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) {
@@ -2779,7 +3182,14 @@ public class OrderDonServiceImpl extends ServiceImpl<OrderDonMapper, OrderDon> i
 		relation.setUserId(userId);
 		//重新置为锁定
 		relation.setStatus(GroupsRelation.Status.locking);
-		relation.setYhsId(yhsId);
+		Long relationYhsId = yhsId == null ? 0L : yhsId;
+		relation.setYhsId(relationYhsId);
+		// Persist a type only for legacy seats whose cmt is NULL. Preserve cmt = 2
+		// on an existing mirror relation until the mirror-shop validation below
+		// applies its explicit shop ownership.
+		if (relation.getCmt() == null) {
+			relation.setCmt(relationYhsId > 0 ? 1 : 0);
+		}
 		groupsRelationMapper.updateById(relation);
 
 		Long skuId = sku.getId();
@@ -3058,6 +3468,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 +3583,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) {
@@ -3938,6 +4353,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;

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

@@ -0,0 +1,340 @@
+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 claimManually(Long orderId, Long recordId, String workerToken) {
+        requireOrderId(orderId);
+        requireRecordId(recordId);
+        requireWorkerToken(workerToken);
+        return ticketRecordMapper.claimManually(orderId, recordId, workerToken) == 1;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Throwable.class)
+    public int recoverExpiredProcessingForOrder(Long orderId, int leaseMinutes) {
+        requireOrderId(orderId);
+        return ticketRecordMapper.recoverExpiredProcessingForOrder(orderId, boundedLease(leaseMinutes));
+    }
+
+    @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> 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);
+        if (businessEventMapper.transferExpiredRefundWorker(orderId, event.getWorkerToken(),
+                newToken, boundedLeaseMinutes) != 1) {
+            return null;
+        }
+        int expected = ticketRecordMapper.countRefundingByWorker(orderId, event.getWorkerToken());
+        int total = ticketRecordMapper.countByOrderAndStatus(
+                orderId, OrderDonTicketRecord.Status.refunding.name());
+        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");
+            }
+        }
+        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;

+ 3 - 1
netflix-service/src/main/java/com/cyksj/service/order/impl/UserRealOrderBenefitsImpl.java

@@ -219,7 +219,9 @@ public class UserRealOrderBenefitsImpl implements UserRealOrderBenefitsService {
 			}
 		}
 		if (cmt == null || cmt != 2) {
-			builder.field(RenewalView::getCmt, 2).op(Operator.NotEqual);
+			// SQL NULL does not satisfy `<> 2`; keep legacy platform tickets visible
+			// while still excluding mirror-shop tickets (cmt = 2).
+			builder.field(RenewalView::getCmt).sql("$1 is null or $1 <> ?", 2);
 		}
 		ticketViews.forEach(ticket -> {
 			List<GroupsRelationView> relationViewList = beanSearcher.searchAll(GroupsRelationView.class, builder

+ 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()) {

+ 9 - 0
netflix-service/src/main/java/com/cyksj/service/relation/GroupRelationClearService.java

@@ -1,6 +1,7 @@
 package com.cyksj.service.relation;
 
 import com.cyksj.model.entity.GroupsRelation;
+import com.cyksj.model.entity.OrderDonTicketRecord;
 import com.cyksj.model.entity.UserTicketClearedRecord;
 
 /*
@@ -16,6 +17,14 @@ public interface GroupRelationClearService {
 	 */
 	void clearTicket(GroupsRelation relation, UserTicketClearedRecord.Source source);
 
+	/**
+	 * Clears one ticket as part of a frozen multi-quantity whole-order refund.
+	 * The caller supplies the already locked slot so ownership is checked once
+	 * without issuing a duplicate FOR UPDATE query. This method must be called
+	 * inside the same transaction that acquired that slot lock.
+	 */
+	void clearRefundTicket(GroupsRelation relation, OrderDonTicketRecord refundingTicket);
+
 	/**
 	 * 续费换车票,清理之前的车票
 	 */

+ 8 - 0
netflix-service/src/main/java/com/cyksj/service/relation/GroupsRelationExpiryRecordService.java

@@ -14,4 +14,12 @@ import com.cyksj.model.entity.OrderDon;
  */
 public interface GroupsRelationExpiryRecordService extends IService<GroupsRelationExpiryRecord> {
 	void syncGroupsRelationExpiryRecord(OrderDon orderDon, GoodsDon goodsDon, GoodsDonSku sku);
+
+	/**
+	 * Sync the renewal-follow record for one ticket of an order without fabricating a
+	 * one-ticket {@link OrderDon}. This overload is used by multi-quantity delivery:
+	 * all tickets still belong to the same order in {@code order_don}.
+	 */
+	void syncGroupsRelationExpiryRecord(OrderDon orderDon, Long relationId,
+			GoodsDon goodsDon, GoodsDonSku sku);
 }

+ 1 - 0
netflix-service/src/main/java/com/cyksj/service/relation/impl/CmsGroupsRelationServiceImpl.java

@@ -152,6 +152,7 @@ public class CmsGroupsRelationServiceImpl implements CmsGroupsRelationService {
 				.eq(GroupsRelation::getGroupsId, groupsId)
 				.eq(GroupsRelation::getStatus, GroupsRelation.Status.none)
 				.eq(GroupsRelation::getUserId, 0)
+				.apply("not exists (select 1 from order_don_ticket_record ticket where ticket.active_relation_id = groups_relation.id)")
 				.orderByAsc(GroupsRelation::getNum)
 				.last("limit 1"));
 		if (relation != null) {

+ 72 - 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,14 +58,40 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 
 	private final CodexService codexService;
 
+	private final OrderDonTicketRecordService orderDonTicketRecordService;
+
     private static final GlobalThreadPoolTaskExecutor TASK_POOL = GlobalThreadPoolTaskExecutor.getInstance();
 
 	@Override
 	public void clearTicket(GroupsRelation relation, UserTicketClearedRecord.Source source) {
+		clearTicket(relation, source, null);
+	}
+
+	@Override
+	public void clearRefundTicket(GroupsRelation relation, OrderDonTicketRecord refundingTicket) {
+		if (relation == null || relation.getId() == null || refundingTicket == null
+				|| refundingTicket.getId() == null
+				|| refundingTicket.getStatus() != OrderDonTicketRecord.Status.refunding
+				|| !relation.getId().equals(refundingTicket.getRelationId())) {
+			throw new IllegalArgumentException("valid refunding ticket is required");
+		}
+		clearTicket(relation, UserTicketClearedRecord.Source.refund, refundingTicket);
+	}
+
+	private void clearTicket(GroupsRelation relation, UserTicketClearedRecord.Source source,
+							 OrderDonTicketRecord lockedActiveTicket) {
 		Long relationId = relation.getId();
 		Long newRelationId = relation.getNewRelationId();
 		Integer num = relation.getNum();
 		final GroupsRelation.Status relationStatus = relation.getStatus();
+		OrderDonTicketRecord activeTicket = lockedActiveTicket != null
+				? lockedActiveTicket
+				: orderDonTicketRecordService.getActiveByRelationIdForUpdate(relationId);
+		if (activeTicket != null
+				&& activeTicket.getStatus() == OrderDonTicketRecord.Status.refunding
+				&& (source != UserTicketClearedRecord.Source.refund || lockedActiveTicket == null)) {
+			throw new IllegalStateException("refunding ticket must be cleared by its locked slot");
+		}
 		//记录用户被清除车票历史记录
 		GroupsRelationView groupsRelation = beanSearcher.searchFirst(GroupsRelationView.class,
 				MapUtils.builder().field(GroupsRelationView::getId, relationId).build());
@@ -85,6 +116,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 +191,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 +216,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 +253,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) {

+ 104 - 38
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;
@@ -267,9 +270,6 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 				builder.field(RenewalView::getCmt, cmt).field(RenewalView::getYhsId, userMirrorShop.getId());
 			}
 		}
-		if (cmt == null || cmt != 2) {
-			builder.field(RenewalView::getCmt, 2).op(Operator.NotEqual);
-		}
 //		Long yhsId = yhShopFrontService.getYhsIdByCustomId(customId);
 		DateTime now = DateTime.now();
 		if (StrUtil.isNotBlank(account)) {
@@ -283,13 +283,22 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 			conditionSql += String.format(" or (sku.is_mirror and gr.expiry_time > '%s')", DateUtil.offsetDay(now, -7));
 		}
 		conditionSql += ")";
+		// Keep legacy platform tickets whose cmt is NULL visible. SQL's
+		// `NULL <> 2` evaluates to UNKNOWN, so the old builder predicate
+		// silently hid these otherwise valid tickets.
+		if (cmt == null || cmt != 2) {
+			conditionSql += " and (gr.cmt is null or gr.cmt <> 2)";
+		}
 		if (StrUtil.isNotEmpty(orderNo)) {
 			OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
 					.eq(OrderDon::getOrderNo, orderNo)
 					.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.active_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)
@@ -392,13 +401,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 					ticket.setRemainDays(remainDays);
 					ticket.setTotal(DateUtil.betweenDay(ticket.getStartTime(), ticket.getExpiryTime(), false) + 1);
 				}
-				OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-						.eq(OrderDon::getRelationId, ticket.getRelationId())
-						.in(OrderDon::getUserId, userIds)
-						.eq(OrderDon::getOrderType, 1)
-						.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-						.orderByDesc(OrderDon::getId)
-						.last("limit 1"));
+				OrderDon orderDon = getCurrentTicketOrder(ticket.getRelationId(), userIds);
 				Long zhSkuId = ticket.getSkuId();
 				if (orderDon == null) {
 					if (ticket.getGoodsId() == Constant.NETFLIX_GID) {
@@ -627,6 +630,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 				.eq(GroupsRelation::getGroupsId, groupsId)
 				.eq(GroupsRelation::getStatus, GroupsRelation.Status.none)
 				.eq(GroupsRelation::getUserId, 0)
+				.apply("not exists (select 1 from order_don_ticket_record ticket where ticket.active_relation_id = groups_relation.id)")
 				.orderByAsc(GroupsRelation::getNum)
 				.last("limit 1"));
 		if (relation != null) {
@@ -709,6 +713,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 							.eq(GroupsRelation::getGroupsId, groupsId)
 							.eq(GroupsRelation::getUserId, 0)
 							.eq(GroupsRelation::getStatus, GroupsRelation.Status.outside)
+							.apply("not exists (select 1 from order_don_ticket_record ticket where ticket.active_relation_id = groups_relation.id)")
 							.last("limit 1"));
 					if (relation == null) {
 						relation = new GroupsRelation();
@@ -736,26 +741,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 //					if (sku.getGoodsId() == Constant.NETFLIX_GID) {
 //						filterAccountTime = DateUtil.parse("2025-06-23");
 //					}
-					groups = groupsMapper.selectTicketGroups(sku.getId(), GroupsTrips.Status.validity, maxNum, ip, filterAccountTime);
-
-					if (groups == null) {
-						//待发车
-						groups = groupsMapper.selectTicketGroups(sku.getId(), GroupsTrips.Status.waiting, maxNum, ip, null);
-					}
-					//新增空车队
-					if (groups == null) {
-						relation = groupsFuncService.createNewGroupsTrips(sku);
-					}
-					//获取车位
-					if (relation == null) {
-						//寻找快满编车队进行占座
-						relation = groupsRelationMapper.selectRandomOneRelationByGroupsId(groups.getId());
-						if (relation == null) {
-							Long groupsId = groups.getId();
-							TASK_EXECUTOR.execute(() -> groupsMapper.updateAvailableNum(groupsId));
-							throw BusinessRuntimeException.getInstance("系统繁忙,请重试...");
-						}
-					}
+					relation = selectAvailableRelation(sku, maxNum, ip, filterAccountTime);
 				}
 			}
 			if (groups == null) {
@@ -768,6 +754,49 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 		return relation;
 	}
 
+	/**
+	 * Repairs stale seat availability and tries another group.
+	 * Creates a new group when no real free seat can be found.
+	 */
+	private GroupsRelation selectAvailableRelation(GoodsDonSku sku, Integer maxNum, String ip,
+	                                               DateTime filterAccountTime) {
+		Set<Long> exhaustedGroupIds = new HashSet<>();
+		for (int attempt = 0; attempt < 3; attempt++) {
+			GroupsTrips candidate = selectTicketGroupsExcludingIds(
+					sku, GroupsTrips.Status.validity, maxNum, ip, filterAccountTime, exhaustedGroupIds);
+			if (candidate == null) {
+				candidate = selectTicketGroupsExcludingIds(
+						sku, GroupsTrips.Status.waiting, maxNum, ip, null, exhaustedGroupIds);
+			}
+			if (candidate == null) {
+				break;
+			}
+			GroupsRelation relation = groupsRelationMapper.selectRandomOneRelationByGroupsId(candidate.getId());
+			if (relation != null) {
+				return relation;
+			}
+			exhaustedGroupIds.add(candidate.getId());
+			groupsMapper.updateAvailableNum(candidate.getId());
+			log.warn("Repair stale available seat count and try another group, groupsId:{}, skuId:{}",
+					candidate.getId(), sku.getId());
+		}
+		return groupsFuncService.createNewGroupsTrips(sku);
+	}
+
+	private GroupsTrips selectTicketGroupsExcludingIds(GoodsDonSku sku, GroupsTrips.Status status,
+	                                                   Integer maxNum, String ip, DateTime filterAccountTime,
+	                                                   Set<Long> exhaustedGroupIds) {
+		for (int repair = 0; repair <= exhaustedGroupIds.size(); repair++) {
+			GroupsTrips candidate = groupsMapper.selectTicketGroups(
+					sku.getId(), status, maxNum, ip, filterAccountTime);
+			if (candidate == null || !exhaustedGroupIds.contains(candidate.getId())) {
+				return candidate;
+			}
+			groupsMapper.updateAvailableNum(candidate.getId());
+		}
+		return null;
+	}
+
 	@Override
 	public String getAiVerifyCode(TicketLoginReq loginReq) throws Exception {
 		Integer type = loginReq.getType();
@@ -1779,6 +1808,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 +1958,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 +2015,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();
@@ -2111,13 +2151,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 				}
 			}
 
-			OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-					.eq(OrderDon::getRelationId, ticket.getRelationId())
-					.eq(OrderDon::getUserId, ticket.getUserId())
-					.eq(OrderDon::getOrderType, 1)
-					.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-					.orderByDesc(OrderDon::getId)
-					.last("limit 1"));
+			OrderDon orderDon = getCurrentTicketOrder(ticket.getRelationId(), userIds);
 			if (orderDon != null) {
 				ticket.setOrderId(orderDon.getId());
 			}
@@ -2126,6 +2160,29 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 		return list;
 	}
 
+	/**
+	 * Resolve the order that currently owns a ticket. A relation id can be reused
+	 * after an old ticket expires or is cleared, so an active multi-quantity slot
+	 * must take precedence over historical order_don.relation_id rows.
+	 */
+	private OrderDon getCurrentTicketOrder(Long relationId, List<Long> userIds) {
+		OrderDonTicketRecord activeTicket = orderDonTicketRecordService.getActiveByRelationId(relationId);
+		if (activeTicket != null) {
+			return orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+					.eq(OrderDon::getId, activeTicket.getOrderId())
+					.in(OrderDon::getUserId, userIds)
+					.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+					.last("limit 1"));
+		}
+		return orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+				.eq(OrderDon::getRelationId, relationId)
+				.in(OrderDon::getUserId, userIds)
+				.eq(OrderDon::getOrderType, 1)
+				.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+				.orderByDesc(OrderDon::getId)
+				.last("limit 1"));
+	}
+
 	@Override
 	public TicketRenewUpgradeView getRenewUpgradeInfo(long userId, Long relationId) {
 		List<Long> userIdList = userBindRelationService.getRelationUserIdList(userId, null);
@@ -2488,6 +2545,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 +2600,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 +2768,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()

+ 26 - 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);
 	}
 
@@ -70,6 +77,8 @@ public class GroupsRelationChangeServiceImpl implements GroupsRelationChangeServ
 			relation = relationMapper.selectOne(Wrappers.lambdaQuery(GroupsRelation.class)
 					.eq(GroupsRelation::getGroupsId, groupsTrips.getId())
 					.eq(GroupsRelation::getStatus, GroupsRelation.Status.none)
+					.eq(GroupsRelation::getUserId, 0)
+					.apply("not exists (select 1 from order_don_ticket_record ticket where ticket.active_relation_id = groups_relation.id)")
 					.last("limit 1"));
 		}
 		if (relation == null) {
@@ -89,7 +98,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 +135,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 +162,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("仅支持更换同规格车票");
+		}
+	}
 }

+ 7 - 1
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationExpiryRecordServiceImpl.java

@@ -29,11 +29,17 @@ public class GroupsRelationExpiryRecordServiceImpl extends ServiceImpl<GroupsRel
 
 	@Override
 	public void syncGroupsRelationExpiryRecord(OrderDon orderDon, GoodsDon goodsDon, GoodsDonSku sku) {
+		syncGroupsRelationExpiryRecord(orderDon, orderDon.getRelationId(), goodsDon, sku);
+	}
+
+	@Override
+	public void syncGroupsRelationExpiryRecord(OrderDon orderDon, Long relationId,
+			GoodsDon goodsDon, GoodsDonSku sku) {
 		List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
 		GroupsRelationExpiryRecord groupsRelationExpiryRecord = this.getOne(Wrappers.lambdaQuery(GroupsRelationExpiryRecord.class)
 				.in(GroupsRelationExpiryRecord::getUserId, userIdList)
 				.eq(GroupsRelationExpiryRecord::getIsReBuy, false)
-				.eq(GroupsRelationExpiryRecord::getRelationId, orderDon.getRelationId()).last("limit 1"));
+				.eq(GroupsRelationExpiryRecord::getRelationId, relationId).last("limit 1"));
 		if (groupsRelationExpiryRecord == null) {
 			List<GroupsRelationExpiryRecord> groupsRelationExpiryRecords = this.list(Wrappers.lambdaQuery(GroupsRelationExpiryRecord.class)
 					.in(GroupsRelationExpiryRecord::getUserId, userIdList)

+ 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;
+
+/**
+ * Only recovers provider-refund results that remained unknown after submission.
+ * Ticket delivery failures are handled manually from the order-management page.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MultiQuantityRefundRecoveryScheduler {
+
+    private static final int BATCH_SIZE = 100;
+    private static final int REFUND_LEASE_MINUTES = 10;
+
+    private final CmsOrderDonService cmsOrderDonService;
+
+    @Scheduled(fixedDelay = 60_000L, initialDelay = 60_000L)
+    public void recoverRefunds() {
+        int processed = cmsOrderDonService.recoverMultiQuantityRefunds(
+                BATCH_SIZE, REFUND_LEASE_MINUTES);
+        if (processed > 0) {
+            log.info("Recovered multi-quantity refund states, processed:{}", processed);
+        }
+    }
+}

+ 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",

+ 53 - 1
netflix-web/src/main/java/com/cyksj/web/controller/manage/CmsOrderController.java

@@ -31,9 +31,11 @@ import com.cyksj.mapper.codex.CodexDeductRecordMapper;
 import com.cyksj.mapper.manage.RealGoodsOrderHandleMapper;
 import com.cyksj.mapper.manage.cms.CmsUserMapper;
 import com.cyksj.mapper.manage.coupon.CouponMapper;
+import com.cyksj.mapper.order.OrderDonTicketRecordMapper;
 import com.cyksj.mapper.shop.YhShopMapper;
 import com.cyksj.model.dto.OriSkuDto;
 import com.cyksj.model.dto.RenewOriOrderSkuDto;
+import com.cyksj.model.dto.MultiQuantityTicketCountDto;
 import com.cyksj.model.entity.*;
 import com.cyksj.model.excel.*;
 import com.cyksj.model.manage.request.ReqSetLogisticsPost;
@@ -41,10 +43,12 @@ import com.cyksj.model.manage.views.AccountView;
 import com.cyksj.model.manage.views.OrderDonBackView;
 import com.cyksj.model.manage.views.UpgradeOrderDonBackView;
 import com.cyksj.model.request.*;
+import com.cyksj.model.response.MultiQuantityRedeliveryResult;
 import com.cyksj.model.views.*;
 import com.cyksj.service.chatgpt.GptUserRechargeRecordService;
 import com.cyksj.service.mange.CmsOrderDonService;
 import com.cyksj.service.order.OrderDonCommentService;
+import com.cyksj.service.order.MultiQuantityOrderService;
 import com.cyksj.service.user.UserBindRelationService;
 import com.cyksj.service.user.UserService;
 import com.cyksj.web.util.EasyExcelUtils;
@@ -146,6 +150,10 @@ public class CmsOrderController {
 
 	private final OrderDonAfterSalesRecordMapper orderDonAfterSalesRecordMapper;
 
+	private final OrderDonTicketRecordMapper orderDonTicketRecordMapper;
+
+	private final MultiQuantityOrderService multiQuantityOrderService;
+
     private final GoodsClickUserOrderRecordMapper goodsClickUserOrderRecordMapper;
 
     private final CodexDeductRecordMapper codexDeductRecordMapper;
@@ -254,7 +262,37 @@ public class CmsOrderController {
 			builder.put("condition", conditionSql);
 		}
 		SearchResult<OrderDonBackView> search = beanSearcher.search(OrderDonBackView.class, builder.build());
+		List<Long> orderIds = search.getDataList().stream()
+				.map(OrderDonBackView::getId)
+				.filter(Objects::nonNull)
+				.collect(Collectors.toList());
+		Map<Long, MultiQuantityTicketCountDto> ticketCountsByOrderId = orderIds.isEmpty()
+				? Collections.emptyMap()
+				: orderDonTicketRecordMapper.selectTicketCounts(orderIds).stream()
+				.collect(Collectors.toMap(MultiQuantityTicketCountDto::getOrderId,
+						count -> count, (left, right) -> left));
 		search.getDataList().forEach(data -> {
+			MultiQuantityTicketCountDto ticketCount = ticketCountsByOrderId.get(data.getId());
+			boolean multiQuantityOrder = ticketCount != null
+					&& data.getNum() != null && data.getNum() > 1
+					&& Objects.equals(ticketCount.getTicketTotalNum(), data.getNum());
+			int successNum = ticketCount == null || ticketCount.getTicketSuccessNum() == null
+					? 0 : ticketCount.getTicketSuccessNum();
+			boolean terminalTicketState = ticketCount != null
+					&& (positive(ticketCount.getTicketRefundingNum())
+					|| positive(ticketCount.getTicketRefundedNum())
+					|| positive(ticketCount.getTicketClearedNum()));
+			boolean paidOrder = data.getStatus() == OrderDon.Status.hasPayment
+					|| data.getStatus() == OrderDon.Status.complete;
+			int undeliveredNum = multiQuantityOrder && paidOrder && !terminalTicketState
+					? Math.max(0, data.getNum() - successNum) : 0;
+			boolean hasRedeliverableSlot = ticketCount != null
+					&& positive(ticketCount.getTicketRedeliverableNum());
+			data.setMultiQuantityOrder(multiQuantityOrder);
+			data.setTicketSuccessNum(successNum);
+			data.setTicketUndeliveredNum(undeliveredNum);
+			data.setCanRedeliver(multiQuantityOrder && paidOrder
+					&& undeliveredNum > 0 && hasRedeliverableSlot && !terminalTicketState);
 			if (data.getPopularizeId() != null) {
 				Optional.ofNullable(channelPopularizeMapper.selectChannelByPid(data.getPopularizeId()))
 						.ifPresent(channelName -> data.setChannel(channelName));
@@ -377,6 +415,19 @@ public class CmsOrderController {
 		return GatewayResponse.SUCCESS.newBuilder().toResult(search);
 	}
 
+	private boolean positive(Integer value) {
+		return value != null && value > 0;
+	}
+
+	@PostMapping("/multi-quantity/redeliver/{orderId}")
+	@SaCheckPermission("order:view")
+	@Log(module = "订单管理/补发多件订单车票", businessType = BusinessType.PUT, isSaveRequestData = true)
+	public Result<MultiQuantityRedeliveryResult> redeliverMultiQuantityTickets(
+			@PathVariable Long orderId) {
+		MultiQuantityRedeliveryResult result = multiQuantityOrderService.redeliverManually(orderId);
+		return GatewayResponse.SUCCESS.newBuilder().toResult(result);
+	}
+
 	/**
 	 * 用户退款订单列表
 	 */
@@ -758,7 +809,8 @@ public class CmsOrderController {
     @Log(module = "订单管理/售前、售后客服", businessType = BusinessType.PUT, isSaveRequestData = true)
     public Result<String> putRealGoodsService(@RequestBody @Validated RealGoodsServiceReq request) {
         OrderDon orderDon = cmsOrderDonService.getById(request.getOrderId());
-        if (orderDon == null || orderDon.getRelationId() != 0) {
+        if (orderDon == null || orderDon.getRelationId() != 0
+                || multiQuantityOrderService.isMultiQuantityOrder(orderDon.getId())) {
             throw BusinessRuntimeException.getInstance("该订单不是实物订单");
         }
 	    Boolean isBefore = false;

+ 14 - 1
netflix-web/src/main/java/com/cyksj/web/controller/manage/goods/CmsGoodsDonSkuController.java

@@ -119,8 +119,9 @@ public class CmsGoodsDonSkuController {
     public Result<String> skuUpdate(@RequestBody GoodsDonSku sku) throws Exception {
         Long skuId = sku.getId();
         GoodsDonSku dbSku = goodsDonSkuService.getById(skuId);
-        Boolean dbIsSubscribeAlert = dbSku.getIsSubscribeAlert();
         Assert.notNull(dbSku, "规格不存在");
+        Boolean dbIsSubscribeAlert = dbSku.getIsSubscribeAlert();
+        normalizeMultiQuantityConfig(sku);
         Long goodsId = sku.getGoodsId();
         GoodsDon goods = goodsDonService.getById(goodsId);
         if (goods.getType() == 1) {
@@ -201,6 +202,18 @@ public class CmsGoodsDonSkuController {
         return GatewayResponse.SUCCESS.newBuilder().toResult();
     }
 
+    private void normalizeMultiQuantityConfig(GoodsDonSku sku) {
+        if (!Boolean.TRUE.equals(sku.getIsMultiQuantity())) {
+            sku.setIsMultiQuantity(false);
+            sku.setMultiQuantityMaxNum(1);
+            return;
+        }
+        Integer maxNum = sku.getMultiQuantityMaxNum();
+        if (maxNum == null || maxNum < 2 || maxNum > 100) {
+            throw BusinessRuntimeException.getInstance("多数量单次购买上限必须在2到100之间");
+        }
+    }
+
 
     /**
      * 删除 sku

+ 91 - 19
netflix-web/src/main/java/com/cyksj/web/controller/manage/group/CmsGroupRelationController.java

@@ -19,6 +19,7 @@ import com.cyksj.enums.BusinessType;
 import com.cyksj.enums.GatewayResponse;
 import com.cyksj.mapper.*;
 import com.cyksj.mapper.manage.cms.CmsUserMapper;
+import com.cyksj.mapper.order.OrderDonTicketRecordMapper;
 import com.cyksj.model.entity.*;
 import com.cyksj.model.excel.ExcelGptRechargeStatisticsData;
 import com.cyksj.model.excel.ExcelRechargeAccountData;
@@ -51,6 +52,7 @@ import org.springframework.web.bind.annotation.*;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
@@ -79,6 +81,8 @@ public class CmsGroupRelationController {
 
     private final OrderDonMapper orderDonMapper;
 
+    private final OrderDonTicketRecordMapper orderDonTicketRecordMapper;
+
     private final GoodsDonSkuMapper goodsDonSkuMapper;
 
     private final GroupsRelationIndependentRenewMapper groupsRelationIndependentRenewMapper;
@@ -95,6 +99,49 @@ public class CmsGroupRelationController {
 
     private final GptRechargeCardKeyChannelMapper gptRechargeCardKeyChannelMapper;
 
+    private List<Long> getOrderRelationIds(OrderDon orderDon) {
+        List<Long> relationIds = new ArrayList<>();
+        if (orderDon.getNum() != null && orderDon.getNum() > 1) {
+            relationIds.addAll(orderDonTicketRecordMapper.selectList(
+                            Wrappers.lambdaQuery(OrderDonTicketRecord.class)
+                                    .select(OrderDonTicketRecord::getRelationId)
+                                    .eq(OrderDonTicketRecord::getOrderId, orderDon.getId())
+                                    .eq(OrderDonTicketRecord::getStatus, OrderDonTicketRecord.Status.success)
+                                    .isNotNull(OrderDonTicketRecord::getRelationId))
+                    .stream()
+                    .map(OrderDonTicketRecord::getRelationId)
+                    .distinct()
+                    .collect(Collectors.toList()));
+        }
+        if (orderDon.getRelationId() != null && orderDon.getRelationId() > 0
+                && !relationIds.contains(orderDon.getRelationId())) {
+            relationIds.add(orderDon.getRelationId());
+        }
+        // An empty IN list has database-dependent semantics; use an impossible relation id.
+        if (relationIds.isEmpty()) {
+            relationIds.add(0L);
+        }
+        return relationIds;
+    }
+
+    private OrderDon getMultiQuantityOrderByRelationId(Long relationId) {
+        if (relationId == null || relationId <= 0) {
+            return null;
+        }
+        OrderDonTicketRecord ticketRecord = orderDonTicketRecordMapper.selectOne(
+                Wrappers.lambdaQuery(OrderDonTicketRecord.class)
+                        .eq(OrderDonTicketRecord::getRelationId, relationId)
+                        .eq(OrderDonTicketRecord::getStatus, OrderDonTicketRecord.Status.success)
+                        .orderByDesc(OrderDonTicketRecord::getId)
+                        .last("limit 1"));
+        if (ticketRecord == null) {
+            return null;
+        }
+        OrderDon orderDon = orderDonMapper.selectById(ticketRecord.getOrderId());
+        return orderDon != null && orderDon.getNum() != null && orderDon.getNum() > 1
+                ? orderDon : null;
+    }
+
     @GetMapping("/get")
     public Result<List<GroupsRelationBackView>> get(){
         List<GroupsRelationBackView> search = beanSearcher.searchAll(GroupsRelationBackView.class, MapUtils.flatBuilder(request.getParameterMap())
@@ -128,12 +175,14 @@ public class CmsGroupRelationController {
                     .eq(StrUtil.isNotBlank(orderNo), OrderDon::getOrderNo, orderNo)
                     .eq(StrUtil.isNotBlank(transactionId), OrderDon::getTransactionId, transactionId)
                     .last("limit 1"));
-            Long relationId = 0l;
             if (orderDon != null) {
-                relationId = orderDon.getRelationId();
                 builder.field(GroupsRelationRechargeView::getUserId, orderDon.getUserId());
+                List<Long> relationIds = getOrderRelationIds(orderDon);
+                builder.field(GroupsRelationRechargeView::getRelationId, relationIds)
+                        .op(Operator.InList);
+            } else {
+                builder.field(GroupsRelationRechargeView::getRelationId, 0L);
             }
-            builder.field(GroupsRelationRechargeView::getRelationId, relationId);
         }
         if (isVipError != null) {
             builder.field(GroupsRelationRechargeView::getRechargeStatus, GroupsRelation.RechargeStatus.recharge_error.name());
@@ -147,12 +196,30 @@ public class CmsGroupRelationController {
                 .field(GroupsRelationRechargeView::getStatus, List.of(GroupsRelation.Status.validity.name(), GroupsRelation.Status.outside.name())).op(Operator.InList)
                 .build());
         search.getDataList().forEach(data -> {
-            OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-                    .eq(OrderDon::getUserId, data.getUserId())
-                    .eq(OrderDon::getRelationId, data.getRelationId())
-                    .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-                    .orderByDesc(OrderDon::getId)
-                    .last("limit 1"));
+            // Ticket-slot ownership is authoritative for multi-quantity orders. Resolve it
+            // before the legacy relation_id lookup, otherwise an old/generated relation order
+            // can mask the real multi-quantity parent order.
+            OrderDon orderDon = null;
+            OrderDonTicketRecord ticketRecord = orderDonTicketRecordMapper.selectOne(
+                    Wrappers.lambdaQuery(OrderDonTicketRecord.class)
+                            .eq(OrderDonTicketRecord::getRelationId, data.getRelationId())
+                            .eq(OrderDonTicketRecord::getStatus, OrderDonTicketRecord.Status.success)
+                            .orderByDesc(OrderDonTicketRecord::getId)
+                            .last("limit 1"));
+            if (ticketRecord != null) {
+                OrderDon slotOrder = orderDonMapper.selectById(ticketRecord.getOrderId());
+                if (slotOrder != null && !Constant.noOrderAllStatus.contains(slotOrder.getStatus())) {
+                    orderDon = slotOrder;
+                }
+            }
+            if (orderDon == null) {
+                orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+                        .eq(OrderDon::getUserId, data.getUserId())
+                        .eq(OrderDon::getRelationId, data.getRelationId())
+                        .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+                        .orderByDesc(OrderDon::getId)
+                        .last("limit 1"));
+            }
             if (orderDon != null) {
                 data.setOrderNo(orderDon.getOrderNo());
             }
@@ -173,12 +240,14 @@ public class CmsGroupRelationController {
             OrderDon orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
                     .eq(OrderDon::getOrderNo, orderNo)
                     .last("limit 1"));
-            Long relationId = 0l;
             if (orderDon != null) {
-                relationId = orderDon.getRelationId();
                 builder.field(ExcelRechargeAccountData::getUserId, orderDon.getUserId());
+                List<Long> relationIds = getOrderRelationIds(orderDon);
+                builder.field(ExcelRechargeAccountData::getRelationId, relationIds)
+                        .op(Operator.InList);
+            } else {
+                builder.field(ExcelRechargeAccountData::getRelationId, 0L);
             }
-            builder.field(ExcelRechargeAccountData::getRelationId, relationId);
         }
         if (isVipError != null) {
             builder.field(ExcelRechargeAccountData::getRechargeStatus, GroupsRelation.RechargeStatus.recharge_error.name());
@@ -238,12 +307,15 @@ public class CmsGroupRelationController {
         OrderDon orderDon = null;
         if (rechargeStatus == GroupsRelation.RechargeStatus.complete) {
             relation.setSendTime(DateTime.now());
-            orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-                    .eq(OrderDon::getUserId, relation.getUserId())
-                    .eq(OrderDon::getRelationId, relationId)
-                    .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-                    .orderByDesc(OrderDon::getId)
-                    .last("limit 1"));
+            orderDon = getMultiQuantityOrderByRelationId(relationId);
+            if (orderDon == null) {
+                orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
+                        .eq(OrderDon::getUserId, relation.getUserId())
+                        .eq(OrderDon::getRelationId, relationId)
+                        .notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
+                        .orderByDesc(OrderDon::getId)
+                        .last("limit 1"));
+            }
             if (orderDon == null){
                 orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
                         .eq(OrderDon::getUserId, relation.getUserId())
@@ -784,4 +856,4 @@ public class CmsGroupRelationController {
         }
         return GatewayResponse.SUCCESS.newBuilder().toResult();
     }
-}
+}

+ 3 - 1
netflix-web/src/main/java/com/cyksj/web/controller/user/UserController.java

@@ -156,7 +156,9 @@ public class UserController {
             }
         }
         if (cmt == null || cmt != 2) {
-            builder.field(RenewalView::getCmt, 2).op(Operator.NotEqual);
+            // SQL NULL does not satisfy `<> 2`; keep legacy platform tickets visible
+            // while still excluding mirror-shop tickets (cmt = 2).
+            builder.field(RenewalView::getCmt).sql("$1 is null or $1 <> ?", 2);
         }
         List<RenewalView> renewalViews = beanSearcher.searchAll(RenewalView.class, builder
                 .field(RenewalView::getUserId, 0).op(Operator.GreaterThan)

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff