瀏覽代碼

feat(backend): 文章关联商品标签匹配推荐(ArticleController/Product/ProductRecommendationService + schema products.tags)

E2E Test Bot 1 周之前
父節點
當前提交
9090ff7207

+ 40 - 39
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -30,45 +30,46 @@ public class JwtInterceptor implements HandlerInterceptor {
     private FamilyMemberMapper familyMemberMapper;
 
 private static final String[] PUBLIC_PATHS = {
-        "/api/auth/send-code",
-        "/api/auth/phone-login",
-        "/api/auth/wechat-phone-login",
-        "/api/auth/silent-login",
-        "/api/auth/sso-login",
-        "/api/auth/auto-login",
-        "/api/auth/register-with-idcard",
-        "/api/auth/wechat-login",
-        "/api/auth/verify",
-        "/api/auth/check-phone",
-        "/api/auth/direct-register",
-        "/api/auth/register-with-invite",
-        "/api/auth/wechat-web/qr",
-        "/api/auth/wechat-web/poll",
-        "/api/internal/",
-        "/api/product/list",
-        "/api/product/detail",
-        "/api/product/type-list",
-        "/api/mini-game/list",
-        "/api/admin-auth/send-code",
-        "/api/admin-auth/login",
-        "/api/admin-auth/login-by-password",
-        "/api/admin-auth/account-roles",
-        "/api/articles/list",
-        "/api/articles/detail",
-        "/api/articles/categories",
-        "/api/articles/featured",
-        "/api/articles/record-read",
-        "/api/growth/**",
-        "/api/activity/list",
-        "/api/activity/detail",
-        "/api/activity/order/notify",
-        "/api/content-sections/visible",
-        "/api/mind/alert/active",
-        "/api/mind/alert/checkin-alerts",
-        "/api/mind/traditional/mirror",
-        "/api/mind/traditional/compatibility",
-        "/api/mcp/**"
-    };
+         "/api/auth/send-code",
+         "/api/auth/phone-login",
+         "/api/auth/wechat-phone-login",
+         "/api/auth/silent-login",
+         "/api/auth/sso-login",
+         "/api/auth/auto-login",
+         "/api/auth/register-with-idcard",
+         "/api/auth/wechat-login",
+         "/api/auth/verify",
+         "/api/auth/check-phone",
+         "/api/auth/direct-register",
+         "/api/auth/register-with-invite",
+         "/api/auth/wechat-web/qr",
+         "/api/auth/wechat-web/poll",
+         "/api/auth/internal/",
+         "/api/product/list",
+         "/api/product/detail",
+         "/api/product/type-list",
+         "/api/mini-game/list",
+         "/api/admin-auth/send-code",
+         "/api/admin-auth/login",
+         "/api/admin-auth/login-by-password",
+         "/api/admin-auth/account-roles",
+         "/api/articles/list",
+         "/api/articles/detail",
+         "/api/articles/categories",
+         "/api/articles/featured",
+         "/api/articles/record-read",
+         "/api/articles/related-products",
+         "/api/growth/**",
+         "/api/activity/list",
+         "/api/activity/detail",
+         "/api/activity/order/notify",
+         "/api/content-sections/visible",
+         "/api/mind/alert/active",
+         "/api/mind/alert/checkin-alerts",
+         "/api/mind/traditional/mirror",
+         "/api/mind/traditional/compatibility",
+         "/api/mcp/**"
+     };
 
     // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
     private static final String[] LOGIN_REQUIRED_PATHS = {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -117,6 +117,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/categories",
                 "/api/articles/featured",
                 "/api/articles/record-read",
+                "/api/articles/related-products",
                 "/api/growth/**",
                 "/api/articles/updated-since",
                 "/api/activity/list",
@@ -162,6 +163,7 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/categories",
                 "/api/articles/featured",
                 "/api/articles/record-read",
+                "/api/articles/related-products",
                 "/api/growth/**",
                 "/api/activity/list",
                 "/api/activity/detail",

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.ArticleCategoryService;
 import com.etotem.cfc.service.ArticleService;
+import com.etotem.cfc.service.ProductRecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -34,6 +35,30 @@ public class ArticleController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    @PostMapping("/related-products")
+    public Result<List<Map<String, Object>>> relatedProducts(@RequestBody Map<String, Object> body,
+                                                             @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long articleId = ParamUtils.getLong(body.get("articleId"));
+        if (articleId == null) {
+            return Result.error("articleId不能为空");
+        }
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 6;
+
+        Long familyId = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+            }
+        }
+
+        List<Map<String, Object>> products = productRecommendationService.getArticleRelatedProducts(articleId, userId, familyId, size);
+        return Result.success(products);
+    }
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
                                        @RequestAttribute(value = "userId", required = false) Long userId,

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -66,6 +66,9 @@ public class Product implements Serializable {
     /** 是否推荐商品: 0=否 1=是(推荐时同步至营养产品表) */
     private Integer isRecommended;
 
+    /** JSON: ["标签1","标签2"],用于文章-商品标签匹配推荐 */
+    private String tags;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 
@@ -112,6 +115,7 @@ public class Product implements Serializable {
     public String getMinVisibleLevel() { return minVisibleLevel; }
     public String getMinDiscountLevel() { return minDiscountLevel; }
     public Boolean getVendorVisible() { return vendorVisible; }
+    public String getTags() { return tags; }
     public Date getCreatedAt() { return createdAt; }
     public Date getUpdatedAt() { return updatedAt; }
 }

+ 168 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -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;
     }
+
+    /**
+     * 文章关联商品推荐:通过文章标签与商品标签匹配,按 标签匹配数 &gt; 用户权重 &gt; 家庭权重 &gt; 销量 排序
+     *
+     * @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;
+    }
 }

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1191,6 +1191,7 @@ CREATE TABLE IF NOT EXISTS products (
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
     min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
     is_recommended TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是',
+    tags VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';