redis->executeCommand('GET', [self::redisKey($mainId, $str)]); if (empty($raw)) { return []; } $data = json_decode($raw, true); return is_array($data) ? $data : []; } public static function setJson($mainId, $str, $payload) { Yii::$app->redis->executeCommand('SET', [ self::redisKey($mainId, $str), 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, $shopId) { $mainId = intval($mainId); $shopId = intval($shopId); if ($shopId <= 0) { util::fail('无效门店'); } $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; } $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, $shopId, 'navGrid'), 'cols' => $cols, 'list' => $list, ]; } /** * 保存金刚区导航:落库 MySQL,并镜像写入 Redis;请求形态校验由 SaveNavGridForm 完成 * * @param int $mainId * @param int $shopId * @param array $payload {cols, list} * @param int $enabled * @return bool */ public static function saveNavGrid($mainId, $shopId, $payload, $enabled = 0) { $mainId = intval($mainId); $shopId = intval($shopId); if ($shopId <= 0) { util::fail('无效门店'); } if (!is_array($payload)) { util::fail('参数错误'); } $cols = isset($payload['cols']) ? intval($payload['cols']) : 5; $list = isset($payload['list']) && is_array($payload['list']) ? $payload['list'] : []; $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; } // -------------------- 秒杀 -------------------- /** * 读取秒杀专区:优先 MySQL 批次化持久化,无数据时回退 Redis */ public static function getSeckill($mainId, $shopId, $refreshStock = true) { return \bizHd\seckill\classes\SeckillActivityClass::getSeckill($mainId, $shopId, $refreshStock); } /** * 仅从 Redis 读取历史单例秒杀配置(迁移过渡 / 无 MySQL 批次时回退) * 秒杀活动数据本身仍按 mainId 存储(跨门店共享同一批活动),仅「开关」状态按 shopId 读取 */ public static function getSeckillFromRedis($mainId, $shopId, $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, $shopId, 'seckill'); $data['goods'] = self::normalizeSeckillGoods($saved['goods'] ?? [], $mainId, $refreshStock); // 按已添加商品数量 + 展开开关动态推导列数与展示数量(不依赖商家手动配置) $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']); $data['layoutCols'] = $layout['layoutCols']; $data['displayCount'] = $layout['displayCount']; $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']); return $data; } /** * 保存秒杀专区:落 MySQL 活动批次/商品版本,并同步 Redis * * @param int $mainId * @param int $shopId * @param array $base 已校验的活动基础字段 * @param array $goods 已校验的商品列表(形态) * @param int $enabled * @return bool */ public static function saveSeckill($mainId, $shopId, $base, $goods = [], $enabled = 0) { return \bizHd\seckill\classes\SeckillActivityClass::saveSeckill($mainId, $shopId, $base, $goods, $enabled); } /** * 业务校验并规范化秒杀商品:归属当前门店,秒杀库存不得超过实际商品库存 * 字段形态校验由 SaveSeckillForm 完成 */ public static function validateSeckillGoods($mainId, $rawList) { $list = []; foreach ($rawList as $index => $item) { if (!is_array($item)) { continue; } $no = $index + 1; $goodsId = intval($item['goodsId'] ?? 0); $price = floatval($item['price'] ?? 0); $stock = intval($item['stock'] ?? 0); $limit = intval($item['limit'] ?? 0); $goods = GoodsClass::getById($goodsId, true); if (empty($goods) || intval($goods->mainId ?? 0) !== intval($mainId)) { util::fail("第{$no}个秒杀商品无效"); } $realStock = intval($goods->stock ?? 0); // 秒杀库存不得超过实际库存 if ($stock > $realStock) { util::fail("第{$no}个秒杀库存不能超过商品实际库存({$realStock})"); } $status = !empty($item['status']) ? 1 : 0; // 实际库存已低于秒杀库存:自动下架该秒杀商品 if ($realStock < $stock) { $status = 0; } $list[] = [ 'goodsId' => $goodsId, 'price' => round($price, 2), 'stock' => $stock, 'limit' => $limit, 'status' => $status, 'name' => GoodsClass::formatSpecDisplayName($goods), '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 = [ 'id' => intval($item['id'] ?? 0), '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) === intval($mainId)) { $realStock = intval($goods->stock); $row['realStock'] = $realStock; $row['name'] = GoodsClass::formatSpecDisplayName($goods); $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover'])); $row['originPrice'] = floatval($goods->price ?? $row['originPrice']); $row['priceType'] = $goods->priceType; 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[] = [ 'id' => intval($g['id'] ?? 0), '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; } /** * 下单时校验秒杀商品:活动进行中、该商品已上架,返回其配置行(含真实秒杀价/库存/限购) * 供订单结算时独立核价,避免客户端伪造价格 * * @param int $mainId * @param int $shopId * @param int $goodsId 下单商品的实际销售单元id(普通商品即goodsId本身,多规格则为规格id) * @return array|null 匹配的秒杀商品配置,未命中/活动未进行中返回 null */ public static function getSeckillActiveRow($mainId, $shopId, $goodsId) { $data = self::getSeckill($mainId, $shopId, true); if (empty($data['enabled']) || intval($data['status'] ?? 0) !== 1) { return null; } $goodsId = intval($goodsId); foreach (($data['goods'] ?? []) as $item) { if (!is_array($item) || empty($item['status'])) { continue; } if (intval($item['goodsId'] ?? 0) === $goodsId) { return $item; } } return null; } /** * 秒杀商品累计已售数量(跨所有客户),用于校验是否超出活动库存 * 按 activityGoodsId(xhSeckillGoods.id)记数,改价新建版本后计数从 0 起 * * @param int $mainId * @param int $activityGoodsId xhSeckillGoods.id * @return float */ public static function getSeckillSoldCount($mainId, $activityGoodsId) { $key = self::SECKILL_SOLD_PREFIX . intval($mainId); $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($activityGoodsId)]); return floatval($val ?? 0); } /** * 单个客户在该秒杀商品版本上已购买的数量,用于校验单人限购 * * @param int $mainId * @param int $activityGoodsId xhSeckillGoods.id * @param int $customId * @return float */ public static function getSeckillCustomBoughtCount($mainId, $activityGoodsId, $customId) { $key = self::SECKILL_BUY_PREFIX . intval($mainId) . ':' . intval($activityGoodsId); $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($customId)]); return floatval($val ?? 0); } /** * 组装秒杀单人限购超限的返回 data,供结算页/购物车按商品高亮并展示超出件数。 * productId 与前端提交的花束 productId(规格 id 优先,否则主商品 id)对齐。 * * @param int $saleGoodsId 实际售卖商品 id * @param int $goodsId 主商品 id * @param int $specGoodsId 规格 id * @param float $seckillLimit 单人限购件数 * @param float $boughtCount 该客户已购件数 * @param float $goodsNum 本单购买件数 * @return array */ public static function buildSeckillLimitExceedData($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum) { $seckillLimit = floatval($seckillLimit); $boughtCount = floatval($boughtCount); $goodsNum = floatval($goodsNum); $wantTotal = bcadd($boughtCount, $goodsNum, 2); $exceed = bcsub($wantTotal, $seckillLimit, 2); if (bccomp($exceed, '0', 2) < 0) { $exceed = '0'; } return [ 'productId' => intval($saleGoodsId), 'goodsId' => intval($goodsId), 'specGoodsId' => intval($specGoodsId), 'property' => 0, 'activityType' => 'seckill', 'reason' => 'limit', 'limitBuy' => $seckillLimit, 'boughtCount' => $boughtCount, 'num' => $goodsNum, 'exceed' => floatval($exceed), ]; } /** * 秒杀单人限购超限:以 code=-1 返回商品标识与超出数量,与花材 handleLimitBuy 的错误形态对齐。 * 前端据此红框高亮对应商品,而不是只弹一句笼统提示。 * * @param int $saleGoodsId 实际售卖商品 id * @param int $goodsId 主商品 id * @param int $specGoodsId 规格 id * @param float $seckillLimit 单人限购件数 * @param float $boughtCount 该客户已购件数 * @param float $goodsNum 本单购买件数 */ public static function failSeckillLimitExceed($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum) { $data = self::buildSeckillLimitExceedData($saleGoodsId, $goodsId, $specGoodsId, $seckillLimit, $boughtCount, $goodsNum); $limitShow = rtrim(rtrim(sprintf('%.2f', $data['limitBuy']), '0'), '.'); util::error(-1, "秒杀商品每人限购{$limitShow}件,已超出可购买数量", $data); } /** * 预占秒杀名额:库存/限购校验通过后调用,累加"已售"与"该客户已购"计数, * 并记录本次请求的回滚快照——下单失败时通过 rollbackSeckillReservationSnapshot 撤销 * * @param int $mainId * @param int $activityGoodsId xhSeckillGoods.id * @param int $customId * @param float|int $num */ public static function reserveSeckillPurchase($mainId, $activityGoodsId, $customId, $num) { $mainId = intval($mainId); $activityGoodsId = intval($activityGoodsId); $customId = intval($customId); $num = floatval($num); if ($mainId <= 0 || $activityGoodsId <= 0 || $customId <= 0 || $num <= 0) { return; } $soldKey = self::SECKILL_SOLD_PREFIX . $mainId; $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $activityGoodsId; Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $activityGoodsId, $num]); Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, $num]); $snapshot = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? []; $snapshot[] = ['mainId' => $mainId, 'activityGoodsId' => $activityGoodsId, 'customId' => $customId, 'num' => $num]; Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] = $snapshot; } /** * 回滚当前请求中已预占的秒杀名额(下单失败/异常时调用) */ public static function rollbackSeckillReservationSnapshot() { $snapshotList = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? []; if (empty($snapshotList) || !is_array($snapshotList)) { return true; } foreach ($snapshotList as $snapshot) { $mainId = intval($snapshot['mainId'] ?? 0); // 兼容旧快照字段 goodsId(迁移前请求内可能仍写的是旧 key) $activityGoodsId = intval($snapshot['activityGoodsId'] ?? ($snapshot['goodsId'] ?? 0)); $customId = intval($snapshot['customId'] ?? 0); $num = floatval($snapshot['num'] ?? 0); if ($mainId <= 0 || $activityGoodsId <= 0 || $customId <= 0 || $num <= 0) { continue; } $soldKey = self::SECKILL_SOLD_PREFIX . $mainId; $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $activityGoodsId; Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $activityGoodsId, -$num]); Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, -$num]); } self::clearSeckillReservationSnapshot(); return true; } /** * 清理当前请求记录的秒杀预占快照(下单成功后调用) */ public static function clearSeckillReservationSnapshot() { unset(Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY]); } // -------------------- 团购 -------------------- /** * 读取团购专区:优先 MySQL 批次化持久化,无数据时回退 Redis */ public static function getGroupBuy($mainId, $shopId, $refreshStock = true) { return \bizHd\groupBuy\classes\GroupBuyActivityClass::getGroupBuy($mainId, $shopId, $refreshStock); } /** * 仅从 Redis 读取历史单例团购配置(迁移过渡 / 无 MySQL 批次时回退) * 团购活动数据本身仍按 mainId 存储(跨门店共享同一批活动),仅「开关」状态按 shopId 读取 */ public static function getGroupBuyFromRedis($mainId, $shopId, $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, $shopId, 'groupBuy'); $data['goods'] = self::normalizeGroupBuyGoods($saved['goods'] ?? [], $mainId, $refreshStock); $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']); $data['layoutCols'] = $layout['layoutCols']; $data['displayCount'] = $layout['displayCount']; $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']); return $data; } /** * 保存团购专区:落 MySQL 活动批次/商品版本,并同步 Redis * * @param int $mainId * @param int $shopId * @param array $base 已校验的活动基础字段 * @param array $goods 已校验的商品列表(形态) * @param int $enabled * @return bool */ public static function saveGroupBuy($mainId, $shopId, $base, $goods = [], $enabled = 0) { return \bizHd\groupBuy\classes\GroupBuyActivityClass::saveGroupBuy($mainId, $shopId, $base, $goods, $enabled); } /** * 业务校验并规范化团购商品:归属当前门店,库存不得超过实际商品库存 * 字段形态校验由 SaveGroupBuyForm 完成 */ 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); $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); $name = GoodsClass::formatSpecDisplayName($goods); $list[] = [ 'goodsId' => $goodsId, 'price' => round($price, 2), 'stock' => $stock, 'limit' => $limit, 'groupSize' => $groupSize, 'virtualGroup' => $virtualGroup, 'virtualMinutes' => $virtualMinutes, 'autoRefund' => 1, 'status' => ($realStock < $stock) ? 0 : (!empty($item['status']) ? 1 : 0), 'name' => $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), // 自动退款统一开启,读取时也强制为 1,兼容历史关闭配置 'autoRefund' => 1, '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) { // TODO 是否直接取 row 里的数据 $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']); $row['priceType'] = $goods->priceType; 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, $shopId, $moduleKey) { $mainId = intval($mainId); $shopId = intval($shopId); if ($shopId <= 0) { util::fail('无效门店'); } $suffix = self::sectionSuffix($moduleKey); $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, $shopId, $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), ]; } /** * 保存热门/上新/下拉商品:落库 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, $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('参数错误'); } $saveValue = [ 'name' => strval($payload['name'] ?? ''), 'type' => intval($payload['type'] ?? 0), 'value' => strval($payload['value'] ?? ''), 'sort' => intval($payload['sort'] ?? self::SORT_PRODUCT), ]; 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; } /** * 按真实商品总数与模块类型自动推导首页列数与展示数量 * - 1个商品:1排1列,展示全部 * - 2个商品:1排2列,展示全部 * - ≥3个:hot/new 按1排3列且只展示3个;pullGoods 按1排2列且展示全部 * * @param int $goodsTotal 关联配置解析出的真实商品总数 * @param string $moduleKey hot|new|pullGoods * @return array{layoutCols:int,displayCount:int} */ public static function resolveGoodsSectionLayout($goodsTotal, $moduleKey) { $goodsTotal = intval($goodsTotal); if ($goodsTotal <= 1) { return ['layoutCols' => 1, 'displayCount' => 0]; } if ($goodsTotal === 2) { return ['layoutCols' => 2, 'displayCount' => 0]; } if ($moduleKey === 'pullGoods') { return ['layoutCols' => 2, 'displayCount' => 0]; } return ['layoutCols' => 3, 'displayCount' => 3]; } /** * 规范化排列规则取值,非法/缺省时回退默认值 */ 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) { $paged = self::resolveDisplayGoodsPaged($mainId, $type, $value, $sort, 1, $limit); return $paged['list']; } /** * 匹配关联配置下的全部商品行(未截断、未格式化封面URL),供分页与总数统计复用 * * @param int $mainId * @param int $type * @param string $value * @param int $sort * @return array */ public static function matchGoodsRows($mainId, $type, $value, $sort) { $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) { // xhGoodsCategory 无 delStatus 字段;未删除/上架状态在下方按 xhGoods 过滤 $rows = GoodsCategoryClass::getAllByCondition( ['cId' => ['in', $ids]], 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,priceType,stock,cover,sold,actualSold,goodsStyle,status,delStatus,masterId,mainId,createTime'); // 仅取当前门店、未删除、上架中的主规格商品 $rows = array_values(array_filter($rows, function ($row) use ($mainId) { return intval($row['mainId']) === $mainId && intval($row['delStatus']) === 0 && intval($row['status']) === 1 && intval($row['masterId']) === 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; } return $rows; } /** * 将原始商品行格式化为首页/列表展示结构(含 coverUrl) * goodsStyle=1 表示多规格,同时输出 specEnabled 兼容旧前端 * * @param array $rows * @return array */ public static function formatGoodsRows($rows) { $list = []; foreach ($rows as $row) { $id = intval($row['id']); $cover = strval($row['cover'] ?? ''); $coverUrl = $cover !== '' ? imgUtil::groupImg($cover) . '?x-oss-process=image/resize,m_fill,h_700,w_700' : ''; $goodsStyle = intval($row['goodsStyle'] ?? 0); $list[] = [ 'id' => $id, 'name' => strval($row['name'] ?? ''), 'price' => floatval($row['price'] ?? 0), 'priceType' => intval($row['priceType'] ?? 0), 'stock' => intval($row['stock'] ?? 0), 'cover' => $cover, 'coverUrl' => $coverUrl, 'sold' => intval(bcadd($row['actualSold'] ?? 0, $row['sold'] ?? 0)), 'goodsStyle' => $goodsStyle, 'specEnabled' => $goodsStyle === 1 ? 1 : 0, ]; } return $list; } /** * 分页解析关联配置下的商品列表,返回 list + total(真实总数,不受首页展示上限限制) * * @param int $mainId * @param int $type * @param string $value * @param int $sort * @param int $page * @param int $pageSize 传 0 表示不分页返回全部 * @return array {list, total} */ public static function resolveDisplayGoodsPaged($mainId, $type, $value, $sort, $page = 1, $pageSize = self::DISPLAY_GOODS_LIMIT) { $rows = self::matchGoodsRows($mainId, $type, $value, $sort); $total = count($rows); $page = max(1, intval($page)); $pageSize = intval($pageSize); if ($pageSize > 0) { $rows = array_slice($rows, ($page - 1) * $pageSize, $pageSize); } return [ 'list' => self::formatGoodsRows($rows), 'total' => $total, ]; } /** * 根据 displayCount 计算首页实际截断条数:1-10 用配置值,0(全部)回退默认上限 * 供秒杀/团购等活动模块使用 * * @param mixed $displayCount * @return int */ public static function resolveHomeDisplayLimit($displayCount) { $count = intval($displayCount); if ($count >= 1 && $count <= 10) { return $count; } return self::DISPLAY_GOODS_LIMIT; } 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, // 商品展开:开启后首页按1排1列展示全部商品,默认关闭 'expand' => !empty($saved['expand']) ? 1 : 0, 'startTime' => intval($saved['startTime'] ?? 0), 'endTime' => intval($saved['endTime'] ?? 0), 'desc' => strval($saved['desc'] ?? ''), ]; } /** * 按已添加商品数量与「商品展开」开关自动推导首页列数与展示数量 * - expand 开启:1排1列,展示全部 * - 1个商品:1排1列;2个:1排2列;≥3个:1排3列且只展示3个 * * @param int $goodsCount 已添加商品总数(含未上架) * @param int $expand 商品展开开关 0/1 * @return array{layoutCols:int,displayCount:int} */ public static function resolveActivityLayout($goodsCount, $expand) { if (!empty($expand)) { return ['layoutCols' => 1, 'displayCount' => 0]; } $goodsCount = intval($goodsCount); if ($goodsCount <= 1) { return ['layoutCols' => 1, 'displayCount' => 0]; } if ($goodsCount === 2) { return ['layoutCols' => 2, 'displayCount' => 0]; } return ['layoutCols' => 3, 'displayCount' => 3]; } /** * 活动状态: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; } }