feat(commission): 新增佣金30天提现冻结机制

- KeyboardTenantCommissionDO 增加 withdrawableAt、withdrawableProcessed 字段
- TenantBalanceDO 增加 withdrawableBalance 字段并注释掉 Oracle 自增序列
- 计算任务在结算时写入可提现时间并默认未处理
- 新增 CommissionWithdrawableJob 定时把到期佣金从冻结余额转到可提现余额
- TenantServiceImpl 创建代理租户时调用 TenantBalanceApi 初始化钱包
- 提供 TenantBalanceApi 及实现,支持初始化与余额转换
This commit is contained in:
2025-12-30 16:06:31 +08:00
parent baf38df6c3
commit 98e427c65a
7 changed files with 217 additions and 2 deletions

View File

@@ -0,0 +1,44 @@
package com.yolo.keyboard.api.tenantbalance;
import com.yolo.keyboard.dal.dataobject.tenantbalance.TenantBalanceDO;
import com.yolo.keyboard.dal.mysql.tenantbalance.TenantBalanceMapper;
import com.yolo.keyboard.module.system.api.tenantbalance.TenantBalanceApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
/**
* 租户余额 API 实现类
*
* @author ziin
*/
@Service
@Slf4j
public class TenantBalanceApiImpl implements TenantBalanceApi {
@Resource
private TenantBalanceMapper tenantBalanceMapper;
@Override
public void initTenantBalance(Long tenantId) {
// 检查是否已存在
TenantBalanceDO existingBalance = tenantBalanceMapper.selectById(tenantId);
if (existingBalance != null) {
log.info("[initTenantBalance] 租户 {} 钱包已存在,跳过初始化", tenantId);
return;
}
// 初始化租户钱包
TenantBalanceDO balance = new TenantBalanceDO();
balance.setId(tenantId);
balance.setBalance(BigDecimal.ZERO);
balance.setWithdrawableBalance(BigDecimal.ZERO);
balance.setFrozenAmt(BigDecimal.ZERO);
balance.setVersion(0);
tenantBalanceMapper.insert(balance);
log.info("[initTenantBalance] 租户 {} 钱包初始化成功", tenantId);
}
}

View File

@@ -15,7 +15,7 @@ import com.yolo.keyboard.framework.mybatis.core.dataobject.BaseDO;
* @author 芋道源码 * @author 芋道源码
*/ */
@TableName("system_tenant_balance") @TableName("system_tenant_balance")
@KeySequence("system_tenant_balance_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 //@KeySequence("system_tenant_balance_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true) @ToString(callSuper = true)

View File

@@ -100,6 +100,16 @@ public class KeyboardTenantCommissionDO {
*/ */
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/**
* 可提现时间(结算时间 + 30天
*/
private LocalDateTime withdrawableAt;
/**
* 是否已处理转可提现false-未处理true-已处理
*/
private Boolean withdrawableProcessed;
/** /**
* 备注 * 备注
*/ */

View File

@@ -0,0 +1,132 @@
package com.yolo.keyboard.job;
import cn.hutool.core.collection.CollUtil;
import com.yolo.keyboard.dal.dataobject.tenantbalance.TenantBalanceDO;
import com.yolo.keyboard.dal.dataobject.tenantbalancetransaction.TenantBalanceTransactionDO;
import com.yolo.keyboard.dal.dataobject.tenantcommission.KeyboardTenantCommissionDO;
import com.yolo.keyboard.dal.mysql.tenantbalance.TenantBalanceMapper;
import com.yolo.keyboard.dal.mysql.tenantbalancetransaction.TenantBalanceTransactionMapper;
import com.yolo.keyboard.dal.mysql.tenantcommission.KeyboardTenantCommissionMapper;
import com.yolo.keyboard.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.yolo.keyboard.framework.quartz.core.handler.JobHandler;
import com.yolo.keyboard.framework.tenant.core.aop.TenantIgnore;
import com.yolo.keyboard.utils.BizNoGenerator;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 分成可提现转换定时任务
* 每小时执行一次将满30天的分成金额转为可提现余额
*
* @author ziin
*/
@Component
@Slf4j
public class CommissionWithdrawableJob implements JobHandler {
@Resource
private KeyboardTenantCommissionMapper commissionMapper;
@Resource
private TenantBalanceMapper tenantBalanceMapper;
@Resource
private TenantBalanceTransactionMapper balanceTransactionMapper;
private static final String WITHDRAWABLE_TYPE = "WITHDRAWABLE";
@Override
@TenantIgnore
@Transactional(rollbackFor = Exception.class)
public String execute(String param) {
log.info("[CommissionWithdrawableJob] 开始执行分成可提现转换任务");
LocalDateTime now = LocalDateTime.now();
// 1. 查询已到可提现时间且未处理的分成记录
List<KeyboardTenantCommissionDO> commissions = commissionMapper.selectList(
new LambdaQueryWrapperX<KeyboardTenantCommissionDO>()
.le(KeyboardTenantCommissionDO::getWithdrawableAt, now)
.eq(KeyboardTenantCommissionDO::getWithdrawableProcessed, false)
.eq(KeyboardTenantCommissionDO::getStatus, "SETTLED")
);
if (CollUtil.isEmpty(commissions)) {
log.info("[CommissionWithdrawableJob] 没有需要处理的分成记录");
return "没有需要处理的分成记录";
}
// 2. 按租户分组汇总金额
Map<Long, List<KeyboardTenantCommissionDO>> tenantCommissionsMap = commissions.stream()
.collect(Collectors.groupingBy(KeyboardTenantCommissionDO::getTenantId));
int tenantCount = 0;
int commissionCount = 0;
BigDecimal totalAmount = BigDecimal.ZERO;
// 3. 逐个租户处理
for (Map.Entry<Long, List<KeyboardTenantCommissionDO>> entry : tenantCommissionsMap.entrySet()) {
Long tenantId = entry.getKey();
List<KeyboardTenantCommissionDO> tenantCommissions = entry.getValue();
// 计算该租户的总可提现金额
BigDecimal tenantTotalAmount = tenantCommissions.stream()
.map(KeyboardTenantCommissionDO::getCommissionAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 更新租户可提现余额
TenantBalanceDO balance = tenantBalanceMapper.selectById(tenantId);
if (balance == null) {
log.warn("[CommissionWithdrawableJob] 租户 {} 余额记录不存在,跳过", tenantId);
continue;
}
BigDecimal currentWithdrawable = balance.getWithdrawableBalance() != null
? balance.getWithdrawableBalance() : BigDecimal.ZERO;
BigDecimal newWithdrawable = currentWithdrawable.add(tenantTotalAmount);
balance.setWithdrawableBalance(newWithdrawable);
tenantBalanceMapper.updateById(balance);
// 创建余额交易记录
String bizNo = BizNoGenerator.generate("WDB");
TenantBalanceTransactionDO transaction = TenantBalanceTransactionDO.builder()
.bizNo(bizNo)
.points(tenantTotalAmount)
.balance(balance.getBalance())
.tenantId(tenantId)
.type(WITHDRAWABLE_TYPE)
.description("分成转可提现")
.createdAt(now)
.remark("" + tenantCommissions.size() + " 笔分成转为可提现")
.build();
balanceTransactionMapper.insert(transaction);
// 标记分成记录为已处理
for (KeyboardTenantCommissionDO commission : tenantCommissions) {
commission.setWithdrawableProcessed(true);
commission.setUpdatedAt(now);
commissionMapper.updateById(commission);
}
tenantCount++;
commissionCount += tenantCommissions.size();
totalAmount = totalAmount.add(tenantTotalAmount);
log.info("[CommissionWithdrawableJob] 处理租户 {},分成 {} 笔,金额 {}",
tenantId, tenantCommissions.size(), tenantTotalAmount);
}
String result = String.format("处理租户 %d 个,分成记录 %d 条,总金额 %s",
tenantCount, commissionCount, totalAmount.toPlainString());
log.info("[CommissionWithdrawableJob] 任务执行完成: {}", result);
return result;
}
}

View File

@@ -145,6 +145,7 @@ public class TenantCommissionCalculateJob implements JobHandler {
// 4. 创建分成记录 // 4. 创建分成记录
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime withdrawableAt = now.plusDays(30); // 30天后可提现
KeyboardTenantCommissionDO commission = KeyboardTenantCommissionDO.builder() KeyboardTenantCommissionDO commission = KeyboardTenantCommissionDO.builder()
.purchaseRecordId(record.getId()) .purchaseRecordId(record.getId())
.transactionId(record.getTransactionId()) .transactionId(record.getTransactionId())
@@ -157,17 +158,20 @@ public class TenantCommissionCalculateJob implements JobHandler {
.status("SETTLED") .status("SETTLED")
.purchaseTime(record.getPurchaseTime()) .purchaseTime(record.getPurchaseTime())
.settledAt(now) .settledAt(now)
.withdrawableAt(withdrawableAt)
.withdrawableProcessed(false)
.createdAt(now) .createdAt(now)
.updatedAt(now) .updatedAt(now)
.build(); .build();
// 5. 更新租户余额 // 5. 更新租户余额分成金额先计入总余额30天后才可提现
TenantBalanceDO balance = tenantBalanceMapper.selectById(tenantId); TenantBalanceDO balance = tenantBalanceMapper.selectById(tenantId);
if (balance == null) { if (balance == null) {
// 如果租户余额记录不存在,创建一个 // 如果租户余额记录不存在,创建一个
balance = new TenantBalanceDO(); balance = new TenantBalanceDO();
balance.setId(tenantId); balance.setId(tenantId);
balance.setBalance(commissionAmount); balance.setBalance(commissionAmount);
balance.setWithdrawableBalance(BigDecimal.ZERO);
balance.setFrozenAmt(BigDecimal.ZERO); balance.setFrozenAmt(BigDecimal.ZERO);
balance.setVersion(0); balance.setVersion(0);
tenantBalanceMapper.insert(balance); tenantBalanceMapper.insert(balance);

View File

@@ -0,0 +1,17 @@
package com.yolo.keyboard.module.system.api.tenantbalance;
/**
* 租户余额 API 接口
*
* @author ziin
*/
public interface TenantBalanceApi {
/**
* 初始化租户钱包
*
* @param tenantId 租户ID
*/
void initTenantBalance(Long tenantId);
}

View File

@@ -22,6 +22,7 @@ import com.yolo.keyboard.module.system.dal.dataobject.tenant.TenantDO;
import com.yolo.keyboard.module.system.dal.dataobject.tenant.TenantPackageDO; import com.yolo.keyboard.module.system.dal.dataobject.tenant.TenantPackageDO;
import com.yolo.keyboard.module.system.dal.mysql.tenant.TenantMapper; import com.yolo.keyboard.module.system.dal.mysql.tenant.TenantMapper;
import com.yolo.keyboard.module.system.api.invitecode.UserInviteCodeApi; import com.yolo.keyboard.module.system.api.invitecode.UserInviteCodeApi;
import com.yolo.keyboard.module.system.api.tenantbalance.TenantBalanceApi;
import com.yolo.keyboard.module.system.enums.permission.RoleCodeEnum; import com.yolo.keyboard.module.system.enums.permission.RoleCodeEnum;
import com.yolo.keyboard.module.system.enums.permission.RoleTypeEnum; import com.yolo.keyboard.module.system.enums.permission.RoleTypeEnum;
import com.yolo.keyboard.module.system.service.permission.MenuService; import com.yolo.keyboard.module.system.service.permission.MenuService;
@@ -79,6 +80,9 @@ public class TenantServiceImpl implements TenantService {
@Autowired(required = false) @Autowired(required = false)
private UserInviteCodeApi userInviteCodeApi; private UserInviteCodeApi userInviteCodeApi;
@Autowired(required = false)
private TenantBalanceApi tenantBalanceApi;
@Override @Override
public List<Long> getTenantIdList() { public List<Long> getTenantIdList() {
List<TenantDO> tenants = tenantMapper.selectList(); List<TenantDO> tenants = tenantMapper.selectList();
@@ -143,6 +147,10 @@ public class TenantServiceImpl implements TenantService {
if ("代理".equals(createReqVO.getTenantType()) && userInviteCodeApi != null) { if ("代理".equals(createReqVO.getTenantType()) && userInviteCodeApi != null) {
userInviteCodeApi.createInviteCodeForAgent(userId, tenant.getId()); userInviteCodeApi.createInviteCodeForAgent(userId, tenant.getId());
} }
// 为代理租户初始化钱包
if ("代理".equals(createReqVO.getTenantType()) && tenantBalanceApi != null) {
tenantBalanceApi.initTenantBalance(tenant.getId());
}
}); });
return tenant.getId(); return tenant.getId();
} }