Explorar o código

feat: 商品推荐维度映射 + 复购提醒 + 供应商/规格迁移补全

- 修复 DatabaseInitializer 缩进与多余括号导致编译失败
- 迁移80: 创建 product_dimension_mapping 表 + 索引
- 迁移81: products 表添加 recommendation_tags/purchase_count_threshold/repurchase_interval_days
- 迁移82: 创建 repurchase_reminder_record 表
- 迁移83: 创建 repurchase_reminder_config 表
- 新增 ProductDimensionMapping/RepurchaseReminderConfig/RepurchaseReminderRecord 实体+Mapper
- schema.sql 同步新表DDL
- 小程序: DimensionProductList 组件 + api.js 推荐/复购接口
- 修复 RecommendationQuery DTO
Xiaogang Liao hai 2 meses
pai
achega
65a3b851df

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

@@ -5215,6 +5215,70 @@ try {
         }
 
         runMigration80();
+        runMigration81();
+    }
 
+    private void runMigration81() {
+        // 迁移 81: 创建 product_dimension_mapping 表(商品维度关联)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "product_id BIGINT NOT NULL COMMENT '商品 ID', " +
+                    "dimension_code VARCHAR(32) NOT NULL COMMENT '维度:body/wisdom/mind/action/wealth', " +
+                    "match_score INT DEFAULT 100 COMMENT '匹配度 0-100', " +
+                    "match_reason VARCHAR(200) COMMENT '匹配原因', " +
+                    "tags VARCHAR(500) COMMENT '推荐标签 JSON', " +
+                    "enabled TINYINT DEFAULT 1, " +
+                    "created_at DATETIME, " +
+                    "updated_at DATETIME, " +
+                    "INDEX idx_product (product_id), " +
+                    "INDEX idx_dimension (dimension_code) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'");
+            log.info("已创建 product_dimension_mapping 表");
+        } catch (Exception e) {
+            log.warn("创建 product_dimension_mapping 表失败:{}", e.getMessage());
+        }
+
+        // 迁移 81: products 表添加 recommendation_tags 字段
+        ensureColumn("products", "recommendation_tags", "VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain");
+        // 迁移 81: products 表添加 purchase_count_threshold 字段
+        ensureColumn("products", "purchase_count_threshold", "INT DEFAULT 0");
+        // 迁移 81: products 表添加 repurchase_interval_days 字段
+        ensureColumn("products", "repurchase_interval_days", "INT DEFAULT 30");
+
+        // 迁移 82: 创建 repurchase_reminder_record 表(复购提醒记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL, " +
+                    "product_id BIGINT NOT NULL, " +
+                    "order_id BIGINT COMMENT '关联订单 ID', " +
+                    "reminder_days INT DEFAULT 30, " +
+                    "sent_at DATETIME, " +
+                    "clicked TINYINT DEFAULT 0, " +
+                    "purchased TINYINT DEFAULT 0, " +
+                    "INDEX idx_user_pending (user_id, purchased) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒发送记录'");
+            log.info("已创建 repurchase_reminder_record 表");
+        } catch (Exception e) {
+            log.warn("创建 repurchase_reminder_record 表失败:{}", e.getMessage());
         }
+
+        // 迁移 83: 创建 repurchase_reminder_config 表(复购提醒配置)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_config (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "product_category VARCHAR(100), " +
+                    "product_id BIGINT COMMENT '特定商品 ID(优先于 category)', " +
+                    "reminder_days INT DEFAULT 30, " +
+                    "reminder_template VARCHAR(500) COMMENT '提醒话术模板', " +
+                    "max_reminders INT DEFAULT 3, " +
+                    "enabled TINYINT DEFAULT 1, " +
+                    "created_at DATETIME " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表'");
+            log.info("已创建 repurchase_reminder_config 表");
+        } catch (Exception e) {
+            log.warn("创建 repurchase_reminder_config 表失败:{}", e.getMessage());
+        }
+    }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationQuery.java

@@ -11,5 +11,6 @@ public class RecommendationQuery {
     private List<String> nutritionTags;   // 营养需求标签
     private List<String> types;           // ["product", "activity", "article"]
     private Integer limit = 5;            // 每类最多返回数量
-    private Long userId;                  // 用户ID
+    private Long userId;                  // 用户 ID
+    private Long familyId;                // 家庭 ID
 }

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductDimensionMapping.java

@@ -0,0 +1,37 @@
+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("product_dimension_mapping")
+public class ProductDimensionMapping implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    /** 维度:body/wisdom/mind/action/wealth */
+    private String dimensionCode;
+
+    /** 匹配度 0-100 */
+    private Integer matchScore;
+
+    /** 匹配原因 */
+    private String matchReason;
+
+    /** 推荐标签 JSON */
+    private String tags;
+
+    private Integer enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderConfig.java

@@ -0,0 +1,36 @@
+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("repurchase_reminder_config")
+public class RepurchaseReminderConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 商品类目 */
+    private String productCategory;
+
+    /** 特定商品 ID(优先于 category) */
+    private Long productId;
+
+    /** 提醒天数 */
+    private Integer reminderDays;
+
+    /** 提醒话术模板 */
+    private String reminderTemplate;
+
+    /** 最大提醒次数 */
+    private Integer maxReminders;
+
+    private Integer enabled;
+
+    private Date createdAt;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderRecord.java

@@ -0,0 +1,34 @@
+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("repurchase_reminder_record")
+public class RepurchaseReminderRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long productId;
+
+    private Long orderId;
+
+    /** 提醒天数 */
+    private Integer reminderDays;
+
+    private Date sentAt;
+
+    /** 是否点击 */
+    private Integer clicked;
+
+    /** 是否购买 */
+    private Integer purchased;
+}

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

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

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

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

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

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

+ 48 - 3
cfc-backend/src/main/resources/schema.sql

@@ -2672,11 +2672,56 @@ CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (
 -- 商品推荐日志表(迁移 80)
 CREATE TABLE IF NOT EXISTS product_recommendation_log (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    user_id BIGINT COMMENT '用户ID',
-    product_id BIGINT COMMENT '商品ID',
-    scene VARCHAR(32) COMMENT '触发场景: report_upload/manual/chat',
+    user_id BIGINT COMMENT '用户 ID',
+    product_id BIGINT COMMENT '商品 ID',
+    scene VARCHAR(32) COMMENT '触发场景report_upload/manual/chat',
     reason VARCHAR(255) COMMENT '推荐理由',
     match_score DOUBLE COMMENT '匹配分',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐日志';
+
+-- 商品维度关联表(迁移 81)
+CREATE TABLE IF NOT EXISTS product_dimension_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品 ID',
+    dimension_code VARCHAR(32) NOT NULL COMMENT '维度: body/wisdom/mind/action/wealth',
+    match_score INT DEFAULT 100 COMMENT '匹配度 0-100',
+    match_reason VARCHAR(200) COMMENT '匹配原因',
+    tags VARCHAR(500) COMMENT '推荐标签 JSON',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME,
+    updated_at DATETIME,
+    INDEX idx_product (product_id),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表';
+
+-- 复购提醒发送记录表(迁移 82)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL,
+    product_id BIGINT NOT NULL,
+    order_id BIGINT COMMENT '关联订单 ID',
+    reminder_days INT DEFAULT 30,
+    sent_at DATETIME,
+    clicked TINYINT DEFAULT 0,
+    purchased TINYINT DEFAULT 0,
+    INDEX idx_user_pending (user_id, purchased)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒发送记录';
+
+-- 复购提醒配置表(迁移 83)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_category VARCHAR(100),
+    product_id BIGINT COMMENT '特定商品 ID(优先于 category)',
+    reminder_days INT DEFAULT 30,
+    reminder_template VARCHAR(500) COMMENT '提醒话术模板',
+    max_reminders INT DEFAULT 3,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表';
+
+-- products 表添加推荐相关字段(迁移 81-83)
+ALTER TABLE products ADD COLUMN IF NOT EXISTS recommendation_tags VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS purchase_count_threshold INT DEFAULT 0;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS repurchase_interval_days INT DEFAULT 30;

+ 195 - 0
cfc-frontend/components/DimensionProductList.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="dimension-product-list">
+    <view class="section-header">
+      <text class="section-title">{{ title }}</text>
+    </view>
+    <view class="product-grid" v-if="products.length > 0">
+      <view class="product-card" v-for="product in products" :key="product.id" @click="goProduct(product)">
+        <image class="product-cover" :src="product.coverImage || '/static/default-product.png'" mode="aspectFill"></image>
+        <view class="product-info">
+          <text class="product-name">{{ product.name }}</text>
+          <view class="product-price-row">
+            <text class="product-price">¥{{ (product.price / 100).toFixed(2) }}</text>
+            <text class="product-reason" v-if="product.reason">{{ product.reason }}</text>
+          </view>
+          <view class="match-badge" v-if="product.matchScore">
+            <text class="match-score">{{ product.matchScore }}分</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    <view class="empty-state" v-else-if="!loading">
+      <text class="empty-text">暂无推荐商品</text>
+    </view>
+    <view class="loading-state" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getDimensionProducts, clickRepurchaseReminder } from '../../utils/api.js'
+
+export default {
+  props: {
+    dimensionCode: { type: String, required: true },
+    familyId: { type: [Number, String], required: true },
+    memberScores: { type: Object, default: function() { return {} } },
+    title: { type: String, default: '为你推荐' }
+  },
+  data: function() {
+    return {
+      products: [],
+      loading: false
+    }
+  },
+  attached: function() {
+    this.loadProducts()
+  },
+  methods: {
+    loadProducts: function() {
+      var self = this
+      self.loading = true
+      var params = {
+        dimensionCode: self.dimensionCode,
+        familyId: self.familyId,
+        limit: 6
+      }
+      if (self.memberScores && Object.keys(self.memberScores).length > 0) {
+        params.memberScores = self.memberScores
+      }
+      
+      getDimensionProducts(params).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.products = Array.isArray(res.data) ? res.data : (res.data.records || [])
+        }
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    goProduct: function(product) {
+      if (!product || !product.id) return
+      
+      // 如果有点击追踪 API,先调用
+      // 这里简化处理,直接跳转
+      uni.navigateTo({
+        url: '/pages/discover/product-detail/product-detail?id=' + product.id + '&from=dimension'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dimension-product-list {
+  margin: 20rpx 20rpx;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.product-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.product-card {
+  width: calc(50% - 10rpx);
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+
+.product-cover {
+  width: 100%;
+  height: 240rpx;
+  background: #f0f0f0;
+}
+
+.product-info {
+  padding: 16rpx;
+}
+
+.product-name {
+  font-size: 26rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.product-price-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+
+.product-price {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+
+.product-reason {
+  font-size: 22rpx;
+  color: #999;
+  max-width: 60%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.match-badge {
+  display: inline-block;
+  background: linear-gradient(135deg, #6366F1, #818CF8);
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  margin-top: 8rpx;
+}
+
+.match-score {
+  font-size: 22rpx;
+  color: #fff;
+  font-weight: 500;
+}
+
+.empty-state {
+  display: flex;
+  justify-content: center;
+  padding: 60rpx 0;
+}
+
+.empty-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.loading-state {
+  display: flex;
+  justify-content: center;
+  padding: 40rpx 0;
+}
+
+.loading-text {
+  font-size: 24rpx;
+  color: #999;
+}
+</style>

+ 14 - 0
cfc-frontend/utils/api.js

@@ -1729,4 +1729,18 @@ export const supplierProducts = (data) => {
 // ========== 商品规格 ==========
 export const productSpecGroups = (productId) => {
   return request('/api/product/spec/groups', 'POST', { productId })
+}
+
+// ========== 推荐系统 - 维度商品 ==========
+export const getDimensionProducts = (data) => {
+  return request('/api/recommend/dimension-products', 'POST', data)
+}
+
+// ========== 推荐系统 - 复购提醒 ==========
+export const getRepurchaseReminders = (params) => {
+  return request('/api/recommend/repurchase-reminders', 'GET', params)
+}
+
+export const clickRepurchaseReminder = (id) => {
+  return request('/api/recommend/repurchase-reminder/' + id + '/click', 'POST', {})
 }