From c0c1232768dbe16d5bea863c8bfe5fca6796146f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Sat, 7 Feb 2026 17:26:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(shop):=20=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E9=94=80=E4=BD=A3=E9=87=91=E8=A7=A3=E5=86=BB=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E6=89=A9=E5=B1=95=E8=B5=84=E9=87=91=E6=B5=81=E5=8A=A8?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ShopDealerCapital 和 ShopDealerCapitalParam 中添加资金流动类型 50(佣金解冻) - 新增 DealerCommissionUnfreeze10584Task 定时任务处理分销佣金解冻逻辑 - 实现送水套餐和非送水套餐的差异化解冻规则 - 添加基于订单状态和水票配送状态的解冻条件判断 - 实现幂等性检查防止重复解冻操作 - 添加分布式锁确保并发安全的解冻处理 - 记录解冻流水作为佣金解冻的标记凭证 --- .../DealerCommissionUnfreeze10584Task.java | 345 ++++++++++++++++++ .../shop/entity/ShopDealerCapital.java | 2 +- .../shop/param/ShopDealerCapitalParam.java | 2 +- 3 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java diff --git a/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java new file mode 100644 index 0000000..aca651a --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/task/DealerCommissionUnfreeze10584Task.java @@ -0,0 +1,345 @@ +package com.gxwebsoft.glt.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.common.core.annotation.IgnoreTenant; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.entity.GltUserTicket; +import com.gxwebsoft.glt.service.GltTicketOrderService; +import com.gxwebsoft.glt.service.GltUserTicketService; +import com.gxwebsoft.shop.entity.ShopDealerCapital; +import com.gxwebsoft.shop.entity.ShopDealerUser; +import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.service.ShopDealerCapitalService; +import com.gxwebsoft.shop.service.ShopDealerUserService; +import com.gxwebsoft.shop.service.ShopOrderService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 租户10584:分销佣金解冻任务 + * + *

规则:

+ *

1) 送水套餐(formId=10074):订单号关联的水票产生了第一次送水订单,且该第一次送水订单状态=已完成(40) -> 解冻。

+ *

2) 非送水套餐(formId!=10074):订单已确认收货(orderStatus=1) -> 解冻。

+ * + *

实现策略:以 ShopDealerCapital(flowType=10) 的“佣金明细”为解冻粒度, + * 每条佣金明细对应生成一条 ShopDealerCapital(flowType=50) 作为幂等标记,并执行 + * ShopDealerUser.freezeMoney -> ShopDealerUser.money 的转移。

+ */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "dealer.commission.unfreeze10584", name = "enabled", havingValue = "true", matchIfMissing = true) +public class DealerCommissionUnfreeze10584Task { + + private static final int TENANT_ID = 10584; + private static final int WATER_FORM_ID = 10074; + + private static final int ORDER_STATUS_CONFIRMED_RECEIVE = 1; + + private static final int MAX_ELIGIBLE_ORDER_NOS_PER_RUN = 200; + private static final int MAX_ELIGIBLE_TICKET_ORDERS_PER_RUN = 200; + private static final int MAX_CAPITALS_PER_RUN = 500; + + @Resource + private TransactionTemplate transactionTemplate; + + @Resource + private ShopOrderService shopOrderService; + + @Resource + private ShopDealerCapitalService shopDealerCapitalService; + + @Resource + private ShopDealerUserService shopDealerUserService; + + @Resource + private GltUserTicketService gltUserTicketService; + + @Resource + private GltTicketOrderService gltTicketOrderService; + + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled(cron = "${dealer.commission.unfreeze10584.cron:0 */1 * * * ?}") + @IgnoreTenant("定时任务无登录态,需忽略租户隔离;内部使用 tenantId=10584 精确过滤") + public void run() { + if (!running.compareAndSet(false, true)) { + log.warn("分销佣金解冻任务仍在执行中,本轮跳过 - tenantId={}", TENANT_ID); + return; + } + + try { + // 先按“最近确认收货”的订单扫描,避免总是卡在很早的历史订单上。 + Set eligibleOrderNos = new HashSet<>(); + eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(true)); + eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder()); + + if (eligibleOrderNos.isEmpty()) { + return; + } + + List capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos); + if (capitals.isEmpty()) { + // 若本轮没有取到佣金明细,回退再按“最早确认收货”的订单扫一轮,尽量覆盖历史遗留未解冻。 + eligibleOrderNos.clear(); + eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(false)); + eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder()); + capitals = findCapitalsByEligibleOrderNos(eligibleOrderNos); + } + + if (capitals.isEmpty()) { + return; + } + + int unfrozen = 0; + for (ShopDealerCapital cap : capitals) { + try { + boolean ok = unfreezeOneCapitalIfNeeded(cap); + if (ok) { + unfrozen++; + } + } catch (Exception e) { + log.error("解冻佣金失败,将在下次任务重试 - tenantId={}, capitalId={}, orderNo={}, userId={}", + TENANT_ID, cap != null ? cap.getId() : null, cap != null ? cap.getOrderNo() : null, cap != null ? cap.getUserId() : null, e); + } + } + + if (unfrozen > 0) { + log.info("分销佣金解冻完成 - tenantId={}, eligibleOrderNos={}, scannedCapitals={}, unfrozen={}", + TENANT_ID, eligibleOrderNos.size(), capitals.size(), unfrozen); + } + } finally { + running.set(false); + } + } + + private List findEligibleNonWaterOrderNos(boolean newestFirst) { + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getPayStatus, true) + .eq(ShopOrder::getOrderStatus, ORDER_STATUS_CONFIRMED_RECEIVE) + .and(w -> w.ne(ShopOrder::getFormId, WATER_FORM_ID).or().isNull(ShopOrder::getFormId)) + .isNotNull(ShopOrder::getOrderNo); + + if (newestFirst) { + qw.orderByDesc(ShopOrder::getUpdateTime).orderByDesc(ShopOrder::getOrderId); + } else { + qw.orderByAsc(ShopOrder::getUpdateTime).orderByAsc(ShopOrder::getOrderId); + } + qw.last("limit " + MAX_ELIGIBLE_ORDER_NOS_PER_RUN); + + return shopOrderService.list(qw).stream() + .map(ShopOrder::getOrderNo) + .filter(s -> s != null && !s.isBlank()) + .toList(); + } + + private Set findEligibleWaterOrderNosByFirstFinishedTicketOrder() { + // 缓存减少 DB 往返:userTicketId -> 是否“第一次送水单已完成” + Map firstFinishedCache = new HashMap<>(); + Map userTicketOrderNoCache = new HashMap<>(); + + List finishedTicketOrders = gltTicketOrderService.list( + new LambdaQueryWrapper() + .eq(GltTicketOrder::getTenantId, TENANT_ID) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getDeliveryStatus, GltTicketOrderService.DELIVERY_STATUS_FINISHED) + .isNotNull(GltTicketOrder::getUserTicketId) + .orderByDesc(GltTicketOrder::getId) + .last("limit " + MAX_ELIGIBLE_TICKET_ORDERS_PER_RUN) + ); + + Set orderNos = new HashSet<>(); + for (GltTicketOrder ticketOrder : finishedTicketOrders) { + Integer userTicketId = ticketOrder.getUserTicketId(); + if (userTicketId == null) { + continue; + } + + boolean firstFinished = firstFinishedCache.computeIfAbsent(userTicketId, id -> isFirstTicketOrderFinished(id)); + if (!firstFinished) { + continue; + } + + String orderNo = userTicketOrderNoCache.computeIfAbsent(userTicketId, id -> findOrderNoByUserTicketId(id)); + if (orderNo == null || orderNo.isBlank()) { + continue; + } + + // 再校验一次确实是送水套餐订单,避免误关联 + ShopOrder shopOrder = shopOrderService.getOne( + new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, TENANT_ID) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getPayStatus, true) + .eq(ShopOrder::getOrderNo, orderNo) + .last("limit 1") + ); + if (shopOrder == null || shopOrder.getFormId() == null || shopOrder.getFormId() != WATER_FORM_ID) { + continue; + } + + orderNos.add(orderNo); + if (orderNos.size() >= MAX_ELIGIBLE_ORDER_NOS_PER_RUN) { + break; + } + } + return orderNos; + } + + private boolean isFirstTicketOrderFinished(Integer userTicketId) { + if (userTicketId == null) { + return false; + } + GltTicketOrder first = gltTicketOrderService.getOne( + new LambdaQueryWrapper() + .eq(GltTicketOrder::getTenantId, TENANT_ID) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getUserTicketId, userTicketId) + .orderByAsc(GltTicketOrder::getId) + .last("limit 1") + ); + return first != null && first.getDeliveryStatus() != null && first.getDeliveryStatus() == GltTicketOrderService.DELIVERY_STATUS_FINISHED; + } + + private String findOrderNoByUserTicketId(Integer userTicketId) { + if (userTicketId == null) { + return null; + } + GltUserTicket userTicket = gltUserTicketService.getOne( + new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, TENANT_ID) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, userTicketId) + .last("limit 1") + ); + return userTicket != null ? userTicket.getOrderNo() : null; + } + + private List findCapitalsByEligibleOrderNos(Set eligibleOrderNos) { + if (eligibleOrderNos == null || eligibleOrderNos.isEmpty()) { + return List.of(); + } + return shopDealerCapitalService.list( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 10) + .in(ShopDealerCapital::getOrderNo, eligibleOrderNos) + .isNotNull(ShopDealerCapital::getOrderNo) + .orderByAsc(ShopDealerCapital::getId) + .last("limit " + MAX_CAPITALS_PER_RUN) + ); + } + + private boolean unfreezeOneCapitalIfNeeded(ShopDealerCapital cap) { + if (cap == null) { + return false; + } + Integer capitalId = cap.getId(); + Integer dealerUserId = cap.getUserId(); + String orderNo = cap.getOrderNo(); + BigDecimal amount = cap.getMoney(); + if (capitalId == null || dealerUserId == null || orderNo == null || orderNo.isBlank() || amount == null || amount.signum() <= 0) { + return false; + } + + String markerComment = buildUnfreezeMarkerComment(capitalId); + + // 快速幂等检查(避免每条都进事务) + boolean already = shopDealerCapitalService.count( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 50) + .eq(ShopDealerCapital::getUserId, dealerUserId) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .eq(ShopDealerCapital::getComments, markerComment) + ) > 0; + if (already) { + return false; + } + + return Boolean.TRUE.equals(transactionTemplate.execute(status -> { + LocalDateTime now = LocalDateTime.now(); + // 锁定分销商账户行,避免多实例并发导致同一条佣金重复解冻。 + ShopDealerUser dealerUser = shopDealerUserService.getOne( + new LambdaQueryWrapper() + .eq(ShopDealerUser::getTenantId, TENANT_ID) + .eq(ShopDealerUser::getUserId, dealerUserId) + .last("limit 1 for update") + ); + if (dealerUser == null) { + log.warn("解冻失败:未找到分销账户 - tenantId={}, orderNo={}, dealerUserId={}, amount={}", + TENANT_ID, orderNo, dealerUserId, amount); + return false; + } + + // RR 隔离级别下,先锁 user 行,再用锁定读检查 marker,避免“读到旧快照”导致重复解冻。 + ShopDealerCapital existedMarker = shopDealerCapitalService.getOne( + new LambdaQueryWrapper() + .eq(ShopDealerCapital::getTenantId, TENANT_ID) + .eq(ShopDealerCapital::getFlowType, 50) + .eq(ShopDealerCapital::getUserId, dealerUserId) + .eq(ShopDealerCapital::getOrderNo, orderNo) + .eq(ShopDealerCapital::getComments, markerComment) + .last("limit 1 for update") + ); + if (existedMarker != null) { + return false; + } + + BigDecimal freezeMoney = dealerUser.getFreezeMoney() != null ? dealerUser.getFreezeMoney() : BigDecimal.ZERO; + if (freezeMoney.compareTo(amount) < 0) { + log.warn("解冻失败:冻结金额不足 - tenantId={}, orderNo={}, dealerUserId={}, freezeMoney={}, amount={}", + TENANT_ID, orderNo, dealerUserId, freezeMoney, amount); + return false; + } + + BigDecimal moneyVal = dealerUser.getMoney() != null ? dealerUser.getMoney() : BigDecimal.ZERO; + dealerUser.setFreezeMoney(freezeMoney.subtract(amount)); + dealerUser.setMoney(moneyVal.add(amount)); + dealerUser.setUpdateTime(now); + if (!shopDealerUserService.updateById(dealerUser)) { + log.warn("解冻失败:更新分销账户失败 - tenantId={}, orderNo={}, dealerUserId={}, amount={}", + TENANT_ID, orderNo, dealerUserId, amount); + return false; + } + + ShopDealerCapital marker = new ShopDealerCapital(); + marker.setUserId(dealerUserId); + marker.setOrderNo(orderNo); + marker.setFlowType(50); + marker.setMoney(amount); + marker.setComments(markerComment); + marker.setToUserId(cap.getToUserId()); + marker.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); + marker.setTenantId(TENANT_ID); + marker.setCreateTime(now); + marker.setUpdateTime(now); + shopDealerCapitalService.save(marker); + + log.info("佣金解冻成功 - tenantId={}, orderNo={}, dealerUserId={}, amount={}, capitalId={}", + TENANT_ID, orderNo, dealerUserId, amount, capitalId); + return true; + })); + } + + private String buildUnfreezeMarkerComment(Integer capitalId) { + return "佣金解冻(capitalId=" + capitalId + ")"; + } +} diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopDealerCapital.java b/src/main/java/com/gxwebsoft/shop/entity/ShopDealerCapital.java index 11707ba..66eae4b 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopDealerCapital.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopDealerCapital.java @@ -37,7 +37,7 @@ public class ShopDealerCapital implements Serializable { @Schema(description = "订单编号") private String orderNo; - @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入)") + @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入 50佣金解冻)") private Integer flowType; @Schema(description = "金额") diff --git a/src/main/java/com/gxwebsoft/shop/param/ShopDealerCapitalParam.java b/src/main/java/com/gxwebsoft/shop/param/ShopDealerCapitalParam.java index 1bde4f9..96938dd 100644 --- a/src/main/java/com/gxwebsoft/shop/param/ShopDealerCapitalParam.java +++ b/src/main/java/com/gxwebsoft/shop/param/ShopDealerCapitalParam.java @@ -36,7 +36,7 @@ public class ShopDealerCapitalParam extends BaseParam { @QueryField(type = QueryType.EQ) private String orderNo; - @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入)") + @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入 50佣金解冻)") @QueryField(type = QueryType.EQ) private Integer flowType;