HomePageModuleClass.php 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880
  1. <?php
  2. namespace bizHd\homePageConfig\classes;
  3. use bizHd\goods\classes\GoodsClass;
  4. use bizHd\goods\classes\GoodsCategoryClass;
  5. use bizHd\goods\classes\GoodsUseCaseClass;
  6. use common\components\imgUtil;
  7. use common\components\util;
  8. use Yii;
  9. /**
  10. * 门店首页其余模块配置(金刚区/秒杀/团购/热门/上新/下拉商品)
  11. * 数据按 mainId 存 Redis,供 hdApp 配置、mallApp 读取展示。
  12. */
  13. class HomePageModuleClass
  14. {
  15. const REDIS_NAV_GRID = 'home_page_config:%d:nav_grid';
  16. const REDIS_SECKILL = 'home_page_config:%d:seckill';
  17. const REDIS_GROUP_BUY = 'home_page_config:%d:group_buy';
  18. const REDIS_HOT = 'home_page_config:%d:hot';
  19. const REDIS_NEW = 'home_page_config:%d:new';
  20. const REDIS_PULL_GOODS = 'home_page_config:%d:pull_goods';
  21. /** 秒杀已售数量:hash key 按门店,field 为 goodsId */
  22. const SECKILL_SOLD_PREFIX = 'seckill_sold:';
  23. /** 秒杀单客户已购数量:hash key 按门店+goodsId,field 为 customId */
  24. const SECKILL_BUY_PREFIX = 'seckill_buy:';
  25. /** 当前请求内的秒杀名额预占快照,下单失败时据此回滚 */
  26. const SECKILL_ROLLBACK_SNAPSHOT_KEY = 'seckillReserveRollbackSnapshot';
  27. const NAV_OSS_ROOT = 'uploads_home_nav';
  28. const MAX_NAV_COUNT = 20;
  29. /** 关联:商品 */
  30. const LINK_GOODS = 2;
  31. /** 关联:分类 */
  32. const LINK_CATEGORY = 3;
  33. /** 关联:场景 */
  34. const LINK_USE_CASE = 4;
  35. /** 排序:按商品排序 */
  36. const SORT_PRODUCT = 1;
  37. /** 排序:按销量 */
  38. const SORT_SALES = 2;
  39. /** 排序:按上新时间 */
  40. const SORT_NEW = 3;
  41. /** 排列规则合法取值:1排1列/1排2列/1排3列 */
  42. const LAYOUT_COLS_OPTIONS = [1, 2, 3];
  43. /** 排列规则默认值 */
  44. const DEFAULT_LAYOUT_COLS = 3;
  45. /** 首页展示商品解析默认条数上限 */
  46. const DISPLAY_GOODS_LIMIT = 20;
  47. public static function redisKey($mainId, $str)
  48. {
  49. return sprintf($str, $mainId);
  50. //return $str . intval($mainId);
  51. }
  52. public static function getJson($mainId, $str)
  53. {
  54. $raw = Yii::$app->redis->executeCommand('GET', [self::redisKey($mainId, $str)]);
  55. if (empty($raw)) {
  56. return [];
  57. }
  58. $data = json_decode($raw, true);
  59. return is_array($data) ? $data : [];
  60. }
  61. public static function setJson($mainId, $str, $payload)
  62. {
  63. Yii::$app->redis->executeCommand('SET', [
  64. self::redisKey($mainId, $str),
  65. json_encode($payload, JSON_UNESCAPED_UNICODE),
  66. ]);
  67. }
  68. public static function decodeList($raw)
  69. {
  70. if (is_string($raw)) {
  71. $decoded = json_decode($raw, true);
  72. return is_array($decoded) ? $decoded : [];
  73. }
  74. return is_array($raw) ? $raw : [];
  75. }
  76. // -------------------- 金刚区 --------------------
  77. public static function getNavGrid($mainId)
  78. {
  79. $mainId = intval($mainId);
  80. if ($mainId <= 0) {
  81. util::fail('无效门店');
  82. }
  83. $saved = self::getJson($mainId, self::REDIS_NAV_GRID);
  84. $cols = isset($saved['cols']) ? intval($saved['cols']) : 5;
  85. if (!in_array($cols, [4, 5], true)) {
  86. $cols = 5;
  87. }
  88. $list = [];
  89. foreach (self::decodeList($saved['list'] ?? []) as $item) {
  90. if (!is_array($item)) {
  91. continue;
  92. }
  93. $list[] = [
  94. 'id' => strval($item['id'] ?? ''),
  95. 'name' => strval($item['name'] ?? ''),
  96. 'icon' => strval($item['icon'] ?? ''),
  97. 'iconType' => intval($item['iconType'] ?? 1),
  98. 'enabled' => !empty($item['enabled']) ? 1 : 0,
  99. 'type' => intval($item['type'] ?? self::LINK_CATEGORY),
  100. 'value' => strval($item['value'] ?? ''),
  101. ];
  102. }
  103. return [
  104. 'enabled' => HomePageConfigClass::getModuleEnabled($mainId, 'navGrid'),
  105. 'cols' => $cols,
  106. 'list' => $list,
  107. ];
  108. }
  109. /**
  110. * 保存金刚区导航;请求形态校验由 SaveNavGridForm 完成
  111. *
  112. * @param int $mainId
  113. * @param array $payload {cols, list}
  114. * @param int $enabled
  115. * @return bool
  116. */
  117. public static function saveNavGrid($mainId, $payload, $enabled = 0)
  118. {
  119. $mainId = intval($mainId);
  120. if ($mainId <= 0) {
  121. util::fail('无效门店');
  122. }
  123. if (!is_array($payload)) {
  124. util::fail('参数错误');
  125. }
  126. $cols = isset($payload['cols']) ? intval($payload['cols']) : 5;
  127. $list = isset($payload['list']) && is_array($payload['list']) ? $payload['list'] : [];
  128. self::setJson($mainId, self::REDIS_NAV_GRID, ['cols' => $cols, 'list' => $list]);
  129. HomePageConfigClass::updateModuleEnabled($mainId, 'navGrid', $enabled);
  130. return true;
  131. }
  132. // -------------------- 秒杀 --------------------
  133. public static function getSeckill($mainId, $refreshStock = true)
  134. {
  135. $mainId = intval($mainId);
  136. if ($mainId <= 0) {
  137. util::fail('无效门店');
  138. }
  139. $saved = self::getJson($mainId, self::REDIS_SECKILL);
  140. $data = self::normalizeActivityBase($saved);
  141. $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'seckill');
  142. $data['goods'] = self::normalizeSeckillGoods($saved['goods'] ?? [], $mainId, $refreshStock);
  143. // 按已添加商品数量 + 展开开关动态推导列数与展示数量(不依赖商家手动配置)
  144. $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']);
  145. $data['layoutCols'] = $layout['layoutCols'];
  146. $data['displayCount'] = $layout['displayCount'];
  147. $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']);
  148. return $data;
  149. }
  150. /**
  151. * 保存秒杀专区;请求形态校验由 SaveSeckillForm 完成,此处做商品归属与库存业务校验
  152. *
  153. * @param int $mainId
  154. * @param array $base 已校验的活动基础字段
  155. * @param array $goods 已校验的商品列表(形态)
  156. * @param int $enabled
  157. * @return bool
  158. */
  159. public static function saveSeckill($mainId, $base, $goods = [], $enabled = 0)
  160. {
  161. $mainId = intval($mainId);
  162. if ($mainId <= 0) {
  163. util::fail('无效门店');
  164. }
  165. if (!is_array($base)) {
  166. util::fail('参数错误');
  167. }
  168. $goods = self::validateSeckillGoods($mainId, is_array($goods) ? $goods : []);
  169. self::setJson($mainId, self::REDIS_SECKILL, array_merge($base, ['goods' => $goods]));
  170. HomePageConfigClass::updateModuleEnabled($mainId, 'seckill', $enabled);
  171. return true;
  172. }
  173. /**
  174. * 业务校验并规范化秒杀商品:归属当前门店,秒杀库存不得超过实际商品库存
  175. * 字段形态校验由 SaveSeckillForm 完成
  176. */
  177. public static function validateSeckillGoods($mainId, $rawList)
  178. {
  179. $list = [];
  180. foreach ($rawList as $index => $item) {
  181. if (!is_array($item)) {
  182. continue;
  183. }
  184. $no = $index + 1;
  185. $goodsId = intval($item['goodsId'] ?? 0);
  186. $price = floatval($item['price'] ?? 0);
  187. $stock = intval($item['stock'] ?? 0);
  188. $limit = intval($item['limit'] ?? 0);
  189. $goods = GoodsClass::getById($goodsId, true);
  190. if (empty($goods) || intval($goods->mainId ?? 0) !== intval($mainId)) {
  191. util::fail("第{$no}个秒杀商品无效");
  192. }
  193. $realStock = intval($goods->stock ?? 0);
  194. // 秒杀库存不得超过实际库存
  195. if ($stock > $realStock) {
  196. util::fail("第{$no}个秒杀库存不能超过商品实际库存({$realStock})");
  197. }
  198. $status = !empty($item['status']) ? 1 : 0;
  199. // 实际库存已低于秒杀库存:自动下架该秒杀商品
  200. if ($realStock < $stock) {
  201. $status = 0;
  202. }
  203. if ($goods->masterId > 0) {
  204. $goods->name = $goods->name . '(' . $goods->specName . ')';
  205. }
  206. $list[] = [
  207. 'goodsId' => $goodsId,
  208. 'price' => round($price, 2),
  209. 'stock' => $stock,
  210. 'limit' => $limit,
  211. 'status' => $status,
  212. 'name' => $goods->name ?? ($item['name'] ?? ''),
  213. 'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
  214. 'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
  215. ];
  216. }
  217. return $list;
  218. }
  219. /**
  220. * 读取时刷新秒杀商品状态:实际库存低于秒杀库存则下架,并回写 Redis
  221. */
  222. public static function normalizeSeckillGoods($rawList, $mainId, $refreshStock = true)
  223. {
  224. $list = [];
  225. $changed = false;
  226. foreach (self::decodeList($rawList) as $item) {
  227. if (!is_array($item) || empty($item['goodsId'])) {
  228. continue;
  229. }
  230. $row = [
  231. 'goodsId' => intval($item['goodsId']),
  232. 'price' => floatval($item['price'] ?? 0),
  233. 'stock' => intval($item['stock'] ?? 0),
  234. 'limit' => intval($item['limit'] ?? 0),
  235. 'status' => !empty($item['status']) ? 1 : 0,
  236. 'name' => strval($item['name'] ?? ''),
  237. 'cover' => strval($item['cover'] ?? ''),
  238. 'originPrice' => floatval($item['originPrice'] ?? 0),
  239. 'realStock' => intval($item['stock'] ?? 0),
  240. ];
  241. if ($refreshStock) {
  242. $goods = GoodsClass::getById($row['goodsId'], true);
  243. if (!empty($goods) && intval($goods->mainId) === intval($mainId)) {
  244. if ($goods->masterId > 0) {
  245. $goods->name = $goods->name . '(' . $goods->specName . ')';
  246. }
  247. $realStock = intval($goods->stock);
  248. $row['realStock'] = $realStock;
  249. $row['name'] = strval($goods->name ?? $row['name']);
  250. $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
  251. $row['originPrice'] = floatval($goods->price ?? $row['originPrice']);
  252. if ($realStock < $row['stock'] && $row['status'] == 1) {
  253. $row['status'] = 0;
  254. $changed = true;
  255. }
  256. }
  257. }
  258. $list[] = $row;
  259. }
  260. if ($changed && $refreshStock) {
  261. $saved = self::getJson($mainId, self::REDIS_SECKILL);
  262. $persist = [];
  263. foreach ($list as $g) {
  264. $persist[] = [
  265. 'goodsId' => $g['goodsId'],
  266. 'price' => $g['price'],
  267. 'stock' => $g['stock'],
  268. 'limit' => $g['limit'],
  269. 'status' => $g['status'],
  270. 'name' => $g['name'],
  271. 'cover' => $g['cover'],
  272. 'originPrice' => $g['originPrice'],
  273. ];
  274. }
  275. $saved['goods'] = $persist;
  276. self::setJson($mainId, self::REDIS_SECKILL, $saved);
  277. }
  278. return $list;
  279. }
  280. /**
  281. * 下单时校验秒杀商品:活动进行中、该商品已上架,返回其配置行(含真实秒杀价/库存/限购)
  282. * 供订单结算时独立核价,避免客户端伪造价格
  283. *
  284. * @param int $mainId
  285. * @param int $goodsId 下单商品的实际销售单元id(普通商品即goodsId本身,多规格则为规格id)
  286. * @return array|null 匹配的秒杀商品配置,未命中/活动未进行中返回 null
  287. */
  288. public static function getSeckillActiveRow($mainId, $goodsId)
  289. {
  290. $data = self::getSeckill($mainId, true);
  291. if (empty($data['enabled']) || intval($data['status'] ?? 0) !== 1) {
  292. return null;
  293. }
  294. $goodsId = intval($goodsId);
  295. foreach (($data['goods'] ?? []) as $item) {
  296. if (!is_array($item) || empty($item['status'])) {
  297. continue;
  298. }
  299. if (intval($item['goodsId'] ?? 0) === $goodsId) {
  300. return $item;
  301. }
  302. }
  303. return null;
  304. }
  305. /**
  306. * 秒杀商品累计已售数量(跨所有客户),用于校验是否超出活动库存
  307. */
  308. public static function getSeckillSoldCount($mainId, $goodsId)
  309. {
  310. $key = self::SECKILL_SOLD_PREFIX . intval($mainId);
  311. $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($goodsId)]);
  312. return floatval($val ?? 0);
  313. }
  314. /**
  315. * 单个客户在该秒杀商品上已购买的数量,用于校验单人限购
  316. */
  317. public static function getSeckillCustomBoughtCount($mainId, $goodsId, $customId)
  318. {
  319. $key = self::SECKILL_BUY_PREFIX . intval($mainId) . ':' . intval($goodsId);
  320. $val = Yii::$app->redis->executeCommand('HGET', [$key, intval($customId)]);
  321. return floatval($val ?? 0);
  322. }
  323. /**
  324. * 预占秒杀名额:库存/限购校验通过后调用,累加"已售"与"该客户已购"计数,
  325. * 并记录本次请求的回滚快照——下单失败时通过 rollbackSeckillReservationSnapshot 撤销
  326. */
  327. public static function reserveSeckillPurchase($mainId, $goodsId, $customId, $num)
  328. {
  329. $mainId = intval($mainId);
  330. $goodsId = intval($goodsId);
  331. $customId = intval($customId);
  332. $num = floatval($num);
  333. if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
  334. return;
  335. }
  336. $soldKey = self::SECKILL_SOLD_PREFIX . $mainId;
  337. $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $goodsId;
  338. Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $goodsId, $num]);
  339. Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, $num]);
  340. $snapshot = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? [];
  341. $snapshot[] = ['mainId' => $mainId, 'goodsId' => $goodsId, 'customId' => $customId, 'num' => $num];
  342. Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] = $snapshot;
  343. }
  344. /**
  345. * 回滚当前请求中已预占的秒杀名额(下单失败/异常时调用)
  346. */
  347. public static function rollbackSeckillReservationSnapshot()
  348. {
  349. $snapshotList = Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY] ?? [];
  350. if (empty($snapshotList) || !is_array($snapshotList)) {
  351. return true;
  352. }
  353. foreach ($snapshotList as $snapshot) {
  354. $mainId = intval($snapshot['mainId'] ?? 0);
  355. $goodsId = intval($snapshot['goodsId'] ?? 0);
  356. $customId = intval($snapshot['customId'] ?? 0);
  357. $num = floatval($snapshot['num'] ?? 0);
  358. if ($mainId <= 0 || $goodsId <= 0 || $customId <= 0 || $num <= 0) {
  359. continue;
  360. }
  361. $soldKey = self::SECKILL_SOLD_PREFIX . $mainId;
  362. $buyKey = self::SECKILL_BUY_PREFIX . $mainId . ':' . $goodsId;
  363. Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$soldKey, $goodsId, -$num]);
  364. Yii::$app->redis->executeCommand('HINCRBYFLOAT', [$buyKey, $customId, -$num]);
  365. }
  366. self::clearSeckillReservationSnapshot();
  367. return true;
  368. }
  369. /**
  370. * 清理当前请求记录的秒杀预占快照(下单成功后调用)
  371. */
  372. public static function clearSeckillReservationSnapshot()
  373. {
  374. unset(Yii::$app->params[self::SECKILL_ROLLBACK_SNAPSHOT_KEY]);
  375. }
  376. // -------------------- 团购 --------------------
  377. /**
  378. * 读取团购专区:优先 MySQL 批次化持久化,无数据时回退 Redis
  379. */
  380. public static function getGroupBuy($mainId, $refreshStock = true)
  381. {
  382. return \bizHd\groupBuy\classes\GroupBuyActivityClass::getGroupBuy($mainId, $refreshStock);
  383. }
  384. /**
  385. * 仅从 Redis 读取历史单例团购配置(迁移过渡 / 无 MySQL 批次时回退)
  386. */
  387. public static function getGroupBuyFromRedis($mainId, $refreshStock = true)
  388. {
  389. $mainId = intval($mainId);
  390. if ($mainId <= 0) {
  391. util::fail('无效门店');
  392. }
  393. $saved = self::getJson($mainId, self::REDIS_GROUP_BUY);
  394. $data = self::normalizeActivityBase($saved);
  395. $data['enabled'] = HomePageConfigClass::getModuleEnabled($mainId, 'groupBuy');
  396. $data['goods'] = self::normalizeGroupBuyGoods($saved['goods'] ?? [], $mainId, $refreshStock);
  397. $layout = self::resolveActivityLayout(count($data['goods']), $data['expand']);
  398. $data['layoutCols'] = $layout['layoutCols'];
  399. $data['displayCount'] = $layout['displayCount'];
  400. $data['status'] = self::calcActivityStatus($data['startTime'], $data['endTime'], $data['enabled']);
  401. return $data;
  402. }
  403. /**
  404. * 保存团购专区:落 MySQL 活动批次/商品版本,并同步 Redis
  405. *
  406. * @param int $mainId
  407. * @param array $base 已校验的活动基础字段
  408. * @param array $goods 已校验的商品列表(形态)
  409. * @param int $enabled
  410. * @return bool
  411. */
  412. public static function saveGroupBuy($mainId, $base, $goods = [], $enabled = 0)
  413. {
  414. return \bizHd\groupBuy\classes\GroupBuyActivityClass::saveGroupBuy($mainId, $base, $goods, $enabled);
  415. }
  416. /**
  417. * 业务校验并规范化团购商品:归属当前门店,库存不得超过实际商品库存
  418. * 字段形态校验由 SaveGroupBuyForm 完成
  419. */
  420. public static function validateGroupBuyGoods($mainId, $rawList)
  421. {
  422. $list = [];
  423. foreach ($rawList as $index => $item) {
  424. if (!is_array($item)) {
  425. continue;
  426. }
  427. $no = $index + 1;
  428. $goodsId = intval($item['goodsId'] ?? 0);
  429. $price = floatval($item['price'] ?? 0);
  430. $stock = intval($item['stock'] ?? 0);
  431. $limit = intval($item['limit'] ?? 0);
  432. $groupSize = intval($item['groupSize'] ?? 3);
  433. $goods = GoodsClass::getById($goodsId, true);
  434. if (empty($goods) || intval($goods->mainId ?? 0) !== intval($mainId)) {
  435. util::fail("第{$no}个团购商品无效");
  436. }
  437. $realStock = intval($goods->stock ?? 0);
  438. if ($stock > $realStock) {
  439. util::fail("第{$no}个团购库存不能超过商品实际库存({$realStock})");
  440. }
  441. $virtualGroup = !empty($item['virtualGroup']) ? 1 : 0;
  442. $virtualMinutes = intval($item['virtualMinutes'] ?? 0);
  443. $name = $goods->masterId > 0 ? $goods->name . '(' . $goods->specName . ')' : $goods->name;
  444. $list[] = [
  445. 'goodsId' => $goodsId,
  446. 'price' => round($price, 2),
  447. 'stock' => $stock,
  448. 'limit' => $limit,
  449. 'groupSize' => $groupSize,
  450. 'virtualGroup' => $virtualGroup,
  451. 'virtualMinutes' => $virtualMinutes,
  452. 'autoRefund' => 1,
  453. 'status' => ($realStock < $stock) ? 0 : (!empty($item['status']) ? 1 : 0),
  454. 'name' => $name,
  455. 'cover' => strval($goods->shortCover ?? ($goods->cover ?? ($item['cover'] ?? ''))),
  456. 'originPrice' => floatval($goods->price ?? ($item['originPrice'] ?? 0)),
  457. ];
  458. }
  459. return $list;
  460. }
  461. public static function normalizeGroupBuyGoods($rawList, $mainId, $refreshStock = true)
  462. {
  463. $list = [];
  464. $changed = false;
  465. foreach (self::decodeList($rawList) as $item) {
  466. if (!is_array($item) || empty($item['goodsId'])) {
  467. continue;
  468. }
  469. $row = [
  470. 'goodsId' => intval($item['goodsId']),
  471. 'price' => floatval($item['price'] ?? 0),
  472. 'stock' => intval($item['stock'] ?? 0),
  473. 'limit' => intval($item['limit'] ?? 0),
  474. 'groupSize' => intval($item['groupSize'] ?? 3),
  475. 'virtualGroup' => !empty($item['virtualGroup']) ? 1 : 0,
  476. 'virtualMinutes' => intval($item['virtualMinutes'] ?? 0),
  477. // 自动退款统一开启,读取时也强制为 1,兼容历史关闭配置
  478. 'autoRefund' => 1,
  479. 'status' => !empty($item['status']) ? 1 : 0,
  480. 'name' => strval($item['name'] ?? ''),
  481. 'cover' => strval($item['cover'] ?? ''),
  482. 'originPrice' => floatval($item['originPrice'] ?? 0),
  483. 'realStock' => intval($item['stock'] ?? 0),
  484. ];
  485. if ($refreshStock) { // TODO 是否直接取 row 里的数据
  486. $goods = GoodsClass::getById($row['goodsId'], true);
  487. if (!empty($goods) && intval($goods->mainId ?? 0) === intval($mainId)) {
  488. $realStock = intval($goods->stock ?? 0);
  489. $row['realStock'] = $realStock;
  490. //$row['name'] = strval($goods->name ?? $row['name']);
  491. $row['cover'] = strval($goods->shortCover ?? ($goods->cover ?? $row['cover']));
  492. $row['originPrice'] = floatval($goods->price ?? $row['originPrice']);
  493. if ($realStock < $row['stock'] && $row['status'] == 1) {
  494. $row['status'] = 0;
  495. $changed = true;
  496. }
  497. }
  498. }
  499. $list[] = $row;
  500. }
  501. if ($changed && $refreshStock) {
  502. $saved = self::getJson($mainId, self::REDIS_GROUP_BUY);
  503. $persist = [];
  504. foreach ($list as $g) {
  505. unset($g['realStock']);
  506. $persist[] = $g;
  507. }
  508. $saved['goods'] = $persist;
  509. self::setJson($mainId, self::REDIS_GROUP_BUY, $saved);
  510. }
  511. return $list;
  512. }
  513. // -------------------- 热门/上新/下拉 --------------------
  514. public static function getGoodsSection($mainId, $moduleKey)
  515. {
  516. $mainId = intval($mainId);
  517. $suffix = self::sectionSuffix($moduleKey);
  518. $saved = self::getJson($mainId, $suffix);
  519. $defaults = [
  520. 'hot' => '热门推荐',
  521. 'new' => '今日上新',
  522. 'pullGoods' => '更多商品',
  523. ];
  524. return [
  525. 'enabled' => HomePageConfigClass::getModuleEnabled($mainId, $moduleKey),
  526. 'name' => strval($saved['name'] ?? ($defaults[$moduleKey] ?? '')),
  527. 'type' => intval($saved['type'] ?? self::LINK_GOODS),
  528. 'value' => strval($saved['value'] ?? ''),
  529. 'sort' => intval($saved['sort'] ?? self::SORT_PRODUCT),
  530. ];
  531. }
  532. /**
  533. * 保存热门/上新/下拉商品;请求形态校验由 SaveGoodsSectionForm 完成
  534. *
  535. * @param int $mainId
  536. * @param string $moduleKey hot|new|pullGoods
  537. * @param array $payload {name,type,value,sort}
  538. * @param int $enabled
  539. * @return bool
  540. */
  541. public static function saveGoodsSection($mainId, $moduleKey, $payload, $enabled = 0)
  542. {
  543. $mainId = intval($mainId);
  544. $suffix = self::sectionSuffix($moduleKey);
  545. if (!is_array($payload)) {
  546. util::fail('参数错误');
  547. }
  548. self::setJson($mainId, $suffix, [
  549. 'name' => strval($payload['name'] ?? ''),
  550. 'type' => intval($payload['type'] ?? 0),
  551. 'value' => strval($payload['value'] ?? ''),
  552. 'sort' => intval($payload['sort'] ?? self::SORT_PRODUCT),
  553. ]);
  554. HomePageConfigClass::updateModuleEnabled($mainId, $moduleKey, $enabled);
  555. return true;
  556. }
  557. /**
  558. * 按真实商品总数与模块类型自动推导首页列数与展示数量
  559. * - 1个商品:1排1列,展示全部
  560. * - 2个商品:1排2列,展示全部
  561. * - ≥3个:hot/new 按1排3列且只展示3个;pullGoods 按1排2列且展示全部
  562. *
  563. * @param int $goodsTotal 关联配置解析出的真实商品总数
  564. * @param string $moduleKey hot|new|pullGoods
  565. * @return array{layoutCols:int,displayCount:int}
  566. */
  567. public static function resolveGoodsSectionLayout($goodsTotal, $moduleKey)
  568. {
  569. $goodsTotal = intval($goodsTotal);
  570. if ($goodsTotal <= 1) {
  571. return ['layoutCols' => 1, 'displayCount' => 0];
  572. }
  573. if ($goodsTotal === 2) {
  574. return ['layoutCols' => 2, 'displayCount' => 0];
  575. }
  576. if ($moduleKey === 'pullGoods') {
  577. return ['layoutCols' => 2, 'displayCount' => 0];
  578. }
  579. return ['layoutCols' => 3, 'displayCount' => 3];
  580. }
  581. /**
  582. * 规范化排列规则取值,非法/缺省时回退默认值
  583. */
  584. public static function normalizeLayoutCols($cols)
  585. {
  586. $cols = intval($cols);
  587. return in_array($cols, self::LAYOUT_COLS_OPTIONS, true) ? $cols : self::DEFAULT_LAYOUT_COLS;
  588. }
  589. /**
  590. * 将热门推荐/今日上新/下拉商品的关联配置(type+value)解析为真实商品列表,用于首页展示
  591. * type=2 商品:value 为商品ID逗号拼接,按选择顺序展示
  592. * type=3 分类:value 为分类ID逗号拼接,取分类下全部商品
  593. * type=4 场景:value 为场景ID逗号拼接,取场景下全部商品
  594. * sort=1 按上面解析出的原始顺序;sort=2 按销量(虚拟+实际)降序;sort=3 按上架时间降序
  595. *
  596. * @param int $mainId
  597. * @param int $type
  598. * @param string $value
  599. * @param int $sort
  600. * @param int $limit
  601. * @return array [{id,name,price,stock,cover,coverUrl,sold}]
  602. */
  603. public static function resolveDisplayGoods($mainId, $type, $value, $sort, $limit = self::DISPLAY_GOODS_LIMIT)
  604. {
  605. $paged = self::resolveDisplayGoodsPaged($mainId, $type, $value, $sort, 1, $limit);
  606. return $paged['list'];
  607. }
  608. /**
  609. * 匹配关联配置下的全部商品行(未截断、未格式化封面URL),供分页与总数统计复用
  610. *
  611. * @param int $mainId
  612. * @param int $type
  613. * @param string $value
  614. * @param int $sort
  615. * @return array
  616. */
  617. public static function matchGoodsRows($mainId, $type, $value, $sort)
  618. {
  619. $mainId = intval($mainId);
  620. $type = intval($type);
  621. $value = trim(strval($value));
  622. if ($mainId <= 0 || $value === '') {
  623. return [];
  624. }
  625. $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', $value)))));
  626. if (empty($ids)) {
  627. return [];
  628. }
  629. $goodsIds = [];
  630. if ($type === self::LINK_GOODS) {
  631. // 商品类型:保留用户选择顺序
  632. $goodsIds = $ids;
  633. } elseif ($type === self::LINK_CATEGORY) {
  634. // xhGoodsCategory 无 delStatus 字段;未删除/上架状态在下方按 xhGoods 过滤
  635. $rows = GoodsCategoryClass::getAllByCondition(
  636. ['cId' => ['in', $ids]],
  637. null,
  638. 'gId'
  639. );
  640. $goodsIds = array_values(array_unique(array_map('intval', array_column($rows, 'gId'))));
  641. } elseif ($type === self::LINK_USE_CASE) {
  642. $rows = GoodsUseCaseClass::getAllByCondition(
  643. ['useCaseId' => ['in', $ids]],
  644. null,
  645. 'goodsId'
  646. );
  647. $goodsIds = array_values(array_unique(array_map('intval', array_column($rows, 'goodsId'))));
  648. }
  649. if (empty($goodsIds)) {
  650. return [];
  651. }
  652. // 注意:Base::getByIds 传入 $order 会走到未定义的 order() 方法而报错,这里统一取回后在 PHP 侧排序
  653. $rows = GoodsClass::getByIds($goodsIds, null, null, 'id,name,price,stock,cover,sold,actualSold,status,delStatus,masterId,mainId,createTime');
  654. // 仅取当前门店、未删除、上架中的主规格商品
  655. $rows = array_values(array_filter($rows, function ($row) use ($mainId) {
  656. return intval($row['mainId'] ?? 0) === $mainId
  657. && intval($row['delStatus'] ?? 0) === 0
  658. && intval($row['status'] ?? 0) === 1
  659. && intval($row['masterId'] ?? 0) === 0;
  660. }));
  661. if ($sort == self::SORT_SALES) {
  662. usort($rows, function ($a, $b) {
  663. $soldA = floatval($a['actualSold'] ?? 0) + floatval($a['sold'] ?? 0);
  664. $soldB = floatval($b['actualSold'] ?? 0) + floatval($b['sold'] ?? 0);
  665. return $soldB <=> $soldA;
  666. });
  667. } elseif ($sort == self::SORT_NEW) {
  668. usort($rows, function ($a, $b) {
  669. return strtotime($b['createTime'] ?? '') <=> strtotime($a['createTime'] ?? '');
  670. });
  671. } elseif ($type === self::LINK_GOODS) {
  672. // 商品类型按用户选择顺序重新排列;分类/场景按商品排序时无自定义顺序可依,保持数据库默认返回顺序
  673. $indexBy = [];
  674. foreach ($rows as $row) {
  675. $indexBy[intval($row['id'])] = $row;
  676. }
  677. $ordered = [];
  678. foreach ($goodsIds as $gid) {
  679. if (isset($indexBy[$gid])) {
  680. $ordered[] = $indexBy[$gid];
  681. }
  682. }
  683. $rows = $ordered;
  684. }
  685. return $rows;
  686. }
  687. /**
  688. * 将原始商品行格式化为首页/列表展示结构(含 coverUrl)
  689. *
  690. * @param array $rows
  691. * @return array
  692. */
  693. public static function formatGoodsRows($rows)
  694. {
  695. $list = [];
  696. $goodsIds = [];
  697. foreach ($rows as $row) {
  698. $goodsIds[] = intval($row['id'] ?? 0);
  699. }
  700. $specEnabledMap = GoodsClass::getSpecEnabledMap($goodsIds);
  701. foreach ($rows as $row) {
  702. $id = intval($row['id']);
  703. $cover = strval($row['cover'] ?? '');
  704. $coverUrl = $cover !== '' ? imgUtil::groupImg($cover) . '?x-oss-process=image/resize,m_fill,h_700,w_700' : '';
  705. $list[] = [
  706. 'id' => $id,
  707. 'name' => strval($row['name'] ?? ''),
  708. 'price' => floatval($row['price'] ?? 0),
  709. 'stock' => intval($row['stock'] ?? 0),
  710. 'cover' => $cover,
  711. 'coverUrl' => $coverUrl,
  712. 'sold' => intval(bcadd($row['actualSold'] ?? 0, $row['sold'] ?? 0)),
  713. 'specEnabled' => intval($specEnabledMap[$id] ?? 0),
  714. ];
  715. }
  716. return $list;
  717. }
  718. /**
  719. * 分页解析关联配置下的商品列表,返回 list + total(真实总数,不受首页展示上限限制)
  720. *
  721. * @param int $mainId
  722. * @param int $type
  723. * @param string $value
  724. * @param int $sort
  725. * @param int $page
  726. * @param int $pageSize 传 0 表示不分页返回全部
  727. * @return array {list, total}
  728. */
  729. public static function resolveDisplayGoodsPaged($mainId, $type, $value, $sort, $page = 1, $pageSize = self::DISPLAY_GOODS_LIMIT)
  730. {
  731. $rows = self::matchGoodsRows($mainId, $type, $value, $sort);
  732. $total = count($rows);
  733. $page = max(1, intval($page));
  734. $pageSize = intval($pageSize);
  735. if ($pageSize > 0) {
  736. $rows = array_slice($rows, ($page - 1) * $pageSize, $pageSize);
  737. }
  738. return [
  739. 'list' => self::formatGoodsRows($rows),
  740. 'total' => $total,
  741. ];
  742. }
  743. /**
  744. * 根据 displayCount 计算首页实际截断条数:1-10 用配置值,0(全部)回退默认上限
  745. * 供秒杀/团购等活动模块使用
  746. *
  747. * @param mixed $displayCount
  748. * @return int
  749. */
  750. public static function resolveHomeDisplayLimit($displayCount)
  751. {
  752. $count = intval($displayCount);
  753. if ($count >= 1 && $count <= 10) {
  754. return $count;
  755. }
  756. return self::DISPLAY_GOODS_LIMIT;
  757. }
  758. public static function sectionSuffix($moduleKey)
  759. {
  760. $map = [
  761. 'hot' => self::REDIS_HOT,
  762. 'new' => self::REDIS_NEW,
  763. 'pullGoods' => self::REDIS_PULL_GOODS,
  764. ];
  765. if (!isset($map[$moduleKey])) {
  766. util::fail('无效模块');
  767. }
  768. return $map[$moduleKey];
  769. }
  770. // -------------------- 活动公共 --------------------
  771. public static function normalizeActivityBase($saved)
  772. {
  773. return [
  774. 'title' => strval($saved['title'] ?? ''),
  775. 'subtitle' => strval($saved['subtitle'] ?? ''),
  776. 'showCountdown' => !empty($saved['showCountdown']) ? 1 : 0,
  777. // 商品展开:开启后首页按1排1列展示全部商品,默认关闭
  778. 'expand' => !empty($saved['expand']) ? 1 : 0,
  779. 'startTime' => intval($saved['startTime'] ?? 0),
  780. 'endTime' => intval($saved['endTime'] ?? 0),
  781. 'desc' => strval($saved['desc'] ?? ''),
  782. ];
  783. }
  784. /**
  785. * 按已添加商品数量与「商品展开」开关自动推导首页列数与展示数量
  786. * - expand 开启:1排1列,展示全部
  787. * - 1个商品:1排1列;2个:1排2列;≥3个:1排3列且只展示3个
  788. *
  789. * @param int $goodsCount 已添加商品总数(含未上架)
  790. * @param int $expand 商品展开开关 0/1
  791. * @return array{layoutCols:int,displayCount:int}
  792. */
  793. public static function resolveActivityLayout($goodsCount, $expand)
  794. {
  795. if (!empty($expand)) {
  796. return ['layoutCols' => 1, 'displayCount' => 0];
  797. }
  798. $goodsCount = intval($goodsCount);
  799. if ($goodsCount <= 1) {
  800. return ['layoutCols' => 1, 'displayCount' => 0];
  801. }
  802. if ($goodsCount === 2) {
  803. return ['layoutCols' => 2, 'displayCount' => 0];
  804. }
  805. return ['layoutCols' => 3, 'displayCount' => 3];
  806. }
  807. /**
  808. * 活动状态:0未开始 1进行中 2已结束;模块关闭视为已结束展示用
  809. */
  810. public static function calcActivityStatus($startTime, $endTime, $enabled)
  811. {
  812. if (empty($enabled)) {
  813. return 2;
  814. }
  815. $now = time();
  816. $startTime = intval($startTime);
  817. $endTime = intval($endTime);
  818. if ($startTime <= 0 || $endTime <= 0) {
  819. return 0;
  820. }
  821. if ($now < $startTime) {
  822. return 0;
  823. }
  824. if ($now > $endTime) {
  825. return 2;
  826. }
  827. return 1;
  828. }
  829. }