Эх сурвалжийг харах

feat(home-page): 首页模块配置按门店持久化

- 将首页模块配置迁移至 MySQL,并按 shopId 隔离门店数据
- 保留 Redis 镜像及历史 mainId 配置的自动回退迁移
- 秒杀、团购展示与下单核价同步使用门店级模块开关

API: 首页配置及活动核验接口新增内部 shopId 维度,外部请求参数与响应结构保持兼容
Database: 新增 xhHomePageModuleConfig 表,部署时需执行 sql/20260811_home_page_module_config.sql
shizhongqi 4 өдөр өмнө
parent
commit
da531f0ee3

+ 17 - 17
app-hd/controllers/HomePageConfigController.php

@@ -32,7 +32,7 @@ class HomePageConfigController extends BaseController
      */
     public function actionGetHome()
     {
-        util::success(HomePageDisplayClass::buildHome($this->mainId));
+        util::success(HomePageDisplayClass::buildHome($this->mainId, $this->shopId));
     }
 
     /**
@@ -40,7 +40,7 @@ class HomePageConfigController extends BaseController
      */
     public function actionGetModules()
     {
-        $list = HomePageConfigClass::getModules($this->mainId);
+        $list = HomePageConfigClass::getModules($this->mainId, $this->shopId);
         util::success(['list' => $list]);
     }
 
@@ -51,7 +51,7 @@ class HomePageConfigController extends BaseController
     {
         $form = new SaveModulesForm();
         $form->loadAndValidate();
-        HomePageConfigClass::saveModules($this->mainId, $form->getItems());
+        HomePageConfigClass::saveModules($this->mainId, $this->shopId, $form->getItems());
         util::complete('保存成功');
     }
 
@@ -60,7 +60,7 @@ class HomePageConfigController extends BaseController
      */
     public function actionGetTopNav()
     {
-        $data = HomePageConfigClass::getTopNav($this->mainId);
+        $data = HomePageConfigClass::getTopNav($this->mainId, $this->shopId);
         $data['merchantName'] = $this->shop->merchantName ?? '';
         util::success($data);
     }
@@ -74,7 +74,7 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageConfigClass::saveTopNav($this->mainId, $form->getPayload(), $form->getEnabled());
+        HomePageConfigClass::saveTopNav($this->mainId, $this->shopId, $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 
@@ -83,7 +83,7 @@ class HomePageConfigController extends BaseController
      */
     public function actionGetBanner()
     {
-        $data = HomePageConfigClass::getBanner($this->mainId);
+        $data = HomePageConfigClass::getBanner($this->mainId, $this->shopId);
         util::success($data);
     }
 
@@ -96,7 +96,7 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageConfigClass::saveBanner($this->mainId, $form->getPayload(), $form->getEnabled());
+        HomePageConfigClass::saveBanner($this->mainId, $this->shopId, $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 
@@ -158,7 +158,7 @@ class HomePageConfigController extends BaseController
     /** 金刚区导航 */
     public function actionGetNavGrid()
     {
-        util::success(HomePageModuleClass::getNavGrid($this->mainId));
+        util::success(HomePageModuleClass::getNavGrid($this->mainId, $this->shopId));
     }
 
     public function actionSaveNavGrid()
@@ -167,7 +167,7 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageModuleClass::saveNavGrid($this->mainId, $form->getPayload(), $form->getEnabled());
+        HomePageModuleClass::saveNavGrid($this->mainId, $this->shopId, $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 
@@ -182,7 +182,7 @@ class HomePageConfigController extends BaseController
     /** 秒杀专区 */
     public function actionGetSeckill()
     {
-        util::success(HomePageModuleClass::getSeckill($this->mainId, true));
+        util::success(HomePageModuleClass::getSeckill($this->mainId, $this->shopId, true));
     }
 
     public function actionSaveSeckill()
@@ -204,7 +204,7 @@ class HomePageConfigController extends BaseController
     /** 团购专区 */
     public function actionGetGroupBuy()
     {
-        util::success(HomePageModuleClass::getGroupBuy($this->mainId, true));
+        util::success(HomePageModuleClass::getGroupBuy($this->mainId, $this->shopId, true));
     }
 
     public function actionSaveGroupBuy()
@@ -226,7 +226,7 @@ class HomePageConfigController extends BaseController
     /** 热门推荐 */
     public function actionGetHot()
     {
-        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'hot'));
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, $this->shopId, 'hot'));
     }
 
     public function actionSaveHot()
@@ -235,14 +235,14 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageModuleClass::saveGoodsSection($this->mainId, 'hot', $form->getPayload(), $form->getEnabled());
+        HomePageModuleClass::saveGoodsSection($this->mainId, $this->shopId, 'hot', $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 
     /** 今日上新 */
     public function actionGetNew()
     {
-        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'new'));
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, $this->shopId, 'new'));
     }
 
     public function actionSaveNew()
@@ -251,14 +251,14 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageModuleClass::saveGoodsSection($this->mainId, 'new', $form->getPayload(), $form->getEnabled());
+        HomePageModuleClass::saveGoodsSection($this->mainId, $this->shopId, 'new', $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 
     /** 下拉商品 */
     public function actionGetPullGoods()
     {
-        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'pullGoods'));
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, $this->shopId, 'pullGoods'));
     }
 
     public function actionSavePullGoods()
@@ -267,7 +267,7 @@ class HomePageConfigController extends BaseController
         $data = Yii::$app->request->post();
         $form->load($data ?: ['enabled' => 0], '');
         $form->validateForm();
-        HomePageModuleClass::saveGoodsSection($this->mainId, 'pullGoods', $form->getPayload(), $form->getEnabled());
+        HomePageModuleClass::saveGoodsSection($this->mainId, $this->shopId, 'pullGoods', $form->getPayload(), $form->getEnabled());
         util::complete('保存成功');
     }
 

+ 5 - 5
app-mall/controllers/GroupBuyController.php

@@ -46,7 +46,7 @@ class GroupBuyController extends BaseController
         if ($goodsId <= 0) {
             util::fail('请选择商品');
         }
-        $row = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, $goodsId);
+        $row = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($this->shopId), $goodsId);
         if (empty($row)) {
             util::fail('团购活动已结束或商品已下架');
         }
@@ -135,9 +135,9 @@ class GroupBuyController extends BaseController
         }
         $detail = GroupBuyClass::getDetail($mainId, $id);
         // 附带活动商品落地信息,便于分享进入后完整展示
-        $landing = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($detail['activityGoodsId']));
+        $landing = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($this->shopId), intval($detail['activityGoodsId']));
         if (empty($landing)) {
-            $landing = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($detail['goodsId']));
+            $landing = GroupBuyActivityClass::getActiveGoodsRowByGoodsId($mainId, intval($this->shopId), intval($detail['goodsId']));
         }
         $detail['activityGoods'] = $landing;
 
@@ -183,7 +183,7 @@ class GroupBuyController extends BaseController
         $activityGoodsId = intval($form->activityGoodsId);
         $goodsNum = max(1, intval($form->goodsNum));
 
-        $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, $activityGoodsId);
+        $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($this->shopId), $activityGoodsId);
         if (empty($row)) {
             util::fail('团购活动已结束或商品已下架');
         }
@@ -259,7 +259,7 @@ class GroupBuyController extends BaseController
         $transaction = $connection->beginTransaction();
         try {
             $group = GroupBuyClass::assertCanJoin($mainId, $groupBuyId, $customId);
-            $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($group->activityGoodsId));
+            $row = GroupBuyActivityClass::getActiveGoodsRowById($mainId, intval($this->shopId), intval($group->activityGoodsId));
             if (empty($row)) {
                 // 活动结束后仍允许在截止前参团:用团上快照价
                 $row = [

+ 18 - 8
app-mall/controllers/HomePageConfigController.php

@@ -33,42 +33,42 @@ class HomePageConfigController extends BaseController
      */
     public function actionGetHome()
     {
-        util::success(HomePageDisplayClass::buildHome($this->requireMainId()));
+        util::success(HomePageDisplayClass::buildHome($this->requireMainId(), $this->requireShopId()));
     }
 
     public function actionGetBanner()
     {
-        util::success(HomePageDisplayClass::formatBanner(HomePageConfigClass::getBanner($this->requireMainId())));
+        util::success(HomePageDisplayClass::formatBanner(HomePageConfigClass::getBanner($this->requireMainId(), $this->requireShopId())));
     }
 
     public function actionGetNavGrid()
     {
-        util::success(HomePageDisplayClass::formatNavGrid(HomePageModuleClass::getNavGrid($this->requireMainId())));
+        util::success(HomePageDisplayClass::formatNavGrid(HomePageModuleClass::getNavGrid($this->requireMainId(), $this->requireShopId())));
     }
 
     public function actionGetSeckill()
     {
-        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getSeckill($this->requireMainId(), true)));
+        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getSeckill($this->requireMainId(), $this->requireShopId(), true)));
     }
 
     public function actionGetGroupBuy()
     {
-        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getGroupBuy($this->requireMainId(), true)));
+        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getGroupBuy($this->requireMainId(), $this->requireShopId(), true)));
     }
 
     public function actionGetHot()
     {
-        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'hot'));
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), $this->requireShopId(), 'hot'));
     }
 
     public function actionGetNew()
     {
-        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'new'));
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), $this->requireShopId(), 'new'));
     }
 
     public function actionGetPullGoods()
     {
-        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'pullGoods'));
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), $this->requireShopId(), 'pullGoods'));
     }
 
     /**
@@ -81,6 +81,7 @@ class HomePageConfigController extends BaseController
         $form->loadAndValidate();
         util::success(HomePageDisplayClass::getSectionGoodsPage(
             $this->requireMainId(),
+            $this->requireShopId(),
             $form->moduleKey,
             (int)$form->page,
             (int)$form->pageSize
@@ -95,4 +96,13 @@ class HomePageConfigController extends BaseController
         }
         return $mainId;
     }
+
+    private function requireShopId()
+    {
+        $shopId = intval($this->shopId);
+        if ($shopId <= 0) {
+            util::fail('无效门店');
+        }
+        return $shopId;
+    }
 }

+ 2 - 2
app-mall/controllers/OrderController.php

@@ -654,7 +654,7 @@ class OrderController extends BaseController
             $seckillRow = null;
             $seckillActivityGoodsId = 0;
             if ($activityType === 'seckill') {
-                $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
+                $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, intval($this->shopId), $saleGoodsId);
                 if (empty($seckillRow)) {
                     util::fail('秒杀活动已结束或商品已下架,请重新选择');
                 }
@@ -1212,7 +1212,7 @@ class OrderController extends BaseController
                     $seckillRow = null;
                     $seckillActivityGoodsId = 0;
                     if (strval($element['activityType'] ?? '') === 'seckill') {
-                        $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, $saleGoodsId);
+                        $seckillRow = HomePageModuleClass::getSeckillActiveRow($mainId, intval($this->shopId), $saleGoodsId);
                         if (empty($seckillRow)) {
                             util::fail('秒杀活动已结束或商品已下架,请重新选择');
                         }

+ 15 - 9
biz-hd/groupBuy/classes/GroupBuyActivityClass.php

@@ -21,21 +21,23 @@ class GroupBuyActivityClass extends BaseClass
      * 读取当前门店「生效中」的团购配置,返回结构与历史 Redis getGroupBuy 兼容
      *
      * @param int $mainId
+     * @param int $shopId
      * @param bool $refreshStock 是否回查真实库存
      * @return array
      */
-    public static function getGroupBuy($mainId, $refreshStock = true)
+    public static function getGroupBuy($mainId, $shopId, $refreshStock = true)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
         if ($mainId <= 0) {
             util::fail('无效门店');
         }
 
-        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'groupBuy');
         $activity = self::getCurrentActivity($mainId);
         if (empty($activity)) {
             // 无 MySQL 批次时回退 Redis,兼容尚未迁移的旧数据
-            return HomePageModuleClass::getGroupBuyFromRedis($mainId, $refreshStock);
+            return HomePageModuleClass::getGroupBuyFromRedis($mainId, $shopId, $refreshStock);
         }
 
         $goodsRows = GroupBuyGoodsClass::getActiveGoodsByActivityId(intval($activity['id']), $mainId, $refreshStock);
@@ -100,7 +102,7 @@ class GroupBuyActivityClass extends BaseClass
         ]);
 
         GroupBuyGoodsClass::syncActivityGoods($mainId, $shopId, $activityId, $validatedGoods);
-        HomePageConfigClass::updateModuleEnabled($mainId, 'groupBuy', $enabled);
+        HomePageConfigClass::updateModuleEnabled($mainId, $shopId, 'groupBuy', $enabled);
 
         // 同步写 Redis,保证旧读路径与预览链路仍可用
         $syncGoods = GroupBuyGoodsClass::getActiveGoodsByActivityId($activityId, $mainId, false);
@@ -284,12 +286,14 @@ class GroupBuyActivityClass extends BaseClass
      * 按 activityGoodsId 取进行中活动的有效商品行(下单核价用)
      *
      * @param int $mainId
+     * @param int $shopId
      * @param int $activityGoodsId
      * @return array|null
      */
-    public static function getActiveGoodsRowById($mainId, $activityGoodsId)
+    public static function getActiveGoodsRowById($mainId, $shopId, $activityGoodsId)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
         $activityGoodsId = intval($activityGoodsId);
         if ($mainId <= 0 || $activityGoodsId <= 0) {
             return null;
@@ -302,7 +306,7 @@ class GroupBuyActivityClass extends BaseClass
         if (empty($activity) || intval($activity->mainId) !== $mainId || intval($activity->delStatus) !== 0) {
             return null;
         }
-        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'groupBuy');
         $status = HomePageModuleClass::calcActivityStatus(
             intval($activity->startTime),
             intval($activity->endTime),
@@ -341,12 +345,14 @@ class GroupBuyActivityClass extends BaseClass
      * 按商品 id 取当前进行中活动的有效商品行
      *
      * @param int $mainId
+     * @param int $shopId
      * @param int $goodsId
      * @return array|null
      */
-    public static function getActiveGoodsRowByGoodsId($mainId, $goodsId)
+    public static function getActiveGoodsRowByGoodsId($mainId, $shopId, $goodsId)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
         $goodsId = intval($goodsId);
         if ($mainId <= 0 || $goodsId <= 0) {
             return null;
@@ -355,7 +361,7 @@ class GroupBuyActivityClass extends BaseClass
         if (empty($activity)) {
             return null;
         }
-        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'groupBuy');
         $status = HomePageModuleClass::calcActivityStatus(
             intval($activity['startTime']),
             intval($activity['endTime']),
@@ -374,6 +380,6 @@ class GroupBuyActivityClass extends BaseClass
         if (empty($row)) {
             return null;
         }
-        return self::getActiveGoodsRowById($mainId, intval($row->id));
+        return self::getActiveGoodsRowById($mainId, $shopId, intval($row->id));
     }
 }

+ 112 - 74
biz-hd/homePageConfig/classes/HomePageConfigClass.php

@@ -7,7 +7,8 @@ use Yii;
 
 /**
  * 门店首页配置业务类
- * 配置项顺序与开关、顶部导航详情均按 mainId 维度落 Redis,不落库。
+ * 配置项顺序与开关、顶部导航详情按 shopId 维度落 MySQL(xhHomePageModuleConfig,唯一权威数据源),
+ * Redis 仅作兼容镜像;历史仅按 mainId 存在 Redis 的存量数据读取时自动回退迁移,见 HomePageModuleConfigClass。
  */
 class HomePageConfigClass
 {
@@ -74,30 +75,27 @@ class HomePageConfigClass
     }
 
     /**
-     * 模块配置 Redis key
+     * 模块配置 Redis key;$id 现按 shopId 生成新 key,传 mainId 则生成历史迁移用的旧 key
      */
-    public static function getModulesKey($mainId)
+    public static function getModulesKey($id)
     {
-        //return self::MODULES_PREFIX . intval($mainId);
-        return sprintf(self::MODULES_PREFIX, $mainId);
+        return sprintf(self::MODULES_PREFIX, $id);
     }
 
     /**
-     * 顶部导航 Redis key
+     * 顶部导航 Redis key;$id 现按 shopId 生成新 key,传 mainId 则生成历史迁移用的旧 key
      */
-    public static function getTopNavKey($mainId)
+    public static function getTopNavKey($id)
     {
-        //return self::TOP_NAV_PREFIX . intval($mainId);
-        return sprintf(self::TOP_NAV_PREFIX, $mainId);
+        return sprintf(self::TOP_NAV_PREFIX, $id);
     }
 
     /**
-     * 轮播图 Redis key(按 mainId,便于 mallApp 按店铺主账号读取)
+     * 轮播图 Redis key;$id 现按 shopId 生成新 key,传 mainId 则生成历史迁移用的旧 key
      */
-    public static function getBannerKey($mainId)
+    public static function getBannerKey($id)
     {
-        //return self::BANNER_PREFIX . intval($mainId);
-        return sprintf(self::BANNER_PREFIX, $mainId);
+        return sprintf(self::BANNER_PREFIX, $id);
     }
 
     /**
@@ -132,23 +130,29 @@ class HomePageConfigClass
      * 读取模块顺序与开关;缺失时用默认值补全
      *
      * @param int $mainId
+     * @param int $shopId
      * @return array
      */
-    public static function getModules($mainId)
+    public static function getModules($mainId, $shopId)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
 
-        $raw = Yii::$app->redis->executeCommand('GET', [self::getModulesKey($mainId)]);
-        $saved = [];
-        if (!empty($raw)) {
-            $decoded = json_decode($raw, true);
-            if (is_array($decoded)) {
-                $saved = $decoded;
-            }
+        $saved = HomePageModuleConfigClass::getConfigValue($shopId, 'modules');
+        if ($saved === null) {
+            // MySQL 无记录:回退历史 Redis 存量数据(按 mainId 存),命中后自愈迁移落库+镜像新 key
+            $saved = HomePageModuleConfigClass::migrateFromRedis(
+                $mainId,
+                $shopId,
+                'modules',
+                self::getModulesKey($shopId),
+                self::getModulesKey($mainId)
+            );
         }
+        $saved = is_array($saved) ? $saved : [];
 
         // 按已保存顺序保留合法项,再补全缺失的默认模块
         $result = [];
@@ -178,27 +182,35 @@ class HomePageConfigClass
     }
 
     /**
-     * 保存模块顺序与开关(合并写入同一 Redis key)
+     * 保存模块顺序与开关:落库 MySQL(权威数据源),并镜像写入 Redis
      * 请求形态校验由 SaveModulesForm 完成;$items 应为已规范化的 [{key,enabled}, ...]
      *
      * @param int $mainId
+     * @param int $shopId
      * @param array $items [{key,enabled}, ...]
      * @return bool
      */
-    public static function saveModules($mainId, $items)
+    public static function saveModules($mainId, $shopId, $items)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
         if (!is_array($items)) {
             util::fail('配置项格式错误');
         }
 
-        Yii::$app->redis->executeCommand('SET', [
-            self::getModulesKey($mainId),
-            json_encode(array_values($items), JSON_UNESCAPED_UNICODE),
-        ]);
+        $payload = array_values($items);
+        HomePageModuleConfigClass::saveConfigValue($mainId, $shopId, 'modules', $payload);
+        try {
+            Yii::$app->redis->executeCommand('SET', [
+                self::getModulesKey($shopId),
+                json_encode($payload, JSON_UNESCAPED_UNICODE),
+            ]);
+        } catch (\Exception $e) {
+            Yii::error('同步首页模块配置到Redis失败: ' . $e->getMessage());
+        }
         return true;
     }
 
@@ -206,12 +218,13 @@ class HomePageConfigClass
      * 从模块配置中取某个模块的开关状态
      *
      * @param int $mainId
+     * @param int $shopId
      * @param string $moduleKey
      * @return int 0|1
      */
-    public static function getModuleEnabled($mainId, $moduleKey)
+    public static function getModuleEnabled($mainId, $shopId, $moduleKey)
     {
-        $modules = self::getModules($mainId);
+        $modules = self::getModules($mainId, $shopId);
         foreach ($modules as $item) {
             if (($item['key'] ?? '') === $moduleKey) {
                 return !empty($item['enabled']) ? 1 : 0;
@@ -221,20 +234,21 @@ class HomePageConfigClass
     }
 
     /**
-     * 更新单个模块开关,并写回 modules Redis(与列表页共用同一份状态)
+     * 更新单个模块开关,并写回 modules 配置(与列表页共用同一份状态)
      *
      * @param int $mainId
+     * @param int $shopId
      * @param string $moduleKey
      * @param int $enabled
      * @return bool
      */
-    public static function updateModuleEnabled($mainId, $moduleKey, $enabled)
+    public static function updateModuleEnabled($mainId, $shopId, $moduleKey, $enabled)
     {
         $moduleKey = strval($moduleKey);
         if (!in_array($moduleKey, self::DEFAULT_KEYS, true)) {
             util::fail('无效配置项');
         }
-        $modules = self::getModules($mainId);
+        $modules = self::getModules($mainId, $shopId);
         foreach ($modules as &$item) {
             if (($item['key'] ?? '') === $moduleKey) {
                 $item['enabled'] = !empty($enabled) ? 1 : 0;
@@ -242,36 +256,41 @@ class HomePageConfigClass
             }
         }
         unset($item);
-        return self::saveModules($mainId, $modules);
+        return self::saveModules($mainId, $shopId, $modules);
     }
 
     /**
      * 读取顶部导航与搜索配置,与默认值合并
-     * 模块开关 enabled 以 modules Redis 为准,保证与门店首页配置列表同步
+     * 模块开关 enabled 以 modules 配置为准,保证与门店首页配置列表同步
      *
      * @param int $mainId
+     * @param int $shopId
      * @return array
      */
-    public static function getTopNav($mainId)
+    public static function getTopNav($mainId, $shopId)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
 
         $defaults = self::getDefaultTopNav();
-        $raw = Yii::$app->redis->executeCommand('GET', [self::getTopNavKey($mainId)]);
-        $saved = [];
-        if (!empty($raw)) {
-            $decoded = json_decode($raw, true);
-            if (is_array($decoded)) {
-                $saved = $decoded;
-            }
+        $saved = HomePageModuleConfigClass::getConfigValue($shopId, 'topNav');
+        if ($saved === null) {
+            $saved = HomePageModuleConfigClass::migrateFromRedis(
+                $mainId,
+                $shopId,
+                'topNav',
+                self::getTopNavKey($shopId),
+                self::getTopNavKey($mainId)
+            );
         }
+        $saved = is_array($saved) ? $saved : [];
 
         return [
             // 开关以首页模块配置为准,不读 topNav 详情里的旧字段
-            'enabled' => self::getModuleEnabled($mainId, 'topNav'),
+            'enabled' => self::getModuleEnabled($mainId, $shopId, 'topNav'),
             'placeholder' => isset($saved['placeholder']) ? strval($saved['placeholder']) : $defaults['placeholder'],
             'searchBtnColor' => isset($saved['searchBtnColor']) ? strval($saved['searchBtnColor']) : $defaults['searchBtnColor'],
             'searchFontColor' => isset($saved['searchFontColor']) ? strval($saved['searchFontColor']) : $defaults['searchFontColor'],
@@ -282,55 +301,67 @@ class HomePageConfigClass
     }
 
     /**
-     * 保存顶部导航与搜索配置(不含门店名称)
+     * 保存顶部导航与搜索配置(不含门店名称):落库 MySQL,并镜像写入 Redis
      * 请求形态校验由 SaveTopNavForm 完成;$payload 为已规范化样式字段
      *
      * @param int $mainId
+     * @param int $shopId
      * @param array $payload {placeholder,searchBtnColor,searchFontColor,customerServiceEnabled}
      * @param int $enabled 模块开关
      * @return bool
      */
-    public static function saveTopNav($mainId, $payload, $enabled = 0)
+    public static function saveTopNav($mainId, $shopId, $payload, $enabled = 0)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
         if (!is_array($payload)) {
             util::fail('参数错误');
         }
 
-        Yii::$app->redis->executeCommand('SET', [
-            self::getTopNavKey($mainId),
-            json_encode($payload, JSON_UNESCAPED_UNICODE),
-        ]);
+        HomePageModuleConfigClass::saveConfigValue($mainId, $shopId, 'topNav', $payload);
+        try {
+            Yii::$app->redis->executeCommand('SET', [
+                self::getTopNavKey($shopId),
+                json_encode($payload, JSON_UNESCAPED_UNICODE),
+            ]);
+        } catch (\Exception $e) {
+            Yii::error('同步顶部导航配置到Redis失败: ' . $e->getMessage());
+        }
         // 同步到门店首页配置列表的 topNav 开关
-        self::updateModuleEnabled($mainId, 'topNav', $enabled);
+        self::updateModuleEnabled($mainId, $shopId, 'topNav', $enabled);
         return true;
     }
 
     /**
-     * 读取首页轮播图配置;模块开关以 modules Redis 为准
+     * 读取首页轮播图配置;模块开关以 modules 配置为准
      *
      * @param int $mainId
+     * @param int $shopId
      * @return array {enabled, interval, list:[{img,type,value}]}
      */
-    public static function getBanner($mainId)
+    public static function getBanner($mainId, $shopId)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
 
         $defaults = self::getDefaultBanner();
-        $raw = Yii::$app->redis->executeCommand('GET', [self::getBannerKey($mainId)]);
-        $saved = [];
-        if (!empty($raw)) {
-            $decoded = json_decode($raw, true);
-            if (is_array($decoded)) {
-                $saved = $decoded;
-            }
+        $saved = HomePageModuleConfigClass::getConfigValue($shopId, 'banner');
+        if ($saved === null) {
+            $saved = HomePageModuleConfigClass::migrateFromRedis(
+                $mainId,
+                $shopId,
+                'banner',
+                self::getBannerKey($shopId),
+                self::getBannerKey($mainId)
+            );
         }
+        $saved = is_array($saved) ? $saved : [];
 
         $list = [];
         if (!empty($saved['list']) && is_array($saved['list'])) {
@@ -364,25 +395,27 @@ class HomePageConfigClass
         }
 
         return [
-            'enabled' => self::getModuleEnabled($mainId, 'banner'),
+            'enabled' => self::getModuleEnabled($mainId, $shopId, 'banner'),
             'interval' => $interval,
             'list' => $list,
         ];
     }
 
     /**
-     * 保存首页轮播图配置,并同步模块开关;会删除本次被移除的 OSS 图片
+     * 保存首页轮播图配置:落库 MySQL,镜像写入 Redis,并同步模块开关;会删除本次被移除的 OSS 图片
      * 请求形态校验由 SaveBannerForm 完成;$payload 为已规范化 {interval,list}
      *
      * @param int $mainId
+     * @param int $shopId
      * @param array $payload {interval, list:[{img,type,value}]}
      * @param int $enabled 模块开关
      * @return bool
      */
-    public static function saveBanner($mainId, $payload, $enabled = 0)
+    public static function saveBanner($mainId, $shopId, $payload, $enabled = 0)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
         if (!is_array($payload)) {
@@ -393,7 +426,7 @@ class HomePageConfigClass
         $list = isset($payload['list']) && is_array($payload['list']) ? $payload['list'] : [];
 
         // 对比旧配置,删除本次不再使用的 OSS 图片
-        $old = self::getBanner($mainId);
+        $old = self::getBanner($mainId, $shopId);
         $oldImgs = [];
         foreach ($old['list'] as $oldItem) {
             if (!empty($oldItem['img'])) {
@@ -418,11 +451,16 @@ class HomePageConfigClass
             'interval' => $interval,
             'list' => $list,
         ];
-        Yii::$app->redis->executeCommand('SET', [
-            self::getBannerKey($mainId),
-            json_encode($savePayload, JSON_UNESCAPED_UNICODE),
-        ]);
-        self::updateModuleEnabled($mainId, 'banner', $enabled);
+        HomePageModuleConfigClass::saveConfigValue($mainId, $shopId, 'banner', $savePayload);
+        try {
+            Yii::$app->redis->executeCommand('SET', [
+                self::getBannerKey($shopId),
+                json_encode($savePayload, JSON_UNESCAPED_UNICODE),
+            ]);
+        } catch (\Exception $e) {
+            Yii::error('同步首页轮播图配置到Redis失败: ' . $e->getMessage());
+        }
+        self::updateModuleEnabled($mainId, $shopId, 'banner', $enabled);
         return true;
     }
 

+ 20 - 16
biz-hd/homePageConfig/classes/HomePageDisplayClass.php

@@ -19,20 +19,21 @@ class HomePageDisplayClass
      * 一次性组装首页全部模块的展示数据
      *
      * @param int $mainId
+     * @param int $shopId
      * @return array
      */
-    public static function buildHome($mainId)
+    public static function buildHome($mainId, $shopId)
     {
         return [
-            'modules' => HomePageConfigClass::getModules($mainId),
-            'topNav' => self::formatTopNav($mainId, HomePageConfigClass::getTopNav($mainId)),
-            'banner' => self::formatBanner(HomePageConfigClass::getBanner($mainId)),
-            'navGrid' => self::formatNavGrid(HomePageModuleClass::getNavGrid($mainId)),
-            'seckill' => self::formatActivity(HomePageModuleClass::getSeckill($mainId, true)),
-            'groupBuy' => self::formatActivity(HomePageModuleClass::getGroupBuy($mainId, true)),
-            'hot' => self::formatGoodsSection($mainId, 'hot'),
-            'new' => self::formatGoodsSection($mainId, 'new'),
-            'pullGoods' => self::formatGoodsSection($mainId, 'pullGoods'),
+            'modules' => HomePageConfigClass::getModules($mainId, $shopId),
+            'topNav' => self::formatTopNav($mainId, HomePageConfigClass::getTopNav($mainId, $shopId)),
+            'banner' => self::formatBanner(HomePageConfigClass::getBanner($mainId, $shopId)),
+            'navGrid' => self::formatNavGrid(HomePageModuleClass::getNavGrid($mainId, $shopId)),
+            'seckill' => self::formatActivity(HomePageModuleClass::getSeckill($mainId, $shopId, true)),
+            'groupBuy' => self::formatActivity(HomePageModuleClass::getGroupBuy($mainId, $shopId, true)),
+            'hot' => self::formatGoodsSection($mainId, $shopId, 'hot'),
+            'new' => self::formatGoodsSection($mainId, $shopId, 'new'),
+            'pullGoods' => self::formatGoodsSection($mainId, $shopId, 'pullGoods'),
         ];
     }
 
@@ -165,12 +166,13 @@ class HomePageDisplayClass
      * 格式化热门推荐/今日上新/下拉商品:关闭时清空商品;开启时按真实商品数量自动推导列数与展示条数
      *
      * @param int $mainId
+     * @param int $shopId
      * @param string $moduleKey hot|new|pullGoods
      * @return array
      */
-    public static function formatGoodsSection($mainId, $moduleKey)
+    public static function formatGoodsSection($mainId, $shopId, $moduleKey)
     {
-        $data = HomePageModuleClass::getGoodsSection($mainId, $moduleKey);
+        $data = HomePageModuleClass::getGoodsSection($mainId, $shopId, $moduleKey);
         if (empty($data['enabled'])) {
             $data['goods'] = [];
             $data['goodsTotal'] = 0;
@@ -193,14 +195,16 @@ class HomePageDisplayClass
      * 「更多」列表页分页入口:按模块返回全部可展示商品
      *
      * @param int $mainId
+     * @param int $shopId
      * @param string $moduleKey seckill|groupBuy|hot|new|pullGoods
      * @param int $page
      * @param int $pageSize
      * @return array {list, total, totalPage, page, pageSize, moreData, title}
      */
-    public static function getSectionGoodsPage($mainId, $moduleKey, $page = 1, $pageSize = 10)
+    public static function getSectionGoodsPage($mainId, $shopId, $moduleKey, $page = 1, $pageSize = 10)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
         $moduleKey = strval($moduleKey);
         $page = max(1, intval($page));
         $pageSize = max(1, intval($pageSize));
@@ -218,8 +222,8 @@ class HomePageDisplayClass
 
         if ($moduleKey === 'seckill' || $moduleKey === 'groupBuy') {
             $data = $moduleKey === 'seckill'
-                ? HomePageModuleClass::getSeckill($mainId, true)
-                : HomePageModuleClass::getGroupBuy($mainId, true);
+                ? HomePageModuleClass::getSeckill($mainId, $shopId, true)
+                : HomePageModuleClass::getGroupBuy($mainId, $shopId, true);
             $title = strval($data['title'] ?? '');
             if ($title === '') {
                 $title = $moduleKey === 'groupBuy' ? '团购优惠' : '限时秒杀';
@@ -230,7 +234,7 @@ class HomePageDisplayClass
             $total = count($all);
             $list = array_slice($all, ($page - 1) * $pageSize, $pageSize);
         } else {
-            $data = HomePageModuleClass::getGoodsSection($mainId, $moduleKey);
+            $data = HomePageModuleClass::getGoodsSection($mainId, $shopId, $moduleKey);
             $title = strval($data['name'] ?? '');
             if (empty($data['enabled'])) {
                 $list = [];

+ 76 - 28
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -11,7 +11,8 @@ use Yii;
 
 /**
  * 门店首页其余模块配置(金刚区/秒杀/团购/热门/上新/下拉商品)
- * 数据按 mainId 存 Redis,供 hdApp 配置、mallApp 读取展示。
+ * 金刚区/热门/上新/下拉商品的配置内容按 shopId 维度落 MySQL(xhHomePageModuleConfig),Redis 仅作兼容镜像;
+ * 秒杀/团购的活动数据仍按 mainId 维度落各自的 MySQL 批次表(跨门店共享同一批活动),仅「开关」状态按 shopId 维度读取。
  */
 class HomePageModuleClass
 {
@@ -88,13 +89,24 @@ class HomePageModuleClass
 
     // -------------------- 金刚区 --------------------
 
-    public static function getNavGrid($mainId)
+    public static function getNavGrid($mainId, $shopId)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
-        $saved = self::getJson($mainId, self::REDIS_NAV_GRID);
+        $saved = HomePageModuleConfigClass::getConfigValue($shopId, 'navGrid');
+        if ($saved === null) {
+            $saved = HomePageModuleConfigClass::migrateFromRedis(
+                $mainId,
+                $shopId,
+                'navGrid',
+                self::redisKey($shopId, self::REDIS_NAV_GRID),
+                self::redisKey($mainId, self::REDIS_NAV_GRID)
+            );
+        }
+        $saved = is_array($saved) ? $saved : [];
         $cols = isset($saved['cols']) ? intval($saved['cols']) : 5;
         if (!in_array($cols, [4, 5], true)) {
             $cols = 5;
@@ -115,24 +127,26 @@ class HomePageModuleClass
             ];
         }
         return [
-            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, 'navGrid'),
+            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'navGrid'),
             'cols' => $cols,
             'list' => $list,
         ];
     }
 
     /**
-     * 保存金刚区导航;请求形态校验由 SaveNavGridForm 完成
+     * 保存金刚区导航:落库 MySQL,并镜像写入 Redis;请求形态校验由 SaveNavGridForm 完成
      *
      * @param int $mainId
+     * @param int $shopId
      * @param array $payload {cols, list}
      * @param int $enabled
      * @return bool
      */
-    public static function saveNavGrid($mainId, $payload, $enabled = 0)
+    public static function saveNavGrid($mainId, $shopId, $payload, $enabled = 0)
     {
         $mainId = intval($mainId);
-        if ($mainId <= 0) {
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
             util::fail('无效门店');
         }
         if (!is_array($payload)) {
@@ -140,8 +154,14 @@ class HomePageModuleClass
         }
         $cols = isset($payload['cols']) ? intval($payload['cols']) : 5;
         $list = isset($payload['list']) && is_array($payload['list']) ? $payload['list'] : [];
-        self::setJson($mainId, self::REDIS_NAV_GRID, ['cols' => $cols, 'list' => $list]);
-        HomePageConfigClass::updateModuleEnabled($mainId, 'navGrid', $enabled);
+        $saveValue = ['cols' => $cols, 'list' => $list];
+        HomePageModuleConfigClass::saveConfigValue($mainId, $shopId, 'navGrid', $saveValue);
+        try {
+            self::setJson($shopId, self::REDIS_NAV_GRID, $saveValue);
+        } catch (\Exception $e) {
+            Yii::error('同步金刚区配置到Redis失败: ' . $e->getMessage());
+        }
+        HomePageConfigClass::updateModuleEnabled($mainId, $shopId, 'navGrid', $enabled);
         return true;
     }
 
@@ -150,15 +170,16 @@ class HomePageModuleClass
     /**
      * 读取秒杀专区:优先 MySQL 批次化持久化,无数据时回退 Redis
      */
-    public static function getSeckill($mainId, $refreshStock = true)
+    public static function getSeckill($mainId, $shopId, $refreshStock = true)
     {
-        return \bizHd\seckill\classes\SeckillActivityClass::getSeckill($mainId, $refreshStock);
+        return \bizHd\seckill\classes\SeckillActivityClass::getSeckill($mainId, $shopId, $refreshStock);
     }
 
     /**
      * 仅从 Redis 读取历史单例秒杀配置(迁移过渡 / 无 MySQL 批次时回退)
+     * 秒杀活动数据本身仍按 mainId 存储(跨门店共享同一批活动),仅「开关」状态按 shopId 读取
      */
-    public static function getSeckillFromRedis($mainId, $refreshStock = true)
+    public static function getSeckillFromRedis($mainId, $shopId, $refreshStock = true)
     {
         $mainId = intval($mainId);
         if ($mainId <= 0) {
@@ -166,7 +187,7 @@ class HomePageModuleClass
         }
         $saved = self::getJson($mainId, self::REDIS_SECKILL);
         $data = self::normalizeActivityBase($saved);
-        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'seckill');
+        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'seckill');
         $data['goods'] = self::normalizeSeckillGoods($saved['goods'] ?? [], $mainId, $refreshStock);
         // 按已添加商品数量 + 展开开关动态推导列数与展示数量(不依赖商家手动配置)
         $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']);
@@ -307,12 +328,13 @@ class HomePageModuleClass
      * 供订单结算时独立核价,避免客户端伪造价格
      *
      * @param int $mainId
+     * @param int $shopId
      * @param int $goodsId 下单商品的实际销售单元id(普通商品即goodsId本身,多规格则为规格id)
      * @return array|null 匹配的秒杀商品配置,未命中/活动未进行中返回 null
      */
-    public static function getSeckillActiveRow($mainId, $goodsId)
+    public static function getSeckillActiveRow($mainId, $shopId, $goodsId)
     {
-        $data = self::getSeckill($mainId, true);
+        $data = self::getSeckill($mainId, $shopId, true);
         if (empty($data['enabled']) || intval($data['status'] ?? 0) !== 1) {
             return null;
         }
@@ -425,15 +447,16 @@ class HomePageModuleClass
     /**
      * 读取团购专区:优先 MySQL 批次化持久化,无数据时回退 Redis
      */
-    public static function getGroupBuy($mainId, $refreshStock = true)
+    public static function getGroupBuy($mainId, $shopId, $refreshStock = true)
     {
-        return \bizHd\groupBuy\classes\GroupBuyActivityClass::getGroupBuy($mainId, $refreshStock);
+        return \bizHd\groupBuy\classes\GroupBuyActivityClass::getGroupBuy($mainId, $shopId, $refreshStock);
     }
 
     /**
      * 仅从 Redis 读取历史单例团购配置(迁移过渡 / 无 MySQL 批次时回退)
+     * 团购活动数据本身仍按 mainId 存储(跨门店共享同一批活动),仅「开关」状态按 shopId 读取
      */
-    public static function getGroupBuyFromRedis($mainId, $refreshStock = true)
+    public static function getGroupBuyFromRedis($mainId, $shopId, $refreshStock = true)
     {
         $mainId = intval($mainId);
         if ($mainId <= 0) {
@@ -441,7 +464,7 @@ class HomePageModuleClass
         }
         $saved = self::getJson($mainId, self::REDIS_GROUP_BUY);
         $data = self::normalizeActivityBase($saved);
-        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'groupBuy');
         $data['goods'] = self::normalizeGroupBuyGoods($saved['goods'] ?? [], $mainId, $refreshStock);
         $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']);
         $data['layoutCols'] = $layout['layoutCols'];
@@ -566,18 +589,32 @@ class HomePageModuleClass
 
     // -------------------- 热门/上新/下拉 --------------------
 
-    public static function getGoodsSection($mainId, $moduleKey)
+    public static function getGoodsSection($mainId, $shopId, $moduleKey)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
+            util::fail('无效门店');
+        }
         $suffix = self::sectionSuffix($moduleKey);
-        $saved = self::getJson($mainId, $suffix);
+        $saved = HomePageModuleConfigClass::getConfigValue($shopId, $moduleKey);
+        if ($saved === null) {
+            $saved = HomePageModuleConfigClass::migrateFromRedis(
+                $mainId,
+                $shopId,
+                $moduleKey,
+                self::redisKey($shopId, $suffix),
+                self::redisKey($mainId, $suffix)
+            );
+        }
+        $saved = is_array($saved) ? $saved : [];
         $defaults = [
             'hot' => '热门推荐',
             'new' => '今日上新',
             'pullGoods' => '更多商品',
         ];
         return [
-            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, $moduleKey),
+            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, $shopId, $moduleKey),
             'name' => strval($saved['name'] ?? ($defaults[$moduleKey] ?? '')),
             'type' => intval($saved['type'] ?? self::LINK_GOODS),
             'value' => strval($saved['value'] ?? ''),
@@ -586,28 +623,39 @@ class HomePageModuleClass
     }
 
     /**
-     * 保存热门/上新/下拉商品;请求形态校验由 SaveGoodsSectionForm 完成
+     * 保存热门/上新/下拉商品:落库 MySQL,并镜像写入 Redis;请求形态校验由 SaveGoodsSectionForm 完成
      *
      * @param int $mainId
+     * @param int $shopId
      * @param string $moduleKey hot|new|pullGoods
      * @param array $payload {name,type,value,sort}
      * @param int $enabled
      * @return bool
      */
-    public static function saveGoodsSection($mainId, $moduleKey, $payload, $enabled = 0)
+    public static function saveGoodsSection($mainId, $shopId, $moduleKey, $payload, $enabled = 0)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
+        if ($shopId <= 0) {
+            util::fail('无效门店');
+        }
         $suffix = self::sectionSuffix($moduleKey);
         if (!is_array($payload)) {
             util::fail('参数错误');
         }
-        self::setJson($mainId, $suffix, [
+        $saveValue = [
             'name' => strval($payload['name'] ?? ''),
             'type' => intval($payload['type'] ?? 0),
             'value' => strval($payload['value'] ?? ''),
             'sort' => intval($payload['sort'] ?? self::SORT_PRODUCT),
-        ]);
-        HomePageConfigClass::updateModuleEnabled($mainId, $moduleKey, $enabled);
+        ];
+        HomePageModuleConfigClass::saveConfigValue($mainId, $shopId, $moduleKey, $saveValue);
+        try {
+            self::setJson($shopId, $suffix, $saveValue);
+        } catch (\Exception $e) {
+            Yii::error('同步首页商品模块配置到Redis失败: ' . $e->getMessage());
+        }
+        HomePageConfigClass::updateModuleEnabled($mainId, $shopId, $moduleKey, $enabled);
         return true;
     }
 

+ 102 - 0
biz-hd/homePageConfig/classes/HomePageModuleConfigClass.php

@@ -0,0 +1,102 @@
+<?php
+
+namespace bizHd\homePageConfig\classes;
+
+use bizHd\base\classes\BaseClass;
+use Yii;
+
+/**
+ * 门店首页模块配置的 MySQL 落库层
+ * 表 xhHomePageModuleConfig 以 shopId+moduleKey 唯一标识一条配置,是首页配置的唯一权威数据源,
+ * Redis 仅作为兼容镜像:读取以本类为准,不存在时才回退旧 Redis key 并自愈迁移。
+ */
+class HomePageModuleConfigClass extends BaseClass
+{
+    public static $baseFile = '\bizHd\homePageConfig\models\HomePageModuleConfig';
+
+    /**
+     * 读取某模块配置,不存在返回 null(区别于「已保存的空数组/空对象」)
+     *
+     * @param int $shopId
+     * @param string $moduleKey
+     * @return array|null
+     */
+    public static function getConfigValue($shopId, $moduleKey)
+    {
+        $row = self::getByCondition(['shopId' => intval($shopId), 'moduleKey' => strval($moduleKey)]);
+        if (empty($row) || !isset($row['configValue'])) {
+            return null;
+        }
+        $decoded = json_decode($row['configValue'], true);
+        return is_array($decoded) ? $decoded : null;
+    }
+
+    /**
+     * 保存某模块配置:存在则更新,不存在则新建;并发写入命中唯一索引冲突时兜底改为更新
+     *
+     * @param int $mainId 仅作参考字段落库,不参与查重
+     * @param int $shopId
+     * @param string $moduleKey
+     * @param array $payload
+     * @return bool
+     */
+    public static function saveConfigValue($mainId, $shopId, $moduleKey, $payload)
+    {
+        $mainId = intval($mainId);
+        $shopId = intval($shopId);
+        $moduleKey = strval($moduleKey);
+        $json = json_encode($payload, JSON_UNESCAPED_UNICODE);
+
+        $exist = self::getByCondition(['shopId' => $shopId, 'moduleKey' => $moduleKey], true);
+        if (!empty($exist)) {
+            self::updateById($exist->id, ['mainId' => $mainId, 'configValue' => $json]);
+            return true;
+        }
+        try {
+            self::add(['mainId' => $mainId, 'shopId' => $shopId, 'moduleKey' => $moduleKey, 'configValue' => $json]);
+        } catch (\Exception $e) {
+            // 并发写入命中唯一索引冲突时兜底改为更新
+            $exist = self::getByCondition(['shopId' => $shopId, 'moduleKey' => $moduleKey], true);
+            if (!empty($exist)) {
+                self::updateById($exist->id, ['mainId' => $mainId, 'configValue' => $json]);
+                return true;
+            }
+            throw $e;
+        }
+        return true;
+    }
+
+    /**
+     * 存量数据自愈迁移:MySQL 无记录时,回退读取旧 Redis key(历史按 mainId 存,新 key 按 shopId 存),
+     * 命中后立即落库 MySQL 并镜像写入新 Redis key,保证后续请求都能直接从 MySQL 命中
+     *
+     * @param int $mainId
+     * @param int $shopId
+     * @param string $moduleKey
+     * @param string $newRedisKey 按 shopId 生成的新 key
+     * @param string $legacyRedisKey 按 mainId 生成的历史 key
+     * @return array|null
+     */
+    public static function migrateFromRedis($mainId, $shopId, $moduleKey, $newRedisKey, $legacyRedisKey)
+    {
+        $raw = Yii::$app->redis->executeCommand('GET', [$newRedisKey]);
+        if (empty($raw) && $legacyRedisKey !== $newRedisKey) {
+            $raw = Yii::$app->redis->executeCommand('GET', [$legacyRedisKey]);
+        }
+        if (empty($raw)) {
+            return null;
+        }
+        $decoded = json_decode($raw, true);
+        if (!is_array($decoded)) {
+            return null;
+        }
+
+        self::saveConfigValue($mainId, $shopId, $moduleKey, $decoded);
+        try {
+            Yii::$app->redis->executeCommand('SET', [$newRedisKey, json_encode($decoded, JSON_UNESCAPED_UNICODE)]);
+        } catch (\Exception $e) {
+            Yii::error('首页配置迁移写入新Redis key失败: ' . $e->getMessage());
+        }
+        return $decoded;
+    }
+}

+ 17 - 0
biz-hd/homePageConfig/models/HomePageModuleConfig.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace bizHd\homePageConfig\models;
+
+use bizHd\base\models\Base;
+
+/**
+ * 门店首页模块配置表模型
+ * 对应表 xhHomePageModuleConfig,按 shopId+moduleKey 唯一区分一条配置记录
+ */
+class HomePageModuleConfig extends Base
+{
+    public static function tableName()
+    {
+        return 'xhHomePageModuleConfig';
+    }
+}

+ 6 - 4
biz-hd/seckill/classes/SeckillActivityClass.php

@@ -21,21 +21,23 @@ class SeckillActivityClass extends BaseClass
      * 读取当前门店「生效中」的秒杀配置,返回结构与历史 Redis getSeckill 兼容
      *
      * @param int $mainId
+     * @param int $shopId
      * @param bool $refreshStock 是否回查真实库存
      * @return array
      */
-    public static function getSeckill($mainId, $refreshStock = true)
+    public static function getSeckill($mainId, $shopId, $refreshStock = true)
     {
         $mainId = intval($mainId);
+        $shopId = intval($shopId);
         if ($mainId <= 0) {
             util::fail('无效门店');
         }
 
-        $enabled = HomePageConfigClass::getModuleEnabled($mainId, 'seckill');
+        $enabled = HomePageConfigClass::getModuleEnabled($mainId, $shopId, 'seckill');
         $activity = self::getCurrentActivity($mainId);
         if (empty($activity)) {
             // 无 MySQL 批次时回退 Redis,兼容尚未迁移的旧数据
-            return HomePageModuleClass::getSeckillFromRedis($mainId, $refreshStock);
+            return HomePageModuleClass::getSeckillFromRedis($mainId, $shopId, $refreshStock);
         }
 
         $goodsRows = SeckillGoodsClass::getActiveGoodsByActivityId(intval($activity['id']), $mainId, $refreshStock);
@@ -100,7 +102,7 @@ class SeckillActivityClass extends BaseClass
         ]);
 
         SeckillGoodsClass::syncActivityGoods($mainId, $shopId, $activityId, $validatedGoods);
-        HomePageConfigClass::updateModuleEnabled($mainId, 'seckill', $enabled);
+        HomePageConfigClass::updateModuleEnabled($mainId, $shopId, 'seckill', $enabled);
 
         // 同步写 Redis,保证旧读路径与预览链路仍可用
         $syncGoods = SeckillGoodsClass::getActiveGoodsByActivityId($activityId, $mainId, false);

+ 15 - 0
sql/20260811_home_page_module_config.sql

@@ -0,0 +1,15 @@
+-- 门店首页模块配置落库:原仅存 Redis 的 modules/topNav/banner/navGrid/hot/new/pullGoods
+-- 统一按门店(shopId)维度落一张通用配置表,moduleKey 区分具体模块,configValue 存 JSON
+
+CREATE TABLE IF NOT EXISTS `xhHomePageModuleConfig` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `mainId` int(11) NOT NULL DEFAULT 0 COMMENT '中央id',
+  `shopId` int(11) NOT NULL DEFAULT 0 COMMENT '门店id',
+  `moduleKey` varchar(32) NOT NULL DEFAULT '' COMMENT '配置项标识:modules/topNav/banner/navGrid/hot/new/pullGoods',
+  `configValue` text COMMENT '配置内容JSON,结构随moduleKey而定',
+  `addTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_shop_module` (`shopId`,`moduleKey`),
+  KEY `mainId` (`mainId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店首页模块配置';