新增聊天室成就系统与消息保留策略

This commit is contained in:
pllx
2026-04-30 16:19:49 +08:00
parent 92e3dd0cdf
commit f354516869
26 changed files with 1966 additions and 14 deletions
+33 -13
View File
@@ -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']);
});
});
});
}
/**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/
@@ -0,0 +1,98 @@
<?php
/**
* 文件功能:扫描并补算用户成就的 Artisan 命令。
*
* 支持单用户、全量与最近活跃用户三种扫描方式,便于定时任务和后台补算复用。
*/
namespace App\Console\Commands;
use App\Models\User;
use App\Services\AchievementService;
use Illuminate\Console\Command;
/**
* 类功能:通过命令行批量检查用户成就进度并写入解锁记录。
*/
class ScanAchievementsCommand extends Command
{
/**
* 命令签名。
*
* @var string
*/
protected $signature = 'achievements:scan
{--user= : 指定用户 ID 或用户名}
{--all : 扫描全部用户}
{--notify : 解锁时向用户推送本人可见通知}
{--dry-run : 仅预览,不写入成就记录}';
/**
* 命令描述。
*
* @var string
*/
protected $description = '扫描聊天室用户成就进度并补齐解锁记录';
/**
* 创建命令依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {
parent::__construct();
}
/**
* 执行成就扫描命令。
*/
public function handle(): int
{
$notify = (bool) $this->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();
}
}
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:前台用户成就展示控制器。
*
* 展示当前登录用户的成就分类、解锁状态和进度。
*/
namespace App\Http\Controllers;
use App\Services\AchievementService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 类功能:提供“我的成就”页面数据。
*/
class AchievementController extends Controller
{
/**
* 创建成就控制器依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {}
/**
* 展示当前登录用户的成就总览。
*/
public function index(Request $request): View
{
$user = Auth::user();
$this->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,
]);
}
}
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:后台成就记录查询控制器。
*
* 提供固定成就目录的解锁统计与用户成就记录只读查询。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserAchievement;
use App\Support\AchievementCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* 类功能:展示后台成就总览、解锁记录与按成就分组统计。
*/
class AchievementController extends Controller
{
/**
* 展示成就记录总览。
*/
public function index(Request $request): View
{
$definitions = AchievementCatalog::definitions();
$query = UserAchievement::query()
->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'));
}
}
+10
View File
@@ -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
+1
View File
@@ -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']),
]);
+115
View File
@@ -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<int, string>
*/
public static function purgableRetentionTypes(): array
{
return [
self::RETENTION_GAME_NOTICE,
self::RETENTION_EPHEMERAL_NOTICE,
];
}
/**
* 根据广播消息载荷推断数据库保留类型。
*
* @param array<string, mixed> $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<string, mixed> $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',
];
+16
View File
@@ -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');
}
/**
* 关联:用户全部身份徽章。
*/
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:用户成就解锁记录模型。
*
* 保存每个用户在固定成就目录中的进度快照、达成时间与通知状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就记录字段、类型转换与用户关联。
*/
class UserAchievement extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'achieved_at',
'notified_at',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
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);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:用户成就进度模型。
*
* 保存用户在每个固定成就上的最新进度快照,解锁状态由 user_achievements 单独记录。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就进度字段、类型转换与用户关联。
*/
class UserAchievementProgress extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementProgressFactory> */
use HasFactory;
/**
* 对应的数据表名。
*
* @var string
*/
protected $table = 'user_achievement_progress';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'threshold_value',
'last_scanned_at',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'progress_value' => 'integer',
'threshold_value' => 'integer',
'last_scanned_at' => 'datetime',
];
}
/**
* 关联:进度记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* 文件功能:用户成就扫描与授予服务。
*
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\BaccaratBet;
use App\Models\DailySignIn;
use App\Models\GomokuGame;
use App\Models\HorseBet;
use App\Models\LotteryTicket;
use App\Models\Marriage;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\PositionDutyLog;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\SlotMachineLog;
use App\Models\User;
use App\Models\UserAchievement;
use App\Models\UserAchievementProgress;
use App\Models\UserCurrencyLog;
use App\Models\UserPosition;
use App\Support\AchievementCatalog;
use Illuminate\Support\Collection;
/**
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
*/
class AchievementService
{
/**
* 创建成就服务依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 扫描单个用户的所有固定成就。
*
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
*/
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
{
$progress = $this->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<string, string>, achievements: Collection<int, array<string, mixed>>, 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<int, array<string, mixed>>
*/
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<string, int>
*/
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<int, string>
*/
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<int, string>
*/
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<string, mixed> $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<string, mixed> $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']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
'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();
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* 文件功能:聊天室固定成就目录。
*
* 第一版成就规则全部写在代码里,避免过早引入后台规则引擎。
*/
namespace App\Support;
/**
* 类功能:集中提供成就定义、分类与展示文案。
*/
class AchievementCatalog
{
/**
* 返回全部成就定义。
*
* @return array<string, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}>
*/
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<int, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int}>
*/
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<string, string>
*/
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;
}
}