فهرست منبع

merge: member-cost 分支合入主线(保留生产在跑功能,消除与主线迁移编号冲突)

- 保留 fix/member-cost-in-ppoints:送会员商品P点扣除会员成本价 + 菜谱分享只读 + 会员清理等
- 迁移编号消歧:其「迁移469 membership_levels.cost_price」与主线「迁移469 五维注释」撞车 → 重排为 474;其「迁移468 工作台索引」与主线 473 重复 → 去重保留 473
- 迁移474 幂等:生产已由 member-cost 构建应用过 cost_price,ensureColumn 跳过,不重复回填
- 仅 4 个前端构建产物文件冲突(.last_build_commit/package.json/CHANGELOG×2),取主线版本
- 验证:后端 mvn package 成功;前端 npm run build 成功(app.6a199f57.js)
Xiaogang Liao 1 روز پیش
والد
کامیت
d86ba6aeaa

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

@@ -3538,6 +3538,10 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移473(FamilyWorkbench 2026-11-13): tasks 加 (family_id, created_at) 索引(工作台按家庭聚合与逐日趋势分桶)
         addTasksFamilyCreatedIndex();
+
+        // 迁移474: membership_levels 加会员成本价列 cost_price(F-3a 送会员商品P点口径:
+        // 配置了「送会员」规则的商品订单,P 点 = 实付 − 商品成本 − 赠送等级会员成本价)
+        addMembershipCostPriceColumn();
     }
 
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
@@ -15903,4 +15907,27 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             return null;
         }
     }
-}
+
+    /**
+     * 迁移474: membership_levels.cost_price 会员成本价(分)。
+     * 幂等:ensureColumn 忽略「列已存在」;仅有默认值 UPDATE,不覆盖管理端已设值。
+     */
+    private void addMembershipCostPriceColumn() {
+        try {
+            ensureColumn("membership_levels", "cost_price",
+                    "INT NOT NULL DEFAULT 0 COMMENT '会员成本价(分):送会员商品计算P点时扣除的商品外成本'");
+            // FAMILY 等级初始成本价 99900 分(¥999)——仅当该等级成本价为 0/默认时回填,不覆盖已设置值
+            Integer cnt = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM membership_levels WHERE level_code = 'FAMILY' AND (cost_price IS NULL OR cost_price = 0)",
+                    Integer.class);
+            if (cnt != null && cnt > 0) {
+                jdbcTemplate.update("UPDATE membership_levels SET cost_price = 99900 WHERE level_code = 'FAMILY'");
+                log.info("迁移474: 已回填 FAMILY 会员成本价 = 99900 分");
+            } else {
+                log.info("迁移474: 已存在,跳过(membership_levels.cost_price)");
+            }
+        } catch (Exception e) {
+            log.warn("迁移474: membership_levels.cost_price 失败(忽略): {}", e.getMessage());
+        }
+    }
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -1610,6 +1610,7 @@ public class AdminController {
         if (level.getMaxTasksPerDay() != null) existing.setMaxTasksPerDay(level.getMaxTasksPerDay());
         if (level.getAiReviewEnabled() != null) existing.setAiReviewEnabled(level.getAiReviewEnabled());
         if (level.getPrioritySupport() != null) existing.setPrioritySupport(level.getPrioritySupport());
+        if (level.getCostPrice() != null) existing.setCostPrice(level.getCostPrice());
         int rows = membershipLevelMapper.updateById(existing);
         if (rows > 0) {
             return Result.success("更新成功");

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.controller.diet;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.DietRecommendationService;
+import com.etotem.cfc.util.ParamUtils;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
@@ -35,6 +36,24 @@ public class DietRecommendationController {
         return Result.success(dietRecommendationService.getTodayRecommendation(familyId, date, viewKey));
     }
 
+    /**
+     * 菜谱分享只读查询:按 id 获取菜谱详情(跨家庭可看,无需 familyId)。
+     * 仅需登录用户;数据只含菜谱/营养信息,无个人信息。
+     */
+    @PostMapping("/share")
+    public Result<Map> getShareDetail(
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestBody(required = false) Map<String, Object> request) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long id = (request != null) ? ParamUtils.getLong(request.get("id")) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        return Result.success(dietRecommendationService.getShareDetail(id));
+    }
+
     /**
      * 生成食谱(K3):改 Map&lt;String,Object&gt; 接住 participant_member_ids 的 JSON 字符串与原生数组双形态,
      * 以及可选 view_key;date / meal_type / selected_foods 由 Service 侧 asString 兜底。

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipLevel.java

@@ -49,6 +49,8 @@ public class MembershipLevel implements Serializable {
 
     private Integer levelOrder;  // 级别数字(越大级别越高): FREE=1 BASIC=2 FAMILY=3 PREMIUM=4 LIFETIME=5; PROVIDER=1(非会员,服务商独立角色)
 
+    private Integer costPrice;  // 会员成本价(分):送会员商品计算P点时扣除的商品外成本
+
     private Date createdAt;
 
     private Date updatedAt;

+ 38 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -13,11 +13,15 @@ import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.PpointConsumptionLog;
 import com.etotem.cfc.entity.OrderItem;
+import com.etotem.cfc.entity.MembershipLevel;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.ProductGiftRuleMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
@@ -100,6 +104,10 @@ public class CfCommissionService {
     @Resource
     private ProductOrderMapper productOrderMapper;
     @Resource
+    private ProductGiftRuleMapper productGiftRuleMapper;
+    @Resource
+    private MembershipLevelMapper membershipLevelMapper;
+    @Resource
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
     @Resource
     private OctopusEffectRecordMapper octopusEffectRecordMapper;
@@ -369,20 +377,22 @@ public class CfCommissionService {
                             Product p = productMapper.selectById(it.getProductId());
                             int costPerUnit = (p != null && p.getPurchasePrice() != null) ? p.getPurchasePrice() : 0;
                             long lineCost = (long) costPerUnit * qty;
-                            long linePpoint = allocPaid - lineCost;
+                            long memberCost = giftMembershipCost(it.getProductId());
+                            long linePpoint = allocPaid - lineCost - memberCost;
                             if (linePpoint > 0) total += (int) linePpoint;
                         }
                         return total;
                     }
                 }
 
-                // 单商品订单:实付 − 成本 × 数量
+                // 单商品订单:实付 − 成本 × 数量 − 赠送会员成本价
                 if (productId == null) return 0;
                 Product p = productMapper.selectById(productId);
                 if (p == null) return 0;
                 int costPerUnit = p.getPurchasePrice() != null ? p.getPurchasePrice() : 0;
                 int qty = order.getQuantity() != null ? order.getQuantity() : 1;
-                int ppoints = paid - costPerUnit * qty;
+                int memberCost = giftMembershipCost(productId);
+                int ppoints = paid - costPerUnit * qty - memberCost;
                 return ppoints > 0 ? ppoints : 0;
             } catch (Exception e) {
                 log.warn("计算本单 P 点失败: orderId={}, err={}", orderId, e.getMessage());
@@ -395,6 +405,31 @@ public class CfCommissionService {
         return amountYuan * serviceRateBps / 10000;
     }
 
+
+    /**
+     * 赠送会员成本价(分):商品若配置了「送会员规则」(product_gift_rules 启用),
+     * 返回其赠送等级的 membership_levels.cost_price;未配置或等级无成本价返回 0。
+     */
+    private int giftMembershipCost(Long productId) {
+        if (productId == null) return 0;
+        try {
+            ProductGiftRule rule = productGiftRuleMapper.selectOne(
+                    new LambdaQueryWrapper<ProductGiftRule>()
+                            .eq(ProductGiftRule::getProductId, productId)
+                            .eq(ProductGiftRule::getStatus, 1)
+                            .last("LIMIT 1"));
+            if (rule == null || rule.getLevelCode() == null) return 0;
+            MembershipLevel level = membershipLevelMapper.selectOne(
+                    new LambdaQueryWrapper<MembershipLevel>()
+                            .eq(MembershipLevel::getLevelCode, rule.getLevelCode())
+                            .last("LIMIT 1"));
+            return (level == null || level.getCostPrice() == null) ? 0 : level.getCostPrice();
+        } catch (Exception e) {
+            log.warn("查询赠送会员成本价失败: productId={}, err={}", productId, e.getMessage());
+            return 0;
+        }
+    }
+
     /** 落 P 点消费日志(唯一键 order_type+order_id,重复触发忽略) */
     private void recordPpointConsumption(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                          Integer amountCent, Long productId, int ppoints) {

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -83,6 +83,36 @@ public class DietRecommendationService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    /**
+     * 菜谱分享只读查询:按 id 获取菜谱详情,不做 familyId 归属校验(跨家庭可看)。
+     * 分享数据仅含菜谱/营养信息,无个人信息,只读展示所需字段。
+     * 幂等:未命中返回 id=null + message,不抛异常。
+     */
+    public Map<String, Object> getShareDetail(Long id) {
+        Map<String, Object> result = new HashMap<>();
+        if (id == null) {
+            result.put("id", null);
+            result.put("message", "分享参数无效");
+            return result;
+        }
+        DietRecommendation record = dietRecommendationMapper.selectById(id);
+        if (record == null) {
+            result.put("id", null);
+            result.put("message", "菜谱不存在或已失效");
+            return result;
+        }
+        result.put("id", record.getId());
+        result.put("date", record.getRecommendationDate() != null
+                ? record.getRecommendationDate().toString() : null);
+        result.put("mealType", record.getMealType());
+        result.put("menu", record.getMenuJson());
+        result.put("nutritionSummary", record.getNutritionSummary());
+        result.put("status", record.getStatus());
+        result.put("version", record.getVersion());
+        result.put("participantMemberIds", record.getParticipantMemberIds());
+        return result;
+    }
+
     /**
      * 兼容老客户端 / 其它存量调用方:不传 {@code view_key},走原查询。
      *

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

@@ -622,6 +622,10 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
 
         // 方案B:成员被踢出后,重新核算原家庭会员身份
         membershipService.reconcileFamilyMembership(oldFamilyId);
+        // 方案B补充:剥离被踢成员本人从原家庭继承的会员身份(reconcileFamilyMembership 只处理仍留在原家庭的成员,已迁出者不在降级范围)
+        if (targetUserId != null) {
+            membershipService.reconcileLeavingMemberMembership(targetUserId, oldFamilyId, newFamilyId);
+        }
 
         // T28:成员被移出家庭后,其食材指数不再计入原家庭总览(AC-10)
         // 只 warn 不抛出,避免影响踢人主流程
@@ -696,6 +700,9 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         // 方案B:成员离开后,重新核算原家庭会员身份(成员离开不影响新家庭,新家庭通常是新建的无会员)
         membershipService.reconcileFamilyMembership(oldFamilyId);
 
+        // 方案B补充:剥离退出成员本人从原家庭继承的会员身份(reconcileFamilyMembership 只处理仍留在原家庭的成员)
+        membershipService.reconcileLeavingMemberMembership(userId, oldFamilyId, ownFamilyId);
+
         logMemberAction(oldFamilyId, member.getId(), "leave", member.getNickname(), null, userId);
 
         // T28:成员退出后,其食材指数不再计入原家庭总览(AC-10)
@@ -903,6 +910,8 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         // 方案B:切换家庭后,重新核算新旧家庭的会员身份
         membershipService.reconcileFamilyMembership(targetFamilyId);
         membershipService.reconcileFamilyMembership(oldFamilyId);
+        // 方案B补充:剥离切换成员本人从原家庭继承的会员身份(reconcileFamilyMembership 只处理仍留在原家庭的成员)
+        membershipService.reconcileLeavingMemberMembership(userId, oldFamilyId, targetFamilyId);
 
         logMemberAction(oldFamilyId, member.getId(), "switch", member.getNickname(), null, userId);
     }

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

@@ -136,6 +136,15 @@ public class FoodRecommendService {
         Long writeKey = resolveFoodIndexUserId(userId);
         HealthReport report = healthReportService.getLatestReport(userId);
         if (report == null) {
+            // 删除分支同样做成员视角权威化:report==null 时无 subject_id 可参考,但入参 userId 可能是
+            // family_members.id(报告确认链路 subjectId 口径)。若入参恰为成员卡 id,而该数值在 users 表
+            // 也有同名行(users 与 family_members 独立自增、数值高度重叠),resolveFoodIndexUserId 的
+            // users 优先启发式会误判(如家庭71 报告56 案例:memberId=19 被误判为 users.id=19,
+            // 导致删错对象、真实用户 53 的指数残留)。故删除键同样按成员视角解析。
+            Long memberViewKey = resolveMemberViewToUserId(userId);
+            if (memberViewKey != null) {
+                writeKey = memberViewKey;
+            }
             foodRecommendIndexMapper.delete(new LambdaQueryWrapper<FoodRecommendIndex>()
                     .eq(FoodRecommendIndex::getUserId, writeKey));
             log.info("用户{}无最新健康报告,已清除推荐指数", userId);

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

@@ -1240,6 +1240,53 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * 方案B:成员被移出家庭(被踢/退出/切换)后,剥离其从原家庭继承的会员身份缓存。
+     * <p>背景:reconcileFamilyMembership(oldFamilyId) 只按 family_id 反查仍在该家庭的成员,
+     * 已迁出的成员(如 CF 被踢后迁移到初始家庭)不在降级范围内,导致继承的 FAMILY 身份残留。
+     * <p>规则(与 downgradeFamilyMembers 一致):
+     * - 用户当前等级不高于原家庭历史最高等级 → 判定身份来自家庭继承 → 降级 FREE 并清空到期时间
+     * - 用户当前等级高于原家庭历史最高等级(个人购买的 PREMIUM/LIFETIME)→ 保留个人身份
+     * - 降级后新家庭存在有效会员 → 按新家庭身份重新继承(syncMembershipForNewMember 只升不降)
+     * <p>由 FamilyMemberService.kickMember()/leaveFamily()/switchMemberToFamily() 调用
+     */
+    public void reconcileLeavingMemberMembership(Long userId, Long oldFamilyId, Long newFamilyId) {
+        if (userId == null || oldFamilyId == null) return;
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getMemberLevel() == null || "FREE".equals(user.getMemberLevel())) return;
+
+        // 原家庭历史最高会员等级(含已过期记录,用于判定身份来源)
+        int oldFamilyMaxOrder = 0;
+        List<FamilyMembership> oldMemberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getFamilyId, oldFamilyId));
+        if (oldMemberships != null) {
+            for (FamilyMembership m : oldMemberships) {
+                if (m.getLevelCode() != null) {
+                    oldFamilyMaxOrder = Math.max(oldFamilyMaxOrder, getLevelOrder(m.getLevelCode()));
+                }
+            }
+        }
+
+        // 高于原家庭历史最高等级 → 个人购买的更高级别身份,保留
+        if (getLevelOrder(user.getMemberLevel()) > oldFamilyMaxOrder) {
+            return;
+        }
+
+        // 继承自原家庭的会员身份 → 剥离为 FREE
+        user.setMemberLevel("FREE");
+        user.setMemberExpireTime(null);
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+        log.info("剥离迁出成员继承的家庭会员身份: userId={}, oldFamilyId={}, newFamilyId={}",
+                userId, oldFamilyId, newFamilyId);
+
+        // 新家庭存在有效会员 → 按新家庭身份重新继承(只升不降)
+        if (newFamilyId != null && !newFamilyId.equals(oldFamilyId)) {
+            syncMembershipForNewMember(userId, newFamilyId);
+        }
+    }
+
     /**
      * 按家庭维度设置会员等级(后台授予/调整的唯一入口)。
      * 写入事实源 family_memberships,并全套同步 users 缓存(方案B:会员属于家庭)。

+ 78 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -22,7 +22,11 @@ import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.entity.ProductGiftRule;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
 import com.etotem.cfc.mapper.OrderItemMapper;
+import com.etotem.cfc.mapper.ProductGiftRuleMapper;
 import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
@@ -103,6 +107,10 @@ class CfCommissionServiceSettleTest {
     @Mock
     private PpointConsumptionLogMapper ppointConsumptionLogMapper;
     @Mock
+    private ProductGiftRuleMapper productGiftRuleMapper;
+    @Mock
+    private MembershipLevelMapper membershipLevelMapper;
+    @Mock
     private ConfigService configService;
     @Spy
     private ObjectMapper objectMapper = new ObjectMapper();
@@ -493,4 +501,74 @@ class CfCommissionServiceSettleTest {
         verify(cfBalanceService, never()).earnByRule(anyLong(), anyString(), anyLong(), any());
         verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
     }
+
+    @Test
+    void settle_songhuiyuan_shangpin_Pdian_jichu_kouchu_huiyuanchengben() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("xinxin", 100, 10));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(5140, false));
+
+        // Product: purchase price 30000 fen; gift rule -> FAMILY membership
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(product(30000));
+        ProductGiftRule giftRule = new ProductGiftRule();
+        giftRule.setProductId(PRODUCT_ID);
+        giftRule.setLevelCode("FAMILY");
+        giftRule.setStatus(1);
+        when(productGiftRuleMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(giftRule);
+        MembershipLevel fam = new MembershipLevel();
+        fam.setLevelCode("FAMILY");
+        fam.setCostPrice(99900);
+        when(membershipLevelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(fam);
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 181300, PRODUCT_ID);
+
+        // P points = paid 181300 - product cost 30000 - membership cost 99900 = 51400
+        ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
+        assertEquals(51400, captor.getValue().get("base_ppoints"));
+        assertEquals(10, captor.getValue().get("rate_percent"));
+    }
+
+    @Test
+    void settle_duo_shangpin_dan_Pdian_jichu_kouchu_huiyuanchengben() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("xinxin", 100, 10));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(5140, false));
+
+        // Multi-item order: order 21 has order_items detail (one line, product 3)
+        ProductOrder po = new ProductOrder();
+        po.setId(ORDER_ID);
+        po.setQuantity(1);
+        when(productOrderMapper.selectById(ORDER_ID)).thenReturn(po);
+        OrderItem item = new OrderItem();
+        item.setOrderId(ORDER_ID);
+        item.setProductId(PRODUCT_ID);
+        item.setQuantity(1);
+        item.setPrice(java.math.BigDecimal.valueOf(181300));
+        when(orderItemMapper.findByOrderId(ORDER_ID)).thenReturn(java.util.Collections.singletonList(item));
+
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(product(30000));
+        ProductGiftRule giftRule = new ProductGiftRule();
+        giftRule.setProductId(PRODUCT_ID);
+        giftRule.setLevelCode("FAMILY");
+        giftRule.setStatus(1);
+        when(productGiftRuleMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(giftRule);
+        MembershipLevel fam = new MembershipLevel();
+        fam.setLevelCode("FAMILY");
+        fam.setCostPrice(99900);
+        when(membershipLevelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(fam);
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 181300, PRODUCT_ID);
+
+        // P = 181300 - 30000 - 99900 = 51400
+        ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
+        assertEquals(51400, captor.getValue().get("base_ppoints"));
+    }
 }

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/DietRecommendationServiceTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.mapper.DietRecommendationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 回归测试:菜谱分享只读查询 getShareDetail(id)。
+ *
+ * 背景(菜谱分享功能):今日食谱为持久化实体 DietRecommendation,但现有 /today、/generate 等
+ * 接口都依赖 JWT 的 familyId 查自己的家庭,无法跨家庭展示。分享功能需要按 id 只读查询,
+ * 不做 familyId 归属校验(数据仅含菜谱/营养信息,无个人信息)。
+ *
+ * 行为约定:
+ * - 按 id 命中:返回 menuJson/nutritionSummary/date/mealType/status(只读展示所需字段)
+ * - 按 id 未命中:返回 id=null + message,不抛异常
+ * - 不涉及任何写操作
+ */
+class DietRecommendationServiceTest {
+
+    @Mock private DietRecommendationMapper dietRecommendationMapper;
+
+    private DietRecommendationService service;
+
+    @BeforeEach
+    void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new DietRecommendationService();
+        setField(service, "dietRecommendationMapper", dietRecommendationMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        java.lang.reflect.Field f = DietRecommendationService.class.getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private DietRecommendation rec(Long id, Long familyId, String menuJson, String nutrition) {
+        DietRecommendation r = new DietRecommendation();
+        r.setId(id);
+        r.setFamilyId(familyId);
+        r.setRecommendationDate(new Date());
+        r.setMenuJson(menuJson);
+        r.setNutritionSummary(nutrition);
+        r.setStatus("accepted");
+        return r;
+    }
+
+    @Test
+    void getShareDetail_found_returnsMenuAndNutritionWithoutFamilyCheck() {
+        DietRecommendation record = rec(101L, 42L,
+                "{\"meals\":[{\"name\":\"午餐\",\"dishes\":[{\"name\":\"清炒时蔬\"}]}]}",
+                "{\"calories\":500,\"protein\":20}");
+        when(dietRecommendationMapper.selectById(101L)).thenReturn(record);
+
+        Map<String, Object> result = service.getShareDetail(101L);
+
+        assertNotNull(result);
+        assertEquals(101L, result.get("id"));
+        assertEquals(record.getMenuJson(), result.get("menu"));
+        assertEquals(record.getNutritionSummary(), result.get("nutritionSummary"));
+        assertEquals("accepted", result.get("status"));
+        // 只读:不做 familyId 校验(跨家庭可看),不注入家族信息
+        assertFalse(result.containsKey("familyId"), "分享详情不应暴露家庭归属");
+    }
+
+    @Test
+    void getShareDetail_notFound_returnsNullIdAndMessage() {
+        when(dietRecommendationMapper.selectById(999L)).thenReturn(null);
+
+        Map<String, Object> result = service.getShareDetail(999L);
+
+        assertNotNull(result);
+        assertNull(result.get("id"));
+        assertNotNull(result.get("message"));
+    }
+}

+ 38 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FoodRecommendMemberViewResolutionTest.java

@@ -161,6 +161,44 @@ class FoodRecommendMemberViewResolutionTest {
                 "食材写入键应为成员84的登录用户 users.id=27,而非数值重叠的无关用户 users.id=84");
     }
 
+    /**
+     * 回归测试:recalculateForUser 删除分支(无最新报告时)的 writeKey 也应按成员视角权威化。
+     *
+     * 背景(家庭71 报告56 改归属案例):user53(沈怡均) 将报告56 从成员卡19(用户5918, user_id=53)
+     * 改到儿子成员卡20 后,reassignReport 触发 recalculateForUser(19)。此时 getsLatestReport(19)
+     * 返回 null(报告已不在成员19),走删除分支。旧逻辑删除键 writeKey=resolveFoodIndexUserId(19),
+     * 而 users.id=19 是无关用户"用户"(family20) → 误删 user_id=19,真实用户 53 的 224 条指数残留。
+     * 修复:删除分支同样以成员视角 resolveMemberViewToUserId(19)=53 权威化删除键。
+     */
+    @Test
+    void recalculateForUser_deleteBranch_memberViewAuthoritativeClearsRealUser() {
+        // users.id=19 存在(无关用户"用户"family20)—— 与成员卡19 数值碰撞
+        when(userMapper.selectById(19L)).thenReturn(memberUser(19L));
+        // family_members.id=19 → 沈怡均(user_id=53)
+        when(familyMemberMapper.selectById(19L)).thenReturn(member(19L, 53L, 71L));
+        // 报告已改归属到儿子(20),成员19 无 active 报告 → 删除分支
+        when(healthReportService.getLatestReport(19L)).thenReturn(null);
+        // refreshFamilyOverviews 内部反查成员家庭:返回空列表即可
+        when(familyMemberMapper.selectList(any())).thenReturn(new java.util.ArrayList<>());
+
+        // 验证删除的 writeKey = 53(沈怡均真实登录用户),而非碰撞误判的 users.id=19
+        final java.util.Map<String, Object>[] paramsHolder = new java.util.Map[1];
+        doAnswer(inv -> {
+            @SuppressWarnings("unchecked")
+            com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FoodRecommendIndex> w =
+                    (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FoodRecommendIndex>) inv.getArgument(0);
+            w.getSqlSegment(); // 触发 wrapper 参数初始化(MBP 延迟填充 paramNameValuePairs)
+            paramsHolder[0] = w.getParamNameValuePairs();
+            return 0;
+        }).when(foodRecommendIndexMapper).delete(any());
+
+        service.recalculateForUser(19L);
+
+        assertNotNull(paramsHolder[0], "应捕获到 delete 的 wrapper 参数");
+        assertTrue(paramsHolder[0].containsValue(53L),
+                "删除分支的 writeKey 应为成员19 的登录用户 users.id=53,而非数值重叠的无关用户 users.id=19,实际=" + paramsHolder[0]);
+    }
+
     private static void installLambdaColumnCache() {
         try {
             java.lang.reflect.Field f = com.baomidou.mybatisplus.core.toolkit.LambdaUtils.class.getDeclaredField("COLUMN_CACHE_MAP");

+ 85 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/MembershipServiceTest.java

@@ -19,7 +19,10 @@ import java.util.Collections;
 import java.util.Date;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -259,4 +262,86 @@ public class MembershipServiceTest {
             throw new RuntimeException(e);
         }
     }
+
+    // ── reconcileLeavingMemberMembership(成员迁出家庭后剥离继承身份)────────────
+
+    @Test
+    void reconcileLeavingMember_inheritedLevel_downgradesToFree() {
+        // given: 用户从原家庭继承 FAMILY(原家庭历史最高 FAMILY),迁入新家庭无会员
+        User inherited = user(1L, "FAMILY", future(), 30L);
+        when(userMapper.selectById(1L)).thenReturn(inherited);
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", future())));
+
+        // when: 成员被踢出/离开家庭
+        service.reconcileLeavingMemberMembership(1L, 72L, 30L);
+
+        // then: 继承身份被剥离为 FREE,到期时间清空
+        verify(userMapper).updateById(inherited);
+        assertEquals("FREE", inherited.getMemberLevel());
+        assertNull(inherited.getMemberExpireTime());
+    }
+
+    @Test
+    void reconcileLeavingMember_noTargetFamilyMembership_reconcileUsesNull() {
+        // given: 用户 FAMILY 继承自原家庭,新家庭(Null=非微信用户解除关联)
+        User inherited = user(1L, "FAMILY", future(), null);
+        when(userMapper.selectById(1L)).thenReturn(inherited);
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", future())));
+
+        // when: 非微信用户被踢出,无新家庭
+        service.reconcileLeavingMemberMembership(1L, 72L, null);
+
+        // then: 剥离为 FREE
+        verify(userMapper).updateById(inherited);
+        assertEquals("FREE", inherited.getMemberLevel());
+    }
+
+    @Test
+    void reconcileLeavingMember_higherPersonalLevel_kept() {
+        // given: 用户 LIFETIME 高于原家庭历史最高 FAMILY(个人购买身份)
+        User personal = user(1L, "LIFETIME", null, 30L);
+        when(userMapper.selectById(1L)).thenReturn(personal);
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", future())));
+
+        // when
+        service.reconcileLeavingMemberMembership(1L, 72L, 30L);
+
+        // then: 个人更高级别身份保留,不动
+        verify(userMapper, never()).updateById(personal);
+    }
+
+    @Test
+    void reconcileLeavingMember_noOldFamilyHistory_kept() {
+        // given: 原家庭无任何会员历史(无法判定身份来源家庭)
+        User premium = user(1L, "PREMIUM", future(), 30L);
+        when(userMapper.selectById(1L)).thenReturn(premium);
+        when(membershipMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        // when
+        service.reconcileLeavingMemberMembership(1L, 72L, 30L);
+
+        // then: 无家庭历史 → 不剥离
+        verify(userMapper, never()).updateById(premium);
+    }
+
+    @Test
+    void reconcileLeavingMember_targetFamilyHasMembership_resyncsToNewFamily() {
+        // given: 用户 FAMILY 继承自原家庭,迁入新家庭(30)已有有效 FAMILY 会员
+        User inherited = user(1L, "FAMILY", future(), 30L);
+        when(userMapper.selectById(1L)).thenReturn(inherited);
+        when(membershipMapper.selectList(any())).thenReturn(
+                Collections.singletonList(membership("FAMILY", future())));
+        when(membershipMapper.selectOne(any())).thenReturn(activeMembership("FAMILY"));
+
+        // when
+        service.reconcileLeavingMemberMembership(1L, 72L, 30L);
+
+        // then: 先剥离再按新家庭重新继承(同步只升不降,写入升级记录)
+        verify(userMapper, atLeastOnce()).updateById(inherited);
+        assertEquals("FAMILY", inherited.getMemberLevel());
+        assertNotNull(inherited.getMemberExpireTime());
+    }
 }

+ 115 - 20
cfc-frontend/pages/diet/recommendation.vue

@@ -15,8 +15,8 @@
           @overview="switchView('family')"
           @select="switchMember" />
 
-        <!-- Tab 切换行:今日食谱 | 适合 | 戒备 | 全部 -->
-        <view class="filter-bar">
+        <!-- Tab 切换行:今日食谱 | 适合 | 戒备 | 全部(只读分享模式下隐藏,避免切到空食材区) -->
+        <view class="filter-bar" v-if="!shareMode">
           <view class="filter-item" :class="{'filter-active': currentTab === 'recipe'}" @tap="switchTab('recipe')">
             <text>🍽️ 今日食谱</text>
           </view>
@@ -161,10 +161,10 @@
         <text class="recipe-loading-text">正在加载今日食谱...</text>
       </view>
 
-      <!-- 失败态:接口异常 → 明确提示 + 重试入口,文案与空态区分(AC-16 / AC-38) -->
+      <!-- 失败态:接口异常 → 明确提示 + 重试入口,文案与空态区分(AC-16 / AC-38);只读分享模式下文案改分享语义 -->
       <view class="empty-state" v-else-if="recipeError">
         <view class="empty-icon-wrap"><text class="empty-icon">⚠️</text></view>
-        <text class="empty-title">今日食谱加载失败</text>
+        <text class="empty-title">{{ shareMode ? '菜谱加载失败' : '今日食谱加载失败' }}</text>
         <text class="empty-sub">{{ recipeError }}</text>
         <view class="generate-btn" @tap="retryRecommendation">
           <text>点击重试</text>
@@ -174,19 +174,25 @@
       <!-- 无推荐状态 / 视角级空态(AC-04;不回退展示其它视角食谱) -->
       <view class="empty-state" v-else-if="!recommendation">
         <view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
-        <text class="empty-title">今日菜单空着</text>
-        <text class="empty-sub">点击生成今日三餐食谱</text>
-        <view class="generate-btn" @tap="generateRecipe">
-          <text>生成食谱</text>
-        </view>
+        <block v-if="shareMode">
+          <text class="empty-title">菜谱不存在或已失效</text>
+          <text class="empty-sub">分享链接可能已过期</text>
+        </block>
+        <block v-else>
+          <text class="empty-title">今日菜单空着</text>
+          <text class="empty-sub">点击生成今日三餐食谱</text>
+          <view class="generate-btn" @tap="generateRecipe">
+            <text>生成食谱</text>
+          </view>
+        </block>
       </view>
 
-      <!-- 整份空态:所有餐次菜数为 0(删除后可能出现,AC-35) -->
+      <!-- 整份空态:所有餐次菜数为 0(删除后可能出现,AC-35);只读分享模式下隐藏重新生成入口 -->
       <view class="empty-state" v-else-if="allMealsEmpty">
         <view class="empty-icon-wrap"><text class="empty-icon">🍽️</text></view>
         <text class="empty-title">今天的菜品都删完啦</text>
-        <text class="empty-sub">点击重新生成今日三餐食谱</text>
-        <view class="generate-btn" @tap="regenerate">
+        <text class="empty-sub" v-if="!shareMode">点击重新生成今日三餐食谱</text>
+        <view class="generate-btn" @tap="regenerate" v-if="!shareMode">
           <text>重新生成</text>
         </view>
       </view>
@@ -204,6 +210,16 @@
           </view>
         </view>
 
+        <!-- 分享操作区:只读分享模式显示"这是朋友分享的菜谱",本人模式显示分享按钮 -->
+        <view class="share-bar">
+          <text v-if="shareMode" class="share-mode-tip">📤 朋友分享的今日食谱</text>
+          <view v-else class="share-btn-wrap">
+            <button class="share-btn" open-type="share">
+              <text class="share-btn-text">📤 分享菜谱</text>
+            </button>
+          </view>
+        </view>
+
         <!-- 菜品列表 -->
         <view class="meal-section" v-for="(meal, index) in meals" :key="index">
           <view class="meal-header">
@@ -236,7 +252,7 @@
                 <text class="method-text">🍳 {{ dish.method }}</text>
               </view>
               <!-- D1①:收敛为单一「🗑 删除」;按钮组合由 dishActions 数据位驱动,便于回退 -->
-              <view class="dish-actions" v-if="dishActions === 'delete-only'">
+              <view class="dish-actions" v-if="dishActions === 'delete-only' && !shareMode">
                 <view class="action-btn delete-btn"
                   :class="{'action-btn-disabled': isDeleting(meal.name, dIndex)}"
                   @tap="deleteDish(meal.name, dIndex)">🗑 删除</view>
@@ -275,8 +291,8 @@
           </view>
         </view>
 
-        <!-- 操作按钮 -->
-        <view class="action-area">
+        <!-- 操作按钮:只读分享模式下隐藏本人操作 -->
+        <view class="action-area" v-if="!shareMode">
           <view class="btn-row">
             <view class="btn-secondary" @tap="regenerate">重新生成</view>
             <view class="btn-primary" @tap="completeRecipe" v-if="recommendation.status !== 'completed'">
@@ -322,15 +338,16 @@
 </template>
 
 <script>
-import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation, updateDietRecommendation } from '@/utils/api.js'
+import { getDietRecommendation, generateDietRecommendation, completeDietRecommendation, regenerateDietRecommendation, updateDietRecommendation, getShareDietRecommendation } from '@/utils/api.js'
 import config from '@/config.js'
 import MemberSelector from '@/components/MemberSelector.vue'
 import { memberIdOf } from '@/utils/memberNormalize.js'
+import shareMixin from '@/components/share-mixin.js'
 
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
-  mixins: [fontScaleMixin],
+  mixins: [fontScaleMixin, shareMixin],
   components: { MemberSelector },
   data() {
     return {
@@ -358,13 +375,16 @@ export default {
       // ===== 共餐人选择(需求7:家庭总览生成食谱先选共餐人)=====
       showParticipantPicker: false,
       pendingParticipantIds: [],
-      // ===== 今日食谱视角隔离 / 删除交互 / 空·加载·失败三态(本轮增量)=====
+// ===== 今日食谱视角隔离 / 删除交互 / 空·加载·失败三态(本轮增量)=====
       recipeLoading: false,
       recipeError: '',
       deleting: false,
       deletingKey: '',
       // D1① 可回退数据位:'delete-only'(当前)/ 'replace-and-delete'(备选,改此值 + 模板分支即可)
-      dishActions: 'delete-only'
+      dishActions: 'delete-only',
+      // ===== 菜谱分享(id 参数进入只读模式,跨家庭可看,跳过本人成员/家庭/食材数据加载)=====
+      shareMode: false,
+      sharedId: null
     }
   },
   computed: {
@@ -475,12 +495,20 @@ export default {
   onLoad: function(options) {
     var opts = options || {}
     this.currentMemberId = opts.memberId ? parseInt(opts.memberId) : null
+    // 菜谱分享只读模式:带 id 进入 → 按 id 加载该份菜谱(跨家庭可看),跳过本人成员/家庭/食材数据加载
+    this.shareMode = !!(opts.id && !opts.memberId)
+    if (this.shareMode) {
+      this.sharedId = parseInt(opts.id)
+      this.loadSharedRecommendation(this.sharedId)
+      return
+    }
     this.loadRecommendation()
     this.loadMembers()
     this.loadFamilyData()
     this.loadFoods()
   },
   onShow: function() {
+    if (this.shareMode) return
     this.loadRecommendation()
   },
   methods: {
@@ -514,6 +542,7 @@ export default {
           self.parseMenu(d.menu)
           var ns = d.nutritionSummary
           self.summary = ns ? (typeof ns === 'string' ? JSON.parse(ns) : ns) : {}
+          self.setShareInfo('今日食谱 ' + (d.date || today), '/pages/diet/recommendation?id=' + d.id)
         } else {
           self.recommendation = null
           self.meals = []
@@ -527,7 +556,7 @@ export default {
         self.recipeError = (err && err.message) || '加载失败,请重试'
       })
     },
-    // 视角键生成(K1 可回退的唯一落点):家庭总览固定 'family';个人/成员 'member:<id>';
+// 视角键生成(K1 可回退的唯一落点):家庭总览固定 'family';个人/成员 'member:<id>';
     // loadMembers 未完成、memberList 为空且无 currentMemberId 时返回 null 且不发请求。
     // 后端把 view_key 当不透明字符串,语义全在此方法一处。
     buildViewKey: function() {
@@ -542,8 +571,38 @@ export default {
     },
     // 失败态重试:按当前视角重新加载,不重置用户当前视角
     retryRecommendation: function() {
+      if (this.shareMode) {
+        this.loadSharedRecommendation(this.sharedId)
+        return
+      }
       this.loadRecommendation()
     },
+    // 分享只读模式:按 id 加载该份菜谱(后端 getShareDetail,跨家庭可看,无 familyId 归属校验)
+    loadSharedRecommendation: function(id) {
+      var self = this
+      if (!id) return
+      self.recipeLoading = true
+      getShareDietRecommendation({ id: id }).then(function(res) {
+        self.recipeLoading = false
+        var d = res && res.data
+        if (d && d.id) {
+          self.recommendation = d
+          self.parseMenu(d.menu)
+          var ns = d.nutritionSummary
+          self.summary = ns ? (typeof ns === 'string' ? JSON.parse(ns) : ns) : {}
+          self.setShareInfo('今日食谱 ' + (d.date || ''), '/pages/diet/recommendation?id=' + id)
+        } else {
+          self.recipeLoading = false
+          self.recommendation = null
+          self.meals = []
+          self.summary = {}
+          uni.showToast({ title: (d && d.message) || '菜谱不存在或已失效', icon: 'none' })
+        }
+      }).catch(function() {
+        self.recipeLoading = false
+        uni.showToast({ title: '加载分享菜谱失败', icon: 'none' })
+      })
+    },
     parseMenu: function(menuJson) {
       try {
         var menu = (typeof menuJson === 'string') ? JSON.parse(menuJson || '{}') : (menuJson || {})
@@ -1275,6 +1334,42 @@ export default {
   display: block;
   margin-top: 4rpx;
 }
+/* ===== 菜谱分享栏(本人模式分享按钮 / 只读模式提示条)===== */
+.share-bar {
+  padding: 24rpx $uni-space-page 0;
+}
+.share-btn-wrap {
+  display: flex;
+  justify-content: center;
+}
+.share-btn {
+  width: 320rpx;
+  padding: 18rpx 0;
+  background: linear-gradient(135deg, #ff8c42, #ffb26b);
+  border-radius: 999rpx;
+  border: none;
+  line-height: 1.4;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: #fff;
+  font-weight: 600;
+  box-shadow: 0 6rpx 20rpx rgba(255, 140, 66, 0.25);
+}
+.share-btn::after {
+  border: none;
+}
+.share-btn-text {
+  color: #fff;
+}
+.share-mode-tip {
+  display: block;
+  text-align: center;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $brand-primary-deep;
+  background: rgba(255, 140, 66, 0.08);
+  border-radius: 16rpx;
+  padding: 20rpx 0;
+}
+
 .action-area {
   padding: 40rpx $uni-space-page;
 }

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

@@ -3134,6 +3134,9 @@ export const generateDietRecommendation = (data) => request('/api/diet/recommend
 export const regenerateDietRecommendation = (data) => request('/api/diet/recommendation/regenerate', 'POST', data)
 export const updateDietRecommendation = (data) => request('/api/diet/recommendation/update', 'POST', data)
 export const completeDietRecommendation = (data) => request('/api/diet/recommendation/complete', 'POST', data)
+
+// 菜谱分享只读查询(按 id,跨家庭可看,仅需登录)
+export const getShareDietRecommendation = (data) => request('/api/diet/recommendation/share', 'POST', data)
 export const recognizeDietFood = (data) => request('/api/diet/record/recognize', 'POST', data)
 export const saveDietRecord = (data) => request('/api/diet/record/save', 'POST', data)
 export const getDietDailyRecords = (data) => request('/api/diet/record/daily', 'POST', data)

+ 9 - 0
cfc-web/src/views/admin/MembershipCenter.vue

@@ -37,6 +37,9 @@
               <el-table-column label="年费" width="80">
                 <template slot-scope="{ row }">{{ formatPrice(row.priceYearly) }}</template>
               </el-table-column>
+              <el-table-column label="会员成本价" width="100">
+                <template slot-scope="{ row }">{{ formatPrice(row.costPrice) }}</template>
+              </el-table-column>
               <el-table-column label="原价月费" width="90">
                 <template slot-scope="{ row }">{{ formatPrice(row.originalPriceMonthly) }}</template>
               </el-table-column>
@@ -258,6 +261,10 @@
         <el-form-item label="年费(元)">
           <el-input-number v-model="editForm.priceYearly" :min="0" :step="100" :precision="0" class="w-160" />
         </el-form-item>
+        <el-form-item label="会员成本价(元)">
+          <el-input-number v-model="editForm.costPrice" :min="0" :step="100" :precision="0" class="w-160" />
+          <span class="mv-hint">送会员商品计算P点时扣除的成本(商品成本价之外)</span>
+        </el-form-item>
         <el-form-item label="月原价(元)">
           <el-input-number v-model="editForm.originalPriceMonthly" :min="0" :step="100" :precision="0" class="w-160" />
           <span class="mv-hint">留空/0 表示无折扣</span>
@@ -352,6 +359,7 @@ export default {
         priceMonthly: 0,
         priceQuarterly: 0,
         priceYearly: 0,
+        costPrice: 0,
         originalPriceMonthly: 0,
         originalPriceQuarterly: 0,
         originalPriceYearly: 0,
@@ -471,6 +479,7 @@ export default {
         priceMonthly: row.priceMonthly,
         priceQuarterly: row.priceQuarterly,
         priceYearly: row.priceYearly,
+        costPrice: row.costPrice || 0,
         originalPriceMonthly: row.originalPriceMonthly,
         originalPriceQuarterly: row.originalPriceQuarterly,
         originalPriceYearly: row.originalPriceYearly,