Bläddra i källkod

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

zoujiajian 1 vecka sedan
förälder
incheckning
a0c46e8895

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

@@ -399,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
@@ -472,7 +472,7 @@
             #{item}
         </foreach>
         and gr.status in ('validity','outside')
-        and gr.cmt != 2
+        and (gr.cmt is null or gr.cmt != 2)
         and gr.is_vip = 0
         and (gr.expiry_time is null or gr.expiry_time > now() or (gdk.is_mirror and gr.expiry_time >
         DATE_ADD(now(),interval -7 day)))

+ 8 - 3
netflix-service/src/main/java/com/cyksj/service/exchange/impl/ExchangeCodeServiceImpl.java

@@ -558,9 +558,14 @@ 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,

+ 41 - 3
netflix-service/src/main/java/com/cyksj/service/mange/impl/CmsOrderDonServiceImpl.java

@@ -1612,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();
@@ -1741,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("退至余额的金额字段不正确");
+        }
+    }
+
     /**
      * 退款后 处理关联
      */
@@ -1832,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(),

+ 72 - 3
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;
@@ -46,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
@@ -60,6 +63,7 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
     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;
@@ -239,7 +243,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(
@@ -299,7 +304,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));
                 deliveredNow++;
             } catch (Exception e) {
                 requiresNew(() -> ticketRecordService.markFailed(
@@ -411,10 +417,32 @@ 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.
 		Long yhsId = order.getYhsId() == null ? 0L : order.getYhsId();
 		GroupsRelation relation = exchangeCodeService.getRelationNoOrderOnce(sku, order.getUserId(), yhsId);
@@ -432,6 +460,47 @@ public class MultiQuantityOrderServiceImpl implements MultiQuantityOrderService
 		}
 	}
 
+    /**
+     * 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

+ 8 - 1
netflix-service/src/main/java/com/cyksj/service/order/impl/OrderDonServiceImpl.java

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

+ 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

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

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

@@ -278,13 +278,14 @@ public class CmsOrderController {
 					&& 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;
+			int undeliveredNum = multiQuantityOrder && paidOrder && !terminalTicketState
+					? Math.max(0, data.getNum() - successNum) : 0;
 			boolean hasRedeliverableSlot = ticketCount != null
 					&& positive(ticketCount.getTicketRedeliverableNum());
 			data.setMultiQuantityOrder(multiQuantityOrder);

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