Browse Source

feat(coupon): 会员券转赠功能(赠送/撤回/通知/管理端记录)

- 新增 coupon_gift_transfer 表与 family_coupon.is_gift 列(迁移428,同步 schema.sql)
- CouponGiftService/Controller:可赠券列表、转赠给推荐下级(直推+隔级)、撤回、赠送/接收记录
- 转赠采用转移券实例方式,接收方非会员也能用该券购买会员(apply 豁免会员校验);核销后记录自动置为已使用
- 转赠成功向接收人发送 coupon_gift 站内通知(NotificationDispatchService,失败不影响主流程)
- AdminCouponController 新增 /api/admin/coupon/gift-transfers 分页查询
- 小程序:赠送页 gift-coupon.vue + 5 个 api + coupons.vue 入口 + pay.vue 兼容 isGift 券
- 管理端:CouponGiftTransfers.vue 记录页 + 路由 + 菜单项
E2E Test Bot 1 week ago
parent
commit
278dd23e0b

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

@@ -3376,6 +3376,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移427: 创建 content_dimension_threshold 表(A-后端 内容维度阈值显隐配置:商品/文章/活动/游戏 五维+子项+自定义指标)
         createContentDimensionThresholdTable();
+        // 迁移428: 会员优惠券转赠表 coupon_gift_transfer + family_coupon.is_gift 列
+        // (用户可把自己的会员券转赠给推荐下级,用于购买会员,可撤回)
+        createCouponGiftTransferTable();
     }
 
     /**
@@ -14373,7 +14376,55 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    
+    // ==================== 迁移428:会员优惠券转赠 ====================
+
+    /**
+     * 迁移428: 会员优惠券转赠。
+     * <p>- 新建 coupon_gift_transfer 表(记录券实例从赠送方家庭转移到接收方家庭的过程,支持撤回)。
+     * - family_coupon 增加 is_gift 列(1=来自转赠,接收方可被非会员用于购买会员)。</p>
+     * 幂等:建表用 IF NOT EXISTS,加列前检查 information_schema。
+     */
+    void createCouponGiftTransferTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `coupon_gift_transfer` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`giver_user_id` BIGINT NOT NULL COMMENT '赠送人用户ID(users.id)', "
+                    + "`giver_family_id` BIGINT NOT NULL COMMENT '赠送人家庭ID', "
+                    + "`receiver_user_id` BIGINT NOT NULL COMMENT '接收人用户ID(users.id)', "
+                    + "`receiver_family_id` BIGINT NOT NULL COMMENT '接收人家庭ID', "
+                    + "`coupon_id` BIGINT NOT NULL COMMENT '券模板ID(coupon.id)', "
+                    + "`family_coupon_id` BIGINT NOT NULL COMMENT '被转移的 family_coupon 实例ID', "
+                    + "`gift_level` INT NOT NULL COMMENT '推荐层级 1=直推 2=隔级', "
+                    + "`status` VARCHAR(16) NOT NULL DEFAULT 'GIFTED' COMMENT 'GIFTED/USED/REVOKED', "
+                    + "`remark` VARCHAR(255) NULL COMMENT '赠送留言', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`used_at` DATETIME NULL, "
+                    + "`revoked_at` DATETIME NULL, "
+                    + "INDEX `idx_receiver` (`receiver_family_id`, `status`), "
+                    + "INDEX `idx_giver` (`giver_user_id`), "
+                    + "INDEX `idx_family_coupon` (`family_coupon_id`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员优惠券转赠记录'");
+            log.info("迁移428: 已创建 coupon_gift_transfer 表");
+        } catch (Exception e) {
+            log.warn("迁移428: 创建 coupon_gift_transfer 表失败(可能已存在): {}", e.getMessage());
+        }
+        // family_coupon.is_gift 列(1=来自转赠)
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'family_coupon' AND COLUMN_NAME = 'is_gift'",
+                    Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE family_coupon ADD COLUMN is_gift INT DEFAULT 0 COMMENT '1=该券实例来自他人转赠'");
+                log.info("迁移428: 已为 family_coupon 增加 is_gift 列");
+            } else {
+                log.info("迁移428: family_coupon.is_gift 列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("迁移428: 检查/添加 family_coupon.is_gift 列失败: {}", e.getMessage());
+        }
+    }
+
+
 
     void createPpointConsumptionLogTable() {
         try {

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CouponGiftController.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.CouponGiftService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 会员优惠券转赠(C 端)。
+ * 仅支持把自己的会员专属券(applicableTo=MEMBERSHIP)转赠给推荐下级(直推/隔级),用于购买会员。
+ */
+@RestController
+@RequestMapping("/api/coupon/gift")
+public class CouponGiftController {
+
+    @Resource
+    private CouponGiftService couponGiftService;
+
+    /** 可接收转赠的推荐下级列表(直推 + 隔级) */
+    @PostMapping("/recipients")
+    public Result<List<Map<String, Object>>> recipients(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponGiftService.listRecipients(userId));
+    }
+
+    /** 自己可赠送的会员券列表 */
+    @PostMapping("/my-coupons")
+    public Result<List<Map<String, Object>>> myCoupons(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponGiftService.listGiftableCoupons(userId));
+    }
+
+    /** 转赠:将指定券实例送给指定推荐下级 */
+    @PostMapping("/transfer")
+    public Result<Map<String, Object>> transfer(@RequestAttribute("userId") Long userId,
+                                               @RequestBody Map<String, Object> params) {
+        Long receiverUserId = ParamUtils.getLong(params.get("receiverUserId"));
+        Long familyCouponId = ParamUtils.getLong(params.get("familyCouponId"));
+        String remark = params.get("remark") != null ? String.valueOf(params.get("remark")) : null;
+        try {
+            Long transferId = couponGiftService.transfer(userId, receiverUserId, familyCouponId, remark);
+            Map<String, Object> data = new java.util.HashMap<>();
+            data.put("transferId", transferId);
+            data.put("message", "转赠成功");
+            return Result.success(data);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /** 撤回一笔尚未被使用的转赠 */
+    @PostMapping("/revoke")
+    public Result<Map<String, Object>> revoke(@RequestAttribute("userId") Long userId,
+                                             @RequestBody Map<String, Object> params) {
+        Long transferId = ParamUtils.getLong(params.get("transferId"));
+        try {
+            couponGiftService.revoke(transferId, userId);
+            return Result.success(java.util.Collections.singletonMap("message", "已撤回"));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /** 我发出的转赠记录 */
+    @PostMapping("/sent")
+    public Result<List<Map<String, Object>>> sent(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponGiftService.listSent(userId));
+    }
+
+    /** 我收到的转赠券(可选,便于前端提示来源) */
+    @PostMapping("/received")
+    public Result<List<Map<String, Object>>> received(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponGiftService.listReceived(userId));
+    }
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.entity.CouponGrantLog;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
+import com.etotem.cfc.service.CouponGiftService;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.mapper.FamilyCouponMapper;
@@ -44,6 +45,9 @@ public class AdminCouponController {
     @Resource
     private FamilyCouponMapper familyCouponMapper;
 
+    @Resource
+    private CouponGiftService couponGiftService;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestBody(required = false) Map<String, Object> params,
                                      @RequestAttribute("role") String role) {
@@ -218,6 +222,22 @@ public class AdminCouponController {
         return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
 
+    /** 会员券转赠记录(分页,支持按赠送方/接收方家庭、状态、券模板过滤) */
+    @PostMapping("/gift-transfers")
+    public Result<Page<Map<String, Object>>> giftTransfers(@RequestBody Map<String, Object> params,
+                                                          @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long giverFamilyId = ParamUtils.getLong(params.get("giverFamilyId"));
+        Long receiverFamilyId = ParamUtils.getLong(params.get("receiverFamilyId"));
+        String status = params.get("status") == null ? null : params.get("status").toString();
+        Long couponId = ParamUtils.getLong(params.get("couponId"));
+        int page = params.get("page") == null ? 1 : Integer.parseInt(params.get("page").toString());
+        int size = params.get("size") == null ? 20 : Integer.parseInt(params.get("size").toString());
+        return Result.success(couponGiftService.adminList(giverFamilyId, receiverFamilyId, status, couponId, page, size));
+    }
+
     /** 家庭券列表(family_coupon 分页,JOIN coupon 取模板信息) */
     @PostMapping("/family-coupons")
     public Result<Page<Map<String, Object>>> familyCoupons(@RequestBody Map<String, Object> params,

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CouponGiftTransfer.java

@@ -0,0 +1,58 @@
+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;
+
+/**
+ * 会员优惠券转赠记录。
+ *
+ * <p>用户可把自己家庭券包中 {@code applicableTo=MEMBERSHIP} 的可用券,
+ * 转赠给自己的推荐下级(直推 level=1 / 隔级 level=2)。转赠采用「转移已有券实例」方式:
+ * {@link FamilyCoupon#getFamilyId()} 从赠送方家庭改为接收方家庭,并打 {@code is_gift=1} 标记,
+ * 接收方即可像普通券一样用于购买会员。赠送方在接收方使用前可撤回(券实例回退回赠送方)。</p>
+ */
+@Data
+@TableName("coupon_gift_transfer")
+public class CouponGiftTransfer implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 赠送人用户ID (users.id) */
+    private Long giverUserId;
+
+    /** 赠送人家庭ID */
+    private Long giverFamilyId;
+
+    /** 接收人用户ID (users.id) */
+    private Long receiverUserId;
+
+    /** 接收人家庭ID(券实例最终归属的家庭) */
+    private Long receiverFamilyId;
+
+    /** 券模板ID (coupon.id) */
+    private Long couponId;
+
+    /** 被转移的 family_coupon 实例ID */
+    private Long familyCouponId;
+
+    /** 推荐层级 1=直推 2=隔级 */
+    private Integer giftLevel;
+
+    /** GIFTED=已赠送(可撤回) / USED=已被接收方使用 / REVOKED=已撤回 */
+    private String status;
+
+    /** 赠送留言 */
+    private String remark;
+
+    private Date createdAt;
+
+    private Date usedAt;
+
+    private Date revokedAt;
+}

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

@@ -27,4 +27,7 @@ public class FamilyCoupon implements Serializable {
     private Date usedAt;
 
     private Long orderId;
+
+    /** 1=该券实例来自他人转赠(接收后可被非会员用于购买会员,apply 时豁免会员校验) */
+    private Integer isGift;
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserNotification.java

@@ -12,6 +12,7 @@ import java.util.Date;
  * 站内通知(改版v1,用户级主动通知)
  * type: report_ready=报告完成 / reward_granted=奖励到账 / logistics=物流变化 / system=系统
  *       energy_change=能量变动 / points_change=积分变动 / cf_change=CF值变动
+ *       coupon_gift=会员券转赠到账
  * 与 notices(平台公告) 并存:本表按 user_id 定向推送
  */
 @Data

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

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

+ 436 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CouponGiftService.java

@@ -0,0 +1,436 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.CouponGiftTransfer;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CouponGiftTransferMapper;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 会员优惠券转赠服务。
+ *
+ * <p>仅支持将「会员专属券」(coupon.applicable_to = MEMBERSHIP)转赠给自己的推荐下级:
+ * 直推(referral_tree.level = 1)与隔级(level = 2)。转赠采用「转移已有券实例」方式,
+ * 接收方无需是会员即可用该券购买会员(family_coupon.is_gift = 1,apply 时豁免会员校验)。
+ * 赠送方在接收方核销前可撤回。</p>
+ */
+@Service
+public class CouponGiftService {
+
+    private static final Logger logger = LoggerFactory.getLogger(CouponGiftService.class);
+
+    private static final int GIFT_LEVEL_DIRECT = 1;
+    private static final int GIFT_LEVEL_INDIRECT = 2;
+
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+    @Resource
+    private CouponMapper couponMapper;
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+    @Resource
+    private CouponGiftTransferMapper couponGiftTransferMapper;
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /** 仅允许赠送的券范围 */
+    private static final String GIFTABLE_APPLICABLE_TO = "MEMBERSHIP";
+
+    // ----------------------------------------------------------------
+    // 查询
+    // ----------------------------------------------------------------
+
+    /**
+     * 可赠送的接收人列表:赠送人的推荐下级(直推 + 隔级),且已加入家庭、非同家庭。
+     */
+    public List<Map<String, Object>> listRecipients(Long giverUserId) {
+        if (giverUserId == null) return new ArrayList<>();
+        Long giverFamilyId = getFamilyId(giverUserId);
+        if (giverFamilyId == null) return new ArrayList<>();
+        List<ReferralTree> rows = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, giverUserId)
+                        .in(ReferralTree::getLevel, GIFT_LEVEL_DIRECT, GIFT_LEVEL_INDIRECT)
+                        .orderByAsc(ReferralTree::getLevel)
+                        .orderByAsc(ReferralTree::getId));
+        if (rows.isEmpty()) return new ArrayList<>();
+        Map<Long, Integer> levelByChild = new HashMap<>();
+        List<Long> childIds = new ArrayList<>();
+        for (ReferralTree r : rows) {
+            if (r.getChildId() == null) continue;
+            if (!levelByChild.containsKey(r.getChildId())
+                    || levelByChild.get(r.getChildId()) > r.getLevel()) {
+                levelByChild.put(r.getChildId(), r.getLevel());
+            }
+            childIds.add(r.getChildId());
+        }
+        List<User> users = userMapper.selectBatchIds(childIds);
+        if (users == null || users.isEmpty()) return new ArrayList<>();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (User u : users) {
+            if (u == null || u.getFamilyId() == null) continue;
+            if (Objects.equals(u.getFamilyId(), giverFamilyId)) continue; // 同家庭不赠送
+            Map<String, Object> m = new HashMap<>();
+            m.put("userId", u.getId());
+            m.put("nickname", StringUtils.hasText(u.getNickname()) ? u.getNickname() : "微信用户");
+            m.put("avatar", u.getAvatar());
+            m.put("familyId", u.getFamilyId());
+            m.put("giftLevel", levelByChild.getOrDefault(u.getId(), GIFT_LEVEL_DIRECT));
+            m.put("memberLevel", u.getMemberLevel());
+            m.put("isMember", "FAMILY".equals(u.getMemberLevel()) || "PREMIUM".equals(u.getMemberLevel()));
+            result.add(m);
+        }
+        return result;
+    }
+
+    /**
+     * 赠送人可赠送的券列表:自己家庭券包中、可用的、applicableTo=MEMBERSHIP 的券。
+     */
+    public List<Map<String, Object>> listGiftableCoupons(Long giverUserId) {
+        Long familyId = getFamilyId(giverUserId);
+        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<>();
+        Date now = new Date();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (FamilyCoupon fc : familyCoupons) {
+            Coupon coupon = couponMapper.selectById(fc.getCouponId());
+            if (coupon == null) continue;
+            if (!GIFTABLE_APPLICABLE_TO.equals(coupon.getApplicableTo())) continue;
+            if (!"ACTIVE".equals(coupon.getStatus())) continue;
+            if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) continue;
+            if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) continue;
+            // 已处于转赠中(is_gift=1 且仍在赠送方名下不可能,这里 family_id 仍是赠送方才列出)
+            result.add(toCouponVO(fc, coupon));
+        }
+        return result;
+    }
+
+    /**
+     * 赠送人发出的转赠记录(含接收人信息、状态)。
+     */
+    public List<Map<String, Object>> listSent(Long giverUserId) {
+        if (giverUserId == null) return new ArrayList<>();
+        List<CouponGiftTransfer> transfers = couponGiftTransferMapper.selectList(
+                new LambdaQueryWrapper<CouponGiftTransfer>()
+                        .eq(CouponGiftTransfer::getGiverUserId, giverUserId)
+                        .orderByDesc(CouponGiftTransfer::getCreatedAt));
+        if (transfers.isEmpty()) return new ArrayList<>();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (CouponGiftTransfer t : transfers) {
+            Coupon coupon = couponMapper.selectById(t.getCouponId());
+            User receiver = userMapper.selectById(t.getReceiverUserId());
+            Map<String, Object> m = new HashMap<>();
+            m.put("transferId", t.getId());
+            m.put("couponId", t.getCouponId());
+            m.put("couponName", coupon != null ? coupon.getName() : "优惠券");
+            m.put("couponValue", coupon != null ? coupon.getValue() : null);
+            m.put("receiverUserId", t.getReceiverUserId());
+            m.put("receiverNickname", receiver != null && StringUtils.hasText(receiver.getNickname())
+                    ? receiver.getNickname() : "微信用户");
+            m.put("giftLevel", t.getGiftLevel());
+            m.put("giftLevelLabel", t.getGiftLevel() != null && t.getGiftLevel() == GIFT_LEVEL_INDIRECT ? "隔级" : "直推");
+            m.put("status", t.getStatus());
+            m.put("remark", t.getRemark());
+            m.put("createdAt", t.getCreatedAt());
+            m.put("usedAt", t.getUsedAt());
+            m.put("revokedAt", t.getRevokedAt());
+            result.add(m);
+        }
+        return result;
+    }
+
+    /**
+     * 接收人收到的、且仍可使用的转赠券(供前端在「我的券」中提示来源,可选)。
+     */
+    public List<Map<String, Object>> listReceived(Long receiverUserId) {
+        if (receiverUserId == null) return new ArrayList<>();
+        List<CouponGiftTransfer> transfers = couponGiftTransferMapper.selectList(
+                new LambdaQueryWrapper<CouponGiftTransfer>()
+                        .eq(CouponGiftTransfer::getReceiverUserId, receiverUserId)
+                        .eq(CouponGiftTransfer::getStatus, "GIFTED")
+                        .orderByDesc(CouponGiftTransfer::getCreatedAt));
+        if (transfers.isEmpty()) return new ArrayList<>();
+        return transfers.stream().map(t -> {
+            Coupon coupon = couponMapper.selectById(t.getCouponId());
+            User giver = userMapper.selectById(t.getGiverUserId());
+            Map<String, Object> m = new HashMap<>();
+            m.put("transferId", t.getId());
+            m.put("giverNickname", giver != null && StringUtils.hasText(giver.getNickname())
+                    ? giver.getNickname() : "好友");
+            m.put("couponName", coupon != null ? coupon.getName() : "优惠券");
+            m.put("giftLevelLabel", t.getGiftLevel() != null && t.getGiftLevel() == GIFT_LEVEL_INDIRECT ? "隔级" : "直推");
+            m.put("createdAt", t.getCreatedAt());
+            return m;
+        }).collect(Collectors.toList());
+    }
+
+    // ----------------------------------------------------------------
+    // 写操作
+    // ----------------------------------------------------------------
+
+    /**
+     * 转赠一张券给推荐下级。返回 transferId;失败抛出 IllegalArgumentException。
+     */
+    @Transactional
+    public Long transfer(Long giverUserId, Long receiverUserId, Long familyCouponId, String remark) {
+        if (giverUserId == null || receiverUserId == null || familyCouponId == null) {
+            throw new IllegalArgumentException("参数不完整");
+        }
+        Long giverFamilyId = getFamilyId(giverUserId);
+        if (giverFamilyId == null) throw new IllegalArgumentException("请先加入家庭");
+        if (Objects.equals(giverUserId, receiverUserId)) throw new IllegalArgumentException("不能转赠给自己");
+
+        FamilyCoupon fc = familyCouponMapper.selectById(familyCouponId);
+        if (fc == null || !Objects.equals(fc.getFamilyId(), giverFamilyId)
+                || !"AVAILABLE".equals(fc.getStatus())) {
+            throw new IllegalArgumentException("该优惠券不可用或不属于你");
+        }
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
+        if (coupon == null || !"ACTIVE".equals(coupon.getStatus())) {
+            throw new IllegalArgumentException("优惠券已失效");
+        }
+        if (!GIFTABLE_APPLICABLE_TO.equals(coupon.getApplicableTo())) {
+            throw new IllegalArgumentException("仅会员专属券可转赠");
+        }
+        Date now = new Date();
+        if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())
+                || coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
+            throw new IllegalArgumentException("优惠券已过期");
+        }
+
+        User receiver = userMapper.selectById(receiverUserId);
+        if (receiver == null || receiver.getFamilyId() == null) {
+            throw new IllegalArgumentException("接收人未加入家庭,无法接收");
+        }
+        if (Objects.equals(receiver.getFamilyId(), giverFamilyId)) {
+            throw new IllegalArgumentException("不能转赠给同家庭成员");
+        }
+
+        // 必须是赠送人的推荐下级(直推 / 隔级)
+        ReferralTree referral = referralTreeMapper.selectOne(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, giverUserId)
+                        .eq(ReferralTree::getChildId, receiverUserId)
+                        .in(ReferralTree::getLevel, GIFT_LEVEL_DIRECT, GIFT_LEVEL_INDIRECT)
+                        .last("LIMIT 1"));
+        if (referral == null) {
+            throw new IllegalArgumentException("只能转赠给自己的直推或隔级推荐人");
+        }
+
+        // 转移券实例到接收方家庭,并打 is_gift 标记(接收方非会员也可用其购买会员)
+        fc.setFamilyId(receiver.getFamilyId());
+        fc.setIsGift(1);
+        familyCouponMapper.updateById(fc);
+
+        CouponGiftTransfer transfer = new CouponGiftTransfer();
+        transfer.setGiverUserId(giverUserId);
+        transfer.setGiverFamilyId(giverFamilyId);
+        transfer.setReceiverUserId(receiverUserId);
+        transfer.setReceiverFamilyId(receiver.getFamilyId());
+        transfer.setCouponId(coupon.getId());
+        transfer.setFamilyCouponId(fc.getId());
+        transfer.setGiftLevel(referral.getLevel());
+        transfer.setStatus("GIFTED");
+        transfer.setRemark(remark);
+        transfer.setCreatedAt(now);
+        couponGiftTransferMapper.insert(transfer);
+
+        // 通知接收人收到券(失败不影响转赠主流程)
+        try {
+            User giver = userMapper.selectById(giverUserId);
+            String giverName = (giver != null && StringUtils.hasText(giver.getNickname()))
+                    ? giver.getNickname() : "好友";
+            Map<String, Object> vars = NotificationDispatchService.vars("giverName", giverName);
+            vars.put("couponName", coupon.getName());
+            notificationDispatchService.sendByTemplate(
+                    "coupon_gift",
+                    receiverUserId,
+                    vars,
+                    "coupon_gift",
+                    transfer.getId(),
+                    "你收到一张会员优惠券",
+                    "{giverName} 送了你一张「{couponName}」会员专属券,可用于开通会员。");
+        } catch (Exception e) {
+            logger.warn("转赠通知发送失败(不影响转赠): transferId={}, error={}",
+                    transfer.getId(), e.getMessage());
+        }
+
+        // 发放流水(审计)
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(receiver.getFamilyId());
+        log.setCouponId(coupon.getId());
+        log.setGrantType("GIFT_TRANSFER");
+        log.setPeriod(null);
+        log.setQuantity(1);
+        log.setSource("transfer:" + transfer.getId());
+        log.setCreatedAt(now);
+        familyCouponGrantLogMapper.insert(log);
+
+        logger.info("会员券转赠成功: giver={}, receiver={}, familyCouponId={}, level={}, transferId={}",
+                giverUserId, receiverUserId, familyCouponId, referral.getLevel(), transfer.getId());
+        return transfer.getId();
+    }
+
+    /**
+     * 撤回一笔转赠(接收方未使用前)。返回 true 成功。
+     */
+    @Transactional
+    public boolean revoke(Long transferId, Long giverUserId) {
+        CouponGiftTransfer transfer = couponGiftTransferMapper.selectById(transferId);
+        if (transfer == null) throw new IllegalArgumentException("转赠记录不存在");
+        if (!Objects.equals(transfer.getGiverUserId(), giverUserId)) {
+            throw new IllegalArgumentException("无权操作该转赠");
+        }
+        if (!"GIFTED".equals(transfer.getStatus())) {
+            throw new IllegalArgumentException("该券已被使用或已撤回,无法撤回");
+        }
+        FamilyCoupon fc = familyCouponMapper.selectById(transfer.getFamilyCouponId());
+        if (fc == null || !Objects.equals(fc.getFamilyId(), transfer.getReceiverFamilyId())
+                || !"AVAILABLE".equals(fc.getStatus()) || !Objects.equals(fc.getIsGift(), 1)) {
+            throw new IllegalArgumentException("该券已被使用,无法撤回");
+        }
+        // 券实例回退到赠送方家庭
+        fc.setFamilyId(transfer.getGiverFamilyId());
+        fc.setIsGift(0);
+        familyCouponMapper.updateById(fc);
+
+        transfer.setStatus("REVOKED");
+        transfer.setRevokedAt(new Date());
+        couponGiftTransferMapper.updateById(transfer);
+        logger.info("会员券转赠撤回: transferId={}, giver={}", transferId, giverUserId);
+        return true;
+    }
+
+    /**
+     * 券被核销时由 CouponService.markUsed 回调:将对应转赠记录置为 USED。
+     */
+    @Transactional
+    public void markUsedByFamilyCouponId(Long familyCouponId, Long orderId) {
+        if (familyCouponId == null) return;
+        CouponGiftTransfer transfer = couponGiftTransferMapper.selectOne(
+                new LambdaQueryWrapper<CouponGiftTransfer>()
+                        .eq(CouponGiftTransfer::getFamilyCouponId, familyCouponId)
+                        .eq(CouponGiftTransfer::getStatus, "GIFTED")
+                        .last("LIMIT 1"));
+        if (transfer != null) {
+            transfer.setStatus("USED");
+            transfer.setUsedAt(new Date());
+            couponGiftTransferMapper.updateById(transfer);
+        }
+    }
+
+    // ----------------------------------------------------------------
+    // 内部辅助
+    // ----------------------------------------------------------------
+
+    /**
+     * 管理端:转赠记录分页查询(可按赠送方家庭 / 接收方家庭 / 状态 / 券模板过滤)。
+     */
+    public Page<Map<String, Object>> adminList(Long giverFamilyId, Long receiverFamilyId,
+                                              String status, Long couponId, int page, int size) {
+        LambdaQueryWrapper<CouponGiftTransfer> wrapper = new LambdaQueryWrapper<>();
+        if (giverFamilyId != null) {
+            wrapper.eq(CouponGiftTransfer::getGiverFamilyId, giverFamilyId);
+        }
+        if (receiverFamilyId != null) {
+            wrapper.eq(CouponGiftTransfer::getReceiverFamilyId, receiverFamilyId);
+        }
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(CouponGiftTransfer::getStatus, status);
+        }
+        if (couponId != null) {
+            wrapper.eq(CouponGiftTransfer::getCouponId, couponId);
+        }
+        wrapper.orderByDesc(CouponGiftTransfer::getCreatedAt);
+        Page<CouponGiftTransfer> pg = couponGiftTransferMapper.selectPage(new Page<>(page, size), wrapper);
+
+        List<Map<String, Object>> records = pg.getRecords().stream().map(t -> {
+            Coupon c = couponMapper.selectById(t.getCouponId());
+            User giver = userMapper.selectById(t.getGiverUserId());
+            User receiver = userMapper.selectById(t.getReceiverUserId());
+            Map<String, Object> m = new HashMap<>();
+            m.put("transferId", t.getId());
+            m.put("giverUserId", t.getGiverUserId());
+            m.put("giverNickname", giver != null && StringUtils.hasText(giver.getNickname())
+                    ? giver.getNickname() : "用户" + t.getGiverUserId());
+            m.put("giverFamilyId", t.getGiverFamilyId());
+            m.put("receiverUserId", t.getReceiverUserId());
+            m.put("receiverNickname", receiver != null && StringUtils.hasText(receiver.getNickname())
+                    ? receiver.getNickname() : "用户" + t.getReceiverUserId());
+            m.put("receiverFamilyId", t.getReceiverFamilyId());
+            m.put("couponId", t.getCouponId());
+            m.put("couponName", c != null ? c.getName() : "优惠券");
+            m.put("couponValue", c != null ? c.getValue() : null);
+            m.put("giftLevel", t.getGiftLevel());
+            m.put("giftLevelLabel", t.getGiftLevel() != null && t.getGiftLevel() == GIFT_LEVEL_INDIRECT ? "隔级" : "直推");
+            m.put("status", t.getStatus());
+            m.put("remark", t.getRemark());
+            m.put("createdAt", t.getCreatedAt());
+            m.put("usedAt", t.getUsedAt());
+            m.put("revokedAt", t.getRevokedAt());
+            return m;
+        }).collect(Collectors.toList());
+
+        Page<Map<String, Object>> result = new Page<>(pg.getCurrent(), pg.getSize(), pg.getTotal());
+        result.setRecords(records);
+        return result;
+    }
+
+    private Long getFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) return null;
+        return user.getFamilyId();
+    }
+
+    private Map<String, Object> toCouponVO(FamilyCoupon fc, Coupon coupon) {
+        Map<String, Object> item = new HashMap<>();
+        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", "AVAILABLE");
+        item.put("isGift", 0);
+        return item;
+    }
+}

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -47,6 +47,10 @@ public class CouponService {
     @Lazy
     private MembershipService membershipService;
 
+    @Resource
+    @Lazy
+    private CouponGiftService couponGiftService;
+
     // ----------------------------------------------------------------
     // 内部辅助方法
     // ----------------------------------------------------------------
@@ -90,6 +94,7 @@ public class CouponService {
         item.put("orderId", fc.getOrderId());
         item.put("productId", coupon.getProductId());
         item.put("grantType", coupon.getGrantType());
+        item.put("isGift", fc.getIsGift() != null ? fc.getIsGift() : 0);
         return item;
     }
 
@@ -213,9 +218,11 @@ public class CouponService {
             return null;
         }
         // 新增:会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用
-        // 例外:手动发放的券(grantType=MANUAL)允许非会员使用,用于赠送升级场景
+        // 例外1:手动发放的券(grantType=MANUAL)允许非会员使用,用于赠送升级场景
+        // 例外2:来自他人转赠的券实例(family_coupon.is_gift=1)允许非会员用于购买会员
         if ("MEMBERSHIP".equals(coupon.getApplicableTo())
                 && !Objects.equals(coupon.getGrantType(), "MANUAL")
+                && !Objects.equals(fc.getIsGift(), 1)
                 && !isMember(userId)) {
             return null;
         }
@@ -240,6 +247,12 @@ public class CouponService {
             fc.setUsedAt(new Date());
             fc.setOrderId(orderId);
             familyCouponMapper.updateById(fc);
+            // 同步转赠记录为已使用(若该券来自他人转赠)
+            try {
+                couponGiftService.markUsedByFamilyCouponId(userCouponId, orderId);
+            } catch (Exception e) {
+                logger.warn("同步会员券转赠使用状态失败(忽略): {}", e.getMessage());
+            }
         }
     }
 

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

@@ -4706,15 +4706,35 @@ 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',
+    status VARCHAR(16) DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE/USED/RESERVED/EXPIRED/REVOKED',
     received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     used_at DATETIME,
     order_id BIGINT COMMENT '核销时写入的订单号',
+    is_gift INT DEFAULT 0 COMMENT '1=该券实例来自他人转赠(接收方可被非会员用于购买会员)',
     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 coupon_gift_transfer (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    giver_user_id BIGINT NOT NULL COMMENT '赠送人用户ID(users.id)',
+    giver_family_id BIGINT NOT NULL COMMENT '赠送人家庭ID',
+    receiver_user_id BIGINT NOT NULL COMMENT '接收人用户ID(users.id)',
+    receiver_family_id BIGINT NOT NULL COMMENT '接收人家庭ID',
+    coupon_id BIGINT NOT NULL COMMENT '券模板ID(coupon.id)',
+    family_coupon_id BIGINT NOT NULL COMMENT '被转移的 family_coupon 实例ID',
+    gift_level INT NOT NULL COMMENT '推荐层级 1=直推 2=隔级',
+    status VARCHAR(16) NOT NULL DEFAULT 'GIFTED' COMMENT 'GIFTED/USED/REVOKED',
+    remark VARCHAR(255) NULL COMMENT '赠送留言',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    used_at DATETIME NULL,
+    revoked_at DATETIME NULL,
+    INDEX idx_receiver (receiver_family_id, status),
+    INDEX idx_giver (giver_user_id),
+    INDEX idx_family_coupon (family_coupon_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',

+ 6 - 0
cfc-frontend/pages.json

@@ -893,6 +893,12 @@
           "style": {
             "navigationBarTitleText": "确认订单"
           }
+        },
+        {
+          "path": "gift-coupon",
+          "style": {
+            "navigationBarTitleText": "赠送会员券"
+          }
         }
       ]
     },

+ 450 - 0
cfc-frontend/pages/membership/gift-coupon.vue

@@ -0,0 +1,450 @@
+<template>
+  <view class="page">
+    <view class="tab-bar">
+      <view
+        v-for="(tab, idx) in tabs"
+        :key="idx"
+        class="tab-item"
+        :class="activeTab === idx ? 'active' : ''"
+        @click="switchTab(idx)"
+      >
+        <text>{{ tab.name }}</text>
+      </view>
+    </view>
+
+    <!-- 赠送 -->
+    <scroll-view v-if="activeTab === 0" scroll-y class="content" refresher-enabled
+      :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
+      <view class="tip">把你的会员券转赠给推荐的人(直推 / 隔级),对方可直接用它购买会员。</view>
+
+      <view class="section">
+        <view class="section-title">1. 选择接收人(你的推荐下级)</view>
+        <view v-if="recipients.length === 0" class="empty-tip">暂无可赠送的推荐下级(仅支持直推/隔级推荐人)</view>
+        <view v-for="r in recipients" :key="r.userId" class="recipient-item"
+          :class="selectedRecipient && selectedRecipient.userId === r.userId ? 'selected' : ''"
+          @click="pickRecipient(r)">
+          <image v-if="r.avatar" class="avatar" :src="r.avatar" mode="aspectFill"></image>
+          <view v-else class="avatar avatar-default">{{ r.nickname | firstChar }}</view>
+          <view class="recipient-info">
+            <text class="nick">{{ r.nickname }}</text>
+            <text class="level-tag">{{ r.giftLevel === 2 ? '隔级' : '直推' }}</text>
+          </view>
+          <view v-if="selectedRecipient && selectedRecipient.userId === r.userId" class="check">✓</view>
+        </view>
+      </view>
+
+      <view class="section">
+        <view class="section-title">2. 选择要赠送的会员券</view>
+        <view v-if="coupons.length === 0" class="empty-tip">你还没有可赠送的会员券</view>
+        <view v-for="c in coupons" :key="c.id" class="coupon-item"
+          :class="selectedCoupon && selectedCoupon.id === c.id ? 'selected' : ''"
+          @click="pickCoupon(c)">
+          <view class="coupon-left">
+            <text class="coupon-value">{{ formatValue(c) }}</text>
+            <text class="coupon-type">{{ typeName(c) }}</text>
+          </view>
+          <view class="coupon-body">
+            <text class="coupon-name">{{ c.name }}</text>
+            <text class="coupon-cond">{{ conditionText(c.minSpend) }}</text>
+            <text class="coupon-exp">有效期至 {{ formatDate(c.validUntil) }}</text>
+          </view>
+          <view v-if="selectedCoupon && selectedCoupon.id === c.id" class="check">✓</view>
+        </view>
+      </view>
+
+      <view class="section" v-if="coupons.length > 0">
+        <view class="section-title">3. 留言(选填)</view>
+        <input class="remark-input" v-model="remark" placeholder="写句祝福吧~" maxlength="30" />
+      </view>
+
+      <view class="action-bar">
+        <button class="gift-btn" :disabled="!canGift || sending" @click="doGift">
+          {{ sending ? '赠送中...' : '赠送' }}
+        </button>
+      </view>
+    </scroll-view>
+
+    <!-- 赠送记录 -->
+    <scroll-view v-else scroll-y class="content" refresher-enabled
+      :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
+      <view v-if="sentList.length === 0" class="empty-state">
+        <text class="empty-icon">🎁</text>
+        <text class="empty-text">还没有赠送记录</text>
+      </view>
+      <view v-for="item in sentList" :key="item.transferId" class="sent-item">
+        <view class="sent-row">
+          <text class="sent-name">{{ item.couponName }}</text>
+          <text class="sent-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</text>
+        </view>
+        <view class="sent-row sub">
+          <text>赠给:{{ item.receiverNickname }}</text>
+          <text class="sent-level">{{ item.giftLevelLabel }}</text>
+        </view>
+        <view v-if="item.remark" class="sent-remark">留言:{{ item.remark }}</view>
+        <button v-if="item.status === 'GIFTED'" class="revoke-btn" @click="doRevoke(item)">撤回</button>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import {
+  getGiftRecipients,
+  getGiftableCoupons,
+  giftCoupon,
+  revokeGiftCoupon,
+  getSentGifts
+} from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      tabs: [{ name: '赠送会员券' }, { name: '赠送记录' }],
+      activeTab: 0,
+      recipients: [],
+      coupons: [],
+      selectedRecipient: null,
+      selectedCoupon: null,
+      remark: '',
+      sending: false,
+      sentList: [],
+      refreshing: false
+    }
+  },
+  computed: {
+    canGift: function() {
+      return !!this.selectedRecipient && !!this.selectedCoupon && !this.sending
+    }
+  },
+  onShow: function() {
+    this.loadAll()
+  },
+  filters: {
+    firstChar: function(name) {
+      if (!name) return '友'
+      return name.substring(0, 1)
+    }
+  },
+  methods: {
+    loadAll: function() {
+      var self = this
+      self.refreshing = true
+      Promise.all([getGiftRecipients(), getGiftableCoupons()]).then(function(res) {
+        self.recipients = (res[0] && res[0].data) || []
+        self.coupons = (res[1] && res[1].data) || []
+      }).catch(function(e) {
+        console.error('加载可赠送数据失败', e)
+      }).then(function() {
+        self.refreshing = false
+      })
+      self.loadSent()
+    },
+    loadSent: function() {
+      var self = this
+      getSentGifts().then(function(res) {
+        self.sentList = (res && res.data) || []
+      }).catch(function(e) {
+        console.error('加载赠送记录失败', e)
+      })
+    },
+    onRefresh: function() {
+      this.loadAll()
+    },
+    switchTab: function(idx) {
+      this.activeTab = idx
+    },
+    pickRecipient: function(r) {
+      this.selectedRecipient = r
+    },
+    pickCoupon: function(c) {
+      this.selectedCoupon = c
+    },
+    doGift: function() {
+      var self = this
+      if (!self.canGift) return
+      self.sending = true
+      giftCoupon(self.selectedRecipient.userId, self.selectedCoupon.id, self.remark).then(function(res) {
+        if (res && res.code === 200) {
+          uni.showToast({ title: '赠送成功', icon: 'success' })
+          self.selectedRecipient = null
+          self.selectedCoupon = null
+          self.remark = ''
+          self.loadAll()
+        } else {
+          uni.showToast({ title: (res && res.message) || '赠送失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.showToast({ title: '赠送失败,请重试', icon: 'none' })
+        console.error('赠送失败', e)
+      }).then(function() {
+        self.sending = false
+      })
+    },
+    doRevoke: function(item) {
+      var self = this
+      uni.showModal({
+        title: '撤回转赠',
+        content: '确定撤回赠给「' + item.receiverNickname + '」的券吗?',
+        success: function(m) {
+          if (!m.confirm) return
+          revokeGiftCoupon(item.transferId).then(function(res) {
+            if (res && res.code === 200) {
+              uni.showToast({ title: '已撤回', icon: 'success' })
+              self.loadSent()
+            } else {
+              uni.showToast({ title: (res && res.message) || '撤回失败', icon: 'none' })
+            }
+          }).catch(function(e) {
+            uni.showToast({ title: '撤回失败', icon: 'none' })
+            console.error(e)
+          })
+        }
+      })
+    },
+    formatValue: function(c) {
+      if (c.type === 'DISCOUNT') {
+        return (c.discountRate ? (c.discountRate / 100).toFixed(1) : '9.0') + '折'
+      }
+      return '¥' + (c.value ? (c.value / 100).toFixed(2) : '0.00')
+    },
+    typeName: function(c) {
+      if (c.type === 'FIXED') return '满减券'
+      if (c.type === 'DISCOUNT') return '折扣券'
+      return '代金券'
+    },
+    conditionText: function(minSpend) {
+      if (!minSpend || minSpend <= 0) return '无门槛'
+      return '满' + (minSpend / 100).toFixed(2) + '元可用'
+    },
+    formatDate: function(d) {
+      if (!d) return '长期有效'
+      var dt = new Date(d)
+      if (isNaN(dt.getTime())) return String(d)
+      var m = dt.getMonth() + 1
+      var day = dt.getDate()
+      return dt.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
+    },
+    statusLabel: function(status) {
+      if (status === 'GIFTED') return '待使用'
+      if (status === 'USED') return '已使用'
+      if (status === 'REVOKED') return '已撤回'
+      return status
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  background: #f5f6fa;
+}
+.tab-bar {
+  display: flex;
+  background: #fff;
+  border-bottom: 1px solid #eee;
+}
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 24rpx 0;
+  font-size: 30rpx;
+  color: #888;
+}
+.tab-item.active {
+  color: #ff6b35;
+  font-weight: bold;
+  border-bottom: 4rpx solid #ff6b35;
+}
+.content {
+  flex: 1;
+  padding: 24rpx;
+}
+.tip {
+  background: #fff7f2;
+  color: #ff6b35;
+  font-size: 24rpx;
+  padding: 18rpx 22rpx;
+  border-radius: 12rpx;
+  margin-bottom: 24rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  margin-bottom: 20rpx;
+  color: #333;
+}
+.empty-tip {
+  font-size: 26rpx;
+  color: #aaa;
+  text-align: center;
+  padding: 30rpx 0;
+}
+.recipient-item,
+.coupon-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx;
+  border: 2rpx solid #eee;
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+  position: relative;
+}
+.recipient-item.selected,
+.coupon-item.selected {
+  border-color: #ff6b35;
+  background: #fff7f2;
+}
+.avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  margin-right: 18rpx;
+}
+.avatar-default {
+  background: #ff6b35;
+  color: #fff;
+  font-size: 30rpx;
+  text-align: center;
+  line-height: 72rpx;
+}
+.recipient-info {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+}
+.nick {
+  font-size: 28rpx;
+  color: #333;
+}
+.level-tag {
+  font-size: 22rpx;
+  color: #ff6b35;
+  margin-top: 6rpx;
+}
+.coupon-left {
+  width: 140rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border-right: 2rpx dashed #eee;
+  padding-right: 16rpx;
+}
+.coupon-value {
+  font-size: 36rpx;
+  color: #ff6b35;
+  font-weight: bold;
+}
+.coupon-type {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 6rpx;
+}
+.coupon-body {
+  flex: 1;
+  padding-left: 20rpx;
+  display: flex;
+  flex-direction: column;
+}
+.coupon-name {
+  font-size: 28rpx;
+  color: #333;
+}
+.coupon-cond,
+.coupon-exp {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 6rpx;
+}
+.check {
+  color: #ff6b35;
+  font-size: 36rpx;
+  font-weight: bold;
+  margin-left: 12rpx;
+}
+.remark-input {
+  background: #f7f7f7;
+  border-radius: 10rpx;
+  padding: 18rpx 20rpx;
+  font-size: 28rpx;
+}
+.action-bar {
+  padding: 20rpx 0 40rpx;
+}
+.gift-btn {
+  background: #ff6b35;
+  color: #fff;
+  font-size: 32rpx;
+  border-radius: 44rpx;
+}
+.gift-btn[disabled] {
+  background: #ffc4ad;
+}
+.empty-state {
+  text-align: center;
+  padding: 120rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+  display: block;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #aaa;
+  margin-top: 20rpx;
+}
+.sent-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.sent-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.sent-row.sub {
+  margin-top: 10rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+.sent-name {
+  font-size: 30rpx;
+  color: #333;
+  font-weight: bold;
+}
+.sent-level {
+  font-size: 22rpx;
+  color: #ff6b35;
+}
+.sent-status {
+  font-size: 24rpx;
+}
+.st-GIFTED { color: #ff6b35; }
+.st-USED { color: #999; }
+.st-REVOKED { color: #bbb; }
+.sent-remark {
+  font-size: 24rpx;
+  color: #888;
+  margin-top: 10rpx;
+}
+.revoke-btn {
+  margin-top: 18rpx;
+  background: #fff;
+  color: #ff6b35;
+  border: 2rpx solid #ff6b35;
+  font-size: 26rpx;
+  border-radius: 30rpx;
+  padding: 4rpx 0;
+}
+</style>

+ 4 - 2
cfc-frontend/pages/membership/pay.vue

@@ -154,7 +154,8 @@ export default {
         if (c.applicableTo && c.applicableTo !== 'ALL' && c.applicableTo !== 'MEMBERSHIP') return false
         // 会员专属券:非会员不显示在可选列表中
         // 会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用;手动发放券(grantType=MANUAL)例外,允许非会员购买时使用
-        if (c.applicableTo === 'MEMBERSHIP' && !self.isMember && c.grantType !== 'MANUAL') return false
+        // 来自他人转赠的券(isGift=1)同样允许非会员用于购买会员
+        if (c.applicableTo === 'MEMBERSHIP' && !self.isMember && c.grantType !== 'MANUAL' && !c.isGift) return false
         return true
       })
     },
@@ -241,7 +242,8 @@ export default {
     getCouponCondition: function(coupon) {
       if (!coupon) return '无门槛'
       // 会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用;手动发放券(grantType=MANUAL)例外,允许非会员购买时使用
-      if (coupon.applicableTo === 'MEMBERSHIP' && !this.isMember && coupon.grantType !== 'MANUAL') {
+      // 来自他人转赠的券(isGift=1)同样允许非会员用于购买会员
+      if (coupon.applicableTo === 'MEMBERSHIP' && !this.isMember && coupon.grantType !== 'MANUAL' && !coupon.isGift) {
         return '开通会员后可用'
       }
       if (!coupon.minSpend || coupon.minSpend <= 0) return '无门槛'

+ 29 - 1
cfc-frontend/pages/profile-extra/coupons.vue

@@ -12,6 +12,11 @@
       </view>
     </view>
 
+    <view class="gift-entry" @click="goGiftCoupon">
+      <text class="gift-entry-text">🎁 把我的会员券送给推荐的人</text>
+      <text class="gift-entry-arrow">›</text>
+    </view>
+
     <scroll-view
       scroll-y
       class="coupon-scroll"
@@ -78,7 +83,7 @@
               class="claim-btn"
               @click="handleClaim(coupon)"
             >立即领取</button>
-            <view v-else-if="coupon.applicableTo === 'MEMBERSHIP' && !isMember && (!coupon.status || coupon.status === 'AVAILABLE')" class="member-guide-btn" @click="goToMembership">
+            <view v-else-if="coupon.applicableTo === 'MEMBERSHIP' && !isMember && !coupon.isGift && (!coupon.status || coupon.status === 'AVAILABLE')" class="member-guide-btn" @click="goToMembership">
               <text>去开通会员</text>
             </view>
           </view>
@@ -230,6 +235,9 @@ export default {
       // 非会员也可领取会员券,但使用时需开通会员
       return this.tabIndex === 0 && (!coupon.status || coupon.status === 'AVAILABLE')
     },
+    goGiftCoupon() {
+      uni.navigateTo({ url: '/pages/membership/gift-coupon' })
+    },
     async handleClaim(coupon) {
       try {
         var res = await claimCoupon(coupon.id)
@@ -519,4 +527,24 @@ export default {
 .member-guide-btn:active {
   opacity: 0.85;
 }
+
+.gift-entry {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 20rpx 0;
+  padding: 24rpx;
+  background: linear-gradient(135deg, #ff8a4c, #ff6b35);
+  border-radius: 16rpx;
+  box-shadow: 0 6rpx 18rpx rgba(255, 107, 53, 0.25);
+}
+.gift-entry-text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+}
+.gift-entry-arrow {
+  color: #fff;
+  font-size: 36rpx;
+}
 </style>

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

@@ -2638,6 +2638,23 @@ export const getJoinCouponRules = () => {
   return request('/api/coupon/join-rules', 'POST', {})
 }
 
+// ===== 会员优惠券转赠 =====
+export const getGiftRecipients = () => {
+  return request('/api/coupon/gift/recipients', 'POST', {})
+}
+export const getGiftableCoupons = () => {
+  return request('/api/coupon/gift/my-coupons', 'POST', {})
+}
+export const giftCoupon = (receiverUserId, familyCouponId, remark) => {
+  return request('/api/coupon/gift/transfer', 'POST', { receiverUserId, familyCouponId, remark })
+}
+export const revokeGiftCoupon = (transferId) => {
+  return request('/api/coupon/gift/revoke', 'POST', { transferId })
+}
+export const getSentGifts = () => {
+  return request('/api/coupon/gift/sent', 'POST', {})
+}
+
 // ===== 新手任务(Onboarding Rewards) =====
 export const getOnboardingProgress = () => {
   return request('/api/onboarding/progress', 'POST')

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

@@ -47,3 +47,7 @@ export function getFamilyCoupons(data) {
 export function getFamilyCouponUsage(data) {
   return request({ url: '/api/admin/coupon/family-coupon-usage', method: 'post', data })
 }
+
+export function getCouponGiftTransfers(data) {
+  return request({ url: '/api/admin/coupon/gift-transfers', method: 'post', data })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -750,6 +750,12 @@ const routes = [
         component: () => import('@/views/admin/CouponGrantLog.vue'),
         meta: { title: '发券记录', perm: 'marketing:coupon' }
       },
+      {
+        path: 'coupon-gift-transfers',
+        name: 'CouponGiftTransfers',
+        component: () => import('@/views/admin/CouponGiftTransfers.vue'),
+        meta: { title: '会员券转赠记录', perm: 'marketing:coupon' }
+      },
       {
         path: 'discount-rules',
         name: 'DiscountRuleManagement',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -220,6 +220,7 @@ export default {
             { path: '/product-profit-rate', label: '产品利润率', icon: 'el-icon-data-line', perm: 'finance:profit-rate' },
             { path: '/coupon', label: '优惠券管理', icon: 'el-icon-ticket', perm: 'commerce:coupon' },
             { path: '/coupon-grant-log', label: '发券记录', icon: 'el-icon-document', perm: 'commerce:coupon' },
+            { path: '/coupon-gift-transfers', label: '会员券转赠记录', icon: 'el-icon-present', perm: 'commerce:coupon' },
             { path: '/discount-rules', label: '限时折扣管理', icon: 'el-icon-timer', perm: 'commerce:discount' },
             { path: '/promotion', label: '推广管理', icon: 'el-icon-s-marketing', perm: 'commerce:promotion' },
             { path: '/home-banner-manage', label: '首页广告栏', icon: 'el-icon-picture', perm: 'marketing:promotion' },

+ 151 - 0
cfc-web/src/views/admin/CouponGiftTransfers.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="coupon-gift-transfers admin-page">
+    <el-card>
+      <PageHeader slot="header" title="会员券转赠记录">
+        <div class="admin-page-actions">
+          <el-input v-model="giverFamilyId" placeholder="赠送方家庭ID" clearable class="filter-input-140" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-input v-model="receiverFamilyId" placeholder="接收方家庭ID" clearable class="filter-input-140" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-input v-model="couponId" placeholder="优惠券ID" clearable class="filter-input-140" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-select v-model="status" placeholder="状态" clearable class="filter-input-140" @change="handleSearch">
+            <el-option label="已赠送" value="GIFTED" />
+            <el-option label="已使用" value="USED" />
+            <el-option label="已撤回" value="REVOKED" />
+          </el-select>
+          <el-button type="primary" size="small" icon="el-icon-search" @click="handleSearch">查询</el-button>
+          <el-button size="small" icon="el-icon-refresh" @click="resetFilters">重置</el-button>
+        </div>
+      </PageHeader>
+
+      <div class="adm-table-wrap">
+        <el-table height="100%" :data="list" v-loading="loading" border stripe>
+          <el-table-column prop="transferId" label="转赠ID" width="90" />
+          <el-table-column label="赠送方" min-width="170">
+            <template slot-scope="{ row }">
+              <div>{{ row.giverNickname }}</div>
+              <div class="muted">家庭 {{ row.giverFamilyId }}</div>
+            </template>
+          </el-table-column>
+          <el-table-column label="接收方" min-width="170">
+            <template slot-scope="{ row }">
+              <div>{{ row.receiverNickname }}</div>
+              <div class="muted">家庭 {{ row.receiverFamilyId }}</div>
+            </template>
+          </el-table-column>
+          <el-table-column label="优惠券" min-width="180">
+            <template slot-scope="{ row }">
+              <div>{{ row.couponName }}</div>
+              <div class="muted">ID {{ row.couponId }}<span v-if="row.couponValue != null"> · ¥{{ row.couponValue }}</span></div>
+            </template>
+          </el-table-column>
+          <el-table-column label="关系" width="80">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="row.giftLevel === 2 ? 'warning' : 'success'">{{ row.giftLevelLabel }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="100">
+            <template slot-scope="{ row }">
+              <StatusTag size="mini" :type="statusTagType(row.status)" :text="statusLabel(row.status)" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="remark" label="留言" min-width="140" show-overflow-tooltip>
+            <template slot-scope="{ row }">{{ row.remark || '-' }}</template>
+          </el-table-column>
+          <el-table-column prop="createdAt" label="转赠时间" width="170" />
+          <el-table-column label="使用时间" width="170">
+            <template slot-scope="{ row }">{{ row.usedAt || '-' }}</template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <el-pagination
+        @current-change="onPageChange"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        class="pagination-wrap"
+      />
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getCouponGiftTransfers } from '@/api/coupon'
+
+export default {
+  name: 'CouponGiftTransfers',
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      giverFamilyId: '',
+      receiverFamilyId: '',
+      couponId: '',
+      status: ''
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    statusLabel(val) {
+      const map = { GIFTED: '已赠送', USED: '已使用', REVOKED: '已撤回' }
+      return map[val] || val
+    },
+    statusTagType(val) {
+      const map = { GIFTED: 'primary', USED: 'success', REVOKED: 'info' }
+      return map[val] || 'info'
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const params = {
+          page: this.page,
+          size: this.size,
+          giverFamilyId: this.giverFamilyId || undefined,
+          receiverFamilyId: this.receiverFamilyId || undefined,
+          couponId: this.couponId || undefined,
+          status: this.status || undefined
+        }
+        const res = await getCouponGiftTransfers(params)
+        if (res && res.data) {
+          this.list = res.data.records || res.data.list || res.data
+          this.total = res.data.total || this.list.length
+        }
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.page = 1
+      this.loadData()
+    },
+    resetFilters() {
+      this.giverFamilyId = ''
+      this.receiverFamilyId = ''
+      this.couponId = ''
+      this.status = ''
+      this.page = 1
+      this.loadData()
+    },
+    onPageChange(p) {
+      if (p) this.page = p
+      this.loadData()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.coupon-gift-transfers { padding: 24px; }
+
+/* == C 批内联清理工具类 == */
+.filter-input-140 { width: 140px; }
+.muted { color: #999; font-size: 12px; }
+.pagination-wrap { margin-top: 12px; text-align: right; }
+</style>