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;