Преглед изворни кода

feat: 商品推荐 Phase 3-5 — FamilyContextService Dify输入 + AIChatController userId + 复购提醒

Phase 3 (P1):
- FamilyContextService: 新增3个Dify inputs (dimensionScores/recentCognitiveResult/purchasedProductTags) + 3私有辅助方法
- AIChatController: RecommendationQuery传入userId和familyId用于已购过滤

Phase 4 (P1):
- AssessmentService已有异步hook: DanAssessmentResult parent_upload 触发认知弱维度商品推荐

Phase 5 (P2):
- RepurchaseReminderService: @Scheduled 每日09:00扫描30-60天订单创建复购提醒 + getPendingReminders/onReminderClicked/onReminderPurchased
- RepurchaseReminderController: GET list + POST click/purchased 3个端点
- RepurchaseReminder.vue: 前端待处理复购提醒组件
Xiaogang Liao пре 3 месеци
родитељ
комит
06831deebb

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -262,6 +262,10 @@ public class AIChatController {
                         rq.setNutritionTags(tags);
                         rq.setTypes(types != null && !types.isEmpty() ? types : null);
                         rq.setLimit(limit);
+                        rq.setUserId(userId);
+                        if (user != null) {
+                            rq.setFamilyId(user.getFamilyId());
+                        }
                         recommendations = recommendationService.search(rq);
                     }
                 }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -4,7 +4,11 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import com.alibaba.fastjson.JSON;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -13,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -23,6 +29,12 @@ public class ProductController {
     @Resource
     private ProductService productService;
 
+    @Resource
+    private ProductSkuService productSkuService;
+
+    @Resource
+    private PpointService ppointService;
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody ProductListQueryDTO query,
                                              @RequestAttribute(value = "userId", required = false) Long userId) {

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RepurchaseReminderController.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.RepurchaseReminderService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 复购提醒接口
+ */
+@Slf4j
+@Tag(name = "复购提醒", description = "复购提醒相关接口")
+@RestController
+@RequestMapping("/api/recommend")
+public class RepurchaseReminderController {
+
+    @Resource
+    private RepurchaseReminderService repurchaseReminderService;
+
+    @Operation(summary = "获取待处理的复购提醒")
+    @PostMapping("/repurchase-reminders/list")
+    public Result<List<Map<String, Object>>> getPendingReminders(
+            @RequestAttribute("userId") Long userId) {
+        List<Map<String, Object>> reminders = repurchaseReminderService.getPendingReminders(userId);
+        return Result.success(reminders);
+    }
+
+    @Operation(summary = "标记复购提醒为已点击")
+    @PostMapping("/repurchase-reminders/click")
+    public Result<Void> clickReminder(@RequestBody Map<String, Object> params) {
+        Object idObj = params.get("id");
+        if (idObj == null) {
+            return Result.error("id不能为空");
+        }
+        Long reminderId = idObj instanceof Number ? ((Number) idObj).longValue() : Long.valueOf(idObj.toString());
+        repurchaseReminderService.onReminderClicked(reminderId);
+        return Result.success();
+    }
+
+    @Operation(summary = "标记复购提醒为已购买")
+    @PostMapping("/repurchase-reminders/purchased")
+    public Result<Void> purchasedReminder(@RequestBody Map<String, Object> params) {
+        Object idObj = params.get("id");
+        if (idObj == null) {
+            return Result.error("id不能为空");
+        }
+        Long reminderId = idObj instanceof Number ? ((Number) idObj).longValue() : Long.valueOf(idObj.toString());
+        Object orderIdObj = params.get("orderId");
+        Long orderId = orderIdObj != null
+                ? (orderIdObj instanceof Number ? ((Number) orderIdObj).longValue() : Long.valueOf(orderIdObj.toString()))
+                : null;
+        repurchaseReminderService.onReminderPurchased(reminderId, orderId);
+        return Result.success();
+    }
+}

+ 113 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java

@@ -1,14 +1,16 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.ChildInfoDTO;
-import com.etotem.cfc.entity.HealthReport;
-import com.etotem.cfc.entity.ReportSurvey;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * 家庭上下文组装服务
@@ -17,6 +19,8 @@ import java.util.*;
 @Service
 public class FamilyContextService {
 
+    private static final Logger log = LoggerFactory.getLogger(FamilyContextService.class);
+
     @Resource
     private UserService userService;
 
@@ -32,6 +36,21 @@ public class FamilyContextService {
     @Resource
     private ReportSurveyService reportSurveyService;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private OrderItemMapper orderItemMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
     /**
      * 构建当前用户的家庭上下文
      *
@@ -102,6 +121,28 @@ public class FamilyContextService {
         }
         ctx.put("children", childList);
 
+        // 3. 家庭成员五维能量分数(用于 AI 回答关于能量状态的问题)
+        if (currentUser != null && currentUser.getFamilyId() != null) {
+            Map<String, Integer> dimensionScores = getDimensionScores(currentUser.getFamilyId());
+            if (dimensionScores != null && !dimensionScores.isEmpty()) {
+                ctx.put("dimensionScores", dimensionScores);
+            }
+        }
+
+        // 4. 最近认知测评结果(用于 AI 了解孩子认知发展水平)
+        Map<String, Object> cognitiveResult = getRecentCognitiveResult(userId);
+        if (cognitiveResult != null && !cognitiveResult.isEmpty()) {
+            ctx.put("recentCognitiveResult", cognitiveResult);
+        }
+
+        // 5. 已购商品标签(用于 AI 推荐个性化商品)
+        if (currentUser != null && currentUser.getFamilyId() != null) {
+            List<String> purchasedTags = getPurchasedProductTags(currentUser.getFamilyId());
+            if (purchasedTags != null && !purchasedTags.isEmpty()) {
+                ctx.put("purchasedProductTags", purchasedTags);
+            }
+        }
+
         return ctx;
     }
 
@@ -149,4 +190,72 @@ public class FamilyContextService {
 
         return ctx;
     }
+
+    /**
+     * 获取家庭成员的最新五维能量分数
+     */
+    private Map<String, Integer> getDimensionScores(Long familyId) {
+        try {
+            return fiveDimensionScoreService.getLatestByFamilyMap(familyId);
+        } catch (Exception e) {
+            log.warn("获取维度分数失败: {}", e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    /**
+     * 获取最近认知测评结果(取第一个孩子的最新 completed 记录)
+     */
+    private Map<String, Object> getRecentCognitiveResult(Long userId) {
+        try {
+            List<ChildInfoDTO> children = userService.getChildren(userId);
+            if (children == null || children.isEmpty()) return null;
+            Long childId = children.get(0).getId();
+            DanAssessmentResult result = danAssessmentResultMapper.selectOne(
+                    new LambdaQueryWrapper<DanAssessmentResult>()
+                            .eq(DanAssessmentResult::getFamilyMemberId, childId)
+                            .eq(DanAssessmentResult::getStatus, "completed")
+                            .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                            .last("LIMIT 1"));
+            if (result == null) return null;
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("overallScore", result.getOverallScore());
+            map.put("assessmentDate", result.getAssessmentDate() != null ? result.getAssessmentDate().toString() : "");
+            map.put("source", result.getSource());
+            return map;
+        } catch (Exception e) {
+            log.warn("获取认知测评结果失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 获取已购商品标签(最近10个已完成订单的商品 domain)
+     */
+    private List<String> getPurchasedProductTags(Long familyId) {
+        try {
+            List<ProductOrder> orders = productOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .eq(ProductOrder::getFamilyId, familyId)
+                            .eq(ProductOrder::getStatus, "completed")
+                            .orderByDesc(ProductOrder::getCreatedAt)
+                            .last("LIMIT 10"));
+            if (orders == null || orders.isEmpty()) return Collections.emptyList();
+
+            Set<String> tags = new LinkedHashSet<>();
+            for (ProductOrder order : orders) {
+                List<OrderItem> items = orderItemMapper.findByOrderId(order.getId());
+                for (OrderItem item : items) {
+                    Product product = productMapper.selectById(item.getProductId());
+                    if (product != null && product.getDomain() != null) {
+                        tags.add(product.getDomain());
+                    }
+                }
+            }
+            return new ArrayList<>(tags);
+        } catch (Exception e) {
+            log.warn("获取已购商品标签失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
 }

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseReminderService.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.RepurchaseReminderConfig;
+import com.etotem.cfc.entity.RepurchaseReminderRecord;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.RepurchaseReminderConfigMapper;
+import com.etotem.cfc.mapper.RepurchaseReminderRecordMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class RepurchaseReminderService {
+
+    private static final Logger log = LoggerFactory.getLogger(RepurchaseReminderService.class);
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private RepurchaseReminderConfigMapper reminderConfigMapper;
+
+    @Resource
+    private RepurchaseReminderRecordMapper reminderRecordMapper;
+
+    /**
+     * 每日 09:00 扫描到期复购提醒
+     */
+    @Scheduled(cron = "0 0 9 * * ?")
+    public void scanAndCreateReminders() {
+        log.info("开始扫描复购提醒...");
+
+        // 1. 查询 30~60 天前已完成的订单
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -60);
+        Date fromDate = cal.getTime();
+        cal.add(Calendar.DAY_OF_YEAR, 30);
+        Date toDate = cal.getTime();
+
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getStatus, "completed")
+                        .ge(ProductOrder::getCreatedAt, fromDate)
+                        .lt(ProductOrder::getCreatedAt, toDate));
+
+        if (orders.isEmpty()) {
+            log.info("无到期复购订单");
+            return;
+        }
+
+        // 2. 加载所有启用的 config
+        List<RepurchaseReminderConfig> configs = reminderConfigMapper.selectList(
+                new LambdaQueryWrapper<RepurchaseReminderConfig>()
+                        .eq(RepurchaseReminderConfig::getEnabled, 1));
+
+        if (configs.isEmpty()) {
+            log.info("无复购提醒配置");
+            return;
+        }
+
+        // 3. 为每个订单匹配 config 并创建提醒
+        int created = 0;
+        for (ProductOrder order : orders) {
+            for (RepurchaseReminderConfig config : configs) {
+                if (!matchesConfig(order, config)) continue;
+
+                // 检查是否已发送过 reminder 且未超出最大次数
+                Long count = reminderRecordMapper.selectCount(
+                        new LambdaQueryWrapper<RepurchaseReminderRecord>()
+                                .eq(RepurchaseReminderRecord::getUserId, order.getBuyerId())
+                                .eq(RepurchaseReminderRecord::getProductId, order.getProductId()));
+
+                int maxReminders = config.getMaxReminders() != null ? config.getMaxReminders() : 3;
+                if (count != null && count >= maxReminders) {
+                    continue;
+                }
+
+                RepurchaseReminderRecord record = new RepurchaseReminderRecord();
+                record.setUserId(order.getBuyerId());
+                record.setProductId(order.getProductId());
+                record.setOrderId(order.getId());
+                record.setReminderDays(config.getReminderDays() != null ? config.getReminderDays() : 30);
+                record.setSentAt(new Date());
+                record.setClicked(0);
+                record.setPurchased(0);
+                reminderRecordMapper.insert(record);
+                created++;
+                log.info("已创建复购提醒: userId={}, productId={}", order.getBuyerId(), order.getProductId());
+            }
+        }
+
+        log.info("复购提醒扫描完成,共创建 {} 条", created);
+    }
+
+    /**
+     * 检查订单商品是否匹配提醒配置
+     */
+    private boolean matchesConfig(ProductOrder order, RepurchaseReminderConfig config) {
+        if (config.getProductId() != null) {
+            return config.getProductId().equals(order.getProductId());
+        }
+        if (config.getProductCategory() != null) {
+            Product product = productMapper.selectById(order.getProductId());
+            return product != null && config.getProductCategory().equals(product.getDomain());
+        }
+        return false;
+    }
+
+    /**
+     * 获取用户待处理的复购提醒
+     */
+    public List<Map<String, Object>> getPendingReminders(Long userId) {
+        List<RepurchaseReminderRecord> records = reminderRecordMapper.selectList(
+                new LambdaQueryWrapper<RepurchaseReminderRecord>()
+                        .eq(RepurchaseReminderRecord::getUserId, userId)
+                        .eq(RepurchaseReminderRecord::getPurchased, 0)
+                        .orderByDesc(RepurchaseReminderRecord::getSentAt));
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (RepurchaseReminderRecord record : records) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", record.getId());
+            item.put("productId", record.getProductId());
+            item.put("reminderDays", record.getReminderDays());
+            item.put("sentAt", record.getSentAt());
+            item.put("clicked", record.getClicked());
+
+            Product product = productMapper.selectById(record.getProductId());
+            if (product != null) {
+                item.put("productName", product.getName());
+                item.put("coverImage", product.getCoverImage());
+                item.put("price", product.getPrice());
+            }
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 标记提醒为已点击
+     */
+    @Transactional
+    public void onReminderClicked(Long reminderId) {
+        RepurchaseReminderRecord record = reminderRecordMapper.selectById(reminderId);
+        if (record != null) {
+            record.setClicked(1);
+            reminderRecordMapper.updateById(record);
+            log.info("复购提醒已点击: id={}", reminderId);
+        }
+    }
+
+    /**
+     * 标记提醒为已购买
+     */
+    @Transactional
+    public void onReminderPurchased(Long reminderId, Long orderId) {
+        RepurchaseReminderRecord record = reminderRecordMapper.selectById(reminderId);
+        if (record != null) {
+            record.setPurchased(1);
+            reminderRecordMapper.updateById(record);
+            log.info("复购提醒已购买: id={}, orderId={}", reminderId, orderId);
+        }
+    }
+}

+ 120 - 0
cfc-frontend/components/RepurchaseReminder.vue

@@ -0,0 +1,120 @@
+<template>
+  <view class="repurchase-container" v-if="reminders.length > 0">
+    <view class="rr-header">
+      <text class="rr-title">复购提醒</text>
+    </view>
+    <view
+      class="rr-item"
+      v-for="item in reminders"
+      :key="item.id"
+      @click="goProduct(item)">
+      <image class="rr-image" :src="item.coverImage" mode="aspectFill" />
+      <view class="rr-info">
+        <text class="rr-name">{{ item.productName }}</text>
+        <text class="rr-price" v-if="item.price">¥{{ (item.price / 100).toFixed(2) }}</text>
+        <text class="rr-hint">上次购买已过 {{ item.reminderDays }} 天,需要补货吗?</text>
+      </view>
+      <view class="rr-action">
+        <text class="rr-btn">去看看</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getRepurchaseReminders, clickRepurchaseReminder } from '../utils/api.js'
+
+export default {
+  data() {
+    return {
+      reminders: []
+    }
+  },
+  mounted() {
+    this.loadReminders()
+  },
+  methods: {
+    loadReminders: function() {
+      var self = this
+      getRepurchaseReminders().then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.reminders = res.data
+        }
+      }).catch(function() {})
+    },
+    goProduct: function(item) {
+      clickRepurchaseReminder({ id: item.id })
+      uni.navigateTo({
+        url: '/pages/shop/detail?id=' + item.productId + '&from=repurchase'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.repurchase-container {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 20rpx 30rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.rr-header {
+  margin-bottom: 16rpx;
+}
+.rr-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #333;
+}
+.rr-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #f5f5f5;
+}
+.rr-image {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 12rpx;
+  flex-shrink: 0;
+}
+.rr-info {
+  flex: 1;
+  margin-left: 16rpx;
+  min-width: 0;
+}
+.rr-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #333;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.rr-price {
+  font-size: 24rpx;
+  color: #F97316;
+  margin-top: 4rpx;
+  display: block;
+}
+.rr-hint {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.rr-action {
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.rr-btn {
+  background: #F97316;
+  color: #fff;
+  font-size: 22rpx;
+  padding: 8rpx 20rpx;
+  border-radius: 24rpx;
+}
+</style>