Просмотр исходного кода

feat: 商品配送与履约扩展(自取/线上服务)

E2E Test Bot 3 месяцев назад
Родитель
Сommit
c7334c60f1
26 измененных файлов с 1599 добавлено и 2 удалено
  1. 117 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
  3. 72 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductDeliveryConfigController.java
  4. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java
  5. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  6. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  7. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java
  8. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipBenefits.java
  9. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOnlineSlot.java
  10. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  11. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPickupLocation.java
  12. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPickupTimeSlot.java
  13. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductServicePerson.java
  14. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductServicePersonSlot.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MembershipBenefitsMapper.java
  16. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOnlineSlotMapper.java
  17. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPickupLocationMapper.java
  18. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPickupTimeSlotMapper.java
  19. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductServicePersonMapper.java
  20. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductServicePersonSlotMapper.java
  21. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  22. 266 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductDeliveryConfigService.java
  23. 247 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  24. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  25. 132 1
      cfc-backend/src/main/resources/schema.sql
  26. 453 0
      docs/superpowers/plans/2026-08-05-product-delivery-fulfillment.md

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

@@ -7762,6 +7762,123 @@ private void runMigration100() {
 		}
 
 		// 迁移172: products 添加 min_stock_alert 列(库存预警阈值,配合库存管理模块)
+		// 迁移173: 商品配送与履约扩展(自取/线上服务),需求: 配送方式1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人
+		// 173a: product_orders 新增履约字段
+		ensureColumn("product_orders", "pickup_location_id", "BIGINT COMMENT '自取地点ID'");
+		ensureColumn("product_orders", "pickup_location_snapshot", "TEXT COMMENT '自取地点快照(JSON)'");
+		ensureColumn("product_orders", "time_slot_id", "BIGINT COMMENT '所选时间段ID'");
+		ensureColumn("product_orders", "time_slot_snapshot", "TEXT COMMENT '时间段快照(JSON)'");
+		ensureColumn("product_orders", "service_person_id", "BIGINT COMMENT '所选服务者ID(deliveryMethod=4)'");
+		ensureColumn("product_orders", "service_person_snapshot", "TEXT COMMENT '服务者快照(JSON)'");
+		ensureColumn("product_orders", "fulfill_status", "VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态: pending/confirmed_by_vendor/received_by_user/completed'");
+		ensureColumn("product_orders", "fulfill_confirmed_at", "DATETIME COMMENT '供应商确认时间'");
+		ensureColumn("product_orders", "received_at", "DATETIME COMMENT '用户确认收货时间'");
+		// 173b: 自取地点表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_locations (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
+				"product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
+				"name VARCHAR(100) NOT NULL COMMENT '地点名称', " +
+				"address VARCHAR(500) NOT NULL COMMENT '详细地址', " +
+				"longitude DECIMAL(10,7) COMMENT '经度', " +
+				"latitude DECIMAL(10,7) COMMENT '纬度', " +
+				"contact_name VARCHAR(50) COMMENT '联系人', " +
+				"contact_phone VARCHAR(20) COMMENT '联系电话', " +
+				"sort_order INT DEFAULT 0 COMMENT '排序', " +
+				"enabled TINYINT DEFAULT 1 COMMENT '启用: 0/1', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_product_id (product_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品自取地点'");
+			log.info("已创建product_pickup_locations表");
+		} catch (Exception ex) {
+			log.warn("创建product_pickup_locations表失败: {}", ex.getMessage());
+		}
+		// 173c: 自取时间段模板表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_pickup_time_slots (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
+				"location_id BIGINT NOT NULL COMMENT '所属地点ID', " +
+				"start_time TIME NOT NULL COMMENT '开始时间 HH:MM', " +
+				"end_time TIME NOT NULL COMMENT '结束时间 HH:MM', " +
+				"weekday TINYINT COMMENT '周几(1-7), null=按具体日期', " +
+				"specific_date DATE COMMENT '具体日期, null=按周循环', " +
+				"max_capacity INT DEFAULT 1 COMMENT '该时段最大接待人数', " +
+				"booked_count INT DEFAULT 0 COMMENT '已预订数', " +
+				"enabled TINYINT DEFAULT 1, " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_location_id (location_id), " +
+				"INDEX idx_specific_date (specific_date)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自取时间段模板'");
+			log.info("已创建product_pickup_time_slots表");
+		} catch (Exception ex) {
+			log.warn("创建product_pickup_time_slots表失败: {}", ex.getMessage());
+		}
+		// 173d: 线上服务者表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_persons (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
+				"product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
+				"user_id BIGINT NOT NULL COMMENT '服务者用户ID(关联users表)', " +
+				"name VARCHAR(50) NOT NULL COMMENT '展示名称', " +
+				"title VARCHAR(100) COMMENT '头衔(如高级营养师)', " +
+				"intro TEXT COMMENT '个人介绍', " +
+				"avatar VARCHAR(500) COMMENT '头像URL', " +
+				"price INT NOT NULL COMMENT '该人专属价格(分)', " +
+				"original_price INT COMMENT '原价(分)', " +
+				"sort_order INT DEFAULT 0, " +
+				"enabled TINYINT DEFAULT 1, " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_product_id (product_id), " +
+				"INDEX idx_user_id (user_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品线上服务者'");
+			log.info("已创建product_service_persons表");
+		} catch (Exception ex) {
+			log.warn("创建product_service_persons表失败: {}", ex.getMessage());
+		}
+		// 173e: 服务者可预约时间段表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_service_person_slots (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
+				"person_id BIGINT NOT NULL COMMENT '所属服务者ID', " +
+				"start_time DATETIME NOT NULL COMMENT '开始时间', " +
+				"end_time DATETIME NOT NULL COMMENT '结束时间', " +
+				"max_capacity INT DEFAULT 1 COMMENT '容量', " +
+				"booked_count INT DEFAULT 0 COMMENT '已预订数', " +
+				"enabled TINYINT DEFAULT 1, " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_person_id (person_id), " +
+				"INDEX idx_start_time (start_time)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务者可预约时间段'");
+			log.info("已创建product_service_person_slots表");
+		} catch (Exception ex) {
+			log.warn("创建product_service_person_slots表失败: {}", ex.getMessage());
+		}
+		// 173f: 单人线上服务时间段表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_online_slots (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', " +
+				"product_id BIGINT NOT NULL COMMENT '所属商品ID', " +
+				"start_time DATETIME NOT NULL COMMENT '开始时间', " +
+				"end_time DATETIME NOT NULL COMMENT '结束时间', " +
+				"max_capacity INT DEFAULT 1 COMMENT '容量', " +
+				"booked_count INT DEFAULT 0 COMMENT '已预订数', " +
+				"enabled TINYINT DEFAULT 1, " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_product_id (product_id), " +
+				"INDEX idx_start_time (start_time)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单人线上服务时间段'");
+			log.info("已创建product_online_slots表");
+		} catch (Exception ex) {
+			log.warn("创建product_online_slots表失败: {}", ex.getMessage());
+		}
+	}
+}
+
 		ensureColumn("products", "min_stock_alert", "INT DEFAULT 0 COMMENT '库存预警阈值(低于此值预警)'");
 
 		// 迁移173: 创建库存管理4张表(inventory_transactions/counting_records/inbound_orders/inbound_items)

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.*;
 import com.etotem.cfc.entity.MemberUpgradeRecord;
+import com.etotem.cfc.entity.MembershipBenefits;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
@@ -398,4 +399,21 @@ public class MembershipController {
         return Result.success(result);
     }
 
+    /**
+     * 获取会员权益列表(按等级)
+     */
+    @Operation(summary = "获取会员权益列表")
+    @PostMapping("/benefits")
+    public Result<List<MembershipBenefits>> getBenefits(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        String levelCode = (String) params.get("levelCode");
+        if (levelCode == null || levelCode.isEmpty()) {
+            // 如果没有传等级,尝试从用户信息中获取当前等级
+            levelCode = "FREE";
+        }
+        List<MembershipBenefits> benefits = membershipService.getBenefitsByLevel(levelCode);
+        return Result.success(benefits);
+    }
+
 }

+ 72 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductDeliveryConfigController.java

@@ -0,0 +1,72 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ProductDeliveryConfigService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 商品配送与履约配置
+ * 统一管理自取地点/时间段、线上服务者/时间段、单人线上时间段的CRUD
+ */
+@RestController
+@RequestMapping("/api/product/delivery-config")
+public class ProductDeliveryConfigController {
+
+    @Resource
+    private ProductDeliveryConfigService deliveryConfigService;
+
+    // ========== 自取地点 ==========
+
+    @PostMapping("/pickup-location/list")
+    public Result<List<Map<String, Object>>> listPickupLocations(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        return Result.success(deliveryConfigService.listPickupLocationsWithSlots(productId));
+    }
+
+    @PostMapping("/pickup-location/save")
+    public Result<String> savePickupLocations(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        List<Map<String, Object>> locations = (List<Map<String, Object>>) params.get("locations");
+        deliveryConfigService.savePickupLocations(productId, locations);
+        return Result.success("ok");
+    }
+
+    // ========== 线上服务者 ==========
+
+    @PostMapping("/service-person/list")
+    public Result<List<Map<String, Object>>> listServicePersons(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        return Result.success(deliveryConfigService.listServicePersonsWithSlots(productId));
+    }
+
+    @PostMapping("/service-person/save")
+    public Result<String> saveServicePersons(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        List<Map<String, Object>> persons = (List<Map<String, Object>>) params.get("persons");
+        deliveryConfigService.saveServicePersons(productId, persons);
+        return Result.success("ok");
+    }
+
+    // ========== 单人线上时间段 ==========
+
+    @PostMapping("/online-slot/list")
+    public Result<List<?>> listOnlineSlots(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        return Result.success(deliveryConfigService.listOnlineSlots(productId));
+    }
+
+    @PostMapping("/online-slot/save")
+    public Result<String> saveOnlineSlots(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        List<Map<String, Object>> slots = (List<Map<String, Object>>) params.get("slots");
+        deliveryConfigService.saveOnlineSlots(productId, slots);
+        return Result.success("ok");
+    }
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java

@@ -93,6 +93,13 @@ private QrCodeService qrCodeService;
         return orderService.confirmReceive(orderNo, userId);
     }
 
+    @PostMapping("/vendor-confirm")
+    public Result<String> vendorConfirm(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("userId") Long userId) {
+        String orderNo = (String) params.get("orderNo");
+        return orderService.vendorConfirm(orderNo, userId);
+    }
+
     @PostMapping("/ship")
     public Result<String> ship(@RequestBody Map<String, Object> params) {
         String orderNo = (String) params.get("orderNo");

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -20,6 +20,11 @@ public class CreateProductOrderDTO {
     private Long consigneeId;         // 收货人ID
     private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
 
+    // 配送与履约扩展
+    private Long pickupLocationId;      // 自取地点ID
+    private Long timeSlotId;             // 时间段ID(自取/线上)
+    private Long servicePersonId;       // 服务者ID(线上-可选人)
+
     // Explicit getters needed due to Lombok issues
     public List<OrderItemVO> getItems() { return items; }
     public Long getProductId() { return productId; }

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

@@ -7,6 +7,7 @@ import lombok.Data;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 @Data
 public class ProductDTO {
@@ -47,6 +48,9 @@ public class ProductDTO {
     private String giftLevelCode;
     private String giftLevelName;
 
+    // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
+    private Map<String, Object> deliveryConfig;
+
     private Date createdAt;
     private Date updatedAt;
 

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

@@ -46,6 +46,16 @@ public class ProductOrderDTO {
     private Date paidAt;
     private Date createdAt;
     private Date updatedAt;
+    // 配送与履约扩展
+    private Long pickupLocationId;
+    private String pickupLocationSnapshot;
+    private Long timeSlotId;
+    private String timeSlotSnapshot;
+    private Long servicePersonId;
+    private String servicePersonSnapshot;
+    private String fulfillStatus;
+    private Date fulfillConfirmedAt;
+    private Date receivedAt;
 
     public static ProductOrderDTO from(ProductOrder o) {
         if (o == null) return null;
@@ -88,6 +98,15 @@ public class ProductOrderDTO {
         d.paidAt = o.getPaidAt();
         d.createdAt = o.getCreatedAt();
         d.updatedAt = o.getUpdatedAt();
+        d.pickupLocationId = o.getPickupLocationId();
+        d.pickupLocationSnapshot = o.getPickupLocationSnapshot();
+        d.timeSlotId = o.getTimeSlotId();
+        d.timeSlotSnapshot = o.getTimeSlotSnapshot();
+        d.servicePersonId = o.getServicePersonId();
+        d.servicePersonSnapshot = o.getServicePersonSnapshot();
+        d.fulfillStatus = o.getFulfillStatus();
+        d.fulfillConfirmedAt = o.getFulfillConfirmedAt();
+        d.receivedAt = o.getReceivedAt();
         return d;
     }
 }

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipBenefits.java

@@ -0,0 +1,35 @@
+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("membership_benefits")
+public class MembershipBenefits implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String levelCode;
+
+    private String benefitCode;
+
+    private String benefitName;
+
+    private String benefitDesc;
+
+    private String icon;
+
+    private String bgColor;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOnlineSlot.java

@@ -0,0 +1,25 @@
+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_online_slots")
+public class ProductOnlineSlot implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long productId;
+    private Date startTime;
+    private Date endTime;
+    private Integer maxCapacity;
+    private Integer bookedCount;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

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

@@ -65,6 +65,17 @@ public class ProductOrder implements Serializable {
     private String auditReason;
     private Integer shippingCost;
 
+    // 配送与履约扩展
+    private Long pickupLocationId;           // 自取地点ID
+    private String pickupLocationSnapshot;   // 自取地点快照(JSON)
+    private Long timeSlotId;                  // 所选时间段ID
+    private String timeSlotSnapshot;          // 时间段快照(JSON)
+    private Long servicePersonId;             // 所选服务者ID
+    private String servicePersonSnapshot;      // 服务者快照(JSON)
+    private String fulfillStatus;             // 履约状态: pending/confirmed_by_vendor/received_by_user
+    private Date fulfillConfirmedAt;          // 供应商确认时间
+    private Date receivedAt;                   // 用户确认收货时间
+
     private Date createdAt;
     private Date updatedAt;
 
@@ -94,4 +105,13 @@ public class ProductOrder implements Serializable {
     public void setAuditorId(Long auditorId) { this.auditorId = auditorId; }
     public void setAuditedAt(Date auditedAt) { this.auditedAt = auditedAt; }
     public void setAuditReason(String auditReason) { this.auditReason = auditReason; }
+    public void setPickupLocationId(Long pickupLocationId) { this.pickupLocationId = pickupLocationId; }
+    public void setPickupLocationSnapshot(String pickupLocationSnapshot) { this.pickupLocationSnapshot = pickupLocationSnapshot; }
+    public void setTimeSlotId(Long timeSlotId) { this.timeSlotId = timeSlotId; }
+    public void setTimeSlotSnapshot(String timeSlotSnapshot) { this.timeSlotSnapshot = timeSlotSnapshot; }
+    public void setServicePersonId(Long servicePersonId) { this.servicePersonId = servicePersonId; }
+    public void setServicePersonSnapshot(String servicePersonSnapshot) { this.servicePersonSnapshot = servicePersonSnapshot; }
+    public void setFulfillStatus(String fulfillStatus) { this.fulfillStatus = fulfillStatus; }
+    public void setFulfillConfirmedAt(Date fulfillConfirmedAt) { this.fulfillConfirmedAt = fulfillConfirmedAt; }
+    public void setReceivedAt(Date receivedAt) { this.receivedAt = receivedAt; }
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPickupLocation.java

@@ -0,0 +1,29 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("product_pickup_locations")
+public class ProductPickupLocation implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long productId;
+    private String name;
+    private String address;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+    private String contactName;
+    private String contactPhone;
+    private Integer sortOrder;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPickupTimeSlot.java

@@ -0,0 +1,28 @@
+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.sql.Time;
+import java.util.Date;
+
+@Data
+@TableName("product_pickup_time_slots")
+public class ProductPickupTimeSlot implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long locationId;
+    private Time startTime;
+    private Time endTime;
+    private Integer weekday;
+    private Date specificDate;
+    private Integer maxCapacity;
+    private Integer bookedCount;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductServicePerson.java

@@ -0,0 +1,29 @@
+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_service_persons")
+public class ProductServicePerson implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long productId;
+    private Long userId;
+    private String name;
+    private String title;
+    private String intro;
+    private String avatar;
+    private Integer price;
+    private Integer originalPrice;
+    private Integer sortOrder;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductServicePersonSlot.java

@@ -0,0 +1,25 @@
+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_service_person_slots")
+public class ProductServicePersonSlot implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long personId;
+    private Date startTime;
+    private Date endTime;
+    private Integer maxCapacity;
+    private Integer bookedCount;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

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

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

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOnlineSlotMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductOnlineSlot;
+
+public interface ProductOnlineSlotMapper extends BaseMapper<ProductOnlineSlot> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPickupLocationMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductPickupLocation;
+
+public interface ProductPickupLocationMapper extends BaseMapper<ProductPickupLocation> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPickupTimeSlotMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductPickupTimeSlot;
+
+public interface ProductPickupTimeSlotMapper extends BaseMapper<ProductPickupTimeSlot> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductServicePersonMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductServicePerson;
+
+public interface ProductServicePersonMapper extends BaseMapper<ProductServicePerson> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductServicePersonSlotMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductServicePersonSlot;
+
+public interface ProductServicePersonSlotMapper extends BaseMapper<ProductServicePersonSlot> {
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -46,6 +46,9 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
+    @Resource
+    private MembershipBenefitsMapper benefitsMapper;
+
     @Resource
     private CouponService couponService;
 
@@ -1056,4 +1059,16 @@ public class MembershipService implements MembershipServiceInterface {
 
         return dto;
     }
+
+    /**
+     * 获取指定等级的权益列表
+     */
+    public List<MembershipBenefits> getBenefitsByLevel(String levelCode) {
+        List<MembershipBenefits> benefits = benefitsMapper.selectList(
+                new LambdaQueryWrapper<MembershipBenefits>()
+                        .eq(MembershipBenefits::getLevelCode, levelCode)
+                        .orderByAsc(MembershipBenefits::getSortOrder)
+        );
+        return benefits;
+    }
 }

+ 266 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductDeliveryConfigService.java

@@ -0,0 +1,266 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 商品配送与履约配置服务
+ * 统一管理自取地点/时间段、线上服务者/时间段、单人线上时间段的CRUD和商品详情组装
+ */
+@Service
+public class ProductDeliveryConfigService {
+
+    @Resource private ProductPickupLocationMapper pickupLocationMapper;
+    @Resource private ProductPickupTimeSlotMapper pickupTimeSlotMapper;
+    @Resource private ProductServicePersonMapper servicePersonMapper;
+    @Resource private ProductServicePersonSlotMapper servicePersonSlotMapper;
+    @Resource private ProductOnlineSlotMapper onlineSlotMapper;
+
+    // ========== 商品详情组装 ==========
+
+    /**
+     * 根据配送方式组装商品详情中的配送相关信息
+     */
+    public void enrichProductDetail(Long productId, Integer deliveryMethod, Map<String, Object> detailMap) {
+        if (deliveryMethod == null) return;
+        switch (deliveryMethod) {
+            case 2: case 3:
+                detailMap.put("pickupLocations", listPickupLocationsWithSlots(productId));
+                break;
+            case 4:
+                detailMap.put("servicePersons", listServicePersonsWithSlots(productId));
+                break;
+            case 5:
+                detailMap.put("onlineSlots", listOnlineSlots(productId));
+                break;
+            default:
+                break;
+        }
+    }
+
+    // ========== 自取地点 ==========
+
+    public List<ProductPickupLocation> listPickupLocations(Long productId) {
+        return pickupLocationMapper.selectList(
+                new LambdaQueryWrapper<ProductPickupLocation>()
+                        .eq(ProductPickupLocation::getProductId, productId)
+                        .eq(ProductPickupLocation::getEnabled, 1)
+                        .orderByAsc(ProductPickupLocation::getSortOrder));
+    }
+
+    public List<Map<String, Object>> listPickupLocationsWithSlots(Long productId) {
+        List<ProductPickupLocation> locations = listPickupLocations(productId);
+        if (locations.isEmpty()) return Collections.emptyList();
+        List<Long> locationIds = locations.stream().map(ProductPickupLocation::getId).collect(Collectors.toList());
+        List<ProductPickupTimeSlot> allSlots = pickupTimeSlotMapper.selectList(
+                new LambdaQueryWrapper<ProductPickupTimeSlot>()
+                        .in(ProductPickupTimeSlot::getLocationId, locationIds)
+                        .eq(ProductPickupTimeSlot::getEnabled, 1));
+        Map<Long, List<ProductPickupTimeSlot>> slotMap = allSlots.stream()
+                .collect(Collectors.groupingBy(ProductPickupTimeSlot::getLocationId));
+        return locations.stream().map(loc -> {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", loc.getId());
+            m.put("name", loc.getName());
+            m.put("address", loc.getAddress());
+            m.put("longitude", loc.getLongitude());
+            m.put("latitude", loc.getLatitude());
+            m.put("contactName", loc.getContactName());
+            m.put("contactPhone", loc.getContactPhone());
+            m.put("timeSlots", slotMap.getOrDefault(loc.getId(), Collections.emptyList()));
+            return m;
+        }).collect(Collectors.toList());
+    }
+
+    @Transactional
+    public void savePickupLocations(Long productId, List<Map<String, Object>> locations) {
+        // 先清除旧的
+        deletePickupLocationsByProduct(productId);
+        if (locations == null) return;
+        int sortOrder = 0;
+        for (Map<String, Object> locData : locations) {
+            ProductPickupLocation loc = new ProductPickupLocation();
+            loc.setProductId(productId);
+            loc.setName((String) locData.get("name"));
+            loc.setAddress((String) locData.get("address"));
+            loc.setContactName((String) locData.get("contactName"));
+            loc.setContactPhone((String) locData.get("contactPhone"));
+            loc.setSortOrder(sortOrder++);
+            loc.setEnabled(1);
+            pickupLocationMapper.insert(loc);
+            // 时间段
+            List<Map<String, Object>> slots = (List<Map<String, Object>>) locData.get("timeSlots");
+            if (slots != null) {
+                for (Map<String, Object> slotData : slots) {
+                    ProductPickupTimeSlot slot = new ProductPickupTimeSlot();
+                    slot.setLocationId(loc.getId());
+                    String startTime = (String) slotData.get("startTime");
+                    String endTime = (String) slotData.get("endTime");
+                    if (startTime != null) slot.setStartTime(java.sql.Time.valueOf(startTime));
+                    if (endTime != null) slot.setEndTime(java.sql.Time.valueOf(endTime));
+                    Object weekday = slotData.get("weekday");
+                    if (weekday != null) slot.setWeekday(Integer.valueOf(weekday.toString()));
+                    Object specificDate = slotData.get("specificDate");
+                    if (specificDate != null) slot.setSpecificDate(java.sql.Date.valueOf(specificDate.toString()));
+                    Object maxCap = slotData.get("maxCapacity");
+                    slot.setMaxCapacity(maxCap != null ? Integer.valueOf(maxCap.toString()) : 1);
+                    slot.setBookedCount(0);
+                    slot.setEnabled(1);
+                    pickupTimeSlotMapper.insert(slot);
+                }
+            }
+        }
+    }
+
+    @Transactional
+    public void deletePickupLocationsByProduct(Long productId) {
+        List<ProductPickupLocation> existing = pickupLocationMapper.selectList(
+                new LambdaQueryWrapper<ProductPickupLocation>().eq(ProductPickupLocation::getProductId, productId));
+        for (ProductPickupLocation loc : existing) {
+            pickupTimeSlotMapper.delete(new LambdaQueryWrapper<ProductPickupTimeSlot>()
+                    .eq(ProductPickupTimeSlot::getLocationId, loc.getId()));
+            pickupLocationMapper.deleteById(loc.getId());
+        }
+    }
+
+    // ========== 线上服务者 ==========
+
+    public List<ProductServicePerson> listServicePersons(Long productId) {
+        return servicePersonMapper.selectList(
+                new LambdaQueryWrapper<ProductServicePerson>()
+                        .eq(ProductServicePerson::getProductId, productId)
+                        .eq(ProductServicePerson::getEnabled, 1)
+                        .orderByAsc(ProductServicePerson::getSortOrder));
+    }
+
+    public List<Map<String, Object>> listServicePersonsWithSlots(Long productId) {
+        List<ProductServicePerson> persons = listServicePersons(productId);
+        if (persons.isEmpty()) return Collections.emptyList();
+        List<Long> personIds = persons.stream().map(ProductServicePerson::getId).collect(Collectors.toList());
+        List<ProductServicePersonSlot> allSlots = servicePersonSlotMapper.selectList(
+                new LambdaQueryWrapper<ProductServicePersonSlot>()
+                        .in(ProductServicePersonSlot::getPersonId, personIds)
+                        .eq(ProductServicePersonSlot::getEnabled, 1)
+                        .ge(ProductServicePersonSlot::getStartTime, new Date()));
+        Map<Long, List<ProductServicePersonSlot>> slotMap = allSlots.stream()
+                .collect(Collectors.groupingBy(ProductServicePersonSlot::getPersonId));
+        return persons.stream().map(p -> {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", p.getId());
+            m.put("userId", p.getUserId());
+            m.put("name", p.getName());
+            m.put("title", p.getTitle());
+            m.put("intro", p.getIntro());
+            m.put("avatar", p.getAvatar());
+            m.put("price", p.getPrice());
+            m.put("originalPrice", p.getOriginalPrice());
+            m.put("slots", slotMap.getOrDefault(p.getId(), Collections.emptyList()));
+            return m;
+        }).collect(Collectors.toList());
+    }
+
+    @Transactional
+    public void saveServicePersons(Long productId, List<Map<String, Object>> persons) {
+        deleteServicePersonsByProduct(productId);
+        if (persons == null) return;
+        int sortOrder = 0;
+        for (Map<String, Object> personData : persons) {
+            ProductServicePerson person = new ProductServicePerson();
+            person.setProductId(productId);
+            Object userId = personData.get("userId");
+            if (userId != null) person.setUserId(Long.valueOf(userId.toString()));
+            person.setName((String) personData.get("name"));
+            person.setTitle((String) personData.get("title"));
+            person.setIntro((String) personData.get("intro"));
+            person.setAvatar((String) personData.get("avatar"));
+            Object price = personData.get("price");
+            if (price != null) person.setPrice(Integer.valueOf(price.toString()));
+            Object origPrice = personData.get("originalPrice");
+            if (origPrice != null) person.setOriginalPrice(Integer.valueOf(origPrice.toString()));
+            person.setSortOrder(sortOrder++);
+            person.setEnabled(1);
+            servicePersonMapper.insert(person);
+            List<Map<String, Object>> slots = (List<Map<String, Object>>) personData.get("slots");
+            if (slots != null) {
+                for (Map<String, Object> slotData : slots) {
+                    ProductServicePersonSlot slot = new ProductServicePersonSlot();
+                    slot.setPersonId(person.getId());
+                    slot.setStartTime(parseDate(slotData.get("startTime")));
+                    slot.setEndTime(parseDate(slotData.get("endTime")));
+                    Object maxCap = slotData.get("maxCapacity");
+                    slot.setMaxCapacity(maxCap != null ? Integer.valueOf(maxCap.toString()) : 1);
+                    slot.setBookedCount(0);
+                    slot.setEnabled(1);
+                    servicePersonSlotMapper.insert(slot);
+                }
+            }
+        }
+    }
+
+    @Transactional
+    public void deleteServicePersonsByProduct(Long productId) {
+        List<ProductServicePerson> existing = servicePersonMapper.selectList(
+                new LambdaQueryWrapper<ProductServicePerson>().eq(ProductServicePerson::getProductId, productId));
+        for (ProductServicePerson p : existing) {
+            servicePersonSlotMapper.delete(new LambdaQueryWrapper<ProductServicePersonSlot>()
+                    .eq(ProductServicePersonSlot::getPersonId, p.getId()));
+            servicePersonMapper.deleteById(p.getId());
+        }
+    }
+
+    // ========== 单人线上时间段 ==========
+
+    public List<ProductOnlineSlot> listOnlineSlots(Long productId) {
+        return onlineSlotMapper.selectList(
+                new LambdaQueryWrapper<ProductOnlineSlot>()
+                        .eq(ProductOnlineSlot::getProductId, productId)
+                        .eq(ProductOnlineSlot::getEnabled, 1)
+                        .ge(ProductOnlineSlot::getStartTime, new Date())
+                        .orderByAsc(ProductOnlineSlot::getStartTime));
+    }
+
+    @Transactional
+    public void saveOnlineSlots(Long productId, List<Map<String, Object>> slots) {
+        deleteOnlineSlotsByProduct(productId);
+        if (slots == null) return;
+        for (Map<String, Object> slotData : slots) {
+            ProductOnlineSlot slot = new ProductOnlineSlot();
+            slot.setProductId(productId);
+            slot.setStartTime(parseDate(slotData.get("startTime")));
+            slot.setEndTime(parseDate(slotData.get("endTime")));
+            Object maxCap = slotData.get("maxCapacity");
+            slot.setMaxCapacity(maxCap != null ? Integer.valueOf(maxCap.toString()) : 1);
+            slot.setBookedCount(0);
+            slot.setEnabled(1);
+            onlineSlotMapper.insert(slot);
+        }
+    }
+
+    @Transactional
+    public void deleteOnlineSlotsByProduct(Long productId) {
+        onlineSlotMapper.delete(new LambdaQueryWrapper<ProductOnlineSlot>()
+                .eq(ProductOnlineSlot::getProductId, productId));
+    }
+
+    // ========== 工具 ==========
+
+    private Date parseDate(Object val) {
+        if (val == null) return null;
+        String s = val.toString();
+        // 兼容 "2026-08-10 09:00:00" 或 ISO 格式
+        if (s.length() == 19 && s.charAt(10) == ' ') {
+            return java.sql.Timestamp.valueOf(s);
+        }
+        if (s.contains("T")) {
+            return java.sql.Timestamp.valueOf(s.replace("T", " "));
+        }
+        return java.sql.Timestamp.valueOf(s);
+    }
+}

+ 247 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -9,10 +9,16 @@ import com.etotem.cfc.dto.OrderItemVO;
 import com.etotem.cfc.dto.ProductOrderDTO;
 import com.etotem.cfc.entity.AfterSalesRequest;
 import com.etotem.cfc.entity.AssessmentProduct;
+import com.etotem.cfc.entity.Consignee;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
+import com.etotem.cfc.entity.ProductOnlineSlot;
 import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductPickupLocation;
+import com.etotem.cfc.entity.ProductPickupTimeSlot;
+import com.etotem.cfc.entity.ProductServicePerson;
+import com.etotem.cfc.entity.ProductServicePersonSlot;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.entity.SupplySystemMember;
@@ -111,6 +117,27 @@ public class ProductOrderService {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private ProductDeliveryConfigService productDeliveryConfigService;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductPickupLocationMapper productPickupLocationMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductPickupTimeSlotMapper productPickupTimeSlotMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductServicePersonMapper productServicePersonMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductServicePersonSlotMapper productServicePersonSlotMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductOnlineSlotMapper productOnlineSlotMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
             return Result.error("请先登录");
@@ -146,6 +173,9 @@ public class ProductOrderService {
             return Result.error("该商品最少需购买" + product.getMinQuantity() + "件");
         }
 
+        // ===== 配送方式与履约校验 =====
+        FulfillValidation fv = validateFulfill(product, dto, buyerId);
+        if (fv.error != null) return Result.error(fv.error);
         User buyer = userMapper.selectById(buyerId);
         ProductOrder order = new ProductOrder();
         order.setOrderNo(generateOrderNo());
@@ -173,7 +203,22 @@ public class ProductOrderService {
         if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
             order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
         }
-        order.setDeliveryMethod(dto.getDeliveryMethod() != null ? dto.getDeliveryMethod() : 1);
+        order.setDeliveryMethod(fv.deliveryMethod != null ? fv.deliveryMethod
+                : (dto.getDeliveryMethod() != null ? dto.getDeliveryMethod() : 1));
+        // 应用配送扩展校验结果
+        if (fv.unitPrice != null) {
+            order.setUnitPrice(fv.unitPrice);
+            order.setTotalAmount(fv.unitPrice * dto.getQuantity());
+            int ma = order.getTotalAmount() - order.getPointsCost();
+            order.setMoneyAmount(ma > 0 ? ma : 0);
+        }
+        if (fv.pickupLocationId != null) order.setPickupLocationId(fv.pickupLocationId);
+        if (fv.pickupLocationSnapshot != null) order.setPickupLocationSnapshot(fv.pickupLocationSnapshot);
+        if (fv.timeSlotId != null) order.setTimeSlotId(fv.timeSlotId);
+        if (fv.timeSlotSnapshot != null) order.setTimeSlotSnapshot(fv.timeSlotSnapshot);
+        if (fv.servicePersonId != null) order.setServicePersonId(fv.servicePersonId);
+        if (fv.servicePersonSnapshot != null) order.setServicePersonSnapshot(fv.servicePersonSnapshot);
+        order.setFulfillStatus("pending");
         // Generate pickup code for self-pickup orders
         if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
             order.setPickupCode(generatePickupCode());
@@ -185,6 +230,180 @@ public class ProductOrderService {
         return Result.success(ProductOrderDTO.from(order));
     }
 
+    /**
+     * 配送方式与履约校验(设计文档十一节)
+     * 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
+     */
+    private FulfillValidation validateFulfill(Product product, CreateProductOrderDTO dto, Long buyerId) {
+        FulfillValidation fv = new FulfillValidation();
+        Integer productMethod = product.getDeliveryMethod() != null ? product.getDeliveryMethod() : 1;
+        Integer chosen = dto.getDeliveryMethod();
+        int actualMethod;
+        if (productMethod == 3) {
+            // 两者皆可:必须由用户明确选择快递(1)或自提(2)
+            if (chosen == null || (chosen != 1 && chosen != 2)) {
+                fv.error = "请选择配送方式";
+                return fv;
+            }
+            actualMethod = chosen;
+        } else {
+            actualMethod = productMethod;
+        }
+        fv.deliveryMethod = actualMethod;
+
+        switch (actualMethod) {
+            case 1:
+                // 快递:校验收货地址存在且属于当前用户
+                if (dto.getConsigneeId() == null) {
+                    fv.error = "请选择收货地址";
+                    return fv;
+                }
+                Consignee consignee = consigneeMapper.selectById(dto.getConsigneeId());
+                if (consignee == null || !buyerId.equals(consignee.getUserId())) {
+                    fv.error = "收货地址不存在";
+                    return fv;
+                }
+                break;
+            case 2: {
+                // 自提:校验自取地点 + 时间段
+                if (dto.getPickupLocationId() == null) {
+                    fv.error = "请选择自取地点";
+                    return fv;
+                }
+                ProductPickupLocation loc = productPickupLocationMapper.selectById(dto.getPickupLocationId());
+                if (loc == null || !product.getId().equals(loc.getProductId())
+                        || loc.getEnabled() == null || loc.getEnabled() != 1) {
+                    fv.error = "自取地点不存在或已停用";
+                    return fv;
+                }
+                Map<String, Object> locSnap = new HashMap<>();
+                locSnap.put("name", loc.getName());
+                locSnap.put("address", loc.getAddress());
+                locSnap.put("contactName", loc.getContactName());
+                locSnap.put("contactPhone", loc.getContactPhone());
+                locSnap.put("longitude", loc.getLongitude());
+                locSnap.put("latitude", loc.getLatitude());
+                fv.pickupLocationId = loc.getId();
+                fv.pickupLocationSnapshot = JSON.toJSONString(locSnap);
+                // 时间段校验
+                if (dto.getTimeSlotId() != null) {
+                    ProductPickupTimeSlot slot = productPickupTimeSlotMapper.selectById(dto.getTimeSlotId());
+                    if (slot == null || !loc.getId().equals(slot.getLocationId())
+                            || slot.getEnabled() == null || slot.getEnabled() != 1) {
+                        fv.error = "时间段不存在或已停用";
+                        return fv;
+                    }
+                    if (slot.getBookedCount() != null && slot.getMaxCapacity() != null
+                            && slot.getBookedCount() >= slot.getMaxCapacity()) {
+                        fv.error = "该时间段已约满";
+                        return fv;
+                    }
+                    Map<String, Object> slotSnap = new HashMap<>();
+                    slotSnap.put("startTime", slot.getStartTime());
+                    slotSnap.put("endTime", slot.getEndTime());
+                    slotSnap.put("weekday", slot.getWeekday());
+                    slotSnap.put("specificDate", slot.getSpecificDate());
+                    fv.timeSlotId = slot.getId();
+                    fv.timeSlotSnapshot = JSON.toJSONString(slotSnap);
+                    // 扣减余量
+                    slot.setBookedCount((slot.getBookedCount() == null ? 0 : slot.getBookedCount()) + 1);
+                    productPickupTimeSlotMapper.updateById(slot);
+                }
+                break;
+            }
+            case 4: {
+                // 线上-可选人:校验服务者 + 该服务者的时间段,价格取服务者价格
+                if (dto.getServicePersonId() == null) {
+                    fv.error = "请选择服务者";
+                    return fv;
+                }
+                ProductServicePerson person = productServicePersonMapper.selectById(dto.getServicePersonId());
+                if (person == null || !product.getId().equals(person.getProductId())
+                        || person.getEnabled() == null || person.getEnabled() != 1) {
+                    fv.error = "服务者不存在或已停用";
+                    return fv;
+                }
+                Map<String, Object> personSnap = new HashMap<>();
+                personSnap.put("name", person.getName());
+                personSnap.put("title", person.getTitle());
+                personSnap.put("intro", person.getIntro());
+                personSnap.put("avatar", person.getAvatar());
+                personSnap.put("price", person.getPrice());
+                fv.servicePersonId = person.getId();
+                fv.servicePersonSnapshot = JSON.toJSONString(personSnap);
+                fv.unitPrice = person.getPrice();
+                // 时间段校验
+                if (dto.getTimeSlotId() == null) {
+                    fv.error = "请选择预约时间段";
+                    return fv;
+                }
+                ProductServicePersonSlot personSlot = productServicePersonSlotMapper.selectById(dto.getTimeSlotId());
+                if (personSlot == null || !person.getId().equals(personSlot.getPersonId())
+                        || personSlot.getEnabled() == null || personSlot.getEnabled() != 1) {
+                    fv.error = "预约时间段不存在或已停用";
+                    return fv;
+                }
+                if (personSlot.getBookedCount() != null && personSlot.getMaxCapacity() != null
+                        && personSlot.getBookedCount() >= personSlot.getMaxCapacity()) {
+                    fv.error = "该时间段已约满";
+                    return fv;
+                }
+                Map<String, Object> slotSnap = new HashMap<>();
+                slotSnap.put("startTime", personSlot.getStartTime());
+                slotSnap.put("endTime", personSlot.getEndTime());
+                fv.timeSlotId = personSlot.getId();
+                fv.timeSlotSnapshot = JSON.toJSONString(slotSnap);
+                // 扣减余量
+                personSlot.setBookedCount((personSlot.getBookedCount() == null ? 0 : personSlot.getBookedCount()) + 1);
+                productServicePersonSlotMapper.updateById(personSlot);
+                break;
+            }
+            case 5: {
+                // 线上-单人:校验商品的时间段
+                if (dto.getTimeSlotId() == null) {
+                    fv.error = "请选择预约时间段";
+                    return fv;
+                }
+                ProductOnlineSlot onlineSlot = productOnlineSlotMapper.selectById(dto.getTimeSlotId());
+                if (onlineSlot == null || !product.getId().equals(onlineSlot.getProductId())
+                        || onlineSlot.getEnabled() == null || onlineSlot.getEnabled() != 1) {
+                    fv.error = "预约时间段不存在或已停用";
+                    return fv;
+                }
+                if (onlineSlot.getBookedCount() != null && onlineSlot.getMaxCapacity() != null
+                        && onlineSlot.getBookedCount() >= onlineSlot.getMaxCapacity()) {
+                    fv.error = "该时间段已约满";
+                    return fv;
+                }
+                Map<String, Object> slotSnap = new HashMap<>();
+                slotSnap.put("startTime", onlineSlot.getStartTime());
+                slotSnap.put("endTime", onlineSlot.getEndTime());
+                fv.timeSlotId = onlineSlot.getId();
+                fv.timeSlotSnapshot = JSON.toJSONString(slotSnap);
+                // 扣减余量
+                onlineSlot.setBookedCount((onlineSlot.getBookedCount() == null ? 0 : onlineSlot.getBookedCount()) + 1);
+                productOnlineSlotMapper.updateById(onlineSlot);
+                break;
+            }
+            default:
+                break;
+        }
+        return fv;
+    }
+
+    /** 配送校验结果载体 */
+    static class FulfillValidation {
+        String error;
+        Integer deliveryMethod;      // 实际生效的配送方式
+        Integer unitPrice;           // 线上-可选人时为服务者价格
+        Long pickupLocationId;
+        String pickupLocationSnapshot;
+        Long timeSlotId;
+        String timeSlotSnapshot;
+        Long servicePersonId;
+        String servicePersonSnapshot;
+    }
+
     private Result<ProductOrderDTO> createMultiItem(CreateProductOrderDTO dto, Long buyerId) {
         int totalAmount = 0;
         int totalQuantity = 0;
@@ -522,13 +741,40 @@ public class ProductOrderService {
         if (!"pending_receipt".equals(order.getStatus())) {
             return Result.error("仅待收货订单可确认收货");
         }
+        // 履约扩展: 自取/线上订单(deliveryMethod>=2)必须供应商先确认
+        Integer dm = order.getDeliveryMethod() != null ? order.getDeliveryMethod() : 1;
+        if (dm >= 2 && !"confirmed_by_vendor".equals(order.getFulfillStatus())) {
+            return Result.error("供应商确认后方可确认收货");
+        }
         order.setStatus("completed");
         order.setConfirmTime(new Date());
+        if (dm >= 2) {
+            order.setFulfillStatus("received_by_user");
+            order.setReceivedAt(new Date());
+        }
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
         return Result.success("已确认收货");
     }
 
+    /** 供应商确认已发出/服务完成(履约扩展) */
+    public Result<String> vendorConfirm(String orderNo, Long vendorId) {
+        ProductOrder order = orderMapper.selectOne(
+            new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
+        if (order == null) return Result.error("订单不存在");
+        if (!"paid".equals(order.getStatus()) && !"pending_shipment".equals(order.getStatus())) {
+            return Result.error("当前订单状态不可确认");
+        }
+        if (order.getFulfillStatus() != null && !"pending".equals(order.getFulfillStatus())) {
+            return Result.error("订单已确认");
+        }
+        order.setFulfillStatus("confirmed_by_vendor");
+        order.setFulfillConfirmedAt(new Date());
+        order.setUpdatedAt(new Date());
+        orderMapper.updateById(order);
+        return Result.success("已确认发出");
+    }
+
     // ========== 退款流程 ==========
 
     public Result<String> applyRefund(String orderNo, Long userId, String reason) {

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

@@ -46,6 +46,9 @@ public class ProductService {
     @Resource
     private GrowthArchiveProductService growthArchiveProductService;
 
+    @Resource
+    private ProductDeliveryConfigService productDeliveryConfigService;
+
     public Product getById(Long id) {
         return productMapper.selectById(id);
     }
@@ -129,6 +132,12 @@ public class ProductService {
             dto.setGiftLevelCode(giftRule.getLevelCode());
             dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
         }
+        // 配送与履约配置(按 deliveryMethod 附带自取地点/服务者/线上时间段)
+        Map<String, Object> deliveryConfig = new HashMap<>();
+        productDeliveryConfigService.enrichProductDetail(id, product.getDeliveryMethod(), deliveryConfig);
+        if (!deliveryConfig.isEmpty()) {
+            dto.setDeliveryConfig(deliveryConfig);
+        }
         return Result.success(dto);
     }
 

+ 132 - 1
cfc-backend/src/main/resources/schema.sql

@@ -1091,7 +1091,7 @@ CREATE TABLE IF NOT EXISTS products (
     domain VARCHAR(32) COMMENT '五维领域: action/mind/body/wisdom/wealth',
     dimension_weights JSON DEFAULT NULL COMMENT '五维权重百分比: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0}',
     profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)',
-    delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可',
+    delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
     purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制',
     category_id BIGINT COMMENT '商品类目ID',
     distribution_system_id BIGINT COMMENT '销售体系ID',
@@ -1204,6 +1204,15 @@ CREATE TABLE IF NOT EXISTS product_orders (
     points_cost INT DEFAULT 0 COMMENT '积分抵扣金额(分)',
     money_amount INT DEFAULT 0 COMMENT '现金支付金额(分)',
     consignee_id BIGINT COMMENT '收货人ID',
+    pickup_location_id BIGINT COMMENT '自取地点ID',
+    pickup_location_snapshot TEXT COMMENT '自取地点快照(JSON)',
+    time_slot_id BIGINT COMMENT '所选时间段ID',
+    time_slot_snapshot TEXT COMMENT '时间段快照(JSON)',
+    service_person_id BIGINT COMMENT '所选服务者ID(deliveryMethod=4)',
+    service_person_snapshot TEXT COMMENT '服务者快照(JSON)',
+    fulfill_status VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态: pending/confirmed_by_vendor/received_by_user/completed',
+    fulfill_confirmed_at DATETIME COMMENT '供应商确认时间',
+    received_at DATETIME COMMENT '用户确认收货时间',
 purchase_info TEXT COMMENT '购买信息JSON: {id_card, hand_signature, ...}',
 provision_status VARCHAR(32) COMMENT '活动provision状态: unprovisioned(待审核)/provisioned(已发货)/signed_in(已签到)/completed(已完成)/cancelled(审核拒绝)',
 auditor_id BIGINT COMMENT '审核人ID',
@@ -1219,6 +1228,89 @@ audit_reason TEXT COMMENT '审核拒绝原因',
     INDEX idx_supply_system_id (supply_system_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品订单表';
 
+-- 商品自取地点表
+CREATE TABLE IF NOT EXISTS product_pickup_locations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    name VARCHAR(100) NOT NULL COMMENT '地点名称',
+    address VARCHAR(500) NOT NULL COMMENT '详细地址',
+    longitude DECIMAL(10,7) COMMENT '经度',
+    latitude DECIMAL(10,7) COMMENT '纬度',
+    contact_name VARCHAR(50) COMMENT '联系人',
+    contact_phone VARCHAR(20) COMMENT '联系电话',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    enabled TINYINT DEFAULT 1 COMMENT '启用: 0/1',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品自取地点';
+
+-- 自取时间段模板表
+CREATE TABLE IF NOT EXISTS product_pickup_time_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    location_id BIGINT NOT NULL COMMENT '所属地点ID',
+    start_time TIME NOT NULL COMMENT '开始时间 HH:MM',
+    end_time TIME NOT NULL COMMENT '结束时间 HH:MM',
+    weekday TINYINT COMMENT '周几(1-7), null=按具体日期',
+    specific_date DATE COMMENT '具体日期, null=按周循环',
+    max_capacity INT DEFAULT 1 COMMENT '该时段最大接待人数',
+    booked_count INT DEFAULT 0 COMMENT '已预订数',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_location_id (location_id),
+    INDEX idx_specific_date (specific_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自取时间段模板';
+
+-- 商品线上服务者表
+CREATE TABLE IF NOT EXISTS product_service_persons (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    user_id BIGINT NOT NULL COMMENT '服务者用户ID(关联users表)',
+    name VARCHAR(50) NOT NULL COMMENT '展示名称',
+    title VARCHAR(100) COMMENT '头衔(如高级营养师)',
+    intro TEXT COMMENT '个人介绍',
+    avatar VARCHAR(500) COMMENT '头像URL',
+    price INT NOT NULL COMMENT '该人专属价格(分)',
+    original_price INT COMMENT '原价(分)',
+    sort_order INT DEFAULT 0,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id),
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品线上服务者';
+
+-- 服务者可预约时间段表
+CREATE TABLE IF NOT EXISTS product_service_person_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    person_id BIGINT NOT NULL COMMENT '所属服务者ID',
+    start_time DATETIME NOT NULL COMMENT '开始时间',
+    end_time DATETIME NOT NULL COMMENT '结束时间',
+    max_capacity INT DEFAULT 1 COMMENT '容量',
+    booked_count INT DEFAULT 0 COMMENT '已预订数',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_person_id (person_id),
+    INDEX idx_start_time (start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务者可预约时间段';
+
+-- 单人线上服务时间段表
+CREATE TABLE IF NOT EXISTS product_online_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    start_time DATETIME NOT NULL COMMENT '开始时间',
+    end_time DATETIME NOT NULL COMMENT '结束时间',
+    max_capacity INT DEFAULT 1 COMMENT '容量',
+    booked_count INT DEFAULT 0 COMMENT '已预订数',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id),
+    INDEX idx_start_time (start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单人线上服务时间段';
+
 -- =============================================
 -- 系统配置与内容表
 -- =============================================
@@ -3519,6 +3611,45 @@ CREATE TABLE IF NOT EXISTS membership_levels (
     UNIQUE KEY uk_level_code (level_code)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员等级配置';
 
+-- ============================================================
+-- 会员权益配置表(会员等级独立权益管理)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS membership_benefits (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/BASIC/PROFESSIONAL/ENTERPRISE',
+    benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等',
+    benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称',
+    benefit_desc VARCHAR(500) COMMENT '权益描述',
+    icon VARCHAR(50) COMMENT '图标emoji',
+    bg_color VARCHAR(20) COMMENT '背景色',
+    sort_order INT DEFAULT 0 COMMENT '排序顺序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_level_benefit (level_code, benefit_code),
+    INDEX idx_level (level_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员权益配置';
+
+-- 会员权益种子数据
+INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES
+('FREE',  'NO_BENEFIT', '暂无会员权益', '开通会员解锁更多权益', '🔒', '#F3F4F6', 0),
+('FREE',  'UPGRADE_HINT', '升级会员', '开通会员享专属权益', '👑', '#FEF3C7', 99),
+('FAMILY', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1),
+('FAMILY', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 10 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2),
+('FAMILY', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案', '📋', '#FEF3C7', 3),
+('FAMILY', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 解读', '📊', '#FCE7F3', 4),
+('FAMILY', 'MANAGER_SERVICE', '专属健康教练服务', '认证健康教练月度随访', '👨‍🏫', '#FFEDD5', 5),
+('FAMILY', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 8-9 折优惠', '🛒', '#E0E7FF', 6),
+('FAMILY', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券享更多优惠', '🎫', '#FEE2E2', 7),
+('PREMIUM', 'AI_CHAT', 'AI 健康顾问', '7×24 小时不限次数问答', '🤖', '#DBEAFE', 1),
+('PREMIUM', 'FAMILY_MEMBERS', '家庭档案管理', '管理最多 20 位家庭成员', '👨‍👩‍👧‍👦', '#D1FAE5', 2),
+('PREMIUM', 'HEALTH_PLAN', '月度健康计划', '个性化健康改善方案 + 定制营养建议', '📋', '#FEF3C7', 3),
+('PREMIUM', 'REPORT_READING', '报告智能解读', '体检/菌群/测评报告 AI 深度解读', '📊', '#FCE7F3', 4),
+('PREMIUM', 'MANAGER_SERVICE', '专属健康教练服务', '每周 1 次电话随访 + 在线答疑', '👨‍🏫', '#FFEDD5', 5),
+('PREMIUM', 'PRODUCT_DISCOUNT', '商品会员折扣', '全场商品 6-8 折优惠', '🛒', '#E0E7FF', 6),
+('PREMIUM', 'COUPON_ACCESS', '优惠券使用权限', '可使用平台优惠券 + 会员专属券', '🎫', '#FEE2E2', 7),
+('PREMIUM', 'PRIORITY_SUPPORT', '优先客服', '专属客服通道,快速响应', '🎧', '#EDE9FE', 8),
+('PREMIUM', 'TEACHER_CONSULT', '规划师一对一咨询', '每月 1 次成长规划师专业咨询', '👩‍🏫', '#FBCFE8', 9);
+
 -- ============================================================
 -- 家庭会员订阅表(迁移120)
 -- ============================================================

+ 453 - 0
docs/superpowers/plans/2026-08-05-product-delivery-fulfillment.md

@@ -0,0 +1,453 @@
+# 商品配送与履约系统设计
+
+> 需求:用户下单时可选配送方式(快递/上门自取/线上服务)。自取需供应商录入地址+时间段;线上服务可选人(规划师/营养师,每人不同价格)或不可选人(一人一商品);订单页展示地址/联系人/电话;供应商确认已发出已收货后用户评价。
+
+## 一、配送方式扩展
+
+### 1.1 配送方式枚举
+
+`delivery_method` 字段从 `TINYINT` 扩展为多选位掩码 + 新增 `service_mode` 区分线上子类型:
+
+| 值 | 含义 | 说明 |
+|----|------|------|
+| 1 | 快递 | 供应商发货,用户填收货地址 |
+| 2 | 上门自取 | 供应商录入自取点+时间段,用户选地点+时间段 |
+| 3 | 两者皆可 | 快递或自取(兼容旧值) |
+| 4 | 线上服务-可选人 | 多个服务者(规划师/营养师),每人不同价格+时间段 |
+| 5 | 线上服务-单人 | 一人一商品,仅需选时间段 |
+
+> **设计决策**:保留旧 `delivery_method` 枚举(1/2/3 兼容),新增值 4/5 给线上服务。这样不影响存量商品/订单。
+
+### 1.2 Product 实体新增字段
+
+```java
+// Product.java
+private Integer deliveryMethod;   // 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人
+private Integer serviceMode;      // 线上服务子类型: 1=可选人 2=单人 (deliveryMethod=4/5时必填, 其余null)
+```
+
+不对 — `serviceMode` 与 `deliveryMethod` 重复。简化:`deliveryMethod` 唯一决定模式,去掉 `serviceMode`。
+
+```sql
+-- schema.sql: products 表
+-- delivery_method 注释更新
+delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
+```
+
+---
+
+## 二、自取地址(pickup_locations)
+
+### 2.1 新建表:`product_pickup_locations`
+
+供应商在创建/编辑商品时录入多个自取地址,每个地址挂多个时间段模板。
+
+```sql
+CREATE TABLE IF NOT EXISTS product_pickup_locations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    name VARCHAR(100) NOT NULL COMMENT '地点名称(如"亿图腾科技-3楼会议室")',
+    address VARCHAR(500) NOT NULL COMMENT '详细地址',
+    longitude DECIMAL(10,7) COMMENT '经度',
+    latitude DECIMAL(10,7) COMMENT '纬度',
+    contact_name VARCHAR(50) COMMENT '联系人',
+    contact_phone VARCHAR(20) COMMENT '联系电话',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    enabled TINYINT DEFAULT 1 COMMENT '启用: 0/1',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品自取地点';
+```
+
+### 2.2 新建表:`product_pickup_time_slots`
+
+每个地点的可选时间段模板(按周循环 or 具体日期)。
+
+```sql
+CREATE TABLE IF NOT EXISTS product_pickup_time_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    location_id BIGINT NOT NULL COMMENT '所属地点ID',
+    start_time TIME NOT NULL COMMENT '开始时间 HH:MM',
+    end_time TIME NOT NULL COMMENT '结束时间 HH:MM',
+    -- 循环模式
+    weekday TINYINT COMMENT '周几(1-7), null=按具体日期',
+    specific_date DATE COMMENT '具体日期, null=按周循环',
+    -- 容量
+    max_capacity INT DEFAULT 1 COMMENT '该时段最大接待人数',
+    booked_count INT DEFAULT 0 COMMENT '已预订数',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_location_id (location_id),
+    INDEX idx_specific_date (specific_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自取时间段模板';
+```
+
+---
+
+## 三、线上服务-可选人(service_persons)
+
+### 3.1 新建表:`product_service_persons`
+
+每个商品可挂多个服务者(规划师/营养师/老师),每人独立价格+独立时间段。
+
+```sql
+CREATE TABLE IF NOT EXISTS product_service_persons (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    user_id BIGINT NOT NULL COMMENT '服务者用户ID(关联users表)',
+    name VARCHAR(50) NOT NULL COMMENT '展示名称',
+    title VARCHAR(100) COMMENT '头衔(如"高级营养师")',
+    intro TEXT COMMENT '个人介绍',
+    avatar VARCHAR(500) COMMENT '头像URL',
+    price INT NOT NULL COMMENT '该人专属价格(分)',
+    original_price INT COMMENT '原价(分)',
+    sort_order INT DEFAULT 0,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id),
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品线上服务者';
+```
+
+### 3.2 新建表:`product_service_person_slots`
+
+每个服务者的可预约时间段。
+
+```sql
+CREATE TABLE IF NOT EXISTS product_service_person_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    person_id BIGINT NOT NULL COMMENT '所属服务者ID',
+    start_time DATETIME NOT NULL COMMENT '开始时间',
+    end_time DATETIME NOT NULL COMMENT '结束时间',
+    max_capacity INT DEFAULT 1 COMMENT '容量',
+    booked_count INT DEFAULT 0 COMMENT '已预订数',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_person_id (person_id),
+    INDEX idx_start_time (start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务者可预约时间段';
+```
+
+### 3.3 单人线上服务(deliveryMethod=5)
+
+`deliveryMethod=5` 时商品本身即一人,无需 `product_service_persons` 记录,但仍需时间段表:
+
+复用 `product_service_person_slots` 但 `person_id` 关联到一个**自动创建的占位 person**(productId 关联的单条 person 记录),或用单独的表 `product_online_slots`:
+
+```sql
+CREATE TABLE IF NOT EXISTS product_online_slots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '所属商品ID',
+    start_time DATETIME NOT NULL,
+    end_time DATETIME NOT NULL,
+    max_capacity INT DEFAULT 1,
+    booked_count INT DEFAULT 0,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_product_id (product_id),
+    INDEX idx_start_time (start_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单人线上服务时间段';
+```
+
+---
+
+## 四、订单扩展
+
+### 4.1 ProductOrder 新增字段
+
+```sql
+ALTER TABLE product_orders
+    ADD COLUMN pickup_location_id BIGINT COMMENT '自取地点ID',
+    ADD COLUMN pickup_location_snapshot JSON COMMENT '自取地点快照(JSON: name/address/contact/phone)',
+    ADD COLUMN time_slot_id BIGINT COMMENT '所选时间段ID',
+    ADD COLUMN time_slot_snapshot JSON COMMENT '时间段快照(JSON: start/end)',
+    ADD COLUMN service_person_id BIGINT COMMENT '所选服务者ID(deliveryMethod=4)',
+    ADD COLUMN service_person_snapshot JSON COMMENT '服务者快照(JSON: name/title/avatar)',
+    ADD COLUMN fulfill_status VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态: pending/confirmed_by_vendor/received_by_user/completed',
+    ADD COLUMN fulfill_confirmed_at DATETIME COMMENT '供应商确认时间',
+    ADD COLUMN received_at DATETIME COMMENT '用户确认收货时间';
+```
+
+### 4.2 履约状态机
+
+```
+pending(待支付)
+    ↓ 支付成功
+paid(已支付)
+    ↓ 供应商确认发出(自取:用户到场; 线上:服务完成)
+fulfill_status: confirmed_by_vendor
+    ↓ 用户确认收货/完成
+fulfill_status: received_by_user
+    ↓ 用户评价
+completed
+```
+
+> `status` 字段保持现状(pending/paid/completed/cancelled/refunded),`fulfill_status` 新增控制履约子流程。
+
+### 4.3 CreateProductOrderDTO 扩展
+
+```java
+@Data
+public class CreateProductOrderDTO {
+    // 现有字段...
+    private Long pickupLocationId;      // 自取地点ID
+    private Long timeSlotId;             // 时间段ID(自取/线上)
+    private Long servicePersonId;       // 服务者ID(线上-可选人)
+}
+```
+
+`addressSnapshot` 字段语义扩展:
+- 快递:用户收货地址(现有)
+- 自取:供应商录入的自取地点快照
+- 线上:服务者信息快照
+
+---
+
+## 五、快照策略
+
+下单时把地点/时间/服务者信息**序列化为 JSON 存快照**到订单,避免后续商品信息修改影响历史订单展示。
+
+| 字段 | 快照内容 |
+|------|---------|
+| `pickup_location_snapshot` | `{name, address, contactName, contactPhone, longitude, latitude}` |
+| `time_slot_snapshot` | `{startTime, endTime}` |
+| `service_person_snapshot` | `{name, title, intro, avatar, price}` |
+
+---
+
+## 六、API 设计
+
+### 6.1 商品管理(供应商端)
+
+#### 录入自取地点+时间段
+```
+POST /api/product/pickup-location/save
+{
+    "productId": 123,
+    "locations": [
+        {
+            "name": "亿图腾科技-3楼",
+            "address": "北京市朝阳区xxx",
+            "contactName": "张三",
+            "contactPhone": "13800138000",
+            "timeSlots": [
+                {"weekday": 1, "startTime": "09:00", "endTime": "12:00", "maxCapacity": 5},
+                {"weekday": 1, "startTime": "14:00", "endTime": "17:00", "maxCapacity": 5}
+            ]
+        }
+    ]
+}
+```
+
+#### 录入线上服务者+时间段
+```
+POST /api/product/service-person/save
+{
+    "productId": 123,
+    "persons": [
+        {
+            "userId": 456,
+            "name": "李老师",
+            "title": "高级营养师",
+            "intro": "10年营养指导经验",
+            "price": 29900,
+            "slots": [
+                {"startTime": "2026-08-10 09:00:00", "endTime": "2026-08-10 10:00:00"},
+                {"startTime": "2026-08-10 10:00:00", "endTime": "2026-08-10 11:00:00"}
+            ]
+        }
+    ]
+}
+```
+
+#### 录入单人线上时间段
+```
+POST /api/product/online-slot/save
+{
+    "productId": 123,
+    "slots": [
+        {"startTime": "2026-08-10 09:00:00", "endTime": "2026-08-10 10:00:00", "maxCapacity": 1}
+    ]
+}
+```
+
+### 6.2 商品详情(用户端)
+
+```
+POST /api/product/detail
+→ 返回商品信息 + 根据deliveryMethod附带:
+  - deliveryMethod=2/3: pickupLocations[] (含timeSlots[])
+  - deliveryMethod=4: servicePersons[] (含 slots[])
+  - deliveryMethod=5: onlineSlots[]
+```
+
+### 6.3 下单
+
+```
+POST /api/product/order/create
+{
+    "productId": 123,
+    "deliveryMethod": 2,
+    "pickupLocationId": 10,
+    "timeSlotId": 20,
+    "consigneeId": null,        // 自取不需要收货人
+    "paymentMethod": "wechat",
+    ...
+}
+```
+
+线上-可选人还需 `servicePersonId`。
+
+### 6.4 履约流程
+
+```
+POST /api/product/order/vendor-confirm    // 供应商确认已发出/服务完成
+    {orderNo} → fulfill_status = confirmed_by_vendor
+
+POST /api/product/order/confirm           // 用户确认收货(现有,扩展)
+    {orderNo} → fulfill_status = received_by_user, status = completed
+
+POST /api/product/review/submit           // 用户评价(新增 or 复用现有评价系统)
+```
+
+### 6.5 订单详情展示
+
+订单详情返回(用户端 + 供应商端):
+```json
+{
+    "orderNo": "...",
+    "deliveryMethod": 2,
+    "pickupLocationSnapshot": {
+        "name": "亿图腾科技-3楼",
+        "address": "北京市朝阳区xxx",
+        "contactName": "张三",
+        "contactPhone": "13800138000"
+    },
+    "timeSlotSnapshot": {
+        "startTime": "09:00",
+        "endTime": "12:00"
+    },
+    "fulfillStatus": "confirmed_by_vendor",
+    "canReview": false
+}
+```
+
+---
+
+## 七、数据库迁移(DatabaseInitializer)
+
+```java
+// 迁移N: 商品配送与履约系统
+// 1. products 表更新 delivery_method 注释(无需ALTER,注释仅元数据)
+
+// 2. product_orders 表新增字段
+ensureColumn("product_orders", "pickup_location_id", "BIGINT COMMENT '自取地点ID'");
+ensureColumn("product_orders", "pickup_location_snapshot", "JSON COMMENT '自取地点快照'");
+ensureColumn("product_orders", "time_slot_id", "BIGINT COMMENT '时间段ID'");
+ensureColumn("product_orders", "time_slot_snapshot", "JSON COMMENT '时间段快照'");
+ensureColumn("product_orders", "service_person_id", "BIGINT COMMENT '服务者ID'");
+ensureColumn("product_orders", "service_person_snapshot", "JSON COMMENT '服务者快照'");
+ensureColumn("product_orders", "fulfill_status", "VARCHAR(32) DEFAULT 'pending' COMMENT '履约状态'");
+ensureColumn("product_orders", "fulfill_confirmed_at", "DATETIME COMMENT '供应商确认时间'");
+ensureColumn("product_orders", "received_at", "DATETIME COMMENT '用户确认收货时间'");
+
+// 3. 新建表 product_pickup_locations / product_pickup_time_slots
+//    product_service_persons / product_service_person_slots / product_online_slots
+// (CREATE TABLE IF NOT EXISTS, 见上方DDL)
+```
+
+---
+
+## 八、实体与 DTO 清单
+
+| 新增实体 | 表 | 用途 |
+|---------|-----|------|
+| `ProductPickupLocation` | `product_pickup_locations` | 自取地点 |
+| `ProductPickupTimeSlot` | `product_pickup_time_slots` | 自取时间段 |
+| `ProductServicePerson` | `product_service_persons` | 线上服务者 |
+| `ProductServicePersonSlot` | `product_service_person_slots` | 服务者时间段 |
+| `ProductOnlineSlot` | `product_online_slots` | 单人线上时间段 |
+
+| 实体扩展 | 新增字段 |
+|---------|---------|
+| `ProductOrder` | `pickupLocationId, pickupLocationSnapshot, timeSlotId, timeSlotSnapshot, servicePersonId, servicePersonSnapshot, fulfillStatus, fulfillConfirmedAt, receivedAt` |
+| `CreateProductOrderDTO` | `pickupLocationId, timeSlotId, servicePersonId` |
+| `ProductDetailVO` | `pickupLocations[], servicePersons[], onlineSlots[]` |
+
+---
+
+## 九、Controller 清单
+
+| Controller | 路径前缀 | 职责 |
+|-----------|---------|------|
+| `ProductPickupLocationController` | `/api/product/pickup-location` | 自取地点CRUD |
+| `ProductServicePersonController` | `/api/product/service-person` | 线上服务者CRUD |
+| `ProductOnlineSlotController` | `/api/product/online-slot` | 单人线上时间段CRUD |
+| `ProductOrderController`(扩展) | `/api/product/order` | 新增 `vendor-confirm` 端点 |
+
+---
+
+## 十、Service 清单
+
+| Service | 职责 |
+|---------|------|
+| `ProductPickupLocationService` | 自取地点+时间段管理 |
+| `ProductServicePersonService` | 线上服务者+时间段管理 |
+| `ProductOnlineSlotService` | 单人线上时间段管理 |
+| `ProductOrderService`(扩展) | 下单时校验时间段可用性、生成快照、扣减 bookedCount、履约状态流转 |
+| `ProductFulfillService` | 履约确认(供应商confirm)、用户收货确认、评价触发 |
+
+---
+
+## 十一、下单校验逻辑(ProductOrderService.create 扩展)
+
+```
+1. 查商品 → 校验 status=on_shelf
+2. 根据 deliveryMethod 分支校验:
+   - 1(快递): 校验 consigneeId 存在且属于 buyer
+   - 2(自提): 校验 pickupLocationId 属于该商品且 enabled; 校验 timeSlotId 属于该地点且 bookedCount < maxCapacity
+   - 3(两者皆可): 根据 dto.deliveryMethod 再走分支1或2
+   - 4(线上-可选人): 校验 servicePersonId 属于该商品; 校验 timeSlotId 属于该人且有余量; 价格取 person.price
+   - 5(线上-单人): 校验 timeSlotId 属于该商品 onlineSlots 且有余量
+3. 生成快照 JSON 存入订单
+4. 扣减时间槽 bookedCount (加事务)
+5. 计算金额(线上-可选人用 person.price; 其余用 product.price * quantity)
+6. 保存订单
+```
+
+## 十二、履约流程
+
+| 角色 | 动作 | API | 状态变化 |
+|------|------|-----|---------|
+| 用户 | 下单+支付 | `/order/create` + `/order/pay` | `status=paid, fulfill_status=pending` |
+| 供应商 | 确认发出(自取到场/线上完成) | `/order/vendor-confirm` | `fulfill_status=confirmed_by_vendor` |
+| 用户 | 确认收货 | `/order/confirm` | `fulfill_status=received_by_user, status=completed` |
+| 用户 | 评价 | `/review/submit` | 触发评价写入 |
+
+> 供应商确认后用户才能确认收货;用户确认收货后才能评价。前端按 `fulfillStatus` 控制按钮显隐。
+
+---
+
+## 十三、schema.sql 同步
+
+所有新建表 DDL 同步追加到 `schema.sql` 末尾的商品相关区域(`products` 表之后),保持 schema.sql 为完整快照。
+
+---
+
+## 十四、实现顺序
+
+1. ✅ 数据库迁移 + schema.sql 同步
+2. ✅ 5个新实体 + Mapper
+3. ✅ 3个新 Controller (CRUD)
+4. ✅ 3个新 Service
+5. ✅ ProductOrder 实体字段 + CreateProductOrderDTO 字段
+6. ✅ ProductOrderService.create 扩展(分支校验+快照+扣量)
+7. ✅ ProductOrderController 新增 `/vendor-confirm` 端点
+8. ✅ ProductOrderService.confirmReceive 扩展(更新 fulfill_status)
+9. ✅ 评价端点(复用现有 or 新增 ProductReview)
+10. ✅ 前端商品详情/下单页/订单详情页适配