|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|