|
@@ -1 +1,986 @@
|
|
|
-"213: return sortAndReturn(candidates, limit, userId, familyId);"
|
|
|
|
|
|
|
+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;
|
|
|
|
|
+import com.etotem.cfc.util.SortUtil;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 商品推荐服务
|
|
|
|
|
+ * 支持三类推荐场景:
|
|
|
|
|
+ * 1. 维度页推荐(基于维度匹配 + 关联关系)
|
|
|
|
|
+ * 2. AI对话推荐(基于画像缺口 + 购买关联)
|
|
|
|
|
+ * 3. 报告关联推荐(健康报告/认知测评触发)
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 核心思路:<strong>静态维度映射</strong> 为基础,<strong>动态关联关系表</strong> 为补充,
|
|
|
|
|
+ * 实现<strong>画像驱动</strong>与<strong>购买关联</strong>双轮驱动的个性化推荐。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class ProductRecommendationService {
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProductMapper productMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProductDimensionMappingMapper dimensionMappingMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProductOrderMapper orderMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FiveDimensionScoreMapper fiveDimensionScoreMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProductRecommendationLogMapper productRecommendationLogMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthFilterService healthFilterService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ReferralTreeMapper referralTreeMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private SupplySystemMemberMapper supplySystemMemberMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private SupplySystemService supplySystemService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private UserContentWeightService userContentWeightService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 维度页推荐
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param dimensionCode 维度编码: body/wisdom/mind/action/wealth
|
|
|
|
|
+ * @param familyId 家庭ID
|
|
|
|
|
+ * @param memberId 成员ID(可选)
|
|
|
|
|
+ * @param excludeProductIds 排除的商品ID列表
|
|
|
|
|
+ * @param limit 返回数量
|
|
|
|
|
+ * @param userId 当前登录用户ID(用于供应商优先级计算,可为null)
|
|
|
|
|
+ * @return 推荐商品列表
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<Map<String, Object>> getDimensionRecommendations(
|
|
|
|
|
+ String dimensionCode, Long familyId, Long memberId,
|
|
|
|
|
+ List<Long> excludeProductIds, int limit, Long userId) {
|
|
|
|
|
+
|
|
|
|
|
+ // 0. 加载成员健康画像(硬拦截基础)
|
|
|
|
|
+ HealthFilterService.HealthProfile healthProfile = healthFilterService.loadProfile(memberId, null, null);
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 获取已购商品ID(90天内)
|
|
|
|
|
+ Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
|
|
|
|
|
+ if (excludeProductIds != null) {
|
|
|
|
|
+ purchasedIds.addAll(excludeProductIds);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 获取成员维度得分(画像)
|
|
|
|
|
+ Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 从维度匹配表获取候选商品(静态基础)
|
|
|
|
|
+ List<Map<String, Object>> candidates = new ArrayList<>();
|
|
|
|
|
+
|
|
|
|
|
+ // 从 mapping 表获取关联商品
|
|
|
|
|
+ LambdaQueryWrapper<ProductDimensionMapping> mappingQw = new LambdaQueryWrapper<ProductDimensionMapping>()
|
|
|
|
|
+ .eq(ProductDimensionMapping::getEnabled, 1);
|
|
|
|
|
+ if (dimensionCode != null && !dimensionCode.isEmpty()) {
|
|
|
|
|
+ mappingQw.eq(ProductDimensionMapping::getDimensionCode, dimensionCode);
|
|
|
|
|
+ }
|
|
|
|
|
+ SortUtil.applySort(mappingQw);
|
|
|
|
|
+ List<ProductDimensionMapping> mappings = dimensionMappingMapper.selectList(mappingQw);
|
|
|
|
|
+
|
|
|
|
|
+ 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, "on_shelf")
|
|
|
|
|
+ .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
|
|
|
|
|
+ .orderByAsc(Product::getSortOrder)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ HealthFilterService.FilterResult mappedFilter = healthFilterService.filterProducts(healthProfile, mapped);
|
|
|
|
|
+ mapped = mappedFilter.allowed;
|
|
|
|
|
+
|
|
|
|
|
+ // 构建 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);
|
|
|
|
|
+ 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-detail/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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3.5 fallback: 当 dimension_mapping 表无数据时,从 Product.domain 匹配
|
|
|
|
|
+ if (candidates.isEmpty()) {
|
|
|
|
|
+ LambdaQueryWrapper<Product> domainQw = new LambdaQueryWrapper<Product>()
|
|
|
|
|
+ .eq(Product::getStatus, "on_shelf")
|
|
|
|
|
+ .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
|
|
|
|
|
+ .orderByAsc(Product::getSortOrder);
|
|
|
|
|
+ if (dimensionCode != null && !dimensionCode.isEmpty()) {
|
|
|
|
|
+ domainQw.eq(Product::getDomain, dimensionCode);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!purchasedIds.isEmpty()) {
|
|
|
|
|
+ domainQw.notIn(Product::getId, purchasedIds);
|
|
|
|
|
+ }
|
|
|
|
|
+ SortUtil.applySort(domainQw);
|
|
|
|
|
+ List<Product> domainMatched = productMapper.selectList(domainQw);
|
|
|
|
|
+ HealthFilterService.FilterResult domainFilter = healthFilterService.filterProducts(healthProfile, domainMatched);
|
|
|
|
|
+ domainMatched = domainFilter.allowed;
|
|
|
|
|
+ for (Product p : domainMatched) {
|
|
|
|
|
+ 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-detail/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);
|
|
|
|
|
+ candidates.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 关联关系推荐(动态:画像缺口、共购、复购)
|
|
|
|
|
+ List<Map<String, Object>> assocResults = queryAssociationRecommendations(
|
|
|
|
|
+ dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile);
|
|
|
|
|
+
|
|
|
|
|
+ // 合并并去重(关联推荐优先级高于维度映射,但保留维度的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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 最终排序并返回
|
|
|
|
|
+ return sortAndReturn(candidates, limit, userId, familyId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 查询关联关系推荐(画像缺口 + 共购 + 复购)
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<Map<String, Object>> queryAssociationRecommendations(
|
|
|
|
|
+ String dimensionCode, Long familyId, Long memberId,
|
|
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
|
|
|
|
|
+ HealthFilterService.HealthProfile healthProfile) {
|
|
|
|
|
+
|
|
|
|
|
+ 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, healthProfile);
|
|
|
|
|
+ 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(), healthProfile);
|
|
|
|
|
+ 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(), healthProfile);
|
|
|
|
|
+ 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,
|
|
|
|
|
+ HealthFilterService.HealthProfile healthProfile) {
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 无画像数据时,默认中等强度
|
|
|
|
|
+ qw.orderByDesc(RecommendationProductAssociation::getStrength);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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()) || !hasSellableStock(product)) continue;
|
|
|
|
|
+ if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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_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);
|
|
|
|
|
+
|
|
|
|
|
+ results.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ return results;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 共购关联:从订单数据挖掘“买了A的用户也买了B”
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<Map<String, Object>> queryCoPurchaseAssociations(
|
|
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
|
|
|
|
|
+ HealthFilterService.HealthProfile healthProfile) {
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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()) || !hasSellableStock(product)) continue;
|
|
|
|
|
+ if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 复购关联:买了A后X天再买相关商品的关联
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<Map<String, Object>> queryRepurchaseAssociations(
|
|
|
|
|
+ Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
|
|
|
|
|
+ HealthFilterService.HealthProfile healthProfile) {
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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()) || !hasSellableStock(product)) continue;
|
|
|
|
|
+ if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 计算供应商优先级加分
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 规则1:若直接引荐人是某产品供应商(supply_system.admin),优先推荐其体系内产品
|
|
|
|
|
+ * 规则2:沿引荐链向上追溯,找到任一层级是供应商的引荐人,优先推荐其产品
|
|
|
|
|
+ * 规则3:同一供应商体系引荐的用户越多,权重越高(按体系成员数加权)
|
|
|
|
|
+ * 补充规则:用户所属多个供应商体系时,其关联产品均优先推荐
|
|
|
|
|
+ */
|
|
|
|
|
+ private Map<Long, Double> getSupplierPriorityScores(Long userId) {
|
|
|
|
|
+ if (userId == null) return Collections.emptyMap();
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 收集引荐链上的所有祖先(直接推荐人、间接推荐人)
|
|
|
|
|
+ List<ReferralTree> ancestorNodes = referralTreeMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<ReferralTree>()
|
|
|
|
|
+ .eq(ReferralTree::getChildId, userId)
|
|
|
|
|
+ .orderByAsc(ReferralTree::getLevel));
|
|
|
|
|
+ if (ancestorNodes.isEmpty()) return Collections.emptyMap();
|
|
|
|
|
+
|
|
|
|
|
+ // 祖先 userId -> 层级(level 越小=关系越近,权重越高)
|
|
|
|
|
+ Map<Long, Integer> ancestorLevels = new HashMap<>();
|
|
|
|
|
+ for (ReferralTree node : ancestorNodes) {
|
|
|
|
|
+ ancestorLevels.putIfAbsent(node.getParentId(), node.getLevel());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 找出引荐链中的供应商(adminId 对应 supply_system)
|
|
|
|
|
+ // supplySystemId -> 引荐层级(取最小即最近层级,权重最高)
|
|
|
|
|
+ Map<Long, Integer> supplierSystemToNearestLevel = new HashMap<>();
|
|
|
|
|
+ for (Map.Entry<Long, Integer> entry : ancestorLevels.entrySet()) {
|
|
|
|
|
+ Long ancestorId = entry.getKey();
|
|
|
|
|
+ Integer level = entry.getValue();
|
|
|
|
|
+ SupplySystem system = supplySystemService.getByAdminId(ancestorId);
|
|
|
|
|
+ if (system != null) {
|
|
|
|
|
+ supplierSystemToNearestLevel.merge(system.getId(), level,
|
|
|
|
|
+ (existing, newVal) -> Math.min(existing, newVal));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 也检查当前用户本身是否属于某些供应商体系(补充规则)
|
|
|
|
|
+ List<SupplySystemMember> userSystems = supplySystemMemberMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<SupplySystemMember>()
|
|
|
|
|
+ .eq(SupplySystemMember::getUserId, userId)
|
|
|
|
|
+ .eq(SupplySystemMember::getStatus, "active"));
|
|
|
|
|
+ for (SupplySystemMember mem : userSystems) {
|
|
|
|
|
+ supplierSystemToNearestLevel.merge(mem.getSystemId(), 0,
|
|
|
|
|
+ (existing, newVal) -> Math.min(existing, newVal));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (supplierSystemToNearestLevel.isEmpty()) return Collections.emptyMap();
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 计算每个供应商体系的成员数作为权重(规则3)
|
|
|
|
|
+ // supplySystemId -> 加分系数 = memberCount * 0.1 / levelPenalty
|
|
|
|
|
+ // levelPenalty: 直接推荐人(level=1) 系数为1.0,每远一层衰减
|
|
|
|
|
+ Map<Long, Double> systemBoosts = new HashMap<>();
|
|
|
|
|
+ for (Map.Entry<Long, Integer> sysEntry : supplierSystemToNearestLevel.entrySet()) {
|
|
|
|
|
+ Long systemId = sysEntry.getKey();
|
|
|
|
|
+ Integer level = sysEntry.getValue();
|
|
|
|
|
+ // 成员数
|
|
|
|
|
+ Long memberCount = supplySystemMemberMapper.selectCount(
|
|
|
|
|
+ new LambdaQueryWrapper<SupplySystemMember>()
|
|
|
|
|
+ .eq(SupplySystemMember::getSystemId, systemId)
|
|
|
|
|
+ .eq(SupplySystemMember::getStatus, "active"));
|
|
|
|
|
+ int count = memberCount == null ? 0 : memberCount.intValue();
|
|
|
|
|
+ // 层级衰减:level=1(直接推荐人)不打折,level越大折扣越大
|
|
|
|
|
+ double levelFactor = Math.max(0.1, 1.0 - (level - 1) * 0.15);
|
|
|
|
|
+ // 加分系数 = 成员数 * 0.1 * 层级因子,基础分50,最大加成5分
|
|
|
|
|
+ double boost = Math.min(5.0, count * 0.1 * levelFactor);
|
|
|
|
|
+ systemBoosts.put(systemId, boost);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 按 distributionSystemId 聚合加分
|
|
|
|
|
+ // 同一个产品的 distributionSystemId 对应多个体系时,取最高加分
|
|
|
|
|
+ Map<Long, Double> productIdBoost = new HashMap<>();
|
|
|
|
|
+ for (Map.Entry<Long, Double> sysEntry : systemBoosts.entrySet()) {
|
|
|
|
|
+ Long systemId = sysEntry.getKey();
|
|
|
|
|
+ Double boost = sysEntry.getValue();
|
|
|
|
|
+ List<Product> products = productMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
|
|
+ .eq(Product::getDistributionSystemId, systemId)
|
|
|
|
|
+ .eq(Product::getStatus, "on_shelf"));
|
|
|
|
|
+ for (Product p : products) {
|
|
|
|
|
+ productIdBoost.merge(p.getId(), boost, Math::max);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return productIdBoost;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 根据 matchScore 和供应商优先级进行最终排序
|
|
|
|
|
+ * 权重表有数据时按家庭权重总和排序,无权重数据时回退 matchScore
|
|
|
|
|
+ */
|
|
|
|
|
+ private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId, Long familyId) {
|
|
|
|
|
+ if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
|
|
|
|
|
+
|
|
|
|
|
+ // 计算供应商优先级加分(规则1~3)
|
|
|
|
|
+ Map<Long, Double> supplierPriorityScores = getSupplierPriorityScores(userId);
|
|
|
|
|
+
|
|
|
|
|
+ // 将供应商优先级加入 matchScore
|
|
|
|
|
+ if (!supplierPriorityScores.isEmpty()) {
|
|
|
|
|
+ for (Map<String, Object> item : candidates) {
|
|
|
|
|
+ Long productId = (Long) item.get("productId");
|
|
|
|
|
+ Double priorityBoost = productId != null ? supplierPriorityScores.get(productId) : null;
|
|
|
|
|
+ Double currentScore = (Double) item.get("matchScore");
|
|
|
|
|
+ if (priorityBoost != null && priorityBoost > 0) {
|
|
|
|
|
+ double boostedScore = (currentScore != null ? currentScore : 0) + priorityBoost;
|
|
|
|
|
+ item.put("matchScore", boostedScore);
|
|
|
|
|
+ }
|
|
|
|
|
+ item.put("supplierPriorityBoost", priorityBoost != null ? priorityBoost : 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 计算每个商品的家庭权重总和(家庭聚合:Σ成员权重,方向一致均为正向)
|
|
|
|
|
+ Map<Long, Double> productWeightSum = new HashMap<>();
|
|
|
|
|
+ if (familyId != null) {
|
|
|
|
|
+ for (Map<String, Object> item : candidates) {
|
|
|
|
|
+ Long productId = (Long) item.get("productId");
|
|
|
|
|
+ if (productId == null) continue;
|
|
|
|
|
+ // 查该家庭所有成员对该商品的权重,求和
|
|
|
|
|
+ List<UserContentWeight> weights = userContentWeightService.listWeights(null, familyId, "product", productId);
|
|
|
|
|
+ double sum = 0.0;
|
|
|
|
|
+ if (weights != null) {
|
|
|
|
|
+ for (UserContentWeight w : weights) {
|
|
|
|
|
+ sum += w.getWeight() != null ? w.getWeight().doubleValue() : 0.0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ productWeightSum.put(productId, sum);
|
|
|
|
|
+ item.put("userWeight", sum);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 排序:优先按用户权重(降序),权重相同或无权重数据时回退 matchScore(降序)
|
|
|
|
|
+ candidates.sort((a, b) -> {
|
|
|
|
|
+ Double weightA = productWeightSum.getOrDefault((Long) a.get("productId"), 0.0);
|
|
|
|
|
+ Double weightB = productWeightSum.getOrDefault((Long) b.get("productId"), 0.0);
|
|
|
|
|
+ int cmp = weightB.compareTo(weightA);
|
|
|
|
|
+ if (cmp != 0) return cmp;
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 原有私有辅助方法(保持不变)──
|
|
|
|
|
+
|
|
|
|
|
+ // 仅实物商品要求库存>0(库存NULL视为可购),与 ProductOrderService 下单校验口径一致;虚拟商品不看库存
|
|
|
|
|
+ private boolean hasSellableStock(Product product) {
|
|
|
|
|
+ if (!"physical".equals(product.getProductType())) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return product.getStock() == null || product.getStock() > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取家庭成员在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)
|
|
|
|
|
+ .in(ProductOrder::getStatus, "paid", "pending_receipt", "shipped", "completed")
|
|
|
|
|
+ .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);
|
|
|
|
|
+
|
|
|
|
|
+ SortUtil.applySort(qw);
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ if (dimensionCode == null || dimensionCode.isEmpty()) {
|
|
|
|
|
+ return baseScore;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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;
|
|
|
|
|
+ if (dimensionCode == null || dimensionCode.isEmpty()) {
|
|
|
|
|
+ Integer minScore = memberScores.values().stream().min(Integer::compareTo).orElse(null);
|
|
|
|
|
+ if (minScore != null) {
|
|
|
|
|
+ if (minScore < 50) base = 90;
|
|
|
|
|
+ else if (minScore < 70) base = 80;
|
|
|
|
|
+ }
|
|
|
|
|
+ return base;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ if (dimensionCode == null || dimensionCode.isEmpty()) {
|
|
|
|
|
+ return "为您推荐的好物";
|
|
|
|
|
+ }
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 报告关联推荐(健康报告/认知测评触发)
|
|
|
|
|
+ *
|
|
|
|
|
+ * @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(), null);
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 文章关联商品推荐:通过文章标签与商品标签匹配,按 标签匹配数 > 用户权重 > 家庭权重 > 销量 排序
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param articleId 文章ID
|
|
|
|
|
+ * @param userId 当前用户ID(可为空,用于用户权重)
|
|
|
|
|
+ * @param familyId 家庭ID(可为空,用于家庭权重)
|
|
|
|
|
+ * @param limit 返回数量
|
|
|
|
|
+ * @return 关联商品列表
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<Map<String, Object>> getArticleRelatedProducts(Long articleId, Long userId, Long familyId, int limit) {
|
|
|
|
|
+ if (articleId == null || limit <= 0) return Collections.emptyList();
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 获取文章标签
|
|
|
|
|
+ Article article = articleMapper.selectById(articleId);
|
|
|
|
|
+ if (article == null || article.getTags() == null || article.getTags().trim().isEmpty()) {
|
|
|
|
|
+ return Collections.emptyList();
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> articleTags = parseTagList(article.getTags());
|
|
|
|
|
+ if (articleTags.isEmpty()) return Collections.emptyList();
|
|
|
|
|
+ Set<String> articleTagSet = new HashSet<>(articleTags);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 查询所有在售且有库存的商品
|
|
|
|
|
+ List<Product> products = productMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
|
|
+ .eq(Product::getStatus, "on_shelf")
|
|
|
|
|
+ .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
|
|
|
|
|
+ );
|
|
|
|
|
+ if (products == null || products.isEmpty()) return Collections.emptyList();
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 预加载用户权重(推荐曝光日志:购买=1.0,点击=0.6)
|
|
|
|
|
+ Map<Long, Double> userWeights = new HashMap<>();
|
|
|
|
|
+ if (userId != null) {
|
|
|
|
|
+ List<ProductRecommendationLog> logs = productRecommendationLogMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<ProductRecommendationLog>()
|
|
|
|
|
+ .eq(ProductRecommendationLog::getUserId, userId));
|
|
|
|
|
+ if (logs != null) {
|
|
|
|
|
+ for (ProductRecommendationLog log : logs) {
|
|
|
|
|
+ if (log.getProductId() == null) continue;
|
|
|
|
|
+ double w = userWeights.getOrDefault(log.getProductId(), 0.0);
|
|
|
|
|
+ if (log.getWasPurchased() != null && log.getWasPurchased() == 1) {
|
|
|
|
|
+ w += 1.0;
|
|
|
|
|
+ } else if (log.getWasClicked() != null && log.getWasClicked() == 1) {
|
|
|
|
|
+ w += 0.6;
|
|
|
|
|
+ }
|
|
|
|
|
+ userWeights.put(log.getProductId(), w);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 预加载家庭权重(家庭订单数量)
|
|
|
|
|
+ Map<Long, Double> familyWeights = new HashMap<>();
|
|
|
|
|
+ if (familyId != null) {
|
|
|
|
|
+ List<ProductOrder> orders = orderMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<ProductOrder>()
|
|
|
|
|
+ .eq(ProductOrder::getFamilyId, familyId)
|
|
|
|
|
+ .in(ProductOrder::getStatus, "paid", "pending_receipt", "shipped", "completed"));
|
|
|
|
|
+ if (orders != null) {
|
|
|
|
|
+ for (ProductOrder order : orders) {
|
|
|
|
|
+ if (order.getProductId() == null) continue;
|
|
|
|
|
+ familyWeights.merge(order.getProductId(), 1.0, Double::sum);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 计算标签匹配数与权重,构建候选列表
|
|
|
|
|
+ List<Map<String, Object>> candidates = new ArrayList<>();
|
|
|
|
|
+ for (Product product : products) {
|
|
|
|
|
+ if (product.getTags() == null || product.getTags().trim().isEmpty()) continue;
|
|
|
|
|
+ List<String> productTags = parseTagList(product.getTags());
|
|
|
|
|
+ if (productTags.isEmpty()) continue;
|
|
|
|
|
+
|
|
|
|
|
+ int matchCount = 0;
|
|
|
|
|
+ for (String tag : productTags) {
|
|
|
|
|
+ if (articleTagSet.contains(tag)) matchCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (matchCount == 0) continue;
|
|
|
|
|
+
|
|
|
|
|
+ double userWeight = userWeights.getOrDefault(product.getId(), 0.0);
|
|
|
|
|
+ double familyWeight = familyWeights.getOrDefault(product.getId(), 0.0);
|
|
|
|
|
+
|
|
|
|
|
+ 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.getMemberPrice() != null ? product.getMemberPrice() : product.getPrice());
|
|
|
|
|
+ item.put("productType", product.getProductType());
|
|
|
|
|
+ item.put("tagMatchCount", matchCount);
|
|
|
|
|
+ item.put("userWeight", userWeight);
|
|
|
|
|
+ item.put("familyWeight", familyWeight);
|
|
|
|
|
+ item.put("reason", "与文章标签匹配 " + matchCount + " 个标签");
|
|
|
|
|
+ item.put("source", "article_tag");
|
|
|
|
|
+ item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + product.getId());
|
|
|
|
|
+ candidates.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 排序:标签匹配数(降序) > 用户权重(降序) > 家庭权重(降序) > 销量(降序)
|
|
|
|
|
+ candidates.sort((a, b) -> {
|
|
|
|
|
+ int matchA = (Integer) a.get("tagMatchCount");
|
|
|
|
|
+ int matchB = (Integer) b.get("tagMatchCount");
|
|
|
|
|
+ if (matchA != matchB) return matchB - matchA;
|
|
|
|
|
+
|
|
|
|
|
+ double userA = (Double) a.get("userWeight");
|
|
|
|
|
+ double userB = (Double) b.get("userWeight");
|
|
|
|
|
+ if (userA != userB) return Double.compare(userB, userA);
|
|
|
|
|
+
|
|
|
|
|
+ double familyA = (Double) a.get("familyWeight");
|
|
|
|
|
+ double familyB = (Double) b.get("familyWeight");
|
|
|
|
|
+ if (familyA != familyB) return Double.compare(familyB, familyA);
|
|
|
|
|
+
|
|
|
|
|
+ Product pa = findProductById(products, (Long) a.get("productId"));
|
|
|
|
|
+ Product pb = findProductById(products, (Long) b.get("productId"));
|
|
|
|
|
+ int salesA = pa != null && pa.getSalesCount() != null ? pa.getSalesCount() : 0;
|
|
|
|
|
+ int salesB = pb != null && pb.getSalesCount() != null ? pb.getSalesCount() : 0;
|
|
|
|
|
+ return salesB - salesA;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 7. 截取 limit 条
|
|
|
|
|
+ if (candidates.size() > limit) {
|
|
|
|
|
+ return candidates.subList(0, limit);
|
|
|
|
|
+ }
|
|
|
|
|
+ return candidates;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 解析标签 JSON 数组字符串(兼容逗号分隔) */
|
|
|
|
|
+ private List<String> parseTagList(String tagsJson) {
|
|
|
|
|
+ if (tagsJson == null || tagsJson.trim().isEmpty()) return Collections.emptyList();
|
|
|
|
|
+ String trimmed = tagsJson.trim();
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (trimmed.startsWith("[")) {
|
|
|
|
|
+ com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(trimmed);
|
|
|
|
|
+ if (arr == null) return Collections.emptyList();
|
|
|
|
|
+ List<String> tags = new ArrayList<>();
|
|
|
|
|
+ for (int i = 0; i < arr.size(); i++) {
|
|
|
|
|
+ Object o = arr.get(i);
|
|
|
|
|
+ if (o != null && !o.toString().trim().isEmpty()) {
|
|
|
|
|
+ tags.add(o.toString().trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return tags;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> tags = new ArrayList<>();
|
|
|
|
|
+ for (String raw : trimmed.split(",")) {
|
|
|
|
|
+ String tag = raw.trim();
|
|
|
|
|
+ if (!tag.isEmpty()) tags.add(tag);
|
|
|
|
|
+ }
|
|
|
|
|
+ return tags;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析标签失败: {}", e.getMessage());
|
|
|
|
|
+ return Collections.emptyList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 在产品列表中查找商品 */
|
|
|
|
|
+ private Product findProductById(List<Product> products, Long id) {
|
|
|
|
|
+ if (products == null || id == null) return null;
|
|
|
|
|
+ for (Product p : products) {
|
|
|
|
|
+ if (id.equals(p.getId())) return p;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|