EnergyService.java 99 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357
  1. package com.etotem.cfc.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  4. import com.etotem.cfc.dto.CfBalanceView;
  5. import com.etotem.cfc.dto.EnergySandboxDTO;
  6. import com.etotem.cfc.dto.MemberEnergyDTO;
  7. import com.etotem.cfc.dto.DimensionNode;
  8. import com.etotem.cfc.entity.*;
  9. import com.etotem.cfc.mapper.*;
  10. import com.alibaba.fastjson.JSON;
  11. import com.alibaba.fastjson.JSONArray;
  12. import com.alibaba.fastjson.JSONObject;
  13. import lombok.extern.slf4j.Slf4j;
  14. import org.springframework.context.annotation.Lazy;
  15. import org.springframework.stereotype.Service;
  16. import org.springframework.transaction.annotation.Transactional;
  17. import javax.annotation.Resource;
  18. import java.util.*;
  19. import java.util.stream.Collectors;
  20. import com.etotem.cfc.util.SortUtil;
  21. import com.etotem.cfc.service.NotificationDispatchService;
  22. /**
  23. * 家庭能量沙盘 — 核心计算服务
  24. * 计算每个家庭成员(家庭主账号+家庭成员)的身/心/智/行/富五维能量值 (0-100%),
  25. * 并聚合出家庭整体能量视图。
  26. */
  27. @Slf4j
  28. @Service
  29. public class EnergyService {
  30. @Resource
  31. private UserMapper userMapper;
  32. @Resource
  33. private FamilyMemberMapper familyMemberMapper;
  34. @Resource
  35. private NotificationDispatchService notificationDispatchService;
  36. @Resource
  37. private TaskMapper taskMapper;
  38. @Resource
  39. private PointsLogMapper pointsLogMapper;
  40. @Resource
  41. private GameRecordMapper gameRecordMapper;
  42. @Resource
  43. private DanAssessmentResultMapper danAssessmentResultMapper;
  44. @Resource
  45. private ArticleReadingRecordMapper articleReadingRecordMapper;
  46. @Resource
  47. private StreakMilestoneMapper streakMilestoneMapper;
  48. @Resource
  49. private HealthReportService healthReportService;
  50. @Resource
  51. private HealthReportMapper healthReportMapper;
  52. @Resource
  53. private FamilyMemberAttributesMapper familyMemberAttributesMapper;
  54. @Resource
  55. private FiveDimensionScoreService fiveDimensionScoreService;
  56. @Resource
  57. private HealthDimensionScoreService healthDimensionScoreService;
  58. /**
  59. * CF 值统一账本(F-2)。家庭主账号富维度「财务积累」改读个人账本 cf_balance.total_earned。
  60. *
  61. * <p>{@code @Lazy} 延迟注入:CfBalanceServiceImpl → MembershipService → … → CfBalanceService
  62. * 存在启动期循环依赖风险(见 commit c4813f72d / 02ca6aa0c),此处用代理延迟解析,
  63. * 运行时调用才真正取 bean,避免应用启动失败。</p>
  64. */
  65. @Resource
  66. @Lazy
  67. private CfBalanceService cfBalanceService;
  68. @Resource
  69. private ReferralTreeMapper referralTreeMapper;
  70. @Resource
  71. private GuideMapper guideMapper;
  72. @Resource
  73. private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
  74. /** 统一维度注册中心:五维根节点五行归属(wuxing_rule_ref)的权威来源 */
  75. @Resource
  76. private DimensionRegistryService dimensionRegistryService;
  77. /**
  78. * 计算整个家庭的能量沙盘数据
  79. *
  80. * @param familyId 家庭ID
  81. * @return 家庭能量沙盘DTO(家庭聚合 + 每个成员的个人数据)
  82. */
  83. public EnergySandboxDTO calculateFamilyEnergy(Long familyId) {
  84. if (familyId == null) {
  85. return buildEmptyDTO();
  86. }
  87. try {
  88. return doCalculateFamilyEnergy(familyId);
  89. } catch (Exception e) {
  90. log.error("计算家庭能量异常, familyId={}: {}", familyId, e.getMessage(), e);
  91. return buildEmptyDTO();
  92. }
  93. }
  94. private EnergySandboxDTO doCalculateFamilyEnergy(Long familyId) {
  95. EnergySandboxDTO dto = new EnergySandboxDTO();
  96. List<MemberEnergyDTO> members = new ArrayList<>();
  97. // 1. 收集所有家庭成员(查询全部用户,后续通过 hasParentRole 精确过滤 parent)
  98. // 注意:多角色用户(roles 包含 parent 但当前 role 切换为 child)不会被排除
  99. List<User> parents = userMapper.selectList(
  100. new LambdaQueryWrapper<User>()
  101. .eq(User::getFamilyId, familyId)
  102. );
  103. // 过滤掉单纯是 teacher/admin 角色且不是 parent 角色的用户
  104. // 注意:用户可能拥有多角色(如 parent + teacher),切换到 teacher 时 role 字段为 teacher,
  105. // 但只要 roles 列表中包含 parent,仍应作为家庭主账号参与能量计算
  106. List<User> familyParents = new ArrayList<>();
  107. for (User u : parents) {
  108. if (hasParentRole(u)) {
  109. familyParents.add(u);
  110. }
  111. }
  112. // family_members 可能包含“本人”的重复记录(user_id 指向家庭主账号),
  113. // 必须先按家庭主账号 userId 排除,并按家庭成员 userId 去重,否则同一家庭主账号会被重复计算。
  114. List<FamilyMember> children = getActualChildren(familyId);
  115. Date now = new Date();
  116. // 2. 计算每个家庭主账号的能量
  117. for (User parent : familyParents) {
  118. MemberEnergyDTO member = calcParentEnergy(parent);
  119. members.add(member);
  120. persistMemberScores(parent.getId(), "user", familyId, member, now);
  121. }
  122. // 3. 计算每个家庭成员的能量
  123. for (FamilyMember child : children) {
  124. MemberEnergyDTO member = calcChildEnergy(child);
  125. members.add(member);
  126. persistMemberScores(child.getId(), "member", familyId, member, now);
  127. }
  128. if (members.isEmpty()) {
  129. return buildEmptyDTO();
  130. }
  131. dto.setMembers(members);
  132. // 4. 聚合家庭五维(按配置 family_metric_agg_config 取 agg_type:AVG=平均 / SUM=合计 / COLLECT=汇集)
  133. // 默认 AVG 保证向后兼容;读不到配置时回落 AVG;COLLECT 对数值维度不适用,返回 AVG 并记录日志
  134. int bodySum = 0, mindSum = 0, wisdomSum = 0, actionSum = 0, wealthSum = 0;
  135. for (MemberEnergyDTO m : members) {
  136. bodySum += safeScore(m.getBodyScore());
  137. mindSum += safeScore(m.getMindScore());
  138. wisdomSum += safeScore(m.getWisdomScore());
  139. actionSum += safeScore(m.getActionScore());
  140. wealthSum += safeScore(m.getWealthScore());
  141. }
  142. int count = members.size();
  143. dto.setBodyScore(aggregateFamilyMetric("energy.body", bodySum, count));
  144. dto.setMindScore(aggregateFamilyMetric("energy.mind", mindSum, count));
  145. dto.setWisdomScore(aggregateFamilyMetric("energy.wisdom", wisdomSum, count));
  146. dto.setActionScore(aggregateFamilyMetric("energy.action", actionSum, count));
  147. dto.setWealthScore(aggregateFamilyMetric("energy.wealth", wealthSum, count));
  148. int overall = (dto.getBodyScore() + dto.getMindScore() + dto.getWisdomScore()
  149. + dto.getActionScore() + dto.getWealthScore()) / 5;
  150. dto.setOverallScore(overall);
  151. return dto;
  152. }
  153. /**
  154. * 按配置聚合家庭数值维度指标。
  155. *
  156. * <p>依据 {@code family_metric_agg_config} 中 metricKey 对应的 agg_type:
  157. * <ul>
  158. * <li>SUM → 合计(返回 sum,不除以 count)</li>
  159. * <li>COLLECT → 数值维度不适用,回落 AVG 并记录日志</li>
  160. * <li>AVG 或读不到配置 → 平均(默认,保持既有行为)</li>
  161. * </ul>
  162. */
  163. private int aggregateFamilyMetric(String metricKey, int sum, int count) {
  164. if (count <= 0) {
  165. return 0;
  166. }
  167. String aggType = resolveMetricAggType(metricKey);
  168. if ("SUM".equalsIgnoreCase(aggType)) {
  169. return sum;
  170. }
  171. if ("COLLECT".equalsIgnoreCase(aggType)) {
  172. log.info("配置项 {} 为 COLLECT 类型,对数值维度不适用,回落 AVG 计算", metricKey);
  173. }
  174. return sum / count;
  175. }
  176. /**
  177. * 读取某数据项的汇总方式(agg_type),读不到配置或配置无效时回落 AVG。
  178. */
  179. private String resolveMetricAggType(String metricKey) {
  180. if (metricKey == null) {
  181. return "AVG";
  182. }
  183. try {
  184. FamilyMetricAggConfig config = familyMetricAggConfigMapper.selectOne(
  185. new LambdaQueryWrapper<FamilyMetricAggConfig>()
  186. .eq(FamilyMetricAggConfig::getMetricKey, metricKey)
  187. .eq(FamilyMetricAggConfig::getStatus, "active")
  188. .last("LIMIT 1"));
  189. if (config != null && config.getAggType() != null && !config.getAggType().trim().isEmpty()) {
  190. return config.getAggType().trim().toUpperCase();
  191. }
  192. } catch (Exception e) {
  193. log.warn("读取家庭指标聚合配置失败 metricKey={}: {}", metricKey, e.getMessage());
  194. }
  195. return "AVG";
  196. }
  197. /**
  198. * 在家庭沙盘中查找指定成员的能量数据。
  199. * 匹配顺序:memberId+memberType → 仅 memberId → family_members.id 解析到的 userId(家庭主账号)。
  200. */
  201. public MemberEnergyDTO findMemberEnergy(Long familyId, Long memberId, String memberType) {
  202. if (familyId == null || memberId == null) {
  203. return null;
  204. }
  205. EnergySandboxDTO sandbox = calculateFamilyEnergy(familyId);
  206. if (sandbox == null || sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
  207. return null;
  208. }
  209. List<MemberEnergyDTO> members = sandbox.getMembers();
  210. if (memberType != null && !memberType.isEmpty()) {
  211. for (MemberEnergyDTO m : members) {
  212. if (memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType())) {
  213. return m;
  214. }
  215. }
  216. }
  217. for (MemberEnergyDTO m : members) {
  218. if (memberId.equals(m.getMemberId())) {
  219. return m;
  220. }
  221. }
  222. FamilyMember fm = familyMemberMapper.selectById(memberId);
  223. if (fm != null && fm.getUserId() != null) {
  224. for (MemberEnergyDTO m : members) {
  225. if (fm.getUserId().equals(m.getMemberId()) && "parent".equals(m.getMemberType())) {
  226. return m;
  227. }
  228. }
  229. }
  230. return null;
  231. }
  232. /**
  233. * 计算当前用户个人能量沙盘(只含用户本人,不聚合家庭)
  234. */
  235. public EnergySandboxDTO calculatePersonalEnergy(Long userId) {
  236. if (userId == null) {
  237. return buildEmptyDTO();
  238. }
  239. try {
  240. User user = userMapper.selectById(userId);
  241. if (user == null) {
  242. return buildEmptyDTO();
  243. }
  244. EnergySandboxDTO dto = new EnergySandboxDTO();
  245. List<MemberEnergyDTO> members = new ArrayList<>();
  246. MemberEnergyDTO me = calcParentEnergy(user);
  247. members.add(me);
  248. dto.setMembers(members);
  249. dto.setBodyScore(safeScore(me.getBodyScore()));
  250. dto.setMindScore(safeScore(me.getMindScore()));
  251. dto.setWisdomScore(safeScore(me.getWisdomScore()));
  252. dto.setActionScore(safeScore(me.getActionScore()));
  253. dto.setWealthScore(safeScore(me.getWealthScore()));
  254. int overall = (safeScore(me.getBodyScore()) + safeScore(me.getMindScore())
  255. + safeScore(me.getWisdomScore()) + safeScore(me.getActionScore())
  256. + safeScore(me.getWealthScore())) / 5;
  257. dto.setOverallScore(overall);
  258. return dto;
  259. } catch (Exception e) {
  260. log.error("计算个人能量异常, userId={}: {}", userId, e.getMessage(), e);
  261. return buildEmptyDTO();
  262. }
  263. }
  264. private void persistMemberScores(Long memberId, String memberType, Long familyId,
  265. MemberEnergyDTO member, Date assessedAt) {
  266. recordDimension(memberId, memberType, familyId, "body", member.getBodyScore(), assessedAt);
  267. recordDimension(memberId, memberType, familyId, "mind", member.getMindScore(), assessedAt);
  268. recordDimension(memberId, memberType, familyId, "wisdom", member.getWisdomScore(), assessedAt);
  269. recordDimension(memberId, memberType, familyId, "action", member.getActionScore(), assessedAt);
  270. recordDimension(memberId, memberType, familyId, "wealth", member.getWealthScore(), assessedAt);
  271. }
  272. private void recordDimension(Long memberId, String memberType, Long familyId,
  273. String dimensionCode, Integer score, Date assessedAt) {
  274. if (score == null) return;
  275. try {
  276. fiveDimensionScoreService.recordSnapshot(
  277. memberId, memberType, familyId,
  278. dimensionCode, score, assessedAt,
  279. "periodic", null, null);
  280. } catch (Exception e) {
  281. log.warn("持久化五维分数失败 memberId={} dimension={}: {}", memberId, dimensionCode, e.getMessage());
  282. }
  283. }
  284. /**
  285. * 获取家庭中真正的家庭成员记录。
  286. * 排除 user_id 指向本人的重复记录,并对同一家庭成员账号只保留一条。
  287. */
  288. List<FamilyMember> getActualChildren(Long familyId) {
  289. if (familyId == null) return Collections.emptyList();
  290. List<FamilyMember> rows = familyMemberMapper.selectList(
  291. new LambdaQueryWrapper<FamilyMember>()
  292. .eq(FamilyMember::getFamilyId, familyId)
  293. );
  294. List<User> users = userMapper.selectList(
  295. new LambdaQueryWrapper<User>()
  296. .eq(User::getFamilyId, familyId)
  297. );
  298. Set<Long> parentUserIds = new HashSet<>();
  299. if (users != null) {
  300. for (User u : users) {
  301. if (hasParentRole(u)) parentUserIds.add(u.getId());
  302. }
  303. }
  304. return filterActualChildren(rows, parentUserIds);
  305. }
  306. static List<FamilyMember> filterActualChildren(List<FamilyMember> rows, Set<Long> parentUserIds) {
  307. List<FamilyMember> result = new ArrayList<>();
  308. if (rows == null) return result;
  309. Set<Long> seenChildUserIds = new HashSet<>();
  310. for (FamilyMember row : rows) {
  311. if (row == null) continue;
  312. Long userId = row.getUserId();
  313. if (userId != null) {
  314. if (parentUserIds != null && parentUserIds.contains(userId)) continue;
  315. if (!seenChildUserIds.add(userId)) continue;
  316. }
  317. result.add(row);
  318. }
  319. return result;
  320. }
  321. /**
  322. * 收集家庭内所有「数据归属主体」列表(供珍珠图/章鱼图家庭汇集接口复用)。
  323. * <p>
  324. * 与 {@link #doCalculateFamilyEnergy} 的成员收集逻辑一致:
  325. * 家庭主账号(userMapper 取 role=parent 的用户)+ 家庭成员(getActualChildren 去重)。
  326. * </p>
  327. * <p>每项字段:</p>
  328. * <ul>
  329. * <li>{@code memberId}:前端归属键(主账号=users.id;成员=family_members.id)</li>
  330. * <li>{@code memberName}:昵称</li>
  331. * <li>{@code memberType}:user=家庭主账号 / member=家庭成员(契约约定)</li>
  332. * <li>{@code colorKey}:稳定色值键(0..n-1 索引,同一成员前后一致)</li>
  333. * <li>{@code dataUserId}:拉取个人数据所用 userId(主账号=userId;成员=family_members.userId,可为 null)</li>
  334. * </ul>
  335. */
  336. public List<Map<String, Object>> collectFamilyMetricMembers(Long familyId) {
  337. List<Map<String, Object>> result = new ArrayList<>();
  338. if (familyId == null) {
  339. return result;
  340. }
  341. List<User> parents = userMapper.selectList(
  342. new LambdaQueryWrapper<User>()
  343. .eq(User::getFamilyId, familyId)
  344. .orderByAsc(User::getId));
  345. List<FamilyMember> children = getActualChildren(familyId);
  346. int colorIdx = 0;
  347. for (User parent : parents) {
  348. if (parent == null || !hasParentRole(parent)) {
  349. continue;
  350. }
  351. Map<String, Object> m = new LinkedHashMap<>();
  352. m.put("memberId", parent.getId());
  353. m.put("memberName", parent.getNickname() != null ? parent.getNickname() : "成员");
  354. m.put("memberType", "user");
  355. m.put("colorKey", colorIdx++);
  356. m.put("dataUserId", parent.getId());
  357. result.add(m);
  358. }
  359. for (FamilyMember child : children) {
  360. if (child == null) {
  361. continue;
  362. }
  363. Map<String, Object> m = new LinkedHashMap<>();
  364. m.put("memberId", child.getId());
  365. m.put("memberName", child.getNickname() != null ? child.getNickname() : "成员");
  366. m.put("memberType", "member");
  367. m.put("colorKey", colorIdx++);
  368. m.put("dataUserId", child.getUserId());
  369. result.add(m);
  370. }
  371. return result;
  372. }
  373. // ==================== 家庭主账号能量计算 ====================
  374. private MemberEnergyDTO calcParentEnergy(User parent) {
  375. MemberEnergyDTO dto = new MemberEnergyDTO();
  376. dto.setMemberId(parent.getId());
  377. dto.setMemberType("parent");
  378. dto.setName(parent.getNickname());
  379. dto.setAvatar(parent.getAvatar());
  380. dto.setFamilyRole(parent.getFamilyRole()); // 爸爸/妈妈/爷爷/奶奶
  381. // 家庭主账号五维计算(数据相对少,主要是任务完成率 + 积分)
  382. dto.setBodyScore(calcParentBody(parent));
  383. dto.setMindScore(calcParentMind(parent));
  384. dto.setWisdomScore(calcParentWisdom(parent));
  385. dto.setActionScore(calcParentAction(parent));
  386. dto.setWealthScore(calcParentWealth(parent, dto));
  387. // 心/行/智/富 子维度(家庭主账号暂无DAN数据,心13维回0;行6维/富传承留待自检问卷)
  388. setMindSubDimensionsForParent(dto);
  389. setActionSubDimensionsForParent(dto);
  390. setWisdomSubDimensionsForParent(dto);
  391. setWealthSubDimensionsForParent(dto);
  392. // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
  393. applyAllRestraints(dto);
  394. applyAllBoosts(dto);
  395. int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
  396. + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
  397. + safeScore(dto.getWealthScore())) / 5;
  398. dto.setOverallScore(overall);
  399. return dto;
  400. }
  401. /**
  402. * 家庭主账号 身 — 运动健康类任务完成率
  403. * 注:家庭主账号如绑定了家庭成员(child ID),可通过该家庭成员的健康报告间接获取身体数据
  404. * Optimized: batch-fetch all children's health scores in a single query
  405. */
  406. private int calcParentBody(User parent) {
  407. // 1. 本人有 active 健康报告时,优先使用自己的数据,
  408. // 避免多个家庭主账号共用同一条家庭成员报告(此前所有家庭主账号 body 都会变成 75)。
  409. if (parent.getId() != null) {
  410. HealthReport ownReport = queryLatestActiveReport(Collections.singletonList(parent.getId()));
  411. if (ownReport != null && ownReport.getOverallScore() != null) {
  412. return ownReport.getOverallScore();
  413. }
  414. }
  415. if (parent.getFamilyId() != null) {
  416. List<FamilyMember> children = getActualChildren(parent.getFamilyId());
  417. // Batch query: fetch health reports for ALL actual children at once
  418. List<Long> childUserIds = children.stream()
  419. .map(FamilyMember::getUserId)
  420. .filter(Objects::nonNull)
  421. .distinct()
  422. .collect(Collectors.toList());
  423. if (!childUserIds.isEmpty()) {
  424. HealthReport report = queryLatestActiveReport(childUserIds);
  425. if (report != null && report.getOverallScore() != null) {
  426. return report.getOverallScore();
  427. }
  428. }
  429. // 7维健康维度评分加权:将所有家庭成员的7维评分取均值作为家庭主账号body能量
  430. List<HealthDimensionScore> allChildScores = new ArrayList<>();
  431. for (FamilyMember child : children) {
  432. try {
  433. List<HealthDimensionScore> scores = healthDimensionScoreService.getLatestScores(child.getId());
  434. if (scores != null) {
  435. allChildScores.addAll(scores);
  436. }
  437. } catch (Exception ignored) {}
  438. }
  439. if (!allChildScores.isEmpty()) {
  440. int avgScore = calcSevenDimBodyScoreFromScores(allChildScores);
  441. if (avgScore > 0) return avgScore;
  442. }
  443. }
  444. return calcTaskCompletionRate(parent.getId(), "parent",
  445. Arrays.asList("运动", "体育", "户外", "健康"), 365);
  446. }
  447. private HealthReport queryLatestActiveReport(List<Long> userIds) {
  448. if (userIds == null || userIds.isEmpty()) return null;
  449. LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<HealthReport>()
  450. .in(HealthReport::getUserId, userIds)
  451. .eq(HealthReport::getStatus, "active")
  452. .isNotNull(HealthReport::getOverallScore)
  453. .orderByDesc(HealthReport::getReportDate)
  454. .last("LIMIT 1");
  455. try {
  456. SortUtil.applySort(wrapper);
  457. return healthReportMapper.selectOne(wrapper);
  458. } catch (Exception e) {
  459. log.warn("健康报告查询异常, userIds={}: {}", userIds, e.getMessage());
  460. return null;
  461. }
  462. }
  463. /** 家庭主账号 心 — 学习/阅读/亲子类任务完成率 */
  464. private int calcParentMind(User parent) {
  465. return calcTaskCompletionRate(parent.getId(), "parent",
  466. Arrays.asList("学习", "阅读", "亲子", "情感", "成长"), 365);
  467. }
  468. /** 家庭主账号 智 — 学习提升类任务完成率(家庭主账号没有游戏/测评数据) */
  469. private int calcParentWisdom(User parent) {
  470. int taskScore = calcTaskCompletionRate(parent.getId(), "parent",
  471. Arrays.asList("学习", "培训", "技能", "阅读", "知识"), 365);
  472. return taskScore;
  473. }
  474. /** 家庭主账号 行 — 习惯/自律类任务完成率 */
  475. private int calcParentAction(User parent) {
  476. return calcTaskCompletionRate(parent.getId(), "parent",
  477. Arrays.asList("习惯", "好习惯", "自律", "家务", "自理", "作息"), 365);
  478. }
  479. /** 家庭主账号 富 — 利他创富四子维度加权:个人能力25% + 利他贡献30% + 财务积累(CF值)35% + 财务素养10% */
  480. private int calcParentWealth(User parent, MemberEnergyDTO dto) {
  481. int skill = calcParentWealthSkill(parent);
  482. int altruism = calcParentWealthAltruism(parent);
  483. int income = calcParentWealthIncome(parent);
  484. int fx = calcParentWealthFx(parent);
  485. dto.setWealthSkill(skill);
  486. dto.setWealthAltruism(altruism);
  487. dto.setWealthIncome(income);
  488. dto.setWealthFx(fx);
  489. // 旧字段兼容(新前端改为读 wealthSkill/wealthAltruism/wealthIncome/wealthFx)
  490. dto.setWealthAchievement(skill);
  491. dto.setWealthNetwork(altruism);
  492. return (int) Math.round(skill * 0.25 + altruism * 0.3 + income * 0.35 + fx * 0.1);
  493. }
  494. /** 家庭主账号富-个人能力:服务商/规划师资质(active) 满分100,否则按成长类任务完成率 */
  495. private int calcParentWealthSkill(User parent) {
  496. Long guideCount = guideMapper.selectCount(
  497. new LambdaQueryWrapper<Guide>()
  498. .eq(Guide::getUserId, parent.getId())
  499. .eq(Guide::getStatus, "active"));
  500. if (guideCount != null && guideCount > 0) {
  501. return 100;
  502. }
  503. return calcTaskCompletionRate(parent.getId(), "parent",
  504. Arrays.asList("学习", "培训", "技能", "阅读", "知识"), 365);
  505. }
  506. /** 家庭主账号富-利他贡献:ReferralTree 直接邀请人数折算(1人=20分,每多1人+15分,最高100) */
  507. private int calcParentWealthAltruism(User parent) {
  508. Long count = referralTreeMapper.selectCount(
  509. new LambdaQueryWrapper<ReferralTree>().eq(ReferralTree::getParentId, parent.getId()));
  510. if (count == null || count == 0) return 0;
  511. return clamp(20 + (int) ((count - 1) * 15), 0, 100);
  512. }
  513. /**
  514. * 家庭主账号富-财务积累:个人 CF 值累计获得(cf_balance.total_earned)折算,5000CF=100分。
  515. *
  516. * <p>F-7 口径修复:CF 值已统一为个人维度账本(cf_balance,成员维度 family_members.id),
  517. * 此处不再读家庭池 family_platform_balance,避免与 C 端展示口径不一致。</p>
  518. */
  519. private int calcParentWealthIncome(User parent) {
  520. if (parent == null || parent.getId() == null) return 0;
  521. Long memberId = resolveMemberId(parent.getId());
  522. if (memberId == null) return 0;
  523. CfBalanceView balance = cfBalanceService.getBalance(memberId);
  524. if (balance == null || balance.getTotalEarned() == null) return 0;
  525. return clamp(balance.getTotalEarned() / 50, 0, 100);
  526. }
  527. /**
  528. * 登录用户 → 家庭成员(family_members.id)。
  529. *
  530. * <p>口径与 {@code CfEarnDispatchService}(F-3a)/ {@code CfBalanceController}(F-7a)一致:
  531. * user_id 匹配 + 按 id 升序取第一条(LIMIT 1),未命中返回 null。</p>
  532. */
  533. private Long resolveMemberId(Long userId) {
  534. if (userId == null || userId <= 0) {
  535. return null;
  536. }
  537. FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
  538. .eq(FamilyMember::getUserId, userId)
  539. .orderByAsc(FamilyMember::getId)
  540. .last("LIMIT 1"));
  541. return member == null ? null : member.getId();
  542. }
  543. /** 家庭主账号富-财务素养:记账/理财/储蓄类任务完成率 */
  544. private int calcParentWealthFx(User parent) {
  545. return calcTaskCompletionRate(parent.getId(), "parent",
  546. Arrays.asList("记账", "理财", "财务", "储蓄", "投资"), 365);
  547. }
  548. // ==================== 家庭成员能量计算 ====================
  549. private MemberEnergyDTO calcChildEnergy(FamilyMember child) {
  550. MemberEnergyDTO dto = new MemberEnergyDTO();
  551. dto.setMemberId(child.getId());
  552. dto.setMemberType("child"); // 非登录用户(不可登录成员)
  553. dto.setName(child.getNickname());
  554. dto.setAvatar(null); // 家庭成员没有独立头像,用默认
  555. int bodyScore = calcChildBody(child);
  556. dto.setBodyScore(bodyScore);
  557. dto.setMindScore(calcChildMind(child));
  558. dto.setWisdomScore(calcChildWisdom(child));
  559. dto.setActionScore(calcChildAction(child));
  560. dto.setWealthScore(calcChildWealth(child, dto));
  561. // 心/行/智/富 子维度(家庭成员:心13维从DAN报告取,行6维/富传承留待自检问卷)
  562. setMindSubDimensionsForChild(dto, child);
  563. setActionSubDimensionsForChild(dto);
  564. setWisdomSubDimensionsForChild(dto, child);
  565. setWealthSubDimensionsForChild(dto);
  566. // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
  567. applyAllRestraints(dto);
  568. applyAllBoosts(dto);
  569. int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
  570. + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
  571. + safeScore(dto.getWealthScore())) / 5;
  572. dto.setOverallScore(overall);
  573. return dto;
  574. }
  575. /**
  576. * 家庭成员 身 — 优先使用健康检测报告得分
  577. * 数据源优先级:健康报告overallScore > 子项加权 > 运动/户外类任务完成率(降级)
  578. */
  579. private int calcChildBody(FamilyMember child) {
  580. // 1. 优先使用健康报告中的身体评分
  581. Integer healthScore = healthReportService.calculateBodyScore(child.getUserId());
  582. if (healthScore != null) {
  583. return healthScore;
  584. }
  585. // 2. 7维健康维度评分加权(T4: 7-Dim→Body Energy)
  586. int sevenDimScore = calcSevenDimBodyScore(child.getId());
  587. if (sevenDimScore > 0) {
  588. return sevenDimScore;
  589. }
  590. // 3. 降级:运动/户外类任务完成率
  591. return calcTaskCompletionRate(child.getId(), "child",
  592. Arrays.asList("运动", "体育", "户外", "健康"), 365);
  593. }
  594. /**
  595. * 7维健康维度评分加权 → body 能量 (T4/T12)
  596. * 从 health_dimension_scores 表读取成员最新的所有维度评分,
  597. * 按设计权重加权计算 0-100 分的综合 body 能量。
  598. * 各维度权重参考 specs/2026-06-26-health-dimensions-design-spec.md:
  599. * 肠胃(20%) 营养(18%) 运动(15%) 睡眠(15%) 生长(12%) 免疫(10%) 视力(10%)
  600. */
  601. private int calcSevenDimBodyScore(Long memberId) {
  602. try {
  603. List<HealthDimensionScore> scores = healthDimensionScoreService.getLatestScores(memberId);
  604. if (scores == null || scores.isEmpty()) {
  605. return 0;
  606. }
  607. double weightedSum = 0;
  608. double totalWeight = 0;
  609. java.util.Map<String, Double> weights = new java.util.HashMap<>();
  610. weights.put("gut", 0.20);
  611. weights.put("nutrition", 0.18);
  612. weights.put("exercise", 0.15);
  613. weights.put("sleep", 0.15);
  614. weights.put("growth", 0.12);
  615. weights.put("immunity", 0.10);
  616. weights.put("vision", 0.10);
  617. for (HealthDimensionScore s : scores) {
  618. Double w = weights.get(s.getDimension());
  619. if (w != null && s.getScore() != null) {
  620. weightedSum += s.getScore() * w;
  621. totalWeight += w;
  622. }
  623. }
  624. if (totalWeight == 0) {
  625. return 0;
  626. }
  627. return (int) Math.round(weightedSum / totalWeight);
  628. } catch (Exception e) {
  629. log.warn("7维健康维度评分加权失败, memberId={}: {}", memberId, e.getMessage());
  630. return 0;
  631. }
  632. }
  633. private int calcSevenDimBodyScoreFromScores(List<HealthDimensionScore> scores) {
  634. if (scores == null || scores.isEmpty()) return 0;
  635. java.util.Map<String, Double> weights = new java.util.HashMap<>();
  636. weights.put("gut", 0.20);
  637. weights.put("nutrition", 0.18);
  638. weights.put("exercise", 0.15);
  639. weights.put("sleep", 0.15);
  640. weights.put("growth", 0.12);
  641. weights.put("immunity", 0.10);
  642. weights.put("vision", 0.10);
  643. double weightedSum = 0;
  644. double totalWeight = 0;
  645. for (HealthDimensionScore s : scores) {
  646. Double w = weights.get(s.getDimension());
  647. if (w != null && s.getScore() != null) {
  648. weightedSum += s.getScore() * w;
  649. totalWeight += w;
  650. }
  651. }
  652. if (totalWeight == 0) return 0;
  653. return (int) Math.round(weightedSum / totalWeight);
  654. }
  655. /** 家庭成员 心 — DAN EMI 70% + 先天 30% + 行为修正 */
  656. private int calcChildMind(FamilyMember child) {
  657. Long memberId = child.getId();
  658. Long userId = child.getUserId();
  659. // 1. DAN EMI score
  660. DanAssessmentResult dan = findLatestDanResult(memberId);
  661. double danScore = -1;
  662. if (dan != null) {
  663. Integer ems = dan.getEmotionManagementScore();
  664. Integer es = dan.getEmpathyScore();
  665. Integer sas = dan.getSocialAdaptabilityScore();
  666. Integer sms = dan.getSelfMotivationScore();
  667. if (ems != null && es != null && sas != null && sms != null) {
  668. danScore = (ems + es + sas + sms) / 4.0;
  669. }
  670. }
  671. // 2. Innate score (先天)
  672. double innateScore = calcInnateScore(userId, "mind");
  673. // 3. Behavior modifier
  674. double modifier = calcBehaviorModifier(memberId, "mind");
  675. // 4. Model: has DAN
  676. if (danScore >= 0) {
  677. double base = danScore * 0.70;
  678. if (innateScore >= 0) {
  679. base += innateScore * 0.30;
  680. }
  681. return clamp((int) Math.round(base * (1 + modifier)), 0, 100);
  682. }
  683. // 5. Fallback: innate only
  684. if (innateScore >= 0) {
  685. return clamp((int) Math.round(innateScore * (1 + modifier)), 0, 100);
  686. }
  687. // 6. Fallback: task completion rate (old logic)
  688. return calcTaskCompletionRate(memberId, "child",
  689. Arrays.asList("学习", "阅读", "亲子"), 365);
  690. }
  691. /** 家庭成员 智 — DAN COG 70% + 先天 30% + 行为修正 */
  692. private int calcChildWisdom(FamilyMember child) {
  693. Long memberId = child.getId();
  694. Long userId = child.getUserId();
  695. // 1. DAN COG score - weighted average of 6 cognitive dimensions
  696. DanAssessmentResult dan = findLatestDanResult(memberId);
  697. double danScore = -1;
  698. if (dan != null) {
  699. Integer as = dan.getAttentionScore();
  700. Integer fs = dan.getFocusScore();
  701. Integer ms = dan.getMemoryScore();
  702. Integer ls = dan.getLogicScore();
  703. Integer ps = dan.getPerceptionScore();
  704. Integer ss = dan.getSpatialScore();
  705. if (as != null && fs != null && ms != null && ls != null && ps != null && ss != null) {
  706. danScore = as * 0.15 + fs * 0.15 + ms * 0.20 + ls * 0.20 + ps * 0.15 + ss * 0.15;
  707. }
  708. }
  709. // 2. Innate score
  710. double innateScore = calcInnateScore(userId, "wisdom");
  711. // 3. Behavior modifier
  712. double modifier = calcBehaviorModifier(memberId, "wisdom");
  713. // 4. Model: has DAN
  714. if (danScore >= 0) {
  715. double base = danScore * 0.70;
  716. if (innateScore >= 0) {
  717. base += innateScore * 0.30;
  718. }
  719. return clamp((int) Math.round(base * (1 + modifier)), 0, 100);
  720. }
  721. // 5. Fallback: innate only
  722. if (innateScore >= 0) {
  723. return clamp((int) Math.round(innateScore * (1 + modifier)), 0, 100);
  724. }
  725. // 6. Fallback: old multi-source weighted calculation
  726. return calcOldChildWisdom(child);
  727. }
  728. /** Find the latest completed DAN assessment for a child */
  729. private DanAssessmentResult findLatestDanResult(Long memberId) {
  730. LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
  731. .eq(DanAssessmentResult::getMemberId, memberId)
  732. .eq(DanAssessmentResult::getStatus, "completed")
  733. .orderByDesc(DanAssessmentResult::getAssessmentDate)
  734. .last("LIMIT 1");
  735. SortUtil.applySort(qw);
  736. return danAssessmentResultMapper.selectOne(qw);
  737. }
  738. /** Calculate innate attribute base score for a dimension */
  739. private double calcInnateScore(Long userId, String dimensionCode) {
  740. FamilyMemberAttributes attrs = familyMemberAttributesMapper.selectOne(
  741. new LambdaQueryWrapper<FamilyMemberAttributes>()
  742. .eq(FamilyMemberAttributes::getMemberId, userId)
  743. .eq(FamilyMemberAttributes::getMemberType, "parent")
  744. );
  745. if (attrs == null) return -1;
  746. if ("mind".equals(dimensionCode) && attrs.getMindBaseScore() != null) {
  747. return attrs.getMindBaseScore().doubleValue();
  748. }
  749. if ("wisdom".equals(dimensionCode) && attrs.getWisdomBaseScore() != null) {
  750. return attrs.getWisdomBaseScore().doubleValue();
  751. }
  752. return -1;
  753. }
  754. /** Calculate behavior modifier based on recent activity */
  755. private double calcBehaviorModifier(Long memberId, String dimensionCode) {
  756. double modifier = 0;
  757. // Task completion rate (±5%)
  758. int taskRate = calcTaskCompletionRate(memberId, "child",
  759. Arrays.asList("学习", "阅读", "亲子", "好习惯", "运动"), 90);
  760. modifier += (taskRate - 50) / 100.0 * 0.05;
  761. // Game performance (±5%)
  762. LambdaQueryWrapper<GameRecord> gw = new LambdaQueryWrapper<GameRecord>()
  763. .eq(GameRecord::getMemberId, memberId);
  764. SortUtil.applySort(gw);
  765. List<GameRecord> games = gameRecordMapper.selectList(gw);
  766. if (!games.isEmpty()) {
  767. double avgScore = games.stream()
  768. .filter(g -> g.getScore() != null)
  769. .mapToInt(GameRecord::getScore)
  770. .average()
  771. .orElse(50);
  772. modifier += (avgScore - 50) / 100.0 * 0.05;
  773. }
  774. // Clamp to ±0.20
  775. return Math.max(-0.20, Math.min(0.20, modifier));
  776. }
  777. /** Old wisdom calculation as fallback (游戏 + 测评overallScore + 阅读时长) */
  778. private int calcOldChildWisdom(FamilyMember child) {
  779. Long memberId = child.getId();
  780. double totalWeight = 0;
  781. double weightedSum = 0;
  782. // 1. 游戏得分 (0-100, 平均分)
  783. LambdaQueryWrapper<GameRecord> gw = new LambdaQueryWrapper<GameRecord>()
  784. .eq(GameRecord::getMemberId, memberId);
  785. SortUtil.applySort(gw);
  786. List<GameRecord> games = gameRecordMapper.selectList(gw);
  787. if (!games.isEmpty()) {
  788. double avgScore = games.stream()
  789. .filter(g -> g.getScore() != null)
  790. .mapToInt(GameRecord::getScore)
  791. .average()
  792. .orElse(0);
  793. weightedSum += Math.min(avgScore, 100) * 0.34;
  794. totalWeight += 0.34;
  795. }
  796. // 2. 最近一次完成的测评得分
  797. DanAssessmentResult result = findLatestDanResult(memberId);
  798. if (result != null && result.getOverallScore() != null) {
  799. weightedSum += result.getOverallScore().doubleValue() * 0.33;
  800. totalWeight += 0.33;
  801. }
  802. // 3. 近30天阅读时长 (每3600秒=1小时=满分)
  803. Calendar cal = Calendar.getInstance();
  804. cal.add(Calendar.DAY_OF_MONTH, -30);
  805. Date thirtyDaysAgo = cal.getTime();
  806. Integer totalSeconds = articleReadingRecordMapper
  807. .selectTotalDurationByChildSince(memberId, thirtyDaysAgo);
  808. if (totalSeconds != null && totalSeconds > 0) {
  809. double readingScore = Math.min((double) totalSeconds / 3600 * 100, 100);
  810. weightedSum += readingScore * 0.33;
  811. totalWeight += 0.33;
  812. }
  813. if (totalWeight == 0) return 0;
  814. int score = (int) Math.round(weightedSum / totalWeight);
  815. return Math.min(Math.max(score, 0), 100);
  816. }
  817. private int clamp(int value, int min, int max) {
  818. return Math.max(min, Math.min(max, value));
  819. }
  820. /** 家庭成员 行 — 打卡天数 + 习惯类任务完成率 */
  821. private int calcChildAction(FamilyMember child) {
  822. Long memberId = child.getId();
  823. // 1. 连续打卡天数 (0-100)
  824. int streakDays = child.getStreakDays() != null ? child.getStreakDays() : 0;
  825. // 获取最高里程碑天数
  826. LambdaQueryWrapper<StreakMilestone> mw = new LambdaQueryWrapper<StreakMilestone>()
  827. .orderByDesc(StreakMilestone::getDays)
  828. .last("LIMIT 1");
  829. SortUtil.applySort(mw);
  830. List<StreakMilestone> milestones = streakMilestoneMapper.selectList(mw);
  831. int maxMilestone = 30;
  832. if (!milestones.isEmpty() && milestones.get(0).getDays() != null) {
  833. maxMilestone = milestones.get(0).getDays();
  834. }
  835. int dynamicRef = Math.max(streakDays, maxMilestone);
  836. int streakScore = Math.min(streakDays * 100 / Math.max(dynamicRef, 1), 100);
  837. // 2. 习惯类任务完成率
  838. int habitScore = calcTaskCompletionRate(memberId, "child",
  839. Arrays.asList("好习惯", "习惯", "自理", "家务"), 365);
  840. // 加权:打卡40% + 习惯任务60%
  841. return Math.min((int) Math.round(streakScore * 0.4 + habitScore * 0.6), 100);
  842. }
  843. /** 家庭成员 富 — 利他创富四子维度加权:学业能力25% + 利他贡献30% + 积分积累35% + 财务素养10% */
  844. private int calcChildWealth(FamilyMember child, MemberEnergyDTO dto) {
  845. int edu = calcChildWealthEducation(child);
  846. int altruism = calcChildWealthAltruism(child);
  847. int points = calcChildWealthPoints(child);
  848. int fx = calcChildWealthFx(child);
  849. dto.setWealthSkill(edu);
  850. dto.setWealthAltruism(altruism);
  851. dto.setWealthIncome(points);
  852. dto.setWealthFx(fx);
  853. // 旧字段兼容(新前端改为读 wealthSkill/wealthAltruism/wealthIncome/wealthFx)
  854. dto.setWealthEducation(edu);
  855. dto.setWealthSocial(altruism);
  856. dto.setWealthPoints(points);
  857. return (int) Math.round(edu * 0.25 + altruism * 0.3 + points * 0.35 + fx * 0.1);
  858. }
  859. /** 家庭成员富-学业能力:DAN测评最近3次综合分+进步趋势 */
  860. private int calcChildWealthEducation(FamilyMember child) {
  861. Long memberId = child.getId();
  862. LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
  863. .eq(DanAssessmentResult::getMemberId, memberId)
  864. .eq(DanAssessmentResult::getStatus, "completed")
  865. .orderByDesc(DanAssessmentResult::getAssessmentDate)
  866. .last("LIMIT 3");
  867. SortUtil.applySort(qw);
  868. List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(qw);
  869. if (results.isEmpty()) return 0;
  870. double avg = results.stream()
  871. .filter(r -> r.getOverallScore() != null)
  872. .mapToInt(DanAssessmentResult::getOverallScore)
  873. .average()
  874. .orElse(0);
  875. // 进步趋势:最近一次 vs 前面平均
  876. if (results.size() >= 2 && results.get(0).getOverallScore() != null && results.get(1).getOverallScore() != null) {
  877. int latest = results.get(0).getOverallScore();
  878. double prevAvg = results.subList(1, results.size()).stream()
  879. .filter(r -> r.getOverallScore() != null)
  880. .mapToInt(DanAssessmentResult::getOverallScore)
  881. .average()
  882. .orElse(latest);
  883. double trend = (latest - prevAvg) / Math.max(prevAvg, 1) * 10;
  884. avg += trend;
  885. }
  886. return clamp((int) Math.round(avg), 0, 100);
  887. }
  888. /** 家庭成员富-利他贡献:帮助他人/分享/公益/家务类任务完成率 */
  889. private int calcChildWealthAltruism(FamilyMember child) {
  890. return calcTaskCompletionRate(child.getId(), "child",
  891. Arrays.asList("帮助", "分享", "公益", "家务", "合作"), 365);
  892. }
  893. /** 家庭成员富-财务素养:财商/记账/理财/储蓄类任务完成率 */
  894. private int calcChildWealthFx(FamilyMember child) {
  895. return calcTaskCompletionRate(child.getId(), "child",
  896. Arrays.asList("财商", "记账", "理财", "储蓄", "投资"), 365);
  897. }
  898. /** 家庭成员富-规则博弈:积分获取效率 */
  899. private int calcChildWealthPoints(FamilyMember child) {
  900. Long memberId = child.getId();
  901. Calendar cal = Calendar.getInstance();
  902. cal.add(Calendar.YEAR, -1);
  903. Date oneYearAgo = cal.getTime();
  904. LambdaQueryWrapper<PointsLog> earnedWrapper = new LambdaQueryWrapper<PointsLog>()
  905. .eq(PointsLog::getMemberId, memberId)
  906. .gt(PointsLog::getAmount, 0)
  907. .gt(PointsLog::getCreatedAt, oneYearAgo);
  908. SortUtil.applySort(earnedWrapper);
  909. List<PointsLog> earnedLogs = pointsLogMapper.selectList(earnedWrapper);
  910. int earned = earnedLogs.stream()
  911. .mapToInt(pl -> pl.getAmount() != null ? pl.getAmount() : 0)
  912. .sum();
  913. LambdaQueryWrapper<Task> taskWrapper = new LambdaQueryWrapper<Task>()
  914. .eq(Task::getMemberId, memberId)
  915. .ne(Task::getIsTemplate, 1)
  916. .ne(Task::getStatus, "cancelled")
  917. .gt(Task::getCreatedAt, oneYearAgo);
  918. SortUtil.applySort(taskWrapper);
  919. List<Task> tasks = taskMapper.selectList(taskWrapper);
  920. int available = tasks.stream()
  921. .mapToInt(t -> t.getPoints() != null ? t.getPoints() : 0)
  922. .sum();
  923. int score = (int) Math.min((long) earned * 100 / Math.max(available, 1), 100);
  924. return Math.max(score, 0);
  925. }
  926. /**
  927. * 身克富杠杆:身体底盘制约财富能量(ke-full-design §克② 重写版)
  928. * - body=0 && wealth=0 → normal(无数据边界保护)
  929. * - 身<50 且 富>身+20 → overdraw(透支预警,不下调)
  930. * - 身<40 → penalty(wealth *= 0.5 + 0.5×身/100,文案"健康正在稀释财富能量")
  931. * - 否则 → normal(身≥70 且 富≥60:双优;身≥70 且 富<40:创富时机;其他:空)
  932. */
  933. private int applyBodyWealthRestraint(Integer bodyScore, Integer wealthScore, MemberEnergyDTO dto) {
  934. int body = safeScore(bodyScore);
  935. int wealth = safeScore(wealthScore);
  936. if (body == 0 && wealth == 0) {
  937. dto.setBodyWealthStatus("normal");
  938. dto.setBodyWealthMessage("");
  939. return wealth;
  940. }
  941. if (body < 50 && wealth > body + 20) {
  942. dto.setBodyWealthStatus("overdraw");
  943. dto.setBodyWealthMessage("财富能量超出身体承受范围,注意劳逸结合");
  944. return wealth;
  945. }
  946. if (body < 40) {
  947. double factor = 0.5 + 0.5 * body / 100.0;
  948. int adjusted = (int) Math.round(wealth * factor);
  949. dto.setBodyWealthStatus("penalty");
  950. dto.setBodyWealthMessage("健康正在稀释财富能量,建议优先关注身体健康");
  951. return Math.max(adjusted, 0);
  952. }
  953. dto.setBodyWealthStatus("normal");
  954. if (body >= 70 && wealth >= 60) {
  955. dto.setBodyWealthMessage("身体是财富的底盘,您处于双优状态");
  956. } else if (body >= 70 && wealth < 40) {
  957. dto.setBodyWealthMessage("您的健康基础很好,是启动创富的好时机");
  958. } else {
  959. dto.setBodyWealthMessage("");
  960. }
  961. return wealth;
  962. }
  963. // ==================== 五行相克(v2.0 克环其余4个 + 统一入口) ====================
  964. /**
  965. * 行克身(木克土)—— 行动社交对身体健康的制约
  966. * - 行>75 且 身<40 → overdraw(身 *= 0.5 + 0.5×身/100,透支)
  967. * - 行>60 且 身>60 → balanced(不下调,身心兼修)
  968. * - 身>70 且 行<30 → cautious(不下调,缺少行动提示)
  969. * - 否则 → normal
  970. */
  971. private int applyActionBodyRestraint(Integer actionScore, Integer bodyScore, MemberEnergyDTO dto) {
  972. int action = safeScore(actionScore);
  973. int body = safeScore(bodyScore);
  974. if (action > 75 && body < 40) {
  975. double penalty = 0.5 + 0.5 * (body / 100.0);
  976. int adjusted = (int) Math.round(body * penalty);
  977. dto.setActionBodyStatus("overdraw");
  978. dto.setActionBodyMessage("行动力很强,但身体在报警。适度暂停,是另一种前进");
  979. return Math.min(adjusted, body);
  980. }
  981. if (action > 60 && body > 60) {
  982. dto.setActionBodyStatus("balanced");
  983. dto.setActionBodyMessage("身心兼修,动态平衡");
  984. return body;
  985. }
  986. if (body > 70 && action < 30) {
  987. dto.setActionBodyStatus("cautious");
  988. dto.setActionBodyMessage("身体底子好,但缺少行动。出门走走,也是养生的一部分");
  989. return body;
  990. }
  991. dto.setActionBodyStatus("normal");
  992. dto.setActionBodyMessage("");
  993. return body;
  994. }
  995. /**
  996. * 富克心(水克火)—— 财富对心(情绪/包容/传承)三层面的侵蚀
  997. * 检测优先级:情绪<40→eruption / 包容<40→judgmental / 传承<20→materialized / 富>70心>65→nourished / normal
  998. */
  999. private int applyWealthHeartRestraint(Integer wealthScore, Integer heartScore, MemberEnergyDTO dto) {
  1000. int wealth = safeScore(wealthScore);
  1001. int heart = safeScore(heartScore);
  1002. int emotionStable = dto.getHeartEmotionStable() != null ? dto.getHeartEmotionStable() : 50;
  1003. int understanding = dto.getHeartUnderstanding() != null ? dto.getHeartUnderstanding() : 50;
  1004. int legacy = dto.getHeartLegacy() != null ? dto.getHeartLegacy() : 50;
  1005. if (wealth > 70 && emotionStable < 40) {
  1006. double penalty = 0.4 + 0.6 * (emotionStable / 100.0);
  1007. int adjusted = (int) Math.round(heart * penalty);
  1008. dto.setWealthHeartStatus("eruption");
  1009. dto.setWealthHeartMessage("财富给了底气,也偷走了对家人的耐心。先稳住情绪,再谈其他");
  1010. return Math.min(adjusted, heart);
  1011. }
  1012. if (wealth > 60 && understanding < 40) {
  1013. dto.setWealthHeartStatus("judgmental");
  1014. dto.setWealthHeartMessage("站在高处久了,可能忘了普通人走路的难处");
  1015. return heart;
  1016. }
  1017. if (wealth > 60 && legacy < 20) {
  1018. dto.setWealthHeartStatus("materialized");
  1019. dto.setWealthHeartMessage("留给下一代的只有存款,价值观也需要传下去");
  1020. return heart;
  1021. }
  1022. if (wealth > 70 && heart > 65) {
  1023. dto.setWealthHeartStatus("nourished");
  1024. dto.setWealthHeartMessage("您的财富正在滋养家庭情感,这是最好的传承");
  1025. return heart;
  1026. }
  1027. dto.setWealthHeartStatus("normal");
  1028. dto.setWealthHeartMessage("");
  1029. return heart;
  1030. }
  1031. /**
  1032. * 心克智(火克金)—— 情感关爱对理性培养的制约
  1033. * - 心>70 且 智<40 → overprotect(智 *= 0.5+0.5×智/100,过度保护)
  1034. * - 智>70 且 心<40 → cold_wise(不下调,理性冷淡提示)
  1035. * - 心>60 且 智>60 → balanced(不下调)
  1036. * - 否则 → normal
  1037. */
  1038. private int applyMindWisdomRestraint(Integer mindScore, Integer wisdomScore, MemberEnergyDTO dto) {
  1039. int mind = safeScore(mindScore);
  1040. int wisdom = safeScore(wisdomScore);
  1041. if (mind > 70 && wisdom < 40) {
  1042. double penalty = 0.5 + 0.5 * (wisdom / 100.0);
  1043. int adjusted = (int) Math.round(wisdom * penalty);
  1044. dto.setMindWisdomStatus("overprotect");
  1045. dto.setMindWisdomMessage("放手让家人试错,是培养智慧的第一步");
  1046. return Math.min(adjusted, wisdom);
  1047. }
  1048. if (wisdom > 70 && mind < 40) {
  1049. dto.setMindWisdomStatus("cold_wise");
  1050. dto.setMindWisdomMessage("智慧需要温度,不然长大了只会做事不会做人");
  1051. return wisdom;
  1052. }
  1053. if (mind > 60 && wisdom > 60) {
  1054. dto.setMindWisdomStatus("balanced");
  1055. dto.setMindWisdomMessage("爱与理性并行,是最好的教育");
  1056. return wisdom;
  1057. }
  1058. dto.setMindWisdomStatus("normal");
  1059. dto.setMindWisdomMessage("");
  1060. return wisdom;
  1061. }
  1062. /**
  1063. * 智克行(金克木)—— 认知分析对人际关系的制约
  1064. * - 智<30 且 行>50 → blind(行 *= 0.6+0.4×智/100,盲目社交下调)
  1065. * - 智>80 且 行<40 → paralysis(不下调,认知孤独预警)
  1066. * - 智>60 且 行>60 → balanced(不下调)
  1067. * - 否则 → normal
  1068. */
  1069. private int applyWisdomActionRestraint(Integer wisdomScore, Integer actionScore, MemberEnergyDTO dto) {
  1070. int wisdom = safeScore(wisdomScore);
  1071. int action = safeScore(actionScore);
  1072. if (wisdom < 30 && action > 50) {
  1073. double penalty = 0.6 + 0.4 * (wisdom / 100.0);
  1074. int adjusted = (int) Math.round(action * penalty);
  1075. dto.setWisdomActionStatus("blind");
  1076. dto.setWisdomActionMessage("行动力很强,但有方向会让努力更有效。建议先做一次测评");
  1077. return Math.min(adjusted, action);
  1078. }
  1079. if (wisdom > 80 && action < 40) {
  1080. dto.setWisdomActionStatus("paralysis");
  1081. dto.setWisdomActionMessage("聪明不等于会相处。朋友不是用逻辑交来的");
  1082. return action;
  1083. }
  1084. if (wisdom >= 60 && action >= 60) {
  1085. dto.setWisdomActionStatus("balanced");
  1086. dto.setWisdomActionMessage("知行合一状态,继续保持");
  1087. return action;
  1088. }
  1089. dto.setWisdomActionStatus("normal");
  1090. if (action >= 60 && wisdom < 50) {
  1091. dto.setWisdomActionMessage("行动力很好,建议增加测评了解方向");
  1092. } else {
  1093. dto.setWisdomActionMessage("");
  1094. }
  1095. return action;
  1096. }
  1097. /**
  1098. * 五行相克统一入口 — 按克环顺序:行克身→身克富→富克心→心克智→智克行。
  1099. * 关键:每个克方法使用前一个克方法调整后的分值(链式调用,避免迭代收敛问题)。
  1100. */
  1101. private void applyAllRestraints(MemberEnergyDTO dto) {
  1102. int bodyAdj = applyActionBodyRestraint(dto.getActionScore(), dto.getBodyScore(), dto);
  1103. dto.setBodyScore(bodyAdj);
  1104. int wealthAdj = applyBodyWealthRestraint(dto.getBodyScore(), dto.getWealthScore(), dto);
  1105. dto.setWealthScore(wealthAdj);
  1106. int mindAdj = applyWealthHeartRestraint(dto.getWealthScore(), dto.getMindScore(), dto);
  1107. dto.setMindScore(mindAdj);
  1108. int wisdomAdj = applyMindWisdomRestraint(dto.getMindScore(), dto.getWisdomScore(), dto);
  1109. dto.setWisdomScore(wisdomAdj);
  1110. int actionAdj = applyWisdomActionRestraint(dto.getWisdomScore(), dto.getActionScore(), dto);
  1111. dto.setActionScore(actionAdj);
  1112. }
  1113. // ==================== 五行相生(v2.0 生环5个 + 统一入口) ====================
  1114. /**
  1115. * 生增益统一公式:boost = (upstream-50)/100×10,仅当 upstream>60 且 downstream<80 触发,
  1116. * 上限 80-downstream(留成长空间)。
  1117. */
  1118. private int applyBoost(Integer upstreamScore, Integer downstreamScore) {
  1119. int up = safeScore(upstreamScore);
  1120. int down = safeScore(downstreamScore);
  1121. if (up > 60 && down < 80) {
  1122. double boost = (up - 50) / 100.0 * 10;
  1123. int room = 80 - down;
  1124. int gain = (int) Math.round(boost);
  1125. if (gain > room) gain = room;
  1126. if (gain < 0) gain = 0;
  1127. return down + gain;
  1128. }
  1129. return down;
  1130. }
  1131. /** 心生身(火生土) */
  1132. private int applyMindBodyBoost(Integer mindScore, Integer bodyScore, MemberEnergyDTO dto) {
  1133. return applyBoost(mindScore, bodyScore);
  1134. }
  1135. /** 身生智(土生金) */
  1136. private int applyBodyWisdomBoost(Integer bodyScore, Integer wisdomScore, MemberEnergyDTO dto) {
  1137. return applyBoost(bodyScore, wisdomScore);
  1138. }
  1139. /** 智生富(金生水) */
  1140. private int applyWisdomWealthBoost(Integer wisdomScore, Integer wealthScore, MemberEnergyDTO dto) {
  1141. return applyBoost(wisdomScore, wealthScore);
  1142. }
  1143. /** 富生行(水生木) */
  1144. private int applyWealthActionBoost(Integer wealthScore, Integer actionScore, MemberEnergyDTO dto) {
  1145. return applyBoost(wealthScore, actionScore);
  1146. }
  1147. /** 行生心(木生火) */
  1148. private int applyActionMindBoost(Integer actionScore, Integer mindScore, MemberEnergyDTO dto) {
  1149. return applyBoost(actionScore, mindScore);
  1150. }
  1151. /**
  1152. * 五行相生统一入口 — 按生环顺序:心生身→身生智→智生富→富生行→行生心。
  1153. * 链式调用,每个生方法使用前一个的输出作为下游输入。
  1154. */
  1155. private void applyAllBoosts(MemberEnergyDTO dto) {
  1156. dto.setBodyScore(applyMindBodyBoost(dto.getMindScore(), dto.getBodyScore(), dto));
  1157. dto.setWisdomScore(applyBodyWisdomBoost(dto.getBodyScore(), dto.getWisdomScore(), dto));
  1158. dto.setWealthScore(applyWisdomWealthBoost(dto.getWisdomScore(), dto.getWealthScore(), dto));
  1159. dto.setActionScore(applyWealthActionBoost(dto.getWealthScore(), dto.getActionScore(), dto));
  1160. dto.setMindScore(applyActionMindBoost(dto.getActionScore(), dto.getMindScore(), dto));
  1161. }
  1162. // ==================== 心的子维度(v2.0 心三层拆分) ====================
  1163. /** 心·情绪稳定 — 家庭成员:DAN EMI 三子分(emotionManagement+resilience+stressCoping)均值。
  1164. * 文档原 emotionCheckinService 在本项目不存在,用此现有 DAN 字段做代理实现。 */
  1165. private int calcHeartEmotionStableForChild(Long memberId, DanAssessmentResult dan) {
  1166. if (dan == null) return 50;
  1167. Integer ems = dan.getEmotionManagementScore();
  1168. Integer resilience = dan.getResilienceScore();
  1169. Integer stress = dan.getStressCopingScore();
  1170. if (ems == null || resilience == null || stress == null) return 50;
  1171. return clamp((ems + resilience + stress) / 3, 0, 100);
  1172. }
  1173. /** 心·情绪稳定 — 家庭主账号:暂无数据,回0(ke-full-design 维度表显式标注) */
  1174. private int calcHeartEmotionStableForParent() {
  1175. return 0;
  1176. }
  1177. /** 心·理解包容 — 家庭成员:DAN (empathy+socialAdaptability)/2。
  1178. * 文档原家庭天盘 helper 不存在,按 doc §克③ line 295-296 用 100% DAN fallback。 */
  1179. private int calcHeartUnderstandingForChild(Long memberId, DanAssessmentResult dan) {
  1180. if (dan == null) return 50;
  1181. Integer empathy = dan.getEmpathyScore();
  1182. Integer social = dan.getSocialAdaptabilityScore();
  1183. if (empathy == null || social == null) return 50;
  1184. return clamp((empathy + social) / 2, 0, 100);
  1185. }
  1186. /** 心·理解包容 — 家庭主账号:基于连续打卡天数折算(每30天≈50分,最高100) */
  1187. private int calcHeartUnderstandingForParent() {
  1188. return 50;
  1189. }
  1190. /** 心·传承传递 — 基于家庭主账号连续打卡天数(每60天≈50分,最高100) */
  1191. private int calcHeartLegacy() {
  1192. return 50;
  1193. }
  1194. // ==================== 新五维子维度设置方法 ====================
  1195. /** 心13维 — 家庭主账号版:暂无DAN数据,全部回0,旧字段兼容回填 */
  1196. private void setMindSubDimensionsForParent(MemberEnergyDTO dto) {
  1197. dto.setBigFiveOpenness(0);
  1198. dto.setBigFiveConscientiousness(0);
  1199. dto.setBigFiveExtraversion(0);
  1200. dto.setBigFiveAgreeableness(0);
  1201. dto.setBigFiveNeuroticism(0);
  1202. dto.setEmotionEmotion(0);
  1203. dto.setEmotionResilience(0);
  1204. dto.setEmotionStressCoping(0);
  1205. dto.setEmotionSelfAwareness(0);
  1206. dto.setDriveAutonomy(0);
  1207. dto.setDriveCompetence(0);
  1208. dto.setDriveRelatedness(0);
  1209. dto.setSelfConceptOverall(0);
  1210. // 旧字段兼容
  1211. dto.setHeartEmotionStable(0);
  1212. dto.setHeartUnderstanding(0);
  1213. dto.setHeartLegacy(0);
  1214. }
  1215. /** 心13维 — 家庭成员版:从 DAN 报告取大五+EMI,从 structuredAnalysis 取自驱力+自我概念 */
  1216. private void setMindSubDimensionsForChild(MemberEnergyDTO dto, FamilyMember child) {
  1217. DanAssessmentResult dan = findLatestDanResult(child.getId());
  1218. if (dan != null) {
  1219. // 大五人格(5维)
  1220. dto.setBigFiveOpenness(safeScore(dan.getOpennessScore()));
  1221. dto.setBigFiveConscientiousness(safeScore(dan.getConscientiousnessScore()));
  1222. dto.setBigFiveExtraversion(safeScore(dan.getExtraversionScore()));
  1223. dto.setBigFiveAgreeableness(safeScore(dan.getAgreeablenessScore()));
  1224. dto.setBigFiveNeuroticism(safeScore(dan.getNeuroticismScore()));
  1225. // 情绪稳定(4维)
  1226. dto.setEmotionEmotion(safeScore(dan.getEmotionScore()));
  1227. dto.setEmotionResilience(safeScore(dan.getResilienceScore()));
  1228. dto.setEmotionStressCoping(safeScore(dan.getStressCopingScore()));
  1229. dto.setEmotionSelfAwareness(safeScore(dan.getSelfAwarenessScore()));
  1230. // 情绪稳定性 = 四个 EMI 维度均值
  1231. int emiSum = safeScore(dan.getEmotionScore()) + safeScore(dan.getResilienceScore())
  1232. + safeScore(dan.getStressCopingScore()) + safeScore(dan.getSelfAwarenessScore());
  1233. dto.setEmotionStability(emiSum / 4);
  1234. // 自驱力(3维)+ 自我概念(1维)— 从 structuredAnalysis JSON 嵌套 extra 解析
  1235. Map<String, Object> analysis = parseStructuredAnalysis(dan.getStructuredAnalysis());
  1236. if (analysis != null) {
  1237. parseSelfDrivingFromAnalysis(dto, analysis);
  1238. parseSelfConceptFromAnalysis(dto, analysis);
  1239. }
  1240. }
  1241. // 旧字段兼容
  1242. dto.setHeartEmotionStable(dto.getEmotionEmotion() != null ? dto.getEmotionEmotion() : 0);
  1243. dto.setHeartUnderstanding(dto.getBigFiveAgreeableness() != null ? dto.getBigFiveAgreeableness() : 0);
  1244. dto.setHeartLegacy(0);
  1245. }
  1246. /** 行6维 — 家庭主账号版:初期全部回0,后续由自检问卷填充 */
  1247. private void setActionSubDimensionsForParent(MemberEnergyDTO dto) {
  1248. dto.setActionParentChild(0);
  1249. dto.setActionMarital(0);
  1250. dto.setActionParental(0);
  1251. dto.setActionLeadership(0);
  1252. dto.setActionSociality(0);
  1253. dto.setActionInterpersonal(0);
  1254. }
  1255. /** 行6维 — 家庭成员版:初期全部回0,后续由自检问卷填充 */
  1256. private void setActionSubDimensionsForChild(MemberEnergyDTO dto) {
  1257. dto.setActionChildParentChild(0);
  1258. dto.setActionSibling(0);
  1259. dto.setActionRelative(0);
  1260. dto.setActionTeacherStudent(0);
  1261. dto.setActionChildSociality(0);
  1262. dto.setActionChildInterpersonal(0);
  1263. }
  1264. /** 智·成长型思维 — 家庭主账号版:暂无数据,回0 */
  1265. private void setWisdomSubDimensionsForParent(MemberEnergyDTO dto) {
  1266. dto.setWisdomGrowthMindset(0);
  1267. }
  1268. /** 智·成长型思维 — 家庭成员版:从 DAN structuredAnalysis extra.growthMindset 解析 */
  1269. private void setWisdomSubDimensionsForChild(MemberEnergyDTO dto, FamilyMember child) {
  1270. DanAssessmentResult dan = findLatestDanResult(child.getId());
  1271. if (dan != null) {
  1272. Map<String, Object> analysis = parseStructuredAnalysis(dan.getStructuredAnalysis());
  1273. if (analysis != null) {
  1274. Map<String, Object> extra = (Map<String, Object>) analysis.get("extra");
  1275. Map<String, Object> gm = (Map<String, Object>) getNestedMap(extra, "growthMindset");
  1276. if (gm != null) {
  1277. dto.setWisdomGrowthMindset(getIntFromNestedMap(gm, "成长型思维", "growthMindset"));
  1278. }
  1279. }
  1280. }
  1281. }
  1282. /** 富·家族传承 — 家庭主账号版:初期回0,后续由自检问卷填充 */
  1283. private void setWealthSubDimensionsForParent(MemberEnergyDTO dto) {
  1284. dto.setWealthLegacy(0);
  1285. }
  1286. /** 富·家族传承 — 家庭成员版:初期回0,后续由自检问卷填充 */
  1287. private void setWealthSubDimensionsForChild(MemberEnergyDTO dto) {
  1288. dto.setWealthChildLegacy(0);
  1289. }
  1290. /** 从 structuredAnalysis 解析自驱力3维(extra.selfDriving 嵌套 Map,中文 key) */
  1291. @SuppressWarnings("unchecked")
  1292. private void parseSelfDrivingFromAnalysis(MemberEnergyDTO dto, Map<String, Object> analysis) {
  1293. if (analysis == null) return;
  1294. Map<String, Object> extra = (Map<String, Object>) analysis.get("extra");
  1295. if (extra == null) return;
  1296. Map<String, Object> sd = (Map<String, Object>) extra.get("selfDriving");
  1297. if (sd == null) return;
  1298. dto.setDriveAutonomy(getIntFromNestedMap(sd, "自主性", "selfDriving_autonomy"));
  1299. dto.setDriveCompetence(getIntFromNestedMap(sd, "胜任感", "selfDriving_competence"));
  1300. dto.setDriveRelatedness(getIntFromNestedMap(sd, "归属感", "selfDriving_relatedness"));
  1301. }
  1302. /** 从 structuredAnalysis 解析综合自我概念(extra.selfConcept 各维均值) */
  1303. @SuppressWarnings("unchecked")
  1304. private void parseSelfConceptFromAnalysis(MemberEnergyDTO dto, Map<String, Object> analysis) {
  1305. if (analysis == null) return;
  1306. Map<String, Object> extra = (Map<String, Object>) analysis.get("extra");
  1307. if (extra == null) return;
  1308. Map<String, Object> sc = (Map<String, Object>) extra.get("selfConcept");
  1309. if (sc == null || sc.isEmpty()) return;
  1310. // 取所有值的均值作为综合自我概念分
  1311. int sum = 0, count = 0;
  1312. for (Object val : sc.values()) {
  1313. Integer v = parseNumber(val);
  1314. if (v != null) { sum += v; count++; }
  1315. }
  1316. dto.setSelfConceptOverall(count > 0 ? sum / count : 0);
  1317. }
  1318. /** 安全获取嵌套 map */
  1319. @SuppressWarnings("unchecked")
  1320. private Map<String, Object> getNestedMap(Map<String, Object> parent, String key) {
  1321. if (parent == null) return null;
  1322. Object val = parent.get(key);
  1323. return val instanceof Map ? (Map<String, Object>) val : null;
  1324. }
  1325. /** 从嵌套 map 中提取整数分值(优先中文 key,回退英文 key) */
  1326. private Integer getIntFromNestedMap(Map<String, Object> map, String chineseKey, String englishKey) {
  1327. if (map == null) return 0;
  1328. // 先试中文 key
  1329. Object val = map.get(chineseKey);
  1330. if (val == null) val = map.get(englishKey);
  1331. return parseNumber(val);
  1332. }
  1333. /** 将 Object 解析为整数 */
  1334. private Integer parseNumber(Object val) {
  1335. if (val == null) return null;
  1336. if (val instanceof Number) return ((Number) val).intValue();
  1337. if (val instanceof String) {
  1338. try { return Integer.parseInt(((String) val).trim()); } catch (NumberFormatException e) { return null; }
  1339. }
  1340. return null;
  1341. }
  1342. /**
  1343. * 解析 structuredAnalysis JSON(使用 fastjson)。
  1344. * 注意:structured_analysis 实际存储的是 DAN 解析 items 平铺数组
  1345. * [{code,name,value,category},...](见 DanReportUploadService#confirmUpload)。
  1346. * 本方法只消费嵌套对象形态(extra.selfDriving/selfConcept 等),
  1347. * 数组形态无这些数据,静默返回 null 由调用方降级(自驱力/自我概念回 0)。
  1348. */
  1349. @SuppressWarnings("unchecked")
  1350. private Map<String, Object> parseStructuredAnalysis(String json) {
  1351. if (json == null || json.isEmpty()) return null;
  1352. String trimmed = json.trim();
  1353. if (trimmed.startsWith("[")) {
  1354. // 平铺 items 数组(无 extra 嵌套):非异常,静默返回 null
  1355. return null;
  1356. }
  1357. try {
  1358. return JSON.parseObject(trimmed, Map.class);
  1359. } catch (Exception e) {
  1360. log.warn("解析 structuredAnalysis 失败: {}", e.getMessage());
  1361. return null;
  1362. }
  1363. }
  1364. /**
  1365. * 计算指定成员的任务完成率得分 (0-100)
  1366. * Optimized: single query for all categories instead of N queries per category
  1367. *
  1368. * @param memberId executorId (家庭主账号=userId, 家庭成员=memberId)
  1369. * @param memberType 'parent' 或 'child'
  1370. * @param categories Task.category 匹配列表
  1371. * @param daysWindow 统计窗口(天)
  1372. */
  1373. private int calcTaskCompletionRate(Long memberId, String memberType,
  1374. List<String> categories, int daysWindow) {
  1375. Calendar cal = Calendar.getInstance();
  1376. cal.add(Calendar.DAY_OF_YEAR, -daysWindow);
  1377. Date since = cal.getTime();
  1378. // Single query for ALL categories — eliminates N+1
  1379. LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
  1380. .eq(Task::getExecutorType, memberType)
  1381. .eq(Task::getExecutorId, memberId)
  1382. .in(Task::getCategory, categories)
  1383. .ne(Task::getIsTemplate, 1)
  1384. .ne(Task::getStatus, "cancelled")
  1385. .gt(Task::getCreatedAt, since);
  1386. SortUtil.applySort(wrapper);
  1387. List<Task> tasks = taskMapper.selectList(wrapper);
  1388. long total = tasks.size();
  1389. long completed = tasks.stream().filter(t -> "completed".equals(t.getStatus())).count();
  1390. if (total == 0) return 0;
  1391. return (int) Math.round((double) completed / total * 100);
  1392. }
  1393. private int safeScore(Integer score) {
  1394. return score != null ? score : 0;
  1395. }
  1396. private boolean hasParentRole(User user) {
  1397. if (user.getRole() != null && "parent".equals(user.getRole())) {
  1398. return true;
  1399. }
  1400. if (user.getRoles() == null || user.getRoles().isEmpty()) {
  1401. return false;
  1402. }
  1403. return user.getRoles().contains("parent");
  1404. }
  1405. private EnergySandboxDTO buildEmptyDTO() {
  1406. EnergySandboxDTO dto = new EnergySandboxDTO();
  1407. dto.setBodyScore(0);
  1408. dto.setMindScore(0);
  1409. dto.setWisdomScore(0);
  1410. dto.setActionScore(0);
  1411. dto.setWealthScore(0);
  1412. dto.setOverallScore(0);
  1413. dto.setMembers(Collections.emptyList());
  1414. return dto;
  1415. }
  1416. // ==================== 五维能量账本系统(新增) ====================
  1417. private static final Map<String, Integer> DAILY_LIMIT_MAP = new LinkedHashMap<>();
  1418. static {
  1419. DAILY_LIMIT_MAP.put("body", 50);
  1420. DAILY_LIMIT_MAP.put("mind", 40);
  1421. DAILY_LIMIT_MAP.put("wisdom", 80);
  1422. DAILY_LIMIT_MAP.put("action", 60);
  1423. DAILY_LIMIT_MAP.put("wealth", 50);
  1424. }
  1425. private static final int GLOBAL_DAILY_LIMIT = 200;
  1426. @Resource
  1427. private EnergyDimensionMapper energyDimensionMapper;
  1428. @Resource
  1429. private EnergySourceConfigMapper energySourceConfigMapper;
  1430. @Resource
  1431. private EnergyBehaviorConfigMapper energyBehaviorConfigMapper;
  1432. @Resource
  1433. private EnergyLogMapper energyLogMapper;
  1434. @Resource
  1435. private EnergyBalanceMapper energyBalanceMapper;
  1436. @Resource
  1437. private WishMapper wishMapper;
  1438. // 维度缓存(懒加载)
  1439. private List<EnergyDimension> dimCache;
  1440. private Map<String, EnergyDimension> dimCodeMap;
  1441. private Map<Long, EnergyDimension> dimIdMap;
  1442. /**
  1443. * 发放能量 — 按 energy_source_config 比例分配至各维度
  1444. *
  1445. * @param memberId 家庭成员ID
  1446. * @param sourceType 来源类型 (task/product/medical_report/etc)
  1447. * @param sourceId 来源ID
  1448. * @param totalAmount 总能量值
  1449. * @param description 描述
  1450. * @param daysToExpire 过期天数(null=不过期)
  1451. * @return Map<dimensionCode, amount>
  1452. */
  1453. @Transactional
  1454. public Map<String, Integer> awardEnergy(Long memberId, String sourceType, Long sourceId,
  1455. Integer totalAmount, String description,
  1456. Integer daysToExpire) {
  1457. if (totalAmount == null || totalAmount <= 0) return new HashMap<>();
  1458. // 0. 查 behavior_config:sourceType 映射到 behavior_code
  1459. EnergyBehaviorConfig behaviorConfig = matchBehaviorConfig(sourceType);
  1460. if (behaviorConfig != null) {
  1461. return awardByConfig(memberId, behaviorConfig, sourceType, sourceId, totalAmount, description, daysToExpire);
  1462. }
  1463. // 1. 无 behavior_config 时使用旧逻辑:查 energy_source_config → Fallback
  1464. List<EnergySourceConfig> configs = energySourceConfigMapper.selectList(
  1465. new LambdaQueryWrapper<EnergySourceConfig>()
  1466. .eq(EnergySourceConfig::getSourceType, sourceType)
  1467. .eq(EnergySourceConfig::getSourceId, sourceId)
  1468. );
  1469. Map<Long, Integer> dimRatios = resolveDimensionRatios(configs, sourceType, sourceId);
  1470. if (dimRatios.isEmpty()) return new HashMap<>();
  1471. // 2. 日上限检查
  1472. if (isDailyLimitExceeded(memberId, dimRatios.keySet(), totalAmount)) {
  1473. log.warn("日上限已达,跳过发放: memberId={}, amount={}", memberId, totalAmount);
  1474. return new HashMap<>();
  1475. }
  1476. // 3. 按比例分配
  1477. Map<Long, Integer> allocations = calculateAllocations(totalAmount, dimRatios);
  1478. // 4. 更新余额 + 写流水
  1479. return executeAward(memberId, sourceType, sourceId, totalAmount, description, daysToExpire, allocations);
  1480. }
  1481. /**
  1482. * 根据 behavior_config 发放能量
  1483. */
  1484. @Transactional
  1485. public Map<String, Integer> awardByConfig(Long memberId, EnergyBehaviorConfig config,
  1486. String sourceType, Long sourceId,
  1487. Integer totalAmount, String description,
  1488. Integer daysToExpire) {
  1489. if (!isEnabled(config)) return new HashMap<>();
  1490. // 解析维度分配JSON
  1491. Map<Long, Integer> dimRatios = parseDimensionAssign(config.getDimensionAssign());
  1492. if (dimRatios.isEmpty()) {
  1493. // 无维度配置时使用旧 Fallback
  1494. EnergyDimension action = getDimByCode("action");
  1495. if (action != null) dimRatios = Collections.singletonMap(action.getId(), 10000);
  1496. else return new HashMap<>();
  1497. }
  1498. // 日上限检查(使用配置的 daily_limit,大于0时覆盖全局限制)
  1499. Integer configDailyLimit = config.getDailyLimit();
  1500. if (configDailyLimit != null && configDailyLimit > 0) {
  1501. if (isDailyLimitExceeded(memberId, dimRatios.keySet(), totalAmount, configDailyLimit)) {
  1502. log.warn("行为日上限已达,跳过: behaviorCode={}, memberId={}", config.getBehaviorCode(), memberId);
  1503. return new HashMap<>();
  1504. }
  1505. } else {
  1506. if (isDailyLimitExceeded(memberId, dimRatios.keySet(), totalAmount)) {
  1507. log.warn("全局日上限已达,跳过: behaviorCode={}, memberId={}", config.getBehaviorCode(), memberId);
  1508. return new HashMap<>();
  1509. }
  1510. }
  1511. // 计算过期时间
  1512. Integer expireDays = config.getExpireDays() != null && config.getExpireDays() > 0
  1513. ? config.getExpireDays() : (daysToExpire != null ? daysToExpire : null);
  1514. // 按比例分配
  1515. Map<Long, Integer> allocations = calculateAllocations(totalAmount, dimRatios);
  1516. return executeAward(memberId, sourceType, sourceId, totalAmount, description, expireDays, allocations);
  1517. }
  1518. /**
  1519. * 执行能量发放(更新余额+写流水)
  1520. */
  1521. private Map<String, Integer> executeAward(Long memberId, String sourceType, Long sourceId,
  1522. Integer totalAmount, String description,
  1523. Integer expireDays, Map<Long, Integer> allocations) {
  1524. Map<String, Integer> result = new LinkedHashMap<>();
  1525. Date now = new Date();
  1526. Date expiresAt = expireDays != null
  1527. ? new Date(now.getTime() + (long) expireDays * 86400000L) : null;
  1528. for (Map.Entry<Long, Integer> entry : allocations.entrySet()) {
  1529. if (entry.getValue() <= 0) continue;
  1530. EnergyBalance balance = getOrCreateBalance(memberId, entry.getKey());
  1531. balance.setBalance(balance.getBalance() + entry.getValue());
  1532. balance.setTotalEarned(balance.getTotalEarned() + entry.getValue());
  1533. balance.setUpdatedAt(now);
  1534. energyBalanceMapper.updateById(balance);
  1535. EnergyLog elog = new EnergyLog();
  1536. elog.setMemberId(memberId);
  1537. elog.setDimensionId(entry.getKey());
  1538. elog.setAmount(entry.getValue());
  1539. elog.setBalanceAfter(balance.getBalance());
  1540. elog.setSourceType(sourceType);
  1541. elog.setSourceId(sourceId);
  1542. elog.setExpiresAt(expiresAt);
  1543. elog.setDescription(description);
  1544. elog.setCreatedAt(now);
  1545. energyLogMapper.insert(elog);
  1546. EnergyDimension dim = getDimById(entry.getKey());
  1547. if (dim != null) result.put(dim.getCode(), entry.getValue());
  1548. }
  1549. // 发送能量变更通知
  1550. try { sendEnergyNotification(memberId, sourceType, totalAmount, description); } catch (Exception e) { log.warn("energy change notification failed", e); }
  1551. return result;
  1552. }
  1553. /**
  1554. * 通过 sourceType 匹配 behavior_config
  1555. * 映射规则: sourceType → behavior_code
  1556. * 如 "task" → "task_complete", "game" → "game_finish"
  1557. */
  1558. private EnergyBehaviorConfig matchBehaviorConfig(String sourceType) {
  1559. if (sourceType == null || sourceType.isEmpty()) return null;
  1560. String behaviorCode = sourceTypeToBehaviorCode(sourceType);
  1561. if (behaviorCode == null) return null;
  1562. try {
  1563. return energyBehaviorConfigMapper.selectOne(
  1564. new LambdaQueryWrapper<EnergyBehaviorConfig>()
  1565. .eq(EnergyBehaviorConfig::getBehaviorCode, behaviorCode)
  1566. .eq(EnergyBehaviorConfig::getEnabled, 1)
  1567. );
  1568. } catch (Exception e) {
  1569. log.warn("查询能量行为配置失败: behaviorCode={}, error={}", behaviorCode, e.getMessage());
  1570. return null;
  1571. }
  1572. }
  1573. /**
  1574. * sourceType → behavior_code 映射
  1575. */
  1576. private String sourceTypeToBehaviorCode(String sourceType) {
  1577. switch (sourceType) {
  1578. case "task": return "task_complete";
  1579. case "game": return "game_finish";
  1580. case "activity": return "activity_checkin";
  1581. case "article": return "article_read";
  1582. case "product": return "product_purchase";
  1583. case "health_checkin": return "health_checkin";
  1584. case "checkin": return "finance_checkin"; // 理财打卡
  1585. case "emotion_checkin": return "emotion_checkin";
  1586. case "appointment": return "appointment_submit"; // 预约/完成共用
  1587. case "streak": return "streak_daily";
  1588. case "growth_day": return "growth_task";
  1589. case "micro_action": return "micro_action";
  1590. case "onboarding": return "onboarding";
  1591. case "invite_milestone": return "invite_milestone";
  1592. case "dimension": return "dimension_sync";
  1593. default: return null;
  1594. }
  1595. }
  1596. /**
  1597. * 解析维度分配JSON → Map<dimensionId, ratio>
  1598. * 输入: [{"dim":"action","ratio":50},{"dim":"wisdom","ratio":50}]
  1599. * 输出: {4: 5000, 3: 5000}
  1600. */
  1601. private Map<Long, Integer> parseDimensionAssign(String dimensionAssign) {
  1602. Map<Long, Integer> result = new LinkedHashMap<>();
  1603. if (dimensionAssign == null || dimensionAssign.isEmpty()) return result;
  1604. try {
  1605. com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(dimensionAssign);
  1606. for (int i = 0; i < arr.size(); i++) {
  1607. com.alibaba.fastjson.JSONObject item = arr.getJSONObject(i);
  1608. String dimCode = item.getString("dim");
  1609. int ratio = item.getIntValue("ratio");
  1610. EnergyDimension dim = getDimByCode(dimCode);
  1611. if (dim != null) {
  1612. result.put(dim.getId(), ratio * 100); // 转为10000制
  1613. }
  1614. }
  1615. } catch (Exception e) {
  1616. log.warn("解析维度分配JSON失败: {}", e.getMessage());
  1617. }
  1618. return result;
  1619. }
  1620. private boolean isEnabled(EnergyBehaviorConfig config) {
  1621. return config != null && config.getEnabled() != null && config.getEnabled() == 1;
  1622. }
  1623. /**
  1624. * 扣除能量
  1625. *
  1626. * @return 扣除后余额,-1 = 余额不足
  1627. */
  1628. @Transactional
  1629. public int deductEnergy(Long memberId, Long dimensionId, Integer amount, String reason) {
  1630. if (amount == null || amount <= 0)
  1631. throw new IllegalArgumentException("扣除数量必须为正数");
  1632. EnergyBalance balance = getOrCreateBalance(memberId, dimensionId);
  1633. if (balance.getBalance() < amount) return -1;
  1634. balance.setBalance(balance.getBalance() - amount);
  1635. balance.setTotalSpent(balance.getTotalSpent() + amount);
  1636. balance.setUpdatedAt(new Date());
  1637. energyBalanceMapper.updateById(balance);
  1638. EnergyLog elog = new EnergyLog();
  1639. elog.setMemberId(memberId);
  1640. elog.setDimensionId(dimensionId);
  1641. elog.setAmount(-amount);
  1642. elog.setBalanceAfter(balance.getBalance());
  1643. elog.setDescription(reason);
  1644. elog.setCreatedAt(new Date());
  1645. energyLogMapper.insert(elog);
  1646. // 发送能量扣除通知
  1647. try { sendEnergyDeductionNotification(memberId, "manual", amount, reason); } catch (Exception e) { log.warn("energy deduction notification failed", e); }
  1648. return balance.getBalance();
  1649. }
  1650. /**
  1651. * 扣除能量 — 通过维度code(自动查ID)
  1652. *
  1653. * @return 扣除后余额,-1 = 余额不足
  1654. */
  1655. @Transactional
  1656. public int deductEnergyByCode(Long memberId, String dimensionCode, Integer amount, String reason) {
  1657. EnergyDimension dim = getDimByCode(dimensionCode);
  1658. if (dim == null) {
  1659. log.warn("EnergyService.deductEnergyByCode: dimension not found, code={}", dimensionCode);
  1660. return -1;
  1661. }
  1662. return deductEnergy(memberId, dim.getId(), amount, reason);
  1663. }
  1664. /**
  1665. * 发放能量 — 通过维度code(自动查ID),无日上限/配置约束,供低频行为直接发放
  1666. *
  1667. * @return 发放结果 Map<dimensionCode, amount>(空 = 维度不存在未发放)
  1668. */
  1669. @Transactional
  1670. public Map<String, Integer> awardEnergyByCode(Long memberId, String dimensionCode, Integer amount, String reason) {
  1671. if (amount == null || amount <= 0) return new HashMap<>();
  1672. EnergyDimension dim = getDimByCode(dimensionCode);
  1673. if (dim == null) {
  1674. log.warn("EnergyService.awardEnergyByCode: dimension not found, code={}", dimensionCode);
  1675. return new HashMap<>();
  1676. }
  1677. Map<Long, Integer> allocations = new LinkedHashMap<>();
  1678. allocations.put(dim.getId(), amount);
  1679. return executeAward(memberId, "challenge", null, amount, reason, null, allocations);
  1680. }
  1681. /**
  1682. * 查询五维能量概览
  1683. */
  1684. public Map<String, Object> getOverview(Long memberId) {
  1685. List<EnergyDimension> allDims = getAllDimensions();
  1686. List<Map<String, Object>> dimList = new ArrayList<>();
  1687. int totalEnergy = 0;
  1688. for (EnergyDimension dim : allDims) {
  1689. if (dim.getStatus() != 1) continue;
  1690. EnergyBalance balance = energyBalanceMapper.selectOne(
  1691. new LambdaQueryWrapper<EnergyBalance>()
  1692. .eq(EnergyBalance::getMemberId, memberId)
  1693. .eq(EnergyBalance::getDimensionId, dim.getId())
  1694. );
  1695. int energy = balance != null ? balance.getBalance() : 0;
  1696. totalEnergy += energy;
  1697. Map<String, Object> item = new LinkedHashMap<>();
  1698. item.put("code", dim.getCode());
  1699. item.put("name", dim.getName());
  1700. item.put("icon", dim.getIcon());
  1701. item.put("element", dim.getElement());
  1702. item.put("energy", energy);
  1703. item.put("healthIndex", 0); // 算法待定
  1704. dimList.add(item);
  1705. }
  1706. Map<String, Object> result = new LinkedHashMap<>();
  1707. result.put("dimensions", dimList);
  1708. result.put("totalEnergy", totalEnergy);
  1709. result.put("totalHealthIndex", 0);
  1710. return result;
  1711. }
  1712. /**
  1713. * 流水查询(按维度筛选,分页)
  1714. */
  1715. public Page<EnergyLog> getLogs(Long memberId, String dimensionCode, Integer page, Integer size) {
  1716. if (page == null || page < 1) page = 1;
  1717. if (size == null || size < 1) size = 10;
  1718. Page<EnergyLog> pageParam = new Page<>(page, size);
  1719. LambdaQueryWrapper<EnergyLog> wrapper = new LambdaQueryWrapper<EnergyLog>()
  1720. .eq(EnergyLog::getMemberId, memberId);
  1721. if (dimensionCode != null && !dimensionCode.isEmpty()) {
  1722. EnergyDimension dim = getDimByCode(dimensionCode);
  1723. if (dim != null) wrapper.eq(EnergyLog::getDimensionId, dim.getId());
  1724. }
  1725. wrapper.orderByDesc(EnergyLog::getCreatedAt);
  1726. SortUtil.applySort(wrapper);
  1727. return energyLogMapper.selectPage(pageParam, wrapper);
  1728. }
  1729. /**
  1730. * 配置来源比例
  1731. */
  1732. @Transactional
  1733. public void configureSourceRatios(String sourceType, Long sourceId, String sourceName,
  1734. List<Map<String, Object>> ratios) {
  1735. energySourceConfigMapper.delete(
  1736. new LambdaQueryWrapper<EnergySourceConfig>()
  1737. .eq(EnergySourceConfig::getSourceType, sourceType)
  1738. .eq(EnergySourceConfig::getSourceId, sourceId)
  1739. );
  1740. for (Map<String, Object> r : ratios) {
  1741. String dimCode = (String) r.get("dimensionCode");
  1742. Integer ratioVal = Integer.valueOf(r.get("ratio").toString());
  1743. EnergyDimension dim = getDimByCode(dimCode);
  1744. if (dim == null) continue;
  1745. EnergySourceConfig config = new EnergySourceConfig();
  1746. config.setSourceType(sourceType);
  1747. config.setSourceId(sourceId);
  1748. config.setSourceName(sourceName);
  1749. config.setDimensionId(dim.getId());
  1750. config.setRatio(ratioVal);
  1751. config.setCreatedAt(new Date());
  1752. energySourceConfigMapper.insert(config);
  1753. }
  1754. }
  1755. /**
  1756. * 发放能量 - 任务奖励
  1757. *
  1758. * @param memberId 家庭成员ID
  1759. * @param taskId 任务ID
  1760. * @param amount 能量数量
  1761. * @param dimensionCode 维度代码
  1762. * @return EnergyLog 能量日志
  1763. */
  1764. @Transactional
  1765. public EnergyLog grantEnergy(Long memberId, Long taskId, Long amount, String dimensionCode) {
  1766. // CFC-EN-001 修复:grantEnergy 的维度由调用方显式指定,能量应直接发放到该维度。
  1767. // 旧实现委托 awardEnergy("task"),命中 task_complete 行为配置(100% 分配到 action),
  1768. // 随后按请求的 dimensionCode 从分配结果取数,请求 body 维度时 balanceMap.get("body")=null
  1769. // → 抛 RuntimeException → GlobalExceptionHandler 未映射 → code=500「服务器内部错误」。
  1770. if (amount == null || amount <= 0) {
  1771. throw new IllegalArgumentException("能量数量必须为正数");
  1772. }
  1773. EnergyDimension dim = getDimByCode(dimensionCode);
  1774. if (dim == null) {
  1775. throw new IllegalArgumentException("维度代码无效:" + dimensionCode);
  1776. }
  1777. Map<Long, Integer> allocations = new LinkedHashMap<>();
  1778. allocations.put(dim.getId(), amount.intValue());
  1779. // 日上限检查(沿用全局/维度上限逻辑,超限改为 400 业务提示而非 500)
  1780. if (isDailyLimitExceeded(memberId, allocations.keySet(), amount.intValue())) {
  1781. log.warn("日上限已达,跳过发放: memberId={}, dimensionCode={}, amount={}",
  1782. memberId, dimensionCode, amount);
  1783. throw new IllegalStateException("今日能量发放已达上限,请明天再试");
  1784. }
  1785. // 执行发放(更新余额 + 写流水 + 站内通知)
  1786. Map<String, Integer> balanceMap = executeAward(memberId, "task", taskId,
  1787. amount.intValue(), "任务奖励", null, allocations);
  1788. Integer energyAmount = balanceMap.get(dimensionCode);
  1789. if (energyAmount == null || energyAmount <= 0) {
  1790. throw new IllegalStateException("能量发放失败:维度 " + dimensionCode + " 没有分配能量");
  1791. }
  1792. // 构建返回对象(executeAward 已写入日志,此处不再重复插入)
  1793. EnergyLog energyLog = new EnergyLog();
  1794. energyLog.setMemberId(memberId);
  1795. energyLog.setDimensionId(dim.getId());
  1796. energyLog.setAmount(energyAmount);
  1797. energyLog.setSourceType("task");
  1798. energyLog.setSourceId(taskId);
  1799. energyLog.setDescription("任务奖励");
  1800. energyLog.setCreatedAt(new Date());
  1801. return energyLog;
  1802. }
  1803. /**
  1804. * 扣除能量 - 用于心愿兑换
  1805. *
  1806. * @param memberId 家庭成员ID
  1807. * @param wishId 心愿ID
  1808. * @param amount 扣除能量数量
  1809. * @return Boolean 是否成功
  1810. */
  1811. @Transactional
  1812. public Boolean deductForWish(Long memberId, Long wishId, Long amount) {
  1813. // 使用 deductEnergyByCode 扣除能量,维度为 "action"
  1814. int result = deductEnergyByCode(memberId, "action", amount.intValue(), "心愿兑换");
  1815. // 返回 true 表示成功,false 表示余额不足
  1816. return result != -1;
  1817. }
  1818. /**
  1819. * 发送能量变更站内通知
  1820. */
  1821. private void sendEnergyNotification(Long memberId, String sourceType, Integer amount, String description) {
  1822. FamilyMember member = familyMemberMapper.selectById(memberId);
  1823. Long userId = member != null ? member.getUserId() : null;
  1824. if (userId == null) return;
  1825. String title = "";
  1826. String content = "";
  1827. switch (sourceType) {
  1828. case "task":
  1829. title = "任务完成获得能量";
  1830. content = String.format("完成了%s获得%d能量", description != null ? description : "任务", amount);
  1831. break;
  1832. case "product":
  1833. title = "购买商品扣除能量";
  1834. content = String.format("购买了%s扣除%d能量", description != null ? description : "商品", Math.abs(amount));
  1835. break;
  1836. case "medical_report":
  1837. title = "上传体检报告获得能量";
  1838. content = String.format("上传了%s获得%d能量", description != null ? description : "体检报告", amount);
  1839. break;
  1840. case "daily_record":
  1841. title = "日常记录获得能量";
  1842. content = String.format("完成了日常记录获得%d能量", amount);
  1843. break;
  1844. default:
  1845. title = "能量变动";
  1846. content = String.format("能量变动%d", amount);
  1847. }
  1848. Map<String, Object> vars = new HashMap<>();
  1849. vars.put("amount", (amount > 0 ? "+" : "") + String.valueOf(amount));
  1850. vars.put("source", sourceType);
  1851. vars.put("description", description);
  1852. notificationDispatchService.sendByTemplate("energy_change", userId, vars, "energyLog", memberId, title, content);
  1853. }
  1854. /**
  1855. * 发送能量扣除站内通知
  1856. */
  1857. private void sendEnergyDeductionNotification(Long memberId, String sourceType, Integer amount, String description) {
  1858. FamilyMember member = familyMemberMapper.selectById(memberId);
  1859. Long userId = member != null ? member.getUserId() : null;
  1860. if (userId == null) return;
  1861. String title = "";
  1862. String content = "";
  1863. switch (sourceType) {
  1864. case "task":
  1865. title = "任务完成获得能量";
  1866. content = String.format("完成了%s获得%d能量", description != null ? description : "任务", amount);
  1867. break;
  1868. case "wish":
  1869. title = "心愿兑换扣除能量";
  1870. content = String.format("完成了心愿兑换扣除%d能量", Math.abs(amount));
  1871. break;
  1872. default:
  1873. title = "能量扣除";
  1874. content = String.format("能量扣除%d", Math.abs(amount));
  1875. }
  1876. Map<String, Object> vars = new HashMap<>();
  1877. vars.put("amount", (amount > 0 ? "+" : "") + String.valueOf(amount));
  1878. vars.put("source", sourceType);
  1879. vars.put("description", description);
  1880. notificationDispatchService.sendByTemplate("energy_deduction", userId, vars, "energyLog", memberId, title, content);
  1881. }
  1882. // ==================== 辅助方法 ====================
  1883. /**
  1884. * 解析维度比例配置,按Fallback链降级
  1885. */
  1886. private Map<Long, Integer> resolveDimensionRatios(
  1887. List<EnergySourceConfig> configs, String sourceType, Long sourceId) {
  1888. if (!configs.isEmpty()) {
  1889. Map<Long, Integer> result = new LinkedHashMap<>();
  1890. for (EnergySourceConfig c : configs) result.put(c.getDimensionId(), c.getRatio());
  1891. return result;
  1892. }
  1893. // Fallback: 默认 → 行(action) 100%
  1894. EnergyDimension action = getDimByCode("action");
  1895. if (action != null) return Collections.singletonMap(action.getId(), 10000);
  1896. return Collections.emptyMap();
  1897. }
  1898. /**
  1899. * 按比例分配整数,余数加到最大比例维度
  1900. */
  1901. private Map<Long, Integer> calculateAllocations(Integer total, Map<Long, Integer> ratios) {
  1902. int totalRatio = ratios.values().stream().mapToInt(Integer::intValue).sum();
  1903. if (totalRatio == 0) return Collections.emptyMap();
  1904. Map<Long, Integer> result = new LinkedHashMap<>();
  1905. int allocated = 0;
  1906. // 第一轮:按比例分配(向下取整)
  1907. Long maxRatioDim = null;
  1908. int maxRatio = 0;
  1909. for (Map.Entry<Long, Integer> entry : ratios.entrySet()) {
  1910. int val = total * entry.getValue() / totalRatio;
  1911. result.put(entry.getKey(), val);
  1912. allocated += val;
  1913. if (entry.getValue() > maxRatio) {
  1914. maxRatio = entry.getValue();
  1915. maxRatioDim = entry.getKey();
  1916. }
  1917. }
  1918. // 第二轮:余数加到最大比例维度
  1919. int remainder = total - allocated;
  1920. if (remainder > 0 && maxRatioDim != null) {
  1921. result.put(maxRatioDim, result.get(maxRatioDim) + remainder);
  1922. }
  1923. return result;
  1924. }
  1925. /**
  1926. * 获取或创建维度余额记录
  1927. */
  1928. private EnergyBalance getOrCreateBalance(Long memberId, Long dimId) {
  1929. EnergyBalance balance = energyBalanceMapper.selectOne(
  1930. new LambdaQueryWrapper<EnergyBalance>()
  1931. .eq(EnergyBalance::getMemberId, memberId)
  1932. .eq(EnergyBalance::getDimensionId, dimId)
  1933. );
  1934. if (balance == null) {
  1935. balance = new EnergyBalance();
  1936. balance.setMemberId(memberId);
  1937. balance.setDimensionId(dimId);
  1938. balance.setBalance(0);
  1939. balance.setTotalEarned(0);
  1940. balance.setTotalSpent(0);
  1941. balance.setUpdatedAt(new Date());
  1942. energyBalanceMapper.insert(balance);
  1943. }
  1944. return balance;
  1945. }
  1946. /**
  1947. * 检查日上限是否已达
  1948. */
  1949. private boolean isDailyLimitExceeded(Long memberId, Set<Long> dimIds, Integer amount) {
  1950. return isDailyLimitExceeded(memberId, dimIds, amount, GLOBAL_DAILY_LIMIT);
  1951. }
  1952. /**
  1953. * 检查日上限(支持自定义全局上限)
  1954. */
  1955. private boolean isDailyLimitExceeded(Long memberId, Set<Long> dimIds, Integer amount, Integer customGlobalLimit) {
  1956. if (amount == null || amount <= 0) return true;
  1957. Date todayStart = getTodayStart();
  1958. Date todayEnd = getTodayEnd();
  1959. // 全局日上限(使用自定义上限)
  1960. LambdaQueryWrapper<EnergyLog> globalWrapper = new LambdaQueryWrapper<EnergyLog>()
  1961. .eq(EnergyLog::getMemberId, memberId)
  1962. .gt(EnergyLog::getAmount, 0)
  1963. .between(EnergyLog::getCreatedAt, todayStart, todayEnd);
  1964. Integer globalToday = energyLogMapper.selectList(globalWrapper).stream()
  1965. .mapToInt(e -> e.getAmount() != null ? e.getAmount() : 0)
  1966. .sum();
  1967. int globalLimit = customGlobalLimit != null ? customGlobalLimit : GLOBAL_DAILY_LIMIT;
  1968. if (globalToday + amount > globalLimit) return true;
  1969. // 各维度日上限
  1970. for (Long dimId : dimIds) {
  1971. LambdaQueryWrapper<EnergyLog> dimWrapper = new LambdaQueryWrapper<EnergyLog>()
  1972. .eq(EnergyLog::getMemberId, memberId)
  1973. .eq(EnergyLog::getDimensionId, dimId)
  1974. .gt(EnergyLog::getAmount, 0)
  1975. .between(EnergyLog::getCreatedAt, todayStart, todayEnd);
  1976. Integer dimToday = energyLogMapper.selectList(dimWrapper).stream()
  1977. .mapToInt(e -> e.getAmount() != null ? e.getAmount() : 0)
  1978. .sum();
  1979. EnergyDimension dim = getDimById(dimId);
  1980. String code = dim != null ? dim.getCode() : "";
  1981. Integer limit = DAILY_LIMIT_MAP.getOrDefault(code, 50);
  1982. if (dimToday + amount > limit) return true;
  1983. }
  1984. return false;
  1985. }
  1986. /**
  1987. * 获取今日起始时间(00:00:00)
  1988. */
  1989. private Date getTodayStart() {
  1990. Calendar cal = Calendar.getInstance();
  1991. cal.set(Calendar.HOUR_OF_DAY, 0);
  1992. cal.set(Calendar.MINUTE, 0);
  1993. cal.set(Calendar.SECOND, 0);
  1994. cal.set(Calendar.MILLISECOND, 0);
  1995. return cal.getTime();
  1996. }
  1997. /**
  1998. * 获取今日结束时间(23:59:59)
  1999. */
  2000. private Date getTodayEnd() {
  2001. Calendar cal = Calendar.getInstance();
  2002. cal.set(Calendar.HOUR_OF_DAY, 23);
  2003. cal.set(Calendar.MINUTE, 59);
  2004. cal.set(Calendar.SECOND, 59);
  2005. cal.set(Calendar.MILLISECOND, 999);
  2006. return cal.getTime();
  2007. }
  2008. /**
  2009. * 获取所有维度(带缓存)
  2010. */
  2011. private List<EnergyDimension> getAllDimensions() {
  2012. if (dimCache == null) {
  2013. dimCache = energyDimensionMapper.selectList(
  2014. new LambdaQueryWrapper<EnergyDimension>()
  2015. .orderByAsc(EnergyDimension::getSortOrder)
  2016. );
  2017. // 统一维度体系:五维五行归属改以注册中心为准(root 节点 wuxing_rule_ref,与旧
  2018. // energy_dimension.element 同值,迁移448 已对齐)。注册中心未就绪或维度缺失时
  2019. // 保留旧表值,保证五行回归结果不变、返回结构不变。
  2020. enrichWuxingElementFromRegistry(dimCache);
  2021. }
  2022. return dimCache;
  2023. }
  2024. /**
  2025. * 统一维度体系:按注册中心 5 根节点的 wuxing_rule_ref 覆盖五行元素归属(element)。
  2026. *
  2027. * <p>维度 code(body/mind/wisdom/action/wealth)与统一 5 根同码,无需 alias 翻译;
  2028. * id/name/icon/sortOrder/status 仍以 energy_dimension 表为准(energy_log/energy_balance
  2029. * 以 dimension_id 关联该表 id,切换仅限五行归属来源,保证回归不变)。</p>
  2030. *
  2031. * @param dims 已加载的旧表维度列表(就地更新 element)
  2032. */
  2033. private void enrichWuxingElementFromRegistry(List<EnergyDimension> dims) {
  2034. if (dims == null || dims.isEmpty() || dimensionRegistryService == null) {
  2035. return;
  2036. }
  2037. try {
  2038. List<DimensionNode> roots = dimensionRegistryService.getRoots();
  2039. if (roots == null || roots.isEmpty()) {
  2040. return;
  2041. }
  2042. Map<String, String> elementByCode = new HashMap<>();
  2043. for (DimensionNode root : roots) {
  2044. if (root == null || root.getCode() == null || root.getWuxingRuleRef() == null) {
  2045. continue;
  2046. }
  2047. elementByCode.put(root.getCode(), root.getWuxingRuleRef());
  2048. }
  2049. for (EnergyDimension d : dims) {
  2050. if (d == null || d.getCode() == null) {
  2051. continue;
  2052. }
  2053. String element = elementByCode.get(d.getCode());
  2054. if (element != null) {
  2055. d.setElement(element);
  2056. }
  2057. }
  2058. } catch (Exception e) {
  2059. log.warn("EnergyService.enrichWuxingElementFromRegistry: 注册中心五行归属补齐失败(忽略): {}", e.getMessage());
  2060. }
  2061. }
  2062. /**
  2063. * 按code查维度
  2064. */
  2065. private EnergyDimension getDimByCode(String code) {
  2066. initDimMaps();
  2067. return dimCodeMap.get(code);
  2068. }
  2069. /**
  2070. * 按ID查维度
  2071. */
  2072. private EnergyDimension getDimById(Long id) {
  2073. initDimMaps();
  2074. return dimIdMap.get(id);
  2075. }
  2076. private void initDimMaps() {
  2077. if (dimCodeMap == null || dimIdMap == null) {
  2078. List<EnergyDimension> all = getAllDimensions();
  2079. dimCodeMap = all.stream().collect(Collectors.toMap(EnergyDimension::getCode, d -> d));
  2080. dimIdMap = all.stream().collect(Collectors.toMap(EnergyDimension::getId, d -> d));
  2081. }
  2082. }
  2083. }