SuperballActivityService.php 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855
  1. <?php
  2. namespace App\Services;
  3. use App\Facade\TableName;
  4. use App\Game\Services\OuroGameService;
  5. use App\Http\helper\NumConfig;
  6. use App\Models\PrivateMail;
  7. use Carbon\Carbon;
  8. use Illuminate\Database\QueryException;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Redis;
  11. use App\Services\VipService;
  12. /**
  13. * Superball Activity: recharge + turnover task, balls, lucky number, prize pool.
  14. * All amounts in internal units (NUM_VALUE) where needed for DB/score.
  15. */
  16. class SuperballActivityService
  17. {
  18. public const TIER_MAX = 'SSS';
  19. public const MULTIPLIER_MIN = 1.0;
  20. public const MULTIPLIER_MAX = 3.0;
  21. public const MULTIPLIER_STEP = 0.5;
  22. public const LUCKY_REWARD_PER_BALL = 10; // display unit per matching ball
  23. /**
  24. * Get full activity info: yesterday data, today data, tiers, user task, multiplier.
  25. */
  26. public function getInfo(int $userId): array
  27. {
  28. $today = Carbon::today()->format('Y-m-d');
  29. $yesterday = Carbon::yesterday()->format('Y-m-d');
  30. $tiers = $this->getTierConfig();
  31. $yesterdayDaily = $this->getOrCreateDaily($yesterday);
  32. $todayDaily = $this->getOrCreateDaily($today);
  33. $userTask = $this->getUserTask($userId, $today);
  34. $multiplierRow = $this->getUserMultiplier($userId);
  35. // VIP 等级与每日免费球数(赠送球数 = VIP 等级)
  36. $vipLevel = $this->getUserVipLevel($userId);
  37. $level = VipService::getVipByField('VIP', $vipLevel);
  38. $vipFreeBalls = $level ? ($level->SuperballNum ?? 0) : 0;
  39. if ($vipFreeBalls == 0 && $userTask === null) {
  40. try {
  41. $this->selectTier($userId, 'E');
  42. } catch (QueryException $e) {
  43. if (stripos($e->getMessage(), 'duplicate key') === false) {
  44. throw $e;
  45. }
  46. }
  47. $userTask = $this->getUserTask($userId, $today);
  48. }
  49. $rechargeToday = $this->getUserRechargeForDate($userId, $today);
  50. $turnoverToday = $this->getUserTotalBetForDate($userId, $today);
  51. $turnoverProgress = (int)$turnoverToday;
  52. $tierConfig = $userTask ? $this->getTierConfigByTier($userTask->tier) : null;
  53. $rechargeRequired = $tierConfig ? (int)$tierConfig->recharge_required : 0;
  54. $turnoverRequired = $tierConfig ? (int)$tierConfig->turnover_required : 0;
  55. $rechargeDisplay = $rechargeToday;
  56. $turnoverDisplay = $turnoverProgress / NumConfig::NUM_VALUE;
  57. $taskCompleted = $tierConfig && $rechargeDisplay >= $rechargeRequired && $turnoverDisplay >= $turnoverRequired;
  58. if ($userTask && $userTask->status == 1) {
  59. $taskCompleted = $userTask->complete;
  60. }
  61. $canClaim = $taskCompleted && $userTask && (int)$userTask->status == 0;
  62. $canUpgrade = $userTask && $userTask->tier !== self::TIER_MAX && $taskCompleted;
  63. // 能升级自动升
  64. if ($canUpgrade && $userTask->status != 1) {
  65. $tierConfigs = $this->getTierConfig();
  66. $up = null;
  67. foreach (array_reverse($tierConfigs) as $c) {
  68. // 已经是最大的档位,直接升级
  69. if ($c['tier'] == self::TIER_MAX
  70. && $c['recharge_required'] < $rechargeToday && $c['turnover_required'] < $turnoverDisplay) {
  71. $up = $c;
  72. break;
  73. }
  74. if ($c['recharge_required'] < $rechargeToday && $c['turnover_required'] < $turnoverDisplay) {
  75. continue;
  76. }
  77. $up = $c;
  78. break;
  79. }
  80. $res = $this->upgradeTier($userId, $up['tier']);
  81. if ($res['success']) {
  82. $tierConfig = $this->getTierConfigByTier($res['new_tier']);
  83. $userTask = $this->getUserTask($userId, $today);
  84. $rechargeRequired = $tierConfig ? (int)$tierConfig->recharge_required : 0;
  85. $turnoverRequired = $tierConfig ? (int)$tierConfig->turnover_required : 0;
  86. $taskCompleted = $tierConfig && $rechargeDisplay >= $rechargeRequired && $turnoverDisplay >= $turnoverRequired;
  87. $canClaim = $taskCompleted && $userTask && (int)$userTask->status === 0;
  88. }
  89. }
  90. $rechargeDisplay = min($rechargeDisplay, $tierConfig->recharge_required ?? 0);
  91. $turnoverDisplay = min($turnoverDisplay, $tierConfig->turnover_required ?? 0);
  92. $yesterdayBalls = $this->getUserBalls($userId, $yesterday);
  93. $yesterdayLucky = (int)($yesterdayDaily->lucky_number ?? 0);
  94. $yesterdayPrizeLog = $this->getUserPrizeLog($userId, $yesterday);
  95. $yesterdayBasePerBall = 0;
  96. if ($yesterdayDaily->total_balls > 0 && $yesterdayDaily->pool_amount > 0) {
  97. $yesterdayBasePerBall = (int)($yesterdayDaily->pool_amount / $yesterdayDaily->total_balls);
  98. }
  99. $yesterdayMyPrize = $yesterdayPrizeLog ? (int)$yesterdayPrizeLog->total_amount : 0;
  100. $yesterdayMultiplier = $yesterdayPrizeLog ? (float)$yesterdayPrizeLog->multiplier : 1.0;
  101. $canClaimYesterday = false;
  102. $yesterdayPendingPrize = 0;
  103. if (!$yesterdayPrizeLog && count($yesterdayBalls) > 0) {
  104. $canClaimYesterday = true;
  105. $yesterdayPendingPrize = $this->calculateYesterdayPrizeForUser($userId, $yesterday);
  106. }
  107. if (count($yesterdayBalls) > 0) {
  108. $daily = DB::table(TableName::agent() . 'superball_daily')->where('pool_date', $yesterday)->first();
  109. $hitCount = $baseAmount = 0;
  110. if ($daily) {
  111. $basePerBall = (int)($daily->pool_amount / $daily->total_balls);
  112. $hitCount = count(array_filter($yesterdayBalls, function ($item) use ($yesterdayLucky) {
  113. return $item->number == $yesterdayLucky;
  114. }));
  115. }
  116. $multiplier = DB::table('agent.dbo.superball_user_multiplier')->where('user_id', $userId)
  117. ->value('multiplier') ?: 1.0;
  118. self::sendOpenRewardMailOnce($userId, count($yesterdayBalls), $yesterdayLucky, $multiplier, $hitCount, $basePerBall/NumConfig::NUM_VALUE);
  119. }
  120. // 用户昨日未领取球
  121. $yesterdayNotClaimedBallCount = $this->getNotClaimBallByUserIdDate($userId, $yesterday);
  122. $todayBasePerBall = 0;
  123. if ($todayDaily->total_balls > 0 && $todayDaily->pool_amount > 0) {
  124. $todayBasePerBall = (int)($todayDaily->pool_amount / $todayDaily->total_balls);
  125. }
  126. $todayBalls = $this->getUserBalls($userId, $today);
  127. $todayMyBallsList = array_map(function ($b) {
  128. return ['ball_index' => (int)$b->ball_index, 'number' => (int)$b->number];
  129. }, $todayBalls);
  130. $todayNumberCounts = [];
  131. foreach ($todayBalls as $b) {
  132. $n = (int)$b->number;
  133. $todayNumberCounts[$n] = ($todayNumberCounts[$n] ?? 0) + 1;
  134. }
  135. // 0 点到 1 点前,前端展示的今日整体数据全部置为 0(不影响实际统计与任务进度)
  136. $hour = (int)Carbon::now()->format('G'); // 0-23
  137. if ($hour < 1) {
  138. $todayDisplayPoolAmount = 0;
  139. $todayDisplayCompleted = 0;
  140. $todayDisplayTotalBalls = 0;
  141. $todayDisplayBasePerBall = 0;
  142. $todayDisplayMyBalls = $todayMyBallsList;
  143. $todayDisplayNumberCounts = $todayNumberCounts;
  144. } else {
  145. $todayDisplayPoolAmount = (int)$todayDaily->pool_amount;
  146. $todayDisplayCompleted = (int)($todayDaily->completed_count ?? 0);
  147. $todayDisplayTotalBalls = (int)($todayDaily->total_balls ?? 0);
  148. $todayDisplayBasePerBall = $todayBasePerBall;
  149. $todayDisplayMyBalls = $todayMyBallsList;
  150. $todayDisplayNumberCounts = $todayNumberCounts;
  151. }
  152. $last7Lucky = $this->getLast7DaysLuckyNumbersPrivate();
  153. $data = [
  154. 'yesterday' => [
  155. 'pool_amount' => (int)$yesterdayDaily->pool_amount,
  156. 'pool_amount_display' => (int)$yesterdayDaily->pool_amount / NumConfig::NUM_VALUE,
  157. 'base_reward_per_ball' => $yesterdayBasePerBall,
  158. 'base_reward_per_ball_display' => $yesterdayBasePerBall / NumConfig::NUM_VALUE,
  159. 'lucky_number' => $yesterdayLucky,
  160. 'completed_count' => (int)($yesterdayDaily->completed_count ?? 0),
  161. 'total_balls' => (int)($yesterdayDaily->total_balls ?? 0),
  162. 'my_balls' => $yesterdayBalls,
  163. 'my_balls_with_lucky' => array_map(function ($b) use ($yesterdayLucky) {
  164. return ['number' => (int)$b->number, 'is_lucky' => (int)$b->number === $yesterdayLucky];
  165. }, $yesterdayBalls),
  166. 'my_prize' => $yesterdayMyPrize,
  167. 'my_prize_display' => $yesterdayMyPrize / NumConfig::NUM_VALUE,
  168. 'my_multiplier' => $yesterdayMultiplier,
  169. 'can_claim_yesterday' => $canClaimYesterday,
  170. 'pending_prize' => $yesterdayPendingPrize,
  171. 'pending_prize_display' => $yesterdayPendingPrize / NumConfig::NUM_VALUE,
  172. 'yesterday_not_claimed_ball_count' => $yesterdayNotClaimedBallCount,
  173. ],
  174. 'today' => [
  175. 'pool_amount' => $todayDisplayPoolAmount,
  176. 'pool_amount_display' => $todayDisplayPoolAmount / NumConfig::NUM_VALUE,
  177. 'completed_count' => $todayDisplayCompleted,
  178. 'total_balls' => $todayDisplayTotalBalls,
  179. 'base_reward_per_ball' => $todayDisplayBasePerBall,
  180. 'base_reward_per_ball_display' => $todayDisplayBasePerBall / NumConfig::NUM_VALUE,
  181. 'my_balls' => $todayDisplayMyBalls,
  182. 'number_counts' => $todayDisplayNumberCounts,
  183. ],
  184. 'tiers' => $tiers,
  185. 'user_task' => $userTask ? [
  186. 'tier' => $userTask->tier,
  187. 'recharge_required' => $rechargeRequired,
  188. 'turnover_required' => $turnoverRequired,
  189. 'recharge_progress' => $rechargeDisplay,
  190. 'turnover_progress' => $turnoverDisplay,
  191. 'task_completed' => (bool) $taskCompleted,
  192. 'status' => (int)$userTask->status,
  193. 'can_claim' => $canClaim,
  194. 'can_upgrade' => $canUpgrade,
  195. 'ball_count' => $tierConfig ? (int)$tierConfig->ball_count : 0,
  196. ] : null,
  197. 'multiplier' => [
  198. 'value' => (float)($multiplierRow->multiplier ?? 1.0),
  199. 'consecutive_days' => (int)($multiplierRow->consecutive_days ?? 0),
  200. 'min' => self::MULTIPLIER_MIN,
  201. 'max' => self::MULTIPLIER_MAX,
  202. 'step' => self::MULTIPLIER_STEP,
  203. ],
  204. 'vip' => [
  205. 'level' => $vipLevel,
  206. 'daily_free_balls' => $vipFreeBalls,
  207. ],
  208. 'lucky_reward_per_ball' => self::LUCKY_REWARD_PER_BALL,
  209. 'lucky_numbers_7_days' => $last7Lucky,
  210. 'can_sumbit' => array_sum(array_keys($todayNumberCounts)) < $vipFreeBalls + ($taskCompleted ? $tierConfig->ball_count : 0)
  211. ];
  212. return $data;
  213. }
  214. /**
  215. * 获取用户 VIP 等级(用于每日赠送球数计算)
  216. */
  217. protected function getUserVipLevel(int $userId): int
  218. {
  219. if ($userId <= 0) {
  220. return 0;
  221. }
  222. // 从 YN_VIPAccount 读取累计充值金额,交由 VipService 计算 VIP 等级
  223. $userRecharge = (int)DB::table('QPAccountsDB.dbo.YN_VIPAccount')
  224. ->where('UserID', $userId)
  225. ->value('Recharge');
  226. return (int)VipService::calculateVipLevel($userId, $userRecharge);
  227. }
  228. /**
  229. * Select task tier for today (with optional confirm). Creates or updates user task.
  230. * @return array success: ['success' => true] | failure: ['success' => false, 'message' => ['key', 'fallback']]
  231. */
  232. public function selectTier(int $userId, string $tier): array
  233. {
  234. $today = Carbon::today()->format('Y-m-d');
  235. $config = $this->getTierConfigByTier($tier);
  236. if (!$config) {
  237. return ['success' => false, 'message' => ['web.superball.activity_not_found', 'Activity not found']];
  238. }
  239. $existing = $this->getUserTask($userId, $today);
  240. if ($existing) {
  241. return ['success' => false, 'message' => ['web.superball.tier_already_selected', 'Already selected tier for today']];
  242. }
  243. DB::connection('write')->table(TableName::agent() . 'superball_user_task')->insert([
  244. 'user_id' => $userId,
  245. 'task_date' => $today,
  246. 'tier' => $tier,
  247. 'total_bet_snapshot' => 0,
  248. 'status' => 0,
  249. 'created_at' => now()->format('Y-m-d H:i:s'),
  250. 'updated_at' => now()->format('Y-m-d H:i:s'),
  251. ]);
  252. return ['success' => true];
  253. }
  254. /**
  255. * Upgrade to higher tier (keep progress). Only allowed when task completed and not A.
  256. * @return array success: ['success' => true] | failure: ['success' => false, 'message' => [...]]
  257. */
  258. public function upgradeTier(int $userId, string $newTier): array
  259. {
  260. $today = Carbon::today()->format('Y-m-d');
  261. $task = $this->getUserTask($userId, $today);
  262. if (!$task) {
  263. return ['success' => false, 'message' => ['web.superball.no_task_today', 'No task for today']];
  264. }
  265. if ((int)$task->status === 1) {
  266. return ['success' => false, 'message' => ['web.superball.already_claimed', 'Already claimed']];
  267. }
  268. $tierOrder = ['E' => 1, 'D' => 2, 'C' => 3, 'B' => 4, 'A' => 5, 'S' => 6, 'SS' => 7, 'SSS' => 8];
  269. $currentOrder = $tierOrder[$task->tier] ?? 0;
  270. $newOrder = $tierOrder[$newTier] ?? 0;
  271. if ($newOrder <= $currentOrder) {
  272. return ['success' => false, 'message' => ['web.superball.cannot_downgrade', 'Can only upgrade to higher tier']];
  273. }
  274. $rechargeToday = $this->getUserRechargeForDate($userId, $today);
  275. $turnoverToday = $this->getUserTotalBetForDate($userId, $today);
  276. $newConfig = $this->getTierConfigByTier($task->tier);
  277. $rechargeOk = $rechargeToday >= (int)$newConfig->recharge_required;
  278. $turnoverOk = ($turnoverToday / NumConfig::NUM_VALUE) >= (int)$newConfig->turnover_required;
  279. // var_dump($rechargeToday,$turnoverToday,$newConfig);
  280. if (!$rechargeOk || !$turnoverOk) {
  281. return ['success' => false, 'message' => ['web.superball.task_not_completed', 'Task not completed']];
  282. }
  283. DB::connection('write')->table(TableName::agent() . 'superball_user_task')
  284. ->where('user_id', $userId)
  285. ->where('task_date', $today)
  286. ->update(['tier' => $newTier, 'updated_at' => now()->format('Y-m-d H:i:s')]);
  287. return ['success' => true, 'user_id' => $userId, 'new_tier' => $newTier, 'task_date' => $today];
  288. }
  289. /**
  290. * Claim reward: grant balls, update daily total_balls/completed_count, update multiplier, mark task claimed.
  291. * @return array success: ['ball_count' => n, 'message' => ...] | failure: ['success' => false, 'message' => [...]]
  292. */
  293. public function claimReward(int $userId): array
  294. {
  295. $today = Carbon::today()->format('Y-m-d');
  296. $task = $this->getUserTask($userId, $today);
  297. $vipLevel = $this->getUserVipLevel($userId);
  298. $level = VipService::getVipByField('VIP', $vipLevel);
  299. $vipFreeBalls = $level ? ($level->SuperballNum ?? 0) : 0;
  300. if (!$task) {
  301. // 昨日未领取奖励
  302. $yesterday = Carbon::yesterday()->format('Y-m-d');
  303. $yesterdayBallCount = $this->getNotClaimBallByUserIdDate($userId, $yesterday);
  304. if ($yesterdayBallCount > 0) {
  305. $vipFreeBalls += $yesterdayBallCount;
  306. }
  307. $this->claimIfHasVipReward($userId, $vipFreeBalls);
  308. $this->selectTier($userId, 'E');
  309. return [
  310. 'ball_count' => $vipFreeBalls,
  311. 'base_ball_count' => 0,
  312. 'vip_free_balls' => $vipFreeBalls,
  313. 'message' => 'Claim success, please select numbers for your balls',
  314. ];
  315. }
  316. if ((int)$task->status === 1) {
  317. return ['success' => false, 'message' => ['web.superball.already_claimed', 'Already claimed']];
  318. }
  319. $tierConfig = $this->getTierConfigByTier($task->tier);
  320. if (!$tierConfig) {
  321. return ['success' => false, 'message' => ['web.superball.activity_not_found', 'Activity not found']];
  322. }
  323. $rechargeToday = $this->getUserRechargeForDate($userId, $today);
  324. $turnoverToday = $this->getUserTotalBetForDate($userId, $today);
  325. $rechargeOk = ($rechargeToday) >= (int)$tierConfig->recharge_required;
  326. $turnoverOk = ($turnoverToday / NumConfig::NUM_VALUE) >= (int)$tierConfig->turnover_required;
  327. $complete = 1;
  328. if (!$rechargeOk || !$turnoverOk) {
  329. // 本档没完成
  330. $complete = 0;
  331. }
  332. // 所有任务累加
  333. $ballCount = 0;
  334. if ($complete) {
  335. $ballCount = $tierConfig->ball_count;
  336. } else if ($tierConfig->tier !== 'E') { // 没完成获取上一级奖励
  337. $idx = $tierConfig->sort_index + 1;
  338. $configs = $this->getTierConfig();
  339. foreach ($configs as $config) {
  340. if ($config['sort_index'] == $idx) {
  341. $ballCount += $config['ball_count'];
  342. }
  343. }
  344. }
  345. if ($ballCount < 1) {
  346. return ['success' => false, 'message' => ['web.superball.task_not_completed', 'Task not completed']];
  347. }
  348. DB::connection('write')->transaction(function () use ($userId, $today, $task, $ballCount, $complete) {
  349. DB::connection('write')->table(TableName::agent() . 'superball_user_task')
  350. ->where('user_id', $userId)
  351. ->where('task_date', $today)
  352. ->update(['status' => 1, 'complete' => $complete, 'updated_at' => now()->format('Y-m-d H:i:s')]);
  353. $this->getOrCreateDaily($today);
  354. DB::connection('write')->table(TableName::agent() . 'superball_daily')
  355. ->where('pool_date', $today)
  356. ->update([
  357. 'total_balls' => DB::raw('total_balls+' . $ballCount),
  358. 'completed_count' => DB::raw('completed_count+1'),
  359. 'updated_at' => now()->format('Y-m-d H:i:s'),
  360. ]);
  361. $this->updateUserMultiplier($userId, $today);
  362. });
  363. return [
  364. 'ball_count' => $ballCount,
  365. 'base_ball_count' => $ballCount,
  366. 'vip_free_balls' => $vipFreeBalls,
  367. 'message' => 'Claim success, please select numbers for your balls',
  368. ];
  369. }
  370. public function claimIfHasVipReward(int $userId, $vipFreeBalls): bool
  371. {
  372. $key = sprintf('claim_vip_reward_%s_%s', $userId, date('Ymd'));
  373. if (Redis::exists($key)) {
  374. return false;
  375. }
  376. $today = Carbon::today()->format('Y-m-d');
  377. $this->getOrCreateDaily($today);
  378. DB::connection('write')->table(TableName::agent() . 'superball_daily')
  379. ->where('pool_date', $today)
  380. ->update([
  381. 'total_balls' => DB::raw('total_balls+' . $vipFreeBalls),
  382. 'updated_at' => now()->format('Y-m-d H:i:s'),
  383. ]);
  384. Redis::set($key, $vipFreeBalls);
  385. Redis::expire($key, 86400);
  386. return true;
  387. }
  388. /**
  389. * Submit numbers for all balls (0-9 per ball). Must have claimed and not yet submitted.
  390. * @return array success: ['success' => true] | failure: ['success' => false, 'message' => [...]]
  391. */
  392. public function submitNumbers(int $userId, array $numbers): array
  393. {
  394. $key = sprintf('claim_vip_reward_%s_%s', $userId, date('Ymd'));
  395. $vipFreeBalls = (int)Redis::get($key);
  396. $vipLevel = $this->getUserVipLevel($userId);
  397. $level = VipService::getVipByField('VIP', $vipLevel);
  398. $vipFreeBalls2 = $level ? ($level->SuperballNum ?? 0) : 0;
  399. $vipFreeBalls = max($vipFreeBalls, $vipFreeBalls2);
  400. $today = Carbon::today()->format('Y-m-d');
  401. $task = $this->getUserTask($userId, $today);
  402. $ballCount = 0;
  403. $tierConfig = $this->getTierConfigByTier($task->tier);
  404. if ($task && $task->status == 1) {
  405. if ($task->complete == 1) {
  406. $ballCount = $tierConfig->ball_count;
  407. } else if ($tierConfig->tier !== 'E') { // 没完成获取上一级奖励
  408. $idx = $tierConfig->sort_index + 1;
  409. $configs = $this->getTierConfig();
  410. foreach ($configs as $config) {
  411. if ($config['sort_index'] == $idx) {
  412. $ballCount += $config['ball_count'];
  413. }
  414. }
  415. }
  416. }
  417. $existing = DB::connection('write')->table(TableName::agent() . 'superball_user_balls')
  418. ->where('user_id', $userId)
  419. ->where('ball_date', $today)
  420. ->count();
  421. // 与领取时保持一致:基础任务球数 + 每日 VIP 免费球数
  422. $ballCount = $ballCount + $vipFreeBalls;
  423. if ($ballCount < 1) {
  424. return ['success' => false, 'message' => ['web.superball.number_count_mismatch', 'no ball']];
  425. }
  426. if (count($numbers) + $existing > $ballCount) {
  427. $remain = $ballCount - $existing;
  428. if ($remain < 1) {
  429. return ['success' => false, 'message' => ['web.superball.number_count_mismatch', 'Number count mismatch']];
  430. }
  431. $numbers = array_slice($numbers, 0, $remain);
  432. }
  433. DB::connection('write')->transaction(function () use ($userId, $today, $numbers) {
  434. // 写入用户今日所有球号
  435. foreach ($numbers as $index => $num) {
  436. $n = (int)$num;
  437. if ($n < 0 || $n > 9) {
  438. throw new \RuntimeException('Number must be 0-9');
  439. }
  440. DB::connection('write')->table(TableName::agent() . 'superball_user_balls')->insert([
  441. 'user_id' => $userId,
  442. 'ball_date' => $today,
  443. 'ball_index' => $index + 1,
  444. 'number' => $n,
  445. 'created_at' => now()->format('Y-m-d H:i:s'),
  446. ]);
  447. }
  448. // 若用户选择的号码等于当日 lucky_number,则把 superball_daily.lucky_count 累加
  449. $daily = DB::connection('write')
  450. ->table(TableName::agent() . 'superball_daily')
  451. ->where('pool_date', $today)
  452. ->first();
  453. if ($daily) {
  454. $luckyNumber = (int)$daily->lucky_number;
  455. $matched = 0;
  456. foreach ($numbers as $num) {
  457. if ((int)$num === $luckyNumber) {
  458. $matched++;
  459. }
  460. }
  461. if ($matched > 0) {
  462. DB::connection('write')
  463. ->table(TableName::agent() . 'superball_daily')
  464. ->where('pool_date', $today)
  465. ->update([
  466. 'lucky_count' => DB::raw("lucky_count + {$matched}"),
  467. 'updated_at' => now()->format('Y-m-d H:i:s'),
  468. ]);
  469. }
  470. }
  471. });
  472. return ['success' => true];
  473. }
  474. /**
  475. * Get user's balls for a date (for number selection page or display).
  476. */
  477. public function getMyBalls(int $userId, string $date): array
  478. {
  479. $rows = DB::table(TableName::agent() . 'superball_user_balls')
  480. ->where('user_id', $userId)
  481. ->where('ball_date', $date)
  482. ->orderBy('ball_index')
  483. ->get();
  484. return array_map(function ($r) {
  485. return ['ball_index' => (int)$r->ball_index, 'number' => (int)$r->number];
  486. }, $rows->all());
  487. }
  488. /**
  489. * User claims yesterday's reward (next-day claim, no auto distribution).
  490. * Formula: base = pool / total_balls * ball_count, lucky = 10 * matched_balls (display), total = base * multiplier + lucky.
  491. * @return array success: data with total_amount etc. | failure: ['success' => false, 'message' => [...]]
  492. */
  493. public function claimYesterdayReward(int $userId): array
  494. {
  495. $yesterday = Carbon::yesterday()->format('Y-m-d');
  496. $existing = $this->getUserPrizeLog($userId, $yesterday);
  497. if ($existing) {
  498. return ['success' => false, 'message' => ['web.superball.yesterday_already_claimed', 'Yesterday reward already claimed']];
  499. }
  500. $balls = $this->getUserBalls($userId, $yesterday);
  501. if (count($balls) === 0) {
  502. return ['success' => false, 'message' => ['web.superball.no_balls_yesterday', 'No balls for yesterday, nothing to claim']];
  503. }
  504. $daily = DB::table(TableName::agent() . 'superball_daily')->where('pool_date', $yesterday)->first();
  505. if (!$daily || (int)$daily->total_balls <= 0) {
  506. return ['success' => false, 'message' => ['web.superball.pool_not_ready', 'Yesterday pool not ready']];
  507. }
  508. $basePerBall = (int)($daily->pool_amount / $daily->total_balls);
  509. $luckyNumber = (int)$daily->lucky_number;
  510. $multiplierRow = $this->getUserMultiplier($userId);
  511. $multiplier = (float)($multiplierRow->multiplier ?? 1.0);
  512. // 选号可重复:每个球单独比对幸运号,中奖球数 = 号码等于幸运号的球个数(同一号码可多球)
  513. $matched = 0;
  514. foreach ($balls as $b) {
  515. if ((int)$b->number === $luckyNumber) {
  516. $matched++;
  517. }
  518. }
  519. $baseAmount = $basePerBall * count($balls);
  520. $luckyAmountDisplay = self::LUCKY_REWARD_PER_BALL * $matched;
  521. $luckyAmountInternal = $luckyAmountDisplay * NumConfig::NUM_VALUE;
  522. $totalAmount = (int)round($baseAmount * $multiplier) + $luckyAmountInternal;
  523. DB::connection('write')->table(TableName::agent() . 'superball_prize_log')->insert([
  524. 'user_id' => $userId,
  525. 'settle_date' => $yesterday,
  526. 'base_amount' => $baseAmount,
  527. 'lucky_amount' => $luckyAmountInternal,
  528. 'multiplier' => $multiplier,
  529. 'total_amount' => $totalAmount,
  530. 'created_at' => now()->format('Y-m-d H:i:s'),
  531. ]);
  532. OuroGameService::AddScore($userId, $totalAmount, 90);
  533. return [
  534. 'total_amount' => $totalAmount,
  535. 'total_amount_display' => $totalAmount / NumConfig::NUM_VALUE,
  536. 'base_amount' => $baseAmount,
  537. 'lucky_amount' => $luckyAmountInternal,
  538. 'multiplier' => $multiplier,
  539. ];
  540. }
  541. /**
  542. * Calculate yesterday prize for user (for display only, no claim).
  543. */
  544. public function calculateYesterdayPrizeForUser(int $userId, string $yesterday): int
  545. {
  546. $daily = DB::table(TableName::agent() . 'superball_daily')->where('pool_date', $yesterday)->first();
  547. if (!$daily || (int)$daily->total_balls <= 0) {
  548. return 0;
  549. }
  550. $balls = $this->getUserBalls($userId, $yesterday);
  551. if (count($balls) === 0) {
  552. return 0;
  553. }
  554. $basePerBall = (int)($daily->pool_amount / $daily->total_balls);
  555. $luckyNumber = (int)$daily->lucky_number;
  556. $multiplierRow = $this->getUserMultiplier($userId);
  557. $multiplier = (float)($multiplierRow->multiplier ?? 1.0);
  558. // 选号可重复:按球逐个比对幸运号,中奖球数 = 号码等于幸运号的球个数
  559. $matched = 0;
  560. foreach ($balls as $b) {
  561. if ((int)$b->number === $luckyNumber) {
  562. $matched++;
  563. }
  564. }
  565. $baseAmount = $basePerBall * count($balls);
  566. $luckyAmountInternal = self::LUCKY_REWARD_PER_BALL * $matched * NumConfig::NUM_VALUE;
  567. return (int)round($baseAmount * $multiplier) + $luckyAmountInternal;
  568. }
  569. /**
  570. * Get last 7 days lucky numbers (for display).
  571. */
  572. public function getLast7DaysLuckyNumbers(): array
  573. {
  574. return $this->getLast7DaysLuckyNumbersPrivate();
  575. }
  576. /**
  577. * Ensure daily row and lucky number for date (idempotent).
  578. */
  579. public function getOrCreateDaily(string $date): \stdClass
  580. {
  581. $row = DB::table(TableName::agent() . 'superball_daily')->where('pool_date', $date)->first();
  582. if ($row) {
  583. return $row;
  584. }
  585. $lucky = mt_rand(0, 9);
  586. try {
  587. DB::connection('write')->table(TableName::agent() . 'superball_daily')->insert([
  588. 'pool_date' => $date,
  589. 'pool_amount' => 0,
  590. 'total_balls' => 0,
  591. 'lucky_number' => $lucky,
  592. 'completed_count' => 0,
  593. 'lucky_count' => 0,
  594. 'created_at' => now()->format('Y-m-d H:i:s'),
  595. 'updated_at' => now()->format('Y-m-d H:i:s'),
  596. ]);
  597. } catch (QueryException $e) {
  598. // Concurrent getOrCreateDaily: unique index IX_superball_daily_date (pool_date)
  599. if (stripos($e->getMessage(), 'duplicate key') === false) {
  600. throw $e;
  601. }
  602. }
  603. return DB::table(TableName::agent() . 'superball_daily')->where('pool_date', $date)->first();
  604. }
  605. /**
  606. * Update pool amount for a date (call from job that aggregates daily turnover).
  607. */
  608. public function updatePoolAmount(string $date, int $poolAmountInternal): void
  609. {
  610. DB::connection('write')->table(TableName::agent() . 'superball_daily')
  611. ->where('pool_date', $date)
  612. ->update(['pool_amount' => $poolAmountInternal, 'updated_at' => now()->format('Y-m-d H:i:s')]);
  613. }
  614. /**
  615. * 获取某天未领取的球数
  616. * @param $userId
  617. * @param $date
  618. * @return int
  619. */
  620. public function getNotClaimBallByUserIdDate($userId, $date): int
  621. {
  622. $cacheKey = sprintf('superball_yesterday_not_claim_%d_%s', $userId, $date);
  623. if (Redis::exists($cacheKey)) {
  624. $ball = Redis::get($cacheKey);
  625. return (int)$ball;
  626. }
  627. $ball = 0;
  628. $task = $this->getUserTask($userId, $date);
  629. if ($task && $task->status == 0) {
  630. $recharge = $this->getUserRechargeForDate($userId, $date);
  631. $turnover = $this->getUserTotalBetForDate($userId, $date);
  632. $config = $this->getTierConfigByTier($task->tier);
  633. if ($recharge >= $config->recharge_required && $turnover / NumConfig::NUM_VALUE >= $config->turnover_required) {
  634. $ball = $config->ball_count;
  635. }
  636. }
  637. Redis::set($cacheKey, $ball);
  638. Redis::expireAt($cacheKey, strtotime('today +1 day'));
  639. return $ball;
  640. }
  641. // --- private helpers ---
  642. private function getTierConfig(): array
  643. {
  644. $rows = DB::table(TableName::agent() . 'superball_tier_config')
  645. ->orderBy('sort_index')
  646. ->get();
  647. return array_map(function ($r) {
  648. return [
  649. 'sort_index' => $r->sort_index,
  650. 'tier' => $r->tier,
  651. 'recharge_required' => (int)$r->recharge_required,
  652. 'turnover_required' => (int)$r->turnover_required,
  653. 'ball_count' => (int)$r->ball_count,
  654. ];
  655. }, $rows->all());
  656. }
  657. private function getTierConfigByTier(string $tier): ?\stdClass
  658. {
  659. return DB::table(TableName::agent() . 'superball_tier_config')->where('tier', $tier)->first();
  660. }
  661. private function getUserTask(int $userId, string $date): ?\stdClass
  662. {
  663. return DB::table(TableName::agent() . 'superball_user_task')
  664. ->where('user_id', $userId)
  665. ->where('task_date', $date)
  666. ->first();
  667. }
  668. private function getUserRechargeForDate(int $userId, string $date): int
  669. {
  670. $dateId = str_replace('-', '', $date);
  671. $row = DB::table(TableName::QPRecordDB() . 'RecordUserDataStatisticsNew')
  672. ->where('UserID', $userId)
  673. ->where('DateID', $dateId)
  674. ->first();
  675. return $row ? (int)$row->Recharge : 0;
  676. }
  677. /** 当日流水:从按日统计表取当天 TotalBet(内部单位) */
  678. private function getUserTotalBetForDate(int $userId, string $date): int
  679. {
  680. $dateId = str_replace('-', '', $date);
  681. $row = DB::table(TableName::QPRecordDB() . 'RecordUserDataStatisticsNew')
  682. ->where('UserID', $userId)
  683. ->where('DateID', $dateId)
  684. ->first();
  685. return $row && isset($row->TotalBet) ? (int)$row->TotalBet : 0;
  686. }
  687. private function getUserTotalBet(int $userId): int
  688. {
  689. $row = DB::table(TableName::QPRecordDB() . 'RecordUserTotalStatistics')
  690. ->where('UserID', $userId)
  691. ->first();
  692. return $row ? (int)$row->TotalBet : 0;
  693. }
  694. private function getUserBalls(int $userId, string $date): array
  695. {
  696. return DB::table(TableName::agent() . 'superball_user_balls')
  697. ->where('user_id', $userId)
  698. ->where('ball_date', $date)
  699. ->orderBy('ball_index')
  700. ->get()
  701. ->all();
  702. }
  703. private function getUserMultiplier(int $userId): ?\stdClass
  704. {
  705. return DB::table(TableName::agent() . 'superball_user_multiplier')->where('user_id', $userId)->first();
  706. }
  707. private function getUserPrizeLog(int $userId, string $date): ?\stdClass
  708. {
  709. return DB::table(TableName::agent() . 'superball_prize_log')
  710. ->where('user_id', $userId)
  711. ->where('settle_date', $date)
  712. ->first();
  713. }
  714. private function getLast7DaysLuckyNumbersPrivate(): array
  715. {
  716. $dates = [];
  717. for ($i = 0; $i < 7; $i++) {
  718. $dates[] = Carbon::today()->subDays($i)->format('Y-m-d');
  719. }
  720. $rows = DB::table(TableName::agent() . 'superball_daily')
  721. ->whereIn('pool_date', $dates)
  722. ->orderBy('pool_date', 'desc')
  723. ->get();
  724. $map = [];
  725. foreach ($rows as $r) {
  726. $map[$r->pool_date] = (int)$r->lucky_number;
  727. }
  728. return array_map(function ($d) use ($map) {
  729. return ['date' => $d, 'lucky_number' => $map[$d] ?? null];
  730. }, $dates);
  731. }
  732. private function updateUserMultiplier(int $userId, string $taskDate): void
  733. {
  734. $row = DB::connection('write')->table(TableName::agent() . 'superball_user_multiplier')
  735. ->where('user_id', $userId)
  736. ->lockForUpdate()
  737. ->first();
  738. $multiplier = self::MULTIPLIER_MIN;
  739. $consecutive = 1;
  740. if ($row) {
  741. $last = $row->last_task_date ? (string)$row->last_task_date : null;
  742. $prev = Carbon::parse($taskDate)->subDay()->format('Y-m-d');
  743. if ($last === $prev) {
  744. $consecutive = (int)$row->consecutive_days + 1;
  745. $multiplier = min(self::MULTIPLIER_MAX, (float)$row->multiplier + self::MULTIPLIER_STEP);
  746. } elseif ($last !== null && $last !== $taskDate) {
  747. $daysDiff = (int)Carbon::parse($taskDate)->diffInDays(Carbon::parse($last));
  748. if ($daysDiff > 1) {
  749. $multiplier = max(self::MULTIPLIER_MIN, (float)$row->multiplier - self::MULTIPLIER_STEP);
  750. $consecutive = 1;
  751. } else {
  752. $multiplier = (float)$row->multiplier;
  753. $consecutive = (int)$row->consecutive_days + 1;
  754. }
  755. }
  756. }
  757. DB::connection('write')->table(TableName::agent() . 'superball_user_multiplier')->updateOrInsert(
  758. ['user_id' => $userId],
  759. [
  760. 'consecutive_days' => $consecutive,
  761. 'last_task_date' => $taskDate,
  762. 'multiplier' => $multiplier,
  763. 'updated_at' => now()->format('Y-m-d H:i:s'),
  764. ]
  765. );
  766. }
  767. private function sendOpenRewardMailOnce($userId, $ballCount, $luckyNumber, $multiplier, $hitCount, $basePrize): void
  768. {
  769. $existsKey = sprintf('superball_open_%s_%s', $userId, date('Ymd'));
  770. if (Redis::exists($existsKey)) {
  771. return;
  772. }
  773. $totalPrize = $ballCount * $basePrize * $multiplier + $hitCount * 10;
  774. PrivateMail::sendMail(
  775. 2,
  776. $userId,
  777. 'Your Lucky Ball Results!',
  778. "Yesterday you earned {$ballCount} balls and your lucky number was {$luckyNumber}.
  779. You matched {$hitCount} balls, winning a base prize of {$basePrize} with a {$multiplier}x reward multiplier.
  780. Total Prize: {$ballCount}*{$basePrize}*{$multiplier}+{$hitCount}*10={$totalPrize}",
  781. '',
  782. '');
  783. Redis::setex($existsKey, 86400 * 2, 1);
  784. }
  785. }