首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >优惠券功能设计与实现

优惠券功能设计与实现

作者头像
果酱带你啃java
发布2026-04-14 13:59:29
发布2026-04-14 13:59:29
310
举报

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

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

1.1 核心业务价值

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

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

1.2 核心功能需求

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

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

1.3 关键业务约束

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

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

二、优惠券系统架构设计

2.1 整体架构设计

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 核心数据模型关系

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

3.2.1 优惠券主表(coupon)
代码语言:javascript
复制
CREATE TABLE`coupon` (
`coupon_id`bigintNOTNULL AUTO_INCREMENT COMMENT'优惠券ID',
`coupon_name`varchar(100) NOTNULLCOMMENT'优惠券名称',
`coupon_type`tinyintNOTNULLCOMMENT'优惠券类型:1-满减券 2-折扣券 3-无门槛券 4-现金券',
`face_value`decimal(10,2) NOTNULLCOMMENT'面额:满减券/无门槛券/现金券为金额,折扣券为折扣比例(如90代表9折)',
`min_spend`decimal(10,2) NOTNULLDEFAULT0.00COMMENT'最低消费金额:0表示无门槛',
`valid_type`tinyintNOTNULLCOMMENT'有效期类型:1-固定时间 2-领取后N天有效',
`start_time` datetime DEFAULTNULLCOMMENT'生效时间(固定时间类型必填)',
`end_time` datetime DEFAULTNULLCOMMENT'失效时间(固定时间类型必填)',
`valid_days`intDEFAULTNULLCOMMENT'领取后有效天数(领取后N天有效类型必填)',
`issue_num`intNOTNULLCOMMENT'发放总量',
`per_user_limit`intNOTNULLDEFAULT1COMMENT'每人限领数量',
`status`tinyintNOTNULLDEFAULT0COMMENT'状态:0-未生效 1-生效中 2-已过期 3-已停用',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
`create_by`varchar(50) NOTNULLCOMMENT'创建人',
  PRIMARY KEY (`coupon_id`),
KEY`idx_status` (`status`),
KEY`idx_time` (`start_time`,`end_time`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='优惠券主表';
3.2.2 优惠券规则表(coupon_rule)
代码语言:javascript
复制
CREATE TABLE`coupon_rule` (
`rule_id`bigintNOTNULL AUTO_INCREMENT COMMENT'规则ID',
`coupon_id`bigintNOTNULLCOMMENT'关联优惠券ID',
`rule_type`tinyintNOTNULLCOMMENT'规则类型:1-商品品类限制 2-商品SKU限制 3-用户等级限制 4-订单类型限制',
`rule_value`varchar(200) NOTNULLCOMMENT'规则值:多个值用逗号分隔',
`rule_operator`tinyintNOTNULLDEFAULT1COMMENT'匹配方式:1-包含 2-排除',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
  PRIMARY KEY (`rule_id`),
KEY`idx_coupon_id` (`coupon_id`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='优惠券规则表';
3.2.3 优惠券库存表(coupon_stock)
代码语言:javascript
复制
CREATE TABLE`coupon_stock` (
`stock_id`bigintNOTNULL AUTO_INCREMENT COMMENT'库存ID',
`coupon_id`bigintNOTNULLCOMMENT'关联优惠券ID',
`total_num`intNOTNULLCOMMENT'总库存',
`used_num`intNOTNULLDEFAULT0COMMENT'已使用数量',
`surplus_num`intNOTNULLCOMMENT'剩余库存',
`lock_num`intNOTNULLDEFAULT0COMMENT'锁定数量(发放中未确认)',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
  PRIMARY KEY (`stock_id`),
UNIQUEKEY`uk_coupon_id` (`coupon_id`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='优惠券库存表';
3.2.4 用户优惠券表(user_coupon)
代码语言:javascript
复制
CREATE TABLE`user_coupon` (
`user_coupon_id`bigintNOTNULL AUTO_INCREMENT COMMENT'用户优惠券ID',
`user_id`bigintNOTNULLCOMMENT'用户ID',
`coupon_id`bigintNOTNULLCOMMENT'关联优惠券ID',
`coupon_code`varchar(50) NOTNULLCOMMENT'优惠券编码(唯一)',
`get_time` datetime NOTNULLCOMMENT'领取时间',
`valid_start_time` datetime NOTNULLCOMMENT'实际生效时间',
`valid_end_time` datetime NOTNULLCOMMENT'实际失效时间',
`use_status`tinyintNOTNULLDEFAULT0COMMENT'使用状态:0-未使用 1-已使用 2-已过期 3-已作废',
`use_time` datetime DEFAULTNULLCOMMENT'使用时间',
`order_id`bigintDEFAULTNULLCOMMENT'关联订单ID',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
  PRIMARY KEY (`user_coupon_id`),
UNIQUEKEY`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=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='用户优惠券表';
3.2.5 优惠券发放记录表(coupon_issue_record)
代码语言:javascript
复制
CREATE TABLE`coupon_issue_record` (
`record_id`bigintNOTNULL AUTO_INCREMENT COMMENT'记录ID',
`coupon_id`bigintNOTNULLCOMMENT'关联优惠券ID',
`user_id`bigintNOTNULLCOMMENT'用户ID',
`issue_type`tinyintNOTNULLCOMMENT'发放类型:1-用户主动领取 2-系统主动发放 3-活动发放',
`issue_status`tinyintNOTNULLDEFAULT1COMMENT'发放状态:1-成功 2-失败',
`fail_reason`varchar(200) DEFAULTNULLCOMMENT'失败原因',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
  PRIMARY KEY (`record_id`),
KEY`idx_coupon_id` (`coupon_id`),
KEY`idx_user_id` (`user_id`),
KEY`idx_issue_type` (`issue_type`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='优惠券发放记录表';

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

3.3.1 优惠券主表实体(Coupon.java)
代码语言:javascript
复制
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 = "优惠券主表")
publicclass 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)
代码语言:javascript
复制
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 = "用户优惠券表")
publicclass 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依赖配置
代码语言:javascript
复制
<?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)
代码语言:javascript
复制
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:001**?

4.2 优惠券配置功能实现

4.2.1 Mapper层(CouponMapper.java)
代码语言:javascript
复制
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)
代码语言:javascript
复制
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
 */
publicinterface 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)
代码语言:javascript
复制
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
publicclass CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> implements CouponService {

    privatefinal CouponStockService couponStockService;
    privatefinal 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);
            thrownew 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);
            thrownew 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);
                thrownew 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);
            thrownew BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
        }

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

        // 4. 更新优惠券主表
        Coupon updateCoupon = buildCouponFromEditVO(couponEditVO, existCoupon);
        boolean updateSuccess = this.updateById(updateCoupon);
        if (!updateSuccess) {
            log.error("编辑优惠券失败:更新主表失败,couponEditVO:{}", couponEditVO);
            thrownew 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);
                thrownew 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不能为空");
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }

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

        // 3. 校验状态:已停用/已过期优惠券不可重复停用
        if (existCoupon.getStatus() == 3 || existCoupon.getStatus() == 2) {
            log.error("停用优惠券失败:优惠券状态不允许停用,couponId:{}, status:{}", couponId, existCoupon.getStatus());
            thrownew 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);
            thrownew 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不能为空");
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }

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

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

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

    /**
     * 校验优惠券编辑参数
     * @param couponEditVO 编辑参数
     */
    private void validateCouponEditParam(CouponEditVO couponEditVO) {
        if (ObjectUtils.isEmpty(couponEditVO)) {
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }
        if (ObjectUtils.isEmpty(couponEditVO.getCouponId())) {
            thrownew 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)

代码语言:javascript
复制
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 = "优惠券创建、编辑、停用、详情查询接口")
publicclass CouponController {

    privatefinal 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. 并发安全:使用分布式锁防止并发冲突,确保数据一致性。

领取流程:

4.3.2 枚举与常量定义
代码语言:javascript
复制
package com.jam.demo.coupon.constant;

/**
 * 优惠券领取相关常量
 * @author ken
 * @date 2025-12-01
 */
publicclass CouponReceiveConstant {
    /** 优惠券领取库存缓存key:coupon:cache:stock:{couponId} */
    publicstaticfinal String COUPON_STOCK_CACHE_KEY = "coupon:cache:stock:%s";
    /** 用户领取计数缓存key:coupon:cache:receive:count:{couponId}:{userId} */
    publicstaticfinal String USER_RECEIVE_COUNT_CACHE_KEY = "coupon:cache:receive:count:%s:%s";
    /** 优惠券领取分布式锁key:coupon:lock:receive:{couponId}:{userId} */
    publicstaticfinal String RECEIVE_LOCK_KEY = "coupon:lock:receive:%s:%s";
    /** 优惠券编码生成前缀 */
    publicstaticfinal String COUPON_CODE_PREFIX = "COUPON_";
    /** 优惠券编码长度(含前缀) */
    publicstaticfinalint COUPON_CODE_LENGTH = 20;
}
4.3.3 VO定义(CouponReceiveVO.java)
代码语言:javascript
复制
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 = "优惠券领取请求参数")
publicclass 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)
代码语言:javascript
复制
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
publicinterface 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)
代码语言:javascript
复制
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
 */
publicinterface 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)
代码语言:javascript
复制
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
publicclass UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements UserCouponService {

    privatefinal CouponService couponService;
    privatefinal CouponStockService couponStockService;
    privatefinal StringRedisTemplate stringRedisTemplate;
    privatefinal 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);
            thrownew 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);
            thrownew BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
        }
        // 校验状态:仅生效中(1)的优惠券可领取
        if (coupon.getStatus() != 1) {
            log.error("领取优惠券失败:优惠券状态不可领取,couponId:{}, status:{}", couponId, coupon.getStatus());
            thrownew 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());
                thrownew 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());
                thrownew 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);
                thrownew 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());
            thrownew 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);
                thrownew BusinessException(BusinessErrorCode.COUPON_RECEIVE_LOCK_FAIL);
            }

            // 6. 再次校验库存和领取次数(防止锁等待期间数据变化)
            surplusStock = Integer.parseInt(stringRedisTemplate.opsForValue().get(stockCacheKey));
            if (surplusStock <= 0) {
                thrownew BusinessException(BusinessErrorCode.COUPON_STOCK_INSUFFICIENT);
            }
            receiveCount = Integer.parseInt(stringRedisTemplate.opsForValue().get(receiveCountCacheKey));
            if (receiveCount >= coupon.getPerUserLimit()) {
                thrownew 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);
            thrownew 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不能为空");
            thrownew 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());
            thrownew BusinessException(BusinessErrorCode.USER_COUPON_CREATE_FAIL);
        }
        return userCoupon.getUserCouponId();
    }
}
4.3.7 Controller层(UserCouponController.java)
代码语言:javascript
复制
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 = "优惠券领取、查询接口")
publicclass UserCouponController {

    privatefinal 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. 互斥处理:同一订单不可使用互斥优惠券。

使用流程:

4.4.2 核心VO定义
代码语言:javascript
复制
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 = "优惠券使用请求参数")
publicclass 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;
}
代码语言:javascript
复制
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")
publicclass 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)
代码语言:javascript
复制
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
publicclass CouponUseRuleEngine {

    privatefinal CouponRuleService couponRuleService;
    privatefinal RulesEngine rulesEngine;
    privatefinal 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
    publicstaticclass CouponUseRuleFacts {
        /** 用户优惠券 */
        private UserCoupon userCoupon;
        /** 优惠券主信息 */
        private Coupon coupon;
        /** 订单金额 */
        private BigDecimal orderAmount;
        /** 订单商品列表 */
        private List<OrderItemVO> orderItems;
    }

    /**
     * 规则1:优惠券状态有效性规则
     */
    @Rule(name = "CouponValidStatusRule", description = "优惠券状态有效性校验:必须为未使用状态")
    publicstaticclass 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");
            thrownew BusinessException(BusinessErrorCode.COUPON_USE_STATUS_INVALID,
                    "优惠券状态无效,userCouponId:" + ruleFacts.getUserCoupon().getUserCouponId());
        }
    }

    /**
     * 规则2:优惠券有效期规则
     */
    @Rule(name = "CouponValidTimeRule", description = "优惠券有效期校验:必须在有效期内")
    publicstaticclass 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();
            thrownew BusinessException(BusinessErrorCode.COUPON_USE_OUT_OF_TIME,
                    "优惠券已过期或未生效,userCouponId:" + userCoupon.getUserCouponId() +
                    ", validStartTime:" + userCoupon.getValidStartTime() +
                    ", validEndTime:" + userCoupon.getValidEndTime());
        }
    }

    /**
     * 规则3:最低消费规则
     */
    @Rule(name = "MinSpendRule", description = "最低消费金额校验:订单金额需满足优惠券最低消费要求")
    publicstaticclass 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();
            thrownew BusinessException(BusinessErrorCode.COUPON_USE_MIN_SPEND_NOT_MEET,
                    "订单金额未满足最低消费要求,minSpend:" + coupon.getMinSpend() +
                    ", orderAmount:" + ruleFacts.getOrderAmount());
        }
    }

    /**
     * 规则4:商品限制规则(品类/sku限制)
     */
    @Rule(name = "GoodsLimitRule", description = "商品限制校验:订单商品需满足优惠券的商品规则")
    publicstaticclass 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)) {
                returntrue;
            }

            // 查询该优惠券的商品相关规则(品类限制/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)) {
                // 无商品限制规则,直接通过
                returnfalse;
            }

            // 提取订单中的品类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);
                } elseif (rule.getRuleType() == 2) { // SKU限制
                    isMatch = orderSkuIds.stream().anyMatch(ruleValues::contains);
                }

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

            returnfalse;
        }

        @Action
        public void throwGoodsNotMeetRuleException(Facts facts) {
            CouponUseRuleFacts ruleFacts = facts.get("ruleFacts");
            thrownew BusinessException(BusinessErrorCode.COUPON_USE_GOODS_NOT_MEET,
                    "订单商品不满足优惠券使用规则,couponId:" + ruleFacts.getCoupon().getCouponId());
        }
    }
}
4.4.4 Service层接口(CouponUseService.java)
代码语言:javascript
复制
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
 */
publicinterface 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)
代码语言:javascript
复制
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
publicclass CouponUseServiceImpl implements CouponUseService {

    privatefinal CouponService couponService;
    privatefinal UserCouponService userCouponService;
    privatefinal CouponUseRuleEngine couponUseRuleEngine;
    privatefinal RedissonClient redissonClient;

    /** 优惠券锁定/核销分布式锁key:coupon:lock:use:{orderId} */
    privatestaticfinal 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);
            thrownew BusinessException(BusinessErrorCode.COUPON_NOT_EXIST);
        }

        // 3. 校验优惠券归属(必须是当前用户的优惠券)
        boolean hasInvalidOwner = userCouponList.stream().anyMatch(coupon -> !coupon.getUserId().equals(userId));
        if (hasInvalidOwner) {
            log.error("计算优惠券抵扣失败:存在非当前用户的优惠券,userId:{}, userCouponIds:{}", userId, userCouponIds);
            thrownew 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);
                thrownew 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);
            thrownew 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);
            thrownew 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);
                thrownew 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());
                    thrownew 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);
                thrownew 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);
            thrownew 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);
            thrownew 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);
                thrownew 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());
                    thrownew 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);
                thrownew 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);
            thrownew 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);
            thrownew 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);
                thrownew 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());
                    thrownew 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);
                thrownew 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);
            thrownew BusinessException(BusinessErrorCode.SYSTEM_ERROR);
        } finally {
            if (lockAcquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /**
     * 校验使用参数
     * @param useVO 使用参数
     */
    private void validateUseParam(CouponUseVO useVO) {
        if (ObjectUtils.isEmpty(useVO)) {
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }
        if (ObjectUtils.isEmpty(useVO.getUserId())) {
            thrownew BusinessException(BusinessErrorCode.USER_ID_EMPTY);
        }
        if (ObjectUtils.isEmpty(useVO.getOrderId())) {
            thrownew BusinessException(BusinessErrorCode.ORDER_ID_EMPTY);
        }
        if (ObjectUtils.isEmpty(useVO.getOrderAmount()) || useVO.getOrderAmount().compareTo(BigDecimal.ZERO) <= 0) {
            thrownew BusinessException(BusinessErrorCode.ORDER_AMOUNT_INVALID);
        }
        if (CollectionUtils.isEmpty(useVO.getUserCouponIds())) {
            thrownew BusinessException(BusinessErrorCode.COUPON_ID_LIST_EMPTY);
        }
        if (CollectionUtils.isEmpty(useVO.getOrderItems())) {
            thrownew 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) {
            thrownew BusinessException(BusinessErrorCode.COUPON_USE_MIN_SPEND_NOT_MEET);
        }

        switch (couponType) {
            case1: // 满减券:面额为减金额
                return faceValue;
            case2: // 折扣券:面额为折扣比例(如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;
            case3: // 无门槛券:直接抵扣面额(不超过订单金额)
                return faceValue.compareTo(orderAmount) > 0 ? orderAmount : faceValue;
            case4: // 现金券:同无门槛券
                return faceValue.compareTo(orderAmount) > 0 ? orderAmount : faceValue;
            default:
                thrownew BusinessException(BusinessErrorCode.COUPON_TYPE_ERROR);
        }
    }
}
4.4.6 Controller层(CouponUseController.java)
代码语言:javascript
复制
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 = "优惠券抵扣计算、锁定、核销、解锁接口")
publicclass CouponUseController {

    privatefinal 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. 通知性:可选功能,向用户推送优惠券过期通知。

处理流程(flowchart TD):

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

代码语言:javascript
复制
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
publicclass CouponExpireTask {

    privatefinal UserCouponService userCouponService;
    privatefinal MessagePushService messagePushService;

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

    /** 线程池(异步处理通知推送) */
    privatestaticfinal 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)
代码语言:javascript
复制
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
publicclass MessagePushService {

    privatefinal 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
    privatestaticclass CouponExpireNotifyDTO {
        private Long userId;
        private Long couponId;
        private String couponCode;
        private Long notifyTime;
        private Integer notifyType;
    }
}

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

4.6.1 统计DTO定义
代码语言:javascript
复制
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")
publicclass 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)
代码语言:javascript
复制
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
publicinterface 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)
代码语言:javascript
复制
<?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)
代码语言:javascript
复制
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
 */
publicinterface 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)
代码语言:javascript
复制
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
publicclass CouponStatServiceImpl implements CouponStatService {

    privatefinal CouponStatMapper couponStatMapper;
    privatefinal 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不能为空");
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }
        if (ObjectUtils.isEmpty(statStartTime) || ObjectUtils.isEmpty(statEndTime) || statStartTime.isAfter(statEndTime)) {
            log.error("统计单张优惠券失败:统计时间范围无效,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
            thrownew BusinessException(BusinessErrorCode.STAT_TIME_RANGE_INVALID);
        }

        // 校验优惠券是否存在
        Coupon coupon = couponMapper.selectById(couponId);
        if (ObjectUtils.isEmpty(coupon)) {
            log.error("统计单张优惠券失败:优惠券不存在,couponId:{}", couponId);
            thrownew 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列表不能为空");
            thrownew BusinessException(BusinessErrorCode.PARAM_ERROR);
        }
        if (ObjectUtils.isEmpty(statStartTime) || ObjectUtils.isEmpty(statEndTime) || statStartTime.isAfter(statEndTime)) {
            log.error("批量统计优惠券失败:统计时间范围无效,statStartTime:{}, statEndTime:{}", statStartTime, statEndTime);
            thrownew 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);
            thrownew 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)
代码语言:javascript
复制
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 = "优惠券发放、使用、核销数据统计接口")
publicclass CouponStatController {

    privatefinal 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;
    • 优惠券服务消费消息,执行核销操作;
    • 消息队列支持重试机制,失败时自动重试;
    • 定时任务兜底,校验订单状态和优惠券状态,发现不一致时补偿处理;
  3. 幂等性设计:所有优惠券操作(领取、核销、锁定)均支持幂等,通过唯一标识(如优惠券编码、订单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)

代码语言:javascript
复制
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;

importstatic org.mockito.ArgumentMatchers.any;
importstatic org.mockito.ArgumentMatchers.eq;
importstatic 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);
            returntrue;
        });
        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. 无超发

七、总结与扩展

7.1 核心总结

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

  1. 业务层面:明确优惠券的核心价值和场景,定义严格的业务约束(唯一性、时效性、库存控制等);
  2. 架构层面:采用“分层架构+微服务拆分”,通过Redis、消息队列、分布式锁等组件提升系统性能和可用性;
  3. 实现层面:核心功能覆盖配置、领取、使用、过期、统计,代码严格遵循阿里巴巴开发手册,确保可编译运行;
  4. 分布式层面:针对超发、并发冲突、数据一致性等核心问题,提供了经过验证的解决方案。
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-12-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 果酱带你啃java 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 在电商、O2O、本地生活等各类平台中,优惠券作为拉新、促活、转化、留存的核心营销工具,其功能设计的合理性与实现的稳定性直接影响业务增长效果。本文将从底层逻辑出发,全面拆解优惠券功能的核心设计要点,结合JDK17、MyBatis-Plus、MySQL8.0等最新稳定技术栈,提供全量可编译运行的实现代码,帮助开发者快速掌握从需求分析到落地部署的完整流程。
    • 一、优惠券核心需求与业务场景分析
      • 1.1 核心业务价值
      • 1.2 核心功能需求
      • 1.3 关键业务约束
    • 二、优惠券系统架构设计
      • 2.1 整体架构设计
      • 2.2 分层职责说明
      • 2.3 核心技术栈选型
    • 三、数据模型设计
      • 3.1 核心数据模型关系
      • 3.2 数据库表设计(MySQL8.0)
      • 3.3 核心实体类设计(Java JDK17)
    • 四、核心功能实现
      • 4.1 项目基础配置
      • 4.2 优惠券配置功能实现
  • 4.2.4 Controller 层(CouponController.java)
    • 4.3 优惠券领取功能实现
    • 4.4 优惠券使用与核销功能实现
    • 4.5 优惠券过期处理功能实现
  • 4.5.2 定时任务实现(CouponExpireTask.java)
    • 4.6 优惠券数据统计功能实现
    • 五、分布式场景核心问题解决方案
      • 5.1 优惠券超发问题
      • 5.2 并发使用冲突问题
      • 5.3 数据一致性问题
      • 5.4 缓存一致性问题
      • 5.5 高可用设计
    • 六、核心功能测试用例
      • 6.1 单元测试(基于JUnit5)
      • 6.2 集成测试核心场景
    • 七、总结与扩展
      • 7.1 核心总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档