新增每日签到与补签卡功能

This commit is contained in:
2026-04-24 22:47:27 +08:00
parent 34356a26ae
commit be9fc09d9d
46 changed files with 3934 additions and 55 deletions
+416
View File
@@ -0,0 +1,416 @@
<?php
/**
* 文件功能:每日签到前台接口测试
*
* 覆盖签到领取、重复拦截、连续天数、身份有效期与聊天室播报。
*/
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Models\DailySignIn;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\SignInRewardRule;
use App\Models\User;
use App\Models\UserPurchase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* 类功能:验证每日签到前台接口的核心行为。
*/
class DailySignInControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:每个用例前清理 Redis 与时间,避免跨测试污染。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
Carbon::setTestNow('2026-04-24 10:00:00');
}
/**
* 方法功能:每个用例后恢复系统时间。
*/
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
/**
* 测试首次签到会发放奖励、写入流水并生成身份。
*/
public function test_user_can_claim_daily_sign_in_reward(): void
{
$user = User::factory()->create(['jjb' => 10, 'exp_num' => 0, 'meili' => 0]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
'streak_days' => 1,
'gold_reward' => 100,
'exp_reward' => 20,
'charm_reward' => 3,
'identity_badge_code' => 'sign_1',
'identity_badge_name' => '签到新星',
'identity_badge_icon' => '⭐',
'identity_badge_color' => '#0f766e',
'identity_duration_days' => 7,
]);
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'));
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.sign_in.streak_days', 1)
->assertJsonPath('data.user.jjb', 110)
->assertJsonPath('data.identity.label', '签到新星');
$this->assertDatabaseHas('daily_sign_ins', [
'user_id' => $user->id,
'sign_in_date' => '2026-04-24',
'streak_days' => 1,
'gold_reward' => 100,
'exp_reward' => 20,
'charm_reward' => 3,
]);
$this->assertDatabaseHas('user_currency_logs', [
'user_id' => $user->id,
'currency' => 'gold',
'amount' => 100,
'source' => CurrencySource::SIGN_IN->value,
]);
$this->assertDatabaseHas('user_identity_badges', [
'user_id' => $user->id,
'source' => 'sign_in',
'badge_code' => 'sign_1',
'is_active' => true,
]);
}
/**
* 测试同一天重复签到不会重复发放奖励。
*/
public function test_duplicate_daily_sign_in_is_rejected_without_second_reward(): void
{
$user = User::factory()->create(['jjb' => 0]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
'streak_days' => 1,
'gold_reward' => 50,
]);
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
$currencyLogCount = \App\Models\UserCurrencyLog::query()
->where('user_id', $user->id)
->where('source', CurrencySource::SIGN_IN->value)
->count();
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'));
$response->assertStatus(422)
->assertJsonPath('status', 'error')
->assertJsonPath('message', '今日已签到,请明天再来。');
$this->assertSame(1, DailySignIn::query()->where('user_id', $user->id)->count());
$this->assertSame($currencyLogCount, \App\Models\UserCurrencyLog::query()->where('user_id', $user->id)->where('source', CurrencySource::SIGN_IN->value)->count());
$this->assertSame(50, (int) $user->fresh()->jjb);
}
/**
* 测试连续签到会递增,断签后会重新从 1 开始。
*/
public function test_streak_increments_and_resets_after_gap(): void
{
$user = User::factory()->create();
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], ['streak_days' => 2, 'gold_reward' => 2]);
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1);
Carbon::setTestNow('2026-04-25 10:00:00');
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 2);
Carbon::setTestNow('2026-04-27 10:00:00');
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1);
}
/**
* 测试连续签到满一年后下一天会重新从 1 计算。
*/
public function test_streak_resets_after_yearly_cycle_is_completed(): void
{
$user = User::factory()->create();
DailySignIn::factory()->create([
'user_id' => $user->id,
'sign_in_date' => '2026-04-23',
'streak_days' => 365,
]);
$this->actingAs($user)
->postJson(route('daily-sign-in.claim'))
->assertOk()
->assertJsonPath('data.sign_in.streak_days', 1);
}
/**
* 测试闰年需要满 366 天后才重新计算。
*/
public function test_leap_year_streak_resets_after_366_days(): void
{
$user = User::factory()->create();
Carbon::setTestNow('2024-12-31 10:00:00');
DailySignIn::factory()->create([
'user_id' => $user->id,
'sign_in_date' => '2024-12-30',
'streak_days' => 365,
]);
$this->actingAs($user)
->postJson(route('daily-sign-in.claim'))
->assertOk()
->assertJsonPath('data.sign_in.streak_days', 366);
Carbon::setTestNow('2025-01-01 10:00:00');
$this->actingAs($user)
->postJson(route('daily-sign-in.claim'))
->assertOk()
->assertJsonPath('data.sign_in.streak_days', 1);
}
/**
* 测试身份有效期过后不会继续出现在状态接口。
*/
public function test_expired_identity_is_not_returned_in_status(): void
{
$user = User::factory()->create();
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
'streak_days' => 1,
'identity_badge_code' => 'short',
'identity_badge_name' => '短期身份',
'identity_badge_icon' => '⏳',
'identity_duration_days' => 1,
]);
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
Carbon::setTestNow('2026-04-26 10:00:00');
$this->actingAs($user)
->getJson(route('daily-sign-in.status'))
->assertOk()
->assertJsonPath('data.identity', null);
}
/**
* 测试当前房间签到会写入聊天室通知并附带快速签到按钮。
*/
public function test_claim_with_room_broadcasts_chat_notice_with_quick_button(): void
{
Event::fake([MessageSent::class, UserStatusUpdated::class]);
$room = Room::create(['room_name' => 'testroom', 'door_open' => true]);
$user = User::factory()->create(['jjb' => 0]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
'streak_days' => 1,
'gold_reward' => 100,
'exp_reward' => 20,
]);
Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
Redis::setex("room:{$room->id}:alive:{$user->username}", 90, 1);
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'), [
'room_id' => $room->id,
]);
$response->assertOk();
$rawMessage = Redis::lindex("room:{$room->id}:messages", -1);
$message = json_decode((string) $rawMessage, true);
$this->assertSame('签到播报', $message['from_user']);
$this->assertStringContainsString('快速签到', $message['content']);
$this->assertStringContainsString('quickDailySignIn', $message['content']);
Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($room): bool {
return $event->roomId === $room->id
&& ($event->message['from_user'] ?? null) === '签到播报';
});
}
/**
* 测试不携带房间 ID 时不会发送聊天室通知。
*/
public function test_claim_without_room_does_not_write_chat_notice(): void
{
Event::fake([MessageSent::class]);
$user = User::factory()->create();
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]);
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
$this->assertSame([], Redis::keys('room:*:messages'));
Event::assertNotDispatched(MessageSent::class);
}
/**
* 测试签到日历会展示已签、漏签和补签卡数量。
*/
public function test_calendar_returns_month_days_and_makeup_card_count(): void
{
$user = User::factory()->create();
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
UserPurchase::query()->create([
'user_id' => $user->id,
'shop_item_id' => $card->id,
'status' => 'active',
'price_paid' => 1200,
]);
DailySignIn::factory()->create([
'user_id' => $user->id,
'sign_in_date' => '2026-04-23',
'streak_days' => 1,
]);
$response = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-04']));
$response->assertOk()
->assertJsonPath('data.month', '2026-04')
->assertJsonPath('data.makeup_card_count', 1)
->assertJsonPath('data.sign_repair_card_item.slug', 'sign_repair_card')
->assertJsonPath('data.sign_repair_card_item.price', 10000)
->assertJsonPath('data.reward_rules.0.streak_days', 1);
$days = collect($response->json('data.days'));
$this->assertTrue((bool) $days->firstWhere('date', '2026-04-23')['signed']);
$this->assertTrue((bool) $days->firstWhere('date', '2026-04-22')['can_makeup']);
$previousMonthResponse = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-03']));
$previousMonthDays = collect($previousMonthResponse->json('data.days'));
$this->assertFalse((bool) $previousMonthDays->firstWhere('date', '2026-03-31')['can_makeup']);
}
/**
* 测试用户可以消耗补签卡补签历史漏签日期。
*/
public function test_user_can_makeup_missed_day_with_sign_repair_card(): void
{
$user = User::factory()->create(['jjb' => 0]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
'streak_days' => 1,
'gold_reward' => 10,
]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], [
'streak_days' => 2,
'gold_reward' => 20,
]);
SignInRewardRule::query()->updateOrCreate(['streak_days' => 3], [
'streak_days' => 3,
'gold_reward' => 30,
]);
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$purchase = UserPurchase::query()->create([
'user_id' => $user->id,
'shop_item_id' => $card->id,
'status' => 'active',
'price_paid' => 1200,
]);
DailySignIn::factory()->create([
'user_id' => $user->id,
'sign_in_date' => '2026-04-22',
'streak_days' => 1,
]);
DailySignIn::factory()->create([
'user_id' => $user->id,
'sign_in_date' => '2026-04-24',
'streak_days' => 1,
]);
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
'target_date' => '2026-04-23',
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.sign_in.is_makeup', true)
->assertJsonPath('data.sign_in.streak_days', 2)
->assertJsonPath('data.current_streak_days', 3);
$this->assertStringContainsString('当前连续签到 3 天', (string) $response->json('message'));
$this->assertDatabaseHas('daily_sign_ins', [
'user_id' => $user->id,
'sign_in_date' => '2026-04-23',
'is_makeup' => true,
'makeup_purchase_id' => $purchase->id,
'streak_days' => 2,
'gold_reward' => 30,
]);
$this->assertDatabaseHas('daily_sign_ins', [
'user_id' => $user->id,
'sign_in_date' => '2026-04-24',
'streak_days' => 3,
]);
$this->assertSame('used', $purchase->fresh()->status);
$this->assertSame(30, (int) $user->fresh()->jjb);
$this->assertDatabaseHas('user_currency_logs', [
'user_id' => $user->id,
'currency' => 'gold',
'amount' => 30,
'source' => CurrencySource::SIGN_IN->value,
'remark' => '补签 2026-04-23:当前连续签到 3 天',
]);
}
/**
* 测试没有补签卡时不能补签。
*/
public function test_makeup_requires_available_sign_repair_card(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
'target_date' => '2026-04-23',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('target_date');
}
/**
* 测试补签卡不能补签本月之外的漏签日期。
*/
public function test_makeup_only_allows_current_month_missed_days(): void
{
$user = User::factory()->create();
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$purchase = UserPurchase::query()->create([
'user_id' => $user->id,
'shop_item_id' => $card->id,
'status' => 'active',
'price_paid' => 1200,
]);
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
'target_date' => '2026-03-31',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('target_date')
->assertJsonPath('errors.target_date.0', '补签卡只能补签本月的未签到日期。');
$this->assertSame('active', $purchase->fresh()->status);
$this->assertDatabaseMissing('daily_sign_ins', [
'user_id' => $user->id,
'sign_in_date' => '2026-03-31',
]);
}
}
@@ -0,0 +1,115 @@
<?php
/**
* 文件功能:后台签到奖励规则管理测试
*
* 覆盖后台连续签到奖励档位的创建、更新、启停与校验。
*/
namespace Tests\Feature\Feature;
use App\Models\SignInRewardRule;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台签到奖励规则管理页行为。
*/
class AdminSignInRewardRuleControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 测试管理员可以创建签到奖励规则。
*/
public function test_admin_can_create_sign_in_reward_rule(): void
{
$admin = $this->createSuperAdmin();
$response = $this->actingAs($admin)->post(route('admin.sign-in-rules.store'), [
'streak_days' => 11,
'gold_reward' => 700,
'exp_reward' => 70,
'charm_reward' => 7,
'identity_badge_code' => 'sign_11',
'identity_badge_name' => '十一日星辉',
'identity_badge_icon' => '🔥',
'identity_badge_color' => '#0f766e',
'identity_duration_days' => 30,
'sort_order' => 11,
'is_enabled' => '1',
]);
$response->assertRedirect(route('admin.sign-in-rules.index'));
$this->assertDatabaseHas('sign_in_reward_rules', [
'streak_days' => 11,
'gold_reward' => 700,
'identity_badge_code' => 'sign_11',
'identity_duration_days' => 30,
'is_enabled' => true,
]);
}
/**
* 测试管理员可以更新并停用签到奖励规则。
*/
public function test_admin_can_update_and_toggle_sign_in_reward_rule(): void
{
$admin = $this->createSuperAdmin();
$rule = SignInRewardRule::query()->where('streak_days', 3)->firstOrFail();
$rule->update(['gold_reward' => 100, 'is_enabled' => true]);
$this->actingAs($admin)->put(route('admin.sign-in-rules.update', $rule), [
'streak_days' => 5,
'gold_reward' => 500,
'exp_reward' => 50,
'charm_reward' => 5,
'identity_badge_code' => 'sign_5',
'identity_badge_name' => '五日之星',
'identity_badge_icon' => '⭐',
'identity_badge_color' => '#4338ca',
'identity_duration_days' => 10,
'sort_order' => 5,
'is_enabled' => '1',
])->assertRedirect(route('admin.sign-in-rules.index'));
$this->assertDatabaseHas('sign_in_reward_rules', [
'id' => $rule->id,
'streak_days' => 5,
'gold_reward' => 500,
'identity_badge_name' => '五日之星',
]);
$this->actingAs($admin)
->postJson(route('admin.sign-in-rules.toggle', $rule))
->assertOk()
->assertJsonPath('is_enabled', false);
}
/**
* 测试重复连续天数会被校验拦截。
*/
public function test_duplicate_streak_days_are_rejected(): void
{
$admin = $this->createSuperAdmin();
$this->actingAs($admin)->post(route('admin.sign-in-rules.store'), [
'streak_days' => 7,
'gold_reward' => 100,
'exp_reward' => 10,
'charm_reward' => 0,
'identity_duration_days' => 0,
'sort_order' => 0,
])->assertSessionHasErrors('streak_days');
}
/**
* 创建可访问后台的超级管理员账号。
*/
private function createSuperAdmin(): User
{
return User::factory()->create([
'user_level' => 100,
]);
}
}
+58
View File
@@ -300,4 +300,62 @@ class ShopControllerTest extends TestCase
&& $event->broadcastWith()['operator'] === $buyer->username;
});
}
/**
* 测试购买签到补签卡会扣金币并生成可用背包记录。
*/
public function test_buy_sign_repair_card_creates_active_purchase(): void
{
$user = User::factory()->create(['jjb' => 12000]);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([
'price' => 10000,
'is_active' => true,
]);
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('jjb', 2000);
$this->assertDatabaseHas('user_purchases', [
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => 10000,
]);
}
/**
* 测试补签卡支持一次购买多张。
*/
public function test_buy_sign_repair_card_supports_quantity(): void
{
$user = User::factory()->create(['jjb' => 35000]);
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
$item->update([
'price' => 10000,
'is_active' => true,
]);
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
'item_id' => $item->id,
'quantity' => 3,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('quantity', 3)
->assertJsonPath('total_price', 30000)
->assertJsonPath('jjb', 5000);
$this->assertSame(3, UserPurchase::query()
->where('user_id', $user->id)
->where('shop_item_id', $item->id)
->where('status', 'active')
->count());
}
}