From eb4b615ed63686980871e8a4e3f33d46768a7502 Mon Sep 17 00:00:00 2001 From: ziin Date: Tue, 30 Dec 2025 14:15:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=A2=9E=E5=8A=A0=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E6=8F=90=E6=88=90=E8=AE=A1=E7=AE=97=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E5=8D=87=E7=BA=A7Quartz=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增KeyboardTenantCommissionDO、KeyboardTenantCommissionMapper及TenantCommissionCalculateJob,实现租户提成定时计算 - 升级Quartz至2.5.2,开启acquireTriggersWithinLock防并发 - 精简BannerApplicationRunner,移除模块启用提示 - 调整IDEA HTTP客户端端口至48081 --- .../KeyboardTenantCommissionDO.java | 107 +++++++++ .../KeyboardTenantCommissionMapper.java | 28 +++ .../job/TenantCommissionCalculateJob.java | 211 ++++++++++++++++++ pom.xml | 2 + script/idea/http-client.env.json | 2 +- yolo-dependencies/pom.xml | 9 + .../yolo-spring-boot-starter-job/pom.xml | 11 + .../banner/core/BannerApplicationRunner.java | 49 +--- .../src/main/resources/application-dev.yaml | 1 + .../src/main/resources/application-local.yaml | 4 +- 10 files changed, 374 insertions(+), 50 deletions(-) create mode 100644 keyboard-server/src/main/java/com/yolo/keyboard/dal/dataobject/tenantcommission/KeyboardTenantCommissionDO.java create mode 100644 keyboard-server/src/main/java/com/yolo/keyboard/dal/mysql/tenantcommission/KeyboardTenantCommissionMapper.java create mode 100644 keyboard-server/src/main/java/com/yolo/keyboard/job/TenantCommissionCalculateJob.java diff --git a/keyboard-server/src/main/java/com/yolo/keyboard/dal/dataobject/tenantcommission/KeyboardTenantCommissionDO.java b/keyboard-server/src/main/java/com/yolo/keyboard/dal/dataobject/tenantcommission/KeyboardTenantCommissionDO.java new file mode 100644 index 0000000..e51be3d --- /dev/null +++ b/keyboard-server/src/main/java/com/yolo/keyboard/dal/dataobject/tenantcommission/KeyboardTenantCommissionDO.java @@ -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; +} diff --git a/keyboard-server/src/main/java/com/yolo/keyboard/dal/mysql/tenantcommission/KeyboardTenantCommissionMapper.java b/keyboard-server/src/main/java/com/yolo/keyboard/dal/mysql/tenantcommission/KeyboardTenantCommissionMapper.java new file mode 100644 index 0000000..291a81f --- /dev/null +++ b/keyboard-server/src/main/java/com/yolo/keyboard/dal/mysql/tenantcommission/KeyboardTenantCommissionMapper.java @@ -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 { + + /** + * 根据交易ID查询分成记录 + */ + default KeyboardTenantCommissionDO selectByTransactionId(String transactionId) { + return selectOne(KeyboardTenantCommissionDO::getTransactionId, transactionId); + } + + /** + * 根据内购记录ID查询分成记录 + */ + default KeyboardTenantCommissionDO selectByPurchaseRecordId(Integer purchaseRecordId) { + return selectOne(KeyboardTenantCommissionDO::getPurchaseRecordId, purchaseRecordId); + } +} diff --git a/keyboard-server/src/main/java/com/yolo/keyboard/job/TenantCommissionCalculateJob.java b/keyboard-server/src/main/java/com/yolo/keyboard/job/TenantCommissionCalculateJob.java new file mode 100644 index 0000000..e0f02c7 --- /dev/null +++ b/keyboard-server/src/main/java/com/yolo/keyboard/job/TenantCommissionCalculateJob.java @@ -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 purchaseRecords = purchaseRecordsMapper.selectList( + new LambdaQueryWrapperX() + .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; + } +} diff --git a/pom.xml b/pom.xml index 454ca18..4288df1 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,8 @@ 3.5.5 1.6.3 UTF-8 + + 2.5.2 diff --git a/script/idea/http-client.env.json b/script/idea/http-client.env.json index e6e8a7c..61a1f30 100644 --- a/script/idea/http-client.env.json +++ b/script/idea/http-client.env.json @@ -1,6 +1,6 @@ { "local": { - "baseUrl": "http://127.0.0.1:48080/admin-api", + "baseUrl": "http://127.0.0.1:48081/admin-api", "token": "test1", "adminTenantId": "1", diff --git a/yolo-dependencies/pom.xml b/yolo-dependencies/pom.xml index 6fbb6f1..974137c 100644 --- a/yolo-dependencies/pom.xml +++ b/yolo-dependencies/pom.xml @@ -18,6 +18,8 @@ 1.7.2 3.5.8 + + 2.5.2 2.8.14 4.5.0 @@ -95,6 +97,13 @@ import + + + org.quartz-scheduler + quartz + ${quartz.version} + + io.github.mouzt diff --git a/yolo-framework/yolo-spring-boot-starter-job/pom.xml b/yolo-framework/yolo-spring-boot-starter-job/pom.xml index 4ceec33..95021c4 100644 --- a/yolo-framework/yolo-spring-boot-starter-job/pom.xml +++ b/yolo-framework/yolo-spring-boot-starter-job/pom.xml @@ -28,8 +28,19 @@ org.springframework.boot spring-boot-starter-quartz + + + quartz + org.quartz-scheduler + + + + org.quartz-scheduler + quartz + 2.5.2 + jakarta.validation diff --git a/yolo-framework/yolo-spring-boot-starter-web/src/main/java/com/yolo/keyboard/framework/banner/core/BannerApplicationRunner.java b/yolo-framework/yolo-spring-boot-starter-web/src/main/java/com/yolo/keyboard/framework/banner/core/BannerApplicationRunner.java index c615991..1cc5e00 100644 --- a/yolo-framework/yolo-spring-boot-starter-web/src/main/java/com/yolo/keyboard/framework/banner/core/BannerApplicationRunner.java +++ b/yolo-framework/yolo-spring-boot-starter-web/src/main/java/com/yolo/keyboard/framework/banner/core/BannerApplicationRunner.java @@ -22,55 +22,8 @@ public class BannerApplicationRunner implements ApplicationRunner { ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾 log.info("\n----------------------------------------------------------\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()); - } - } diff --git a/yolo-server/src/main/resources/application-dev.yaml b/yolo-server/src/main/resources/application-dev.yaml index 787e169..95491ff 100644 --- a/yolo-server/src/main/resources/application-dev.yaml +++ b/yolo-server/src/main/resources/application-dev.yaml @@ -87,6 +87,7 @@ spring: isClustered: true # 是集群模式 clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000,即 15 秒 misfireThreshold: 60000 # misfire 阀值,单位:毫秒。 + acquireTriggersWithinLock: true # 获取触发器时加锁,防止并发问题 # 线程池相关配置 threadPool: threadCount: 25 # 线程池大小。默认为 10 。 diff --git a/yolo-server/src/main/resources/application-local.yaml b/yolo-server/src/main/resources/application-local.yaml index da33cf1..b942d83 100644 --- a/yolo-server/src/main/resources/application-local.yaml +++ b/yolo-server/src/main/resources/application-local.yaml @@ -6,7 +6,7 @@ spring: autoconfigure: # noinspection SpringBootApplicationYaml 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.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建 # 数据源配置项 @@ -97,9 +97,11 @@ spring: jobStore: # JobStore 实现类。可见博客:https://blog.csdn.net/weixin_42458219/article/details/122247162 class: org.springframework.scheduling.quartz.LocalDataSourceJobStore + driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate isClustered: true # 是集群模式 clusterCheckinInterval: 15000 # 集群检查频率,单位:毫秒。默认为 15000,即 15 秒 misfireThreshold: 60000 # misfire 阀值,单位:毫秒。 + acquireTriggersWithinLock: true # 获取触发器时加锁,防止并发问题 # 线程池相关配置 threadPool: threadCount: 25 # 线程池大小。默认为 10 。