HomePageModuleClass.php 39 KB

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