Преглед изворни кода

feat(distribution): enhance virtual team management with invite controller and scheduled tasks

Add DistributionInviteController for invite code generation/validation, CommissionScheduledTasks for automated commission settlement, extend DatabaseInitializer, DistributionRelation, DistributionService, and UserService with team management support.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao пре 2 месеци
родитељ
комит
6406a84170

+ 53 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3651,10 +3651,61 @@ try {
 
         // 迁移: 创建问卷回答结果表
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (id BIGINT AUTO_INCREMENT PRIMARY KEY, snapshot_id BIGINT NOT NULL, respondent_id BIGINT NOT NULL, answers_json LONGTEXT NOT NULL COMMENT '用户回答JSON', trust_score DECIMAL(5,2), intimacy_score DECIMAL(5,2), communication_score DECIMAL(5,2), interaction_bonus DECIMAL(5,2) DEFAULT 0, total_score DECIMAL(5,2), calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_snapshot (snapshot_id), INDEX idx_respondent (respondent_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '关系问卷回答结果'");
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (id BIGINT AUTO_INCREMENT PRIMARY KEY, snapshot_id BIGINT NOT NULL, respondent_id BIGINT NOT NULL, answers_json LONGTEXT NOT NULL COMMENT '用户回答JSON', trust_score DECIMAL(5,2), intimacy_score DECIMAL(5,2), communication_score DECIMAL(5,2), interaction_bonus DECIMAL(5,2) DEFAULT 0, total_score DECIMAL(5,2), calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_snapshot (snapshot_id), INDEX idx_respondent (respondent_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答结果'");
             log.info("已创建relationship_questionnaire_responses表");
         } catch (Exception e) {}
-    }
+
+        // 迁移: 创建分销体系表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_systems (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "name VARCHAR(100) NOT NULL COMMENT '团队名称', " +
+                    "description VARCHAR(500) COMMENT '团队描述', " +
+                    "owner_id BIGINT COMMENT '创建人(服务商用户ID)', " +
+                    "profit_rate INT DEFAULT 0 COMMENT '利润分成比例(bps, 如2000=20%)', " +
+                    "enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='虚拟服务商团队(分销体系)'");
+            log.info("已创建distribution_systems表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
+        // 迁移: 创建分销体系成员关系表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS distribution_relations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "system_id BIGINT NOT NULL COMMENT '所属团队ID', " +
+                    "user_id BIGINT NOT NULL COMMENT '服务商用户ID', " +
+                    "parent_id BIGINT COMMENT '上级服务商用户ID', " +
+                    "depth INT DEFAULT 0 COMMENT '层级深度(0=root)', " +
+                    "invite_code VARCHAR(32) COMMENT '分销邀请码', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_system (system_id), " +
+                    "INDEX idx_user (user_id), " +
+                    "INDEX idx_parent (parent_id), " +
+                    "INDEX idx_invite_code (invite_code)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队-服务商关系'");
+            log.info("已创建distribution_relations表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
+        // 迁移: distribution_relations表添加invite_code字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE distribution_relations ADD COLUMN invite_code VARCHAR(32) COMMENT '分销邀请码'");
+            log.info("已添加invite_code列到distribution_relations表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE distribution_relations ADD INDEX idx_invite_code (invite_code)");
+            log.info("已添加idx_invite_code索引到distribution_relations表");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
+    }
 
     private void ensureColumn(String table, String column, String definition) {
         try {

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DistributionInviteController.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DistributionRelation;
+import com.etotem.cfc.entity.DistributionSystem;
+import com.etotem.cfc.service.DistributionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "分销邀请", description = "服务商分销邀请码和小程序端接口")
+@RestController
+@RequestMapping("/api/distribution/invite")
+public class DistributionInviteController {
+
+    @Resource
+    private DistributionService distributionService;
+
+    @Operation(summary = "生成/获取我的分销邀请码")
+    @PostMapping("/generate")
+    public Result<String> generate(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long systemId = params.get("systemId") != null
+                ? Long.valueOf(params.get("systemId").toString()) : null;
+        if (systemId == null) return Result.error("systemId不能为空");
+        return distributionService.generateInviteCode(systemId, userId);
+    }
+
+    @Operation(summary = "校验邀请码有效性")
+    @PostMapping("/validate")
+    public Result<Map<String, Object>> validate(@RequestBody Map<String, Object> params) {
+        String inviteCode = params.get("inviteCode") != null
+                ? params.get("inviteCode").toString() : null;
+        if (inviteCode == null || inviteCode.isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+
+        Result<DistributionRelation> relationResult = distributionService.findByInviteCode(inviteCode);
+        if (relationResult.getCode() != 200 || relationResult.getData() == null) {
+            return Result.error("邀请码无效");
+        }
+
+        DistributionRelation relation = relationResult.getData();
+        // Load the system to get team info
+        Result<List<DistributionSystem>> teamsResult = distributionService.getUserTeams(relation.getUserId());
+        String teamName = null;
+        if (teamsResult.getCode() == 200 && teamsResult.getData() != null
+                && !teamsResult.getData().isEmpty()) {
+            teamName = teamsResult.getData().get(0).getName();
+        }
+
+        Map<String, Object> result = new java.util.LinkedHashMap<>();
+        result.put("valid", true);
+        result.put("inviteCode", inviteCode);
+        result.put("systemId", relation.getSystemId());
+        result.put("inviterId", relation.getUserId());
+        result.put("teamName", teamName);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "我所在的虚拟团队列表")
+    @PostMapping("/my-teams")
+    public Result<List<DistributionSystem>> myTeams(
+            @RequestAttribute("userId") Long userId) {
+        return distributionService.getUserTeams(userId);
+    }
+
+    @Operation(summary = "我在团队中的位置信息")
+    @PostMapping("/my-position")
+    public Result<Map<String, Object>> myPosition(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long systemId = params.get("systemId") != null
+                ? Long.valueOf(params.get("systemId").toString()) : null;
+        if (systemId == null) return Result.error("systemId不能为空");
+        return distributionService.getUserPosition(systemId, userId);
+    }
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DistributionRelation.java

@@ -16,5 +16,6 @@ public class DistributionRelation implements Serializable {
     private Long userId;
     private Long parentId;
     private Integer depth;
+    private String inviteCode;
     private Date createdAt;
 }

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DistributionService.java

@@ -325,4 +325,71 @@ public class DistributionService {
         distributionRelationMapper.updateById(relation);
         return Result.success("更新成功");
     }
+
+    public Result<String> generateInviteCode(Long systemId, Long userId) {
+        DistributionSystem system = distributionSystemMapper.selectById(systemId);
+        if (system == null) return Result.error("分销体系不存在");
+        if (!Boolean.TRUE.equals(system.getEnabled())) return Result.error("分销体系未启用");
+
+        DistributionRelation relation = distributionRelationMapper.selectOne(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, systemId)
+                .eq(DistributionRelation::getUserId, userId)
+        );
+        if (relation == null) return Result.error("用户不在该分销体系中");
+
+        String code = generateUniqueCode();
+        relation.setInviteCode(code);
+        distributionRelationMapper.updateById(relation);
+        return Result.success(code);
+    }
+
+    private String generateUniqueCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        Random random = new Random();
+        StringBuilder code = new StringBuilder();
+        for (int i = 0; i < 8; i++) {
+            code.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        return code.toString();
+    }
+
+    public Result<DistributionRelation> findByInviteCode(String inviteCode) {
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        DistributionRelation relation = distributionRelationMapper.selectOne(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getInviteCode, inviteCode)
+        );
+        if (relation == null) return Result.error("邀请码不存在");
+        return Result.success(relation);
+    }
+
+    public Result<String> registerByInviteCode(String inviteCode, Long newUserId) {
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        DistributionRelation inviter = distributionRelationMapper.selectOne(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getInviteCode, inviteCode)
+        );
+        if (inviter == null) return Result.error("邀请码不存在");
+
+        DistributionRelation existing = distributionRelationMapper.selectOne(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, inviter.getSystemId())
+                .eq(DistributionRelation::getUserId, newUserId)
+        );
+        if (existing != null) return Result.error("用户已在该分销体系中");
+
+        DistributionRelation relation = new DistributionRelation();
+        relation.setSystemId(inviter.getSystemId());
+        relation.setUserId(newUserId);
+        relation.setParentId(inviter.getUserId());
+        relation.setDepth(inviter.getDepth() + 1);
+        relation.setCreatedAt(new Date());
+        distributionRelationMapper.insert(relation);
+        return Result.success("注册成功");
+    }
 }

+ 57 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -12,7 +12,9 @@ import com.etotem.cfc.mapper.ChildMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.VerificationCodeService;
-import com.etotem.cfc.service.WechatService;
+import com.etotem.cfc.entity.DistributionRelation;
+import com.etotem.cfc.service.DistributionService;
+import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.api.UserServiceInterface;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Service;
@@ -43,6 +45,12 @@ private VerificationCodeService verificationCodeService;
 @Resource
 private WechatService wechatService;
 
+    @Resource
+    private DanshopSyncService danshopSyncService;
+
+    @Resource
+    private DistributionService distributionService;
+
     @Resource
     private OnboardingService onboardingService;
 
@@ -1086,22 +1094,58 @@ private WechatService wechatService;
         String inviteCode = dto.getInviteCode();
         String role = dto.getRole() != null ? dto.getRole() : "parent";
         String nickname = dto.getNickname() != null ? dto.getNickname() : "用户" + phone.substring(7);
-        
-        // 查找家庭
+
+        Result<DistributionRelation> distResult = distributionService.findByInviteCode(inviteCode);
+        if (distResult.getCode() == 200 && distResult.getData() != null) {
+            Family family = new Family();
+            family.setName("我的家庭");
+            family.setInviteCode(generateInviteCode());
+            family.setCreatedAt(new Date());
+            family.setUpdatedAt(new Date());
+            familyMapper.insert(family);
+
+            User user = new User();
+            user.setOpenid("phone:" + phone);
+            user.setUnionid("");
+            user.setFamilyId(family.getId());
+            user.setRole(role);
+            user.setNickname(nickname);
+            user.setPhone(phone);
+            user.setPassword("");
+            user.setCreatedAt(new Date());
+            user.setUpdatedAt(new Date());
+            userMapper.insert(user);
+
+            distributionService.registerByInviteCode(inviteCode, user.getId());
+
+            String token = jwtConfig.generateToken(user.getId(), user.getRole());
+
+            LoginResultDTO result = new LoginResultDTO();
+            result.setToken(token);
+            result.setUserId(user.getId());
+            result.setRole(user.getRole());
+            result.setRoles(user.getRoles());
+            result.setFamilyId(user.getFamilyId());
+            result.setNickname(user.getNickname());
+            result.setIsNewUser(true);
+            populateTeacherFields(result, user);
+            return result;
+        }
+
         Family family = familyMapper.selectOne(new LambdaQueryWrapper<Family>()
                 .eq(Family::getInviteCode, inviteCode));
-        
-        User user;
-        boolean isNewUser = false;
-        
+
         if (family == null) {
             throw new RuntimeException("邀请码无效");
         }
-        
+
+        User user;
+        boolean isNewUser = false;
+
         // 检查手机号是否已注册
         User existingUser = userMapper.selectOne(new LambdaQueryWrapper<User>()
                 .eq(User::getPhone, phone));
-        
+
         if (existingUser != null) {
             // 管理员不能登录小程序
             if ("admin".equals(existingUser.getRole())) {
@@ -1128,7 +1172,7 @@ private WechatService wechatService;
             user.setUpdatedAt(new Date());
             userMapper.insert(user);
         }
-        
+
         // 如果家庭绑定了成长规划师,自动将家庭ID添加到成长规划师的 teacherFamilyIds
         if (family.getTeacherId() != null) {
             User teacher = userMapper.selectById(family.getTeacherId());
@@ -1143,10 +1187,10 @@ private WechatService wechatService;
                 }
             }
         }
-        
+
         // 生成token
         String token = jwtConfig.generateToken(user.getId(), user.getRole());
-        
+
         LoginResultDTO result = new LoginResultDTO();
         result.setToken(token);
         result.setUserId(user.getId());
@@ -1156,7 +1200,7 @@ private WechatService wechatService;
         result.setNickname(user.getNickname());
         result.setIsNewUser(isNewUser);
         populateTeacherFields(result, user);
-        
+
         return result;
     }
 

+ 157 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/CommissionScheduledTasks.java

@@ -0,0 +1,157 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CommissionRecord;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.mapper.CommissionRecordMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 分佣定时任务
+ * - 更新推荐统计(每日2:00)
+ * - 结算待处理佣金(每日3:00)
+ * - 批量处理提现(每日23:30)
+ */
+@Slf4j
+@Component
+public class CommissionScheduledTasks {
+
+    @Resource
+    private CommissionRecordMapper commissionRecordMapper;
+
+    @Resource
+    private WithdrawalRequestMapper withdrawalRequestMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 每天 2:00 AM 执行
+     * 重新计算所有推荐人的直接推荐人数和间接推荐人数
+     */
+    @Scheduled(cron = "0 0 2 * * ?")
+    public void updateDistributionStats() {
+        log.info("开始执行推荐统计更新定时任务");
+        try {
+            // 查询所有有推荐人的用户,提取不重复的推荐人ID
+            List<User> referredUsers = userMapper.selectList(
+                    new LambdaQueryWrapper<User>().isNotNull(User::getReferrerId)
+            );
+            List<Long> referrerIds = referredUsers.stream()
+                    .map(User::getReferrerId)
+                    .distinct()
+                    .collect(java.util.stream.Collectors.toList());
+
+            int updatedCount = 0;
+            for (Long referrerId : referrerIds) {
+                // 直接推荐人数 = referrer_id = referrerId 的用户数
+                Long directCount = userMapper.selectCount(
+                        new LambdaQueryWrapper<User>().eq(User::getReferrerId, referrerId)
+                );
+
+                // 间接推荐人数 = 所有L1用户的直接推荐人数之和
+                List<User> l1Users = userMapper.selectList(
+                        new LambdaQueryWrapper<User>().eq(User::getReferrerId, referrerId)
+                );
+                long indirectCount = 0;
+                for (User l1User : l1Users) {
+                    indirectCount += userMapper.selectCount(
+                            new LambdaQueryWrapper<User>().eq(User::getReferrerId, l1User.getId())
+                    );
+                }
+
+                // 更新推荐人统计
+                User referrer = userMapper.selectById(referrerId);
+                if (referrer != null) {
+                    referrer.setDirectCount(directCount.intValue());
+                    referrer.setIndirectCount((int) indirectCount);
+                    referrer.setUpdatedAt(new Date());
+                    userMapper.updateById(referrer);
+                    updatedCount++;
+                }
+            }
+            log.info("推荐统计更新定时任务执行完成,共更新{}个推荐人", updatedCount);
+        } catch (Exception e) {
+            log.error("推荐统计更新定时任务执行失败", e);
+        }
+    }
+
+    /**
+     * 每天 3:00 AM 执行
+     * 结算创建超过7天的待处理佣金记录
+     */
+    @Scheduled(cron = "0 0 3 * * ?")
+    @Transactional(rollbackFor = Exception.class)
+    public void processPendingCommissions() {
+        log.info("开始执行待结算佣金处理定时任务");
+        try {
+            Calendar calendar = Calendar.getInstance();
+            calendar.add(Calendar.DAY_OF_MONTH, -7);
+            Date sevenDaysAgo = calendar.getTime();
+
+            List<CommissionRecord> pendingRecords = commissionRecordMapper.selectList(
+                    new LambdaQueryWrapper<CommissionRecord>()
+                            .eq(CommissionRecord::getStatus, "pending")
+                            .lt(CommissionRecord::getCreatedAt, sevenDaysAgo)
+            );
+
+            for (CommissionRecord record : pendingRecords) {
+                record.setStatus("settled");
+                record.setSettledAt(new Date());
+                commissionRecordMapper.updateById(record);
+            }
+            log.info("待结算佣金处理定时任务执行完成,共处理{}条记录", pendingRecords.size());
+        } catch (Exception e) {
+            log.error("待结算佣金处理定时任务执行失败", e);
+        }
+    }
+
+    /**
+     * 每天 23:30 执行
+     * 批量处理已审核通过的提现请求
+     */
+    @Scheduled(cron = "0 30 23 * * ?")
+    @Transactional(rollbackFor = Exception.class)
+    public void batchProcessWithdrawals() {
+        log.info("开始执行批量提现处理定时任务");
+        try {
+            List<WithdrawalRequest> approvedRequests = withdrawalRequestMapper.selectList(
+                    new LambdaQueryWrapper<WithdrawalRequest>()
+                            .eq(WithdrawalRequest::getStatus, "approved")
+            );
+
+            for (WithdrawalRequest request : approvedRequests) {
+                // 将该用户所有已结算的佣金记录标记为已提现
+                List<CommissionRecord> settledRecords = commissionRecordMapper.selectList(
+                        new LambdaQueryWrapper<CommissionRecord>()
+                                .eq(CommissionRecord::getReferrerId, request.getUserId())
+                                .eq(CommissionRecord::getStatus, "settled")
+                );
+                for (CommissionRecord record : settledRecords) {
+                    record.setStatus("withdrawn");
+                    record.setWithdrawnAt(new Date());
+                    commissionRecordMapper.updateById(record);
+                }
+
+                // 更新提现请求状态
+                request.setStatus("processed");
+                request.setRemark("批量处理");
+                withdrawalRequestMapper.updateById(request);
+            }
+            log.info("批量提现处理定时任务执行完成,共处理{}条请求", approvedRequests.size());
+        } catch (Exception e) {
+            log.error("批量提现处理定时任务执行失败", e);
+        }
+    }
+}