PHP 怎么优惠券发放

wen PHP项目 2

本文目录导读:

PHP 怎么优惠券发放

  1. 数据库表结构设计(MySQL)
  2. 核心发放逻辑(PHP代码)
  3. 异步队列发放(适合大规模营销活动)
  4. 关键优化建议
  5. 完整调用示例(前端接口)

在 PHP 中实现优惠券发放,通常涉及生成存储发放核销几个环节,下面我会从业务逻辑代码实现两个层面,为你提供一个完整的实施方案,重点在于解决并发问题和防止超发。


数据库表结构设计(MySQL)

需要设计两张核心表:coupon_templates(优惠券模板)和 user_coupons(用户持有的优惠券)。

-- 优惠券模板表
CREATE TABLE `coupon_templates` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL COMMENT '优惠券名称',
  `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '类型:1-满减券,2-折扣券',
  `threshold` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '使用门槛(满多少可用)',
  `discount` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '优惠金额/折扣率',
  `total_stock` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '总库存',
  `issued_stock` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '已发放数量',
  `start_time` datetime NOT NULL COMMENT '生效开始时间',
  `end_time` datetime NOT NULL COMMENT '生效结束时间',
  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用,0-禁用',
  `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号(可选,用于并发控制)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户优惠券表
CREATE TABLE `user_coupons` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(11) unsigned NOT NULL COMMENT '用户ID',
  `template_id` int(11) unsigned NOT NULL COMMENT '模板ID',
  `code` varchar(32) NOT NULL COMMENT '优惠券码(唯一)',
  `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-未使用,1-已使用,2-已过期',
  `received_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间',
  `used_at` datetime DEFAULT NULL COMMENT '使用时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code` (`code`), -- 唯一索引防止重复
  KEY `idx_user_id_status` (`user_id`, `status`), 
  KEY `idx_template_id` (`template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心发放逻辑(PHP代码)

关键点:防止并发超发,需要使用数据库事务 + 行锁SELECT ... FOR UPDATE)或原子更新

方案A:使用数据库原子更新(推荐,性能较好)

这种方法不依赖锁,利用 SQL 的原子特性,保证库存不会超发。

<?php
class CouponService
{
    private PDO $pdo;
    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }
    /**
     * 发放优惠券(推荐方案:原子更新 + 防重复)
     * @param int $userId 用户ID
     * @param int $templateId 优惠券模板ID
     * @return bool|string 成功返回优惠券码,失败返回false
     * @throws Exception
     */
    public function issueCoupon(int $userId, int $templateId)
    {
        // 1. 查询模板信息(开启事务)
        $this->pdo->beginTransaction();
        try {
            // 检查是否已领取(防重复领取)
            $checkSql = "SELECT id FROM user_coupons WHERE user_id = ? AND template_id = ? AND status = 0 LIMIT 1";
            $checkStmt = $this->pdo->prepare($checkSql);
            $checkStmt->execute([$userId, $templateId]);
            if ($checkStmt->fetch()) {
                $this->pdo->commit();
                return false; // 已领取过
            }
            // 2. 原子更新库存:增加已发放数量,但不超过总库存
            // 关键SQL: UPDATE ... SET issued_stock = issued_stock + 1 WHERE id = ? AND issued_stock < total_stock
            $updateSql = "UPDATE coupon_templates 
                          SET issued_stock = issued_stock + 1 
                          WHERE id = ? AND issued_stock < total_stock AND status = 1
                          AND start_time <= NOW() AND end_time >= NOW()";
            $updateStmt = $this->pdo->prepare($updateSql);
            $updateStmt->execute([$templateId]);
            $affectedRows = $updateStmt->rowCount();
            if ($affectedRows === 0) {
                // 如果没有更新成功,说明没库存或不在有效期
                $this->pdo->rollBack();
                return false;
            }
            // 3. 生成唯一优惠券码
            $code = $this->generateUniqueCode(); // 建议使用UUID或加密随机数
            // 4. 插入用户优惠券记录
            $insertSql = "INSERT INTO user_coupons (user_id, template_id, code, status, received_at) VALUES (?, ?, ?, 0, NOW())";
            $insertStmt = $this->pdo->prepare($insertSql);
            $insertStmt->execute([$userId, $templateId, $code]);
            // 5. 提交事务
            $this->pdo->commit();
            return $code;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
    /**
     * 生成唯一优惠券码
     */
    private function generateUniqueCode(): string
    {
        // 使用 UUID 前缀 + 随机数,确保唯一性
        $uuid = md5(uniqid(mt_rand(), true));
        // 可以自定义算法,例如生成 16 位大写字母数字组合
        return strtoupper(substr($uuid, 0, 16));
    }
}

优点

  • 性能高(无行锁阻塞)
  • 并发下保证不会超发

方案B:使用 SELECT ... FOR UPDATE(行锁,适合库存量小或对一致性要求极高的场景)

这种方案通过数据库行锁来阻塞并发请求。

public function issueCouponWithLock(int $userId, int $templateId)
{
    $this->pdo->beginTransaction();
    try {
        // 1. 锁住模板记录(悲观锁)
        $selectSql = "SELECT * FROM coupon_templates WHERE id = ? AND status = 1 FOR UPDATE";
        $selectStmt = $this->pdo->prepare($selectSql);
        $selectStmt->execute([$templateId]);
        $template = $selectStmt->fetch();
        if (!$template) {
            $this->pdo->rollBack();
            throw new Exception('优惠券模板不存在或已禁用');
        }
        // 2. 检查有效期
        $now = time();
        if ($now < strtotime($template['start_time']) || $now > strtotime($template['end_time'])) {
            $this->pdo->rollBack();
            throw new Exception('不在领取时间范围内');
        }
        // 3. 检查库存
        if ($template['issued_stock'] >= $template['total_stock']) {
            $this->pdo->rollBack();
            throw new Exception('优惠券已领完');
        }
        // 4. 检查用户是否已领取(防止重复)
        $checkSql = "SELECT id FROM user_coupons WHERE user_id = ? AND template_id = ? LIMIT 1";
        $checkStmt = $this->pdo->prepare($checkSql);
        $checkStmt->execute([$userId, $templateId]);
        if ($checkStmt->fetch()) {
            $this->pdo->rollBack();
            throw new Exception('您已领取过该优惠券');
        }
        // 5. 更新库存
        $updateSql = "UPDATE coupon_templates SET issued_stock = issued_stock + 1 WHERE id = ?";
        $updateStmt = $this->pdo->prepare($updateSql);
        $updateStmt->execute([$templateId]);
        // 6. 生成并插入用户券
        $code = $this->generateUniqueCode();
        $insertSql = "INSERT INTO user_coupons (user_id, template_id, code, status) VALUES (?, ?, ?, 0)";
        $insertStmt = $this->pdo->prepare($insertSql);
        $insertStmt->execute([$userId, $templateId, $code]);
        $this->pdo->commit();
        return true;
    } catch (Exception $e) {
        $this->pdo->rollBack();
        throw $e;
    }
}

异步队列发放(适合大规模营销活动)

如果是类似双十一的抢券场景,直接同步写数据库可能导致数据库压力过大,此时可以采用以下架构:

  1. 请求先写入 Redis 队列lpush coupon_queue_{templateId} userId)。
  2. 后端 Worker 进程(如使用 RabbitMQRedis Stream)消费队列,批量预生成优惠券码存入缓存。
  3. Worker 异步将发券记录写入数据库

这种方案能有效削峰填谷,但实现复杂度较高。


关键优化建议

优化点 说明
库存预热 大促时提前将库存写入 Redis,发放时先扣减 Redis 库存,再异步同步到 MySQL
防刷处理 增加接口频率限制(如 RateLimiter 或 Redis 计数),防止脚本刷券
优惠券码生成 使用 snowflake 算法或 UUID 生成唯一码,避免使用简单的自增ID
事务控制 确保原子性操作,避免超发
索引优化 user_id + template_id 上建唯一索引,从数据库层面防止重复领取

完整调用示例(前端接口)

public function couponController(Request $request)
{
    $userId = $request->input('user_id');
    $templateId = $request->input('template_id');
    $service = new CouponService(new PDO(...));
    try {
        $result = $service->issueCoupon($userId, $templateId);
        if ($result) {
            return json_encode(['code' => 200, 'msg' => '领取成功', 'coupon_code' => $result]);
        } else {
            return json_encode(['code' => 400, 'msg' => '领取失败(可能已领完或已领取)']);
        }
    } catch (Exception $e) {
        return json_encode(['code' => 500, 'msg' => $e->getMessage()]);
    }
}

最推荐使用 方案A(原子更新),它在高并发下性能最优,且能有效防止超卖,如果业务允许,还可以结合 Redis 预扣库存进一步提升性能。

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