feat(shop): 新增分销佣金解冻功能并扩展资金流动类型
- 在 ShopDealerCapital 和 ShopDealerCapitalParam 中添加资金流动类型 50(佣金解冻) - 新增 DealerCommissionUnfreeze10584Task 定时任务处理分销佣金解冻逻辑 - 实现送水套餐和非送水套餐的差异化解冻规则 - 添加基于订单状态和水票配送状态的解冻条件判断 - 实现幂等性检查防止重复解冻操作 - 添加分布式锁确保并发安全的解冻处理 - 记录解冻流水作为佣金解冻的标记凭证
This commit is contained in:
@@ -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 + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "金额")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user