diff --git a/app/Console/Commands/AiHeartbeatCommand.php b/app/Console/Commands/AiHeartbeatCommand.php index 848cd02..f69b7fd 100644 --- a/app/Console/Commands/AiHeartbeatCommand.php +++ b/app/Console/Commands/AiHeartbeatCommand.php @@ -16,9 +16,11 @@ namespace App\Console\Commands; use App\Enums\CurrencySource; use App\Events\MessageSent; +use App\Jobs\AiFishingJob; use App\Jobs\SaveMessageJob; use App\Models\Autoact; use App\Models\DailySignIn; +use App\Models\GameConfig; use App\Models\Sysparam; use App\Models\User; use App\Services\AiFinanceService; @@ -61,11 +63,15 @@ class AiHeartbeatCommand extends Command */ public function handle(): int { + $startedAt = microtime(true); + // 1. 检查总开关 if (Sysparam::getValue('chatbot_enabled', '0') !== '1') { return Command::SUCCESS; } + $config = $this->heartbeatConfig(); + // 2. 获取 AI 实体 $user = User::where('username', 'AI小班长')->first(); if (! $user) { @@ -73,21 +79,26 @@ class AiHeartbeatCommand extends Command } // 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。 - $this->aiFinance->bankExcessGold($user); + if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) { + $this->aiFinance->bankExcessGold($user); + } // 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖) - $this->performDailySignIn($user); + if ($this->performDailySignIn($user)) { + // 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。 + $user->refresh(); + } // 3. 常规心跳经验与金币发放 // (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励) - $expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1')); + $expGain = $this->parseRewardValue($config['exp_per_heartbeat']); if ($expGain > 0) { $expMultiplier = $this->vipService->getExpMultiplier($user); $actualExpGain = (int) round($expGain * $expMultiplier); $user->exp_num += $actualExpGain; } - $jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0')); + $jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']); if ($jjbGain > 0) { $jjbMultiplier = $this->vipService->getJjbMultiplier($user); $actualJjbGain = (int) round($jjbGain * $jjbMultiplier); @@ -95,30 +106,35 @@ class AiHeartbeatCommand extends Command } $user->save(); - $user->refresh(); // 4. 重算等级(基础心跳升级) - $superLevel = (int) Sysparam::getValue('superlevel', '100'); + $superLevel = (int) $config['superlevel']; $leveledUp = $this->calculateNewLevel($user, $superLevel); // 5. 随机事件触发 - $eventChance = (int) Sysparam::getValue('auto_event_chance', '10'); + $eventChance = (int) $config['auto_event_chance']; if ($eventChance > 0 && rand(1, 100) <= $eventChance) { $autoEvent = Autoact::randomEvent(); if ($autoEvent) { + $hasCurrencyChange = false; + // 执行随机事件的金钱经验惩奖 if ($autoEvent->exp_change !== 0) { $this->currencyService->change( $user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 ); + $hasCurrencyChange = true; } if ($autoEvent->jjb_change !== 0) { $this->currencyService->change( $user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 ); + $hasCurrencyChange = true; } - $user->refresh(); + if ($hasCurrencyChange) { + $user->refresh(); + } // 重新计算等级 if ($this->calculateNewLevel($user, $superLevel)) { @@ -149,39 +165,68 @@ class AiHeartbeatCommand extends Command } // 7. 钓鱼小游戏随机参与逻辑 - $fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1'; - $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')); - // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。 - if ($this->aiFinance->prepareSpend($user, $cost)) { - // 先扣除费用 - $this->currencyService->change( - $user, 'gold', -$cost, - CurrencySource::FISHING_COST, - "AI小班长钓鱼抛竿消耗 {$cost} 金币", - 1, - ); + $fishingEnabled = $config['chatbot_fishing_enabled'] === '1'; + $fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。 + if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) { + $fishingConfig = GameConfig::forGame('fishing'); - // 模拟玩家等待时间 - $waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); - $waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); - $waitTime = rand($waitMin, $waitMax); + if ($fishingConfig?->enabled) { + $cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']); + // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。 + if ($this->aiFinance->prepareSpend($user, $cost)) { + // 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。 + $this->currencyService->change( + $user, 'gold', -$cost, + CurrencySource::FISHING_COST, + "AI小班长钓鱼抛竿消耗 {$cost} 金币", + 1, + ); - // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids) - $activeRoomIds = $this->chatState->getAllActiveRoomIds(); - $roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; + // 模拟玩家等待时间 + $waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']); + $waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']); + $waitTime = rand($waitMin, $waitMax); - \App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); + // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids) + $activeRoomIds = $this->chatState->getAllActiveRoomIds(); + $roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; + + AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); + } } } // 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。 - $this->aiFinance->bankExcessGold($user); + if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) { + $this->aiFinance->bankExcessGold($user); + } + + $elapsedMs = (int) round((microtime(true) - $startedAt) * 1000); + $this->info("AI心跳完成,耗时 {$elapsedMs}ms。"); return Command::SUCCESS; } + /** + * 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。 + * + * @return array + */ + private function heartbeatConfig(): array + { + return [ + 'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'), + 'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'), + 'superlevel' => Sysparam::getValue('superlevel', '100'), + 'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'), + 'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'), + 'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'), + 'fishing_cost' => Sysparam::getValue('fishing_cost', '5'), + 'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'), + 'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'), + ]; + } + /** * 计算并更新用户等级 */ @@ -222,7 +267,7 @@ class AiHeartbeatCommand extends Command /** * 尝试为 AI小班长 执行今日签到,成功时广播签到通知。 */ - private function performDailySignIn(User $user): void + private function performDailySignIn(User $user): bool { // 先检查今日是否已签,避免每分钟都调用事务 $alreadySigned = DailySignIn::query() @@ -231,7 +276,7 @@ class AiHeartbeatCommand extends Command ->exists(); if ($alreadySigned) { - return; + return false; } // 获取活跃房间作为签到归属(默认房间 1) @@ -242,7 +287,7 @@ class AiHeartbeatCommand extends Command // 仅当本次心跳实际完成签到时才广播(幂等保护) if (! $dailySignIn->wasRecentlyCreated) { - return; + return false; } $rewardParts = []; @@ -265,6 +310,8 @@ class AiHeartbeatCommand extends Command .$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'; $this->broadcastSystemMessage('系统传音', $content, '#0f766e'); + + return true; } /** diff --git a/app/Console/Commands/AutoSaveExp.php b/app/Console/Commands/AutoSaveExp.php index 0f1b8ba..ed92e66 100644 --- a/app/Console/Commands/AutoSaveExp.php +++ b/app/Console/Commands/AutoSaveExp.php @@ -20,6 +20,7 @@ use App\Enums\CurrencySource; use App\Events\MessageSent; use App\Jobs\SaveMessageJob; use App\Models\PositionDutyLog; +use App\Models\Room; use App\Models\Sysparam; use App\Models\User; use App\Services\ChatStateService; @@ -27,6 +28,7 @@ use App\Services\ChatUserPresenceService; use App\Services\UserCurrencyService; use App\Services\VipService; use Illuminate\Console\Command; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; @@ -65,6 +67,8 @@ class AutoSaveExp extends Command */ public function handle(): int { + $startedAt = microtime(true); + // 读取奖励配置 $expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1'); $jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0'); @@ -81,15 +85,22 @@ class AutoSaveExp extends Command // 统计本次处理总人次(一个用户在多个房间会被计算多次) $totalProcessed = 0; + $usersByUsername = $this->preloadOnlineUsers($roomMap); foreach ($roomMap as $roomId => $usernames) { foreach ($usernames as $username) { - $this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel); + $user = $usersByUsername->get($username); + if (! $user) { + continue; + } + + $this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel); $totalProcessed++; } } - $this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。"); + $elapsedMs = (int) round((microtime(true) - $startedAt) * 1000); + $this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。"); return Command::SUCCESS; } @@ -108,7 +119,7 @@ class AutoSaveExp extends Command $roomMap = []; // 从数据库取出所有房间 ID - $roomIds = \App\Models\Room::pluck('id'); + $roomIds = Room::pluck('id'); foreach ($roomIds as $roomId) { // Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致 @@ -121,27 +132,46 @@ class AutoSaveExp extends Command return $roomMap; } + /** + * 预加载所有在线用户名对应的用户资料与身份关系,避免循环内逐个查询用户和身份信息。 + * + * @param array> $roomMap 在线房间与用户名映射 + * @return Collection 以用户名为键的用户集合 + */ + private function preloadOnlineUsers(array $roomMap): Collection + { + $usernames = collect($roomMap) + ->flatten() + ->unique() + ->values(); + + if ($usernames->isEmpty()) { + return collect(); + } + + return User::query() + ->with(['activePosition.position.department', 'vipLevel']) + ->whereIn('username', $usernames) + ->get() + ->keyBy('username'); + } + /** * 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。 * - * @param string $username 用户名 + * @param User $user 已预加载身份关系的在线用户 * @param int $roomId 所在房间ID * @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围) * @param string $jjbGainRaw 金币奖励原始配置 * @param int $superLevel 管理员等级阈值 */ private function processUser( - string $username, + User $user, int $roomId, string $expGainRaw, string $jjbGainRaw, int $superLevel ): void { - $user = User::where('username', $username)->first(); - if (! $user) { - return; - } - // 1. 计算奖励量(经验/金币 均支持 VIP 倍率) $expGain = $this->parseRewardValue($expGainRaw); $expMultiplier = $this->vipService->getExpMultiplier($user); @@ -165,8 +195,11 @@ class AutoSaveExp extends Command $user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId, ); } - $user->refresh(); // 刷新获取最新属性(service 已原子更新) - $user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。 + if ($actualExpGain > 0 || $actualJjbGain > 0) { + // 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系。 + $user->refresh(); + $user->load(['activePosition.position.department', 'vipLevel']); + } // 3. 自动升降级逻辑 // - 有在职职务的用户:等级固定为职务对应等级,不随经验变化 @@ -241,7 +274,7 @@ class AutoSaveExp extends Command 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统', - 'to_user' => $username, // 定向推送给本人 + 'to_user' => $user->username, // 定向推送给本人 'content' => $content, 'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到 'font_color' => '#16a34a', // 草绿色 diff --git a/routes/console.php b/routes/console.php index 7cb4037..a13703e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -12,14 +12,14 @@ Artisan::command('inspire', function () { Schedule::command('messages:purge')->dailyAt('03:00'); // 每 5 分钟为所有在线用户自动存点(经验/金币/等级) -Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes(); +Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes()->withoutOverlapping()->runInBackground(); // 每 10 分钟扫描最近活跃用户的成就进度,夜间再做一次全量补算 Schedule::command('achievements:scan --notify')->everyTenMinutes()->withoutOverlapping(); Schedule::command('achievements:scan --all')->dailyAt('03:30')->withoutOverlapping(); // 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件 -Schedule::command('chatroom:ai-heartbeat')->everyMinute(); +Schedule::command('chatroom:ai-heartbeat')->everyMinute()->withoutOverlapping()->runInBackground(); // 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at) Schedule::command('duty:close-stale-logs')->everyFifteenMinutes(); diff --git a/tests/Feature/AiHeartbeatCommandTest.php b/tests/Feature/AiHeartbeatCommandTest.php new file mode 100644 index 0000000..e14d93a --- /dev/null +++ b/tests/Feature/AiHeartbeatCommandTest.php @@ -0,0 +1,196 @@ +create([ + 'username' => 'AI小班长', + 'exp_num' => 10, + 'jjb' => 20, + ]); + + Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '0']); + + $this->artisan('chatroom:ai-heartbeat')->assertSuccessful(); + + $aiUser->refresh(); + $this->assertSame(10, (int) $aiUser->exp_num); + $this->assertSame(20, (int) $aiUser->jjb); + } + + /** + * AI 开启时,基础心跳仍会发放配置中的经验和金币。 + */ + public function test_ai_heartbeat_grants_base_exp_and_gold(): void + { + Event::fake([MessageSent::class]); + + $aiUser = User::factory()->create([ + 'username' => 'AI小班长', + 'user_level' => 1, + 'exp_num' => 10, + 'jjb' => 20, + ]); + $this->markAlreadySigned($aiUser); + $this->enableHeartbeat([ + 'exp_per_heartbeat' => '5', + 'jjb_per_heartbeat' => '7', + 'auto_event_chance' => '0', + ]); + + $this->artisan('chatroom:ai-heartbeat')->assertSuccessful(); + + $aiUser->refresh(); + $this->assertSame(15, (int) $aiUser->exp_num); + $this->assertSame(27, (int) $aiUser->jjb); + } + + /** + * 今日已签到时,命令不应再进入签到事务,避免每分钟重复做重活。 + */ + public function test_ai_heartbeat_skips_sign_in_claim_when_already_signed_today(): void + { + $aiUser = User::factory()->create([ + 'username' => 'AI小班长', + 'exp_num' => 0, + 'jjb' => 0, + ]); + $this->markAlreadySigned($aiUser); + $this->enableHeartbeat([ + 'exp_per_heartbeat' => '0', + 'jjb_per_heartbeat' => '0', + 'auto_event_chance' => '0', + ]); + + $this->mock(SignInService::class, function (MockInterface $mock): void { + $mock->shouldReceive('claim')->never(); + }); + + $this->artisan('chatroom:ai-heartbeat')->assertSuccessful(); + } + + /** + * 钓鱼开启且命中概率时,命令只派发延迟收竿任务,不在当前心跳里等待结果。 + */ + public function test_ai_heartbeat_dispatches_delayed_fishing_job(): void + { + Bus::fake(); + Event::fake([MessageSent::class]); + + $aiUser = User::factory()->create([ + 'username' => 'AI小班长', + 'exp_num' => 0, + 'jjb' => 100, + ]); + $this->markAlreadySigned($aiUser); + $this->enableHeartbeat([ + 'exp_per_heartbeat' => '0', + 'jjb_per_heartbeat' => '0', + 'auto_event_chance' => '0', + 'chatbot_fishing_enabled' => '1', + 'chatbot_fishing_chance' => '100', + ]); + GameConfig::query()->create([ + 'game_key' => 'fishing', + 'name' => '钓鱼', + 'icon' => '🎣', + 'enabled' => true, + 'params' => [ + 'fishing_cost' => 5, + 'fishing_wait_min' => 8, + 'fishing_wait_max' => 8, + ], + ]); + + $this->artisan('chatroom:ai-heartbeat')->assertSuccessful(); + + Bus::assertDispatched(AiFishingJob::class); + $aiUser->refresh(); + $this->assertSame(95, (int) $aiUser->jjb); + } + + /** + * 批量写入本轮心跳需要的系统参数。 + * + * @param array $overrides 覆盖默认值的配置 + */ + private function enableHeartbeat(array $overrides = []): void + { + $defaults = [ + 'chatbot_enabled' => '1', + 'exp_per_heartbeat' => '0', + 'jjb_per_heartbeat' => '0', + 'superlevel' => '100', + 'auto_event_chance' => '0', + 'chatbot_fishing_enabled' => '0', + 'chatbot_fishing_chance' => '0', + 'fishing_cost' => '5', + 'fishing_wait_min' => '8', + 'fishing_wait_max' => '15', + ]; + + foreach (array_merge($defaults, $overrides) as $alias => $body) { + Sysparam::query()->updateOrCreate(['alias' => $alias], ['body' => $body]); + } + + Cache::flush(); + } + + /** + * 创建今日已签到记录,让测试聚焦心跳命令本身。 + */ + private function markAlreadySigned(User $user): void + { + DailySignIn::query()->create([ + 'user_id' => $user->id, + 'room_id' => 1, + 'sign_in_date' => today()->toDateString(), + 'streak_days' => 1, + 'gold_reward' => 0, + 'exp_reward' => 0, + 'charm_reward' => 0, + ]); + } +}