Просмотр исходного кода

feat(seckill): 强化秒杀下单核价与限购

- 下单时按活动配置回查秒杀价、库存和单人限购,避免客户端价格被伪造
- 使用 Redis 记录秒杀已售和客户已购数量,并在订单失败时回滚预占
- 列表分页返回秒杀/团购活动起止时间,便于前端详情承接
API: 商城下单支持 activityType=seckill 的服务端核价逻辑,活动列表分页响应新增 startTime/endTime 字段
shizhongqi 2 недель назад
Родитель
Сommit
e673c80a7b

+ 101 - 17
app-mall/controllers/OrderController.php

@@ -8,6 +8,7 @@ use biz\wx\classes\WxMessageClass;
 use bizHd\custom\classes\HdClass;
 use bizHd\express\classes\HdDeliveryOrderClass;
 use bizHd\hb\classes\HbClass;
+use bizHd\homePageConfig\classes\HomePageModuleClass;
 use bizHd\order\classes\ScanPayClass;
 use bizHd\wx\classes\WxOpenClass;
 use bizMall\custom\classes\CustomClass;
@@ -605,10 +606,22 @@ class OrderController extends BaseController
         if ($stock <= 0) {
             util::fail('库存不足');
         }
+        $saleGoodsId = $specGoodsId > 0 ? $specGoodsId : $goodsId;
+        $activityType = strval($post['activityType'] ?? '');
 
         //事务处理
         $connection = Yii::$app->db;
         $transaction = $connection->beginTransaction();
+        $transactionFinished = false;
+        register_shutdown_function(function () use (&$transactionFinished, $transaction) {
+            if ($transactionFinished) {
+                return;
+            }
+            HomePageModuleClass::rollbackSeckillReservationSnapshot();
+            if ($transaction->isActive) {
+                $transaction->rollBack();
+            }
+        });
         try {
             $custom = $this->custom;
             if (empty($custom)) {
@@ -631,16 +644,48 @@ class OrderController extends BaseController
             $post['payWay'] = 0;
 
             $shop = $this->shop;
-            $params = [];
-            $goodsData = $goodsInfo->attributes;
-            $ret = \bizHd\goods\classes\GoodsClass::getFinalPrice($goodsData, $shop, $custom, $params);
-            $goodsInfo->priceType = $ret['priceType'] ?? 0;
-            if ($goodsInfo->priceType == 0) {
-                util::fail('没有价格,不能下单哈');
+
+            // 秒杀商品:不信任前端传的价格,独立回查 Redis 秒杀配置核实活动状态/库存/限购后再核价
+            $seckillRow = null;
+            if ($activityType === 'seckill') {
+                $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
+                if (empty($seckillRow)) {
+                    util::fail('秒杀活动已结束或商品已下架,请重新选择');
+                }
+                $seckillStock = floatval($seckillRow['stock'] ?? 0);
+                $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $saleGoodsId);
+                if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
+                    util::fail('秒杀库存不足,请重新选择');
+                }
+                $seckillLimit = floatval($seckillRow['limit'] ?? 0);
+                if ($seckillLimit > 0) {
+                    $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $customId);
+                    if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
+                        util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
+                    }
+                }
             }
-            $unitPrice = $ret['price'] ?? 0;
-            if ($unitPrice <= 0) {
-                util::fail('没有价格,不能下单呢');
+
+            if ($seckillRow) {
+                $goodsInfo->priceType = 1;
+                $unitPrice = floatval($seckillRow['price'] ?? 0);
+                if ($unitPrice <= 0) {
+                    util::fail('秒杀价格异常,不能下单');
+                }
+                // 通过校验后立即预占名额,失败/异常时随事务一起回滚(见 register_shutdown_function)
+                HomePageModuleClass::reserveSeckillPurchase($mainId, $saleGoodsId, $customId, $goodsNum);
+            } else {
+                $params = [];
+                $goodsData = $goodsInfo->attributes;
+                $ret = \bizHd\goods\classes\GoodsClass::getFinalPrice($goodsData, $shop, $custom, $params);
+                $goodsInfo->priceType = $ret['priceType'] ?? 0;
+                if ($goodsInfo->priceType == 0) {
+                    util::fail('没有价格,不能下单哈');
+                }
+                $unitPrice = $ret['price'] ?? 0;
+                if ($unitPrice <= 0) {
+                    util::fail('没有价格,不能下单呢');
+                }
             }
 
             //计算总金额
@@ -878,7 +923,6 @@ class OrderController extends BaseController
             $post['mainId'] = $mainId;
             $post['needPrint'] = dict::getDict('needPrint', 'need');
             $hasPay = 0;
-            $saleGoodsId = $specGoodsId > 0 ? $specGoodsId : $goodsId;
             $product = [['productId' => $saleGoodsId, 'unitType' => 0, 'property' => 0, 'unitPrice' => $unitPrice, 'num' => $goodsNum]];
             $post['product'] = $product;
             $return = \bizHd\order\services\OrderService::createHdOrder($post, $custom, $hasPay);
@@ -894,9 +938,13 @@ class OrderController extends BaseController
             $orderSn = $return->orderSn;
             $actPrice = $return->actPrice ?? 0;
             $transaction->commit();
+            $transactionFinished = true;
+            HomePageModuleClass::clearSeckillReservationSnapshot();
             util::success(['orderSn' => $orderSn, 'totalPrice' => $actPrice, 'couponId' => 0, 'id' => $orderId]);
         } catch (Exception $e) {
             $transaction->rollBack();
+            HomePageModuleClass::rollbackSeckillReservationSnapshot();
+            $transactionFinished = true;
             Yii::info("失败原因:" . $e->getMessage());
             util::fail('下单失败');
         }
@@ -1031,6 +1079,7 @@ class OrderController extends BaseController
                 return;
             }
             \bizHd\product\classes\ProductClass::rollbackLimitBuySnapshot();
+            HomePageModuleClass::rollbackSeckillReservationSnapshot();
             if ($transaction->isActive) {
                 $transaction->rollBack();
             }
@@ -1143,15 +1192,48 @@ class OrderController extends BaseController
                     if ($stockSet == 1 && $stock <= 0) {
                         util::fail('花束库存不足');
                     }
+
+                    // 秒杀商品:不信任前端传的价格,独立回查 Redis 秒杀配置核实活动状态/库存/限购后再核价
+                    $seckillRow = null;
+                    if (strval($element['activityType'] ?? '') === 'seckill') {
+                        $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
+                        if (empty($seckillRow)) {
+                            util::fail('秒杀活动已结束或商品已下架,请重新选择');
+                        }
+                        $seckillStock = floatval($seckillRow['stock'] ?? 0);
+                        $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $saleGoodsId);
+                        if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
+                            util::fail('秒杀库存不足,请重新选择');
+                        }
+                        $seckillLimit = floatval($seckillRow['limit'] ?? 0);
+                        if ($seckillLimit > 0) {
+                            $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $hdCustomId);
+                            if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
+                                util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
+                            }
+                        }
+                    }
+
                     $goodsData = $goodsInfo->attributes;
-                    $ret = \bizHd\goods\classes\GoodsClass::getFinalPrice($goodsData, $shop, $custom, []);
-                    $priceType = $ret['priceType'] ?? 0;
-                    if ($priceType == 0) {
-                        util::fail('花束没有价格,不能下单');
+                    if ($seckillRow) {
+                        $unitPrice = floatval($seckillRow['price'] ?? 0);
+                        if ($unitPrice <= 0) {
+                            util::fail('秒杀价格异常,不能下单');
+                        }
+                    } else {
+                        $ret = \bizHd\goods\classes\GoodsClass::getFinalPrice($goodsData, $shop, $custom, []);
+                        $priceType = $ret['priceType'] ?? 0;
+                        if ($priceType == 0) {
+                            util::fail('花束没有价格,不能下单');
+                        }
+                        $unitPrice = $ret['price'] ?? 0;
+                        if ($unitPrice <= 0) {
+                            util::fail('花束没有价格,不能下单');
+                        }
                     }
-                    $unitPrice = $ret['price'] ?? 0;
-                    if ($unitPrice <= 0) {
-                        util::fail('花束没有价格,不能下单');
+                    if ($seckillRow) {
+                        // 通过校验后立即预占名额,失败/异常时随事务一起回滚(见 register_shutdown_function)
+                        HomePageModuleClass::reserveSeckillPurchase($mainId, $saleGoodsId, $hdCustomId, $goodsNum);
                     }
                     $currentTotal = bcmul($unitPrice, $goodsNum, 2);
                     $modifyPrice = bcadd($modifyPrice, $currentTotal, 2);
@@ -1305,6 +1387,7 @@ class OrderController extends BaseController
             $transaction->commit();
             $transactionFinished = true;
             \bizHd\product\classes\ProductClass::clearLimitBuyRollbackSnapshot();
+            HomePageModuleClass::clearSeckillReservationSnapshot();
 
             $orderSn = $return->orderSn ?? '';
             $orderPrice = $return->orderPrice ?? ($return->actPrice ?? 0);
@@ -1320,6 +1403,7 @@ class OrderController extends BaseController
         } catch (\Exception $e) {
             $transaction->rollBack();
             \bizHd\product\classes\ProductClass::rollbackLimitBuySnapshot();
+            HomePageModuleClass::rollbackSeckillReservationSnapshot();
             $transactionFinished = true;
             Yii::error("合并下单失败:" . $e->getMessage());
             util::fail('下单失败');

+ 7 - 0
biz-hd/homePageConfig/classes/HomePageDisplayClass.php

@@ -211,6 +211,9 @@ class HomePageDisplayClass
         $title = '';
         $list = [];
         $total = 0;
+        // 秒杀/团购活动的起止时间,供列表页「更多」点击进详情时一并带上活动信息
+        $activityEndTime = 0;
+        $activityStartTime = 0;
 
         if ($moduleKey === 'seckill' || $moduleKey === 'groupBuy') {
             $data = $moduleKey === 'seckill'
@@ -220,6 +223,8 @@ class HomePageDisplayClass
             if ($title === '') {
                 $title = $moduleKey === 'groupBuy' ? '团购优惠' : '限时秒杀';
             }
+            $activityStartTime = intval($data['startTime'] ?? 0);
+            $activityEndTime = intval($data['endTime'] ?? 0);
             $all = empty($data['enabled']) ? [] : self::filterActivityGoods($data['goods'] ?? []);
             $total = count($all);
             $list = array_slice($all, ($page - 1) * $pageSize, $pageSize);
@@ -256,6 +261,8 @@ class HomePageDisplayClass
             'pageSize' => $pageSize,
             'moreData' => $moreData,
             'title' => $title,
+            'startTime' => $activityStartTime,
+            'endTime' => $activityEndTime,
         ];
     }
 }

+ 116 - 2
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -22,6 +22,13 @@ class HomePageModuleClass
     const REDIS_NEW = 'home_page_config:%d:new';
     const REDIS_PULL_GOODS = 'home_page_config:%d:pull_goods';
 
+    /** 秒杀已售数量:hash key 按门店,field 为 goodsId */
+    const SECKILL_SOLD_PREFIX = 'seckill_sold:';
+    /** 秒杀单客户已购数量:hash key 按门店+goodsId,field 为 customId */
+    const SECKILL_BUY_PREFIX = 'seckill_buy:';
+    /** 当前请求内的秒杀名额预占快照,下单失败时据此回滚 */
+    const SECKILL_ROLLBACK_SNAPSHOT_KEY = 'seckillReserveRollbackSnapshot';
+
     const NAV_OSS_ROOT = 'uploads_home_nav';
     const MAX_NAV_COUNT = 20;
 
@@ -161,7 +168,6 @@ class HomePageModuleClass
     }
 
     // -------------------- 秒杀 --------------------
-
     public static function getSeckill($mainId, $refreshStock = true)
     {
         $mainId = intval($mainId);
@@ -234,13 +240,16 @@ class HomePageModuleClass
             if ($realStock < $stock) {
                 $status = 0;
             }
+            if ($goods->masterId > 0) {
+                $goods->name = $goods->name . '(' . $goods->specName . ')';
+            }
             $list[] = [
                 'goodsId' => $goodsId,
                 'price' => round($price, 2),
                 'stock' => $stock,
                 'limit' => $limit,
                 'status' => $status,
-                'name' => strval($goods->name ?? ($item['name'] ?? '')),
+                'name' => $goods->name ?? ($item['name'] ?? ''),
                 'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
                 'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
             ];
@@ -273,6 +282,9 @@ class HomePageModuleClass
             if ($refreshStock) {
                 $goods = GoodsClass::getById($row['goodsId'], true);
                 if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
+                    if ($goods->masterId > 0) {
+                        $goods->name = $goods->name . '(' . $goods->specName . ')';
+                    }
                     $realStock = intval($goods->stock ?? 0);
                     $row['realStock'] = $realStock;
                     $row['name'] = strval($goods->name ?? $row['name']);
@@ -307,6 +319,108 @@ class HomePageModuleClass
         return $list;
     }
 
+    /**
+     * 下单时校验秒杀商品:活动进行中、该商品已上架,返回其配置行(含真实秒杀价/库存/限购)
+     * 供订单结算时独立核价,避免客户端伪造价格
+     *
+     * @param int $mainId
+     * @param int $goodsId 下单商品的实际销售单元id(普通商品即goodsId本身,多规格则为规格id)
+     * @return array|null 匹配的秒杀商品配置,未命中/活动未进行中返回 null
+     */
+    public static function getSeckillActiveRow($mainId, $goodsId)
+    {
+        $data = self::getSeckill($mainId, true);
+        if (empty($data['enabled']) || intval($data['status'] ?? 0) !== 1) {
+            return null;
+        }
+        $goodsId = intval($goodsId);
+        foreach (($data['goods'] ?? []) as $item) {
+            if (!is_array($item) || empty($item['status'])) {
+                continue;
+            }
+            if (intval($item['goodsId'] ?? 0) === $goodsId) {
+                return $item;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 秒杀商品累计已售数量(跨所有客户),用于校验是否超出活动库存
+     */
+    public static function getSeckillSoldCount($mainId, $goodsId)
+    {
+        $key = self::SECKILL_SOLD_PREFIX . intval($mainId);
+        $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($goodsId)]);
+        return floatval($val ?? 0);
+    }
+
+    /**
+     * 单个客户在该秒杀商品上已购买的数量,用于校验单人限购
+     */
+    public static function getSeckillCustomBoughtCount($mainId, $goodsId, $customId)
+    {
+        $key = self::SECKILL_BUY_PREFIX . intval($mainId) . ':' . intval($goodsId);
+        $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($customId)]);
+        return floatval($val ?? 0);
+    }
+
+    /**
+     * 预占秒杀名额:库存/限购校验通过后调用,累加"已售"与"该客户已购"计数,
+     * 并记录本次请求的回滚快照——下单失败时通过 rollbackSeckillReservationSnapshot 撤销
+     */
+    public static function reserveSeckillPurchase($mainId, $goodsId, $customId, $num)
+    {
+        $mainId = intval($mainId);
+        $goodsId = intval($goodsId);
+        $customId = intval($customId);
+        $num = floatval($num);
+        if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
+            return;
+        }
+        $soldKey = self::SECKILL_SOLD_PREFIX . $mainId;
+        $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $goodsId;
+        Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $goodsId, $num]);
+        Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, $num]);
+        $snapshot = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? [];
+        $snapshot[] = ['mainId' => $mainId, 'goodsId' => $goodsId, 'customId' => $customId, 'num' => $num];
+        Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] = $snapshot;
+    }
+
+    /**
+     * 回滚当前请求中已预占的秒杀名额(下单失败/异常时调用)
+     */
+    public static function rollbackSeckillReservationSnapshot()
+    {
+        $snapshotList = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? [];
+        if (empty($snapshotList) || !is_array($snapshotList)) {
+            return true;
+        }
+        foreach ($snapshotList as $snapshot) {
+            $mainId = intval($snapshot['mainId'] ?? 0);
+            $goodsId = intval($snapshot['goodsId'] ?? 0);
+            $customId = intval($snapshot['customId'] ?? 0);
+            $num = floatval($snapshot['num'] ?? 0);
+            if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
+                continue;
+            }
+            $soldKey = self::SECKILL_SOLD_PREFIX . $mainId;
+            $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $goodsId;
+            Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $goodsId, -$num]);
+            Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, -$num]);
+        }
+        self::clearSeckillReservationSnapshot();
+        return true;
+    }
+
+    /**
+     * 清理当前请求记录的秒杀预占快照(下单成功后调用)
+     */
+    public static function clearSeckillReservationSnapshot()
+    {
+        unset(Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY]);
+    }
+
     // -------------------- 团购 --------------------
 
     public static function getGroupBuy($mainId, $refreshStock = true)