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

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
+10
View File
@@ -51,6 +51,16 @@ class ChatUserPresenceService
$payload['daily_status_expires_at'] = $activeStatus['expires_at'];
}
$signIdentity = $user->currentSignInIdentity();
if ($signIdentity !== null) {
$payload['sign_identity_key'] = $signIdentity->badge_code;
$payload['sign_identity_label'] = $signIdentity->badge_name;
$payload['sign_identity_icon'] = $signIdentity->badge_icon ?? '✅';
$payload['sign_identity_color'] = $signIdentity->badge_color ?? '#0f766e';
$payload['sign_identity_expires_at'] = $signIdentity->expires_at?->toIso8601String();
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
}
return $payload;
}
+56 -4
View File
@@ -19,13 +19,19 @@ class ShopService
/**
* 购买商品入口:扣金币、按类型分发处理
*
* @return array{ok:bool, message:string, play_effect?:string}
* @return array{ok:bool, message:string, play_effect?:string, quantity?:int, total_price?:int}
*/
public function buyItem(User $user, ShopItem $item): array
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
{
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) {
return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
}
$totalPrice = $item->price * $quantity;
// 校验金币是否足够
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
if ($user->jjb < $totalPrice) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
}
return match ($item->type) {
@@ -34,6 +40,7 @@ class ShopService
'one_time' => $this->buyRenameCard($user, $item),
'ring' => $this->buyRing($user, $item),
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
default => ['ok' => false, 'message' => '未知商品类型'],
};
}
@@ -243,6 +250,39 @@ class ShopService
];
}
/**
* 购买签到补签卡:扣金币并写入可消耗背包记录。
*
* @return array{ok:bool, message:string, quantity:int, total_price:int}
*/
public function buySignRepairCard(User $user, ShopItem $item, int $quantity = 1): array
{
$quantity = max(1, min(99, $quantity));
$totalPrice = $item->price * $quantity;
DB::transaction(function () use ($user, $item, $quantity, $totalPrice): void {
// 补签卡支持一次购买多张,扣款必须按总价执行。
$user->decrement('jjb', $totalPrice);
for ($i = 0; $i < $quantity; $i++) {
// 每张补签卡保留独立购买记录,补签时逐张消耗。
UserPurchase::create([
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
]);
}
});
return [
'ok' => true,
'message' => "🗓️ {$item->name} × {$quantity} 购买成功!可在签到日历中补签漏掉的日期。",
'quantity' => $quantity,
'total_price' => $totalPrice,
];
}
/**
* 获取用户当前激活的改名卡(是否持有未用改名卡)
*/
@@ -254,6 +294,18 @@ class ShopService
->exists();
}
/**
* 获取用户可用补签卡数量。
*/
public function getSignRepairCardCount(User $user): int
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($q) => $q->where('type', ShopItem::TYPE_SIGN_REPAIR))
->count();
}
/**
* 购买自动钓鱼卡:手刺金币,写入 active 记录,到期时间 = 现在 + duration_minutes。
*
+521
View File
@@ -0,0 +1,521 @@
<?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);
}
}