Pārlūkot izejas kodu

fix 多件商品下单

zoujiajian 2 nedēļas atpakaļ
vecāks
revīzija
c0cdef6656
31 mainītis faili ar 925 papildinājumiem un 197 dzēšanām
  1. 30 0
      netflix-dao/src/main/java/com/cyksj/mapper/GroupsRelationMapper.java
  2. 5 1
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonBusinessEventMapper.java
  3. 2 2
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketQueryMapper.java
  4. 43 7
      netflix-dao/src/main/java/com/cyksj/mapper/order/OrderDonTicketRecordMapper.java
  5. 32 0
      netflix-dao/src/main/java/com/cyksj/model/dto/MultiQuantityTicketCountDto.java
  6. 9 0
      netflix-dao/src/main/java/com/cyksj/model/entity/OrderDonTicketRecord.java
  7. 20 1
      netflix-dao/src/main/java/com/cyksj/model/manage/views/OrderDonBackView.java
  8. 22 0
      netflix-dao/src/main/java/com/cyksj/model/response/MultiQuantityRedeliveryResult.java
  9. 11 3
      netflix-dao/src/main/resources/mapper/GroupRelationMapper.xml
  10. 15 6
      netflix-service/src/main/java/com/cyksj/server/recharge/impl/GptProxyRechargeServiceImpl.java
  11. 2 1
      netflix-service/src/main/java/com/cyksj/service/exchange/ExchangeCodeService.java
  12. 25 5
      netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java
  13. 15 6
      netflix-service/src/main/java/com/cyksj/service/mange/gpt/impl/GptRechargeServiceImpl.java
  14. 102 46
      netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java
  15. 5 0
      netflix-service/src/main/java/com/cyksj/service/order/MultiQuantityOrderService.java
  16. 4 0
      netflix-service/src/main/java/com/cyksj/service/order/OrderDonTicketRecordService.java
  17. 221 29
      netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java
  18. 36 11
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java
  19. 19 4
      netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonTicketRecordServiceImpl.java
  20. 3 1
      netflix-service/src/main/java/com/cyksj/service/order/impl/UserRealOrderBenefitsImpl.java
  21. 9 0
      netflix-service/src/main/java/com/cyksj/service/relation/GroupRelationClearService.java
  22. 8 0
      netflix-service/src/main/java/com/cyksj/service/relation/GroupsRelationExpiryRecordService.java
  23. 1 0
      netflix-service/src/main/java/com/cyksj/service/relation/impl/CmsGroupsRelationServiceImpl.java
  24. 21 4
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationClearServiceImpl.java
  25. 78 48
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java
  26. 2 0
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationChangeServiceImpl.java
  27. 7 1
      netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupsRelationExpiryRecordServiceImpl.java
  28. 31 0
      netflix-service/src/main/java/com/cyksj/task/MultiQuantityRefundRecoveryScheduler.java
  29. 53 1
      netflix-web/src/main/java/com/cyksj/web/controller/manage/CmsOrderController.java
  30. 91 19
      netflix-web/src/main/java/com/cyksj/web/controller/manage/group/CmsGroupRelationController.java
  31. 3 1
      netflix-web/src/main/java/com/cyksj/web/controller/user/UserController.java

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

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

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

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

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

@@ -10,7 +10,7 @@ public interface OrderDonTicketQueryMapper {
 
     @Select({
             "<script>",
-            "select r.item_index as itemIndex, r.relation_id as relationId,",
+            "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>",
@@ -25,7 +25,7 @@ public interface OrderDonTicketQueryMapper {
             "<otherwise>null as account, null as password</otherwise>",
             "</choose>",
             "from order_don_ticket_record r",
-            "left join groups_relation gr on gr.id = r.relation_id",
+            "left join groups_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}",

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

@@ -1,6 +1,7 @@
 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;
@@ -27,15 +28,44 @@ public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRec
     @Select("select count(*) from order_don_ticket_record where order_id = #{orderId}")
     int countByOrderId(@Param("orderId") Long orderId);
 
-    @Update("update order_don_ticket_record set status = 'processing', worker_token = #{workerToken}, processing_time = now(), retry_num = retry_num + 1, error_msg = null, update_time = now() where id = #{id} and status in ('pending', 'failed') and retry_num < #{maxRetries}")
+    @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}")
+    @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}")
@@ -89,23 +119,29 @@ public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRec
     int replaceActiveRelation(@Param("oldRelationId") Long oldRelationId,
                               @Param("newRelationId") Long newRelationId);
 
-    @Select("select r.* from order_don_ticket_record r inner join groups_relation gr on gr.id = r.relation_id where gr.groups_id = #{groupsId} and r.status = 'success' order by r.item_index")
+    @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.relation_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'")
+    @Update("update order_don_ticket_record set status = 'cleared', worker_token = null, " +
+            "processing_time = null, error_msg = null, update_time = now() " +
+            "where active_relation_id = #{relationId} and status = 'success'")
     int clearActiveRelation(@Param("relationId") Long relationId);
 
-    @Select("select * from order_don_ticket_record where active_relation_id = #{relationId} limit 1")
+    @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} limit 1 for update")
+    @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}")

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

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

@@ -1,5 +1,7 @@
 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;
@@ -21,6 +23,13 @@ public class OrderDonTicketRecord extends BaseEntity {
 
     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;

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

+ 11 - 3
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
@@ -462,7 +470,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)))
@@ -502,4 +510,4 @@
         group by gur.operator
         order by num desc
     </select>
-</mapper>
+</mapper>

+ 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 - 1
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;
@@ -48,7 +49,7 @@ public interface ExchangeCodeService extends IService<ExchangeCode> {
 	 * Attempts one ticket allocation without swallowing the failure. Multi-quantity
 	 * fulfillment uses this entry so its per-slot transaction can roll back cleanly.
 	 */
-	GroupsRelation getRelationNoOrderOnce(GoodsDonSku sku, Long userId, Long yhsId);
+	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);

+ 25 - 5
netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java

@@ -510,8 +510,12 @@ public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, Exc
 	}
 
 	@Override
-	public GroupsRelation getRelationNoOrderOnce(GoodsDonSku sku, Long userId, Long yhsId) {
-		return allocateRelation(sku, userId, yhsId);
+	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;
 	}
 
 	@Override
@@ -558,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();

+ 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("代充订单不存在或已退款");
 			}

+ 102 - 46
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java

@@ -94,6 +94,7 @@ import java.math.RoundingMode;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -479,10 +480,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             return;
         }
         validateMultiQuantityWholeRefund(refundReq, orderDon);
-        multiQuantityOrderService.prepareRefund(orderId);
-        if (orderDon.getType() == OrderDon.Type.PAYPAL || orderDon.getType() == OrderDon.Type.STRIPE) {
+        if (requiresManualMultiQuantityRefund(orderDon)) {
             throw BusinessRuntimeException.getInstance("PayPal/Stripe批量订单请人工核对后退款");
         }
+        multiQuantityOrderService.prepareRefund(orderId);
         OrderDonBusinessEvent refundEvent = orderDonBusinessEventService.get(
                 orderId, OrderDonBusinessEvent.Type.refund);
         if (refundEvent != null && refundEvent.getStatus() == OrderDonBusinessEvent.Status.processing) {
@@ -588,17 +589,13 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             }
             return;
         }
-        String workerToken;
-        if (providerStatus == ProviderRefundStatus.SUCCESS) {
-            workerToken = refundEvent.getWorkerToken();
-            if (StrUtil.isBlank(workerToken)) {
-                throw new IllegalStateException("refund worker token is missing");
-            }
-        } else {
-            workerToken = orderDonTicketRecordService.reclaimExpiredRefund(order.getId(), leaseMinutes);
-            if (workerToken == null) {
-                return;
-            }
+        if (!requiresRefundLeaseReclaim(providerStatus)) {
+            return;
+        }
+        String workerToken = orderDonTicketRecordService.reclaimExpiredRefund(
+                order.getId(), leaseMinutes);
+        if (workerToken == null) {
+            return;
         }
 
         if (providerStatus == ProviderRefundStatus.FAILED) {
@@ -752,7 +749,16 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
     }
 
-    private enum ProviderRefundStatus {
+    static boolean requiresManualMultiQuantityRefund(OrderDon order) {
+        return order != null && (order.getType() == OrderDon.Type.PAYPAL
+                || order.getType() == OrderDon.Type.STRIPE);
+    }
+
+    static boolean requiresRefundLeaseReclaim(ProviderRefundStatus status) {
+        return status == ProviderRefundStatus.SUCCESS || status == ProviderRefundStatus.FAILED;
+    }
+
+    enum ProviderRefundStatus {
         SUCCESS,
         PROCESSING,
         FAILED,
@@ -796,7 +802,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             if (record.getStatus() != OrderDonTicketRecord.Status.refunding) {
                 throw BusinessRuntimeException.getInstance("订单车票状态异常,序号: {0}", record.getItemIndex());
             }
-            if (record.getRelationId() != null) {
+            if (currentTicketRelationId(record) != null) {
                 clearMultiQuantityTicket(orderDon, record);
             }
             if (!orderDonTicketRecordService.markRefunded(record.getId(), workerToken)) {
@@ -824,16 +830,17 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
     }
 
     private void clearMultiQuantityTicket(OrderDon orderDon, OrderDonTicketRecord record) {
+        Long relationId = currentTicketRelationId(record);
         OrderDonTicketRecord activeRecord = orderDonTicketRecordService.getActiveByRelationIdForUpdate(
-                record.getRelationId());
+                relationId);
         if (activeRecord == null || !activeRecord.getId().equals(record.getId())
                 || activeRecord.getStatus() != OrderDonTicketRecord.Status.refunding
                 || !orderDon.getId().equals(activeRecord.getOrderId())) {
-            throw BusinessRuntimeException.getInstance("车票分配记录已变更,relationId: {0}", record.getRelationId());
+            throw BusinessRuntimeException.getInstance("车票分配记录已变更,relationId: {0}", relationId);
         }
-        GroupsRelation relation = relationMapper.selectByIdForUpdate(record.getRelationId());
+        GroupsRelation relation = relationMapper.selectByIdForUpdate(relationId);
         if (relation == null) {
-            throw BusinessRuntimeException.getInstance("退款车票不存在,relationId: {0}", record.getRelationId());
+            throw BusinessRuntimeException.getInstance("退款车票不存在,relationId: {0}", relationId);
         }
         if (relation.getUserId() == null || relation.getUserId() == 0) {
             return;
@@ -841,7 +848,11 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         if (!isMultiQuantityTicketOwner(orderDon, relation)) {
             throw BusinessRuntimeException.getInstance("车票归属异常,relationId: {0}", relation.getId());
         }
-        relationClearService.clearTicket(relation, UserTicketClearedRecord.Source.refund);
+        relationClearService.clearRefundTicket(relation, activeRecord);
+    }
+
+    private Long currentTicketRelationId(OrderDonTicketRecord record) {
+        return record.getRelationId();
     }
 
     static boolean isMultiQuantityTicketOwner(OrderDon orderDon, GroupsRelation relation) {
@@ -1601,6 +1612,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         if (orderDon == null) {
             throw BusinessRuntimeException.getInstance("订单不存在");
         }
+        validateOrdinaryRefundRequest(orderDon, refundReq);
         BigDecimal orderDonRefundMoney = Optional.ofNullable(orderDon.getRefundMoney()).orElse(BigDecimal.ZERO);
         BigDecimal orderDonRefundBalance = Optional.ofNullable(orderDon.getRefundBalance()).orElse(BigDecimal.ZERO);
         String refundType = refundReq.getRefundType();
@@ -1730,6 +1742,40 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 //        checkMirrorRenewSku(orderDon);
     }
 
+    /**
+     * 普通订单退款仍由管理端选择退款方式,但金额字段必须与退款方式一致。
+     * 否则错误地把纯余额订单按 money 提交时,会清票并关闭订单,却不会退回余额。
+     */
+    private void validateOrdinaryRefundRequest(OrderDon orderDon, OrderRefundReq refundReq) {
+        String refundType = refundReq.getRefundType();
+        boolean supportedType = OrderRefundReq.RefundType.money.name().equals(refundType)
+                || OrderRefundReq.RefundType.balance.name().equals(refundType)
+                || OrderRefundReq.RefundType.bxj.name().equals(refundType);
+        if (!supportedType) {
+            throw BusinessRuntimeException.getInstance("退款类型不正确");
+        }
+
+        BigDecimal refundMoney = Optional.ofNullable(refundReq.getRefundMoney()).orElse(BigDecimal.ZERO);
+        BigDecimal refundBalance = Optional.ofNullable(refundReq.getRefundBalance()).orElse(BigDecimal.ZERO);
+        if (refundMoney.signum() < 0 || refundBalance.signum() < 0) {
+            throw BusinessRuntimeException.getInstance("退款金额不能小于0");
+        }
+        // Existing user-side expired-ticket refunds use money + refundBalance with
+        // isOnly=true to return the balance-funded portion while refunding cash.
+        // Reject only the malformed management request that carries a balance
+        // amount without opting into that established mixed-refund path.
+        if (OrderRefundReq.RefundType.money.name().equals(refundType)
+                && !Boolean.TRUE.equals(refundReq.getIsOnly())
+                && (refundBalance.signum() > 0 || orderDon.getType() == OrderDon.Type.BALANCE)) {
+            throw BusinessRuntimeException.getInstance("退款类型与余额退款金额不匹配");
+        }
+        if (OrderRefundReq.RefundType.balance.name().equals(refundType)
+                && !Boolean.TRUE.equals(refundReq.getIsOnly())
+                && refundMoney.signum() <= 0 && refundBalance.signum() > 0) {
+            throw BusinessRuntimeException.getInstance("退至余额的金额字段不正确");
+        }
+    }
+
     /**
      * 退款后 处理关联
      */
@@ -1742,10 +1788,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             goodsDonStockService.upStockRelate(null, orderDon.getId(), "refund");
         }
         try {
-            if (!multiQuantityOrder && !orderDon.getIsDp()) {
+            if (!multiQuantityOrder && !Boolean.TRUE.equals(orderDon.getIsDp())) {
                 GroupsRelation relation = relationMapper.selectById(orderDon.getRelationId());
                 //冻结车票 清除
-                if (relation != null && relation.getIsFrozen()) {
+                if (relation != null && Boolean.TRUE.equals(relation.getIsFrozen())) {
                     Optional.ofNullable(receivedRecordMapper.selectOne(Wrappers.lambdaQuery(AccountNetflixFrozenTicketReceivedRecord.class).eq(AccountNetflixFrozenTicketReceivedRecord::getRelationId, relation.getId()).last("limit 1")))
                             .ifPresent(rr -> {
                                 Long sendRelationId = rr.getSendRelationId();
@@ -1763,7 +1809,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             log.error("清除车票错误:{}", StringUtil.getErrorText(e));
         }
         //若存在优惠券使用,退还优惠券
-        if (orderDon.getCouponUserId() != 0) {
+        if (orderDon.getCouponUserId() != null && orderDon.getCouponUserId() != 0L) {
             couponFontService.updateOrderDonCouponStatus(orderDon);
         }
         //退等比例
@@ -1799,7 +1845,8 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 //        }
 
 	    //扣除 该笔订单产生的返现银河币
-	    if (orderDon.getMoney().compareTo(BigDecimal.ZERO) > 0) {
+	    if (Optional.ofNullable(orderDon.getMoney()).orElse(BigDecimal.ZERO)
+			    .compareTo(BigDecimal.ZERO) > 0) {
 		    userGalaxyCoinService.subOrderGenerateGalaxyCoin(orderDon.getId(), OrderDonGalaxyCoinRecord.Source.ordinary);
 	    }
 
@@ -1810,7 +1857,8 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
         try {
             marketFrontService.subSpecificChanceNum(orderDon.getUserId(), orderDon.getId(), orderDon.getGoodsId(), orderDon.getUpdateTime());
-            if (goodsDon.getType() == 2 && !orderDon.getIsDistribute()) {
+            if (goodsDon != null && Objects.equals(goodsDon.getType(), 2)
+                    && !Boolean.TRUE.equals(orderDon.getIsDistribute())) {
                 equipmentDistributeService.subDistributeBenefitsChance(orderDon.getUserId(), orderDon.getId());
                 //扣除用户自身实物权益
                 userRealOrderBenefitsService.deductUserRealGoodsBenefits(orderDon.getId());
@@ -1819,9 +1867,12 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             log.error("wx减少抽奖机会错误,orderId:{},error:{}", orderDon.getId(), e);
         }
         if (OrderRefundReq.RefundType.balance.name().equals(refundType)) {
-            if (isOnly == null) {
-                BigDecimal balanceAmount = Optional.ofNullable(refundBalance)
-                        .orElse(Optional.ofNullable(refundMoney).orElse(BigDecimal.ZERO));
+            if (!Boolean.TRUE.equals(isOnly)) {
+                // 普通订单选择“退还至余额”时,退款数额放在 refundMoney;
+                // 多件整单退款的余额部分由服务端固定写入 refundBalance。
+                BigDecimal balanceAmount = multiQuantityOrder
+                        ? Optional.ofNullable(refundBalance).orElse(BigDecimal.ZERO)
+                        : Optional.ofNullable(refundMoney).orElse(BigDecimal.ZERO);
                 if (balanceAmount.compareTo(BigDecimal.ZERO) > 0) {
                     userBenefitsService.addUserBalance(orderDon.getUserId(), balanceAmount,
                             UserBalanceSourceRecord.Source.refund, orderDon.getYhsId(),
@@ -1839,7 +1890,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             deductUserMirrorShopMoney(orderDon);
         }
 
-        if (orderDon.getIsFixedDistribute()) {
+        if (Boolean.TRUE.equals(orderDon.getIsFixedDistribute())) {
             //商务提成 因订单退款去掉
             UserDistributeBusinessOrderDonRecord record = businessOrderDonRecordMapper.selectOne(Wrappers.lambdaQuery(UserDistributeBusinessOrderDonRecord.class)
                     .eq(UserDistributeBusinessOrderDonRecord::getOrderId, orderDon.getId())
@@ -1863,7 +1914,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
             }
         }
         //是否是优惠加购关联订单
-        if (orderDon.getIsDp()) {
+        if (Boolean.TRUE.equals(orderDon.getIsDp())) {
             OrderDiscountPlusPurchaseSkuRelation discountPlusPurchaseSkuRelation = orderDiscountPlusPurchaseSkuRelationMapper.selectOne(Wrappers.lambdaQuery(OrderDiscountPlusPurchaseSkuRelation.class)
                     .eq(OrderDiscountPlusPurchaseSkuRelation::getOrderId, orderDon.getId()).last("limit 1"));
             if (discountPlusPurchaseSkuRelation != null) {
@@ -1946,7 +1997,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         handleSubRewards(orderDon);
 
         //是否是用户套餐优惠权益订单
-        if (orderDon.getGoodsId() == 15) {
+        if (Objects.equals(orderDon.getGoodsId(), 15L)) {
             UserBenefitsComboRelationRecord userBenefitsComboRelationRecord = userBenefitsComboRelationRecordMapper.selectOne(Wrappers.lambdaQuery(UserBenefitsComboRelationRecord.class)
                     .eq(UserBenefitsComboRelationRecord::getOrderId, orderDon.getId()).last("limit 1"));
             if (userBenefitsComboRelationRecord != null) {
@@ -1965,7 +2016,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         //HBO GO退款,车队上架
         setUpIfEmpty(orderDon.getGoodsId(), orderDon.getRelationId());
 
-        if (orderDon.getGoodsId() == Constant.NETFLIX_GID) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.NETFLIX_GID)) {
             List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
             NetflixUserAccountSubmitRecoverRecord recoverRecord = netflixUserAccountSubmitRecoverRecordMapper.selectOne(Wrappers.lambdaQuery(NetflixUserAccountSubmitRecoverRecord.class)
                     .eq(NetflixUserAccountSubmitRecoverRecord::getRelationId, orderDon.getRelationId())
@@ -1979,12 +2030,15 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
 
         //GPT独立会员
-        if (orderDon.getGoodsId() == Constant.GPT_PLUS_GID && sku.getNum() == 1) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.GPT_PLUS_GID)
+                && sku != null && Objects.equals(sku.getNum(), 1)) {
             subRechargeNumOrder(orderDon.getUserId(), orderDon.getRelationId(), sku.getMonths(), operator);
         }
 
         //GPT独立会员退款 或代充退款 退还平台发券
-        if ((orderDon.getGoodsId() == Constant.GPT_PLUS_GID && sku.getNum() == 1) || orderDon.getGoodsId() == Constant.GPT_RECHARGE_GOODS_ID) {
+        if ((Objects.equals(orderDon.getGoodsId(), Constant.GPT_PLUS_GID)
+                && sku != null && Objects.equals(sku.getNum(), 1))
+                || Objects.equals(orderDon.getGoodsId(), Constant.GPT_RECHARGE_GOODS_ID)) {
             //若不存在对应的车票 则清除优惠券
             List<Long> skuIds = goodsDonSkuMapper.selectIdpGptAndRechargeSkuIds(Constant.GPT_PLUS_GID, Constant.GPT_RECHARGE_GOODS_ID);
             List<Long> userIdList = userBindRelationService.getRelationUserIdList(orderDon.getUserId(), null);
@@ -2005,7 +2059,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         }
 
         //claude code 退款
-        if (orderDon.getGoodsId() == Constant.CLAUDE_CODE_GOODS_ID) {
+        if (Objects.equals(orderDon.getGoodsId(), Constant.CLAUDE_CODE_GOODS_ID)) {
             DistributeWaitingSendPoints distributeWaitingSendPoints = distributeWaitingSendPointsMapper.selectOne(Wrappers.lambdaQuery(DistributeWaitingSendPoints.class).eq(DistributeWaitingSendPoints::getOrderId, orderDon.getId()).last("limit 1"));
             if (distributeWaitingSendPoints != null) {
                 if (distributeWaitingSendPoints.getSendStatus() == DistributeWaitingSendPoints.Status.close) {
@@ -2022,13 +2076,13 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
 
     //处理下级渠道分销提成
     static boolean shouldRestoreStock(GoodsDon goodsDon, boolean multiQuantityOrder) {
-        return !multiQuantityOrder || goodsDon == null || goodsDon.getType() != 1;
+        return !multiQuantityOrder || goodsDon == null || !Objects.equals(goodsDon.getType(), 1);
     }
 
     public void handleSubRewards(OrderDon orderDon) {
         //是否是分销订单
         //处理下级渠道分销提成
-        if (orderDon.getIsDistribute()) {
+        if (orderDon != null && Boolean.TRUE.equals(orderDon.getIsDistribute())) {
             UserDistributeSub subRelate = userDistributeSubMapper.getSubRelate(orderDon.getUserId());
             if (subRelate != null) {
                 List<UserDistributeSubWaiting> subWaitings = subWaitingMapper.selectList(Wrappers.lambdaQuery(UserDistributeSubWaiting.class)
@@ -2061,7 +2115,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
      * 退款 分销提成积分扣除
      */
     public void deductDsPoints(OrderDon orderDon) {
-        if (orderDon.getIsDistribute()) {
+        if (orderDon != null && Boolean.TRUE.equals(orderDon.getIsDistribute())) {
             DistributeWaitingSendPoints distributeWaitingSendPoints = distributeWaitingSendPointsMapper.selectOne(Wrappers.lambdaQuery(DistributeWaitingSendPoints.class).eq(DistributeWaitingSendPoints::getOrderId, orderDon.getId()).eq(DistributeWaitingSendPoints::getIsVip,Boolean.FALSE).last("limit 1"));
             if (distributeWaitingSendPoints != null) {
                 BigDecimal money = orderDon.getMoney();
@@ -2071,7 +2125,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                 BigDecimal points = distributeWaitingSendPoints.getPoints();
                 if (sendStatus == DistributeWaitingSendPoints.Status.waiting) {
                     //实物设备退款就清0
-                    if (refundMoney.compareTo(money) == 0 || orderDon.getGoodsId() == 15) {
+                    if (refundMoney.compareTo(money) == 0 || Objects.equals(orderDon.getGoodsId(), 15L)) {
                         //待发送状态 全额退款
                         distributeWaitingSendPoints.setSendStatus(DistributeWaitingSendPoints.Status.close);
                         distributeWaitingSendPoints.setRefundPoints(points);
@@ -2092,9 +2146,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                         BigDecimal remainPoints = points.subtract(needSubPoints);
                         distributeWaitingSendPoints.setPoints(remainPoints);
                         //原先获取积分也扣除
-                        if (distributeWaitingSendPoints.getOriPoints().compareTo(BigDecimal.ZERO) > 0) {
-                            BigDecimal oriNeedSubPoints = distributeWaitingSendPoints.getOriPoints().multiply(refundRate);
-                            distributeWaitingSendPoints.setOriPoints(distributeWaitingSendPoints.getOriPoints().subtract(oriNeedSubPoints));
+                        BigDecimal oriPoints = distributeWaitingSendPoints.getOriPoints();
+                        if (oriPoints != null && oriPoints.compareTo(BigDecimal.ZERO) > 0) {
+                            BigDecimal oriNeedSubPoints = oriPoints.multiply(refundRate);
+                            distributeWaitingSendPoints.setOriPoints(oriPoints.subtract(oriNeedSubPoints));
                         }
                         distributeWaitingSendPointsMapper.updateById(distributeWaitingSendPoints);
                     }
@@ -2102,7 +2157,7 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                     //已发送的提成 部分退款 全额退款 需要扣除用户积分
                     //扣除固定推广者积分
                     //实物设备退款就清0
-                    if (refundMoney.compareTo(money) == 0 || orderDon.getGoodsId() == 15) {
+                    if (refundMoney.compareTo(money) == 0 || Objects.equals(orderDon.getGoodsId(), 15L)) {
                         userBenefitsService.deductDistributePoints(orderDon.getId(), distributeWaitingSendPoints.getSharedId(), points);
                         distributeWaitingSendPoints.setRefundPoints(points);
                         distributeWaitingSendPoints.setPoints(BigDecimal.ZERO);
@@ -2123,9 +2178,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
                         distributeWaitingSendPoints.setRefundPoints(needSubPoints);
                         distributeWaitingSendPoints.setPoints(points.subtract(needSubPoints));
                         //原先获取积分也扣除
-                        if (distributeWaitingSendPoints.getOriPoints().compareTo(BigDecimal.ZERO) > 0) {
-                            BigDecimal oriNeedSubPoints = distributeWaitingSendPoints.getOriPoints().multiply(refundRate);
-                            distributeWaitingSendPoints.setOriPoints(distributeWaitingSendPoints.getOriPoints().subtract(oriNeedSubPoints));
+                        BigDecimal oriPoints = distributeWaitingSendPoints.getOriPoints();
+                        if (oriPoints != null && oriPoints.compareTo(BigDecimal.ZERO) > 0) {
+                            BigDecimal oriNeedSubPoints = oriPoints.multiply(refundRate);
+                            distributeWaitingSendPoints.setOriPoints(oriPoints.subtract(oriNeedSubPoints));
                         }
                         distributeWaitingSendPointsMapper.updateById(distributeWaitingSendPoints);
                     }

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

@@ -3,6 +3,7 @@ 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 {
 
@@ -18,5 +19,9 @@ public interface MultiQuantityOrderService {
 
     int deliver(Long orderId);
 
+    MultiQuantityRedeliveryResult redeliverManually(Long orderId);
+
     boolean completeIfAllTicketsAvailable(Long orderId);
+
+    OrderDon findOrderByRelationId(Long relationId);
 }

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

@@ -14,6 +14,10 @@ public interface OrderDonTicketRecordService {
 
     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);

+ 221 - 29
netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java

@@ -5,6 +5,7 @@ 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;
@@ -16,6 +17,7 @@ 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;
@@ -45,7 +47,9 @@ 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
@@ -54,10 +58,25 @@ import java.util.UUID;
 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;
@@ -185,16 +204,16 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
         order.setStatus(paidStatus);
         afterCommit(() -> {
             try {
-                runOrderEvents(order.getId());
+                deliver(order.getId());
             } catch (Exception e) {
-                // Ticket delivery must not be blocked by a failed order-level side effect.
-                log.error("Immediate multi-quantity order events failed, orderId:{}", order.getId(), e);
+                // Payment is already committed. Durable ticket slots allow safe manual redelivery.
+                log.error("Immediate multi-quantity ticket delivery failed, orderId:{}", order.getId(), e);
             }
             try {
-                deliver(order.getId());
+                runOrderEvents(order.getId());
             } catch (Exception e) {
-                // Payment is already committed. Durable ticket slots let the recovery job retry safely.
-                log.error("Immediate multi-quantity ticket delivery failed, orderId:{}", order.getId(), e);
+                // Distribution and other order-level side effects never gate ticket delivery.
+                log.error("Immediate multi-quantity order events failed, orderId:{}", order.getId(), e);
             }
         });
     }
@@ -237,7 +256,8 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
                 continue;
             }
             try {
-                requiresNew(() -> deliverClaimedSlot(order, goods, sku, record.getId(), token));
+                requiresNew(() -> deliverClaimedSlot(order, goods, sku, record.getId(),
+                        record.getRelationId(), token));
                 delivered++;
             } catch (Exception e) {
                 requiresNew(() -> ticketRecordService.markFailed(
@@ -250,6 +270,126 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
         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) {
@@ -290,12 +430,35 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
     }
 
     private void deliverClaimedSlot(OrderDon order, GoodsDon goods, GoodsDonSku sku,
-                                    Long recordId, String workerToken) {
+                                    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.
-		GroupsRelation relation = exchangeCodeService.getRelationNoOrderOnce(sku, order.getUserId(), 0L);
+		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");
 		}
@@ -303,14 +466,54 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
 		if (!ticketRecordService.markSuccess(recordId, relation.getId(), workerToken)) {
 			throw new IllegalStateException("ticket slot claim expired");
 		}
-        OrderDon oneTicketOrder = copyForOneTicket(order, relation.getId());
         try {
-            expiryRecordService.syncGroupsRelationExpiryRecord(oneTicketOrder, goods, sku);
+			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
@@ -420,8 +623,13 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
         if (config == null || config.getSysValue() == null || config.getSysValue().isBlank()) {
             throw new IllegalStateException("galaxy coin cashback configuration is missing");
         }
-        GalaxyCoinConsumeRateDto rate = com.cyksj.common.util.Jsons.parseObject(
-                (Object) config.getSysValue(), GalaxyCoinConsumeRateDto.class);
+        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");
@@ -433,22 +641,6 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
                 com.cyksj.model.entity.OrderDonGalaxyCoinRecord.Source.ordinary);
     }
 
-    private OrderDon copyForOneTicket(OrderDon source, Long relationId) {
-        OrderDon copy = new OrderDon();
-        copy.setId(source.getId());
-        copy.setOrderNo(source.getOrderNo());
-        copy.setGoodsId(source.getGoodsId());
-        copy.setSkuId(source.getSkuId());
-        copy.setUserId(source.getUserId());
-        copy.setPayTime(source.getPayTime());
-        copy.setCreatedTime(source.getCreatedTime());
-        copy.setRelationId(relationId);
-        copy.setNum(1);
-        copy.setOrderType(1);
-        copy.setYhsId(0L);
-        return copy;
-    }
-
     private void afterCommit(Runnable action) {
         if (!TransactionSynchronizationManager.isSynchronizationActive()) {
             action.run();

+ 36 - 11
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java

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

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

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

+ 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

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

+ 21 - 4
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationClearServiceImpl.java

@@ -64,16 +64,33 @@ public class GroupRelationClearServiceImpl implements GroupRelationClearService
 
 	@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 = orderDonTicketRecordService
-				.getActiveByRelationIdForUpdate(relationId);
+		OrderDonTicketRecord activeTicket = lockedActiveTicket != null
+				? lockedActiveTicket
+				: orderDonTicketRecordService.getActiveByRelationIdForUpdate(relationId);
 		if (activeTicket != null
 				&& activeTicket.getStatus() == OrderDonTicketRecord.Status.refunding
-				&& source != UserTicketClearedRecord.Source.refund) {
-			throw new IllegalStateException("ticket refund is processing");
+				&& (source != UserTicketClearedRecord.Source.refund || lockedActiveTicket == null)) {
+			throw new IllegalStateException("refunding ticket must be cleared by its locked slot");
 		}
 		//记录用户被清除车票历史记录
 		GroupsRelationView groupsRelation = beanSearcher.searchFirst(GroupsRelationView.class,

+ 78 - 48
netflix-service/src/main/java/com/cyksj/service/relation/impl/GroupRelationFrontServiceImpl.java

@@ -270,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)) {
@@ -286,6 +283,12 @@ 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)
@@ -294,7 +297,7 @@ public class GroupRelationFrontServiceImpl implements GroupRelationFrontService
 			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)" +
 					" or exists(select 1 from order_don_ticket_record r inner join order_don o on o.id = r.order_id" +
-					" where r.relation_id = gr.id and r.status = 'success' and o.user_id = gr.user_id and o.id = %s))",
+					" 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
@@ -398,23 +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"));
-				if (orderDon == null) {
-					OrderDonTicketRecord multiTicket = orderDonTicketRecordService.getActiveByRelationId(ticket.getRelationId());
-					if (multiTicket != null) {
-						orderDon = orderDonMapper.selectOne(Wrappers.lambdaQuery(OrderDon.class)
-								.eq(OrderDon::getId, multiTicket.getOrderId())
-								.in(OrderDon::getUserId, userIds)
-								.notIn(OrderDon::getStatus, Constant.noOrderAllStatus)
-								.last("limit 1"));
-					}
-				}
+				OrderDon orderDon = getCurrentTicketOrder(ticket.getRelationId(), userIds);
 				Long zhSkuId = ticket.getSkuId();
 				if (orderDon == null) {
 					if (ticket.getGoodsId() == Constant.NETFLIX_GID) {
@@ -643,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) {
@@ -725,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();
@@ -752,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) {
@@ -784,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();
@@ -2138,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());
 			}
@@ -2153,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);

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

@@ -77,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) {

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

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

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

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