feat(core): 增加租户提成计算功能并升级Quartz版本

- 新增KeyboardTenantCommissionDO、KeyboardTenantCommissionMapper及TenantCommissionCalculateJob,实现租户提成定时计算
- 升级Quartz至2.5.2,开启acquireTriggersWithinLock防并发
- 精简BannerApplicationRunner,移除模块启用提示
- 调整IDEA HTTP客户端端口至48081
This commit is contained in:
2025-12-30 14:15:17 +08:00
parent 2ed121926b
commit eb4b615ed6
10 changed files with 374 additions and 50 deletions

View File

@@ -0,0 +1,107 @@
package com.yolo.keyboard.dal.dataobject.tenantcommission;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.yolo.keyboard.framework.tenant.core.aop.TenantIgnore;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 租户分成记录 DO
* 记录每笔内购订单的分成计算结果
*
* @author ziin
*/
@TableName("keyboard_tenant_commission")
@KeySequence("keyboard_tenant_commission_seq")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TenantIgnore
public class KeyboardTenantCommissionDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 内购记录ID
*/
private Integer purchaseRecordId;
/**
* 内购交易ID唯一标识
*/
private String transactionId;
/**
* 被邀请用户ID购买用户
*/
private Integer inviteeUserId;
/**
* 邀请人用户ID
*/
private Long inviterUserId;
/**
* 收益归属租户ID
*/
private Long tenantId;
/**
* 内购金额
*/
private BigDecimal purchaseAmount;
/**
* 分成比例
*/
private BigDecimal commissionRate;
/**
* 分成金额
*/
private BigDecimal commissionAmount;
/**
* 状态PENDING-待结算SETTLED-已结算REFUNDED-已退款
*/
private String status;
/**
* 内购时间
*/
private LocalDateTime purchaseTime;
/**
* 结算时间
*/
private LocalDateTime settledAt;
/**
* 关联的余额交易记录ID
*/
private Long balanceTransactionId;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,28 @@
package com.yolo.keyboard.dal.mysql.tenantcommission;
import com.yolo.keyboard.dal.dataobject.tenantcommission.KeyboardTenantCommissionDO;
import com.yolo.keyboard.framework.mybatis.core.mapper.BaseMapperX;
import org.apache.ibatis.annotations.Mapper;
/**
* 租户分成记录 Mapper
*
* @author ziin
*/
@Mapper
public interface KeyboardTenantCommissionMapper extends BaseMapperX<KeyboardTenantCommissionDO> {
/**
* 根据交易ID查询分成记录
*/
default KeyboardTenantCommissionDO selectByTransactionId(String transactionId) {
return selectOne(KeyboardTenantCommissionDO::getTransactionId, transactionId);
}
/**
* 根据内购记录ID查询分成记录
*/
default KeyboardTenantCommissionDO selectByPurchaseRecordId(Integer purchaseRecordId) {
return selectOne(KeyboardTenantCommissionDO::getPurchaseRecordId, purchaseRecordId);
}
}

View File

@@ -0,0 +1,211 @@
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.dataobject.userinvites.KeyboardUserInvitesDO;
import com.yolo.keyboard.dal.dataobject.userpurchaserecords.KeyboardUserPurchaseRecordsDO;
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.dal.mysql.userinvites.KeyboardUserInvitesMapper;
import com.yolo.keyboard.dal.mysql.userpurchaserecords.KeyboardUserPurchaseRecordsMapper;
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.module.system.dal.dataobject.tenant.TenantDO;
import com.yolo.keyboard.module.system.dal.mysql.tenant.TenantMapper;
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.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/**
* 租户分成计算定时任务
* 每小时执行一次,计算邀请用户的内购分成
*
* @author ziin
*/
@Component
@Slf4j
public class TenantCommissionCalculateJob implements JobHandler {
@Resource
private KeyboardUserPurchaseRecordsMapper purchaseRecordsMapper;
@Resource
private KeyboardUserInvitesMapper userInvitesMapper;
@Resource
private KeyboardTenantCommissionMapper commissionMapper;
@Resource
private TenantMapper tenantMapper;
@Resource
private TenantBalanceMapper tenantBalanceMapper;
@Resource
private TenantBalanceTransactionMapper balanceTransactionMapper;
private static final String COMMISSION_TYPE = "COMMISSION";
private static final String STATUS_PAID = "PAID";
private static final String INVITE_TYPE_AGENT = "AGENT";
@Override
@TenantIgnore
@Transactional(rollbackFor = Exception.class)
public String execute(String param) {
log.info("[TenantCommissionCalculateJob] 开始执行分成计算任务");
// 1. 查询最近一小时内已支付的内购记录
LocalDateTime endTime = LocalDateTime.now();
LocalDateTime startTime = endTime.minusHours(1);
List<KeyboardUserPurchaseRecordsDO> purchaseRecords = purchaseRecordsMapper.selectList(
new LambdaQueryWrapperX<KeyboardUserPurchaseRecordsDO>()
.eq(KeyboardUserPurchaseRecordsDO::getStatus, STATUS_PAID)
.between(KeyboardUserPurchaseRecordsDO::getPurchaseTime, startTime, endTime)
);
if (CollUtil.isEmpty(purchaseRecords)) {
log.info("[TenantCommissionCalculateJob] 最近一小时内没有已支付的内购记录");
return "没有需要处理的内购记录";
}
int processedCount = 0;
int commissionCount = 0;
BigDecimal totalCommission = BigDecimal.ZERO;
// 2. 遍历内购记录,检查是否有邀请关系
for (KeyboardUserPurchaseRecordsDO record : purchaseRecords) {
// 检查是否已经计算过分成
if (commissionMapper.selectByPurchaseRecordId(record.getId()) != null) {
log.debug("[TenantCommissionCalculateJob] 内购记录 {} 已计算过分成,跳过", record.getId());
continue;
}
processedCount++;
// 查询该用户的邀请关系
KeyboardUserInvitesDO invite = userInvitesMapper.selectOne(
KeyboardUserInvitesDO::getInviteeUserId, record.getUserId().longValue()
);
if (invite == null) {
log.debug("[TenantCommissionCalculateJob] 用户 {} 没有邀请关系,跳过", record.getUserId());
continue;
}
// 只处理代理邀请类型
if (!INVITE_TYPE_AGENT.equals(invite.getInviteType())) {
log.debug("[TenantCommissionCalculateJob] 用户 {} 的邀请类型不是代理,跳过", record.getUserId());
continue;
}
// 获取收益归属租户
Long tenantId = invite.getProfitTenantId();
if (tenantId == null) {
tenantId = invite.getInviterTenantId();
}
if (tenantId == null) {
log.warn("[TenantCommissionCalculateJob] 用户 {} 的邀请关系没有关联租户,跳过", record.getUserId());
continue;
}
// 获取租户信息和分成比例
TenantDO tenant = tenantMapper.selectById(tenantId);
if (tenant == null) {
log.warn("[TenantCommissionCalculateJob] 租户 {} 不存在,跳过", tenantId);
continue;
}
BigDecimal commissionRate = tenant.getProfitShareRatio();
if (commissionRate == null || commissionRate.compareTo(BigDecimal.ZERO) <= 0) {
log.debug("[TenantCommissionCalculateJob] 租户 {} 没有设置分成比例,跳过", tenantId);
continue;
}
// 3. 计算分成金额
BigDecimal purchaseAmount = record.getPrice();
if (purchaseAmount == null || purchaseAmount.compareTo(BigDecimal.ZERO) <= 0) {
log.debug("[TenantCommissionCalculateJob] 内购记录 {} 金额无效,跳过", record.getId());
continue;
}
BigDecimal commissionAmount = purchaseAmount.multiply(commissionRate)
.setScale(2, RoundingMode.HALF_UP);
// 4. 创建分成记录
LocalDateTime now = LocalDateTime.now();
KeyboardTenantCommissionDO commission = KeyboardTenantCommissionDO.builder()
.purchaseRecordId(record.getId())
.transactionId(record.getTransactionId())
.inviteeUserId(record.getUserId())
.inviterUserId(invite.getProfitEmployeeId())
.tenantId(tenantId)
.purchaseAmount(purchaseAmount)
.commissionRate(commissionRate)
.commissionAmount(commissionAmount)
.status("SETTLED")
.purchaseTime(record.getPurchaseTime())
.settledAt(now)
.createdAt(now)
.updatedAt(now)
.build();
// 5. 更新租户余额
TenantBalanceDO balance = tenantBalanceMapper.selectById(tenantId);
if (balance == null) {
// 如果租户余额记录不存在,创建一个
balance = new TenantBalanceDO();
balance.setId(tenantId);
balance.setBalance(commissionAmount);
balance.setFrozenAmt(BigDecimal.ZERO);
balance.setVersion(0);
tenantBalanceMapper.insert(balance);
} else {
BigDecimal newBalance = balance.getBalance().add(commissionAmount);
balance.setBalance(newBalance);
tenantBalanceMapper.updateById(balance);
}
// 6. 创建余额交易记录
String bizNo = BizNoGenerator.generate("COMM");
TenantBalanceTransactionDO transaction = TenantBalanceTransactionDO.builder()
.bizNo(bizNo)
.points(commissionAmount)
.balance(balance.getBalance())
.tenantId(tenantId)
.type(COMMISSION_TYPE)
.description("邀请用户内购分成")
.orderId(record.getTransactionId())
.createdAt(now)
.remark("内购记录ID: " + record.getId() + ", 被邀请用户: " + record.getUserId())
.build();
balanceTransactionMapper.insert(transaction);
// 更新分成记录的关联交易ID
commission.setBalanceTransactionId(transaction.getId());
commissionMapper.insert(commission);
commissionCount++;
totalCommission = totalCommission.add(commissionAmount);
log.info("[TenantCommissionCalculateJob] 处理内购记录 {}, 租户 {}, 分成金额 {}",
record.getId(), tenantId, commissionAmount);
}
String result = String.format("处理内购记录 %d 条,生成分成 %d 条,总分成金额 %s",
processedCount, commissionCount, totalCommission.toPlainString());
log.info("[TenantCommissionCalculateJob] 任务执行完成: {}", result);
return result;
}
}

View File

@@ -46,6 +46,8 @@
<spring.boot.version>3.5.5</spring.boot.version> <spring.boot.version>3.5.5</spring.boot.version>
<mapstruct.version>1.6.3</mapstruct.version> <mapstruct.version>1.6.3</mapstruct.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 覆盖 Spring Boot 默认的 Quartz 版本 -->
<quartz.version>2.5.2</quartz.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>

View File

@@ -1,6 +1,6 @@
{ {
"local": { "local": {
"baseUrl": "http://127.0.0.1:48080/admin-api", "baseUrl": "http://127.0.0.1:48081/admin-api",
"token": "test1", "token": "test1",
"adminTenantId": "1", "adminTenantId": "1",

View File

@@ -18,6 +18,8 @@
<flatten-maven-plugin.version>1.7.2</flatten-maven-plugin.version> <flatten-maven-plugin.version>1.7.2</flatten-maven-plugin.version>
<!-- 统一依赖管理 --> <!-- 统一依赖管理 -->
<spring.boot.version>3.5.8</spring.boot.version> <spring.boot.version>3.5.8</spring.boot.version>
<!-- 覆盖 Spring Boot 默认的 Quartz 版本 -->
<quartz.version>2.5.2</quartz.version>
<!-- Web 相关 --> <!-- Web 相关 -->
<springdoc.version>2.8.14</springdoc.version> <springdoc.version>2.8.14</springdoc.version>
<knife4j.version>4.5.0</knife4j.version> <knife4j.version>4.5.0</knife4j.version>
@@ -95,6 +97,13 @@
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<!-- 覆盖 Spring Boot 默认的 Quartz 版本 -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>${quartz.version}</version>
</dependency>
<!-- 业务组件 --> <!-- 业务组件 -->
<dependency> <dependency>
<groupId>io.github.mouzt</groupId> <groupId>io.github.mouzt</groupId>

View File

@@ -28,8 +28,19 @@
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId> <artifactId>spring-boot-starter-quartz</artifactId>
<exclusions>
<exclusion>
<artifactId>quartz</artifactId>
<groupId>org.quartz-scheduler</groupId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.5.2</version>
</dependency>
<!-- 工具类相关 --> <!-- 工具类相关 -->
<dependency> <dependency>
<groupId>jakarta.validation</groupId> <groupId>jakarta.validation</groupId>

View File

@@ -22,55 +22,8 @@ public class BannerApplicationRunner implements ApplicationRunner {
ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾 ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾
log.info("\n----------------------------------------------------------\n\t" + log.info("\n----------------------------------------------------------\n\t" +
"项目启动成功!\n\t" + "项目启动成功!\n\t" +
"接口文档: \t{} \n\t" +
"开发文档: \t{} \n\t" +
"视频教程: \t{} \n" +
"----------------------------------------------------------",
"https://doc.iocoder.cn/api-doc/",
"https://doc.iocoder.cn",
"https://t.zsxq.com/02Yf6M7Qn");
// 数据报表 "----------------------------------------------------------");
if (isNotPresent("com.yolo.keyboard.module.report.framework.security.config.SecurityConfiguration")) {
System.out.println("[报表模块 yolo-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]");
}
// 工作流
if (isNotPresent("com.yolo.keyboard.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) {
System.out.println("[工作流模块 yolo-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]");
}
// 商城系统
if (isNotPresent("com.yolo.keyboard.module.trade.framework.web.config.TradeWebConfiguration")) {
System.out.println("[商城系统 yolo-module-mall - 已禁用][参考 https://doc.iocoder.cn/mall/build/ 开启]");
}
// ERP 系统
if (isNotPresent("com.yolo.keyboard.module.erp.framework.web.config.ErpWebConfiguration")) {
System.out.println("[ERP 系统 yolo-module-erp - 已禁用][参考 https://doc.iocoder.cn/erp/build/ 开启]");
}
// CRM 系统
if (isNotPresent("com.yolo.keyboard.module.crm.framework.web.config.CrmWebConfiguration")) {
System.out.println("[CRM 系统 yolo-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]");
}
// 微信公众号
if (isNotPresent("com.yolo.keyboard.module.mp.framework.mp.config.MpConfiguration")) {
System.out.println("[微信公众号 yolo-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]");
}
// 支付平台
if (isNotPresent("com.yolo.keyboard.module.pay.framework.pay.config.PayConfiguration")) {
System.out.println("[支付系统 yolo-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]");
}
// AI 大模型
if (isNotPresent("com.yolo.keyboard.module.ai.framework.web.config.AiWebConfiguration")) {
System.out.println("[AI 大模型 yolo-module-ai - 已禁用][参考 https://doc.iocoder.cn/ai/build/ 开启]");
}
// IoT 物联网
if (isNotPresent("com.yolo.keyboard.module.iot.framework.web.config.IotWebConfiguration")) {
System.out.println("[IoT 物联网 yolo-module-iot - 已禁用][参考 https://doc.iocoder.cn/iot/build/ 开启]");
}
}); });
} }
private static boolean isNotPresent(String className) {
return !ClassUtils.isPresent(className, ClassUtils.getDefaultClassLoader());
}
} }

View File

@@ -87,6 +87,7 @@ spring:
isClustered: true # 是集群模式 isClustered: true # 是集群模式
clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000即 15 秒 clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000即 15 秒
misfireThreshold: 60000 # misfire 阀值,单位:毫秒。 misfireThreshold: 60000 # misfire 阀值,单位:毫秒。
acquireTriggersWithinLock: true # 获取触发器时加锁,防止并发问题
# 线程池相关配置 # 线程池相关配置
threadPool: threadPool:
threadCount: 25 # 线程池大小。默认为 10 。 threadCount: 25 # 线程池大小。默认为 10 。

View File

@@ -6,7 +6,7 @@ spring:
autoconfigure: autoconfigure:
# noinspection SpringBootApplicationYaml # noinspection SpringBootApplicationYaml
exclude: exclude:
- org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration # 默认 local 环境,不开启 Quartz 的自动配置 # - org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration # 默认 local 环境,不开启 Quartz 的自动配置
- org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant手动创建 - org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant手动创建
- org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus手动创建 - org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus手动创建
# 数据源配置项 # 数据源配置项
@@ -97,9 +97,11 @@ spring:
jobStore: jobStore:
# JobStore 实现类。可见博客https://blog.csdn.net/weixin_42458219/article/details/122247162 # JobStore 实现类。可见博客https://blog.csdn.net/weixin_42458219/article/details/122247162
class: org.springframework.scheduling.quartz.LocalDataSourceJobStore class: org.springframework.scheduling.quartz.LocalDataSourceJobStore
driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
isClustered: true # 是集群模式 isClustered: true # 是集群模式
clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000即 15 秒 clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000即 15 秒
misfireThreshold: 60000 # misfire 阀值,单位:毫秒。 misfireThreshold: 60000 # misfire 阀值,单位:毫秒。
acquireTriggersWithinLock: true # 获取触发器时加锁,防止并发问题
# 线程池相关配置 # 线程池相关配置
threadPool: threadPool:
threadCount: 25 # 线程池大小。默认为 10 。 threadCount: 25 # 线程池大小。默认为 10 。