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
+8
View File
@@ -96,6 +96,12 @@ enum CurrencySource: string
/** 领取礼包红包——经验(用户抢到经验礼包时收入) */
case RED_PACKET_RECV_EXP = 'red_packet_recv_exp';
/** 神秘箱子——领取奖励(普通箱/稀有箱,正数金币) */
case MYSTERY_BOX = 'mystery_box';
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -127,6 +133,8 @@ enum CurrencySource: string
self::SLOT_CURSE => '老虎机诅咒',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
};
}
}
@@ -69,4 +69,36 @@ class GameConfigController extends Controller
return back()->with('success', "{$gameConfig->name}」参数已保存!");
}
/**
* 管理员手动投放神秘箱子。
*
* 立即分发 DropMysteryBoxJob 到队列,由 Horizon 执行箱子投放和公屏广播。
*/
public function dropMysteryBox(Request $request): JsonResponse
{
if (! \App\Models\GameConfig::isEnabled('mystery_box')) {
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放,请先开启。']);
}
$boxType = $request->input('box_type', 'normal');
if (! in_array($boxType, ['normal', 'rare', 'trap'], true)) {
return response()->json(['ok' => false, 'message' => '无效的箱子类型。']);
}
// 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
]);
}
}
@@ -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);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* 文件功能:投放神秘箱子队列任务
*
* 由调度器或管理员手动触发,执行以下操作:
* 1. 创建神秘箱记录(含暗号、类型、奖励范围)
* 2. 公屏广播暗号提示(全服可见)
* 3. 设定定时关闭任务(windows_seconds 秒后过期)
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
class DropMysteryBoxJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* @param string $boxType 箱子类型:normal | rare | trap
* @param int|null $roomId 投放目标房间,null 时默认 1
* @param string|null $passcode 手动指定暗号(null=自动生成)
* @param int|null $droppedBy 投放者用户IDnull=系统自动)
*/
public function __construct(
public readonly string $boxType = 'normal',
public readonly ?int $roomId = 1,
public readonly ?string $passcode = null,
public readonly ?int $droppedBy = null,
) {}
/**
* 执行投放逻辑。
*/
public function handle(ChatStateService $chatState): void
{
// 检查游戏是否开启
if (! GameConfig::isEnabled('mystery_box')) {
return;
}
$config = GameConfig::forGame('mystery_box')?->params ?? [];
$claimWindow = (int) ($config['claim_window_seconds'] ?? 120);
$targetRoom = $this->roomId ?? 1;
// 自动生成随机暗号(若未指定)
$passcode = $this->passcode ?? strtoupper(Str::random(6));
// 根据类型确定奖励范围
[$rewardMin, $rewardMax] = match ($this->boxType) {
'rare' => [
(int) ($config['rare_reward_min'] ?? 5000),
(int) ($config['rare_reward_max'] ?? 20000),
],
'trap' => [
(int) ($config['trap_penalty_min'] ?? 200),
(int) ($config['trap_penalty_max'] ?? 1000),
],
default => [
(int) ($config['normal_reward_min'] ?? 500),
(int) ($config['normal_reward_max'] ?? 2000),
],
};
// 创建箱子记录
$box = MysteryBox::create([
'box_type' => $this->boxType,
'passcode' => $passcode,
'reward_min' => $rewardMin,
'reward_max' => $rewardMax,
'status' => 'open',
'expires_at' => now()->addSeconds($claimWindow),
'dropped_by' => $this->droppedBy,
]);
// 公屏广播暗号提示
$emoji = $box->typeEmoji();
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}{$typeName}{$source}投放了一个神秘箱子!"
. "发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$msg = [
'id' => $chatState->nextMessageId($targetRoom),
'room_id' => $targetRoom,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => match ($this->boxType) {
'rare' => '#c4b5fd',
'trap' => '#f87171',
default => '#34d399',
},
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($targetRoom, $msg);
broadcast(new MessageSent($targetRoom, $msg));
SaveMessageJob::dispatch($msg);
// 定时关闭任务:到期后将箱子标记为 expired
ExpireMysteryBoxJob::dispatch($box->id)->delay(now()->addSeconds($claimWindow + 5));
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:过期神秘箱子队列任务
*
* 在箱子到期后将其状态更新为 expired(若尚未被领取),并向公屏广播过期通知。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\MessageSent;
use App\Models\MysteryBox;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ExpireMysteryBoxJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* @param int $boxId 要过期的箱子ID
*/
public function __construct(public readonly int $boxId) {}
/**
* 执行过期逻辑。
*/
public function handle(ChatStateService $chatState): void
{
$box = MysteryBox::find($this->boxId);
// 箱子不存在或已经被领取/过期,跳过
if (! $box || $box->status !== 'open') {
return;
}
// 标记为过期
$box->update(['status' => 'expired']);
// 公屏广播过期通知
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
'is_secret' => false,
'font_color' => '#9ca3af',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
/**
* 文件功能:神秘箱子模型
*
* 管理聊天室内投放的神秘箱记录,提供领取状态管理及类型标签工具方法。
* 对应表:mystery_boxes
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class MysteryBox extends Model
{
protected $fillable = [
'box_type',
'passcode',
'reward_min',
'reward_max',
'status',
'expires_at',
'dropped_by',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'reward_min' => 'integer',
'reward_max' => 'integer',
'expires_at' => 'datetime',
];
}
// ─── 关联关系 ────────────────────────────────────────────────────
/**
* 领取记录(一个箱子只能被一人领取,但关联为 HasOne
*/
public function claim(): HasOne
{
return $this->hasOne(MysteryBoxClaim::class);
}
/**
* 所有领取记录(逻辑上只有一条,保留 HasMany 供统计使用)
*/
public function claims(): HasMany
{
return $this->hasMany(MysteryBoxClaim::class);
}
// ─── 查询作用域 ──────────────────────────────────────────────────
/**
* 当前可领取(open 状态 + 未过期)的箱子。
*/
public static function currentOpenBox(): ?static
{
return static::query()
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->latest()
->first();
}
// ─── 工具方法 ────────────────────────────────────────────────────
/**
* 返回箱子类型的 emoji 前缀。
*/
public function typeEmoji(): string
{
return match ($this->box_type) {
'normal' => '📦',
'rare' => '💎',
'trap' => '☠️',
default => '📦',
};
}
/**
* 返回箱子类型中文名称。
*/
public function typeName(): string
{
return match ($this->box_type) {
'normal' => '普通箱',
'rare' => '稀有箱',
'trap' => '黑化箱',
default => '神秘箱',
};
}
/**
* 随机生成奖励金额(trap 类型为负数)。
*/
public function rollReward(): int
{
$amount = random_int(
min(abs($this->reward_min), abs($this->reward_max)),
max(abs($this->reward_min), abs($this->reward_max)),
);
// trap 类型:倒扣金币(负数)
return $this->box_type === 'trap' ? -$amount : $amount;
}
/**
* 判断箱子是否已过期
*/
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* 文件功能:神秘箱子领取记录模型
*
* 记录每次神秘箱被用户发送暗号成功领取后的详情(关联箱子 + 领取者 + 实际奖励)。
* 对应表:mystery_box_claims
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MysteryBoxClaim extends Model
{
protected $fillable = [
'mystery_box_id',
'user_id',
'reward_amount',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'reward_amount' => 'integer',
];
}
// ─── 关联关系 ────────────────────────────────────────────────────
/**
* 关联神秘箱子。
*/
public function mysteryBox(): BelongsTo
{
return $this->belongsTo(MysteryBox::class);
}
/**
* 关联领取用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}