优惠券系统设计与实现

优惠券系统设计与实现

前言

优惠券系统是电商平台促销的核心功能之一。京东、淘宝等平台每天发放数以亿计的优惠券,涉及多种券类型、复杂的使用规则、高并发的领取场景。本文将从系统架构、数据库设计、核心功能实现等方面,详细讲解如何设计一个完整的优惠券系统。

一、优惠券系统业务分析

1.1 优惠券类型

diff 复制代码
+------------------+------------------+------------------+------------------+
|    满减券        |     折扣券       |     立减券       |     运费券       |
|  满100减20      |    全场8折       |   无门槛减10元   |   免运费券       |
+------------------+------------------+------------------+------------------+
         |                  |                  |                  |
         v                  v                  v                  v
+------------------+------------------+------------------+------------------+
|    品类券        |     店铺券       |    单品券        |    通用券        |
|  限特定品类使用   |   限特定店铺     |   限特定商品     |   全平台可用     |
+------------------+------------------+------------------+------------------+

1.2 优惠券生命周期

lua 复制代码
+----------+     +----------+     +----------+     +----------+     +----------+
|  创建    | --> |  发布    | --> |  领取    | --> |  使用    | --> |  核销    |
+----------+     +----------+     +----------+     +----------+     +----------+
     |               |               |               |               |
     v               v               v               v               v
+----------+     +----------+     +----------+     +----------+     +----------+
| 模板配置  |     | 活动上线  |     | 用户领券  |     | 下单抵扣  |     | 订单完成  |
| 规则设置  |     | 库存分配  |     | 库存扣减  |     | 金额计算  |     | 状态更新  |
+----------+     +----------+     +----------+     +----------+     +----------+
                                       |
                                       v
                                  +----------+
                                  |  过期    |
                                  | 自动失效  |
                                  +----------+

1.3 核心业务指标

指标 说明 目标值
领券QPS 大促期间领券峰值 10万+
用券计算 订单优惠计算耗时 <50ms
券核销率 领取后实际使用比例 30-50%
发放成功率 优惠券发放成功率 99.99%

二、系统架构设计

2.1 整体架构

sql 复制代码
                           +--------------------+
                           |      用户端        |
                           |  APP/小程序/H5    |
                           +---------+----------+
                                     |
                           +---------v----------+
                           |    API Gateway     |
                           |   认证/限流/路由    |
                           +---------+----------+
                                     |
         +---------------------------+---------------------------+
         |                           |                           |
+--------v--------+        +---------v--------+        +---------v--------+
|   优惠券服务     |        |    订单服务      |        |    营销服务      |
| Coupon-Service  |        |  Order-Service   |        | Marketing-Service|
+--------+--------+        +---------+--------+        +---------+--------+
         |                           |                           |
         +---------------------------+---------------------------+
                                     |
         +---------------------------+---------------------------+
         |                           |                           |
+--------v--------+        +---------v--------+        +---------v--------+
|   Redis集群     |        |   RocketMQ      |        |   MySQL集群      |
|  缓存/分布式锁   |        |   异步处理      |        |   数据持久化     |
+-----------------+        +------------------+        +------------------+

2.2 优惠券服务核心模块

lua 复制代码
+------------------------------------------------------------------+
|                        优惠券服务                                 |
+------------------------------------------------------------------+
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|  | 模板管理    |  | 活动管理    |  | 领券服务    |  | 用券服务  | |
|  | Template    |  | Activity    |  | Receive     |  | Apply     | |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|                                                                   |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
|  | 库存管理    |  | 规则引擎    |  | 过期处理    |  | 统计分析  | |
|  | Stock       |  | Rule        |  | Expire      |  | Statistics| |
|  +-------------+  +-------------+  +-------------+  +-----------+ |
+------------------------------------------------------------------+

三、数据库设计

3.1 核心表结构

sql 复制代码
-- 优惠券模板表(定义券的基本属性)
CREATE TABLE `coupon_template` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '模板ID',
    `name` VARCHAR(64) NOT NULL COMMENT '券名称',
    `logo` VARCHAR(256) DEFAULT NULL COMMENT '券Logo',
    `description` VARCHAR(256) DEFAULT NULL COMMENT '券描述',
    `category` TINYINT(1) NOT NULL COMMENT '券类型:1-满减券,2-折扣券,3-立减券,4-运费券',
    `product_line` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '产品线:1-通用,2-品类,3-店铺,4-单品',
    `coupon_count` INT(11) NOT NULL COMMENT '总发行量',
    `user_limit` INT(11) NOT NULL DEFAULT 1 COMMENT '每人限领数量',
    `threshold` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '使用门槛金额',
    `discount_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '优惠金额(满减/立减)',
    `discount_rate` DECIMAL(4,2) DEFAULT NULL COMMENT '折扣率(折扣券)',
    `max_discount` DECIMAL(10,2) DEFAULT NULL COMMENT '最大优惠金额(折扣券上限)',
    `valid_type` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '有效期类型:1-固定时间,2-领取后N天',
    `valid_start_time` DATETIME DEFAULT NULL COMMENT '固定有效期开始时间',
    `valid_end_time` DATETIME DEFAULT NULL COMMENT '固定有效期结束时间',
    `valid_days` INT(11) DEFAULT NULL COMMENT '领取后有效天数',
    `rule_json` TEXT DEFAULT NULL COMMENT '使用规则JSON(品类ID、店铺ID、商品ID等)',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-草稿,2-待审核,3-已发布,4-已停止',
    `create_user` VARCHAR(32) DEFAULT NULL COMMENT '创建人',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_status` (`status`),
    KEY `idx_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板表';

-- 优惠券活动表(定义发券活动)
CREATE TABLE `coupon_activity` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '活动ID',
    `name` VARCHAR(64) NOT NULL COMMENT '活动名称',
    `template_id` BIGINT(20) NOT NULL COMMENT '券模板ID',
    `activity_type` TINYINT(1) NOT NULL COMMENT '活动类型:1-用户领取,2-系统发放,3-新人专享,4-会员专享',
    `total_count` INT(11) NOT NULL COMMENT '活动发券总量',
    `remaining_count` INT(11) NOT NULL COMMENT '剩余数量',
    `start_time` DATETIME NOT NULL COMMENT '活动开始时间',
    `end_time` DATETIME NOT NULL COMMENT '活动结束时间',
    `target_user` VARCHAR(512) DEFAULT NULL COMMENT '目标用户(JSON:会员等级、用户标签等)',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-未开始,2-进行中,3-已结束,4-已停止',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_template_id` (`template_id`),
    KEY `idx_status_time` (`status`, `start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券活动表';

-- 用户优惠券表(用户领取的券)
CREATE TABLE `user_coupon` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `coupon_code` VARCHAR(32) NOT NULL COMMENT '券码(唯一标识)',
    `user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
    `template_id` BIGINT(20) NOT NULL COMMENT '券模板ID',
    `activity_id` BIGINT(20) DEFAULT NULL COMMENT '活动ID',
    `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-未使用,2-已使用,3-已过期,4-已冻结',
    `receive_time` DATETIME NOT NULL COMMENT '领取时间',
    `valid_start_time` DATETIME NOT NULL COMMENT '有效期开始时间',
    `valid_end_time` DATETIME NOT NULL COMMENT '有效期结束时间',
    `use_time` DATETIME DEFAULT NULL COMMENT '使用时间',
    `order_no` VARCHAR(32) DEFAULT NULL COMMENT '使用订单号',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_coupon_code` (`coupon_code`),
    KEY `idx_user_status` (`user_id`, `status`),
    KEY `idx_template_id` (`template_id`),
    KEY `idx_valid_end_time` (`valid_end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户优惠券表';

-- 优惠券使用记录表
CREATE TABLE `coupon_use_record` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
    `coupon_code` VARCHAR(32) NOT NULL COMMENT '券码',
    `user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
    `order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
    `order_amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
    `discount_amount` DECIMAL(10,2) NOT NULL COMMENT '优惠金额',
    `use_time` DATETIME NOT NULL COMMENT '使用时间',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_coupon_code` (`coupon_code`),
    KEY `idx_order_no` (`order_no`),
    KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券使用记录表';

-- 领券记录表(用于限领校验)
CREATE TABLE `coupon_receive_record` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
    `user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
    `template_id` BIGINT(20) NOT NULL COMMENT '模板ID',
    `activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
    `receive_count` INT(11) NOT NULL DEFAULT 1 COMMENT '领取数量',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领券记录表';

3.2 ER关系图

lua 复制代码
+------------------+       +------------------+       +------------------+
|  coupon_template |       | coupon_activity  |       |   user_coupon    |
+------------------+       +------------------+       +------------------+
| id (PK)          |<------| template_id (FK) |       | id (PK)          |
| name             |       | id (PK)          |<------| activity_id (FK) |
| category         |       | name             |       | template_id (FK) |----+
| product_line     |       | activity_type    |       | user_id          |    |
| coupon_count     |       | total_count      |       | coupon_code      |    |
| threshold        |       | remaining_count  |       | status           |    |
| discount_amount  |       | start_time       |       | valid_start_time |    |
| discount_rate    |       | end_time         |       | valid_end_time   |    |
| ...              |       | status           |       | ...              |    |
+------------------+       +------------------+       +------------------+    |
         ^                                                                    |
         +--------------------------------------------------------------------+

四、核心实体类设计

4.1 实体类定义

java 复制代码
package com.coupon.service.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 优惠券模板实体
 */
@Data
@TableName("coupon_template")
public class CouponTemplate {

    @TableId(type = IdType.AUTO)
    private Long id;

    private String name;

    private String logo;

    private String description;

    /**
     * 券类型:1-满减券,2-折扣券,3-立减券,4-运费券
     */
    private Integer category;

    /**
     * 产品线:1-通用,2-品类,3-店铺,4-单品
     */
    private Integer productLine;

    private Integer couponCount;

    private Integer userLimit;

    /**
     * 使用门槛
     */
    private BigDecimal threshold;

    /**
     * 优惠金额
     */
    private BigDecimal discountAmount;

    /**
     * 折扣率
     */
    private BigDecimal discountRate;

    /**
     * 最大优惠金额
     */
    private BigDecimal maxDiscount;

    /**
     * 有效期类型:1-固定时间,2-领取后N天
     */
    private Integer validType;

    private LocalDateTime validStartTime;

    private LocalDateTime validEndTime;

    private Integer validDays;

    /**
     * 使用规则JSON
     */
    private String ruleJson;

    private Integer status;

    private String createUser;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}
java 复制代码
package com.coupon.service.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;

/**
 * 用户优惠券实体
 */
@Data
@TableName("user_coupon")
public class UserCoupon {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 券码
     */
    private String couponCode;

    private Long userId;

    private Long templateId;

    private Long activityId;

    /**
     * 状态:1-未使用,2-已使用,3-已过期,4-已冻结
     */
    private Integer status;

    private LocalDateTime receiveTime;

    private LocalDateTime validStartTime;

    private LocalDateTime validEndTime;

    private LocalDateTime useTime;

    private String orderNo;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

4.2 枚举定义

java 复制代码
package com.coupon.service.enums;

import lombok.Getter;

/**
 * 优惠券类型枚举
 */
@Getter
public enum CouponCategoryEnum {

    FULL_REDUCTION(1, "满减券"),
    DISCOUNT(2, "折扣券"),
    IMMEDIATE_REDUCTION(3, "立减券"),
    FREIGHT(4, "运费券");

    private final Integer code;
    private final String desc;

    CouponCategoryEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public static CouponCategoryEnum of(Integer code) {
        for (CouponCategoryEnum value : values()) {
            if (value.getCode().equals(code)) {
                return value;
            }
        }
        return null;
    }
}
java 复制代码
package com.coupon.service.enums;

import lombok.Getter;

/**
 * 用户券状态枚举
 */
@Getter
public enum UserCouponStatusEnum {

    UNUSED(1, "未使用"),
    USED(2, "已使用"),
    EXPIRED(3, "已过期"),
    FROZEN(4, "已冻结");

    private final Integer code;
    private final String desc;

    UserCouponStatusEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}
java 复制代码
package com.coupon.service.enums;

import lombok.Getter;

/**
 * 产品线枚举
 */
@Getter
public enum ProductLineEnum {

    UNIVERSAL(1, "通用券"),
    CATEGORY(2, "品类券"),
    SHOP(3, "店铺券"),
    PRODUCT(4, "单品券");

    private final Integer code;
    private final String desc;

    ProductLineEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}

4.3 DTO定义

java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import java.math.BigDecimal;
import java.util.List;

/**
 * 优惠券使用规则DTO
 */
@Data
public class CouponRuleDTO {

    /**
     * 适用品类ID列表
     */
    private List<Long> categoryIds;

    /**
     * 适用店铺ID列表
     */
    private List<Long> shopIds;

    /**
     * 适用商品ID列表
     */
    private List<Long> productIds;

    /**
     * 排除品类ID列表
     */
    private List<Long> excludeCategoryIds;

    /**
     * 排除商品ID列表
     */
    private List<Long> excludeProductIds;

    /**
     * 是否可与其他券叠加
     */
    private Boolean canStack;

    /**
     * 叠加上限
     */
    private Integer stackLimit;

    /**
     * 适用渠道:1-APP,2-小程序,3-H5,4-全渠道
     */
    private List<Integer> channels;

    /**
     * 适用支付方式
     */
    private List<Integer> payTypes;
}
java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 用户券信息DTO(展示用)
 */
@Data
public class UserCouponDTO {

    private Long id;

    private String couponCode;

    private String name;

    private String description;

    private Integer category;

    private String categoryDesc;

    private BigDecimal threshold;

    private BigDecimal discountAmount;

    private BigDecimal discountRate;

    private Integer status;

    private String statusDesc;

    private LocalDateTime validStartTime;

    private LocalDateTime validEndTime;

    /**
     * 是否即将过期(3天内)
     */
    private Boolean expiringSoon;

    /**
     * 使用范围描述
     */
    private String scopeDesc;
}

五、领券服务实现

5.1 领券流程

rust 复制代码
+----------+     +----------+     +----------+     +----------+     +----------+
| 请求校验  | --> | 活动校验  | --> | 限领校验  | --> | 库存扣减  | --> | 生成券码  |
+----------+     +----------+     +----------+     +----------+     +----------+
     |               |               |               |               |
     v               v               v               v               v
  参数校验       时间/状态校验    用户限领数校验    Redis原子扣减   雪花算法生成
  用户认证       目标用户校验    活动限领数校验    DB异步更新      写入user_coupon

5.2 领券服务核心代码

java 复制代码
package com.coupon.service.service;

import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.coupon.common.exception.CouponException;
import com.coupon.common.result.ResultCode;
import com.coupon.service.dto.ReceiveCouponRequest;
import com.coupon.service.entity.CouponActivity;
import com.coupon.service.entity.CouponReceiveRecord;
import com.coupon.service.entity.CouponTemplate;
import com.coupon.service.entity.UserCoupon;
import com.coupon.service.mapper.CouponActivityMapper;
import com.coupon.service.mapper.CouponReceiveRecordMapper;
import com.coupon.service.mapper.CouponTemplateMapper;
import com.coupon.service.mapper.UserCouponMapper;
import com.coupon.service.utils.CouponCodeGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;

/**
 * 领券服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponReceiveService {

    private final CouponActivityMapper activityMapper;
    private final CouponTemplateMapper templateMapper;
    private final UserCouponMapper userCouponMapper;
    private final CouponReceiveRecordMapper receiveRecordMapper;
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisScript<Long> receiveScript;

    private static final String ACTIVITY_STOCK_KEY = "coupon:activity:stock:";
    private static final String USER_RECEIVE_KEY = "coupon:receive:";

    /**
     * 领取优惠券
     */
    @Transactional(rollbackFor = Exception.class)
    public String receiveCoupon(Long userId, ReceiveCouponRequest request) {
        Long activityId = request.getActivityId();

        // 1. 查询活动信息
        CouponActivity activity = activityMapper.selectById(activityId);
        if (activity == null) {
            throw new CouponException(ResultCode.ACTIVITY_NOT_EXIST);
        }

        // 2. 校验活动状态
        validateActivity(activity);

        // 3. 查询券模板
        CouponTemplate template = templateMapper.selectById(activity.getTemplateId());
        if (template == null) {
            throw new CouponException(ResultCode.TEMPLATE_NOT_EXIST);
        }

        // 4. 校验用户领取资格
        validateUserEligibility(userId, activity, template);

        // 5. Redis原子扣减库存 + 记录领取
        Long result = executeReceiveScript(userId, activityId, template.getUserLimit());

        if (result == -1) {
            throw new CouponException(ResultCode.COUPON_STOCK_EMPTY);
        }
        if (result == -2) {
            throw new CouponException(ResultCode.COUPON_RECEIVE_LIMIT);
        }

        // 6. 生成券码
        String couponCode = CouponCodeGenerator.generate();

        // 7. 计算有效期
        LocalDateTime validStartTime;
        LocalDateTime validEndTime;

        if (template.getValidType() == 1) {
            // 固定有效期
            validStartTime = template.getValidStartTime();
            validEndTime = template.getValidEndTime();
        } else {
            // 领取后N天有效
            validStartTime = LocalDateTime.now();
            validEndTime = validStartTime.plusDays(template.getValidDays());
        }

        // 8. 保存用户券
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setCouponCode(couponCode);
        userCoupon.setUserId(userId);
        userCoupon.setTemplateId(template.getId());
        userCoupon.setActivityId(activityId);
        userCoupon.setStatus(1);  // 未使用
        userCoupon.setReceiveTime(LocalDateTime.now());
        userCoupon.setValidStartTime(validStartTime);
        userCoupon.setValidEndTime(validEndTime);

        userCouponMapper.insert(userCoupon);

        // 9. 更新领取记录
        updateReceiveRecord(userId, activityId, template.getId());

        log.info("用户领券成功: userId={}, activityId={}, couponCode={}",
            userId, activityId, couponCode);

        return couponCode;
    }

    /**
     * 校验活动状态
     */
    private void validateActivity(CouponActivity activity) {
        LocalDateTime now = LocalDateTime.now();

        if (activity.getStatus() == 4) {
            throw new CouponException(ResultCode.ACTIVITY_STOPPED);
        }

        if (now.isBefore(activity.getStartTime())) {
            throw new CouponException(ResultCode.ACTIVITY_NOT_START);
        }

        if (now.isAfter(activity.getEndTime())) {
            throw new CouponException(ResultCode.ACTIVITY_ENDED);
        }
    }

    /**
     * 校验用户领取资格
     */
    private void validateUserEligibility(Long userId, CouponActivity activity,
                                          CouponTemplate template) {
        // 校验目标用户(会员等级、用户标签等)
        if (activity.getTargetUser() != null && !activity.getTargetUser().isEmpty()) {
            // TODO: 调用用户服务校验用户标签
        }

        // 校验用户已领取数量
        String userReceiveKey = USER_RECEIVE_KEY + activity.getId() + ":" + userId;
        Object receivedCount = redisTemplate.opsForValue().get(userReceiveKey);

        if (receivedCount != null && Integer.parseInt(receivedCount.toString()) >= template.getUserLimit()) {
            throw new CouponException(ResultCode.COUPON_RECEIVE_LIMIT);
        }
    }

    /**
     * 执行领券Lua脚本
     */
    private Long executeReceiveScript(Long userId, Long activityId, Integer userLimit) {
        String stockKey = ACTIVITY_STOCK_KEY + activityId;
        String userKey = USER_RECEIVE_KEY + activityId + ":" + userId;

        return redisTemplate.execute(
            receiveScript,
            Arrays.asList(stockKey, userKey),
            userLimit
        );
    }

    /**
     * 更新领取记录
     */
    private void updateReceiveRecord(Long userId, Long activityId, Long templateId) {
        CouponReceiveRecord record = receiveRecordMapper.selectOne(
            new LambdaQueryWrapper<CouponReceiveRecord>()
                .eq(CouponReceiveRecord::getUserId, userId)
                .eq(CouponReceiveRecord::getActivityId, activityId)
        );

        if (record == null) {
            record = new CouponReceiveRecord();
            record.setUserId(userId);
            record.setTemplateId(templateId);
            record.setActivityId(activityId);
            record.setReceiveCount(1);
            receiveRecordMapper.insert(record);
        } else {
            record.setReceiveCount(record.getReceiveCount() + 1);
            receiveRecordMapper.updateById(record);
        }
    }
}

5.3 领券Lua脚本

lua 复制代码
-- 领券Lua脚本
-- KEYS[1]: 活动库存key
-- KEYS[2]: 用户领取记录key
-- ARGV[1]: 用户限领数量

local stockKey = KEYS[1]
local userKey = KEYS[2]
local userLimit = tonumber(ARGV[1])

-- 检查库存
local stock = tonumber(redis.call('GET', stockKey) or 0)
if stock <= 0 then
    return -1  -- 库存不足
end

-- 检查用户领取数量
local userReceived = tonumber(redis.call('GET', userKey) or 0)
if userReceived >= userLimit then
    return -2  -- 超过限领数量
end

-- 扣减库存
redis.call('DECR', stockKey)

-- 增加用户领取数量
redis.call('INCR', userKey)
redis.call('EXPIRE', userKey, 86400 * 30)  -- 30天过期

return stock - 1  -- 返回剩余库存

5.4 券码生成器

java 复制代码
package com.coupon.service.utils;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;

/**
 * 券码生成器
 */
public class CouponCodeGenerator {

    private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyMMdd");

    /**
     * 生成券码
     * 格式:日期(6位) + 随机字符(10位) = 16位
     */
    public static String generate() {
        StringBuilder sb = new StringBuilder();

        // 日期部分
        sb.append(LocalDateTime.now().format(FORMATTER));

        // 随机字符部分
        ThreadLocalRandom random = ThreadLocalRandom.current();
        for (int i = 0; i < 10; i++) {
            sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
        }

        return sb.toString();
    }

    /**
     * 生成带前缀的券码
     */
    public static String generateWithPrefix(String prefix) {
        return prefix + generate();
    }
}

六、用券服务实现

6.1 优惠计算流程

lua 复制代码
                         +------------------+
                         |    订单商品列表   |
                         +--------+---------+
                                  |
                         +--------v---------+
                         |   查询可用券列表   |
                         +--------+---------+
                                  |
         +------------------------+------------------------+
         |                        |                        |
+--------v--------+     +---------v--------+     +---------v--------+
|   满减券计算     |     |   折扣券计算      |     |   立减券计算      |
|  threshold校验  |     |  discountRate    |     |  直接减免         |
|  适用商品筛选   |     |  maxDiscount上限  |     |                  |
+--------+--------+     +---------+--------+     +---------+--------+
         |                        |                        |
         +------------------------+------------------------+
                                  |
                         +--------v---------+
                         |   最优组合计算    |
                         +--------+---------+
                                  |
                         +--------v---------+
                         |   返回优惠明细    |
                         +------------------+

6.2 优惠计算服务

java 复制代码
package com.coupon.service.service;

import com.alibaba.fastjson.JSON;
import com.coupon.service.dto.CouponRuleDTO;
import com.coupon.service.dto.OrderItemDTO;
import com.coupon.service.dto.CouponCalculateResult;
import com.coupon.service.dto.UserCouponDTO;
import com.coupon.service.entity.CouponTemplate;
import com.coupon.service.entity.UserCoupon;
import com.coupon.service.enums.CouponCategoryEnum;
import com.coupon.service.enums.UserCouponStatusEnum;
import com.coupon.service.mapper.CouponTemplateMapper;
import com.coupon.service.mapper.UserCouponMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 优惠券计算服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponCalculateService {

    private final UserCouponMapper userCouponMapper;
    private final CouponTemplateMapper templateMapper;

    /**
     * 计算订单可用优惠券
     */
    public List<CouponCalculateResult> calculateAvailableCoupons(
            Long userId, List<OrderItemDTO> orderItems) {

        // 1. 查询用户未使用的有效券
        List<UserCoupon> userCoupons = userCouponMapper.selectAvailableCoupons(
            userId, UserCouponStatusEnum.UNUSED.getCode(), LocalDateTime.now());

        if (userCoupons.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 获取券模板信息
        List<Long> templateIds = userCoupons.stream()
            .map(UserCoupon::getTemplateId)
            .distinct()
            .collect(Collectors.toList());

        Map<Long, CouponTemplate> templateMap = templateMapper.selectBatchIds(templateIds)
            .stream()
            .collect(Collectors.toMap(CouponTemplate::getId, t -> t));

        // 3. 计算每张券的优惠金额
        List<CouponCalculateResult> results = new ArrayList<>();

        for (UserCoupon userCoupon : userCoupons) {
            CouponTemplate template = templateMap.get(userCoupon.getTemplateId());
            if (template == null) continue;

            CouponCalculateResult result = calculateSingleCoupon(
                userCoupon, template, orderItems);

            if (result != null && result.isAvailable()) {
                results.add(result);
            }
        }

        // 4. 按优惠金额降序排序
        results.sort((a, b) -> b.getDiscountAmount().compareTo(a.getDiscountAmount()));

        return results;
    }

    /**
     * 计算单张券的优惠
     */
    private CouponCalculateResult calculateSingleCoupon(
            UserCoupon userCoupon, CouponTemplate template, List<OrderItemDTO> orderItems) {

        CouponCalculateResult result = new CouponCalculateResult();
        result.setCouponCode(userCoupon.getCouponCode());
        result.setCouponName(template.getName());
        result.setCategory(template.getCategory());

        // 1. 筛选适用商品
        List<OrderItemDTO> applicableItems = filterApplicableItems(template, orderItems);

        if (applicableItems.isEmpty()) {
            result.setAvailable(false);
            result.setUnavailableReason("无适用商品");
            return result;
        }

        // 2. 计算适用商品总金额
        BigDecimal applicableAmount = applicableItems.stream()
            .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        // 3. 校验使用门槛
        if (applicableAmount.compareTo(template.getThreshold()) < 0) {
            result.setAvailable(false);
            result.setUnavailableReason(String.format("未满%.2f元", template.getThreshold()));
            return result;
        }

        // 4. 计算优惠金额
        BigDecimal discountAmount = calculateDiscount(template, applicableAmount);

        result.setAvailable(true);
        result.setApplicableAmount(applicableAmount);
        result.setDiscountAmount(discountAmount);
        result.setApplicableItems(applicableItems);

        return result;
    }

    /**
     * 筛选适用商品
     */
    private List<OrderItemDTO> filterApplicableItems(
            CouponTemplate template, List<OrderItemDTO> orderItems) {

        // 解析使用规则
        CouponRuleDTO rule = null;
        if (template.getRuleJson() != null && !template.getRuleJson().isEmpty()) {
            rule = JSON.parseObject(template.getRuleJson(), CouponRuleDTO.class);
        }

        // 通用券,所有商品适用
        if (template.getProductLine() == 1 || rule == null) {
            return new ArrayList<>(orderItems);
        }

        final CouponRuleDTO finalRule = rule;

        return orderItems.stream()
            .filter(item -> isItemApplicable(item, template.getProductLine(), finalRule))
            .collect(Collectors.toList());
    }

    /**
     * 判断商品是否适用
     */
    private boolean isItemApplicable(OrderItemDTO item, Integer productLine, CouponRuleDTO rule) {
        // 排除商品校验
        if (rule.getExcludeProductIds() != null &&
            rule.getExcludeProductIds().contains(item.getProductId())) {
            return false;
        }

        // 排除品类校验
        if (rule.getExcludeCategoryIds() != null &&
            rule.getExcludeCategoryIds().contains(item.getCategoryId())) {
            return false;
        }

        switch (productLine) {
            case 2:  // 品类券
                return rule.getCategoryIds() != null &&
                       rule.getCategoryIds().contains(item.getCategoryId());
            case 3:  // 店铺券
                return rule.getShopIds() != null &&
                       rule.getShopIds().contains(item.getShopId());
            case 4:  // 单品券
                return rule.getProductIds() != null &&
                       rule.getProductIds().contains(item.getProductId());
            default:
                return true;
        }
    }

    /**
     * 计算优惠金额
     */
    private BigDecimal calculateDiscount(CouponTemplate template, BigDecimal applicableAmount) {
        CouponCategoryEnum category = CouponCategoryEnum.of(template.getCategory());

        if (category == null) {
            return BigDecimal.ZERO;
        }

        switch (category) {
            case FULL_REDUCTION:
            case IMMEDIATE_REDUCTION:
                // 满减券、立减券:直接返回优惠金额
                return template.getDiscountAmount();

            case DISCOUNT:
                // 折扣券:计算折扣金额,但不超过上限
                BigDecimal discount = applicableAmount
                    .multiply(BigDecimal.ONE.subtract(template.getDiscountRate()))
                    .setScale(2, RoundingMode.HALF_UP);

                // 校验最大优惠上限
                if (template.getMaxDiscount() != null &&
                    discount.compareTo(template.getMaxDiscount()) > 0) {
                    return template.getMaxDiscount();
                }
                return discount;

            case FREIGHT:
                // 运费券:返回运费金额(由订单服务传入)
                return template.getDiscountAmount();

            default:
                return BigDecimal.ZERO;
        }
    }

    /**
     * 计算最优券组合(支持叠加)
     */
    public List<CouponCalculateResult> calculateBestCombination(
            Long userId, List<OrderItemDTO> orderItems, Integer maxStack) {

        List<CouponCalculateResult> availableCoupons = calculateAvailableCoupons(userId, orderItems);

        if (availableCoupons.isEmpty()) {
            return Collections.emptyList();
        }

        // 不支持叠加,返回最优单张券
        if (maxStack == null || maxStack <= 1) {
            return Collections.singletonList(availableCoupons.get(0));
        }

        // 支持叠加:使用贪心算法选择最优组合
        return selectBestCombination(availableCoupons, orderItems, maxStack);
    }

    /**
     * 贪心算法选择最优券组合
     */
    private List<CouponCalculateResult> selectBestCombination(
            List<CouponCalculateResult> coupons, List<OrderItemDTO> orderItems, int maxStack) {

        List<CouponCalculateResult> selected = new ArrayList<>();
        Set<Long> usedProducts = new HashSet<>();
        BigDecimal totalDiscount = BigDecimal.ZERO;

        // 按优惠金额排序,优先选择优惠大的
        List<CouponCalculateResult> sorted = new ArrayList<>(coupons);
        sorted.sort((a, b) -> b.getDiscountAmount().compareTo(a.getDiscountAmount()));

        for (CouponCalculateResult coupon : sorted) {
            if (selected.size() >= maxStack) {
                break;
            }

            // 检查是否有商品重叠(简化处理:不允许同一商品使用多张券)
            boolean hasOverlap = coupon.getApplicableItems().stream()
                .anyMatch(item -> usedProducts.contains(item.getProductId()));

            if (!hasOverlap) {
                selected.add(coupon);
                totalDiscount = totalDiscount.add(coupon.getDiscountAmount());

                // 标记已使用的商品
                coupon.getApplicableItems().forEach(item ->
                    usedProducts.add(item.getProductId()));
            }
        }

        return selected;
    }
}

6.3 计算结果DTO

java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import java.math.BigDecimal;
import java.util.List;

/**
 * 优惠券计算结果
 */
@Data
public class CouponCalculateResult {

    /**
     * 券码
     */
    private String couponCode;

    /**
     * 券名称
     */
    private String couponName;

    /**
     * 券类型
     */
    private Integer category;

    /**
     * 是否可用
     */
    private boolean available;

    /**
     * 不可用原因
     */
    private String unavailableReason;

    /**
     * 适用商品总金额
     */
    private BigDecimal applicableAmount;

    /**
     * 优惠金额
     */
    private BigDecimal discountAmount;

    /**
     * 适用商品列表
     */
    private List<OrderItemDTO> applicableItems;
}
java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import java.math.BigDecimal;

/**
 * 订单商品DTO
 */
@Data
public class OrderItemDTO {

    /**
     * 商品ID
     */
    private Long productId;

    /**
     * 商品名称
     */
    private String productName;

    /**
     * 品类ID
     */
    private Long categoryId;

    /**
     * 店铺ID
     */
    private Long shopId;

    /**
     * 商品单价
     */
    private BigDecimal price;

    /**
     * 购买数量
     */
    private Integer quantity;

    /**
     * 商品小计
     */
    private BigDecimal subtotal;
}

七、用券核销服务

7.1 核销流程

lua 复制代码
+----------+     +----------+     +----------+     +----------+     +----------+
| 参数校验  | --> | 券状态校验 | --> | 有效期校验 | --> | 规则校验  | --> | 冻结优惠券 |
+----------+     +----------+     +----------+     +----------+     +----------+
                                                                          |
                                                                          v
+----------+     +----------+     +----------+                      +----------+
| 使用记录  | <-- | 更新状态  | <-- | 订单支付  | <------------------ | 预占优惠券 |
+----------+     +----------+     +----------+                      +----------+
                      |
                      v
               +----------+
               | 订单取消  |
               | 释放券    |
               +----------+

7.2 核销服务实现

java 复制代码
package com.coupon.service.service;

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.coupon.common.exception.CouponException;
import com.coupon.common.result.ResultCode;
import com.coupon.service.dto.UseCouponRequest;
import com.coupon.service.entity.CouponTemplate;
import com.coupon.service.entity.CouponUseRecord;
import com.coupon.service.entity.UserCoupon;
import com.coupon.service.enums.UserCouponStatusEnum;
import com.coupon.service.mapper.CouponTemplateMapper;
import com.coupon.service.mapper.CouponUseRecordMapper;
import com.coupon.service.mapper.UserCouponMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

/**
 * 优惠券核销服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponUseService {

    private final UserCouponMapper userCouponMapper;
    private final CouponTemplateMapper templateMapper;
    private final CouponUseRecordMapper useRecordMapper;
    private final CouponCalculateService calculateService;
    private final RedissonClient redissonClient;

    private static final String COUPON_LOCK_PREFIX = "coupon:lock:";

    /**
     * 预占优惠券(下单时调用)
     */
    @Transactional(rollbackFor = Exception.class)
    public void lockCoupon(Long userId, String couponCode, String orderNo) {
        String lockKey = COUPON_LOCK_PREFIX + couponCode;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (!acquired) {
                throw new CouponException(ResultCode.COUPON_BUSY);
            }

            // 1. 查询优惠券
            UserCoupon userCoupon = userCouponMapper.selectByCode(couponCode);
            if (userCoupon == null) {
                throw new CouponException(ResultCode.COUPON_NOT_EXIST);
            }

            // 2. 校验券归属
            if (!userCoupon.getUserId().equals(userId)) {
                throw new CouponException(ResultCode.COUPON_NOT_BELONG);
            }

            // 3. 校验券状态
            if (!UserCouponStatusEnum.UNUSED.getCode().equals(userCoupon.getStatus())) {
                throw new CouponException(ResultCode.COUPON_STATUS_ERROR);
            }

            // 4. 校验有效期
            LocalDateTime now = LocalDateTime.now();
            if (now.isBefore(userCoupon.getValidStartTime()) ||
                now.isAfter(userCoupon.getValidEndTime())) {
                throw new CouponException(ResultCode.COUPON_EXPIRED);
            }

            // 5. 冻结优惠券
            int affected = userCouponMapper.update(null,
                new LambdaUpdateWrapper<UserCoupon>()
                    .eq(UserCoupon::getCouponCode, couponCode)
                    .eq(UserCoupon::getStatus, UserCouponStatusEnum.UNUSED.getCode())
                    .set(UserCoupon::getStatus, UserCouponStatusEnum.FROZEN.getCode())
                    .set(UserCoupon::getOrderNo, orderNo)
            );

            if (affected == 0) {
                throw new CouponException(ResultCode.COUPON_STATUS_ERROR);
            }

            log.info("优惠券预占成功: couponCode={}, orderNo={}", couponCode, orderNo);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CouponException(ResultCode.SYSTEM_ERROR);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /**
     * 使用优惠券(支付成功后调用)
     */
    @Transactional(rollbackFor = Exception.class)
    public void useCoupon(UseCouponRequest request) {
        String couponCode = request.getCouponCode();

        // 1. 查询优惠券
        UserCoupon userCoupon = userCouponMapper.selectByCode(couponCode);
        if (userCoupon == null) {
            throw new CouponException(ResultCode.COUPON_NOT_EXIST);
        }

        // 2. 校验状态(必须是冻结状态)
        if (!UserCouponStatusEnum.FROZEN.getCode().equals(userCoupon.getStatus())) {
            throw new CouponException(ResultCode.COUPON_STATUS_ERROR);
        }

        // 3. 校验订单号
        if (!request.getOrderNo().equals(userCoupon.getOrderNo())) {
            throw new CouponException(ResultCode.COUPON_ORDER_NOT_MATCH);
        }

        // 4. 更新为已使用
        LocalDateTime now = LocalDateTime.now();
        userCouponMapper.update(null,
            new LambdaUpdateWrapper<UserCoupon>()
                .eq(UserCoupon::getCouponCode, couponCode)
                .set(UserCoupon::getStatus, UserCouponStatusEnum.USED.getCode())
                .set(UserCoupon::getUseTime, now)
        );

        // 5. 记录使用流水
        CouponUseRecord record = new CouponUseRecord();
        record.setCouponCode(couponCode);
        record.setUserId(userCoupon.getUserId());
        record.setOrderNo(request.getOrderNo());
        record.setOrderAmount(request.getOrderAmount());
        record.setDiscountAmount(request.getDiscountAmount());
        record.setUseTime(now);

        useRecordMapper.insert(record);

        log.info("优惠券核销成功: couponCode={}, orderNo={}, discountAmount={}",
            couponCode, request.getOrderNo(), request.getDiscountAmount());
    }

    /**
     * 释放优惠券(订单取消/支付超时时调用)
     */
    @Transactional(rollbackFor = Exception.class)
    public void releaseCoupon(String couponCode, String orderNo) {
        // 1. 查询优惠券
        UserCoupon userCoupon = userCouponMapper.selectByCode(couponCode);
        if (userCoupon == null) {
            log.warn("优惠券不存在,跳过释放: couponCode={}", couponCode);
            return;
        }

        // 2. 校验状态
        if (!UserCouponStatusEnum.FROZEN.getCode().equals(userCoupon.getStatus())) {
            log.warn("优惠券状态不是冻结,跳过释放: couponCode={}, status={}",
                couponCode, userCoupon.getStatus());
            return;
        }

        // 3. 校验订单号
        if (!orderNo.equals(userCoupon.getOrderNo())) {
            log.warn("订单号不匹配,跳过释放: couponCode={}, expectedOrder={}, actualOrder={}",
                couponCode, orderNo, userCoupon.getOrderNo());
            return;
        }

        // 4. 检查是否已过期
        LocalDateTime now = LocalDateTime.now();
        Integer newStatus;
        if (now.isAfter(userCoupon.getValidEndTime())) {
            newStatus = UserCouponStatusEnum.EXPIRED.getCode();
        } else {
            newStatus = UserCouponStatusEnum.UNUSED.getCode();
        }

        // 5. 释放优惠券
        userCouponMapper.update(null,
            new LambdaUpdateWrapper<UserCoupon>()
                .eq(UserCoupon::getCouponCode, couponCode)
                .eq(UserCoupon::getOrderNo, orderNo)
                .set(UserCoupon::getStatus, newStatus)
                .set(UserCoupon::getOrderNo, null)
        );

        log.info("优惠券释放成功: couponCode={}, orderNo={}, newStatus={}",
            couponCode, orderNo, newStatus);
    }
}

八、过期处理服务

8.1 过期处理方案

diff 复制代码
+------------------+------------------+------------------+
|    定时扫描       |    延迟队列       |    被动过期       |
+------------------+------------------+------------------+
| 优点:实现简单    | 优点:时效性好    | 优点:无额外开销   |
| 缺点:时效性差    | 缺点:消息堆积    | 缺点:数据不一致   |
| 适用:T+1结算    | 适用:精确过期    | 适用:查询时校验   |
+------------------+------------------+------------------+

8.2 定时任务实现

java 复制代码
package com.coupon.service.job;

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.coupon.service.entity.UserCoupon;
import com.coupon.service.enums.UserCouponStatusEnum;
import com.coupon.service.mapper.UserCouponMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 优惠券过期处理定时任务
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponExpireJob {

    private final UserCouponMapper userCouponMapper;

    /**
     * 每小时执行一次,处理过期券
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void processExpiredCoupons() {
        log.info("开始处理过期优惠券...");

        LocalDateTime now = LocalDateTime.now();
        int batchSize = 1000;
        int totalProcessed = 0;

        while (true) {
            // 分批查询过期券
            List<UserCoupon> expiredCoupons = userCouponMapper.selectExpiredCoupons(
                UserCouponStatusEnum.UNUSED.getCode(),
                now,
                batchSize
            );

            if (expiredCoupons.isEmpty()) {
                break;
            }

            // 批量更新状态
            List<String> couponCodes = expiredCoupons.stream()
                .map(UserCoupon::getCouponCode)
                .toList();

            int affected = userCouponMapper.batchUpdateStatus(
                couponCodes,
                UserCouponStatusEnum.EXPIRED.getCode()
            );

            totalProcessed += affected;

            log.info("本批次处理过期券: {}", affected);

            // 如果本批次数据量小于批次大小,说明已处理完
            if (expiredCoupons.size() < batchSize) {
                break;
            }
        }

        log.info("过期优惠券处理完成,共处理: {}", totalProcessed);
    }

    /**
     * 处理冻结超时的券(30分钟未支付)
     */
    @Scheduled(cron = "0 */5 * * * ?")
    public void processFrozenTimeoutCoupons() {
        log.info("开始处理冻结超时优惠券...");

        LocalDateTime timeoutThreshold = LocalDateTime.now().minusMinutes(30);

        // 查询冻结超过30分钟的券
        List<UserCoupon> frozenCoupons = userCouponMapper.selectFrozenTimeout(
            UserCouponStatusEnum.FROZEN.getCode(),
            timeoutThreshold,
            500
        );

        for (UserCoupon coupon : frozenCoupons) {
            try {
                // 检查是否过期
                LocalDateTime now = LocalDateTime.now();
                Integer newStatus = now.isAfter(coupon.getValidEndTime()) ?
                    UserCouponStatusEnum.EXPIRED.getCode() :
                    UserCouponStatusEnum.UNUSED.getCode();

                userCouponMapper.update(null,
                    new LambdaUpdateWrapper<UserCoupon>()
                        .eq(UserCoupon::getCouponCode, coupon.getCouponCode())
                        .eq(UserCoupon::getStatus, UserCouponStatusEnum.FROZEN.getCode())
                        .set(UserCoupon::getStatus, newStatus)
                        .set(UserCoupon::getOrderNo, null)
                );

                log.info("冻结超时券已释放: couponCode={}", coupon.getCouponCode());

            } catch (Exception e) {
                log.error("处理冻结超时券失败: couponCode={}", coupon.getCouponCode(), e);
            }
        }

        log.info("冻结超时优惠券处理完成,共处理: {}", frozenCoupons.size());
    }
}

8.3 延迟队列方案(RocketMQ)

java 复制代码
package com.coupon.service.mq;

import com.alibaba.fastjson.JSON;
import com.coupon.service.dto.CouponExpireMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.LocalDateTime;

/**
 * 优惠券过期消息生产者
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponExpireProducer {

    private final RocketMQTemplate rocketMQTemplate;

    private static final String TOPIC = "coupon-expire-topic";

    /**
     * 发送过期消息
     */
    public void sendExpireMessage(String couponCode, LocalDateTime expireTime) {
        CouponExpireMessage message = new CouponExpireMessage();
        message.setCouponCode(couponCode);
        message.setExpireTime(expireTime);

        String jsonMessage = JSON.toJSONString(message);
        Message<String> msg = MessageBuilder.withPayload(jsonMessage).build();

        // 计算延迟级别
        int delayLevel = calculateDelayLevel(expireTime);

        rocketMQTemplate.syncSend(TOPIC, msg, 3000, delayLevel);

        log.debug("过期消息已发送: couponCode={}, expireTime={}", couponCode, expireTime);
    }

    /**
     * 计算延迟级别
     * RocketMQ延迟级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
     */
    private int calculateDelayLevel(LocalDateTime expireTime) {
        Duration duration = Duration.between(LocalDateTime.now(), expireTime);
        long hours = duration.toHours();

        if (hours <= 1) return 17;  // 1h
        if (hours <= 2) return 18;  // 2h
        // 超过2小时的,设置为2小时后重新计算
        return 18;
    }
}
java 复制代码
package com.coupon.service.mq;

import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.coupon.service.dto.CouponExpireMessage;
import com.coupon.service.entity.UserCoupon;
import com.coupon.service.enums.UserCouponStatusEnum;
import com.coupon.service.mapper.UserCouponMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * 优惠券过期消息消费者
 */
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
    topic = "coupon-expire-topic",
    consumerGroup = "coupon-expire-consumer-group"
)
public class CouponExpireConsumer implements RocketMQListener<String> {

    private final UserCouponMapper userCouponMapper;
    private final CouponExpireProducer expireProducer;

    @Override
    public void onMessage(String message) {
        log.debug("收到过期消息: {}", message);

        try {
            CouponExpireMessage expireMessage = JSON.parseObject(message, CouponExpireMessage.class);
            String couponCode = expireMessage.getCouponCode();
            LocalDateTime expireTime = expireMessage.getExpireTime();

            // 查询券信息
            UserCoupon userCoupon = userCouponMapper.selectByCode(couponCode);
            if (userCoupon == null) {
                return;
            }

            // 非未使用状态,不处理
            if (!UserCouponStatusEnum.UNUSED.getCode().equals(userCoupon.getStatus())) {
                return;
            }

            LocalDateTime now = LocalDateTime.now();

            // 还未到过期时间,重新发送延迟消息
            if (now.isBefore(expireTime)) {
                expireProducer.sendExpireMessage(couponCode, expireTime);
                return;
            }

            // 更新为过期状态
            userCouponMapper.update(null,
                new LambdaUpdateWrapper<UserCoupon>()
                    .eq(UserCoupon::getCouponCode, couponCode)
                    .eq(UserCoupon::getStatus, UserCouponStatusEnum.UNUSED.getCode())
                    .set(UserCoupon::getStatus, UserCouponStatusEnum.EXPIRED.getCode())
            );

            log.info("优惠券已过期: couponCode={}", couponCode);

        } catch (Exception e) {
            log.error("处理过期消息失败: {}", message, e);
        }
    }
}

九、库存管理

9.1 库存预热

java 复制代码
package com.coupon.service.service;

import com.coupon.service.entity.CouponActivity;
import com.coupon.service.mapper.CouponActivityMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 库存管理服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class StockService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponActivityMapper activityMapper;

    private static final String ACTIVITY_STOCK_KEY = "coupon:activity:stock:";

    /**
     * 启动时预热库存
     */
    @PostConstruct
    public void warmUpOnStartup() {
        log.info("系统启动,开始预热优惠券库存...");
        warmUpStock();
    }

    /**
     * 定时预热库存(每5分钟)
     */
    @Scheduled(cron = "0 */5 * * * ?")
    public void scheduledWarmUp() {
        warmUpStock();
    }

    /**
     * 预热库存
     */
    public void warmUpStock() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime future = now.plusHours(1);  // 预热1小时内的活动

        // 查询即将开始或进行中的活动
        List<CouponActivity> activities = activityMapper.selectActiveActivities(now, future);

        for (CouponActivity activity : activities) {
            String stockKey = ACTIVITY_STOCK_KEY + activity.getId();

            // 检查是否已预热
            if (Boolean.TRUE.equals(redisTemplate.hasKey(stockKey))) {
                continue;
            }

            // 预热库存
            redisTemplate.opsForValue().set(stockKey, activity.getRemainingCount());

            // 设置过期时间(活动结束后1小时)
            long expireSeconds = java.time.Duration.between(
                now, activity.getEndTime().plusHours(1)).getSeconds();
            if (expireSeconds > 0) {
                redisTemplate.expire(stockKey, expireSeconds, TimeUnit.SECONDS);
            }

            log.info("活动库存预热完成: activityId={}, stock={}",
                activity.getId(), activity.getRemainingCount());
        }
    }

    /**
     * 获取库存
     */
    public Integer getStock(Long activityId) {
        String key = ACTIVITY_STOCK_KEY + activityId;
        Object stock = redisTemplate.opsForValue().get(key);
        return stock != null ? Integer.parseInt(stock.toString()) : 0;
    }

    /**
     * 同步库存到数据库
     */
    @Scheduled(cron = "0 */10 * * * ?")
    public void syncStockToDb() {
        log.info("开始同步库存到数据库...");

        List<CouponActivity> activities = activityMapper.selectActiveActivities(
            LocalDateTime.now(), LocalDateTime.now().plusDays(7));

        for (CouponActivity activity : activities) {
            String stockKey = ACTIVITY_STOCK_KEY + activity.getId();
            Object redisStock = redisTemplate.opsForValue().get(stockKey);

            if (redisStock != null) {
                int stock = Integer.parseInt(redisStock.toString());
                activityMapper.updateRemainingCount(activity.getId(), stock);
            }
        }

        log.info("库存同步完成");
    }
}

十、接口层实现

10.1 Controller

java 复制代码
package com.coupon.service.controller;

import com.coupon.common.result.Result;
import com.coupon.service.dto.*;
import com.coupon.service.service.CouponCalculateService;
import com.coupon.service.service.CouponQueryService;
import com.coupon.service.service.CouponReceiveService;
import com.coupon.service.service.CouponUseService;
import com.coupon.service.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

/**
 * 优惠券接口
 */
@Slf4j
@RestController
@RequestMapping("/coupon")
@RequiredArgsConstructor
public class CouponController {

    private final CouponReceiveService receiveService;
    private final CouponQueryService queryService;
    private final CouponCalculateService calculateService;
    private final CouponUseService useService;

    /**
     * 领取优惠券
     */
    @PostMapping("/receive")
    public Result<String> receiveCoupon(@Valid @RequestBody ReceiveCouponRequest request) {
        Long userId = UserContext.getCurrentUserId();
        String couponCode = receiveService.receiveCoupon(userId, request);
        return Result.success(couponCode);
    }

    /**
     * 查询用户优惠券列表
     */
    @GetMapping("/list")
    public Result<List<UserCouponDTO>> getUserCoupons(
            @RequestParam(required = false) Integer status) {
        Long userId = UserContext.getCurrentUserId();
        List<UserCouponDTO> coupons = queryService.getUserCoupons(userId, status);
        return Result.success(coupons);
    }

    /**
     * 查询优惠券详情
     */
    @GetMapping("/{couponCode}")
    public Result<UserCouponDTO> getCouponDetail(@PathVariable String couponCode) {
        Long userId = UserContext.getCurrentUserId();
        UserCouponDTO coupon = queryService.getCouponDetail(userId, couponCode);
        return Result.success(coupon);
    }

    /**
     * 计算订单可用优惠券
     */
    @PostMapping("/calculate")
    public Result<List<CouponCalculateResult>> calculateCoupons(
            @Valid @RequestBody CalculateCouponRequest request) {
        Long userId = UserContext.getCurrentUserId();
        List<CouponCalculateResult> results = calculateService.calculateAvailableCoupons(
            userId, request.getOrderItems());
        return Result.success(results);
    }

    /**
     * 预占优惠券
     */
    @PostMapping("/lock")
    public Result<Void> lockCoupon(@Valid @RequestBody LockCouponRequest request) {
        Long userId = UserContext.getCurrentUserId();
        useService.lockCoupon(userId, request.getCouponCode(), request.getOrderNo());
        return Result.success();
    }

    /**
     * 使用优惠券
     */
    @PostMapping("/use")
    public Result<Void> useCoupon(@Valid @RequestBody UseCouponRequest request) {
        useService.useCoupon(request);
        return Result.success();
    }

    /**
     * 释放优惠券
     */
    @PostMapping("/release")
    public Result<Void> releaseCoupon(@Valid @RequestBody ReleaseCouponRequest request) {
        useService.releaseCoupon(request.getCouponCode(), request.getOrderNo());
        return Result.success();
    }

    /**
     * 查询可领取的活动列表
     */
    @GetMapping("/activity/list")
    public Result<List<CouponActivityDTO>> getActivityList() {
        Long userId = UserContext.getCurrentUserId();
        List<CouponActivityDTO> activities = queryService.getAvailableActivities(userId);
        return Result.success(activities);
    }
}

10.2 请求DTO

java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import javax.validation.constraints.NotNull;

/**
 * 领券请求
 */
@Data
public class ReceiveCouponRequest {

    @NotNull(message = "活动ID不能为空")
    private Long activityId;
}
java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;

/**
 * 使用优惠券请求
 */
@Data
public class UseCouponRequest {

    @NotBlank(message = "券码不能为空")
    private String couponCode;

    @NotBlank(message = "订单号不能为空")
    private String orderNo;

    @NotNull(message = "订单金额不能为空")
    private BigDecimal orderAmount;

    @NotNull(message = "优惠金额不能为空")
    private BigDecimal discountAmount;
}

十一、统计分析

11.1 统计指标

diff 复制代码
+------------------+------------------+------------------+------------------+
|    发放统计       |    领取统计       |    使用统计       |    效果分析       |
+------------------+------------------+------------------+------------------+
| 发放总量          | 领取总量          | 使用总量          | 核销率           |
| 发放金额          | 领取人数          | 使用人数          | 拉新效果         |
| 活动维度统计      | 渠道维度统计      | 订单维度统计      | GMV贡献          |
+------------------+------------------+------------------+------------------+

11.2 统计服务

java 复制代码
package com.coupon.service.service;

import com.coupon.service.dto.CouponStatisticsDTO;
import com.coupon.service.mapper.CouponStatisticsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;

/**
 * 优惠券统计服务
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponStatisticsService {

    private final CouponStatisticsMapper statisticsMapper;

    /**
     * 获取活动统计数据
     */
    public CouponStatisticsDTO getActivityStatistics(Long activityId) {
        CouponStatisticsDTO stats = new CouponStatisticsDTO();

        // 发放统计
        stats.setTotalIssued(statisticsMapper.countIssuedByActivity(activityId));

        // 领取统计
        stats.setTotalReceived(statisticsMapper.countReceivedByActivity(activityId));
        stats.setReceivedUsers(statisticsMapper.countReceivedUsersByActivity(activityId));

        // 使用统计
        stats.setTotalUsed(statisticsMapper.countUsedByActivity(activityId));
        stats.setUsedUsers(statisticsMapper.countUsedUsersByActivity(activityId));

        // 金额统计
        stats.setTotalDiscountAmount(statisticsMapper.sumDiscountAmountByActivity(activityId));

        // 计算核销率
        if (stats.getTotalReceived() > 0) {
            BigDecimal useRate = BigDecimal.valueOf(stats.getTotalUsed())
                .divide(BigDecimal.valueOf(stats.getTotalReceived()), 4, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100));
            stats.setUseRate(useRate);
        }

        return stats;
    }

    /**
     * 获取日统计数据
     */
    public CouponStatisticsDTO getDailyStatistics(LocalDate date) {
        CouponStatisticsDTO stats = new CouponStatisticsDTO();

        stats.setTotalReceived(statisticsMapper.countReceivedByDate(date));
        stats.setTotalUsed(statisticsMapper.countUsedByDate(date));
        stats.setTotalDiscountAmount(statisticsMapper.sumDiscountAmountByDate(date));
        stats.setReceivedUsers(statisticsMapper.countReceivedUsersByDate(date));
        stats.setUsedUsers(statisticsMapper.countUsedUsersByDate(date));

        return stats;
    }
}
java 复制代码
package com.coupon.service.dto;

import lombok.Data;
import java.math.BigDecimal;

/**
 * 优惠券统计DTO
 */
@Data
public class CouponStatisticsDTO {

    /**
     * 发放总量
     */
    private Integer totalIssued;

    /**
     * 领取总量
     */
    private Integer totalReceived;

    /**
     * 领取人数
     */
    private Integer receivedUsers;

    /**
     * 使用总量
     */
    private Integer totalUsed;

    /**
     * 使用人数
     */
    private Integer usedUsers;

    /**
     * 总优惠金额
     */
    private BigDecimal totalDiscountAmount;

    /**
     * 核销率(%)
     */
    private BigDecimal useRate;

    /**
     * 带动GMV
     */
    private BigDecimal gmvContribution;
}

十二、异常处理

12.1 统一异常定义

java 复制代码
package com.coupon.common.exception;

import com.coupon.common.result.ResultCode;
import lombok.Getter;

/**
 * 优惠券业务异常
 */
@Getter
public class CouponException extends RuntimeException {

    private final Integer code;
    private final String message;

    public CouponException(ResultCode resultCode) {
        super(resultCode.getMessage());
        this.code = resultCode.getCode();
        this.message = resultCode.getMessage();
    }

    public CouponException(Integer code, String message) {
        super(message);
        this.code = code;
        this.message = message;
    }
}
java 复制代码
package com.coupon.common.result;

import lombok.Getter;

/**
 * 响应状态码
 */
@Getter
public enum ResultCode {

    SUCCESS(200, "操作成功"),
    ERROR(500, "系统错误"),

    // 活动相关 1xxx
    ACTIVITY_NOT_EXIST(1001, "活动不存在"),
    ACTIVITY_NOT_START(1002, "活动未开始"),
    ACTIVITY_ENDED(1003, "活动已结束"),
    ACTIVITY_STOPPED(1004, "活动已停止"),

    // 模板相关 2xxx
    TEMPLATE_NOT_EXIST(2001, "券模板不存在"),

    // 优惠券相关 3xxx
    COUPON_NOT_EXIST(3001, "优惠券不存在"),
    COUPON_NOT_BELONG(3002, "优惠券不属于当前用户"),
    COUPON_STATUS_ERROR(3003, "优惠券状态异常"),
    COUPON_EXPIRED(3004, "优惠券已过期"),
    COUPON_STOCK_EMPTY(3005, "优惠券已领完"),
    COUPON_RECEIVE_LIMIT(3006, "超过领取限制"),
    COUPON_BUSY(3007, "系统繁忙,请稍后重试"),
    COUPON_ORDER_NOT_MATCH(3008, "订单号不匹配"),

    // 用户相关 4xxx
    USER_NOT_LOGIN(4001, "用户未登录");

    private final Integer code;
    private final String message;

    ResultCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

12.2 全局异常处理

java 复制代码
package com.coupon.service.handler;

import com.coupon.common.exception.CouponException;
import com.coupon.common.result.Result;
import com.coupon.common.result.ResultCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     */
    @ExceptionHandler(CouponException.class)
    public Result<Void> handleCouponException(CouponException e) {
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return Result.error(e.getCode(), e.getMessage());
    }

    /**
     * 参数校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldError() != null ?
            e.getBindingResult().getFieldError().getDefaultMessage() : "参数错误";
        log.warn("参数校验失败: {}", message);
        return Result.error(400, message);
    }

    /**
     * 参数绑定异常
     */
    @ExceptionHandler(BindException.class)
    public Result<Void> handleBindException(BindException e) {
        String message = e.getFieldError() != null ?
            e.getFieldError().getDefaultMessage() : "参数错误";
        log.warn("参数绑定失败: {}", message);
        return Result.error(400, message);
    }

    /**
     * 其他异常
     */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error(ResultCode.ERROR);
    }
}

十三、配置文件

13.1 application.yml

yaml 复制代码
server:
  port: 8081

spring:
  application:
    name: coupon-service

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/coupon?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    hikari:
      minimum-idle: 10
      maximum-pool-size: 50
      connection-timeout: 30000

  redis:
    host: localhost
    port: 6379
    database: 0
    lettuce:
      pool:
        max-active: 100
        max-idle: 50
        min-idle: 10

rocketmq:
  name-server: localhost:9876
  producer:
    group: coupon-producer-group
    send-message-timeout: 3000

mybatis-plus:
  mapper-locations: classpath:mapper/*.xml
  global-config:
    db-config:
      id-type: auto
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

logging:
  level:
    com.coupon: debug
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"

# 优惠券配置
coupon:
  # 领券限流
  receive-rate-limit: 100
  # 库存预热提前时间(分钟)
  warmup-advance-minutes: 60

十四、总结

14.1 核心技术点

diff 复制代码
+------------------+------------------+----------------------------------+
|      模块        |      技术        |             说明                 |
+------------------+------------------+----------------------------------+
|     领券服务     |  Redis+Lua      |   原子扣减库存,防止超发          |
+------------------+------------------+----------------------------------+
|     用券服务     |  规则引擎        |   灵活的优惠计算规则              |
+------------------+------------------+----------------------------------+
|     库存管理     |  预热+异步同步   |   高性能库存管理                  |
+------------------+------------------+----------------------------------+
|     过期处理     |  定时+延迟队列   |   券过期自动处理                  |
+------------------+------------------+----------------------------------+
|     分布式锁     |  Redisson       |   防止并发问题                    |
+------------------+------------------+----------------------------------+

14.2 最佳实践

  1. 券码设计:采用日期+随机字符的方式,避免被猜测
  2. 库存预热:活动开始前将库存加载到Redis
  3. 分层过滤:本地缓存 -> Redis -> 数据库,减少压力
  4. 预占机制:下单时冻结券,支付后核销,超时自动释放
  5. 异步处理:非核心链路异步化,提高响应速度
  6. 幂等设计:所有操作保证幂等,防止重复处理

14.3 扩展方向

  1. 券包功能:支持一次性发放多张券
  2. 转赠功能:支持用户间优惠券转赠
  3. 智能推荐:基于用户行为推荐最优券
  4. AB测试:支持不同发券策略对比
  5. 风控系统:识别刷券、黄牛等异常行为

本文完整演示了仿京东优惠券系统的设计与实现,涵盖了优惠券的完整生命周期管理。在实际项目中,还需要根据业务需求进行调整和优化。

相关推荐
1***t82741 分钟前
将 vue3 项目打包后部署在 springboot 项目运行
java·spring boot·后端
芬加达1 小时前
leetcode34
java·数据结构·算法
__万波__1 小时前
二十三种设计模式(三)--抽象工厂模式
java·设计模式·抽象工厂模式
better_liang1 小时前
每日Java面试场景题知识点之-线程池配置与优化
java·性能优化·面试题·线程池·并发编程
q***2511 小时前
Windows操作系统部署Tomcat详细讲解
java·windows·tomcat
N***H4861 小时前
使用Springboot实现MQTT通信
java·spring boot·后端
CoderYanger1 小时前
优选算法-队列+宽搜(BFS):72.二叉树的最大宽度
java·开发语言·算法·leetcode·职场和发展·宽度优先·1024程序员节
赵大海2 小时前
黑马《Java架构师实战训练营 (含完整资料)》
java
不带刺仙人球2 小时前
list.stream().collect例子
java·list·dubbo