|
@@ -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));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|