PHP项目如何实现优惠券系统?

wen PHP项目 2

本文目录导读:

PHP项目如何实现优惠券系统?

  1. 核心数据库设计
  2. 核心业务流程
  3. 高并发与防超发(关键)
  4. PHP代码示例:核销优惠券
  5. 进阶优化点

这是一个非常典型且很有价值的电商或会员系统需求,一个健壮的优惠券系统不仅仅是“生成一个码”,它需要处理生成、发放、核销、过期、阈值(满减/折扣)以及防超发等核心逻辑。

下面从数据库设计核心功能逻辑防并发处理以及代码示例四个维度,为你提供一个PHP可落地的完整方案。

核心数据库设计

你需要至少两张核心表:优惠券定义表(券模板)和用户持有表(券实例)。

-- 1. 优惠券定义表:定义一张券长什么样
CREATE TABLE `coupon_define` (
    `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
    `name` varchar(50) NOT NULL DEFAULT '' COMMENT '优惠券名称',
    `type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '类型:1=满减券 2=折扣券 3=无门槛/代金券',
    `value` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '减免金额(元) 或 折扣率(如0.85表示85折)',
    `condition_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '满减门槛金额,0表示无门槛',
    `total_count` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '发行总量(0表示不限)',
    `received_count` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '已领取数(控制超发)',
    `start_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '可领取开始时间',
    `end_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '可领取截止时间',
    `valid_days` smallint(5) UNSIGNED NOT NULL DEFAULT 0 COMMENT '领取后有效期(天数),0表示使用固定截止日',
    `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0=未发布 1=已发布 2=已失效',
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 用户持有的优惠券实例:用户领到的一张具体券
CREATE TABLE `coupon_user` (
    `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
    `user_id` int(11) UNSIGNED NOT NULL COMMENT '用户ID',
    `coupon_define_id` int(11) UNSIGNED NOT NULL COMMENT '关联的券定义ID',
    `code` varchar(32) NOT NULL DEFAULT '' COMMENT '优惠券唯一码(可用于展示和验证)',
    `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0=未使用 1=已使用 2=已过期 3=已冻结',
    `used_at` datetime DEFAULT NULL COMMENT '使用时间',
    `order_id` int(11) UNSIGNED DEFAULT NULL COMMENT '用在哪笔订单ID',
    `start_time` datetime NOT NULL COMMENT '有效期起始',
    `end_time` datetime NOT NULL COMMENT '有效期截止',
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_user_status` (`user_id`, `status`),
    UNIQUE KEY `uniq_code` (`code`) -- 防重复核销
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计要点

  • received_count 一定要加锁控制。
  • coupon_user 中存储 start_time/end_time,是实际生效的时间,可以是固定日期,也可以按领取时间+有效天数动态生成。

核心业务流程

用户领取优惠券

  • 校验:领取时间 start_time <= now() < end_time;发行总量 received_count < total_count;每人限领(建议用单独的字段或查 coupon_user 记录判断)。
  • 生成:生成唯一码(uuid / uniqid + rand / 雪花ID / 防重随机码),目的是防止被猜出或重复。
  • 扣减:原子操作 UPDATE coupon_define SET received_count = received_count + 1 WHERE id = ? AND received_count < total_count
  • 写入:插入 coupon_user 记录,并计算失效时间(valid_days > 0start_time = now(), end_time = DATE_ADD(now(), INTERVAL valid_days DAY);否则使用定义表中的固定时间段)。

订单结算/核销优惠券

  • 选择优惠券:前端展示用户可用的券(未使用 + 在有效期 + 订单金额 >= condition_amount)。
  • 适用校验:后端接收 user_id, coupon_user_id, order_amount
    • 校验券状态为“未使用”。
    • 校验券归属用户。
    • 校验 order_amount >= condition_amount
    • 校验有效期。
  • 计算优惠金额:根据 type 计算(满减直接减value,折扣则 order_amount * (1 - value),可设封顶)。
  • 锁定/预占:建议将 coupon_user.status = 3(冻结),防止重复提交。
  • 下单成功后:更新 status = 1(已使用), used_at, order_id

过期处理

  • 方案A(推荐)查询时实时判断,在查询用户优惠券列表时,用 end_time < NOW() 来判断是否过期,不依赖定时任务,更可靠。
  • 方案B:定时任务(Cron)每小时更新 status=2,适合数据量超大、需要查询性能极致的场景。

高并发与防超发(关键)

优惠券抢购容易遇到并发问题,PHP + MySQL 下最常见的处理方式:

使用 UPDATE ... WHERE ... 加行锁(最常用且可靠):

<?php
try {
    $db->beginTransaction();
    // 1. 锁定并扣减库存(行锁)
    $sql = "UPDATE coupon_define SET received_count = received_count + 1 
            WHERE id = ? AND total_count > received_count AND status = 1 
            AND start_time <= NOW() AND end_time > NOW()";
    $stmt = $db->prepare($sql);
    $stmt->execute([$couponDefineId]);
    $affected = $stmt->rowCount();
    if ($affected <= 0) {
        $db->rollBack();
        throw new \Exception('优惠券已领完或不在领取期间');
    }
    // 2. 生成唯一券码(建议用足够随机的字符串避免恶意碰撞)
    $code = bin2hex(random_bytes(8)); // 生成16位十六进制字符串
    // 3. 计算用户这张券的有效期
    $now = date('Y-m-d H:i:s');
    $expireDate = date('Y-m-d H:i:s', strtotime('+30 days')); // 假设有效期为30天
    // 4. 写入用户领券记录
    $sql = "INSERT INTO coupon_user (user_id, coupon_define_id, code, status, start_time, end_time, created_at) 
            VALUES (?, ?, ?, 0, ?, ?, ?)";
    $stmt = $db->prepare($sql);
    $stmt->execute([$userId, $couponDefineId, $code, $now, $expireDate, $now]);
    $db->commit();
    return ['coupon_user_id' => $db->lastInsertId(), 'code' => $code];
} catch (\Exception $e) {
    $db->rollBack();
    throw $e;
}

注意:不要使用 SELECT ... FOR UPDATE + 应用层判断,除非你确信事务隔离级别并自行处理死锁。UPDATE 直接在WHERE中加条件是最简单高效的。


PHP代码示例:核销优惠券

<?php
class CouponService
{
    /**
     * 核销优惠券
     * @param int $userId
     * @param int $couponUserId
     * @param float $orderAmount
     * @param int $orderId
     * @return float 优惠金额
     * @throws \Exception
     */
    public function useCoupon(int $userId, int $couponUserId, float $orderAmount, int $orderId): float
    {
        $db = \DB::connection();
        try {
            $db->beginTransaction();
            // 1. 锁定用户持有的券记录(行锁)
            $sql = "SELECT * FROM coupon_user WHERE id = ? AND status = 0 FOR UPDATE";
            $couponUser = $db->selectOne($sql, [$couponUserId]);
            if (!$couponUser) {
                throw new \Exception('优惠券不存在或已使用');
            }
            if ($couponUser->user_id !== $userId) {
                throw new \Exception('优惠券不属于当前用户');
            }
            if ($couponUser->end_time < date('Y-m-d H:i:s')) {
                throw new \Exception('优惠券已过期');
            }
            // 2. 查询券定义
            $couponDefine = $db->selectOne(
                "SELECT * FROM coupon_define WHERE id = ?", 
                [$couponUser->coupon_define_id]
            );
            if (!$couponDefine || $couponDefine->status !== 1) {
                throw new \Exception('优惠券活动无效');
            }
            // 3. 计算优惠金额
            $discountAmount = 0;
            if ($orderAmount < $couponDefine->condition_amount) {
                throw new \Exception('订单金额不满足使用条件');
            }
            switch ($couponDefine->type) {
                case 1: // 满减
                    $discountAmount = min($couponDefine->value, $orderAmount);
                    break;
                case 2: // 折扣
                    $discountAmount = $orderAmount * (1 - $couponDefine->value);
                    // 可以设定最高减免金额
                    break;
                case 3: // 无门槛
                    $discountAmount = min($couponDefine->value, $orderAmount);
                    break;
                default:
                    throw new \Exception('优惠券类型错误');
            }
            // 4. 标记为已使用
            $db->update(
                "UPDATE coupon_user SET status = 1, used_at = NOW(), order_id = ? WHERE id = ? AND status = 0",
                [$orderId, $couponUserId]
            );
            $db->commit();
            return round($discountAmount, 2);
        } catch (\Exception $e) {
            $db->rollBack();
            throw $e;
        }
    }
}

进阶优化点

  1. 唯一码防撞:对于批量生成或安全要求高,可以使用 hashidsuniqid + crc32 组合。
  2. 用户领券频控:在领取接口加 Redis 计数器,防止脚本刷券:$redis->incr('coupon:limit:user_' . $userId, 1, 3600)
  3. 分表分库:用户券量级上千万时,考虑按 user_id 哈希分表。
  4. 异步核销:高并发下单场景,可以将“用券”操作放入消息队列(RabbitMQ / Redis list),后端异步更新状态。
  5. 缓存:用户券列表可以缓存到 Redis,但状态变更(过期、使用)需要及时同步清理。

  • 表结构:券定义 + 用户券,区分模板与实例。
  • 核心逻辑:领取时用 UPDATE ... WHERE received_count < total_count 保证不超发;使用事务 + FOR UPDATE 保证状态一致。
  • 计算优惠:按类型处理,注意折扣不能倒贴。
  • 并发处理:数据库行锁是 PHP 项目的首选,简单且稳定。

按照这个方案,可以支撑绝大多数中小型项目的优惠券系统,代码示例可以直接用于 Laravel、ThinkPHP 或原生 PHP。

抱歉,评论功能暂时关闭!