瀏覽代碼

feat(onboarding): add newbie task reward system with 6 task types

Xiaogang Liao 2 月之前
父節點
當前提交
e27ed82535

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/OnboardingController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.OnboardingTask;
+import com.etotem.cfc.service.OnboardingService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/onboarding")
+public class OnboardingController {
+
+    @Resource
+    private OnboardingService onboardingService;
+
+    @PostMapping("/progress")
+    public Result<List<OnboardingTask>> progress(@RequestAttribute("userId") Long userId) {
+        return Result.success(onboardingService.getProgress(userId));
+    }
+
+    @PostMapping("/claim")
+    public Result<String> claim(@RequestAttribute("userId") Long userId,
+                                @RequestBody Map<String, String> params) {
+        String taskType = params.get("taskType");
+        String result = onboardingService.claimReward(userId, taskType);
+        return Result.success(result);
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -52,6 +52,9 @@ public class UserController {
     @Resource
     private AssessmentOrderMapper assessmentOrderMapper;
 
+    @Resource
+    private com.etotem.cfc.service.OnboardingService onboardingService;
+
     @PostMapping("/children/list")
     @Operation(summary = "获取子女列表")
     public Result<List<ChildInfoDTO>> getChildren(HttpServletRequest request) {
@@ -90,6 +93,9 @@ public class UserController {
         if (params.containsKey("familyRole")) dto.setFamilyRole((String) params.get("familyRole"));
 
         boolean success = userService.updateUserInfo(userId, dto);
+        if (success) {
+            try { onboardingService.completeTask(userId, "COMPLETE_PROFILE"); } catch (Exception e) { /* onboarding trigger failure should not block profile update */ }
+        }
         return success ? Result.success(true) : Result.error("更新失败");
     }
 

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/OnboardingTask.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("onboarding_task")
+public class OnboardingTask implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String taskType;
+
+    private Integer rewardPoints;
+
+    private Integer rewardEnergy;
+
+    private String status;
+
+    private Date completedAt;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/OnboardingTaskMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.OnboardingTask;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OnboardingTaskMapper extends BaseMapper<OnboardingTask> {
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -47,6 +47,9 @@ public class CommissionService {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private OnboardingService onboardingService;
+
     // ==================== 工具方法 ====================
 
     /**
@@ -107,6 +110,8 @@ public class CommissionService {
         // 更新推荐人直接推荐人数
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         userMapper.updateById(referrer);
+
+        try { onboardingService.completeTask(userId, "BIND_REFERRAL"); } catch (Exception e) { /* onboarding trigger failure should not block referral bind */ }
     }
 
     public Page<User> getMyReferrals(Long userId, int page, int size) {

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -40,6 +40,9 @@ public class FamilyMemberService {
     @Resource
     private FamilyMemberAttributesMapper familyMemberAttributesMapper;
 
+    @Resource
+    private OnboardingService onboardingService;
+
     /**
      * 添加家庭成员
      */
@@ -145,6 +148,7 @@ public class FamilyMemberService {
             child.setCreatedAt(new Date());
             child.setUpdatedAt(new Date());
             childMapper.insert(child);
+            try { onboardingService.completeTask(userId, "ADD_CHILD"); } catch (Exception e) { /* onboarding trigger failure should not block member add */ }
         }
 
         return toFamilyMemberVO(member, relType);

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java

@@ -44,6 +44,9 @@ public class HealthCheckinService {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private OnboardingService onboardingService;
+
     public List<HealthCheckin> getCheckins(Long userId, Long childId, String yearMonth) {
         LambdaQueryWrapper<HealthCheckin> wrapper = new LambdaQueryWrapper<HealthCheckin>()
                 .eq(HealthCheckin::getUserId, userId);
@@ -165,6 +168,8 @@ public class HealthCheckinService {
             log.warn("健康打卡能量发放失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
         }
 
+        try { onboardingService.completeTask(checkin.getUserId(), "FIRST_CHECKIN"); } catch (Exception e) { /* onboarding trigger failure should not block checkin */ }
+
         return checkin;
     }
 

+ 126 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OnboardingService.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.OnboardingTask;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.OnboardingTaskMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class OnboardingService {
+
+    @Resource
+    private OnboardingTaskMapper onboardingTaskMapper;
+
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    private static final Map<String, int[]> REWARDS = new LinkedHashMap<>();
+    static {
+        REWARDS.put("REGISTER", new int[]{100, 10});
+        REWARDS.put("COMPLETE_PROFILE", new int[]{50, 5});
+        REWARDS.put("ADD_CHILD", new int[]{100, 10});
+        REWARDS.put("FIRST_CHECKIN", new int[]{30, 5});
+        REWARDS.put("FIRST_TASK", new int[]{50, 5});
+        REWARDS.put("BIND_REFERRAL", new int[]{50, 10});
+    }
+
+    /**
+     * Initialize 6 PENDING onboarding tasks for a new user
+     */
+    public void initForUser(Long userId) {
+        for (Map.Entry<String, int[]> entry : REWARDS.entrySet()) {
+            OnboardingTask task = new OnboardingTask();
+            task.setUserId(userId);
+            task.setTaskType(entry.getKey());
+            task.setRewardPoints(entry.getValue()[0]);
+            task.setRewardEnergy(entry.getValue()[1]);
+            task.setStatus("PENDING");
+            task.setCreatedAt(new Date());
+            onboardingTaskMapper.insert(task);
+        }
+    }
+
+    /**
+     * Mark a task as COMPLETED if it is still PENDING
+     */
+    public void completeTask(Long userId, String taskType) {
+        OnboardingTask task = onboardingTaskMapper.selectOne(
+                new LambdaQueryWrapper<OnboardingTask>()
+                        .eq(OnboardingTask::getUserId, userId)
+                        .eq(OnboardingTask::getTaskType, taskType)
+                        .eq(OnboardingTask::getStatus, "PENDING")
+                        .last("LIMIT 1")
+        );
+        if (task != null) {
+            task.setStatus("COMPLETED");
+            task.setCompletedAt(new Date());
+            onboardingTaskMapper.updateById(task);
+        }
+    }
+
+    /**
+     * Claim reward for a COMPLETED task: set CLAIMED, award points + energy
+     */
+    @Transactional
+    public String claimReward(Long userId, String taskType) {
+        OnboardingTask task = onboardingTaskMapper.selectOne(
+                new LambdaQueryWrapper<OnboardingTask>()
+                        .eq(OnboardingTask::getUserId, userId)
+                        .eq(OnboardingTask::getTaskType, taskType)
+                        .eq(OnboardingTask::getStatus, "COMPLETED")
+                        .last("LIMIT 1")
+        );
+        if (task == null) {
+            return "任务未完成或已领取";
+        }
+
+        task.setStatus("CLAIMED");
+        onboardingTaskMapper.updateById(task);
+
+        // Find child by userId to award points and energy
+        Child child = childMapper.selectOne(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getUserId, userId)
+                        .last("LIMIT 1")
+        );
+        if (child != null) {
+            try {
+                pointsService.awardSystemPoints(child.getId(), task.getRewardPoints(),
+                        "新手引导奖励: " + taskType);
+            } catch (Exception e) {
+                // Points award failure should not block claim
+            }
+            try {
+                energyService.awardEnergy(child.getId(), "onboarding", 0L,
+                        task.getRewardEnergy(), "新手引导奖励: " + taskType, 30);
+            } catch (Exception e) {
+                // Energy award failure should not block claim
+            }
+        }
+
+        return "奖励已发放";
+    }
+
+    /**
+     * Get all onboarding tasks for a user
+     */
+    public List<OnboardingTask> getProgress(Long userId) {
+        return onboardingTaskMapper.selectList(
+                new LambdaQueryWrapper<OnboardingTask>()
+                        .eq(OnboardingTask::getUserId, userId)
+                        .orderByAsc(OnboardingTask::getTaskType)
+        );
+    }
+}

+ 7 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -45,6 +45,9 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private OnboardingService onboardingService;
+
     private static final int EARLY_BONUS = 1; // 提前完成奖励
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
     private static final int LATE_GRACE_MINUTES = 10; // 10分钟内不算迟到
@@ -567,8 +570,10 @@ return result;
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
 
-    Map<String, Object> result = new HashMap<>();
-    result.put("pointsEarned", pointsEarned);
+        Map<String, Object> result = new HashMap<>();
+        result.put("pointsEarned", pointsEarned);
+
+        try { onboardingService.completeTask(child.getUserId(), "FIRST_TASK"); } catch (Exception e) { /* onboarding trigger failure should not block task completion */ }
     result.put("newBalance", user.getTotalPoints());
     result.put("needReview", task.getNeedReview() == 1);
 

+ 8 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -43,8 +43,11 @@ private VerificationCodeService verificationCodeService;
 @Resource
 private WechatService wechatService;
 
-@Resource
-private DanshopSyncService danshopSyncService;
+    @Resource
+    private DanshopSyncService danshopSyncService;
+
+    @Resource
+    private OnboardingService onboardingService;
 
     public LoginResultDTO wechatLogin(WechatLoginDTO dto) {
         // 模拟微信登录,实际需要调用微信API获取openid
@@ -74,6 +77,7 @@ private DanshopSyncService danshopSyncService;
             user.setCreatedAt(new Date());
             user.setUpdatedAt(new Date());
             userMapper.insert(user);
+            try { onboardingService.initForUser(user.getId()); onboardingService.completeTask(user.getId(), "REGISTER"); } catch (Exception e) { /* onboarding init failure should not block login */ }
         } else {
             // 管理员不能登录小程序
             if ("admin".equals(user.getRole())) {
@@ -145,6 +149,7 @@ private DanshopSyncService danshopSyncService;
     // 设置家庭创建者
     family.setCreatorId(user.getId());
     familyMapper.updateById(family);
+    try { onboardingService.initForUser(user.getId()); onboardingService.completeTask(user.getId(), "REGISTER"); } catch (Exception e) { /* onboarding init failure should not block login */ }
   } else {
     // 老用户,更新信息(如果提供了)
     if (dto.getNickname() != null && !dto.getNickname().isEmpty()) {
@@ -220,6 +225,7 @@ private DanshopSyncService danshopSyncService;
                 user.setCreatedAt(new Date());
                 user.setUpdatedAt(new Date());
                 userMapper.insert(user);
+                try { onboardingService.initForUser(user.getId()); onboardingService.completeTask(user.getId(), "REGISTER"); } catch (Exception e) { /* onboarding init failure should not block login */ }
             } else {
                 // 6. 手机号已存在但openid不同,绑定openid
                 user.setOpenid(openid);

+ 16 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1588,3 +1588,19 @@ CREATE TABLE IF NOT EXISTS user_address (
     INDEX idx_user_id (user_id),
     INDEX idx_is_default (is_default)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户收货地址表';
+
+-- =============================================
+-- 新手引导奖励表
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS onboarding_task (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL,
+    task_type VARCHAR(32) NOT NULL,
+    reward_points INT DEFAULT 0,
+    reward_energy INT DEFAULT 0,
+    status VARCHAR(16) DEFAULT 'PENDING',
+    completed_at DATETIME,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;