优惠券系统设计与实现
前言
优惠券系统是电商平台促销的核心功能之一。京东、淘宝等平台每天发放数以亿计的优惠券,涉及多种券类型、复杂的使用规则、高并发的领取场景。本文将从系统架构、数据库设计、核心功能实现等方面,详细讲解如何设计一个完整的优惠券系统。
一、优惠券系统业务分析
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 最佳实践
- 券码设计:采用日期+随机字符的方式,避免被猜测
- 库存预热:活动开始前将库存加载到Redis
- 分层过滤:本地缓存 -> Redis -> 数据库,减少压力
- 预占机制:下单时冻结券,支付后核销,超时自动释放
- 异步处理:非核心链路异步化,提高响应速度
- 幂等设计:所有操作保证幂等,防止重复处理
14.3 扩展方向
- 券包功能:支持一次性发放多张券
- 转赠功能:支持用户间优惠券转赠
- 智能推荐:基于用户行为推荐最优券
- AB测试:支持不同发券策略对比
- 风控系统:识别刷券、黄牛等异常行为
本文完整演示了仿京东优惠券系统的设计与实现,涵盖了优惠券的完整生命周期管理。在实际项目中,还需要根据业务需求进行调整和优化。