Răsfoiți Sursa

feat(order): T01 数据层底座——product_reviews 独立评价表(迁移475)+product_orders 收货超时列(迁移476)+实体/Mapper/配置

P0-A 普通商品评价域(主理人 Q1 拍板=独立表,与活动评价 review_orders 完全隔离)
P0-B 待收货超时兜底数据源(shipped_at/receipt_remind_at 两列)
product_reviews.uk_order_id 一单一评幂等;mvn clean compile 通过
Agnes Agent 1 zi în urmă
părinte
comite
24fb6e22d6

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

@@ -3542,6 +3542,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移474: membership_levels 加会员成本价列 cost_price(F-3a 送会员商品P点口径:
         // 配置了「送会员」规则的商品订单,P 点 = 实付 − 商品成本 − 赠送等级会员成本价)
         addMembershipCostPriceColumn();
+
+        // 迁移475: 创建商品评价表 product_reviews(P0-A 普通商品评价域,独立于活动评价 review_orders)
+        migrateProductReviewsTable();
+
+        // 迁移476: product_orders 补收货超时兜底列(P0-B 待收货超时自动完成/第9天收货提醒)
+        migrateProductOrderShippedColumns();
     }
 
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
@@ -15930,4 +15936,55 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移474: membership_levels.cost_price 失败(忽略): {}", e.getMessage());
         }
     }
+
+    /**
+     * 迁移475: 创建商品评价表 product_reviews(P0-A 普通商品评价域,独立于活动评价 review_orders)。
+     *
+     * <p>一单一评({@code uk_order_id} 唯一键);商品详情页按 {@code product_id} 聚合列表;
+     * 评价人列表索引 {@code reviewer_id}。与活动评价 {@code review_orders} 通过 {@code order_id}
+     * 完全隔离,两域互不干扰(Q1 口径:新建商品评价域,不复用活动评价表)。</p>
+     */
+    private void migrateProductReviewsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_reviews ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', "
+                    + "order_id BIGINT NOT NULL COMMENT '关联 product_orders.id', "
+                    + "product_id BIGINT NOT NULL COMMENT '关联 products.id(冗余,便于商品详情页列表)', "
+                    + "reviewer_id BIGINT NOT NULL COMMENT '评价人ID(user.id)', "
+                    + "rating TINYINT NOT NULL COMMENT '综合星级 1-5', "
+                    + "review_text VARCHAR(500) NOT NULL DEFAULT '' COMMENT '评价文字 ≤500字', "
+                    + "images TEXT COMMENT '晒图URL数组 JSON(≤3张),如 [\"/upload/a.jpg\"]', "
+                    + "reply_text VARCHAR(500) DEFAULT NULL COMMENT '商家回复 ≤500字', "
+                    + "replied_at DATETIME DEFAULT NULL COMMENT '商家回复时间', "
+                    + "reply_by BIGINT DEFAULT NULL COMMENT '回复人ID(商家 user.id)', "
+                    + "status TINYINT NOT NULL DEFAULT 1 COMMENT '1=展示 0=隐藏(P2治理预留)', "
+                    + "review_at DATETIME NOT NULL COMMENT '评价时间', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY uk_order_id (order_id), "
+                    + "KEY idx_product_id (product_id), "
+                    + "KEY idx_reviewer_id (reviewer_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品订单评价表(独立于活动评价 review_orders)'");
+            log.info("迁移475: product_reviews 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移475: product_reviews 表创建失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移476: product_orders 补收货超时兜底列(P0-B)。
+     *
+     * <p>{@code shipped_at}=发货时间(ship() 落库写,作为超时自动完成/收货提醒的基准时间);
+     * {@code receipt_remind_at}=收货提醒推送时间(第9天推送成功后写,幂等防重复推送)。
+     * 使用 {@code ensureColumn} 幂等:列已存在时自动跳过。</p>
+     */
+    private void migrateProductOrderShippedColumns() {
+        try {
+            ensureColumn("product_orders", "shipped_at", "DATETIME NULL COMMENT '发货时间'");
+            ensureColumn("product_orders", "receipt_remind_at", "DATETIME NULL COMMENT '收货提醒推送时间'");
+            log.info("迁移476: product_orders.shipped_at / receipt_remind_at 列已就绪");
+        } catch (Exception e) {
+            log.warn("迁移476: product_orders 收货超时兜底列失败(忽略): {}", e.getMessage());
+        }
+    }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -81,6 +81,8 @@ public class ProductOrder implements Serializable {
     private String fulfillStatus;             // 履约状态: pending/confirmed_by_vendor/received_by_user
     private Date fulfillConfirmedAt;          // 供应商确认时间
     private Date receivedAt;                   // 用户确认收货时间
+    private Date shippedAt;                    // 发货时间(超时自动完成/收货提醒的基准时间,P0-B)
+    private Date receiptRemindAt;              // 收货提醒推送时间(第9天推送成功后写,幂等防重复,P0-B)
 
     private Date createdAt;
     private Date updatedAt;
@@ -124,6 +126,8 @@ public class ProductOrder implements Serializable {
     public void setFulfillStatus(String fulfillStatus) { this.fulfillStatus = fulfillStatus; }
     public void setFulfillConfirmedAt(Date fulfillConfirmedAt) { this.fulfillConfirmedAt = fulfillConfirmedAt; }
     public void setReceivedAt(Date receivedAt) { this.receivedAt = receivedAt; }
+    public void setShippedAt(Date shippedAt) { this.shippedAt = shippedAt; }
+    public void setReceiptRemindAt(Date receiptRemindAt) { this.receiptRemindAt = receiptRemindAt; }
     public Date getPaidAt() { return paidAt; }
     public Date getCreatedAt() { return createdAt; }
 }

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductReview.java

@@ -0,0 +1,31 @@
+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_reviews")
+public class ProductReview implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long orderId;        // 关联 product_orders.id
+    private Long productId;      // 关联 products.id(冗余,便于商品详情页列表)
+    private Long reviewerId;     // 评价人ID(user.id)
+    private Integer rating;      // 综合星级 1-5
+    private String reviewText;   // 评价文字 ≤500字
+    private String images;       // 晒图URL数组 JSON(≤3张)
+    private String replyText;    // 商家回复 ≤500字
+    private Date repliedAt;      // 商家回复时间
+    private Long replyBy;        // 回复人ID(商家 user.id)
+    private Integer status;      // 1=展示 0=隐藏(P2治理预留)
+    private Date reviewAt;       // 评价时间
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductReviewMapper.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductReview;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface ProductReviewMapper extends BaseMapper<ProductReview> {
+
+    /**
+     * 按订单ID查询评价(一单一评,幂等保护用)。
+     */
+    ProductReview findByOrderId(@Param("orderId") Long orderId);
+
+    /**
+     * 商品评价列表(仅展示态,按评价时间倒序分页)。
+     */
+    List<ProductReview> listByProduct(@Param("productId") Long productId,
+                                      @Param("offset") int offset,
+                                      @Param("size") int size);
+
+    /**
+     * 商品评价统计:total + avgRating。
+     */
+    Map<String, Object> statsByProduct(@Param("productId") Long productId);
+
+    /**
+     * 待评价订单ID列表:completed 且在 90 天窗口内、买家本人、普通商品、尚未评价。
+     */
+    List<Long> findReviewableOrderIds(@Param("buyerId") Long buyerId,
+                                      @Param("since") Date since);
+}

+ 4 - 0
cfc-backend/src/main/resources/application.yml

@@ -204,6 +204,10 @@ tencent:
 shop:
   order:
     payment-timeout-minutes: 30  # 订单超时时间(分钟)
+    receipt-timeout-days: 10      # 发货后未确认收货超时自动完成天数(P0-B 待收货兜底)
+    receipt-remind-days: 9        # 发货后第N天推送收货提醒订阅消息(P0-B 联动)
+    receipt-auto-complete-enabled: true   # 自动完成总开关(灰度/回滚用,false 时仅提醒不自动完成)
+    receipt-remind-template-id: ''        # 微信订阅消息模板ID(空=跳过推送,fail-open,运营配置)
 
 virtual-pay:
   payment-timeout-minutes: 30  # 虚拟支付订单超时时间(分钟)

+ 43 - 0
cfc-backend/src/main/resources/mapper/ProductReviewMapper.xml

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.etotem.cfc.mapper.ProductReviewMapper">
+
+    <select id="findByOrderId" resultType="com.etotem.cfc.entity.ProductReview">
+        SELECT *
+        FROM product_reviews
+        WHERE order_id = #{orderId}
+        LIMIT 1
+    </select>
+
+    <select id="listByProduct" resultType="com.etotem.cfc.entity.ProductReview">
+        SELECT *
+        FROM product_reviews
+        WHERE product_id = #{productId}
+          AND status = 1
+        ORDER BY review_at DESC, id DESC
+        LIMIT #{size} OFFSET #{offset}
+    </select>
+
+    <select id="statsByProduct" resultType="java.util.Map">
+        SELECT
+            COUNT(*) AS total,
+            AVG(rating) AS avgRating
+        FROM product_reviews
+        WHERE product_id = #{productId}
+          AND status = 1
+    </select>
+
+    <select id="findReviewableOrderIds" resultType="java.lang.Long">
+        SELECT o.id
+        FROM product_orders o
+        WHERE o.buyer_id = #{buyerId}
+          AND o.status = 'completed'
+          AND (o.product_type IS NULL OR o.product_type &lt;&gt; 'activity')
+          AND o.confirm_time &gt;= #{since}
+          AND NOT EXISTS (
+              SELECT 1 FROM product_reviews r WHERE r.order_id = o.id
+          )
+        ORDER BY o.confirm_time DESC
+    </select>
+
+</mapper>

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

@@ -1358,6 +1358,8 @@ audit_reason TEXT COMMENT '审核拒绝原因',
     refund_time DATETIME DEFAULT NULL COMMENT '退款时间',
     refund_before_status VARCHAR(32) DEFAULT NULL COMMENT '退款前订单状态(售后取消回滚用)',
     refund_before_refund_status TINYINT(1) DEFAULT 0 COMMENT '退款前退款状态(售后取消回滚用)',
+    shipped_at DATETIME DEFAULT NULL COMMENT '发货时间',
+    receipt_remind_at DATETIME DEFAULT NULL COMMENT '收货提醒推送时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     special_deal_id BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)',
@@ -1368,6 +1370,26 @@ audit_reason TEXT COMMENT '审核拒绝原因',
     INDEX idx_supply_system_id (supply_system_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品订单表';
 
+CREATE TABLE IF NOT EXISTS product_reviews (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    order_id BIGINT NOT NULL COMMENT '关联 product_orders.id',
+    product_id BIGINT NOT NULL COMMENT '关联 products.id(冗余,便于商品详情页列表)',
+    reviewer_id BIGINT NOT NULL COMMENT '评价人ID(user.id)',
+    rating TINYINT NOT NULL COMMENT '综合星级 1-5',
+    review_text VARCHAR(500) NOT NULL DEFAULT '' COMMENT '评价文字 ≤500字',
+    images TEXT COMMENT '晒图URL数组 JSON(≤3张),如 ["/upload/a.jpg"]',
+    reply_text VARCHAR(500) DEFAULT NULL COMMENT '商家回复 ≤500字',
+    replied_at DATETIME DEFAULT NULL COMMENT '商家回复时间',
+    reply_by BIGINT DEFAULT NULL COMMENT '回复人ID(商家 user.id)',
+    status TINYINT NOT NULL DEFAULT 1 COMMENT '1=展示 0=隐藏(P2治理预留)',
+    review_at DATETIME NOT NULL COMMENT '评价时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_order_id (order_id),
+    KEY idx_product_id (product_id),
+    KEY idx_reviewer_id (reviewer_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品订单评价表(独立于活动评价 review_orders)';
+
 -- 限时特价活动表
 CREATE TABLE IF NOT EXISTS special_price_deals (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',