Kaynağa Gözat

feat: add backend services and controllers for energy/commission/article/badge/game/invite modules

New services: ArticleCategory, ArticlePermission, Article, Badge, Commission, ContentSection, Energy, GameRecord, Withdrawal. New controllers: Badge, Commission, DanAssessment, Energy, GameRecord, Invite, ReportStats, SeedEnergy, Withdrawal, AdminArticle, AdminCommission. New content controllers: Article, ArticleCategory, ContentSection.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 3 ay önce
ebeveyn
işleme
c3e32460a9
23 değiştirilmiş dosya ile 3169 ekleme ve 0 silme
  1. 125 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/BadgeController.java
  2. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java
  3. 252 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java
  4. 113 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  5. 73 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/GameRecordController.java
  6. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java
  7. 189 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ReportStatsController.java
  8. 109 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/SeedEnergyController.java
  9. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/WithdrawalController.java
  10. 120 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  11. 75 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java
  12. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java
  13. 105 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  14. 85 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ContentSectionController.java
  15. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java
  16. 89 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java
  17. 152 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  18. 169 0
      cfc-backend/src/main/java/com/etotem/cfc/service/BadgeService.java
  19. 243 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
  20. 104 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ContentSectionService.java
  21. 770 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  22. 123 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GameRecordService.java
  23. 81 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WithdrawalService.java

+ 125 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/BadgeController.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Badge;
+import com.etotem.cfc.entity.ChildBadge;
+import com.etotem.cfc.service.BadgeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "勋章管理", description = "勋章系统相关接口")
+@RestController
+@RequestMapping("/api/badges")
+public class BadgeController {
+
+    @Resource
+    private BadgeService badgeService;
+
+    // ===== 勋章定义 =====
+
+    @Operation(summary = "获取所有勋章(管理端)")
+    @PostMapping("/all")
+    public Result<List<Badge>> getAllBadges() {
+        return Result.success(badgeService.getAllBadges());
+    }
+
+    @Operation(summary = "获取启用的勋章列表")
+    @PostMapping("/list")
+    public Result<List<Badge>> getActiveBadges() {
+        return Result.success(badgeService.getActiveBadges());
+    }
+
+    @Operation(summary = "获取勋章详情")
+    @PostMapping("/detail")
+    public Result<Badge> getBadgeDetail(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        Badge badge = badgeService.getBadgeById(id);
+        if (badge == null) {
+            return Result.error("勋章不存在");
+        }
+        return Result.success(badge);
+    }
+
+    @Operation(summary = "创建勋章")
+    @PostMapping("/create")
+    public Result<String> createBadge(@RequestBody Badge badge) {
+        badgeService.createBadge(badge);
+        return Result.success("创建成功");
+    }
+
+    @Operation(summary = "更新勋章")
+    @PostMapping("/update")
+    public Result<String> updateBadge(@RequestBody Badge badge) {
+        badgeService.updateBadge(badge);
+        return Result.success("更新成功");
+    }
+
+    @Operation(summary = "删除勋章")
+    @PostMapping("/delete")
+    public Result<String> deleteBadge(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        badgeService.deleteBadge(id);
+        return Result.success("删除成功");
+    }
+
+    // ===== 勋章授予 =====
+
+    @Operation(summary = "手动授予勋章")
+    @PostMapping("/grant")
+    public Result<String> grantBadge(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Long badgeId = Long.valueOf(body.get("badgeId").toString());
+        badgeService.grantBadge(childId, badgeId);
+        return Result.success("授予成功");
+    }
+
+    // ===== 孩子勋章 =====
+
+    @Operation(summary = "获取孩子的勋章列表")
+    @PostMapping("/child/list")
+    public Result<List<Map<String, Object>>> getChildBadges(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        List<ChildBadge> childBadges = badgeService.getActiveChildBadges(childId);
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (ChildBadge cb : childBadges) {
+            Badge badge = badgeService.getBadgeById(cb.getBadgeId());
+            if (badge != null) {
+                Map<String, Object> item = new java.util.HashMap<>();
+                item.put("childBadge", cb);
+                item.put("badge", badge);
+                result.add(item);
+            }
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "收藏/取消收藏勋章")
+    @PostMapping("/favorite")
+    public Result<String> toggleFavorite(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Long badgeId = Long.valueOf(body.get("badgeId").toString());
+        badgeService.toggleFavorite(childId, badgeId);
+        return Result.success("操作成功");
+    }
+
+    // ===== 统计与排行 =====
+
+    @Operation(summary = "孩子勋章统计")
+    @PostMapping("/statistics/child")
+    public Result<Map<String, Object>> getChildStats(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        return Result.success(badgeService.getChildBadgeStats(childId));
+    }
+
+    @Operation(summary = "家庭勋章排行")
+    @PostMapping("/ranking/family")
+    public Result<List<Map<String, Object>>> getFamilyRanking(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        return Result.success(badgeService.getFamilyRanking(familyId));
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CommissionSummaryDTO;
+import com.etotem.cfc.entity.CommissionRecord;
+import com.etotem.cfc.service.CommissionService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/commission")
+public class CommissionController {
+
+    @Resource
+    private CommissionService commissionService;
+
+    @PostMapping("/summary")
+    public Result<CommissionSummaryDTO> summary(@RequestAttribute("userId") Long userId) {
+        return Result.success(commissionService.getSummary(userId));
+    }
+
+    @PostMapping("/list")
+    public Result<Page<CommissionRecord>> list(@RequestAttribute("userId") Long userId,
+                                               @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        return Result.success(commissionService.getList(userId, page, size));
+    }
+}

+ 252 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -0,0 +1,252 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.service.*;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "DAN测评", description = "DAN专注力测评相关接口")
+@RestController
+@RequestMapping("/api/dan-assessment")
+public class DanAssessmentController {
+
+    @Resource
+    private AssessmentService assessmentService;
+
+    @Resource
+    private AssessmentAppointmentService appointmentService;
+
+    @Resource
+    private AssessmentOrderService orderService;
+
+    @Resource
+    private AssessmentPermissionService permissionService;
+
+    // ===== 测评材料 =====
+
+    @Operation(summary = "获取当前测评材料")
+    @GetMapping("/material/active")
+    public Result<AssessmentMaterial> getActiveMaterial() {
+        AssessmentMaterial material = assessmentService.getActiveMaterial();
+        return Result.success(material);
+    }
+
+    @Operation(summary = "获取测评材料列表")
+    @GetMapping("/material/list")
+    public Result<List<AssessmentMaterial>> getMaterialList() {
+        return Result.success(assessmentService.getMaterialList());
+    }
+
+    // ===== 积分配置 =====
+
+    @Operation(summary = "获取积分配置")
+    @GetMapping("/points-config")
+    public Result<AssessmentPointsConfig> getPointsConfig() {
+        return Result.success(assessmentService.getPointsConfig());
+    }
+
+    // ===== 家庭配置 =====
+
+    @Operation(summary = "获取家庭测评配置")
+    @GetMapping("/family/config")
+    public Result<FamilyAssessmentConfig> getFamilyConfig(@RequestParam Long familyId) {
+        return Result.success(assessmentService.getFamilyConfig(familyId));
+    }
+
+    @Operation(summary = "启用家庭测评")
+    @PostMapping("/family/enable")
+    public Result<String> enableFamilyAssessment(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        Long materialId = body.get("materialId") != null
+                ? Long.valueOf(body.get("materialId").toString()) : null;
+        Integer remindMonths = body.get("remindMonths") != null
+                ? Integer.valueOf(body.get("remindMonths").toString()) : 6;
+        Long createdBy = body.get("createdBy") != null
+                ? Long.valueOf(body.get("createdBy").toString()) : null;
+        boolean ok = assessmentService.enableFamilyAssessment(familyId, materialId, remindMonths, createdBy);
+        return ok ? Result.success("启用成功") : Result.error("启用失败");
+    }
+
+    @Operation(summary = "禁用家庭测评")
+    @PostMapping("/family/disable")
+    public Result<String> disableFamilyAssessment(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        boolean ok = assessmentService.disableFamilyAssessment(familyId);
+        return ok ? Result.success("禁用成功") : Result.error("禁用失败");
+    }
+
+    // ===== 测评记录 =====
+
+    @Operation(summary = "创建测评记录")
+    @PostMapping("/record/create")
+    public Result<AssessmentRecord> createRecord(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Long materialId = body.get("materialId") != null
+                ? Long.valueOf(body.get("materialId").toString()) : null;
+        return Result.success(assessmentService.createRecord(familyId, childId, materialId));
+    }
+
+    @Operation(summary = "完成测评记录")
+    @PostMapping("/record/complete")
+    public Result<String> completeRecord(@RequestBody Map<String, Object> body) {
+        Long recordId = Long.valueOf(body.get("recordId").toString());
+        boolean ok = assessmentService.completeRecord(recordId);
+        return ok ? Result.success("完成成功") : Result.error("记录不存在");
+    }
+
+    @Operation(summary = "获取家庭测评记录")
+    @GetMapping("/record/family")
+    public Result<List<AssessmentRecord>> getFamilyRecords(@RequestParam Long familyId) {
+        return Result.success(assessmentService.getFamilyRecords(familyId));
+    }
+
+    @Operation(summary = "获取当前测评")
+    @GetMapping("/record/current")
+    public Result<AssessmentRecord> getCurrentAssessment(@RequestParam Long familyId) {
+        return Result.success(assessmentService.getCurrentAssessment(familyId));
+    }
+
+    // ===== 测评结果 =====
+
+    @Operation(summary = "记录/提交测评结果")
+    @PostMapping("/result/record")
+    public Result<DanAssessmentResult> recordResult(@RequestBody DanAssessmentResult result) {
+        return Result.success(assessmentService.recordResult(result));
+    }
+
+    @Operation(summary = "获取我的测评报告列表")
+    @GetMapping("/my-results")
+    public Result<List<DanAssessmentResult>> getMyResults(@RequestParam(required = false) Long userId) {
+        if (userId == null) {
+            return Result.success(assessmentService.getAccessibleResults(0L, "teacher"));
+        }
+        // 这里简化为返回该用户相关的报告,实际可根据角色判断
+        return Result.success(assessmentService.getAccessibleResults(userId, "teacher"));
+    }
+
+    @Operation(summary = "获取下级成长规划师的测评报告")
+    @GetMapping("/child-results")
+    public Result<List<DanAssessmentResult>> getChildResults(@RequestParam(required = false) Long userId) {
+        List<DanAssessmentResult> results = assessmentService.getAccessibleResults(
+                userId != null ? userId : 0L, "teacher");
+        return Result.success(results);
+    }
+
+    @Operation(summary = "获取测评报告详情")
+    @GetMapping("/result/{id}")
+    public Result<DanAssessmentResult> getResult(@PathVariable Long id) {
+        return Result.success(assessmentService.getByAppointmentId(id));
+    }
+
+    // ===== 预约 =====
+
+    @Operation(summary = "创建测评预约")
+    @PostMapping("/appointment/create")
+    public Result<AssessmentAppointment> createAppointment(@RequestBody Map<String, Object> body) {
+        Long userId = body.get("userId") != null ? Long.valueOf(body.get("userId").toString()) : null;
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Long guideId = Long.valueOf(body.get("guideId").toString());
+        Long packageId = body.get("packageId") != null ? Long.valueOf(body.get("packageId").toString()) : null;
+        String appointmentDate = (String) body.get("appointmentDate");
+        String appointmentTime = (String) body.get("appointmentTime");
+        String notes = (String) body.get("notes");
+        AssessmentAppointment appointment = appointmentService.createAppointment(
+                userId, childId, guideId, packageId, appointmentDate, appointmentTime, notes);
+        return Result.success(appointment);
+    }
+
+    @Operation(summary = "确认预约")
+    @PostMapping("/appointment/confirm")
+    public Result<String> confirmAppointment(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        Long teacherId = Long.valueOf(body.get("teacherId").toString());
+        boolean ok = appointmentService.confirmAppointment(id, teacherId);
+        return ok ? Result.success("确认成功") : Result.error("确认失败");
+    }
+
+    @Operation(summary = "完成预约")
+    @PostMapping("/appointment/complete")
+    public Result<String> completeAppointment(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        Long teacherId = Long.valueOf(body.get("teacherId").toString());
+        boolean ok = appointmentService.completeAppointment(id, teacherId);
+        return ok ? Result.success("完成成功") : Result.error("完成失败");
+    }
+
+    @Operation(summary = "取消预约")
+    @PostMapping("/appointment/cancel")
+    public Result<String> cancelAppointment(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        boolean ok = appointmentService.cancelAppointment(id);
+        return ok ? Result.success("取消成功") : Result.error("取消失败");
+    }
+
+    @Operation(summary = "获取用户的预约列表")
+    @GetMapping("/appointment/user")
+    public Result<List<AssessmentAppointment>> getUserAppointments(@RequestParam Long userId) {
+        return Result.success(appointmentService.getByUserId(userId));
+    }
+
+    @Operation(summary = "获取规划师的预约列表")
+    @GetMapping("/appointment/guide")
+    public Result<List<AssessmentAppointment>> getGuideAppointments(@RequestParam Long guideId) {
+        return Result.success(appointmentService.getByGuideId(guideId));
+    }
+
+    // ===== 订单 =====
+
+    @Operation(summary = "创建测评订单(含预约)")
+    @PostMapping("/order/create")
+    public Result<AssessmentOrder> createOrder(@RequestBody Map<String, Object> body) {
+        Long familyId = body.get("familyId") != null ? Long.valueOf(body.get("familyId").toString()) : null;
+        Long userId = body.get("userId") != null ? Long.valueOf(body.get("userId").toString()) : null;
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Long guideId = Long.valueOf(body.get("guideId").toString());
+        Long packageId = body.get("packageId") != null ? Long.valueOf(body.get("packageId").toString()) : null;
+        String guideName = (String) body.get("guideName");
+        String packageName = (String) body.get("packageName");
+        Long totalPrice = body.get("totalPrice") != null ? Long.valueOf(body.get("totalPrice").toString()) : 0L;
+        Long discountAmount = body.get("discountAmount") != null
+                ? Long.valueOf(body.get("discountAmount").toString()) : 0L;
+
+        AssessmentOrder order = orderService.createOrder(familyId, userId, childId,
+                guideId, packageId, guideName, packageName, totalPrice, discountAmount);
+        return Result.success(order);
+    }
+
+    @Operation(summary = "支付测评订单")
+    @PostMapping("/order/pay")
+    public Result<String> payOrder(@RequestBody Map<String, Object> body) {
+        String orderNo = (String) body.get("orderNo");
+        String payType = (String) body.get("payType");
+        boolean ok = orderService.payOrder(orderNo, payType);
+        return ok ? Result.success("支付成功") : Result.error("支付失败");
+    }
+
+    @Operation(summary = "取消订单")
+    @PostMapping("/order/cancel")
+    public Result<String> cancelOrder(@RequestBody Map<String, Object> body) {
+        String orderNo = (String) body.get("orderNo");
+        boolean ok = orderService.cancelOrder(orderNo);
+        return ok ? Result.success("取消成功") : Result.error("取消失败");
+    }
+
+    @Operation(summary = "获取订单详情")
+    @GetMapping("/order/detail")
+    public Result<AssessmentOrder> getOrderDetail(@RequestParam String orderNo) {
+        return Result.success(orderService.getByOrderNo(orderNo));
+    }
+
+    @Operation(summary = "获取家庭订单列表")
+    @GetMapping("/order/family")
+    public Result<List<AssessmentOrder>> getFamilyOrders(@RequestParam Long familyId) {
+        return Result.success(orderService.getFamilyOrders(familyId));
+    }
+}

+ 113 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -0,0 +1,113 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EnergySandboxDTO;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.EnergyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Tag(name = "家庭能量沙盘", description = "家庭五维能量数据接口")
+@RestController
+@RequestMapping("/api/energy")
+public class EnergyController {
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 获取家庭能量沙盘数据
+     * 返回家庭五维聚合值 + 每个家庭成员的个人五维数据
+     */
+    @Operation(summary = "获取家庭能量沙盘")
+    @PostMapping("/sandbox")
+    public Result<EnergySandboxDTO> getFamilyEnergySandbox(
+            @RequestAttribute("userId") Long userId) {
+
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+        if (user.getFamilyId() == null) {
+            return Result.error("用户未加入家庭");
+        }
+
+        EnergySandboxDTO dto = energyService.calculateFamilyEnergy(user.getFamilyId());
+        return Result.success(dto);
+    }
+
+    /**
+     * 获取孩子五维能量概览
+     */
+    @Operation(summary = "获取五维能量概览")
+    @PostMapping("/overview")
+    public Result<Map<String, Object>> getEnergyOverview(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        Map<String, Object> data = energyService.getOverview(childId);
+        return Result.success(data);
+    }
+
+    /**
+     * 查询能量流水(分页,可按维度筛选)
+     */
+    @Operation(summary = "查询能量流水")
+    @PostMapping("/logs")
+    public Result<Page<EnergyLog>> getEnergyLogs(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        String dimensionCode = (String) params.get("dimensionCode");
+        Integer page = params.get("page") != null
+                ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null
+                ? Integer.valueOf(params.get("size").toString()) : 10;
+
+        Page<EnergyLog> data = energyService.getLogs(childId, dimensionCode, page, size);
+        return Result.success(data);
+    }
+
+    /**
+     * 配置能量来源比例(需登录)
+     */
+    @Operation(summary = "配置来源比例")
+    @PostMapping("/configure")
+    public Result<String> configureSourceRatios(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        String sourceType = (String) params.get("sourceType");
+        Long sourceId = params.get("sourceId") != null
+                ? Long.valueOf(params.get("sourceId").toString()) : null;
+        String sourceName = (String) params.get("sourceName");
+
+        if (sourceType == null || sourceId == null) {
+            return Result.error("sourceType和sourceId不能为空");
+        }
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> ratios = (List<Map<String, Object>>) params.get("ratios");
+        if (ratios == null || ratios.isEmpty()) {
+            return Result.error("ratios不能为空");
+        }
+
+        energyService.configureSourceRatios(sourceType, sourceId, sourceName, ratios);
+        return Result.success("配置成功");
+    }
+}

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/GameRecordController.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GameRecordService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "游戏记录管理", description = "游戏记录与统计相关接口")
+@RestController
+@RequestMapping("/api/game")
+public class GameRecordController {
+
+    @Resource
+    private GameRecordService gameRecordService;
+
+    @Operation(summary = "保存游戏记录")
+    @PostMapping("/record")
+    public Result<Map<String, Object>> saveRecord(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        String gameCode = (String) body.get("gameCode");
+        Integer score = body.get("score") != null
+                ? Integer.valueOf(body.get("score").toString()) : null;
+        Integer completionTime = body.get("completionTime") != null
+                ? Integer.valueOf(body.get("completionTime").toString()) : null;
+        String difficulty = (String) body.get("difficulty");
+        Integer pointsEarned = body.get("pointsEarned") != null
+                ? Integer.valueOf(body.get("pointsEarned").toString()) : 0;
+
+        gameRecordService.saveRecord(childId, gameCode, completionTime, score, difficulty, pointsEarned);
+        return Result.success(Collections.singletonMap("saved", true));
+    }
+
+    @Operation(summary = "获取游戏记录历史")
+    @PostMapping("/history")
+    public Result<Map<String, Object>> getHistory(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        String gameCode = (String) body.get("gameCode");
+        Integer page = body.get("page") != null
+                ? Integer.valueOf(body.get("page").toString()) : 1;
+        Integer size = body.get("size") != null
+                ? Integer.valueOf(body.get("size").toString()) : 20;
+        return Result.success(gameRecordService.getHistory(childId, gameCode, page, size));
+    }
+
+    @Operation(summary = "获取各游戏最佳成绩")
+    @PostMapping("/best")
+    public Result<List<Map<String, Object>>> getBestScores(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        return Result.success(gameRecordService.getBestScores(childId));
+    }
+
+    @Operation(summary = "获取游戏统计概览")
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> getStats(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        return Result.success(gameRecordService.getStats(childId));
+    }
+
+    @Operation(summary = "获取排行榜")
+    @PostMapping("/leaderboard")
+    public Result<List<Map<String, Object>>> getLeaderboard(@RequestBody Map<String, Object> body) {
+        String gameCode = (String) body.get("gameCode");
+        Integer limit = body.get("limit") != null
+                ? Integer.valueOf(body.get("limit").toString()) : 10;
+        return Result.success(gameRecordService.getLeaderboard(gameCode, limit));
+    }
+}

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ReferralSummaryDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.service.CommissionService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/invite")
+public class InviteController {
+
+    @Resource
+    private CommissionService commissionService;
+
+    @PostMapping("/code")
+    public Result<String> getCode(@RequestAttribute("userId") Long userId) {
+        String code = commissionService.getOrCreateReferralCode(userId);
+        return Result.success(code);
+    }
+
+    @PostMapping("/bind")
+    public Result<String> bind(@RequestAttribute("userId") Long userId,
+                               @RequestBody Map<String, String> params) {
+        String referralCode = params.get("referralCode");
+        try {
+            commissionService.bindReferral(userId, referralCode);
+            return Result.success("绑定成功");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/list")
+    public Result<Page<User>> list(@RequestAttribute("userId") Long userId,
+                                   @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        return Result.success(commissionService.getMyReferrals(userId, page, size));
+    }
+
+    @PostMapping("/summary")
+    public Result<ReferralSummaryDTO> summary(@RequestAttribute("userId") Long userId) {
+        return Result.success(commissionService.getReferralSummary(userId));
+    }
+}

+ 189 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportStatsController.java

@@ -0,0 +1,189 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.GameRecord;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.TaskService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Tag(name = "统计报表", description = "统一数据统计与报表接口")
+@RestController
+@RequestMapping("/api/stats")
+public class ReportStatsController {
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private GameRecordService gameRecordService;
+
+    @Operation(summary = "孩子数据概览")
+    @PostMapping("/child/overview")
+    public Result<Map<String, Object>> childOverview(@RequestBody Map<String, Object> body) {
+        Long childId = Long.valueOf(body.get("childId").toString());
+        Map<String, Object> result = new HashMap<>();
+
+        // 基础信息
+        Child child = childMapper.selectById(childId);
+        result.put("totalPoints", child != null ? child.getTotalPoints() : 0);
+
+        // 任务统计
+        List<Task> tasks = taskService.getAllTasksByChild(childId);
+        long taskTotal = tasks.size();
+        long taskCompleted = tasks.stream().filter(t -> "completed".equals(t.getStatus())).count();
+        long taskPending = tasks.stream().filter(t -> "pending".equals(t.getStatus())).count();
+        long taskInReview = tasks.stream().filter(t -> "review".equals(t.getStatus())).count();
+
+        Map<String, Object> taskStats = new HashMap<>();
+        taskStats.put("total", taskTotal);
+        taskStats.put("completed", taskCompleted);
+        taskStats.put("pending", taskPending);
+        taskStats.put("inReview", taskInReview);
+        taskStats.put("completionRate", taskTotal > 0 ? (double) Math.round(taskCompleted * 10000.0 / taskTotal) / 100 : 0);
+        result.put("taskStats", taskStats);
+
+        // 游戏统计
+        List<GameRecord> records = gameRecordService.listByChildId(childId);
+        long gameTotal = records.size();
+        int gameTotalPoints = records.stream().mapToInt(GameRecord::getPointsEarned).sum();
+        int bestScore = records.stream().mapToInt(GameRecord::getScore).max().orElse(0);
+        int totalTime = records.stream().mapToInt(GameRecord::getCompletionTime).sum();
+
+        Map<String, Object> gameStats = new HashMap<>();
+        gameStats.put("total", gameTotal);
+        gameStats.put("totalPoints", gameTotalPoints);
+        gameStats.put("bestScore", bestScore);
+        gameStats.put("totalTime", totalTime);
+        result.put("gameStats", gameStats);
+
+        // 积分趋势(近7天)
+        Calendar cal = Calendar.getInstance();
+        Date today = cal.getTime();
+        cal.add(Calendar.DAY_OF_YEAR, -7);
+        Date sevenDaysAgo = cal.getTime();
+
+        List<PointsLog> recentLogs = pointsLogMapper.selectList(
+                new LambdaQueryWrapper<PointsLog>()
+                        .eq(PointsLog::getChildId, childId)
+                        .ge(PointsLog::getCreatedAt, sevenDaysAgo)
+                        .orderByAsc(PointsLog::getCreatedAt));
+
+        List<Map<String, Object>> pointsTrend = new ArrayList<>();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        for (int i = 6; i >= 0; i--) {
+            Calendar dayCal = Calendar.getInstance();
+            dayCal.add(Calendar.DAY_OF_YEAR, -i);
+            dayCal.set(Calendar.HOUR_OF_DAY, 0);
+            dayCal.set(Calendar.MINUTE, 0);
+            dayCal.set(Calendar.SECOND, 0);
+            dayCal.set(Calendar.MILLISECOND, 0);
+            Date dayStart = dayCal.getTime();
+
+            dayCal.set(Calendar.HOUR_OF_DAY, 23);
+            dayCal.set(Calendar.MINUTE, 59);
+            dayCal.set(Calendar.SECOND, 59);
+            Date dayEnd = dayCal.getTime();
+
+            Date finalDayStart = dayStart;
+            Date finalDayEnd = dayEnd;
+            int dayPoints = recentLogs.stream()
+                    .filter(l -> l.getCreatedAt() != null
+                            && l.getCreatedAt().after(finalDayStart)
+                            && l.getCreatedAt().before(finalDayEnd))
+                    .mapToInt(PointsLog::getAmount)
+                    .sum();
+            Map<String, Object> point = new HashMap<>();
+            point.put("date", sdf.format(dayStart));
+            point.put("points", dayPoints);
+            pointsTrend.add(point);
+        }
+        result.put("pointsTrend", pointsTrend);
+
+        return Result.success(result);
+    }
+
+    @Operation(summary = "家庭汇总统计")
+    @PostMapping("/family/summary")
+    public Result<Map<String, Object>> familySummary(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>().eq(Child::getFamilyId, familyId));
+
+        List<Map<String, Object>> childrenStats = new ArrayList<>();
+        int familyTotalTasks = 0;
+        int familyCompletedTasks = 0;
+        int familyTotalGames = 0;
+
+        for (Child child : children) {
+            Map<String, Object> childStat = new HashMap<>();
+            childStat.put("childId", child.getId());
+            childStat.put("childName", child.getNickname());
+
+            List<Task> tasks = taskService.getAllTasksByChild(child.getId());
+            long c = tasks.stream().filter(t -> "completed".equals(t.getStatus())).count();
+            childStat.put("taskTotal", tasks.size());
+            childStat.put("taskCompleted", c);
+            childStat.put("points", child.getTotalPoints());
+
+            List<GameRecord> records = gameRecordService.listByChildId(child.getId());
+            childStat.put("gameTotal", records.size());
+
+            familyTotalTasks += tasks.size();
+            familyCompletedTasks += c;
+            familyTotalGames += records.size();
+            childrenStats.add(childStat);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("children", childrenStats);
+        result.put("familyTotalTasks", familyTotalTasks);
+        result.put("familyCompletedTasks", familyCompletedTasks);
+        result.put("familyCompletionRate", familyTotalTasks > 0
+                ? (double) Math.round(familyCompletedTasks * 10000.0 / familyTotalTasks) / 100 : 0);
+        result.put("familyTotalGames", familyTotalGames);
+        result.put("childCount", children.size());
+
+        return Result.success(result);
+    }
+
+    @Operation(summary = "积分排行榜(家庭内)")
+    @PostMapping("/points/ranking")
+    public Result<List<Map<String, Object>>> pointsRanking(@RequestBody Map<String, Object> body) {
+        Long familyId = Long.valueOf(body.get("familyId").toString());
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getFamilyId, familyId)
+                        .orderByDesc(Child::getTotalPoints));
+
+        List<Map<String, Object>> ranking = new ArrayList<>();
+        int rank = 1;
+        for (Child child : children) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("rank", rank++);
+            item.put("childId", child.getId());
+            item.put("childName", child.getNickname());
+            item.put("points", child.getTotalPoints());
+            ranking.add(item);
+        }
+        return Result.success(ranking);
+    }
+}

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/SeedEnergyController.java

@@ -0,0 +1,109 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 种子数据 — 为测试用户 (familyId=29) 生成能量沙盘演示数据
+ * 调用后插入任务记录并更新积分,使能量沙盘显示非零分数
+ */
+@Tag(name = "种子数据", description = "生成演示用的能量沙盘种子数据")
+@RestController
+@RequestMapping("/api/energy")
+public class SeedEnergyController {
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    /**
+     * 为当前测试用户的家庭生成种子数据
+     * familyId=29, userId=81087 (测试登录用户)
+     */
+    @Operation(summary = "生成能量沙盘种子数据")
+    @PostMapping("/seed")
+    public Result<String> seedEnergyData() {
+        Long userId = 81087L;
+        Long familyId = 29L;
+
+        // 1. 更新用户总分
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+        user.setTotalPoints(300);
+        userMapper.updateById(user);
+
+        // 2. 插入家长任务 (executorType=parent, executorId=userId)
+        // 身 — 运动/健康类
+        createTask(familyId, userId, "parent", "晨跑30分钟", "运动", "completed");
+        createTask(familyId, userId, "parent", "健身房训练", "体育", "completed");
+        createTask(familyId, userId, "parent", "周末徒步", "户外", "completed");
+        createTask(familyId, userId, "parent", "瑜伽放松", "健康", "completed");
+        createTask(familyId, userId, "parent", "游泳锻炼", "运动", "completed");
+        createTask(familyId, userId, "parent", "夜跑5公里", "运动", "pending");
+
+        // 心 — 学习/阅读/亲子类
+        createTask(familyId, userId, "parent", "阅读《正面管教》", "阅读", "completed");
+        createTask(familyId, userId, "parent", "亲子沟通训练", "亲子", "completed");
+        createTask(familyId, userId, "parent", "情绪管理笔记", "成长", "completed");
+        createTask(familyId, userId, "parent", "听家庭教育讲座", "学习", "completed");
+        createTask(familyId, userId, "parent", "每天陪孩子聊天20分钟", "亲子", "completed");
+
+        // 智 — 学习/培训类
+        createTask(familyId, userId, "parent", "学习Python基础", "学习", "completed");
+        createTask(familyId, userId, "parent", "参加技能培训", "培训", "completed");
+        createTask(familyId, userId, "parent", "阅读《思考快与慢》", "阅读", "completed");
+        createTask(familyId, userId, "parent", "学习营养学知识", "知识", "completed");
+        createTask(familyId, userId, "parent", "阅读育儿书籍", "阅读", "completed");
+
+        // 行 — 习惯/自律类
+        createTask(familyId, userId, "parent", "早起6:30", "好习惯", "completed");
+        createTask(familyId, userId, "parent", "记账习惯", "习惯", "completed");
+        createTask(familyId, userId, "parent", "每周打扫房间", "家务", "completed");
+        createTask(familyId, userId, "parent", "作息规律打卡", "作息", "completed");
+        createTask(familyId, userId, "parent", "每天冥想10分钟", "好习惯", "completed");
+        createTask(familyId, userId, "parent", "不熬夜打卡", "自律", "pending");
+
+        return Result.success("种子数据生成成功,家长任务已插入,积分已更新");
+    }
+
+    private void createTask(Long familyId, Long executorId,
+                            String executorType, String title,
+                            String category, String status) {
+        Task task = new Task();
+        task.setFamilyId(familyId);
+        task.setCreatorId(executorId);
+        task.setExecutorType(executorType);
+        task.setExecutorId(executorId);
+        task.setChildId(null);
+        task.setTitle(title);
+        task.setDescription("");
+        task.setPoints(10);
+        task.setCategory(category);
+        task.setNeedReview(0);
+        task.setReviewByCategory(0);
+        task.setReviewType("none");
+        task.setStatus(status);
+        task.setIsTemplate(0);
+        task.setTaskType("onetime");
+        task.setRepeatType("none");
+        task.setCreatedAt(new Date());
+        task.setUpdatedAt(new Date());
+        if ("completed".equals(status)) {
+            task.setCompletedAt(new Date());
+        }
+        taskMapper.insert(task);
+    }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/WithdrawalController.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.service.WithdrawalService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/withdrawal")
+public class WithdrawalController {
+
+    @Resource
+    private WithdrawalService withdrawalService;
+
+    @PostMapping("/apply")
+    public Result<String> apply(@RequestAttribute("userId") Long userId,
+                                @RequestBody Map<String, Object> params) {
+        BigDecimal amount = params.get("amount") != null
+                ? new BigDecimal(params.get("amount").toString())
+                : BigDecimal.ZERO;
+        String accountInfo = (String) params.get("accountInfo");
+        return withdrawalService.apply(userId, amount, accountInfo);
+    }
+
+    @PostMapping("/history")
+    public Result<Page<WithdrawalRequest>> history(@RequestAttribute("userId") Long userId,
+                                                   @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        return Result.success(withdrawalService.getHistory(userId, page, size));
+    }
+}

+ 120 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -0,0 +1,120 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/admin/articles")
+public class AdminArticleController {
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @PostMapping("/list")
+    public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
+        String status = (String) body.get("status");
+        Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
+        String keyword = (String) body.get("keyword");
+        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
+        return Result.success(articleService.getAdminList(status, categoryId, keyword, page, size));
+    }
+
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Article article,
+                                  @RequestAttribute("userId") Long adminId) {
+        articleService.create(article, adminId);
+        return Result.success("创建成功");
+    }
+
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Article article) {
+        articleService.update(article);
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        articleService.delete(id);
+        return Result.success("删除成功");
+    }
+
+    @PostMapping("/publish")
+    public Result<String> publish(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        String status = (String) body.get("status");
+        articleService.toggleStatus(id, status);
+        return Result.success("操作成功");
+    }
+
+    @PostMapping("/toggle-featured")
+    public Result<String> toggleFeatured(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        int isFeatured = Integer.parseInt(body.get("isFeatured").toString());
+        articleService.toggleFeatured(id, isFeatured);
+        return Result.success("操作成功");
+    }
+
+    @PostMapping("/upload/image")
+    public Result<String> uploadImage(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件为空");
+        }
+        try {
+            String uploadDir = System.getProperty("user.dir") + "/uploads/articles/";
+            File dir = new File(uploadDir);
+            if (!dir.exists()) dir.mkdirs();
+
+            String ext = file.getOriginalFilename();
+            ext = ext != null && ext.contains(".") ? ext.substring(ext.lastIndexOf(".")) : ".jpg";
+            String filename = UUID.randomUUID().toString() + ext;
+            File dest = new File(uploadDir + filename);
+            file.transferTo(dest);
+
+            String url = "/uploads/articles/" + filename;
+            return Result.success(url);
+        } catch (IOException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/categories/list")
+    public Result<List<ArticleCategory>> categoryList() {
+        return Result.success(articleCategoryService.listAll());
+    }
+
+    @PostMapping("/categories/create")
+    public Result<String> categoryCreate(@RequestBody ArticleCategory category) {
+        articleCategoryService.create(category);
+        return Result.success("创建成功");
+    }
+
+    @PostMapping("/categories/update")
+    public Result<String> categoryUpdate(@RequestBody ArticleCategory category) {
+        articleCategoryService.update(category);
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/categories/delete")
+    public Result<String> categoryDelete(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        articleCategoryService.delete(id);
+        return Result.success("删除成功");
+    }
+}

+ 75 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java

@@ -0,0 +1,75 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.WithdrawalService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/commission")
+public class AdminCommissionController {
+
+    @Resource
+    private WithdrawalService withdrawalService;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @PostMapping("/withdrawals")
+    public Result<Page<WithdrawalRequest>> withdrawals(@RequestBody Map<String, Object> params) {
+        String status = (String) params.get("status");
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        return Result.success(withdrawalService.getAdminList(status, page, size));
+    }
+
+    @PostMapping("/audit")
+    public Result<String> audit(@RequestAttribute("userId") Long adminId,
+                                @RequestBody Map<String, Object> params) {
+        Long requestId = params.get("requestId") != null
+                ? Long.valueOf(params.get("requestId").toString())
+                : null;
+        String status = (String) params.get("status");
+        String remark = (String) params.get("remark");
+        if (requestId == null) {
+            return Result.error("requestId不能为空");
+        }
+        if (status == null || (!"approved".equals(status) && !"rejected".equals(status))) {
+            return Result.error("status必须为 approved 或 rejected");
+        }
+        try {
+            withdrawalService.audit(requestId, adminId, status, remark);
+            String msg = "approved".equals(status) ? "已通过" : "已拒绝";
+            return Result.success(msg);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/update-profit-rate")
+    public Result<String> updateProfitRate(@RequestBody Map<String, Object> params) {
+        Long productId = params.get("productId") != null
+                ? Long.valueOf(params.get("productId").toString())
+                : null;
+        BigDecimal profitRate = params.get("profitRate") != null
+                ? new BigDecimal(params.get("profitRate").toString())
+                : null;
+        if (productId == null || profitRate == null) {
+            return Result.error("productId和profitRate不能为空");
+        }
+        Product product = productMapper.selectById(productId);
+        if (product == null) {
+            return Result.error("商品不存在");
+        }
+        product.setProfitRate(profitRate);
+        productMapper.updateById(product);
+        return Result.success("设置成功");
+    }
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleCategoryController {
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @PostMapping("/categories")
+    public Result<List<ArticleCategory>> getCategories() {
+        return Result.success(articleCategoryService.getActiveCategories());
+    }
+}

+ 105 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -0,0 +1,105 @@
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleController {
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @PostMapping("/list")
+    public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
+                                       @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
+        String keyword = (String) body.get("keyword");
+        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 10;
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        Page<Article> result = articleService.getPublicList(categoryId, keyword, page, size,
+                userId, familyId, vendorType);
+        return Result.success(result);
+    }
+
+    @PostMapping("/detail")
+    public Result<Article> detail(@RequestBody Map<String, Object> body,
+                                   @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long id = Long.valueOf(body.get("id").toString());
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        Article article = articleService.getDetail(id, userId, familyId, vendorType);
+        if (article == null) {
+            return Result.error(403, "无权访问该文章");
+        }
+        return Result.success(article);
+    }
+
+    @PostMapping("/featured")
+    public Result<List<Article>> featured(@RequestBody Map<String, Object> body,
+                                           @RequestAttribute(value = "userId", required = false) Long userId) {
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 5;
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        List<Article> list = articleService.getFeatured(size, userId, familyId, vendorType);
+        return Result.success(list);
+    }
+
+    @PostMapping("/record-read")
+    public Result<String> recordRead(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long articleId = Long.valueOf(body.get("id").toString());
+        int durationSeconds = body.get("durationSeconds") != null
+                ? Integer.parseInt(body.get("durationSeconds").toString()) : 0;
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString()) : null;
+
+        articleService.recordRead(articleId, userId, childId, durationSeconds);
+        return Result.success("ok");
+    }
+}

+ 85 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ContentSectionController.java

@@ -0,0 +1,85 @@
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ContentSection;
+import com.etotem.cfc.service.ContentSectionService;
+import org.springframework.web.bind.annotation.PostMapping;
+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;
+
+@RestController
+@RequestMapping("/api/content-sections")
+public class ContentSectionController {
+
+    @Resource
+    private ContentSectionService contentSectionService;
+
+    /**
+     * 获取指定页面对指定角色可见的区块
+     * role 为可选参数,不传或为空时只返回 public 区块(allowedRoles 包含 "anonymous")
+     */
+    @PostMapping("/visible")
+    public Result<List<ContentSection>> getVisibleSections(@RequestBody Map<String, Object> body) {
+        String pageKey = (String) body.get("pageKey");
+        if (pageKey == null || pageKey.trim().isEmpty()) {
+            return Result.error("pageKey 不能为空");
+        }
+        String role = (String) body.get("role");
+        List<ContentSection> sections = contentSectionService.getVisibleSections(pageKey.trim(), role);
+        return Result.success(sections);
+    }
+
+    /**
+     * 获取所有内容区块配置(管理端)
+     */
+    @PostMapping("/list")
+    public Result<List<ContentSection>> listAll() {
+        return Result.success(contentSectionService.listAll());
+    }
+
+    /**
+     * 获取单个区块详情
+     */
+    @PostMapping("/detail")
+    public Result<ContentSection> getDetail(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        ContentSection section = contentSectionService.getById(id);
+        if (section == null) {
+            return Result.error("区块不存在");
+        }
+        return Result.success(section);
+    }
+
+    /**
+     * 创建区块(管理端)
+     */
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody ContentSection section) {
+        contentSectionService.create(section);
+        return Result.success("创建成功");
+    }
+
+    /**
+     * 更新区块(管理端)
+     */
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody ContentSection section) {
+        contentSectionService.update(section);
+        return Result.success("更新成功");
+    }
+
+    /**
+     * 删除区块(管理端)
+     */
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        contentSectionService.delete(id);
+        return Result.success("删除成功");
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.mapper.ArticleCategoryMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class ArticleCategoryService {
+
+    @Resource
+    private ArticleCategoryMapper articleCategoryMapper;
+
+    public List<ArticleCategory> getActiveCategories() {
+        return articleCategoryMapper.selectList(
+                new LambdaQueryWrapper<ArticleCategory>()
+                        .eq(ArticleCategory::getStatus, 1)
+                        .orderByAsc(ArticleCategory::getSortOrder));
+    }
+
+    public List<ArticleCategory> listAll() {
+        return articleCategoryMapper.selectList(
+                new LambdaQueryWrapper<ArticleCategory>()
+                        .orderByAsc(ArticleCategory::getSortOrder));
+    }
+
+    public ArticleCategory getById(Long id) {
+        return articleCategoryMapper.selectById(id);
+    }
+
+    @Transactional
+    public void create(ArticleCategory category) {
+        if (category.getSortOrder() == null) category.setSortOrder(0);
+        if (category.getStatus() == null) category.setStatus(1);
+        articleCategoryMapper.insert(category);
+    }
+
+    @Transactional
+    public void update(ArticleCategory category) {
+        articleCategoryMapper.updateById(category);
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        articleCategoryMapper.deleteById(id);
+    }
+}

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java

@@ -0,0 +1,89 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Article;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 文章权限过滤核心逻辑
+ * 根据用户身份和文章的visibility+visible_to字段判断可见性
+ */
+@Slf4j
+@Service
+public class ArticlePermissionService {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<Article> filterVisible(List<Article> articles, Long userId, Long familyId, String vendorType) {
+        if (articles == null || articles.isEmpty()) return Collections.emptyList();
+        return articles.stream()
+                .filter(a -> isVisible(a, userId, familyId, vendorType))
+                .collect(Collectors.toList());
+    }
+
+    public boolean isVisible(Article article, Long userId, Long familyId, String vendorType) {
+        if (article == null) return false;
+        String visibility = article.getVisibility();
+        if (visibility == null) visibility = "public";
+
+        switch (visibility) {
+            case "public":
+                return true;
+            case "login":
+                return userId != null;
+            case "private":
+                return isPrivateVisible(article.getVisibleTo(), userId, familyId, vendorType);
+            default:
+                return true;
+        }
+    }
+
+    private boolean isPrivateVisible(String visibleToJson, Long userId, Long familyId, String vendorType) {
+        if (visibleToJson == null || visibleToJson.isEmpty()) return false;
+        try {
+            List<Map<String, Object>> rules = objectMapper.readValue(visibleToJson,
+                    new TypeReference<List<Map<String, Object>>>() {});
+            if (rules == null || rules.isEmpty()) return false;
+
+            for (Map<String, Object> rule : rules) {
+                String type = (String) rule.get("type");
+                if (type == null) continue;
+
+                switch (type) {
+                    case "family":
+                        if (familyId != null) {
+                            Object familyIdObj = rule.get("familyId");
+                            if (familyIdObj != null) {
+                                Long targetId = Long.valueOf(familyIdObj.toString());
+                                if (targetId.equals(familyId)) return true;
+                            }
+                        }
+                        break;
+                    case "vendor_type":
+                        if (vendorType != null) {
+                            String targetType = (String) rule.get("vendorType");
+                            if (targetType != null && targetType.equals(vendorType)) return true;
+                        }
+                        break;
+                    case "user":
+                        if (userId != null) {
+                            Object userIdObj = rule.get("userId");
+                            if (userIdObj != null) {
+                                Long targetId = Long.valueOf(userIdObj.toString());
+                                if (targetId.equals(userId)) return true;
+                            }
+                        }
+                        break;
+                }
+            }
+            return false;
+        } catch (Exception e) {
+            log.warn("解析visibleTo失败: {}", visibleToJson, e);
+            return false;
+        }
+    }
+}

+ 152 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -0,0 +1,152 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class ArticleService {
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private ArticlePermissionService articlePermissionService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public Page<Article> getPublicList(Long categoryId, String keyword, int page, int size,
+                                        Long userId, Long familyId, String vendorType) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt);
+
+        if (categoryId != null && categoryId > 0) {
+            wrapper.eq(Article::getCategoryId, categoryId);
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            wrapper.like(Article::getTitle, keyword.trim());
+        }
+
+        Page<Article> p = new Page<>(page, size);
+        Page<Article> result = articleMapper.selectPage(p, wrapper);
+
+        List<Article> visible = articlePermissionService.filterVisible(
+                result.getRecords(), userId, familyId, vendorType);
+
+        Page<Article> filtered = new Page<>(page, size);
+        filtered.setTotal(visible.size());
+        filtered.setRecords(visible);
+        return filtered;
+    }
+
+    public List<Article> getFeatured(int size, Long userId, Long familyId, String vendorType) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + Math.max(size, 50));
+
+        List<Article> all = articleMapper.selectList(wrapper);
+        List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
+
+        return visible.size() > size ? visible.subList(0, size) : visible;
+    }
+
+    public Article getDetail(Long id, Long userId, Long familyId, String vendorType) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return null;
+        if (!"published".equals(article.getStatus())) return null;
+        if (!articlePermissionService.isVisible(article, userId, familyId, vendorType)) return null;
+        return article;
+    }
+
+    @Transactional
+    public void recordRead(Long articleId, Long userId, Long childId, int durationSeconds) {
+        LambdaUpdateWrapper<Article> wrapper = Wrappers.lambdaUpdate(Article.class)
+                .setSql("view_count = view_count + 1")
+                .eq(Article::getId, articleId);
+        articleMapper.update(null, wrapper);
+    }
+
+    public void enrichWithCategoryName(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) return;
+    }
+
+    public Page<Article> getAdminList(String status, Long categoryId, String keyword, int page, int size) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .orderByDesc(Article::getCreatedAt);
+
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(Article::getStatus, status);
+        }
+        if (categoryId != null && categoryId > 0) {
+            wrapper.eq(Article::getCategoryId, categoryId);
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            wrapper.like(Article::getTitle, keyword.trim());
+        }
+
+        return articleMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
+    @Transactional
+    public void create(Article article, Long adminId) {
+        article.setCreatedBy(adminId);
+        article.setViewCount(0);
+        article.setCreatedAt(new Date());
+        article.setUpdatedAt(new Date());
+        if ("published".equals(article.getStatus())) {
+            article.setPublishedAt(new Date());
+        }
+        articleMapper.insert(article);
+    }
+
+    @Transactional
+    public void update(Article article) {
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        articleMapper.deleteById(id);
+    }
+
+    @Transactional
+    public void toggleStatus(Long id, String status) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setStatus(status);
+        article.setUpdatedAt(new Date());
+        if ("published".equals(status) && article.getPublishedAt() == null) {
+            article.setPublishedAt(new Date());
+        }
+        articleMapper.updateById(article);
+    }
+
+    @Transactional
+    public void toggleFeatured(Long id, int isFeatured) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setIsFeatured(isFeatured);
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
+}

+ 169 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BadgeService.java

@@ -0,0 +1,169 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Badge;
+import com.etotem.cfc.entity.ChildBadge;
+import com.etotem.cfc.mapper.BadgeMapper;
+import com.etotem.cfc.mapper.ChildBadgeMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class BadgeService {
+
+    @Resource
+    private BadgeMapper badgeMapper;
+
+    @Resource
+    private ChildBadgeMapper childBadgeMapper;
+
+    // ===== 勋章定义管理 =====
+
+    public List<Badge> getAllBadges() {
+        return badgeMapper.selectList(
+                new LambdaQueryWrapper<Badge>().orderByAsc(Badge::getSortOrder));
+    }
+
+    public List<Badge> getActiveBadges() {
+        return badgeMapper.selectList(
+                new LambdaQueryWrapper<Badge>()
+                        .eq(Badge::getIsActive, 1)
+                        .orderByAsc(Badge::getSortOrder));
+    }
+
+    public Badge getBadgeById(Long id) {
+        return badgeMapper.selectById(id);
+    }
+
+    public Badge getBadgeByBadgeId(String badgeId) {
+        return badgeMapper.selectOne(
+                new LambdaQueryWrapper<Badge>().eq(Badge::getBadgeId, badgeId));
+    }
+
+    @Transactional
+    public void createBadge(Badge badge) {
+        badge.setCreatedAt(new Date());
+        badge.setUpdatedAt(new Date());
+        badgeMapper.insert(badge);
+    }
+
+    @Transactional
+    public void updateBadge(Badge badge) {
+        badge.setUpdatedAt(new Date());
+        badgeMapper.updateById(badge);
+    }
+
+    @Transactional
+    public void deleteBadge(Long id) {
+        badgeMapper.deleteById(id);
+    }
+
+    // ===== 勋章授予 =====
+
+    @Transactional
+    public void grantBadge(Long childId, Long badgeId) {
+        ChildBadge existing = childBadgeMapper.selectOne(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId)
+                        .eq(ChildBadge::getBadgeId, badgeId));
+        if (existing != null) {
+            return;
+        }
+
+        Badge badge = badgeMapper.selectById(badgeId);
+        if (badge == null) {
+            throw new RuntimeException("勋章不存在");
+        }
+
+        ChildBadge cb = new ChildBadge();
+        cb.setChildId(childId);
+        cb.setBadgeId(badgeId);
+        cb.setEarnedAt(new Date());
+        cb.setIsFavorite(false);
+        cb.setStatus(1);
+        cb.setCreatedAt(new Date());
+        cb.setUpdatedAt(new Date());
+
+        if (badge.getExpireDays() != null && badge.getExpireDays() > 0) {
+            cb.setExpireAt(new Date(System.currentTimeMillis() + badge.getExpireDays() * 86400000L));
+        }
+
+        childBadgeMapper.insert(cb);
+    }
+
+    // ===== 孩子勋章查询 =====
+
+    public List<ChildBadge> getChildBadges(Long childId) {
+        return childBadgeMapper.selectList(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId)
+                        .orderByDesc(ChildBadge::getIsFavorite)
+                        .orderByDesc(ChildBadge::getEarnedAt));
+    }
+
+    public List<ChildBadge> getActiveChildBadges(Long childId) {
+        return childBadgeMapper.selectList(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId)
+                        .eq(ChildBadge::getStatus, 1)
+                        .orderByDesc(ChildBadge::getIsFavorite)
+                        .orderByDesc(ChildBadge::getEarnedAt));
+    }
+
+    public ChildBadge getChildBadgeDetail(Long childId, Long badgeId) {
+        return childBadgeMapper.selectOne(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId)
+                        .eq(ChildBadge::getBadgeId, badgeId));
+    }
+
+    @Transactional
+    public void toggleFavorite(Long childId, Long badgeId) {
+        ChildBadge cb = childBadgeMapper.selectOne(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId)
+                        .eq(ChildBadge::getBadgeId, badgeId));
+        if (cb != null) {
+            cb.setIsFavorite(cb.getIsFavorite() != null && cb.getIsFavorite() ? false : true);
+            cb.setUpdatedAt(new Date());
+            childBadgeMapper.updateById(cb);
+        }
+    }
+
+    // ===== 统计与排行 =====
+
+    public Map<String, Object> getChildBadgeStats(Long childId) {
+        List<ChildBadge> all = childBadgeMapper.selectList(
+                new LambdaQueryWrapper<ChildBadge>()
+                        .eq(ChildBadge::getChildId, childId));
+
+        long total = all.size();
+        long active = all.stream().filter(cb -> cb.getStatus() != null && cb.getStatus() == 1).count();
+        long expired = all.stream().filter(cb -> cb.getStatus() != null && cb.getStatus() == 0).count();
+        long revoked = all.stream().filter(cb -> cb.getStatus() != null && cb.getStatus() == -1).count();
+
+        List<Map<String, Object>> categoryStats = childBadgeMapper.selectCategoryStats(childId);
+        List<Map<String, Object>> levelStats = childBadgeMapper.selectLevelStats(childId);
+
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", total);
+        stats.put("active", active);
+        stats.put("expired", expired);
+        stats.put("revoked", revoked);
+        stats.put("categoryStats", categoryStats);
+        stats.put("levelStats", levelStats);
+        return stats;
+    }
+
+    public List<Map<String, Object>> getFamilyRanking(Long familyId) {
+        return childBadgeMapper.selectFamilyRanking(familyId);
+    }
+}

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

@@ -0,0 +1,243 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.CommissionSummaryDTO;
+import com.etotem.cfc.dto.ReferralSummaryDTO;
+import com.etotem.cfc.entity.CommissionRecord;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.mapper.CommissionRecordMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.Random;
+
+@Service
+public class CommissionService {
+
+    private static final Logger log = LoggerFactory.getLogger(CommissionService.class);
+
+    private static final BigDecimal MEMBER_COMMISSION_RATE = new BigDecimal("0.30");
+    private static final BigDecimal PROFIT_COMMISSION_RATE = new BigDecimal("0.30");
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private CommissionRecordMapper commissionRecordMapper;
+
+    @Resource
+    private WithdrawalRequestMapper withdrawalRequestMapper;
+
+    // ==================== 邀请码相关 ====================
+
+    public String getOrCreateReferralCode(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        if (user.getReferralCode() != null) {
+            return user.getReferralCode();
+        }
+        String code = generateReferralCode(userId);
+        user.setReferralCode(code);
+        userMapper.updateById(user);
+        return code;
+    }
+
+    public void bindReferral(Long userId, String referralCode) {
+        if (referralCode == null || referralCode.trim().isEmpty()) {
+            throw new RuntimeException("邀请码不能为空");
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        if (user.getReferrerId() != null) {
+            throw new RuntimeException("已绑定推荐人,不可修改");
+        }
+        User referrer = userMapper.selectOne(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getReferralCode, referralCode.trim())
+        );
+        if (referrer == null) {
+            throw new RuntimeException("邀请码无效");
+        }
+        if (referrer.getId().equals(userId)) {
+            throw new RuntimeException("不能邀请自己");
+        }
+        user.setReferrerId(referrer.getId());
+        userMapper.updateById(user);
+    }
+
+    public Page<User> getMyReferrals(Long userId, int page, int size) {
+        Page<User> pageParam = new Page<>(page, size);
+        return userMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getReferrerId, userId)
+                        .orderByDesc(User::getCreatedAt));
+    }
+
+    public ReferralSummaryDTO getReferralSummary(Long userId) {
+        ReferralSummaryDTO dto = new ReferralSummaryDTO();
+        User user = userMapper.selectById(userId);
+        if (user != null) {
+            dto.setReferralCode(user.getReferralCode());
+        }
+        Long count = userMapper.selectCount(
+                new LambdaQueryWrapper<User>().eq(User::getReferrerId, userId));
+        dto.setInvitedCount(count != null ? count.intValue() : 0);
+        LambdaQueryWrapper<CommissionRecord> wrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId)
+                .eq(CommissionRecord::getStatus, "settled");
+        BigDecimal total = commissionRecordMapper.selectList(wrapper).stream()
+                .map(CommissionRecord::getCommissionAmount)
+                .filter(a -> a != null)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        dto.setTotalEarnings(total);
+        return dto;
+    }
+
+    // ==================== 佣金结算 ====================
+
+    public void settle(Long orderId, String orderType, Long buyerId,
+                       BigDecimal orderAmount, Long productId) {
+        try {
+            if (buyerId == null || orderAmount == null || orderAmount.compareTo(BigDecimal.ZERO) <= 0) {
+                return;
+            }
+            User buyer = userMapper.selectById(buyerId);
+            if (buyer == null || buyer.getReferrerId() == null) {
+                return;
+            }
+            Long referrerId = buyer.getReferrerId();
+            BigDecimal commissionAmount;
+            String commissionType;
+            BigDecimal profitRate = BigDecimal.ZERO;
+
+            if ("membership".equals(orderType)) {
+                commissionAmount = orderAmount.multiply(MEMBER_COMMISSION_RATE);
+                commissionType = "member";
+            } else {
+                if (productId != null) {
+                    Product product = productMapper.selectById(productId);
+                    if (product != null && product.getProfitRate() != null) {
+                        profitRate = product.getProfitRate();
+                    }
+                }
+                commissionAmount = orderAmount.multiply(profitRate)
+                        .divide(new BigDecimal("100"))
+                        .multiply(PROFIT_COMMISSION_RATE);
+                commissionType = "profit";
+            }
+
+            CommissionRecord record = new CommissionRecord();
+            record.setOrderId(orderId);
+            record.setOrderType(orderType);
+            record.setReferrerId(referrerId);
+            record.setBuyerId(buyerId);
+            record.setCommissionType(commissionType);
+            record.setOrderAmount(orderAmount);
+            record.setProfitRate(profitRate);
+            record.setCommissionAmount(commissionAmount);
+            record.setStatus("settled");
+            record.setCreatedAt(new Date());
+            commissionRecordMapper.insert(record);
+
+            log.info("佣金结算成功: orderId={}, referrerId={}, amount={}", orderId, referrerId, commissionAmount);
+        } catch (Exception e) {
+            log.error("佣金结算异常: orderId={}", orderId, e);
+        }
+    }
+
+    public CommissionSummaryDTO getSummary(Long userId) {
+        CommissionSummaryDTO dto = new CommissionSummaryDTO();
+
+        LambdaQueryWrapper<CommissionRecord> allWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId);
+        BigDecimal totalCommission = commissionRecordMapper.selectList(allWrapper).stream()
+                .map(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        dto.setTotalCommission(totalCommission);
+
+        LambdaQueryWrapper<CommissionRecord> settledWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId)
+                .eq(CommissionRecord::getStatus, "settled");
+        BigDecimal settledAmount = commissionRecordMapper.selectList(settledWrapper).stream()
+                .map(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        dto.setSettledAmount(settledAmount);
+
+        LambdaQueryWrapper<CommissionRecord> pendingWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId)
+                .eq(CommissionRecord::getStatus, "pending");
+        BigDecimal pendingAmount = commissionRecordMapper.selectList(pendingWrapper).stream()
+                .map(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        dto.setPendingAmount(pendingAmount);
+
+        dto.setAvailableAmount(getAvailableAmount(userId));
+        dto.setWithdrawnAmount(getWithdrawnAmount(userId));
+
+        return dto;
+    }
+
+    public Page<CommissionRecord> getList(Long userId, int page, int size) {
+        Page<CommissionRecord> pageParam = new Page<>(page, size);
+        return commissionRecordMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<CommissionRecord>()
+                        .eq(CommissionRecord::getReferrerId, userId)
+                        .orderByDesc(CommissionRecord::getCreatedAt));
+    }
+
+    public BigDecimal getAvailableAmount(Long userId) {
+        LambdaQueryWrapper<CommissionRecord> settledWrapper = new LambdaQueryWrapper<CommissionRecord>()
+                .eq(CommissionRecord::getReferrerId, userId)
+                .eq(CommissionRecord::getStatus, "settled");
+        BigDecimal settled = commissionRecordMapper.selectList(settledWrapper).stream()
+                .map(r -> r.getCommissionAmount() != null ? r.getCommissionAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        BigDecimal withdrawn = getWithdrawnAmount(userId);
+
+        return settled.subtract(withdrawn).max(BigDecimal.ZERO);
+    }
+
+    private BigDecimal getWithdrawnAmount(Long userId) {
+        LambdaQueryWrapper<WithdrawalRequest> wrapper =
+                new LambdaQueryWrapper<WithdrawalRequest>()
+                        .eq(WithdrawalRequest::getUserId, userId)
+                        .eq(WithdrawalRequest::getStatus, "approved");
+        return withdrawalRequestMapper.selectList(wrapper).stream()
+                .map(r -> r.getAmount() != null ? r.getAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+    }
+
+    private String generateReferralCode(Long userId) {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        Random random = new Random();
+        StringBuilder code = new StringBuilder();
+        for (int i = 0; i < 6; i++) {
+            code.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        String candidate = code.toString();
+        User existing = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getReferralCode, candidate));
+        if (existing != null) {
+            return generateReferralCode(userId);
+        }
+        return candidate;
+    }
+}

+ 104 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ContentSectionService.java

@@ -0,0 +1,104 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ContentSection;
+import com.etotem.cfc.mapper.ContentSectionMapper;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class ContentSectionService {
+
+    @Resource
+    private ContentSectionMapper contentSectionMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 获取指定页面下对指定角色可见的已启用的内容区块
+     *
+     * @param pageKey 页面标识
+     * @param role    角色,如果为null则返回public(allowedRoles包含"anonymous")的区块
+     * @return 可见区块列表,按sortOrder升序排列
+     */
+    public List<ContentSection> getVisibleSections(String pageKey, String role) {
+        // 查询指定页面下所有已启用的区块
+        List<ContentSection> allActive = contentSectionMapper.selectList(
+                new LambdaQueryWrapper<ContentSection>()
+                        .eq(ContentSection::getPageKey, pageKey)
+                        .eq(ContentSection::getStatus, "active")
+                        .orderByAsc(ContentSection::getSortOrder));
+
+        if (allActive == null || allActive.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        // 在Java层过滤allowedRoles
+        return allActive.stream()
+                .filter(section -> isRoleAllowed(section.getAllowedRoles(), role))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 判断角色是否在allowedRoles JSON数组中
+     */
+    private boolean isRoleAllowed(String allowedRolesJson, String role) {
+        if (allowedRolesJson == null || allowedRolesJson.isEmpty()) {
+            return false;
+        }
+        try {
+            List<String> roles = objectMapper.readValue(allowedRolesJson,
+                    new TypeReference<List<String>>() {});
+            if (role == null || role.isEmpty()) {
+                // 未登录用户只能看到public区块
+                return roles.contains("anonymous");
+            }
+            return roles.contains(role);
+        } catch (JsonProcessingException e) {
+            log.warn("解析allowedRoles失败: {}", allowedRolesJson, e);
+            return false;
+        }
+    }
+
+    // ===== CRUD =====
+
+    public List<ContentSection> listAll() {
+        return contentSectionMapper.selectList(
+                new LambdaQueryWrapper<ContentSection>()
+                        .orderByAsc(ContentSection::getPageKey)
+                        .orderByAsc(ContentSection::getSortOrder));
+    }
+
+    public ContentSection getById(Long id) {
+        return contentSectionMapper.selectById(id);
+    }
+
+    @Transactional
+    public void create(ContentSection section) {
+        section.setCreatedAt(new Date());
+        section.setUpdatedAt(new Date());
+        contentSectionMapper.insert(section);
+    }
+
+    @Transactional
+    public void update(ContentSection section) {
+        section.setUpdatedAt(new Date());
+        contentSectionMapper.updateById(section);
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        contentSectionMapper.deleteById(id);
+    }
+}

+ 770 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -0,0 +1,770 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.EnergySandboxDTO;
+import com.etotem.cfc.dto.MemberEnergyDTO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 家庭能量沙盘 — 核心计算服务
+ * 计算每个家庭成员(家长+孩子)的身/心/智/行/富五维能量值 (0-100%),
+ * 并聚合出家庭整体能量视图。
+ */
+@Slf4j
+@Service
+public class EnergyService {
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private GameRecordMapper gameRecordMapper;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private ArticleReadingRecordMapper articleReadingRecordMapper;
+
+    @Resource
+    private StreakMilestoneMapper streakMilestoneMapper;
+
+    /**
+     * 计算整个家庭的能量沙盘数据
+     *
+     * @param familyId 家庭ID
+     * @return 家庭能量沙盘DTO(家庭聚合 + 每个成员的个人数据)
+     */
+    public EnergySandboxDTO calculateFamilyEnergy(Long familyId) {
+        EnergySandboxDTO dto = new EnergySandboxDTO();
+        List<MemberEnergyDTO> members = new ArrayList<>();
+
+        // 1. 收集所有家庭成员(家长 + 孩子)
+        List<User> parents = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+                        .ne(User::getRole, "child")  // 排除以child角色为主的账号(用children表管理)
+        );
+        // 过滤掉单纯是 teacher/admin 角色且不是 parent 角色的用户
+        List<User> familyParents = new ArrayList<>();
+        for (User u : parents) {
+            if (u.getRole() != null && ("parent".equals(u.getRole()))) {
+                familyParents.add(u);
+            }
+        }
+
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getFamilyId, familyId)
+        );
+
+        // 2. 计算每个家长的能量
+        for (User parent : familyParents) {
+            MemberEnergyDTO member = calcParentEnergy(parent);
+            members.add(member);
+        }
+
+        // 3. 计算每个孩子的能量
+        for (Child child : children) {
+            MemberEnergyDTO member = calcChildEnergy(child);
+            members.add(member);
+        }
+
+        if (members.isEmpty()) {
+            return buildEmptyDTO();
+        }
+
+        dto.setMembers(members);
+
+        // 4. 聚合家庭五维平均值
+        int bodySum = 0, mindSum = 0, wisdomSum = 0, actionSum = 0, wealthSum = 0;
+        for (MemberEnergyDTO m : members) {
+            bodySum += safeScore(m.getBodyScore());
+            mindSum += safeScore(m.getMindScore());
+            wisdomSum += safeScore(m.getWisdomScore());
+            actionSum += safeScore(m.getActionScore());
+            wealthSum += safeScore(m.getWealthScore());
+        }
+        int count = members.size();
+        dto.setBodyScore(bodySum / count);
+        dto.setMindScore(mindSum / count);
+        dto.setWisdomScore(wisdomSum / count);
+        dto.setActionScore(actionSum / count);
+        dto.setWealthScore(wealthSum / count);
+
+        int overall = (dto.getBodyScore() + dto.getMindScore() + dto.getWisdomScore()
+                + dto.getActionScore() + dto.getWealthScore()) / 5;
+        dto.setOverallScore(overall);
+
+        return dto;
+    }
+
+    // ==================== 家长能量计算 ====================
+
+    private MemberEnergyDTO calcParentEnergy(User parent) {
+        MemberEnergyDTO dto = new MemberEnergyDTO();
+        dto.setMemberId(parent.getId());
+        dto.setMemberType("parent");
+        dto.setName(parent.getNickname());
+        dto.setAvatar(parent.getAvatar());
+        dto.setFamilyRole(parent.getFamilyRole());  // 爸爸/妈妈/爷爷/奶奶
+
+        // 家长五维计算(数据相对少,主要是任务完成率 + 积分)
+        dto.setBodyScore(calcParentBody(parent));
+        dto.setMindScore(calcParentMind(parent));
+        dto.setWisdomScore(calcParentWisdom(parent));
+        dto.setActionScore(calcParentAction(parent));
+        dto.setWealthScore(calcParentWealth(parent));
+
+        int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
+                + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
+                + safeScore(dto.getWealthScore())) / 5;
+        dto.setOverallScore(overall);
+
+        return dto;
+    }
+
+    /** 家长 身 — 运动健康类任务完成率 */
+    private int calcParentBody(User parent) {
+        return calcTaskCompletionRate(parent.getId(), "parent",
+                Arrays.asList("运动", "体育", "户外", "健康"), 365);
+    }
+
+    /** 家长 心 — 学习/阅读/亲子类任务完成率 */
+    private int calcParentMind(User parent) {
+        return calcTaskCompletionRate(parent.getId(), "parent",
+                Arrays.asList("学习", "阅读", "亲子", "情感", "成长"), 365);
+    }
+
+    /** 家长 智 — 学习提升类任务完成率(家长没有游戏/测评数据) */
+    private int calcParentWisdom(User parent) {
+        int taskScore = calcTaskCompletionRate(parent.getId(), "parent",
+                Arrays.asList("学习", "培训", "技能", "阅读", "知识"), 365);
+        return taskScore;
+    }
+
+    /** 家长 行 — 习惯/自律类任务完成率 */
+    private int calcParentAction(User parent) {
+        return calcTaskCompletionRate(parent.getId(), "parent",
+                Arrays.asList("习惯", "好习惯", "自律", "家务", "自理", "作息"), 365);
+    }
+
+    /** 家长 富 — 积分积累效率 */
+    private int calcParentWealth(User parent) {
+        Integer totalPoints = parent.getTotalPoints();
+        if (totalPoints == null) totalPoints = 0;
+
+        // 参考上限:假设 500 积分为满分
+        int refMax = 500;
+        int score = Math.min(totalPoints * 100 / Math.max(refMax, 1), 100);
+        return Math.max(score, 0);
+    }
+
+    // ==================== 孩子能量计算 ====================
+
+    private MemberEnergyDTO calcChildEnergy(Child child) {
+        MemberEnergyDTO dto = new MemberEnergyDTO();
+        dto.setMemberId(child.getId());
+        dto.setMemberType("child");
+        dto.setName(child.getNickname());
+        dto.setAvatar(null);  // 孩子没有独立头像,用默认
+
+        dto.setBodyScore(calcChildBody(child));
+        dto.setMindScore(calcChildMind(child));
+        dto.setWisdomScore(calcChildWisdom(child));
+        dto.setActionScore(calcChildAction(child));
+        dto.setWealthScore(calcChildWealth(child));
+
+        int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
+                + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
+                + safeScore(dto.getWealthScore())) / 5;
+        dto.setOverallScore(overall);
+
+        return dto;
+    }
+
+    /** 孩子 身 — 运动/户外类任务完成率 */
+    private int calcChildBody(Child child) {
+        return calcTaskCompletionRate(child.getId(), "child",
+                Arrays.asList("运动", "体育", "户外", "健康"), 365);
+    }
+
+    /** 孩子 心 — 学习/阅读类任务完成率 */
+    private int calcChildMind(Child child) {
+        return calcTaskCompletionRate(child.getId(), "child",
+                Arrays.asList("学习", "阅读", "亲子"), 365);
+    }
+
+    /** 孩子 智 — 游戏得分 + 测评结果 + 阅读时长 多源加权 */
+    private int calcChildWisdom(Child child) {
+        Long childId = child.getId();
+        double totalWeight = 0;
+        double weightedSum = 0;
+
+        // 1. 游戏得分 (0-100, 平均分)
+        LambdaQueryWrapper<GameRecord> gw = new LambdaQueryWrapper<GameRecord>()
+                .eq(GameRecord::getChildId, childId);
+        List<GameRecord> games = gameRecordMapper.selectList(gw);
+        if (!games.isEmpty()) {
+            double avgScore = games.stream()
+                    .filter(g -> g.getScore() != null)
+                    .mapToInt(GameRecord::getScore)
+                    .average()
+                    .orElse(0);
+            weightedSum += Math.min(avgScore, 100) * 0.34;
+            totalWeight += 0.34;
+        }
+
+        // 2. 最近一次完成的测评得分
+        LambdaQueryWrapper<DanAssessmentResult> aw = new LambdaQueryWrapper<DanAssessmentResult>()
+                .eq(DanAssessmentResult::getChildId, childId)
+                .eq(DanAssessmentResult::getStatus, "completed")
+                .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                .last("LIMIT 1");
+        DanAssessmentResult result = danAssessmentResultMapper.selectOne(aw);
+        if (result != null && result.getOverallScore() != null) {
+            weightedSum += result.getOverallScore().doubleValue() * 0.33;
+            totalWeight += 0.33;
+        }
+
+        // 3. 近30天阅读时长 (每3600秒=1小时=满分)
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -30);
+        Date thirtyDaysAgo = cal.getTime();
+        Integer totalSeconds = articleReadingRecordMapper
+                .selectTotalDurationByChildSince(childId, thirtyDaysAgo);
+        if (totalSeconds != null && totalSeconds > 0) {
+            double readingScore = Math.min((double) totalSeconds / 3600 * 100, 100);
+            weightedSum += readingScore * 0.33;
+            totalWeight += 0.33;
+        }
+
+        if (totalWeight == 0) return 0;
+        int score = (int) Math.round(weightedSum / totalWeight);
+        return Math.min(Math.max(score, 0), 100);
+    }
+
+    /** 孩子 行 — 打卡天数 + 习惯类任务完成率 */
+    private int calcChildAction(Child child) {
+        Long childId = child.getId();
+
+        // 1. 连续打卡天数 (0-100)
+        int streakDays = child.getStreakDays() != null ? child.getStreakDays() : 0;
+        // 获取最高里程碑天数
+        LambdaQueryWrapper<StreakMilestone> mw = new LambdaQueryWrapper<StreakMilestone>()
+                .orderByDesc(StreakMilestone::getDays)
+                .last("LIMIT 1");
+        List<StreakMilestone> milestones = streakMilestoneMapper.selectList(mw);
+        int maxMilestone = 30;
+        if (!milestones.isEmpty() && milestones.get(0).getDays() != null) {
+            maxMilestone = milestones.get(0).getDays();
+        }
+        int dynamicRef = Math.max(streakDays, maxMilestone);
+        int streakScore = Math.min(streakDays * 100 / Math.max(dynamicRef, 1), 100);
+
+        // 2. 习惯类任务完成率
+        int habitScore = calcTaskCompletionRate(childId, "child",
+                Arrays.asList("好习惯", "习惯", "自理", "家务"), 365);
+
+        // 加权:打卡40% + 习惯任务60%
+        return Math.min((int) Math.round(streakScore * 0.4 + habitScore * 0.6), 100);
+    }
+
+    /** 孩子 富 — 积分获取效率 */
+    private int calcChildWealth(Child child) {
+        Long childId = child.getId();
+
+        // 已获得积分: points_log 中正向入账
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.YEAR, -1);
+        Date oneYearAgo = cal.getTime();
+
+        LambdaQueryWrapper<PointsLog> earnedWrapper = new LambdaQueryWrapper<PointsLog>()
+                .eq(PointsLog::getChildId, childId)
+                .gt(PointsLog::getAmount, 0)
+                .gt(PointsLog::getCreatedAt, oneYearAgo);
+        List<PointsLog> earnedLogs = pointsLogMapper.selectList(earnedWrapper);
+        int earned = earnedLogs.stream()
+                .mapToInt(pl -> pl.getAmount() != null ? pl.getAmount() : 0)
+                .sum();
+
+        // 参考上限: 同期可获得的积分上限
+        LambdaQueryWrapper<Task> taskWrapper = new LambdaQueryWrapper<Task>()
+                .eq(Task::getChildId, childId)
+                .ne(Task::getIsTemplate, 1)
+                .ne(Task::getStatus, "cancelled")
+                .gt(Task::getCreatedAt, oneYearAgo);
+        List<Task> tasks = taskMapper.selectList(taskWrapper);
+        int available = tasks.stream()
+                .mapToInt(t -> t.getPoints() != null ? t.getPoints() : 0)
+                .sum();
+
+        int score = (int) Math.min((long) earned * 100 / Math.max(available, 1), 100);
+        return Math.max(score, 0);
+    }
+
+    // ==================== 通用方法 ====================
+
+    /**
+     * 计算指定成员的任务完成率得分 (0-100)
+     *
+     * @param memberId     executorId (家长=userId, 孩子=childId)
+     * @param memberType   'parent' 或 'child'
+     * @param categories   Task.category 匹配列表
+     * @param daysWindow   统计窗口(天)
+     */
+    private int calcTaskCompletionRate(Long memberId, String memberType,
+                                        List<String> categories, int daysWindow) {
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysWindow);
+        Date since = cal.getTime();
+
+        long total = 0;
+        long completed = 0;
+
+        for (String category : categories) {
+            LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
+                    .eq(Task::getExecutorType, memberType)
+                    .eq(Task::getExecutorId, memberId)
+                    .eq(Task::getCategory, category)
+                    .ne(Task::getIsTemplate, 1)
+                    .ne(Task::getStatus, "cancelled")
+                    .gt(Task::getCreatedAt, since);
+
+            List<Task> tasks = taskMapper.selectList(wrapper);
+            for (Task t : tasks) {
+                total++;
+                if ("completed".equals(t.getStatus())) {
+                    completed++;
+                }
+            }
+        }
+
+        if (total == 0) return 0;
+        return (int) Math.round((double) completed / total * 100);
+    }
+
+    private int safeScore(Integer score) {
+        return score != null ? score : 0;
+    }
+
+    private EnergySandboxDTO buildEmptyDTO() {
+        EnergySandboxDTO dto = new EnergySandboxDTO();
+        dto.setBodyScore(0);
+        dto.setMindScore(0);
+        dto.setWisdomScore(0);
+        dto.setActionScore(0);
+        dto.setWealthScore(0);
+        dto.setOverallScore(0);
+        dto.setMembers(Collections.emptyList());
+        return dto;
+    }
+
+    // ==================== 五维能量账本系统(新增) ====================
+
+    private static final Map<String, Integer> DAILY_LIMIT_MAP = new LinkedHashMap<>();
+
+    static {
+        DAILY_LIMIT_MAP.put("body", 50);
+        DAILY_LIMIT_MAP.put("mind", 40);
+        DAILY_LIMIT_MAP.put("wisdom", 80);
+        DAILY_LIMIT_MAP.put("action", 60);
+        DAILY_LIMIT_MAP.put("wealth", 30);
+    }
+
+    private static final int GLOBAL_DAILY_LIMIT = 200;
+
+    @Resource
+    private EnergyDimensionMapper energyDimensionMapper;
+
+    @Resource
+    private EnergySourceConfigMapper energySourceConfigMapper;
+
+    @Resource
+    private EnergyLogMapper energyLogMapper;
+
+    @Resource
+    private EnergyBalanceMapper energyBalanceMapper;
+
+    // 维度缓存(懒加载)
+    private List<EnergyDimension> dimCache;
+    private Map<String, EnergyDimension> dimCodeMap;
+    private Map<Long, EnergyDimension> dimIdMap;
+
+    /**
+     * 发放能量 — 按 energy_source_config 比例分配至各维度
+     *
+     * @param childId      孩子ID
+     * @param sourceType   来源类型 (task/product/medical_report/etc)
+     * @param sourceId     来源ID
+     * @param totalAmount  总能量值
+     * @param description  描述
+     * @param daysToExpire 过期天数(null=不过期)
+     * @return Map<dimensionCode, amount>
+     */
+    @Transactional
+    public Map<String, Integer> awardEnergy(Long childId, String sourceType, Long sourceId,
+                                            Integer totalAmount, String description,
+                                            Integer daysToExpire) {
+        if (totalAmount == null || totalAmount <= 0) return new HashMap<>();
+
+        // 1. 查比例配置 → Fallback链
+        List<EnergySourceConfig> configs = energySourceConfigMapper.selectList(
+                new LambdaQueryWrapper<EnergySourceConfig>()
+                        .eq(EnergySourceConfig::getSourceType, sourceType)
+                        .eq(EnergySourceConfig::getSourceId, sourceId)
+        );
+        Map<Long, BigDecimal> dimRatios = resolveDimensionRatios(configs, sourceType, sourceId);
+
+        if (dimRatios.isEmpty()) return new HashMap<>();
+
+        // 2. 日上限检查
+        if (isDailyLimitExceeded(childId, dimRatios.keySet(), totalAmount)) {
+            log.warn("日上限已达,跳过发放: childId={}, amount={}", childId, totalAmount);
+            return new HashMap<>();
+        }
+
+        // 3. 按比例分配(整数,余数加到最大比例维度)
+        Map<Long, Integer> allocations = calculateAllocations(totalAmount, dimRatios);
+
+        // 4. 更新余额 + 写流水
+        Map<String, Integer> result = new LinkedHashMap<>();
+        Date now = new Date();
+        Date expiresAt = daysToExpire != null
+                ? new Date(now.getTime() + (long) daysToExpire * 86400000L) : null;
+
+        for (Map.Entry<Long, Integer> entry : allocations.entrySet()) {
+            if (entry.getValue() <= 0) continue;
+
+            EnergyBalance balance = getOrCreateBalance(childId, entry.getKey());
+            balance.setBalance(balance.getBalance() + entry.getValue());
+            balance.setTotalEarned(balance.getTotalEarned() + entry.getValue());
+            balance.setUpdatedAt(now);
+            energyBalanceMapper.updateById(balance);
+
+            EnergyLog elog = new EnergyLog();
+            elog.setChildId(childId);
+            elog.setDimensionId(entry.getKey());
+            elog.setAmount(entry.getValue());
+            elog.setBalanceAfter(balance.getBalance());
+            elog.setSourceType(sourceType);
+            elog.setSourceId(sourceId);
+            elog.setExpiresAt(expiresAt);
+            elog.setDescription(description);
+            elog.setCreatedAt(now);
+            energyLogMapper.insert(elog);
+
+            EnergyDimension dim = getDimById(entry.getKey());
+            if (dim != null) result.put(dim.getCode(), entry.getValue());
+        }
+        return result;
+    }
+
+    /**
+     * 扣除能量
+     *
+     * @return 扣除后余额,-1 = 余额不足
+     */
+    @Transactional
+    public int deductEnergy(Long childId, Long dimensionId, Integer amount, String reason) {
+        if (amount == null || amount <= 0)
+            throw new IllegalArgumentException("扣除数量必须为正数");
+
+        EnergyBalance balance = getOrCreateBalance(childId, dimensionId);
+        if (balance.getBalance() < amount) return -1;
+
+        balance.setBalance(balance.getBalance() - amount);
+        balance.setTotalSpent(balance.getTotalSpent() + amount);
+        balance.setUpdatedAt(new Date());
+        energyBalanceMapper.updateById(balance);
+
+        EnergyLog elog = new EnergyLog();
+        elog.setChildId(childId);
+        elog.setDimensionId(dimensionId);
+        elog.setAmount(-amount);
+        elog.setBalanceAfter(balance.getBalance());
+        elog.setDescription(reason);
+        elog.setCreatedAt(new Date());
+        energyLogMapper.insert(elog);
+
+        return balance.getBalance();
+    }
+
+    /**
+     * 查询五维能量概览
+     */
+    public Map<String, Object> getOverview(Long childId) {
+        List<EnergyDimension> allDims = getAllDimensions();
+        List<Map<String, Object>> dimList = new ArrayList<>();
+        int totalEnergy = 0;
+
+        for (EnergyDimension dim : allDims) {
+            if (dim.getStatus() != 1) continue;
+            EnergyBalance balance = energyBalanceMapper.selectOne(
+                    new LambdaQueryWrapper<EnergyBalance>()
+                            .eq(EnergyBalance::getChildId, childId)
+                            .eq(EnergyBalance::getDimensionId, dim.getId())
+            );
+            int energy = balance != null ? balance.getBalance() : 0;
+            totalEnergy += energy;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("code", dim.getCode());
+            item.put("name", dim.getName());
+            item.put("icon", dim.getIcon());
+            item.put("element", dim.getElement());
+            item.put("energy", energy);
+            item.put("healthIndex", 0); // 算法待定
+            dimList.add(item);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("dimensions", dimList);
+        result.put("totalEnergy", totalEnergy);
+        result.put("totalHealthIndex", 0);
+        return result;
+    }
+
+    /**
+     * 流水查询(按维度筛选,分页)
+     */
+    public Page<EnergyLog> getLogs(Long childId, String dimensionCode, Integer page, Integer size) {
+        if (page == null || page < 1) page = 1;
+        if (size == null || size < 1) size = 10;
+        Page<EnergyLog> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<EnergyLog> wrapper = new LambdaQueryWrapper<EnergyLog>()
+                .eq(EnergyLog::getChildId, childId);
+
+        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+            EnergyDimension dim = getDimByCode(dimensionCode);
+            if (dim != null) wrapper.eq(EnergyLog::getDimensionId, dim.getId());
+        }
+        wrapper.orderByDesc(EnergyLog::getCreatedAt);
+        return energyLogMapper.selectPage(pageParam, wrapper);
+    }
+
+    /**
+     * 配置来源比例
+     */
+    @Transactional
+    public void configureSourceRatios(String sourceType, Long sourceId, String sourceName,
+                                      List<Map<String, Object>> ratios) {
+        energySourceConfigMapper.delete(
+                new LambdaQueryWrapper<EnergySourceConfig>()
+                        .eq(EnergySourceConfig::getSourceType, sourceType)
+                        .eq(EnergySourceConfig::getSourceId, sourceId)
+        );
+        for (Map<String, Object> r : ratios) {
+            String dimCode = (String) r.get("dimensionCode");
+            BigDecimal ratioVal = new BigDecimal(r.get("ratio").toString());
+            EnergyDimension dim = getDimByCode(dimCode);
+            if (dim == null) continue;
+
+            EnergySourceConfig config = new EnergySourceConfig();
+            config.setSourceType(sourceType);
+            config.setSourceId(sourceId);
+            config.setSourceName(sourceName);
+            config.setDimensionId(dim.getId());
+            config.setRatio(ratioVal);
+            config.setCreatedAt(new Date());
+            energySourceConfigMapper.insert(config);
+        }
+    }
+
+    // ==================== 辅助方法 ====================
+
+    /**
+     * 解析维度比例配置,按Fallback链降级
+     */
+    private Map<Long, BigDecimal> resolveDimensionRatios(
+            List<EnergySourceConfig> configs, String sourceType, Long sourceId) {
+
+        if (!configs.isEmpty()) {
+            Map<Long, BigDecimal> result = new LinkedHashMap<>();
+            for (EnergySourceConfig c : configs) result.put(c.getDimensionId(), c.getRatio());
+            return result;
+        }
+
+        // Fallback: 默认 → 行(action) 100%
+        EnergyDimension action = getDimByCode("action");
+        if (action != null) return Collections.singletonMap(action.getId(), BigDecimal.ONE);
+        return Collections.emptyMap();
+    }
+
+    /**
+     * 按比例分配整数,余数加到最大比例维度
+     */
+    private Map<Long, Integer> calculateAllocations(Integer total, Map<Long, BigDecimal> ratios) {
+        BigDecimal totalRatio = ratios.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        if (totalRatio.compareTo(BigDecimal.ZERO) == 0) return Collections.emptyMap();
+
+        Map<Long, Integer> result = new LinkedHashMap<>();
+        int allocated = 0;
+
+        // 第一轮:按比例分配(向下取整)
+        Long maxRatioDim = null;
+        BigDecimal maxRatio = BigDecimal.ZERO;
+        for (Map.Entry<Long, BigDecimal> entry : ratios.entrySet()) {
+            BigDecimal share = BigDecimal.valueOf(total).multiply(entry.getValue()).divide(totalRatio, 0, RoundingMode.DOWN);
+            int val = share.intValue();
+            result.put(entry.getKey(), val);
+            allocated += val;
+
+            if (entry.getValue().compareTo(maxRatio) > 0) {
+                maxRatio = entry.getValue();
+                maxRatioDim = entry.getKey();
+            }
+        }
+
+        // 第二轮:余数加到最大比例维度
+        int remainder = total - allocated;
+        if (remainder > 0 && maxRatioDim != null) {
+            result.put(maxRatioDim, result.get(maxRatioDim) + remainder);
+        }
+
+        return result;
+    }
+
+    /**
+     * 获取或创建维度余额记录
+     */
+    private EnergyBalance getOrCreateBalance(Long childId, Long dimId) {
+        EnergyBalance balance = energyBalanceMapper.selectOne(
+                new LambdaQueryWrapper<EnergyBalance>()
+                        .eq(EnergyBalance::getChildId, childId)
+                        .eq(EnergyBalance::getDimensionId, dimId)
+        );
+        if (balance == null) {
+            balance = new EnergyBalance();
+            balance.setChildId(childId);
+            balance.setDimensionId(dimId);
+            balance.setBalance(0);
+            balance.setTotalEarned(0);
+            balance.setTotalSpent(0);
+            balance.setUpdatedAt(new Date());
+            energyBalanceMapper.insert(balance);
+        }
+        return balance;
+    }
+
+    /**
+     * 检查日上限是否已达
+     */
+    private boolean isDailyLimitExceeded(Long childId, Set<Long> dimIds, Integer amount) {
+        if (amount == null || amount <= 0) return true;
+
+        Date todayStart = getTodayStart();
+        Date todayEnd = getTodayEnd();
+
+        // 全局日上限
+        LambdaQueryWrapper<EnergyLog> globalWrapper = new LambdaQueryWrapper<EnergyLog>()
+                .eq(EnergyLog::getChildId, childId)
+                .gt(EnergyLog::getAmount, 0)
+                .between(EnergyLog::getCreatedAt, todayStart, todayEnd);
+        Integer globalToday = energyLogMapper.selectList(globalWrapper).stream()
+                .mapToInt(e -> e.getAmount() != null ? e.getAmount() : 0)
+                .sum();
+        if (globalToday + amount > GLOBAL_DAILY_LIMIT) return true;
+
+        // 各维度日上限
+        for (Long dimId : dimIds) {
+            LambdaQueryWrapper<EnergyLog> dimWrapper = new LambdaQueryWrapper<EnergyLog>()
+                    .eq(EnergyLog::getChildId, childId)
+                    .eq(EnergyLog::getDimensionId, dimId)
+                    .gt(EnergyLog::getAmount, 0)
+                    .between(EnergyLog::getCreatedAt, todayStart, todayEnd);
+            Integer dimToday = energyLogMapper.selectList(dimWrapper).stream()
+                    .mapToInt(e -> e.getAmount() != null ? e.getAmount() : 0)
+                    .sum();
+
+            EnergyDimension dim = getDimById(dimId);
+            String code = dim != null ? dim.getCode() : "";
+            Integer limit = DAILY_LIMIT_MAP.getOrDefault(code, 50);
+
+            if (dimToday + amount > limit) return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * 获取今日起始时间(00:00:00)
+     */
+    private Date getTodayStart() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
+    /**
+     * 获取今日结束时间(23:59:59)
+     */
+    private Date getTodayEnd() {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 999);
+        return cal.getTime();
+    }
+
+    /**
+     * 获取所有维度(带缓存)
+     */
+    private List<EnergyDimension> getAllDimensions() {
+        if (dimCache == null) {
+            dimCache = energyDimensionMapper.selectList(
+                    new LambdaQueryWrapper<EnergyDimension>()
+                            .orderByAsc(EnergyDimension::getSortOrder)
+            );
+        }
+        return dimCache;
+    }
+
+    /**
+     * 按code查维度
+     */
+    private EnergyDimension getDimByCode(String code) {
+        initDimMaps();
+        return dimCodeMap.get(code);
+    }
+
+    /**
+     * 按ID查维度
+     */
+    private EnergyDimension getDimById(Long id) {
+        initDimMaps();
+        return dimIdMap.get(id);
+    }
+
+    private void initDimMaps() {
+        if (dimCodeMap == null || dimIdMap == null) {
+            List<EnergyDimension> all = getAllDimensions();
+            dimCodeMap = all.stream().collect(Collectors.toMap(EnergyDimension::getCode, d -> d));
+            dimIdMap = all.stream().collect(Collectors.toMap(EnergyDimension::getId, d -> d));
+        }
+    }
+}

+ 123 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GameRecordService.java

@@ -0,0 +1,123 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.GameRecord;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.mapper.GameRecordMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class GameRecordService {
+
+    @Resource
+    private GameRecordMapper gameRecordMapper;
+
+    @Resource
+    private MiniGameMapper miniGameMapper;
+
+    public List<GameRecord> listByChildId(Long childId) {
+        return gameRecordMapper.selectList(
+                new LambdaQueryWrapper<GameRecord>()
+                        .eq(GameRecord::getChildId, childId));
+    }
+
+    public void saveRecord(Long childId, String gameCode, Integer completionTime,
+                           Integer score, String difficulty, Integer pointsEarned) {
+        GameRecord record = new GameRecord();
+        record.setChildId(childId);
+        record.setGameCode(gameCode);
+        record.setScore(score);
+        record.setCompletionTime(completionTime);
+        record.setDifficulty(difficulty != null ? difficulty : "medium");
+        record.setPointsEarned(pointsEarned);
+        record.setPlayedAt(new Date());
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        gameRecordMapper.insert(record);
+    }
+
+    public Map<String, Object> getHistory(Long childId, String gameCode,
+                                          Integer page, Integer size) {
+        if (page == null || page < 1) page = 1;
+        if (size == null || size < 1) size = 20;
+
+        LambdaQueryWrapper<GameRecord> wrapper = new LambdaQueryWrapper<GameRecord>()
+                .eq(GameRecord::getChildId, childId)
+                .orderByDesc(GameRecord::getPlayedAt);
+
+        if (gameCode != null && !gameCode.isEmpty()) {
+            wrapper.eq(GameRecord::getGameCode, gameCode);
+        }
+
+        int offset = (page - 1) * size;
+        wrapper.last("LIMIT " + size + " OFFSET " + offset);
+
+        List<GameRecord> records = gameRecordMapper.selectList(wrapper);
+
+        LambdaQueryWrapper<GameRecord> countWrapper = new LambdaQueryWrapper<GameRecord>()
+                .eq(GameRecord::getChildId, childId);
+        if (gameCode != null && !gameCode.isEmpty()) {
+            countWrapper.eq(GameRecord::getGameCode, gameCode);
+        }
+        long total = gameRecordMapper.selectCount(countWrapper);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", records);
+        result.put("total", total);
+        result.put("page", page);
+        result.put("size", size);
+        return result;
+    }
+
+    public List<Map<String, Object>> getBestScores(Long childId) {
+        List<Map<String, Object>> bestScores = gameRecordMapper.selectBestScoresByChild(childId);
+        for (Map<String, Object> item : bestScores) {
+            String code = (String) item.get("game_code");
+            MiniGame game = miniGameMapper.selectOne(
+                    new LambdaQueryWrapper<MiniGame>()
+                            .eq(MiniGame::getGameCode, code));
+            if (game != null) {
+                item.put("game_name", game.getGameName());
+                item.put("icon", game.getIcon());
+            }
+        }
+        return bestScores;
+    }
+
+    public Map<String, Object> getStats(Long childId) {
+        LambdaQueryWrapper<GameRecord> wrapper = new LambdaQueryWrapper<GameRecord>()
+                .eq(GameRecord::getChildId, childId);
+        long totalGames = gameRecordMapper.selectCount(wrapper);
+
+        List<GameRecord> all = gameRecordMapper.selectList(wrapper);
+        int totalPoints = 0;
+        int bestScore = 0;
+        for (GameRecord r : all) {
+            if (r.getPointsEarned() != null) totalPoints += r.getPointsEarned();
+            if (r.getScore() != null && r.getScore() > bestScore) bestScore = r.getScore();
+        }
+
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("totalGames", totalGames);
+        stats.put("totalPoints", totalPoints);
+        stats.put("bestScore", bestScore);
+        stats.put("avgScore", totalGames > 0
+                ? all.stream().filter(r -> r.getScore() != null).mapToInt(GameRecord::getScore).average().orElse(0)
+                : 0);
+        return stats;
+    }
+
+    public List<Map<String, Object>> getLeaderboard(String gameCode, Integer limit) {
+        if (limit == null || limit < 1) limit = 10;
+        return gameRecordMapper.selectLeaderboard(gameCode, limit);
+    }
+}

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WithdrawalService.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Service
+public class WithdrawalService {
+
+    private static final BigDecimal MIN_WITHDRAWAL = new BigDecimal("100.00");
+
+    @Resource
+    private WithdrawalRequestMapper withdrawalRequestMapper;
+
+    @Resource
+    private CommissionService commissionService;
+
+    public Result<String> apply(Long userId, BigDecimal amount, String accountInfo) {
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+            return Result.error("提现金额必须大于0");
+        }
+        if (amount.compareTo(MIN_WITHDRAWAL) < 0) {
+            return Result.error("最低提现金额为 ¥" + MIN_WITHDRAWAL);
+        }
+        if (accountInfo == null || accountInfo.trim().isEmpty()) {
+            return Result.error("请填写账户信息");
+        }
+        BigDecimal available = commissionService.getAvailableAmount(userId);
+        if (available.compareTo(amount) < 0) {
+            return Result.error("可提现余额不足,当前可提现 ¥" + available);
+        }
+        WithdrawalRequest request = new WithdrawalRequest();
+        request.setUserId(userId);
+        request.setAmount(amount);
+        request.setAccountInfo(accountInfo);
+        request.setStatus("pending");
+        request.setCreatedAt(new Date());
+        withdrawalRequestMapper.insert(request);
+        return Result.success("提现申请已提交,等待审核");
+    }
+
+    public void audit(Long requestId, Long adminId, String status, String remark) {
+        WithdrawalRequest request = withdrawalRequestMapper.selectById(requestId);
+        if (request == null) {
+            throw new RuntimeException("提现申请不存在");
+        }
+        if (!"pending".equals(request.getStatus())) {
+            throw new RuntimeException("该申请已处理");
+        }
+        request.setStatus(status);
+        request.setAuditBy(adminId);
+        request.setAuditAt(new Date());
+        request.setRemark(remark != null ? remark : "");
+        withdrawalRequestMapper.updateById(request);
+    }
+
+    public Page<WithdrawalRequest> getHistory(Long userId, int page, int size) {
+        Page<WithdrawalRequest> pageParam = new Page<>(page, size);
+        return withdrawalRequestMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<WithdrawalRequest>()
+                        .eq(WithdrawalRequest::getUserId, userId)
+                        .orderByDesc(WithdrawalRequest::getCreatedAt));
+    }
+
+    public Page<WithdrawalRequest> getAdminList(String status, int page, int size) {
+        Page<WithdrawalRequest> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<WithdrawalRequest> wrapper = new LambdaQueryWrapper<WithdrawalRequest>()
+                .orderByDesc(WithdrawalRequest::getCreatedAt);
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(WithdrawalRequest::getStatus, status);
+        }
+        return withdrawalRequestMapper.selectPage(pageParam, wrapper);
+    }
+}