|
|
@@ -13,7 +13,13 @@ import com.etotem.cfc.util.SortUtil;
|
|
|
|
|
|
/**
|
|
|
* 商品推荐服务
|
|
|
- * 支持维度页推荐、AI对话推荐、报告关联推荐
|
|
|
+ * 支持三类推荐场景:
|
|
|
+ * 1. 维度页推荐(基于维度匹配 + 关联关系)
|
|
|
+ * 2. AI对话推荐(基于画像缺口 + 购买关联)
|
|
|
+ * 3. 报告关联推荐(健康报告/认知测评触发)
|
|
|
+ * <p>
|
|
|
+ * 核心思路:<strong>静态维度映射</strong> 为基础,<strong>动态关联关系表</strong> 为补充,
|
|
|
+ * 实现<strong>画像驱动</strong>与<strong>购买关联</strong>双轮驱动的个性化推荐。
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
@@ -34,6 +40,9 @@ public class ProductRecommendationService {
|
|
|
@Resource
|
|
|
private ProductRecommendationLogMapper productRecommendationLogMapper;
|
|
|
|
|
|
+ @Resource
|
|
|
+ private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
|
|
|
+
|
|
|
/**
|
|
|
* 维度页推荐
|
|
|
*
|
|
|
@@ -54,11 +63,11 @@ public class ProductRecommendationService {
|
|
|
purchasedIds.addAll(excludeProductIds);
|
|
|
}
|
|
|
|
|
|
- // 2. 获取成员维度得分
|
|
|
+ // 2. 获取成员维度得分(画像)
|
|
|
Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
|
|
|
|
|
|
- // 3. 查询维度匹配商品(优先从 dimension_mapping 表)
|
|
|
- List<Product> candidates = new ArrayList<>();
|
|
|
+ // 3. 从维度匹配表获取候选商品(静态基础)
|
|
|
+ List<Map<String, Object>> candidates = new ArrayList<>();
|
|
|
|
|
|
// 从 mapping 表获取关联商品
|
|
|
LambdaQueryWrapper<ProductDimensionMapping> mappingQw = new LambdaQueryWrapper<ProductDimensionMapping>()
|
|
|
@@ -78,7 +87,7 @@ public class ProductRecommendationService {
|
|
|
if (!mappedProductIds.isEmpty()) {
|
|
|
List<Product> mapped = productMapper.selectList(
|
|
|
new LambdaQueryWrapper<Product>()
|
|
|
- .in(Product::getId, mappedProductIds)
|
|
|
+ .in(Product::getId, mappedProductIds)
|
|
|
.eq(Product::getStatus, "on_shelf")
|
|
|
.gt(Product::getStock, 0)
|
|
|
.orderByAsc(Product::getSortOrder)
|
|
|
@@ -102,112 +111,293 @@ public class ProductRecommendationService {
|
|
|
Integer baseScore = mappingScores.getOrDefault(p.getId(), 100);
|
|
|
String reason = mappingReasons.getOrDefault(p.getId(), "");
|
|
|
double finalScore = applyDimensionBoost(baseScore, dimensionCode, memberScores);
|
|
|
- candidates.add(p);
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("id", p.getId());
|
|
|
+ item.put("productId", p.getId());
|
|
|
+ item.put("name", p.getName());
|
|
|
+ item.put("coverImage", p.getCoverImage());
|
|
|
+ item.put("price", p.getPrice());
|
|
|
+ item.put("memberPrice", p.getPrice());
|
|
|
+ item.put("productType", p.getProductType());
|
|
|
+ item.put("source", "dimension");
|
|
|
+ item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
|
|
|
+
|
|
|
+ // 推荐理由
|
|
|
+ String dimReason = buildRecommendationReason(p, dimensionCode, memberScores);
|
|
|
+ item.put("reason", dimReason);
|
|
|
+
|
|
|
+ // 匹配分
|
|
|
+ double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
|
|
|
+ item.put("matchScore", matchScore);
|
|
|
+
|
|
|
+ candidates.add(item);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // fallback: 从 Product.domain 匹配
|
|
|
- if (candidates.isEmpty()) {
|
|
|
- LambdaQueryWrapper<Product> domainQw = new LambdaQueryWrapper<Product>()
|
|
|
- .eq(Product::getStatus, "on_shelf")
|
|
|
- .gt(Product::getStock, 0)
|
|
|
- .orderByAsc(Product::getSortOrder);
|
|
|
- if (dimensionCode != null && !dimensionCode.isEmpty()) {
|
|
|
- domainQw.eq(Product::getDomain, dimensionCode);
|
|
|
+ // 4. 关联关系推荐(动态:画像缺口、共购、复购)
|
|
|
+ List<Map<String, Object>> assocResults = queryAssociationRecommendations(
|
|
|
+ dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size());
|
|
|
+
|
|
|
+ // 合并并去重(关联推荐优先级高于维度映射,但保留维度的reason/matchScore)
|
|
|
+ if (assocResults != null && !assocResults.isEmpty()) {
|
|
|
+ for (Map<String, Object> item : assocResults) {
|
|
|
+ Long prodId = (Long) item.get("id");
|
|
|
+ // 已在 candidates 中则跳过(由 limit 控制数量,这里不再二次去重复用 id,而是追加)
|
|
|
+ if (candidates.size() >= limit) break;
|
|
|
+ candidates.add(item);
|
|
|
}
|
|
|
- if (!purchasedIds.isEmpty()) {
|
|
|
- domainQw.notIn(Product::getId, purchasedIds);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 最终排序并返回
|
|
|
+ return sortAndReturn(candidates, limit);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询关联关系推荐(画像缺口 + 共购 + 复购)
|
|
|
+ */
|
|
|
+ private List<Map<String, Object>> queryAssociationRecommendations(
|
|
|
+ String dimensionCode, Long familyId, Long memberId,
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
|
|
|
+
|
|
|
+ if (reqCount <= 0) return Collections.emptyList();
|
|
|
+
|
|
|
+ Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
|
|
|
+ List<Map<String, Object>> results = new ArrayList<>();
|
|
|
+
|
|
|
+ // 4.1 画像缺口关联:维度得分低时推荐对应商品
|
|
|
+ if (dimensionCode != null && !dimensionCode.isEmpty()) {
|
|
|
+ List<Map<String, Object>> profileAssocs = queryProfileGapAssociations(
|
|
|
+ dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3);
|
|
|
+ results.addAll(profileAssocs);
|
|
|
+ if (results.size() >= reqCount) return results.subList(0, reqCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4.2 共购关联:买了A的用户也买了B(从订单数据挖掘)
|
|
|
+ if (purchasedIds != null && !purchasedIds.isEmpty()) {
|
|
|
+ List<Map<String, Object>> coPurchaseAssocs = queryCoPurchaseAssociations(
|
|
|
+ purchasedIds, excludeProductIds, reqCount - results.size());
|
|
|
+ results.addAll(coPurchaseAssocs);
|
|
|
+ if (results.size() >= reqCount) return results.subList(0, reqCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4.3 复购关联:买了A后X天再买A的关联商品
|
|
|
+ if (purchasedIds != null && !purchasedIds.isEmpty()) {
|
|
|
+ List<Map<String, Object>> repurchaseAssocs = queryRepurchaseAssociations(
|
|
|
+ purchasedIds, excludeProductIds, reqCount - results.size());
|
|
|
+ results.addAll(repurchaseAssocs);
|
|
|
+ }
|
|
|
+
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 画像缺口关联:某维度得分低时,从关联关系表推荐商品
|
|
|
+ */
|
|
|
+ private List<Map<String, Object>> queryProfileGapAssociations(
|
|
|
+ String dimensionCode, Map<String, Integer> memberScores,
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
|
|
|
+
|
|
|
+ if (reqCount <= 0) return Collections.emptyList();
|
|
|
+
|
|
|
+ // 寻找关联关系:association_type='profile_gap' 且 dimension_code 匹配
|
|
|
+ LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
|
|
|
+ .eq(RecommendationProductAssociation::getAssociationType, "profile_gap")
|
|
|
+ .eq(RecommendationProductAssociation::getDimensionCode, dimensionCode)
|
|
|
+ .eq(RecommendationProductAssociation::getEnabled, 1);
|
|
|
+
|
|
|
+ // 如果有成员得分,筛选“得分低于阈值”才触发推荐
|
|
|
+ if (memberScores != null && !memberScores.isEmpty()) {
|
|
|
+ Integer score = memberScores.get(dimensionCode);
|
|
|
+ if (score != null && score < 70) {
|
|
|
+ // 分数越低,关联强度越高(可选逻辑,此处直接查出所有并按 strength 排序)
|
|
|
+ qw.orderByDesc(RecommendationProductAssociation::getStrength);
|
|
|
}
|
|
|
- SortUtil.applySort(domainQw);
|
|
|
- List<Product> domainMatched = productMapper.selectList(domainQw);
|
|
|
- candidates.addAll(domainMatched);
|
|
|
+ } else {
|
|
|
+ // 无画像数据时,默认中等强度
|
|
|
+ qw.orderByDesc(RecommendationProductAssociation::getStrength);
|
|
|
}
|
|
|
|
|
|
- // 4. 排序并返回
|
|
|
- List<Map<String, Object>> result = new ArrayList<>();
|
|
|
- for (Product p : candidates) {
|
|
|
- if (result.size() >= limit) break;
|
|
|
+ List<Map<String, Object>> results = new ArrayList<>();
|
|
|
+ List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
|
|
|
+ for (RecommendationProductAssociation assoc : assocs) {
|
|
|
+ if (reqCount <= 0 || results.size() >= reqCount) break;
|
|
|
+ // 跳过已购商品
|
|
|
+ if (purchasedIds != null && purchasedIds.contains(assoc.getTargetProductId())) continue;
|
|
|
+ // 跳过被排除
|
|
|
+ if (excludeProductIds != null && excludeProductIds.contains(assoc.getTargetProductId())) continue;
|
|
|
+
|
|
|
+ Product product = productMapper.selectById(assoc.getTargetProductId());
|
|
|
+ if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
|
|
|
|
|
|
Map<String, Object> item = new LinkedHashMap<>();
|
|
|
- item.put("id", p.getId());
|
|
|
- item.put("productId", p.getId());
|
|
|
- item.put("name", p.getName());
|
|
|
- item.put("coverImage", p.getCoverImage());
|
|
|
- item.put("price", p.getPrice());
|
|
|
- item.put("memberPrice", p.getPrice());
|
|
|
- item.put("productType", p.getProductType());
|
|
|
- item.put("source", "dimension");
|
|
|
- item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
|
|
|
-
|
|
|
- // 推荐理由
|
|
|
- String reason = buildRecommendationReason(p, dimensionCode, memberScores);
|
|
|
+ item.put("id", product.getId());
|
|
|
+ item.put("productId", product.getId());
|
|
|
+ item.put("name", product.getName());
|
|
|
+ item.put("coverImage", product.getCoverImage());
|
|
|
+ item.put("price", product.getPrice());
|
|
|
+ item.put("memberPrice", product.getPrice());
|
|
|
+ item.put("productType", product.getProductType());
|
|
|
+ item.put("source", "assoc_profile_gap");
|
|
|
+
|
|
|
+ // 关联强度作为匹配分
|
|
|
+ java.math.BigDecimal strength = assoc.getStrength();
|
|
|
+ item.put("matchScore", strength != null ? strength.doubleValue() / 10.0 : 0.5);
|
|
|
+
|
|
|
+ // 关联理由
|
|
|
+ String reason = assoc.getReason();
|
|
|
+ if (reason == null || reason.isEmpty()) {
|
|
|
+ Integer score = memberScores != null ? memberScores.get(dimensionCode) : null;
|
|
|
+ if (score != null && score < 50) {
|
|
|
+ reason = "该维度得分偏低,重点推荐";
|
|
|
+ } else if (score != null && score < 70) {
|
|
|
+ reason = "该维度有提升空间,推荐关注";
|
|
|
+ } else {
|
|
|
+ reason = "根据维度匹配推荐";
|
|
|
+ }
|
|
|
+ }
|
|
|
item.put("reason", reason);
|
|
|
|
|
|
- // 匹配分
|
|
|
- double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
|
|
|
- item.put("matchScore", matchScore);
|
|
|
-
|
|
|
- result.add(item);
|
|
|
+ results.add(item);
|
|
|
}
|
|
|
-
|
|
|
- return result;
|
|
|
+ return results;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 报告关联推荐(健康报告/认知测评触发)
|
|
|
+ * 共购关联:从订单数据挖掘“买了A的用户也买了B”
|
|
|
*/
|
|
|
- public List<Map<String, Object>> getReportRelatedProducts(
|
|
|
- String reportType, Object analysis, Long userId, int limit) {
|
|
|
+ private List<Map<String, Object>> queryCoPurchaseAssociations(
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
|
|
|
|
|
|
- List<String> dimensionCodes = new ArrayList<>();
|
|
|
- if ("health_report".equals(reportType)) {
|
|
|
- // 从健康分析结果提取维度需求
|
|
|
- dimensionCodes = extractDimensionNeedsFromHealth(analysis);
|
|
|
- } else if ("cognitive_assessment".equals(reportType)) {
|
|
|
- // 从认知测评结果提取弱维度
|
|
|
- dimensionCodes = extractDimensionNeedsFromCognitive(analysis);
|
|
|
+ if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
|
|
|
+
|
|
|
+ List<Map<String, Object>> results = new ArrayList<>();
|
|
|
+
|
|
|
+ // 找出非空的 purchasedIds,查找关联关系
|
|
|
+ LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
|
|
|
+ .eq(RecommendationProductAssociation::getAssociationType, "co_purchase")
|
|
|
+ .eq(RecommendationProductAssociation::getEnabled, 1)
|
|
|
+ .gt(RecommendationProductAssociation::getCoPurchaseCount, 0) // 只取已挖掘的
|
|
|
+ .orderByDesc(RecommendationProductAssociation::getCoPurchaseCount);
|
|
|
+
|
|
|
+ // 限制数量
|
|
|
+ if (reqCount > 0) {
|
|
|
+ qw.last("LIMIT " + reqCount);
|
|
|
}
|
|
|
|
|
|
- if (dimensionCodes.isEmpty()) {
|
|
|
- dimensionCodes.add("body");
|
|
|
+ List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
|
|
|
+ for (RecommendationProductAssociation assoc : assocs) {
|
|
|
+ Long targetId = assoc.getTargetProductId();
|
|
|
+ if (targetId == null) continue;
|
|
|
+ // 跳过已购和被排除
|
|
|
+ if (purchasedIds.contains(targetId)) continue;
|
|
|
+ if (excludeProductIds != null && excludeProductIds.contains(targetId)) continue;
|
|
|
+
|
|
|
+ Product product = productMapper.selectById(targetId);
|
|
|
+ if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("id", product.getId());
|
|
|
+ item.put("productId", product.getId());
|
|
|
+ item.put("name", product.getName());
|
|
|
+ item.put("coverImage", product.getCoverImage());
|
|
|
+ item.put("price", product.getPrice());
|
|
|
+ item.put("memberPrice", product.getPrice());
|
|
|
+ item.put("productType", product.getProductType());
|
|
|
+ item.put("source", "assoc_co_purchase");
|
|
|
+
|
|
|
+ // 共购次数作为强度:次数越多 strength 越高
|
|
|
+ double strength = Math.min(1.0, assoc.getCoPurchaseCount() / 20.0); // 假设20次以上算很强
|
|
|
+ item.put("matchScore", strength);
|
|
|
+
|
|
|
+ // 理由:共购 X 次
|
|
|
+ item.put("reason", "与您常购商品共购 " + assoc.getCoPurchaseCount() + " 次");
|
|
|
+
|
|
|
+ results.add(item);
|
|
|
}
|
|
|
+ return results;
|
|
|
+ }
|
|
|
|
|
|
- // 去重
|
|
|
- List<String> uniqueDims = dimensionCodes.stream().distinct().collect(Collectors.toList());
|
|
|
+ /**
|
|
|
+ * 复购关联:买了A后X天再买相关商品的关联
|
|
|
+ */
|
|
|
+ private List<Map<String, Object>> queryRepurchaseAssociations(
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
|
|
|
|
|
|
- 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);
|
|
|
- }
|
|
|
+ if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
|
|
|
+
|
|
|
+ List<Map<String, Object>> results = new ArrayList<>();
|
|
|
+
|
|
|
+ LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
|
|
|
+ .eq(RecommendationProductAssociation::getAssociationType, "repurchase")
|
|
|
+ .eq(RecommendationProductAssociation::getEnabled, 1);
|
|
|
+
|
|
|
+ if (reqCount > 0) {
|
|
|
+ qw.last("LIMIT " + reqCount);
|
|
|
}
|
|
|
|
|
|
- return result;
|
|
|
+ List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
|
|
|
+ for (RecommendationProductAssociation assoc : assocs) {
|
|
|
+ Long targetId = assoc.getTargetProductId();
|
|
|
+ if (targetId == null) continue;
|
|
|
+ if (purchasedIds.contains(targetId)) continue;
|
|
|
+ if (excludeProductIds != null && excludeProductIds.contains(targetId)) continue;
|
|
|
+
|
|
|
+ Product product = productMapper.selectById(targetId);
|
|
|
+ if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("id", product.getId());
|
|
|
+ item.put("productId", product.getId());
|
|
|
+ item.put("name", product.getName());
|
|
|
+ item.put("coverImage", product.getCoverImage());
|
|
|
+ item.put("price", product.getPrice());
|
|
|
+ item.put("memberPrice", product.getPrice());
|
|
|
+ item.put("productType", product.getProductType());
|
|
|
+ item.put("source", "assoc_repurchase");
|
|
|
+
|
|
|
+ // 复购天数:days越小说明周期越短,推荐意图越强
|
|
|
+ int days = assoc.getRepurchaseDays() != null ? assoc.getRepurchaseDays() : 30;
|
|
|
+ double strength = 1.0 / Math.max(1, days / 7.0); // 每周递减
|
|
|
+ item.put("matchScore", Math.min(1.0, strength));
|
|
|
+
|
|
|
+ item.put("reason", "复购周期 " + days + " 天内关联推荐");
|
|
|
+
|
|
|
+ results.add(item);
|
|
|
+ }
|
|
|
+ return results;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 记录推荐日志
|
|
|
+ * 根据 matchScore 和 strength 进行最终排序
|
|
|
*/
|
|
|
- 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());
|
|
|
- productRecommendationLogMapper.insert(logEntity);
|
|
|
- } catch (Exception e) {
|
|
|
- log.warn("记录推荐日志失败: userId={}, productId={}, error={}", userId, productId, e.getMessage());
|
|
|
+ private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit) {
|
|
|
+ if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
|
|
|
+
|
|
|
+ // 按 matchScore 降序排序
|
|
|
+ candidates.sort((a, b) -> {
|
|
|
+ Double scoreA = (Double) a.get("matchScore");
|
|
|
+ Double scoreB = (Double) b.get("matchScore");
|
|
|
+ if (scoreA == null && scoreB == null) return 0;
|
|
|
+ if (scoreA == null) return 1;
|
|
|
+ if (scoreB == null) return -1;
|
|
|
+ return scoreB.compareTo(scoreA);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 去重(按 productId 去重)
|
|
|
+ Set<Long> seenIds = new HashSet<>();
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ for (Map<String, Object> item : candidates) {
|
|
|
+ Long id = (Long) item.get("id");
|
|
|
+ if (id == null || seenIds.contains(id)) continue;
|
|
|
+ seenIds.add(id);
|
|
|
+ if (result.size() >= limit) break;
|
|
|
+ result.add(item);
|
|
|
}
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
- // ─── Private helpers ───
|
|
|
+ // ── 原有私有辅助方法(保持不变)──
|
|
|
|
|
|
/**
|
|
|
* 获取家庭成员在90天内购买过的商品ID
|
|
|
@@ -329,7 +519,6 @@ public class ProductRecommendationService {
|
|
|
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");
|
|
|
@@ -379,4 +568,49 @@ public class ProductRecommendationService {
|
|
|
}
|
|
|
return dims;
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 报告关联推荐(健康报告/认知测评触发)
|
|
|
+ *
|
|
|
+ * @param reportType 报告类型: "health_report" 或 "cognitive_assessment"
|
|
|
+ * @param analysis 分析结果(可为null,由内部推断维度)
|
|
|
+ * @param userId 用户ID
|
|
|
+ * @param limit 返回数量
|
|
|
+ * @return 推荐商品列表
|
|
|
+ */
|
|
|
+ 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());
|
|
|
+ Set<Long> seenProductIds = new HashSet<>();
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ Long pid = (Long) p.get("id");
|
|
|
+ if (pid == null || seenProductIds.contains(pid)) continue;
|
|
|
+ seenProductIds.add(pid);
|
|
|
+ p.put("triggerDimension", dim);
|
|
|
+ result.add(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
}
|