Răsfoiți Sursa

feat(membership): 商品赠送会员功能完整实现

Sisyphus 1 zi în urmă
părinte
comite
7bd0b8de97

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -10,8 +10,11 @@ public class CartItemDTO {
     private Long skuId;
     private Integer quantity;
     private String productName;
-    private Integer unitPrice;
+    private Integer unitPrice;        // 原价(分)
+    private Integer memberPrice;      // 会员价(分),null=无会员价
     private String coverImage;
     private String specDesc;      // SKU规格描述,如"颜色:红色;尺码:M"
     private String supplierName; // 供应商名称
+    private String giftLevelCode;     // 购此商品赠会员等级编码(null=无赠送规则)
+    private String giftLevelName;     // 赠会员等级名称(如"家庭会员")
 }

+ 19 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -5,9 +5,12 @@ import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.entity.Cart;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.service.ProductSkuService;
+import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.mapper.CartMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.PpointService;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductSkuService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -33,6 +36,9 @@ public class CartService {
     @Resource
     private ProductSkuService productSkuService;
 
+    @Resource
+    private ProductGiftRuleService productGiftRuleService;
+
     @Transactional
     public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
         Product product = productMapper.selectById(productId);
@@ -147,7 +153,13 @@ public class CartService {
             dto.setProductName(product.getName());
             dto.setCoverImage(product.getCoverImage());
             dto.setUnitPrice(product.getPrice() != null ? product.getPrice() : 0);
+            dto.setMemberPrice(product.getMemberPrice());
             dto.setSupplierName(product.getVendorName());
+            ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(product.getId());
+            if (giftRule != null) {
+                dto.setGiftLevelCode(giftRule.getLevelCode());
+                dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
+            }
         }
         if (cart.getSkuId() != null) {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
@@ -160,7 +172,13 @@ public class CartService {
                         dto.setProductName(linkedProduct.getName());
                         dto.setCoverImage(linkedProduct.getCoverImage());
                         dto.setUnitPrice(linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0);
+                        dto.setMemberPrice(linkedProduct.getMemberPrice());
                         dto.setSupplierName(linkedProduct.getVendorName());
+                        ProductGiftRule linkedGift = productGiftRuleService.getActiveByProductId(linkedProduct.getId());
+                        if (linkedGift != null) {
+                            dto.setGiftLevelCode(linkedGift.getLevelCode());
+                            dto.setGiftLevelName(productGiftRuleService.getLevelName(linkedGift.getLevelCode()));
+                        }
                     }
                 } else {
                     // Legacy SKU: 用 SKU 价格覆盖

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductGiftRuleService.java

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 
@@ -30,6 +31,16 @@ public class ProductGiftRuleService {
                 .last("LIMIT 1"));
     }
 
+    /** 批量查询多个商品启用的赠送规则(列表页避免 N+1) */
+    public List<ProductGiftRule> listActiveByProductIds(List<Long> productIds) {
+        if (productIds == null || productIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return mapper.selectList(new LambdaQueryWrapper<ProductGiftRule>()
+                .in(ProductGiftRule::getProductId, productIds)
+                .eq(ProductGiftRule::getStatus, 1));
+    }
+
     /** 查询商品全部规则(管理端回显) */
     public List<ProductGiftRule> listByProduct(Long productId) {
         return mapper.selectList(new LambdaQueryWrapper<ProductGiftRule>()

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

@@ -140,6 +140,20 @@ wrapper.in(Product::getId, productIds)
         List<ProductDTO> records = result.getRecords().stream()
             .map(p -> ProductDTO.from(p, isGuest, level))
             .collect(Collectors.toList());
+        // 批量查询赠送规则,避免 N+1(列表页优化)
+        List<Long> productIds = result.getRecords().stream()
+            .map(Product::getId)
+            .collect(Collectors.toList());
+        List<ProductGiftRule> giftRules = productGiftRuleService.listActiveByProductIds(productIds);
+        Map<Long, ProductGiftRule> giftRuleMap = giftRules.stream()
+            .collect(Collectors.toMap(ProductGiftRule::getProductId, rule -> rule));
+        for (ProductDTO dto : records) {
+            ProductGiftRule giftRule = giftRuleMap.get(dto.getId());
+            if (giftRule != null) {
+                dto.setGiftLevelCode(giftRule.getLevelCode());
+                dto.setGiftLevelName(productGiftRuleService.getLevelName(giftRule.getLevelCode()));
+            }
+        }
         Map<String, Object> data = new HashMap<>();
         data.put("records", records);
         data.put("total", result.getTotal());

+ 92 - 1
cfc-frontend/pages/shop/cart/cart.vue

@@ -43,12 +43,16 @@
                 </text>
               </view>
               <text class="item-name">{{ item.productName }}</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
               <text v-if="item.specDesc" class="item-spec">{{ item.specDesc }}</text>
               <text v-if="item.supplierName" class="item-supplier">供应商: {{ item.supplierName }}</text>
               <text class="item-delete" @click="onRemove(item.productId)">🗑</text>
             </view>
             <view class="item-bottom">
-              <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
+              <view class="item-price-wrap">
+                <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
+                <text v-if="isMember && item.memberPrice && item.memberPrice > 0 && item.memberPrice < item.unitPrice" class="item-member-price">会员 {{ formatPriceWithSymbol(item.memberPrice) }}</text>
+              </view>
               <view class="stepper">
                 <text class="stepper-btn" @click="decreaseQty(item)">−</text>
                 <text class="stepper-value">{{ item.quantity }}</text>
@@ -71,6 +75,18 @@
     <view class="bottom-bar" v-if="items.length > 0">
       <view class="bottom-left">
         <text class="selected-count">已选 {{ selectedCount }} 件</text>
+      </view>
+      <view class="bottom-right" v-if="isMember">
+        <view class="price-compare">
+          <text class="price-original-label">原价</text>
+          <text class="price-original">{{ formatPriceWithSymbol(originalTotalAmount) }}</text>
+        </view>
+        <view class="price-compare">
+          <text class="price-estimate-label">预计支付</text>
+          <text class="price-estimate">{{ formatPriceWithSymbol(estimatedTotalAmount) }}</text>
+        </view>
+      </view>
+      <view class="bottom-right" v-else>
         <text class="total-amount-label">合计: </text>
         <text class="total-amount">{{ formatPriceWithSymbol(totalAmount) }}</text>
       </view>
@@ -120,6 +136,45 @@ export default {
         }
       }
       return total
+    },
+    originalTotalAmount() {
+      var total = 0
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked) {
+          var price = this.items[i].unitPrice || 0
+          total = total + (price * this.items[i].quantity)
+        }
+      }
+      return total
+    },
+    estimatedTotalAmount() {
+      var total = 0
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked) {
+          var price = this.items[i].unitPrice || 0
+          // 会员可用会员价,优先用会员价
+          if (this.isMember && this.items[i].memberPrice && this.items[i].memberPrice > 0 && this.items[i].memberPrice < price) {
+            price = this.items[i].memberPrice
+          }
+          total = total + (price * this.items[i].quantity)
+        }
+      }
+      return total
+    },
+    hasGiftItems() {
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].giftLevelName) return true
+      }
+      return false
+    },
+    giftLevelNames() {
+      var names = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].giftLevelName) {
+          names.push(this.items[i].giftLevelName)
+        }
+      }
+      return names
     }
   },
   onLoad() {
@@ -265,6 +320,12 @@ export default {
           if (this.items[i].specDesc) {
             item.specDesc = this.items[i].specDesc
           }
+          if (this.items[i].giftLevelName) {
+            item.giftLevelName = this.items[i].giftLevelName
+          }
+          if (this.items[i].memberPrice) {
+            item.memberPrice = this.items[i].memberPrice
+          }
           selected.push(item)
         }
       }
@@ -518,4 +579,34 @@ export default {
   color: #F97316;
   font-weight: 500;
 }
+.price-original {
+  font-size: 22rpx;
+  color: #94A3B8;
+  text-decoration: line-through;
+}
+.price-estimate {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+/* Gift badge */
+.gift-badge {
+  font-size: 20rpx;
+  color: #F97316;
+  background: #FFF7ED;
+  padding: 2rpx 8rpx;
+  border-radius: 6rpx;
+  margin-left: 8rpx;
+  border: 1rpx solid #FED7AA;
+}
+/* Member price */
+.item-member-price {
+  font-size: 22rpx;
+  color: #10B981;
+  margin-left: 8rpx;
+}
+.item-price-wrap {
+  display: flex;
+  align-items: baseline;
+}
 </style>

+ 27 - 1
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -146,7 +146,15 @@
             <text v-if="item.supplierName" class="item-supplier-name">供应商: {{ item.supplierName }}</text>
             <text class="item-qty">x{{ item.quantity }}</text>
           </view>
-          <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
+          <text class="item-price">{{ formatPriceWithSymbol(effectivePrice(item)) }}</text>
+        </view>
+      </view>
+
+      <!-- 赠会员提示 -->
+      <view v-if="giftLevelNames.length > 0" class="section gift-member-section">
+        <view class="gift-member-row">
+          <text class="gift-member-icon">🎁</text>
+          <text class="gift-member-text">本单含{{ giftLevelNames.join('、') }}赠送商品,支付成功后将自动开通会员</text>
         </view>
       </view>
 
@@ -470,6 +478,15 @@ export default {
     }
   },
   computed: {
+    giftLevelNames() {
+      var names = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].giftLevelName && names.indexOf(this.items[i].giftLevelName) < 0) {
+          names.push(this.items[i].giftLevelName)
+        }
+      }
+      return names
+    },
     finalAmount() {
       var total = (this.actualAmount || 0) + (this.freight || 0)
       if (this.selectedCoupon) {
@@ -578,6 +595,15 @@ export default {
     }
   },
   methods: {
+    effectivePrice(item) {
+      if (!item) return 0
+      var price = item.unitPrice || 0
+      // 会员可享会员价
+      if (this.isMember && item.memberPrice && item.memberPrice > 0 && item.memberPrice < price) {
+        price = item.memberPrice
+      }
+      return price
+    },
     calcAmount() {
       var total = 0
       for (var i = 0; i < this.items.length; i++) {

+ 1 - 0
cfc-frontend/pages/shop/index/index.vue

@@ -126,6 +126,7 @@
             <view class="price-row">
               <text class="price">{{ formatPriceWithSymbol(item.price) }}</text>
               <text v-if="item.memberPrice" class="member-price">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
             </view>
             <text class="vendor-name">{{ item.brandName || '平台官方' }}</text>
           </view>

+ 1 - 0
cfc-frontend/pages/shop/search/search.vue

@@ -58,6 +58,7 @@
             <view class="price-row">
               <text class="price">{{ formatPriceWithSymbol(item.price) }}</text>
               <text v-if="item.memberPrice" class="member-price">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
+              <text v-if="item.giftLevelName" class="gift-badge">🎁购此送 {{ item.giftLevelName }}会员</text>
             </view>
           </view>
         </view>