Ver Fonte

feat(coupon): 优惠券全链路改为家庭维度

- 新建 family_coupon / family_coupon_grant_log 表(迁移269 + schema.sql 同步)
- 6条发放路径全部改为发到家庭(Admin/JOIN/POPULATION/PERIODIC/EXCHANGE/CF_EXCHANGE)
- CouponService 内部改为读 family_coupon,保留 API 路径与响应字段不变
- AdminCouponController 新增 /issue-family,/issue 改为 familyIds
- 家庭管理页新增发放优惠券弹窗,批量发放与发券记录改为家庭维度
E2E Test Bot há 2 semanas atrás
pai
commit
de95d89558

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

@@ -10067,5 +10067,43 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (SQLException e) {
             log.warn("注册报告类型 gut_flora 失败:{}", e.getMessage());
         }
+
+        // 迁移269: 创建 family_coupon 表 + family_coupon_grant_log 表
+        // (优惠券全链路改为家庭维度:2026-08-31)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
+                    "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
+                    "status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED', " +
+                    "received_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "used_at DATETIME, " +
+                    "order_id BIGINT COMMENT '核销时写入的订单号', " +
+                    "INDEX idx_family_coupon (family_id, coupon_id), " +
+                    "INDEX idx_status (status), " +
+                    "INDEX idx_order_id (order_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表'");
+            log.info("已创建 family_coupon 表");
+        } catch (Exception e) {
+            log.warn("family_coupon 表已存在,跳过创建: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_coupon_grant_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '受赠家庭ID', " +
+                    "coupon_id BIGINT NOT NULL COMMENT '券模板ID', " +
+                    "grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE', " +
+                    "period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用', " +
+                    "quantity INT DEFAULT 1 COMMENT '发放数量', " +
+                    "source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period), " +
+                    "INDEX idx_coupon (coupon_id), " +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表'");
+            log.info("已创建 family_coupon_grant_log 表");
+        } catch (Exception e) {
+            log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
+        }
     }
 }

+ 39 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 
@@ -29,6 +31,9 @@ public class AdminCouponController {
     @Resource
     private CouponGrantLogMapper couponGrantLogMapper;
 
+    @Resource
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
@@ -69,16 +74,36 @@ public class AdminCouponController {
             return Result.error("无权限");
         }
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        Object userIdsObj = params.get("userIds");
-        if (userIdsObj instanceof List) {
-            List<?> userIds = (List<?>) userIdsObj;
-            for (Object uid : userIds) {
-                couponService.issueToUser(couponId, Long.valueOf(uid.toString()));
+        Object familyIdsObj = params.get("familyIds");
+        if (familyIdsObj instanceof List) {
+            List<?> familyIds = (List<?>) familyIdsObj;
+            for (Object fid : familyIds) {
+                couponService.issueToFamily(couponId, Long.valueOf(fid.toString()));
             }
         }
         return Result.success("发放成功");
     }
 
+    /** 单家庭发放(家庭管理页使用) */
+    @PostMapping("/issue-family")
+    public Result<String> issueFamily(@RequestBody Map<String, Object> params,
+                                      @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        Long couponId = ParamUtils.getLong(params.get("couponId"));
+        Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
+        if (familyId == null || couponId == null) {
+            return Result.error("参数错误");
+        }
+        if (quantity == null || quantity < 1) quantity = 1;
+        for (int i = 0; i < quantity; i++) {
+            couponService.issueToFamily(couponId, familyId);
+        }
+        return Result.success("发放成功");
+    }
+
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("role") String role) {
@@ -111,21 +136,21 @@ public class AdminCouponController {
     }
 
     @PostMapping("/grant-log")
-    public Result<List<CouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
-                                                 @RequestAttribute("role") String role) {
+    public Result<List<FamilyCouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
+                                                       @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
             return Result.error("无权限");
         }
-        Long userId = ParamUtils.getLong(params.get("userId"));
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        LambdaQueryWrapper<CouponGrantLog> wrapper = new LambdaQueryWrapper<>();
-        if (userId != null) {
-            wrapper.eq(CouponGrantLog::getUserId, userId);
+        LambdaQueryWrapper<FamilyCouponGrantLog> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(FamilyCouponGrantLog::getFamilyId, familyId);
         }
         if (couponId != null) {
-            wrapper.eq(CouponGrantLog::getCouponId, couponId);
+            wrapper.eq(FamilyCouponGrantLog::getCouponId, couponId);
         }
-        wrapper.orderByDesc(CouponGrantLog::getCreatedAt);
-        return Result.success(couponGrantLogMapper.selectList(wrapper));
+        wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
+        return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
 }

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCoupon.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_coupon")
+public class FamilyCoupon implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** 状态: AVAILABLE / USED */
+    private String status;
+
+    private Date receivedAt;
+
+    private Date usedAt;
+
+    private Long orderId;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_coupon_grant_log")
+public class FamilyCouponGrantLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE */
+    private String grantType;
+
+    /** 周期标识(YYYY-MM或YYYY-Qn), PERIODIC防重用 */
+    private String period;
+
+    private Integer quantity;
+
+    /** 触发来源(订单号/成员ID等) */
+    private String source;
+
+    private Date createdAt;
+}

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

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

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

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

+ 167 - 105
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -2,11 +2,13 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
-import com.etotem.cfc.entity.UserCoupon;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CouponMapper;
-import com.etotem.cfc.mapper.UserCouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -29,26 +31,79 @@ public class CouponService {
     private CouponMapper couponMapper;
 
     @Resource
-    private UserCouponMapper userCouponMapper;
+    private FamilyCouponMapper familyCouponMapper;
 
     @Resource
-    private CouponGrantLogMapper couponGrantLogMapper;
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    @Resource
+    private UserMapper userMapper;
 
     @Resource
     @Lazy
     private MembershipService membershipService;
 
+    // ----------------------------------------------------------------
+    // 内部辅助方法
+    // ----------------------------------------------------------------
+
+    /** userId → familyId(用于查询当前用户所在家庭) */
+    private Long getFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return null;
+        }
+        return user.getFamilyId();
+    }
+
+    /** 构建与 CouponService.listMyCoupons 相同格式的响应 Map */
+    private Map<String, Object> toCouponVO(FamilyCoupon fc) {
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
+        if (coupon == null) return null;
+        Date now = new Date();
+        String status = fc.getStatus();
+        if ("AVAILABLE".equals(status) && !"ACTIVE".equals(coupon.getStatus())) {
+            status = "DISABLED";
+        } else if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
+            status = "EXPIRED";
+        }
+        Map<String, Object> item = new HashMap<>();
+        // 对外字段名保持 userCouponId / id 等原有契约,前端不感知内部实现变化
+        item.put("userCouponId", fc.getId());
+        item.put("id", fc.getId());
+        item.put("couponId", coupon.getId());
+        item.put("name", coupon.getName());
+        item.put("type", coupon.getType());
+        item.put("value", coupon.getValue());
+        item.put("discountRate", coupon.getDiscountRate());
+        item.put("minSpend", coupon.getMinSpend());
+        item.put("applicableTo", coupon.getApplicableTo());
+        item.put("validFrom", coupon.getValidFrom());
+        item.put("validUntil", coupon.getValidUntil());
+        item.put("status", status);
+        item.put("receivedAt", fc.getReceivedAt());
+        item.put("usedAt", fc.getUsedAt());
+        item.put("orderId", fc.getOrderId());
+        return item;
+    }
+
+    // ----------------------------------------------------------------
+    // 对外保留接口(签名不变,内部改为读 family_coupon)
+    // ----------------------------------------------------------------
+
     public List<Coupon> listAvailable(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
         List<Long> couponIds = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            couponIds.add(uc.getCouponId());
+        for (FamilyCoupon fc : familyCoupons) {
+            couponIds.add(fc.getCouponId());
         }
         Date now = new Date();
         return couponMapper.selectList(
@@ -62,6 +117,8 @@ public class CouponService {
 
     @Transactional
     public String claim(Long userId, Long couponId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return "未加入家庭";
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return "优惠券不存在";
@@ -80,20 +137,15 @@ public class CouponService {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
             return "优惠券已过期";
         }
-        // 非会员也可领取会员券,但在使用时会校验会员身份
-        Long count = userCouponMapper.selectCount(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getCouponId, couponId));
+        // 去重:同一个家庭同一张券只发一次(同 grantType 的幂等也通过 grant 方法保证)
+        Long count = familyCouponMapper.selectCount(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getCouponId, couponId));
         if (count != null && count > 0) {
             return "已领取过该优惠券";
         }
-        UserCoupon uc = new UserCoupon();
-        uc.setUserId(userId);
-        uc.setCouponId(couponId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+        issueToFamily(couponId, familyId);
         coupon.setUsedCount(coupon.getUsedCount() == null ? 1 : coupon.getUsedCount() + 1);
         couponMapper.updateById(coupon);
         return "领取成功";
@@ -109,11 +161,13 @@ public class CouponService {
     }
 
     public Integer apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc == null || !uc.getUserId().equals(userId) || !"AVAILABLE".equals(uc.getStatus())) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return null;
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc == null || !fc.getFamilyId().equals(familyId) || !"AVAILABLE".equals(fc.getStatus())) {
             return null;
         }
-        Coupon coupon = couponMapper.selectById(uc.getCouponId());
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
         if (coupon == null) {
             return null;
         }
@@ -149,23 +203,23 @@ public class CouponService {
 
     @Transactional
     public void markUsed(Long userCouponId, Long orderId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("USED");
-            uc.setUsedAt(new Date());
-            uc.setOrderId(orderId);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("USED");
+            fc.setUsedAt(new Date());
+            fc.setOrderId(orderId);
+            familyCouponMapper.updateById(fc);
         }
     }
 
     @Transactional
     public void revert(Long userCouponId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("AVAILABLE");
-            uc.setUsedAt(null);
-            uc.setOrderId(null);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("AVAILABLE");
+            fc.setUsedAt(null);
+            fc.setOrderId(null);
+            familyCouponMapper.updateById(fc);
         }
     }
 
@@ -177,22 +231,23 @@ public class CouponService {
         return couponMapper.selectById(couponId);
     }
 
+    /** 单条发放到家庭(替代旧 issueToUser) */
     @Transactional
-    public void issueToUser(Long couponId, Long userId) {
-        UserCoupon uc = new UserCoupon();
-        uc.setCouponId(couponId);
-        uc.setUserId(userId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+    public void issueToFamily(Long couponId, Long familyId) {
+        FamilyCoupon fc = new FamilyCoupon();
+        fc.setCouponId(couponId);
+        fc.setFamilyId(familyId);
+        fc.setStatus("AVAILABLE");
+        fc.setReceivedAt(new Date());
+        familyCouponMapper.insert(fc);
     }
 
     /**
-     * 发放优惠券并落流水(幂等)。
+     * 发放优惠券并落流水(幂等,家庭维度)。
      * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
      */
     @Transactional
-    public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
+    public boolean grantFamily(Long familyId, Long couponId, String grantType, String period, String source) {
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return false;
@@ -201,12 +256,12 @@ public class CouponService {
             return false;
         }
         if (period != null) {
-            Long exists = couponGrantLogMapper.selectCount(
-                    new LambdaQueryWrapper<CouponGrantLog>()
-                            .eq(CouponGrantLog::getUserId, userId)
-                            .eq(CouponGrantLog::getCouponId, couponId)
-                            .eq(CouponGrantLog::getGrantType, grantType)
-                            .eq(CouponGrantLog::getPeriod, period));
+            Long exists = familyCouponGrantLogMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyCouponGrantLog>()
+                            .eq(FamilyCouponGrantLog::getFamilyId, familyId)
+                            .eq(FamilyCouponGrantLog::getCouponId, couponId)
+                            .eq(FamilyCouponGrantLog::getGrantType, grantType)
+                            .eq(FamilyCouponGrantLog::getPeriod, period));
             if (exists != null && exists > 0) {
                 return false;
             }
@@ -214,46 +269,64 @@ public class CouponService {
         int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
                 ? coupon.getGrantQuantity() : 1;
         for (int i = 0; i < quantity; i++) {
-            issueToUser(couponId, userId);
+            issueToFamily(couponId, familyId);
         }
-        CouponGrantLog log = new CouponGrantLog();
-        log.setUserId(userId);
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(familyId);
         log.setCouponId(couponId);
         log.setGrantType(grantType);
         log.setPeriod(period);
         log.setQuantity(quantity);
         log.setSource(source);
-        couponGrantLogMapper.insert(log);
+        familyCouponGrantLogMapper.insert(log);
         return true;
     }
 
     /** JOIN: 开通/续费指定等级会员时发放 */
     @Transactional
-    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+    public void grantFamilyJoinCoupons(Long familyId, String levelCode, String source) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getGrantType, "JOIN")
                         .eq(Coupon::getGrantLevelCode, levelCode)
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         if (coupons.isEmpty()) {
-            logger.warn("未找到 JOIN 赠券模板: userId={}, levelCode={}", userId, levelCode);
+            logger.warn("未找到 JOIN 赠券模板: familyId={}, levelCode={}", familyId, levelCode);
             return;
         }
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "JOIN", "JOIN", source);
+            grantFamily(familyId, c.getId(), "JOIN", "JOIN", source);
+        }
+        logger.info("发放 JOIN 赠券完成: familyId={}, levelCode={}, count={}", familyId, levelCode, coupons.size());
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyJoinCoupons(familyId, levelCode, source);
         }
-        logger.info("发放 JOIN 赠券完成: userId={}, levelCode={}, count={}", userId, levelCode, coupons.size());
     }
 
-    /** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
+    /** POPULATION: 新增家庭成员时发放 */
     @Transactional
-    public void grantPopulationCoupons(Long userId, Long memberId) {
+    public void grantFamilyPopulationCoupons(Long familyId, Long memberId) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getGrantType, "POPULATION")
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
+            grantFamily(familyId, c.getId(), "POPULATION", null, "member:" + memberId);
+        }
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantPopulationCoupons(Long userId, Long memberId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyPopulationCoupons(familyId, memberId);
         }
     }
 
@@ -291,16 +364,18 @@ public class CouponService {
     }
 
     /**
-     * 查询用户尚未拥有的可兑换优惠券(用于结算页直接兑换)
+     * 查询家庭尚未拥有的可兑换优惠券(用于结算页直接兑换)
      * @param productId 商品ID,非空时只返回该商品绑定的券
      */
     public List<Coupon> listUnownedExchangeable(Long userId, Long productId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
         Date now = new Date();
-        List<Long> ownedIds = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"))
-                .stream().map(UserCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
+        List<Long> ownedIds = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"))
+                .stream().map(FamilyCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
         LambdaQueryWrapper<Coupon> wrapper = new LambdaQueryWrapper<Coupon>()
                 .gt(Coupon::getPointsPrice, 0)
                 .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
@@ -322,45 +397,32 @@ public class CouponService {
                         .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
     }
 
-    /** 我的优惠券:返回 user_coupon + coupon 联合数据,含 status/receivedAt/usedAt */
+    /**
+     * 我的优惠券:返回 family_coupon + coupon 联合数据,含 status/receivedAt/usedAt
+     * 对外字段名保持不变(userCouponId = family_coupon.id),前端不感知内部变化
+     */
     public List<Map<String, Object>> listMyCoupons(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .orderByDesc(UserCoupon::getReceivedAt));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .orderByDesc(FamilyCoupon::getReceivedAt));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
-        Date now = new Date();
         List<Map<String, Object>> result = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            Coupon coupon = couponMapper.selectById(uc.getCouponId());
-            if (coupon == null) continue;
-            // 过期判断
-            String status = uc.getStatus();
-            if ("AVAILABLE".equals(status) && !"ACTIVE".equals(coupon.getStatus())) {
-                status = "DISABLED";
-            } else if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
-                status = "EXPIRED";
-            }
-            Map<String, Object> item = new HashMap<>();
-            item.put("userCouponId", uc.getId());
-            item.put("id", uc.getId());
-            item.put("couponId", coupon.getId());
-            item.put("name", coupon.getName());
-            item.put("type", coupon.getType());
-            item.put("value", coupon.getValue());
-            item.put("discountRate", coupon.getDiscountRate());
-            item.put("minSpend", coupon.getMinSpend());
-            item.put("applicableTo", coupon.getApplicableTo());
-            item.put("validFrom", coupon.getValidFrom());
-            item.put("validUntil", coupon.getValidUntil());
-            item.put("status", status);
-            item.put("receivedAt", uc.getReceivedAt());
-            item.put("usedAt", uc.getUsedAt());
-            item.put("orderId", uc.getOrderId());
-            result.add(item);
+        for (FamilyCoupon fc : familyCoupons) {
+            Map<String, Object> vo = toCouponVO(fc);
+            if (vo != null) result.add(vo);
         }
         return result;
     }
+
+    /**
+     * 查询指定会员等级的活跃家庭 ID 列表(供 CouponGrantTask 周期补发)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        return membershipService.listActiveMemberFamilyIds(levelCode);
+    }
 }

+ 2 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -153,15 +153,9 @@ public class FamilyMemberService {
         // 为新成员创建与所有现有成员的双向关系
         familyRelationshipService.createRelationsForNewMember(familyId, member.getId(), genLevel.getOffset());
 
-        // 发放家庭人口券(POPULATION):有账号发到成员自己,无账号发到家庭创建者
+        // 发放家庭人口券(POPULATION):发到家庭维度
         try {
-            Long targetUserId = member.getUserId();
-            if (targetUserId == null) {
-                targetUserId = user.getFamilyId() != null
-                        ? familyMapper.selectById(user.getFamilyId()).getCreatorId()
-                        : userId;
-            }
-            couponService.grantPopulationCoupons(targetUserId, member.getId());
+            couponService.grantFamilyPopulationCoupons(familyId, member.getId());
         } catch (Exception e) {
             log.error("发放家庭人口券异常: memberId={}", member.getId(), e);
         }

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -267,8 +267,8 @@ public class FamilyPlatformPointsService {
         spend(familyId, pointsPrice, "coupon_exchange", couponId,
               "兑换优惠券: " + (coupon.getName() != null ? coupon.getName() : "优惠券"));
 
-        // 5. 发券给操作用户
-        couponService.grant(refUserId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
+        // 5. 发券给家庭
+        couponService.grantFamily(familyId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
 
         // 6. 落兑换记录
         FamilyPlatformExchangeRecord record = new FamilyPlatformExchangeRecord();

+ 21 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -775,7 +775,7 @@ public class MembershipService implements MembershipServiceInterface {
 
             // 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
             try {
-                couponService.grantJoinCoupons(adminUserId, order.getLevelCode(), order.getOrderNo());
+                couponService.grantFamilyJoinCoupons(order.getFamilyId(), order.getLevelCode(), order.getOrderNo());
             } catch (Exception e) {
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
             }
@@ -995,6 +995,25 @@ public class MembershipService implements MembershipServiceInterface {
         return userIds.stream().distinct().collect(Collectors.toList());
     }
 
+    /**
+     * 查询指定会员等级当前有效的家庭 ID 列表(PERIODIC 发券用,按家庭维度)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        Date now = new Date();
+        List<FamilyMembership> memberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getLevelCode, levelCode)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, now));
+        List<Long> familyIds = new ArrayList<>();
+        for (FamilyMembership m : memberships) {
+            if (m.getFamilyId() != null) {
+                familyIds.add(m.getFamilyId());
+            }
+        }
+        return familyIds.stream().distinct().collect(Collectors.toList());
+    }
+
     /**
      * 应用会员折扣,返回折后价格(分)
      */
@@ -1095,7 +1114,7 @@ public class MembershipService implements MembershipServiceInterface {
         memberUpgradeRecordMapper.insert(record);
         // 发放JOIN会员券(赠送会员同样享有加入赠券)
         try {
-            couponService.grantJoinCoupons(adminUserId, levelCode, sourceOrderNo);
+            couponService.grantFamilyJoinCoupons(familyId, levelCode, sourceOrderNo);
         } catch (Exception e) {
             log.error("发放JOIN会员券异常(赠送): userId={}, levelCode={}", adminUserId, levelCode, e);
         }

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java

@@ -275,8 +275,12 @@ public class PointsExchangeService {
             throw new RuntimeException("积分不足");
         }
 
-        // 发券 + 流水(EXCHANGE,period=null)
-        couponService.grant(userId, couponId, "EXCHANGE", null, null);
+        // 发券 + 流水(EXCHANGE,period=null)— 发到用户所在家庭
+        Long targetFamilyId = null;
+        FamilyMember targetMember = familyMemberMapper.selectById(targetFamilyMemberId);
+        if (targetMember != null) targetFamilyId = targetMember.getFamilyId();
+        if (targetFamilyId == null) throw new RuntimeException("未找到目标成员所在家庭");
+        couponService.grantFamily(targetFamilyId, couponId, "EXCHANGE", null, null);
 
         String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
         PointsExchangeRecord record = new PointsExchangeRecord();

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/task/CouponGrantTask.java

@@ -42,14 +42,14 @@ public class CouponGrantTask {
 
             for (Coupon c : templates) {
                 String periodKey = "QUARTERLY".equals(c.getGrantPeriod()) ? quarterKey : monthKey;
-                List<Long> userIds = membershipService.listActiveMemberUserIds(c.getGrantLevelCode());
+                List<Long> familyIds = membershipService.listActiveMemberFamilyIds(c.getGrantLevelCode());
                 int granted = 0;
-                for (Long uid : userIds) {
-                    if (couponService.grant(uid, c.getId(), "PERIODIC", periodKey, null)) {
+                for (Long familyId : familyIds) {
+                    if (couponService.grantFamily(familyId, c.getId(), "PERIODIC", periodKey, null)) {
                         granted++;
                     }
                 }
-                log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放用户数={}",
+                log.info("PERIODIC券补发: couponId={}, level={}, period={}, 发放家庭数={}",
                         c.getId(), c.getGrantLevelCode(), periodKey, granted);
             }
             log.info("周期性会员券补发任务执行完成");

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

@@ -4365,6 +4365,38 @@ CREATE TABLE IF NOT EXISTS coupon_grant_log (
     INDEX idx_coupon (coupon_id),
     INDEX idx_user (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放流水表';
+
+-- =============================================
+-- 家庭优惠券表(全链路改为家庭维度:2026-08-31)
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS family_coupon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME,
+    order_id BIGINT COMMENT '核销时写入的订单号',
+    INDEX idx_family_coupon (family_id, coupon_id),
+    INDEX idx_status (status),
+    INDEX idx_order_id (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
+
+CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE',
+    period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用',
+    quantity INT DEFAULT 1 COMMENT '发放数量',
+    source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period),
+    INDEX idx_coupon (coupon_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
+
 -- 家庭加入请求(邀请人审批,旧数据由家庭管理员兜底)
 CREATE TABLE IF NOT EXISTS family_join_requests (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 8 - 0
cfc-web/src/api/coupon.js

@@ -20,6 +20,14 @@ export function batchIssueCoupons(data) {
   return request({ url: '/api/admin/coupon/issue', method: 'post', data })
 }
 
+export function issueFamilyCoupon(data) {
+  return request({ url: '/api/admin/coupon/issue-family', method: 'post', data })
+}
+
+export function getFamilyCouponGrantLog(data) {
+  return request({ url: '/api/admin/coupon/grant-log', method: 'post', data })
+}
+
 export function getCouponGrantLog(data) {
   return request({ url: '/api/admin/coupon/grant-log', method: 'post', data })
 }

+ 83 - 1
cfc-web/src/views/Families.vue

@@ -55,6 +55,7 @@
               </el-button>
               <el-dropdown-menu slot="dropdown">
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
+                <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
             </el-dropdown>
@@ -143,11 +144,42 @@
         <el-empty v-else description="暂无孩子" :image-size="60"></el-empty>
       </div>
     </el-dialog>
+
+    <!-- 发放优惠券弹窗 -->
+    <el-dialog
+      title="发放优惠券"
+      :visible.sync="couponDialogVisible"
+      width="500px"
+    >
+      <el-form :model="couponForm" label-width="100px">
+        <el-form-item label="家庭">
+          <el-input :value="couponForm.familyName" disabled></el-input>
+        </el-form-item>
+        <el-form-item label="优惠券" required>
+          <el-select v-model="couponForm.couponId" placeholder="请选择优惠券" style="width: 100%">
+            <el-option
+              v-for="c in couponOptions"
+              :key="c.id"
+              :label="couponLabel(c)"
+              :value="c.id"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="发放数量">
+          <el-input-number v-model="couponForm.quantity" :min="1" :max="100" :step="1"></el-input-number>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="couponDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
+import { getCouponList, issueFamilyCoupon } from '@/api/coupon'
 
 export default {
   name: 'Families',
@@ -184,7 +216,15 @@ export default {
         family: null,
         parents: [],
         children: []
-      }
+      },
+      couponDialogVisible: false,
+      couponForm: {
+        familyId: null,
+        couponId: null,
+        quantity: 1
+      },
+      couponOptions: [],
+      couponSubmitting: false
     }
   },
   mounted() {
@@ -199,6 +239,7 @@ export default {
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
+        case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
       }
@@ -300,6 +341,47 @@ export default {
         this.loading = false
       }
     },
+    async handleIssueCoupon(row) {
+      this.couponForm = {
+        familyId: row.id,
+        familyName: row.name,
+        couponId: null,
+        quantity: 1
+      }
+      this.couponDialogVisible = true
+      try {
+        const res = await getCouponList({ page: 1, size: 999 })
+        this.couponOptions = res.data.records || res.data.list || res.data || []
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      }
+    },
+    couponLabel(c) {
+      if (c.type === 'DISCOUNT') {
+        return `${c.name}(${(c.discountRate / 100).toFixed(1)}折)`
+      }
+      return `${c.name}(${(c.value / 100).toFixed(2)}元)`
+    },
+    async confirmIssueCoupon() {
+      if (!this.couponForm.couponId) {
+        this.$message.warning('请选择优惠券')
+        return
+      }
+      this.couponSubmitting = true
+      try {
+        await issueFamilyCoupon({
+          familyId: this.couponForm.familyId,
+          couponId: this.couponForm.couponId,
+          quantity: this.couponForm.quantity
+        })
+        this.$message.success('发放成功')
+        this.couponDialogVisible = false
+      } catch (error) {
+        this.$message.error(error.message || '发放失败')
+      } finally {
+        this.couponSubmitting = false
+      }
+    },
     formatDate(dateStr) {
       if (!dateStr) return '-'
       const date = new Date(dateStr)

+ 19 - 20
cfc-web/src/views/admin/CouponGrantLog.vue

@@ -4,7 +4,7 @@
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">发券记录</span>
         <div class="admin-page-actions">
-          <el-input v-model="userId" placeholder="用户ID/用户名" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-input v-model="familyId" placeholder="家庭ID" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
           <el-input v-model="couponId" placeholder="优惠券ID" clearable style="width: 140px" @keyup.enter.native="handleSearch" @clear="handleSearch" />
           <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
         </div>
@@ -13,8 +13,8 @@
       <div class="table-scroll-wrap-sm">
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
           <el-table-column prop="id" label="ID" width="80" />
-          <el-table-column label="用户" width="140">
-            <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
+          <el-table-column label="家庭" width="140">
+            <template slot-scope="{ row }">{{ row.familyName || '家庭' + row.familyId }}</template>
           </el-table-column>
           <el-table-column prop="couponId" label="优惠券ID" width="110" />
           <el-table-column label="发放类型" width="120">
@@ -46,8 +46,8 @@
 </template>
 
 <script>
-import { getCouponGrantLog } from '@/api/coupon'
-import { searchUsers } from '@/api/admin'
+import { getFamilyCouponGrantLog } from '@/api/coupon'
+import { getFamilyDetail } from '@/api/admin'
 
 export default {
   name: 'CouponGrantLog',
@@ -63,7 +63,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
-      userId: '',
+      familyId: '',
       couponId: ''
     }
   },
@@ -72,26 +72,26 @@ export default {
   },
   methods: {
     grantTypeLabel(val) {
-      const map = { JOIN: '开通赠券', PERIODIC: '周期补发', POPULATION: '家庭人口券', EXCHANGE: '积分兑换' }
+      const map = { JOIN: '开通赠券', PERIODIC: '周期补发', POPULATION: '家庭人口券', EXCHANGE: '积分兑换', CF_EXCHANGE: 'CF值兑换' }
       return map[val] || val
     },
     grantTypeTagType(val) {
-      const map = { JOIN: 'success', PERIODIC: 'primary', POPULATION: 'warning', EXCHANGE: 'danger' }
+      const map = { JOIN: 'success', PERIODIC: 'primary', POPULATION: 'warning', EXCHANGE: 'danger', CF_EXCHANGE: 'info' }
       return map[val] || 'info'
     },
     async loadData() {
       this.loading = true
       try {
-        const res = await getCouponGrantLog({
+        const res = await getFamilyCouponGrantLog({
           page: this.page,
           size: this.size,
-          userId: this.userId || undefined,
+          familyId: this.familyId || undefined,
           couponId: this.couponId || undefined
         })
         if (res.data) {
           this.list = res.data
           this.total = this.list.length
-          await this.resolveUserNames(this.list, 'userId')
+          await this.resolveFamilyNames(this.list)
         }
       } catch (e) {
         console.error(e)
@@ -99,28 +99,27 @@ export default {
         this.loading = false
       }
     },
-    async resolveUserNames(items, idField) {
+    async resolveFamilyNames(items) {
       if (!items || items.length === 0) return
       var ids = []
       items.forEach(function(item) {
-        if (item[idField] && ids.indexOf(item[idField]) === -1) ids.push(item[idField])
+        if (item.familyId && ids.indexOf(item.familyId) === -1) ids.push(item.familyId)
       })
       var nameMap = {}
       for (var i = 0; i < ids.length; i++) {
         try {
-          var res = await searchUsers({ keyword: String(ids[i]) })
-          if (res.data && res.data.length > 0) {
-            var u = res.data[0]
-            nameMap[ids[i]] = u.nickname || u.realName || '用户' + ids[i]
+          var res = await getFamilyDetail(ids[i])
+          if (res.data && res.data.name) {
+            nameMap[ids[i]] = res.data.name
           } else {
-            nameMap[ids[i]] = '用户' + ids[i]
+            nameMap[ids[i]] = '家庭' + ids[i]
           }
         } catch (e) {
-          nameMap[ids[i]] = '用户' + ids[i]
+          nameMap[ids[i]] = '家庭' + ids[i]
         }
       }
       items.forEach(function(item) {
-        item.userName = nameMap[item[idField]] || '用户' + item[idField]
+        item.familyName = nameMap[item.familyId] || '家庭' + item.familyId
       })
     },
     handleSearch() {

+ 8 - 8
cfc-web/src/views/admin/CouponManagement.vue

@@ -193,8 +193,8 @@
             <el-option v-for="c in couponOptions" :key="c.id" :label="c.name" :value="c.id" />
           </el-select>
         </el-form-item>
-        <el-form-item label="用户ID列表" required>
-          <el-input v-model="batchUserIds" type="textarea" :rows="6" placeholder="每行一个用户ID&#10;例如:&#10;1001&#10;1002&#10;1003" />
+        <el-form-item label="家庭ID列表" required>
+          <el-input v-model="batchFamilyIds" type="textarea" :rows="6" placeholder="每行一个家庭ID&#10;例如:&#10;1001&#10;1002&#10;1003" />
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -233,7 +233,7 @@ export default {
       form: this.getEmptyForm(),
       batchDialogVisible: false,
       batchCouponId: '',
-      batchUserIds: '',
+      batchFamilyIds: '',
       batchSubmitting: false,
       couponOptions: [],
       keyword: '',
@@ -396,7 +396,7 @@ export default {
     },
     async handleBatchIssue() {
       this.batchCouponId = ''
-      this.batchUserIds = ''
+      this.batchFamilyIds = ''
       this.batchDialogVisible = true
       try {
         const res = await getCouponList({ page: 1, size: 999, sort: this.sortState.length > 0 ? this.sortState : undefined })
@@ -411,15 +411,15 @@ export default {
         this.$message.warning('请选择优惠券')
         return
       }
-      const ids = this.batchUserIds.split('\n').map(s => s.trim()).filter(Boolean)
+      const ids = this.batchFamilyIds.split('\n').map(s => s.trim()).filter(Boolean)
       if (ids.length === 0) {
-        this.$message.warning('请输入至少一个用户ID')
+        this.$message.warning('请输入至少一个家庭ID')
         return
       }
       this.batchSubmitting = true
       try {
-        await batchIssueCoupons({ couponId: this.batchCouponId, userIds: ids })
-        this.$message.success(`成功向 ${ids.length} 个用户发放优惠券`)
+        await batchIssueCoupons({ couponId: this.batchCouponId, familyIds: ids })
+        this.$message.success(`成功向 ${ids.length} 个家庭发放优惠券`)
         this.batchDialogVisible = false
         this.loadData()
       } catch (e) {

+ 25 - 4
docs/superpowers/api/API_REFERENCE.md

@@ -548,12 +548,14 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 |------|------|
 | `POST /api/diet/preferences/current-member` | 当前成员饮食偏好 |
 | `POST /api/diet/preferences/save` | 保存饮食偏好 |
-| `POST /api/diet/recommendation/today` | 今日推荐 |
-| `POST /api/diet/recommendation/generate` | 生成推荐 |
+| `POST /api/diet/recommendation/today` | 今日推荐(返回 `data.id / data.menu / data.nutritionSummary / data.status`) |
+| `POST /api/diet/recommendation/generate` | 生成推荐(body: `date, meal_type, selected_foods JSON string`;返回 `data.id / data.menu / data.nutritionSummary`) |
 | `POST /api/diet/recommendation/complete` | 完成推荐 |
 | `POST /api/diet/record/save` | 保存饮食记录 |
 | `POST /api/diet/record/daily` | 每日记录 |
-| `POST /api/diet/ingredients/suggest` | 食材推荐 |
+| `POST /api/diet/ingredients/suggest` | 食材推荐(返回 `data.ingredients` 列表) |
+| `POST /api/diet/ingredients/recommend` | 换一批(返回 `data.ingredients`,每次不同) |
+| `POST /api/diet/ingredients/search` | 搜索食材(body `{keyword}`,返回 `data.foods:[{id, name, category}]`) |
 | `POST /api/diet/meals/config` | 餐食配置 |
 
 ### 4.12 内容(`/api/articles`, `/api/content/*`)
@@ -861,6 +863,25 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/butler/my-butler` | 我家当前管家绑定信息(含管家昵称/等级/分配时间) |
 | `POST /api/butler/select` | 绑定/更换管家(校验 L2 订阅、管家容量与接单状态;换绑自动解绑旧关系) |
 
+### 4.32 优惠券(家庭维度,`/api/coupon/*` + `/api/admin/coupon/*`)
+
+> 2026-08-31 起,优惠券由「用户维度」全链路改为「家庭维度」。`user_coupon` 保留历史数据,新发放与消费统一走 `family_coupon`。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/coupon/list` | 优惠券模板列表(admin) |
+| `POST /api/admin/coupon/create` | 新建优惠券模板(admin) |
+| `POST /api/admin/coupon/update` | 更新优惠券模板(admin) |
+| `POST /api/admin/coupon/issue` | 批量发放到家庭,`{ couponId, familyIds: [Long] }`(admin) |
+| `POST /api/admin/coupon/issue-family` | 单家庭发放,`{ familyId, couponId, quantity }`(admin,家庭管理页使用) |
+| `POST /api/admin/coupon/grant-log` | 家庭发券流水查询,`{ familyId?, couponId? }`(admin,返回 family_coupon_grant_log) |
+| `POST /api/admin/coupon/toggle-status` | 启用/停用优惠券模板(admin) |
+| `POST /api/coupon/list` | 当前家庭可用优惠券(小程序) |
+| `POST /api/coupon/my` | 我的(家庭)优惠券(小程序) |
+| `POST /api/coupon/apply` | 下单抵扣(家庭券) |
+| `POST /api/coupon/claim` | 领取优惠券(发到家庭) |
+| `POST /api/coupon/checkout-list` | 结算页可兑换未拥有券 |
+
 ---
 
 ## 六、待清理的废弃接口
@@ -877,4 +898,4 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
-*文档最后更新:2026-08-23*
+*文档最后更新:2026-08-31*

+ 126 - 0
docs/superpowers/plans/2026-08-31-coupon-family-based.md

@@ -0,0 +1,126 @@
+# 优惠券全链路改为家庭维度 — 实施计划
+
+**设计稿:** `specs/2026-08-31-coupon-family-based-design.md`
+**状态:** 待执行
+**预计工时:** ~2h(后端主导,前端轻量)
+
+---
+
+## Task 1 — 数据库迁移 + schema.sql(迁移 269)
+
+**文件:** `DatabaseInitializer.java`, `schema.sql`
+**验证:** `mvn clean compile`
+
+```
+// 迁移269: 创建 family_coupon + family_coupon_grant_log 表
+// (优惠券全链路改为家庭维度:2026-08-31)
+```
+
+**Schema.sql 末尾追加**(在 `coupon_grant_log` CREATE 语句之后):
+```sql
+CREATE TABLE IF NOT EXISTS family_coupon (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED',
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME,
+    order_id BIGINT COMMENT '核销时写入的订单号',
+    INDEX idx_family_coupon (family_id, coupon_id),
+    INDEX idx_status (status),
+    INDEX idx_order_id (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券表';
+
+CREATE TABLE IF NOT EXISTS family_coupon_grant_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '受赠家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID',
+    grant_type VARCHAR(16) NOT NULL COMMENT 'JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE',
+    period VARCHAR(16) COMMENT '周期标识(YYYY-MM或YYYY-Qn),PERIODIC防重用',
+    quantity INT DEFAULT 1 COMMENT '发放数量',
+    source VARCHAR(64) COMMENT '触发来源(订单号/成员ID等)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_family_grant (family_id, coupon_id, grant_type, period),
+    INDEX idx_coupon (coupon_id),
+    INDEX idx_family (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭优惠券发放流水表';
+```
+
+---
+
+## Task 2 — Entity + Mapper(4个新文件)
+
+| 文件 | 包 | 说明 |
+|------|----|------|
+| `entity/FamilyCoupon.java` | `com.etotem.cfc.entity` | `@TableName("family_coupon")` |
+| `entity/FamilyCouponGrantLog.java` | `com.etotem.cfc.entity` | `@TableName("family_coupon_grant_log")` |
+| `mapper/FamilyCouponMapper.java` | `com.etotem.cfc.mapper` | 继承 `BaseMapper<FamilyCoupon>`,加 `@Mapper` |
+| `mapper/FamilyCouponGrantLogMapper.java` | `com.etotem.cfc.mapper` | 同上 |
+
+---
+
+## Task 3 — CouponService 重构(核心)
+
+**文件:** `service/CouponService.java`
+
+**改动要点:**
+1. 注入 `FamilyCouponMapper`, `FamilyCouponGrantLogMapper`, `UserMapper`
+2. 保留所有公开方法签名不变
+3. 内部实现全部替换为读 `family_coupon`
+4. 新增私有方法 `getFamilyId(Long userId)` 做 userId→familyId 转换
+5. 新增 public 方法(供外部调用方用):
+   - `grantFamily(Long familyId, Long couponId, String grantType, String period, String source)`
+   - `grantFamilyJoinCoupons(Long familyId, String levelCode, String source)`
+   - `grantFamilyPopulationCoupons(Long familyId, Long memberId)`
+   - `listActiveMemberFamilyIds(String levelCode)`
+
+**不删的旧方法**(保留供降级兼容,但停止使用):
+- `issueToUser` — 标记 `@Deprecated`,不动实现
+
+---
+
+## Task 4 — 6 条发放路径改写
+
+| # | 文件 | 改动 |
+|---|------|------|
+| 1 | `controller/admin/AdminCouponController.java` | `/issue` 参数改为 `familyIds`;新增 `/issue-family` |
+| 2 | `service/MembershipService.java` | `grantJoinCoupons(adminUserId,...)` → `grantFamilyJoinCoupons(order.getFamilyId(),...)` |
+| 3 | `service/FamilyMemberService.java` | `grantPopulationCoupons(targetUserId,...)` → `grantFamilyPopulationCoupons(familyId, member.getId())` |
+| 4 | `task/CouponGrantTask.java` | 改为调用 `listActiveMemberFamilyIds` + `grantFamily` |
+| 5 | `service/PointsExchangeService.java` | 取 `familyMemberMapper.selectOne(userId).getFamilyId()`,调 `grantFamily` |
+| 6 | `service/FamilyPlatformPointsService.java` | 直接调 `grantFamily(familyId, ...)` |
+
+---
+
+## Task 5 — CouponController(小程序端,内部改实现)
+
+**文件:** `controller/CouponController.java`
+
+不改路由、不改响应字段。内部 `userId` 先解析 `userMapper.selectById(userId).getFamilyId()`,再调 CouponService 新方法。
+`apply` 方法中增加归属校验。
+
+---
+
+## Task 6 — 管理端前端(cfc-web)
+
+| 文件 | 改动 |
+|------|------|
+| `api/coupon.js` | 新增 `issueFamilyCoupon`、`getFamilyCouponGrantLog` |
+| `views/Families.vue` | 下拉菜单加「发放优惠券」按钮 + 弹窗 |
+| `views/admin/CouponManagement.vue` | 批量发放弹窗改为家庭ID列表 |
+| `views/admin/CouponGrantLog.vue` | 搜索框加「家庭ID」;列表显示家庭名 |
+
+---
+
+## Task 7 — API_REFERENCE.md 更新
+
+追加 `/api/admin/coupon/issue-family` 和 `/api/coupon/*` 的家庭维度说明。
+
+---
+
+## Task 8 — 编译验证 + 提交
+
+```bash
+cd cfc-backend && mvn clean compile
+git add -A && git commit && git push
+```