Browse Source

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

E2E Test Bot 2 days ago
parent
commit
1036c75f91

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

@@ -1047,7 +1047,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             insertSysConfigSeed("trial_days", "7", "试用天数");
             insertSysConfigSeed("trial_features", "[\"free_activities\",\"free_courses\",\"teacher_consultation\"]", "试用期间可用功能");
             insertSysConfigSeed("member_pay_virtual_enabled", "1", "会员支付是否使用虚拟支付通道(1=虚拟,0=普通微信)");
-            insertSysConfigSeed("gift_membership_threshold", "20000", "购买送会员商品累计达到该金额(分)赠送会员");
+            insertSysConfigSeed("gift_membership_threshold", "198000", "购买送会员商品累计达到该金额(分)赠送会员");
             insertSysConfigSeed("gift_membership_level", "FAMILY", "达到送会员额度后赠送的会员等级");
             insertSysConfigSeed("health_dimension_weights", "{\"growth\":0.20,\"sleep\":0.15,\"vision\":0.10,\"immunity\":0.15,\"nutrition\":0.15,\"gut\":0.10,\"exercise\":0.15}", "7维健康→身能量权重配置");
             insertSysConfigSeed("health_dimension_sync_factor", "0.5", "7维健康同步至身能量系数");

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

@@ -10,7 +10,10 @@ import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.entity.GiftMembershipProgress;
+import com.etotem.cfc.service.GiftMembershipProgressService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.SysConfigService;
 import com.etotem.cfc.service.VirtualPayService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +47,12 @@ public class MembershipController {
     @Resource
     private VirtualPayService virtualPayService;
 
+    @Resource
+    private GiftMembershipProgressService giftMembershipProgressService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     /**
      * 获取所有会员等级
      */
@@ -420,4 +429,28 @@ public class MembershipController {
         return Result.success(benefits);
     }
 
+    /**
+     * 获取家庭赠会员累计进度
+     */
+    @Operation(summary = "获取家庭赠会员累计进度")
+    @PostMapping("/gift-membership-progress")
+    public Result<GiftMembershipProgress> getFamilyGiftMembershipProgress(
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        Long familyId = membershipService.getUserFamilyId(userId);
+        if (familyId == null) {
+            return Result.success(new GiftMembershipProgress());
+        }
+        GiftMembershipProgress progress = giftMembershipProgressService.getOrCreate(familyId);
+        // 读取全局阈值
+        String thresholdStr = sysConfigService.getValue("gift_membership_threshold");
+        int threshold = thresholdStr != null ? Integer.parseInt(thresholdStr) : 0;
+        int accumulated = progress.getAccumulatedAmount() != null ? progress.getAccumulatedAmount() : 0;
+        progress.setThreshold(threshold);
+        progress.setRemaining(Math.max(0, threshold - accumulated));
+        return Result.success(progress);
+    }
+
 }

+ 42 - 6
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -2,6 +2,8 @@ package com.etotem.cfc.dto;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class CartItemDTO {
     private Long id;
@@ -10,11 +12,45 @@ public class CartItemDTO {
     private Long skuId;
     private Integer quantity;
     private String productName;
-    private Integer unitPrice;        // 原价(分)
-    private Integer memberPrice;      // 会员价(分),null=无会员价
+    private Integer unitPrice;
+    private Integer memberPrice;
     private String coverImage;
-    private String specDesc;      // SKU规格描述,如"颜色:红色;尺码:M"
-    private String supplierName; // 供应商名称
-    private String giftLevelCode;     // 购此商品赠会员等级编码(null=无赠送规则)
-    private String giftLevelName;     // 赠会员等级名称(如"家庭会员")
+    private String specDesc;
+    private String supplierName;
+    private String giftLevelCode;
+    private String giftLevelName;
+
+    // 购物车项本身就是套餐商品(productType=bundle)时非空:展示套餐组成
+    private BundleInfo bundleInfo;
+
+    // 购物车项为子商品、且与其它子商品恰好凑成某套餐时非空:自动套餐模式
+    private BundleMatch bundleMatch;
+
+    @Data
+    public static class BundleInfo {
+        private Long bundleProductId;
+        private List<Long> childProductIds;
+        private List<String> childProductNames;
+        private Integer childQuantityTotal;
+        private Long bundlePrice;
+        private Integer singleTotalPrice;
+        private Integer saving;
+    }
+
+    @Data
+    public static class BundleMatch {
+        private Long bundleProductId;
+        private String bundleProductName;
+        private Integer matchedGroups;
+        private Long bundlePrice;
+        private Integer singleTotalPrice;
+        private Integer saving;
+        private List<BundleChild> children;
+    }
+
+    @Data
+    public static class BundleChild {
+        private Long childProductId;
+        private Integer quantity;
+    }
 }

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GiftMembershipProgress.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -27,6 +28,13 @@ public class GiftMembershipProgress implements Serializable {
 
     private Date grantedAt;              // 赠送时间
 
+    // 以下字段非数据库列,仅供接口返回使用
+    @TableField(exist = false)
+    private Integer threshold;           // 阈值(分),由接口动态填入
+
+    @TableField(exist = false)
+    private Integer remaining;           // 还差多少达标(分),已达标为0
+
     private Date createdAt;
 
     private Date updatedAt;

+ 172 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -4,9 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.entity.Cart;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductSku;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.mapper.CartMapper;
+import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductGiftRuleService;
@@ -18,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import com.alibaba.fastjson.JSON;
@@ -33,6 +36,9 @@ public class CartService {
     @Resource
     private ProductMapper productMapper;
 
+    @Resource
+    private ProductBundleItemMapper bundleItemMapper;
+
     @Resource
     private ProductSkuService productSkuService;
 
@@ -85,16 +91,182 @@ public class CartService {
                 .orderByDesc(Cart::getCreatedAt)
         );
 
+        List<Long> productIds = new ArrayList<>();
+        for (Cart item : cartList) {
+            if (!productIds.contains(item.getProductId())) {
+                productIds.add(item.getProductId());
+            }
+        }
+        Map<Long, Product> productMap = new HashMap<>();
+        if (!productIds.isEmpty()) {
+            List<Product> products = productMapper.selectBatchIds(productIds);
+            for (Product p : products) {
+                if (p != null) {
+                    productMap.put(p.getId(), p);
+                }
+            }
+        }
+
         List<CartItemDTO> result = new ArrayList<>();
         for (Cart item : cartList) {
             CartItemDTO dto = buildCartItemDTO(item);
             if (dto != null) {
+                Product product = productMap.get(item.getProductId());
+                if (product != null && "bundle".equals(product.getProductType())) {
+                    dto.setBundleInfo(buildBundleInfo(product, dto));
+                }
                 result.add(dto);
             }
         }
+
+        // 自动套餐模式:检测购物车子商品组合是否凑成某套餐,命中则挂 bundleMatch 提示
+        attachBundleMatches(cartList, result);
+
         return result;
     }
 
+    /**
+     * 自动套餐检测:聚合购物车中子商品数量,遍历所有 bundle 商品,若能覆盖套餐组成
+     * (每个子商品数量 ≥ 组成数量),则在该子商品项上挂 bundleMatch(含套餐价/节省金额/组成明细)。
+     * 命中多个套餐时,参与项上只挂第一个命中结果,供前端提示「可享套餐价」。
+     */
+    private void attachBundleMatches(List<Cart> cartList, List<CartItemDTO> result) {
+        if (cartList == null || cartList.isEmpty() || result == null || result.isEmpty()) {
+            return;
+        }
+
+        // 1. 聚合购物车数量(按 productId / productId+skuId 两级)
+        Map<Long, Integer> productQtyMap = new HashMap<>();
+        Map<String, Integer> productSkuQtyMap = new HashMap<>();
+        for (Cart c : cartList) {
+            productQtyMap.put(c.getProductId(), productQtyMap.getOrDefault(c.getProductId(), 0) + c.getQuantity());
+            if (c.getSkuId() != null) {
+                String key = c.getProductId() + "_" + c.getSkuId();
+                productSkuQtyMap.put(key, productSkuQtyMap.getOrDefault(key, 0) + c.getQuantity());
+            }
+        }
+
+        // 2. 查询所有 bundle 商品
+        List<Product> bundles = productMapper.selectList(
+            new LambdaQueryWrapper<Product>().eq(Product::getProductType, "bundle"));
+        if (bundles == null || bundles.isEmpty()) {
+            return;
+        }
+
+        // 3. 对每个 bundle 检测购物车覆盖情况
+        for (Product bundle : bundles) {
+            if (bundle.getId() == null) {
+                continue;
+            }
+            List<ProductBundleItem> items = bundleItemMapper.selectList(
+                new LambdaQueryWrapper<ProductBundleItem>()
+                    .eq(ProductBundleItem::getBundleProductId, bundle.getId()));
+            if (items == null || items.isEmpty()) {
+                continue;
+            }
+
+            // 计算最大可匹配组数 + 1 组子商品单品总价
+            int maxGroups = Integer.MAX_VALUE;
+            int singleTotalPrice = 0;
+            boolean matchable = true;
+            for (ProductBundleItem bi : items) {
+                int needQty = bi.getQuantity() != null ? bi.getQuantity() : 1;
+                Integer haveQty;
+                if (bi.getChildSkuId() != null) {
+                    haveQty = productSkuQtyMap.get(bi.getChildProductId() + "_" + bi.getChildSkuId());
+                } else {
+                    haveQty = productQtyMap.get(bi.getChildProductId());
+                }
+                if (haveQty == null || haveQty < needQty) {
+                    matchable = false;
+                    break;
+                }
+                maxGroups = Math.min(maxGroups, haveQty / needQty);
+                Product child = productMapper.selectById(bi.getChildProductId());
+                if (child != null && child.getPrice() != null) {
+                    singleTotalPrice += child.getPrice() * needQty;
+                }
+            }
+            if (!matchable || maxGroups <= 0) {
+                continue;
+            }
+
+            Long bundlePrice = bundle.getPrice() != null ? bundle.getPrice().longValue() : 0L;
+            int saving = bundlePrice < singleTotalPrice ? singleTotalPrice - bundlePrice.intValue() : 0;
+            if (saving <= 0) {
+                continue; // 无优惠不提示
+            }
+
+            // 构造 BundleMatch
+            CartItemDTO.BundleMatch match = new CartItemDTO.BundleMatch();
+            match.setBundleProductId(bundle.getId());
+            match.setBundleProductName(bundle.getName());
+            match.setMatchedGroups(maxGroups);
+            match.setBundlePrice(bundlePrice);
+            match.setSingleTotalPrice(singleTotalPrice);
+            match.setSaving(saving);
+            List<CartItemDTO.BundleChild> children = new ArrayList<>();
+            for (ProductBundleItem bi : items) {
+                CartItemDTO.BundleChild bc = new CartItemDTO.BundleChild();
+                bc.setChildProductId(bi.getChildProductId());
+                bc.setQuantity(bi.getQuantity() != null ? bi.getQuantity() : 1);
+                children.add(bc);
+            }
+            match.setChildren(children);
+
+            // 4. 挂到参与组成的子商品项上(每项只挂第一个命中,供前端提示横幅)
+            for (ProductBundleItem bi : items) {
+                for (CartItemDTO dto : result) {
+                    if (dto.getProductId() != null && dto.getProductId().equals(bi.getChildProductId())
+                            && dto.getBundleMatch() == null) {
+                        dto.setBundleMatch(match);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    private CartItemDTO.BundleInfo buildBundleInfo(Product bundleProduct, CartItemDTO dto) {
+        List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
+            new LambdaQueryWrapper<ProductBundleItem>()
+                .eq(ProductBundleItem::getBundleProductId, bundleProduct.getId())
+        );
+        if (bundleItems == null || bundleItems.isEmpty()) {
+            return null;
+        }
+
+        CartItemDTO.BundleInfo info = new CartItemDTO.BundleInfo();
+        info.setBundleProductId(bundleProduct.getId());
+
+        List<Long> childIds = new ArrayList<>();
+        List<String> childNames = new ArrayList<>();
+        int childQtyPerBundle = 0;
+        for (ProductBundleItem bi : bundleItems) {
+            childIds.add(bi.getChildProductId());
+            if (bi.getQuantity() != null) {
+                childQtyPerBundle += bi.getQuantity();
+            }
+            Product child = productMapper.selectById(bi.getChildProductId());
+            childNames.add(child != null ? child.getName() : "");
+        }
+        int cartQty = dto.getQuantity() != null ? dto.getQuantity() : 1;
+        info.setChildProductIds(childIds);
+        info.setChildProductNames(childNames);
+        info.setChildQuantityTotal(childQtyPerBundle * cartQty);
+
+        Integer unitPrice = dto.getUnitPrice() != null ? dto.getUnitPrice() : 0;
+        Integer quantity = dto.getQuantity() != null ? dto.getQuantity() : 1;
+        Integer singleTotal = unitPrice * quantity;
+        Long bundlePrice = bundleProduct.getPrice() != null ? bundleProduct.getPrice().longValue() : 0L;
+        info.setBundlePrice(bundlePrice);
+        info.setSingleTotalPrice(singleTotal);
+
+        int saving = bundlePrice < singleTotal ? singleTotal - bundlePrice.intValue() : 0;
+        info.setSaving(saving);
+        return info;
+    }
+
     @Transactional
     public void updateQuantity(Long userId, Long productId, Integer quantity) {
         Cart cart = cartMapper.selectOne(

+ 41 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -23,11 +23,11 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.WithdrawalRequestMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import java.util.Calendar;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -1000,11 +1000,15 @@ public class CommissionService {
         for (int i = 0; i < months; i++) {
             Calendar c = Calendar.getInstance();
             c.setTime(startMonth);
+            c.set(Calendar.DAY_OF_MONTH, 1);
             c.add(Calendar.MONTH, i);
-            int dayOfMonth = c.getActualMinimum(Calendar.DAY_OF_MONTH);
-            int lastDayOfMonth = c.getActualMaximum(Calendar.DAY_OF_MONTH);
-            long monthBegin = dayOfMonth * 24 * 60 * 60 * 1000;
-            long monthLast = lastDayOfMonth * 24 * 60 * 60 * 1000;
+            long monthBegin = c.getTimeInMillis();
+            c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH));
+            c.set(Calendar.HOUR_OF_DAY, 23);
+            c.set(Calendar.MINUTE, 59);
+            c.set(Calendar.SECOND, 59);
+            c.set(Calendar.MILLISECOND, 999);
+            long monthLast = c.getTimeInMillis();
 
             LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
                     .ge(User::getCreatedAt, monthBegin)
@@ -1061,11 +1065,15 @@ public class CommissionService {
         for (int i = 0; i < months; i++) {
             Calendar c = Calendar.getInstance();
             c.setTime(startMonth);
+            c.set(Calendar.DAY_OF_MONTH, 1);
             c.add(Calendar.MONTH, i);
-            int dayOfMonth = c.getActualMinimum(Calendar.DAY_OF_MONTH);
-            int lastDayOfMonth = c.getActualMaximum(Calendar.DAY_OF_MONTH);
-            long monthBegin = dayOfMonth * 24 * 60 * 60 * 1000;
-            long monthLast = lastDayOfMonth * 24 * 60 * 60 * 1000;
+            long monthBegin = c.getTimeInMillis();
+            c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH));
+            c.set(Calendar.HOUR_OF_DAY, 23);
+            c.set(Calendar.MINUTE, 59);
+            c.set(Calendar.SECOND, 59);
+            c.set(Calendar.MILLISECOND, 999);
+            long monthLast = c.getTimeInMillis();
 
             LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
                     .ge(ProductOrder::getCreatedAt, monthBegin)
@@ -1112,25 +1120,39 @@ public class CommissionService {
         calendar.add(Calendar.MONTH, -months + 1);
         Date startMonth = calendar.getTime();
 
+        // L1 直属下级 userId 列表(ProductOrder 无 referrerId 列,需经 users.referrer_id 关联)
+        List<Long> l1UserIds = userMapper.selectList(
+                        new LambdaQueryWrapper<User>().eq(User::getReferrerId, userId))
+                .stream().map(User::getId).collect(Collectors.toList());
+
         Map<Long, Integer> monthL1ConsumptionMap = new LinkedHashMap<>();
         for (int i = 0; i < months; i++) {
             Calendar c = Calendar.getInstance();
             c.setTime(startMonth);
+            c.set(Calendar.DAY_OF_MONTH, 1);
             c.add(Calendar.MONTH, i);
-            int dayOfMonth = c.getActualMinimum(Calendar.DAY_OF_MONTH);
-            int lastDayOfMonth = c.getActualMaximum(Calendar.DAY_OF_MONTH);
-            long monthBegin = dayOfMonth * 24 * 60 * 60 * 1000;
-            long monthLast = lastDayOfMonth * 24 * 60 * 60 * 1000;
+            long monthBegin = c.getTimeInMillis();
+            c.set(Calendar.DAY_OF_MONTH, c.getActualMaximum(Calendar.DAY_OF_MONTH));
+            c.set(Calendar.HOUR_OF_DAY, 23);
+            c.set(Calendar.MINUTE, 59);
+            c.set(Calendar.SECOND, 59);
+            c.set(Calendar.MILLISECOND, 999);
+            long monthLast = c.getTimeInMillis();
+
+            if (l1UserIds.isEmpty()) {
+                monthL1ConsumptionMap.put((long) i, 0);
+                continue;
+            }
 
             LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
                     .ge(ProductOrder::getCreatedAt, monthBegin)
                     .le(ProductOrder::getCreatedAt, monthLast)
-.eq(ProductOrder::getFulfillStatus, "completed")
-            .eq(ProductOrder::getBuyerId, userId);
-             int sum = (int) productOrderMapper.selectList(wrapper).stream()
-                     .mapToLong(o -> o.getTotalAmount() != null ? o.getTotalAmount() : 0)
-                     .sum();
-             monthL1ConsumptionMap.put((long) i, sum);
+                    .eq(ProductOrder::getFulfillStatus, "completed")
+                    .in(ProductOrder::getBuyerId, l1UserIds);
+            int sum = (int) productOrderMapper.selectList(wrapper).stream()
+                    .mapToLong(o -> o.getTotalAmount() != null ? o.getTotalAmount() : 0)
+                    .sum();
+            monthL1ConsumptionMap.put((long) i, sum);
         }
 
         for (int i = 0; i < months; i++) {

+ 2 - 2
cfc-frontend/pages/growth/profile/index.vue

@@ -200,8 +200,8 @@
 </template>
 
 <script>
-import config from '../../config'
-import { parseDate } from '../../utils/format.js'
+import config from '@/config.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   data() {

+ 13 - 2
cfc-frontend/pages/promotion/team.vue

@@ -108,6 +108,7 @@
       </view>
     </view>
   </view>
+</template>
 
 <script>
 import { getCfRate, getTeamList } from '../../utils/api.js'
@@ -128,11 +129,21 @@ export default {
   onLoad() {
     this.loadTeamStats()
     this.loadList()
+    // 小程序首次进入时 onLoad 与 onShow 都会触发,此标记让随后的首次 onShow
+    // 跳过 loadTeamStats/loadList,避免同一接口一次冷启动被请求两遍
+    this._onLoadFired = true
   },
   onShow() {
+    // loadCurveData 只在 onShow 调用(onLoad 不含),每次进入都需刷新,保持无条件执行
     this.loadCurveData()
-    this.loadTeamStats()
-    this.loadList()
+    if (this._onLoadFired) {
+      // 首次进入:onLoad 刚加载完,跳过避免双发
+      this._onLoadFired = false
+    } else {
+      // 切 Tab 返回 / 从子页面返回:刷新
+      this.loadTeamStats()
+      this.loadList()
+    }
   },
   onReachBottom() {
     if (this.hasMore && !this.loading) {

+ 131 - 2
cfc-frontend/pages/shop/cart/cart.vue

@@ -72,6 +72,30 @@
       <text class="member-hint-action">去开通 ›</text>
     </view>
 
+    <!-- Gift Membership Progress -->
+    <view v-if="giftProgress && !giftIsGranted && giftProgress.threshold > 0" class="gift-progress-section">
+      <view class="gift-progress-header">
+        <text class="gift-progress-icon">🎁</text>
+        <text class="gift-progress-title">购会员商品累计进度</text>
+        <text class="gift-progress-percent">{{ giftProgressPercent }}%</text>
+      </view>
+      <view class="gift-progress-bar-bg">
+        <view class="gift-progress-bar-fill" :style="{ width: giftProgressPercent + '%' }"></view>
+      </view>
+      <view class="gift-progress-info">
+        <text class="gift-progress-accumulated">已累 {{ giftAccumulatedYuan }} 元</text>
+        <text class="gift-progress-remaining">还差 {{ giftRemainingYuan }} 元送会员</text>
+      </view>
+      <view v-if="giftProgress.grantedLevel" class="gift-progress-level">
+        <text>达标将赠送 {{ giftProgress.grantedLevel === 'FAMILY' ? '家庭会员' : giftProgress.grantedLevel }} 权益</text>
+      </view>
+    </view>
+
+    <view v-else-if="giftIsGranted" class="gift-granted-hint">
+      <text class="gift-granted-icon">✅</text>
+      <text class="gift-granted-text">本家庭已赠送会员,请前往会员中心查看</text>
+    </view>
+
     <view class="bottom-bar" v-if="items.length > 0">
       <view class="bottom-left">
         <text class="selected-count">已选 {{ selectedCount }} 件</text>
@@ -101,7 +125,7 @@
 
 <script>
 import config from '@/config.js'
-import { getMyMembership } from '@/utils/api.js'
+import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
 
 export default {
   data() {
@@ -110,7 +134,8 @@ export default {
       loading: false,
       refreshing: false,
       userId: null,
-      isMember: false
+      isMember: false,
+      giftProgress: null  // 赠会员累计进度
     }
   },
   computed: {
@@ -175,12 +200,34 @@ export default {
         }
       }
       return names
+    },
+    // 累计进度相关计算
+    giftAccumulatedYuan() {
+      if (!this.giftProgress || !this.giftProgress.accumulatedAmount) return 0
+      return this.giftProgress.accumulatedAmount / 100
+    },
+    giftThresholdYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold) return 0
+      return this.giftProgress.threshold / 100
+    },
+    giftRemainingYuan() {
+      if (!this.giftProgress || !this.giftProgress.remaining) return 0
+      return this.giftProgress.remaining / 100
+    },
+    giftProgressPercent() {
+      if (!this.giftProgress || !this.giftProgress.threshold || this.giftProgress.threshold <= 0) return 0
+      if (!this.giftProgress.accumulatedAmount) return 0
+      return Math.min(100, Math.round(this.giftProgress.accumulatedAmount / this.giftProgress.threshold * 100))
+    },
+    giftIsGranted() {
+      return this.giftProgress && this.giftProgress.granted === 1
     }
   },
   onLoad() {
     this.userId = uni.getStorageSync('userId')
     this.checkMemberStatus()
     this.loadCart()
+    this.loadGiftProgress()
     this._onLoadFired = true
   },
   onShow() {
@@ -188,6 +235,7 @@ export default {
       this._onLoadFired = false
     } else if (this.items.length > 0) {
       this.loadCart()
+      this.loadGiftProgress()
     }
   },
   methods: {
@@ -199,6 +247,14 @@ export default {
         }
       }).catch(function() {})
     },
+    loadGiftProgress() {
+      var that = this
+      getGiftMembershipProgress().then(function(res) {
+        if (res.data && res.data.code === 200) {
+          that.giftProgress = res.data.data || null
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -609,4 +665,77 @@ export default {
   display: flex;
   align-items: baseline;
 }
+/* Gift membership progress */
+.gift-progress-section {
+  padding: 20rpx 30rpx;
+  background: #FFF7ED;
+  border-top: 2rpx solid #FED7AA;
+}
+.gift-progress-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.gift-progress-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-progress-title {
+  flex: 1;
+  font-size: 26rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-percent {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+.gift-progress-bar-bg {
+  height: 20rpx;
+  background: #FED7AA;
+  border-radius: 10rpx;
+  overflow: hidden;
+  margin-bottom: 12rpx;
+}
+.gift-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  border-radius: 10rpx;
+  transition: width 0.3s ease;
+}
+.gift-progress-info {
+  display: flex;
+  justify-content: space-between;
+}
+.gift-progress-accumulated {
+  font-size: 22rpx;
+  color: #9A3412;
+}
+.gift-progress-remaining {
+  font-size: 22rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-level {
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #F97316;
+}
+.gift-granted-hint {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #F0FDF4;
+  border-top: 2rpx solid #BBF7D0;
+}
+.gift-granted-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-granted-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #15803D;
+}
 </style>

+ 140 - 3
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -158,6 +158,33 @@
         </view>
       </view>
 
+      <!-- 赠会员累计进度 -->
+      <view v-if="giftProgress && !giftIsGranted && giftProgress.threshold > 0" class="section gift-progress-section">
+        <view class="gift-progress-header">
+          <text class="gift-progress-icon">🎁</text>
+          <text class="gift-progress-title">购会员商品累计进度</text>
+          <text class="gift-progress-percent">{{ giftProgressPercent }}%</text>
+        </view>
+        <view class="gift-progress-bar-bg">
+          <view class="gift-progress-bar-fill" :style="{ width: giftProgressPercent + '%' }"></view>
+        </view>
+        <view class="gift-progress-info">
+          <text class="gift-progress-accumulated">已累 {{ giftAccumulatedYuan }} 元</text>
+          <text class="gift-progress-remaining">还差 {{ giftRemainingYuan }} 元送会员</text>
+        </view>
+        <text class="gift-progress-note">仅原价实付订单计入累计,使用积分、优惠券或折扣的订单不计入</text>
+        <view v-if="giftProgress.grantedLevel" class="gift-progress-level">
+          <text>达标将赠送 {{ giftProgress.grantedLevel === 'FAMILY' ? '家庭会员' : giftProgress.grantedLevel }} 权益</text>
+        </view>
+      </view>
+
+      <view v-else-if="giftIsGranted" class="section gift-granted-section">
+        <view class="gift-granted-row">
+          <text class="gift-granted-icon">✅</text>
+          <text class="gift-granted-text">本家庭已赠送会员,可前往会员中心查看</text>
+        </view>
+      </view>
+
       <!-- Freight Section -->
       <view class="section">
         <view class="info-row">
@@ -430,7 +457,7 @@
 
 <script>
 import config from '@/config.js'
-import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
+import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, getGiftMembershipProgress, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
 import CountdownTag from '@/components/countdown-tag.vue'
 
@@ -473,11 +500,32 @@ export default {
       showTimeSlotModal: false,
       showServicePersonModal: false,
        showOnlineSlotModal: false,
-       giftItemIds: [],
-       discountInfo: null
+      giftItemIds: [],
+      giftProgress: null,   // 赠会员累计进度
+      discountInfo: null
     }
   },
   computed: {
+    giftAccumulatedYuan() {
+      if (!this.giftProgress || !this.giftProgress.accumulatedAmount) return 0
+      return this.giftProgress.accumulatedAmount / 100
+    },
+    giftThresholdYuan() {
+      if (!this.giftProgress || !this.giftProgress.threshold) return 0
+      return this.giftProgress.threshold / 100
+    },
+    giftRemainingYuan() {
+      if (!this.giftProgress || !this.giftProgress.remaining) return 0
+      return this.giftProgress.remaining / 100
+    },
+    giftProgressPercent() {
+      if (!this.giftProgress || !this.giftProgress.threshold || this.giftProgress.threshold <= 0) return 0
+      if (!this.giftProgress.accumulatedAmount) return 0
+      return Math.min(100, Math.round(this.giftProgress.accumulatedAmount / this.giftProgress.threshold * 100))
+    },
+    giftIsGranted() {
+      return this.giftProgress && this.giftProgress.granted === 1
+    },
     giftLevelNames() {
       var names = []
       for (var i = 0; i < this.items.length; i++) {
@@ -584,6 +632,7 @@ export default {
     this.loadUserPoints()
     this.loadDeliveryConfig()
     this.loadDiscount()
+    this.loadGiftProgress()
     this._onLoadFired = true
   },
   onShow() {
@@ -785,6 +834,14 @@ export default {
         }
       }).catch(function() {})
     },
+    loadGiftProgress() {
+      var that = this
+      getGiftMembershipProgress().then(function(res) {
+        if (res.data && res.data.code === 200) {
+          that.giftProgress = res.data.data || null
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -1679,4 +1736,84 @@ showOnlineSlotPicker: function() {
   color: #F97316;
   font-size: 32rpx;
 }
+/* Gift membership progress */
+.gift-progress-section {
+  background: #FFF7ED;
+  border-radius: 16rpx;
+  border: 2rpx solid #FED7AA;
+}
+.gift-progress-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.gift-progress-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-progress-title {
+  flex: 1;
+  font-size: 26rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-percent {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+.gift-progress-bar-bg {
+  height: 20rpx;
+  background: #FED7AA;
+  border-radius: 10rpx;
+  overflow: hidden;
+  margin-bottom: 12rpx;
+}
+.gift-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  border-radius: 10rpx;
+}
+.gift-progress-info {
+  display: flex;
+  justify-content: space-between;
+}
+.gift-progress-accumulated {
+  font-size: 22rpx;
+  color: #9A3412;
+}
+.gift-progress-remaining {
+  font-size: 22rpx;
+  color: #C2410C;
+  font-weight: 500;
+}
+.gift-progress-level {
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #F97316;
+}
+.gift-progress-note {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 20rpx;
+  color: #9A3412;
+}
+.gift-granted-section {
+  background: #F0FDF4;
+  border-radius: 16rpx;
+  border: 2rpx solid #BBF7D0;
+}
+.gift-granted-row {
+  display: flex;
+  align-items: center;
+}
+.gift-granted-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.gift-granted-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #15803D;
+}
 </style>

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

@@ -476,6 +476,10 @@ export const getMyMembership = () => {
 	return request('/api/membership/my', 'POST')
 }
 
+export const getGiftMembershipProgress = () => {
+	return request('/api/membership/gift-membership-progress', 'POST')
+}
+
 export const getAllQuotaStatus = () => {
 	return request('/api/basic-quota/status/all', 'POST')
 }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-a4616d2dfa9b82f8b3ec48560af2fb9ce119bbf1
+87119319d22c7cf18d7a83d933cd822e4aa62e9c

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1476",
+  "version": "1.0.1482",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1476",
+      "version": "1.0.1482",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1477",
+  "version": "1.0.1483",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 60 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,66 @@
 
 ---
 
+## v1.0.1483 (2026-09-29)
+
+### Bug 修复
+- 修正 growth/profile/index.vue 悬空 import 路径
+
+### 其他
+- - format.js: '../../utils/format.js' -> '@/utils/format.js'(该页在 pages/growth/profile/ 深 3 层,原路径少一级 ../)
+- - 验证:全项目 387 文件/660 条本地 import 悬空 0;348 个 .vue 结构扫描 0 问题
+- Co-Authored-By: Sisyphus (Anthropic) <noreply@anthropic.com>
+- 
+
+
+## v1.0.1482 (2026-09-29)
+
+### Bug 修复
+- 修复 team.vue 模板未闭合导致编译失败 + onLoad/onShow 双重调用
+
+
+## v1.0.1481 (2026-09-29)
+
+### 新功能
+- 赠会员进度接口补充 remaining 字段与实付口径文案
+
+### 其他
+- - MembershipController 计算 remaining = max(0, threshold - accumulatedAmount)
+- - cart/checkout 改读后端 remaining,消除前端重复业务计算
+- - checkout 补充「仅原价实付订单计入」说明文案
+- - API_REFERENCE 章节号 4.38→4.43 避免重复,补 remaining 字段与无家庭返回说明
+- 
+
+
+## v1.0.1480 (2026-09-29)
+
+### 新功能
+- 购物车/结算页展示赠会员累计进度
+
+### 其他
+-   金额、阈值、已赠送状态
+- - GiftMembershipProgress.threshold 标记 @TableField(exist = false),
+-   避免非数据库列被拼进 SELECT 报 Unknown column
+- - cart.vue / checkout.vue 增加进度条、已累/还差金额、达标提示
+- - API_REFERENCE.md 补充新接口说明
+- 
+
+### Bug 修复
+- 修复月度曲线月份边界计算错误 + 阈值默认值改1980元
+
+
+## v1.0.1479 (2026-09-28)
+
+### 新功能
+- 购物车功能完善 - CartItemDTO和CartService改造
+
+
+## v1.0.1478 (2026-09-28)
+
+### 新功能
+- 补任务编排流程任务+保单上传AI解析+统一TabBar+服务中心页(TASK-SYS-248)
+
+
 ## v1.0.1477 (2026-09-28)
 
 ### 文档

+ 61 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1477
+> 当前版本: v1.0.1483
 
 ## 历史版本
 
@@ -8,6 +8,66 @@
 
 ---
 
+## v1.0.1483 (2026-09-29)
+
+### Bug 修复
+- 修正 growth/profile/index.vue 悬空 import 路径
+
+### 其他
+- - format.js: '../../utils/format.js' -> '@/utils/format.js'(该页在 pages/growth/profile/ 深 3 层,原路径少一级 ../)
+- - 验证:全项目 387 文件/660 条本地 import 悬空 0;348 个 .vue 结构扫描 0 问题
+- Co-Authored-By: Sisyphus (Anthropic) <noreply@anthropic.com>
+- 
+
+
+## v1.0.1482 (2026-09-29)
+
+### Bug 修复
+- 修复 team.vue 模板未闭合导致编译失败 + onLoad/onShow 双重调用
+
+
+## v1.0.1481 (2026-09-29)
+
+### 新功能
+- 赠会员进度接口补充 remaining 字段与实付口径文案
+
+### 其他
+- - MembershipController 计算 remaining = max(0, threshold - accumulatedAmount)
+- - cart/checkout 改读后端 remaining,消除前端重复业务计算
+- - checkout 补充「仅原价实付订单计入」说明文案
+- - API_REFERENCE 章节号 4.38→4.43 避免重复,补 remaining 字段与无家庭返回说明
+- 
+
+
+## v1.0.1480 (2026-09-29)
+
+### 新功能
+- 购物车/结算页展示赠会员累计进度
+
+### 其他
+-   金额、阈值、已赠送状态
+- - GiftMembershipProgress.threshold 标记 @TableField(exist = false),
+-   避免非数据库列被拼进 SELECT 报 Unknown column
+- - cart.vue / checkout.vue 增加进度条、已累/还差金额、达标提示
+- - API_REFERENCE.md 补充新接口说明
+- 
+
+### Bug 修复
+- 修复月度曲线月份边界计算错误 + 阈值默认值改1980元
+
+
+## v1.0.1479 (2026-09-28)
+
+### 新功能
+- 购物车功能完善 - CartItemDTO和CartService改造
+
+
+## v1.0.1478 (2026-09-28)
+
+### 新功能
+- 补任务编排流程任务+保单上传AI解析+统一TabBar+服务中心页(TASK-SYS-248)
+
+
 ## v1.0.1477 (2026-09-28)
 
 ### 文档

+ 44 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -1354,4 +1354,47 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
-*文档最后更新:2026-09-13*
+### 4.43 商品赠送会员(`/api/membership/gift-membership-progress`)
+
+家庭维度累计「赠会员商品」原价实付金额,达到阈值后自动赠送会员。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/membership/gift-membership-progress` | 获取当前家庭的赠会员累计进度 |
+
+**POST `/api/membership/gift-membership-progress`**
+
+请求:无(familyId 由 JWT userId 反查)
+
+返回:
+```json
+{
+  "code": 200,
+  "data": {
+    "familyId": 12,
+    "accumulatedAmount": 158000,
+    "granted": 0,
+    "grantedLevel": null,
+    "grantedOrderNo": null,
+    "grantedAt": null,
+    "threshold": 198000,
+    "remaining": 40000
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `accumulatedAmount` | Integer | 累计赠会员商品原价金额(分),仅统计无积分/无券/无折扣的实付订单 |
+| `granted` | Integer | 1=已赠送 0=未赠送 |
+| `grantedLevel` | String | 已赠送的会员等级(当前全局配置默认 `FAMILY`) |
+| `threshold` | Integer | 阈值(分),由 `sys_config.gift_membership_threshold` 动态填入 |
+| `remaining` | Integer | 还差多少达标(分)= `max(0, threshold - accumulatedAmount)`,已达标为 `0` |
+
+> **注意**:`threshold`、`remaining` 为 `@TableField(exist = false)` 非数据库列,仅接口返回用。
+> **无家庭时**:接口返回空对象,`threshold`/`remaining`/`accumulatedAmount` 均为 `null`(此时不存在赠会员累计口径),前端据此隐藏进度条。
+> 阈值单位为分(1980 元 = 198000 分),实际生效值以 `sys_config` 表中 `gift_membership_threshold` 为准(`insertSysConfigSeed` 仅在缺失时插入,不覆盖已有配置)。
+
+---
+
+*文档最后更新:2026-09-29*