522 lines
19 KiB
PHP
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);
|
|
}
|
|
}
|