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

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
+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]);
}
/**
* 测试心跳接口可以正常返回成功响应。
*/