diff --git a/app/Console/Commands/PurgeOldMessages.php b/app/Console/Commands/PurgeOldMessages.php index dc53cf3..c566030 100644 --- a/app/Console/Commands/PurgeOldMessages.php +++ b/app/Console/Commands/PurgeOldMessages.php @@ -3,8 +3,8 @@ /** * 文件功能:定期清理聊天记录 Artisan 命令 * - * 每天自动清理超过指定天数的聊天记录,保持数据库体积可控。 - * 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。 + * 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息。 + * 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。 * * @author ChatRoom Laravel * @@ -31,7 +31,7 @@ class PurgeOldMessages extends Command * @var string */ protected $signature = 'messages:purge - {--days= : 覆盖默认保留天数} + {--days= : 覆盖通知消息默认保留天数} {--image-days=3 : 聊天图片单独保留天数} {--dry-run : 仅预览不实际删除}'; @@ -40,7 +40,7 @@ class PurgeOldMessages extends Command * * @var string */ - protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件'; + protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件'; /** * 执行命令 @@ -49,9 +49,9 @@ class PurgeOldMessages extends Command */ public function handle(): int { - // 保留天数:命令行参数 > sysparam 配置 > 默认 30 天 + // 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。 $days = (int) ($this->option('days') - ?: Sysparam::getValue('message_retention_days', '30')); + ?: Sysparam::getValue('game_message_retention_days', '30')); $imageDays = max(0, (int) $this->option('image-days')); $cutoff = Carbon::now()->subDays($days); @@ -59,22 +59,22 @@ class PurgeOldMessages extends Command $this->cleanupExpiredImages($imageDays, $isDryRun); - // 统计待清理数量 - $totalCount = Message::where('sent_at', '<', $cutoff)->count(); + $expiredNoticeQuery = $this->expiredNoticeQuery($cutoff); + $totalCount = (clone $expiredNoticeQuery)->count(); if ($totalCount === 0) { - $this->info("✅ 没有超过 {$days} 天的聊天记录需要清理。"); + $this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留。"); return self::SUCCESS; } if ($isDryRun) { - $this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})"); + $this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})"); return self::SUCCESS; } - $this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})..."); + $this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})..."); $this->info(" 待清理数量:{$totalCount} 条"); // 分批删除,每批 1000 条,避免长时间锁表 @@ -82,7 +82,7 @@ class PurgeOldMessages extends Command $batchSize = 1000; do { - $batch = Message::where('sent_at', '<', $cutoff) + $batch = $this->expiredNoticeQuery($cutoff) ->limit($batchSize) ->delete(); @@ -93,11 +93,31 @@ class PurgeOldMessages extends Command } } while ($batch === $batchSize); - $this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。"); + $this->info("✅ 清理完成!共删除 {$deleted} 条游戏/临时通知,用户聊天记录未删除。"); return self::SUCCESS; } + /** + * 构造过期通知清理查询,兼容新增字段前已经落库的旧通知。 + */ + private function expiredNoticeQuery(Carbon $cutoff): \Illuminate\Database\Eloquent\Builder + { + return Message::query() + ->where('sent_at', '<', $cutoff) + ->where(function ($query) { + $query->whereIn('retention_type', Message::purgableRetentionTypes()) + ->orWhere(function ($legacyQuery) { + // 兼容迁移前默认归为 user_chat 的旧通知,避免历史游戏播报继续堆积。 + $legacyQuery->where('retention_type', Message::RETENTION_USER_CHAT) + ->where(function ($noticeQuery) { + $noticeQuery->whereIn('from_user', ['钓鱼播报', '星海小博士', '进出播报', '座驾播报']) + ->orWhereIn('action', ['fishing_result', 'idiom_result', 'riddle_result', 'system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp']); + }); + }); + }); + } + /** * 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。 */ diff --git a/app/Console/Commands/ScanAchievementsCommand.php b/app/Console/Commands/ScanAchievementsCommand.php new file mode 100644 index 0000000..77542cb --- /dev/null +++ b/app/Console/Commands/ScanAchievementsCommand.php @@ -0,0 +1,98 @@ +option('notify'); + $dryRun = (bool) $this->option('dry-run'); + + if ($this->option('user')) { + $user = $this->resolveUser((string) $this->option('user')); + if (! $user) { + $this->error('未找到指定用户。'); + + return self::FAILURE; + } + + $result = $this->achievementService->scanUser($user, $notify, $dryRun); + $this->info("已扫描用户 {$user->username}:检查 {$result['checked']} 项,解锁 {$result['unlocked']} 项,更新 {$result['updated']} 项。"); + + return self::SUCCESS; + } + + $query = User::query()->orderBy('id'); + if (! $this->option('all')) { + // 默认只扫最近活跃用户,避免定时任务每次全表扫描。 + $query->where('updated_at', '>=', now()->subDay())->limit(200); + } + + $summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun]; + $query->chunkById(100, function ($users) use (&$summary, $notify, $dryRun): void { + $chunkSummary = $this->achievementService->scanUsers($users, $notify, $dryRun); + $summary['users'] += $chunkSummary['users']; + $summary['checked'] += $chunkSummary['checked']; + $summary['unlocked'] += $chunkSummary['unlocked']; + $summary['updated'] += $chunkSummary['updated']; + }); + + $this->info("成就扫描完成:用户 {$summary['users']} 人,检查 {$summary['checked']} 项,解锁 {$summary['unlocked']} 项,更新 {$summary['updated']} 项。"); + + return self::SUCCESS; + } + + /** + * 根据 ID 或用户名解析用户。 + */ + private function resolveUser(string $value): ?User + { + return User::query() + ->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('username', $value)) + ->first(); + } +} diff --git a/app/Http/Controllers/AchievementController.php b/app/Http/Controllers/AchievementController.php new file mode 100644 index 0000000..66334dc --- /dev/null +++ b/app/Http/Controllers/AchievementController.php @@ -0,0 +1,73 @@ +achievementService->scanUser($user); + $achievementData = $this->achievementService->displayForUser($user); + $activeTab = in_array($request->query('status'), ['unlocked', 'locked'], true) + ? $request->query('status') + : 'all'; + $allAchievements = $achievementData['achievements']; + + // 页面 tab 只影响展示列表,不影响顶部总进度统计。 + $achievementTabs = [ + 'all' => [ + 'label' => '全部', + 'count' => $allAchievements->count(), + 'url' => route('achievements.index'), + ], + 'unlocked' => [ + 'label' => '已完成', + 'count' => $allAchievements->where('unlocked', true)->count(), + 'url' => route('achievements.index', ['status' => 'unlocked']), + ], + 'locked' => [ + 'label' => '未达成', + 'count' => $allAchievements->where('unlocked', false)->count(), + 'url' => route('achievements.index', ['status' => 'locked']), + ], + ]; + + $achievementData['achievements'] = match ($activeTab) { + 'unlocked' => $allAchievements->where('unlocked', true)->values(), + 'locked' => $allAchievements->where('unlocked', false)->values(), + default => $allAchievements, + }; + + return view('achievements.index', [ + 'user' => $user, + 'active_tab' => $activeTab, + 'achievement_tabs' => $achievementTabs, + ...$achievementData, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AchievementController.php b/app/Http/Controllers/Admin/AchievementController.php new file mode 100644 index 0000000..4d0212d --- /dev/null +++ b/app/Http/Controllers/Admin/AchievementController.php @@ -0,0 +1,60 @@ +with('user:id,username') + ->whereNotNull('achieved_at') + ->latest('achieved_at'); + + if ($request->filled('username')) { + $query->whereHas('user', function ($userQuery) use ($request): void { + $userQuery->where('username', 'like', '%'.$request->string('username')->toString().'%'); + }); + } + + if ($request->filled('achievement_key')) { + $query->where('achievement_key', $request->string('achievement_key')->toString()); + } + + $records = $query->paginate(30)->withQueryString(); + $summary = [ + 'total_definitions' => count($definitions), + 'unlocked_records' => UserAchievement::query()->whereNotNull('achieved_at')->count(), + 'unlocked_users' => UserAchievement::query()->whereNotNull('achieved_at')->distinct('user_id')->count('user_id'), + ]; + $topAchievements = UserAchievement::query() + ->whereNotNull('achieved_at') + ->select('achievement_key', DB::raw('count(*) as unlocked_count')) + ->groupBy('achievement_key') + ->orderByDesc('unlocked_count') + ->limit(10) + ->get(); + + return view('admin.achievements.index', compact('definitions', 'records', 'summary', 'topAchievements')); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index df2e404..c46e3d5 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -21,6 +21,7 @@ use App\Http\Requests\UpdateDailyStatusRequest; use App\Http\Requests\UpdateProfileRequest; use App\Models\Sysparam; use App\Models\User; +use App\Services\AchievementService; use App\Services\ChatStateService; use App\Services\ChatUserPresenceService; use App\Services\PositionPermissionService; @@ -58,6 +59,7 @@ class UserController extends Controller private readonly ChatStateService $chatState, private readonly ChatUserPresenceService $chatUserPresenceService, private readonly UserCurrencyService $currencyService, + private readonly AchievementService $achievementService, private readonly PositionPermissionService $positionPermissionService, ) {} @@ -159,6 +161,14 @@ class UserController extends Controller 'expires_at' => $signIdentity->expires_at?->toIso8601String(), ] : null, ]; + // 名片展示前先静默补算一次,避免进度已达标但解锁记录尚未落库。 + $this->achievementService->scanUser($targetUser); + $achievementDisplay = $this->achievementService->displayForUser($targetUser); + $data['achievements'] = [ + 'unlocked_count' => $achievementDisplay['unlocked_count'], + 'total_count' => $achievementDisplay['total_count'], + 'recent' => $this->achievementService->recentUnlockedForUser($targetUser, 5)->values()->all(), + ]; // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。 $canViewNetworkInfo = $operator diff --git a/app/Jobs/SaveMessageJob.php b/app/Jobs/SaveMessageJob.php index 66bc543..684545b 100644 --- a/app/Jobs/SaveMessageJob.php +++ b/app/Jobs/SaveMessageJob.php @@ -50,6 +50,7 @@ class SaveMessageJob implements ShouldQueue 'image_path' => $this->messageData['image_path'] ?? null, 'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null, 'image_original_name' => $this->messageData['image_original_name'] ?? null, + 'retention_type' => Message::resolveRetentionType($this->messageData), // 恢复 Carbon 时间对象 'sent_at' => Carbon::parse($this->messageData['sent_at']), ]); diff --git a/app/Models/Message.php b/app/Models/Message.php index c16adfb..338e09a 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -20,6 +20,120 @@ use Illuminate\Database\Eloquent\Model; */ class Message extends Model { + public const RETENTION_USER_CHAT = 'user_chat'; + + public const RETENTION_SYSTEM_NOTICE = 'system_notice'; + + public const RETENTION_GAME_NOTICE = 'game_notice'; + + public const RETENTION_EPHEMERAL_NOTICE = 'ephemeral_notice'; + + /** + * 可按过期策略清理的消息保留类型。 + * + * @return array + */ + public static function purgableRetentionTypes(): array + { + return [ + self::RETENTION_GAME_NOTICE, + self::RETENTION_EPHEMERAL_NOTICE, + ]; + } + + /** + * 根据广播消息载荷推断数据库保留类型。 + * + * @param array $messageData 聊天室消息载荷 + */ + public static function resolveRetentionType(array $messageData): string + { + $explicitType = (string) ($messageData['retention_type'] ?? ''); + if (in_array($explicitType, [ + self::RETENTION_USER_CHAT, + self::RETENTION_SYSTEM_NOTICE, + self::RETENTION_GAME_NOTICE, + self::RETENTION_EPHEMERAL_NOTICE, + ], true)) { + return $explicitType; + } + + $fromUser = (string) ($messageData['from_user'] ?? ''); + $action = (string) ($messageData['action'] ?? ''); + $messageType = (string) ($messageData['message_type'] ?? 'text'); + + if (self::isEphemeralNotice($fromUser, $action)) { + return self::RETENTION_EPHEMERAL_NOTICE; + } + + if (self::isGameNotice($fromUser, $action, $messageType, $messageData)) { + return self::RETENTION_GAME_NOTICE; + } + + if (self::isSystemNotice($fromUser)) { + return self::RETENTION_SYSTEM_NOTICE; + } + + return self::RETENTION_USER_CHAT; + } + + /** + * 判断消息是否属于可短期保留的进出场类通知。 + */ + public static function isEphemeralNotice(string $fromUser, string $action = ''): bool + { + return in_array($fromUser, ['进出播报', '座驾播报'], true) + || in_array($action, ['system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp'], true); + } + + /** + * 判断消息是否属于游戏或玩法通知。 + * + * @param array $messageData 聊天室消息载荷 + */ + public static function isGameNotice(string $fromUser, string $action, string $messageType = 'text', array $messageData = []): bool + { + $gameSenders = ['钓鱼播报', '星海小博士']; + $gameActions = [ + 'fishing_result', + 'idiom_result', + 'riddle_result', + 'ride_purchase', + ]; + + if (in_array($fromUser, $gameSenders, true) || in_array($action, $gameActions, true)) { + return true; + } + + if (isset($messageData['toast_notification'])) { + $title = (string) data_get($messageData, 'toast_notification.title', ''); + + return str_contains($title, '下注') + || str_contains($title, '赛马') + || str_contains($title, '百家乐') + || str_contains($title, '双色球') + || str_contains($title, '红包') + || str_contains($title, '结算'); + } + + return in_array($messageType, ['game_notice'], true); + } + + /** + * 判断消息是否来自系统发送者。 + */ + public static function isSystemNotice(string $fromUser): bool + { + return in_array($fromUser, [ + '系统', + '系统公告', + '系统传音', + '系统播报', + '送花播报', + 'AI小班长', + ], true); + } + /** * The attributes that are mass assignable. * @@ -37,6 +151,7 @@ class Message extends Model 'image_path', 'image_thumb_path', 'image_original_name', + 'retention_type', 'sent_at', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 7ed6503..43ee111 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -261,6 +261,22 @@ class User extends Authenticatable return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date'); } + /** + * 关联:用户已解锁和进行中的成就记录。 + */ + public function achievements(): HasMany + { + return $this->hasMany(UserAchievement::class, 'user_id')->latest('achieved_at'); + } + + /** + * 关联:用户各成就的最新进度快照。 + */ + public function achievementProgress(): HasMany + { + return $this->hasMany(UserAchievementProgress::class, 'user_id')->latest('last_scanned_at'); + } + /** * 关联:用户全部身份徽章。 */ diff --git a/app/Models/UserAchievement.php b/app/Models/UserAchievement.php new file mode 100644 index 0000000..b301f14 --- /dev/null +++ b/app/Models/UserAchievement.php @@ -0,0 +1,59 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'achievement_key', + 'progress_value', + 'achieved_at', + 'notified_at', + 'metadata', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'progress_value' => 'integer', + 'achieved_at' => 'datetime', + 'notified_at' => 'datetime', + 'metadata' => 'array', + ]; + } + + /** + * 关联:成就记录所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/UserAchievementProgress.php b/app/Models/UserAchievementProgress.php new file mode 100644 index 0000000..69d443e --- /dev/null +++ b/app/Models/UserAchievementProgress.php @@ -0,0 +1,64 @@ + */ + use HasFactory; + + /** + * 对应的数据表名。 + * + * @var string + */ + protected $table = 'user_achievement_progress'; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'achievement_key', + 'progress_value', + 'threshold_value', + 'last_scanned_at', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'progress_value' => 'integer', + 'threshold_value' => 'integer', + 'last_scanned_at' => 'datetime', + ]; + } + + /** + * 关联:进度记录所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/AchievementService.php b/app/Services/AchievementService.php new file mode 100644 index 0000000..5465bdc --- /dev/null +++ b/app/Services/AchievementService.php @@ -0,0 +1,425 @@ +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 + */ + 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' => 8000, + ], + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $message); + broadcast(new MessageSent($roomId, $message)); + SaveMessageJob::dispatch($message); + + $achievement->forceFill(['notified_at' => now()])->save(); + } +} diff --git a/app/Support/AchievementCatalog.php b/app/Support/AchievementCatalog.php new file mode 100644 index 0000000..857e548 --- /dev/null +++ b/app/Support/AchievementCatalog.php @@ -0,0 +1,229 @@ + + */ + public static function definitions(): array + { + $definitions = [ + ['key' => 'chat_first_message', 'category' => 'chat', 'name' => '初来乍到', 'icon' => '💬', 'description' => '发送第一条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1, 'sort' => 10], + ['key' => 'chat_100_messages', 'category' => 'chat', 'name' => '百句达人', 'icon' => '🗣️', 'description' => '累计发送 100 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100, 'sort' => 20], + ['key' => 'chat_500_messages', 'category' => 'chat', 'name' => '话题熟客', 'icon' => '📢', 'description' => '累计发送 500 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 500, 'sort' => 30], + ['key' => 'chat_1000_messages', 'category' => 'chat', 'name' => '千句常驻', 'icon' => '📣', 'description' => '累计发送 1000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1000, 'sort' => 40], + ['key' => 'chat_5000_messages', 'category' => 'chat', 'name' => '五千热聊', 'icon' => '🔥', 'description' => '累计发送 5000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 5000, 'sort' => 50], + ['key' => 'chat_10000_messages', 'category' => 'chat', 'name' => '万句元老', 'icon' => '🏛️', 'description' => '累计发送 10000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 10000, 'sort' => 60], + ['key' => 'chat_50000_messages', 'category' => 'chat', 'name' => '五万传声', 'icon' => '📡', 'description' => '累计发送 50000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 50000, 'sort' => 70], + ['key' => 'chat_100000_messages', 'category' => 'chat', 'name' => '十万回响', 'icon' => '🌌', 'description' => '累计发送 100000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100000, 'sort' => 80], + ['key' => 'chat_welcome_10', 'category' => 'chat', 'name' => '迎新助手', 'icon' => '🙋', 'description' => '累计欢迎他人 10 次', 'metric' => 'welcome_messages', 'threshold' => 10, 'sort' => 90], + ['key' => 'chat_welcome_50', 'category' => 'chat', 'name' => '欢迎达人', 'icon' => '👋', 'description' => '累计欢迎他人 50 次', 'metric' => 'welcome_messages', 'threshold' => 50, 'sort' => 100], + ['key' => 'chat_welcome_100', 'category' => 'chat', 'name' => '迎宾队长', 'icon' => '🎉', 'description' => '累计欢迎他人 100 次', 'metric' => 'welcome_messages', 'threshold' => 100, 'sort' => 110], + ['key' => 'chat_welcome_500', 'category' => 'chat', 'name' => '满堂迎客', 'icon' => '🏮', 'description' => '累计欢迎他人 500 次', 'metric' => 'welcome_messages', 'threshold' => 500, 'sort' => 120], + + ['key' => 'signin_total_1', 'category' => 'sign_in', 'name' => '首次打卡', 'icon' => '☀️', 'description' => '累计签到 1 天', 'metric' => 'total_sign_ins', 'threshold' => 1, 'sort' => 130], + ['key' => 'signin_total_7', 'category' => 'sign_in', 'name' => '一周到场', 'icon' => '🗓️', 'description' => '累计签到 7 天', 'metric' => 'total_sign_ins', 'threshold' => 7, 'sort' => 140], + ['key' => 'signin_total_30', 'category' => 'sign_in', 'name' => '月度出勤', 'icon' => '📆', 'description' => '累计签到 30 天', 'metric' => 'total_sign_ins', 'threshold' => 30, 'sort' => 150], + ['key' => 'signin_total_100', 'category' => 'sign_in', 'name' => '百日足迹', 'icon' => '👣', 'description' => '累计签到 100 天', 'metric' => 'total_sign_ins', 'threshold' => 100, 'sort' => 160], + ['key' => 'signin_total_365', 'category' => 'sign_in', 'name' => '年度常客', 'icon' => '🏅', 'description' => '累计签到 365 天', 'metric' => 'total_sign_ins', 'threshold' => 365, 'sort' => 170], + ['key' => 'signin_3_streak', 'category' => 'sign_in', 'name' => '三日连到', 'icon' => '✅', 'description' => '连续签到 3 天', 'metric' => 'sign_in_streak', 'threshold' => 3, 'sort' => 180], + ['key' => 'signin_7_streak', 'category' => 'sign_in', 'name' => '七日不断', 'icon' => '☑️', 'description' => '连续签到 7 天', 'metric' => 'sign_in_streak', 'threshold' => 7, 'sort' => 190], + ['key' => 'signin_15_streak', 'category' => 'sign_in', 'name' => '半月不断', 'icon' => '🌙', 'description' => '连续签到 15 天', 'metric' => 'sign_in_streak', 'threshold' => 15, 'sort' => 200], + ['key' => 'signin_30_streak', 'category' => 'sign_in', 'name' => '月度全勤', 'icon' => '📅', 'description' => '连续签到 30 天', 'metric' => 'sign_in_streak', 'threshold' => 30, 'sort' => 210], + ['key' => 'signin_60_streak', 'category' => 'sign_in', 'name' => '双月坚守', 'icon' => '🔥', 'description' => '连续签到 60 天', 'metric' => 'sign_in_streak', 'threshold' => 60, 'sort' => 220], + ['key' => 'signin_100_streak', 'category' => 'sign_in', 'name' => '百日坚持', 'icon' => '💯', 'description' => '连续签到 100 天', 'metric' => 'sign_in_streak', 'threshold' => 100, 'sort' => 230], + ['key' => 'signin_365_streak', 'category' => 'sign_in', 'name' => '全年不断', 'icon' => '🏆', 'description' => '连续签到 365 天', 'metric' => 'sign_in_streak', 'threshold' => 365, 'sort' => 240], + ['key' => 'signin_makeup_used', 'category' => 'sign_in', 'name' => '补签救场', 'icon' => '🧩', 'description' => '使用过 1 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 1, 'sort' => 250], + ['key' => 'signin_makeup_5', 'category' => 'sign_in', 'name' => '补签老手', 'icon' => '🪄', 'description' => '累计使用 5 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 5, 'sort' => 260], + ['key' => 'signin_makeup_20', 'category' => 'sign_in', 'name' => '断线重连', 'icon' => '🔁', 'description' => '累计使用 20 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 20, 'sort' => 270], + + ['key' => 'growth_exp_10000', 'category' => 'growth', 'name' => '小有所成', 'icon' => '✨', 'description' => '累计获得 10000 经验', 'metric' => 'exp_gain', 'threshold' => 10000, 'sort' => 210], + ['key' => 'growth_gold_100000', 'category' => 'growth', 'name' => '金币新贵', 'icon' => '💰', 'description' => '累计获得 100000 金币', 'metric' => 'gold_gain', 'threshold' => 100000, 'sort' => 220], + ['key' => 'growth_charm_1000', 'category' => 'growth', 'name' => '魅力初显', 'icon' => '🌸', 'description' => '累计获得 1000 魅力', 'metric' => 'charm_gain', 'threshold' => 1000, 'sort' => 230], + ['key' => 'growth_assets_1000000', 'category' => 'growth', 'name' => '百万身家', 'icon' => '💎', 'description' => '金币资产达到 1000000', 'metric' => 'gold_assets', 'threshold' => 1000000, 'sort' => 240], + ['key' => 'growth_assets_10000000', 'category' => 'growth', 'name' => '千万富豪', 'icon' => '👑', 'description' => '金币资产达到 10000000', 'metric' => 'gold_assets', 'threshold' => 10000000, 'sort' => 250], + ['key' => 'growth_assets_100000000', 'category' => 'growth', 'name' => '亿级资产', 'icon' => '🏆', 'description' => '金币资产达到 100000000', 'metric' => 'gold_assets', 'threshold' => 100000000, 'sort' => 260], + ['key' => 'growth_bank_500000', 'category' => 'growth', 'name' => '存款达人', 'icon' => '🏦', 'description' => '银行存款达到 500000 金币', 'metric' => 'bank_balance', 'threshold' => 500000, 'sort' => 270], + ['key' => 'growth_bank_1000000', 'category' => 'growth', 'name' => '百万存款', 'icon' => '🏧', 'description' => '银行存款达到 1000000 金币', 'metric' => 'bank_balance', 'threshold' => 1000000, 'sort' => 280], + ['key' => 'growth_bank_10000000', 'category' => 'growth', 'name' => '金库存户', 'icon' => '🔐', 'description' => '银行存款达到 10000000 金币', 'metric' => 'bank_balance', 'threshold' => 10000000, 'sort' => 290], + + ['key' => 'game_baccarat_20', 'category' => 'game', 'name' => '百家乐入门', 'icon' => '🎲', 'description' => '累计参与百家乐下注 20 次', 'metric' => 'baccarat_bets', 'threshold' => 20, 'sort' => 310], + ['key' => 'game_horse_20', 'category' => 'game', 'name' => '赛马看客', 'icon' => '🐎', 'description' => '累计参与赛马下注 20 次', 'metric' => 'horse_bets', 'threshold' => 20, 'sort' => 320], + ['key' => 'game_lottery_20', 'category' => 'game', 'name' => '双色球常客', 'icon' => '🎟️', 'description' => '累计购买双色球 20 注', 'metric' => 'lottery_tickets', 'threshold' => 20, 'sort' => 330], + ['key' => 'game_slot_20', 'category' => 'game', 'name' => '老虎机试手', 'icon' => '🎰', 'description' => '累计转动老虎机 20 次', 'metric' => 'slot_spins', 'threshold' => 20, 'sort' => 340], + ['key' => 'game_gomoku_win', 'category' => 'game', 'name' => '五子棋首胜', 'icon' => '♟️', 'description' => '获得 1 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 1, 'sort' => 350], + ['key' => 'game_fishing_20', 'category' => 'game', 'name' => '垂钓小能手', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 20 次', 'metric' => 'fishing_times', 'threshold' => 20, 'sort' => 360], + ['key' => 'game_riddle_win', 'category' => 'game', 'name' => '猜谜破题', 'icon' => '🧠', 'description' => '成功答对 1 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 1, 'sort' => 370], + ['key' => 'game_win_1000', 'category' => 'game', 'name' => '小赚一笔', 'icon' => '🪙', 'description' => '游戏累计赢取 1000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000, 'sort' => 380], + ['key' => 'game_win_10000', 'category' => 'game', 'name' => '手气渐热', 'icon' => '💵', 'description' => '游戏累计赢取 10000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000, 'sort' => 390], + ['key' => 'game_win_100000', 'category' => 'game', 'name' => '十万进账', 'icon' => '💰', 'description' => '游戏累计赢取 100000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000, 'sort' => 400], + ['key' => 'game_win_1000000', 'category' => 'game', 'name' => '百万赢家', 'icon' => '🏆', 'description' => '游戏累计赢取 1000000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000000, 'sort' => 410], + ['key' => 'game_win_10000000', 'category' => 'game', 'name' => '千万胜手', 'icon' => '👑', 'description' => '游戏累计赢取 10000000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000000, 'sort' => 420], + ['key' => 'game_loss_1000', 'category' => 'game', 'name' => '小输当练', 'icon' => '🧾', 'description' => '游戏累计输掉 1000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000, 'sort' => 430], + ['key' => 'game_loss_10000', 'category' => 'game', 'name' => '万金试炼', 'icon' => '📉', 'description' => '游戏累计输掉 10000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000, 'sort' => 440], + ['key' => 'game_loss_100000', 'category' => 'game', 'name' => '十万学费', 'icon' => '🎒', 'description' => '游戏累计输掉 100000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000, 'sort' => 450], + ['key' => 'game_loss_1000000', 'category' => 'game', 'name' => '百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 1000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000000, 'sort' => 460], + ['key' => 'game_loss_10000000', 'category' => 'game', 'name' => '千万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 10000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000000, 'sort' => 470], + + ['key' => 'social_red_packet_sent', 'category' => 'social', 'name' => '慷慨发包', 'icon' => '🧧', 'description' => '发送过 1 次红包', 'metric' => 'red_packets_sent', 'threshold' => 1, 'sort' => 410], + ['key' => 'social_red_packet_claimed', 'category' => 'social', 'name' => '手气不错', 'icon' => '🙌', 'description' => '领取过 1 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 1, 'sort' => 420], + ['key' => 'social_married', 'category' => 'social', 'name' => '情定聊天室', 'icon' => '💍', 'description' => '完成一次结婚', 'metric' => 'marriages', 'threshold' => 1, 'sort' => 430], + ['key' => 'social_intimacy_1000', 'category' => 'social', 'name' => '亲密搭档', 'icon' => '💞', 'description' => '婚姻亲密度达到 1000', 'metric' => 'marriage_intimacy', 'threshold' => 1000, 'sort' => 440], + ['key' => 'social_gift_sent', 'category' => 'social', 'name' => '赠礼之友', 'icon' => '🎁', 'description' => '送出过 1 次礼物', 'metric' => 'gifts_sent', 'threshold' => 1, 'sort' => 450], + ['key' => 'social_gift_received', 'category' => 'social', 'name' => '人气收礼', 'icon' => '💐', 'description' => '收到过 1 次礼物', 'metric' => 'gifts_received', 'threshold' => 1, 'sort' => 460], + + ['key' => 'duty_first_position', 'category' => 'duty', 'name' => '首次任命', 'icon' => '🎖️', 'description' => '获得过 1 次职务任命', 'metric' => 'positions', 'threshold' => 1, 'sort' => 510], + ['key' => 'duty_60_minutes', 'category' => 'duty', 'name' => '勤务一小时', 'icon' => '⏱️', 'description' => '累计值班 60 分钟', 'metric' => 'duty_minutes', 'threshold' => 60, 'sort' => 520], + ['key' => 'duty_admin_action', 'category' => 'duty', 'name' => '管理出手', 'icon' => '🛡️', 'description' => '执行过 1 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1, 'sort' => 530], + ]; + $definitions = array_merge($definitions, self::extendedTierDefinitions()); + + return collect($definitions)->keyBy('key')->all(); + } + + /** + * 返回长期运营需要的扩展阶梯成就。 + * + * @return array + */ + private static function extendedTierDefinitions(): array + { + return [ + ['key' => 'chat_2000_messages', 'category' => 'chat', 'name' => '两千连珠', 'icon' => '🧵', 'description' => '累计发送 2000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 2000, 'sort' => 45], + ['key' => 'chat_20000_messages', 'category' => 'chat', 'name' => '两万谈资', 'icon' => '🛰️', 'description' => '累计发送 20000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 20000, 'sort' => 65], + ['key' => 'chat_200000_messages', 'category' => 'chat', 'name' => '二十万长谈', 'icon' => '🌠', 'description' => '累计发送 200000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 200000, 'sort' => 85], + ['key' => 'chat_300000_messages', 'category' => 'chat', 'name' => '三十万星河', 'icon' => '🌌', 'description' => '累计发送 300000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 300000, 'sort' => 86], + ['key' => 'chat_welcome_1000', 'category' => 'chat', 'name' => '千次迎客', 'icon' => '🎊', 'description' => '累计欢迎他人 1000 次', 'metric' => 'welcome_messages', 'threshold' => 1000, 'sort' => 121], + ['key' => 'chat_welcome_3000', 'category' => 'chat', 'name' => '迎宾长明灯', 'icon' => '🏵️', 'description' => '累计欢迎他人 3000 次', 'metric' => 'welcome_messages', 'threshold' => 3000, 'sort' => 122], + + ['key' => 'signin_total_60', 'category' => 'sign_in', 'name' => '两月足迹', 'icon' => '📍', 'description' => '累计签到 60 天', 'metric' => 'total_sign_ins', 'threshold' => 60, 'sort' => 155], + ['key' => 'signin_total_180', 'category' => 'sign_in', 'name' => '半年到场', 'icon' => '🧭', 'description' => '累计签到 180 天', 'metric' => 'total_sign_ins', 'threshold' => 180, 'sort' => 165], + ['key' => 'signin_total_730', 'category' => 'sign_in', 'name' => '两年常驻', 'icon' => '🏕️', 'description' => '累计签到 730 天', 'metric' => 'total_sign_ins', 'threshold' => 730, 'sort' => 171], + ['key' => 'signin_total_1000', 'category' => 'sign_in', 'name' => '千日留名', 'icon' => '📜', 'description' => '累计签到 1000 天', 'metric' => 'total_sign_ins', 'threshold' => 1000, 'sort' => 172], + ['key' => 'signin_180_streak', 'category' => 'sign_in', 'name' => '半年不断', 'icon' => '🧱', 'description' => '连续签到 180 天', 'metric' => 'sign_in_streak', 'threshold' => 180, 'sort' => 241], + ['key' => 'signin_730_streak', 'category' => 'sign_in', 'name' => '两年不断', 'icon' => '🗻', 'description' => '连续签到 730 天', 'metric' => 'sign_in_streak', 'threshold' => 730, 'sort' => 242], + ['key' => 'signin_makeup_50', 'category' => 'sign_in', 'name' => '时光修补匠', 'icon' => '🧵', 'description' => '累计使用 50 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 50, 'sort' => 271], + + ['key' => 'growth_exp_50000', 'category' => 'growth', 'name' => '经验老练', 'icon' => '🌟', 'description' => '累计获得 50000 经验', 'metric' => 'exp_gain', 'threshold' => 50000, 'sort' => 211], + ['key' => 'growth_exp_100000', 'category' => 'growth', 'name' => '十万经验', 'icon' => '🎓', 'description' => '累计获得 100000 经验', 'metric' => 'exp_gain', 'threshold' => 100000, 'sort' => 212], + ['key' => 'growth_exp_500000', 'category' => 'growth', 'name' => '经验厚积', 'icon' => '📚', 'description' => '累计获得 500000 经验', 'metric' => 'exp_gain', 'threshold' => 500000, 'sort' => 213], + ['key' => 'growth_exp_1000000', 'category' => 'growth', 'name' => '百万经验', 'icon' => '🏫', 'description' => '累计获得 1000000 经验', 'metric' => 'exp_gain', 'threshold' => 1000000, 'sort' => 214], + ['key' => 'growth_gold_500000', 'category' => 'growth', 'name' => '半百万进账', 'icon' => '💴', 'description' => '累计获得 500000 金币', 'metric' => 'gold_gain', 'threshold' => 500000, 'sort' => 221], + ['key' => 'growth_gold_1000000', 'category' => 'growth', 'name' => '百万进账', 'icon' => '💵', 'description' => '累计获得 1000000 金币', 'metric' => 'gold_gain', 'threshold' => 1000000, 'sort' => 222], + ['key' => 'growth_gold_5000000', 'category' => 'growth', 'name' => '五百万进账', 'icon' => '💶', 'description' => '累计获得 5000000 金币', 'metric' => 'gold_gain', 'threshold' => 5000000, 'sort' => 223], + ['key' => 'growth_gold_10000000', 'category' => 'growth', 'name' => '千万进账', 'icon' => '💷', 'description' => '累计获得 10000000 金币', 'metric' => 'gold_gain', 'threshold' => 10000000, 'sort' => 224], + ['key' => 'growth_gold_100000000', 'category' => 'growth', 'name' => '亿级进账', 'icon' => '🪙', 'description' => '累计获得 100000000 金币', 'metric' => 'gold_gain', 'threshold' => 100000000, 'sort' => 225], + ['key' => 'growth_charm_5000', 'category' => 'growth', 'name' => '魅力上扬', 'icon' => '🌺', 'description' => '累计获得 5000 魅力', 'metric' => 'charm_gain', 'threshold' => 5000, 'sort' => 231], + ['key' => 'growth_charm_10000', 'category' => 'growth', 'name' => '万点魅力', 'icon' => '💐', 'description' => '累计获得 10000 魅力', 'metric' => 'charm_gain', 'threshold' => 10000, 'sort' => 232], + ['key' => 'growth_charm_50000', 'category' => 'growth', 'name' => '魅力满堂', 'icon' => '🪷', 'description' => '累计获得 50000 魅力', 'metric' => 'charm_gain', 'threshold' => 50000, 'sort' => 233], + ['key' => 'growth_charm_100000', 'category' => 'growth', 'name' => '十万魅力', 'icon' => '👒', 'description' => '累计获得 100000 魅力', 'metric' => 'charm_gain', 'threshold' => 100000, 'sort' => 234], + ['key' => 'growth_assets_5000000', 'category' => 'growth', 'name' => '五百万身家', 'icon' => '💍', 'description' => '金币资产达到 5000000', 'metric' => 'gold_assets', 'threshold' => 5000000, 'sort' => 245], + ['key' => 'growth_assets_50000000', 'category' => 'growth', 'name' => '五千万资产', 'icon' => '🏦', 'description' => '金币资产达到 50000000', 'metric' => 'gold_assets', 'threshold' => 50000000, 'sort' => 255], + ['key' => 'growth_assets_500000000', 'category' => 'growth', 'name' => '五亿资产', 'icon' => '🏛️', 'description' => '金币资产达到 500000000', 'metric' => 'gold_assets', 'threshold' => 500000000, 'sort' => 261], + ['key' => 'growth_assets_1000000000', 'category' => 'growth', 'name' => '十亿传说', 'icon' => '🚀', 'description' => '金币资产达到 1000000000', 'metric' => 'gold_assets', 'threshold' => 1000000000, 'sort' => 262], + ['key' => 'growth_bank_5000000', 'category' => 'growth', 'name' => '五百万存款', 'icon' => '🧱', 'description' => '银行存款达到 5000000 金币', 'metric' => 'bank_balance', 'threshold' => 5000000, 'sort' => 285], + ['key' => 'growth_bank_50000000', 'category' => 'growth', 'name' => '五千万金库', 'icon' => '🏦', 'description' => '银行存款达到 50000000 金币', 'metric' => 'bank_balance', 'threshold' => 50000000, 'sort' => 291], + ['key' => 'growth_bank_100000000', 'category' => 'growth', 'name' => '亿级金库', 'icon' => '🔒', 'description' => '银行存款达到 100000000 金币', 'metric' => 'bank_balance', 'threshold' => 100000000, 'sort' => 292], + + ['key' => 'game_baccarat_100', 'category' => 'game', 'name' => '百局百家乐', 'icon' => '🎲', 'description' => '累计参与百家乐下注 100 次', 'metric' => 'baccarat_bets', 'threshold' => 100, 'sort' => 311], + ['key' => 'game_baccarat_500', 'category' => 'game', 'name' => '百家乐熟手', 'icon' => '🃏', 'description' => '累计参与百家乐下注 500 次', 'metric' => 'baccarat_bets', 'threshold' => 500, 'sort' => 312], + ['key' => 'game_baccarat_1000', 'category' => 'game', 'name' => '千局庄闲', 'icon' => '🎴', 'description' => '累计参与百家乐下注 1000 次', 'metric' => 'baccarat_bets', 'threshold' => 1000, 'sort' => 313], + ['key' => 'game_horse_100', 'category' => 'game', 'name' => '百场赛马', 'icon' => '🏇', 'description' => '累计参与赛马下注 100 次', 'metric' => 'horse_bets', 'threshold' => 100, 'sort' => 321], + ['key' => 'game_horse_500', 'category' => 'game', 'name' => '马场熟客', 'icon' => '🎠', 'description' => '累计参与赛马下注 500 次', 'metric' => 'horse_bets', 'threshold' => 500, 'sort' => 322], + ['key' => 'game_horse_1000', 'category' => 'game', 'name' => '千场观赛', 'icon' => '🏁', 'description' => '累计参与赛马下注 1000 次', 'metric' => 'horse_bets', 'threshold' => 1000, 'sort' => 323], + ['key' => 'game_lottery_100', 'category' => 'game', 'name' => '百注双色球', 'icon' => '🎟️', 'description' => '累计购买双色球 100 注', 'metric' => 'lottery_tickets', 'threshold' => 100, 'sort' => 331], + ['key' => 'game_lottery_500', 'category' => 'game', 'name' => '彩池常客', 'icon' => '🔵', 'description' => '累计购买双色球 500 注', 'metric' => 'lottery_tickets', 'threshold' => 500, 'sort' => 332], + ['key' => 'game_lottery_1000', 'category' => 'game', 'name' => '千注追梦', 'icon' => '🔴', 'description' => '累计购买双色球 1000 注', 'metric' => 'lottery_tickets', 'threshold' => 1000, 'sort' => 333], + ['key' => 'game_slot_100', 'category' => 'game', 'name' => '百转老虎机', 'icon' => '🎰', 'description' => '累计转动老虎机 100 次', 'metric' => 'slot_spins', 'threshold' => 100, 'sort' => 341], + ['key' => 'game_slot_500', 'category' => 'game', 'name' => '转轮熟手', 'icon' => '⚙️', 'description' => '累计转动老虎机 500 次', 'metric' => 'slot_spins', 'threshold' => 500, 'sort' => 342], + ['key' => 'game_slot_1000', 'category' => 'game', 'name' => '千转不歇', 'icon' => '🔔', 'description' => '累计转动老虎机 1000 次', 'metric' => 'slot_spins', 'threshold' => 1000, 'sort' => 343], + ['key' => 'game_gomoku_5_wins', 'category' => 'game', 'name' => '五子五胜', 'icon' => '⚫', 'description' => '获得 5 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 5, 'sort' => 351], + ['key' => 'game_gomoku_20_wins', 'category' => 'game', 'name' => '棋盘强手', 'icon' => '⚪', 'description' => '获得 20 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 20, 'sort' => 352], + ['key' => 'game_gomoku_100_wins', 'category' => 'game', 'name' => '百胜棋手', 'icon' => '♟️', 'description' => '获得 100 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 100, 'sort' => 353], + ['key' => 'game_fishing_100', 'category' => 'game', 'name' => '百竿垂钓', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 100 次', 'metric' => 'fishing_times', 'threshold' => 100, 'sort' => 361], + ['key' => 'game_fishing_500', 'category' => 'game', 'name' => '鱼塘熟手', 'icon' => '🐟', 'description' => '累计抛竿钓鱼 500 次', 'metric' => 'fishing_times', 'threshold' => 500, 'sort' => 362], + ['key' => 'game_fishing_1000', 'category' => 'game', 'name' => '千竿钓客', 'icon' => '🐠', 'description' => '累计抛竿钓鱼 1000 次', 'metric' => 'fishing_times', 'threshold' => 1000, 'sort' => 363], + ['key' => 'game_riddle_10_wins', 'category' => 'game', 'name' => '十题小成', 'icon' => '🧠', 'description' => '成功答对 10 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 10, 'sort' => 371], + ['key' => 'game_riddle_50_wins', 'category' => 'game', 'name' => '破题熟手', 'icon' => '💡', 'description' => '成功答对 50 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 50, 'sort' => 372], + ['key' => 'game_riddle_200_wins', 'category' => 'game', 'name' => '谜面克星', 'icon' => '📘', 'description' => '成功答对 200 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 200, 'sort' => 373], + ['key' => 'game_win_5000', 'category' => 'game', 'name' => '五千到手', 'icon' => '🪙', 'description' => '游戏累计赢取 5000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000, 'sort' => 385], + ['key' => 'game_win_50000', 'category' => 'game', 'name' => '五万好运', 'icon' => '💵', 'description' => '游戏累计赢取 50000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000, 'sort' => 395], + ['key' => 'game_win_500000', 'category' => 'game', 'name' => '半百万赢家', 'icon' => '💰', 'description' => '游戏累计赢取 500000 金币', 'metric' => 'game_gold_won', 'threshold' => 500000, 'sort' => 405], + ['key' => 'game_win_5000000', 'category' => 'game', 'name' => '五百万胜手', 'icon' => '🏆', 'description' => '游戏累计赢取 5000000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000000, 'sort' => 415], + ['key' => 'game_win_50000000', 'category' => 'game', 'name' => '五千万战绩', 'icon' => '👑', 'description' => '游戏累计赢取 50000000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000000, 'sort' => 421], + ['key' => 'game_win_100000000', 'category' => 'game', 'name' => '亿级赢家', 'icon' => '🌟', 'description' => '游戏累计赢取 100000000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000000, 'sort' => 422], + ['key' => 'game_loss_5000', 'category' => 'game', 'name' => '五千试水', 'icon' => '🧾', 'description' => '游戏累计输掉 5000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000, 'sort' => 435], + ['key' => 'game_loss_50000', 'category' => 'game', 'name' => '五万起伏', 'icon' => '📉', 'description' => '游戏累计输掉 50000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000, 'sort' => 445], + ['key' => 'game_loss_500000', 'category' => 'game', 'name' => '半百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 500000 金币', 'metric' => 'game_gold_lost', 'threshold' => 500000, 'sort' => 455], + ['key' => 'game_loss_5000000', 'category' => 'game', 'name' => '五百万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 5000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000000, 'sort' => 465], + ['key' => 'game_loss_50000000', 'category' => 'game', 'name' => '五千万风浪', 'icon' => '🌪️', 'description' => '游戏累计输掉 50000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000000, 'sort' => 471], + ['key' => 'game_loss_100000000', 'category' => 'game', 'name' => '亿级沉浮', 'icon' => '🕳️', 'description' => '游戏累计输掉 100000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000000, 'sort' => 472], + + ['key' => 'social_red_packet_sent_10', 'category' => 'social', 'name' => '十次发包', 'icon' => '🧧', 'description' => '累计发送 10 次红包', 'metric' => 'red_packets_sent', 'threshold' => 10, 'sort' => 411], + ['key' => 'social_red_packet_sent_50', 'category' => 'social', 'name' => '红包常客', 'icon' => '🎁', 'description' => '累计发送 50 次红包', 'metric' => 'red_packets_sent', 'threshold' => 50, 'sort' => 412], + ['key' => 'social_red_packet_sent_100', 'category' => 'social', 'name' => '百包散财', 'icon' => '🏮', 'description' => '累计发送 100 次红包', 'metric' => 'red_packets_sent', 'threshold' => 100, 'sort' => 413], + ['key' => 'social_red_packet_claimed_10', 'category' => 'social', 'name' => '十次手气', 'icon' => '🙌', 'description' => '累计领取 10 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 10, 'sort' => 421], + ['key' => 'social_red_packet_claimed_50', 'category' => 'social', 'name' => '抢包熟手', 'icon' => '🫴', 'description' => '累计领取 50 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 50, 'sort' => 422], + ['key' => 'social_red_packet_claimed_100', 'category' => 'social', 'name' => '百包入手', 'icon' => '🧧', 'description' => '累计领取 100 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 100, 'sort' => 423], + ['key' => 'social_intimacy_5000', 'category' => 'social', 'name' => '亲密升温', 'icon' => '💞', 'description' => '婚姻亲密度达到 5000', 'metric' => 'marriage_intimacy', 'threshold' => 5000, 'sort' => 441], + ['key' => 'social_intimacy_10000', 'category' => 'social', 'name' => '万点亲密', 'icon' => '💕', 'description' => '婚姻亲密度达到 10000', 'metric' => 'marriage_intimacy', 'threshold' => 10000, 'sort' => 442], + ['key' => 'social_intimacy_50000', 'category' => 'social', 'name' => '情深五万', 'icon' => '💖', 'description' => '婚姻亲密度达到 50000', 'metric' => 'marriage_intimacy', 'threshold' => 50000, 'sort' => 443], + ['key' => 'social_gift_sent_10', 'category' => 'social', 'name' => '十礼相赠', 'icon' => '🎁', 'description' => '累计送出 10 次礼物', 'metric' => 'gifts_sent', 'threshold' => 10, 'sort' => 451], + ['key' => 'social_gift_sent_50', 'category' => 'social', 'name' => '赠礼熟手', 'icon' => '🎀', 'description' => '累计送出 50 次礼物', 'metric' => 'gifts_sent', 'threshold' => 50, 'sort' => 452], + ['key' => 'social_gift_sent_100', 'category' => 'social', 'name' => '百礼往来', 'icon' => '💝', 'description' => '累计送出 100 次礼物', 'metric' => 'gifts_sent', 'threshold' => 100, 'sort' => 453], + ['key' => 'social_gift_received_10', 'category' => 'social', 'name' => '十礼入怀', 'icon' => '💐', 'description' => '累计收到 10 次礼物', 'metric' => 'gifts_received', 'threshold' => 10, 'sort' => 461], + ['key' => 'social_gift_received_50', 'category' => 'social', 'name' => '人气渐盛', 'icon' => '🌹', 'description' => '累计收到 50 次礼物', 'metric' => 'gifts_received', 'threshold' => 50, 'sort' => 462], + ['key' => 'social_gift_received_100', 'category' => 'social', 'name' => '百礼人气', 'icon' => '🌷', 'description' => '累计收到 100 次礼物', 'metric' => 'gifts_received', 'threshold' => 100, 'sort' => 463], + + ['key' => 'duty_3_positions', 'category' => 'duty', 'name' => '多职历练', 'icon' => '🎖️', 'description' => '累计获得 3 次职务任命', 'metric' => 'positions', 'threshold' => 3, 'sort' => 511], + ['key' => 'duty_10_positions', 'category' => 'duty', 'name' => '十任履历', 'icon' => '📌', 'description' => '累计获得 10 次职务任命', 'metric' => 'positions', 'threshold' => 10, 'sort' => 512], + ['key' => 'duty_300_minutes', 'category' => 'duty', 'name' => '勤务五小时', 'icon' => '⏱️', 'description' => '累计值班 300 分钟', 'metric' => 'duty_minutes', 'threshold' => 300, 'sort' => 521], + ['key' => 'duty_600_minutes', 'category' => 'duty', 'name' => '勤务十小时', 'icon' => '🕰️', 'description' => '累计值班 600 分钟', 'metric' => 'duty_minutes', 'threshold' => 600, 'sort' => 522], + ['key' => 'duty_3000_minutes', 'category' => 'duty', 'name' => '值班老手', 'icon' => '📋', 'description' => '累计值班 3000 分钟', 'metric' => 'duty_minutes', 'threshold' => 3000, 'sort' => 523], + ['key' => 'duty_10000_minutes', 'category' => 'duty', 'name' => '万分钟勤务', 'icon' => '🏢', 'description' => '累计值班 10000 分钟', 'metric' => 'duty_minutes', 'threshold' => 10000, 'sort' => 524], + ['key' => 'duty_10_admin_actions', 'category' => 'duty', 'name' => '十次管理', 'icon' => '🛡️', 'description' => '累计执行 10 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 10, 'sort' => 531], + ['key' => 'duty_100_admin_actions', 'category' => 'duty', 'name' => '百次管理', 'icon' => '⚖️', 'description' => '累计执行 100 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 100, 'sort' => 532], + ['key' => 'duty_1000_admin_actions', 'category' => 'duty', 'name' => '千次执勤', 'icon' => '🏛️', 'description' => '累计执行 1000 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1000, 'sort' => 533], + ]; + } + + /** + * 返回成就分类标题。 + * + * @return array + */ + public static function categories(): array + { + return [ + 'chat' => '聊天', + 'sign_in' => '签到', + 'growth' => '成长', + 'game' => '游戏', + 'social' => '社交', + 'duty' => '职务', + ]; + } + + /** + * 根据 key 获取单个成就定义。 + * + * @return array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}|null + */ + public static function find(string $key): ?array + { + return self::definitions()[$key] ?? null; + } +} diff --git a/database/factories/UserAchievementFactory.php b/database/factories/UserAchievementFactory.php new file mode 100644 index 0000000..6f42366 --- /dev/null +++ b/database/factories/UserAchievementFactory.php @@ -0,0 +1,36 @@ + + */ +class UserAchievementFactory extends Factory +{ + /** + * 定义模型的默认测试状态。 + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'achievement_key' => 'chat_first_message', + 'progress_value' => 1, + 'metadata' => ['threshold' => 1], + ]; + } +} diff --git a/database/factories/UserAchievementProgressFactory.php b/database/factories/UserAchievementProgressFactory.php new file mode 100644 index 0000000..c09022e --- /dev/null +++ b/database/factories/UserAchievementProgressFactory.php @@ -0,0 +1,37 @@ + + */ +class UserAchievementProgressFactory extends Factory +{ + /** + * 定义模型的默认测试状态。 + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'achievement_key' => 'chat_first_message', + 'progress_value' => 0, + 'threshold_value' => 1, + 'last_scanned_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_04_30_155115_add_retention_type_to_messages_table.php b/database/migrations/2026_04_30_155115_add_retention_type_to_messages_table.php new file mode 100644 index 0000000..f6189b9 --- /dev/null +++ b/database/migrations/2026_04_30_155115_add_retention_type_to_messages_table.php @@ -0,0 +1,31 @@ +string('retention_type', 30) + ->default('user_chat') + ->index() + ->comment('消息保留类型:user_chat/system_notice/game_notice/ephemeral_notice'); + }); + } + + /** + * 回滚迁移:移除聊天消息保留类型字段。 + */ + public function down(): void + { + Schema::table('messages', function (Blueprint $table) { + $table->dropColumn('retention_type'); + }); + } +}; diff --git a/database/migrations/2026_04_30_155115_create_user_achievements_table.php b/database/migrations/2026_04_30_155115_create_user_achievements_table.php new file mode 100644 index 0000000..b7861dc --- /dev/null +++ b/database/migrations/2026_04_30_155115_create_user_achievements_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID'); + $table->string('achievement_key', 80)->comment('成就唯一标识'); + $table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度快照'); + $table->timestamp('achieved_at')->nullable()->index()->comment('达成时间'); + $table->timestamp('notified_at')->nullable()->comment('通知时间'); + $table->json('metadata')->nullable()->comment('成就解锁附加信息'); + $table->timestamps(); + + $table->unique(['user_id', 'achievement_key']); + $table->index(['achievement_key', 'achieved_at']); + }); + } + + /** + * 回滚迁移:删除用户成就解锁记录表。 + */ + public function down(): void + { + Schema::dropIfExists('user_achievements'); + } +}; diff --git a/database/migrations/2026_04_30_155640_create_user_achievement_progress_table.php b/database/migrations/2026_04_30_155640_create_user_achievement_progress_table.php new file mode 100644 index 0000000..83e45d4 --- /dev/null +++ b/database/migrations/2026_04_30_155640_create_user_achievement_progress_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID'); + $table->string('achievement_key', 80)->comment('成就唯一标识'); + $table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度值'); + $table->unsignedBigInteger('threshold_value')->default(0)->comment('达成门槛快照'); + $table->timestamp('last_scanned_at')->nullable()->index()->comment('最近扫描时间'); + $table->timestamps(); + + $table->unique(['user_id', 'achievement_key']); + }); + } + + /** + * 回滚迁移:删除用户成就进度快照表。 + */ + public function down(): void + { + Schema::dropIfExists('user_achievement_progress'); + } +}; diff --git a/resources/views/achievements/index.blade.php b/resources/views/achievements/index.blade.php new file mode 100644 index 0000000..f4b8cdb --- /dev/null +++ b/resources/views/achievements/index.blade.php @@ -0,0 +1,108 @@ +{{-- + 文件功能:我的成就页面 + 按分类展示当前用户的固定成就解锁状态与进度。 +--}} +@extends('layouts.app') + +@section('title', '我的成就 - 飘落流星') + +@section('nav-icon', '🏅') +@section('nav-title', '我的成就') + +@section('content') +
+
+
+
+
+

{{ $user->username }} 的成就档案

+

已解锁 {{ $unlocked_count }} / {{ $total_count }} 项

+
+
+
+
+
+
+
+ + + + @foreach ($categories as $categoryKey => $categoryLabel) + @php + $items = $achievements->where('category', $categoryKey)->values(); + @endphp + + @if ($items->isEmpty()) + @continue + @endif + +
+
+

{{ $categoryLabel }}成就

+ + {{ $items->where('unlocked', true)->count() }} / {{ $items->count() }} + +
+ +
+ @foreach ($items as $achievement) +
+
+ {{ $achievement['icon'] }} +
+
+
+

{{ $achievement['name'] }}

+ + {{ $achievement['unlocked'] ? '已解锁' : '进行中' }} + +
+

{{ $achievement['description'] }}

+
+
+
+
+ + {{ number_format($achievement['progress_value']) }} / + {{ number_format($achievement['threshold']) }} + +
+ @if ($achievement['achieved_at']) +

+ 解锁于 {{ $achievement['achieved_at']->format('Y-m-d H:i') }} +

+ @endif +
+
+ @endforeach +
+
+ @endforeach + + @if ($achievements->isEmpty()) +
+

暂无对应成就

+

切换其他筛选查看成就列表。

+
+ @endif +
+
+@endsection diff --git a/resources/views/admin/achievements/index.blade.php b/resources/views/admin/achievements/index.blade.php new file mode 100644 index 0000000..fd4255e --- /dev/null +++ b/resources/views/admin/achievements/index.blade.php @@ -0,0 +1,118 @@ +{{-- + 文件功能:后台成就记录页面 + 提供固定成就目录、解锁统计与用户成就记录只读查询。 +--}} +@extends('admin.layouts.app') + +@section('title', '成就记录') + +@section('content') +
+
+
+

固定成就

+

{{ number_format($summary['total_definitions']) }}

+
+
+

解锁记录

+

{{ number_format($summary['unlocked_records']) }}

+
+
+

解锁用户

+

{{ number_format($summary['unlocked_users']) }}

+
+
+ +
+
+
+
+ + + + 重置 +
+
+ +
+ + + + + + + + + + + @forelse ($records as $record) + @php + $definition = $definitions[$record->achievement_key] ?? null; + $threshold = (int) data_get($record->metadata, 'threshold', $definition['threshold'] ?? 0); + @endphp + + + + + + + @empty + + + + @endforelse + +
用户成就进度解锁时间
+ {{ $record->user?->username ?? '未知用户' }} + +
+ {{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $record->achievement_key }} +
+
{{ $definition['description'] ?? '' }}
+
+ {{ number_format($record->progress_value) }} / {{ number_format($threshold) }} + + {{ $record->achieved_at?->format('Y-m-d H:i') }} +
暂无解锁记录
+
+ +
+ {{ $records->links() }} +
+
+ +
+

热门成就

+
+ @forelse ($topAchievements as $row) + @php $definition = $definitions[$row->achievement_key] ?? null; @endphp +
+
+

+ {{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $row->achievement_key }} +

+

{{ $definition['description'] ?? '' }}

+
+ + {{ number_format($row->unlocked_count) }} + +
+ @empty +

暂无热门成就。

+ @endforelse +
+
+
+
+@endsection diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index a364494..a4e9e71 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -68,6 +68,10 @@ class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> {!! '💴 用户流水' !!} + + 🏅 成就记录 + {!! '🏠 房间管理' !!} diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 5ead63f..80b4593 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -188,7 +188,7 @@ $welcomeMessages = [
常用操作
+ style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 本地清屏