Эх сурвалжийг харах

feat: implement product recommendation & procurement workflow with 5-dimension tiers, customization, and task lifecycle with message reminders

E2E Test Bot 1 өдөр өмнө
parent
commit
c15b10c3cd

+ 36 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3017,6 +3017,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createIndicatorCorrelationsTable();
         ensureIndicatorCollectionDate();
         seedDimensionDefinitions();
+
+        // 商品组合推荐与采购任务迁移(363)
+        createProcurementTaskTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -9209,7 +9212,8 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                         "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " +
                         "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " +
                         "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " +
-                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app')");
+                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app'), " +
+                        "('procurement_completed', '采购任务已完成', '您的【{comboTier}】套餐采购任务已完成,共{productCount}件商品,下单时间:{createTime},产品即将送达,请及时查收。', 'in_app,wechat')");
                 log.info("notification_templates 种子数据已插入");
             } else {
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
@@ -12326,4 +12330,35 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addProductVendorVisibleColumn() {
         ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
     }
+
+    // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+    private void createProcurementTaskTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS procurement_tasks (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识', " +
+                    "user_id BIGINT NOT NULL COMMENT '所属用户ID', " +
+                    "family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID', " +
+                    "combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low', " +
+                    "original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)', " +
+                    "selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)', " +
+                    "custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)', " +
+                    "custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)', " +
+                    "status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED', " +
+                    "create_time DATETIME NOT NULL COMMENT '创建时间', " +
+                    "complete_time DATETIME DEFAULT NULL COMMENT '完成时间', " +
+                    "message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒', " +
+                    "execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID', " +
+                    "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_task_id (task_id), " +
+                    "INDEX idx_user_id (user_id), " +
+                    "INDEX idx_family_member_id (family_member_id), " +
+                    "INDEX idx_status (status), " +
+                    "INDEX idx_create_time (create_time)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)'");
+            log.info("已创建procurement_tasks表");
+        } catch (Exception e) {
+            log.warn("创建procurement_tasks表失败: {}", e.getMessage());
+        }
+    }
 }

+ 266 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java

@@ -0,0 +1,266 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProcurementTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 商品组合推荐与采购任务控制器
+ * 提供三种方案(高/中/低端)+ 用户定制 + 采购任务全生命周期管理
+ * 路由前缀:/api/product/
+ */
+@RestController
+@RequestMapping("/api/product")
+public class ProcurementTaskController {
+
+    private static final Logger log = LoggerFactory.getLogger(ProcurementTaskController.class);
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProcurementTaskService procurementTaskService;
+
+    /**
+     * 获取三种商品组合方案(高/中/低端),从数据库按维度动态生成
+     */
+    @PostMapping("/combo-tiers")
+    public Result<List<ComboTierDTO>> getComboTiers(@RequestAttribute(value = "userId", required = false) Long userId) {
+        List<ComboTierDTO> tiers = new ArrayList<>();
+
+        // === 高端:五维全覆盖,价格区间较高 ===
+        Map<String, List<Product>> highProducts = productService.listByDimensions(
+                Arrays.asList("body", "mind", "wisdom", "action", "wealth"), 2);
+        List<Long> highSkuIds = buildSkuIdsFromProducts(highProducts);
+        tiers.add(buildTier("high", "尊享全家幸福包",
+                "涵盖身、智、富、行、心五大维度的完整幸福提案,精选优质产品,助您全家安享福贵双全",
+                highProducts, highSkuIds, 100000, 300000));
+
+        // === 中端:身+智+行,中等价位 ===
+        Map<String, List<Product>> mediumProducts = productService.listByDimensions(
+                Arrays.asList("body", "wisdom", "action"), 2);
+        List<Long> mediumSkuIds = buildSkuIdsFromProducts(mediumProducts);
+        tiers.add(buildTier("medium", "平衡成长包",
+                "精选2-3个核心维度的关键产品,性价比高,适合大多数家庭的起步配置",
+                mediumProducts, mediumSkuIds, 10000, 80000));
+
+        // === 低端:单一维度,入门价位 ===
+        List<Product> lowProducts = productService.listByDimension("body", null, null, 3);
+        List<Long> lowSkuIds = lowProducts.stream().map(p -> p.getId()).collect(java.util.stream.Collectors.toList());
+        tiers.add(buildTier("low", "基础启动包",
+                "单一维度必需品,入门首选,让您以最低成本体验核心关怀",
+                Collections.singletonMap("body", lowProducts), lowSkuIds, 0, 50000));
+
+        return Result.success(tiers);
+    }
+
+    /**
+     * 用户选择并确认套餐
+     */
+    @PostMapping("/select-combo")
+    public Result<ProcurementTaskDTO> selectCombo(@RequestBody ComboSelectionDTO selection,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (selection.getSelectedTier() == null || !Arrays.asList("high", "medium", "low").contains(selection.getSelectedTier())) {
+            return Result.error("无效的套餐等级");
+        }
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        Long memberId = selection.getFamilyMemberId() != null ? selection.getFamilyMemberId() : familyMemberId;
+        if (memberId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+
+        ProcurementTaskDTO task = procurementTaskService.createTask(
+                userId, memberId, selection.getSelectedTier(),
+                selection.getSelectedSkuIds(), selection.getAddedSkuIds(), selection.getRemovedSkuIds());
+        return Result.success(task);
+    }
+
+    /**
+     * 获取当前定制状态
+     */
+    @PostMapping("/customization-state")
+    public Result<CustomizationStateDTO> getCustomizationState(
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (userId == null) return Result.error("请先登录");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setUserId(userId);
+        state.setFamilyMemberId(familyMemberId);
+        state.setCurrentTier("low");
+        state.setBaseSkuIds(new ArrayList<>());
+        state.setAddedSkuIds(new ArrayList<>());
+        state.setRemovedSkuIds(new ArrayList<>());
+        state.setFinalSkuIds(new ArrayList<>());
+        state.setIsModified(false);
+        state.setLogEntries(new ArrayList<>());
+        return Result.success(state);
+    }
+
+    /**
+     * 添加产品到当前选择
+     */
+    @PostMapping("/add-product")
+    public Result<CustomizationStateDTO> addProduct(@RequestBody Map<String, Object> req,
+                                                     @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        String skuName = req.get("skuName") != null ? req.get("skuName").toString() : "";
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setAddedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        CustomizationStateDTO.CustomizationLogEntry entry = new CustomizationStateDTO.CustomizationLogEntry();
+        entry.setAction("add");
+        entry.setSkuId(skuId);
+        entry.setSkuName(skuName);
+        entry.setTimestamp(new Date());
+        state.setLogEntries(Collections.singletonList(entry));
+        return Result.success(state);
+    }
+
+    /**
+     * 从当前选择中移除产品
+     */
+    @PostMapping("/remove-product")
+    public Result<CustomizationStateDTO> removeProduct(@RequestBody Map<String, Object> req) {
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setRemovedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        return Result.success(state);
+    }
+
+    /**
+     * 启动采购流程(SELECTED → PROCURING → EXECUTING)
+     */
+    @PostMapping("/task/procure/{taskId}")
+    public Result<ProcurementTaskDTO> startProcurement(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.startProcurement(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 完成执行(EXECUTING → COMPLETED + 发送消息提醒)
+     */
+    @PostMapping("/task/execute/{taskId}")
+    public Result<ProcurementTaskDTO> completeExecution(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.completeExecution(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 取消任务
+     */
+    @PostMapping("/task/cancel/{taskId}")
+    public Result<ProcurementTaskDTO> cancelTask(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.cancelTask(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取任务详情
+     */
+    @PostMapping("/task-detail")
+    public Result<ProcurementTaskDTO> taskDetail(@RequestBody Map<String, Object> params) {
+        String taskId = params.get("taskId") != null ? params.get("taskId").toString() : null;
+        if (taskId == null) return Result.error("缺少任务ID");
+        ProcurementTaskDTO task = procurementTaskService.getTaskById(taskId);
+        return task != null ? Result.success(task) : Result.error("任务不存在");
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    @PostMapping("/task-list")
+    public Result<List<ProcurementTaskDTO>> taskList(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long queryUserId = params.get("userId") != null ?
+                ((Number) params.get("userId")).longValue() : userId;
+        return Result.success(procurementTaskService.getUserTasks(queryUserId));
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private ComboTierDTO buildTier(String tier, String title, String description,
+                                    Map<String, List<Product>> productMap,
+                                    List<Long> skuIds, int minPrice, int maxPrice) {
+        ComboTierDTO c = new ComboTierDTO();
+        c.setTier(tier);
+        c.setTitle(title);
+        c.setDescription(description);
+        c.setPrice(calculateComboPrice(productMap, skuIds));
+        c.setDimensions(new ArrayList<>(productMap.keySet()));
+        c.setBaseSkuIds(skuIds);
+        c.setCustomizable(true);
+        // 可添加/移除的品类名称
+        c.setAddableCategories(Arrays.asList("nutrition", "assessment", "workshop", "counseling", "membership"));
+        c.setRemovableCategories(new ArrayList<>(productMap.keySet()));
+        return c;
+    }
+
+    /**
+     * 从产品映射中提取实际 SKU ID 列表(取每个产品的第一个上架 SKU ID 或 productId 作为 base)
+     */
+    private List<Long> buildSkuIdsFromProducts(Map<String, List<Product>> productMap) {
+        List<Long> ids = new ArrayList<>();
+        for (List<Product> products : productMap.values()) {
+            if (products == null) continue;
+            for (Product p : products) {
+                if (p != null && p.getId() != null) {
+                    ids.add(p.getId());
+                }
+            }
+        }
+        return ids;
+    }
+
+    private int calculateComboPrice(Map<String, List<Product>> productMap, List<Long> skuIds) {
+        int total = 0;
+        if (productMap != null) {
+            for (List<Product> products : productMap.values()) {
+                if (products == null) continue;
+                for (Product p : products) {
+                    if (p.getPrice() != null) total += p.getPrice();
+                }
+            }
+        }
+        return Math.max(total, 1000); // 最低 10 元(DB 无数据兜底)
+    }
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboSelectionDTO.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class ComboSelectionDTO {
+    /** 用户选择的套餐等级 */
+    private String selectedTier; // high|medium|low
+    /** 是否进行了自定义 (add/remove) */
+    private Boolean customized;
+    /** 原始套餐等级 (customization时使用) */
+    private String originalTier;
+    /** 最终选中的商品 ID 列表 */
+    private List<Long> selectedSkuIds;
+    /** 用户新增的商品 ID 列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品 ID 列表 */
+    private List<Long> removedSkuIds;
+    /** 当前登录用户ID */
+    private Long userId;
+    /** 当前家庭成员ID */
+    private Long familyMemberId;
+    /** 维度权重调整 (可选) */
+    private Map<String, Integer> dimensionWeights; // body,mind,wisdom,action,wealth
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboTierDTO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class ComboTierDTO {
+    /** 套餐等级: high|medium|low */
+    private String tier;
+    /** 套餐标题 */
+    private String title;
+    /** 套餐描述 */
+    private String description;
+    /** 套餐价格(分) */
+    private Integer price;
+    /** 覆盖的维度列表 */
+    private List<String> dimensions;
+    /** 基础商品 ID 列表(对应 products.id) */
+    private List<Long> baseSkuIds;
+    /** 是否可定制 */
+    private Boolean customizable;
+    /** 可添加的产品类目名称 */
+    private List<String> addableCategories;
+    /** 可移除的产品维度码 */
+    private List<String> removableCategories;
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CustomizationStateDTO.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class CustomizationStateDTO {
+    /** 当前选中的套餐等级 */
+    private String currentTier; // high|medium|low
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 基础套餐的商品ID列表(在定制前的状态) */
+    private List<Long> baseSkuIds;
+    /** 用户新增的商品ID列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> removedSkuIds;
+    /** 最终确定的商品ID列表(基础+新增-移除) */
+    private List<Long> finalSkuIds;
+    /** 可供添加的产品分类及对应的SKU选项 */
+    private Map<String, List<Long>> availableAdditions; // categoryId -> [productId, ...]
+    /** 当前套餐覆盖的维度 */
+    private List<String> currentDimensions;
+    /** 定制是否已修改过 (与原始套餐比较) */
+    private Boolean isModified;
+    /** 定制操作日志(简化版) */
+    private List<CustomizationLogEntry> logEntries;
+
+    @Data
+    public static class CustomizationLogEntry {
+        private String action; // "add" 或 "remove"
+        private Long skuId;
+        private String skuName;
+        private Date timestamp;
+    }
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProcurementTaskDTO.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class ProcurementTaskDTO {
+    /** 任务唯一标识 */
+    private String taskId;
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+    /** 套餐原始等级 (用于自定义追踪) */
+    private String originalComboTier;
+    /** 最终选中的商品ID列表(对应 products.id) */
+    private List<Long> selectedProductSkuIds;
+    /** 用户自新增的商品ID列表 */
+    private List<Long> customAdditionSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> customRemovalSkuIds;
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+    /** 创建时间 */
+    private Date createTime;
+    /** 完成时间 */
+    private Date completeTime;
+    /** 是否已发送消息提醒 */
+    private Boolean messageReminderSent;
+    /** 关联的执行任务ID (status=EXECUTING时设置) */
+    private String executionTaskId;
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProcurementTask.java

@@ -0,0 +1,63 @@
+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.util.Date;
+
+/**
+ * 采购任务实体
+ * 管理商品组合推荐、用户定制、采购任务生命周期
+ */
+@Data
+@TableName("procurement_tasks")
+public class ProcurementTask implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 任务唯一标识 */
+    private String taskId;
+
+    /** 所属用户ID */
+    private Long userId;
+
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+
+    /** 原始套餐等级 (用于自定义追踪) */
+    private String originalComboTier;
+
+    /** 最终选中的产品SKU列表 (JSON数组) */
+    private String selectedProductSkuIds;
+
+    /** 用户自新增的产品SKU列表 (JSON数组) */
+    private String customAdditionSkuIds;
+
+    /** 用户移除的产品SKU列表 (JSON数组) */
+    private String customRemovalSkuIds;
+
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 完成时间 */
+    private Date completeTime;
+
+    /** 是否已发送消息提醒 */
+    private Integer messageReminderSent;
+
+    /** 关联的执行任务ID */
+    private String executionTaskId;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProcurementTaskMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProcurementTask;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProcurementTaskMapper extends BaseMapper<ProcurementTask> {
+}

+ 301 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProcurementTaskService.java

@@ -0,0 +1,301 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ProcurementTaskDTO;
+import com.etotem.cfc.entity.ProcurementTask;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProcurementTaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 采购任务服务
+ * 管理商品组合推荐、用户定制、采购任务全生命周期
+ */
+@Slf4j
+@Service
+public class ProcurementTaskService {
+
+    @Resource
+    private ProcurementTaskMapper procurementTaskMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProductSkuService productSkuService;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /**
+     * 创建采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO createTask(Long userId, Long familyMemberId, String selectedTier,
+                                         List<Long> baseSkuIds, List<Long> addedSkuIds,
+                                         List<Long> removedSkuIds) {
+        List<Long> resolvedBase = resolveSkuIds(baseSkuIds);
+        List<Long> resolvedAdded = resolveSkuIds(addedSkuIds);
+        List<Long> resolvedRemoved = resolveSkuIds(removedSkuIds);
+        List<Long> finalSkuIds = mergeSkus(resolvedBase, resolvedAdded, resolvedRemoved);
+
+        ProcurementTaskDTO task = new ProcurementTaskDTO();
+        task.setTaskId(UUID.randomUUID().toString());
+        task.setUserId(userId);
+        task.setFamilyMemberId(familyMemberId);
+        task.setComboTier(selectedTier);
+        task.setOriginalComboTier(selectedTier);
+        task.setSelectedProductSkuIds(finalSkuIds);
+        task.setCustomAdditionSkuIds(resolvedAdded);
+        task.setCustomRemovalSkuIds(resolvedRemoved);
+        task.setStatus("SELECTED");
+        task.setCreateTime(new Date());
+        task.setMessageReminderSent(false);
+
+        saveTask(task);
+        log.info("创建采购任务: taskId={}, userId={}, tier={}, skus={}",
+                task.getTaskId(), userId, selectedTier, finalSkuIds);
+        return task;
+    }
+
+    /**
+     * 启动采购流程 - SELECTED → PROCURING → PROCURED → EXECUTING
+     */
+    @Transactional
+    public ProcurementTaskDTO startProcurement(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 状态的任务可进行采购流程");
+        }
+
+        task.setStatus("PROCURING");
+        updateTask(task);
+
+        // 模拟采购执行(实际应调用供应商API/库存服务)
+        boolean success = executeProcurement(task);
+
+        if (success) {
+            task.setStatus("PROCURED");
+            task.setExecutionTaskId(UUID.randomUUID().toString());
+            task.setStatus("EXECUTING");
+            log.info("采购成功,进入执行阶段: taskId={}, executionTaskId={}", taskId, task.getExecutionTaskId());
+        } else {
+            task.setStatus("CANCELLED");
+            log.warn("采购失败: taskId={}", taskId);
+        }
+
+        updateTask(task);
+        return task;
+    }
+
+    /**
+     * 完成任务执行 - EXECUTING → COMPLETED,发送消息提醒
+     */
+    @Transactional
+    public ProcurementTaskDTO completeExecution(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"EXECUTING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 EXECUTING 状态的任务可标记为完成");
+        }
+
+        task.setStatus("COMPLETED");
+        task.setCompleteTime(new Date());
+
+        if (task.getMessageReminderSent() == null || !task.getMessageReminderSent()) {
+            sendCompletionReminder(task);
+            task.setMessageReminderSent(true);
+        }
+
+        updateTask(task);
+        log.info("执行任务完成: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 取消采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO cancelTask(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus()) && !"PROCURING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 或 PROCURING 状态的任务可取消");
+        }
+
+        task.setStatus("CANCELLED");
+        task.setCompleteTime(new Date());
+        updateTask(task);
+        log.info("取消采购任务: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 获取任务详情
+     */
+    public ProcurementTaskDTO getTaskById(String taskId) {
+        return convertToDTO(procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, taskId)));
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    public List<ProcurementTaskDTO> getUserTasks(Long userId) {
+        List<ProcurementTask> tasks = procurementTaskMapper.selectList(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getUserId, userId)
+                        .orderByDesc(ProcurementTask::getCreateTime));
+        return tasks.stream().map(this::convertToDTO).collect(Collectors.toList());
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private List<Long> resolveSkuIds(List<Long> skuIds) {
+        if (skuIds == null || skuIds.isEmpty()) return new ArrayList<>();
+        // 过滤无效 ID(<=0)
+        return skuIds.stream()
+                .filter(id -> id != null && id > 0)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private List<Long> mergeSkus(List<Long> base, List<Long> added, List<Long> removed) {
+        Set<Long> result = new LinkedHashSet<>(base);
+        result.addAll(added);
+        result.removeAll(removed);
+        return new ArrayList<>(result);
+    }
+
+    private boolean executeProcurement(ProcurementTaskDTO task) {
+        // 实际应:检查库存、调用供应商API、扣减库存、生成订单等
+        // 简化实现:假设采购成功
+        log.info("执行采购: taskId={}, skus={}", task.getTaskId(), task.getSelectedProductSkuIds());
+        return true;
+    }
+
+    private void sendCompletionReminder(ProcurementTaskDTO task) {
+        try {
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("comboTier", formatTierLabel(task.getComboTier()));
+            vars.put("productCount", task.getSelectedProductSkuIds() != null ? task.getSelectedProductSkuIds().size() : 0);
+            vars.put("createTime", task.getCreateTime() != null ?
+                    new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(task.getCreateTime()) : "");
+            notificationDispatchService.sendByTemplate(
+                    "procurement_completed",
+                    task.getUserId(),
+                    vars,
+                    "procurement_task",
+                    null,
+                    "采购任务完成",
+                    "您的采购任务已完成,产品即将送达。请及时查收!"
+            );
+            log.info("发送采购完成提醒: taskId={}, userId={}", task.getTaskId(), task.getUserId());
+        } catch (Exception e) {
+            log.warn("发送采购完成提醒失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
+        }
+    }
+
+    private String formatTierLabel(String tier) {
+        if (tier == null) return "未知";
+        switch (tier) {
+            case "high": return "尊享";
+            case "medium": return "平衡";
+            case "low": return "入门";
+            default: return tier;
+        }
+    }
+
+    private ProcurementTaskDTO convertToDTO(ProcurementTask task) {
+        if (task == null) return null;
+        ProcurementTaskDTO dto = new ProcurementTaskDTO();
+        dto.setTaskId(task.getTaskId());
+        dto.setUserId(task.getUserId());
+        dto.setFamilyMemberId(task.getFamilyMemberId());
+        dto.setComboTier(task.getComboTier());
+        dto.setOriginalComboTier(task.getOriginalComboTier());
+        dto.setSelectedProductSkuIds(fromJson(task.getSelectedProductSkuIds()));
+        dto.setCustomAdditionSkuIds(fromJson(task.getCustomAdditionSkuIds()));
+        dto.setCustomRemovalSkuIds(fromJson(task.getCustomRemovalSkuIds()));
+        dto.setStatus(task.getStatus());
+        dto.setCreateTime(task.getCreateTime());
+        dto.setCompleteTime(task.getCompleteTime());
+        dto.setMessageReminderSent(task.getMessageReminderSent() != null && task.getMessageReminderSent() == 1);
+        dto.setExecutionTaskId(task.getExecutionTaskId());
+        return dto;
+    }
+
+    private void saveTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = new ProcurementTask();
+        task.setTaskId(dto.getTaskId());
+        task.setUserId(dto.getUserId());
+        task.setFamilyMemberId(dto.getFamilyMemberId());
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCreateTime(dto.getCreateTime());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.insert(task);
+    }
+
+    private void updateTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, dto.getTaskId()));
+        if (task == null) return;
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.updateById(task);
+    }
+
+    private String toJson(List<Long> list) {
+        if (list == null) return null;
+        return com.alibaba.fastjson.JSON.toJSONString(list);
+    }
+
+    private List<Long> fromJson(String json) {
+        if (json == null || json.isEmpty()) return new ArrayList<>();
+        try {
+            return com.alibaba.fastjson.JSON.parseArray(json, Long.class);
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -28,6 +28,7 @@ import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -71,6 +72,65 @@ public class ProductService {
         return productMapper.selectById(id);
     }
 
+    /**
+     * 按维度查询上架商品(用于商品组合方案生成)
+     * 支持价格区间筛选,返回前 limit 个上架商品
+     *
+     * @param dimensionCode 维度码 (body/mind/wisdom/action/wealth)
+     * @param minPrice      最低价(分,可为 null)
+     * @param maxPrice      最高价(分,可为 null)
+     * @param limit         最大返回数量
+     * @return 上架且维度匹配的商品列表
+     */
+    public List<Product> listByDimension(String dimensionCode, Integer minPrice, Integer maxPrice, int limit) {
+        if (dimensionCode == null || dimensionCode.isEmpty() || !DimensionCodes.isValid(dimensionCode)) {
+            return new ArrayList<>();
+        }
+        String dim = dimensionCode.trim();
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .and(w -> w
+                        .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                        .or().eq(Product::getDomain, dim));
+        if (minPrice != null) {
+            wrapper.ge(Product::getPrice, minPrice);
+        }
+        if (maxPrice != null) {
+            wrapper.le(Product::getPrice, maxPrice);
+        }
+        wrapper.orderByAsc(Product::getSortOrder).orderByAsc(Product::getId).last("LIMIT " + Math.max(1, limit));
+        return productMapper.selectList(wrapper);
+    }
+
+    /**
+     * 按维度批量查询上架商品(五维方案生成用)
+     * 对每个维度码调用 listByDimension 并返回 维度码→商品列表 的映射
+     *
+     * @param dimensionCodes 维度码列表
+     * @param limitPerDim    每维度最大返回数量
+     * @return 维度码→上架商品列表
+     */
+    public Map<String, List<Product>> listByDimensions(List<String> dimensionCodes, int limitPerDim) {
+        Map<String, List<Product>> result = new LinkedHashMap<>();
+        if (dimensionCodes == null) return result;
+        for (String dim : dimensionCodes) {
+            if (dim != null && !dim.isEmpty()) {
+                result.put(dim, listByDimension(dim, null, null, limitPerDim));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 按商品名称精确查找(用于占位 SKU 名解析)
+     */
+    public Product findByName(String name) {
+        if (name == null || name.trim().isEmpty()) return null;
+        return productMapper.selectOne(new LambdaQueryWrapper<Product>()
+                .eq(Product::getName, name.trim())
+                .last("LIMIT 1"));
+    }
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId, String role) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()

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

@@ -6252,3 +6252,27 @@ CREATE TABLE IF NOT EXISTS indicator_correlations (
     INDEX idx_def_a (definition_id_a),
     INDEX idx_def_b (definition_id_b)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鎸囨爣鍏宠仈鍏崇郴(浜哄伐瀹氫箟+鏁版嵁鏍″噯)';
+
+-- 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+CREATE TABLE IF NOT EXISTS procurement_tasks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
+    user_id BIGINT NOT NULL COMMENT '所属用户ID',
+    family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID',
+    combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low',
+    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)',
+    selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)',
+    custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)',
+    custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)',
+    status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
+    create_time DATETIME NOT NULL COMMENT '创建时间',
+    complete_time DATETIME DEFAULT NULL COMMENT '完成时间',
+    message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒',
+    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID',
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_task_id (task_id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_family_member_id (family_member_id),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';