HomePageModuleClass.php 41 KB

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