|
|
@@ -55,6 +55,9 @@ public class ProductRecommendationService {
|
|
|
@Resource
|
|
|
private SupplySystemService supplySystemService;
|
|
|
|
|
|
+ @Resource
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
+
|
|
|
/**
|
|
|
* 维度页推荐
|
|
|
*
|
|
|
@@ -133,7 +136,7 @@ public class ProductRecommendationService {
|
|
|
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());
|
|
|
+ item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + p.getId());
|
|
|
|
|
|
// 推荐理由
|
|
|
String dimReason = buildRecommendationReason(p, dimensionCode, memberScores);
|
|
|
@@ -172,7 +175,7 @@ public class ProductRecommendationService {
|
|
|
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());
|
|
|
+ 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);
|
|
|
@@ -770,4 +773,167 @@ public class ProductRecommendationService {
|
|
|
|
|
|
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;
|
|
|
+ }
|
|
|
}
|