Files
chatroom/app/Services/SignInService.php
T

522 lines
19 KiB
PHP

<?php
/**
* 文件功能:每日签到服务。
*
* 负责查询签到状态、领取每日签到奖励、防止重复领取、刷新签到身份徽章。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\DailySignIn;
use App\Models\ShopItem;
use App\Models\SignInRewardRule;
use App\Models\User;
use App\Models\UserIdentityBadge;
use App\Models\UserPurchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 类功能:封装每日签到状态查询与领取奖励的核心业务流程。
*/
class SignInService
{
/**
* 构造每日签到服务。
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 查询用户当前签到状态与今日可领取预览。
*
* @return array{
* signed_today: bool,
* can_claim: bool,
* current_streak_days: int,
* claimable_streak_days: int,
* today_sign_in: DailySignIn|null,
* latest_sign_in: DailySignIn|null,
* matched_rule: SignInRewardRule|null,
* current_identity: UserIdentityBadge|null
* }
*/
public function status(User $user): array
{
$today = today();
$todaySignIn = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', $today)
->first();
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
$signedToday = $todaySignIn !== null;
$claimableStreakDays = $signedToday
? (int) $todaySignIn->streak_days
: $this->calculateNextStreakDays($latestSignIn, $today);
return [
'signed_today' => $signedToday,
'can_claim' => ! $signedToday,
'current_streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
'claimable_streak_days' => $claimableStreakDays,
'today_sign_in' => $todaySignIn,
'latest_sign_in' => $latestSignIn,
'matched_rule' => $this->matchRewardRule($claimableStreakDays),
'current_identity' => $user->currentSignInIdentity(),
];
}
/**
* 查询指定月份的签到日历状态。
*
* @return array<string, mixed>
*/
public function calendar(User $user, ?string $month = null): array
{
$monthStart = $month
? Carbon::createFromFormat('Y-m', $month)->startOfMonth()
: today()->startOfMonth();
$monthEnd = $monthStart->copy()->endOfMonth();
$today = today();
// 补签卡只允许修补当前自然月,查看历史月份时不再开放补签入口。
$isCurrentMonth = $monthStart->isSameMonth($today);
$signIns = DailySignIn::query()
->where('user_id', $user->id)
->whereBetween('sign_in_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->get()
->keyBy(fn (DailySignIn $signIn): string => $signIn->sign_in_date->toDateString());
$days = [];
for ($date = $monthStart->copy(); $date->lte($monthEnd); $date->addDay()) {
$dateKey = $date->toDateString();
$signIn = $signIns->get($dateKey);
$isFuture = $date->gt($today);
$days[] = [
'date' => $dateKey,
'day' => (int) $date->day,
'weekday' => (int) $date->dayOfWeek,
'signed' => $signIn !== null,
'is_today' => $date->isSameDay($today),
'is_future' => $isFuture,
'can_makeup' => $isCurrentMonth && $signIn === null && $date->lt($today),
'is_makeup' => (bool) ($signIn?->is_makeup ?? false),
'streak_days' => (int) ($signIn?->streak_days ?? 0),
'reward_text' => $signIn ? $this->buildRewardText($signIn) : '',
];
}
return [
'month' => $monthStart->format('Y-m'),
'month_label' => $monthStart->format('Y年n月'),
'prev_month' => $monthStart->copy()->subMonth()->format('Y-m'),
'next_month' => $monthStart->copy()->addMonth()->format('Y-m'),
'days' => $days,
'reward_rules' => $this->rewardRulePreview(),
'makeup_card_count' => $this->availableSignRepairCardCount($user),
'sign_repair_card_item' => $this->activeSignRepairCard()?->only(['id', 'name', 'slug', 'icon', 'price', 'description']),
];
}
/**
* 领取每日签到奖励。
*/
public function claim(User $user, ?int $roomId = null): DailySignIn
{
return DB::transaction(function () use ($user, $roomId): DailySignIn {
$today = today();
// 锁定用户行,将同一用户的并发签到串行化,避免“查无今日记录”时并发重复发奖。
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
$existingSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', $today)
->lockForUpdate()
->first();
if ($existingSignIn !== null) {
return $existingSignIn->load('rewardRule');
}
$latestSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', '<', $today)
->latest('sign_in_date')
->lockForUpdate()
->first();
$streakDays = $this->calculateNextStreakDays($latestSignIn, $today);
$rewardRule = $this->matchRewardRule($streakDays);
$dailySignIn = DailySignIn::query()->create([
'user_id' => $lockedUser->id,
'room_id' => $roomId,
'sign_in_date' => $today->toDateString(),
'streak_days' => $streakDays,
'reward_rule_id' => $rewardRule?->id,
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
'identity_badge_code' => $rewardRule?->identity_badge_code,
'identity_badge_name' => $rewardRule?->identity_badge_name,
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
'identity_badge_color' => $rewardRule?->identity_badge_color,
]);
$this->grantRewards($lockedUser, $dailySignIn, $roomId);
$this->refreshSignInIdentityBadge($lockedUser, $rewardRule, $streakDays);
return $dailySignIn->load('rewardRule');
});
}
/**
* 使用补签卡补签指定历史日期。
*/
public function makeup(User $user, string $targetDate, ?int $roomId = null): DailySignIn
{
return DB::transaction(function () use ($user, $targetDate, $roomId): DailySignIn {
$date = Carbon::parse($targetDate)->startOfDay();
if ($date->greaterThanOrEqualTo(today())) {
throw ValidationException::withMessages(['target_date' => '只能补签今天之前的漏签日期。']);
}
// 后端再次限制补签月份,避免绕过前端日历直接提交历史月份。
if (! $date->isSameMonth(today())) {
throw ValidationException::withMessages(['target_date' => '补签卡只能补签本月的未签到日期。']);
}
// 锁定用户行,保证补签卡消耗和签到记录创建不会并发重复执行。
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
$existingSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', $date)
->lockForUpdate()
->first();
if ($existingSignIn !== null) {
throw ValidationException::withMessages(['target_date' => '这一天已经签到过,不能重复补签。']);
}
$repairCard = $this->nextAvailableSignRepairCard($lockedUser);
if ($repairCard === null) {
throw ValidationException::withMessages(['target_date' => '没有可用补签卡,请先购买补签卡。']);
}
$latestBeforeTarget = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', '<', $date)
->latest('sign_in_date')
->lockForUpdate()
->first();
$streakDays = $this->calculateNextStreakDays($latestBeforeTarget, $date);
$dailySignIn = DailySignIn::query()->create([
'user_id' => $lockedUser->id,
'room_id' => $roomId,
'is_makeup' => true,
'makeup_purchase_id' => $repairCard->id,
'makeup_at' => now(),
'sign_in_date' => $date->toDateString(),
'streak_days' => $streakDays,
'reward_rule_id' => null,
'gold_reward' => 0,
'exp_reward' => 0,
'charm_reward' => 0,
'identity_badge_code' => null,
'identity_badge_name' => null,
'identity_badge_icon' => null,
'identity_badge_color' => null,
]);
// 补签卡与补签记录必须在同一事务里完成状态流转。
$repairCard->update(['status' => 'used', 'used_at' => now()]);
$this->recalculateFutureStreaks($lockedUser, $date);
$currentStreakDays = $this->latestStreakDays($lockedUser);
$rewardRule = $this->matchRewardRule($currentStreakDays);
$dailySignIn->update([
'reward_rule_id' => $rewardRule?->id,
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
'identity_badge_code' => $rewardRule?->identity_badge_code,
'identity_badge_name' => $rewardRule?->identity_badge_name,
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
'identity_badge_color' => $rewardRule?->identity_badge_color,
]);
$this->grantRewards(
$lockedUser,
$dailySignIn->fresh(),
$roomId,
"补签 {$date->toDateString()}:当前连续签到 {$currentStreakDays}"
);
$this->refreshIdentityFromLatestSignIn($lockedUser);
return $dailySignIn->fresh('rewardRule');
});
}
/**
* 计算下一次签到应获得的连续天数。
*/
private function calculateNextStreakDays(?DailySignIn $latestSignIn, ?Carbon $targetDate = null): int
{
$targetDate ??= today();
if ($latestSignIn === null) {
return 1;
}
$yesterday = $targetDate->copy()->subDay()->toDateString();
if ($latestSignIn->sign_in_date?->toDateString() === $yesterday) {
// 签到满一年后开启新周期,下一天重新从第 1 天计算。
if ((int) $latestSignIn->streak_days >= $this->daysInSignInYear($latestSignIn)) {
return 1;
}
return (int) $latestSignIn->streak_days + 1;
}
return 1;
}
/**
* 按签到记录所在年份计算当年天数,闰年为 366 天。
*/
private function daysInSignInYear(DailySignIn $dailySignIn): int
{
return $dailySignIn->sign_in_date?->isLeapYear() ? 366 : 365;
}
/**
* 匹配当前连续天数可用的最高奖励规则。
*/
private function matchRewardRule(int $streakDays): ?SignInRewardRule
{
return SignInRewardRule::query()
->where('is_enabled', true)
->where('streak_days', '<=', $streakDays)
->orderByDesc('streak_days')
->first();
}
/**
* 按签到记录快照发放金币、经验和魅力。
*/
private function grantRewards(User $user, DailySignIn $dailySignIn, ?int $roomId, ?string $remark = null): void
{
$remark ??= "每日签到:连续 {$dailySignIn->streak_days}";
$this->currencyService->change($user, 'gold', $dailySignIn->gold_reward, CurrencySource::SIGN_IN, $remark, $roomId);
$this->currencyService->change($user, 'exp', $dailySignIn->exp_reward, CurrencySource::SIGN_IN, $remark, $roomId);
$this->currencyService->change($user, 'charm', $dailySignIn->charm_reward, CurrencySource::SIGN_IN, $remark, $roomId);
}
/**
* 刷新用户当前签到身份徽章。
*/
private function refreshSignInIdentityBadge(User $user, ?SignInRewardRule $rewardRule, int $streakDays): void
{
UserIdentityBadge::query()
->where('user_id', $user->id)
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
->where('is_active', true)
->update(['is_active' => false]);
if ($rewardRule?->identity_badge_code === null || $rewardRule->identity_badge_name === null) {
return;
}
// 同一来源同一徽章唯一,重复命中时更新状态和连续天数快照即可。
UserIdentityBadge::query()->updateOrCreate(
[
'user_id' => $user->id,
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
'badge_code' => $rewardRule->identity_badge_code,
],
[
'badge_name' => $rewardRule->identity_badge_name,
'badge_icon' => $rewardRule->identity_badge_icon,
'badge_color' => $rewardRule->identity_badge_color,
'acquired_at' => now(),
// 身份有效期由后台规则控制,0 表示长期有效。
'expires_at' => $rewardRule->identity_duration_days > 0
? now()->addDays($rewardRule->identity_duration_days)
: null,
'is_active' => true,
'metadata' => [
'streak_days' => $streakDays,
'reward_rule_id' => $rewardRule->id,
],
]
);
}
/**
* 按日期向后重算连续天数,补齐断点后让后续日历展示同步变化。
*/
private function recalculateFutureStreaks(User $user, Carbon $fromDate): void
{
$records = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', '>=', $fromDate)
->orderBy('sign_in_date')
->lockForUpdate()
->get();
$previous = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', '<', $fromDate)
->latest('sign_in_date')
->lockForUpdate()
->first();
$previousDate = $previous?->sign_in_date;
$previousStreak = (int) ($previous?->streak_days ?? 0);
$records->each(function (DailySignIn $record) use (&$previousDate, &$previousStreak): void {
$streakDays = $previousDate?->copy()->addDay()->toDateString() === $record->sign_in_date?->toDateString()
? $previousStreak + 1
: 1;
if ((int) $record->streak_days !== $streakDays) {
// 只重算连续天数快照,不追溯变更已发放奖励流水。
$record->update(['streak_days' => $streakDays]);
}
$previousDate = $record->sign_in_date;
$previousStreak = $streakDays;
});
}
/**
* 使用最新签到记录刷新当前签到身份。
*/
private function refreshIdentityFromLatestSignIn(User $user): void
{
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
if ($latestSignIn === null) {
return;
}
$this->refreshSignInIdentityBadge($user, $this->matchRewardRule((int) $latestSignIn->streak_days), (int) $latestSignIn->streak_days);
}
/**
* 查询用户最新签到记录的连续天数。
*/
private function latestStreakDays(User $user): int
{
return (int) DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->value('streak_days');
}
/**
* 查询用户下一张可消耗的补签卡。
*/
private function nextAvailableSignRepairCard(User $user): ?UserPurchase
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
->oldest()
->lockForUpdate()
->first();
}
/**
* 查询用户可用补签卡数量。
*/
private function availableSignRepairCardCount(User $user): int
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
->count();
}
/**
* 查询当前上架的补签卡商品。
*/
private function activeSignRepairCard(): ?ShopItem
{
return ShopItem::query()
->where('type', ShopItem::TYPE_SIGN_REPAIR)
->where('is_active', true)
->orderBy('sort_order')
->first();
}
/**
* 查询启用中的签到奖励档位,供前台展示目标奖励。
*
* @return array<int, array<string, mixed>>
*/
private function rewardRulePreview(): array
{
return SignInRewardRule::query()
->where('is_enabled', true)
->orderBy('streak_days')
->get()
->map(fn (SignInRewardRule $rule): array => [
'streak_days' => (int) $rule->streak_days,
'gold_reward' => (int) $rule->gold_reward,
'exp_reward' => (int) $rule->exp_reward,
'charm_reward' => (int) $rule->charm_reward,
'identity_badge_name' => $rule->identity_badge_name,
'identity_badge_icon' => $rule->identity_badge_icon,
'identity_badge_color' => $rule->identity_badge_color,
'identity_duration_days' => (int) $rule->identity_duration_days,
])
->all();
}
/**
* 生成日历内展示的签到奖励文本。
*/
private function buildRewardText(DailySignIn $dailySignIn): string
{
$items = [];
if ($dailySignIn->gold_reward > 0) {
$items[] = $dailySignIn->gold_reward.'金';
}
if ($dailySignIn->exp_reward > 0) {
$items[] = $dailySignIn->exp_reward.'经验';
}
if ($dailySignIn->charm_reward > 0) {
$items[] = $dailySignIn->charm_reward.'魅力';
}
return $items === [] ? '已记录' : implode(' + ', $items);
}
}