LotteryService.php 19 KB

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