From ef407a8c6ea238042edcb0d9c553f2241bfac4d7 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 12 Apr 2026 22:25:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96ai=E5=B0=8F=E7=8F=AD=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/AiHeartbeatCommand.php | 20 +- app/Http/Controllers/ChatBotController.php | 11 +- app/Jobs/AiBaccaratBetJob.php | 163 +++++++-------- app/Services/AiFinanceService.php | 215 ++++++++++++++++++++ tests/Feature/AiBaccaratBetJobTest.php | 139 +++++++++++++ tests/Feature/AiFinanceServiceTest.php | 111 ++++++++++ tests/Feature/ChatBotControllerTest.php | 40 +++- 7 files changed, 608 insertions(+), 91 deletions(-) create mode 100644 app/Services/AiFinanceService.php create mode 100644 tests/Feature/AiBaccaratBetJobTest.php create mode 100644 tests/Feature/AiFinanceServiceTest.php diff --git a/app/Console/Commands/AiHeartbeatCommand.php b/app/Console/Commands/AiHeartbeatCommand.php index cdd945f..d57a274 100644 --- a/app/Console/Commands/AiHeartbeatCommand.php +++ b/app/Console/Commands/AiHeartbeatCommand.php @@ -20,11 +20,15 @@ use App\Jobs\SaveMessageJob; use App\Models\Autoact; use App\Models\Sysparam; use App\Models\User; +use App\Services\AiFinanceService; use App\Services\ChatStateService; use App\Services\UserCurrencyService; use App\Services\VipService; use Illuminate\Console\Command; +/** + * 定时模拟 AI小班长心跳,并同步维护其常规存款理财行为。 + */ class AiHeartbeatCommand extends Command { /** @@ -37,14 +41,21 @@ class AiHeartbeatCommand extends Command */ protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件'; + /** + * 注入聊天室状态、VIP、积分与 AI 资金调度服务。 + */ public function __construct( private readonly ChatStateService $chatState, private readonly VipService $vipService, private readonly UserCurrencyService $currencyService, + private readonly AiFinanceService $aiFinance, ) { parent::__construct(); } + /** + * 执行 AI小班长单次心跳,并处理奖励、随机事件与资金调度。 + */ public function handle(): int { // 1. 检查总开关 @@ -58,6 +69,9 @@ class AiHeartbeatCommand extends Command return Command::SUCCESS; } + // 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。 + $this->aiFinance->bankExcessGold($user); + // 3. 常规心跳经验与金币发放 // (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励) $expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1')); @@ -133,7 +147,8 @@ class AiHeartbeatCommand extends Command $fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率 if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) { $cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); - if ($user->jjb >= $cost) { + // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。 + if ($this->aiFinance->prepareSpend($user, $cost)) { // 先扣除费用 $this->currencyService->change( $user, 'gold', -$cost, @@ -155,6 +170,9 @@ class AiHeartbeatCommand extends Command } } + // 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。 + $this->aiFinance->bankExcessGold($user); + return Command::SUCCESS; } diff --git a/app/Http/Controllers/ChatBotController.php b/app/Http/Controllers/ChatBotController.php index 4de71a5..01f8293 100644 --- a/app/Http/Controllers/ChatBotController.php +++ b/app/Http/Controllers/ChatBotController.php @@ -18,6 +18,7 @@ use App\Events\MessageSent; use App\Jobs\SaveMessageJob; use App\Models\Sysparam; use App\Services\AiChatService; +use App\Services\AiFinanceService; use App\Services\ChatStateService; use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; @@ -25,6 +26,9 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redis; +/** + * 处理用户与 AI小班长的对话、金币福利与上下文清理。 + */ class ChatBotController extends Controller { /** @@ -34,6 +38,7 @@ class ChatBotController extends Controller private readonly AiChatService $aiChat, private readonly ChatStateService $chatState, private readonly UserCurrencyService $currencyService, + private readonly AiFinanceService $aiFinance, ) {} /** @@ -90,7 +95,8 @@ class ChatBotController extends Controller if ($dailyCount < $maxDailyRewards) { $goldAmount = rand(100, $maxGold); - if ($aiUser && $aiUser->jjb >= $goldAmount) { + // 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。 + if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) { Redis::incr($redisKey); Redis::expire($redisKey, 86400); // 缓存 24 小时 @@ -129,6 +135,9 @@ class ChatBotController extends Controller $this->chatState->pushMessage($roomId, $sysMsg); broadcast(new MessageSent($roomId, $sysMsg)); SaveMessageJob::dispatch($sysMsg); + + // 福利发放完成后,若手上金币仍高于 100 万,则把超出的部分回存银行。 + $this->aiFinance->bankExcessGold($aiUser); } else { // 如果余额不足 $reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)"; diff --git a/app/Jobs/AiBaccaratBetJob.php b/app/Jobs/AiBaccaratBetJob.php index fa8eec3..f874186 100644 --- a/app/Jobs/AiBaccaratBetJob.php +++ b/app/Jobs/AiBaccaratBetJob.php @@ -23,6 +23,8 @@ use App\Models\BaccaratRound; use App\Models\GameConfig; use App\Models\Sysparam; use App\Models\User; +use App\Services\AiFinanceService; +use App\Services\BaccaratLossCoverService; use App\Services\BaccaratPredictionService; use App\Services\ChatStateService; use App\Services\UserCurrencyService; @@ -35,15 +37,24 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; +/** + * 控制 AI小班长在百家乐中的下注、观望与资金调度行为。 + */ class AiBaccaratBetJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * 注入当前需要处理的百家乐局次。 + */ public function __construct( public readonly BaccaratRound $round, ) {} - public function handle(UserCurrencyService $currency): void + /** + * 执行 AI小班长本局百家乐的完整决策流程。 + */ + public function handle(UserCurrencyService $currency, AiFinanceService $aiFinance): void { // 1. 检查总开关与游戏开关 if (Sysparam::getValue('chatbot_enabled', '0') !== '1' || ! GameConfig::isEnabled('baccarat')) { @@ -55,10 +66,8 @@ class AiBaccaratBetJob implements ShouldQueue return; } - $chatState = app(ChatStateService::class); - // 2. 资金管理:自动存款与领取补偿 - $this->manageFinances($user, $chatState); + $this->manageFinances($user, $aiFinance); $round = $this->round->fresh(); if (! $round || ! $round->isBettingOpen()) { @@ -75,14 +84,20 @@ class AiBaccaratBetJob implements ShouldQueue $minBet = (int) ($config['min_bet'] ?? 100); $maxBet = (int) ($config['max_bet'] ?? 50000); - // 至少保留 100万 金币底仓(确保有充足资金参与多轮下注) - $reserve = 1000000; - $availableGold = ($user->jjb ?? 0) - $reserve; - if ($availableGold < $minBet) { + // 5. 查询当前是否命中“你玩游戏我买单”活动窗口。 + $lossCoverService = app(BaccaratLossCoverService::class); + $lossCoverEvent = $lossCoverService->findEventForBetTime(now()); + $hasActiveLossCover = $lossCoverEvent?->status === 'active'; + + // 买单活动进行中时允许 AI 全仓下注;平时只动用当前手上的 jjb,不再强制保留 100 万。 + $bettableGold = $hasActiveLossCover + ? $aiFinance->getTotalGoldAssets($user) + : $aiFinance->getSpendableGold($user); + if ($bettableGold < $minBet) { return; // 资金不足以支撑最小下注 } - // 5. 获取近期路单和 AI 历史下注 + // 6. 获取近期路单和 AI 历史下注 $recentResults = BaccaratRound::query() ->where('status', 'settled') ->orderByDesc('id') @@ -108,12 +123,13 @@ class AiBaccaratBetJob implements ShouldQueue }) ->toArray(); - // 5. 调用 AI 接口出具统筹策略 + // 7. 调用 AI 接口出具统筹策略 $predictionService = app(BaccaratPredictionService::class); $context = [ 'recent_results' => $recentResults, - 'available_gold' => $availableGold, + 'available_gold' => $bettableGold, 'historical_bets' => $historicalBets, + 'loss_cover_active' => $hasActiveLossCover, ]; $aiPrediction = $predictionService->predict($context); @@ -128,14 +144,19 @@ class AiBaccaratBetJob implements ShouldQueue $percent = $aiPrediction['percentage']; $reason = $aiPrediction['reason']; - // 限定单局最高下注不超过可用金额的 5% 以防止 AI "乱梭哈" 破产 - $percent = min(5, max(0, $percent)); - if ($betType !== 'pass') { - $amount = (int) round($availableGold * ($percent / 100.0)); - $amount = max($minBet, min($amount, $maxBet)); - if ($amount > $user->jjb) { - $amount = $user->jjb; + if ($hasActiveLossCover) { + // 买单活动进行中时,AI 可放心动用全部总资产,因为本局若输可返还。 + $amount = $bettableGold; + $reason = trim($reason.' 买单活动进行中,本局采用总资产全额下注策略。'); + } else { + // 非买单活动期间,限定单局最高下注不超过手头金币的 5% 以防止 AI 破产。 + $percent = min(5, max(0, $percent)); + $amount = (int) round($bettableGold * ($percent / 100.0)); + $amount = max($minBet, min($amount, $maxBet)); + if ($amount > $bettableGold) { + $amount = $bettableGold; + } } } } else { @@ -156,9 +177,15 @@ class AiBaccaratBetJob implements ShouldQueue } if ($betType !== 'pass') { - $percent = rand(2, 5) / 100.0; - $amount = (int) round($availableGold * $percent); - $amount = max($minBet, min($amount, $maxBet)); + if ($hasActiveLossCover) { + // 本地兜底策略命中买单活动时,同样执行总资产全额下注。 + $amount = $bettableGold; + $reason = '买单活动进行中,采用本地总资产全额下注兜底策略。'; + } else { + $percent = rand(2, 5) / 100.0; + $amount = (int) round($bettableGold * $percent); + $amount = max($minBet, min($amount, $maxBet)); + } } } @@ -180,6 +207,14 @@ class AiBaccaratBetJob implements ShouldQueue return; } + // 买单活动期间允许全仓下注;非活动期间只检查当前手上金币是否够本次下注。 + $isReadyToSpend = $hasActiveLossCover + ? $aiFinance->prepareAllInSpend($user, $amount) + : $aiFinance->prepareSpend($user, $amount); + if (! $isReadyToSpend) { + return; + } + // 二次校验,防止大模型接口调用太慢导致下注时该局已关闭 $round->refresh(); if (! $round->isBettingOpen()) { @@ -188,12 +223,17 @@ class AiBaccaratBetJob implements ShouldQueue return; } - // 6. 执行下注 (同 BaccaratController::bet 逻辑) - DB::transaction(function () use ($user, $round, $betType, $amount, $currency) { + // 8. 执行下注 (同 BaccaratController::bet 逻辑) + DB::transaction(function () use ($user, $round, $betType, $amount, $currency, $lossCoverEvent, $lossCoverService) { + $lockedUser = User::query()->lockForUpdate()->find($user->id); + if (! $lockedUser || (int) ($lockedUser->jjb ?? 0) < $amount) { + return; + } + // 幂等:同一局只能下一注 $existing = BaccaratBet::query() ->where('round_id', $round->id) - ->where('user_id', $user->id) + ->where('user_id', $lockedUser->id) ->lockForUpdate() ->exists(); @@ -203,7 +243,7 @@ class AiBaccaratBetJob implements ShouldQueue // 扣除金币 $currency->change( - $user, + $lockedUser, 'gold', -$amount, CurrencySource::BACCARAT_BET, @@ -213,14 +253,20 @@ class AiBaccaratBetJob implements ShouldQueue ); // 写入下注记录 - BaccaratBet::create([ + $bet = BaccaratBet::create([ 'round_id' => $round->id, - 'user_id' => $user->id, + 'user_id' => $lockedUser->id, + 'loss_cover_event_id' => $lossCoverEvent?->id, 'bet_type' => $betType, 'amount' => $amount, 'status' => 'pending', ]); + // 命中买单活动的下注需要登记到活动聚合记录里,确保后续能正确补偿返还。 + if ($lossCoverEvent) { + $lossCoverService->registerBet($bet); + } + // 更新局次汇总统计 $field = 'total_bet_'.$betType; $countField = 'bet_count_'.$betType; @@ -232,6 +278,9 @@ class AiBaccaratBetJob implements ShouldQueue event(new \App\Events\BaccaratPoolUpdated($round)); }); + // 下注完成后,若手上金币仍高于 100 万,则把超出的部分继续回存银行。 + $aiFinance->bankExcessGold($user); + // 下注成功后,在聊天室发送一条普通聊天消息告知大家 $this->broadcastBetMessage($user, $round->room_id ?? 1, $betType, $amount, $decisionSource); } @@ -311,9 +360,9 @@ class AiBaccaratBetJob implements ShouldQueue } /** - * AI 资金管理逻辑:自动存款、领取买单补偿 + * AI 资金管理逻辑:优先领取补偿,再按“超过 100 万才存款”的规则整理持仓。 */ - private function manageFinances(User $user, ChatStateService $chatState): void + private function manageFinances(User $user, AiFinanceService $aiFinance): void { // 1. 检查是否有“买单”活动补偿可领取 (jjb 较低时优先领取) $lossCoverService = app(\App\Services\BaccaratLossCoverService::class); @@ -330,60 +379,6 @@ class AiBaccaratBetJob implements ShouldQueue } } - // 2. 自动存款逻辑:长期目标 1000万 / 3000万 - $bankTarget1 = 10000000; - $bankTarget2 = 30000000; - $currentBank = (int) $user->bank_jjb; - - // 如果手上金币超过 10万,且存款未达 3000万,则进行存款 - if ($user->jjb > 1000000 && $currentBank < $bankTarget2) { - $depositAmount = $user->jjb - 500000; // 留 5万在手上作为本金 - - // 如果存款快到目标了,按需存入 - if ($currentBank < $bankTarget1 && ($currentBank + $depositAmount) > $bankTarget1) { - $depositAmount = $bankTarget1 - $currentBank; - } - - DB::transaction(function () use ($user, $depositAmount, $currentBank, $bankTarget1, $bankTarget2, $chatState) { - $user->decrement('jjb', $depositAmount); - $user->increment('bank_jjb', $depositAmount); - $newBank = $user->bank_jjb; - - \App\Models\BankLog::create([ - 'user_id' => $user->id, - 'type' => 'deposit', - 'amount' => $depositAmount, - 'balance_after' => $newBank, - ]); - - // 达成阶段性目标时大声宣布 - $milestone = 0; - if ($currentBank < $bankTarget1 && $newBank >= $bankTarget1) { - $milestone = 1000; // 1000万 - } elseif ($currentBank < $bankTarget2 && $newBank >= $bankTarget2) { - $milestone = 3000; // 3000万 - } - - if ($milestone > 0) { - $content = "🏆 🎉 【全站公告】 恭喜 AI小班长 达成理财新高度!
他在银行的存款已成功突破 {$milestone}万 金币!💰✨"; - $msg = [ - 'id' => $chatState->nextMessageId(1), - 'room_id' => 1, - 'from_user' => '系统传音', - 'to_user' => '大家', - 'content' => $content, - 'is_secret' => false, - 'font_color' => '#f59e0b', - 'action' => '大声宣告', - 'sent_at' => now()->toDateTimeString(), - ]; - $chatState->pushMessage(1, $msg); - broadcast(new MessageSent(1, $msg)); - SaveMessageJob::dispatch($msg); - } - }); - - Log::info("AI小班长自动存款: Amount: {$depositAmount}, New Bank Balance: {$user->bank_jjb}"); - } + $aiFinance->rebalanceHoldings($user); } } diff --git a/app/Services/AiFinanceService.php b/app/Services/AiFinanceService.php new file mode 100644 index 0000000..99f46ba --- /dev/null +++ b/app/Services/AiFinanceService.php @@ -0,0 +1,215 @@ + + */ + private const BANK_MILESTONES = [ + 10000000, + 30000000, + ]; + + /** + * 注入聊天室状态服务,用于里程碑公告广播。 + */ + public function __construct( + private readonly ChatStateService $chatState, + ) {} + + /** + * 计算 AI 当前手上可直接使用的金币。 + */ + public function getSpendableGold(User $user): int + { + return (int) ($user->jjb ?? 0); + } + + /** + * 计算 AI 当前持有的金币总资产(手上 + 银行)。 + */ + public function getTotalGoldAssets(User $user): int + { + return (int) ($user->jjb ?? 0) + (int) ($user->bank_jjb ?? 0); + } + + /** + * 判断 AI 当前手上金币是否足够支付本次支出。 + */ + public function prepareSpend(User $user, int $spendAmount): bool + { + if ($spendAmount <= 0) { + return true; + } + + $user->refresh(); + + return (int) ($user->jjb ?? 0) >= $spendAmount; + } + + /** + * 为全仓支出准备手头金币。 + * + * 该模式不会保留手上余额阈值,适用于买单活动等可接受全额下注的场景。 + */ + public function prepareAllInSpend(User $user, int $spendAmount): bool + { + if ($spendAmount <= 0) { + return true; + } + + return $this->raiseWalletTo($user, $spendAmount); + } + + /** + * 将 100 万以上的富余金币自动转存到银行。 + */ + public function bankExcessGold(User $user): void + { + $milestones = DB::transaction(function () use ($user): array { + $lockedUser = User::query()->lockForUpdate()->find($user->id); + if (! $lockedUser) { + return []; + } + + $walletGold = (int) ($lockedUser->jjb ?? 0); + if ($walletGold <= self::AVAILABLE_GOLD_RESERVE) { + return []; + } + + $bankBefore = (int) ($lockedUser->bank_jjb ?? 0); + $depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE; + + // 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。 + $lockedUser->jjb = self::AVAILABLE_GOLD_RESERVE; + $lockedUser->bank_jjb = $bankBefore + $depositAmount; + $lockedUser->save(); + + BankLog::create([ + 'user_id' => $lockedUser->id, + 'type' => 'deposit', + 'amount' => $depositAmount, + 'balance_after' => (int) $lockedUser->bank_jjb, + ]); + + return array_values(array_filter( + self::BANK_MILESTONES, + fn (int $milestone): bool => $bankBefore < $milestone && (int) $lockedUser->bank_jjb >= $milestone, + )); + }); + + $user->refresh(); + + foreach ($milestones as $milestone) { + $this->broadcastMilestoneAnnouncement($milestone); + } + } + + /** + * 一次性完成 AI 常规理财:仅把超过 100 万的部分转入银行。 + */ + public function rebalanceHoldings(User $user): void + { + $this->bankExcessGold($user); + } + + /** + * 将手上金币抬升到目标值,必要时从银行自动取款。 + */ + private function raiseWalletTo(User $user, int $targetWalletGold): bool + { + $reachedTarget = DB::transaction(function () use ($user, $targetWalletGold): bool { + $lockedUser = User::query()->lockForUpdate()->find($user->id); + if (! $lockedUser) { + return false; + } + + $walletGold = (int) ($lockedUser->jjb ?? 0); + if ($walletGold >= $targetWalletGold) { + return true; + } + + $bankGold = (int) ($lockedUser->bank_jjb ?? 0); + $withdrawAmount = min($targetWalletGold - $walletGold, $bankGold); + if ($withdrawAmount <= 0) { + return false; + } + + // 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。 + $lockedUser->jjb = $walletGold + $withdrawAmount; + $lockedUser->bank_jjb = $bankGold - $withdrawAmount; + $lockedUser->save(); + + BankLog::create([ + 'user_id' => $lockedUser->id, + 'type' => 'withdraw', + 'amount' => $withdrawAmount, + 'balance_after' => (int) $lockedUser->bank_jjb, + ]); + + return (int) $lockedUser->jjb >= $targetWalletGold; + }); + + $user->refresh(); + + return $reachedTarget; + } + + /** + * 广播 AI小班长达成银行存款目标的全站公告。 + */ + private function broadcastMilestoneAnnouncement(int $milestone): void + { + $roomIds = $this->chatState->getAllActiveRoomIds(); + if (empty($roomIds)) { + $roomIds = [1]; + } + + $milestoneInWan = (int) ($milestone / 10000); + $content = "🏆 🎉 【全站公告】 恭喜 AI小班长 达成理财新高度!
他在银行的存款已成功突破 {$milestoneInWan}万 金币!💰✨"; + + foreach ($roomIds as $roomId) { + $message = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => $content, + 'is_secret' => false, + 'font_color' => '#f59e0b', + 'action' => '大声宣告', + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $message); + broadcast(new MessageSent($roomId, $message)); + SaveMessageJob::dispatch($message); + } + } +} diff --git a/tests/Feature/AiBaccaratBetJobTest.php b/tests/Feature/AiBaccaratBetJobTest.php new file mode 100644 index 0000000..906e238 --- /dev/null +++ b/tests/Feature/AiBaccaratBetJobTest.php @@ -0,0 +1,139 @@ + 'baccarat'], + [ + 'name' => 'Baccarat', + 'icon' => 'baccarat', + 'description' => 'Baccarat Game', + 'enabled' => true, + 'params' => [ + 'min_bet' => 100, + 'max_bet' => 50000, + ], + ] + ); + + Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '1']); + Sysparam::clearCache('chatbot_enabled'); + } + + /** + * 买单活动进行中时,AI 会动用全部总资产下注,并把下注挂到活动名下。 + */ + public function test_ai_bets_all_spendable_gold_when_loss_cover_event_is_active(): void + { + Event::fake(); + Queue::fake([SaveMessageJob::class]); + + $this->mock(ChatStateService::class, function (MockInterface $mock): void { + $mock->shouldReceive('nextMessageId')->andReturn(1); + $mock->shouldReceive('pushMessage')->zeroOrMoreTimes(); + $mock->shouldReceive('getAllActiveRoomIds')->andReturn([1]); + }); + + $this->mock(BaccaratPredictionService::class, function (MockInterface $mock): void { + $mock->shouldReceive('predict') + ->once() + ->andReturn([ + 'action' => 'big', + 'percentage' => 1, + 'reason' => '测试用低仓位建议', + ]); + }); + + $aiUser = User::factory()->create([ + 'username' => 'AI小班长', + 'jjb' => 1000000, + 'bank_jjb' => 200000, + ]); + + $event = BaccaratLossCoverEvent::factory()->create([ + 'status' => 'active', + 'starts_at' => now()->subMinutes(2), + 'ends_at' => now()->addMinutes(10), + ]); + + $round = BaccaratRound::forceCreate([ + 'status' => 'betting', + 'bet_opens_at' => now()->subSeconds(10), + 'bet_closes_at' => now()->addMinute(), + 'total_bet_big' => 0, + 'total_bet_small' => 0, + 'total_bet_triple' => 0, + 'bet_count' => 0, + 'bet_count_big' => 0, + 'bet_count_small' => 0, + 'bet_count_triple' => 0, + 'total_payout' => 0, + ]); + + $job = new AiBaccaratBetJob($round); + $job->handle(app(\App\Services\UserCurrencyService::class), app(\App\Services\AiFinanceService::class)); + + $this->assertDatabaseHas('baccarat_bets', [ + 'round_id' => $round->id, + 'user_id' => $aiUser->id, + 'loss_cover_event_id' => $event->id, + 'bet_type' => 'big', + 'amount' => 1200000, + 'status' => 'pending', + ]); + + $this->assertDatabaseHas('baccarat_loss_cover_records', [ + 'event_id' => $event->id, + 'user_id' => $aiUser->id, + 'total_bet_amount' => 1200000, + 'claim_status' => 'not_eligible', + ]); + + $this->assertDatabaseHas('baccarat_loss_cover_events', [ + 'id' => $event->id, + 'participant_count' => 1, + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $aiUser->id, + 'jjb' => 0, + 'bank_jjb' => 0, + ]); + } +} diff --git a/tests/Feature/AiFinanceServiceTest.php b/tests/Feature/AiFinanceServiceTest.php new file mode 100644 index 0000000..f8a9ad1 --- /dev/null +++ b/tests/Feature/AiFinanceServiceTest.php @@ -0,0 +1,111 @@ +create([ + 'jjb' => 700000, + 'bank_jjb' => 600000, + ]); + + $result = app(AiFinanceService::class)->prepareSpend($user, 800000); + + $this->assertFalse($result); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'jjb' => 700000, + 'bank_jjb' => 600000, + ]); + $this->assertDatabaseMissing('bank_logs', [ + 'user_id' => $user->id, + 'type' => 'withdraw', + ]); + } + + /** + * 全仓支出场景允许从银行提取差额,把总资产集中到手上。 + */ + public function test_prepare_all_in_spend_withdraws_needed_gold_from_bank(): void + { + $user = User::factory()->create([ + 'jjb' => 1000000, + 'bank_jjb' => 200000, + ]); + + $result = app(AiFinanceService::class)->prepareAllInSpend($user, 1150000); + + $this->assertTrue($result); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'jjb' => 1150000, + 'bank_jjb' => 50000, + ]); + + $this->assertDatabaseHas('bank_logs', [ + 'user_id' => $user->id, + 'type' => 'withdraw', + 'amount' => 150000, + 'balance_after' => 50000, + ]); + } + + /** + * 超过 100 万的金币会自动存入银行,并在跨过阶段目标时发送全站公告。 + */ + public function test_bank_excess_gold_deposits_surplus_and_broadcasts_when_crossing_milestone(): void + { + Event::fake([MessageSent::class]); + Queue::fake([SaveMessageJob::class]); + + $user = User::factory()->create([ + 'username' => 'AI小班长', + 'jjb' => 1250000, + 'bank_jjb' => 9900000, + ]); + + app(AiFinanceService::class)->bankExcessGold($user); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'jjb' => 1000000, + 'bank_jjb' => 10150000, + ]); + + $this->assertDatabaseHas('bank_logs', [ + 'user_id' => $user->id, + 'type' => 'deposit', + 'amount' => 250000, + 'balance_after' => 10150000, + ]); + + Event::assertDispatched(MessageSent::class); + Queue::assertPushed(SaveMessageJob::class); + } +} diff --git a/tests/Feature/ChatBotControllerTest.php b/tests/Feature/ChatBotControllerTest.php index 4697a83..face434 100644 --- a/tests/Feature/ChatBotControllerTest.php +++ b/tests/Feature/ChatBotControllerTest.php @@ -1,5 +1,12 @@ create(); @@ -33,7 +49,10 @@ class ChatBotControllerTest extends TestCase $response->assertJson(['status' => 'error']); } - public function test_chatbot_can_reply() + /** + * AI 功能开启后,控制器会返回模型生成的正常回复。 + */ + public function test_chatbot_can_reply(): void { $user = User::factory()->create(); @@ -69,7 +88,10 @@ class ChatBotControllerTest extends TestCase ]); } - public function test_chatbot_can_give_gold() + /** + * 发放金币福利时,只使用 AI 当前手上金币,不会为了凑金额自动动用银行存款。 + */ + public function test_chatbot_can_give_gold(): void { $user = User::factory()->create(['jjb' => 0]); @@ -80,7 +102,8 @@ class ChatBotControllerTest extends TestCase User::factory()->create([ 'username' => 'AI小班长', 'exp_num' => 0, - 'jjb' => 1000, // Ensure AI bot has enough gold + 'jjb' => 1000000, + 'bank_jjb' => 1000, ]); // Mock the AiChatService @@ -105,9 +128,16 @@ class ChatBotControllerTest extends TestCase $user->refresh(); $this->assertGreaterThan(0, $user->jjb); $this->assertLessThanOrEqual(500, $user->jjb); + + $aiUser = User::query()->where('username', 'AI小班长')->firstOrFail(); + $this->assertLessThan(1000000, (int) $aiUser->jjb); + $this->assertSame(1000, (int) $aiUser->bank_jjb); } - public function test_clear_context() + /** + * 用户主动清空上下文时,应调用 AI 服务清理其上下文缓存。 + */ + public function test_clear_context(): void { $user = User::factory()->create();