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

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