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

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
+4 -1
View File
@@ -41,6 +41,9 @@ enum CurrencySource: string
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
case POSITION_REWARD = 'position_reward';
/** 每日签到奖励(连续签到按规则发放) */
case SIGN_IN = 'sign_in';
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
@@ -48,7 +51,6 @@ enum CurrencySource: string
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
@@ -153,6 +155,7 @@ enum CurrencySource: string
self::SHOP_BUY => '商城购买',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,'.ShopItem::TYPE_SIGN_REPAIR,
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:后台签到奖励规则管理控制器
*
* 提供连续签到奖励档位的列表、新增、编辑、启停和删除功能。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SaveSignInRewardRuleRequest;
use App\Models\SignInRewardRule;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
/**
* 类功能:管理后台每日签到奖励规则。
*/
class SignInRewardRuleController extends Controller
{
/**
* 方法功能:展示签到奖励规则列表。
*/
public function index(): View
{
$rules = SignInRewardRule::query()
->orderBy('sort_order')
->orderBy('streak_days')
->get();
return view('admin.sign-in-rules.index', compact('rules'));
}
/**
* 方法功能:新增签到奖励规则。
*/
public function store(SaveSignInRewardRuleRequest $request): RedirectResponse
{
SignInRewardRule::query()->create($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。');
}
/**
* 方法功能:更新签到奖励规则。
*/
public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->update($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。');
}
/**
* 方法功能:切换签到奖励规则启用状态。
*/
public function toggle(SignInRewardRule $signInRewardRule): JsonResponse
{
$signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]);
return response()->json([
'ok' => true,
'is_enabled' => $signInRewardRule->is_enabled,
'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。',
]);
}
/**
* 方法功能:删除签到奖励规则。
*/
public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->delete();
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。');
}
/**
* 方法功能:整理后台表单提交的数据。
*
* @return array<string, mixed>
*/
private function payload(SaveSignInRewardRuleRequest $request): array
{
$data = $request->validated();
$data['is_enabled'] = $request->boolean('is_enabled');
foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) {
$data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
}
return $data;
}
}
@@ -0,0 +1,293 @@
<?php
/**
* 文件功能:前台每日签到控制器
*
* 提供签到状态查询、领取奖励、刷新在线名单载荷和聊天室签到通知。
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ClaimDailySignInRequest;
use App\Http\Requests\DailySignInCalendarRequest;
use App\Http\Requests\MakeupDailySignInRequest;
use App\Models\DailySignIn;
use App\Models\User;
use App\Models\UserIdentityBadge;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\SignInService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:处理前台用户每日签到状态与领取奖励流程。
*/
class DailySignInController extends Controller
{
/**
* 构造每日签到控制器依赖。
*/
public function __construct(
private readonly SignInService $signInService,
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $presenceService,
) {}
/**
* 方法功能:查询当前用户今日签到状态和奖励预览。
*/
public function status(): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$status = $this->signInService->status($user);
return response()->json([
'status' => 'success',
'data' => $this->formatStatusPayload($user, $status),
]);
}
/**
* 方法功能:查询指定月份的签到日历与补签卡状态。
*/
public function calendar(DailySignInCalendarRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
return response()->json([
'status' => 'success',
'data' => $this->signInService->calendar($user, $request->validated('month')),
]);
}
/**
* 方法功能:领取今日签到奖励并同步聊天室在线名单。
*/
public function claim(ClaimDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->claim($user, $roomId);
if (! $dailySignIn->wasRecentlyCreated) {
return response()->json([
'status' => 'error',
'message' => '今日已签到,请明天再来。',
'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn),
], 422);
}
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
$this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId);
}
return response()->json([
'status' => 'success',
'message' => $this->buildSuccessMessage($dailySignIn),
'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload),
]);
}
/**
* 方法功能:使用补签卡补签历史漏签日期。
*/
public function makeup(MakeupDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId);
$refreshedSignIn = $dailySignIn->fresh();
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
$currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0);
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
return response()->json([
'status' => 'success',
'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。',
'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays),
]);
}
/**
* 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。
*
* @param array<string, mixed> $presencePayload
*/
private function refreshOnlinePresence(User $user, array $presencePayload): void
{
foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) {
// 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
}
/**
* 方法功能:向当前聊天室广播签到成功通知。
*/
private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId): void
{
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '签到播报',
'to_user' => '大家',
'content' => $this->buildNoticeContent($user, $dailySignIn),
'is_secret' => false,
'font_color' => '#0f766e',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
}
/**
* 方法功能:生成聊天室内的签到播报内容。
*/
private function buildNoticeContent(User $user, DailySignIn $dailySignIn): string
{
$rewardText = $this->buildRewardText($dailySignIn);
$identityText = $dailySignIn->identity_badge_name
? ',获得身份 '.e($dailySignIn->identity_badge_name)
: '';
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton;
}
/**
* 方法功能:生成本机签到成功提示文案。
*/
private function buildSuccessMessage(DailySignIn $dailySignIn): string
{
return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。';
}
/**
* 方法功能:按实际签到奖励快照生成奖励描述。
*/
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);
}
/**
* 方法功能:格式化状态查询响应载荷。
*
* @param array<string, mixed> $status
* @return array<string, mixed>
*/
private function formatStatusPayload(User $user, array $status): array
{
return [
'signed_today' => $status['signed_today'],
'can_claim' => $status['can_claim'],
'current_streak_days' => $status['current_streak_days'],
'claimable_streak_days' => $status['claimable_streak_days'],
'preview_rule' => $status['matched_rule']?->only([
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
]),
'identity' => $this->formatIdentityPayload($status['current_identity']),
'user' => [
'jjb' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到领取响应载荷。
*
* @param array<string, mixed>|null $presencePayload
* @return array<string, mixed>
*/
private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array
{
$identity = $user->currentSignInIdentity();
return [
'sign_in' => [
'id' => $dailySignIn->id,
'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(),
'is_makeup' => (bool) $dailySignIn->is_makeup,
'streak_days' => (int) $dailySignIn->streak_days,
'gold_reward' => (int) $dailySignIn->gold_reward,
'exp_reward' => (int) $dailySignIn->exp_reward,
'charm_reward' => (int) $dailySignIn->charm_reward,
],
'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days,
'identity' => $this->formatIdentityPayload($identity),
'presence' => $presencePayload ?? $this->presenceService->build($user),
'user' => [
'jjb' => (int) $user->jjb,
'gold' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到身份数据供前端展示。
*
* @return array<string, mixed>|null
*/
private function formatIdentityPayload(?UserIdentityBadge $identity): ?array
{
if ($identity === null) {
return null;
}
return [
'key' => $identity->badge_code,
'label' => $identity->badge_name,
'name' => $identity->badge_name,
'icon' => $identity->badge_icon ?? '✅',
'color' => $identity->badge_color ?? '#0f766e',
'expires_at' => $identity->expires_at?->toIso8601String(),
'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0),
];
}
}
+20 -5
View File
@@ -10,6 +10,7 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Models\ShopItem;
use App\Models\UserPurchase;
use App\Services\ShopService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -47,8 +48,10 @@ class ShopController extends Controller
'charm_bonus' => $item->charm_bonus,
]);
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
// 统计背包中各戒指持有数量
$ringCounts = \App\Models\UserPurchase::query()
$ringCounts = UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
@@ -64,6 +67,8 @@ class ShopController extends Controller
'has_rename_card' => $this->shopService->hasRenameCard($user),
'ring_counts' => $ringCounts,
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
'sign_repair_card_item' => $signRepairCard,
]);
}
@@ -74,24 +79,33 @@ class ShopController extends Controller
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
* - message 公屏赠言(可选)
*
* @param Request $request item_id, recipient?, message?
* @param Request $request item_id, recipient?, message?, quantity?
*/
public function buy(Request $request): JsonResponse
{
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
$request->validate([
'item_id' => 'required|integer|exists:shop_items,id',
'quantity' => 'nullable|integer|min:1|max:99',
]);
$item = ShopItem::find($request->item_id);
if (! $item->is_active) {
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
}
$result = $this->shopService->buyItem(Auth::user(), $item);
$quantity = (int) $request->input('quantity', 1);
$result = $this->shopService->buyItem(Auth::user(), $item, $quantity);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
$response = ['status' => 'success', 'message' => $result['message']];
$response = [
'status' => 'success',
'message' => $result['message'],
'quantity' => $result['quantity'] ?? 1,
'total_price' => $result['total_price'] ?? $item->price,
];
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) {
@@ -176,6 +190,7 @@ class ShopController extends Controller
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$user->username}】购买了 {$quantity} 张「{$item->name}」,准备把漏掉的签到补回来!",
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
};
+12
View File
@@ -126,6 +126,18 @@ class UserController extends Controller
->all();
$data['vip']['Name'] = $targetUser->vipName();
$data['vip']['Icon'] = $targetUser->vipIcon();
$signIdentity = $targetUser->currentSignInIdentity();
$latestSignIn = $targetUser->dailySignIns()->first();
$data['sign_in'] = [
'streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
'identity' => $signIdentity ? [
'key' => $signIdentity->badge_code,
'label' => $signIdentity->badge_name,
'icon' => $signIdentity->badge_icon ?? '✅',
'color' => $signIdentity->badge_color ?? '#0f766e',
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
] : null,
];
// 拥有封禁IPlevel_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:后台签到奖励规则保存请求校验
*
* 集中校验连续签到天数、奖励数值与身份徽章配置。
*/
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:校验后台新增和更新签到奖励规则的表单数据。
*/
class SaveSignInRewardRuleRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台权限中间件的管理员继续校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:返回签到奖励规则表单的校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$ruleId = $this->route('signInRewardRule')?->id;
return [
'streak_days' => [
'required',
'integer',
'min:1',
'max:3650',
Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId),
],
'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'identity_badge_code' => ['nullable', 'string', 'max:50'],
'identity_badge_name' => ['nullable', 'string', 'max:50'],
'identity_badge_icon' => ['nullable', 'string', 'max:120'],
'identity_badge_color' => ['nullable', 'string', 'max:20'],
'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'],
'sort_order' => ['required', 'integer', 'min:0', 'max:999999'],
'is_enabled' => ['nullable', 'boolean'],
];
}
/**
* 方法功能:返回签到奖励规则表单的中文字段名。
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'streak_days' => '连续签到天数',
'gold_reward' => '金币奖励',
'exp_reward' => '经验奖励',
'charm_reward' => '魅力奖励',
'identity_badge_name' => '身份名称',
'identity_duration_days' => '身份有效天数',
];
}
}
@@ -0,0 +1,50 @@
<?php
/**
* 文件功能:前台每日签到请求校验
*
* 校验用户发起签到时携带的房间参数,避免脏 room_id 写入签到流水和聊天室通知。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验每日签到领取接口的请求参数。
*/
class ClaimDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录聊天室用户发起签到请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回每日签到领取参数校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回每日签到领取的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.exists' => '当前聊天室不存在,请刷新页面后再签到。',
];
}
}
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:前台每日签到日历查询请求校验。
*
* 校验月份参数,供签到日历按月展示签到状态。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验用户查询签到日历时传入的月份参数。
*/
class DailySignInCalendarRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户查询自己的签到日历。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回签到日历查询规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'month' => ['nullable', 'date_format:Y-m'],
];
}
/**
* 方法功能:返回签到日历查询的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'month.date_format' => '月份格式不正确。',
];
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:前台每日签到补签请求校验。
*
* 校验补签日期和房间参数,确保用户只能补签历史漏签日期。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* 类功能:校验用户在签到日历中提交的补签请求。
*/
class MakeupDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户提交补签请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回补签请求的校验规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'target_date' => ['required', 'date', 'before:today', 'after_or_equal:'.Carbon::today()->startOfMonth()->toDateString()],
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回补签请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'target_date.required' => '请选择要补签的日期。',
'target_date.date' => '补签日期格式不正确。',
'target_date.before' => '只能补签今天之前的漏签日期。',
'target_date.after_or_equal' => '补签卡只能补签本月的未签到日期。',
'room_id.exists' => '当前聊天室不存在,请刷新页面后再补签。',
];
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:每日签到记录模型。
*
* 保存用户每天签到、连续天数、命中奖励规则与实际奖励快照。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装每日签到流水的字段类型与用户、规则关联。
*/
class DailySignIn extends Model
{
/** @use HasFactory<\Database\Factories\DailySignInFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'room_id',
'is_makeup',
'makeup_purchase_id',
'makeup_at',
'sign_in_date',
'streak_days',
'reward_rule_id',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_makeup' => 'boolean',
'makeup_purchase_id' => 'integer',
'makeup_at' => 'datetime',
'sign_in_date' => 'date',
'streak_days' => 'integer',
'reward_rule_id' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
];
}
/**
* 关联:签到记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 关联:本次签到命中的奖励规则。
*/
public function rewardRule(): BelongsTo
{
return $this->belongsTo(SignInRewardRule::class, 'reward_rule_id');
}
}
+10
View File
@@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class ShopItem extends Model
{
public const TYPE_SIGN_REPAIR = 'sign_repair';
protected $table = 'shop_items';
protected $fillable = [
@@ -41,6 +43,14 @@ class ShopItem extends Model
return $this->type === 'auto_fishing';
}
/**
* 是否为签到补签卡。
*/
public function isSignRepairCard(): bool
{
return $this->type === self::TYPE_SIGN_REPAIR;
}
/**
* 是否为特效类商品(instant durationslug once_ week_ 开头)
*/
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:每日签到奖励规则模型。
*
* 通过连续签到天数门槛配置金币、经验、魅力与身份徽章奖励。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:封装签到奖励规则字段、类型转换与命中签到记录关联。
*/
class SignInRewardRule extends Model
{
/** @use HasFactory<\Database\Factories\SignInRewardRuleFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
'is_enabled',
'sort_order',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'streak_days' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
'identity_duration_days' => 'integer',
'is_enabled' => 'boolean',
'sort_order' => 'integer',
];
}
/**
* 关联:命中过该规则的签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'reward_rule_id');
}
}
+43
View File
@@ -252,6 +252,49 @@ class User extends Authenticatable
return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id');
}
/**
* 关联:用户每日签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
}
/**
* 关联:用户全部身份徽章。
*/
public function identityBadges(): HasMany
{
return $this->hasMany(UserIdentityBadge::class, 'user_id')->latest('acquired_at');
}
/**
* 关联:用户当前启用的签到身份徽章。
*/
public function currentSignInIdentityBadge(): HasOne
{
return $this->hasOne(UserIdentityBadge::class, 'user_id')
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
->where('is_active', true)
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->latestOfMany('acquired_at');
}
/**
* 获取当前签到身份徽章辅助方法。
*/
public function currentSignInIdentity(): ?UserIdentityBadge
{
if ($this->relationLoaded('currentSignInIdentityBadge')) {
return $this->getRelation('currentSignInIdentityBadge');
}
return $this->currentSignInIdentityBadge()->first();
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:用户身份徽章模型。
*
* 管理用户从签到等来源获得的当前身份展示标识。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户身份徽章字段、类型转换与用户关联。
*/
class UserIdentityBadge extends Model
{
/** @use HasFactory<\Database\Factories\UserIdentityBadgeFactory> */
use HasFactory;
public const SOURCE_SIGN_IN = 'sign_in';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'source',
'badge_code',
'badge_name',
'badge_icon',
'badge_color',
'acquired_at',
'expires_at',
'is_active',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'acquired_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
/**
* 关联:身份徽章所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+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);
}
}