LotteryService.php 21 KB

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