Selaa lähdekoodia

Merge branch 'codex/multi-quantity-orders' into pre

zoujiajian 2 viikkoa sitten
vanhempi
sitoutus
b5ab52efb4

+ 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, coalesce(r.active_relation_id, r.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 = coalesce(r.active_relation_id, r.relation_id)",
             "left join groups_trips gt on gt.id = gr.groups_id",
             "left join account a on a.id = gt.account_id",
             "where r.order_id = #{orderId}",

+ 37 - 9
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,42 @@ public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRec
     @Select("select count(*) from order_don_ticket_record where order_id = #{orderId}")
     int countByOrderId(@Param("orderId") Long orderId);
 
+    @Select({"<script>",
+            "select order_id as orderId, count(*) as ticketTotalNum,",
+            "sum(case when status = 'success' then 1 else 0 end) as ticketSuccessNum,",
+            "sum(case when status = 'processing' then 1 else 0 end) as ticketProcessingNum,",
+            "sum(case when status in ('pending', 'failed') or (status = 'processing' and processing_time < 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}, active_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}")
@@ -79,33 +107,33 @@ public interface OrderDonTicketRecordMapper extends BaseMapper<OrderDonTicketRec
     int countAvailable(@Param("orderId") Long orderId);
 
     @Update("update order_don_ticket_record r " +
-            "inner join groups_relation old_gr on old_gr.id = r.active_relation_id " +
+            "inner join groups_relation old_gr on old_gr.id = coalesce(r.active_relation_id, r.relation_id) " +
             "inner join groups_trips old_gt on old_gt.id = old_gr.groups_id " +
             "inner join groups_relation new_gr on new_gr.id = #{newRelationId} " +
             "inner join groups_trips new_gt on new_gt.id = new_gr.groups_id " +
-            "set r.relation_id = #{newRelationId}, r.update_time = now() " +
-            "where r.active_relation_id = #{oldRelationId} and r.status = 'success' " +
+            "set r.active_relation_id = #{newRelationId}, r.update_time = now() " +
+            "where coalesce(r.active_relation_id, r.relation_id) = #{oldRelationId} and r.status = 'success' " +
             "and old_gt.sku_id = new_gt.sku_id")
     int replaceActiveRelation(@Param("oldRelationId") Long oldRelationId,
                               @Param("newRelationId") Long newRelationId);
 
-    @Select("select r.* from order_don_ticket_record r inner join groups_relation gr on gr.id = r.relation_id where gr.groups_id = #{groupsId} and r.status = 'success' order by r.item_index")
+    @Select("select r.* from order_don_ticket_record r inner join groups_relation gr on gr.id = coalesce(r.active_relation_id, r.relation_id) where gr.groups_id = #{groupsId} and r.status = 'success' order by r.item_index")
     List<OrderDonTicketRecord> selectSuccessfulByGroupsId(@Param("groupsId") Long groupsId);
 
     @Select("select distinct r.order_id from order_don_ticket_record r " +
             "inner join order_don o on o.id = r.order_id " +
-            "inner join groups_relation gr on gr.id = r.relation_id " +
+            "inner join groups_relation gr on gr.id = coalesce(r.active_relation_id, r.relation_id) " +
             "where gr.groups_id = #{groupsId} and r.status = 'success' " +
             "and o.status in ('hasPayment', 'complete') order by r.order_id")
     List<Long> selectSuccessfulOrderIdsByGroupsId(@Param("groupsId") Long groupsId);
 
-    @Update("update order_don_ticket_record set status = 'cleared', worker_token = null, processing_time = null, error_msg = null, update_time = now() where active_relation_id = #{relationId} and status = 'success'")
+    @Update("update order_don_ticket_record set status = 'cleared', worker_token = null, processing_time = null, error_msg = null, update_time = now() where coalesce(active_relation_id, 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 coalesce(active_relation_id, relation_id) = #{relationId} limit 1")
     OrderDonTicketRecord selectActiveByRelationId(@Param("relationId") Long relationId);
 
-    @Select("select * from order_don_ticket_record where active_relation_id = #{relationId} limit 1 for update")
+    @Select("select * from order_don_ticket_record where coalesce(active_relation_id, relation_id) = #{relationId} limit 1 for update")
     OrderDonTicketRecord selectActiveByRelationIdForUpdate(@Param("relationId") Long relationId);
 
     @Select("select count(*) from order_don_ticket_record where order_id = #{orderId} and status = #{status}")

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

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

@@ -21,6 +21,9 @@ public class OrderDonTicketRecord extends BaseEntity {
 
     private Long relationId;
 
+    /** Current ticket relation; changes when customer service replaces a ticket. */
+    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;
+}

+ 3 - 1
netflix-service/src/main/java/com/cyksj/service/mange/group/CmsGroupServiceImpl.java

@@ -244,7 +244,9 @@ public class CmsGroupServiceImpl extends ServiceImpl<GroupsMapper,GroupsTrips> i
         Boolean isPrdEnv = envCommonService.isPrdEnv();
         java.util.Map<Long, OrderDonTicketRecord> multiTicketByRelationId =
                 orderDonTicketRecordService.listSuccessfulByGroupsId(groupsTrips.getId()).stream()
-                        .collect(Collectors.toMap(OrderDonTicketRecord::getRelationId, record -> record));
+                        .collect(Collectors.toMap(record -> record.getActiveRelationId() == null
+                                        ? record.getRelationId() : record.getActiveRelationId(),
+                                record -> record));
         for (int i = 0; i < groupsRelations.size(); i++) {
             GroupsRelation relation = groupsRelations.get(i);
             if (relation.getExpiryTime() == null) {

+ 10 - 5
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java

@@ -802,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)) {
@@ -830,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;
@@ -850,6 +851,10 @@ public class CmsOrderDonServiceImpl extends ServiceImpl<OrderDonMapper,OrderDon>
         relationClearService.clearTicket(relation, UserTicketClearedRecord.Source.refund);
     }
 
+    private Long currentTicketRelationId(OrderDonTicketRecord record) {
+        return record.getActiveRelationId() == null ? record.getRelationId() : record.getActiveRelationId();
+    }
+
     static boolean isMultiQuantityTicketOwner(OrderDon orderDon, GroupsRelation relation) {
         return orderDon != null && relation != null && orderDon.getUserId() != null
                 && orderDon.getUserId().equals(relation.getUserId());

+ 3 - 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,7 @@ public interface MultiQuantityOrderService {
 
     int deliver(Long orderId);
 
+    MultiQuantityRedeliveryResult redeliverManually(Long orderId);
+
     boolean completeIfAllTicketsAvailable(Long orderId);
 }

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

+ 129 - 3
netflix-service/src/main/java/com/cyksj/service/order/impl/MultiQuantityOrderServiceImpl.java

@@ -16,6 +16,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;
@@ -54,6 +55,7 @@ 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;
 
     private final OrderDonMapper orderDonMapper;
     private final GoodsDonMapper goodsDonMapper;
@@ -187,7 +189,7 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
             try {
                 deliver(order.getId());
             } catch (Exception e) {
-                // Payment is already committed. Durable ticket slots let the recovery job retry safely.
+                // Payment is already committed. Durable ticket slots allow safe manual redelivery.
                 log.error("Immediate multi-quantity ticket delivery failed, orderId:{}", order.getId(), e);
             }
             try {
@@ -250,6 +252,125 @@ 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(), 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) {
@@ -421,8 +542,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");

+ 15 - 0
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);

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

@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
 
 /**
  * Only recovers provider-refund results that remained unknown after submission.
- * Ticket delivery recovery stays exclusively in netflix-job.
+ * Ticket delivery failures are handled manually from the order-management page.
  */
 @Slf4j
 @Component

+ 50 - 0
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,36 @@ 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();
+			int undeliveredNum = multiQuantityOrder ? Math.max(0, data.getNum() - successNum) : 0;
+			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;
+			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 +414,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);
+	}
+
 	/**
 	 * 用户退款订单列表
 	 */