Explorar o código

feat(shop): 商品营养数据与营养标签(合入 328443d43,迁移顺延 417→435)

合入 origin/feature/nutrition-product-tagging 328443d43 到 origin/cfclub b14402bcf。

- products 表新增 calories_kcal/protein_g/fat_g/carb_g/fiber_g/nutrition_tags(ensureColumn 幂等,schema.sql 同步)
- 管理端商品编辑新增「营养信息」区块:数值可空、保存归一化为 null、详情回显
- 小程序商品详情新增「营养成分(每100g)」区块:无数据/标签时整块隐藏
- 商品列表支持 nutritionTag 筛选;推荐搜索命中 nutrition_tags
- 商品编辑支持清空营养字段(显式置 NULL,标签空串归一化为 NULL)
- 新增单元测试 4 个类 11 个用例

冲突解决(3 处):
- DatabaseInitializer:迁移 417 已被主线「旧 CF 账本存量迁移」占用,顺延为 435(主线最新 434 + 1),
  migrateProductNutritionFields 的调用点与日志/注释同步改号
- cfc-frontend product-detail.vue:保留主线字号方案 calc(30rpx * var(--font-scale, 1))(bundle-item-qty),叠加本分支营养区块样式
- cfc-web ProductEdit.vue:保留主线 purchasePrice(分) 字段,叠加本分支营养字段归一化

验证:mvn clean compile 通过;npm run build:mp-weixin 通过;cfc-web vue-cli-service build 通过;新增单测 4 类全绿。
zxyj-web hai 2 semanas
pai
achega
b826270855

+ 26 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3398,8 +3398,8 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移433: 家庭指标聚合方式配置表(五维能量汇总/珍珠图/章鱼图汇集配置)
         createFamilyMetricAggConfigTable();
 
-        // 迁移434: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别)
-        migrateSkuCombo();
+        // 迁移435: products 表添加营养数据字段(热量/蛋白质/脂肪/碳水/膳食纤维/营养标签)
+        migrateProductNutritionFields();
     }
 
     /**
@@ -3511,6 +3511,30 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移435:products 表添加营养数据字段。
+     * <p>商品营养数据用于管理端录入、小程序详情页展示与推荐按营养标签筛选。
+     * 全部字段允许为 NULL(NULL = 未配置),迁移幂等可重复执行。
+     * <p>编号说明:原分支为迁移417,合入主线时 417 已被「旧 CF 账本存量迁移」占用,顺延至主线最新 434 + 1 = 435。
+     */
+    private void migrateProductNutritionFields() {
+        if (!tableExists("products")) {
+            log.warn("迁移435: products 表不存在,跳过营养数据字段迁移");
+            return;
+        }
+        try {
+            ensureColumn("products", "calories_kcal", "DECIMAL(10,2) DEFAULT NULL COMMENT '热量(kcal/100g),NULL=未配置'");
+            ensureColumn("products", "protein_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '蛋白质(g/100g),NULL=未配置'");
+            ensureColumn("products", "fat_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '脂肪(g/100g),NULL=未配置'");
+            ensureColumn("products", "carb_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '碳水化合物(g/100g),NULL=未配置'");
+            ensureColumn("products", "fiber_g", "DECIMAL(10,2) DEFAULT NULL COMMENT '膳食纤维(g/100g),NULL=未配置'");
+            ensureColumn("products", "nutrition_tags", "VARCHAR(255) DEFAULT NULL COMMENT '营养标签,逗号分隔(如\"低糖,高蛋白\"),NULL=无标签'");
+            log.info("迁移435: products 表营养数据字段已就绪");
+        } catch (Exception e) {
+            log.warn("迁移435: products 表营养数据字段迁移失败: {}", e.getMessage());
+        }
+    }
+
     private void migrateHealthDimensionsTable() {
         try {
             if (tableExists("health_dimension_score")) {

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.etotem.cfc.entity.Product;
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
@@ -32,6 +33,18 @@ public class ProductDTO {
     private String dimensionWeights;
     private String memberEligible;
     private String rejectReason;
+    /** 热量(kcal/100g),NULL=未配置(小程序详情页无数据时隐藏整块营养信息) */
+    private BigDecimal caloriesKcal;
+    /** 蛋白质(g/100g),NULL=未配置 */
+    private BigDecimal proteinG;
+    /** 脂肪(g/100g),NULL=未配置 */
+    private BigDecimal fatG;
+    /** 碳水化合物(g/100g),NULL=未配置 */
+    private BigDecimal carbG;
+    /** 膳食纤维(g/100g),NULL=未配置 */
+    private BigDecimal fiberG;
+    /** 营养标签,逗号分隔(如"低糖,高蛋白"),NULL=无标签 */
+    private String nutritionTags;
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
     private List<String> visibleRoles;     // 可见角色列表,NULL=全部角色可见(已废弃,保留兼容)
     private String minVisibleLevel;        // 最低可见会员级别编码,NULL=全部可见
@@ -121,6 +134,12 @@ public class ProductDTO {
         d.dimensionWeights = p.getDimensionWeights();
         d.memberEligible = p.getMemberEligible();
         d.rejectReason = p.getRejectReason();
+        d.caloriesKcal = p.getCaloriesKcal();
+        d.proteinG = p.getProteinG();
+        d.fatG = p.getFatG();
+        d.carbG = p.getCarbG();
+        d.fiberG = p.getFiberG();
+        d.nutritionTags = p.getNutritionTags();
         d.deliveryMethod = p.getDeliveryMethod();
         d.sortOrder = p.getSortOrder();
         d.categoryId = p.getCategoryId();

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java

@@ -13,4 +13,6 @@ public class ProductListQueryDTO {
     private String keyword;
     private Long categoryId;
     private Boolean growthRelated;   // true=仅返回成长档案关联商品
+    /** 营养标签筛选(逗号分隔标签中任一命中即返回,如"低糖"),NULL/空=不限 */
+    private String nutritionTag;
 }

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

@@ -45,6 +45,24 @@ public class Product implements Serializable {
     /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
     private String dimensionWeights;
 
+    /** 热量(kcal/100g),NULL=未配置 */
+    private java.math.BigDecimal caloriesKcal;
+
+    /** 蛋白质(g/100g),NULL=未配置 */
+    private java.math.BigDecimal proteinG;
+
+    /** 脂肪(g/100g),NULL=未配置 */
+    private java.math.BigDecimal fatG;
+
+    /** 碳水化合物(g/100g),NULL=未配置 */
+    private java.math.BigDecimal carbG;
+
+    /** 膳食纤维(g/100g),NULL=未配置 */
+    private java.math.BigDecimal fiberG;
+
+    /** 营养标签,逗号分隔(如"低糖,高蛋白,无添加"),NULL=无标签 */
+    private String nutritionTags;
+
     /** 禁忌基础疾病,逗号分隔(如"慢性肾病,肝病"),NULL=无禁忌 */
     private String contraindicatedDiseases;
 

+ 43 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -225,6 +225,10 @@ wrapper.in(Product::getId, productIds)
         if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
             wrapper.like(Product::getName, query.getKeyword());
         }
+        // 营养标签筛选:逗号分隔标签中任一命中即返回(NULL/空=不限)
+        if (query.getNutritionTag() != null && !query.getNutritionTag().trim().isEmpty()) {
+            wrapper.like(Product::getNutritionTags, query.getNutritionTag().trim());
+        }
         SortUtil.applySort(wrapper);
         IPage<Product> result = productMapper.selectPage(page, wrapper);
         boolean isGuest = (userId == null);
@@ -619,9 +623,23 @@ wrapper.in(Product::getId, productIds)
         // updateById 默认忽略 null 字段:clearable 下拉清空后需显式置 NULL
         boolean clearCategory = product.getCategoryId() == null;
         boolean clearDistributionSystem = product.getDistributionSystemId() == null;
+        // 营养数据字段为 nullable 且允许清空:管理端清空输入框后需显式置 NULL,
+        // 否则 updateById 忽略 null 会残留旧值;空串营养标签同样规整为 NULL。
+        // 仅当原记录确实有值时才需要清空,避免每次编辑都多打一条无意义的 UPDATE。
+        boolean clearCalories = existing.getCaloriesKcal() != null && product.getCaloriesKcal() == null;
+        boolean clearProtein = existing.getProteinG() != null && product.getProteinG() == null;
+        boolean clearFat = existing.getFatG() != null && product.getFatG() == null;
+        boolean clearCarb = existing.getCarbG() != null && product.getCarbG() == null;
+        boolean clearFiber = existing.getFiberG() != null && product.getFiberG() == null;
+        if (product.getNutritionTags() != null && product.getNutritionTags().trim().isEmpty()) {
+            product.setNutritionTags(null);
+        }
+        boolean clearNutritionTags = existing.getNutritionTags() != null && product.getNutritionTags() == null;
         productMapper.updateById(product);
         nutritionProductService.syncFromProduct(product);
-        if (clearMinVisibleLevel || clearCategory || clearDistributionSystem) {
+        if (clearMinVisibleLevel || clearCategory || clearDistributionSystem
+                || clearCalories || clearProtein || clearFat || clearCarb || clearFiber
+                || clearNutritionTags) {
             LambdaUpdateWrapper<Product> clearWrapper = new LambdaUpdateWrapper<Product>()
                     .eq(Product::getId, product.getId());
             if (clearMinVisibleLevel) {
@@ -633,6 +651,24 @@ wrapper.in(Product::getId, productIds)
             if (clearDistributionSystem) {
                 clearWrapper.set(Product::getDistributionSystemId, null);
             }
+            if (clearCalories) {
+                clearWrapper.set(Product::getCaloriesKcal, null);
+            }
+            if (clearProtein) {
+                clearWrapper.set(Product::getProteinG, null);
+            }
+            if (clearFat) {
+                clearWrapper.set(Product::getFatG, null);
+            }
+            if (clearCarb) {
+                clearWrapper.set(Product::getCarbG, null);
+            }
+            if (clearFiber) {
+                clearWrapper.set(Product::getFiberG, null);
+            }
+            if (clearNutritionTags) {
+                clearWrapper.set(Product::getNutritionTags, null);
+            }
             productMapper.update(null, clearWrapper);
         }
         return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
@@ -731,6 +767,12 @@ wrapper.in(Product::getId, productIds)
         copy.setSortOrder(original.getSortOrder());
         copy.setVisibleRoles(original.getVisibleRoles());
         copy.setMinVisibleLevel(original.getMinVisibleLevel());
+        copy.setCaloriesKcal(original.getCaloriesKcal());
+        copy.setProteinG(original.getProteinG());
+        copy.setFatG(original.getFatG());
+        copy.setCarbG(original.getCarbG());
+        copy.setFiberG(original.getFiberG());
+        copy.setNutritionTags(original.getNutritionTags());
         copy.setCreatedAt(new Date());
         copy.setUpdatedAt(new Date());
         productMapper.insert(copy);

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -136,7 +136,10 @@ public class RecommendationService {
                             .gt(Product::getStock, 0)
                             .and(w -> w.like(Product::getName, keyword)
                                     .or()
-                                    .like(Product::getDescription, keyword))
+                                    .like(Product::getDescription, keyword)
+                                    // 营养标签优先命中:商品已配置 nutrition_tags(逗号分隔)时按标签匹配
+                                    .or()
+                                    .like(Product::getNutritionTags, keyword))
                             .last("LIMIT " + (limit - results.size()))
             );
 

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

@@ -1180,6 +1180,12 @@ CREATE TABLE IF NOT EXISTS products (
     profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)',
     sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
     points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '积分倍数【已迁移→service_config cf.rate.points_multiplier,本列仅过渡期兼容】',
+    calories_kcal DECIMAL(10,2) DEFAULT NULL COMMENT '热量(kcal/100g),NULL=未配置',
+    protein_g DECIMAL(10,2) DEFAULT NULL COMMENT '蛋白质(g/100g),NULL=未配置',
+    fat_g DECIMAL(10,2) DEFAULT NULL COMMENT '脂肪(g/100g),NULL=未配置',
+    carb_g DECIMAL(10,2) DEFAULT NULL COMMENT '碳水化合物(g/100g),NULL=未配置',
+    fiber_g DECIMAL(10,2) DEFAULT NULL COMMENT '膳食纤维(g/100g),NULL=未配置',
+    nutrition_tags VARCHAR(255) DEFAULT NULL COMMENT '营养标签,逗号分隔(如"低糖,高蛋白"),NULL=无标签',
     delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送',
     purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制',
     category_id BIGINT COMMENT '商品类目ID',

+ 40 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductDTOMappingTest.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.entity.Product;
 import org.junit.jupiter.api.Test;
 
+import java.math.BigDecimal;
+
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -26,4 +28,42 @@ class ProductDTOMappingTest {
         assertEquals(1L, json.getLong("categoryId"));
         assertEquals(3L, json.getLong("distributionSystemId"));
     }
+
+    @Test
+    void from_映射营养数据与营养标签() {
+        Product p = new Product();
+        p.setId(8L);
+        p.setName("高钙奶粉");
+        p.setCaloriesKcal(new BigDecimal("490"));
+        p.setProteinG(new BigDecimal("23.6"));
+        p.setFatG(new BigDecimal("25.1"));
+        p.setCarbG(new BigDecimal("40.8"));
+        p.setFiberG(new BigDecimal("1.2"));
+        p.setNutritionTags("低糖,高钙");
+
+        ProductDTO dto = ProductDTO.from(p);
+
+        assertEquals(new BigDecimal("490"), dto.getCaloriesKcal());
+        assertEquals(new BigDecimal("23.6"), dto.getProteinG());
+        assertEquals(new BigDecimal("25.1"), dto.getFatG());
+        assertEquals(new BigDecimal("40.8"), dto.getCarbG());
+        assertEquals(new BigDecimal("1.2"), dto.getFiberG());
+        assertEquals("低糖,高钙", dto.getNutritionTags());
+    }
+
+    @Test
+    void from_未配置营养数据时字段为null() {
+        Product p = new Product();
+        p.setId(9L);
+        p.setName("还原水机滤芯");
+
+        ProductDTO dto = ProductDTO.from(p);
+
+        assertNull(dto.getCaloriesKcal());
+        assertNull(dto.getProteinG());
+        assertNull(dto.getFatG());
+        assertNull(dto.getCarbG());
+        assertNull(dto.getFiberG());
+        assertNull(dto.getNutritionTags());
+    }
 }

+ 88 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductListNutritionTagFilterTest.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ProductListQueryDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商城商品列表:nutritionTag 参数必须下推到查询条件(LIKE nutrition_tags),
+ * 而不是只加在 DTO 里不生效。
+ */
+class ProductListNutritionTagFilterTest {
+
+    private ProductService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        service = new ProductService();
+        productMapper = mock(ProductMapper.class);
+        set("productMapper", productMapper);
+
+        ProductGiftRuleService giftRuleService = mock(ProductGiftRuleService.class);
+        when(giftRuleService.listActiveByProductIds(any())).thenReturn(Collections.emptyList());
+        set("productGiftRuleService", giftRuleService);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ProductService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    @Test
+    void list_按营养标签过滤商品() {
+        when(productMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+                .thenReturn(new Page<Product>(1, 10));
+
+        ProductListQueryDTO query = new ProductListQueryDTO();
+        query.setNutritionTag("低糖");
+
+        Result<?> result = service.list(query, null, null);
+        assertEquals(200, result.getCode());
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectPage(any(Page.class), captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("nutrition_tags"), sql);
+    }
+
+    @Test
+    void list_未传营养标签时不加过滤条件() {
+        when(productMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+                .thenReturn(new Page<Product>(1, 10));
+
+        ProductListQueryDTO query = new ProductListQueryDTO();
+
+        assertEquals(200, service.list(query, null, null).getCode());
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectPage(any(Page.class), captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertFalse(sql.contains("nutrition_tags"), sql);
+    }
+}

+ 89 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductUpdateClearTest.java

@@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import java.lang.reflect.Field;
+import java.math.BigDecimal;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -96,4 +97,92 @@ class ProductUpdateClearTest {
 
         verify(productMapper, never()).update(isNull(), any(Wrapper.class));
     }
+
+    /**
+     * 营养字段为可空输入:管理端清空输入框提交 null 时,必须真正写入 NULL,
+     * 否则 updateById 忽略 null 会残留旧值。
+     */
+    @Test
+    void adminUpdate_营养字段清空时显式置NULL() {
+        Product existing = existing();
+        existing.setCaloriesKcal(new BigDecimal("320"));
+        existing.setProteinG(new BigDecimal("18.5"));
+        existing.setFatG(new BigDecimal("9"));
+        existing.setCarbG(new BigDecimal("36"));
+        existing.setFiberG(new BigDecimal("4.2"));
+        existing.setNutritionTags("低糖,高纤维");
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+        // 营养字段全部未填写(清空)
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(productMapper).update(isNull(), captor.capture());
+        String sqlSet = ((LambdaUpdateWrapper<Product>) captor.getValue()).getSqlSet();
+        assertTrue(sqlSet.contains("calories_kcal"), sqlSet);
+        assertTrue(sqlSet.contains("protein_g"), sqlSet);
+        assertTrue(sqlSet.contains("fat_g"), sqlSet);
+        assertTrue(sqlSet.contains("carb_g"), sqlSet);
+        assertTrue(sqlSet.contains("fiber_g"), sqlSet);
+        assertTrue(sqlSet.contains("nutrition_tags"), sqlSet);
+    }
+
+    /**
+     * 原记录本来就没有营养数据、且表单也未提交营养字段时,不应触发多余的清空 UPDATE。
+     */
+    @Test
+    void adminUpdate_原记录无营养数据时不额外置NULL() {
+        Product existing = existing();
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        verify(productMapper, never()).update(isNull(), any(Wrapper.class));
+    }
+
+    /**
+     * 营养标签传空串(含纯空格)时归一化为 NULL:库里不留空串,原有标签被真正清空。
+     */
+    @Test
+    void adminUpdate_营养标签传空串时归一化为NULL() {
+        Product existing = existing();
+        existing.setNutritionTags("低糖");
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+        form.setNutritionTags("   ");
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
+        verify(productMapper).updateById(productCaptor.capture());
+        assertNull(productCaptor.getValue().getNutritionTags());
+
+        ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(productMapper).update(isNull(), captor.capture());
+        String sqlSet = ((LambdaUpdateWrapper<Product>) captor.getValue()).getSqlSet();
+        assertTrue(sqlSet.contains("nutrition_tags"), sqlSet);
+    }
 }

+ 79 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationNutritionTagTest.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.RecommendationService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推荐搜索:营养需求标签必须能命中商品已配置的 nutrition_tags(逗号分隔标签),
+ * 而不是只按名称/描述匹配。
+ */
+class RecommendationNutritionTagTest {
+
+    private RecommendationService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        service = new RecommendationService();
+        productMapper = mock(ProductMapper.class);
+        set("productMapper", productMapper);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = RecommendationService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    @Test
+    void search_按营养标签命中商品nutritionTags() {
+        Product p = new Product();
+        p.setId(11L);
+        p.setName("即食燕麦片");
+        p.setNutritionTags("膳食纤维,低GI");
+        p.setStatus("on_shelf");
+        p.setStock(10);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(p));
+
+        RecommendationQuery query = new RecommendationQuery();
+        query.setNutritionTags(Collections.singletonList("膳食纤维"));
+        query.setTypes(Collections.singletonList("product"));
+        query.setLimit(1);
+
+        List<RecommendationResult> results = service.search(query);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("nutrition_tags"), sql);
+
+        assertEquals(1, results.size());
+        assertEquals("product", results.get(0).getType());
+        assertEquals(11L, results.get(0).getId());
+        assertEquals("即食燕麦片", results.get(0).getName());
+    }
+}

+ 89 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -125,6 +125,20 @@
         </view>
       </view>
 
+      <!-- 营养成分(未配置任何营养数据/标签时整块隐藏) -->
+      <view v-if="hasNutritionInfo" class="nutrition-section">
+        <text class="section-title">营养成分(每100g)</text>
+        <view class="nutrition-list">
+          <view class="nutrition-item" v-for="(item, idx) in nutritionList" :key="idx">
+            <text class="nutrition-label">{{ item.label }}</text>
+            <text class="nutrition-value">{{ item.value }}</text>
+          </view>
+        </view>
+        <view v-if="nutritionTagList.length > 0" class="nutrition-tags">
+          <text class="nutrition-tag" v-for="(tag, ti) in nutritionTagList" :key="ti">{{ tag }}</text>
+        </view>
+      </view>
+
       <view class="intro-section" v-if="product.intro">
         <text class="section-title">商品介绍</text>
         <mp-html :content="product.intro" :domain="apiBaseUrl" :tag-style="contentTagStyle" />
@@ -371,6 +385,40 @@ export default {
         }
       }
       return list
+    },
+    // 营养成分列表(每100g),未配置的项不展示
+    nutritionList: function() {
+      var p = this.product || {}
+      var fields = [
+        { label: '热量', value: p.caloriesKcal, unit: 'kcal' },
+        { label: '蛋白质', value: p.proteinG, unit: 'g' },
+        { label: '脂肪', value: p.fatG, unit: 'g' },
+        { label: '碳水化合物', value: p.carbG, unit: 'g' },
+        { label: '膳食纤维', value: p.fiberG, unit: 'g' }
+      ]
+      var list = []
+      for (var i = 0; i < fields.length; i++) {
+        var f = fields[i]
+        if (f.value === null || f.value === undefined || f.value === '') continue
+        list.push({ label: f.label, value: f.value + ' ' + f.unit })
+      }
+      return list
+    },
+    // 营养标签(逗号分隔)
+    nutritionTagList: function() {
+      var tags = this.product && this.product.nutritionTags
+      if (!tags) return []
+      var arr = String(tags).split(',')
+      var list = []
+      for (var i = 0; i < arr.length; i++) {
+        var t = arr[i].trim()
+        if (t) list.push(t)
+      }
+      return list
+    },
+    // 营养信息区是否展示(无任何营养数据与标签时隐藏)
+    hasNutritionInfo: function() {
+      return this.nutritionList.length > 0 || this.nutritionTagList.length > 0
     }
   },
   onLoad(options) {
@@ -1034,6 +1082,47 @@ export default {
 .bundle-item-name { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .bundle-item-spec { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-muted; margin-top: 4rpx; }
 .bundle-item-qty { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: bold; color: $brand-primary; margin-left: 16rpx; flex-shrink: 0; }
+/* 营养成分 */
+.nutrition-section {
+  background: $app-surface;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+}
+.nutrition-list {
+  display: flex;
+  flex-wrap: wrap;
+}
+.nutrition-item {
+  width: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid $facade-fill-2;
+}
+.nutrition-label {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+.nutrition-value {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  font-weight: bold;
+}
+.nutrition-tags {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 20rpx;
+}
+.nutrition-tag {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  background: $facade-fill-cool;
+  border-radius: 100rpx;
+  padding: 6rpx 20rpx;
+  margin-right: 12rpx;
+  margin-bottom: 12rpx;
+}
 /* SKU 选择器 */
 .sku-section {
   background: $app-surface;

+ 67 - 2
cfc-web/src/views/admin/ProductEdit.vue

@@ -48,6 +48,44 @@
           <div class="form-hint-mt4">用于小程序文章详情页「相关商品」推荐:标签与文章标签一字不差才会匹配,留空则不参与匹配</div>
         </el-form-item>
 
+        <!-- 营养信息(可选) -->
+        <SectionTitle title="营养信息(可选)" />
+        <el-row :gutter="20">
+          <el-col :span="6">
+            <el-form-item label="热量(kcal)">
+              <el-input-number v-model="form.caloriesKcal" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="蛋白质(g)">
+              <el-input-number v-model="form.proteinG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="脂肪(g)">
+              <el-input-number v-model="form.fatG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="碳水(g)">
+              <el-input-number v-model="form.carbG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="6">
+            <el-form-item label="膳食纤维(g)">
+              <el-input-number v-model="form.fiberG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="18">
+            <el-form-item label="营养标签">
+              <el-input v-model="form.nutritionTags" placeholder="逗号分隔,如: 低糖,高蛋白,无添加" maxlength="200" clearable class="full-width" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <div class="form-hint-mt4" style="margin-left: 100px;">以上数据均为每100g含量,留空即视为未配置;营养标签用于小程序详情页展示与推荐筛选,留空则不展示营养信息</div>
+
         <!-- 测评商品扩展信息 -->
         <template v-if="form.productType === 'assessment'">
           <el-form-item label="测评类型">
@@ -424,6 +462,13 @@ export default {
         minVisibleLevel: '',
         isRecommended: 0,
         tags: '',
+        // 营养信息(可选,留空=未配置)
+        caloriesKcal: null,
+        proteinG: null,
+        fatG: null,
+        carbG: null,
+        fiberG: null,
+        nutritionTags: '',
         dimensionWeights: [],
         // 测评商品扩展
         assessmentType: 'single',
@@ -729,7 +774,14 @@ export default {
             validityDays: p.validityDays || 365,
             guideScope: p.guideScope || 'all',
             sortOrder: p.sortOrder != null ? p.sortOrder : 0,
-            categoryId: p.categoryId || null
+            categoryId: p.categoryId || null,
+            // 营养信息回显:null=未配置,保持空值让管理端显示为空
+            caloriesKcal: p.caloriesKcal != null ? Number(p.caloriesKcal) : null,
+            proteinG: p.proteinG != null ? Number(p.proteinG) : null,
+            fatG: p.fatG != null ? Number(p.fatG) : null,
+            carbG: p.carbG != null ? Number(p.carbG) : null,
+            fiberG: p.fiberG != null ? Number(p.fiberG) : null,
+            nutritionTags: p.nutritionTags || ''
           }
           if (p.images) {
             try {
@@ -1090,6 +1142,12 @@ async loadGiftRule(productId) {
       this.imageList.splice(idx, 1)
       this.form.images = JSON.stringify(this.imageList)
     },
+    // 营养数值输入归一化:空值/非法值统一为 null(未配置)
+    toNutritionNumber(value) {
+      if (value === null || value === undefined || value === '') return null
+      var n = Number(value)
+      return isNaN(n) ? null : n
+    },
     async handleSave() {
       this.$refs.form.validate(async (valid) => {
         if (!valid) return
@@ -1100,7 +1158,14 @@ async loadGiftRule(productId) {
             price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
             memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
             purchasePrice: (this.form.purchasePrice != null && this.form.purchasePrice > 0) ? Math.round(this.form.purchasePrice * 100) : 0,
-            images: JSON.stringify(this.imageList)
+            images: JSON.stringify(this.imageList),
+            // 营养数据:空值统一转 null(=未配置),避免发送空串导致后端反序列化失败
+            caloriesKcal: this.toNutritionNumber(this.form.caloriesKcal),
+            proteinG: this.toNutritionNumber(this.form.proteinG),
+            fatG: this.toNutritionNumber(this.form.fatG),
+            carbG: this.toNutritionNumber(this.form.carbG),
+            fiberG: this.toNutritionNumber(this.form.fiberG),
+            nutritionTags: (this.form.nutritionTags || '').trim()
           }
           // Extract dimensionWeights before sending to product API
           const dimensionWeights = productData.dimensionWeights