Kaynağa Gözat

feat(bonus): 实现能量师月度分红结算服务——资金池竞争制

liaoxg 3 ay önce
ebeveyn
işleme
b990c4b1d7

+ 130 - 0
num-server/src/main/java/com/etotem/num/entity/MonthlyBonusRecord.java

@@ -0,0 +1,130 @@
+package com.etotem.num.entity;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "monthly_bonus_records")
+public class MonthlyBonusRecord {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "energy_master_id", nullable = false)
+    private Long energyMasterId;
+
+    @Column(name = "year_month", nullable = false, length = 7)
+    private String yearMonth;
+
+    @Column(name = "platform_revenue_fen", nullable = false)
+    private Long platformRevenueFen = 0L;
+
+    @Column(name = "personal_pool_fen", nullable = false)
+    private Long personalPoolFen = 0L;
+
+    @Column(name = "team_pool_fen", nullable = false)
+    private Long teamPoolFen = 0L;
+
+    @Column(name = "personal_sales_fen", nullable = false)
+    private Long personalSalesFen = 0L;
+
+    @Column(name = "total_personal_sales_fen", nullable = false)
+    private Long totalPersonalSalesFen = 0L;
+
+    @Column(name = "personal_share_pct", nullable = false)
+    private Integer personalSharePct = 0;
+
+    @Column(name = "team_sales_fen", nullable = false)
+    private Long teamSalesFen = 0L;
+
+    @Column(name = "total_team_sales_fen", nullable = false)
+    private Long totalTeamSalesFen = 0L;
+
+    @Column(name = "team_share_pct", nullable = false)
+    private Integer teamSharePct = 0;
+
+    @Column(name = "personal_bonus_fen", nullable = false)
+    private Long personalBonusFen = 0L;
+
+    @Column(name = "team_bonus_fen", nullable = false)
+    private Long teamBonusFen = 0L;
+
+    @Column(name = "total_bonus_fen", nullable = false)
+    private Long totalBonusFen = 0L;
+
+    @Column(nullable = false)
+    private Boolean qualified = false;
+
+    @Column(nullable = false, length = 20)
+    private String status = "pending";
+
+    @Column(name = "settled_at")
+    private LocalDateTime settledAt;
+
+    @Column(name = "paid_at")
+    private LocalDateTime paidAt;
+
+    @Column(length = 200)
+    private String remark;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public Long getEnergyMasterId() { return energyMasterId; }
+    public void setEnergyMasterId(Long energyMasterId) { this.energyMasterId = energyMasterId; }
+
+    public String getYearMonth() { return yearMonth; }
+    public void setYearMonth(String yearMonth) { this.yearMonth = yearMonth; }
+
+    public Long getPlatformRevenueFen() { return platformRevenueFen; }
+    public void setPlatformRevenueFen(Long platformRevenueFen) { this.platformRevenueFen = platformRevenueFen; }
+
+    public Long getPersonalPoolFen() { return personalPoolFen; }
+    public void setPersonalPoolFen(Long personalPoolFen) { this.personalPoolFen = personalPoolFen; }
+
+    public Long getTeamPoolFen() { return teamPoolFen; }
+    public void setTeamPoolFen(Long teamPoolFen) { this.teamPoolFen = teamPoolFen; }
+
+    public Long getPersonalSalesFen() { return personalSalesFen; }
+    public void setPersonalSalesFen(Long personalSalesFen) { this.personalSalesFen = personalSalesFen; }
+
+    public Long getTotalPersonalSalesFen() { return totalPersonalSalesFen; }
+    public void setTotalPersonalSalesFen(Long totalPersonalSalesFen) { this.totalPersonalSalesFen = totalPersonalSalesFen; }
+
+    public Integer getPersonalSharePct() { return personalSharePct; }
+    public void setPersonalSharePct(Integer personalSharePct) { this.personalSharePct = personalSharePct; }
+
+    public Long getTeamSalesFen() { return teamSalesFen; }
+    public void setTeamSalesFen(Long teamSalesFen) { this.teamSalesFen = teamSalesFen; }
+
+    public Long getTotalTeamSalesFen() { return totalTeamSalesFen; }
+    public void setTotalTeamSalesFen(Long totalTeamSalesFen) { this.totalTeamSalesFen = totalTeamSalesFen; }
+
+    public Integer getTeamSharePct() { return teamSharePct; }
+    public void setTeamSharePct(Integer teamSharePct) { this.teamSharePct = teamSharePct; }
+
+    public Long getPersonalBonusFen() { return personalBonusFen; }
+    public void setPersonalBonusFen(Long personalBonusFen) { this.personalBonusFen = personalBonusFen; }
+
+    public Long getTeamBonusFen() { return teamBonusFen; }
+    public void setTeamBonusFen(Long teamBonusFen) { this.teamBonusFen = teamBonusFen; }
+
+    public Long getTotalBonusFen() { return totalBonusFen; }
+    public void setTotalBonusFen(Long totalBonusFen) { this.totalBonusFen = totalBonusFen; }
+
+    public Boolean getQualified() { return qualified; }
+    public void setQualified(Boolean qualified) { this.qualified = qualified; }
+
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+
+    public LocalDateTime getSettledAt() { return settledAt; }
+    public void setSettledAt(LocalDateTime settledAt) { this.settledAt = settledAt; }
+
+    public LocalDateTime getPaidAt() { return paidAt; }
+    public void setPaidAt(LocalDateTime paidAt) { this.paidAt = paidAt; }
+
+    public String getRemark() { return remark; }
+    public void setRemark(String remark) { this.remark = remark; }
+}

+ 18 - 0
num-server/src/main/java/com/etotem/num/repository/MonthlyBonusRecordRepository.java

@@ -0,0 +1,18 @@
+package com.etotem.num.repository;
+
+import com.etotem.num.entity.MonthlyBonusRecord;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface MonthlyBonusRecordRepository extends JpaRepository<MonthlyBonusRecord, Long> {
+
+    List<MonthlyBonusRecord> findByYearMonth(String yearMonth);
+
+    Optional<MonthlyBonusRecord> findByEnergyMasterIdAndYearMonth(Long energyMasterId, String yearMonth);
+
+    List<MonthlyBonusRecord> findByStatus(String status);
+}

+ 5 - 0
num-server/src/main/java/com/etotem/num/repository/OrderRepository.java

@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
 import java.time.LocalDateTime;
+import java.util.List;
 import java.util.Optional;
 
 @Repository
@@ -17,4 +18,8 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
     long countByIsSeedPriceAndProductTypeAndStatus(Boolean isSeedPrice, String productType, String status);
     // US-4.3: Check if a specific user has any paid seed price order
     long countByUserIdAndIsSeedPriceAndStatus(Long userId, Boolean isSeedPrice, String status);
+    // Bonus settlement: find orders by status and paidAt range
+    List<Order> findByStatusAndPaidAtBetween(String status, LocalDateTime start, LocalDateTime end);
+    // Bonus settlement: find orders by userId, status and paidAt range
+    List<Order> findByUserIdAndStatusAndPaidAtBetween(Long userId, String status, LocalDateTime start, LocalDateTime end);
 }

+ 2 - 0
num-server/src/main/java/com/etotem/num/repository/UserRepository.java

@@ -21,6 +21,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
     List<User> findByInvitedByIn(List<Long> invitedByIds);
     long countByInvitedBy(Long invitedBy);
     long countByInvitedByIn(List<Long> invitedByIds);
+    // Bonus settlement: find users by vipType
+    List<User> findByVipType(String vipType);
 
     @Modifying
     @Transactional

+ 190 - 0
num-server/src/main/java/com/etotem/num/service/BonusSettlementService.java

@@ -0,0 +1,190 @@
+package com.etotem.num.service;
+
+import com.etotem.num.entity.MonthlyBonusRecord;
+import com.etotem.num.entity.Order;
+import com.etotem.num.entity.User;
+import com.etotem.num.repository.MonthlyBonusRecordRepository;
+import com.etotem.num.repository.OrderRepository;
+import com.etotem.num.repository.UserRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+/**
+ * 能量师月度分红结算服务(资金池竞争制)。
+ *
+ * 每月 1 日 00:00 执行:
+ * 1. 计算平台月总收入(实付金额-退款)
+ * 2. 划出个人分红池(5%)和团队分红池(5%)
+ * 3. 筛选当月有直接销售的活跃能量师
+ * 4. 计算各能量师的个人直接销售额和团队销售额(递归,遇无销售截断)
+ * 5. 按占比分配两个资金池
+ * 6. 写入 monthly_bonus_records
+ */
+@Service
+public class BonusSettlementService {
+
+    private static final Logger log = LoggerFactory.getLogger(BonusSettlementService.class);
+    private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
+
+    private final OrderRepository orderRepository;
+    private final UserRepository userRepository;
+    private final MonthlyBonusRecordRepository bonusRecordRepository;
+
+    public BonusSettlementService(OrderRepository orderRepository,
+                                  UserRepository userRepository,
+                                  MonthlyBonusRecordRepository bonusRecordRepository) {
+        this.orderRepository = orderRepository;
+        this.userRepository = userRepository;
+        this.bonusRecordRepository = bonusRecordRepository;
+    }
+
+    @Scheduled(cron = "0 0 1 1 * ?") // 每月1日 00:00
+    @Transactional
+    public void settleMonthlyBonus() {
+        YearMonth lastMonth = YearMonth.now().minusMonths(1);
+        String yearMonth = lastMonth.format(YEAR_MONTH_FORMAT);
+        log.info("[Bonus] 开始结算 {} 月度分红", yearMonth);
+
+        try {
+            // 1. 计算平台当月总收入
+            LocalDateTime startDateTime = lastMonth.atDay(1).atStartOfDay();
+            LocalDateTime endDateTime = lastMonth.atEndOfMonth().atTime(LocalTime.MAX);
+
+            List<Order> paidOrders = orderRepository.findByStatusAndPaidAtBetween(
+                    "paid", startDateTime, endDateTime);
+            long totalRevenue = paidOrders.stream()
+                    .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
+                    .sum();
+
+            List<Order> refundedOrders = orderRepository.findByStatusAndPaidAtBetween(
+                    "refunded", startDateTime, endDateTime);
+            long totalRefunded = refundedOrders.stream()
+                    .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
+                    .sum();
+
+            long netRevenue = totalRevenue - totalRefunded;
+            if (netRevenue <= 0) {
+                log.info("[Bonus] {} 月无净收入,跳过结算", yearMonth);
+                return;
+            }
+
+            // 2. 计算两个资金池(各5%)
+            long personalPool = (long) (netRevenue * 0.05);
+            long teamPool = (long) (netRevenue * 0.05);
+            log.info("[Bonus] 平台净收入={},个人池={},团队池={}", netRevenue, personalPool, teamPool);
+
+            // 3. 查询所有能量师
+            List<User> practitioners = userRepository.findByVipType("practitioner");
+            if (practitioners.isEmpty()) {
+                log.info("[Bonus] 无能量师,跳过结算");
+                return;
+            }
+
+            // 4. 计算每个能量师的当月直接销售额,筛选活跃能量师
+            Map<Long, Long> personalSalesMap = new HashMap<>();
+            List<User> activePractitioners = new ArrayList<>();
+            for (User p : practitioners) {
+                long personalSales = getMonthlySales(p.getId(), startDateTime, endDateTime);
+                if (personalSales > 0) {
+                    personalSalesMap.put(p.getId(), personalSales);
+                    activePractitioners.add(p);
+                }
+            }
+            if (activePractitioners.isEmpty()) {
+                log.info("[Bonus] 无活跃能量师,跳过结算");
+                return;
+            }
+
+            // 5. 计算每个活跃能量师的团队销售额(递归)
+            Map<Long, Long> teamSalesMap = new HashMap<>();
+            for (User p : activePractitioners) {
+                long teamSales = calcTeamSalesRecursive(p.getId(), startDateTime, endDateTime, personalSalesMap);
+                teamSalesMap.put(p.getId(), teamSales);
+            }
+
+            // 6. 计算总分母
+            long totalPersonalSales = personalSalesMap.values().stream().mapToLong(Long::longValue).sum();
+            long totalTeamSales = teamSalesMap.values().stream().mapToLong(Long::longValue).sum();
+
+            // 7. 按占比分配,写入记录
+            for (User p : activePractitioners) {
+                Long personalSales = personalSalesMap.getOrDefault(p.getId(), 0L);
+                Long teamSales = teamSalesMap.getOrDefault(p.getId(), 0L);
+
+                int personalPct = totalPersonalSales > 0 ? (int) (personalSales * 10000 / totalPersonalSales) : 0;
+                int teamPct = totalTeamSales > 0 ? (int) (teamSales * 10000 / totalTeamSales) : 0;
+
+                long personalBonus = (long) (personalPct / 10000.0 * personalPool);
+                long teamBonus = (long) (teamPct / 10000.0 * teamPool);
+                long totalBonus = personalBonus + teamBonus;
+
+                MonthlyBonusRecord record = bonusRecordRepository
+                        .findByEnergyMasterIdAndYearMonth(p.getId(), yearMonth)
+                        .orElse(new MonthlyBonusRecord());
+
+                record.setEnergyMasterId(p.getId());
+                record.setYearMonth(yearMonth);
+                record.setPlatformRevenueFen(netRevenue);
+                record.setPersonalPoolFen(personalPool);
+                record.setTeamPoolFen(teamPool);
+                record.setPersonalSalesFen(personalSales);
+                record.setTotalPersonalSalesFen(totalPersonalSales);
+                record.setPersonalSharePct(personalPct);
+                record.setTeamSalesFen(teamSales);
+                record.setTotalTeamSalesFen(totalTeamSales);
+                record.setTeamSharePct(teamPct);
+                record.setPersonalBonusFen(personalBonus);
+                record.setTeamBonusFen(teamBonus);
+                record.setTotalBonusFen(totalBonus);
+                record.setQualified(true);
+                record.setStatus("settled");
+                record.setSettledAt(LocalDateTime.now());
+
+                bonusRecordRepository.save(record);
+                log.info("[Bonus] 能量师 {} 分红: 个人={}, 团队={}, 合计={}",
+                        p.getId(), personalBonus, teamBonus, totalBonus);
+            }
+
+            log.info("[Bonus] {} 月度分红结算完成,共 {} 位能量师参与", yearMonth, activePractitioners.size());
+        } catch (Exception e) {
+            log.error("[Bonus] {} 月度分红结算异常", yearMonth, e);
+            throw e;
+        }
+    }
+
+    private long getMonthlySales(Long userId, LocalDateTime startDateTime, LocalDateTime endDateTime) {
+        List<Order> orders = orderRepository.findByUserIdAndStatusAndPaidAtBetween(
+                userId, "paid", startDateTime, endDateTime);
+        return orders.stream()
+                .mapToLong(o -> o.getTotalFee() != null ? o.getTotalFee() : 0)
+                .sum();
+    }
+
+    private long calcTeamSalesRecursive(Long userId, LocalDateTime startDateTime, LocalDateTime endDateTime,
+                                         Map<Long, Long> personalSalesMap) {
+        // 自身直接销售额
+        long selfSales = personalSalesMap.getOrDefault(userId, 0L);
+
+        // 团队销售额 = 自身直接销售 + 下级团队销售(仅当月有销售的下级才递归)
+        List<User> directReferrals = userRepository.findByInvitedBy(userId);
+        long teamSales = selfSales;
+        for (User referral : directReferrals) {
+            Long referralPersonalSales = personalSalesMap.get(referral.getId());
+            if (referralPersonalSales != null && referralPersonalSales > 0) {
+                // 只有当月有销售的下级才计入团队销售,并递归
+                teamSales += calcTeamSalesRecursive(referral.getId(), startDateTime, endDateTime, personalSalesMap);
+            }
+        }
+        return teamSales;
+    }
+}

+ 25 - 0
num-server/src/main/resources/schema.sql

@@ -0,0 +1,25 @@
+CREATE TABLE IF NOT EXISTS `monthly_bonus_records` (
+  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
+  `energy_master_id` BIGINT NOT NULL COMMENT '能量师用户ID',
+  `year_month` VARCHAR(7) NOT NULL COMMENT '结算月份(如 2026-06)',
+  `platform_revenue_fen` BIGINT NOT NULL DEFAULT 0,
+  `personal_pool_fen` BIGINT NOT NULL DEFAULT 0,
+  `team_pool_fen` BIGINT NOT NULL DEFAULT 0,
+  `personal_sales_fen` BIGINT NOT NULL DEFAULT 0,
+  `total_personal_sales_fen` BIGINT NOT NULL DEFAULT 0,
+  `personal_share_pct` INT NOT NULL DEFAULT 0,
+  `team_sales_fen` BIGINT NOT NULL DEFAULT 0,
+  `total_team_sales_fen` BIGINT NOT NULL DEFAULT 0,
+  `team_share_pct` INT NOT NULL DEFAULT 0,
+  `personal_bonus_fen` BIGINT NOT NULL DEFAULT 0,
+  `team_bonus_fen` BIGINT NOT NULL DEFAULT 0,
+  `total_bonus_fen` BIGINT NOT NULL DEFAULT 0,
+  `qualified` TINYINT(1) NOT NULL DEFAULT 0,
+  `status` VARCHAR(20) NOT NULL DEFAULT 'pending',
+  `settled_at` DATETIME,
+  `paid_at` DATETIME,
+  `remark` VARCHAR(200),
+  UNIQUE KEY `uk_master_month` (`energy_master_id`, `year_month`),
+  INDEX `idx_year_month` (`year_month`),
+  INDEX `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量师月度分红结算表';