Browse Source

feat(nutrition): 新增营养产品推荐Service及DTO

Xiaogang Liao 1 month ago
parent
commit
8b462ca340

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/NutritionRecommendDTO.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class NutritionRecommendDTO {
+
+    @Data
+    public static class SearchQuery {
+        private String name;
+        private Long brandId;
+        private Long categoryId;
+    }
+
+    @Data
+    public static class RecommendQuery {
+        /** 适用人群 */
+        private String suitableFor;
+        /** 目标成分关键词列表 */
+        private List<String> targetIngredients;
+        /** 预算上限 */
+        private BigDecimal maxPrice;
+        /** 返回数量 */
+        private Integer limit;
+    }
+}

+ 240 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NutritionProductService.java

@@ -0,0 +1,240 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.NutritionRecommendDTO;
+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.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class NutritionProductService {
+
+    @Resource
+    private NutritionCategoryMapper categoryMapper;
+
+    @Resource
+    private NutritionBrandMapper brandMapper;
+
+    @Resource
+    private NutritionProductMapper productMapper;
+
+    @Resource
+    private NutritionIngredientMapper ingredientMapper;
+
+    @Resource
+    private NutritionPlatformListingMapper listingMapper;
+
+    @Resource
+    private NutritionProductReviewMapper reviewMapper;
+
+    /** 分类列表(树结构) */
+    public List<NutritionCategory> listCategories() {
+        return categoryMapper.selectList(
+            new LambdaQueryWrapper<NutritionCategory>()
+                .orderByAsc(NutritionCategory::getSortOrder)
+                .orderByAsc(NutritionCategory::getId)
+        );
+    }
+
+    /** 品牌列表(含产品数) */
+    public List<Map<String, Object>> listBrands() {
+        List<NutritionBrand> brands = brandMapper.selectList(null);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (NutritionBrand brand : brands) {
+            Long count = productMapper.selectCount(
+                new LambdaQueryWrapper<NutritionProduct>()
+                    .eq(NutritionProduct::getBrandId, brand.getId())
+            );
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", brand.getId());
+            item.put("name", brand.getName());
+            item.put("logoUrl", brand.getLogoUrl());
+            item.put("country", brand.getCountry());
+            item.put("productCount", count);
+            result.add(item);
+        }
+        return result;
+    }
+
+    /** 产品搜索 */
+    public List<NutritionProduct> search(NutritionRecommendDTO.SearchQuery query) {
+        LambdaQueryWrapper<NutritionProduct> wrapper = new LambdaQueryWrapper<>();
+        if (query.getName() != null && !query.getName().isEmpty()) {
+            wrapper.like(NutritionProduct::getName, query.getName());
+        }
+        if (query.getBrandId() != null) {
+            wrapper.eq(NutritionProduct::getBrandId, query.getBrandId());
+        }
+        if (query.getCategoryId() != null) {
+            wrapper.eq(NutritionProduct::getCategoryId, query.getCategoryId());
+        }
+        wrapper.eq(NutritionProduct::getStatus, 1);
+        wrapper.orderByDesc(NutritionProduct::getUpdateTime);
+        return productMapper.selectList(wrapper);
+    }
+
+    /** 产品详情(含成分、各平台价格、评价) */
+    public Map<String, Object> detail(Long productId) {
+        NutritionProduct product = productMapper.selectById(productId);
+        if (product == null) {
+            return null;
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("product", product);
+        if (product.getBrandId() != null) {
+            result.put("brand", brandMapper.selectById(product.getBrandId()));
+        }
+        if (product.getCategoryId() != null) {
+            result.put("category", categoryMapper.selectById(product.getCategoryId()));
+        }
+        result.put("ingredients", ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, productId)
+        ));
+        List<NutritionPlatformListing> listings = listingMapper.selectList(
+            new LambdaQueryWrapper<NutritionPlatformListing>()
+                .eq(NutritionPlatformListing::getProductId, productId)
+        );
+        result.put("platformListings", listings);
+        Map<Long, NutritionProductReview> reviewMap = new HashMap<>();
+        for (NutritionPlatformListing listing : listings) {
+            NutritionProductReview review = reviewMapper.selectOne(
+                new LambdaQueryWrapper<NutritionProductReview>()
+                    .eq(NutritionProductReview::getPlatformListingId, listing.getId())
+                    .last("LIMIT 1")
+            );
+            if (review != null) {
+                reviewMap.put(listing.getId(), review);
+            }
+        }
+        result.put("reviews", reviewMap);
+        return result;
+    }
+
+    /** 产品推荐引擎 */
+    public List<Map<String, Object>> recommend(NutritionRecommendDTO.RecommendQuery query) {
+        List<NutritionProduct> candidates = search(new NutritionRecommendDTO.SearchQuery());
+        if (candidates.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<Map<String, Object>> scored = new ArrayList<>();
+        for (NutritionProduct product : candidates) {
+            double ingredientScore = calcIngredientScore(product, query);
+            double priceScore = calcPriceScore(product);
+            double brandScore = calcBrandScore(product);
+            double reviewScore = calcReviewScore(product);
+
+            double total = ingredientScore * 0.4 + priceScore * 0.3 + brandScore * 0.2 + reviewScore * 0.1;
+            BigDecimal score = BigDecimal.valueOf(total).setScale(2, RoundingMode.HALF_UP);
+
+            Map<String, Object> item = new HashMap<>();
+            item.put("product", product);
+            item.put("score", score);
+            item.put("ingredientScore", ingredientScore);
+            item.put("priceScore", priceScore);
+            item.put("brandScore", brandScore);
+            item.put("reviewScore", reviewScore);
+            scored.add(item);
+        }
+
+        scored.sort((a, b) -> ((BigDecimal) b.get("score")).compareTo((BigDecimal) a.get("score")));
+
+        int limit = query.getLimit() != null ? query.getLimit() : 10;
+        return scored.stream().limit(limit).collect(Collectors.toList());
+    }
+
+    /** 平台比价 */
+    public List<NutritionPlatformListing> priceCompare(Long productId) {
+        return listingMapper.selectList(
+            new LambdaQueryWrapper<NutritionPlatformListing>()
+                .eq(NutritionPlatformListing::getProductId, productId)
+                .orderByAsc(NutritionPlatformListing::getCurrentPrice)
+        );
+    }
+
+    // ---- 评分私有方法 ----
+
+    private double calcIngredientScore(NutritionProduct product, NutritionRecommendDTO.RecommendQuery query) {
+        List<NutritionIngredient> ingredients = ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, product.getId())
+        );
+        if (ingredients.isEmpty()) {
+            return 0.5;
+        }
+        double score = 0.6;
+        if (query.getTargetIngredients() != null && !query.getTargetIngredients().isEmpty()) {
+            for (String target : query.getTargetIngredients()) {
+                boolean matched = ingredients.stream()
+                    .anyMatch(i -> i.getName() != null && i.getName().contains(target));
+                if (matched) {
+                    score += 0.2;
+                }
+            }
+        }
+        return Math.min(score, 1.0);
+    }
+
+    private double calcPriceScore(NutritionProduct product) {
+        List<NutritionPlatformListing> listings = listingMapper.selectList(
+            new LambdaQueryWrapper<NutritionPlatformListing>()
+                .eq(NutritionPlatformListing::getProductId, product.getId())
+                .isNotNull(NutritionPlatformListing::getCurrentPrice)
+        );
+        if (listings.isEmpty()) {
+            return 0.5;
+        }
+        Optional<BigDecimal> minPrice = listings.stream()
+            .map(NutritionPlatformListing::getCurrentPrice)
+            .filter(Objects::nonNull)
+            .min(Comparator.naturalOrder());
+        if (!minPrice.isPresent()) {
+            return 0.5;
+        }
+        double price = minPrice.get().doubleValue();
+        return Math.max(0, Math.min(1.0, 100.0 / (100.0 + price)));
+    }
+
+    private double calcBrandScore(NutritionProduct product) {
+        if (product.getBrandId() == null) {
+            return 0.5;
+        }
+        return 0.7;
+    }
+
+    private double calcReviewScore(NutritionProduct product) {
+        List<NutritionPlatformListing> listings = listingMapper.selectList(
+            new LambdaQueryWrapper<NutritionPlatformListing>()
+                .eq(NutritionPlatformListing::getProductId, product.getId())
+        );
+        if (listings.isEmpty()) {
+            return 0.5;
+        }
+        double totalRating = 0;
+        int count = 0;
+        for (NutritionPlatformListing listing : listings) {
+            NutritionProductReview review = reviewMapper.selectOne(
+                new LambdaQueryWrapper<NutritionProductReview>()
+                    .eq(NutritionProductReview::getPlatformListingId, listing.getId())
+                    .last("LIMIT 1")
+            );
+            if (review != null && review.getRating() != null) {
+                totalRating += review.getRating().doubleValue();
+                count++;
+            }
+        }
+        if (count == 0) {
+            return 0.5;
+        }
+        return Math.min(1.0, totalRating / count / 5.0);
+    }
+}