progressForUser($user); $checked = 0; $unlocked = 0; $updated = 0; foreach (AchievementCatalog::definitions() as $definition) { $checked++; $value = (int) ($progress[$definition['metric']] ?? 0); $achievement = UserAchievement::query() ->where('user_id', $user->id) ->where('achievement_key', $definition['key']) ->first(); if ($dryRun) { if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) { $unlocked++; } continue; } $this->storeProgress($user, $definition, $value); if (! $achievement) { $achievement = UserAchievement::query()->create([ 'user_id' => $user->id, 'achievement_key' => $definition['key'], 'progress_value' => $value, 'metadata' => ['threshold' => $definition['threshold']], ]); $updated++; } elseif ($achievement->progress_value !== $value) { $achievement->forceFill(['progress_value' => $value])->save(); $updated++; } if ($value < $definition['threshold'] || $achievement->achieved_at) { continue; } $achievement->forceFill([ 'progress_value' => $value, 'achieved_at' => now(), 'metadata' => ['threshold' => $definition['threshold']], ])->save(); $unlocked++; if ($notify) { $this->notifyUnlocked($user, $achievement, $definition); } } return [ 'checked' => $checked, 'unlocked' => $unlocked, 'updated' => $updated, 'dry_run' => $dryRun, ]; } /** * 批量扫描用户成就。 * * @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool} */ public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array { $summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun]; foreach ($users as $user) { $result = $this->scanUser($user, $notify, $dryRun); $summary['users']++; $summary['checked'] += $result['checked']; $summary['unlocked'] += $result['unlocked']; $summary['updated'] += $result['updated']; } return $summary; } /** * 组装用户成就展示数据。 * * @return array{categories: array, achievements: Collection>, unlocked_count: int, total_count: int} */ public function displayForUser(User $user): array { $progress = $this->progressForUser($user); $records = UserAchievement::query() ->where('user_id', $user->id) ->get() ->keyBy('achievement_key'); $achievements = collect(AchievementCatalog::definitions()) ->sortBy('sort') ->map(function (array $definition) use ($progress, $records): array { $record = $records->get($definition['key']); $value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0)); $threshold = (int) $definition['threshold']; return [ ...$definition, 'progress_value' => $value, 'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100, 'achieved_at' => $record?->achieved_at, 'unlocked' => (bool) $record?->achieved_at, ]; }) ->values(); return [ 'categories' => AchievementCatalog::categories(), 'achievements' => $achievements, 'unlocked_count' => $achievements->where('unlocked', true)->count(), 'total_count' => $achievements->count(), ]; } /** * 读取用户最近解锁成就。 * * @return Collection> */ public function recentUnlockedForUser(User $user, int $limit = 5): Collection { return UserAchievement::query() ->where('user_id', $user->id) ->whereNotNull('achieved_at') ->latest('achieved_at') ->limit($limit) ->get() ->map(function (UserAchievement $achievement): array { $definition = AchievementCatalog::find($achievement->achievement_key); return [ 'key' => $achievement->achievement_key, 'name' => $definition['name'] ?? $achievement->achievement_key, 'icon' => $definition['icon'] ?? '🏅', 'description' => $definition['description'] ?? '', 'achieved_at' => $achievement->achieved_at?->toDateTimeString(), ]; }); } /** * 读取用户资料卡使用的成就摘要。 * * @return array{unlocked_count: int, total_count: int, recent: array>} */ public function profileSummaryForUser(User $user): array { return [ 'unlocked_count' => (int) UserAchievement::query() ->where('user_id', $user->id) ->whereNotNull('achieved_at') ->count(), 'total_count' => count(AchievementCatalog::definitions()), 'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(), ]; } /** * 聚合单个用户所有成就进度。 * * @return array */ public function progressForUser(User $user): array { $username = (string) $user->username; return [ 'chat_messages' => $this->chatMessageCount($username), 'welcome_messages' => $this->welcomeMessageCount($username), 'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(), 'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'), 'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(), 'exp_gain' => $this->currencyGain($user->id, 'exp'), 'gold_gain' => $this->currencyGain($user->id, 'gold'), 'charm_gain' => $this->currencyGain($user->id, 'charm'), 'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb), 'bank_balance' => max(0, (int) $user->bank_jjb), 'game_gold_won' => $this->gameGoldWon($user->id), 'game_gold_lost' => $this->gameGoldLost($user->id), 'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(), 'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(), 'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(), 'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(), 'gomoku_wins' => $this->gomokuWinCount($user->id), 'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value), 'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value), 'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(), 'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(), 'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(), 'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'), 'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value), 'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value), 'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(), 'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60), 'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(), ]; } /** * 统计普通用户聊天消息数量。 */ private function chatMessageCount(string $username): int { return (int) Message::query() ->where('from_user', $username) ->whereIn('message_type', ['text', 'image', 'expired_image']) ->where(function ($query) { $query->where('retention_type', Message::RETENTION_USER_CHAT) ->orWhereNull('retention_type'); }) ->count(); } /** * 统计用户发出的欢迎动作次数。 */ private function welcomeMessageCount(string $username): int { return (int) Message::query() ->where('from_user', $username) ->where('action', '欢迎') ->count(); } /** * 统计指定货币的累计正向获得量。 */ private function currencyGain(int $userId, string $currency): int { return (int) UserCurrencyLog::query() ->where('user_id', $userId) ->where('currency', $currency) ->where('amount', '>', 0) ->sum('amount'); } /** * 统计指定流水来源次数。 */ private function currencySourceCount(int $userId, string $source): int { return (int) UserCurrencyLog::query() ->where('user_id', $userId) ->where('source', $source) ->count(); } /** * 统计用户通过游戏相关流水累计赢取的金币。 */ private function gameGoldWon(int $userId): int { return (int) UserCurrencyLog::query() ->where('user_id', $userId) ->where('currency', 'gold') ->where('amount', '>', 0) ->whereIn('source', $this->gameWinSources()) ->sum('amount'); } /** * 统计用户在游戏相关流水中累计输掉或消耗的金币。 */ private function gameGoldLost(int $userId): int { return abs((int) UserCurrencyLog::query() ->where('user_id', $userId) ->where('currency', 'gold') ->where('amount', '<', 0) ->whereIn('source', $this->gameLossSources()) ->sum('amount')); } /** * 返回游戏赢钱来源,用于游戏赢取类成就聚合。 * * @return array */ private function gameWinSources(): array { return [ CurrencySource::BACCARAT_WIN->value, CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value, CurrencySource::HORSE_WIN->value, CurrencySource::LOTTERY_WIN->value, CurrencySource::SLOT_WIN->value, CurrencySource::FISHING_GAIN->value, CurrencySource::MYSTERY_BOX->value, CurrencySource::GOMOKU_WIN->value, CurrencySource::GAME_REWARD->value, ]; } /** * 返回游戏输钱来源,用于游戏输钱类成就聚合。 * * @return array */ private function gameLossSources(): array { return [ CurrencySource::BACCARAT_BET->value, CurrencySource::HORSE_BET->value, CurrencySource::LOTTERY_BUY->value, CurrencySource::SLOT_SPIN->value, CurrencySource::SLOT_CURSE->value, CurrencySource::FISHING_COST->value, CurrencySource::FORTUNE_COST->value, CurrencySource::GOMOKU_ENTRY_FEE->value, CurrencySource::MYSTERY_BOX_TRAP->value, ]; } /** * 统计五子棋胜利次数。 */ private function gomokuWinCount(int $userId): int { return (int) GomokuGame::query() ->where('status', 'finished') ->where(function ($query) use ($userId) { $query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1)) ->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2)); }) ->count(); } /** * 写入用户成就进度快照。 * * @param array $definition 成就定义 */ private function storeProgress(User $user, array $definition, int $value): void { UserAchievementProgress::query()->updateOrCreate( [ 'user_id' => $user->id, 'achievement_key' => $definition['key'], ], [ 'progress_value' => $value, 'threshold_value' => (int) $definition['threshold'], 'last_scanned_at' => now(), ], ); } /** * 给用户推送成就解锁通知。 * * @param array $definition 成就定义 */ private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void { if ($achievement->notified_at) { return; } $roomId = (int) ($user->room_id ?: 1); $message = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统公告', 'to_user' => $user->username, 'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} {$definition['description']}", 'is_secret' => true, 'font_color' => '#ca8a04', 'action' => 'achievement_unlocked', 'retention_type' => Message::RETENTION_SYSTEM_NOTICE, 'toast_notification' => [ 'title' => '🏅 成就解锁', 'message' => "{$definition['icon']} {$definition['name']}", 'icon' => '🏅', 'color' => '#ca8a04', 'duration' => 3000, ], 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $message); broadcast(new MessageSent($roomId, $message)); SaveMessageJob::dispatch($message); $achievement->forceFill(['notified_at' => now()])->save(); } }