LotteryService.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. <?php
  2. namespace app\common\Service\lottery;
  3. use app\common\model\lottery\LotteryActivity;
  4. use app\common\model\lottery\LotteryPrize;
  5. use app\common\model\lottery\LotteryCondition;
  6. use app\common\model\lottery\LotteryDrawRecord;
  7. use app\common\model\lottery\LotteryWinRecord;
  8. use app\common\model\lottery\LotteryUserChance;
  9. use app\common\Enum\LotteryEnum;
  10. use think\Db;
  11. use think\Exception;
  12. use think\Cache;
  13. /**
  14. * 抽奖服务类
  15. * 核心抽奖逻辑处理
  16. */
  17. class LotteryService
  18. {
  19. /**
  20. * 执行抽奖
  21. * @param int $activityId 活动ID
  22. * @param int $userId 用户ID
  23. * @param int $triggerType 触发类型
  24. * @param int $triggerOrderId 触发订单ID
  25. * @param float $triggerAmount 触发金额
  26. * @return array 抽奖结果
  27. * @throws Exception
  28. */
  29. public static function drawLottery($activityId, $userId, $triggerType = 1, $triggerOrderId = null, $triggerAmount = null)
  30. {
  31. // 1. 验证活动有效性
  32. $activity = LotteryActivity::find($activityId);
  33. if (!$activity || !self::isActivityRunning($activity)) {
  34. throw new Exception('活动不存在或未开始');
  35. }
  36. // 2. 验证抽奖时间
  37. if (!self::isInDrawTime($activity)) {
  38. throw new Exception('不在抽奖时间内');
  39. }
  40. // 3. 验证用户资格
  41. if (!static::validateUserQualification($activity, $userId)) {
  42. throw new Exception('用户不符合参与条件');
  43. }
  44. // 4. 检查用户抽奖机会
  45. $userChance = LotteryChanceService::getUserChance($activityId, $userId);
  46. if (!$userChance || !LotteryChanceService::hasChance($userChance)) {
  47. throw new Exception('没有抽奖机会');
  48. }
  49. // 5. 检查用户参与次数限制
  50. if (!static::checkUserDrawLimit($activity, $userId)) {
  51. throw new Exception('已达到参与次数上限');
  52. }
  53. // 6. 防重复抽奖检查(基于订单)
  54. if ($triggerOrderId && static::hasDrawnForOrder($activityId, $userId, $triggerOrderId)) {
  55. throw new Exception('该订单已参与过抽奖');
  56. }
  57. // 7. 使用Redis锁防止并发
  58. $lockKey = "lottery_lock_{$activityId}_{$userId}";
  59. $lock = Cache::store('redis')->handler()->set($lockKey, 1, 'NX', 'EX', 10);
  60. if (!$lock) {
  61. throw new Exception('操作太频繁,请稍后再试');
  62. }
  63. try {
  64. // 8. 开始抽奖流程
  65. return static::processDrawLottery($activity, $userId, $triggerType, $triggerOrderId, $triggerAmount);
  66. } finally {
  67. // 释放锁
  68. Cache::store('redis')->handler()->del($lockKey);
  69. }
  70. }
  71. /**
  72. * 处理抽奖核心逻辑
  73. */
  74. private static function processDrawLottery($activity, $userId, $triggerType, $triggerOrderId, $triggerAmount)
  75. {
  76. // 开启事务
  77. Db::startTrans();
  78. try {
  79. // 1. 获取可用奖品
  80. $prizes = static::getAvailablePrizes($activity);
  81. if (empty($prizes)) {
  82. throw new Exception('暂无可抽取的奖品');
  83. }
  84. // 2. 执行抽奖算法
  85. $selectedPrize = static::executeLotteryAlgorithm($prizes);
  86. // 3. 减少库存
  87. if (!static::decreasePrizeStock($selectedPrize)) {
  88. throw new Exception('奖品库存不足');
  89. }
  90. // 4. 消耗用户抽奖机会
  91. $userChance = LotteryChanceService::getUserChance($activity->id, $userId);
  92. if (!LotteryChanceService::useChance($userChance)) {
  93. throw new Exception('抽奖机会使用失败');
  94. }
  95. // 5. 创建抽奖记录
  96. $isWin = $selectedPrize->type != LotteryEnum::PRIZE_TYPE_NO_PRIZE;
  97. $winInfo = $isWin ? static::buildWinInfo($selectedPrize) : [];
  98. $drawRecord = LotteryRecordService::createDrawRecord(
  99. $activity->id,
  100. $userId,
  101. $selectedPrize->id,
  102. $isWin,
  103. $triggerType,
  104. $triggerOrderId,
  105. $triggerAmount,
  106. $winInfo
  107. );
  108. // 6. 如果中奖,创建中奖记录
  109. $winRecord = null;
  110. if ($isWin) {
  111. $winRecord = LotteryRecordService::createWinRecord(
  112. $drawRecord->id,
  113. $activity->id,
  114. $userId,
  115. $selectedPrize->id,
  116. $selectedPrize->name,
  117. $selectedPrize->type,
  118. static::buildPrizeValue($selectedPrize)
  119. );
  120. // 自动发放奖品
  121. if ($selectedPrize->deliver_type == LotteryEnum::DELIVER_TYPE_AUTO) {
  122. LotteryRecordService::autoDeliverPrize($winRecord, $selectedPrize);
  123. }
  124. }
  125. // 7. 更新活动统计
  126. static::updateActivityStats($activity, $isWin);
  127. // 提交事务
  128. Db::commit();
  129. // 8. 返回抽奖结果
  130. return static::buildDrawResult($drawRecord, $selectedPrize, $winRecord);
  131. } catch (Exception $e) {
  132. Db::rollback();
  133. throw $e;
  134. }
  135. }
  136. /**
  137. * 获取可用奖品列表
  138. */
  139. private static function getAvailablePrizes($activity)
  140. {
  141. $prizes = static::getValidPrizes($activity->id);
  142. // 如果开启按人数解锁功能
  143. if ($activity->unlock_by_people) {
  144. $currentPeopleCount = LotteryChanceService::getActivityParticipants($activity->id);
  145. $prizes = $prizes->filter(function($prize) use ($currentPeopleCount) {
  146. return static::isPrizeUnlocked($prize, $currentPeopleCount);
  147. });
  148. }
  149. return $prizes;
  150. }
  151. /**
  152. * 执行抽奖算法(基于概率权重)
  153. */
  154. private static function executeLotteryAlgorithm($prizes)
  155. {
  156. // 计算总概率
  157. $totalProbability = $prizes->sum('probability');
  158. // 生成随机数
  159. $randomNumber = mt_rand(1, $totalProbability * 100) / 100;
  160. // 按概率选择奖品
  161. $currentProbability = 0;
  162. foreach ($prizes as $prize) {
  163. $currentProbability += $prize->probability;
  164. if ($randomNumber <= $currentProbability) {
  165. return $prize;
  166. }
  167. }
  168. // 保底返回第一个奖品(通常是未中奖)
  169. return $prizes->first();
  170. }
  171. /**
  172. * 验证用户资格
  173. */
  174. private static function validateUserQualification($activity, $userId)
  175. {
  176. // 检查用户群体限制
  177. switch ($activity->user_limit_type) {
  178. case LotteryEnum::USER_LIMIT_ALL:
  179. return true;
  180. case LotteryEnum::USER_LIMIT_LEVEL:
  181. return static::checkUserLevel($userId, $activity->user_limit_value);
  182. case LotteryEnum::USER_LIMIT_TAG:
  183. return static::checkUserTag($userId, $activity->user_limit_value);
  184. default:
  185. return false;
  186. }
  187. }
  188. /**
  189. * 检查用户等级
  190. */
  191. private static function checkUserLevel($userId, $limitValue)
  192. {
  193. if (empty($limitValue)) {
  194. return true;
  195. }
  196. $user = \app\common\model\User::find($userId);
  197. return $user && in_array($user->level, (array)$limitValue);
  198. }
  199. /**
  200. * 检查用户标签
  201. */
  202. private static function checkUserTag($userId, $limitValue)
  203. {
  204. if (empty($limitValue)) {
  205. return true;
  206. }
  207. // 这里需要根据实际的用户标签系统实现
  208. // 暂时返回true
  209. return true;
  210. }
  211. /**
  212. * 检查用户抽奖次数限制
  213. */
  214. private static function checkUserDrawLimit($activity, $userId)
  215. {
  216. if (!$activity->person_limit_num) {
  217. return true;
  218. }
  219. $drawCount = LotteryRecordService::getUserDrawCount($activity->id, $userId);
  220. return $drawCount < $activity->person_limit_num;
  221. }
  222. /**
  223. * 检查订单是否已抽奖
  224. */
  225. private static function hasDrawnForOrder($activityId, $userId, $orderId)
  226. {
  227. return LotteryRecordService::hasUserDrawnForOrder($activityId, $userId, $orderId);
  228. }
  229. /**
  230. * 构建中奖信息
  231. */
  232. private static function buildWinInfo($prize)
  233. {
  234. return [
  235. 'prize_id' => $prize->id,
  236. 'prize_name' => $prize->name,
  237. 'prize_type' => $prize->type,
  238. 'prize_image' => $prize->image,
  239. 'win_prompt' => $prize->win_prompt
  240. ];
  241. }
  242. /**
  243. * 构建奖品价值信息
  244. */
  245. private static function buildPrizeValue($prize)
  246. {
  247. $prizeValue = [
  248. 'type' => $prize->type,
  249. 'name' => $prize->name,
  250. 'image' => $prize->image
  251. ];
  252. switch ($prize->type) {
  253. case LotteryEnum::PRIZE_TYPE_COUPON:
  254. $prizeValue['coupon_id'] = $prize->coupon_id;
  255. break;
  256. case LotteryEnum::PRIZE_TYPE_REDPACK:
  257. $prizeValue['amount'] = $prize->amount;
  258. break;
  259. case LotteryEnum::PRIZE_TYPE_SHOP_GOODS:
  260. $prizeValue['goods_id'] = $prize->goods_id;
  261. $prizeValue['goods_sku_id'] = $prize->goods_sku_id;
  262. break;
  263. case LotteryEnum::PRIZE_TYPE_CODE:
  264. $prizeValue['exchange_code'] = static::getAvailableExchangeCode($prize);
  265. break;
  266. }
  267. return $prizeValue;
  268. }
  269. /**
  270. * 自动发放奖品
  271. */
  272. private static function autoDeliverPrize($winRecord, $prize)
  273. {
  274. try {
  275. switch ($prize->type) {
  276. case LotteryEnum::PRIZE_TYPE_COUPON:
  277. static::deliverCoupon($winRecord, $prize);
  278. break;
  279. case LotteryEnum::PRIZE_TYPE_REDPACK:
  280. static::deliverRedPacket($winRecord, $prize);
  281. break;
  282. case LotteryEnum::PRIZE_TYPE_CODE:
  283. static::deliverExchangeCode($winRecord, $prize);
  284. break;
  285. case LotteryEnum::PRIZE_TYPE_SHOP_GOODS:
  286. static::deliverGoods($winRecord, $prize);
  287. break;
  288. }
  289. } catch (Exception $e) {
  290. LotteryRecordService::markWinRecordAsFailed($winRecord, $e->getMessage());
  291. }
  292. }
  293. /**
  294. * 发放优惠券
  295. */
  296. private static function deliverCoupon($winRecord, $prize)
  297. {
  298. // 这里调用优惠券发放接口
  299. // 示例代码,需要根据实际优惠券系统实现
  300. LotteryRecordService::markWinRecordAsDelivered($winRecord, ['coupon_id' => $prize->coupon_id]);
  301. }
  302. /**
  303. * 发放红包
  304. */
  305. private static function deliverRedPacket($winRecord, $prize)
  306. {
  307. // 这里调用红包发放接口
  308. // 示例代码,需要根据实际红包系统实现
  309. LotteryRecordService::markWinRecordAsDelivered($winRecord, ['amount' => $prize->amount]);
  310. }
  311. /**
  312. * 发放兑换码
  313. */
  314. private static function deliverExchangeCode($winRecord, $prize)
  315. {
  316. $code = static::getAvailableExchangeCode($prize);
  317. if ($code) {
  318. LotteryRecordService::setWinRecordExchangeCode($winRecord, $code);
  319. static::markExchangeCodeUsed($prize, $code);
  320. LotteryRecordService::markWinRecordAsDelivered($winRecord, ['exchange_code' => $code]);
  321. } else {
  322. throw new Exception('兑换码已用完');
  323. }
  324. }
  325. /**
  326. * 发放商城商品
  327. */
  328. private static function deliverGoods($winRecord, $prize)
  329. {
  330. // 这里可以自动加入购物车或创建订单
  331. // 示例代码,需要根据实际商城系统实现
  332. LotteryRecordService::markWinRecordAsDelivered($winRecord, [
  333. 'goods_id' => $prize->goods_id,
  334. 'goods_sku_id' => $prize->goods_sku_id
  335. ]);
  336. }
  337. /**
  338. * 更新活动统计
  339. */
  340. private static function updateActivityStats($activity, $isWin)
  341. {
  342. $activity->total_draw_count += 1;
  343. if ($isWin) {
  344. $activity->total_win_count += 1;
  345. }
  346. $activity->save();
  347. }
  348. /**
  349. * 构建抽奖结果
  350. */
  351. private static function buildDrawResult($drawRecord, $prize, $winRecord = null)
  352. {
  353. $result = [
  354. 'draw_id' => $drawRecord->id,
  355. 'is_win' => $drawRecord->is_win,
  356. 'prize' => [
  357. 'id' => $prize->id,
  358. 'name' => $prize->name,
  359. 'type' => $prize->type,
  360. 'type_text' => $prize->type_text,
  361. 'image' => $prize->image,
  362. 'win_prompt' => $prize->win_prompt
  363. ],
  364. 'draw_time' => $drawRecord->draw_time
  365. ];
  366. if ($winRecord) {
  367. $result['win_record_id'] = $winRecord->id;
  368. $result['deliver_status'] = $winRecord->deliver_status;
  369. $result['exchange_code'] = $winRecord->exchange_code;
  370. }
  371. return $result;
  372. }
  373. /**
  374. * 获取用户抽奖机会
  375. * @param int $activityId 活动ID
  376. * @param int $userId 用户ID
  377. * @return array
  378. */
  379. public static function getUserChances($activityId, $userId)
  380. {
  381. $userChance = LotteryChanceService::getUserChance($activityId, $userId);
  382. if (!$userChance) {
  383. return [
  384. 'total_chances' => 0,
  385. 'used_chances' => 0,
  386. 'remain_chances' => 0
  387. ];
  388. }
  389. return [
  390. 'total_chances' => $userChance->total_chances,
  391. 'used_chances' => $userChance->used_chances,
  392. 'remain_chances' => $userChance->remain_chances,
  393. 'last_get_time' => $userChance->last_get_time,
  394. 'last_use_time' => $userChance->last_use_time
  395. ];
  396. }
  397. // ============ 从LotteryPrize模型移过来的业务逻辑方法 ============
  398. /**
  399. * 检查奖品库存是否充足
  400. */
  401. public static function hasPrizeStock(LotteryPrize $prize, $quantity = 1)
  402. {
  403. return $prize->remain_stock >= $quantity;
  404. }
  405. /**
  406. * 减少奖品库存
  407. */
  408. public static function decreasePrizeStock(LotteryPrize $prize, $quantity = 1)
  409. {
  410. if (!static::hasPrizeStock($prize, $quantity)) {
  411. return false;
  412. }
  413. $prize->remain_stock -= $quantity;
  414. $prize->win_count += $quantity;
  415. return $prize->save();
  416. }
  417. /**
  418. * 获取可用的兑换码
  419. */
  420. public static function getAvailableExchangeCode(LotteryPrize $prize)
  421. {
  422. if ($prize->type != LotteryEnum::PRIZE_TYPE_CODE) {
  423. return null;
  424. }
  425. $allCodes = $prize->exchange_codes_list;
  426. $usedCodes = $prize->used_codes_list;
  427. $availableCodes = array_diff($allCodes, $usedCodes);
  428. if (empty($availableCodes)) {
  429. return null;
  430. }
  431. return array_shift($availableCodes);
  432. }
  433. /**
  434. * 标记兑换码为已使用
  435. */
  436. public static function markExchangeCodeUsed(LotteryPrize $prize, $code)
  437. {
  438. $usedCodes = $prize->used_codes_list;
  439. if (!in_array($code, $usedCodes)) {
  440. $usedCodes[] = $code;
  441. $prize->used_codes = json_encode($usedCodes);
  442. return $prize->save();
  443. }
  444. return true;
  445. }
  446. /**
  447. * 获取有效奖品(库存大于0且状态正常)
  448. */
  449. public static function getValidPrizes($activityId)
  450. {
  451. return LotteryPrize::where('activity_id', $activityId)
  452. ->where('status', 1)
  453. ->where('remain_stock', '>', 0)
  454. ->order('sort_order', 'asc')
  455. ->select();
  456. }
  457. /**
  458. * 检查奖品是否已解锁(按人数解锁功能)
  459. */
  460. public static function isPrizeUnlocked(LotteryPrize $prize, $currentPeopleCount)
  461. {
  462. if (empty($prize->unlock_people_num)) {
  463. return true;
  464. }
  465. return $currentPeopleCount >= $prize->unlock_people_num;
  466. }
  467. // ============ 从LotteryActivity模型移过来的业务逻辑方法 ============
  468. /**
  469. * 检查活动是否正在进行
  470. */
  471. public static function isActivityRunning(LotteryActivity $activity)
  472. {
  473. $now = time();
  474. return $activity->status == LotteryEnum::STATUS_ONGOING
  475. && $activity->start_time <= $now
  476. && $activity->end_time >= $now;
  477. }
  478. /**
  479. * 检查活动是否已结束
  480. */
  481. public static function isActivityEnded(LotteryActivity $activity)
  482. {
  483. $now = time();
  484. return $activity->status == LotteryEnum::STATUS_ENDED
  485. || $activity->end_time < $now;
  486. }
  487. /**
  488. * 检查活动是否未开始
  489. */
  490. public static function isActivityNotStarted(LotteryActivity $activity)
  491. {
  492. $now = time();
  493. return $activity->status == LotteryEnum::STATUS_NOT_STARTED
  494. && $activity->start_time > $now;
  495. }
  496. /**
  497. * 检查活动是否已暂停
  498. */
  499. public static function isActivitySuspended(LotteryActivity $activity)
  500. {
  501. return $activity->status == LotteryEnum::STATUS_SUSPENDED;
  502. }
  503. /**
  504. * 检查活动是否已取消
  505. */
  506. public static function isActivityCancelled(LotteryActivity $activity)
  507. {
  508. return $activity->status == LotteryEnum::STATUS_CANCELLED;
  509. }
  510. /**
  511. * 检查是否在抽奖时间内
  512. */
  513. public static function isInDrawTime(LotteryActivity $activity)
  514. {
  515. if (!$activity->draw_time_enable) {
  516. return true;
  517. }
  518. $currentTime = date('H:i');
  519. return $currentTime >= $activity->draw_time_start
  520. && $currentTime <= $activity->draw_time_end;
  521. }
  522. /**
  523. * 获取正在进行的活动
  524. */
  525. public static function getRunningActivities()
  526. {
  527. $now = time();
  528. return LotteryActivity::where('status', LotteryEnum::STATUS_ONGOING)
  529. ->where('start_time', '<=', $now)
  530. ->where('end_time', '>=', $now)
  531. ->select();
  532. }
  533. /**
  534. * 获取未开始的活动
  535. */
  536. public static function getNotStartedActivities()
  537. {
  538. $now = time();
  539. return LotteryActivity::where('status', LotteryEnum::STATUS_NOT_STARTED)
  540. ->where('start_time', '>', $now)
  541. ->select();
  542. }
  543. /**
  544. * 获取已结束的活动
  545. */
  546. public static function getEndedActivities()
  547. {
  548. $now = time();
  549. return LotteryActivity::where('status', LotteryEnum::STATUS_ENDED)
  550. ->orWhere('end_time', '<', $now)
  551. ->select();
  552. }
  553. /**
  554. * 获取可显示的活动(排除逻辑状态)
  555. */
  556. public static function getDisplayableActivities()
  557. {
  558. $displayableStatuses = array_keys(LotteryEnum::getActivityStatusMap());
  559. return LotteryActivity::whereIn('status', $displayableStatuses)->select();
  560. }
  561. /**
  562. * 验证活动状态是否有效
  563. */
  564. public static function isValidActivityStatus(LotteryActivity $activity)
  565. {
  566. return LotteryEnum::isValidActivityStatus($activity->status);
  567. }
  568. /**
  569. * 验证开奖方式是否有效
  570. */
  571. public static function isValidLotteryType(LotteryActivity $activity)
  572. {
  573. return LotteryEnum::isValidLotteryType($activity->lottery_type);
  574. }
  575. }