Просмотр исходного кода

feat(backend): 营养产品功效标签与年龄/剂量UL校验(实体/迁移/服务/接口 + schema 营养部分 + DatabaseInitializer)

E2E Test Bot 1 неделя назад
Родитель
Сommit
029672ebd1

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3023,6 +3023,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode)
         addDraftParseProgressColumns();
+
+        // 迁移365: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+        addNutritionProductEfficacyTags();
+
+        // 迁移366: products 添加 tags 列(文章-商品标签关联推荐)
+        addProductTagsColumn();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12429,4 +12435,18 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.info("已添加 parse_finished_at 列到 health_report_drafts 表");
         }
     }
+
+    // 迁移365: nutrition_product 添加 efficacy_tags 列(成分功效标签)
+    private void addNutritionProductEfficacyTags() {
+        if (!columnExists("nutrition_product", "efficacy_tags")) {
+            jdbcTemplate.execute("ALTER TABLE nutrition_product ADD COLUMN efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 [\"补钙\",\"补锌\",\"益生菌\",\"维生素D\"])'");
+            log.info("已添加 efficacy_tags 列到 nutrition_product 表");
+        }
+    }
+
+    // 迁移366: products 添加 tags 列(文章-商品标签关联推荐)
+    private void addProductTagsColumn() {
+        ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
+        log.info("迁移366: products.tags 已确认存在");
+    }
 }

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/nutrition/NutritionProductController.java

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -82,4 +83,21 @@ public class NutritionProductController {
         }
         return Result.success(nutritionProductService.priceCompare(productId));
     }
+
+    @Operation(summary = "根据主平台商品ID创建营养产品条目")
+    @PostMapping("/from-product")
+    public Result<Map<String, Object>> addNutritionProductFromProduct(@RequestBody Map<String, Long> params) {
+        Long productId = params.get("productId");
+        if (productId == null) {
+            return Result.error("productId 不能为空");
+        }
+        boolean success = nutritionProductService.addNutritionProductFromProduct(productId);
+        if (!success) {
+            return Result.error("创建失败");
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("message", "营养产品条目创建成功");
+        result.put("productId", productId);
+        return Result.success(result);
+    }
 }

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionAgeDosage.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_age_dosage")
+public class NutritionAgeDosage implements Serializable {
+    
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    
+    private Integer ageMinMonths;
+    private Integer ageMaxMonths;
+    
+    /** 成分名称 */
+    private String ingredientName;
+    
+    /** 每份剂量(毫升/次) */
+    private BigDecimal amountPerServing;
+    
+    /** 单位(如毫升、毫克、片、次) */
+    private String unit;
+    
+    /** 功效说明 */
+    private String purpose;
+    
+    private Date createTime;
+    private Date updateTime;
+}

+ 15 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionProduct.java

@@ -68,7 +68,21 @@ public class NutritionProduct implements Serializable {
     /** 规格 */
     private String spec;
 
-    private Date createTime;
+    /** 适用年龄下限(月) */
+    private Integer ageMinMonths;
+
+    /** 适用年龄上限(月) */
+    private Integer ageMaxMonths;
+
+    /** 孕期/哺乳期标签(0=不适用, 1=孕期, 2=哺乳期, 3=孕期+哺乳期) */
+    private Integer pregnancyLactationTag;
 
+    /** 禁止人群标签(0=无, 1=婴幼儿禁用, 2=成人禁用, 3=特定疾病人群禁用) */
+    private Integer contraindicationTag;
+
+    /** 成分功效标签(JSON数组,如 ["补钙","补锌","益生菌","维生素D"]) */
+    private String efficacyTags;
+
+    private Date createTime;
     private Date updateTime;
 }

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionUlLimit.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("nutrition_ul_limit")
+public class NutritionUlLimit implements Serializable {
+    
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    
+    private Long productId;
+    private String ingredientType;
+    private String nutrientName;
+    private String ageMinMonths;
+    private String ageMaxMonths;
+    private BigDecimal limitValue;
+    private String unit;
+    private String description;
+    private Date createTime;
+    private Date updateTime;
+    
+    public NutritionUlLimit() {}
+    
+    public NutritionUlLimit(Long productId, String ingredientType, String nutrientName, 
+                           String ageMinMonths, String ageMaxMonths, 
+                            BigDecimal limitValue, String unit, String description) {
+        this.productId = productId;
+        this.ingredientType = ingredientType;
+        this.nutrientName = nutrientName;
+        this.ageMinMonths = ageMinMonths;
+        this.ageMaxMonths = ageMaxMonths;
+        this.limitValue = limitValue;
+        this.unit = unit;
+        this.description = description;
+        this.createTime = new Date();
+        this.updateTime = new Date();
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionAgeDosageMapper.java

@@ -0,0 +1,6 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NutritionAgeDosage;
+
+public interface NutritionAgeDosageMapper extends BaseMapper<NutritionAgeDosage> {}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionUlLimitMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NutritionUlLimit;
+
+public interface NutritionUlLimitMapper extends BaseMapper<NutritionUlLimit> {
+}

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

@@ -38,6 +38,18 @@ public class NutritionProductService {
     @Resource
     private NutritionProductReviewMapper reviewMapper;
 
+
+    @Resource
+    private ProductService productService;
+
+
+    @Resource
+    private NutritionUlLimitMapper ulLimitMapper;
+
+
+    @Resource
+    private NutritionAgeDosageMapper ageDosageMapper;
+
     @Resource
     private ProductMapper productMapper;
 
@@ -214,6 +226,11 @@ public class NutritionProductService {
                 }
                 existing.setStatus(1);
                 nutritionProductMapper.insert(existing);
+                // 根据成分功效说明自动生成功效标签
+                existing.setEfficacyTags(buildEfficacyTags(existing.getId()));
+                if (existing.getEfficacyTags() != null) {
+                    nutritionProductMapper.updateById(existing);
+                }
             } else {
                 existing.setName(product.getName());
                 existing.setImageUrl(product.getCoverImage());
@@ -242,6 +259,169 @@ public class NutritionProductService {
         return fen / 100;
     }
 
+    /**
+     * 根据主平台商品ID创建营养产品条目(标记为本平台商品)
+     *
+     * @param productId 主平台商品ID(products.id)
+     * @return true=成功/已存在, false=主商品不存在
+     */
+    public boolean addNutritionProductFromProduct(Long productId) {
+        Product product = productService.getById(productId);
+        if (product == null) {
+            log.warn("主平台商品不存在,ID: {}", productId);
+            return false;
+        }
+        // 检查是否已存在该主平台商品的营养产品条目
+        NutritionProduct existing = nutritionProductMapper.selectOne(
+            new LambdaQueryWrapper<NutritionProduct>()
+                .eq(NutritionProduct::getLocalProductId, productId)
+        );
+        if (existing != null) {
+            log.info("营养产品条目已存在,主平台商品ID: {}", productId);
+            return true; // 已存在,视为成功
+        }
+        NutritionProduct nutritionProduct = new NutritionProduct();
+        nutritionProduct.setName(product.getName());
+        nutritionProduct.setLocalProductId(productId);
+        nutritionProduct.setStatus(1); // 1 = 上架
+        nutritionProduct.setSourcePlatform("platform"); // 标记为平台来源
+        nutritionProduct.setDescription(product.getDescription());
+        nutritionProduct.setImageUrl(product.getCoverImage());
+        nutritionProduct.setPrice(BigDecimal.valueOf(product.getPrice() != null ? product.getPrice() : 0));
+        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice().intValue() : 0);
+        nutritionProduct.setSuitableFor("全家人");
+        nutritionProduct.setCreateTime(new Date());
+        nutritionProduct.setUpdateTime(new Date());
+        // 默认分类和品牌设为空,由前端或后续填充
+        nutritionProductMapper.insert(nutritionProduct);
+        // 根据成分功效说明自动生成功效标签
+        nutritionProduct.setEfficacyTags(buildEfficacyTags(nutritionProduct.getId()));
+        if (nutritionProduct.getEfficacyTags() != null) {
+            nutritionProductMapper.updateById(nutritionProduct);
+        }
+        log.info("已创建营养产品条目,主平台商品ID: {}, 营养产品ID: {}", productId, nutritionProduct.getId());
+        return true;
+    }
+
+    /**
+     * 根据产品的成分功效说明生成成分功效标签(JSON数组字符串,如 ["补钙","补锌"])
+     *
+     * @param nutritionProductId 营养产品ID
+     * @return JSON数组字符串;无成分或无法提取时返回 null
+     */
+    private String buildEfficacyTags(Long nutritionProductId) {
+        List<NutritionIngredient> ingredients = ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, nutritionProductId)
+        );
+        if (ingredients.isEmpty()) {
+            return null;
+        }
+        List<String> tags = new ArrayList<>();
+        for (NutritionIngredient ingredient : ingredients) {
+            String purpose = ingredient.getPurpose();
+            if (purpose == null || purpose.trim().isEmpty()) {
+                continue;
+            }
+            // 按常见分隔符拆分功效描述,取有效片段作为标签(去重、去空、截断过长)
+            for (String part : purpose.split("[,,、;;/|\\s]+")) {
+                String tag = part.trim();
+                if (tag.isEmpty() || tag.length() > 20 || tags.contains(tag)) {
+                    continue;
+                }
+                tags.add(tag);
+            }
+        }
+        if (tags.isEmpty()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < tags.size(); i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append("\"").append(tags.get(i)).append("\"");
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+    /**
+     * 验证营养产品是否符合年龄和剂量要求
+     *
+     * @param productId 营养产品ID
+     * @param ageInMonths 当前年龄(月)
+     * @param ageMinMonths 最小年龄(月)
+     * @param ageMaxMonths 最大年龄(月)
+     * @param dosageRequirement 剂量要求描述(可为 null)
+     * @param dosageUnit 剂量单位(可为 null)
+     * @return 是否通过验证
+     */
+    public boolean validateProductForAgeAndDosage(Long productId, Integer ageInMonths,
+                                                  Integer ageMinMonths, Integer ageMaxMonths,
+                                                  String dosageRequirement, String dosageUnit) {
+        // 1. 检查年龄是否在允许范围内
+        if (ageMinMonths != null && ageInMonths != null
+                && (ageInMonths < ageMinMonths || ageInMonths > ageMaxMonths)) {
+            log.info("产品{} 年龄{}个月不在允许范围[{}, {}]", productId, ageInMonths, ageMinMonths, ageMaxMonths);
+            return false;
+        }
+        // 2. 检查剂量是否超过 UL 限制
+        return validateDosageAgainstUlLimit(productId);
+    }
+
+    /**
+     * 检查产品剂量是否超过 UL 限制(多来源累加自动校验)
+     */
+    private boolean validateDosageAgainstUlLimit(Long productId) {
+        // 1. 获取该产品的所有成分
+        List<NutritionIngredient> ingredients = ingredientMapper.selectList(
+            new LambdaQueryWrapper<NutritionIngredient>()
+                .eq(NutritionIngredient::getProductId, productId)
+        );
+        if (ingredients.isEmpty()) {
+            return true; // 没有成分则视为通过
+        }
+
+        // 2. 按营养成分分组,累加同一成分多来源剂量
+        Map<String, BigDecimal> accumulatedDosage = new HashMap<>();
+
+        for (NutritionIngredient ingredient : ingredients) {
+            String nutrientKey = ingredient.getName() != null
+                ? ingredient.getName().toLowerCase() : ingredient.getIngredientType();
+            if (nutrientKey == null) {
+                continue;
+            }
+            // 累加同一成分剂量(假设所有成分来自同一产品内的多个成分条目)
+            BigDecimal amount = ingredient.getAmountPerServing() != null
+                ? ingredient.getAmountPerServing() : BigDecimal.ZERO;
+            accumulatedDosage.merge(nutrientKey, amount, BigDecimal::add);
+        }
+
+        // 3. 检查每个成分的 UL 限制
+        for (Map.Entry<String, BigDecimal> entry : accumulatedDosage.entrySet()) {
+            // 查找该成分的 UL 限制(按营养名称匹配)
+            List<NutritionUlLimit> ulLimits = ulLimitMapper.selectList(
+                new LambdaQueryWrapper<NutritionUlLimit>()
+                    .eq(NutritionUlLimit::getNutrientName, entry.getKey())
+            );
+            if (ulLimits.isEmpty()) {
+                continue;
+            }
+            NutritionUlLimit ulLimit = ulLimits.get(0);
+            if (ulLimit.getLimitValue() != null && entry.getValue() != null) {
+                double ulValue = ulLimit.getLimitValue().doubleValue();
+                double actualDose = entry.getValue().doubleValue();
+                if (actualDose > ulValue) {
+                    log.warn("成分{}剂量{}超过UL限制{}", entry.getKey(), actualDose, ulValue);
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
     // ---- 评分私有方法 ----
 
     private double calcIngredientScore(NutritionProduct product, NutritionRecommendDTO.RecommendQuery query) {

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

@@ -5073,6 +5073,7 @@ CREATE TABLE IF NOT EXISTS nutrition_product (
     source_url VARCHAR(500) DEFAULT '' COMMENT '来源平台商品链接',
     product_images TEXT COMMENT '商品图片URL(JSON数组)',
     spec VARCHAR(200) DEFAULT '' COMMENT '规格',
+    efficacy_tags TEXT COMMENT '成分功效标签JSON数组(如 ["补钙","补锌","益生菌","维生素D"])',
     create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
     update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_brand (brand_id),
@@ -5080,6 +5081,38 @@ CREATE TABLE IF NOT EXISTS nutrition_product (
     INDEX idx_source (source_platform, source_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品';
 
+-- 营养产品年龄分层剂量建议表
+CREATE TABLE IF NOT EXISTS nutrition_age_dosage (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    age_min_months INT NOT NULL COMMENT '产品适用的最小月龄',
+    age_max_months INT NOT NULL COMMENT '产品适用的最大月龄',
+    ingredient_name VARCHAR(100) NOT NULL COMMENT '成分名称',
+    strain VARCHAR(100) DEFAULT NULL COMMENT '菌株号',
+    amount_per_serving DECIMAL(10,2) DEFAULT NULL COMMENT '每份含量',
+    unit VARCHAR(20) DEFAULT NULL COMMENT '单位',
+    daily_value VARCHAR(50) DEFAULT NULL COMMENT '每日参考值%',
+    purpose VARCHAR(200) DEFAULT NULL COMMENT '功效说明',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品按年龄分组的剂量建议';
+
+-- 营养产品剂量上限(UL)表
+CREATE TABLE IF NOT EXISTS nutrition_ul_limit (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '产品ID',
+    ingredient_type VARCHAR(20) NOT NULL COMMENT '成分类型',
+    nutrient_name VARCHAR(100) NOT NULL COMMENT '营养物质名称',
+    age_min_months VARCHAR(20) NOT NULL COMMENT '最小月龄',
+    age_max_months VARCHAR(20) NOT NULL COMMENT '最大月龄',
+    limit_value DECIMAL(10,2) DEFAULT NULL COMMENT '限值',
+    unit VARCHAR(30) DEFAULT NULL COMMENT '价格单位',
+    description VARCHAR(200) DEFAULT NULL COMMENT '说明',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product (product_id),
+    INDEX idx_platform (ingredient_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品剂量上限';
+
 -- 营养产品成分明细(迁移217)
 CREATE TABLE IF NOT EXISTS nutrition_ingredient (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,