feat(shop): 新增分销佣金解冻功能并扩展资金流动类型

- 在 ShopDealerCapital 和 ShopDealerCapitalParam 中添加资金流动类型 50(佣金解冻)
- 新增 DealerCommissionUnfreeze10584Task 定时任务处理分销佣金解冻逻辑
- 实现送水套餐和非送水套餐的差异化解冻规则
- 添加基于订单状态和水票配送状态的解冻条件判断
- 实现幂等性检查防止重复解冻操作
- 添加分布式锁确保并发安全的解冻处理
- 记录解冻流水作为佣金解冻的标记凭证
This commit is contained in:
2026-02-07 17:26:19 +08:00
parent 54e2654033
commit c0c1232768
3 changed files with 347 additions and 2 deletions

View File

@@ -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分销佣金解冻任务
*
* <p>规则:</p>
* <p>1) 送水套餐(formId=10074):订单号关联的水票产生了第一次送水订单,且该第一次送水订单状态=已完成(40) -> 解冻。</p>
* <p>2) 非送水套餐(formId!=10074):订单已确认收货(orderStatus=1) -> 解冻。</p>
*
* <p>实现策略:以 ShopDealerCapital(flowType=10) 的“佣金明细”为解冻粒度,
* 每条佣金明细对应生成一条 ShopDealerCapital(flowType=50) 作为幂等标记,并执行
* ShopDealerUser.freezeMoney -> ShopDealerUser.money 的转移。</p>
*/
@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<String> eligibleOrderNos = new HashSet<>();
eligibleOrderNos.addAll(findEligibleNonWaterOrderNos(true));
eligibleOrderNos.addAll(findEligibleWaterOrderNosByFirstFinishedTicketOrder());
if (eligibleOrderNos.isEmpty()) {
return;
}
List<ShopDealerCapital> 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<String> findEligibleNonWaterOrderNos(boolean newestFirst) {
LambdaQueryWrapper<ShopOrder> qw = new LambdaQueryWrapper<ShopOrder>()
.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<String> findEligibleWaterOrderNosByFirstFinishedTicketOrder() {
// 缓存减少 DB 往返userTicketId -> 是否“第一次送水单已完成”
Map<Integer, Boolean> firstFinishedCache = new HashMap<>();
Map<Integer, String> userTicketOrderNoCache = new HashMap<>();
List<GltTicketOrder> finishedTicketOrders = gltTicketOrderService.list(
new LambdaQueryWrapper<GltTicketOrder>()
.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<String> 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<ShopOrder>()
.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<GltTicketOrder>()
.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<GltUserTicket>()
.eq(GltUserTicket::getTenantId, TENANT_ID)
.eq(GltUserTicket::getDeleted, 0)
.eq(GltUserTicket::getId, userTicketId)
.last("limit 1")
);
return userTicket != null ? userTicket.getOrderNo() : null;
}
private List<ShopDealerCapital> findCapitalsByEligibleOrderNos(Set<String> eligibleOrderNos) {
if (eligibleOrderNos == null || eligibleOrderNos.isEmpty()) {
return List.of();
}
return shopDealerCapitalService.list(
new LambdaQueryWrapper<ShopDealerCapital>()
.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<ShopDealerCapital>()
.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<ShopDealerUser>()
.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<ShopDealerCapital>()
.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 + ")";
}
}

View File

@@ -37,7 +37,7 @@ public class ShopDealerCapital implements Serializable {
@Schema(description = "订单编号") @Schema(description = "订单编号")
private String orderNo; private String orderNo;
@Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入)") @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入 50佣金解冻)")
private Integer flowType; private Integer flowType;
@Schema(description = "金额") @Schema(description = "金额")

View File

@@ -36,7 +36,7 @@ public class ShopDealerCapitalParam extends BaseParam {
@QueryField(type = QueryType.EQ) @QueryField(type = QueryType.EQ)
private String orderNo; private String orderNo;
@Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入)") @Schema(description = "资金流动类型 (10佣金收入 20提现支出 30转账支出 40转账收入 50佣金解冻)")
@QueryField(type = QueryType.EQ) @QueryField(type = QueryType.EQ)
private Integer flowType; private Integer flowType;