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 */ 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> */ 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); } }