优惠券功能设计与实现

简介: 本文从底层逻辑出发,全面拆解优惠券功能的核心设计要点,结合JDK17、MyBatis-Plus、MySQL8.0等最新稳定技术栈,提供全量可编译运行的实现代码,帮助开发者快速掌握从需求分析到落地部署的完整流程。

在电商、O2O、本地生活等各类平台中,优惠券作为拉新、促活、转化、留存的核心营销工具,其功能设计的合理性与实现的稳定性直接影响业务增长效果。本文将从底层逻辑出发,全面拆解优惠券功能的核心设计要点,结合JDK17、MyBatis-Plus、MySQL8.0等最新稳定技术栈,提供全量可编译运行的实现代码,帮助开发者快速掌握从需求分析到落地部署的完整流程。

一、优惠券核心需求与业务场景分析

1.1 核心业务价值

优惠券的核心目标是通过价格让利实现业务指标提升,常见应用场景包括:

  • 拉新:新用户注册即送无门槛优惠券,降低首次消费决策成本;
  • 促活:沉睡用户唤醒优惠券,提升平台活跃率;
  • 转化:下单未支付用户定向发放优惠券,提升付款转化率;
  • 留存:会员专属优惠券、周期性发放优惠券,增强用户粘性;
  • 清库存:临期商品、滞销商品搭配专属优惠券,加速库存周转。

1.2 核心功能需求

基于业务场景,优惠券系统需实现以下核心功能:

  1. 优惠券配置:支持多种类型优惠券的创建、编辑、停用;
  2. 优惠券发放:支持主动发放、用户领取、活动定向发放等多种模式;
  3. 优惠券使用:支持订单抵扣、使用规则校验、优惠券核销;
  4. 优惠券查询:支持用户查询已拥有、已使用、已过期优惠券;
  5. 过期处理:支持优惠券过期自动失效及相关通知;
  6. 数据统计:支持优惠券发放量、使用率、核销金额等数据统计。

1.3 关键业务约束

设计时需规避业务风险,核心约束包括:

  • 唯一性:每张优惠券需有唯一标识,避免重复核销;
  • 时效性:严格控制优惠券有效期,过期自动失效;
  • 库存控制:限量优惠券需精准控制发放数量,避免超发;
  • 规则冲突:同一订单不可叠加使用互斥优惠券;
  • 一致性:优惠券发放、使用、核销需保证数据一致性,避免漏记、错记。

二、优惠券系统架构设计

2.1 整体架构设计

image.png

2.2 分层职责说明

  1. 客户端层:多端统一接入,包括Web端、APP端、小程序端;
  2. 网关层:负责请求路由、鉴权、限流,统一入口管理;
  3. 应用服务层:核心业务服务拆分,采用微服务架构,包括优惠券服务、订单服务等;
  4. 业务核心层:优惠券核心功能模块,专注业务逻辑实现;
  5. 数据访问层:负责数据持久化与缓存管理,基于MyBatis-Plus和Redis实现;
  6. 数据存储层:MySQL存储核心业务数据,Redis存储缓存数据、分布式锁等;
  7. 公共组件层:提供通用能力支撑,包括规则引擎、分布式锁、定时任务等。

2.3 核心技术栈选型

技术类别 技术选型 版本要求 选型理由
开发语言 Java JDK17 成熟稳定,生态完善,支持最新特性
构建工具 Maven 3.8.8+ 主流构建工具,依赖管理能力强
开发框架 Spring Boot 3.2.5 快速开发脚手架,简化配置
微服务框架 Spring Cloud Alibaba 2023.0.1.0 适配Spring Boot 3.x,组件丰富成熟
持久层框架 MyBatis-Plus 3.5.5 增强MyBatis,简化CRUD操作,支持多数据源
数据库 MySQL 8.0+ 开源稳定,支持事务和复杂查询,生态完善
缓存 Redis 7.2.4 高性能内存数据库,支持多种数据结构
消息队列 RocketMQ 5.2.0 高吞吐量、高可靠性,支持事务消息
规则引擎 Easy Rule 4.1.0 轻量级,易于集成,适合优惠券规则校验
接口文档 Swagger3 2.2.20 自动生成接口文档,支持在线调试
工具类 Lombok、Spring Utils、FastJSON2 Lombok1.18.30+ 简化代码,提升开发效率
定时任务 Spring Scheduler + Quartz Quartz2.3.2 灵活的任务调度,支持分布式部署
分布式锁 Redis Redisson 3.25.0 高性能,支持多种锁类型

三、数据模型设计

3.1 核心数据模型关系

image.png

3.2 数据库表设计(MySQL8.0)

3.2.1 优惠券主表(coupon)

CREATE TABLE `coupon` (
 `coupon_id` bigint NOT NULL AUTO_INCREMENT COMMENT '优惠券ID',
 `coupon_name` varchar(100) NOT NULL COMMENT '优惠券名称',
 `coupon_type` tinyint NOT NULL COMMENT '优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券',
 `face_value` decimal(10,2) NOT NULL COMMENT '面额:满减券/无门槛券/现金券为金额,折扣券为折扣比例(如90代表9折)',
 `min_spend` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '最低消费金额:0表示无门槛',
 `valid_type` tinyint NOT NULL COMMENT '有效期类型:1-固定时间 2-领取后N天有效',
 `start_time` datetime DEFAULT NULL COMMENT '生效时间(固定时间类型必填)',
 `end_time` datetime DEFAULT NULL COMMENT '失效时间(固定时间类型必填)',
 `valid_days` int DEFAULT NULL COMMENT '领取后有效天数(领取后N天有效类型必填)',
 `issue_num` int NOT NULL COMMENT '发放总量',
 `per_user_limit` int NOT NULL DEFAULT 1 COMMENT '每人限领数量',
 `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-未生效 1-生效中 2-已过期 3-已停用',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 `create_by` varchar(50) NOT NULL COMMENT '创建人',
 PRIMARY KEY (`coupon_id`),
 KEY `idx_status` (`status`),
 KEY `idx_time` (`start_time`,`end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券主表';

3.2.2 优惠券规则表(coupon_rule)

CREATE TABLE `coupon_rule` (
 `rule_id` bigint NOT NULL AUTO_INCREMENT COMMENT '规则ID',
 `coupon_id` bigint NOT NULL COMMENT '关联优惠券ID',
 `rule_type` tinyint NOT NULL COMMENT '规则类型:1-商品品类限制 2-商品SKU限制 3-用户等级限制 4-订单类型限制',
 `rule_value` varchar(200) NOT NULL COMMENT '规则值:多个值用逗号分隔',
 `rule_operator` tinyint NOT NULL DEFAULT 1 COMMENT '匹配方式:1-包含 2-排除',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 PRIMARY KEY (`rule_id`),
 KEY `idx_coupon_id` (`coupon_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券规则表';

3.2.3 优惠券库存表(coupon_stock)

CREATE TABLE `coupon_stock` (
 `stock_id` bigint NOT NULL AUTO_INCREMENT COMMENT '库存ID',
 `coupon_id` bigint NOT NULL COMMENT '关联优惠券ID',
 `total_num` int NOT NULL COMMENT '总库存',
 `used_num` int NOT NULL DEFAULT 0 COMMENT '已使用数量',
 `surplus_num` int NOT NULL COMMENT '剩余库存',
 `lock_num` int NOT NULL DEFAULT 0 COMMENT '锁定数量(发放中未确认)',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 PRIMARY KEY (`stock_id`),
 UNIQUE KEY `uk_coupon_id` (`coupon_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券库存表';

3.2.4 用户优惠券表(user_coupon)

CREATE TABLE `user_coupon` (
 `user_coupon_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户优惠券ID',
 `user_id` bigint NOT NULL COMMENT '用户ID',
 `coupon_id` bigint NOT NULL COMMENT '关联优惠券ID',
 `coupon_code` varchar(50) NOT NULL COMMENT '优惠券编码(唯一)',
 `get_time` datetime NOT NULL COMMENT '领取时间',
 `valid_start_time` datetime NOT NULL COMMENT '实际生效时间',
 `valid_end_time` datetime NOT NULL COMMENT '实际失效时间',
 `use_status` tinyint NOT NULL DEFAULT 0 COMMENT '使用状态:0-未使用 1-已使用 2-已过期 3-已作废',
 `use_time` datetime DEFAULT NULL COMMENT '使用时间',
 `order_id` bigint DEFAULT NULL COMMENT '关联订单ID',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 PRIMARY KEY (`user_coupon_id`),
 UNIQUE KEY `uk_coupon_code` (`coupon_code`),
 KEY `idx_user_id` (`user_id`),
 KEY `idx_coupon_id` (`coupon_id`),
 KEY `idx_use_status` (`use_status`),
 KEY `idx_valid_time` (`valid_start_time`,`valid_end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户优惠券表';

3.2.5 优惠券发放记录表(coupon_issue_record)

CREATE TABLE `coupon_issue_record` (
 `record_id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
 `coupon_id` bigint NOT NULL COMMENT '关联优惠券ID',
 `user_id` bigint NOT NULL COMMENT '用户ID',
 `issue_type` tinyint NOT NULL COMMENT '发放类型:1-用户主动领取 2-系统主动发放 3-活动发放',
 `issue_status` tinyint NOT NULL DEFAULT 1 COMMENT '发放状态:1-成功 2-失败',
 `fail_reason` varchar(200) DEFAULT NULL COMMENT '失败原因',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 PRIMARY KEY (`record_id`),
 KEY `idx_coupon_id` (`coupon_id`),
 KEY `idx_user_id` (`user_id`),
 KEY `idx_issue_type` (`issue_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券发放记录表';

3.3 核心实体类设计(Java JDK17)

3.3.1 优惠券主表实体(Coupon.java)

package com.jam.demo.coupon.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
* 优惠券主表实体
* @author ken
* @date 2025-12-01
*/

@Data
@TableName("coupon")
@Schema(description = "优惠券主表")
public class Coupon {
   /**
    * 优惠券ID
    */

   @TableId(type = IdType.AUTO)
   @Schema(description = "优惠券ID")
   private Long couponId;

   /**
    * 优惠券名称
    */

   @Schema(description = "优惠券名称")
   private String couponName;

   /**
    * 优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券
    */

   @Schema(description = "优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券")
   private Integer couponType;

   /**
    * 面额:满减券/无门槛券/现金券为金额,折扣券为折扣比例(如90代表9折)
    */

   @Schema(description = "面额:满减券/无门槛券/现金券为金额,折扣券为折扣比例(如90代表9折)")
   private BigDecimal faceValue;

   /**
    * 最低消费金额:0表示无门槛
    */

   @Schema(description = "最低消费金额:0表示无门槛")
   private BigDecimal minSpend;

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

   @Schema(description = "有效期类型:1-固定时间 2-领取后N天有效")
   private Integer validType;

   /**
    * 生效时间(固定时间类型必填)
    */

   @Schema(description = "生效时间(固定时间类型必填)")
   private LocalDateTime startTime;

   /**
    * 失效时间(固定时间类型必填)
    */

   @Schema(description = "失效时间(固定时间类型必填)")
   private LocalDateTime endTime;

   /**
    * 领取后有效天数(领取后N天有效类型必填)
    */

   @Schema(description = "领取后有效天数(领取后N天有效类型必填)")
   private Integer validDays;

   /**
    * 发放总量
    */

   @Schema(description = "发放总量")
   private Integer issueNum;

   /**
    * 每人限领数量
    */

   @Schema(description = "每人限领数量")
   private Integer perUserLimit;

   /**
    * 状态:0-未生效 1-生效中 2-已过期 3-已停用
    */

   @Schema(description = "状态:0-未生效 1-生效中 2-已过期 3-已停用")
   private Integer status;

   /**
    * 创建时间
    */

   @Schema(description = "创建时间")
   private LocalDateTime createTime;

   /**
    * 更新时间
    */

   @Schema(description = "更新时间")
   private LocalDateTime updateTime;

   /**
    * 创建人
    */

   @Schema(description = "创建人")
   private String createBy;
}

3.3.2 用户优惠券实体(UserCoupon.java)

package com.jam.demo.coupon.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;

/**
* 用户优惠券表实体
* @author ken
* @date 2025-12-01
*/

@Data
@TableName("user_coupon")
@Schema(description = "用户优惠券表")
public class UserCoupon {
   /**
    * 用户优惠券ID
    */

   @TableId(type = IdType.AUTO)
   @Schema(description = "用户优惠券ID")
   private Long userCouponId;

   /**
    * 用户ID
    */

   @Schema(description = "用户ID")
   private Long userId;

   /**
    * 关联优惠券ID
    */

   @Schema(description = "关联优惠券ID")
   private Long couponId;

   /**
    * 优惠券编码(唯一)
    */

   @Schema(description = "优惠券编码(唯一)")
   private String couponCode;

   /**
    * 领取时间
    */

   @Schema(description = "领取时间")
   private LocalDateTime getTime;

   /**
    * 实际生效时间
    */

   @Schema(description = "实际生效时间")
   private LocalDateTime validStartTime;

   /**
    * 实际失效时间
    */

   @Schema(description = "实际失效时间")
   private LocalDateTime validEndTime;

   /**
    * 使用状态:0-未使用 1-已使用 2-已过期 3-已作废
    */

   @Schema(description = "使用状态:0-未使用 1-已使用 2-已过期 3-已作废")
   private Integer useStatus;

   /**
    * 使用时间
    */

   @Schema(description = "使用时间")
   private LocalDateTime useTime;

   /**
    * 关联订单ID
    */

   @Schema(description = "关联订单ID")
   private Long orderId;

   /**
    * 创建时间
    */

   @Schema(description = "创建时间")
   private LocalDateTime createTime;

   /**
    * 更新时间
    */

   @Schema(description = "更新时间")
   private LocalDateTime updateTime;
}

其他实体类(CouponRule、CouponStock、CouponIssueRecord)遵循相同规范,此处省略,完整代码见后续实现部分。

四、核心功能实现

4.1 项目基础配置

4.1.1 pom.xml依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

   <modelVersion>4.0.0</modelVersion>
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.2.5</version>
       <relativePath/>
   </parent>
   <groupId>com.jam.demo</groupId>
   <artifactId>coupon-demo</artifactId>
   <version>1.0.0</version>
   <name>coupon-demo</name>
   <description>优惠券功能设计与实现示例</description>

   <properties>
       <java.version>17</java.version>
       <spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>
       <mybatis-plus.version>3.5.5</mybatis-plus.version>
       <redis.version>7.2.4</redis.version>
       <rocketmq.version>5.2.0</rocketmq.version>
       <easy-rule.version>4.1.0</easy-rule.version>
       <swagger.version>2.2.20</swagger.version>
       <lombok.version>1.18.30</lombok.version>
       <fastjson2.version>2.0.49</fastjson2.version>
       <google-guava.version>33.2.1-jre</google-guava.version>
   </properties>

   <dependencies>
       <!-- Spring Boot核心依赖 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-validation</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-aop</artifactId>
       </dependency>

       <!-- Spring Cloud Alibaba依赖 -->
       <dependency>
           <groupId>com.alibaba.cloud</groupId>
           <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
       </dependency>
       <dependency>
           <groupId>com.alibaba.cloud</groupId>
           <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
       </dependency>

       <!-- 持久层依赖 -->
       <dependency>
           <groupId>com.baomidou</groupId>
           <artifactId>mybatis-plus-boot-starter</artifactId>
           <version>${mybatis-plus.version}</version>
       </dependency>
       <dependency>
           <groupId>mysql</groupId>
           <artifactId>mysql-connector-java</artifactId>
           <scope>runtime</scope>
       </dependency>

       <!-- 缓存依赖 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-data-redis</artifactId>
       </dependency>
       <dependency>
           <groupId>org.redisson</groupId>
           <artifactId>redisson-spring-boot-starter</artifactId>
           <version>3.25.0</version>
       </dependency>

       <!-- 消息队列依赖 -->
       <dependency>
           <groupId>org.apache.rocketmq</groupId>
           <artifactId>rocketmq-spring-boot-starter</artifactId>
           <version>${rocketmq.version}</version>
       </dependency>

       <!-- 规则引擎依赖 -->
       <dependency>
           <groupId>org.jeasy</groupId>
           <artifactId>easy-rules-core</artifactId>
           <version>${easy-rule.version}</version>
       </dependency>
       <dependency>
           <groupId>org.jeasy</groupId>
           <artifactId>easy-rules-spring</artifactId>
           <version>${easy-rule.version}</version>
       </dependency>

       <!-- 接口文档依赖 -->
       <dependency>
           <groupId>org.springdoc</groupId>
           <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
           <version>${swagger.version}</version>
       </dependency>

       <!-- 工具类依赖 -->
       <dependency>
           <groupId>org.projectlombok</groupId>
           <artifactId>lombok</artifactId>
           <version>${lombok.version}</version>
           <scope>provided</scope>
       </dependency>
       <dependency>
           <groupId>com.alibaba.fastjson2</groupId>
           <artifactId>fastjson2</artifactId>
           <version>${fastjson2.version}</version>
       </dependency>
       <dependency>
           <groupId>com.google.guava</groupId>
           <artifactId>guava</artifactId>
           <version>${google-guava.version}</version>
       </dependency>

       <!-- 测试依赖 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
       <dependency>
           <groupId>com.baomidou</groupId>
           <artifactId>mybatis-plus-boot-starter-test</artifactId>
           <version>${mybatis-plus.version}</version>
           <scope>test</scope>
       </dependency>
   </dependencies>

   <dependencyManagement>
       <dependencies>
           <dependency>
               <groupId>com.alibaba.cloud</groupId>
               <artifactId>spring-cloud-alibaba-dependencies</artifactId>
               <version>${spring-cloud-alibaba.version}</version>
               <type>pom</type>
               <scope>import</scope>
           </dependency>
       </dependencies>
   </dependencyManagement>

   <build>
       <plugins>
           <plugin>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-maven-plugin</artifactId>
               <configuration>
                   <excludes>
                       <exclude>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </exclude>
                   </excludes>
               </configuration>
           </plugin>
       </plugins>
   </build>
</project>

4.1.2 应用配置(application.yml)

spring:
 application:
   name: coupon-demo
 # 数据库配置
 datasource:
   url: jdbc:mysql://localhost:3306/coupon_demo?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8
   username: root
   password: root123456
   driver-class-name: com.mysql.cj.jdbc.Driver
 # Redis配置
 redis:
   host: localhost
   port: 6379
   password:
   database: 0
   timeout: 3000ms
   lettuce:
     pool:
       max-active: 8
       max-idle: 8
       min-idle: 0
 # 消息队列配置
 rocketmq:
   name-server: localhost:9876
   producer:
     group: coupon-producer-group
     send-message-timeout: 3000
 # Nacos配置
 cloud:
   nacos:
     discovery:
       server-addr: localhost:8848
     config:
       server-addr: localhost:8848
       file-extension: yml

# MyBatis-Plus配置
mybatis-plus:
 mapper-locations: classpath:mapper/**/*.xml
 type-aliases-package: com.jam.demo.coupon.entity
 configuration:
   map-underscore-to-camel-case: true
   log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
 global-config:
   db-config:
     id-type: auto
     logic-delete-field: isDeleted
     logic-delete-value: 1
     logic-not-delete-value: 0

# Swagger3配置
springdoc:
 api-docs:
   path: /v3/api-docs
 swagger-ui:
   path: /swagger-ui.html
   operationsSorter: method
 packages-to-scan: com.jam.demo.coupon.controller

# 定时任务配置
spring:
 scheduler:
   thread-name-prefix: coupon-scheduler-
   pool:
     size: 5

# 自定义配置
coupon:
 # 优惠券编码前缀
 code-prefix: COUPON_
 # 分布式锁前缀
 lock-prefix: coupon:lock:
 # 缓存前缀
 cache-prefix: coupon:cache:
 # 过期处理任务 cron表达式:每天凌晨1点执行
 expire-task-cron: 0 0 1 * * ?

4.2 优惠券配置功能实现

4.2.1 Mapper层(CouponMapper.java)

package com.jam.demo.coupon.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.coupon.entity.Coupon;
import org.apache.ibatis.annotations.Mapper;

/**
* 优惠券主表Mapper
* @author ken
* @date 2025-12-01
*/

@Mapper
public interface CouponMapper extends BaseMapper<Coupon> {
}

4.2.2 Service层接口(CouponService.java)

package com.jam.demo.coupon.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.CouponRule;
import com.jam.demo.coupon.entity.CouponStock;
import com.jam.demo.coupon.vo.CouponAddVO;
import com.jam.demo.coupon.vo.CouponEditVO;
import com.jam.demo.common.result.Result;

import java.util.List;

/**
* 优惠券服务接口
* @author ken
* @date 2025-12-01
*/

public interface CouponService extends IService<Coupon> {
   /**
    * 创建优惠券(含规则、库存配置)
    * @param couponAddVO 优惠券新增参数
    * @param ruleList 优惠券规则列表
    * @return 优惠券ID
    */

   Result<Long> createCoupon(CouponAddVO couponAddVO, List<CouponRule> ruleList);

   /**
    * 编辑优惠券
    * @param couponEditVO 优惠券编辑参数
    * @param ruleList 优惠券规则列表
    * @return 编辑结果
    */

   Result<Boolean> editCoupon(CouponEditVO couponEditVO, List<CouponRule> ruleList);

   /**
    * 停用优惠券
    * @param couponId 优惠券ID
    * @return 停用结果
    */

   Result<Boolean> stopCoupon(Long couponId);

   /**
    * 查询优惠券详情
    * @param couponId 优惠券ID
    * @return 优惠券详情(含规则、库存)
    */

   Result<Coupon> getCouponDetail(Long couponId);
}

4.2.3 Service层实现(CouponServiceImpl.java)

package com.jam.demo.coupon.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.CouponRule;
import com.jam.demo.coupon.entity.CouponStock;
import com.jam.demo.coupon.mapper.CouponMapper;
import com.jam.demo.coupon.service.CouponRuleService;
import com.jam.demo.coupon.service.CouponService;
import com.jam.demo.coupon.service.CouponStockService;
import com.jam.demo.coupon.vo.CouponAddVO;
import com.jam.demo.coupon.vo.CouponEditVO;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import com.jam.demo.common.result.Result;
import com.jam.demo.common.result.ResultBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

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

/**
* 优惠券服务实现类
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> implements CouponService {

   private final CouponStockService couponStockService;
   private final CouponRuleService couponRuleService;

   /**
    * 创建优惠券(含规则、库存配置)
    * @param couponAddVO 优惠券新增参数
    * @param ruleList 优惠券规则列表
    * @return 优惠券ID
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Long> createCoupon(CouponAddVO couponAddVO, List<CouponRule> ruleList)
{
       // 1. 参数校验
       validateCouponAddParam(couponAddVO);

       // 2. 构建优惠券实体
       Coupon coupon = buildCouponFromAddVO(couponAddVO);

       // 3. 保存优惠券主表
       boolean saveSuccess = this.save(coupon);
       if (!saveSuccess) {
           log.error("创建优惠券失败:保存主表失败,couponAddVO:{}", couponAddVO);
           throw new BusinessException(BusinessErrorCode.COUPON_CREATE_FAIL);
       }
       Long couponId = coupon.getCouponId();

       // 4. 保存优惠券库存
       CouponStock stock = buildCouponStock(couponAddVO, couponId);
       boolean stockSaveSuccess = couponStockService.save(stock);
       if (!stockSaveSuccess) {
           log.error("创建优惠券失败:保存库存失败,couponId:{}, stock:{}", couponId, stock);
           throw new BusinessException(BusinessErrorCode.COUPON_STOCK_SAVE_FAIL);
       }

       // 5. 保存优惠券规则(如有)
       if (!ObjectUtils.isEmpty(ruleList)) {
           ruleList.forEach(rule -> rule.setCouponId(couponId));
           boolean ruleSaveSuccess = couponRuleService.saveBatch(ruleList);
           if (!ruleSaveSuccess) {
               log.error("创建优惠券失败:保存规则失败,couponId:{}, ruleList:{}", couponId, ruleList);
               throw new BusinessException(BusinessErrorCode.COUPON_RULE_SAVE_FAIL);
           }
       }

       log.info("创建优惠券成功,couponId:{}", couponId);
       return ResultBuilder.success(couponId);
   }

   /**
    * 编辑优惠券
    * @param couponEditVO 优惠券编辑参数
    * @param ruleList 优惠券规则列表
    * @return 编辑结果
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Boolean> editCoupon(CouponEditVO couponEditVO, List<CouponRule> ruleList)
{
       // 1. 参数校验
       validateCouponEditParam(couponEditVO);

       // 2. 查询优惠券是否存在
       Long couponId = couponEditVO.getCouponId();
       Coupon existCoupon = this.getById(couponId);
       if (ObjectUtils.isEmpty(existCoupon)) {
           log.error("编辑优惠券失败:优惠券不存在,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }

       // 3. 校验状态:已生效/已过期优惠券不可编辑核心信息
       if (existCoupon.getStatus() == 1 || existCoupon.getStatus() == 2) {
           log.error("编辑优惠券失败:优惠券状态不允许编辑,couponId:{}, status:{}", couponId, existCoupon.getStatus());
           throw new BusinessException(BusinessErrorCode.COUPON_STATUS_NOT_ALLOW_EDIT);
       }

       // 4. 更新优惠券主表
       Coupon updateCoupon = buildCouponFromEditVO(couponEditVO, existCoupon);
       boolean updateSuccess = this.updateById(updateCoupon);
       if (!updateSuccess) {
           log.error("编辑优惠券失败:更新主表失败,couponEditVO:{}", couponEditVO);
           throw new BusinessException(BusinessErrorCode.COUPON_EDIT_FAIL);
       }

       // 5. 更新优惠券规则:先删后加
       couponRuleService.remove(new LambdaQueryWrapper<CouponRule>().eq(CouponRule::getCouponId, couponId));
       if (!ObjectUtils.isEmpty(ruleList)) {
           ruleList.forEach(rule -> rule.setCouponId(couponId));
           boolean ruleSaveSuccess = couponRuleService.saveBatch(ruleList);
           if (!ruleSaveSuccess) {
               log.error("编辑优惠券失败:保存规则失败,couponId:{}, ruleList:{}", couponId, ruleList);
               throw new BusinessException(BusinessErrorCode.COUPON_RULE_SAVE_FAIL);
           }
       }

       // 6. 如需更新库存(仅未生效优惠券可更新)
       if (!ObjectUtils.isEmpty(couponEditVO.getIssueNum()) && existCoupon.getStatus() == 0) {
           CouponStock stock = couponStockService.getOne(new LambdaQueryWrapper<CouponStock>().eq(CouponStock::getCouponId, couponId));
           if (!ObjectUtils.isEmpty(stock)) {
               stock.setTotalNum(couponEditVO.getIssueNum());
               stock.setSurplusNum(couponEditVO.getIssueNum() - stock.getUsedNum());
               couponStockService.updateById(stock);
           }
       }

       log.info("编辑优惠券成功,couponId:{}", couponId);
       return ResultBuilder.success(true);
   }

   /**
    * 停用优惠券
    * @param couponId 优惠券ID
    * @return 停用结果
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Boolean> stopCoupon(Long couponId)
{
       // 1. 参数校验
       if (ObjectUtils.isEmpty(couponId)) {
           log.error("停用优惠券失败:优惠券ID不能为空");
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }

       // 2. 查询优惠券是否存在
       Coupon existCoupon = this.getById(couponId);
       if (ObjectUtils.isEmpty(existCoupon)) {
           log.error("停用优惠券失败:优惠券不存在,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }

       // 3. 校验状态:已停用/已过期优惠券不可重复停用
       if (existCoupon.getStatus() == 3 || existCoupon.getStatus() == 2) {
           log.error("停用优惠券失败:优惠券状态不允许停用,couponId:{}, status:{}", couponId, existCoupon.getStatus());
           throw new BusinessException(BusinessErrorCode.COUPON_STATUS_NOT_ALLOW_STOP);
       }

       // 4. 更新状态为已停用
       Coupon updateCoupon = new Coupon();
       updateCoupon.setCouponId(couponId);
       updateCoupon.setStatus(3);
       updateCoupon.setUpdateTime(LocalDateTime.now());
       boolean updateSuccess = this.updateById(updateCoupon);
       if (!updateSuccess) {
           log.error("停用优惠券失败:更新状态失败,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_STOP_FAIL);
       }

       log.info("停用优惠券成功,couponId:{}", couponId);
       return ResultBuilder.success(true);
   }

   /**
    * 查询优惠券详情
    * @param couponId 优惠券ID
    * @return 优惠券详情(含规则、库存)
    */

   @Override
   public Result<Coupon> getCouponDetail(Long couponId) {
       // 1. 参数校验
       if (ObjectUtils.isEmpty(couponId)) {
           log.error("查询优惠券详情失败:优惠券ID不能为空");
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }

       // 2. 查询优惠券主表
       Coupon coupon = this.getById(couponId);
       if (ObjectUtils.isEmpty(coupon)) {
           log.error("查询优惠券详情失败:优惠券不存在,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }

       // 3. 补充规则和库存信息(实际项目中可通过DTO组装)
       // 此处省略DTO组装逻辑,直接返回实体
       return ResultBuilder.success(coupon);
   }

   /**
    * 校验优惠券新增参数
    * @param couponAddVO 新增参数
    */

   private void validateCouponAddParam(CouponAddVO couponAddVO) {
       if (ObjectUtils.isEmpty(couponAddVO)) {
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       StringUtils.hasText(couponAddVO.getCouponName(), "优惠券名称不能为空");
       if (ObjectUtils.isEmpty(couponAddVO.getCouponType()) || couponAddVO.getCouponType() < 1 || couponAddVO.getCouponType() > 4) {
           throw new BusinessException(BusinessErrorCode.COUPON_TYPE_ERROR);
       }
       if (ObjectUtils.isEmpty(couponAddVO.getFaceValue()) || couponAddVO.getFaceValue().compareTo(BigDecimal.ZERO) <= 0) {
           throw new BusinessException(BusinessErrorCode.COUPON_FACE_VALUE_ERROR);
       }
       if (ObjectUtils.isEmpty(couponAddVO.getValidType()) || couponAddVO.getValidType() < 1 || couponAddVO.getValidType() > 2) {
           throw new BusinessException(BusinessErrorCode.COUPON_VALID_TYPE_ERROR);
       }
       // 有效期类型校验
       if (couponAddVO.getValidType() == 1) {
           if (ObjectUtils.isEmpty(couponAddVO.getStartTime()) || ObjectUtils.isEmpty(couponAddVO.getEndTime())) {
               throw new BusinessException(BusinessErrorCode.COUPON_FIXED_TIME_EMPTY);
           }
           if (couponAddVO.getStartTime().isAfter(couponAddVO.getEndTime())) {
               throw new BusinessException(BusinessErrorCode.COUPON_START_TIME_AFTER_END_TIME);
           }
       } else {
           if (ObjectUtils.isEmpty(couponAddVO.getValidDays()) || couponAddVO.getValidDays() <= 0) {
               throw new BusinessException(BusinessErrorCode.COUPON_VALID_DAYS_ERROR);
           }
       }
       if (ObjectUtils.isEmpty(couponAddVO.getIssueNum()) || couponAddVO.getIssueNum() <= 0) {
           throw new BusinessException(BusinessErrorCode.COUPON_ISSUE_NUM_ERROR);
       }
       if (ObjectUtils.isEmpty(couponAddVO.getPerUserLimit()) || couponAddVO.getPerUserLimit() <= 0) {
           throw new BusinessException(BusinessErrorCode.COUPON_PER_USER_LIMIT_ERROR);
       }
       StringUtils.hasText(couponAddVO.getCreateBy(), "创建人不能为空");
   }

   /**
    * 校验优惠券编辑参数
    * @param couponEditVO 编辑参数
    */

   private void validateCouponEditParam(CouponEditVO couponEditVO) {
       if (ObjectUtils.isEmpty(couponEditVO)) {
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       if (ObjectUtils.isEmpty(couponEditVO.getCouponId())) {
           throw new BusinessException(BusinessErrorCode.COUPON_ID_EMPTY);
       }
       StringUtils.hasText(couponEditVO.getCouponName(), "优惠券名称不能为空");
       // 其他参数校验参考新增逻辑,此处省略
   }

   /**
    * 从新增VO构建优惠券实体
    * @param couponAddVO 新增参数
    * @return 优惠券实体
    */

   private Coupon buildCouponFromAddVO(CouponAddVO couponAddVO) {
       Coupon coupon = new Coupon();
       coupon.setCouponName(couponAddVO.getCouponName());
       coupon.setCouponType(couponAddVO.getCouponType());
       coupon.setFaceValue(couponAddVO.getFaceValue());
       coupon.setMinSpend(couponAddVO.getMinSpend() == null ? BigDecimal.ZERO : couponAddVO.getMinSpend());
       coupon.setValidType(couponAddVO.getValidType());
       coupon.setStartTime(couponAddVO.getStartTime());
       coupon.setEndTime(couponAddVO.getEndTime());
       coupon.setValidDays(couponAddVO.getValidDays());
       coupon.setIssueNum(couponAddVO.getIssueNum());
       coupon.setPerUserLimit(couponAddVO.getPerUserLimit());
       coupon.setStatus(0); // 初始状态:未生效
       coupon.setCreateTime(LocalDateTime.now());
       coupon.setUpdateTime(LocalDateTime.now());
       coupon.setCreateBy(couponAddVO.getCreateBy());
       return coupon;
   }

   /**
    * 从编辑VO构建优惠券实体
    * @param couponEditVO 编辑参数
    * @param existCoupon 原有优惠券实体
    * @return 优惠券实体
    */

   private Coupon buildCouponFromEditVO(CouponEditVO couponEditVO, Coupon existCoupon) {
       existCoupon.setCouponName(couponEditVO.getCouponName());
       existCoupon.setFaceValue(couponEditVO.getFaceValue());
       existCoupon.setMinSpend(couponEditVO.getMinSpend() == null ? BigDecimal.ZERO : couponEditVO.getMinSpend());
       existCoupon.setValidType(couponEditVO.getValidType());
       existCoupon.setStartTime(couponEditVO.getStartTime());
       existCoupon.setEndTime(couponEditVO.getEndTime());
       existCoupon.setValidDays(couponEditVO.getValidDays());
       existCoupon.setPerUserLimit(couponEditVO.getPerUserLimit());
       existCoupon.setUpdateTime(LocalDateTime.now());
       return existCoupon;
   }

   /**
    * 构建优惠券库存实体
    * @param couponAddVO 新增参数
    * @param couponId 优惠券ID
    * @return 库存实体
    */

   private CouponStock buildCouponStock(CouponAddVO couponAddVO, Long couponId) {
       CouponStock stock = new CouponStock();
       stock.setCouponId(couponId);
       stock.setTotalNum(couponAddVO.getIssueNum());
       stock.setUsedNum(0);
       stock.setSurplusNum(couponAddVO.getIssueNum());
       stock.setLockNum(0);
       stock.setCreateTime(LocalDateTime.now());
       stock.setUpdateTime(LocalDateTime.now());
       return stock;
   }
}

4.2.4 Controller 层(CouponController.java)

package com.jam.demo.coupon.controller;

import com.jam.demo.coupon.entity.CouponRule;
import com.jam.demo.coupon.service.CouponService;
import com.jam.demo.coupon.vo.CouponAddVO;
import com.jam.demo.coupon.vo.CouponEditVO;
import com.jam.demo.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 优惠券配置控制器
* @author ken
* @date 2025-12-01
*/

@Slf4j
@RestController
@RequestMapping("/coupon/config")
@RequiredArgsConstructor
@Tag(name = "优惠券配置接口", description = "优惠券创建、编辑、停用、详情查询接口")
public class CouponController {

   private final CouponService couponService;

   /**
    * 创建优惠券
    * @param couponAddVO 优惠券新增参数
    * @param ruleList 优惠券规则列表
    * @return 优惠券ID
    */

   @PostMapping("/create")
   @Operation(summary = "创建优惠券", description = "创建优惠券(含规则、库存配置)")
   public Result<Long> createCoupon(
           @Parameter(description = "优惠券新增参数")
@RequestBody CouponAddVO couponAddVO,
           @Parameter(description = "优惠券规则列表(可选)") @RequestBody(required = false) List<CouponRule> ruleList) {
       log.info("创建优惠券请求,couponAddVO:{}, ruleList:{}", couponAddVO, ruleList);
       return couponService.createCoupon(couponAddVO, ruleList);
   }

   /**
    * 编辑优惠券
    * @param couponEditVO 优惠券编辑参数
    * @param ruleList 优惠券规则列表
    * @return 编辑结果
    */

   @PutMapping("/edit")
   @Operation(summary = "编辑优惠券", description = "编辑未生效优惠券的配置信息")
   public Result<Boolean> editCoupon(
           @Parameter(description = "优惠券编辑参数")
@RequestBody CouponEditVO couponEditVO,
           @Parameter(description = "优惠券规则列表(可选)") @RequestBody(required = false) List<CouponRule> ruleList) {
       log.info("编辑优惠券请求,couponEditVO:{}, ruleList:{}", couponEditVO, ruleList);
       return couponService.editCoupon(couponEditVO, ruleList);
   }

   /**
    * 停用优惠券
    * @param couponId 优惠券ID
    * @return 停用结果
    */

   @PutMapping("/stop/{couponId}")
   @Operation(summary = "停用优惠券", description = "停用生效中的优惠券,已过期优惠券不可操作")
   public Result<Boolean> stopCoupon(
           @Parameter(description = "优惠券ID")
@PathVariable Long couponId) {
       log.info("停用优惠券请求,couponId:{}", couponId);
       return couponService.stopCoupon(couponId);
   }

   /**
    * 查询优惠券详情
    * @param couponId 优惠券ID
    * @return 优惠券详情
    */

   @GetMapping("/detail/{couponId}")
   @Operation(summary = "查询优惠券详情", description = "查询优惠券主信息、规则、库存详情")
   public Result<?> getCouponDetail(
           @Parameter(description = "优惠券ID") @PathVariable Long couponId) {
       log.info("查询优惠券详情请求,couponId:{}", couponId);
       return couponService.getCouponDetail(couponId);
   }
}

4.3 优惠券领取功能实现

4.3.1 核心设计思路

优惠券领取是高并发场景(如秒杀优惠券),需解决三大问题:

  1. 库存控制:避免超发,采用“Redis预扣减+MySQL最终扣减”方案;
  2. 限领控制:每人限领N张,通过Redis计数实现高效校验;
  3. 并发安全:使用分布式锁防止并发冲突,确保数据一致性。

领取流程:

image.png

4.3.2 枚举与常量定义

package com.jam.demo.coupon.constant;

/**
* 优惠券领取相关常量
* @author ken
* @date 2025-12-01
*/

public class CouponReceiveConstant {
   /** 优惠券领取库存缓存key:coupon:cache:stock:{couponId} */
   public static final String COUPON_STOCK_CACHE_KEY = "coupon:cache:stock:%s";
   /** 用户领取计数缓存key:coupon:cache:receive:count:{couponId}:{userId} */
   public static final String USER_RECEIVE_COUNT_CACHE_KEY = "coupon:cache:receive:count:%s:%s";
   /** 优惠券领取分布式锁key:coupon:lock:receive:{couponId}:{userId} */
   public static final String RECEIVE_LOCK_KEY = "coupon:lock:receive:%s:%s";
   /** 优惠券编码生成前缀 */
   public static final String COUPON_CODE_PREFIX = "COUPON_";
   /** 优惠券编码长度(含前缀) */
   public static final int COUPON_CODE_LENGTH = 20;
}

4.3.3 VO定义(CouponReceiveVO.java)

package com.jam.demo.coupon.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotNull;

/**
* 优惠券领取请求参数
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "优惠券领取请求参数")
public class CouponReceiveVO {
   /**
    * 用户ID
    */

   @NotNull(message = "用户ID不能为空")
   @Schema(description = "用户ID", required = true)
   private Long userId;

   /**
    * 优惠券ID
    */

   @NotNull(message = "优惠券ID不能为空")
   @Schema(description = "优惠券ID", required = true)
   private Long couponId;
}

4.3.4 Mapper层(UserCouponMapper.java)

package com.jam.demo.coupon.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.jam.demo.coupon.entity.UserCoupon;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

/**
* 用户优惠券Mapper
* @author ken
* @date 2025-12-01
*/

@Mapper
public interface UserCouponMapper extends BaseMapper<UserCoupon> {
   /**
    * 查询用户已领取优惠券数量
    * @param userId 用户ID
    * @param couponId 优惠券ID
    * @return 领取数量
    */

   int selectReceiveCount(@Param("userId") Long userId, @Param("couponId") Long couponId);

   /**
    * 分页查询用户优惠券列表
    * @param page 分页参数
    * @param userId 用户ID
    * @param useStatus 使用状态
    * @return 分页结果
    */

   IPage<UserCoupon> selectUserCouponPage(
           Page<UserCoupon> page,
           @Param("userId")
Long userId,
           @Param("useStatus") Integer useStatus)
;
}

4.3.5 Service层接口(UserCouponService.java)

package com.jam.demo.coupon.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.vo.CouponReceiveVO;
import com.jam.demo.common.result.Result;

/**
* 用户优惠券服务接口
* @author ken
* @date 2025-12-01
*/

public interface UserCouponService extends IService<UserCoupon> {
   /**
    * 用户领取优惠券
    * @param receiveVO 领取参数
    * @return 领取结果(用户优惠券ID)
    */

   Result<Long> receiveCoupon(CouponReceiveVO receiveVO);

   /**
    * 分页查询用户优惠券列表
    * @param userId 用户ID
    * @param useStatus 使用状态:0-未使用 1-已使用 2-已过期 3-已作废
    * @param pageNum 页码
    * @param pageSize 每页条数
    * @return 分页结果
    */

   Result<IPage<UserCoupon>> getUserCouponPage(Long userId, Integer useStatus, Integer pageNum, Integer pageSize);
}

4.3.6 Service层实现(UserCouponServiceImpl.java)

package com.jam.demo.coupon.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.common.base.Strings;
import com.jam.demo.coupon.constant.CouponReceiveConstant;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.CouponStock;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.mapper.UserCouponMapper;
import com.jam.demo.coupon.service.CouponService;
import com.jam.demo.coupon.service.CouponStockService;
import com.jam.demo.coupon.service.UserCouponService;
import com.jam.demo.coupon.vo.CouponReceiveVO;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import com.jam.demo.common.result.Result;
import com.jam.demo.common.result.ResultBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;

import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
* 用户优惠券服务实现类
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements UserCouponService {

   private final CouponService couponService;
   private final CouponStockService couponStockService;
   private final StringRedisTemplate stringRedisTemplate;
   private final RedissonClient redissonClient;

   /**
    * 用户领取优惠券
    * @param receiveVO 领取参数
    * @return 领取结果(用户优惠券ID)
    */

   @Override
   public Result<Long> receiveCoupon(CouponReceiveVO receiveVO) {
       // 1. 参数校验
       if (ObjectUtils.isEmpty(receiveVO) || ObjectUtils.isEmpty(receiveVO.getUserId()) || ObjectUtils.isEmpty(receiveVO.getCouponId())) {
           log.error("领取优惠券失败:参数为空,receiveVO:{}", receiveVO);
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       Long userId = receiveVO.getUserId();
       Long couponId = receiveVO.getCouponId();

       // 2. 优惠券状态校验(从数据库查询,确保准确性)
       Coupon coupon = couponService.getById(couponId);
       if (ObjectUtils.isEmpty(coupon)) {
           log.error("领取优惠券失败:优惠券不存在,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }
       // 校验状态:仅生效中(1)的优惠券可领取
       if (coupon.getStatus() != 1) {
           log.error("领取优惠券失败:优惠券状态不可领取,couponId:{}, status:{}", couponId, coupon.getStatus());
           throw new BusinessException(BusinessErrorCode.COUPON_STATUS_NOT_ALLOW_RECEIVE);
       }
       // 校验有效期(固定时间类型)
       if (coupon.getValidType() == 1) {
           LocalDateTime now = LocalDateTime.now();
           if (now.isBefore(coupon.getStartTime()) || now.isAfter(coupon.getEndTime())) {
               log.error("领取优惠券失败:优惠券不在有效期内,couponId:{}, startTime:{}, endTime:{}",
                       couponId, coupon.getStartTime(), coupon.getEndTime());
               throw new BusinessException(BusinessErrorCode.COUPON_NOT_IN_VALID_TIME);
           }
       }

       // 3. 库存校验(优先查Redis,未命中则查DB并同步到Redis)
       String stockCacheKey = String.format(CouponReceiveConstant.COUPON_STOCK_CACHE_KEY, couponId);
       String surplusStockStr = stringRedisTemplate.opsForValue().get(stockCacheKey);
       Integer surplusStock;
       if (Strings.isNullOrEmpty(surplusStockStr)) {
           // Redis未命中,查询DB并同步
           CouponStock stock = couponStockService.getOne(new LambdaQueryWrapper<CouponStock>().eq(CouponStock::getCouponId, couponId));
           if (ObjectUtils.isEmpty(stock) || stock.getSurplusNum() <= 0) {
               log.error("领取优惠券失败:库存不足(DB),couponId:{}, surplusNum:{}", couponId, stock == null ? 0 : stock.getSurplusNum());
               throw new BusinessException(BusinessErrorCode.COUPON_STOCK_INSUFFICIENT);
           }
           surplusStock = stock.getSurplusNum();
           stringRedisTemplate.opsForValue().set(stockCacheKey, surplusStock.toString(), 1, TimeUnit.HOURS);
       } else {
           surplusStock = Integer.parseInt(surplusStockStr);
           if (surplusStock <= 0) {
               log.error("领取优惠券失败:库存不足(Redis),couponId:{}, surplusStock:{}", couponId, surplusStock);
               throw new BusinessException(BusinessErrorCode.COUPON_STOCK_INSUFFICIENT);
           }
       }

       // 4. 限领校验(优先查Redis,未命中则查DB并同步到Redis)
       String receiveCountCacheKey = String.format(CouponReceiveConstant.USER_RECEIVE_COUNT_CACHE_KEY, couponId, userId);
       String receiveCountStr = stringRedisTemplate.opsForValue().get(receiveCountCacheKey);
       Integer receiveCount = 0;
       if (!Strings.isNullOrEmpty(receiveCountStr)) {
           receiveCount = Integer.parseInt(receiveCountStr);
       } else {
           // Redis未命中,查询DB并同步
           receiveCount = baseMapper.selectReceiveCount(userId, couponId);
           stringRedisTemplate.opsForValue().set(receiveCountCacheKey, receiveCount.toString(), 1, TimeUnit.HOURS);
       }
       if (receiveCount >= coupon.getPerUserLimit()) {
           log.error("领取优惠券失败:已达领取上限,userId:{}, couponId:{}, receiveCount:{}, perUserLimit:{}",
                   userId, couponId, receiveCount, coupon.getPerUserLimit());
           throw new BusinessException(BusinessErrorCode.COUPON_RECEIVE_LIMIT_REACHED);
       }

       // 5. 获取分布式锁(防止并发领取导致超发)
       String lockKey = String.format(CouponReceiveConstant.RECEIVE_LOCK_KEY, couponId, userId);
       RLock lock = redissonClient.getLock(lockKey);
       boolean lockAcquired = false;
       try {
           // 尝试获取锁,最多等待3秒,持有锁10秒
           lockAcquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
           if (!lockAcquired) {
               log.error("领取优惠券失败:获取锁失败,userId:{}, couponId:{}", userId, couponId);
               throw new BusinessException(BusinessErrorCode.COUPON_RECEIVE_LOCK_FAIL);
           }

           // 6. 再次校验库存和领取次数(防止锁等待期间数据变化)
           surplusStock = Integer.parseInt(stringRedisTemplate.opsForValue().get(stockCacheKey));
           if (surplusStock <= 0) {
               throw new BusinessException(BusinessErrorCode.COUPON_STOCK_INSUFFICIENT);
           }
           receiveCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(receiveCountCacheKey));
           if (receiveCount >= coupon.getPerUserLimit()) {
               throw new BusinessException(BusinessErrorCode.COUPON_RECEIVE_LIMIT_REACHED);
           }

           // 7. Redis预扣减库存和更新领取次数
           stringRedisTemplate.opsForValue().decrement(stockCacheKey);
           stringRedisTemplate.opsForValue().increment(receiveCountCacheKey);

           // 8. 创建用户优惠券记录(核心业务逻辑)
           Long userCouponId = createUserCoupon(coupon, userId);

           // 9. MySQL扣减库存(最终一致性)
           boolean stockUpdateSuccess = couponStockService.decrementSurplusStock(couponId);
           if (!stockUpdateSuccess) {
               log.error("领取优惠券警告:MySQL库存扣减失败,后续需人工核对,couponId:{}, userId:{}", couponId, userId);
               // 此处可发送消息队列,触发库存对账任务
           }

           log.info("领取优惠券成功,userId:{}, couponId:{}, userCouponId:{}", userId, couponId, userCouponId);
           return ResultBuilder.success(userCouponId);
       } catch (BusinessException e) {
           // 业务异常直接抛出
           throw e;
       } catch (Exception e) {
           log.error("领取优惠券失败:系统异常,userId:{}, couponId:{}", userId, couponId, e);
           throw new BusinessException(BusinessErrorCode.SYSTEM_ERROR);
       } finally {
           // 释放锁
           if (lockAcquired && lock.isHeldByCurrentThread()) {
               lock.unlock();
           }
       }
   }

   /**
    * 分页查询用户优惠券列表
    * @param userId 用户ID
    * @param useStatus 使用状态:0-未使用 1-已使用 2-已过期 3-已作废
    * @param pageNum 页码
    * @param pageSize 每页条数
    * @return 分页结果
    */

   @Override
   public Result<IPage<UserCoupon>> getUserCouponPage(Long userId, Integer useStatus, Integer pageNum, Integer pageSize) {
       // 参数校验
       if (ObjectUtils.isEmpty(userId)) {
           log.error("查询用户优惠券失败:用户ID不能为空");
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       pageNum = ObjectUtils.isEmpty(pageNum) ? 1 : pageNum;
       pageSize = ObjectUtils.isEmpty(pageSize) ? 10 : pageSize;

       // 分页查询
       Page<UserCoupon> page = new Page<>(pageNum, pageSize);
       IPage<UserCoupon> userCouponPage = baseMapper.selectUserCouponPage(page, userId, useStatus);
       log.info("查询用户优惠券成功,userId:{}, useStatus:{}, pageNum:{}, pageSize:{}, total:{}",
               userId, useStatus, pageNum, pageSize, userCouponPage.getTotal());
       return ResultBuilder.success(userCouponPage);
   }

   /**
    * 创建用户优惠券记录
    * @param coupon 优惠券信息
    * @param userId 用户ID
    * @return 用户优惠券ID
    */

   @Transactional(rollbackFor = Exception.class)
   public Long createUserCoupon(Coupon coupon, Long userId)
{
       // 构建用户优惠券实体
       UserCoupon userCoupon = new UserCoupon();
       userCoupon.setUserId(userId);
       userCoupon.setCouponId(coupon.getCouponId());
       // 生成唯一优惠券编码(UUID截取+前缀)
       String couponCode = CouponReceiveConstant.COUPON_CODE_PREFIX + UUID.randomUUID().toString().replace("-", "").substring(0, 14);
       userCoupon.setCouponCode(couponCode);
       userCoupon.setGetTime(LocalDateTime.now());

       // 计算实际有效期
       LocalDateTime validStartTime = LocalDateTime.now();
       LocalDateTime validEndTime;
       if (coupon.getValidType() == 1) {
           // 固定时间类型
           validStartTime = coupon.getStartTime();
           validEndTime = coupon.getEndTime();
       } else {
           // 领取后N天有效
           validEndTime = LocalDateTime.now().plusDays(coupon.getValidDays());
       }
       userCoupon.setValidStartTime(validStartTime);
       userCoupon.setValidEndTime(validEndTime);

       // 初始状态:未使用
       userCoupon.setUseStatus(0);
       userCoupon.setCreateTime(LocalDateTime.now());
       userCoupon.setUpdateTime(LocalDateTime.now());

       // 保存记录
       boolean saveSuccess = this.save(userCoupon);
       if (!saveSuccess) {
           log.error("创建用户优惠券失败,userId:{}, couponId:{}", userId, coupon.getCouponId());
           throw new BusinessException(BusinessErrorCode.USER_COUPON_CREATE_FAIL);
       }
       return userCoupon.getUserCouponId();
   }
}

4.3.7 Controller层(UserCouponController.java)

package com.jam.demo.coupon.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.service.UserCouponService;
import com.jam.demo.coupon.vo.CouponReceiveVO;
import com.jam.demo.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
* 用户优惠券控制器
* @author ken
* @date 2025-12-01
*/

@Slf4j
@RestController
@RequestMapping("/coupon/user")
@RequiredArgsConstructor
@Tag(name = "用户优惠券接口", description = "优惠券领取、查询接口")
public class UserCouponController {

   private final UserCouponService userCouponService;

   /**
    * 领取优惠券
    * @param receiveVO 领取参数
    * @return 领取结果(用户优惠券ID)
    */

   @PostMapping("/receive")
   @Operation(summary = "领取优惠券", description = "用户主动领取优惠券,需满足库存和限领规则")
   public Result<Long> receiveCoupon(
           @Parameter(description = "领取参数")
@Validated @RequestBody CouponReceiveVO receiveVO) {
       log.info("用户领取优惠券请求,receiveVO:{}", receiveVO);
       return userCouponService.receiveCoupon(receiveVO);
   }

   /**
    * 分页查询用户优惠券列表
    * @param userId 用户ID
    * @param useStatus 使用状态:0-未使用 1-已使用 2-已过期 3-已作废
    * @param pageNum 页码
    * @param pageSize 每页条数
    * @return 分页结果
    */

   @GetMapping("/page")
   @Operation(summary = "查询用户优惠券列表", description = "分页查询用户名下指定状态的优惠券")
   public Result<IPage<UserCoupon>> getUserCouponPage(
           @Parameter(description = "用户ID") @RequestParam Long userId,
           @Parameter(description = "使用状态:0-未使用 1-已使用 2-已过期 3-已作废") @RequestParam(required = false) Integer useStatus,
           @Parameter(description = "页码") @RequestParam(required = false) Integer pageNum,
           @Parameter(description = "每页条数") @RequestParam(required = false) Integer pageSize) {
       log.info("查询用户优惠券列表请求,userId:{}, useStatus:{}, pageNum:{}, pageSize:{}",
               userId, useStatus, pageNum, pageSize);
       return userCouponService.getUserCouponPage(userId, useStatus, pageNum, pageSize);
   }
}

4.4 优惠券使用与核销功能实现

4.4.1 核心设计思路

优惠券使用是订单流程的核心环节,需解决:

  1. 规则校验:确保优惠券满足使用条件(有效期、最低消费、商品限制等);
  2. 金额计算:根据优惠券类型(满减、折扣等)精准计算抵扣金额;
  3. 一致性:使用与订单创建、支付状态联动,避免未支付订单占用优惠券;
  4. 互斥处理:同一订单不可使用互斥优惠券。

使用流程:

image.png

4.4.2 核心VO定义

package com.jam.demo.coupon.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.List;

/**
* 优惠券使用请求参数
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "优惠券使用请求参数")
public class CouponUseVO {
   /**
    * 用户ID
    */

   @NotNull(message = "用户ID不能为空")
   @Schema(description = "用户ID", required = true)
   private Long userId;

   /**
    * 订单ID
    */

   @NotNull(message = "订单ID不能为空")
   @Schema(description = "订单ID", required = true)
   private Long orderId;

   /**
    * 订单金额(不含优惠券抵扣)
    */

   @NotNull(message = "订单金额不能为空")
   @Schema(description = "订单金额(不含优惠券抵扣)", required = true)
   private BigDecimal orderAmount;

   /**
    * 选中的优惠券ID列表
    */

   @NotNull(message = "优惠券ID列表不能为空")
   @Schema(description = "选中的优惠券ID列表", required = true)
   private List<Long> userCouponIds;

   /**
    * 订单商品信息(用于商品限制校验)
    */

   @NotNull(message = "订单商品信息不能为空")
   @Schema(description = "订单商品信息", required = true)
   private List<OrderItemVO> orderItems;
}

/**
* 订单商品VO
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "订单商品VO")
class OrderItemVO {
   /**
    * 商品SKU ID
    */

   @Schema(description = "商品SKU ID")
   private Long skuId;

   /**
    * 商品品类ID
    */

   @Schema(description = "商品品类ID")
   private Long categoryId;

   /**
    * 商品金额
    */

   @Schema(description = "商品金额")
   private BigDecimal itemAmount;
}

package com.jam.demo.coupon.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

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

/**
* 优惠券使用结果VO
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "优惠券使用结果VO")
public class CouponUseResultVO {
   /**
    * 总抵扣金额
    */

   @Schema(description = "总抵扣金额")
   private BigDecimal totalDiscountAmount;

   /**
    * 各优惠券抵扣详情
    */

   @Schema(description = "各优惠券抵扣详情")
   private List<CouponDiscountDetailVO> discountDetails;

   /**
    * 实付金额(订单金额-总抵扣金额)
    */

   @Schema(description = "实付金额")
   private BigDecimal actualPayAmount;
}

/**
* 优惠券抵扣详情VO
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "优惠券抵扣详情VO")
class CouponDiscountDetailVO {
   /**
    * 用户优惠券ID
    */

   @Schema(description = "用户优惠券ID")
   private Long userCouponId;

   /**
    * 优惠券编码
    */

   @Schema(description = "优惠券编码")
   private String couponCode;

   /**
    * 优惠券类型
    */

   @Schema(description = "优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券")
   private Integer couponType;

   /**
    * 抵扣金额
    */

   @Schema(description = "抵扣金额")
   private BigDecimal discountAmount;
}

4.4.3 规则引擎实现(基于Easy Rule)

package com.jam.demo.coupon.rule;

import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.CouponRule;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.service.CouponRuleService;
import com.jam.demo.coupon.vo.OrderItemVO;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.jeasy.rules.annotation.Action;
import org.jeasy.rules.annotation.Condition;
import org.jeasy.rules.annotation.Rule;
import org.jeasy.rules.api.Facts;
import org.jeasy.rules.api.Rules;
import org.jeasy.rules.api.RulesEngine;
import org.jeasy.rules.core.DefaultRulesEngine;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

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

/**
* 优惠券使用规则引擎
* @author ken
* @date 2025-12-01
*/

@Component
public class CouponUseRuleEngine {

   private final CouponRuleService couponRuleService;
   private final RulesEngine rulesEngine;
   private final Rules rules;

   public CouponUseRuleEngine(CouponRuleService couponRuleService) {
       this.couponRuleService = couponRuleService;
       // 初始化规则引擎(跳过失败的规则)
       this.rulesEngine = new DefaultRulesEngine();
       // 注册所有规则
       this.rules = new Rules();
       this.rules.register(new CouponValidStatusRule());
       this.rules.register(new CouponValidTimeRule());
       this.rules.register(new MinSpendRule());
       this.rules.register(new GoodsLimitRule());
   }

   /**
    * 执行规则校验
    * @param ruleFacts 规则校验上下文
    */

   public void execute(CouponUseRuleFacts ruleFacts) {
       Facts facts = new Facts();
       facts.put("ruleFacts", ruleFacts);
       rulesEngine.fire(rules, facts);
   }

   /**
    * 规则校验上下文
    */

   @Data
   @AllArgsConstructor
   @NoArgsConstructor
   public static class CouponUseRuleFacts {
       /** 用户优惠券 */
       private UserCoupon userCoupon;
       /** 优惠券主信息 */
       private Coupon coupon;
       /** 订单金额 */
       private BigDecimal orderAmount;
       /** 订单商品列表 */
       private List<OrderItemVO> orderItems;
   }

   /**
    * 规则1:优惠券状态有效性规则
    */

   @Rule(name = "CouponValidStatusRule", description = "优惠券状态有效性校验:必须为未使用状态")
   public static class CouponValidStatusRule {
       @Condition
       public boolean isInvalidStatus(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           UserCoupon userCoupon = ruleFacts.getUserCoupon();
           // 状态为0(未使用)则通过校验
           return !ObjectUtils.isEmpty(userCoupon) && userCoupon.getUseStatus() != 0;
       }

       @Action
       public void throwInvalidStatusException(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           throw new BusinessException(BusinessErrorCode.COUPON_USE_STATUS_INVALID,
                   "优惠券状态无效,userCouponId:" + ruleFacts.getUserCoupon().getUserCouponId());
       }
   }

   /**
    * 规则2:优惠券有效期规则
    */

   @Rule(name = "CouponValidTimeRule", description = "优惠券有效期校验:必须在有效期内")
   public static class CouponValidTimeRule {
       @Condition
       public boolean isOutOfValidTime(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           UserCoupon userCoupon = ruleFacts.getUserCoupon();
           LocalDateTime now = LocalDateTime.now();
           // 生效时间<=当前时间<=失效时间则通过校验
           return !ObjectUtils.isEmpty(userCoupon) &&
                   (now.isBefore(userCoupon.getValidStartTime()) || now.isAfter(userCoupon.getValidEndTime()));
       }

       @Action
       public void throwOutOfValidTimeException(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           UserCoupon userCoupon = ruleFacts.getUserCoupon();
           throw new BusinessException(BusinessErrorCode.COUPON_USE_OUT_OF_TIME,
                   "优惠券已过期或未生效,userCouponId:" + userCoupon.getUserCouponId() +
                   ", validStartTime:" + userCoupon.getValidStartTime() +
                   ", validEndTime:" + userCoupon.getValidEndTime());
       }
   }

   /**
    * 规则3:最低消费规则
    */

   @Rule(name = "MinSpendRule", description = "最低消费金额校验:订单金额需满足优惠券最低消费要求")
   public static class MinSpendRule {
       @Condition
       public boolean isLessThanMinSpend(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           Coupon coupon = ruleFacts.getCoupon();
           BigDecimal orderAmount = ruleFacts.getOrderAmount();
           // 无最低消费(minSpend=0)或订单金额>=最低消费则通过校验
           return !ObjectUtils.isEmpty(coupon) && !ObjectUtils.isEmpty(orderAmount) &&
                   coupon.getMinSpend().compareTo(BigDecimal.ZERO) > 0 &&
                   orderAmount.compareTo(coupon.getMinSpend()) < 0;
       }

       @Action
       public void throwLessThanMinSpendException(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           Coupon coupon = ruleFacts.getCoupon();
           throw new BusinessException(BusinessErrorCode.COUPON_USE_MIN_SPEND_NOT_MEET,
                   "订单金额未满足最低消费要求,minSpend:" + coupon.getMinSpend() +
                   ", orderAmount:" + ruleFacts.getOrderAmount());
       }
   }

   /**
    * 规则4:商品限制规则(品类/sku限制)
    */

   @Rule(name = "GoodsLimitRule", description = "商品限制校验:订单商品需满足优惠券的商品规则")
   public static class GoodsLimitRule {
       @Condition
       public boolean isGoodsNotMeetRule(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           Coupon coupon = ruleFacts.getCoupon();
           List<OrderItemVO> orderItems = ruleFacts.getOrderItems();
           if (ObjectUtils.isEmpty(coupon) || CollectionUtils.isEmpty(orderItems)) {
               return true;
           }

           // 查询该优惠券的商品相关规则(品类限制/sku限制)
           List<CouponRule> goodsRules = couponRuleService.list(
                   new LambdaQueryWrapper<CouponRule>()
                           .eq(CouponRule::getCouponId, coupon.getCouponId())
                           .in(CouponRule::getRuleType, 1, 2) // 1-品类限制 2-sku限制
           );
           if (CollectionUtils.isEmpty(goodsRules)) {
               // 无商品限制规则,直接通过
               return false;
           }

           // 提取订单中的品类ID和SKU ID
           List<Long> orderCategoryIds = orderItems.stream().map(OrderItemVO::getCategoryId).distinct().collect(Collectors.toList());
           List<Long> orderSkuIds = orderItems.stream().map(OrderItemVO::getSkuId).distinct().collect(Collectors.toList());

           // 校验每个商品规则
           for (CouponRule rule : goodsRules) {
               List<Long> ruleValues = List.of(rule.getRuleValue().split(",")).stream()
                       .map(Long::valueOf).collect(Collectors.toList());
               boolean isMatch = false;

               if (rule.getRuleType() == 1) { // 品类限制
                   isMatch = orderCategoryIds.stream().anyMatch(ruleValues::contains);
               } else if (rule.getRuleType() == 2) { // SKU限制
                   isMatch = orderSkuIds.stream().anyMatch(ruleValues::contains);
               }

               // 包含规则:需至少匹配一个;排除规则:需全部不匹配
               if (rule.getRuleOperator() == 1 && !isMatch) { // 包含:未匹配则不通过
                   return true;
               }
               if (rule.getRuleOperator() == 2 && isMatch) { // 排除:匹配则不通过
                   return true;
               }
           }

           return false;
       }

       @Action
       public void throwGoodsNotMeetRuleException(Facts facts) {
           CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
           throw new BusinessException(BusinessErrorCode.COUPON_USE_GOODS_NOT_MEET,
                   "订单商品不满足优惠券使用规则,couponId:" + ruleFacts.getCoupon().getCouponId());
       }
   }
}

4.4.4 Service层接口(CouponUseService.java)

package com.jam.demo.coupon.service;

import com.jam.demo.coupon.vo.CouponUseResultVO;
import com.jam.demo.coupon.vo.CouponUseVO;
import com.jam.demo.common.result.Result;

/**
* 优惠券使用服务接口
* @author ken
* @date 2025-12-01
*/

public interface CouponUseService {
   /**
    * 计算优惠券抵扣金额(订单提交时)
    * @param useVO 使用参数
    * @return 抵扣结果
    */

   Result<CouponUseResultVO> calculateDiscount(CouponUseVO useVO);

   /**
    * 锁定优惠券(订单创建后未支付)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 锁定结果
    */

   Result<Boolean> lockCoupons(Long userId, Long orderId, List<Long> userCouponIds);

   /**
    * 核销优惠券(订单支付成功)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 核销结果
    */

   Result<Boolean> writeOffCoupons(Long userId, Long orderId, List<Long> userCouponIds);

   /**
    * 解锁优惠券(订单取消/支付超时)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 解锁结果
    */

   Result<Boolean> unlockCoupons(Long userId, Long orderId, List<Long> userCouponIds);
}

4.4.5 Service层实现(CouponUseServiceImpl.java)

package com.jam.demo.coupon.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.google.common.collect.Lists;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.rule.CouponUseRuleEngine;
import com.jam.demo.coupon.service.CouponService;
import com.jam.demo.coupon.service.CouponUseService;
import com.jam.demo.coupon.service.UserCouponService;
import com.jam.demo.coupon.vo.CouponDiscountDetailVO;
import com.jam.demo.coupon.vo.CouponUseResultVO;
import com.jam.demo.coupon.vo.CouponUseVO;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import com.jam.demo.common.result.Result;
import com.jam.demo.common.result.ResultBuilder;
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 org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

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

/**
* 优惠券使用服务实现类
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class CouponUseServiceImpl implements CouponUseService {

   private final CouponService couponService;
   private final UserCouponService userCouponService;
   private final CouponUseRuleEngine couponUseRuleEngine;
   private final RedissonClient redissonClient;

   /** 优惠券锁定/核销分布式锁key:coupon:lock:use:{orderId} */
   private static final String COUPON_USE_LOCK_KEY = "coupon:lock:use:%s";

   /**
    * 计算优惠券抵扣金额(订单提交时)
    * @param useVO 使用参数
    * @return 抵扣结果
    */

   @Override
   public Result<CouponUseResultVO> calculateDiscount(CouponUseVO useVO) {
       // 1. 参数校验
       validateUseParam(useVO);
       Long userId = useVO.getUserId();
       BigDecimal orderAmount = useVO.getOrderAmount();
       List<Long> userCouponIds = useVO.getUserCouponIds();
       List<OrderItemVO> orderItems = useVO.getOrderItems();

       // 2. 查询优惠券信息
       List<UserCoupon> userCouponList = userCouponService.listByIds(userCouponIds);
       if (CollectionUtils.isEmpty(userCouponList) || userCouponList.size() != userCouponIds.size()) {
           log.error("计算优惠券抵扣失败:部分优惠券不存在,userId:{}, userCouponIds:{}", userId, userCouponIds);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }

       // 3. 校验优惠券归属(必须是当前用户的优惠券)
       boolean hasInvalidOwner = userCouponList.stream().anyMatch(coupon -> !coupon.getUserId().equals(userId));
       if (hasInvalidOwner) {
           log.error("计算优惠券抵扣失败:存在非当前用户的优惠券,userId:{}, userCouponIds:{}", userId, userCouponIds);
           throw new BusinessException(BusinessErrorCode.COUPON_OWNER_INVALID);
       }

       // 4. 规则校验(通过规则引擎)
       List<CouponDiscountDetailVO> discountDetails = Lists.newArrayList();
       BigDecimal totalDiscountAmount = BigDecimal.ZERO;
       for (UserCoupon userCoupon : userCouponList) {
           Long couponId = userCoupon.getCouponId();
           Coupon coupon = couponService.getById(couponId);
           if (ObjectUtils.isEmpty(coupon)) {
               log.error("计算优惠券抵扣失败:优惠券主信息不存在,couponId:{}", couponId);
               throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
           }

           // 执行规则校验
           CouponUseRuleEngine.CouponUseRuleFacts ruleFacts = new CouponUseRuleEngine.CouponUseRuleFacts();
           ruleFacts.setUserCoupon(userCoupon);
           ruleFacts.setCoupon(coupon);
           ruleFacts.setOrderAmount(orderAmount);
           ruleFacts.setOrderItems(orderItems);
           couponUseRuleEngine.execute(ruleFacts);

           // 计算单张优惠券抵扣金额
           BigDecimal discountAmount = calculateSingleCouponDiscount(coupon, orderAmount);
           totalDiscountAmount = totalDiscountAmount.add(discountAmount);

           // 构建抵扣详情
           CouponDiscountDetailVO detailVO = new CouponDiscountDetailVO();
           detailVO.setUserCouponId(userCoupon.getUserCouponId());
           detailVO.setCouponCode(userCoupon.getCouponCode());
           detailVO.setCouponType(coupon.getCouponType());
           detailVO.setDiscountAmount(discountAmount);
           discountDetails.add(detailVO);
       }

       // 5. 校验总抵扣金额(不能超过订单金额)
       if (totalDiscountAmount.compareTo(orderAmount) > 0) {
           log.error("计算优惠券抵扣失败:总抵扣金额超过订单金额,orderAmount:{}, totalDiscountAmount:{}",
                   orderAmount, totalDiscountAmount);
           throw new BusinessException(BusinessErrorCode.COUPON_DISCOUNT_EXCEED_ORDER);
       }

       // 6. 构建返回结果
       CouponUseResultVO resultVO = new CouponUseResultVO();
       resultVO.setTotalDiscountAmount(totalDiscountAmount);
       resultVO.setDiscountDetails(discountDetails);
       resultVO.setActualPayAmount(orderAmount.subtract(totalDiscountAmount));

       log.info("计算优惠券抵扣成功,userId:{}, orderId:{}, totalDiscountAmount:{}",
               userId, useVO.getOrderId(), totalDiscountAmount);
       return ResultBuilder.success(resultVO);
   }

   /**
    * 锁定优惠券(订单创建后未支付)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 锁定结果
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Boolean> lockCoupons(Long userId, Long orderId, List<Long> userCouponIds)
{
       // 参数校验
       if (ObjectUtils.isEmpty(userId) || ObjectUtils.isEmpty(orderId) || CollectionUtils.isEmpty(userCouponIds)) {
           log.error("锁定优惠券失败:参数为空,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }

       // 获取分布式锁
       String lockKey = String.format(COUPON_USE_LOCK_KEY, orderId);
       RLock lock = redissonClient.getLock(lockKey);
       boolean lockAcquired = false;
       try {
           lockAcquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
           if (!lockAcquired) {
               log.error("锁定优惠券失败:获取锁失败,orderId:{}", orderId);
               throw new BusinessException(BusinessErrorCode.COUPON_LOCK_FAIL);
           }

           // 校验优惠券状态并锁定(状态改为4-锁定中)
           List<UserCoupon> userCouponList = userCouponService.listByIds(userCouponIds);
           for (UserCoupon userCoupon : userCouponList) {
               if (!userCoupon.getUserId().equals(userId) || userCoupon.getUseStatus() != 0) {
                   log.error("锁定优惠券失败:优惠券状态或归属无效,userCouponId:{}, userId:{}, useStatus:{}",
                           userCoupon.getUserCouponId(), userId, userCoupon.getUseStatus());
                   throw new BusinessException(BusinessErrorCode.COUPON_LOCK_INVALID);
               }
               userCoupon.setUseStatus(4); // 锁定状态
               userCoupon.setOrderId(orderId);
               userCoupon.setUpdateTime(LocalDateTime.now());
           }

           boolean updateSuccess = userCouponService.updateBatchById(userCouponList);
           if (!updateSuccess) {
               log.error("锁定优惠券失败:更新状态失败,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
               throw new BusinessException(BusinessErrorCode.COUPON_LOCK_FAIL);
           }

           log.info("锁定优惠券成功,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
           return ResultBuilder.success(true);
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("锁定优惠券失败:系统异常,orderId:{}", orderId, e);
           throw new BusinessException(BusinessErrorCode.SYSTEM_ERROR);
       } finally {
           if (lockAcquired && lock.isHeldByCurrentThread()) {
               lock.unlock();
           }
       }
   }

   /**
    * 核销优惠券(订单支付成功)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 核销结果
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Boolean> writeOffCoupons(Long userId, Long orderId, List<Long> userCouponIds)
{
       // 参数校验
       if (ObjectUtils.isEmpty(userId) || ObjectUtils.isEmpty(orderId) || CollectionUtils.isEmpty(userCouponIds)) {
           log.error("核销优惠券失败:参数为空,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }

       // 获取分布式锁
       String lockKey = String.format(COUPON_USE_LOCK_KEY, orderId);
       RLock lock = redissonClient.getLock(lockKey);
       boolean lockAcquired = false;
       try {
           lockAcquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
           if (!lockAcquired) {
               log.error("核销优惠券失败:获取锁失败,orderId:{}", orderId);
               throw new BusinessException(BusinessErrorCode.COUPON_WRITE_OFF_LOCK_FAIL);
           }

           // 校验优惠券状态并核销(状态改为1-已使用)
           List<UserCoupon> userCouponList = userCouponService.listByIds(userCouponIds);
           for (UserCoupon userCoupon : userCouponList) {
               if (!userCoupon.getUserId().equals(userId) || userCoupon.getUseStatus() != 4 || !userCoupon.getOrderId().equals(orderId)) {
                   log.error("核销优惠券失败:优惠券状态或归属或订单ID不匹配,userCouponId:{}, userId:{}, useStatus:{}, orderId:{}",
                           userCoupon.getUserCouponId(), userId, userCoupon.getUseStatus(), userCoupon.getOrderId());
                   throw new BusinessException(BusinessErrorCode.COUPON_WRITE_OFF_INVALID);
               }
               userCoupon.setUseStatus(1); // 已使用状态
               userCoupon.setUseTime(LocalDateTime.now());
               userCoupon.setUpdateTime(LocalDateTime.now());
           }

           boolean updateSuccess = userCouponService.updateBatchById(userCouponList);
           if (!updateSuccess) {
               log.error("核销优惠券失败:更新状态失败,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
               throw new BusinessException(BusinessErrorCode.COUPON_WRITE_OFF_FAIL);
           }

           log.info("核销优惠券成功,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
           return ResultBuilder.success(true);
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("核销优惠券失败:系统异常,orderId:{}", orderId, e);
           throw new BusinessException(BusinessErrorCode.SYSTEM_ERROR);
       } finally {
           if (lockAcquired && lock.isHeldByCurrentThread()) {
               lock.unlock();
           }
       }
   }

   /**
    * 解锁优惠券(订单取消/支付超时)
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 解锁结果
    */

   @Override
   @Transactional(rollbackFor = Exception.class)
   public Result<Boolean> unlockCoupons(Long userId, Long orderId, List<Long> userCouponIds)
{
       // 参数校验
       if (ObjectUtils.isEmpty(userId) || ObjectUtils.isEmpty(orderId) || CollectionUtils.isEmpty(userCouponIds)) {
           log.error("解锁优惠券失败:参数为空,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }

       // 获取分布式锁
       String lockKey = String.format(COUPON_USE_LOCK_KEY, orderId);
       RLock lock = redissonClient.getLock(lockKey);
       boolean lockAcquired = false;
       try {
           lockAcquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
           if (!lockAcquired) {
               log.error("解锁优惠券失败:获取锁失败,orderId:{}", orderId);
               throw new BusinessException(BusinessErrorCode.COUPON_UNLOCK_FAIL);
           }

           // 校验优惠券状态并解锁(状态改为0-未使用)
           List<UserCoupon> userCouponList = userCouponService.listByIds(userCouponIds);
           for (UserCoupon userCoupon : userCouponList) {
               if (!userCoupon.getUserId().equals(userId) || userCoupon.getUseStatus() != 4 || !userCoupon.getOrderId().equals(orderId)) {
                   log.error("解锁优惠券失败:优惠券状态或归属或订单ID不匹配,userCouponId:{}, userId:{}, useStatus:{}, orderId:{}",
                           userCoupon.getUserCouponId(), userId, userCoupon.getUseStatus(), userCoupon.getOrderId());
                   throw new BusinessException(BusinessErrorCode.COUPON_UNLOCK_INVALID);
               }
               userCoupon.setUseStatus(0); // 恢复未使用状态
               userCoupon.setOrderId(null);
               userCoupon.setUpdateTime(LocalDateTime.now());
           }

           boolean updateSuccess = userCouponService.updateBatchById(userCouponList);
           if (!updateSuccess) {
               log.error("解锁优惠券失败:更新状态失败,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
               throw new BusinessException(BusinessErrorCode.COUPON_UNLOCK_FAIL);
           }

           log.info("解锁优惠券成功,orderId:{}, userCouponIds:{}", orderId, userCouponIds);
           return ResultBuilder.success(true);
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("解锁优惠券失败:系统异常,orderId:{}", orderId, e);
           throw new BusinessException(BusinessErrorCode.SYSTEM_ERROR);
       } finally {
           if (lockAcquired && lock.isHeldByCurrentThread()) {
               lock.unlock();
           }
       }
   }

   /**
    * 校验使用参数
    * @param useVO 使用参数
    */

   private void validateUseParam(CouponUseVO useVO) {
       if (ObjectUtils.isEmpty(useVO)) {
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       if (ObjectUtils.isEmpty(useVO.getUserId())) {
           throw new BusinessException(BusinessErrorCode.USER_ID_EMPTY);
       }
       if (ObjectUtils.isEmpty(useVO.getOrderId())) {
           throw new BusinessException(BusinessErrorCode.ORDER_ID_EMPTY);
       }
       if (ObjectUtils.isEmpty(useVO.getOrderAmount()) || useVO.getOrderAmount().compareTo(BigDecimal.ZERO) <= 0) {
           throw new BusinessException(BusinessErrorCode.ORDER_AMOUNT_INVALID);
       }
       if (CollectionUtils.isEmpty(useVO.getUserCouponIds())) {
           throw new BusinessException(BusinessErrorCode.COUPON_ID_LIST_EMPTY);
       }
       if (CollectionUtils.isEmpty(useVO.getOrderItems())) {
           throw new BusinessException(BusinessErrorCode.ORDER_ITEM_EMPTY);
       }
   }

   /**
    * 计算单张优惠券抵扣金额
    * @param coupon 优惠券信息
    * @param orderAmount 订单金额
    * @return 抵扣金额
    */

   private BigDecimal calculateSingleCouponDiscount(Coupon coupon, BigDecimal orderAmount) {
       Integer couponType = coupon.getCouponType();
       BigDecimal faceValue = coupon.getFaceValue();
       BigDecimal minSpend = coupon.getMinSpend();

       // 校验订单金额是否满足最低消费(此处二次校验,避免规则引擎遗漏)
       if (orderAmount.compareTo(minSpend) < 0) {
           throw new BusinessException(BusinessErrorCode.COUPON_USE_MIN_SPEND_NOT_MEET);
       }

       switch (couponType) {
           case 1: // 满减券:面额为减金额
               return faceValue;
           case 2: // 折扣券:面额为折扣比例(如90代表9折)
               BigDecimal discountRate = faceValue.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP);
               BigDecimal discountAmount = orderAmount.multiply(new BigDecimal(1).subtract(discountRate));
               // 折扣券抵扣金额不超过订单金额
               return discountAmount.compareTo(orderAmount) > 0 ? orderAmount : discountAmount;
           case 3: // 无门槛券:直接抵扣面额(不超过订单金额)
               return faceValue.compareTo(orderAmount) > 0 ? orderAmount : faceValue;
           case 4: // 现金券:同无门槛券
               return faceValue.compareTo(orderAmount) > 0 ? orderAmount : faceValue;
           default:
               throw new BusinessException(BusinessErrorCode.COUPON_TYPE_ERROR);
       }
   }
}

4.4.6 Controller层(CouponUseController.java)

package com.jam.demo.coupon.controller;

import com.jam.demo.coupon.service.CouponUseService;
import com.jam.demo.coupon.vo.CouponUseResultVO;
import com.jam.demo.coupon.vo.CouponUseVO;
import com.jam.demo.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* 优惠券使用控制器
* @author ken
* @date 2025-12-01
*/

@Slf4j
@RestController
@RequestMapping("/coupon/use")
@RequiredArgsConstructor
@Tag(name = "优惠券使用接口", description = "优惠券抵扣计算、锁定、核销、解锁接口")
public class CouponUseController {

   private final CouponUseService couponUseService;

   /**
    * 计算优惠券抵扣金额
    * @param useVO 使用参数
    * @return 抵扣结果
    */

   @PostMapping("/calculate")
   @Operation(summary = "计算抵扣金额", description = "订单提交时计算选中优惠券的总抵扣金额")
   public Result<CouponUseResultVO> calculateDiscount(
           @Parameter(description = "使用参数")
@Validated @RequestBody CouponUseVO useVO) {
       log.info("计算优惠券抵扣金额请求,useVO:{}", useVO);
       return couponUseService.calculateDiscount(useVO);
   }

   /**
    * 锁定优惠券
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 锁定结果
    */

   @PostMapping("/lock")
   @Operation(summary = "锁定优惠券", description = "订单创建后锁定优惠券,防止重复使用")
   public Result<Boolean> lockCoupons(
           @Parameter(description = "用户ID")
@RequestParam Long userId,
           @Parameter(description = "订单ID") @RequestParam Long orderId,
           @Parameter(description = "优惠券ID列表") @RequestParam List<Long> userCouponIds) {
       log.info("锁定优惠券请求,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
       return couponUseService.lockCoupons(userId, orderId, userCouponIds);
   }

   /**
    * 核销优惠券
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 核销结果
    */

   @PostMapping("/writeOff")
   @Operation(summary = "核销优惠券", description = "订单支付成功后核销优惠券")
   public Result<Boolean> writeOffCoupons(
           @Parameter(description = "用户ID")
@RequestParam Long userId,
           @Parameter(description = "订单ID") @RequestParam Long orderId,
           @Parameter(description = "优惠券ID列表") @RequestParam List<Long> userCouponIds) {
       log.info("核销优惠券请求,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
       return couponUseService.writeOffCoupons(userId, orderId, userCouponIds);
   }

   /**
    * 解锁优惠券
    * @param userId 用户ID
    * @param orderId 订单ID
    * @param userCouponIds 优惠券ID列表
    * @return 解锁结果
    */

   @PostMapping("/unlock")
   @Operation(summary = "解锁优惠券", description = "订单取消或支付超时后解锁优惠券")
   public Result<Boolean> unlockCoupons(
           @Parameter(description = "用户ID")
@RequestParam Long userId,
           @Parameter(description = "订单ID") @RequestParam Long orderId,
           @Parameter(description = "优惠券ID列表") @RequestParam List<Long> userCouponIds) {
       log.info("解锁优惠券请求,userId:{}, orderId:{}, userCouponIds:{}", userId, orderId, userCouponIds);
       return couponUseService.unlockCoupons(userId, orderId, userCouponIds);
   }
}

4.5 优惠券过期处理功能实现

4.5.1 核心设计思路

优惠券过期处理需保证:

  1. 及时性:过期优惠券及时标记为“已过期”状态;
  2. 高效性:避免全表扫描,通过索引和分批处理提升性能;
  3. 通知性:可选功能,向用户推送优惠券过期通知。

处理流程:

image.png

4.5.2 定时任务实现(CouponExpireTask.java)

package com.jam.demo.coupon.task;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.service.UserCouponService;
import com.jam.demo.coupon.service.MessagePushService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* 优惠券过期处理定时任务
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Component
@RequiredArgsConstructor
public class CouponExpireTask {

   private final UserCouponService userCouponService;
   private final MessagePushService messagePushService;

   /** 分批处理大小 */
   @Value("${coupon.expire-task.batch-size:1000}")
   private Integer batchSize;

   /** 线程池(异步处理通知推送) */
   private static final ExecutorService NOTIFY_EXECUTOR = Executors.newFixedThreadPool(5);

   /**
    * 优惠券过期处理任务(每天凌晨1点执行,对应配置文件cron表达式)
    */

   @Scheduled(cron = "${coupon.expire-task-cron:0 0 1 * * ?}")
   public void handleCouponExpire() {
       log.info("优惠券过期处理任务开始执行,当前时间:{}", LocalDateTime.now());
       long startTime = System.currentTimeMillis();

       try {
           int pageNum = 1;
           while (true) {
               // 分页查询待过期优惠券:未使用(0)或锁定中(4)且失效时间<=当前时间
               LambdaQueryWrapper<UserCoupon> queryWrapper = new LambdaQueryWrapper<UserCoupon>()
                       .in(UserCoupon::getUseStatus, 0, 4)
                       .le(UserCoupon::getValidEndTime, LocalDateTime.now())
                       .last("LIMIT " + (pageNum - 1) * batchSize + "," + batchSize);

               List<UserCoupon> expireCouponList = userCouponService.list(queryWrapper);
               if (CollectionUtils.isEmpty(expireCouponList)) {
                   log.info("优惠券过期处理任务:第{}页无待处理数据,任务结束", pageNum);
                   break;
               }

               // 批量更新状态为已过期(2)
               updateCouponExpireStatus(expireCouponList);

               // 异步推送过期通知(非核心流程,不阻塞主任务)
               pushExpireNotify(expireCouponList);

               log.info("优惠券过期处理任务:第{}页处理完成,处理数量:{}", pageNum, expireCouponList.size());
               pageNum++;
           }

           long costTime = System.currentTimeMillis() - startTime;
           log.info("优惠券过期处理任务执行完成,总耗时:{}ms", costTime);
       } catch (Exception e) {
           log.error("优惠券过期处理任务执行失败", e);
       }
   }

   /**
    * 批量更新优惠券为过期状态
    * @param expireCouponList 待过期优惠券列表
    */

   private void updateCouponExpireStatus(List<UserCoupon> expireCouponList) {
       List<Long> userCouponIds = expireCouponList.stream()
               .map(UserCoupon::getUserCouponId)
               .toList();

       LambdaUpdateWrapper<UserCoupon> updateWrapper = new LambdaUpdateWrapper<UserCoupon>()
               .in(UserCoupon::getUserCouponId, userCouponIds)
               .set(UserCoupon::getUseStatus, 2) // 状态改为已过期
               .set(UserCoupon::getUpdateTime, LocalDateTime.now());

       boolean updateSuccess = userCouponService.update(updateWrapper);
       if (!updateSuccess) {
           log.error("批量更新优惠券过期状态失败,userCouponIds:{}", userCouponIds);
           // 此处可添加失败重试机制或告警通知
       }
   }

   /**
    * 异步推送优惠券过期通知
    * @param expireCouponList 已过期优惠券列表
    */

   private void pushExpireNotify(List<UserCoupon> expireCouponList) {
       NOTIFY_EXECUTOR.submit(() -> {
           try {
               for (UserCoupon userCoupon : expireCouponList) {
                   Long userId = userCoupon.getUserId();
                   Long couponId = userCoupon.getCouponId();
                   String couponCode = userCoupon.getCouponCode();
                   // 推送通知(示例:调用消息推送服务,支持APP推送、短信、站内信等)
                   messagePushService.pushCouponExpireNotify(userId, couponId, couponCode);
               }
               log.info("优惠券过期通知推送完成,推送数量:{}", expireCouponList.size());
           } catch (Exception e) {
               log.error("优惠券过期通知推送失败", e);
           }
       });
   }
}

4.5.3 消息推送服务(MessagePushService.java)

package com.jam.demo.coupon.service;

import com.alibaba.fastjson2.JSON;
import com.jam.demo.common.constant.MqConstant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.stereotype.Service;

/**
* 消息推送服务(优惠券相关通知)
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class MessagePushService {

   private final RocketMQTemplate rocketMQTemplate;

   /**
    * 推送优惠券过期通知
    * @param userId 用户ID
    * @param couponId 优惠券ID
    * @param couponCode 优惠券编码
    */

   public void pushCouponExpireNotify(Long userId, Long couponId, String couponCode) {
       // 构建通知消息体
       CouponExpireNotifyDTO notifyDTO = new CouponExpireNotifyDTO();
       notifyDTO.setUserId(userId);
       notifyDTO.setCouponId(couponId);
       notifyDTO.setCouponCode(couponCode);
       notifyDTO.setNotifyTime(System.currentTimeMillis());
       notifyDTO.setNotifyType(1); // 1-优惠券过期通知

       try {
           // 发送到RocketMQ,由通知服务消费并推送
           rocketMQTemplate.convertAndSend(MqConstant.COUPON_EXPIRE_NOTIFY_TOPIC, notifyDTO);
           log.info("优惠券过期通知消息发送成功,notifyDTO:{}", JSON.toJSONString(notifyDTO));
       } catch (Exception e) {
           log.error("优惠券过期通知消息发送失败,notifyDTO:{}", JSON.toJSONString(notifyDTO), e);
           // 此处可添加消息重试机制
       }
   }

   /**
    * 优惠券过期通知DTO
    */

   @lombok.Data
   private static class CouponExpireNotifyDTO {
       private Long userId;
       private Long couponId;
       private String couponCode;
       private Long notifyTime;
       private Integer notifyType;
   }
}

4.6 优惠券数据统计功能实现

4.6.1 统计DTO定义

package com.jam.demo.coupon.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
* 优惠券统计结果DTO
* @author ken
* @date 2025-12-01
*/

@Data
@Schema(description = "优惠券统计结果DTO")
public class CouponStatDTO {
   /** 优惠券ID */
   @Schema(description = "优惠券ID")
   private Long couponId;

   /** 优惠券名称 */
   @Schema(description = "优惠券名称")
   private String couponName;

   /** 优惠券类型 */
   @Schema(description = "优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券")
   private Integer couponType;

   /** 发放总量 */
   @Schema(description = "发放总量")
   private Integer issueTotal;

   /** 已使用数量 */
   @Schema(description = "已使用数量")
   private Integer usedNum;

   /** 过期数量 */
   @Schema(description = "过期数量")
   private Integer expireNum;

   /** 使用率(保留2位小数) */
   @Schema(description = "使用率(%)")
   private BigDecimal useRate;

   /** 总核销金额 */
   @Schema(description = "总核销金额")
   private BigDecimal totalWriteOffAmount;

   /** 统计开始时间 */
   @Schema(description = "统计开始时间")
   private LocalDateTime statStartTime;

   /** 统计结束时间 */
   @Schema(description = "统计结束时间")
   private LocalDateTime statEndTime;
}

4.6.2 Mapper层(CouponStatMapper.java)

package com.jam.demo.coupon.mapper;

import com.jam.demo.coupon.dto.CouponStatDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;

/**
* 优惠券统计Mapper
* @author ken
* @date 2025-12-01
*/

@Mapper
public interface CouponStatMapper {

   /**
    * 统计单张优惠券数据
    * @param couponId 优惠券ID
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果
    */

   CouponStatDTO statSingleCoupon(
           @Param("couponId")
Long couponId,
           @Param("statStartTime") LocalDateTime statStartTime,
           @Param("statEndTime") LocalDateTime statEndTime)
;

   /**
    * 批量统计优惠券数据
    * @param couponIds 优惠券ID列表
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果列表
    */

   List<CouponStatDTO> statBatchCoupons(
           @Param("couponIds")
List<Long> couponIds,
           @Param("statStartTime") LocalDateTime statStartTime,
           @Param("statEndTime") LocalDateTime statEndTime)
;
}

4.6.3 Mapper XML文件(CouponStatMapper.xml)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jam.demo.coupon.mapper.CouponStatMapper">

   <select id="statSingleCoupon" resultType="com.jam.demo.coupon.dto.CouponStatDTO">
       SELECT
           c.coupon_id AS couponId,
           c.coupon_name AS couponName,
           c.coupon_type AS couponType,
           COUNT(uc.user_coupon_id) AS issueTotal,
           SUM(CASE WHEN uc.use_status = 1 THEN 1 ELSE 0 END) AS usedNum,
           SUM(CASE WHEN uc.use_status = 2 THEN 1 ELSE 0 END) AS expireNum,
           -- 使用率=已使用数量/发放总量(避免除零)
           CASE WHEN COUNT(uc.user_coupon_id) = 0 THEN 0
                ELSE ROUND(SUM(CASE WHEN uc.use_status = 1 THEN 1 ELSE 0 END) / COUNT(uc.user_coupon_id) * 100, 2)
           END AS useRate,
           -- 总核销金额(关联订单表查询实际抵扣金额,此处简化用优惠券面额累加)
           SUM(CASE WHEN uc.use_status = 1 THEN c.face_value ELSE 0 END) AS totalWriteOffAmount,
           #{statStartTime} AS statStartTime,
           #{statEndTime} AS statEndTime
       FROM
           coupon c
       LEFT JOIN
           user_coupon uc ON c.coupon_id = uc.coupon_id
           AND uc.get_time BETWEEN #{statStartTime} AND #{statEndTime}
       WHERE
           c.coupon_id = #{couponId}
       GROUP BY
           c.coupon_id, c.coupon_name, c.coupon_type;
   </select>

   <select id="statBatchCoupons" resultType="com.jam.demo.coupon.dto.CouponStatDTO">
       SELECT
           c.coupon_id AS couponId,
           c.coupon_name AS couponName,
           c.coupon_type AS couponType,
           COUNT(uc.user_coupon_id) AS issueTotal,
           SUM(CASE WHEN uc.use_status = 1 THEN 1 ELSE 0 END) AS usedNum,
           SUM(CASE WHEN uc.use_status = 2 THEN 1 ELSE 0 END) AS expireNum,
           CASE WHEN COUNT(uc.user_coupon_id) = 0 THEN 0
                ELSE ROUND(SUM(CASE WHEN uc.use_status = 1 THEN 1 ELSE 0 END) / COUNT(uc.user_coupon_id) * 100, 2)
           END AS useRate,
           SUM(CASE WHEN uc.use_status = 1 THEN c.face_value ELSE 0 END) AS totalWriteOffAmount,
           #{statStartTime} AS statStartTime,
           #{statEndTime} AS statEndTime
       FROM
           coupon c
       LEFT JOIN
           user_coupon uc ON c.coupon_id = uc.coupon_id
           AND uc.get_time BETWEEN #{statStartTime} AND #{statEndTime}
       WHERE
           c.coupon_id IN
           <foreach collection="couponIds" item="couponId" open="(" separator="," close=")">
               #{couponId}
           </foreach>
       GROUP BY
           c.coupon_id, c.coupon_name, c.coupon_type;
   </select>

</mapper>

4.6.4 Service层接口(CouponStatService.java)

package com.jam.demo.coupon.service;

import com.jam.demo.coupon.dto.CouponStatDTO;
import com.jam.demo.common.result.Result;
import java.time.LocalDateTime;
import java.util.List;

/**
* 优惠券统计服务接口
* @author ken
* @date 2025-12-01
*/

public interface CouponStatService {

   /**
    * 统计单张优惠券数据
    * @param couponId 优惠券ID
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果
    */

   Result<CouponStatDTO> statSingleCoupon(Long couponId, LocalDateTime statStartTime, LocalDateTime statEndTime);

   /**
    * 批量统计优惠券数据
    * @param couponIds 优惠券ID列表
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果列表
    */

   Result<List<CouponStatDTO>> statBatchCoupons(List<Long> couponIds, LocalDateTime statStartTime, LocalDateTime statEndTime);

   /**
    * 统计所有优惠券数据
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果列表
    */

   Result<List<CouponStatDTO>> statAllCoupons(LocalDateTime statStartTime, LocalDateTime statEndTime);
}

4.6.5 Service层实现(CouponStatServiceImpl.java)

package com.jam.demo.coupon.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jam.demo.coupon.dto.CouponStatDTO;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.mapper.CouponMapper;
import com.jam.demo.coupon.mapper.CouponStatMapper;
import com.jam.demo.coupon.service.CouponStatService;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import com.jam.demo.common.result.Result;
import com.jam.demo.common.result.ResultBuilder;
import com.google.common.collect.Lists;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

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

/**
* 优惠券统计服务实现类
* @author ken
* @date 2025-12-01
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class CouponStatServiceImpl implements CouponStatService {

   private final CouponStatMapper couponStatMapper;
   private final CouponMapper couponMapper;

   /**
    * 统计单张优惠券数据
    * @param couponId 优惠券ID
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果
    */

   @Override
   public Result<CouponStatDTO> statSingleCoupon(Long couponId, LocalDateTime statStartTime, LocalDateTime statEndTime) {
       // 参数校验
       if (ObjectUtils.isEmpty(couponId)) {
           log.error("统计单张优惠券失败:优惠券ID不能为空");
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       if (ObjectUtils.isEmpty(statStartTime) || ObjectUtils.isEmpty(statEndTime) || statStartTime.isAfter(statEndTime)) {
           log.error("统计单张优惠券失败:统计时间范围无效,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
           throw new BusinessException(BusinessErrorCode.STAT_TIME_RANGE_INVALID);
       }

       // 校验优惠券是否存在
       Coupon coupon = couponMapper.selectById(couponId);
       if (ObjectUtils.isEmpty(coupon)) {
           log.error("统计单张优惠券失败:优惠券不存在,couponId:{}", couponId);
           throw new BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
       }

       // 执行统计
       CouponStatDTO statDTO = couponStatMapper.statSingleCoupon(couponId, statStartTime, statEndTime);
       log.info("统计单张优惠券成功,couponId:{}, statDTO:{}", couponId, statDTO);
       return ResultBuilder.success(statDTO);
   }

   /**
    * 批量统计优惠券数据
    * @param couponIds 优惠券ID列表
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果列表
    */

   @Override
   public Result<List<CouponStatDTO>> statBatchCoupons(List<Long> couponIds, LocalDateTime statStartTime, LocalDateTime statEndTime) {
       // 参数校验
       if (CollectionUtils.isEmpty(couponIds)) {
           log.error("批量统计优惠券失败:优惠券ID列表不能为空");
           throw new BusinessException(BusinessErrorCode.PARAM_ERROR);
       }
       if (ObjectUtils.isEmpty(statStartTime) || ObjectUtils.isEmpty(statEndTime) || statStartTime.isAfter(statEndTime)) {
           log.error("批量统计优惠券失败:统计时间范围无效,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
           throw new BusinessException(BusinessErrorCode.STAT_TIME_RANGE_INVALID);
       }

       // 执行统计
       List<CouponStatDTO> statDTOList = couponStatMapper.statBatchCoupons(couponIds, statStartTime, statEndTime);
       log.info("批量统计优惠券成功,couponIds:{}, 统计数量:{}", couponIds, statDTOList.size());
       return ResultBuilder.success(statDTOList);
   }

   /**
    * 统计所有优惠券数据
    * @param statStartTime 统计开始时间
    * @param statEndTime 统计结束时间
    * @return 统计结果列表
    */

   @Override
   public Result<List<CouponStatDTO>> statAllCoupons(LocalDateTime statStartTime, LocalDateTime statEndTime) {
       // 参数校验
       if (ObjectUtils.isEmpty(statStartTime) || ObjectUtils.isEmpty(statEndTime) || statStartTime.isAfter(statEndTime)) {
           log.error("统计所有优惠券失败:统计时间范围无效,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
           throw new BusinessException(BusinessErrorCode.STAT_TIME_RANGE_INVALID);
       }

       // 查询所有优惠券ID
       List<Long> couponIds = couponMapper.selectList(new LambdaQueryWrapper<Coupon>())
               .stream()
               .map(Coupon::getCouponId)
               .collect(Collectors.toList());

       if (CollectionUtils.isEmpty(couponIds)) {
           log.info("统计所有优惠券:暂无优惠券数据");
           return ResultBuilder.success(Lists.newArrayList());
       }

       // 执行统计
       List<CouponStatDTO> statDTOList = couponStatMapper.statBatchCoupons(couponIds, statStartTime, statEndTime);
       log.info("统计所有优惠券成功,总优惠券数量:{}, 统计结果数量:{}", couponIds.size(), statDTOList.size());
       return ResultBuilder.success(statDTOList);
   }
}

4.6.6 Controller层(CouponStatController.java)

package com.jam.demo.coupon.controller;

import com.jam.demo.coupon.dto.CouponStatDTO;
import com.jam.demo.coupon.service.CouponStatService;
import com.jam.demo.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;

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

/**
* 优惠券统计控制器
* @author ken
* @date 2025-12-01
*/

@Slf4j
@RestController
@RequestMapping("/coupon/stat")
@RequiredArgsConstructor
@Tag(name = "优惠券统计接口", description = "优惠券发放、使用、核销数据统计接口")
public class CouponStatController {

   private final CouponStatService couponStatService;

   /**
    * 统计单张优惠券数据
    * @param couponId 优惠券ID
    * @param statStartTime 统计开始时间(格式:yyyy-MM-dd HH:mm:ss)
    * @param statEndTime 统计结束时间(格式:yyyy-MM-dd HH:mm:ss)
    * @return 统计结果
    */

   @GetMapping("/single")
   @Operation(summary = "统计单张优惠券", description = "查询指定优惠券的发放、使用、核销数据")
   public Result<CouponStatDTO> statSingleCoupon(
           @Parameter(description = "优惠券ID")
@RequestParam Long couponId,
           @Parameter(description = "统计开始时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statStartTime,
           @Parameter(description = "统计结束时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statEndTime) {
       log.info("统计单张优惠券请求,couponId:{}, statStartTime:{}, statEndTime:{}",
               couponId, statStartTime, statEndTime);
       return couponStatService.statSingleCoupon(couponId, statStartTime, statEndTime);
   }

   /**
    * 批量统计优惠券数据
    * @param couponIds 优惠券ID列表
    * @param statStartTime 统计开始时间(格式:yyyy-MM-dd HH:mm:ss)
    * @param statEndTime 统计结束时间(格式:yyyy-MM-dd HH:mm:ss)
    * @return 统计结果列表
    */

   @PostMapping("/batch")
   @Operation(summary = "批量统计优惠券", description = "批量查询指定优惠券的发放、使用、核销数据")
   public Result<List<CouponStatDTO>> statBatchCoupons(
           @Parameter(description = "优惠券ID列表") @RequestBody List<Long> couponIds,
           @Parameter(description = "统计开始时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statStartTime,
           @Parameter(description = "统计结束时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statEndTime) {
       log.info("批量统计优惠券请求,couponIds:{}, statStartTime:{}, statEndTime:{}",
               couponIds, statStartTime, statEndTime);
       return couponStatService.statBatchCoupons(couponIds, statStartTime, statEndTime);
   }

   /**
    * 统计所有优惠券数据
    * @param statStartTime 统计开始时间(格式:yyyy-MM-dd HH:mm:ss)
    * @param statEndTime 统计结束时间(格式:yyyy-MM-dd HH:mm:ss)
    * @return 统计结果列表
    */

   @GetMapping("/all")
   @Operation(summary = "统计所有优惠券", description = "查询系统中所有优惠券的发放、使用、核销数据")
   public Result<List<CouponStatDTO>> statAllCoupons(
           @Parameter(description = "统计开始时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statStartTime,
           @Parameter(description = "统计结束时间(格式:yyyy-MM-dd HH:mm:ss)")
           @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime statEndTime) {
       log.info("统计所有优惠券请求,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
       return couponStatService.statAllCoupons(statStartTime, statEndTime);
   }
}

五、分布式场景核心问题解决方案

5.1 优惠券超发问题

问题原因

高并发场景下(如秒杀优惠券),多个用户同时领取时,库存校验和扣减存在并发漏洞,导致实际发放量超过配置总量。

解决方案

采用“Redis预扣减+MySQL最终扣减+分布式锁”三重保障:

  1. 库存预热:优惠券生效前,将库存数量同步到Redis,后续领取优先操作Redis;
  2. Redis预扣减:用户领取时,先通过stringRedisTemplate.opsForValue().decrement(stockCacheKey)原子操作扣减Redis库存,确保并发安全;
  3. 分布式锁:针对同一用户-优惠券组合加锁,防止同一用户重复领取;
  4. MySQL最终扣减:Redis预扣减成功后,再扣减MySQL库存,确保数据一致性;
  5. 库存对账:定时任务对比Redis和MySQL库存,发现不一致时进行校准。

核心代码参考4.3.6中receiveCoupon方法的库存处理逻辑。

5.2 并发使用冲突问题

问题原因

同一优惠券被多个订单同时使用,导致重复核销。

解决方案

  1. 状态机控制:将优惠券状态细分为“未使用(0)、已使用(1)、已过期(2)、已作废(3)、锁定中(4)”,订单创建时锁定优惠券,支付成功后核销,取消订单时解锁;
  2. 分布式锁:订单操作优惠券时,基于订单ID加锁,确保同一订单的优惠券操作串行执行;
  3. 数据库唯一索引:user_coupon表中order_id字段添加唯一索引(仅已使用状态),防止重复核销。

核心代码参考4.4.5中lockCouponswriteOffCouponsunlockCoupons方法的锁机制和状态控制。

5.3 数据一致性问题

问题原因

分布式系统中,优惠券发放、使用、核销涉及多服务(优惠券服务、订单服务、用户服务)交互,网络波动或服务异常可能导致数据不一致。

解决方案

  1. 本地事务:单服务内的多表操作(如创建优惠券时同步创建库存和规则),使用@Transactional保证ACID;
  2. 最终一致性:跨服务操作(如订单支付后核销优惠券),采用“消息队列+最终一致性”方案:
  • 订单支付成功后,发送核销消息到RocketMQ;
  • 优惠券服务消费消息,执行核销操作;
  • 消息队列支持重试机制,失败时自动重试;
  • 定时任务兜底,校验订单状态和优惠券状态,发现不一致时补偿处理;
  1. 幂等性设计:所有优惠券操作(领取、核销、锁定)均支持幂等,通过唯一标识(如优惠券编码、订单ID)防止重复处理。

5.4 缓存一致性问题

问题原因

Redis缓存与MySQL数据库数据不一致,导致库存显示错误、领取状态异常等问题。

解决方案

采用“更新数据库后更新缓存”的策略,结合过期时间兜底:

  1. 写操作:先更新MySQL数据库,再更新Redis缓存(或删除缓存,依赖缓存穿透防护);
  2. 读操作:先查Redis,未命中则查MySQL,同步到Redis后返回;
  3. 缓存过期:给Redis缓存设置合理过期时间(如1小时),即使出现不一致,也会在过期后自动恢复;
  4. 主动失效:关键操作(如优惠券停用、库存耗尽)后,主动删除对应Redis缓存,强制刷新。

核心代码参考4.3.6中receiveCoupon方法的缓存同步逻辑。

5.5 高可用设计

核心策略

  1. 服务集群:优惠券服务部署多实例,通过Nacos实现服务注册与发现,负载均衡;
  2. 缓存降级:Redis不可用时,降级为直接操作MySQL(需优化MySQL索引,避免性能问题);
  3. 限流熔断:通过API网关对领取、使用等高频接口限流,防止流量冲击;使用Sentinel实现服务熔断,避免服务雪崩;
  4. 数据库高可用:MySQL采用主从复制,读写分离,提升查询性能和容灾能力;
  5. 监控告警:对优惠券发放量、核销量、接口响应时间、异常率等指标监控,异常时及时告警。

六、核心功能测试用例

6.1 单元测试(基于JUnit5)

package com.jam.demo.coupon.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jam.demo.coupon.entity.Coupon;
import com.jam.demo.coupon.entity.CouponStock;
import com.jam.demo.coupon.entity.UserCoupon;
import com.jam.demo.coupon.mapper.UserCouponMapper;
import com.jam.demo.coupon.service.CouponService;
import com.jam.demo.coupon.service.CouponStockService;
import com.jam.demo.coupon.vo.CouponReceiveVO;
import com.jam.demo.common.enums.BusinessErrorCode;
import com.jam.demo.common.exception.BusinessException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

/**
* UserCouponServiceImpl单元测试
* @author ken
* @date 2025-12-01
*/

@ExtendWith(MockitoExtension.class)
public class UserCouponServiceImplTest
{

   @Mock
   private CouponService couponService;

   @Mock
   private CouponStockService couponStockService;

   @Mock
   private StringRedisTemplate stringRedisTemplate;

   @Mock
   private RedissonClient redissonClient;

   @Mock
   private UserCouponMapper userCouponMapper;

   @InjectMocks
   private UserCouponServiceImpl userCouponService;

   /**
    * 测试优惠券领取成功场景
    */

   @Test
   public void testReceiveCouponSuccess() {
       // 1. 构建测试数据
       Long userId = 1001L;
       Long couponId = 2001L;
       CouponReceiveVO receiveVO = new CouponReceiveVO();
       receiveVO.setUserId(userId);
       receiveVO.setCouponId(couponId);

       Coupon coupon = new Coupon();
       coupon.setCouponId(couponId);
       coupon.setCouponName("测试满减券");
       coupon.setCouponType(1);
       coupon.setFaceValue(new BigDecimal("10"));
       coupon.setMinSpend(new BigDecimal("100"));
       coupon.setValidType(1);
       coupon.setStartTime(LocalDateTime.now().minusDays(1));
       coupon.setEndTime(LocalDateTime.now().plusDays(7));
       coupon.setPerUserLimit(1);
       coupon.setStatus(1); // 生效中

       CouponStock stock = new CouponStock();
       stock.setCouponId(couponId);
       stock.setSurplusNum(100);

       RLock lock = mock(RLock.class);
       when(lock.tryLock(anyLong(), anyLong(), any())).thenReturn(true);

       // 2. Mock依赖行为
       when(couponService.getById(couponId)).thenReturn(coupon);
       when(stringRedisTemplate.opsForValue().get(eq(String.format("coupon:cache:stock:%s", couponId)))).thenReturn("100");
       when(stringRedisTemplate.opsForValue().get(eq(String.format("coupon:cache:receive:count:%s:%s", couponId, userId)))).thenReturn("0");
       when(redissonClient.getLock(anyString())).thenReturn(lock);
       when(couponStockService.getOne(any(LambdaQueryWrapper.class))).thenReturn(stock);
       when(userCouponMapper.selectReceiveCount(userId, couponId)).thenReturn(0);
       doNothing().when(stringRedisTemplate.opsForValue()).decrement(eq(String.format("coupon:cache:stock:%s", couponId)));
       doNothing().when(stringRedisTemplate.opsForValue()).increment(eq(String.format("coupon:cache:receive:count:%s:%s", couponId, userId)));
       when(userCouponService.save(any(UserCoupon.class))).thenAnswer(invocation -> {
           UserCoupon userCoupon = invocation.getArgument(0);
           userCoupon.setUserCouponId(3001L);
           return true;
       });
       when(couponStockService.decrementSurplusStock(couponId)).thenReturn(true);

       // 3. 执行测试方法
       var result = userCouponService.receiveCoupon(receiveVO);

       // 4. 断言结果
       Assertions.assertTrue(result.isSuccess());
       Assertions.assertEquals(3001L, result.getData());

       // 5. 验证依赖调用
       verify(couponService, times(1)).getById(couponId);
       verify(stringRedisTemplate, times(1)).opsForValue().decrement(eq(String.format("coupon:cache:stock:%s", couponId)));
       verify(stringRedisTemplate, times(1)).opsForValue().increment(eq(String.format("coupon:cache:receive:count:%s:%s", couponId, userId)));
       verify(userCouponService, times(1)).save(any(UserCoupon.class));
       verify(couponStockService, times(1)).decrementSurplusStock(couponId);
       verify(lock, times(1)).unlock();
   }

   /**
    * 测试优惠券库存不足场景
    */

   @Test
   public void testReceiveCouponStockInsufficient() {
       // 1. 构建测试数据
       Long userId = 1001L;
       Long couponId = 2001L;
       CouponReceiveVO receiveVO = new CouponReceiveVO();
       receiveVO.setUserId(userId);
       receiveVO.setCouponId(couponId);

       Coupon coupon = new Coupon();
       coupon.setCouponId(couponId);
       coupon.setStatus(1); // 生效中
       coupon.setPerUserLimit(1);
       coupon.setValidType(1);
       coupon.setStartTime(LocalDateTime.now().minusDays(1));
       coupon.setEndTime(LocalDateTime.now().plusDays(7));

       // 2. Mock依赖行为
       when(couponService.getById(couponId)).thenReturn(coupon);
       when(stringRedisTemplate.opsForValue().get(eq(String.format("coupon:cache:stock:%s", couponId)))).thenReturn("0");

       // 3. 执行测试方法并断言异常
       BusinessException exception = Assertions.assertThrows(BusinessException.class, () -> {
           userCouponService.receiveCoupon(receiveVO);
       });

       // 4. 断言异常结果
       Assertions.assertEquals(BusinessErrorCode.COUPON_STOCK_INSUFFICIENT.getCode(), exception.getCode());
   }
}

6.2 集成测试核心场景

测试场景 测试步骤 预期结果
优惠券创建 1. 调用创建优惠券接口,传入满减券配置和商品规则;2. 查询coupon、coupon_rule、coupon_stock表 1. 接口返回成功;2. 三张表均新增对应数据
优惠券领取 1. 确保优惠券生效且库存充足;2. 调用领取接口;3. 查询user_coupon和coupon_stock表 1. 接口返回成功;2. user_coupon新增记录;3. coupon_stock剩余库存减1
优惠券使用 1. 领取优惠券后,调用抵扣计算接口;2. 调用锁定接口;3. 调用核销接口 1. 抵扣金额计算正确;2. 优惠券状态改为锁定中;3. 优惠券状态改为已使用
优惠券过期 1. 创建固定时间过期的优惠券并领取;2. 修改优惠券失效时间为当前时间前;3. 执行过期任务;4. 查询user_coupon表 1. 过期任务执行成功;2. 优惠券状态改为已过期
并发领取 1. 启动100个线程同时调用领取接口(库存100);2. 查询coupon_stock表 1. 所有线程领取成功;2. 剩余库存为0;3. 无超发

七、总结与扩展

本文基于JDK17、MyBatis-Plus、MySQL8.0等最新技术栈,从需求分析、架构设计、数据模型、核心功能实现到分布式问题解决方案,完整拆解了优惠券系统的设计与实现。核心要点包括:

  1. 业务层面:明确优惠券的核心价值和场景,定义严格的业务约束(唯一性、时效性、库存控制等);
  2. 架构层面:采用“分层架构+微服务拆分”,通过Redis、消息队列、分布式锁等组件提升系统性能和可用性;
  3. 实现层面:核心功能覆盖配置、领取、使用、过期、统计,代码严格遵循阿里巴巴开发手册,确保可编译运行;
  4. 分布式层面:针对超发、并发冲突、数据一致性等核心问题,提供了经过验证的解决方案。
目录
相关文章
|
4天前
|
搜索推荐 编译器 Linux
一个可用于企业开发及通用跨平台的Makefile文件
一款适用于企业级开发的通用跨平台Makefile,支持C/C++混合编译、多目标输出(可执行文件、静态/动态库)、Release/Debug版本管理。配置简洁,仅需修改带`MF_CONFIGURE_`前缀的变量,支持脚本化配置与子Makefile管理,具备完善日志、错误提示和跨平台兼容性,附详细文档与示例,便于学习与集成。
300 116
|
19天前
|
域名解析 人工智能
【实操攻略】手把手教学,免费领取.CN域名
即日起至2025年12月31日,购买万小智AI建站或云·企业官网,每单可免费领1个.CN域名首年!跟我了解领取攻略吧~
|
7天前
|
数据采集 人工智能 自然语言处理
Meta SAM3开源:让图像分割,听懂你的话
Meta发布并开源SAM 3,首个支持文本或视觉提示的统一图像视频分割模型,可精准分割“红色条纹伞”等开放词汇概念,覆盖400万独特概念,性能达人类水平75%–80%,推动视觉分割新突破。
479 44
Meta SAM3开源:让图像分割,听懂你的话
|
14天前
|
安全 Java Android开发
深度解析 Android 崩溃捕获原理及从崩溃到归因的闭环实践
崩溃堆栈全是 a.b.c?Native 错误查不到行号?本文详解 Android 崩溃采集全链路原理,教你如何把“天书”变“说明书”。RUM SDK 已支持一键接入。
691 222
|
2天前
|
Windows
dll错误修复 ,可指定下载dll,regsvr32等
dll错误修复 ,可指定下载dll,regsvr32等
135 95
|
12天前
|
人工智能 移动开发 自然语言处理
2025最新HTML静态网页制作工具推荐:10款免费在线生成器小白也能5分钟上手
晓猛团队精选2025年10款真正免费、无需编程的在线HTML建站工具,涵盖AI生成、拖拽编辑、设计稿转代码等多种类型,均支持浏览器直接使用、快速出图与文件导出,特别适合零基础用户快速搭建个人网站、落地页或企业官网。
1696 158
|
存储 人工智能 监控
从代码生成到自主决策:打造一个Coding驱动的“自我编程”Agent
本文介绍了一种基于LLM的“自我编程”Agent系统,通过代码驱动实现复杂逻辑。该Agent以Python为执行引擎,结合Py4j实现Java与Python交互,支持多工具调用、记忆分层与上下文工程,具备感知、认知、表达、自我评估等能力模块,目标是打造可进化的“1.5线”智能助手。
943 62