|
|
@@ -0,0 +1,356 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 商品推荐服务
|
|
|
+ * 支持维度页推荐、AI对话推荐、报告关联推荐
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ProductRecommendationService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductMapper productMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductDimensionMappingMapper dimensionMappingMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductOrderMapper orderMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FiveDimensionScoreMapper fiveDimensionScoreMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductRecommendationLogMapper recommendationLogMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 维度页推荐
|
|
|
+ *
|
|
|
+ * @param dimensionCode 维度编码: body/wisdom/mind/action/wealth
|
|
|
+ * @param familyId 家庭ID
|
|
|
+ * @param memberId 成员ID(可选)
|
|
|
+ * @param excludeProductIds 排除的商品ID列表
|
|
|
+ * @param limit 返回数量
|
|
|
+ * @return 推荐商品列表
|
|
|
+ */
|
|
|
+ public List<Map<String, Object>> getDimensionRecommendations(
|
|
|
+ String dimensionCode, Long familyId, Long memberId,
|
|
|
+ List<Long> excludeProductIds, int limit) {
|
|
|
+
|
|
|
+ // 1. 获取已购商品ID(90天内)
|
|
|
+ Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
|
|
|
+ if (excludeProductIds != null) {
|
|
|
+ purchasedIds.addAll(excludeProductIds);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 获取成员维度得分
|
|
|
+ Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
|
|
|
+
|
|
|
+ // 3. 查询维度匹配商品(优先从 dimension_mapping 表)
|
|
|
+ List<Product> candidates = new ArrayList<>();
|
|
|
+
|
|
|
+ // 从 mapping 表获取关联商品
|
|
|
+ List<ProductDimensionMapping> mappings = dimensionMappingMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ProductDimensionMapping>()
|
|
|
+ .eq(ProductDimensionMapping::getDimensionCode, dimensionCode)
|
|
|
+ .eq(ProductDimensionMapping::getEnabled, 1)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (mappings != null && !mappings.isEmpty()) {
|
|
|
+ List<Long> mappedProductIds = mappings.stream()
|
|
|
+ .map(ProductDimensionMapping::getProductId)
|
|
|
+ .filter(id -> !purchasedIds.contains(id))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ if (!mappedProductIds.isEmpty()) {
|
|
|
+ List<Product> mapped = productMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
+ .in(Product::getId, mappedProductIds)
|
|
|
+ .eq(Product::getStatus, "上架")
|
|
|
+ .gt(Product::getStock, 0)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 构建 productId → mapping 得分
|
|
|
+ Map<Long, Integer> mappingScores = mappings.stream()
|
|
|
+ .collect(Collectors.toMap(
|
|
|
+ ProductDimensionMapping::getProductId,
|
|
|
+ ProductDimensionMapping::getMatchScore,
|
|
|
+ (a, b) -> a
|
|
|
+ ));
|
|
|
+ Map<Long, String> mappingReasons = mappings.stream()
|
|
|
+ .collect(Collectors.toMap(
|
|
|
+ ProductDimensionMapping::getProductId,
|
|
|
+ m -> m.getMatchReason() != null ? m.getMatchReason() : "",
|
|
|
+ (a, b) -> a
|
|
|
+ ));
|
|
|
+
|
|
|
+ for (Product p : mapped) {
|
|
|
+ Integer baseScore = mappingScores.getOrDefault(p.getId(), 100);
|
|
|
+ String reason = mappingReasons.getOrDefault(p.getId(), "");
|
|
|
+ double finalScore = applyDimensionBoost(baseScore, dimensionCode, memberScores);
|
|
|
+ candidates.add(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // fallback: 从 Product.domain 匹配
|
|
|
+ if (candidates.isEmpty()) {
|
|
|
+ List<Product> domainMatched = productMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
+ .eq(Product::getDomain, dimensionCode)
|
|
|
+ .eq(Product::getStatus, "上架")
|
|
|
+ .gt(Product::getStock, 0)
|
|
|
+ .notIn(purchasedIds.isEmpty(), Product::getId, purchasedIds)
|
|
|
+ );
|
|
|
+ candidates.addAll(domainMatched);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 排序并返回
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ for (Product p : candidates) {
|
|
|
+ if (result.size() >= limit) break;
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("id", p.getId());
|
|
|
+ item.put("name", p.getName());
|
|
|
+ item.put("coverImage", p.getCoverImage());
|
|
|
+ item.put("price", p.getPrice());
|
|
|
+ item.put("memberPrice", p.getMemberPrice());
|
|
|
+ item.put("productType", p.getProductType());
|
|
|
+ item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
|
|
|
+
|
|
|
+ // 推荐理由
|
|
|
+ String reason = buildRecommendationReason(p, dimensionCode, memberScores);
|
|
|
+ item.put("reason", reason);
|
|
|
+
|
|
|
+ // 匹配分
|
|
|
+ double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
|
|
|
+ item.put("matchScore", matchScore);
|
|
|
+
|
|
|
+ result.add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 报告关联推荐(健康报告/认知测评触发)
|
|
|
+ */
|
|
|
+ public List<Map<String, Object>> getReportRelatedProducts(
|
|
|
+ String reportType, Object analysis, Long userId, int limit) {
|
|
|
+
|
|
|
+ List<String> dimensionCodes = new ArrayList<>();
|
|
|
+ if ("health_report".equals(reportType)) {
|
|
|
+ // 从健康分析结果提取维度需求
|
|
|
+ dimensionCodes = extractDimensionNeedsFromHealth(analysis);
|
|
|
+ } else if ("cognitive_assessment".equals(reportType)) {
|
|
|
+ // 从认知测评结果提取弱维度
|
|
|
+ dimensionCodes = extractDimensionNeedsFromCognitive(analysis);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (dimensionCodes.isEmpty()) {
|
|
|
+ dimensionCodes.add("body");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 去重
|
|
|
+ List<String> uniqueDims = dimensionCodes.stream().distinct().collect(Collectors.toList());
|
|
|
+
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ for (String dim : uniqueDims) {
|
|
|
+ if (result.size() >= limit) break;
|
|
|
+ List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
|
|
|
+ for (Map<String, Object> p : prods) {
|
|
|
+ p.put("triggerDimension", dim);
|
|
|
+ result.add(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 记录推荐日志
|
|
|
+ */
|
|
|
+ public void logRecommendation(Long userId, Long productId, String scene, String reason, Double matchScore) {
|
|
|
+ try {
|
|
|
+ ProductRecommendationLog logEntity = new ProductRecommendationLog();
|
|
|
+ logEntity.setUserId(userId);
|
|
|
+ logEntity.setProductId(productId);
|
|
|
+ logEntity.setScene(scene);
|
|
|
+ logEntity.setReason(reason);
|
|
|
+ logEntity.setMatchScore(matchScore);
|
|
|
+ logEntity.setCreatedAt(new Date());
|
|
|
+ recommendationLogMapper.insert(logEntity);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("记录推荐日志失败: userId={}, productId={}, error={}", userId, productId, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── Private helpers ───
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取家庭成员在90天内购买过的商品ID
|
|
|
+ */
|
|
|
+ private Set<Long> getPurchasedProductIds(Long familyId, int daysAgo) {
|
|
|
+ Set<Long> ids = new HashSet<>();
|
|
|
+ if (familyId == null) return ids;
|
|
|
+
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
|
|
|
+ Date cutoff = cal.getTime();
|
|
|
+
|
|
|
+ List<ProductOrder> orders = orderMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .eq(ProductOrder::getFamilyId, familyId)
|
|
|
+ .eq(ProductOrder::getStatus, "已支付")
|
|
|
+ .gt(ProductOrder::getPaidAt, cutoff)
|
|
|
+ );
|
|
|
+
|
|
|
+ for (ProductOrder order : orders) {
|
|
|
+ if (order.getProductId() != null) {
|
|
|
+ ids.add(order.getProductId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ids;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取成员维度得分
|
|
|
+ */
|
|
|
+ private Map<String, Integer> getMemberDimensionScores(Long familyId, Long memberId) {
|
|
|
+ Map<String, Integer> scores = new HashMap<>();
|
|
|
+
|
|
|
+ LambdaQueryWrapper<FiveDimensionScore> qw = new LambdaQueryWrapper<>();
|
|
|
+ qw.eq(familyId != null, FiveDimensionScore::getFamilyId, familyId);
|
|
|
+ qw.eq(memberId != null, FiveDimensionScore::getMemberId, memberId);
|
|
|
+ qw.orderByDesc(FiveDimensionScore::getAssessedAt);
|
|
|
+
|
|
|
+ List<FiveDimensionScore> records = fiveDimensionScoreMapper.selectList(qw);
|
|
|
+
|
|
|
+ // 取每个维度的最新得分
|
|
|
+ Set<String> seen = new HashSet<>();
|
|
|
+ for (FiveDimensionScore s : records) {
|
|
|
+ if (s.getDimensionCode() != null && !seen.contains(s.getDimensionCode())) {
|
|
|
+ seen.add(s.getDimensionCode());
|
|
|
+ scores.put(s.getDimensionCode(), s.getScore() != null ? s.getScore() : 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return scores;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对低得分维度加权 boost
|
|
|
+ */
|
|
|
+ private double applyDimensionBoost(int baseScore, String dimensionCode, Map<String, Integer> memberScores) {
|
|
|
+ Integer score = memberScores.get(dimensionCode);
|
|
|
+ if (score == null || score >= 70) {
|
|
|
+ return baseScore;
|
|
|
+ } else if (score >= 50) {
|
|
|
+ return baseScore * 1.1;
|
|
|
+ } else {
|
|
|
+ return baseScore * 1.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算综合匹配分
|
|
|
+ */
|
|
|
+ private double calculateMatchScore(Product product, String dimensionCode, Map<String, Integer> memberScores) {
|
|
|
+ int base = 70;
|
|
|
+ Integer myScore = memberScores.get(dimensionCode);
|
|
|
+ if (myScore != null) {
|
|
|
+ if (myScore < 50) base = 90;
|
|
|
+ else if (myScore < 70) base = 80;
|
|
|
+ else base = 70;
|
|
|
+ }
|
|
|
+ return base;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建推荐理由
|
|
|
+ */
|
|
|
+ private String buildRecommendationReason(Product product, String dimensionCode, Map<String, Integer> memberScores) {
|
|
|
+ Integer score = memberScores.get(dimensionCode);
|
|
|
+ if (score == null) {
|
|
|
+ return "根据您的维度匹配为您推荐";
|
|
|
+ }
|
|
|
+ if (score < 50) {
|
|
|
+ return "该维度得分偏低,重点推荐";
|
|
|
+ } else if (score < 70) {
|
|
|
+ return "该维度有提升空间,推荐关注";
|
|
|
+ }
|
|
|
+ return "丰富您的维度生活";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从健康分析结果提取维度需求
|
|
|
+ */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<String> extractDimensionNeedsFromHealth(Object analysis) {
|
|
|
+ List<String> dims = new ArrayList<>();
|
|
|
+ if (analysis == null) return dims;
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (analysis instanceof Map) {
|
|
|
+ Map<String, Object> map = (Map<String, Object>) analysis;
|
|
|
+ // 通过营养评分、菌群评分等推断维度
|
|
|
+ Object nutritionScore = map.get("nutritionScore");
|
|
|
+ Object gutScore = map.get("gutHealthScore");
|
|
|
+ Object overallScore = map.get("overallScore");
|
|
|
+
|
|
|
+ if (nutritionScore instanceof Number && ((Number) nutritionScore).intValue() < 60) {
|
|
|
+ dims.add("body");
|
|
|
+ }
|
|
|
+ if (gutScore instanceof Number && ((Number) gutScore).intValue() < 60) {
|
|
|
+ dims.add("body");
|
|
|
+ }
|
|
|
+ if (overallScore instanceof Number && ((Number) overallScore).intValue() < 60) {
|
|
|
+ dims.add("body");
|
|
|
+ dims.add("mind");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析健康分析结果维度失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return dims;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从认知测评结果提取弱维度
|
|
|
+ */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<String> extractDimensionNeedsFromCognitive(Object analysis) {
|
|
|
+ List<String> dims = new ArrayList<>();
|
|
|
+ if (analysis == null) return dims;
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (analysis instanceof List) {
|
|
|
+ List<String> weakDims = (List<String>) analysis;
|
|
|
+ for (String dim : weakDims) {
|
|
|
+ if ("focusScore".equals(dim) || "processingSpeedScore".equals(dim)) {
|
|
|
+ dims.add("wisdom");
|
|
|
+ } else if ("attentionScore".equals(dim)) {
|
|
|
+ dims.add("mind");
|
|
|
+ } else if ("memoryScore".equals(dim)) {
|
|
|
+ dims.add("wisdom");
|
|
|
+ } else {
|
|
|
+ dims.add(dim);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析认知测评结果维度失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return dims;
|
|
|
+ }
|
|
|
+}
|