feat: 神秘箱子系统完整实现 + 婚姻状态弹窗 + 工具栏优化

## 新功能
- 神秘箱子系统(MysteryBox)完整实现:
  - 新增 MysteryBox / MysteryBoxClaim 模型及迁移文件
  - DropMysteryBoxJob / ExpireMysteryBoxJob 队列作业
  - MysteryBoxController(/mystery-box/status + /mystery-box/claim)
  - 支持三种类型:普通箱(500~2000金)/ 稀有箱(5000~20000金)/ 黑化箱(陷阱扣200~1000金)
  - 调度器自动投放 + 管理员手动投放
  - CurrencySource 新增 MYSTERY_BOX / MYSTERY_BOX_TRAP 枚举

- 婚姻状态弹窗(工具栏「婚姻」按钮):
  - 工具栏「呼叫」改为「婚姻」,点击打开婚姻状态弹窗
  - 动态渲染三种状态:单身 / 求婚中 / 已婚
  - 被求婚方可直接「答应 / 婉拒」;已婚可申请离婚(含二次确认)

## 优化修复
- frame.blade.php:Alpine.js CDN 补加 defer,修复所有组件初始化报错
- scripts.blade.php:神秘箱子暗号主动拦截(不依赖轮询),领取成功后弹 chatDialog 展示结果,更新金币余额
- MysteryBoxController:claim() 时 change() 补传 room_id 记录来源房间
- 后台游戏管理页(game-configs):投放箱子按钮颜色修复;弹窗替换为 window.adminDialog
- admin/layouts:新增全局 adminDialog 弹窗组件(替代原生 alert/confirm)
- baccarat-panel:FAB 拖动重构为 Alpine.js baccaratFab() 组件,与 slotFab 一致
- GAMES_TODO.md:神秘箱子移入已完成区,补全修复记录
This commit is contained in:
2026-03-03 19:29:43 +08:00
parent 40fcce2db3
commit 602dcd7cf1
21 changed files with 1799 additions and 139 deletions

View File

@@ -0,0 +1,174 @@
<?php
/**
* 文件功能:神秘箱子前台控制器
*
* 提供神秘箱子相关接口:
* - /mystery-box/status 查询当前可领取的箱子(给前端轮询)
* - /mystery-box/claim 用户发送暗号领取箱子
*
* 领取流程:
* 1. 用户在聊天框发送暗号(前端拦截后调用此接口)
* 2. 验证暗号匹配、箱子未过期、未已领取
* 3. 随机奖励金额trap=扣,其余=加)
* 4. 写货币流水日志
* 5. 公屏广播结果(中奖/踩雷)
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox();
if (! $box) {
return response()->json(['active' => false]);
}
// 计算剩余时间
$secondsLeft = $box->expires_at ? max(0, now()->diffInSeconds($box->expires_at, false)) : null;
return response()->json([
'active' => true,
'box_id' => $box->id,
'box_type' => $box->box_type,
'type_name' => $box->typeName(),
'type_emoji' => $box->typeEmoji(),
'passcode' => $box->passcode,
'seconds_left' => $secondsLeft,
]);
}
/**
* 用户用暗号领取箱子。
*/
public function claim(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放。']);
}
$passcode = strtoupper(trim((string) $request->input('passcode', '')));
if ($passcode === '') {
return response()->json(['ok' => false, 'message' => '请输入暗号。']);
}
$user = $request->user();
return DB::transaction(function () use ($user, $passcode): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query()
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->lockForUpdate()
->first();
if (! $box) {
return response()->json(['ok' => false, 'message' => '暗号不正确,或箱子已被领走/已过期。']);
}
// ① 随机奖励金额
$reward = $box->rollReward();
// ② 货币变更
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
$remark = $reward >= 0
? "神秘箱子【{$box->typeName()}】奖励"
: "神秘箱子【黑化箱】陷阱扣除";
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
// ③ 写领取记录 + 更新箱子状态
MysteryBoxClaim::create([
'mystery_box_id' => $box->id,
'user_id' => $user->id,
'reward_amount' => $reward,
]);
$box->update(['status' => 'claimed']);
// ④ 公屏广播结果
$user->refresh();
$this->broadcastResult($box, $user->username, $reward);
return response()->json([
'ok' => true,
'reward' => $reward,
'balance' => $user->jjb ?? 0,
'message' => $reward >= 0
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
: "☠️ 中了黑化陷阱!扣除 " . abs($reward) . ' 金币!',
]);
});
}
/**
* 公屏广播开箱结果。
*
* @param MysteryBox $box 箱子实例
* @param string $username 领取者用户名
* @param int $reward 奖励金额(正/负)
*/
private function broadcastResult(MysteryBox $box, string $username, int $reward): void
{
$emoji = $box->typeEmoji();
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}"
. "获得 🪙" . number_format($reward) . " 金币!";
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "【黑化陷阱】haha{$username}】 中了神秘黑化箱的陷阱!"
. "被扣除 🪙" . number_format(abs($reward)) . " 金币!点背~";
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $color,
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}