CRMEB Pro 优惠券领取校验:为什么同一张券会被重复领或用错场景?

摘要

优惠券领取看起来只是一个按钮:用户点一下,系统给一张券。但在 CRMEB Pro 里,真正安全的领券至少要判断:券是否存在、是否还在领取时间内、是否还有库存、是否允许手动领取、用户是否已经超过领取上限、会员券是否满足会员身份,以及下单时这张券是否仍然能用。

这篇文章围绕 CRMEB Pro 的领券链路做源码解析,并给出二开时常见的增强代码示例。重点是:领券限制不能只写在前端,扣减剩余数量也不能只靠页面防抖。

1. 用户端领券入口在哪里?

用户端优惠券路由在 API 路由里:

text 复制代码
GET  coupons                         可领取优惠券列表
POST coupon/receive                  领取优惠券
POST coupon/receive/batch            批量领取优惠券
GET  coupons/user/num                我的优惠券数量
GET  coupons/user/:types             用户已领取优惠券
GET  coupons/order/:price            下单可使用优惠券

POST coupon/receive 对应控制器:

php 复制代码
class StoreCoupons
{
    #[Inject]
    protected StoreCouponIssueServices $services;

    public function receive(Request $request)
    {
        [$couponId] = $request->getMore([
            ['couponId', 0]
        ], true);

        $couponId = (int)$couponId;
        if (!$couponId) {
            return app('json')->fail('参数错误!');
        }

        $info = $this->services->get($couponId);
        if ($info['receive_type'] != 1) {
            return app('json')->fail('该优惠券不能领取');
        }

        $uid = (int)$request->uid();
        $coupon = $this->services->issueUserCoupon($uid, $couponId, false);
        if ($coupon) {
            $coupon = $coupon->toArray();
            return app('json')->success('领取成功', $coupon);
        }
        return app('json')->fail('领取失败');
    }
}

这段代码有两个重点:

text 复制代码
receive_type != 1 不能手动领取
issueUserCoupon($uid, $couponId, false) 负责真正发券

所以二开时不要把校验全部塞到 receive(),Controller 只处理请求和响应,核心判断应该继续收口到 Services。

2. 真正的领券校验在 issueUserCoupon

StoreCouponIssueServices::issueUserCoupon() 是领券核心:

php 复制代码
public function issueUserCoupon(int $uid, int $id, bool $more = true, string $type = 'get')
{
    if (!$uid || !$id) {
        throw new ValidateException('参数异常');
    }

    $issueCouponInfo = $this->dao->getInfo((int)$id);
    if (!$issueCouponInfo) {
        throw new ValidateException('领取的优惠劵已领完或已过期!');
    }

    if ($issueCouponInfo['remain_count'] <= 0 && !$issueCouponInfo['is_permanent']) {
        throw new ValidateException('抱歉优惠券已经领取完了!');
    }

    if ($issueCouponInfo['category'] == 2) {
        $memberRightService = app()->make(MemberRightServices::class);
        if (!$memberRightService->getMemberRightStatus("coupon")) {
            throw new ValidateException('暂时无法领取!');
        }

        $userServices = app()->make(UserServices::class);
        if (!$userServices->checkUserIsSvip($uid)) {
            throw new ValidateException('请先购买付费会员后领取!');
        }
    }

    $issueUserService = app()->make(StoreCouponIssueUserServices::class);
    $couponUserService = app()->make(StoreCouponUserServices::class);

    $receiveCount = $issueUserService->getCount([
        'uid' => $uid,
        'issue_coupon_id' => $id
    ]);

    if (!$more) {
        if ($receiveCount >= $issueCouponInfo['receive_limit']) {
            throw new ValidateException('已领取过该优惠劵!');
        }
    }

    return $this->transaction(function () use ($issueUserService, $uid, $id, $couponUserService, $issueCouponInfo, $type, $receiveCount) {
        $issueUserService->save([
            'uid' => $uid,
            'issue_coupon_id' => $id,
            'add_time' => time()
        ]);

        $res = $couponUserService->addUserCoupon($uid, $issueCouponInfo, $type);

        if ($issueCouponInfo['total_count'] > 0) {
            $issueCouponInfo['remain_count'] -= 1;
            $issueCouponInfo->save();
        }

        $res['receive_count'] = $receiveCount + 1;
        return $res;
    });
}

它已经覆盖了几类基础问题:

text 复制代码
用户不存在或券 ID 为空
券已领完或过期
剩余库存不足
会员券身份校验
领取次数 receive_limit 校验
事务内写领取记录、写用户券、扣减 remain_count

3. 为什么还会出现"重复领"?

常见原因有三类。

第一类是入口绕过。比如新写了一个活动页,直接调用 StoreCouponUserServices::addUserCoupon(),绕过了 issueUserCoupon() 的库存和领取次数判断。

错误示例:

php 复制代码
// 不建议:跳过了发布券状态、库存、领取次数和会员券校验
$couponUserService = app()->make(StoreCouponUserServices::class);
$couponUserService->addUserCoupon($uid, $issueCouponInfo, 'get');

推荐写法:

php 复制代码
$couponIssueService = app()->make(StoreCouponIssueServices::class);
$couponIssueService->issueUserCoupon($uid, $couponId, false, 'get');

第二类是 $more 参数用错。手动领取入口传的是 false,表示要检查 receive_limit

php 复制代码
$this->services->issueUserCoupon($uid, $couponId, false);

如果二开活动页误传成 true,就可能绕过领取次数限制。

第三类是高并发下的库存扣减。现有逻辑在事务内扣减 remain_count,普通业务一般够用;如果你把优惠券放到高并发直播、秒杀、首页弹窗里,建议把扣减做成条件更新。

4. 高并发领券建议:Dao 中做条件扣减

可以在 StoreCouponIssueDao 中增加一个专门的扣减方法,继续遵守项目 Dao/Model 分层:

php 复制代码
/**
 * 扣减优惠券剩余数量
 * @param int $id 发布券ID
 * @return bool
 */
public function decRemainCount(int $id): bool
{
    return $this->getModel()
        ->where('id', $id)
        ->where('is_permanent', 0)
        ->where('remain_count', '>', 0)
        ->dec('remain_count')
        ->update() > 0;
}

然后在 Service 中封装扣减入口:

php 复制代码
/**
 * 校验并扣减优惠券库存
 * @param array|\ArrayAccess $coupon 发布券信息
 * @return void
 */
protected function checkAndDecCouponStock($coupon): void
{
    if ((int)$coupon['is_permanent'] === 1) {
        return;
    }

    if ((int)$coupon['remain_count'] <= 0) {
        throw new ValidateException('抱歉优惠券已经领取完了!');
    }

    if (!$this->dao->decRemainCount((int)$coupon['id'])) {
        throw new ValidateException('抱歉优惠券已经领取完了!');
    }
}

事务内就不要再直接 $issueCouponInfo['remain_count'] -= 1

php 复制代码
return $this->transaction(function () use ($issueUserService, $uid, $id, $couponUserService, $issueCouponInfo, $type, $receiveCount) {
    $this->checkAndDecCouponStock($issueCouponInfo);

    $issueUserService->save([
        'uid' => $uid,
        'issue_coupon_id' => $id,
        'add_time' => time()
    ]);

    $res = $couponUserService->addUserCoupon($uid, $issueCouponInfo, $type);
    $res['receive_count'] = $receiveCount + 1;
    return $res;
});

这样做的好处是:库存扣减由数据库条件保证,多个请求同时抢最后一张券时,不会因为旧对象值保存导致超发。

5. 场景错用:receive_type 和 type 要一起看

CRMEB Pro 里,领取方式和适用类型是两套概念:

text 复制代码
receive_type  控制这张券怎么发到用户账户
type          控制这张券适用于哪些商品范围

例如:

text 复制代码
receive_type = 1 手动领取
receive_type = 3 系统发放

type = 0 通用券
type = 1 品类券
type = 2 商品券
type = 3 品牌券

所以不能因为 type = 0 是通用券,就允许任何页面领取。手动领取接口已经做了限制:

php 复制代码
$info = $this->services->get($couponId);
if ($info['receive_type'] != 1) {
    return app('json')->fail('该优惠券不能领取');
}

二开专题页时,也应该补上场景校验:

php 复制代码
protected function checkReceiveScene(array $coupon, string $scene): void
{
    $receiveType = (int)($coupon['receive_type'] ?? 0);

    $allowScene = [
        1 => ['coupon_center', 'product_detail'],
        2 => ['new_user'],
        3 => ['admin_send', 'order_give'],
        4 => ['custom_activity'],
    ];

    if (!in_array($scene, $allowScene[$receiveType] ?? [], true)) {
        throw new ValidateException('当前场景不能领取该优惠券');
    }
}

6. 用户券入库也要看有效期

用户领取成功后,会写入 store_coupon_user,有效期由 addUserCoupon() 计算:

php 复制代码
public function addUserCoupon($uid, $issueCouponInfo, string $type = 'get')
{
    $data['cid'] = $issueCouponInfo['id'];
    $data['uid'] = $uid;
    $data['coupon_title'] = $issueCouponInfo['title'];
    $data['applicable_type'] = $issueCouponInfo['type'];
    $data['product_id'] = $issueCouponInfo['product_id'];
    $data['category_id'] = $issueCouponInfo['category_id'];
    $data['brand_id'] = $issueCouponInfo['brand_id'];
    $data['coupon_price'] = $issueCouponInfo['coupon_price'];
    $data['use_min_price'] = $issueCouponInfo['use_min_price'];
    $data['add_time'] = time();

    if ($issueCouponInfo['coupon_time']) {
        $data['start_time'] = $data['add_time'];
        $data['end_time'] = $data['add_time'] + $this->getCouponValidSeconds($issueCouponInfo);
    } else {
        $data['start_time'] = $issueCouponInfo['start_use_time'];
        $data['end_time'] = $issueCouponInfo['end_use_time'];
    }

    $data['type'] = $type;
    return $this->dao->save($data);
}

这里已经支持"按天"和"按分钟"的有效期:

php 复制代码
protected function getCouponValidSeconds($coupon): int
{
    $unit = (int)($coupon['coupon_time_unit'] ?? 1) === 2 ? 60 : 86400;
    return max((int)($coupon['coupon_time'] ?? 0), 0) * $unit;
}

如果你新增"领取后 2 小时内有效",不要硬编码 3600 * 2 到页面里,应该扩展 coupon_time_unit 或统一在这个方法里处理。

7. 下单使用时还会再次验证

领到券不代表下单一定能用。订单创建时会把 couponId 传入价格计算和创建链路:

php 复制代码
$priceData = $computedServices->computedOrder(
    $uid,
    $userInfo,
    $cartGroup,
    $addressId,
    $payType,
    $useIntegral,
    $couponId,
    $shippingType,
    $isSendGift
);

真正使用优惠券时,会调用:

php 复制代码
public function useCoupon(int $couponId, int $uid, array $cartInfo, array $promotions = [], int $liveRoomId = 0)
{
    if (!$couponId || !$uid || !$cartInfo) {
        return true;
    }

    $promotionsServices = app()->make(StorePromotionsServices::class);
    [$couponInfo, $couponPrice] = $promotionsServices->useCoupon(
        $couponId,
        $uid,
        $cartInfo,
        $promotions,
        $liveRoomId
    );

    if ($couponInfo) {
        $this->dao->useCoupon($couponId);
    }

    return true;
}

Dao 中状态更新是:

php 复制代码
public function useCoupon(int $id)
{
    return $this->getModel()
        ->where('id', $id)
        ->update([
            'status' => 1,
            'use_time' => time()
        ]);
}

二开建议:如果你要增强安全性,可以把用户 ID 和未使用状态也作为条件,避免误把别人的券或已使用券更新:

php 复制代码
public function useUserCoupon(int $id, int $uid)
{
    return $this->getModel()
        ->where('id', $id)
        ->where('uid', $uid)
        ->where('status', 0)
        ->update([
            'status' => 1,
            'use_time' => time()
        ]);
}

8. 关键目录说明

text 复制代码
route/api.php
  用户端领券、我的优惠券、下单可用券接口。

app/controller/api/v1/activity/StoreCoupons.php
  用户端优惠券 Controller,负责参数接收和响应。

app/services/activity/coupon/StoreCouponIssueServices.php
  发布券读取、领取校验、领取记录写入、剩余数量扣减。

app/services/activity/coupon/StoreCouponUserServices.php
  用户券写入、可用券筛选、使用状态更新。

app/dao/activity/coupon/StoreCouponIssueDao.php
  有效发布券查询,适合放库存条件扣减。

app/dao/activity/coupon/StoreCouponUserDao.php
  用户券状态更新和用户券查询。

9. 二开注意事项

  1. 手动领券入口必须传 $more = false,否则容易绕过 receive_limit
  2. 活动页不要直接调用 addUserCoupon(),统一走 issueUserCoupon()
  3. 高并发发券建议用 Dao 条件扣减 remain_count
  4. 领取方式 receive_type 和适用范围 type 是两套规则,不要混淆。
  5. 用户券有效期以服务端写入为准,不要让前端自己计算过期时间。
  6. 下单使用优惠券时还要重新验证购物车、活动叠加和券状态。

标签建议

text 复制代码
CRMEB Pro
优惠券
领券校验
二次开发
ThinkPHP
商城源码
高并发
相关推荐
IManiy1 小时前
总结之Vibe Coding:后端骨架
后端
ikoala1 小时前
Codex 怎么买、怎么充值?先把这两套计费搞清楚
前端·javascript·后端
前端Hardy2 小时前
一个时代结束了:npm 终于对 install 脚本下手了
前端·javascript·后端
damaoyou2 小时前
Cog3DRangeImagePlaneEstimatorTool完全指南
后端
Nturmoils2 小时前
分页别写太顺手,LIMIT 背后还有排序和边界
数据库·后端
神奇小汤圆2 小时前
国产版“Codex”初体验,智谱ZCode很强啊!
后端
站大爷IP2 小时前
Python里的“赋值”到底是什么意思?
后端
鹅城剑仙3 小时前
Spring Boot 微服务架构设计与最佳实践
spring boot·后端·微服务
Full Stack Developme4 小时前
Spring Integration 教程
java·后端·spring