新增聊天室成就系统与消息保留策略
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户全部身份徽章。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户成就测试工厂。
|
||||
*
|
||||
* 为成就相关 Feature Test 快速生成解锁或进度记录。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievement;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 类功能:生成用户成就模型的默认测试数据。
|
||||
*
|
||||
* @extends Factory<UserAchievement>
|
||||
*/
|
||||
class UserAchievementFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义模型的默认测试状态。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'achievement_key' => 'chat_first_message',
|
||||
'progress_value' => 1,
|
||||
'metadata' => ['threshold' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户成就进度测试工厂。
|
||||
*
|
||||
* 为成就进度相关测试生成默认记录。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievementProgress;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* 类功能:生成用户成就进度模型的默认测试数据。
|
||||
*
|
||||
* @extends Factory<UserAchievementProgress>
|
||||
*/
|
||||
class UserAchievementProgressFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义模型的默认测试状态。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'achievement_key' => 'chat_first_message',
|
||||
'progress_value' => 0,
|
||||
'threshold_value' => 1,
|
||||
'last_scanned_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 执行迁移:给聊天消息增加保留类型字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 执行迁移:创建用户成就解锁记录表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_achievements', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 执行迁移:创建用户成就进度快照表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_achievement_progress', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
{{--
|
||||
文件功能:我的成就页面
|
||||
按分类展示当前用户的固定成就解锁状态与进度。
|
||||
--}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '我的成就 - 飘落流星')
|
||||
|
||||
@section('nav-icon', '🏅')
|
||||
@section('nav-title', '我的成就')
|
||||
|
||||
@section('content')
|
||||
<main class="p-4 sm:p-6 lg:p-8">
|
||||
<div class="max-w-7xl mx-auto flex flex-col gap-6">
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ $user->username }} 的成就档案</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">已解锁 {{ $unlocked_count }} / {{ $total_count }} 项</p>
|
||||
</div>
|
||||
<div class="w-full sm:w-64 bg-gray-100 rounded-full h-3 overflow-hidden">
|
||||
<div class="h-3 bg-amber-500"
|
||||
style="width: {{ $total_count > 0 ? min(100, floor($unlocked_count / $total_count * 100)) : 0 }}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="bg-white border border-gray-200 rounded-lg shadow-sm p-1 flex flex-col sm:flex-row gap-1"
|
||||
aria-label="成就筛选">
|
||||
@foreach ($achievement_tabs as $tabKey => $tab)
|
||||
<a href="{{ $tab['url'] }}"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-semibold {{ $active_tab === $tabKey ? 'bg-gray-900 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' }}"
|
||||
aria-current="{{ $active_tab === $tabKey ? 'page' : 'false' }}">
|
||||
<span>{{ $tab['label'] }}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {{ $active_tab === $tabKey ? 'bg-white/15 text-white' : 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $tab['count'] }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@foreach ($categories as $categoryKey => $categoryLabel)
|
||||
@php
|
||||
$items = $achievements->where('category', $categoryKey)->values();
|
||||
@endphp
|
||||
|
||||
@if ($items->isEmpty())
|
||||
@continue
|
||||
@endif
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-800">{{ $categoryLabel }}成就</h3>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ $items->where('unlocked', true)->count() }} / {{ $items->count() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
@foreach ($items as $achievement)
|
||||
<article
|
||||
class="bg-white border {{ $achievement['unlocked'] ? 'border-amber-200' : 'border-gray-200' }} rounded-lg p-4 shadow-sm flex gap-3">
|
||||
<div
|
||||
class="w-11 h-11 rounded-lg flex items-center justify-center text-2xl shrink-0 {{ $achievement['unlocked'] ? 'bg-amber-100' : 'bg-gray-100 grayscale' }}">
|
||||
{{ $achievement['icon'] }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="font-bold text-gray-900 truncate">{{ $achievement['name'] }}</h4>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded {{ $achievement['unlocked'] ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $achievement['unlocked'] ? '已解锁' : '进行中' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $achievement['description'] }}</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-2 {{ $achievement['unlocked'] ? 'bg-emerald-500' : 'bg-indigo-500' }}"
|
||||
style="width: {{ $achievement['progress_percent'] }}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">
|
||||
{{ number_format($achievement['progress_value']) }} /
|
||||
{{ number_format($achievement['threshold']) }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($achievement['achieved_at'])
|
||||
<p class="text-xs text-amber-700 mt-2">
|
||||
解锁于 {{ $achievement['achieved_at']->format('Y-m-d H:i') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
@if ($achievements->isEmpty())
|
||||
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-8 text-center">
|
||||
<h3 class="text-base font-bold text-gray-800">暂无对应成就</h3>
|
||||
<p class="text-sm text-gray-500 mt-2">切换其他筛选查看成就列表。</p>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
@endsection
|
||||
@@ -0,0 +1,118 @@
|
||||
{{--
|
||||
文件功能:后台成就记录页面
|
||||
提供固定成就目录、解锁统计与用户成就记录只读查询。
|
||||
--}}
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '成就记录')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<p class="text-sm text-slate-500">固定成就</p>
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">{{ number_format($summary['total_definitions']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<p class="text-sm text-slate-500">解锁记录</p>
|
||||
<p class="mt-2 text-2xl font-bold text-amber-600">{{ number_format($summary['unlocked_records']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<p class="text-sm text-slate-500">解锁用户</p>
|
||||
<p class="mt-2 text-2xl font-bold text-emerald-600">{{ number_format($summary['unlocked_users']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<section class="xl:col-span-2 rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div class="border-b border-slate-200 p-4">
|
||||
<form method="GET" class="flex flex-col md:flex-row gap-3">
|
||||
<input type="text" name="username" value="{{ request('username') }}" placeholder="用户名"
|
||||
class="w-full md:w-56 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
|
||||
<select name="achievement_key"
|
||||
class="w-full md:w-64 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
|
||||
<option value="">全部成就</option>
|
||||
@foreach ($definitions as $key => $definition)
|
||||
<option value="{{ $key }}" @selected(request('achievement_key') === $key)>
|
||||
{{ $definition['icon'] }} {{ $definition['name'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">筛选</button>
|
||||
<a href="{{ route('admin.achievements.index') }}"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-50">重置</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">用户</th>
|
||||
<th class="px-4 py-3">成就</th>
|
||||
<th class="px-4 py-3">进度</th>
|
||||
<th class="px-4 py-3">解锁时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse ($records as $record)
|
||||
@php
|
||||
$definition = $definitions[$record->achievement_key] ?? null;
|
||||
$threshold = (int) data_get($record->metadata, 'threshold', $definition['threshold'] ?? 0);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-semibold text-slate-900">
|
||||
{{ $record->user?->username ?? '未知用户' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-bold text-slate-800">
|
||||
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $record->achievement_key }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ number_format($record->progress_value) }} / {{ number_format($threshold) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-500">
|
||||
{{ $record->achieved_at?->format('Y-m-d H:i') }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-10 text-center text-slate-500">暂无解锁记录</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 p-4">
|
||||
{{ $records->links() }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 class="text-base font-bold text-slate-900">热门成就</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
@forelse ($topAchievements as $row)
|
||||
@php $definition = $definitions[$row->achievement_key] ?? null; @endphp
|
||||
<div class="flex items-center justify-between gap-3 rounded-md bg-slate-50 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">
|
||||
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $row->achievement_key }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
|
||||
{{ number_format($row->unlocked_count) }}
|
||||
</span>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500">暂无热门成就。</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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' }}">
|
||||
{!! '💴 用户流水' !!}
|
||||
</a>
|
||||
<a href="{{ route('admin.achievements.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.achievements.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🏅 成就记录
|
||||
</a>
|
||||
<a href="{{ route('admin.rooms.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '🏠 房间管理' !!}
|
||||
|
||||
@@ -188,7 +188,7 @@ $welcomeMessages = [
|
||||
<div style="font-size:10px;color:#4338ca;padding:0 2px 8px;">常用操作</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
|
||||
<button type="button" data-chat-feature-local-clear
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 清屏</button>
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 本地清屏</button>
|
||||
<button type="button" data-chat-daily-status-open
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#4f46e5;border:1px solid #a5b4fc;border-radius:6px;cursor:pointer;">
|
||||
<span id="daily-status-shortcut-icon">{{ $activeDailyStatus['icon'] ?? '🙂' }}</span>
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex">
|
||||
今日榜
|
||||
</a>
|
||||
<a href="{{ route('achievements.index') }}"
|
||||
class="text-amber-300 hover:text-amber-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('achievements.*') ? 'text-amber-100 underline underline-offset-4' : '' }}">
|
||||
成就
|
||||
</a>
|
||||
<a href="{{ route('duty-hall.index') }}"
|
||||
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
|
||||
勤务台
|
||||
@@ -184,6 +188,10 @@
|
||||
class="px-4 py-2.5 text-green-300 hover:bg-indigo-700 hover:text-green-200 font-medium border-l-4 {{ request()->routeIs('leaderboard.today') ? 'border-green-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||
今日榜
|
||||
</a>
|
||||
<a href="{{ route('achievements.index') }}"
|
||||
class="px-4 py-2.5 text-amber-200 hover:bg-indigo-700 hover:text-amber-100 font-medium border-l-4 {{ request()->routeIs('achievements.*') ? 'border-amber-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||
成就
|
||||
</a>
|
||||
<a href="{{ route('duty-hall.index') }}"
|
||||
class="px-4 py-2.5 text-purple-200 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('duty-hall.*') ? 'border-purple-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||
勤务台
|
||||
|
||||
@@ -14,6 +14,10 @@ Schedule::command('messages:purge')->dailyAt('03:00');
|
||||
// 每 5 分钟为所有在线用户自动存点(经验/金币/等级)
|
||||
Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes();
|
||||
|
||||
// 每 10 分钟扫描最近活跃用户的成就进度,夜间再做一次全量补算
|
||||
Schedule::command('achievements:scan')->everyTenMinutes()->withoutOverlapping();
|
||||
Schedule::command('achievements:scan --all')->dailyAt('03:30')->withoutOverlapping();
|
||||
|
||||
// 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件
|
||||
Schedule::command('chatroom:ai-heartbeat')->everyMinute();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AchievementController;
|
||||
use App\Http\Controllers\Admin\AdminAuthController;
|
||||
use App\Http\Controllers\AdminCommandController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
@@ -78,6 +79,7 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
|
||||
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today');
|
||||
Route::get('/achievements', [AchievementController::class, 'index'])->name('achievements.index');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 邀请排行达人榜
|
||||
@@ -512,6 +514,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
||||
|
||||
// 全局用户金币/积分流水
|
||||
Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index');
|
||||
Route::get('/achievements', [\App\Http\Controllers\Admin\AchievementController::class, 'index'])->name('achievements.index');
|
||||
|
||||
// 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1)
|
||||
Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index');
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户成就系统功能测试。
|
||||
*
|
||||
* 覆盖固定成就扫描、重复扫描幂等性与本人可见通知。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\Message;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证成就扫描服务和 Artisan 命令的核心行为。
|
||||
*/
|
||||
class AchievementServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 每个测试前清空 Redis 房间状态。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->flushChatRoomRedisState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试命令可以按现有日志解锁聊天、签到与游戏成就。
|
||||
*/
|
||||
public function test_scan_command_unlocks_achievements_from_existing_logs(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'ach']);
|
||||
$user = User::factory()->create([
|
||||
'username' => 'achiever',
|
||||
'room_id' => $room->id,
|
||||
'jjb' => 700000,
|
||||
'bank_jjb' => 300000,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '成就聊天',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subMinutes($i + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
DailySignIn::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'room_id' => $room->id,
|
||||
'sign_in_date' => today(),
|
||||
'streak_days' => 7,
|
||||
'gold_reward' => 10,
|
||||
'exp_reward' => 20,
|
||||
'charm_reward' => 0,
|
||||
]);
|
||||
|
||||
UserCurrencyLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'currency' => 'gold',
|
||||
'amount' => 1000,
|
||||
'balance_after' => 1000,
|
||||
'source' => CurrencySource::GAME_REWARD->value,
|
||||
'remark' => '猜谜奖励',
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
UserCurrencyLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'currency' => 'gold',
|
||||
'amount' => -1000,
|
||||
'balance_after' => 0,
|
||||
'source' => CurrencySource::BACCARAT_BET->value,
|
||||
'remark' => '百家乐下注',
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$this->artisan('achievements:scan', ['--user' => $user->username])
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_100_messages',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'signin_7_streak',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_riddle_win',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_win_1000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'game_loss_1000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'growth_assets_1000000',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_achievement_progress', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_100_messages',
|
||||
'progress_value' => 100,
|
||||
'threshold_value' => 100,
|
||||
]);
|
||||
|
||||
$this->assertNotNull(UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_100_messages')
|
||||
->value('achieved_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试重复扫描不会重复创建同一个用户成就。
|
||||
*/
|
||||
public function test_scan_command_is_idempotent_for_same_achievement(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'idem']);
|
||||
$user = User::factory()->create(['username' => 'idem_user', 'room_id' => $room->id]);
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '第一条',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||
|
||||
$this->assertSame(1, UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_first_message')
|
||||
->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试成就解锁通知只发给本人并带悄悄话标记。
|
||||
*/
|
||||
public function test_unlock_notification_is_private_to_the_user(): void
|
||||
{
|
||||
Queue::fake([SaveMessageJob::class]);
|
||||
|
||||
$room = Room::create(['room_name' => 'notice']);
|
||||
$user = User::factory()->create(['username' => 'notice_user', 'room_id' => $room->id]);
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '第一条',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
app(AchievementService::class)->scanUser($user, notify: true);
|
||||
|
||||
$messages = collect(Redis::lrange("room:{$room->id}:messages", 0, -1))
|
||||
->map(fn (string $item): array => json_decode($item, true));
|
||||
$notice = $messages->first(fn (array $item): bool => ($item['action'] ?? '') === 'achievement_unlocked');
|
||||
|
||||
$this->assertNotNull($notice);
|
||||
$this->assertSame($user->username, $notice['to_user'] ?? null);
|
||||
$this->assertTrue((bool) ($notice['is_secret'] ?? false));
|
||||
$this->assertSame(Message::RETENTION_SYSTEM_NOTICE, $notice['retention_type'] ?? null);
|
||||
Queue::assertPushed(SaveMessageJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试打开我的成就页面时会静默补算已达标成就。
|
||||
*/
|
||||
public function test_achievement_page_silently_unlocks_reached_achievements(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'page']);
|
||||
$user = User::factory()->create(['username' => 'page_user', 'room_id' => $room->id]);
|
||||
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '页面触发补算',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index'))
|
||||
->assertOk()
|
||||
->assertSee('已解锁 1 /', false);
|
||||
|
||||
$this->assertDatabaseHas('user_achievements', [
|
||||
'user_id' => $user->id,
|
||||
'achievement_key' => 'chat_first_message',
|
||||
]);
|
||||
|
||||
$this->assertNotNull(UserAchievement::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('achievement_key', 'chat_first_message')
|
||||
->value('achieved_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试我的成就页面可以按已完成和未达成筛选。
|
||||
*/
|
||||
public function test_achievement_page_can_filter_by_unlocked_and_locked_tabs(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'tabs']);
|
||||
$user = User::factory()->create(['username' => 'tab_user', 'room_id' => $room->id]);
|
||||
|
||||
Message::query()->create([
|
||||
'room_id' => $room->id,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => '大家',
|
||||
'content' => '筛选测试',
|
||||
'is_secret' => false,
|
||||
'font_color' => '',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index', ['status' => 'unlocked']))
|
||||
->assertOk()
|
||||
->assertSee('已完成')
|
||||
->assertSee('初来乍到')
|
||||
->assertDontSee('百句达人');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('achievements.index', ['status' => 'locked']))
|
||||
->assertOk()
|
||||
->assertSee('未达成')
|
||||
->assertSee('百句达人')
|
||||
->assertDontSee('初来乍到');
|
||||
}
|
||||
}
|
||||
@@ -854,6 +854,84 @@ class ChatControllerTest extends TestCase
|
||||
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息清理命令会永久保留普通用户聊天,只删除过期游戏/临时通知。
|
||||
*/
|
||||
public function test_purge_command_keeps_user_chat_but_deletes_expired_notices(): void
|
||||
{
|
||||
$userMessage = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => 'normal_user',
|
||||
'to_user' => '大家',
|
||||
'content' => '这条用户聊天需要永久保留',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
$gameNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => '过期游戏通知',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_GAME_NOTICE,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
$temporaryNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => '过期进出播报',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => 'system_welcome',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_EPHEMERAL_NOTICE,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('messages:purge', [
|
||||
'--days' => 30,
|
||||
'--image-days' => 3,
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('messages', ['id' => $userMessage->id]);
|
||||
$this->assertDatabaseMissing('messages', ['id' => $gameNotice->id]);
|
||||
$this->assertDatabaseMissing('messages', ['id' => $temporaryNotice->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试迁移前默认归为 user_chat 的旧游戏通知也会被清理。
|
||||
*/
|
||||
public function test_purge_command_deletes_legacy_game_notice_patterns(): void
|
||||
{
|
||||
$legacyNotice = \App\Models\Message::create([
|
||||
'room_id' => 1,
|
||||
'from_user' => '钓鱼播报',
|
||||
'to_user' => '大家',
|
||||
'content' => '旧钓鱼通知',
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => 'fishing_result',
|
||||
'message_type' => 'text',
|
||||
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||
'sent_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('messages:purge', [
|
||||
'--days' => 30,
|
||||
'--image-days' => 3,
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('messages', ['id' => $legacyNotice->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试心跳接口可以正常返回成功响应。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user