新增每日签到与补签卡功能
This commit is contained in:
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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}」。",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
// 拥有封禁IP(level_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' => '当前聊天室不存在,请刷新页面后再补签。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 或 duration,slug 以 once_ 或 week_ 开头)
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ── 职务相关关联 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到记录测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造用户签到流水和连续签到奖励快照。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<DailySignIn>
|
||||
*/
|
||||
class DailySignInFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认每日签到测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'room_id' => null,
|
||||
'sign_in_date' => today(),
|
||||
'streak_days' => 1,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到奖励规则测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造不同连续天数门槛的签到奖励规则。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SignInRewardRule;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SignInRewardRule>
|
||||
*/
|
||||
class SignInRewardRuleFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认签到奖励规则测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'streak_days' => fake()->unique()->numberBetween(1, 30),
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 10,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => null,
|
||||
'identity_badge_name' => null,
|
||||
'identity_badge_icon' => null,
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 0,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户身份徽章测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造签到来源或其他来源的身份徽章。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserIdentityBadge;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserIdentityBadge>
|
||||
*/
|
||||
class UserIdentityBadgeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认身份徽章测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
|
||||
'badge_code' => 'sign_in_beginner',
|
||||
'badge_name' => '签到新星',
|
||||
'badge_icon' => null,
|
||||
'badge_color' => '#0f766e',
|
||||
'acquired_at' => now(),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建每日签到奖励规则表。
|
||||
*
|
||||
* 规则按连续签到天数配置奖励,签到服务会匹配小于等于当前连续天数的最高启用规则。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 sign_in_reward_rules 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建每日签到奖励规则表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sign_in_reward_rules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('streak_days')->unique()->comment('连续签到天数门槛');
|
||||
$table->unsignedInteger('gold_reward')->default(0)->comment('金币奖励');
|
||||
$table->unsignedInteger('exp_reward')->default(0)->comment('经验奖励');
|
||||
$table->unsignedInteger('charm_reward')->default(0)->comment('魅力奖励');
|
||||
$table->string('identity_badge_code', 50)->nullable()->comment('身份徽章编码');
|
||||
$table->string('identity_badge_name', 50)->nullable()->comment('身份徽章名称');
|
||||
$table->string('identity_badge_icon', 120)->nullable()->comment('身份徽章图标');
|
||||
$table->string('identity_badge_color', 20)->nullable()->comment('身份徽章文字颜色');
|
||||
$table->unsignedSmallInteger('identity_duration_days')->default(0)->comment('身份有效天数,0表示永久');
|
||||
$table->boolean('is_enabled')->default(true)->index()->comment('是否启用');
|
||||
$table->unsignedInteger('sort_order')->default(0)->index()->comment('后台排序值');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_enabled', 'streak_days'], 'idx_sign_in_rules_enabled_streak');
|
||||
});
|
||||
|
||||
DB::table('sign_in_reward_rules')->insert($this->defaultRules());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除每日签到奖励规则表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sign_in_reward_rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认连续签到奖励规则。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function defaultRules(): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
return [
|
||||
[
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 10,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => 'sign_in_new_star',
|
||||
'identity_badge_name' => '签到新星',
|
||||
'identity_badge_icon' => '✅',
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 7,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 3,
|
||||
'gold_reward' => 180,
|
||||
'exp_reward' => 18,
|
||||
'charm_reward' => 1,
|
||||
'identity_badge_code' => 'sign_in_spark',
|
||||
'identity_badge_name' => '三日星火',
|
||||
'identity_badge_icon' => '✨',
|
||||
'identity_badge_color' => '#2563eb',
|
||||
'identity_duration_days' => 10,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 3,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 7,
|
||||
'gold_reward' => 350,
|
||||
'exp_reward' => 35,
|
||||
'charm_reward' => 3,
|
||||
'identity_badge_code' => 'sign_in_flame',
|
||||
'identity_badge_name' => '七日恒星',
|
||||
'identity_badge_icon' => '🔥',
|
||||
'identity_badge_color' => '#dc2626',
|
||||
'identity_duration_days' => 30,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 7,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 14,
|
||||
'gold_reward' => 600,
|
||||
'exp_reward' => 60,
|
||||
'charm_reward' => 5,
|
||||
'identity_badge_code' => 'sign_in_moon_guard',
|
||||
'identity_badge_name' => '半月守望',
|
||||
'identity_badge_icon' => '🌙',
|
||||
'identity_badge_color' => '#7c3aed',
|
||||
'identity_duration_days' => 45,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 14,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 30,
|
||||
'gold_reward' => 1200,
|
||||
'exp_reward' => 120,
|
||||
'charm_reward' => 8,
|
||||
'identity_badge_code' => 'sign_in_month_master',
|
||||
'identity_badge_name' => '月签达人',
|
||||
'identity_badge_icon' => '🏆',
|
||||
'identity_badge_color' => '#ca8a04',
|
||||
'identity_duration_days' => 60,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 30,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 60,
|
||||
'gold_reward' => 2200,
|
||||
'exp_reward' => 220,
|
||||
'charm_reward' => 14,
|
||||
'identity_badge_code' => 'sign_in_diamond',
|
||||
'identity_badge_name' => '双月钻冕',
|
||||
'identity_badge_icon' => '💎',
|
||||
'identity_badge_color' => '#0891b2',
|
||||
'identity_duration_days' => 90,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 60,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 100,
|
||||
'gold_reward' => 4000,
|
||||
'exp_reward' => 400,
|
||||
'charm_reward' => 25,
|
||||
'identity_badge_code' => 'sign_in_legend',
|
||||
'identity_badge_name' => '百日传奇',
|
||||
'identity_badge_icon' => '👑',
|
||||
'identity_badge_color' => '#9333ea',
|
||||
'identity_duration_days' => 180,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 100,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 365,
|
||||
'gold_reward' => 12000,
|
||||
'exp_reward' => 1200,
|
||||
'charm_reward' => 100,
|
||||
'identity_badge_code' => 'sign_in_year_guardian',
|
||||
'identity_badge_name' => '周年守护者',
|
||||
'identity_badge_icon' => '🌟',
|
||||
'identity_badge_color' => '#e11d48',
|
||||
'identity_duration_days' => 0,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 365,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建用户身份徽章表。
|
||||
*
|
||||
* 用于记录用户通过签到等来源获得的身份展示信息,当前阶段只由签到服务刷新 sign_in 来源。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 user_identity_badges 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建用户身份徽章表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_identity_badges', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||
$table->string('source', 30)->default('sign_in')->comment('徽章来源');
|
||||
$table->string('badge_code', 50)->comment('徽章编码');
|
||||
$table->string('badge_name', 50)->comment('徽章名称');
|
||||
$table->string('badge_icon', 120)->nullable()->comment('徽章图标');
|
||||
$table->string('badge_color', 20)->nullable()->comment('徽章文字颜色');
|
||||
$table->timestamp('acquired_at')->nullable()->comment('获得时间');
|
||||
$table->timestamp('expires_at')->nullable()->index()->comment('过期时间,空表示长期有效');
|
||||
$table->boolean('is_active')->default(true)->index()->comment('是否当前启用');
|
||||
$table->json('metadata')->nullable()->comment('扩展信息');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'source', 'badge_code'], 'uniq_user_identity_badge');
|
||||
$table->index(['user_id', 'source', 'is_active'], 'idx_user_identity_badges_current');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除用户身份徽章表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_identity_badges');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建每日签到记录表。
|
||||
*
|
||||
* 每个用户每天只能签到一次,并保存当次连续天数与实际发放奖励快照。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 daily_sign_ins 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建每日签到记录表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('daily_sign_ins', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||
$table->unsignedBigInteger('room_id')->nullable()->index()->comment('签到所在房间ID');
|
||||
$table->date('sign_in_date')->comment('签到日期');
|
||||
$table->unsignedInteger('streak_days')->comment('连续签到天数');
|
||||
$table->foreignId('reward_rule_id')->nullable()->constrained('sign_in_reward_rules')->nullOnDelete()->comment('命中奖励规则ID');
|
||||
$table->unsignedInteger('gold_reward')->default(0)->comment('实际发放金币');
|
||||
$table->unsignedInteger('exp_reward')->default(0)->comment('实际发放经验');
|
||||
$table->unsignedInteger('charm_reward')->default(0)->comment('实际发放魅力');
|
||||
$table->string('identity_badge_code', 50)->nullable()->comment('本次刷新身份徽章编码');
|
||||
$table->string('identity_badge_name', 50)->nullable()->comment('本次刷新身份徽章名称');
|
||||
$table->string('identity_badge_icon', 120)->nullable()->comment('本次刷新身份徽章图标');
|
||||
$table->string('identity_badge_color', 20)->nullable()->comment('本次刷新身份徽章颜色');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'sign_in_date'], 'uniq_user_daily_sign_in');
|
||||
$table->index(['sign_in_date', 'streak_days'], 'idx_daily_sign_ins_date_streak');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除每日签到记录表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('daily_sign_ins');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为每日签到记录补充补签来源字段。
|
||||
*
|
||||
* 记录补签卡消耗的购买记录与补签时间,便于日历区分正常签到和补签。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:新增补签标记、补签卡购买记录和补签完成时间。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('daily_sign_ins', function (Blueprint $table): void {
|
||||
$table->boolean('is_makeup')->default(false)->after('room_id')->comment('是否通过补签卡补签');
|
||||
$table->foreignId('makeup_purchase_id')->nullable()->after('is_makeup')->constrained('user_purchases')->nullOnDelete()->comment('消耗的补签卡购买记录ID');
|
||||
$table->timestamp('makeup_at')->nullable()->after('makeup_purchase_id')->comment('补签完成时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚补签字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('daily_sign_ins', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('makeup_purchase_id');
|
||||
$table->dropColumn(['is_makeup', 'makeup_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为商店商品类型加入签到补签卡。
|
||||
*
|
||||
* 允许后台商店配置补签卡,并提供一条默认上架商品。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:扩展商品类型枚举并写入默认补签卡。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
|
||||
DB::table('shop_items')->updateOrInsert(
|
||||
['slug' => 'sign_repair_card'],
|
||||
[
|
||||
'name' => '补签卡',
|
||||
'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。',
|
||||
'icon' => '🗓️',
|
||||
'price' => 10000,
|
||||
'type' => 'sign_repair',
|
||||
'duration_days' => 0,
|
||||
'duration_minutes' => 0,
|
||||
'intimacy_bonus' => 0,
|
||||
'charm_bonus' => 0,
|
||||
'sort_order' => 35,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚补签卡商品类型。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('shop_items')->where('slug', 'sign_repair_card')->delete();
|
||||
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'sign_repair'");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:更新补签卡默认价格和购买说明。
|
||||
*
|
||||
* 确保已经运行过旧迁移的环境,也能同步补签卡 10000 金币与本月补签限制说明。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:把补签卡默认价格和说明更新为当前业务规则。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('shop_items')
|
||||
->where('slug', 'sign_repair_card')
|
||||
->update([
|
||||
'price' => 10000,
|
||||
'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚补签卡默认价格和旧说明,方便本地调试回退。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('shop_items')
|
||||
->where('slug', 'sign_repair_card')
|
||||
->update([
|
||||
'price' => 1200,
|
||||
'description' => '用于补签漏掉的历史日期,每张可补签 1 天。',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -2,26 +2,21 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 类功能:统一调度项目基础数据填充器。
|
||||
*/
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
* 方法功能:执行系统参数和默认房间等基础数据填充。
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
// User::factory()->create([
|
||||
// 'name' => 'Test User',
|
||||
// 'email' => 'test@example.com',
|
||||
// ]);
|
||||
|
||||
$this->call([
|
||||
SysParamSeeder::class,
|
||||
RoomSeeder::class,
|
||||
|
||||
@@ -427,6 +427,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.user-item .user-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -435,6 +436,13 @@ a:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-item .user-badge-slot {
|
||||
flex-shrink: 0;
|
||||
max-width: 78px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-item .user-sex {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.autoact.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '🎲 随机事件' !!}
|
||||
</a>
|
||||
<a href="{{ route('admin.sign-in-rules.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.sign-in-rules.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '✅ 签到奖励' !!}
|
||||
</a>
|
||||
<a href="{{ route('admin.vip.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
{!! '👑 VIP 会员等级' !!}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
'one_time' => ['label' => '一次性道具', 'color' => 'bg-yellow-100 text-yellow-700'],
|
||||
'ring' => ['label' => '求婚戒指', 'color' => 'bg-rose-100 text-rose-700'],
|
||||
'auto_fishing' => ['label' => '自动钓鱼卡', 'color' => 'bg-emerald-100 text-emerald-700'],
|
||||
'sign_repair' => ['label' => '签到补签卡', 'color' => 'bg-teal-100 text-teal-700'],
|
||||
];
|
||||
$isSuperAdmin = Auth::id() === 1;
|
||||
@endphp
|
||||
@@ -283,6 +284,7 @@
|
||||
<option value="one_time">one_time — 一次性道具</option>
|
||||
<option value="ring">ring — 求婚戒指</option>
|
||||
<option value="auto_fishing">auto_fishing — 自动钓鱼卡</option>
|
||||
<option value="sign_repair">sign_repair — 签到补签卡</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '签到奖励管理')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800">✅ 签到奖励管理</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">按连续签到天数配置金币、经验、魅力和专属身份。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold text-gray-500 uppercase">连续天数</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-bold text-gray-500 uppercase">金币</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-bold text-gray-500 uppercase">经验</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-bold text-gray-500 uppercase">魅力</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold text-gray-500 uppercase">身份</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-bold text-gray-500 uppercase">有效期</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-bold text-gray-500 uppercase">状态</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-bold text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@forelse ($rules as $rule)
|
||||
<tr id="rule-row-{{ $rule->id }}" class="{{ $rule->is_enabled ? '' : 'opacity-50' }}">
|
||||
<td class="px-4 py-3 font-bold text-gray-800">第 {{ $rule->streak_days }} 天</td>
|
||||
<td class="px-4 py-3 text-center text-amber-600 font-mono">+{{ $rule->gold_reward }}</td>
|
||||
<td class="px-4 py-3 text-center text-emerald-600 font-mono">+{{ $rule->exp_reward }}</td>
|
||||
<td class="px-4 py-3 text-center text-pink-600 font-mono">+{{ $rule->charm_reward }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($rule->identity_badge_name)
|
||||
<span class="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-bold"
|
||||
style="color: {{ $rule->identity_badge_color ?: '#0f766e' }}; border-color: #99f6e4; background: #f0fdfa;">
|
||||
<span>{{ $rule->identity_badge_icon ?: '✅' }}</span>
|
||||
<span>{{ $rule->identity_badge_name }}</span>
|
||||
</span>
|
||||
<div class="mt-1 text-[11px] text-gray-400">{{ $rule->identity_badge_code }}</div>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">未配置身份</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-600">
|
||||
{{ $rule->identity_duration_days > 0 ? $rule->identity_duration_days . ' 天' : '永久' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="toggleSignInRule({{ $rule->id }})" id="toggle-rule-{{ $rule->id }}"
|
||||
class="px-2 py-1 rounded-full text-xs font-bold transition {{ $rule->is_enabled ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
|
||||
{{ $rule->is_enabled ? '启用' : '停用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button type="button" onclick="openEditRule({{ $rule->id }})"
|
||||
class="px-3 py-1 bg-indigo-50 text-indigo-700 rounded text-xs font-bold hover:bg-indigo-100 transition mr-1">
|
||||
编辑
|
||||
</button>
|
||||
<form action="{{ route('admin.sign-in-rules.destroy', $rule) }}" method="POST"
|
||||
class="inline" onsubmit="return confirm('确定删除第 {{ $rule->streak_days }} 天签到规则?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="px-3 py-1 bg-red-50 text-red-600 rounded text-xs font-bold hover:bg-red-100 transition">
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">暂无签到奖励规则。</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="p-5 border-b border-gray-100 bg-gray-50">
|
||||
<h3 class="font-bold text-gray-700 text-sm">➕ 新增签到档位</h3>
|
||||
</div>
|
||||
<form action="{{ route('admin.sign-in-rules.store') }}" method="POST" class="p-5">
|
||||
@csrf
|
||||
@include('admin.sign-in-rules.partials.form-fields', ['rule' => null])
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 transition text-sm shadow-sm">
|
||||
💾 添加规则
|
||||
</button>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" name="is_enabled" value="1" checked class="rounded">
|
||||
立即启用
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-rule-modal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-3xl shadow-2xl">
|
||||
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="font-bold text-gray-800">✏️ 编辑签到规则</h3>
|
||||
<button type="button" onclick="closeEditRule()" class="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||
</div>
|
||||
<form id="edit-rule-form" method="POST" class="p-5">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
@include('admin.sign-in-rules.partials.form-fields', ['rule' => null, 'prefix' => 'edit-'])
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-indigo-600 text-white rounded-lg font-bold hover:bg-indigo-700 transition text-sm">
|
||||
💾 保存修改
|
||||
</button>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" name="is_enabled" id="edit-is-enabled" value="1" class="rounded">
|
||||
启用此规则
|
||||
</label>
|
||||
<button type="button" onclick="closeEditRule()"
|
||||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-bold hover:bg-gray-200 transition text-sm">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const signInRules = @json($rules->keyBy('id'));
|
||||
|
||||
function openEditRule(id) {
|
||||
const rule = signInRules[id];
|
||||
if (!rule) return;
|
||||
|
||||
document.getElementById('edit-rule-form').action = `/admin/sign-in-rules/${id}`;
|
||||
['streak_days', 'gold_reward', 'exp_reward', 'charm_reward', 'identity_badge_code',
|
||||
'identity_badge_name', 'identity_badge_icon', 'identity_badge_color',
|
||||
'identity_duration_days', 'sort_order'
|
||||
].forEach((field) => {
|
||||
const input = document.getElementById(`edit-${field}`);
|
||||
if (input) input.value = rule[field] ?? '';
|
||||
});
|
||||
document.getElementById('edit-is-enabled').checked = Boolean(rule.is_enabled);
|
||||
document.getElementById('edit-rule-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeEditRule() {
|
||||
document.getElementById('edit-rule-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function toggleSignInRule(id) {
|
||||
const response = await fetch(`/admin/sign-in-rules/${id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
alert(data.message || '切换失败');
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,66 @@
|
||||
@php
|
||||
$prefix = $prefix ?? '';
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">连续天数</label>
|
||||
<input type="number" id="{{ $prefix }}streak_days" name="streak_days" min="1" required
|
||||
value="{{ old('streak_days') }}"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">金币奖励</label>
|
||||
<input type="number" id="{{ $prefix }}gold_reward" name="gold_reward" min="0" required
|
||||
value="{{ old('gold_reward', 0) }}"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">经验奖励</label>
|
||||
<input type="number" id="{{ $prefix }}exp_reward" name="exp_reward" min="0" required
|
||||
value="{{ old('exp_reward', 0) }}"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">魅力奖励</label>
|
||||
<input type="number" id="{{ $prefix }}charm_reward" name="charm_reward" min="0" required
|
||||
value="{{ old('charm_reward', 0) }}"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">身份编码</label>
|
||||
<input type="text" id="{{ $prefix }}identity_badge_code" name="identity_badge_code"
|
||||
value="{{ old('identity_badge_code') }}" placeholder="sign_in_7"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">身份名称</label>
|
||||
<input type="text" id="{{ $prefix }}identity_badge_name" name="identity_badge_name"
|
||||
value="{{ old('identity_badge_name') }}" placeholder="七日星辉"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">身份图标</label>
|
||||
<input type="text" id="{{ $prefix }}identity_badge_icon" name="identity_badge_icon"
|
||||
value="{{ old('identity_badge_icon') }}" placeholder="🔥"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">身份颜色</label>
|
||||
<input type="text" id="{{ $prefix }}identity_badge_color" name="identity_badge_color"
|
||||
value="{{ old('identity_badge_color', '#0f766e') }}" placeholder="#0f766e"
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">身份有效天数</label>
|
||||
<input type="number" id="{{ $prefix }}identity_duration_days" name="identity_duration_days" min="0"
|
||||
value="{{ old('identity_duration_days', 0) }}" required
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">排序</label>
|
||||
<input type="number" id="{{ $prefix }}sort_order" name="sort_order" min="0"
|
||||
value="{{ old('sort_order', 0) }}" required
|
||||
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +95,10 @@
|
||||
refreshAllUrl: "{{ route('command.refresh_all') }}",
|
||||
chatPreferencesUrl: "{{ route('user.update_chat_preferences') }}",
|
||||
dailyStatusUpdateUrl: "{{ route('user.update_daily_status') }}",
|
||||
dailySignInStatusUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.status') ? route('daily-sign-in.status') : null),
|
||||
dailySignInCalendarUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.calendar') ? route('daily-sign-in.calendar') : null),
|
||||
dailySignInClaimUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.claim') ? route('daily-sign-in.claim') : null),
|
||||
dailySignInMakeupUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.makeup') ? route('daily-sign-in.makeup') : null),
|
||||
userJjb: {{ (int) $user->jjb }}, // 当前用户金币(求婚前金额预检查用)
|
||||
myGold: {{ (int) $user->jjb }}, // 赠金币面板显示余额用(赠送成功后前端更新)
|
||||
chatPreferences: @json($user->chat_preferences ?? []),
|
||||
@@ -207,6 +211,7 @@
|
||||
@include('chat.partials.marriage-modals')
|
||||
{{-- 节日福利弹窗 --}}
|
||||
@include('chat.partials.holiday-modal')
|
||||
@include('chat.partials.daily-sign-in-modal')
|
||||
|
||||
{{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS) ═══════════ --}}
|
||||
@include('chat.partials.games.baccarat-panel')
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
{{--
|
||||
文件功能:每日签到日历弹窗。
|
||||
展示当月签到状态、今日签到入口、补签卡数量,并支持快速购买补签卡。
|
||||
--}}
|
||||
|
||||
<style>
|
||||
.daily-sign-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2600;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.daily-sign-modal {
|
||||
width: min(520px, 96vw);
|
||||
max-height: min(610px, 96vh);
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(15, 23, 42, 0.28);
|
||||
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.daily-sign-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(90deg, #f0fdfa, #ffffff);
|
||||
}
|
||||
|
||||
.daily-sign-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.daily-sign-modal-close {
|
||||
border: none;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
border-radius: 7px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.daily-sign-modal-body {
|
||||
padding: 6px 10px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.daily-sign-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.daily-sign-summary-card {
|
||||
border: 1px solid #dbeafe;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daily-sign-summary-card strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.daily-sign-summary-card span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.daily-sign-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.daily-sign-action-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
background: #0f766e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.daily-sign-action-btn.secondary {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.daily-sign-month-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 3px 0 4px;
|
||||
}
|
||||
|
||||
.daily-sign-month-bar button {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
border-radius: 7px;
|
||||
padding: 3px 7px;
|
||||
cursor: pointer;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.daily-sign-month-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.daily-sign-weekdays,
|
||||
.daily-sign-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.daily-sign-weekday {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.daily-sign-day {
|
||||
min-height: 32px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
color: #334155;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.daily-sign-day.blank {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.daily-sign-day.signed {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.daily-sign-day.missed {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daily-sign-day.today {
|
||||
box-shadow: inset 0 0 0 2px #14b8a6;
|
||||
}
|
||||
|
||||
.daily-sign-day.future {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.daily-sign-day .day-num {
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.daily-sign-day .day-state {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.daily-sign-rewards {
|
||||
margin-top: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.daily-sign-rewards-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #0f172a;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.daily-sign-rewards-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.daily-sign-reward-card {
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
padding: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daily-sign-reward-card.active {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
box-shadow: inset 0 0 0 1px #14b8a6;
|
||||
}
|
||||
|
||||
.daily-sign-reward-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
color: #0f766e;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-sign-reward-name {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-sign-reward-desc {
|
||||
margin-top: 1px;
|
||||
color: #64748b;
|
||||
font-size: 8px;
|
||||
line-height: 1.15;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.daily-sign-summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.daily-sign-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.daily-sign-day {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.daily-sign-rewards-list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="daily-sign-modal" class="daily-sign-modal-overlay" onclick="if(event.target === this) closeDailySignInModal()">
|
||||
<div class="daily-sign-modal">
|
||||
<div class="daily-sign-modal-head">
|
||||
<div class="daily-sign-modal-title">✅ 每日签到</div>
|
||||
<button type="button" class="daily-sign-modal-close" onclick="closeDailySignInModal()">×</button>
|
||||
</div>
|
||||
<div class="daily-sign-modal-body">
|
||||
<div class="daily-sign-summary">
|
||||
<div class="daily-sign-summary-card">
|
||||
<strong id="daily-sign-streak">连续 0 天</strong>
|
||||
<span id="daily-sign-preview">加载中...</span>
|
||||
</div>
|
||||
<div class="daily-sign-summary-card">
|
||||
<strong id="daily-sign-card-count">补签卡 0 张</strong>
|
||||
<span id="daily-sign-card-price">可在商店购买</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="daily-sign-actions">
|
||||
<button type="button" id="daily-sign-claim-btn" class="daily-sign-action-btn" onclick="claimDailySignInFromModal()">今日签到</button>
|
||||
<button type="button" id="daily-sign-buy-card-btn" class="daily-sign-action-btn secondary" onclick="buyDailySignRepairCard()">购买补签卡</button>
|
||||
</div>
|
||||
<div class="daily-sign-month-bar">
|
||||
<button type="button" onclick="loadDailySignInCalendar(window.dailySignInState?.prevMonth)">上月</button>
|
||||
<div id="daily-sign-month-label" class="daily-sign-month-label">本月</div>
|
||||
<button type="button" onclick="loadDailySignInCalendar(window.dailySignInState?.nextMonth)">下月</button>
|
||||
</div>
|
||||
<div class="daily-sign-weekdays">
|
||||
<div class="daily-sign-weekday">日</div>
|
||||
<div class="daily-sign-weekday">一</div>
|
||||
<div class="daily-sign-weekday">二</div>
|
||||
<div class="daily-sign-weekday">三</div>
|
||||
<div class="daily-sign-weekday">四</div>
|
||||
<div class="daily-sign-weekday">五</div>
|
||||
<div class="daily-sign-weekday">六</div>
|
||||
</div>
|
||||
<div id="daily-sign-calendar-grid" class="daily-sign-grid" style="margin-top:4px;"></div>
|
||||
<div class="daily-sign-rewards">
|
||||
<div class="daily-sign-rewards-head">
|
||||
<span>连续奖励目标</span>
|
||||
<span id="daily-sign-reward-progress" style="font-size:11px;color:#64748b;">当前 0 天</span>
|
||||
</div>
|
||||
<div id="daily-sign-rewards-list" class="daily-sign-rewards-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,6 +186,8 @@ $welcomeMessages = [
|
||||
</div>
|
||||
<div style="font-size:10px;color:#4338ca;padding:10px 2px 8px;">快捷入口</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;">
|
||||
<button type="button" onclick="closeFeatureMenu();quickDailySignIn()"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">✅ 签到</button>
|
||||
<button type="button" onclick="runFeatureShortcut('shop')"
|
||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🛍 商店</button>
|
||||
<button type="button" onclick="runFeatureShortcut('vip')"
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<button class="mobile-drawer-close" onclick="closeMobileDrawer()">✕</button>
|
||||
</div>
|
||||
<div class="mobile-drawer-body">
|
||||
<div class="mobile-tool-btn" onclick="closeMobileDrawer();quickDailySignIn();">✅<br>签到</div>
|
||||
<div class="mobile-tool-btn" onclick="closeMobileDrawer();openShopModal();">🛒<br>商店</div>
|
||||
<div class="mobile-tool-btn" onclick="closeMobileDrawer();openVipModal();">👑<br>会员</div>
|
||||
<div class="mobile-tool-btn" onclick="closeMobileDrawer();saveExp();">💾<br>存点</div>
|
||||
|
||||
@@ -1204,7 +1204,7 @@ async function generateWechatBindCode() {
|
||||
{
|
||||
label: '🎭 道具',
|
||||
desc: '',
|
||||
type: 'one_time'
|
||||
type: 'tools'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1212,7 +1212,7 @@ async function generateWechatBindCode() {
|
||||
list.innerHTML = '';
|
||||
|
||||
groups.forEach(g => {
|
||||
const items = data.items.filter(i => i.type === g.type);
|
||||
const items = data.items.filter(i => g.type === 'tools' ? ['one_time', 'sign_repair'].includes(i.type) : i.type === g.type);
|
||||
if (!items.length) return;
|
||||
|
||||
// 分组标题(独占一整行)
|
||||
@@ -1235,6 +1235,9 @@ async function generateWechatBindCode() {
|
||||
const effName = effItem ? effItem.name : effKey;
|
||||
groupSuffix =
|
||||
` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#16a34a;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">✅ 已激活:${effName}</span>`;
|
||||
} else if (g.type === 'tools' && (data.sign_repair_card_count || 0) > 0) {
|
||||
groupSuffix =
|
||||
` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#0f766e;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">🎫 可用 ${data.sign_repair_card_count} 张</span>`;
|
||||
}
|
||||
header.innerHTML = `${g.label}${groupSuffix}${g.desc ? ` <span>${g.desc}</span>` : ''}`;
|
||||
list.appendChild(header);
|
||||
@@ -1295,10 +1298,15 @@ async function generateWechatBindCode() {
|
||||
btn.className = 'shop-btn';
|
||||
btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`;
|
||||
btn.onclick = async () => {
|
||||
let quantity = 1;
|
||||
if (item.type === 'sign_repair') {
|
||||
quantity = await window.promptSignRepairQuantity?.(item);
|
||||
if (quantity === null || quantity === undefined) return;
|
||||
}
|
||||
const confirmMsg =
|
||||
`确认花费 💰 ${Number(item.price).toLocaleString()} 金币购买\n【${item.name}】吗?`;
|
||||
`确认花费 💰 ${Number(item.price * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ' × ' + quantity : ''} 吗?`;
|
||||
const ok = await window.chatDialog.confirm(confirmMsg, '确认购买');
|
||||
if (ok) buyItem(item.id, item.name, item.price, 'all', '');
|
||||
if (ok) buyItem(item.id, item.name, item.price, 'all', '', quantity);
|
||||
};
|
||||
}
|
||||
card.appendChild(btn);
|
||||
@@ -1341,7 +1349,7 @@ async function generateWechatBindCode() {
|
||||
};
|
||||
|
||||
/** 购买商品(最终执行) */
|
||||
window.buyItem = function(itemId, name, price, recipient, message) {
|
||||
window.buyItem = function(itemId, name, price, recipient, message, quantity = 1) {
|
||||
const roomId = window.chatContext?.roomId ?? 0;
|
||||
fetch('{{ route('shop.buy') }}', {
|
||||
method: 'POST',
|
||||
@@ -1354,6 +1362,7 @@ async function generateWechatBindCode() {
|
||||
item_id: itemId,
|
||||
recipient: recipient || 'all',
|
||||
message: message || '',
|
||||
quantity: quantity || 1,
|
||||
room_id: roomId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -423,6 +423,544 @@
|
||||
renderUserList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务端响应中提取最新金币余额。
|
||||
*
|
||||
* @param {Record<string, any>} data 接口响应数据
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function resolveDailySignInGoldBalance(data) {
|
||||
const candidates = [
|
||||
data?.data?.user?.jjb,
|
||||
data?.data?.user?.gold,
|
||||
data?.data?.presence?.jjb,
|
||||
data?.data?.presence?.gold,
|
||||
data?.data?.my_jjb,
|
||||
data?.data?.new_jjb,
|
||||
data?.data?.balance,
|
||||
data?.my_jjb,
|
||||
data?.new_jjb,
|
||||
data?.balance,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const amount = Number(candidate);
|
||||
|
||||
if (Number.isFinite(amount)) {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从签到响应中提取当前用户最新在线载荷。
|
||||
*
|
||||
* @param {Record<string, any>} data 接口响应数据
|
||||
* @returns {Record<string, any>|null}
|
||||
*/
|
||||
function resolveDailySignInPresencePayload(data) {
|
||||
const candidates = [
|
||||
data?.data?.presence,
|
||||
data?.data?.online_user,
|
||||
data?.data?.onlineUser,
|
||||
data?.data?.user_payload,
|
||||
data?.data?.userPayload,
|
||||
data?.data?.user,
|
||||
data?.presence,
|
||||
data?.online_user,
|
||||
data?.onlineUser,
|
||||
];
|
||||
|
||||
return candidates.find(payload => payload && typeof payload === 'object') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从签到响应中提取签到身份字段。
|
||||
*
|
||||
* @param {Record<string, any>} data 接口响应数据
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function resolveDailySignInIdentityPayload(data) {
|
||||
const identity = data?.data?.identity || data?.data?.sign_identity || data?.identity || data?.sign_identity;
|
||||
|
||||
if (!identity || typeof identity !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
sign_identity_key: identity.key ?? identity.sign_identity_key ?? identity.code ?? '',
|
||||
sign_identity_label: identity.label ?? identity.name ?? identity.sign_identity_label ?? '',
|
||||
sign_identity_icon: identity.icon ?? identity.sign_identity_icon ?? '',
|
||||
sign_identity_color: identity.color ?? identity.sign_identity_color ?? undefined,
|
||||
sign_identity_bg_color: identity.bg_color ?? identity.background_color ?? identity.sign_identity_bg_color ?? undefined,
|
||||
sign_identity_border_color: identity.border_color ?? identity.sign_identity_border_color ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将签到成功结果同步到金币余额与在线名单。
|
||||
*
|
||||
* @param {Record<string, any>} data 接口响应数据
|
||||
*/
|
||||
function applyDailySignInResult(data) {
|
||||
const balance = resolveDailySignInGoldBalance(data);
|
||||
const payload = resolveDailySignInPresencePayload(data);
|
||||
const identityPayload = resolveDailySignInIdentityPayload(data);
|
||||
const username = window.chatContext?.username;
|
||||
|
||||
if (balance !== null && window.chatContext) {
|
||||
window.chatContext.userJjb = balance;
|
||||
window.chatContext.myGold = balance;
|
||||
}
|
||||
|
||||
if (username) {
|
||||
hydrateOnlineUserPayload(username, {
|
||||
...(payload || {}),
|
||||
...identityPayload,
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
renderUserList();
|
||||
}
|
||||
|
||||
window.dailySignInState = {
|
||||
month: null,
|
||||
prevMonth: null,
|
||||
nextMonth: null,
|
||||
repairCardItem: null,
|
||||
repairCardCount: 0,
|
||||
rewardRules: [],
|
||||
status: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开每日签到日历弹窗。
|
||||
*/
|
||||
window.openDailySignInModal = async function openDailySignInModal() {
|
||||
const modal = document.getElementById('daily-sign-modal');
|
||||
|
||||
if (!window.chatContext?.dailySignInCalendarUrl || !modal) {
|
||||
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(window.dailySignInState.month),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭每日签到日历弹窗。
|
||||
*/
|
||||
window.closeDailySignInModal = function closeDailySignInModal() {
|
||||
const modal = document.getElementById('daily-sign-modal');
|
||||
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速签到入口:打开日历,让用户能看到当月签到和补签状态。
|
||||
*/
|
||||
window.quickDailySignIn = async function quickDailySignIn() {
|
||||
await window.openDailySignInModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* 拉取今日签到状态。
|
||||
*/
|
||||
async function loadDailySignInStatus() {
|
||||
const statusUrl = window.chatContext?.dailySignInStatusUrl;
|
||||
|
||||
if (!statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(statusUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error') {
|
||||
throw new Error(data?.message || '签到状态加载失败');
|
||||
}
|
||||
|
||||
window.dailySignInState.status = data.data || {};
|
||||
renderDailySignInStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取并渲染指定月份签到日历。
|
||||
*
|
||||
* @param {string|null|undefined} month 月份 YYYY-MM
|
||||
*/
|
||||
window.loadDailySignInCalendar = async function loadDailySignInCalendar(month) {
|
||||
const calendarUrl = window.chatContext?.dailySignInCalendarUrl;
|
||||
|
||||
if (!calendarUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(calendarUrl, window.location.origin);
|
||||
if (month) {
|
||||
url.searchParams.set('month', month);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error') {
|
||||
throw new Error(data?.message || '签到日历加载失败');
|
||||
}
|
||||
|
||||
const payload = data.data || {};
|
||||
window.dailySignInState.month = payload.month || month || null;
|
||||
window.dailySignInState.prevMonth = payload.prev_month || null;
|
||||
window.dailySignInState.nextMonth = payload.next_month || null;
|
||||
window.dailySignInState.repairCardItem = payload.sign_repair_card_item || null;
|
||||
window.dailySignInState.repairCardCount = Number(payload.makeup_card_count || 0);
|
||||
window.dailySignInState.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : [];
|
||||
renderDailySignInCalendar(payload);
|
||||
renderDailySignInStatus();
|
||||
renderDailySignInRewardRules();
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染签到状态摘要。
|
||||
*/
|
||||
function renderDailySignInStatus() {
|
||||
const status = window.dailySignInState.status || {};
|
||||
const streakEl = document.getElementById('daily-sign-streak');
|
||||
const previewEl = document.getElementById('daily-sign-preview');
|
||||
const cardCountEl = document.getElementById('daily-sign-card-count');
|
||||
const cardPriceEl = document.getElementById('daily-sign-card-price');
|
||||
const claimBtn = document.getElementById('daily-sign-claim-btn');
|
||||
const buyBtn = document.getElementById('daily-sign-buy-card-btn');
|
||||
const cardItem = window.dailySignInState.repairCardItem;
|
||||
|
||||
if (streakEl) {
|
||||
streakEl.textContent = `连续 ${Number(status.current_streak_days || 0)} 天`;
|
||||
}
|
||||
if (previewEl) {
|
||||
const rule = status.preview_rule || {};
|
||||
const parts = [];
|
||||
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward} 金币`);
|
||||
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward} 经验`);
|
||||
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward} 魅力`);
|
||||
previewEl.textContent = status.signed_today ? '今日已签到' : `今日可领:${parts.join(' + ') || '签到奖励'}`;
|
||||
}
|
||||
if (cardCountEl) {
|
||||
cardCountEl.textContent = `补签卡 ${window.dailySignInState.repairCardCount || 0} 张`;
|
||||
}
|
||||
if (cardPriceEl) {
|
||||
cardPriceEl.textContent = cardItem ? `${cardItem.icon || '🗓️'} ${cardItem.name}:${Number(cardItem.price || 0).toLocaleString()} 金币` : '补签卡暂未上架';
|
||||
}
|
||||
if (claimBtn) {
|
||||
claimBtn.disabled = !!status.signed_today;
|
||||
claimBtn.textContent = status.signed_today ? '今日已签到' : '今日签到';
|
||||
claimBtn.style.opacity = status.signed_today ? '0.55' : '1';
|
||||
claimBtn.style.cursor = status.signed_today ? 'not-allowed' : 'pointer';
|
||||
}
|
||||
if (buyBtn) {
|
||||
buyBtn.disabled = !cardItem?.id;
|
||||
buyBtn.style.opacity = cardItem?.id ? '1' : '0.55';
|
||||
buyBtn.style.cursor = cardItem?.id ? 'pointer' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染签到月历。
|
||||
*
|
||||
* @param {Record<string, any>} payload 日历响应数据
|
||||
*/
|
||||
function renderDailySignInCalendar(payload) {
|
||||
const grid = document.getElementById('daily-sign-calendar-grid');
|
||||
const label = document.getElementById('daily-sign-month-label');
|
||||
|
||||
if (!grid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
label.textContent = payload.month_label || payload.month || '本月';
|
||||
}
|
||||
|
||||
const days = Array.isArray(payload.days) ? payload.days : [];
|
||||
grid.innerHTML = '';
|
||||
|
||||
const firstWeekday = Number(days[0]?.weekday || 0);
|
||||
for (let i = 0; i < firstWeekday; i += 1) {
|
||||
const blank = document.createElement('div');
|
||||
blank.className = 'daily-sign-day blank';
|
||||
grid.appendChild(blank);
|
||||
}
|
||||
|
||||
days.forEach(day => {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'daily-sign-day';
|
||||
if (day.signed) cell.classList.add('signed');
|
||||
if (day.can_makeup) cell.classList.add('missed');
|
||||
if (day.is_today) cell.classList.add('today');
|
||||
if (day.is_future) cell.classList.add('future');
|
||||
|
||||
const stateText = day.signed
|
||||
? `${day.is_makeup ? '补签' : '已签'} ${day.streak_days || ''}天`
|
||||
: (day.is_future ? '未到' : (day.is_today ? '今天' : '漏签'));
|
||||
|
||||
cell.innerHTML = `<span class="day-num">${day.day}</span><span class="day-state">${escapeHtml(stateText)}</span>`;
|
||||
cell.title = day.reward_text || stateText;
|
||||
if (day.can_makeup) {
|
||||
cell.onclick = () => makeupDailySignIn(day.date);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染连续签到奖励目标列表。
|
||||
*/
|
||||
function renderDailySignInRewardRules() {
|
||||
const list = document.getElementById('daily-sign-rewards-list');
|
||||
const progress = document.getElementById('daily-sign-reward-progress');
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDays = Number(window.dailySignInState.status?.current_streak_days || 0);
|
||||
const rules = window.dailySignInState.rewardRules || [];
|
||||
|
||||
if (progress) {
|
||||
progress.textContent = `当前 ${currentDays} 天`;
|
||||
}
|
||||
|
||||
if (!rules.length) {
|
||||
list.innerHTML = '<div style="font-size:12px;color:#94a3b8;padding:4px;">暂无奖励规则</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = rules.map(rule => {
|
||||
const streakDays = Number(rule.streak_days || 0);
|
||||
const parts = [];
|
||||
if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}金`);
|
||||
if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`);
|
||||
if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`);
|
||||
|
||||
const icon = escapeHtml(rule.identity_badge_icon || '✅');
|
||||
const name = escapeHtml(rule.identity_badge_name || '签到奖励');
|
||||
const color = escapeHtml(rule.identity_badge_color || '#0f766e');
|
||||
const activeClass = currentDays >= streakDays ? ' active' : '';
|
||||
const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)} 天`;
|
||||
const rewardText = escapeHtml(parts.join(' + ') || '签到记录');
|
||||
|
||||
return `
|
||||
<div class="daily-sign-reward-card${activeClass}" title="${name} · ${rewardText} · ${escapeHtml(distanceText)}">
|
||||
<div class="daily-sign-reward-title">
|
||||
<span>第 ${streakDays} 天</span>
|
||||
<span style="color:${color};">${icon}</span>
|
||||
</div>
|
||||
<div class="daily-sign-reward-name">${name}</div>
|
||||
<div class="daily-sign-reward-desc">${rewardText}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在日历弹窗中领取今日签到。
|
||||
*/
|
||||
window.claimDailySignInFromModal = async function claimDailySignInFromModal() {
|
||||
const claimUrl = window.chatContext?.dailySignInClaimUrl;
|
||||
|
||||
if (!claimUrl) {
|
||||
window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(claimUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_id: window.chatContext?.roomId ?? null,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error' || data?.ok === false) {
|
||||
throw new Error(data?.message || '签到失败');
|
||||
}
|
||||
|
||||
applyDailySignInResult(data);
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(window.dailySignInState.month),
|
||||
]);
|
||||
renderDailySignInRewardRules();
|
||||
window.chatToast?.show({
|
||||
title: '签到成功',
|
||||
message: data?.message || '今日签到奖励已到账。',
|
||||
icon: data?.data?.sign_identity_icon || data?.data?.identity?.icon || '✅',
|
||||
color: '#16a34a',
|
||||
duration: 3200,
|
||||
});
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert(error.message || '签到失败,请稍后重试。', '签到失败', '#cc4444');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用补签卡补签指定日期。
|
||||
*
|
||||
* @param {string} targetDate 目标日期 YYYY-MM-DD
|
||||
*/
|
||||
async function makeupDailySignIn(targetDate) {
|
||||
const makeupUrl = window.chatContext?.dailySignInMakeupUrl;
|
||||
|
||||
if (!makeupUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await window.chatDialog?.confirm(`确认使用 1 张补签卡补签 ${targetDate} 吗?`, '确认补签');
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(makeupUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_date: targetDate,
|
||||
room_id: window.chatContext?.roomId ?? null,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data?.status === 'error' || data?.ok === false) {
|
||||
const firstError = data?.errors ? Object.values(data.errors).flat()[0] : null;
|
||||
throw new Error(firstError || data?.message || '补签失败');
|
||||
}
|
||||
|
||||
applyDailySignInResult(data);
|
||||
await Promise.all([
|
||||
loadDailySignInStatus(),
|
||||
loadDailySignInCalendar(window.dailySignInState.month),
|
||||
]);
|
||||
renderDailySignInRewardRules();
|
||||
window.chatToast?.show({
|
||||
title: '补签成功',
|
||||
message: data?.message || '补签已完成。',
|
||||
icon: '🗓️',
|
||||
color: '#0f766e',
|
||||
duration: 3200,
|
||||
});
|
||||
} catch (error) {
|
||||
window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 询问补签卡购买数量。
|
||||
*
|
||||
* @param {Record<string, any>} item 补签卡商品
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
window.promptSignRepairQuantity = async function promptSignRepairQuantity(item) {
|
||||
const unitPrice = Number(item?.price || 0);
|
||||
const ruleText = item?.description || '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。';
|
||||
const promptPromise = window.chatDialog?.prompt(
|
||||
`请输入要购买的补签卡数量(1-99):\n单价 ${unitPrice.toLocaleString()} 金币\n说明:${ruleText}`,
|
||||
'1',
|
||||
'购买补签卡',
|
||||
'#0f766e'
|
||||
);
|
||||
const inputEl = document.getElementById('global-dialog-input');
|
||||
const previousInputStyle = inputEl?.getAttribute('style') || '';
|
||||
|
||||
if (inputEl) {
|
||||
inputEl.style.minHeight = '40px';
|
||||
inputEl.style.height = '40px';
|
||||
inputEl.style.resize = 'none';
|
||||
inputEl.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
const rawQuantity = await promptPromise;
|
||||
|
||||
if (inputEl) {
|
||||
inputEl.setAttribute('style', previousInputStyle);
|
||||
}
|
||||
|
||||
if (rawQuantity === null || rawQuantity === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quantity = Number.parseInt(String(rawQuantity).trim(), 10);
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
|
||||
window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444');
|
||||
return null;
|
||||
}
|
||||
|
||||
return quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在签到弹窗内快速购买补签卡。
|
||||
*/
|
||||
window.buyDailySignRepairCard = async function buyDailySignRepairCard() {
|
||||
const item = window.dailySignInState.repairCardItem;
|
||||
|
||||
if (!item?.id) {
|
||||
window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = await window.promptSignRepairQuantity(item);
|
||||
if (quantity === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPrice = Number(item.price || 0) * quantity;
|
||||
const ok = await window.chatDialog?.confirm(`确认花费 ${totalPrice.toLocaleString()} 金币购买【${item.name}】× ${quantity} 吗?\n说明:补签卡只能补签本月未签到日期。`, '购买补签卡');
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.buyItem === 'function') {
|
||||
window.buyItem(item.id, item.name, item.price, 'all', '', quantity);
|
||||
setTimeout(() => {
|
||||
loadDailySignInCalendar(window.dailySignInState.month);
|
||||
loadDailySignInStatus();
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
|
||||
window.openShopModal?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置新的当日状态。
|
||||
*
|
||||
@@ -1512,7 +2050,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建原有徽标(职务 / 管理员 / VIP)。
|
||||
* 构建职务 / 管理员徽标。
|
||||
*
|
||||
* @param {Record<string, any>} user 用户在线载荷
|
||||
* @param {string} username 用户名
|
||||
@@ -1531,15 +2069,25 @@
|
||||
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
|
||||
}
|
||||
|
||||
if (user.vip_icon) {
|
||||
const vipColor = user.vip_color || '#f59e0b';
|
||||
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
|
||||
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
|
||||
return '';
|
||||
}
|
||||
|
||||
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
|
||||
/**
|
||||
* 构建用户 VIP 徽标。
|
||||
*
|
||||
* @param {Record<string, any>} user 用户在线载荷
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildUserVipBadgeHtml(user) {
|
||||
if (!user.vip_icon) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
const vipColor = user.vip_color || '#f59e0b';
|
||||
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
|
||||
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
|
||||
|
||||
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1559,30 +2107,65 @@
|
||||
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
|
||||
|
||||
return `
|
||||
<span style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;border-radius:999px;background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca;font-size:11px;line-height:18px;vertical-align:middle;"
|
||||
<span class="user-badge-icon" style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;max-width:78px;overflow:hidden;white-space:nowrap;border-radius:999px;background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca;font-size:11px;line-height:18px;vertical-align:middle;"
|
||||
data-instant-tooltip="${safeTooltip}">
|
||||
<span style="font-size:11px;line-height:1;">${safeIcon}</span>
|
||||
<span style="line-height:1;">${safeLabel}</span>
|
||||
<span style="font-size:11px;line-height:1;flex-shrink:0;">${safeIcon}</span>
|
||||
<span style="line-height:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${safeLabel}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 3 秒节奏在原有徽标与状态徽标之间切换。
|
||||
* 构建签到身份徽标。
|
||||
*
|
||||
* @param {Record<string, any>} user 用户在线载荷
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildUserSignIdentityBadgeHtml(user) {
|
||||
const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? '');
|
||||
const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? '');
|
||||
const identityIcon = String(user.sign_identity_icon ?? '');
|
||||
|
||||
if (!identityKey || !identityLabel || !identityIcon) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const color = String(user.sign_identity_color || '#0f766e');
|
||||
const bgColor = String(user.sign_identity_bg_color || '#ccfbf1');
|
||||
const borderColor = String(user.sign_identity_border_color || '#5eead4');
|
||||
const safeIcon = escapeHtml(identityIcon);
|
||||
const safeLabel = escapeHtml(identityLabel);
|
||||
const safeTooltip = escapeHtml(`签到 · ${identityLabel}`);
|
||||
|
||||
return `
|
||||
<span class="user-badge-icon" style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;max-width:78px;overflow:hidden;white-space:nowrap;border-radius:999px;background:${bgColor};border:1px solid ${borderColor};color:${color};font-size:11px;line-height:18px;vertical-align:middle;"
|
||||
data-instant-tooltip="${safeTooltip}">
|
||||
<span style="font-size:11px;line-height:1;flex-shrink:0;">${safeIcon}</span>
|
||||
<span style="line-height:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${safeLabel}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 3 秒节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。
|
||||
*
|
||||
* @param {Record<string, any>} user 用户在线载荷
|
||||
* @param {string} username 用户名
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildUserBadgeHtml(user, username) {
|
||||
const statusBadge = buildUserStatusBadgeHtml(user);
|
||||
const primaryBadge = buildUserPrimaryBadgeHtml(user, username);
|
||||
const badges = [
|
||||
buildUserSignIdentityBadgeHtml(user),
|
||||
buildUserStatusBadgeHtml(user),
|
||||
buildUserPrimaryBadgeHtml(user, username),
|
||||
buildUserVipBadgeHtml(user),
|
||||
].filter(Boolean);
|
||||
|
||||
if (statusBadge && primaryBadge) {
|
||||
return userBadgeRotationTick % 2 === 0 ? statusBadge : primaryBadge;
|
||||
if (badges.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return statusBadge || primaryBadge;
|
||||
return badges[userBadgeRotationTick % badges.length];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1603,9 +2186,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 名单中“状态 / 原徽标”双轨展示时,每 3 秒只刷新徽标槽位,不重建头像行。
|
||||
// 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。
|
||||
window.setInterval(() => {
|
||||
userBadgeRotationTick = (userBadgeRotationTick + 1) % 2;
|
||||
userBadgeRotationTick = (userBadgeRotationTick + 1) % 4;
|
||||
refreshRenderedUserBadges();
|
||||
syncDailyStatusUi();
|
||||
}, 3000);
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
{
|
||||
label: '🎭 道具',
|
||||
desc: '',
|
||||
type: 'one_time'
|
||||
type: 'tools'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
itemsEl.innerHTML = '';
|
||||
|
||||
groups.forEach(g => {
|
||||
const items = data.items.filter(i => i.type === g.type);
|
||||
const items = data.items.filter(i => g.type === 'tools' ? ['one_time', 'sign_repair'].includes(i.type) : i.type === g.type);
|
||||
if (!items.length) return;
|
||||
|
||||
const section = document.createElement('div');
|
||||
@@ -417,7 +417,7 @@
|
||||
} else {
|
||||
btn.className = 'shop-btn';
|
||||
btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`;
|
||||
btn.onclick = () => buyItem(item.id, item.name, item.price);
|
||||
btn.onclick = () => buyItem(item.id, item.name, item.price, item.type);
|
||||
}
|
||||
row.appendChild(btn);
|
||||
card.appendChild(row);
|
||||
@@ -457,14 +457,23 @@
|
||||
}
|
||||
|
||||
/** 购买商品 */
|
||||
window.buyItem = function(itemId, name, price) {
|
||||
// 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发
|
||||
window.chatDialog.confirm(
|
||||
`确定花费 ${Number(price).toLocaleString()} 金币购买【${name}】吗?`,
|
||||
'确认购买',
|
||||
'#336699'
|
||||
).then(ok => {
|
||||
if (!ok) return;
|
||||
window.buyItem = async function(itemId, name, price, typeOrRecipient = '', message = '', presetQuantity = null) {
|
||||
const knownTypes = ['instant', 'duration', 'one_time', 'ring', 'auto_fishing', 'sign_repair'];
|
||||
const type = knownTypes.includes(typeOrRecipient) ? typeOrRecipient : '';
|
||||
const recipient = type === '' ? (typeOrRecipient || 'all') : 'all';
|
||||
let quantity = Number(presetQuantity || 1);
|
||||
|
||||
if (type === 'sign_repair') {
|
||||
quantity = await window.promptSignRepairQuantity?.({
|
||||
name,
|
||||
price,
|
||||
description: '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。',
|
||||
});
|
||||
if (quantity === null || quantity === undefined) return;
|
||||
}
|
||||
|
||||
const signRepairNotice = type === 'sign_repair' ? '\n说明:补签卡只能补签本月未签到日期。' : '';
|
||||
const submitPurchase = () => {
|
||||
fetch('{{ route('shop.buy') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -473,7 +482,10 @@
|
||||
'X-CSRF-TOKEN': _csrf()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
item_id: itemId
|
||||
item_id: itemId,
|
||||
recipient,
|
||||
message: message || '',
|
||||
quantity: quantity || 1
|
||||
}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
@@ -494,6 +506,21 @@
|
||||
}
|
||||
})
|
||||
.catch(() => showShopToast('⚠ 网络异常,请重试', false));
|
||||
};
|
||||
|
||||
if (presetQuantity !== null) {
|
||||
submitPurchase();
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发
|
||||
window.chatDialog.confirm(
|
||||
`确定花费 ${Number(price * quantity).toLocaleString()} 金币购买【${name}】${quantity > 1 ? ' × ' + quantity : ''} 吗?${signRepairNotice}`,
|
||||
'确认购买',
|
||||
'#336699'
|
||||
).then(ok => {
|
||||
if (!ok) return;
|
||||
submitPurchase();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -692,6 +692,20 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div x-show="userInfo.sign_in?.streak_days || userInfo.sign_in?.identity"
|
||||
style="margin-top: 6px;">
|
||||
<span style="display: inline-flex;align-items: center;gap: 6px;padding: 2px 10px;
|
||||
border-radius: 999px;white-space: nowrap;width: fit-content;max-width: 100%;
|
||||
font-size: 11px;font-weight: bold;background: #f0fdfa;border: 1px solid #99f6e4;"
|
||||
:style="'color:' + (userInfo.sign_in?.identity?.color || '#0f766e')">
|
||||
<span x-text="userInfo.sign_in?.identity?.icon || '✅'"></span>
|
||||
<span x-text="'连续签到 ' + (userInfo.sign_in?.streak_days || 0) + ' 天'"></span>
|
||||
<template x-if="userInfo.sign_in?.identity?.label">
|
||||
<span x-text="'· ' + userInfo.sign_in.identity.label"></span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: #999; margin-top: 4px;">
|
||||
加入: <span x-text="userInfo.created_at"></span>
|
||||
</div>
|
||||
|
||||
@@ -277,6 +277,25 @@
|
||||
}
|
||||
}">
|
||||
<form @submit.prevent="saveProfile">
|
||||
@php
|
||||
$currentSignIdentity = Auth::user()->currentSignInIdentity();
|
||||
$latestSignIn = Auth::user()->dailySignIns()->first();
|
||||
@endphp
|
||||
<div class="mb-4 rounded-lg border border-teal-100 bg-teal-50 px-3 py-2 text-sm text-teal-800">
|
||||
<div class="font-bold">每日签到</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>连续签到 {{ (int) ($latestSignIn?->streak_days ?? 0) }} 天</span>
|
||||
@if ($currentSignIdentity)
|
||||
<span class="inline-flex items-center gap-1 rounded-full border border-teal-200 bg-white px-2 py-0.5 font-bold"
|
||||
style="color: {{ $currentSignIdentity->badge_color ?? '#0f766e' }}">
|
||||
<span>{{ $currentSignIdentity->badge_icon ?? '✅' }}</span>
|
||||
<span>{{ $currentSignIdentity->badge_name }}</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="text-teal-600">暂无签到身份</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">性别</label>
|
||||
<select x-model="profileData.sex"
|
||||
|
||||
@@ -100,6 +100,10 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::put('/user/profile', [UserController::class, 'updateProfile'])->name('user.update_profile');
|
||||
Route::put('/user/chat-preferences', [UserController::class, 'updateChatPreferences'])->name('user.update_chat_preferences');
|
||||
Route::put('/user/daily-status', [UserController::class, 'updateDailyStatus'])->name('user.update_daily_status');
|
||||
Route::get('/daily-sign-in/status', [\App\Http\Controllers\DailySignInController::class, 'status'])->name('daily-sign-in.status');
|
||||
Route::get('/daily-sign-in/calendar', [\App\Http\Controllers\DailySignInController::class, 'calendar'])->name('daily-sign-in.calendar');
|
||||
Route::post('/daily-sign-in/claim', [\App\Http\Controllers\DailySignInController::class, 'claim'])->name('daily-sign-in.claim');
|
||||
Route::post('/daily-sign-in/makeup', [\App\Http\Controllers\DailySignInController::class, 'makeup'])->name('daily-sign-in.makeup');
|
||||
Route::post('/user/generate-wechat-code', [UserController::class, 'generateWechatCode'])->name('user.generate_wechat_code');
|
||||
Route::post('/user/unbind-wechat', [UserController::class, 'unbindWechat'])->name('user.unbind_wechat');
|
||||
Route::post('/user/send-email-code', [\App\Http\Controllers\Api\VerificationController::class, 'sendEmailCode'])->name('user.send_email_code');
|
||||
@@ -458,6 +462,13 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
||||
Route::delete('/autoact/{autoact}', [\App\Http\Controllers\Admin\AutoactController::class, 'destroy'])->name('autoact.destroy');
|
||||
Route::post('/autoact/{autoact}/toggle', [\App\Http\Controllers\Admin\AutoactController::class, 'toggle'])->name('autoact.toggle');
|
||||
|
||||
// 每日签到奖励规则管理
|
||||
Route::get('/sign-in-rules', [\App\Http\Controllers\Admin\SignInRewardRuleController::class, 'index'])->name('sign-in-rules.index');
|
||||
Route::post('/sign-in-rules', [\App\Http\Controllers\Admin\SignInRewardRuleController::class, 'store'])->name('sign-in-rules.store');
|
||||
Route::put('/sign-in-rules/{signInRewardRule}', [\App\Http\Controllers\Admin\SignInRewardRuleController::class, 'update'])->name('sign-in-rules.update');
|
||||
Route::post('/sign-in-rules/{signInRewardRule}/toggle', [\App\Http\Controllers\Admin\SignInRewardRuleController::class, 'toggle'])->name('sign-in-rules.toggle');
|
||||
Route::delete('/sign-in-rules/{signInRewardRule}', [\App\Http\Controllers\Admin\SignInRewardRuleController::class, 'destroy'])->name('sign-in-rules.destroy');
|
||||
|
||||
// VIP 会员等级(含新增/编辑/删除)
|
||||
Route::get('/vip', [\App\Http\Controllers\Admin\VipController::class, 'index'])->name('vip.index');
|
||||
Route::get('/vip/{vip}/members', [\App\Http\Controllers\Admin\VipController::class, 'members'])->name('vip.members');
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到前台接口测试
|
||||
*
|
||||
* 覆盖签到领取、重复拦截、连续天数、身份有效期与聊天室播报。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\SignInRewardRule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPurchase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证每日签到前台接口的核心行为。
|
||||
*/
|
||||
class DailySignInControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 方法功能:每个用例前清理 Redis 与时间,避免跨测试污染。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Redis::flushall();
|
||||
Carbon::setTestNow('2026-04-24 10:00:00');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:每个用例后恢复系统时间。
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试首次签到会发放奖励、写入流水并生成身份。
|
||||
*/
|
||||
public function test_user_can_claim_daily_sign_in_reward(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 10, 'exp_num' => 0, 'meili' => 0]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 20,
|
||||
'charm_reward' => 3,
|
||||
'identity_badge_code' => 'sign_1',
|
||||
'identity_badge_name' => '签到新星',
|
||||
'identity_badge_icon' => '⭐',
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 7,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.sign_in.streak_days', 1)
|
||||
->assertJsonPath('data.user.jjb', 110)
|
||||
->assertJsonPath('data.identity.label', '签到新星');
|
||||
|
||||
$this->assertDatabaseHas('daily_sign_ins', [
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-24',
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 20,
|
||||
'charm_reward' => 3,
|
||||
]);
|
||||
$this->assertDatabaseHas('user_currency_logs', [
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'gold',
|
||||
'amount' => 100,
|
||||
'source' => CurrencySource::SIGN_IN->value,
|
||||
]);
|
||||
$this->assertDatabaseHas('user_identity_badges', [
|
||||
'user_id' => $user->id,
|
||||
'source' => 'sign_in',
|
||||
'badge_code' => 'sign_1',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试同一天重复签到不会重复发放奖励。
|
||||
*/
|
||||
public function test_duplicate_daily_sign_in_is_rejected_without_second_reward(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 0]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 50,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
|
||||
$currencyLogCount = \App\Models\UserCurrencyLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('source', CurrencySource::SIGN_IN->value)
|
||||
->count();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonPath('message', '今日已签到,请明天再来。');
|
||||
|
||||
$this->assertSame(1, DailySignIn::query()->where('user_id', $user->id)->count());
|
||||
$this->assertSame($currencyLogCount, \App\Models\UserCurrencyLog::query()->where('user_id', $user->id)->where('source', CurrencySource::SIGN_IN->value)->count());
|
||||
$this->assertSame(50, (int) $user->fresh()->jjb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连续签到会递增,断签后会重新从 1 开始。
|
||||
*/
|
||||
public function test_streak_increments_and_resets_after_gap(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], ['streak_days' => 2, 'gold_reward' => 2]);
|
||||
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1);
|
||||
|
||||
Carbon::setTestNow('2026-04-25 10:00:00');
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 2);
|
||||
|
||||
Carbon::setTestNow('2026-04-27 10:00:00');
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertJsonPath('data.sign_in.streak_days', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连续签到满一年后下一天会重新从 1 计算。
|
||||
*/
|
||||
public function test_streak_resets_after_yearly_cycle_is_completed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
DailySignIn::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-23',
|
||||
'streak_days' => 365,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('daily-sign-in.claim'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.sign_in.streak_days', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试闰年需要满 366 天后才重新计算。
|
||||
*/
|
||||
public function test_leap_year_streak_resets_after_366_days(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Carbon::setTestNow('2024-12-31 10:00:00');
|
||||
DailySignIn::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2024-12-30',
|
||||
'streak_days' => 365,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('daily-sign-in.claim'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.sign_in.streak_days', 366);
|
||||
|
||||
Carbon::setTestNow('2025-01-01 10:00:00');
|
||||
$this->actingAs($user)
|
||||
->postJson(route('daily-sign-in.claim'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.sign_in.streak_days', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试身份有效期过后不会继续出现在状态接口。
|
||||
*/
|
||||
public function test_expired_identity_is_not_returned_in_status(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
|
||||
'streak_days' => 1,
|
||||
'identity_badge_code' => 'short',
|
||||
'identity_badge_name' => '短期身份',
|
||||
'identity_badge_icon' => '⏳',
|
||||
'identity_duration_days' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
|
||||
|
||||
Carbon::setTestNow('2026-04-26 10:00:00');
|
||||
$this->actingAs($user)
|
||||
->getJson(route('daily-sign-in.status'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.identity', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试当前房间签到会写入聊天室通知并附带快速签到按钮。
|
||||
*/
|
||||
public function test_claim_with_room_broadcasts_chat_notice_with_quick_button(): void
|
||||
{
|
||||
Event::fake([MessageSent::class, UserStatusUpdated::class]);
|
||||
|
||||
$room = Room::create(['room_name' => 'testroom', 'door_open' => true]);
|
||||
$user = User::factory()->create(['jjb' => 0]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 20,
|
||||
]);
|
||||
|
||||
Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
|
||||
Redis::setex("room:{$room->id}:alive:{$user->username}", 90, 1);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.claim'), [
|
||||
'room_id' => $room->id,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$rawMessage = Redis::lindex("room:{$room->id}:messages", -1);
|
||||
$message = json_decode((string) $rawMessage, true);
|
||||
$this->assertSame('签到播报', $message['from_user']);
|
||||
$this->assertStringContainsString('快速签到', $message['content']);
|
||||
$this->assertStringContainsString('quickDailySignIn', $message['content']);
|
||||
|
||||
Event::assertDispatched(MessageSent::class, function (MessageSent $event) use ($room): bool {
|
||||
return $event->roomId === $room->id
|
||||
&& ($event->message['from_user'] ?? null) === '签到播报';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试不携带房间 ID 时不会发送聊天室通知。
|
||||
*/
|
||||
public function test_claim_without_room_does_not_write_chat_notice(): void
|
||||
{
|
||||
Event::fake([MessageSent::class]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], ['streak_days' => 1, 'gold_reward' => 1]);
|
||||
|
||||
$this->actingAs($user)->postJson(route('daily-sign-in.claim'))->assertOk();
|
||||
|
||||
$this->assertSame([], Redis::keys('room:*:messages'));
|
||||
Event::assertNotDispatched(MessageSent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试签到日历会展示已签、漏签和补签卡数量。
|
||||
*/
|
||||
public function test_calendar_returns_month_days_and_makeup_card_count(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
|
||||
UserPurchase::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $card->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 1200,
|
||||
]);
|
||||
DailySignIn::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-23',
|
||||
'streak_days' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-04']));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.month', '2026-04')
|
||||
->assertJsonPath('data.makeup_card_count', 1)
|
||||
->assertJsonPath('data.sign_repair_card_item.slug', 'sign_repair_card')
|
||||
->assertJsonPath('data.sign_repair_card_item.price', 10000)
|
||||
->assertJsonPath('data.reward_rules.0.streak_days', 1);
|
||||
|
||||
$days = collect($response->json('data.days'));
|
||||
$this->assertTrue((bool) $days->firstWhere('date', '2026-04-23')['signed']);
|
||||
$this->assertTrue((bool) $days->firstWhere('date', '2026-04-22')['can_makeup']);
|
||||
|
||||
$previousMonthResponse = $this->actingAs($user)->getJson(route('daily-sign-in.calendar', ['month' => '2026-03']));
|
||||
$previousMonthDays = collect($previousMonthResponse->json('data.days'));
|
||||
$this->assertFalse((bool) $previousMonthDays->firstWhere('date', '2026-03-31')['can_makeup']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户可以消耗补签卡补签历史漏签日期。
|
||||
*/
|
||||
public function test_user_can_makeup_missed_day_with_sign_repair_card(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 0]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 1], [
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 10,
|
||||
]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 2], [
|
||||
'streak_days' => 2,
|
||||
'gold_reward' => 20,
|
||||
]);
|
||||
SignInRewardRule::query()->updateOrCreate(['streak_days' => 3], [
|
||||
'streak_days' => 3,
|
||||
'gold_reward' => 30,
|
||||
]);
|
||||
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
|
||||
$purchase = UserPurchase::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $card->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 1200,
|
||||
]);
|
||||
DailySignIn::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-22',
|
||||
'streak_days' => 1,
|
||||
]);
|
||||
DailySignIn::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-24',
|
||||
'streak_days' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
|
||||
'target_date' => '2026-04-23',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.sign_in.is_makeup', true)
|
||||
->assertJsonPath('data.sign_in.streak_days', 2)
|
||||
->assertJsonPath('data.current_streak_days', 3);
|
||||
|
||||
$this->assertStringContainsString('当前连续签到 3 天', (string) $response->json('message'));
|
||||
|
||||
$this->assertDatabaseHas('daily_sign_ins', [
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-23',
|
||||
'is_makeup' => true,
|
||||
'makeup_purchase_id' => $purchase->id,
|
||||
'streak_days' => 2,
|
||||
'gold_reward' => 30,
|
||||
]);
|
||||
$this->assertDatabaseHas('daily_sign_ins', [
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-04-24',
|
||||
'streak_days' => 3,
|
||||
]);
|
||||
$this->assertSame('used', $purchase->fresh()->status);
|
||||
$this->assertSame(30, (int) $user->fresh()->jjb);
|
||||
$this->assertDatabaseHas('user_currency_logs', [
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'gold',
|
||||
'amount' => 30,
|
||||
'source' => CurrencySource::SIGN_IN->value,
|
||||
'remark' => '补签 2026-04-23:当前连续签到 3 天',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试没有补签卡时不能补签。
|
||||
*/
|
||||
public function test_makeup_requires_available_sign_repair_card(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
|
||||
'target_date' => '2026-04-23',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('target_date');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试补签卡不能补签本月之外的漏签日期。
|
||||
*/
|
||||
public function test_makeup_only_allows_current_month_missed_days(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$card = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
|
||||
$purchase = UserPurchase::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $card->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 1200,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('daily-sign-in.makeup'), [
|
||||
'target_date' => '2026-03-31',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('target_date')
|
||||
->assertJsonPath('errors.target_date.0', '补签卡只能补签本月的未签到日期。');
|
||||
|
||||
$this->assertSame('active', $purchase->fresh()->status);
|
||||
$this->assertDatabaseMissing('daily_sign_ins', [
|
||||
'user_id' => $user->id,
|
||||
'sign_in_date' => '2026-03-31',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台签到奖励规则管理测试
|
||||
*
|
||||
* 覆盖后台连续签到奖励档位的创建、更新、启停与校验。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature\Feature;
|
||||
|
||||
use App\Models\SignInRewardRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证后台签到奖励规则管理页行为。
|
||||
*/
|
||||
class AdminSignInRewardRuleControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试管理员可以创建签到奖励规则。
|
||||
*/
|
||||
public function test_admin_can_create_sign_in_reward_rule(): void
|
||||
{
|
||||
$admin = $this->createSuperAdmin();
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.sign-in-rules.store'), [
|
||||
'streak_days' => 11,
|
||||
'gold_reward' => 700,
|
||||
'exp_reward' => 70,
|
||||
'charm_reward' => 7,
|
||||
'identity_badge_code' => 'sign_11',
|
||||
'identity_badge_name' => '十一日星辉',
|
||||
'identity_badge_icon' => '🔥',
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 30,
|
||||
'sort_order' => 11,
|
||||
'is_enabled' => '1',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.sign-in-rules.index'));
|
||||
$this->assertDatabaseHas('sign_in_reward_rules', [
|
||||
'streak_days' => 11,
|
||||
'gold_reward' => 700,
|
||||
'identity_badge_code' => 'sign_11',
|
||||
'identity_duration_days' => 30,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试管理员可以更新并停用签到奖励规则。
|
||||
*/
|
||||
public function test_admin_can_update_and_toggle_sign_in_reward_rule(): void
|
||||
{
|
||||
$admin = $this->createSuperAdmin();
|
||||
$rule = SignInRewardRule::query()->where('streak_days', 3)->firstOrFail();
|
||||
$rule->update(['gold_reward' => 100, 'is_enabled' => true]);
|
||||
|
||||
$this->actingAs($admin)->put(route('admin.sign-in-rules.update', $rule), [
|
||||
'streak_days' => 5,
|
||||
'gold_reward' => 500,
|
||||
'exp_reward' => 50,
|
||||
'charm_reward' => 5,
|
||||
'identity_badge_code' => 'sign_5',
|
||||
'identity_badge_name' => '五日之星',
|
||||
'identity_badge_icon' => '⭐',
|
||||
'identity_badge_color' => '#4338ca',
|
||||
'identity_duration_days' => 10,
|
||||
'sort_order' => 5,
|
||||
'is_enabled' => '1',
|
||||
])->assertRedirect(route('admin.sign-in-rules.index'));
|
||||
|
||||
$this->assertDatabaseHas('sign_in_reward_rules', [
|
||||
'id' => $rule->id,
|
||||
'streak_days' => 5,
|
||||
'gold_reward' => 500,
|
||||
'identity_badge_name' => '五日之星',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson(route('admin.sign-in-rules.toggle', $rule))
|
||||
->assertOk()
|
||||
->assertJsonPath('is_enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试重复连续天数会被校验拦截。
|
||||
*/
|
||||
public function test_duplicate_streak_days_are_rejected(): void
|
||||
{
|
||||
$admin = $this->createSuperAdmin();
|
||||
$this->actingAs($admin)->post(route('admin.sign-in-rules.store'), [
|
||||
'streak_days' => 7,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 10,
|
||||
'charm_reward' => 0,
|
||||
'identity_duration_days' => 0,
|
||||
'sort_order' => 0,
|
||||
])->assertSessionHasErrors('streak_days');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可访问后台的超级管理员账号。
|
||||
*/
|
||||
private function createSuperAdmin(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'user_level' => 100,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -300,4 +300,62 @@ class ShopControllerTest extends TestCase
|
||||
&& $event->broadcastWith()['operator'] === $buyer->username;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试购买签到补签卡会扣金币并生成可用背包记录。
|
||||
*/
|
||||
public function test_buy_sign_repair_card_creates_active_purchase(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 12000]);
|
||||
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
|
||||
$item->update([
|
||||
'price' => 10000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
|
||||
'item_id' => $item->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('jjb', 2000);
|
||||
|
||||
$this->assertDatabaseHas('user_purchases', [
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => 10000,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试补签卡支持一次购买多张。
|
||||
*/
|
||||
public function test_buy_sign_repair_card_supports_quantity(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 35000]);
|
||||
$item = ShopItem::query()->where('slug', 'sign_repair_card')->firstOrFail();
|
||||
$item->update([
|
||||
'price' => 10000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('shop.buy'), [
|
||||
'item_id' => $item->id,
|
||||
'quantity' => 3,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('quantity', 3)
|
||||
->assertJsonPath('total_price', 30000)
|
||||
->assertJsonPath('jjb', 5000);
|
||||
|
||||
$this->assertSame(3, UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('shop_item_id', $item->id)
|
||||
->where('status', 'active')
|
||||
->count());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user