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

Merge branch 'redesign‌-260706' into dev

shizhongqi 3 недель назад
Родитель
Сommit
8089e372ac

+ 264 - 0
app-hd/controllers/HomePageConfigController.php

@@ -0,0 +1,264 @@
+<?php
+
+namespace hd\controllers;
+
+use bizHd\homePageConfig\classes\HomePageConfigClass;
+use bizHd\homePageConfig\classes\HomePageDisplayClass;
+use bizHd\homePageConfig\classes\HomePageModuleClass;
+use common\components\business;
+use common\components\dirUtil;
+use common\components\oss;
+use common\components\stringUtil;
+use common\components\util;
+use Yii;
+
+/**
+ * 门店首页配置接口
+ * 供 hdApp 管理端配置商城首页模块顺序、开关、顶部导航及轮播图详情。
+ */
+class HomePageConfigController extends BaseController
+{
+    /**
+     * 门店首页预览:与 app-mall 顾客端首页共用同一套组装/格式化逻辑(HomePageDisplayClass),
+     * 保证商家在后台点"预览"看到的效果与顾客真实首页完全一致
+     */
+    public function actionGetHome()
+    {
+        util::success(HomePageDisplayClass::buildHome($this->mainId));
+    }
+
+    /**
+     * 获取首页模块顺序与开关
+     */
+    public function actionGetModules()
+    {
+        $list = HomePageConfigClass::getModules($this->mainId);
+        util::success(['list' => $list]);
+    }
+
+    /**
+     * 保存首页模块顺序与开关(合并写入 Redis)
+     */
+    public function actionSaveModules()
+    {
+        $items = Yii::$app->request->post('items', []);
+        HomePageConfigClass::saveModules($this->mainId, $items);
+        util::complete('保存成功');
+    }
+
+    /**
+     * 获取顶部导航与搜索配置;门店名称实时取 xhShop.merchantName
+     */
+    public function actionGetTopNav()
+    {
+        $data = HomePageConfigClass::getTopNav($this->mainId);
+        $data['merchantName'] = $this->shop->merchantName ?? '';
+        util::success($data);
+    }
+
+    /**
+     * 保存顶部导航与搜索配置(不含门店名称)
+     */
+    public function actionSaveTopNav()
+    {
+        $post = Yii::$app->request->post();
+        HomePageConfigClass::saveTopNav($this->mainId, $post);
+        util::complete('保存成功');
+    }
+
+    /**
+     * 获取首页轮播图配置(管理端)
+     */
+    public function actionGetBanner()
+    {
+        $data = HomePageConfigClass::getBanner($this->mainId);
+        util::success($data);
+    }
+
+    /**
+     * 保存首页轮播图配置;同步模块开关,并清理被移除的 OSS 图片
+     */
+    public function actionSaveBanner()
+    {
+        $post = Yii::$app->request->post();
+        HomePageConfigClass::saveBanner($this->mainId, $post);
+        util::complete('保存成功');
+    }
+
+    /**
+     * 上传轮播图到独立 OSS 目录 uploads_home_banner/{mainId}/{shopId}/日期路径
+     * 文件名按日期时间生成,便于按日识别
+     */
+    public function actionUploadBanner()
+    {
+        $file = isset($_FILES['file']) ? $_FILES['file'] : null;
+        if (empty($file) || empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+            util::fail('请上传图片');
+        }
+
+        $mainId = intval($this->mainId);
+        $shopId = intval($this->shopId);
+        if ($mainId <= 0 || $shopId <= 0) {
+            util::fail('无效门店');
+        }
+
+        $root = HomePageConfigClass::BANNER_OSS_ROOT;
+        $month = date('Ym');
+        $day = date('d');
+        // 本地临时目录与 OSS 对象路径保持一致
+        $relativeDir = "{$root}/{$mainId}/{$shopId}/{$month}/{$day}/";
+        $localDir = rtrim(dirUtil::getImgUploadDir(), '/') . '/' . $relativeDir;
+        if (!is_dir($localDir) && !@mkdir($localDir, 0700, true) && !is_dir($localDir)) {
+            util::fail('创建上传目录失败');
+        }
+
+        // 文件名:日期时间 + 短唯一串,满足「按日期创建」并避免冲突
+        $fileName = date('YmdHis') . '_' . substr(stringUtil::uniqueFileName(), 0, 8) . '.jpg';
+        $localFile = $localDir . $fileName;
+        if (!move_uploaded_file($file['tmp_name'], $localFile)) {
+            util::fail('上传失败');
+        }
+
+        $ossPath = $relativeDir . $fileName;
+        oss::uploadImage($ossPath, $localFile);
+        @unlink($localFile);
+
+        $data = business::formatUploadImg($ossPath);
+        $data['shortUrl'] = $ossPath;
+        $data['smallShortUrl'] = $ossPath . '?x-oss-process=image/resize,l_200';
+        util::success($data, 'ok');
+    }
+
+    /**
+     * 删除单张轮播图 OSS(前端移除图片时即时调用)
+     */
+    public function actionDeleteBannerImg()
+    {
+        $filePath = Yii::$app->request->post('filePath', '');
+        $filePath = ltrim(strval($filePath), '/');
+        if ($filePath === '') {
+            util::fail('参数错误');
+        }
+        $prefix = HomePageConfigClass::BANNER_OSS_ROOT . '/' . intval($this->mainId) . '/' . intval($this->shopId) . '/';
+        if (strpos($filePath, $prefix) !== 0) {
+            util::fail('无权删除该图片');
+        }
+        HomePageConfigClass::deleteBannerOssImages([$filePath]);
+        util::complete('删除成功');
+    }
+
+    /** 金刚区导航 */
+    public function actionGetNavGrid()
+    {
+        util::success(HomePageModuleClass::getNavGrid($this->mainId));
+    }
+
+    public function actionSaveNavGrid()
+    {
+        HomePageModuleClass::saveNavGrid($this->mainId, Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /**
+     * 上传金刚区图标到 uploads_home_nav/{mainId}/{shopId}/日期
+     */
+    public function actionUploadNavIcon()
+    {
+        $this->uploadHomeImage(HomePageModuleClass::NAV_OSS_ROOT);
+    }
+
+    /** 秒杀专区 */
+    public function actionGetSeckill()
+    {
+        util::success(HomePageModuleClass::getSeckill($this->mainId, true));
+    }
+
+    public function actionSaveSeckill()
+    {
+        HomePageModuleClass::saveSeckill($this->mainId, Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /** 团购专区 */
+    public function actionGetGroupBuy()
+    {
+        util::success(HomePageModuleClass::getGroupBuy($this->mainId, true));
+    }
+
+    public function actionSaveGroupBuy()
+    {
+        HomePageModuleClass::saveGroupBuy($this->mainId, Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /** 热门推荐 */
+    public function actionGetHot()
+    {
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'hot'));
+    }
+
+    public function actionSaveHot()
+    {
+        HomePageModuleClass::saveGoodsSection($this->mainId, 'hot', Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /** 今日上新 */
+    public function actionGetNew()
+    {
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'new'));
+    }
+
+    public function actionSaveNew()
+    {
+        HomePageModuleClass::saveGoodsSection($this->mainId, 'new', Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /** 下拉商品 */
+    public function actionGetPullGoods()
+    {
+        util::success(HomePageModuleClass::getGoodsSection($this->mainId, 'pullGoods'));
+    }
+
+    public function actionSavePullGoods()
+    {
+        HomePageModuleClass::saveGoodsSection($this->mainId, 'pullGoods', Yii::$app->request->post());
+        util::complete('保存成功');
+    }
+
+    /**
+     * 通用首页配置图片上传:按项目(mainId)+shopId+日期落 OSS
+     */
+    private function uploadHomeImage($root)
+    {
+        $file = isset($_FILES['file']) ? $_FILES['file'] : null;
+        if (empty($file) || empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+            util::fail('请上传图片');
+        }
+        $mainId = intval($this->mainId);
+        $shopId = intval($this->shopId);
+        if ($mainId <= 0 || $shopId <= 0) {
+            util::fail('无效门店');
+        }
+        $month = date('Ym');
+        $day = date('d');
+        $relativeDir = "{$root}/{$mainId}/{$shopId}/{$month}/{$day}/";
+        $localDir = rtrim(dirUtil::getImgUploadDir(), '/') . '/' . $relativeDir;
+        if (!is_dir($localDir) && !@mkdir($localDir, 0700, true) && !is_dir($localDir)) {
+            util::fail('创建上传目录失败');
+        }
+        $fileName = date('YmdHis') . '_' . substr(stringUtil::uniqueFileName(), 0, 8) . '.jpg';
+        $localFile = $localDir . $fileName;
+        if (!move_uploaded_file($file['tmp_name'], $localFile)) {
+            util::fail('上传失败');
+        }
+        $ossPath = $relativeDir . $fileName;
+        oss::uploadImage($ossPath, $localFile);
+        @unlink($localFile);
+        $data = business::formatUploadImg($ossPath);
+        $data['shortUrl'] = $ossPath;
+        $data['smallShortUrl'] = $ossPath . '?x-oss-process=image/resize,l_200';
+        util::success($data, 'ok');
+    }
+}

+ 80 - 0
app-mall/controllers/HomePageConfigController.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace mall\controllers;
+
+use bizHd\homePageConfig\classes\HomePageConfigClass;
+use bizHd\homePageConfig\classes\HomePageDisplayClass;
+use bizHd\homePageConfig\classes\HomePageModuleClass;
+use common\components\util;
+
+/**
+ * 商城端首页配置读取接口
+ * mallApp 通过 account(shopId) 获取配置,与 hd 管理端共用 Redis。
+ * 具体的格式化/真实商品解析逻辑统一收敛在 HomePageDisplayClass,
+ * 保证与 app-hd 的预览接口返回结构完全一致。
+ */
+class HomePageConfigController extends BaseController
+{
+    public $guestAccess = [
+        'get-banner',
+        'get-nav-grid',
+        'get-seckill',
+        'get-group-buy',
+        'get-hot',
+        'get-new',
+        'get-pull-goods',
+        'get-home',
+    ];
+
+    /**
+     * 一次拉取首页全部模块配置,便于 mallApp 首页组装
+     */
+    public function actionGetHome()
+    {
+        util::success(HomePageDisplayClass::buildHome($this->requireMainId()));
+    }
+
+    public function actionGetBanner()
+    {
+        util::success(HomePageDisplayClass::formatBanner(HomePageConfigClass::getBanner($this->requireMainId())));
+    }
+
+    public function actionGetNavGrid()
+    {
+        util::success(HomePageDisplayClass::formatNavGrid(HomePageModuleClass::getNavGrid($this->requireMainId())));
+    }
+
+    public function actionGetSeckill()
+    {
+        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getSeckill($this->requireMainId(), true)));
+    }
+
+    public function actionGetGroupBuy()
+    {
+        util::success(HomePageDisplayClass::formatActivity(HomePageModuleClass::getGroupBuy($this->requireMainId(), true)));
+    }
+
+    public function actionGetHot()
+    {
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'hot'));
+    }
+
+    public function actionGetNew()
+    {
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'new'));
+    }
+
+    public function actionGetPullGoods()
+    {
+        util::success(HomePageDisplayClass::formatGoodsSection($this->requireMainId(), 'pullGoods'));
+    }
+
+    private function requireMainId()
+    {
+        $mainId = intval($this->mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        return $mainId;
+    }
+}

+ 551 - 0
biz-hd/homePageConfig/classes/HomePageConfigClass.php

@@ -0,0 +1,551 @@
+<?php
+
+namespace bizHd\homePageConfig\classes;
+
+use common\components\util;
+use Yii;
+
+/**
+ * 门店首页配置业务类
+ * 配置项顺序与开关、顶部导航详情均按 mainId 维度落 Redis,不落库。
+ */
+class HomePageConfigClass
+{
+    /** Redis:模块顺序+开关,值为 [{key,enabled}, ...] */
+    const REDIS_MODULES_SUFFIX = '_home_page_config_modules';
+
+    /** Redis:顶部导航与搜索详情 */
+    const REDIS_TOP_NAV_SUFFIX = '_home_page_config_top_nav';
+
+    /** Redis:首页轮播图配置 */
+    const REDIS_BANNER_SUFFIX = '_home_page_config_banner';
+
+    /** OSS 轮播图根目录 */
+    const BANNER_OSS_ROOT = 'uploads_home_banner';
+
+    /** 轮播图最大数量 */
+    const MAX_BANNER_COUNT = 10;
+
+    /** 关联类型:图文 */
+    const BANNER_TYPE_PIC_TEXT = 1;
+    /** 关联类型:商品 */
+    const BANNER_TYPE_GOODS = 2;
+    /** 关联类型:分类 */
+    const BANNER_TYPE_CATEGORY = 3;
+    /** 关联类型:场景 */
+    const BANNER_TYPE_USE_CASE = 4;
+
+    /** 合法模块 key 白名单(顺序即默认展示顺序) */
+    const DEFAULT_KEYS = [
+        'topNav',
+        'banner',
+        'navGrid',
+        'seckill',
+        'groupBuy',
+        'hot',
+        'new',
+        'pullGoods',
+    ];
+
+    /**
+     * 顶部导航默认配置(门店名称不在此存储,实时取 xhShop.merchantName)
+     */
+    public static function getDefaultTopNav()
+    {
+        return [
+            'enabled' => 1,
+            'placeholder' => '搜索鲜花、花束、绿植等',
+            'searchBtnColor' => '#09C567',
+            'searchFontColor' => '#FFFFFF',
+            'customerServiceEnabled' => 1,
+        ];
+    }
+
+    /**
+     * 轮播图默认配置
+     */
+    public static function getDefaultBanner()
+    {
+        return [
+            'enabled' => 1,
+            'interval' => 3,
+            'list' => [],
+        ];
+    }
+
+    /**
+     * 模块配置 Redis key
+     */
+    public static function getModulesKey($mainId)
+    {
+        return intval($mainId) . self::REDIS_MODULES_SUFFIX;
+    }
+
+    /**
+     * 顶部导航 Redis key
+     */
+    public static function getTopNavKey($mainId)
+    {
+        return intval($mainId) . self::REDIS_TOP_NAV_SUFFIX;
+    }
+
+    /**
+     * 轮播图 Redis key(按 mainId,便于 mallApp 按店铺主账号读取)
+     */
+    public static function getBannerKey($mainId)
+    {
+        return intval($mainId) . self::REDIS_BANNER_SUFFIX;
+    }
+
+    /**
+     * 合法的轮播关联类型
+     */
+    public static function getBannerTypes()
+    {
+        return [
+            self::BANNER_TYPE_PIC_TEXT,
+            self::BANNER_TYPE_GOODS,
+            self::BANNER_TYPE_CATEGORY,
+            self::BANNER_TYPE_USE_CASE,
+        ];
+    }
+
+    /**
+     * 默认模块列表:默认顺序 + 全部开启
+     */
+    public static function getDefaultModules()
+    {
+        $list = [];
+        foreach (self::DEFAULT_KEYS as $key) {
+            $list[] = [
+                'key' => $key,
+                'enabled' => 1,
+            ];
+        }
+        return $list;
+    }
+
+    /**
+     * 读取模块顺序与开关;缺失时用默认值补全
+     *
+     * @param int $mainId
+     * @return array
+     */
+    public static function getModules($mainId)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 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;
+            }
+        }
+
+        // 按已保存顺序保留合法项,再补全缺失的默认模块
+        $result = [];
+        $used = [];
+        foreach ($saved as $item) {
+            $key = isset($item['key']) ? strval($item['key']) : '';
+            if ($key === '' || !in_array($key, self::DEFAULT_KEYS, true) || isset($used[$key])) {
+                continue;
+            }
+            $result[] = [
+                'key' => $key,
+                'enabled' => !empty($item['enabled']) ? 1 : 0,
+            ];
+            $used[$key] = true;
+        }
+        foreach (self::DEFAULT_KEYS as $key) {
+            if (isset($used[$key])) {
+                continue;
+            }
+            $result[] = [
+                'key' => $key,
+                'enabled' => 1,
+            ];
+        }
+
+        return $result;
+    }
+
+    /**
+     * 保存模块顺序与开关(合并写入同一 Redis key)
+     *
+     * @param int $mainId
+     * @param array $items [{key,enabled}, ...]
+     * @return bool
+     */
+    public static function saveModules($mainId, $items)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        if (!is_array($items) || count($items) !== count(self::DEFAULT_KEYS)) {
+            util::fail('配置项数量不正确');
+        }
+
+        $result = [];
+        $used = [];
+        foreach ($items as $item) {
+            $key = isset($item['key']) ? strval($item['key']) : '';
+            if ($key === '' || !in_array($key, self::DEFAULT_KEYS, true)) {
+                util::fail('存在无效配置项');
+            }
+            if (isset($used[$key])) {
+                util::fail('配置项重复');
+            }
+            $result[] = [
+                'key' => $key,
+                'enabled' => !empty($item['enabled']) ? 1 : 0,
+            ];
+            $used[$key] = true;
+        }
+        if (count($used) !== count(self::DEFAULT_KEYS)) {
+            util::fail('配置项不完整');
+        }
+
+        Yii::$app->redis->executeCommand('SET', [
+            self::getModulesKey($mainId),
+            json_encode($result, JSON_UNESCAPED_UNICODE),
+        ]);
+        return true;
+    }
+
+    /**
+     * 从模块配置中取某个模块的开关状态
+     *
+     * @param int $mainId
+     * @param string $moduleKey
+     * @return int 0|1
+     */
+    public static function getModuleEnabled($mainId, $moduleKey)
+    {
+        $modules = self::getModules($mainId);
+        foreach ($modules as $item) {
+            if (($item['key'] ?? '') === $moduleKey) {
+                return !empty($item['enabled']) ? 1 : 0;
+            }
+        }
+        return 1;
+    }
+
+    /**
+     * 更新单个模块开关,并写回 modules Redis(与列表页共用同一份状态)
+     *
+     * @param int $mainId
+     * @param string $moduleKey
+     * @param int $enabled
+     * @return bool
+     */
+    public static function updateModuleEnabled($mainId, $moduleKey, $enabled)
+    {
+        $moduleKey = strval($moduleKey);
+        if (!in_array($moduleKey, self::DEFAULT_KEYS, true)) {
+            util::fail('无效配置项');
+        }
+        $modules = self::getModules($mainId);
+        foreach ($modules as &$item) {
+            if (($item['key'] ?? '') === $moduleKey) {
+                $item['enabled'] = !empty($enabled) ? 1 : 0;
+                break;
+            }
+        }
+        unset($item);
+        return self::saveModules($mainId, $modules);
+    }
+
+    /**
+     * 读取顶部导航与搜索配置,与默认值合并
+     * 模块开关 enabled 以 modules Redis 为准,保证与门店首页配置列表同步
+     *
+     * @param int $mainId
+     * @return array
+     */
+    public static function getTopNav($mainId)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 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;
+            }
+        }
+
+        return [
+            // 开关以首页模块配置为准,不读 topNav 详情里的旧字段
+            'enabled' => self::getModuleEnabled($mainId, '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'],
+            'customerServiceEnabled' => array_key_exists('customerServiceEnabled', $saved)
+                ? (!empty($saved['customerServiceEnabled']) ? 1 : 0)
+                : $defaults['customerServiceEnabled'],
+        ];
+    }
+
+    /**
+     * 保存顶部导航与搜索配置(不含门店名称)
+     * 同时把模块开关写回 modules Redis,与门店首页配置列表保持同步
+     *
+     * @param int $mainId
+     * @param array $data
+     * @return bool
+     */
+    public static function saveTopNav($mainId, $data)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        if (!is_array($data)) {
+            util::fail('参数错误');
+        }
+
+        $defaults = self::getDefaultTopNav();
+        $enabled = !empty($data['enabled']) ? 1 : 0;
+        // 详情 Redis 只存样式类字段;开关统一落 modules
+        $payload = [
+            'placeholder' => isset($data['placeholder']) ? trim(strval($data['placeholder'])) : $defaults['placeholder'],
+            'searchBtnColor' => isset($data['searchBtnColor']) ? strval($data['searchBtnColor']) : $defaults['searchBtnColor'],
+            'searchFontColor' => isset($data['searchFontColor']) ? strval($data['searchFontColor']) : $defaults['searchFontColor'],
+            'customerServiceEnabled' => !empty($data['customerServiceEnabled']) ? 1 : 0,
+        ];
+        if ($payload['placeholder'] === '') {
+            util::fail('请输入搜索占位文案');
+        }
+        if (mb_strlen($payload['placeholder']) > 50) {
+            util::fail('搜索占位文案不能超过50字');
+        }
+
+        Yii::$app->redis->executeCommand('SET', [
+            self::getTopNavKey($mainId),
+            json_encode($payload, JSON_UNESCAPED_UNICODE),
+        ]);
+        // 同步到门店首页配置列表的 topNav 开关
+        self::updateModuleEnabled($mainId, 'topNav', $enabled);
+        return true;
+    }
+
+    /**
+     * 读取首页轮播图配置;模块开关以 modules Redis 为准
+     *
+     * @param int $mainId
+     * @return array {enabled, interval, list:[{img,type,value}]}
+     */
+    public static function getBanner($mainId)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 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;
+            }
+        }
+
+        $list = [];
+        if (!empty($saved['list']) && is_array($saved['list'])) {
+            foreach ($saved['list'] as $item) {
+                $img = isset($item['img']) ? trim(strval($item['img'])) : '';
+                $type = isset($item['type']) ? intval($item['type']) : self::BANNER_TYPE_PIC_TEXT;
+                $value = isset($item['value']) ? trim(strval($item['value'])) : '';
+                if ($img === '' && $value === '') {
+                    continue;
+                }
+                if (!in_array($type, self::getBannerTypes(), true)) {
+                    $type = self::BANNER_TYPE_PIC_TEXT;
+                }
+                $list[] = [
+                    'img' => $img,
+                    'type' => $type,
+                    'value' => $value,
+                ];
+            }
+        }
+        if (count($list) > self::MAX_BANNER_COUNT) {
+            $list = array_slice($list, 0, self::MAX_BANNER_COUNT);
+        }
+
+        $interval = isset($saved['interval']) ? intval($saved['interval']) : $defaults['interval'];
+        if ($interval < 1) {
+            $interval = $defaults['interval'];
+        }
+        if ($interval > 60) {
+            $interval = 60;
+        }
+
+        return [
+            'enabled' => self::getModuleEnabled($mainId, 'banner'),
+            'interval' => $interval,
+            'list' => $list,
+        ];
+    }
+
+    /**
+     * 保存首页轮播图配置,并同步模块开关;会删除本次被移除的 OSS 图片
+     *
+     * @param int $mainId
+     * @param array $data
+     * @return bool
+     */
+    public static function saveBanner($mainId, $data)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        if (!is_array($data)) {
+            util::fail('参数错误');
+        }
+
+        $enabled = !empty($data['enabled']) ? 1 : 0;
+        $interval = isset($data['interval']) ? intval($data['interval']) : 3;
+        if ($interval < 1) {
+            util::fail('轮播间隔时间至少1秒');
+        }
+        if ($interval > 60) {
+            util::fail('轮播间隔时间不能超过60秒');
+        }
+
+        $rawList = isset($data['list']) ? $data['list'] : [];
+        if (is_string($rawList)) {
+            $decoded = json_decode($rawList, true);
+            $rawList = is_array($decoded) ? $decoded : [];
+        }
+        if (!is_array($rawList)) {
+            util::fail('轮播图数据格式错误');
+        }
+        if (count($rawList) > self::MAX_BANNER_COUNT) {
+            util::fail('最多可配置' . self::MAX_BANNER_COUNT . '张轮播图');
+        }
+
+        $list = [];
+        foreach ($rawList as $index => $item) {
+            if (!is_array($item)) {
+                util::fail('轮播图数据格式错误');
+            }
+            $img = isset($item['img']) ? trim(strval($item['img'])) : '';
+            $type = isset($item['type']) ? intval($item['type']) : 0;
+            $value = isset($item['value']) ? trim(strval($item['value'])) : '';
+            $no = $index + 1;
+            if ($img === '' && $value === '') {
+                continue;
+            }
+            if ($img === '') {
+                util::fail("请上传轮播图{$no}的图片");
+            }
+            if ($value === '') {
+                util::fail("请完善轮播图{$no}的关联内容");
+            }
+            if (!in_array($type, self::getBannerTypes(), true)) {
+                util::fail("轮播图{$no}关联类型无效");
+            }
+            // 仅允许本项目轮播图目录下的相对路径,防止误删其它资源
+            if (strpos($img, self::BANNER_OSS_ROOT . '/') !== 0) {
+                util::fail("轮播图{$no}图片路径无效");
+            }
+            $list[] = [
+                'img' => $img,
+                'type' => $type,
+                'value' => $value,
+            ];
+        }
+
+        // 对比旧配置,删除本次不再使用的 OSS 图片
+        $old = self::getBanner($mainId);
+        $oldImgs = [];
+        foreach ($old['list'] as $oldItem) {
+            if (!empty($oldItem['img'])) {
+                $oldImgs[$oldItem['img']] = true;
+            }
+        }
+        $newImgs = [];
+        foreach ($list as $newItem) {
+            $newImgs[$newItem['img']] = true;
+        }
+        $removeImgs = [];
+        foreach ($oldImgs as $imgPath => $flag) {
+            if (!isset($newImgs[$imgPath])) {
+                $removeImgs[] = $imgPath;
+            }
+        }
+        self::deleteBannerOssImages($removeImgs);
+
+        $payload = [
+            'interval' => $interval,
+            'list' => $list,
+        ];
+        Yii::$app->redis->executeCommand('SET', [
+            self::getBannerKey($mainId),
+            json_encode($payload, JSON_UNESCAPED_UNICODE),
+        ]);
+        self::updateModuleEnabled($mainId, 'banner', $enabled);
+        return true;
+    }
+
+    /**
+     * 删除轮播图 OSS 对象(仅允许 uploads_home_banner 目录)
+     *
+     * @param array $filePaths
+     * @return void
+     */
+    public static function deleteBannerOssImages($filePaths)
+    {
+        if (empty($filePaths) || !is_array($filePaths)) {
+            return;
+        }
+        $safePaths = [];
+        foreach ($filePaths as $path) {
+            $path = ltrim(strval($path), '/');
+            if ($path === '' || strpos($path, self::BANNER_OSS_ROOT . '/') !== 0) {
+                continue;
+            }
+            if (strpos($path, '..') !== false) {
+                continue;
+            }
+            $safePaths[] = $path;
+        }
+        if (empty($safePaths)) {
+            return;
+        }
+        // 仅删除仍存在的对象,避免前端已即时删除后保存时再次报错
+        $existPaths = [];
+        foreach ($safePaths as $path) {
+            try {
+                if (\common\components\oss::fileExist($path)) {
+                    $existPaths[] = $path;
+                }
+            } catch (\Exception $e) {
+                Yii::error('检查轮播图 OSS 是否存在失败: ' . $e->getMessage());
+            }
+        }
+        if (empty($existPaths)) {
+            return;
+        }
+        try {
+            \common\components\oss::deleteObjects($existPaths);
+        } catch (\Exception $e) {
+            Yii::error('删除首页轮播图 OSS 失败: ' . $e->getMessage());
+        }
+    }
+}

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

@@ -0,0 +1,165 @@
+<?php
+
+namespace bizHd\homePageConfig\classes;
+
+use biz\shop\classes\ShopClass;
+use common\components\business;
+
+/**
+ * 门店首页"展示数据"组装类
+ * 把 HomePageConfigClass/HomePageModuleClass 存的原始配置,格式化为可直接渲染的结构
+ * (拼好图片完整URL、过滤未开启/未上架内容、解析出真实商品列表等)。
+ * 供 app-mall(顾客端商城首页)与 app-hd(商家端配置预览)共用同一套数据,
+ * 保证"预览"看到的效果与顾客端真实首页完全一致。
+ */
+class HomePageDisplayClass
+{
+    /**
+     * 一次性组装首页全部模块的展示数据
+     *
+     * @param int $mainId
+     * @return array
+     */
+    public static function buildHome($mainId)
+    {
+        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'),
+        ];
+    }
+
+    /**
+     * 格式化顶部导航:门店名称不落 Redis,实时取 xhShop.merchantName
+     *
+     * @param int $mainId 门店 shopId
+     * @param array $data HomePageConfigClass::getTopNav 返回值
+     * @return array
+     */
+    public static function formatTopNav($mainId, $data)
+    {
+        $shop = ShopClass::getById($mainId, false, 'merchantName');
+        $data['merchantName'] = is_array($shop) ? ($shop['merchantName'] ?? '') : '';
+        return $data;
+    }
+
+    /**
+     * 格式化轮播图:关闭时清空列表;开启时补上完整图片URL
+     *
+     * @param array $data HomePageConfigClass::getBanner 返回值
+     * @return array
+     */
+    public static function formatBanner($data)
+    {
+        if (empty($data['enabled'])) {
+            return ['enabled' => 0, 'interval' => intval($data['interval'] ?? 3), 'list' => []];
+        }
+        $list = [];
+        foreach ($data['list'] as $item) {
+            $img = $item['img'] ?? '';
+            $formatted = $img !== '' ? business::formatUploadImg($img) : ['url' => ''];
+            $list[] = [
+                'img' => $img,
+                'imgUrl' => $formatted['url'] ?? '',
+                'type' => intval($item['type'] ?? 1),
+                'value' => strval($item['value'] ?? ''),
+            ];
+        }
+        return ['enabled' => 1, 'interval' => intval($data['interval'] ?? 3), 'list' => $list];
+    }
+
+    /**
+     * 格式化金刚区:关闭时清空列表;开启时过滤未启用的单项,补上图标完整URL
+     *
+     * @param array $data HomePageModuleClass::getNavGrid 返回值
+     * @return array
+     */
+    public static function formatNavGrid($data)
+    {
+        if (empty($data['enabled'])) {
+            return ['enabled' => 0, 'cols' => intval($data['cols'] ?? 5), 'list' => []];
+        }
+        $list = [];
+        foreach ($data['list'] as $item) {
+            if (empty($item['enabled'])) {
+                continue;
+            }
+            $icon = $item['icon'] ?? '';
+            $iconUrl = $icon;
+            // 上传图标转完整 URL;预设图标前端用本地映射,原样返回
+            if (intval($item['iconType'] ?? 1) === 2 && $icon !== '' && strpos($icon, 'http') !== 0) {
+                $formatted = business::formatUploadImg($icon);
+                $iconUrl = $formatted['url'] ?? $icon;
+            }
+            $list[] = [
+                'id' => $item['id'],
+                'name' => $item['name'],
+                'icon' => $icon,
+                'iconUrl' => $iconUrl,
+                'iconType' => intval($item['iconType'] ?? 1),
+                'type' => intval($item['type'] ?? 3),
+                'value' => strval($item['value'] ?? ''),
+            ];
+        }
+        return ['enabled' => 1, 'cols' => intval($data['cols'] ?? 5), 'list' => $list];
+    }
+
+    /**
+     * 格式化秒杀/团购活动:只返回上架中的活动商品,并补上封面完整URL
+     *
+     * @param array $data HomePageModuleClass::getSeckill/getGroupBuy 返回值
+     * @return array
+     */
+    public static function formatActivity($data)
+    {
+        if (empty($data['enabled'])) {
+            $data['goods'] = [];
+            return $data;
+        }
+        $goods = [];
+        foreach ($data['goods'] as $item) {
+            if (empty($item['status'])) {
+                continue;
+            }
+            $cover = $item['cover'] ?? '';
+            if ($cover !== '' && strpos($cover, 'http') !== 0) {
+                $formatted = business::formatUploadImg($cover);
+                $item['coverUrl'] = $formatted['url'] ?? $cover;
+            } else {
+                $item['coverUrl'] = $cover;
+            }
+            $goods[] = $item;
+        }
+        $data['goods'] = $goods;
+        return $data;
+    }
+
+    /**
+     * 格式化热门推荐/今日上新/下拉商品:关闭时清空商品;开启时按配置解析出真实商品列表
+     *
+     * @param int $mainId
+     * @param string $moduleKey hot|new|pullGoods
+     * @return array
+     */
+    public static function formatGoodsSection($mainId, $moduleKey)
+    {
+        $data = HomePageModuleClass::getGoodsSection($mainId, $moduleKey);
+        if (empty($data['enabled'])) {
+            $data['goods'] = [];
+            return $data;
+        }
+        $data['goods'] = HomePageModuleClass::resolveDisplayGoods(
+            $mainId,
+            $data['type'],
+            $data['value'],
+            $data['sort']
+        );
+        return $data;
+    }
+}

+ 710 - 0
biz-hd/homePageConfig/classes/HomePageModuleClass.php

@@ -0,0 +1,710 @@
+<?php
+
+namespace bizHd\homePageConfig\classes;
+
+use bizHd\goods\classes\GoodsClass;
+use bizHd\goods\classes\GoodsCategoryClass;
+use bizHd\goods\classes\GoodsUseCaseClass;
+use common\components\imgUtil;
+use common\components\util;
+use Yii;
+
+/**
+ * 门店首页其余模块配置(金刚区/秒杀/团购/热门/上新/下拉商品)
+ * 数据按 mainId 存 Redis,供 hdApp 配置、mallApp 读取展示。
+ */
+class HomePageModuleClass
+{
+    const REDIS_NAV_GRID = '_home_page_config_nav_grid';
+    const REDIS_SECKILL = '_home_page_config_seckill';
+    const REDIS_GROUP_BUY = '_home_page_config_group_buy';
+    const REDIS_HOT = '_home_page_config_hot';
+    const REDIS_NEW = '_home_page_config_new';
+    const REDIS_PULL_GOODS = '_home_page_config_pull_goods';
+
+    const NAV_OSS_ROOT = 'uploads_home_nav';
+    const MAX_NAV_COUNT = 20;
+
+    /** 关联:商品 */
+    const LINK_GOODS = 2;
+    /** 关联:分类 */
+    const LINK_CATEGORY = 3;
+    /** 关联:场景 */
+    const LINK_USE_CASE = 4;
+
+    /** 排序:按商品排序 */
+    const SORT_PRODUCT = 1;
+    /** 排序:按销量 */
+    const SORT_SALES = 2;
+    /** 排序:按上新时间 */
+    const SORT_NEW = 3;
+
+    /** 排列规则合法取值:1排1列/1排2列/1排3列 */
+    const LAYOUT_COLS_OPTIONS = [1, 2, 3];
+    /** 排列规则默认值 */
+    const DEFAULT_LAYOUT_COLS = 3;
+    /** 首页展示商品解析默认条数上限 */
+    const DISPLAY_GOODS_LIMIT = 20;
+
+    public static function redisKey($mainId, $suffix)
+    {
+        return intval($mainId) . $suffix;
+    }
+
+    public static function getJson($mainId, $suffix)
+    {
+        $raw = Yii::$app->redis->executeCommand('GET', [self::redisKey($mainId, $suffix)]);
+        if (empty($raw)) {
+            return [];
+        }
+        $data = json_decode($raw, true);
+        return is_array($data) ? $data : [];
+    }
+
+    public static function setJson($mainId, $suffix, $payload)
+    {
+        Yii::$app->redis->executeCommand('SET', [
+            self::redisKey($mainId, $suffix),
+            json_encode($payload, JSON_UNESCAPED_UNICODE),
+        ]);
+    }
+
+    public static function decodeList($raw)
+    {
+        if (is_string($raw)) {
+            $decoded = json_decode($raw, true);
+            return is_array($decoded) ? $decoded : [];
+        }
+        return is_array($raw) ? $raw : [];
+    }
+
+    // -------------------- 金刚区 --------------------
+
+    public static function getNavGrid($mainId)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $saved = self::getJson($mainId, self::REDIS_NAV_GRID);
+        $cols = isset($saved['cols']) ? intval($saved['cols']) : 5;
+        if (!in_array($cols, [4, 5], true)) {
+            $cols = 5;
+        }
+        $list = [];
+        foreach (self::decodeList($saved['list'] ?? []) as $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $list[] = [
+                'id' => strval($item['id'] ?? ''),
+                'name' => strval($item['name'] ?? ''),
+                'icon' => strval($item['icon'] ?? ''),
+                'iconType' => intval($item['iconType'] ?? 1),
+                'enabled' => !empty($item['enabled']) ? 1 : 0,
+                'type' => intval($item['type'] ?? self::LINK_CATEGORY),
+                'value' => strval($item['value'] ?? ''),
+            ];
+        }
+        return [
+            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, 'navGrid'),
+            'cols' => $cols,
+            'list' => $list,
+        ];
+    }
+
+    public static function saveNavGrid($mainId, $data)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $enabled = !empty($data['enabled']) ? 1 : 0;
+        $cols = isset($data['cols']) ? intval($data['cols']) : 5;
+        if (!in_array($cols, [4, 5], true)) {
+            util::fail('排列规则无效');
+        }
+        $rawList = self::decodeList($data['list'] ?? []);
+        if (count($rawList) > self::MAX_NAV_COUNT) {
+            util::fail('最多可配置' . self::MAX_NAV_COUNT . '个导航');
+        }
+        $list = [];
+        foreach ($rawList as $index => $item) {
+            if (!is_array($item)) {
+                continue;
+            }
+            $name = trim(strval($item['name'] ?? ''));
+            $icon = trim(strval($item['icon'] ?? ''));
+            $type = intval($item['type'] ?? 0);
+            $value = trim(strval($item['value'] ?? ''));
+            $no = $index + 1;
+            if ($name === '' || $icon === '' || $value === '') {
+                util::fail("请完善第{$no}个导航项");
+            }
+            if (!in_array($type, [self::LINK_CATEGORY, self::LINK_USE_CASE], true)) {
+                util::fail("第{$no}个导航关联类型无效");
+            }
+            $list[] = [
+                'id' => strval($item['id'] ?? ('nav_' . ($index + 1))),
+                'name' => mb_substr($name, 0, 20),
+                'icon' => $icon,
+                'iconType' => intval($item['iconType'] ?? 1) === 2 ? 2 : 1,
+                'enabled' => !empty($item['enabled']) ? 1 : 0,
+                'type' => $type,
+                'value' => $value,
+            ];
+        }
+        self::setJson($mainId, self::REDIS_NAV_GRID, ['cols' => $cols, 'list' => $list]);
+        HomePageConfigClass::updateModuleEnabled($mainId, 'navGrid', $enabled);
+        return true;
+    }
+
+    // -------------------- 秒杀 --------------------
+
+    public static function getSeckill($mainId, $refreshStock = true)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $saved = self::getJson($mainId, self::REDIS_SECKILL);
+        $data = self::normalizeActivityBase($saved);
+        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'seckill');
+        $data['goods'] = self::normalizeSeckillGoods($saved['goods'] ?? [], $mainId, $refreshStock);
+        $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']);
+        return $data;
+    }
+
+    public static function saveSeckill($mainId, $data)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $base = self::validateActivityBase($data);
+        $goods = self::validateSeckillGoods($mainId, self::decodeList($data['goods'] ?? []));
+        self::setJson($mainId, self::REDIS_SECKILL, array_merge($base, ['goods' => $goods]));
+        HomePageConfigClass::updateModuleEnabled($mainId, 'seckill', !empty($data['enabled']) ? 1 : 0);
+        return true;
+    }
+
+    /**
+     * 校验并规范化秒杀商品;秒杀库存不得超过实际商品库存
+     */
+    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);
+            if ($goodsId <= 0) {
+                util::fail("请选择第{$no}个秒杀商品");
+            }
+            if ($price <= 0) {
+                util::fail("请填写第{$no}个秒杀价格");
+            }
+            if ($stock <= 0) {
+                util::fail("请填写第{$no}个秒杀库存");
+            }
+            if ($limit <= 0) {
+                util::fail("请填写第{$no}个单人限购");
+            }
+            $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;
+            }
+            $list[] = [
+                'goodsId' => $goodsId,
+                'price' => round($price, 2),
+                'stock' => $stock,
+                'limit' => $limit,
+                'status' => $status,
+                'name' => strval($goods->name ?? ($item['name'] ?? '')),
+                'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
+                'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
+            ];
+        }
+        return $list;
+    }
+
+    /**
+     * 读取时刷新秒杀商品状态:实际库存低于秒杀库存则下架,并回写 Redis
+     */
+    public static function normalizeSeckillGoods($rawList, $mainId, $refreshStock = true)
+    {
+        $list = [];
+        $changed = false;
+        foreach (self::decodeList($rawList) as $item) {
+            if (!is_array($item) || empty($item['goodsId'])) {
+                continue;
+            }
+            $row = [
+                'goodsId' => intval($item['goodsId']),
+                'price' => floatval($item['price'] ?? 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'] ?? ''),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'realStock' => intval($item['stock'] ?? 0),
+            ];
+            if ($refreshStock) {
+                $goods = GoodsClass::getById($row['goodsId'], true);
+                if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
+                    $realStock = intval($goods->stock ?? 0);
+                    $row['realStock'] = $realStock;
+                    $row['name'] = strval($goods->name ?? $row['name']);
+                    $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;
+                        $changed = true;
+                    }
+                }
+            }
+            $list[] = $row;
+        }
+        if ($changed && $refreshStock) {
+            $saved = self::getJson($mainId, self::REDIS_SECKILL);
+            $persist = [];
+            foreach ($list as $g) {
+                $persist[] = [
+                    'goodsId' => $g['goodsId'],
+                    'price' => $g['price'],
+                    'stock' => $g['stock'],
+                    'limit' => $g['limit'],
+                    'status' => $g['status'],
+                    'name' => $g['name'],
+                    'cover' => $g['cover'],
+                    'originPrice' => $g['originPrice'],
+                ];
+            }
+            $saved['goods'] = $persist;
+            self::setJson($mainId, self::REDIS_SECKILL, $saved);
+        }
+        return $list;
+    }
+
+    // -------------------- 团购 --------------------
+
+    public static function getGroupBuy($mainId, $refreshStock = true)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $saved = self::getJson($mainId, self::REDIS_GROUP_BUY);
+        $data = self::normalizeActivityBase($saved);
+        $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
+        $data['goods'] = self::normalizeGroupBuyGoods($saved['goods'] ?? [], $mainId, $refreshStock);
+        $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']);
+        return $data;
+    }
+
+    public static function saveGroupBuy($mainId, $data)
+    {
+        $mainId = intval($mainId);
+        if ($mainId <= 0) {
+            util::fail('无效门店');
+        }
+        $base = self::validateActivityBase($data);
+        $goods = self::validateGroupBuyGoods($mainId, self::decodeList($data['goods'] ?? []));
+        self::setJson($mainId, self::REDIS_GROUP_BUY, array_merge($base, ['goods' => $goods]));
+        HomePageConfigClass::updateModuleEnabled($mainId, 'groupBuy', !empty($data['enabled']) ? 1 : 0);
+        return true;
+    }
+
+    public static function validateGroupBuyGoods($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);
+            $groupSize = intval($item['groupSize'] ?? 3);
+            if (!in_array($groupSize, [2, 3, 5], true)) {
+                util::fail("第{$no}个成团人数无效");
+            }
+            if ($goodsId <= 0 || $price <= 0 || $stock <= 0 || $limit <= 0) {
+                util::fail("请完善第{$no}个团购商品");
+            }
+            $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})");
+            }
+            $virtualGroup = !empty($item['virtualGroup']) ? 1 : 0;
+            $virtualMinutes = intval($item['virtualMinutes'] ?? 0);
+            if ($virtualGroup && $virtualMinutes <= 0) {
+                util::fail("请填写第{$no}个虚拟成团时间");
+            }
+            $list[] = [
+                'goodsId' => $goodsId,
+                'price' => round($price, 2),
+                'stock' => $stock,
+                'limit' => $limit,
+                'groupSize' => $groupSize,
+                'virtualGroup' => $virtualGroup,
+                'virtualMinutes' => $virtualMinutes,
+                'autoRefund' => !empty($item['autoRefund']) ? 1 : 0,
+                'status' => ($realStock < $stock) ? 0 : (!empty($item['status']) ? 1 : 0),
+                'name' => strval($goods->name ?? ($item['name'] ?? '')),
+                'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
+                'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
+            ];
+        }
+        return $list;
+    }
+
+    public static function normalizeGroupBuyGoods($rawList, $mainId, $refreshStock = true)
+    {
+        $list = [];
+        $changed = false;
+        foreach (self::decodeList($rawList) as $item) {
+            if (!is_array($item) || empty($item['goodsId'])) {
+                continue;
+            }
+            $row = [
+                'goodsId' => intval($item['goodsId']),
+                'price' => floatval($item['price'] ?? 0),
+                'stock' => intval($item['stock'] ?? 0),
+                'limit' => intval($item['limit'] ?? 0),
+                'groupSize' => intval($item['groupSize'] ?? 3),
+                'virtualGroup' => !empty($item['virtualGroup']) ? 1 : 0,
+                'virtualMinutes' => intval($item['virtualMinutes'] ?? 0),
+                'autoRefund' => !empty($item['autoRefund']) ? 1 : 0,
+                'status' => !empty($item['status']) ? 1 : 0,
+                'name' => strval($item['name'] ?? ''),
+                'cover' => strval($item['cover'] ?? ''),
+                'originPrice' => floatval($item['originPrice'] ?? 0),
+                'realStock' => intval($item['stock'] ?? 0),
+            ];
+            if ($refreshStock) {
+                $goods = GoodsClass::getById($row['goodsId'], true);
+                if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
+                    $realStock = intval($goods->stock ?? 0);
+                    $row['realStock'] = $realStock;
+                    $row['name'] = strval($goods->name ?? $row['name']);
+                    $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;
+                        $changed = true;
+                    }
+                }
+            }
+            $list[] = $row;
+        }
+        if ($changed && $refreshStock) {
+            $saved = self::getJson($mainId, self::REDIS_GROUP_BUY);
+            $persist = [];
+            foreach ($list as $g) {
+                unset($g['realStock']);
+                $persist[] = $g;
+            }
+            $saved['goods'] = $persist;
+            self::setJson($mainId, self::REDIS_GROUP_BUY, $saved);
+        }
+        return $list;
+    }
+
+    // -------------------- 热门/上新/下拉 --------------------
+
+    public static function getGoodsSection($mainId, $moduleKey)
+    {
+        $mainId = intval($mainId);
+        $suffix = self::sectionSuffix($moduleKey);
+        $saved = self::getJson($mainId, $suffix);
+        $defaults = [
+            'hot' => '热门推荐',
+            'new' => '今日上新',
+            'pullGoods' => '更多商品',
+        ];
+        return [
+            'enabled' => HomePageConfigClass::getModuleEnabled($mainId, $moduleKey),
+            'name' => strval($saved['name'] ?? ($defaults[$moduleKey] ?? '')),
+            'type' => intval($saved['type'] ?? self::LINK_GOODS),
+            'value' => strval($saved['value'] ?? ''),
+            'sort' => intval($saved['sort'] ?? self::SORT_PRODUCT),
+            'layoutCols' => self::normalizeLayoutCols($saved['layoutCols'] ?? null),
+        ];
+    }
+
+    public static function saveGoodsSection($mainId, $moduleKey, $data)
+    {
+        $mainId = intval($mainId);
+        $suffix = self::sectionSuffix($moduleKey);
+        $name = trim(strval($data['name'] ?? ''));
+        $type = intval($data['type'] ?? 0);
+        $value = trim(strval($data['value'] ?? ''));
+        $sort = intval($data['sort'] ?? self::SORT_PRODUCT);
+        $layoutCols = intval($data['layoutCols'] ?? self::DEFAULT_LAYOUT_COLS);
+        if ($name === '') {
+            util::fail('请输入首页展示名称');
+        }
+        if (mb_strlen($name) > 20) {
+            util::fail('展示名称不能超过20字');
+        }
+        if (!in_array($type, [self::LINK_GOODS, self::LINK_CATEGORY, self::LINK_USE_CASE], true)) {
+            util::fail('关联类型无效');
+        }
+        if ($value === '') {
+            util::fail('请选择关联内容');
+        }
+        if (!in_array($sort, [self::SORT_PRODUCT, self::SORT_SALES, self::SORT_NEW], true)) {
+            util::fail('排序规则无效');
+        }
+        if (!in_array($layoutCols, self::LAYOUT_COLS_OPTIONS, true)) {
+            util::fail('排列规则无效');
+        }
+        self::setJson($mainId, $suffix, [
+            'name' => $name,
+            'type' => $type,
+            'value' => $value,
+            'sort' => $sort,
+            'layoutCols' => $layoutCols,
+        ]);
+        HomePageConfigClass::updateModuleEnabled($mainId, $moduleKey, !empty($data['enabled']) ? 1 : 0);
+        return true;
+    }
+
+    /**
+     * 规范化排列规则取值,非法/缺省时回退默认值
+     */
+    public static function normalizeLayoutCols($cols)
+    {
+        $cols = intval($cols);
+        return in_array($cols, self::LAYOUT_COLS_OPTIONS, true) ? $cols : self::DEFAULT_LAYOUT_COLS;
+    }
+
+    /**
+     * 将热门推荐/今日上新/下拉商品的关联配置(type+value)解析为真实商品列表,用于首页展示
+     * type=2 商品:value 为商品ID逗号拼接,按选择顺序展示
+     * type=3 分类:value 为分类ID逗号拼接,取分类下全部商品
+     * type=4 场景:value 为场景ID逗号拼接,取场景下全部商品
+     * sort=1 按上面解析出的原始顺序;sort=2 按销量(虚拟+实际)降序;sort=3 按上架时间降序
+     *
+     * @param int $mainId
+     * @param int $type
+     * @param string $value
+     * @param int $sort
+     * @param int $limit
+     * @return array [{id,name,price,stock,cover,coverUrl,sold}]
+     */
+    public static function resolveDisplayGoods($mainId, $type, $value, $sort, $limit = self::DISPLAY_GOODS_LIMIT)
+    {
+        $mainId = intval($mainId);
+        $type = intval($type);
+        $value = trim(strval($value));
+        if ($mainId <= 0 || $value === '') {
+            return [];
+        }
+
+        $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', $value)))));
+        if (empty($ids)) {
+            return [];
+        }
+
+        $goodsIds = [];
+        if ($type === self::LINK_GOODS) {
+            // 商品类型:保留用户选择顺序
+            $goodsIds = $ids;
+        } elseif ($type === self::LINK_CATEGORY) {
+            $rows = GoodsCategoryClass::getAllByCondition(
+                ['cId' => ['in', $ids], 'delStatus' => 0],
+                null,
+                'gId'
+            );
+            $goodsIds = array_values(array_unique(array_map('intval', array_column($rows, 'gId'))));
+        } elseif ($type === self::LINK_USE_CASE) {
+            $rows = GoodsUseCaseClass::getAllByCondition(
+                ['useCaseId' => ['in', $ids]],
+                null,
+                'goodsId'
+            );
+            $goodsIds = array_values(array_unique(array_map('intval', array_column($rows, 'goodsId'))));
+        }
+        if (empty($goodsIds)) {
+            return [];
+        }
+
+        // 注意:Base::getByIds 传入 $order 会走到未定义的 order() 方法而报错,这里统一取回后在 PHP 侧排序
+        $rows = GoodsClass::getByIds($goodsIds, null, null, 'id,name,price,stock,cover,sold,actualSold,status,delStatus,masterId,mainId,createTime');
+        // 仅取当前门店、未删除、上架中的主规格商品
+        $rows = array_values(array_filter($rows, function ($row) use ($mainId) {
+            return intval($row['mainId'] ?? 0) === $mainId
+                && intval($row['delStatus'] ?? 0) === 0
+                && intval($row['status'] ?? 0) === 1
+                && intval($row['masterId'] ?? 0) === 0;
+        }));
+
+        if ($sort == self::SORT_SALES) {
+            usort($rows, function ($a, $b) {
+                $soldA = floatval($a['actualSold'] ?? 0) + floatval($a['sold'] ?? 0);
+                $soldB = floatval($b['actualSold'] ?? 0) + floatval($b['sold'] ?? 0);
+                return $soldB <=> $soldA;
+            });
+        } elseif ($sort == self::SORT_NEW) {
+            usort($rows, function ($a, $b) {
+                return strtotime($b['createTime'] ?? '') <=> strtotime($a['createTime'] ?? '');
+            });
+        } elseif ($type === self::LINK_GOODS) {
+            // 商品类型按用户选择顺序重新排列;分类/场景按商品排序时无自定义顺序可依,保持数据库默认返回顺序
+            $indexBy = [];
+            foreach ($rows as $row) {
+                $indexBy[intval($row['id'])] = $row;
+            }
+            $ordered = [];
+            foreach ($goodsIds as $gid) {
+                if (isset($indexBy[$gid])) {
+                    $ordered[] = $indexBy[$gid];
+                }
+            }
+            $rows = $ordered;
+        }
+
+        if ($limit > 0 && count($rows) > $limit) {
+            $rows = array_slice($rows, 0, $limit);
+        }
+
+        $list = [];
+        foreach ($rows as $row) {
+            $cover = strval($row['cover'] ?? '');
+            $coverUrl = $cover !== '' ? imgUtil::groupImg($cover) . '?x-oss-process=image/resize,m_fill,h_700,w_700' : '';
+            $list[] = [
+                'id' => intval($row['id']),
+                'name' => strval($row['name'] ?? ''),
+                'price' => floatval($row['price'] ?? 0),
+                'stock' => intval($row['stock'] ?? 0),
+                'cover' => $cover,
+                'coverUrl' => $coverUrl,
+                'sold' => intval(bcadd($row['actualSold'] ?? 0, $row['sold'] ?? 0)),
+            ];
+        }
+        return $list;
+    }
+
+    public static function sectionSuffix($moduleKey)
+    {
+        $map = [
+            'hot' => self::REDIS_HOT,
+            'new' => self::REDIS_NEW,
+            'pullGoods' => self::REDIS_PULL_GOODS,
+        ];
+        if (!isset($map[$moduleKey])) {
+            util::fail('无效模块');
+        }
+        return $map[$moduleKey];
+    }
+
+    // -------------------- 活动公共 --------------------
+
+    public static function normalizeActivityBase($saved)
+    {
+        return [
+            'title' => strval($saved['title'] ?? ''),
+            'subtitle' => strval($saved['subtitle'] ?? ''),
+            'showCountdown' => !empty($saved['showCountdown']) ? 1 : 0,
+            'expandHome' => !empty($saved['expandHome']) ? 1 : 0,
+            'startTime' => intval($saved['startTime'] ?? 0),
+            'endTime' => intval($saved['endTime'] ?? 0),
+            'desc' => strval($saved['desc'] ?? ''),
+            'layoutCols' => self::normalizeLayoutCols($saved['layoutCols'] ?? null),
+        ];
+    }
+
+    public static function validateActivityBase($data)
+    {
+        $title = trim(strval($data['title'] ?? ''));
+        $subtitle = trim(strval($data['subtitle'] ?? ''));
+        $desc = trim(strval($data['desc'] ?? ''));
+        $startTime = intval($data['startTime'] ?? 0);
+        $endTime = intval($data['endTime'] ?? 0);
+        $layoutCols = intval($data['layoutCols'] ?? self::DEFAULT_LAYOUT_COLS);
+        if ($title === '') {
+            util::fail('请输入活动标题');
+        }
+        if (mb_strlen($title) > 10) {
+            util::fail('活动标题不能超过10字');
+        }
+        if ($subtitle === '') {
+            util::fail('请输入活动副标题');
+        }
+        if (mb_strlen($subtitle) > 20) {
+            util::fail('活动副标题不能超过20字');
+        }
+        if ($startTime <= 0 || $endTime <= 0) {
+            util::fail('请选择活动时间');
+        }
+        if ($endTime <= $startTime) {
+            util::fail('结束时间必须大于开始时间');
+        }
+        if ($desc === '') {
+            util::fail('请输入活动说明');
+        }
+        if (mb_strlen($desc) > 500) {
+            util::fail('活动说明不能超过500字');
+        }
+        if (!in_array($layoutCols, self::LAYOUT_COLS_OPTIONS, true)) {
+            util::fail('排列规则无效');
+        }
+        return [
+            'title' => $title,
+            'subtitle' => $subtitle,
+            'showCountdown' => !empty($data['showCountdown']) ? 1 : 0,
+            'expandHome' => !empty($data['expandHome']) ? 1 : 0,
+            'startTime' => $startTime,
+            'endTime' => $endTime,
+            'desc' => $desc,
+            'layoutCols' => $layoutCols,
+        ];
+    }
+
+    /**
+     * 活动状态:0未开始 1进行中 2已结束;模块关闭视为已结束展示用
+     */
+    public static function calcActivityStatus($startTime, $endTime, $enabled)
+    {
+        if (empty($enabled)) {
+            return 2;
+        }
+        $now = time();
+        $startTime = intval($startTime);
+        $endTime = intval($endTime);
+        if ($startTime <= 0 || $endTime <= 0) {
+            return 0;
+        }
+        if ($now < $startTime) {
+            return 0;
+        }
+        if ($now > $endTime) {
+            return 2;
+        }
+        return 1;
+    }
+}

+ 41 - 2
common/components/oss.php

@@ -8,17 +8,52 @@ use Yii;
 class oss
 {
     // 文件夹常量数组: 1. uploads_pic_text -- 图文专用文件夹  2.
-    const ROOT_PATHS = ['uploads_pic_text'];
+    /** 允许自定义的 OSS 根目录白名单 */
+    const ROOT_PATHS = ['uploads_pic_text', 'uploads_home_banner', 'uploads_home_nav'];
 
     //上传文件
     public static function uploadImage($object, $filePath)
     {
         $ossClient = self::getOssClient();
         $bucket = Yii::$app->params['ossBucket'];
+        if (empty($bucket)) {
+            util::fail('OSS Bucket 未配置');
+        }
+        if (empty($filePath) || !is_file($filePath) || !is_readable($filePath)) {
+            util::fail('本地上传文件不存在或不可读');
+        }
         try {
             $ossClient->uploadFile($bucket, $object, $filePath);
         } catch (OssException $e) {
-            util::fail($e->getMessage());
+            $httpStatus = $e->getHTTPStatus();
+            $errorCode = $e->getErrorCode();
+            $requestId = $e->getRequestId();
+            $body = (string)$e->getDetails();
+            Yii::error(sprintf(
+                'OSS uploadImage failed: object=%s bucket=%s http=%s code=%s requestId=%s msg=%s body=%s',
+                $object,
+                $bucket,
+                $httpStatus,
+                $errorCode,
+                $requestId,
+                $e->getMessage(),
+                substr($body, 0, 500)
+            ), __METHOD__);
+
+            // SDK 在空响应时 getMessage() 仅为 ": RequestId: ",补充可读信息
+            $msg = trim($e->getMessage());
+            if ($msg === '' || $msg === ': RequestId:' || preg_match('/^:\s*RequestId:\s*$/', $msg)) {
+                $parts = array_filter([
+                    $httpStatus !== '' ? ('HTTP ' . $httpStatus) : '',
+                    $errorCode !== '' ? $errorCode : '',
+                    $requestId !== '' ? ('RequestId: ' . $requestId) : '',
+                ]);
+                $msg = $parts ? ('OSS上传失败: ' . implode(', ', $parts)) : 'OSS上传失败,未返回错误详情';
+                if ($body !== '') {
+                    $msg .= ' (' . substr(strip_tags($body), 0, 120) . ')';
+                }
+            }
+            util::fail($msg);
         }
         return true;
     }
@@ -40,6 +75,10 @@ class oss
         $accessKeyId = Yii::$app->params['accessKeyId'];
         $accessKeySecret = Yii::$app->params['accessKeySecret'];
         $endpoint = Yii::$app->params['endpoint'];
+        // 未带协议时强制走 HTTPS,避免 HTTP:80 PUT 被中间网络截断(unexpected EOF)
+        if (strpos($endpoint, 'http://') !== 0 && strpos($endpoint, 'https://') !== 0) {
+            $endpoint = 'https://' . ltrim($endpoint, '/');
+        }
         try {
             $ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
             return $ossClient;