Bläddra i källkod

feat(seckill): 持久化秒杀活动批次

- 新增秒杀活动与活动商品版本表模型,保存秒杀配置时同步 MySQL 与 Redis

- 下单校验改为按秒杀商品版本统计库存和限购,并写入订单及订单商品秒杀标识

- 商城订单列表补充 groupBuyId 字段,保留拼团状态展示所需上下文

API: 秒杀配置读取返回活动与商品版本标识,下单写入 hasSeckill 和 seckillGoodsId;订单列表返回 groupBuyId

Database: 新增 xhSeckillActivity、xhSeckillGoods,并为 xhOrder、xhOrderGoods 增加秒杀追溯字段
shizhongqi 2 veckor sedan
förälder
incheckning
39d09a07f0

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

@@ -645,21 +645,26 @@ class OrderController extends BaseController
 
             $shop = $this->shop;
 
-            // 秒杀商品:不信任前端传的价格,独立回查 Redis 秒杀配置核实活动状态/库存/限购后再核价
+            // 秒杀商品:不信任前端传的价格,独立回查秒杀配置核实活动状态/库存/限购后再核价
             $seckillRow = null;
+            $seckillActivityGoodsId = 0;
             if ($activityType === 'seckill') {
                 $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
                 if (empty($seckillRow)) {
                     util::fail('秒杀活动已结束或商品已下架,请重新选择');
                 }
+                $seckillActivityGoodsId = intval($seckillRow['id'] ?? ($seckillRow['activityGoodsId'] ?? 0));
+                if ($seckillActivityGoodsId <= 0) {
+                    util::fail('秒杀商品版本无效,请重新选择');
+                }
                 $seckillStock = floatval($seckillRow['stock'] ?? 0);
-                $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $saleGoodsId);
+                $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $seckillActivityGoodsId);
                 if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
                     util::fail('秒杀库存不足,请重新选择');
                 }
                 $seckillLimit = floatval($seckillRow['limit'] ?? 0);
                 if ($seckillLimit > 0) {
-                    $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $customId);
+                    $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $seckillActivityGoodsId, $customId);
                     if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
                         util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
                     }
@@ -673,7 +678,7 @@ class OrderController extends BaseController
                     util::fail('秒杀价格异常,不能下单');
                 }
                 // 通过校验后立即预占名额,失败/异常时随事务一起回滚(见 register_shutdown_function)
-                HomePageModuleClass::reserveSeckillPurchase($mainId, $saleGoodsId, $customId, $goodsNum);
+                HomePageModuleClass::reserveSeckillPurchase($mainId, $seckillActivityGoodsId, $customId, $goodsNum);
             } else {
                 $params = [];
                 $goodsData = $goodsInfo->attributes;
@@ -923,8 +928,9 @@ class OrderController extends BaseController
             $post['mainId'] = $mainId;
             $post['needPrint'] = dict::getDict('needPrint', 'need');
             $hasPay = 0;
-            $product = [['productId' => $saleGoodsId, 'unitType' => 0, 'property' => 0, 'unitPrice' => $unitPrice, 'num' => $goodsNum]];
+            $product = [['productId' => $saleGoodsId, 'unitType' => 0, 'property' => 0, 'unitPrice' => $unitPrice, 'num' => $goodsNum, 'seckillGoodsId' => $seckillActivityGoodsId]];
             $post['product'] = $product;
+            $post['hasSeckill'] = $seckillRow ? 1 : 0;
             $return = \bizHd\order\services\OrderService::createHdOrder($post, $custom, $hasPay);
 
             //红包保存订单id
@@ -1003,8 +1009,8 @@ class OrderController extends BaseController
             util::fail('请选择商品');
         }
 
-        $itemRows = [];
-        $goodsRows = [];
+        $itemRows = []; // 所有花材
+        $goodsRows = []; // 所有花束
         foreach ($productList as $idx => $row) {
             $property = intval($row['property'] ?? -1);
             if ($property === 1) {
@@ -1080,7 +1086,7 @@ class OrderController extends BaseController
         });
 
         try {
-            $orderValidTime = !getenv('ORDER_VALID_TIME') ? 600 : getenv('ORDER_VALID_TIME'); // TODO 找少华确认
+            $orderValidTime = !getenv('ORDER_VALID_TIME') ? 600 : getenv('ORDER_VALID_TIME'); // TODO 找少华确认 -- 测试环境是 600
             $post['deadline'] = time() + $orderValidTime;
             $post['reachDate'] = !empty($post['reachDate']) ? $post['reachDate'] : date("Y-m-d");
             $post['needPrint'] = dict::getDict('needPrint', 'need');
@@ -1097,6 +1103,7 @@ class OrderController extends BaseController
             $itemBigNum = 0;
             $totalReachDiscount = 0;
             $resolvedProduct = [];
+            $hasSeckillGoods = false;
 
             $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap;
             $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap;
@@ -1104,7 +1111,7 @@ class OrderController extends BaseController
             $post['orderType'] = 0;
             foreach ($productList as $eleKey => $element) {
                 $property = $element['property']; // 1: 花材, 0: 花束
-                if ($property === 1) {
+                if ($property === 1) { // 花材
                     $level = 0;
                     $productId = $element['productId'];
                     $product = $productInfoList[$productId] ?? null;
@@ -1141,10 +1148,10 @@ class OrderController extends BaseController
                         'property' => 1,
                         'unitPrice' => $price,
                     ];
-                } else {
-                    $goodsId = $element['goodsId'];
-                    $specGoodsId = $element['specGoodsId'];
-                    $saleGoodsId = $element['productId'];
+                } else if ($property === 0) { // 花束
+                    $goodsId = $element['goodsId']; // 主商品ID
+                    $specGoodsId = $element['specGoodsId']; // 规格ID
+                    $saleGoodsId = $element['productId'];   // 记录用于销售的商品ID,通常为具体的规格或产品ID
                     $goodsNum = isset($element['num']) && $element['num'] > 0 ? $element['num'] : 1;
                     if ($goodsId <= 0 && $saleGoodsId > 0) {
                         $probe = GoodsClass::getById($saleGoodsId, true);
@@ -1190,21 +1197,26 @@ class OrderController extends BaseController
                         util::fail('花束库存不足');
                     }
 
-                    // 秒杀商品:不信任前端传的价格,独立回查 Redis 秒杀配置核实活动状态/库存/限购后再核价
+                    // 秒杀商品:不信任前端传的价格,独立回查秒杀配置核实活动状态/库存/限购后再核价
                     $seckillRow = null;
+                    $seckillActivityGoodsId = 0;
                     if (strval($element['activityType'] ?? '') === 'seckill') {
                         $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
                         if (empty($seckillRow)) {
                             util::fail('秒杀活动已结束或商品已下架,请重新选择');
                         }
+                        $seckillActivityGoodsId = intval($seckillRow['id'] ?? ($seckillRow['activityGoodsId'] ?? 0));
+                        if ($seckillActivityGoodsId <= 0) {
+                            util::fail('秒杀商品版本无效,请重新选择');
+                        }
                         $seckillStock = floatval($seckillRow['stock']);
-                        $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $saleGoodsId);
+                        $soldCount = HomePageModuleClass::getSeckillSoldCount($mainId, $seckillActivityGoodsId);
                         if (bcadd($soldCount, $goodsNum, 2) > $seckillStock) {
                             util::fail('秒杀库存不足,请重新选择');
                         }
                         $seckillLimit = floatval($seckillRow['limit'] ?? 0);
                         if ($seckillLimit > 0) {
-                            $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $saleGoodsId, $hdCustomId); // TODO 同一个商品,如果缓存没有合理清除,就会一直限购
+                            $boughtCount = HomePageModuleClass::getSeckillCustomBoughtCount($mainId, $seckillActivityGoodsId, $hdCustomId); // TODO 同一个商品,如果缓存没有合理清除,就会一直限购
                             if (bcadd($boughtCount, $goodsNum, 2) > $seckillLimit) {
                                 util::fail("秒杀商品每人限购{$seckillLimit}件,已超出可购买数量");
                             }
@@ -1230,7 +1242,8 @@ class OrderController extends BaseController
                     }
                     if ($seckillRow) {
                         // 通过校验后立即预占名额,失败/异常时随事务一起回滚(见 register_shutdown_function)
-                        HomePageModuleClass::reserveSeckillPurchase($mainId, $saleGoodsId, $hdCustomId, $goodsNum);
+                        HomePageModuleClass::reserveSeckillPurchase($mainId, $seckillActivityGoodsId, $hdCustomId, $goodsNum);
+                        $hasSeckillGoods = true;
                     }
                     $currentTotal = bcmul($unitPrice, $goodsNum, 2);
                     $modifyPrice = bcadd($modifyPrice, $currentTotal, 2);
@@ -1247,7 +1260,10 @@ class OrderController extends BaseController
                         'property' => 0,
                         'unitPrice' => $unitPrice,
                         'num' => $goodsNum,
+                        'seckillGoodsId' => $seckillActivityGoodsId,
                     ];
+                } else {
+                    util::fail('商品类型错误');
                 }
             }
             // 根据 $resolvedProduct 设置订单类型:1纯花束 2纯花材 3混合
@@ -1415,6 +1431,7 @@ class OrderController extends BaseController
 
             $post['bigNum'] = $totalNum;
             $post['modifyPrice'] = $modifyPrice;
+            $post['hasSeckill'] = $hasSeckillGoods ? 1 : 0;
             $return = \bizHd\order\services\OrderService::createHdOrder($post, $custom, $hasPay);
 
             $orderSn = $return->orderSn ?? '';

+ 49 - 29
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -22,9 +22,9 @@ class HomePageModuleClass
     const REDIS_NEW = 'home_page_config:%d:new';
     const REDIS_PULL_GOODS = 'home_page_config:%d:pull_goods';
 
-    /** 秒杀已售数量:hash key 按门店,field 为 goodsId */
+    /** 秒杀已售数量:hash key 按门店,field 为 activityGoodsId(xhSeckillGoods.id) */
     const SECKILL_SOLD_PREFIX = 'seckill_sold:';
-    /** 秒杀单客户已购数量:hash key 按门店+goodsId,field 为 customId */
+    /** 秒杀单客户已购数量:hash key 按门店+activityGoodsId,field 为 customId */
     const SECKILL_BUY_PREFIX = 'seckill_buy:';
     /** 当前请求内的秒杀名额预占快照,下单失败时据此回滚 */
     const SECKILL_ROLLBACK_SNAPSHOT_KEY = 'seckillReserveRollbackSnapshot';
@@ -146,7 +146,19 @@ class HomePageModuleClass
     }
 
     // -------------------- 秒杀 --------------------
+
+    /**
+     * 读取秒杀专区:优先 MySQL 批次化持久化,无数据时回退 Redis
+     */
     public static function getSeckill($mainId, $refreshStock = true)
+    {
+        return \bizHd\seckill\classes\SeckillActivityClass::getSeckill($mainId, $refreshStock);
+    }
+
+    /**
+     * 仅从 Redis 读取历史单例秒杀配置(迁移过渡 / 无 MySQL 批次时回退)
+     */
+    public static function getSeckillFromRedis($mainId, $refreshStock = true)
     {
         $mainId = intval($mainId);
         if ($mainId <= 0) {
@@ -165,7 +177,7 @@ class HomePageModuleClass
     }
 
     /**
-     * 保存秒杀专区;请求形态校验由 SaveSeckillForm 完成,此处做商品归属与库存业务校验
+     * 保存秒杀专区:落 MySQL 活动批次/商品版本,并同步 Redis
      *
      * @param int $mainId
      * @param array $base 已校验的活动基础字段
@@ -175,17 +187,7 @@ class HomePageModuleClass
      */
     public static function saveSeckill($mainId, $base, $goods = [], $enabled = 0)
     {
-        $mainId = intval($mainId);
-        if ($mainId <= 0) {
-            util::fail('无效门店');
-        }
-        if (!is_array($base)) {
-            util::fail('参数错误');
-        }
-        $goods = self::validateSeckillGoods($mainId, is_array($goods) ? $goods : []);
-        self::setJson($mainId, self::REDIS_SECKILL, array_merge($base, ['goods' => $goods]));
-        HomePageConfigClass::updateModuleEnabled($mainId, 'seckill', $enabled);
-        return true;
+        return \bizHd\seckill\classes\SeckillActivityClass::saveSeckill($mainId, $base, $goods, $enabled);
     }
 
     /**
@@ -247,6 +249,7 @@ class HomePageModuleClass
                 continue;
             }
             $row = [
+                'id' => intval($item['id'] ?? 0),
                 'goodsId' => intval($item['goodsId']),
                 'price' => floatval($item['price'] ?? 0),
                 'stock' => intval($item['stock'] ?? 0),
@@ -281,6 +284,7 @@ class HomePageModuleClass
             $persist = [];
             foreach ($list as $g) {
                 $persist[] = [
+                    'id' => intval($g['id'] ?? 0),
                     'goodsId' => $g['goodsId'],
                     'price' => $g['price'],
                     'stock' => $g['stock'],
@@ -325,20 +329,30 @@ class HomePageModuleClass
 
     /**
      * 秒杀商品累计已售数量(跨所有客户),用于校验是否超出活动库存
+     * 按 activityGoodsId(xhSeckillGoods.id)记数,改价新建版本后计数从 0 起
+     *
+     * @param int $mainId
+     * @param int $activityGoodsId xhSeckillGoods.id
+     * @return float
      */
-    public static function getSeckillSoldCount($mainId, $goodsId)
+    public static function getSeckillSoldCount($mainId, $activityGoodsId)
     {
         $key = self::SECKILL_SOLD_PREFIX . intval($mainId);
-        $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($goodsId)]);
+        $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($activityGoodsId)]);
         return floatval($val ?? 0);
     }
 
     /**
-     * 单个客户在该秒杀商品上已购买的数量,用于校验单人限购
+     * 单个客户在该秒杀商品版本上已购买的数量,用于校验单人限购
+     *
+     * @param int $mainId
+     * @param int $activityGoodsId xhSeckillGoods.id
+     * @param int $customId
+     * @return float
      */
-    public static function getSeckillCustomBoughtCount($mainId, $goodsId, $customId)
+    public static function getSeckillCustomBoughtCount($mainId, $activityGoodsId, $customId)
     {
-        $key = self::SECKILL_BUY_PREFIX . intval($mainId) . ':' . intval($goodsId);
+        $key = self::SECKILL_BUY_PREFIX . intval($mainId) . ':' . intval($activityGoodsId);
         $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($customId)]);
         return floatval($val ?? 0);
     }
@@ -346,22 +360,27 @@ class HomePageModuleClass
     /**
      * 预占秒杀名额:库存/限购校验通过后调用,累加"已售"与"该客户已购"计数,
      * 并记录本次请求的回滚快照——下单失败时通过 rollbackSeckillReservationSnapshot 撤销
+     *
+     * @param int $mainId
+     * @param int $activityGoodsId xhSeckillGoods.id
+     * @param int $customId
+     * @param float|int $num
      */
-    public static function reserveSeckillPurchase($mainId, $goodsId, $customId, $num)
+    public static function reserveSeckillPurchase($mainId, $activityGoodsId, $customId, $num)
     {
         $mainId = intval($mainId);
-        $goodsId = intval($goodsId);
+        $activityGoodsId = intval($activityGoodsId);
         $customId = intval($customId);
         $num = floatval($num);
-        if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
+        if ($mainId <= 0 || $activityGoodsId <= 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]);
+        $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $activityGoodsId;
+        Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $activityGoodsId, $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];
+        $snapshot[] = ['mainId' => $mainId, 'activityGoodsId' => $activityGoodsId, 'customId' => $customId, 'num' => $num];
         Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] = $snapshot;
     }
 
@@ -376,15 +395,16 @@ class HomePageModuleClass
         }
         foreach ($snapshotList as $snapshot) {
             $mainId = intval($snapshot['mainId'] ?? 0);
-            $goodsId = intval($snapshot['goodsId'] ?? 0);
+            // 兼容旧快照字段 goodsId(迁移前请求内可能仍写的是旧 key)
+            $activityGoodsId = intval($snapshot['activityGoodsId'] ?? ($snapshot['goodsId'] ?? 0));
             $customId = intval($snapshot['customId'] ?? 0);
             $num = floatval($snapshot['num'] ?? 0);
-            if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
+            if ($mainId <= 0 || $activityGoodsId <= 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]);
+            $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $activityGoodsId;
+            Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $activityGoodsId, -$num]);
             Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, -$num]);
         }
         self::clearSeckillReservationSnapshot();

+ 1 - 0
biz-hd/order/services/OrderService.php

@@ -147,6 +147,7 @@ class OrderService extends BaseService
                     'sn' => $sn,
                     'orderSn' => $orderSn,
                     'goodsId' => $currentId,
+                    'seckillGoodsId' => intval($val['seckillGoodsId'] ?? 0),
                     'flower' => $flower,
                     'customId' => $customId,
                     'hdId' => $hdId,

+ 268 - 0
biz-hd/seckill/classes/SeckillActivityClass.php

@@ -0,0 +1,268 @@
+<?php
+
+namespace bizHd\seckill\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\goods\classes\GoodsClass;
+use bizHd\homePageConfig\classes\HomePageConfigClass;
+use bizHd\homePageConfig\classes\HomePageModuleClass;
+use common\components\util;
+
+/**
+ * 秒杀活动批次与商品版本业务类
+ * 供 hd 后台保存/读取、mall 首页展示与下单核价使用
+ * 替代原先仅写 Redis 的单例配置,按起止时间批次化持久化到 MySQL
+ */
+class SeckillActivityClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\seckill\models\SeckillActivity';
+
+    /**
+     * 读取当前门店「生效中」的秒杀配置,返回结构与历史 Redis getSeckill 兼容
+     *
+     * @param int $mainId
+     * @param bool $refreshStock 是否回查真实库存
+     * @return array
+     */
+    public static function getSeckill($mainId, $refreshStock = true)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'seckill');
+        $activity = self::getCurrentActivity($mainId);
+        if (empty($activity)) {
+            // 无 MySQL 批次时回退 Redis,兼容尚未迁移的旧数据
+            return HomePageModuleClass::getSeckillFromRedis($mainId, $refreshStock);
+        }
+
+        $goodsRows = SeckillGoodsClass::getActiveGoodsByActivityId(intval($activity['id']), $mainId, $refreshStock);
+        $base = [
+            'activityId' => intval($activity['id']),
+            'title' => strval($activity['title'] ?? ''),
+            'subtitle' => strval($activity['subtitle'] ?? ''),
+            'showCountdown' => !empty($activity['showCountdown']) ? 1 : 0,
+            'expand' => !empty($activity['expand']) ? 1 : 0,
+            'startTime' => intval($activity['startTime'] ?? 0),
+            'endTime' => intval($activity['endTime'] ?? 0),
+            'desc' => strval($activity['desc'] ?? ''),
+        ];
+        $layout = HomePageModuleClass::resolveActivityLayout(count($goodsRows), $base['expand']);
+        $base['enabled'] = $enabled;
+        $base['goods'] = $goodsRows;
+        $base['layoutCols'] = $layout['layoutCols'];
+        $base['displayCount'] = $layout['displayCount'];
+        $base['status'] = HomePageModuleClass::calcActivityStatus($base['startTime'], $base['endTime'], $enabled);
+        return $base;
+    }
+
+    /**
+     * 保存秒杀配置:按时间重叠复用/新建活动批次,商品按关键字段变化新建版本
+     *
+     * @param int $mainId
+     * @param array $base
+     * @param array $goods
+     * @param int $enabled
+     * @return bool
+     */
+    public static function saveSeckill($mainId, $base, $goods = [], $enabled = 0)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        if (!is_array($base)) {
+            util::fail('参数错误');
+        }
+
+        $validatedGoods = self::validateSeckillGoods($mainId, is_array($goods) ? $goods : []);
+        $activity = self::resolveOrCreateActivity($mainId, $base);
+        $activityId = intval($activity['id'] ?? ($activity->id ?? 0));
+        if ($activityId <= 0) {
+            util::fail('活动保存失败');
+        }
+
+        // 更新活动基础信息(同一批次内修改标题/说明等)
+        self::updateById($activityId, [
+            'title' => $base['title'],
+            'subtitle' => $base['subtitle'],
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'startTime' => $base['startTime'],
+            'endTime' => $base['endTime'],
+            'desc' => strval($base['desc'] ?? ''),
+            'status' => 1,
+            'delStatus' => 0,
+        ]);
+
+        SeckillGoodsClass::syncActivityGoods($mainId, $activityId, $validatedGoods);
+        HomePageConfigClass::updateModuleEnabled($mainId, 'seckill', $enabled);
+
+        // 同步写 Redis,保证旧读路径与预览链路仍可用
+        $syncGoods = SeckillGoodsClass::getActiveGoodsByActivityId($activityId, $mainId, false);
+        $persistGoods = [];
+        foreach ($syncGoods as $row) {
+            $persistGoods[] = [
+                'id' => intval($row['id']),
+                'goodsId' => intval($row['goodsId']),
+                'price' => floatval($row['price']),
+                'stock' => intval($row['stock']),
+                'limit' => intval($row['limit']),
+                'status' => intval($row['status']),
+                'name' => strval($row['name']),
+                'cover' => strval($row['cover']),
+                'originPrice' => floatval($row['originPrice']),
+                'specName' => strval($row['specName'] ?? ''),
+            ];
+        }
+        HomePageModuleClass::setJson($mainId, HomePageModuleClass::REDIS_SECKILL, array_merge([
+            'title' => strval($base['title'] ?? ''),
+            'subtitle' => strval($base['subtitle'] ?? ''),
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'startTime' => intval($base['startTime'] ?? 0),
+            'endTime' => intval($base['endTime'] ?? 0),
+            'desc' => strval($base['desc'] ?? ''),
+        ], ['goods' => $persistGoods]));
+
+        return true;
+    }
+
+    /**
+     * 取当前应展示的活动:优先进行中,其次未开始最近一场,再取最近结束的一场
+     *
+     * @param int $mainId
+     * @return array|null
+     */
+    public static function getCurrentActivity($mainId)
+    {
+        $mainId = intval($mainId);
+        $now = time();
+        $list = self::getAllByCondition([
+            'mainId' => $mainId,
+            'delStatus' => 0,
+            'status' => 1,
+        ], 'startTime DESC', '*', null);
+        if (empty($list)) {
+            return null;
+        }
+
+        $running = null;
+        $upcoming = null;
+        $latestEnded = null;
+        foreach ($list as $row) {
+            $start = intval($row['startTime'] ?? 0);
+            $end = intval($row['endTime'] ?? 0);
+            if ($start <= $now && $now <= $end) {
+                $running = $row;
+                break;
+            }
+            if ($start > $now) {
+                if ($upcoming === null || $start < intval($upcoming['startTime'])) {
+                    $upcoming = $row;
+                }
+            }
+            if ($end < $now) {
+                if ($latestEnded === null || $end > intval($latestEnded['endTime'])) {
+                    $latestEnded = $row;
+                }
+            }
+        }
+        return $running ?: ($upcoming ?: $latestEnded);
+    }
+
+    /**
+     * 按起止时间重叠复用历史活动,否则新建批次
+     *
+     * @param int $mainId
+     * @param array $base
+     * @return array|\yii\db\ActiveRecord
+     */
+    public static function resolveOrCreateActivity($mainId, $base)
+    {
+        $mainId = intval($mainId);
+        $startTime = intval($base['startTime'] ?? 0);
+        $endTime = intval($base['endTime'] ?? 0);
+        if ($startTime <= 0 || $endTime <= $startTime) {
+            util::fail('活动时间无效');
+        }
+
+        // 时间区间有重叠即复用:startA < endB AND endA > startB
+        $existed = self::getAllByCondition([
+            'mainId' => $mainId,
+            'delStatus' => 0,
+            'status' => 1,
+            'startTime<' => $endTime,
+            'endTime>' => $startTime,
+        ], 'id DESC', '*', null);
+        if (!empty($existed[0])) {
+            $row = $existed[0];
+            // 重叠时沿用历史起止时间,不因小改动拆分批次
+            return $row;
+        }
+
+        return self::add([
+            'mainId' => $mainId,
+            'title' => strval($base['title'] ?? ''),
+            'subtitle' => strval($base['subtitle'] ?? ''),
+            'showCountdown' => !empty($base['showCountdown']) ? 1 : 0,
+            'expand' => !empty($base['expand']) ? 1 : 0,
+            'startTime' => $startTime,
+            'endTime' => $endTime,
+            'desc' => strval($base['desc'] ?? ''),
+            'status' => 1,
+            'delStatus' => 0,
+        ]);
+    }
+
+    /**
+     * 业务校验秒杀商品(归属/库存),与历史 validateSeckillGoods 对齐并保留 id/specName
+     *
+     * @param int $mainId
+     * @param array $rawList
+     * @return array
+     */
+    public static function validateSeckillGoods($mainId, $rawList)
+    {
+        $list = [];
+        foreach ($rawList as $index => $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $no = $index + 1;
+            $goodsId = intval($item['goodsId'] ?? 0);
+            $price = floatval($item['price'] ?? 0);
+            $stock = intval($item['stock'] ?? 0);
+            $limit = intval($item['limit'] ?? 0);
+            $goods = GoodsClass::getById($goodsId, true);
+            if (empty($goods) || intval($goods->mainId ?? 0) !== intval($mainId)) {
+                util::fail("第{$no}个秒杀商品无效");
+            }
+            $realStock = intval($goods->stock ?? 0);
+            if ($stock > $realStock) {
+                util::fail("第{$no}个秒杀库存不能超过商品实际库存({$realStock})");
+            }
+            $status = !empty($item['status']) ? 1 : 0;
+            if ($realStock < $stock) {
+                $status = 0;
+            }
+            $name = $goods->masterId > 0 ? $goods->name . '(' . $goods->specName . ')' : $goods->name;
+            $list[] = [
+                'id' => intval($item['id'] ?? 0),
+                'goodsId' => $goodsId,
+                'price' => round($price, 2),
+                'stock' => $stock,
+                'limit' => $limit,
+                'status' => $status,
+                'name' => $name,
+                'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
+                'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
+                'specName' => strval($goods->specName ?? ($item['specName'] ?? '')),
+                'realStock' => $realStock,
+            ];
+        }
+        return $list;
+    }
+}

+ 192 - 0
biz-hd/seckill/classes/SeckillGoodsClass.php

@@ -0,0 +1,192 @@
+<?php
+
+namespace bizHd\seckill\classes;
+
+use bizHd\base\classes\BaseClass;
+use bizHd\goods\classes\GoodsClass;
+
+/**
+ * 秒杀活动商品版本业务类
+ * 负责同一活动下商品版本的同步(改价新建版本、旧版隐藏)与列表读取
+ */
+class SeckillGoodsClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\seckill\models\SeckillGoods';
+
+    /**
+     * 同步活动商品:有 id 且关键字段未变则更新;关键字段变化或新商品则新建版本并隐藏旧版
+     *
+     * @param int $mainId
+     * @param int $activityId
+     * @param array $goodsList validateSeckillGoods 的结果
+     * @return bool
+     */
+    public static function syncActivityGoods($mainId, $activityId, $goodsList)
+    {
+        $mainId = intval($mainId);
+        $activityId = intval($activityId);
+        $keepIds = [];
+
+        foreach ($goodsList as $item) {
+            $goodsId = intval($item['goodsId'] ?? 0);
+            if ($goodsId <= 0) {
+                continue;
+            }
+            $payload = [
+                'activityId' => $activityId,
+                'mainId' => $mainId,
+                'goodsId' => $goodsId,
+                'specName' => strval($item['specName'] ?? ''),
+                'price' => floatval($item['price'] ?? 0),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'stock' => intval($item['stock'] ?? 0),
+                'limit' => intval($item['limit'] ?? 0),
+                'status' => !empty($item['status']) ? 1 : 0,
+                'name' => strval($item['name'] ?? ''),
+                'cover' => strval($item['cover'] ?? ''),
+                'realStock' => intval($item['realStock'] ?? 0),
+                'delStatus' => 0,
+            ];
+
+            $existId = intval($item['id'] ?? 0);
+            $exist = null;
+            if ($existId > 0) {
+                $exist = self::getById($existId, true);
+                if (
+                    empty($exist)
+                    || intval($exist->mainId) !== $mainId
+                    || intval($exist->activityId) !== $activityId
+                    || intval($exist->delStatus) !== 0
+                ) {
+                    $exist = null;
+                    $existId = 0;
+                }
+            }
+            if ($existId <= 0) {
+                // 无显式 id 时按 activityId+goodsId 找当前展示版
+                $exist = self::getByCondition([
+                    'activityId' => $activityId,
+                    'mainId' => $mainId,
+                    'goodsId' => $goodsId,
+                    'status' => 1,
+                    'delStatus' => 0,
+                ], true);
+                $existId = $exist ? intval($exist->id) : 0;
+            }
+
+            if ($exist && self::isSameVersion($exist, $payload)) {
+                self::updateById($existId, $payload);
+                $keepIds[] = $existId;
+                continue;
+            }
+
+            // 关键字段变化:隐藏旧版,新建版本
+            if ($existId > 0) {
+                self::updateById($existId, ['status' => 0]);
+            }
+            // 同活动同商品其它展示版一并隐藏,避免多条 status=1
+            $others = self::getAllByCondition([
+                'activityId' => $activityId,
+                'mainId' => $mainId,
+                'goodsId' => $goodsId,
+                'status' => 1,
+                'delStatus' => 0,
+            ], null, 'id', null);
+            if (!empty($others)) {
+                foreach ($others as $other) {
+                    $oid = intval($other['id'] ?? 0);
+                    if ($oid > 0) {
+                        self::updateById($oid, ['status' => 0]);
+                    }
+                }
+            }
+            $created = self::add($payload);
+            $keepIds[] = intval($created->id ?? ($created['id'] ?? 0));
+        }
+
+        // 本次未提交的展示商品隐藏(不物理删除,保留历史订单可追溯)
+        $activeList = self::getAllByCondition([
+            'activityId' => $activityId,
+            'mainId' => $mainId,
+            'status' => 1,
+            'delStatus' => 0,
+        ], null, 'id', null);
+        if (!empty($activeList)) {
+            foreach ($activeList as $row) {
+                $id = intval($row['id'] ?? 0);
+                if ($id > 0 && !in_array($id, $keepIds, true)) {
+                    self::updateById($id, ['status' => 0]);
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 关键业务字段是否一致:价格/库存/限购
+     *
+     * @param object $exist
+     * @param array $payload
+     * @return bool
+     */
+    public static function isSameVersion($exist, $payload)
+    {
+        return floatval($exist->price) == floatval($payload['price'])
+            && intval($exist->stock) === intval($payload['stock'])
+            && intval($exist->limit) === intval($payload['limit']);
+    }
+
+    /**
+     * 读取活动下当前展示中的商品列表(后台/首页结构兼容)
+     *
+     * @param int $activityId
+     * @param int $mainId
+     * @param bool $refreshStock
+     * @return array
+     */
+    public static function getActiveGoodsByActivityId($activityId, $mainId, $refreshStock = true)
+    {
+        $activityId = intval($activityId);
+        $mainId = intval($mainId);
+        $list = self::getAllByCondition([
+            'activityId' => $activityId,
+            'mainId' => $mainId,
+            'status' => 1,
+            'delStatus' => 0,
+        ], 'id ASC', '*', null);
+        $result = [];
+        foreach ($list as $item) {
+            $row = [
+                'id' => intval($item['id']),
+                'activityGoodsId' => intval($item['id']),
+                'activityId' => $activityId,
+                'goodsId' => intval($item['goodsId']),
+                'price' => floatval($item['price']),
+                'stock' => intval($item['stock']),
+                'limit' => intval($item['limit']),
+                'status' => !empty($item['status']) ? 1 : 0,
+                'name' => strval($item['name'] ?? ''),
+                'cover' => strval($item['cover'] ?? ''),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'specName' => strval($item['specName'] ?? ''),
+                'realStock' => intval($item['realStock'] ?? $item['stock'] ?? 0),
+            ];
+            if ($refreshStock) {
+                $goods = GoodsClass::getById($row['goodsId'], true);
+                if (!empty($goods) && intval($goods->mainId ?? 0) === $mainId) {
+                    $realStock = intval($goods->stock ?? 0);
+                    $row['realStock'] = $realStock;
+                    $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
+                    $row['originPrice'] = floatval($goods->price ?? $row['originPrice']);
+                    if ($realStock < $row['stock'] && $row['status'] == 1) {
+                        $row['status'] = 0;
+                        self::updateById($row['id'], ['status' => 0]);
+                        continue;
+                    }
+                }
+            }
+            $result[] = $row;
+        }
+        return $result;
+    }
+}

+ 17 - 0
biz-hd/seckill/models/SeckillActivity.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\seckill\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 秒杀活动批次表模型
+ * 对应表 xhSeckillActivity,按 mainId+起止时间区分活动批次
+ */
+class SeckillActivity extends Base
+{
+    public static function tableName()
+    {
+        return 'xhSeckillActivity';
+    }
+}

+ 17 - 0
biz-hd/seckill/models/SeckillGoods.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\seckill\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 秒杀活动商品版本表模型
+ * 对应表 xhSeckillGoods;同活动同商品改价会新建版本,旧版 status=0
+ */
+class SeckillGoods extends Base
+{
+    public static function tableName()
+    {
+        return 'xhSeckillGoods';
+    }
+}

+ 1 - 1
biz-mall/order/services/OrderService.php

@@ -226,7 +226,7 @@ class OrderService extends BaseService
     //获取订单信息 TODO 重新实现,要限制查询字段与返回字段,性能第一???? -- 商品项(商品规格)、商品数量
     public static function getOrderList($where)
     {
-        $fields = 'id, shopId, hdId, orderSn, orderType, actPrice, goodsNum, reachDate, reachPeriod, status, addTime';
+        $fields = 'id, shopId, hdId, orderSn, groupBuyId, orderType, actPrice, goodsNum, reachDate, reachPeriod, status, addTime';
         $data = self::getList($fields, $where, 'addTime DESC');
         if (empty($data['list'])) {
             return $data;

+ 54 - 0
sql/20260730_seckill.sql

@@ -0,0 +1,54 @@
+-- 秒杀核心表:活动批次 / 活动商品版本,以及订单秒杀标识字段
+-- 执行前请确认线上库无同名表;xhOrder.hasSeckill / xhOrderGoods.seckillGoodsId 若已存在请跳过对应 ALTER
+
+CREATE TABLE IF NOT EXISTS `xhSeckillActivity` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `title` varchar(32) NOT NULL DEFAULT '' COMMENT '活动标题',
+  `subtitle` varchar(64) NOT NULL DEFAULT '' COMMENT '活动副标题',
+  `showCountdown` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否显示倒计时',
+  `expand` tinyint(4) NOT NULL DEFAULT 0 COMMENT '商品展开',
+  `startTime` int(11) NOT NULL DEFAULT 0 COMMENT '开始时间unix',
+  `endTime` int(11) NOT NULL DEFAULT 0 COMMENT '结束时间unix',
+  `desc` varchar(500) NOT NULL DEFAULT '' COMMENT '活动说明',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1有效 0无效',
+  `delStatus` tinyint(4) NOT NULL DEFAULT 0 COMMENT '删除状态',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `mainId` (`mainId`),
+  KEY `main_time` (`mainId`,`startTime`,`endTime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动批次';
+
+CREATE TABLE IF NOT EXISTS `xhSeckillGoods` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `activityId` int(11) NOT NULL DEFAULT 0 COMMENT '活动批次id',
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `goodsId` int(11) NOT NULL DEFAULT 0 COMMENT '商品id(可含规格子商品)',
+  `specName` varchar(64) NOT NULL DEFAULT '' COMMENT '规格名',
+  `price` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '秒杀价',
+  `originPrice` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '原价快照',
+  `stock` int(11) NOT NULL DEFAULT 0 COMMENT '秒杀库存',
+  `limit` int(11) NOT NULL DEFAULT 0 COMMENT '单人限购',
+  `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1展示 0隐藏(历史版本)',
+  `name` varchar(128) NOT NULL DEFAULT '' COMMENT '商品名快照',
+  `cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面快照',
+  `realStock` int(11) NOT NULL DEFAULT 0 COMMENT '保存时真实库存快照',
+  `delStatus` tinyint(4) NOT NULL DEFAULT 0 COMMENT '删除状态',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `activityId` (`activityId`),
+  KEY `main_goods` (`mainId`,`goodsId`),
+  KEY `activity_goods_status` (`activityId`,`goodsId`,`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动商品版本';
+
+-- 订单级:是否包含秒杀商品(合并结算单可能仅部分行是秒杀)
+ALTER TABLE `xhOrder`
+  ADD COLUMN `hasSeckill` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否包含秒杀商品:0不包含,1包含' AFTER `groupBuyId`,
+  ADD KEY `hasSeckill` (`hasSeckill`);
+
+-- 订单商品行级:关联秒杀活动商品版本,便于追溯价格/库存版本
+ALTER TABLE `xhOrderGoods`
+  ADD COLUMN `seckillGoodsId` int(11) NOT NULL DEFAULT 0 COMMENT '秒杀活动商品版本id(xhSeckillGoods.id),非秒杀为0' AFTER `goodsId`,
+  ADD KEY `seckillGoodsId` (`seckillGoodsId`);