PHP如何实现商城优惠券系统并支持叠加与满减规则

PHP商城优惠券系统深度实战:从规则引擎到叠加艺术的终极指南

各位码农兄弟、后端大佬、以及未来的产品经理们,大家晚上好!

今天我们要聊的东西,比当年的“互联网思维”还烧脑,比“双十一”的后端压力还大——那就是商城优惠券系统

别笑,我知道你们心里在想:“优惠券?不就是给个数字减一减吗?写个 if-else 不就行了?”

大错特错!兄弟们,优惠券是电商平台的“核武器”。用得好,它能瞬间提升客单价,让用户剁手剁到停不下来;用不好,那就是“打折伤财”,不仅没赚利润,还把用户给搞晕了。

今天这堂课,我们不谈空话,直接上手。我们将用 PHP 这门语言,从数据库设计开始,一步步搭建一个支持复杂规则多种类型以及多券叠加的优惠券系统。准备好了吗?Let’s go!


第一章:数据库设计——别让数据在表里流浪

在写代码之前,我们得先给数据找个“家”。优惠券系统最忌讳的就是表结构混乱,一会儿在这里存门槛,一会儿在那里存折扣率,最后查个数据得写个 JOIN 三十行。

我们得把世界分为三类表:基础信息表用户拥有表订单关联表

1.1 优惠券基础表 (coupons)

这张表存储的是“通用”的优惠券定义。比如“满100减10”这个券,不仅属于我,也属于你,也属于你的竞争对手。

CREATE TABLE `coupons` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL COMMENT '券名:双十一狂欢券',
  `type` tinyint(1) NOT NULL COMMENT '类型:1-满减,2-折扣,3-无门槛',
  `value` decimal(10,2) NOT NULL COMMENT '面值:10.00 或 0.9',
  `min_amount` decimal(10,2) DEFAULT '0.00' COMMENT '门槛金额:满100可用',
  `total_stock` int(11) DEFAULT '1000' COMMENT '库存总数',
  `remain_stock` int(11) DEFAULT '1000' COMMENT '剩余库存',
  `valid_start_time` datetime DEFAULT NULL,
  `valid_end_time` datetime DEFAULT NULL,
  `rules` json DEFAULT NULL COMMENT '扩展规则:例如限品类、限品牌,以JSON存储最灵活',
  `is_active` tinyint(1) DEFAULT '1',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

老专家的吐槽:
看到这里别嫌 rules 字段用 JSON。为什么要用 JSON?因为规则这东西天天变!今天要限手机可用,明天要限iPhone 15。你要是把所有规则拆成几十张表,数据库设计得像迷宫一样,维护成本比写代码还高。JSON 虽然反范式,但在灵活性和开发效率上,它是王道。

1.2 用户领取记录表 (user_coupons)

这张表是关键。优惠券不能无限发,得记录谁领了,领了多少张,什么时候过期。我们得区分“通用券”和“专属券”。

CREATE TABLE `user_coupons` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `coupon_id` int(11) NOT NULL COMMENT '关联优惠券ID',
  `batch_no` varchar(50) NOT NULL COMMENT '批次号,用于统计发放量',
  `status` tinyint(1) DEFAULT '1' COMMENT '状态:1-未使用,2-已使用,3-已过期',
  `receive_time` datetime DEFAULT NULL COMMENT '领取时间',
  `use_time` datetime DEFAULT NULL COMMENT '使用时间',
  `order_id` varchar(50) DEFAULT NULL COMMENT '核销订单ID',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_status` (`user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第二章:设计模式——别写面条代码

当我们面对用户手里的一堆优惠券时,如果我们用 if ($coupon->type == 1) ... elseif ($coupon->type == 2) ... 这种写法,三个月后你自己都看不懂了。

我们必须用策略模式工厂模式。这听起来很吓人,其实就是面向对象编程的精髓。

2.1 定义接口:优惠券的“灵魂”

所有优惠券都得遵守的契约。

interface CouponInterface
{
    /**
     * 计算优惠金额
     * @param float $totalPrice 订单总金额
     * @return float 实际减免金额
     */
    public function calculateDiscount(float $totalPrice): float;

    /**
     * 检查是否满足使用条件
     * @param float $totalPrice 订单总金额
     * @return bool
     */
    public function isAvailable(float $totalPrice): bool;
}

2.2 具体实现:满减券的“肌肉”

这是最常用的。

class FullReductionCoupon implements CouponInterface
{
    private $name;
    private $minAmount; // 门槛,比如100
    private $value;     // 减免额,比如10

    public function __construct(string $name, float $minAmount, float $value)
    {
        $this->name = $name;
        $this->minAmount = $minAmount;
        $this->value = $value;
    }

    public function isAvailable(float $totalPrice): bool
    {
        // 核心逻辑:价格必须大于等于门槛
        return $totalPrice >= $this->minAmount;
    }

    public function calculateDiscount(float $totalPrice): float
    {
        // 如果不满足门槛,强制返回0
        if (!$this->isAvailable($totalPrice)) {
            return 0.00;
        }

        // 实际减去的金额不能超过面值(虽然逻辑上一般不会超过,防呆)
        return min($this->value, $totalPrice);
    }
}

2.3 具体实现:折扣券的“魔法”

这个比较坑,涉及到浮点数精度问题。PHP 里处理钱,永远别相信 echo $a + $b,要用 BCMath 或者定点数。

class DiscountCoupon implements CouponInterface
{
    private $name;
    private $discountRate; // 折扣率,比如 0.9 代表9折

    public function __construct(string $name, float $discountRate)
    {
        $this->name = $name;
        // 确保折扣率在 0.1 到 1.0 之间
        $this->discountRate = max(0.1, min(1.0, $discountRate));
    }

    public function isAvailable(float $totalPrice): bool
    {
        // 折扣券通常不需要门槛,或者有特殊的门槛,这里假设无门槛
        return true; 
    }

    public function calculateDiscount(float $totalPrice): float
    {
        // 计算原价 * 折扣率 = 优惠后价格
        // 然后用 原价 - 优惠后价格 = 优惠金额
        $discountAmount = $totalPrice * (1 - $this->discountRate);

        // 防止精度丢失,四舍五入保留两位
        return round($discountAmount, 2);
    }
}

第三章:核心算法——如何实现“叠加”与“最优解”

这是本堂课的重头戏,也是区分“小学生程序员”和“资深架构师”的分水岭。

当用户购物车里有 500 元,他手里有 3 张券:

  1. 满100减10
  2. 满200减30
  3. 满500减80

系统应该用哪张?

3.1 单券逻辑

如果是单选,系统应该直接算:

  • 用第3张:500 – 80 = 420(省80)
  • 用第1张:500 – 10 = 490(省10)

显然选第3张。 这就是“贪心算法”的初级版。

3.2 多券叠加逻辑(高级 Boss)

有些活动规则是“两张券叠加”。比如“满100减10” + “满200减30” = 立减40
这时候怎么算?

通常有两种叠加策略:

策略 A:按金额排序,越贵的越先用
我们要把所有满足条件的券从大到小排序。

  1. 先拿“满500减80”(剩420)。
  2. 再拿“满200减30”(420 >= 200,能用。剩390)。
  3. 再拿“满100减10”(390 >= 100,能用。剩380)。

结果:立省 80 + 30 + 10 = 120! 这就是神仙玩法。

策略 B:固定数量叠加
规则写死“最多使用2张券”。那就好办了,取前两张最大的。

3.3 规则引擎实现

我们来写一个服务类,专门负责这个“大脑”工作。

class CouponRuleEngine
{
    /**
     * 计算最优优惠券组合
     * @param array $userCoupons 用户拥有的优惠券数组
     * @param float $totalPrice 订单总金额
     * @return array ['final_price' => 380, 'discounts' => [...], 'used_coupons' => [...]]
     */
    public function calculateBestCombo(array $userCoupons, float $totalPrice): array
    {
        // 1. 过滤:把过期的、不满足门槛的、库存为0的(如果是查询)统统干掉
        $validCoupons = array_filter($userCoupons, function($coupon) use ($totalPrice) {
            return $coupon->isAvailable($totalPrice);
        });

        if (empty($validCoupons)) {
            return [
                'final_price' => $totalPrice,
                'discounts' => 0,
                'used_coupons' => []
            ];
        }

        // 2. 排序:按优惠力度从大到小排序
        // 如果是满减,按 value 排序;如果是折扣,按 discountRate 排序
        usort($validCoupons, function($a, $b) {
            $amountA = $a->calculateDiscount($totalPrice);
            $amountB = $b->calculateDiscount($totalPrice);
            return $amountB <=> $amountA; // 降序
        });

        // 3. 叠加策略:这里我们假设支持任意数量叠加(除非业务限制)
        // 注意:这是一个简化的叠加逻辑,实际中可能要限制同类型不可叠加
        $finalPrice = $totalPrice;
        $discounts = 0;
        $usedCoupons = [];

        foreach ($validCoupons as $index => $coupon) {
            $discount = $coupon->calculateDiscount($finalPrice);

            // 只有当这张券确实带来了优惠(或者这是必选券),才使用
            if ($discount > 0) {
                $finalPrice -= $discount;
                $discounts += $discount;
                $usedCoupons[] = [
                    'coupon_name' => $coupon->name,
                    'discount' => $discount
                ];

                // 如果业务要求:满减券满减后,满减券不能再用于剩余金额(这种很少见,通常是可以的)
                // 或者业务要求:折扣券和满减券不能混合(这个要在上面过滤里加逻辑)
            }
        }

        // 防止价格算成负数
        $finalPrice = max(0, $finalPrice);

        return [
            'final_price' => round($finalPrice, 2),
            'discounts' => round($discounts, 2),
            'used_coupons' => $usedCoupons
        ];
    }
}

老专家的警告:
上面的代码很简单,但有个致命隐患。如果 calculateDiscount 里写错了逻辑,比如折扣算成了 1.1 倍(涨价了),循环叠加下去,最后价格会变成负无穷大。

还有一点,“满减”和“折扣”能否叠加?这通常是个业务坑。有的系统规定:一张折扣券,一张满减券。这怎么算?

  • 逻辑 1:先满减,再折扣。
  • 逻辑 2:先折扣,再满减。
    这取决于你的业务规则。上面的代码把所有券混在一起算,实际上你可能需要先分个类,折扣类先算,满减类后算。

第四章:实战应用——订单支付时的那一刻

当用户点击“提交订单”按钮时,后端该干什么?别急着调用支付接口,先让优惠券系统跑一圈。

4.1 订单创建前的检查

订单生成时,我们需要锁定库存。这时候,优惠券系统要介入。

class OrderService
{
    private $couponEngine;

    public function __construct(CouponRuleEngine $engine)
    {
        $this->couponEngine = $engine;
    }

    public function createOrder($userId, array $items, $couponIds)
    {
        // 1. 计算商品总价
        $totalPrice = $this->calcItemsPrice($items);

        // 2. 准备优惠券数据(从数据库查出来,组装成对象)
        $coupons = $this->getCouponsByIds($couponIds);

        // 3. 核心计算
        $result = $this->couponEngine->calculateBestCombo($coupons, $totalPrice);

        $finalPrice = $result['final_price'];
        $usedCoupons = $result['used_coupons'];

        // 4. 事务处理
        // 为了保证数据一致性,我们得用数据库事务
        DB::beginTransaction();
        try {
            // 4.1 扣减优惠券库存(如果是通用券)
            $this->decreaseCouponStock($couponIds);

            // 4.2 锁定优惠券(把用户的优惠券状态改成“待支付”或“已使用”)
            $this->lockUserCoupons($userId, $couponIds);

            // 4.3 创建订单记录
            $orderId = $this->saveOrder($userId, $items, $finalPrice, $couponIds, $usedCoupons);

            DB::commit();
            return $orderId;
        } catch (Exception $e) {
            DB::rollBack();
            // 这里要处理异常,比如优惠券库存不够了,或者并发冲突
            throw new OrderException("下单失败:优惠券状态异常");
        }
    }

    private function saveOrder(...)
    {
        // INSERT INTO orders ...
        // INSERT INTO order_coupons ...
    }
}

4.2 库存扣减的“原子性”陷阱

这里有个非常经典的问题:并发扣库存

假设数据库里只剩1张“超级优惠券”了。
用户A和用户B几乎同时点击下单。
线程A查到库存1,准备减1。
线程B查到库存1,准备减1。
结果:两张单子都生成成功了,库存变成了-1。这就是超卖

解决方法:
在 SQL 层面加 UPDATE 语句。

UPDATE coupons 
SET remain_stock = remain_stock - 1 
WHERE id = ? AND remain_stock > 0;

如果 affected_rows 为 0,说明库存没了,抛异常。

或者用 Redis 做缓存。但这需要处理 Redis 和 MySQL 的同步问题(虽然延迟极低,但终究有风险)。对于高并发大促,通常用 Redis Lua 脚本来保证原子性。


第五章:性能优化与扩展性

代码写完了,能跑通?那只是及格。下面我们聊聊如何让它跑得快,且不挂。

5.1 索引是神

记得我在第一章说的 idx_user_status 吗?
如果用户有 1000 张优惠券,你要查他“未使用的”且“未过期的”优惠券。
没有索引:数据库要全表扫描 1000 条记录。
有索引:数据库直接定位,毫秒级。

5.2 缓存策略

优惠券的基本信息(名、面值、类型)是不会变的。除了刚发券那几分钟,其他时间你都在重复查这张表。
建议: 使用 Redis 缓存 coupons 的基本信息。
GET coupon:1 -> 直接返回对象。
但是,库存 是实时变化的。所以缓存库存时,一定要加锁,或者设置较短的过期时间(比如1秒)。

5.3 规则的扩展性

如果产品经理过两天说:“我们要加个‘加价购’,满500再送个手机壳”。
这咋办?
coupons 表里加个 extension_rule 字段不就完了吗?
{"action": "add_product", "product_id": 888}
然后在支付成功回调里,判断这个规则,自动发短信给用户:“恭喜您获得赠品手机壳”。


第六章:避坑指南——那些年我们在优惠券系统里踩过的坑

作为资深专家,我必须给你们提个醒。下面的错误,你们一定不想在上线时犯。

坑 1:精度丢失

PHP 的浮点数运算就像是在算命,有时候准有时候不准。
0.1 + 0.2 = 0.30000000000000004
在处理金额时,永远不要相信浮点数。建议在代码里定义一个 Money 类,内部用整数(分)来存储,只有最后展示给前端的时候才转成小数(元)。

坑 2:优惠券过期时间

很多同学写定时任务扫全表:
SELECT * FROM user_coupons WHERE status=1 AND expire_time < NOW()
用户有 100 万张券,这张表一扫,数据库直接卡死。
正确姿势: 建立一个“过期时间索引”队列。把所有即将过期的优惠券 ID 存到一个 Redis 的有序集合 (ZSET) 里,score 是过期时间戳。然后启动一个定时任务,只扫最近 24 小时内过期的数据。

坑 3:满减门槛的“陷阱”

用户买了 99 元的东西。
优惠券是“满100减10”。
代码直接 return 99 - 10 = 89
错! 这里的逻辑应该是:优惠券不适用。用户还是得付 99 元。
一定要先判断 isAvailable,再进行 calculateDiscount

坑 4:并发扣减

刚才提到了。一定要用数据库事务 + SQL 更新语句,保证原子性。


第七章:总结与展望

好了,兄弟们,今天我们深入探讨了 PHP 优惠券系统的方方面面。

数据库设计的规范化,到设计模式的解耦;从规则引擎的算法实现,到并发控制的性能优化。我们构建了一个能够应对复杂业务逻辑的基石。

记住,优惠券系统的本质不是“减钱”,而是“算计”。
它既要算计成本,让公司赚得更多;又要算计用户心理,让用户觉得占了便宜。而这种心理博弈,最终都落在了我们后端的每一行代码上。

当你看到前端页面弹出“恭喜您,使用优惠券立省 120 元”时,那种成就感,比喝冰可乐还爽。

希望这篇文章能成为你们项目中的基石。记住,代码写得再好,不如让用户开心地买单。祝大家代码无 Bug,奖金拿满手!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注