96 Commits

Author SHA1 Message Date
pllx 74e4803bc2 修复手机端欢迎语弹窗位置 2026-05-12 19:09:23 +08:00
pllx b8feab34a6 优化聊天室手机管理菜单和欢迎语 2026-05-12 19:05:45 +08:00
pllx 0c9e7baca2 精简私信记录时间显示 2026-05-09 11:28:19 +08:00
pllx da0846c7ab 恢复用户卡片私信查看 2026-05-09 11:25:28 +08:00
pllx 8c1b0b0840 修复节日福利过期补发 2026-05-09 11:14:55 +08:00
pllx 1b062f67ea 增加每日游戏三杰榜 2026-05-09 10:23:38 +08:00
pllx 41522393de 缩短弹窗通知显示时间 2026-05-05 22:03:18 +08:00
pllx 645fe2a830 调整手机端弹窗通知规则 2026-05-05 21:57:24 +08:00
pllx 64945a973e 统一会话失效接口响应 2026-05-05 21:55:48 +08:00
pllx 725a38eac3 修复礼包领取弹窗重复显示 2026-05-05 21:48:51 +08:00
pllx 11a882bd8e 优化右下角弹窗交互 2026-05-05 21:48:36 +08:00
pllx a65827c5d9 优化部署脚本无更新时跳过执行 2026-05-04 18:25:31 +08:00
pllx 9b993e487c 优化部署脚本跳过无关构建迁移 2026-05-04 18:23:54 +08:00
pllx 6225a0fb45 优化定时任务调度耗时 2026-05-04 18:18:35 +08:00
pllx b3eebd286e 成就定时扫描增加解锁通知 2026-05-04 17:59:48 +08:00
pllx fdd4f8a179 修复猜谜双题答题状态误同步 2026-04-30 16:49:25 +08:00
pllx 82dbc19319 优化用户资料卡成就摘要查询 2026-04-30 16:45:46 +08:00
pllx ffd8789e67 修复礼包红包弹窗状态提示异常 2026-04-30 16:27:37 +08:00
pllx ee525f049e 修复礼包红包弹窗领取展示残留 2026-04-30 16:23:37 +08:00
pllx f354516869 新增聊天室成就系统与消息保留策略 2026-04-30 16:19:49 +08:00
pllx 92e3dd0cdf 红包领取增加全员通知 2026-04-30 15:52:16 +08:00
pllx 9764961519 优化游戏通知 2026-04-30 15:41:50 +08:00
pllx 4af4468fc4 修复滚屏状态导入缺失 2026-04-30 15:25:54 +08:00
pllx a6e50c36d7 优化红包 2026-04-30 15:21:58 +08:00
pllx b21f583fe5 修复聊天室滚屏开关失效 2026-04-30 15:19:38 +08:00
pllx 8c7b1086ff 修复跑马通知屏蔽识别 2026-04-30 15:07:09 +08:00
pllx 59a417bd10 补充座驾购买流水筛选 2026-04-30 13:27:21 +08:00
pllx 0fe003a773 新增座驾购买公屏通知 2026-04-30 11:27:53 +08:00
pllx 06864a9cec 完善座驾插件验收规则 2026-04-30 11:18:59 +08:00
pllx 622bc94377 精简座驾动画标题名称 2026-04-30 11:15:37 +08:00
pllx 575e92e03f 精简座驾文字播报身份信息 2026-04-30 11:14:53 +08:00
pllx 522eea72f6 避免座驾进房重复欢迎 2026-04-30 11:12:50 +08:00
pllx fc7930046d 统一座驾播报用户信息 2026-04-30 11:08:39 +08:00
pllx 7ba7b34ca7 补充座驾动画标题用户信息 2026-04-30 11:07:46 +08:00
pllx 3eaf37a648 修复座驾与会员入场重复展示 2026-04-30 11:03:09 +08:00
pllx 221f629ec2 优化座驾特效入场标题 2026-04-30 10:29:11 +08:00
pllx 18acd7d890 优化座驾开发插件规范 2026-04-30 10:10:08 +08:00
pllx 09a2b0d85f 删除座驾旧方案空迁移 2026-04-30 10:05:19 +08:00
pllx b60f3615c1 补齐座驾购买金币流水 2026-04-30 10:02:59 +08:00
pllx 363c45a140 避免测试清空登录会话 2026-04-30 09:58:18 +08:00
pllx 181cc6a0b0 改为独立座驾模块 2026-04-30 09:55:20 +08:00
pllx 3c95478097 新增聊天室座驾系统 2026-04-30 09:40:50 +08:00
pllx 45ce8b2b2d 调整聊天输入栏工具字号 2026-04-30 09:07:10 +08:00
pllx 50b050c4bc 修复聊天室字号偏好和游戏通知显示 2026-04-29 18:27:32 +08:00
pllx 6748fbc44e 修复自动钓鱼状态与播报屏蔽范围 2026-04-29 16:54:18 +08:00
pllx 449894e3e5 修复赛马编号通知卡片识别 2026-04-29 16:07:26 +08:00
pllx 5173275a92 恢复游戏通知原始正文样式 2026-04-29 15:58:41 +08:00
pllx ee56792beb 统一猜谜答对通知配色 2026-04-29 15:50:58 +08:00
pllx 02ed8ea319 精简AI百家乐下注通知文案 2026-04-29 15:48:33 +08:00
pllx 2bebc78e82 补充游戏开奖通知编号文案 2026-04-29 15:45:07 +08:00
pllx 4fe4155ec0 修复钓鱼通知与游戏配置保存问题 2026-04-29 15:23:32 +08:00
pllx c640a31302 完善游戏通知文案与屏蔽逻辑 2026-04-29 15:06:01 +08:00
pllx 6ae452c4b9 补充猜谜活动旧Seeder兼容入口 2026-04-29 14:44:57 +08:00
pllx 1607f57e3c 支持所有游戏按房间范围配置和运行 2026-04-29 14:37:28 +08:00
pllx 3672140987 统一聊天室游戏通知胶囊样式 2026-04-29 14:35:52 +08:00
pllx 092b51cd95 拆分猜谜活动后台配置视图 2026-04-29 13:39:41 +08:00
pllx fe3e74b5f8 重构猜谜活动并统一聊天室答题通知 2026-04-29 13:35:20 +08:00
pllx 192259f0a4 调整聊天室名单排序搜索同排布局 2026-04-29 12:01:03 +08:00
pllx a50055deaf 修复赛马线上卡在跑马中状态 2026-04-29 11:42:49 +08:00
pllx 578f587941 拆分猜成语独立屏蔽通知类型 2026-04-29 11:35:14 +08:00
pllx fb4a7171f4 修复猜成语出题消息线上不显示问题 2026-04-29 11:25:57 +08:00
pllx dc9c09c722 修复 Reverb 配置缓存时的函数重定义 2026-04-29 11:18:08 +08:00
pllx 317dfd04d7 修复聊天室在线名单初始化与 Reverb 来源校验 2026-04-29 11:15:24 +08:00
pllx 1192fe5bdb 优化升级 2026-04-29 10:59:35 +08:00
pllx e0679b164e 修复升级bug 2026-04-29 10:52:44 +08:00
pllx 82d762d070 完善部署脚本的 Reverb 重启流程 2026-04-29 10:39:07 +08:00
pllx 5962d6d2b3 完善猜成语过期与答题记录逻辑 2026-04-29 10:32:12 +08:00
pllx 2f9b2eed64 修复拍一拍消息重复显示的问题,按发送者/被拍者路由到包厢,其他用户路由到公屏 2026-04-29 09:40:40 +08:00
pllx 434f2b8e0f fix(idiom): 后台出题按钮无效,管理后台布局缺少 @stack('scripts') 2026-04-29 00:14:41 +08:00
pllx 9bc085cb7d refactor(idiom): 将游戏配置和题库移到 IdiomSeeder,迁移只建表 2026-04-29 00:10:01 +08:00
pllx f13cfe4bc1 feat(idiom): 答对提示的用户名可点击打开用户名片 2026-04-28 23:58:04 +08:00
pllx cd1621f497 feat(idiom): disable answer button after question is solved 2026-04-28 23:53:05 +08:00
pllx 3973b7770c fix(idiom): split display - winner sees in private, others in public 2026-04-28 23:51:16 +08:00
pllx 0847877ce2 feat(idiom): global toast + chat message on answer result 2026-04-28 23:49:27 +08:00
pllx b886d98d8c feat(idiom): add scheduled auto-start task (everyMinute) 2026-04-28 23:48:32 +08:00
pllx 4ff62e29bd feat: 猜成语游戏 - 完整题库、管理后台、答题弹窗
- 创建 idioms 表(102条谜语式成语题库)和 idiom_game_rounds 表
- 后台成语管理页面:增删改题目 + 游戏参数(金币/经验/间隔)内联设置 + 出题按钮
- IdiomQuizController:出题/答题/当前回合查询,Redis 防并发抢答
- IdiomGameStarted / IdiomGameAnswered 广播事件
- 前端答题弹窗模块:聊天消息带【答题】按钮,点击弹出输入框
- GameConfig 注册 idiom 游戏,由 admin.game-configs 统一管理开关
2026-04-28 23:42:48 +08:00
pllx 461c6a6f56 fix: 弹窗点击遮罩层(外部区域)即可关闭 2026-04-28 23:18:26 +08:00
pllx 1850a5f4e9 fix: 选择斜杠命令后自动清除输入框中的 /
- 统一在 selectCommand 公共入口清理输入
- 新增 /签到 命令,自动完成今日签到
2026-04-28 23:12:30 +08:00
pllx 0850004d39 feat: 斜杠命令新增 /签到,自动完成今日签到 2026-04-28 23:10:00 +08:00
pllx df96b56ab0 feat: 斜杠命令新增 /查看资料,直接打开用户名片 2026-04-28 23:01:40 +08:00
pllx 495efdf9e0 feat: 新增 /拍一拍 功能 + 斜杠命令菜单
- 输入框输入 / 弹出命令菜单,当前支持 /拍一拍
- 选择对象后输入 /拍一拍 发送拍一拍通知
- 所有在线用户屏幕抖动 + 正常聊天样式显示消息
- 命令注册表可扩展,后续新增命令只需 push 到数组
2026-04-28 22:59:16 +08:00
pllx 0dd85879af 增加51统计 2026-04-28 14:49:33 +08:00
pllx 64434516d7 视频赚钱按钮改为提示已关闭 2026-04-28 14:45:37 +08:00
pllx e155a0e3d0 恢复视频赚钱路由(只保留控制器关闭逻辑) 2026-04-28 14:43:25 +08:00
pllx d6e8a64ce3 停用看视频赚钱功能(注释路由+控制器返回关闭) 2026-04-28 14:42:41 +08:00
pllx abb5512222 feat: 欢迎语增加右下角弹窗通知;禁止对「大家」发送欢迎 2026-04-28 14:40:27 +08:00
pllx 55fd770fdd 取消看视频赚钱 2026-04-28 14:32:39 +08:00
pllx 4fb78eaca9 feat: 功能菜单增加留言、反馈快捷入口 2026-04-28 14:28:52 +08:00
pllx 05ec4a72b7 feat: 公屏公告同时弹右下角通知(所有人可见) 2026-04-28 14:14:48 +08:00
pllx f3d883b5ed fix: 刷新后屏蔽项虽恢复但已有消息未隐藏 2026-04-28 14:08:15 +08:00
pllx 96e0e21f8b fix: 屏蔽列表刷新后不恢复(缺少从服务端读取的分支逻辑) 2026-04-28 14:04:07 +08:00
pllx c8adbff78e 安全优化 2026-04-28 13:57:15 +08:00
pllx aa6046d89b fix: 弹窗消息改用真正的换行符而非字面量 \n 2026-04-28 13:23:26 +08:00
pllx cdec289740 fix: 对话框 textContent 改为 innerText 支持换行;后端允许装扮批量购买 2026-04-28 13:16:24 +08:00
pllx d63aeef45b feat: 装扮购买增加数量输入弹窗(参照补签卡样式) 2026-04-28 13:12:56 +08:00
pllx 2be7e6caef 优化:去掉装饰品数量弹窗,改为确认对话框中提示叠加天数说明 2026-04-28 13:10:15 +08:00
173 changed files with 15990 additions and 784 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "chatroom-local-marketplace",
"interface": {
"displayName": "Chatroom Local Plugins"
},
"plugins": [
{
"name": "chatroom-ride-development",
"source": {
"source": "local",
"path": "./plugins/chatroom-ride-development"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+64 -17
View File
@@ -16,9 +16,11 @@ namespace App\Console\Commands;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Jobs\AiFishingJob;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\Autoact; use App\Models\Autoact;
use App\Models\DailySignIn; use App\Models\DailySignIn;
use App\Models\GameConfig;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\AiFinanceService; use App\Services\AiFinanceService;
@@ -61,11 +63,15 @@ class AiHeartbeatCommand extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$startedAt = microtime(true);
// 1. 检查总开关 // 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') { if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS; return Command::SUCCESS;
} }
$config = $this->heartbeatConfig();
// 2. 获取 AI 实体 // 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first(); $user = User::where('username', 'AI小班长')->first();
if (! $user) { if (! $user) {
@@ -73,21 +79,26 @@ class AiHeartbeatCommand extends Command
} }
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。 // 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$this->aiFinance->bankExcessGold($user); $this->aiFinance->bankExcessGold($user);
}
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖) // 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
$this->performDailySignIn($user); if ($this->performDailySignIn($user)) {
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
$user->refresh();
}
// 3. 常规心跳经验与金币发放 // 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励) // (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1')); $expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
if ($expGain > 0) { if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user); $expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier); $actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain; $user->exp_num += $actualExpGain;
} }
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0')); $jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
if ($jjbGain > 0) { if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user); $jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier); $actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
@@ -95,30 +106,35 @@ class AiHeartbeatCommand extends Command
} }
$user->save(); $user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级) // 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100'); $superLevel = (int) $config['superlevel'];
$leveledUp = $this->calculateNewLevel($user, $superLevel); $leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发 // 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10'); $eventChance = (int) $config['auto_event_chance'];
if ($eventChance > 0 && rand(1, 100) <= $eventChance) { if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent(); $autoEvent = Autoact::randomEvent();
if ($autoEvent) { if ($autoEvent) {
$hasCurrencyChange = false;
// 执行随机事件的金钱经验惩奖 // 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) { if ($autoEvent->exp_change !== 0) {
$this->currencyService->change( $this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 $user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
); );
$hasCurrencyChange = true;
} }
if ($autoEvent->jjb_change !== 0) { if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change( $this->currencyService->change(
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 $user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
); );
$hasCurrencyChange = true;
} }
if ($hasCurrencyChange) {
$user->refresh(); $user->refresh();
}
// 重新计算等级 // 重新计算等级
if ($this->calculateNewLevel($user, $superLevel)) { if ($this->calculateNewLevel($user, $superLevel)) {
@@ -149,13 +165,16 @@ class AiHeartbeatCommand extends Command
} }
// 7. 钓鱼小游戏随机参与逻辑 // 7. 钓鱼小游戏随机参与逻辑
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1'; $fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率 $fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) { if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); $fishingConfig = GameConfig::forGame('fishing');
if ($fishingConfig?->enabled) {
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。 // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
if ($this->aiFinance->prepareSpend($user, $cost)) { if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除费用 // 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
$this->currencyService->change( $this->currencyService->change(
$user, 'gold', -$cost, $user, 'gold', -$cost,
CurrencySource::FISHING_COST, CurrencySource::FISHING_COST,
@@ -164,24 +183,50 @@ class AiHeartbeatCommand extends Command
); );
// 模拟玩家等待时间 // 模拟玩家等待时间
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); $waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); $waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
$waitTime = rand($waitMin, $waitMax); $waitTime = rand($waitMin, $waitMax);
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids
$activeRoomIds = $this->chatState->getAllActiveRoomIds(); $activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; $roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
}
} }
} }
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。 // 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$this->aiFinance->bankExcessGold($user); $this->aiFinance->bankExcessGold($user);
}
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
$this->info("AI心跳完成,耗时 {$elapsedMs}ms。");
return Command::SUCCESS; return Command::SUCCESS;
} }
/**
* 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。
*
* @return array<string, string>
*/
private function heartbeatConfig(): array
{
return [
'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'),
'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'),
'superlevel' => Sysparam::getValue('superlevel', '100'),
'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'),
'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'),
'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'),
'fishing_cost' => Sysparam::getValue('fishing_cost', '5'),
'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'),
'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'),
];
}
/** /**
* 计算并更新用户等级 * 计算并更新用户等级
*/ */
@@ -222,7 +267,7 @@ class AiHeartbeatCommand extends Command
/** /**
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。 * 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
*/ */
private function performDailySignIn(User $user): void private function performDailySignIn(User $user): bool
{ {
// 先检查今日是否已签,避免每分钟都调用事务 // 先检查今日是否已签,避免每分钟都调用事务
$alreadySigned = DailySignIn::query() $alreadySigned = DailySignIn::query()
@@ -231,7 +276,7 @@ class AiHeartbeatCommand extends Command
->exists(); ->exists();
if ($alreadySigned) { if ($alreadySigned) {
return; return false;
} }
// 获取活跃房间作为签到归属(默认房间 1) // 获取活跃房间作为签到归属(默认房间 1)
@@ -242,7 +287,7 @@ class AiHeartbeatCommand extends Command
// 仅当本次心跳实际完成签到时才广播(幂等保护) // 仅当本次心跳实际完成签到时才广播(幂等保护)
if (! $dailySignIn->wasRecentlyCreated) { if (! $dailySignIn->wasRecentlyCreated) {
return; return false;
} }
$rewardParts = []; $rewardParts = [];
@@ -265,6 +310,8 @@ class AiHeartbeatCommand extends Command
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'; .$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('系统传音', $content, '#0f766e'); $this->broadcastSystemMessage('系统传音', $content, '#0f766e');
return true;
} }
/** /**
+46 -13
View File
@@ -20,6 +20,7 @@ use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\PositionDutyLog; use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
@@ -27,6 +28,7 @@ use App\Services\ChatUserPresenceService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Services\VipService; use App\Services\VipService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -65,6 +67,8 @@ class AutoSaveExp extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$startedAt = microtime(true);
// 读取奖励配置 // 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1'); $expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0'); $jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
@@ -81,15 +85,22 @@ class AutoSaveExp extends Command
// 统计本次处理总人次(一个用户在多个房间会被计算多次) // 统计本次处理总人次(一个用户在多个房间会被计算多次)
$totalProcessed = 0; $totalProcessed = 0;
$usersByUsername = $this->preloadOnlineUsers($roomMap);
foreach ($roomMap as $roomId => $usernames) { foreach ($roomMap as $roomId => $usernames) {
foreach ($usernames as $username) { foreach ($usernames as $username) {
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel); $user = $usersByUsername->get($username);
if (! $user) {
continue;
}
$this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
$totalProcessed++; $totalProcessed++;
} }
} }
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。"); $elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
return Command::SUCCESS; return Command::SUCCESS;
} }
@@ -108,7 +119,7 @@ class AutoSaveExp extends Command
$roomMap = []; $roomMap = [];
// 从数据库取出所有房间 ID // 从数据库取出所有房间 ID
$roomIds = \App\Models\Room::pluck('id'); $roomIds = Room::pluck('id');
foreach ($roomIds as $roomId) { foreach ($roomIds as $roomId) {
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致 // Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
@@ -121,27 +132,46 @@ class AutoSaveExp extends Command
return $roomMap; return $roomMap;
} }
/**
* 预加载所有在线用户名对应的用户资料与身份关系,避免循环内逐个查询用户和身份信息。
*
* @param array<int, array<string>> $roomMap 在线房间与用户名映射
* @return Collection<string, User> 以用户名为键的用户集合
*/
private function preloadOnlineUsers(array $roomMap): Collection
{
$usernames = collect($roomMap)
->flatten()
->unique()
->values();
if ($usernames->isEmpty()) {
return collect();
}
return User::query()
->with(['activePosition.position.department', 'vipLevel'])
->whereIn('username', $usernames)
->get()
->keyBy('username');
}
/** /**
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。 * 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
* *
* @param string $username 用户 * @param User $user 已预加载身份关系的在线用户
* @param int $roomId 所在房间ID * @param int $roomId 所在房间ID
* @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围) * @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置 * @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值 * @param int $superLevel 管理员等级阈值
*/ */
private function processUser( private function processUser(
string $username, User $user,
int $roomId, int $roomId,
string $expGainRaw, string $expGainRaw,
string $jjbGainRaw, string $jjbGainRaw,
int $superLevel int $superLevel
): void { ): void {
$user = User::where('username', $username)->first();
if (! $user) {
return;
}
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率) // 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw); $expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user); $expMultiplier = $this->vipService->getExpMultiplier($user);
@@ -165,8 +195,11 @@ class AutoSaveExp extends Command
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId, $user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
); );
} }
$user->refresh(); // 刷新获取最新属性(service 已原子更新) if ($actualExpGain > 0 || $actualJjbGain > 0) {
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份 // 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系
$user->refresh();
$user->load(['activePosition.position.department', 'vipLevel']);
}
// 3. 自动升降级逻辑 // 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化 // - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -241,7 +274,7 @@ class AutoSaveExp extends Command
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统', 'from_user' => '系统',
'to_user' => $username, // 定向推送给本人 'to_user' => $user->username, // 定向推送给本人
'content' => $content, 'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到 'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'font_color' => '#16a34a', // 草绿色 'font_color' => '#16a34a', // 草绿色
+33 -13
View File
@@ -3,8 +3,8 @@
/** /**
* 文件功能:定期清理聊天记录 Artisan 命令 * 文件功能:定期清理聊天记录 Artisan 命令
* *
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控 * 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。 * 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
* *
* @author ChatRoom Laravel * @author ChatRoom Laravel
* *
@@ -31,7 +31,7 @@ class PurgeOldMessages extends Command
* @var string * @var string
*/ */
protected $signature = 'messages:purge protected $signature = 'messages:purge
{--days= : 覆盖默认保留天数} {--days= : 覆盖通知消息默认保留天数}
{--image-days=3 : 聊天图片单独保留天数} {--image-days=3 : 聊天图片单独保留天数}
{--dry-run : 仅预览不实际删除}'; {--dry-run : 仅预览不实际删除}';
@@ -40,7 +40,7 @@ class PurgeOldMessages extends Command
* *
* @var string * @var string
*/ */
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件'; protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
/** /**
* 执行命令 * 执行命令
@@ -49,9 +49,9 @@ class PurgeOldMessages extends Command
*/ */
public function handle(): int public function handle(): int
{ {
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天 // 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
$days = (int) ($this->option('days') $days = (int) ($this->option('days')
?: Sysparam::getValue('message_retention_days', '30')); ?: Sysparam::getValue('game_message_retention_days', '30'));
$imageDays = max(0, (int) $this->option('image-days')); $imageDays = max(0, (int) $this->option('image-days'));
$cutoff = Carbon::now()->subDays($days); $cutoff = Carbon::now()->subDays($days);
@@ -59,22 +59,22 @@ class PurgeOldMessages extends Command
$this->cleanupExpiredImages($imageDays, $isDryRun); $this->cleanupExpiredImages($imageDays, $isDryRun);
// 统计待清理数量 $expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
$totalCount = Message::where('sent_at', '<', $cutoff)->count(); $totalCount = (clone $expiredNoticeQuery)->count();
if ($totalCount === 0) { if ($totalCount === 0) {
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理"); $this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留");
return self::SUCCESS; return self::SUCCESS;
} }
if ($isDryRun) { if ($isDryRun) {
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}"); $this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}");
return self::SUCCESS; return self::SUCCESS;
} }
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}..."); $this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}...");
$this->info(" 待清理数量:{$totalCount}"); $this->info(" 待清理数量:{$totalCount}");
// 分批删除,每批 1000 条,避免长时间锁表 // 分批删除,每批 1000 条,避免长时间锁表
@@ -82,7 +82,7 @@ class PurgeOldMessages extends Command
$batchSize = 1000; $batchSize = 1000;
do { do {
$batch = Message::where('sent_at', '<', $cutoff) $batch = $this->expiredNoticeQuery($cutoff)
->limit($batchSize) ->limit($batchSize)
->delete(); ->delete();
@@ -93,11 +93,31 @@ class PurgeOldMessages extends Command
} }
} while ($batch === $batchSize); } while ($batch === $batchSize);
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。"); $this->info("✅ 清理完成!共删除 {$deleted}游戏/临时通知,用户聊天记录未删除");
return self::SUCCESS; return self::SUCCESS;
} }
/**
* 构造过期通知清理查询,兼容新增字段前已经落库的旧通知。
*/
private function expiredNoticeQuery(Carbon $cutoff): \Illuminate\Database\Eloquent\Builder
{
return Message::query()
->where('sent_at', '<', $cutoff)
->where(function ($query) {
$query->whereIn('retention_type', Message::purgableRetentionTypes())
->orWhere(function ($legacyQuery) {
// 兼容迁移前默认归为 user_chat 的旧通知,避免历史游戏播报继续堆积。
$legacyQuery->where('retention_type', Message::RETENTION_USER_CHAT)
->where(function ($noticeQuery) {
$noticeQuery->whereIn('from_user', ['钓鱼播报', '星海小博士', '进出播报', '座驾播报'])
->orWhereIn('action', ['fishing_result', 'idiom_result', 'riddle_result', 'system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp']);
});
});
});
}
/** /**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。 * 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/ */
@@ -0,0 +1,98 @@
<?php
/**
* 文件功能:扫描并补算用户成就的 Artisan 命令。
*
* 支持单用户、全量与最近活跃用户三种扫描方式,便于定时任务和后台补算复用。
*/
namespace App\Console\Commands;
use App\Models\User;
use App\Services\AchievementService;
use Illuminate\Console\Command;
/**
* 类功能:通过命令行批量检查用户成就进度并写入解锁记录。
*/
class ScanAchievementsCommand extends Command
{
/**
* 命令签名。
*
* @var string
*/
protected $signature = 'achievements:scan
{--user= : 指定用户 ID 或用户名}
{--all : 扫描全部用户}
{--notify : 解锁时向用户推送本人可见通知}
{--dry-run : 仅预览,不写入成就记录}';
/**
* 命令描述。
*
* @var string
*/
protected $description = '扫描聊天室用户成就进度并补齐解锁记录';
/**
* 创建命令依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {
parent::__construct();
}
/**
* 执行成就扫描命令。
*/
public function handle(): int
{
$notify = (bool) $this->option('notify');
$dryRun = (bool) $this->option('dry-run');
if ($this->option('user')) {
$user = $this->resolveUser((string) $this->option('user'));
if (! $user) {
$this->error('未找到指定用户。');
return self::FAILURE;
}
$result = $this->achievementService->scanUser($user, $notify, $dryRun);
$this->info("已扫描用户 {$user->username}:检查 {$result['checked']} 项,解锁 {$result['unlocked']} 项,更新 {$result['updated']} 项。");
return self::SUCCESS;
}
$query = User::query()->orderBy('id');
if (! $this->option('all')) {
// 默认只扫最近活跃用户,避免定时任务每次全表扫描。
$query->where('updated_at', '>=', now()->subDay())->limit(200);
}
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
$query->chunkById(100, function ($users) use (&$summary, $notify, $dryRun): void {
$chunkSummary = $this->achievementService->scanUsers($users, $notify, $dryRun);
$summary['users'] += $chunkSummary['users'];
$summary['checked'] += $chunkSummary['checked'];
$summary['unlocked'] += $chunkSummary['unlocked'];
$summary['updated'] += $chunkSummary['updated'];
});
$this->info("成就扫描完成:用户 {$summary['users']} 人,检查 {$summary['checked']} 项,解锁 {$summary['unlocked']} 项,更新 {$summary['updated']} 项。");
return self::SUCCESS;
}
/**
* 根据 ID 或用户名解析用户。
*/
private function resolveUser(string $value): ?User
{
return User::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('username', $value))
->first();
}
}
+8
View File
@@ -35,6 +35,9 @@ enum CurrencySource: string
/** 商城购买消耗(扣除金币) */ /** 商城购买消耗(扣除金币) */
case SHOP_BUY = 'shop_buy'; case SHOP_BUY = 'shop_buy';
/** 购买聊天室座驾消耗(扣除金币) */
case RIDE_BUY = 'ride_buy';
/** 管理员手动调整(后台直接修改经验/金币/魅力) */ /** 管理员手动调整(后台直接修改经验/金币/魅力) */
case ADMIN_ADJUST = 'admin_adjust'; case ADMIN_ADJUST = 'admin_adjust';
@@ -158,6 +161,9 @@ enum CurrencySource: string
/** 购买头像框消耗(扣除金币) */ /** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy'; case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward';
/** /**
* 返回该来源的中文名称,用于后台统计展示。 * 返回该来源的中文名称,用于后台统计展示。
*/ */
@@ -171,6 +177,7 @@ enum CurrencySource: string
self::RECV_GIFT => '收到礼物', self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包', self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买', self::SHOP_BUY => '商城购买',
self::RIDE_BUY => '座驾购买(金币)',
self::ADMIN_ADJUST => '管理员调整', self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励', self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到', self::SIGN_IN => '每日签到',
@@ -210,6 +217,7 @@ enum CurrencySource: string
self::MSG_TEXT_COLOR_BUY => '文字颜色购买', self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)', self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买', self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜谜活动奖励',
}; };
} }
} }
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐押注人数变化。
*/
class BaccaratPoolUpdated implements ShouldBroadcastNow class BaccaratPoolUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->round->room_id)];
} }
/** /**
@@ -58,6 +61,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
{ {
return [ return [
'round_id' => $this->round->id, 'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_count_big' => $this->round->bet_count_big, 'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small, 'bet_count_small' => $this->round->bet_count_small,
'bet_count_triple' => $this->round->bet_count_triple, 'bet_count_triple' => $this->round->bet_count_triple,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐开局事件。
*/
class BaccaratRoundOpened implements ShouldBroadcastNow class BaccaratRoundOpened implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->round->room_id)];
} }
/** /**
@@ -58,6 +61,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
{ {
return [ return [
'round_id' => $this->round->id, 'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(), 'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(), 'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at), 'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐结算结果。
*/
class BaccaratRoundSettled implements ShouldBroadcastNow class BaccaratRoundSettled implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->round->room_id)];
} }
/** /**
@@ -58,6 +61,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
{ {
return [ return [
'round_id' => $this->round->id, 'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3], 'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points, 'total_points' => $this->round->total_points,
'result' => $this->round->result, 'result' => $this->round->result,
+14 -2
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
*/
class EffectBroadcast implements ShouldBroadcastNow class EffectBroadcast implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
/** /**
* 支持的特效类型列表(用于校验) * 支持的特效类型列表(用于校验)
*/ */
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies']; public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
/** /**
* 构造函数 * 构造函数
* *
* @param int $roomId 房间 ID * @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies * @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
* @param string $operator 触发特效的用户名(购买者) * @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员) * @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言 * @param string|null $giftMessage 附带赠言
* @param string|null $effectTitle 特效画面标题
* @param string|null $rideName 座驾名称
* @param string|null $effectUserInfo 特效画面用户身份信息
*/ */
public function __construct( public function __construct(
public readonly int $roomId, public readonly int $roomId,
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
public readonly string $operator, public readonly string $operator,
public readonly ?string $targetUsername = null, public readonly ?string $targetUsername = null,
public readonly ?string $giftMessage = null, public readonly ?string $giftMessage = null,
public readonly ?string $effectTitle = null,
public readonly ?string $rideName = null,
public readonly ?string $effectUserInfo = null,
) {} ) {}
/** /**
@@ -70,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
'operator' => $this->operator, 'operator' => $this->operator,
'target_username' => $this->targetUsername, // null = 全员 'target_username' => $this->targetUsername, // null = 全员
'gift_message' => $this->giftMessage, 'gift_message' => $this->giftMessage,
'effect_title' => $this->effectTitle,
'ride_name' => $this->rideName,
'effect_user_info' => $this->effectUserInfo,
]; ];
} }
} }
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播赛马开局事件。
*/
class HorseRaceOpened implements ShouldBroadcastNow class HorseRaceOpened implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->race->room_id)];
} }
/** /**
@@ -58,6 +61,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
{ {
return [ return [
'race_id' => $this->race->id, 'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'horses' => $this->race->horses, 'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool, 'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(), 'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
+6 -1
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间持续广播赛马进度。
*/
class HorseRaceProgress implements ShouldBroadcastNow class HorseRaceProgress implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/ */
public function __construct( public function __construct(
public readonly int $raceId, public readonly int $raceId,
public readonly int $roomId,
public readonly array $positions, public readonly array $positions,
public readonly bool $finished = false, public readonly bool $finished = false,
public readonly ?int $leaderId = null, public readonly ?int $leaderId = null,
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->roomId)];
} }
/** /**
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
{ {
return [ return [
'race_id' => $this->raceId, 'race_id' => $this->raceId,
'room_id' => $this->roomId,
'positions' => $this->positions, 'positions' => $this->positions,
'finished' => $this->finished, 'finished' => $this->finished,
'leader_id' => $this->leaderId, 'leader_id' => $this->leaderId,
+2 -1
View File
@@ -46,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/ */
public function broadcastOn(): array public function broadcastOn(): array
{ {
return [new PresenceChannel('room.1')]; return [new PresenceChannel('room.'.$this->race->room_id)];
} }
/** /**
@@ -94,6 +94,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
return [ return [
'race_id' => $this->race->id, 'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'winner_horse_id' => $this->race->winner_horse_id, 'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName, 'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool, 'total_pool' => (int) $this->race->total_pool,
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:猜谜活动答题结果广播事件
*
* 用户答对题目时广播,通知房间内所有用户结果。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播猜谜活动答题结果。
*/
class RiddleGameAnswered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造答题成功广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly int $roundId,
public readonly string $quizType,
public readonly string $answer,
public readonly string $winnerUsername,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_answer' => $this->answer,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'quiz_round_ended_id' => $this->roundId,
'answer' => $this->answer,
'winner_username' => $this->winnerUsername,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:猜谜活动开始广播事件
*
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播新一轮猜谜活动题目。
*/
class RiddleGameStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造新回合广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly string $quizType,
public readonly string $hint,
public readonly int $roundId,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_hint' => $this->hint,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'hint' => $this->hint,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:用户"拍一拍"广播事件
*
* 用户输入 /拍一拍 用户名 后触发,通过 WebSocket 广播给房间内所有用户,
* 前端显示 "XXX拍了拍XXX" 消息并触发屏幕抖动动画。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserPat implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $fromUser 拍人的用户
* @param string $targetUser 被拍的用户
* @param string $displayText 前端展示文本,如 "流星 拍了拍 张三"
*/
public function __construct(
public readonly int $roomId,
public readonly string $fromUser,
public readonly string $targetUser,
public readonly string $displayText,
public readonly ?string $fromUserHeadface = null,
) {}
/**
* 广播频道:向房间内所有在线用户推送
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'from_user' => $this->fromUser,
'target_user' => $this->targetUser,
'display_text' => $this->displayText,
'from_user_headface' => $this->fromUserHeadface,
];
}
}
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:前台用户成就展示控制器。
*
* 展示当前登录用户的成就分类、解锁状态和进度。
*/
namespace App\Http\Controllers;
use App\Services\AchievementService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 类功能:提供“我的成就”页面数据。
*/
class AchievementController extends Controller
{
/**
* 创建成就控制器依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {}
/**
* 展示当前登录用户的成就总览。
*/
public function index(Request $request): View
{
$user = Auth::user();
$this->achievementService->scanUser($user);
$achievementData = $this->achievementService->displayForUser($user);
$activeTab = in_array($request->query('status'), ['unlocked', 'locked'], true)
? $request->query('status')
: 'all';
$allAchievements = $achievementData['achievements'];
// 页面 tab 只影响展示列表,不影响顶部总进度统计。
$achievementTabs = [
'all' => [
'label' => '全部',
'count' => $allAchievements->count(),
'url' => route('achievements.index'),
],
'unlocked' => [
'label' => '已完成',
'count' => $allAchievements->where('unlocked', true)->count(),
'url' => route('achievements.index', ['status' => 'unlocked']),
],
'locked' => [
'label' => '未达成',
'count' => $allAchievements->where('unlocked', false)->count(),
'url' => route('achievements.index', ['status' => 'locked']),
],
];
$achievementData['achievements'] = match ($activeTab) {
'unlocked' => $allAchievements->where('unlocked', true)->values(),
'locked' => $allAchievements->where('unlocked', false)->values(),
default => $allAchievements,
};
return view('achievements.index', [
'user' => $user,
'active_tab' => $activeTab,
'achievement_tabs' => $achievementTabs,
...$achievementData,
]);
}
}
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:后台成就记录查询控制器。
*
* 提供固定成就目录的解锁统计与用户成就记录只读查询。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserAchievement;
use App\Support\AchievementCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* 类功能:展示后台成就总览、解锁记录与按成就分组统计。
*/
class AchievementController extends Controller
{
/**
* 展示成就记录总览。
*/
public function index(Request $request): View
{
$definitions = AchievementCatalog::definitions();
$query = UserAchievement::query()
->with('user:id,username')
->whereNotNull('achieved_at')
->latest('achieved_at');
if ($request->filled('username')) {
$query->whereHas('user', function ($userQuery) use ($request): void {
$userQuery->where('username', 'like', '%'.$request->string('username')->toString().'%');
});
}
if ($request->filled('achievement_key')) {
$query->where('achievement_key', $request->string('achievement_key')->toString());
}
$records = $query->paginate(30)->withQueryString();
$summary = [
'total_definitions' => count($definitions),
'unlocked_records' => UserAchievement::query()->whereNotNull('achieved_at')->count(),
'unlocked_users' => UserAchievement::query()->whereNotNull('achieved_at')->distinct('user_id')->count('user_id'),
];
$topAchievements = UserAchievement::query()
->whereNotNull('achieved_at')
->select('achievement_key', DB::raw('count(*) as unlocked_count'))
->groupBy('achievement_key')
->orderByDesc('unlocked_count')
->limit(10)
->get();
return view('admin.achievements.index', compact('definitions', 'records', 'summary', 'topAchievements'));
}
}
@@ -14,14 +14,20 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateGameConfigParamsRequest;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\LotteryIssue; use App\Models\LotteryIssue;
use App\Models\Room;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService; use App\Services\LotteryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
/**
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
*/
class GameConfigController extends Controller class GameConfigController extends Controller
{ {
/** /**
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
public function index(): View public function index(): View
{ {
$games = GameConfig::orderBy('id')->get(); $games = GameConfig::orderBy('id')->get();
$availableRooms = Room::query()->orderBy('id')->get();
return view('admin.game-configs.index', compact('games')); return view('admin.game-configs.index', compact('games', 'availableRooms'));
} }
/** /**
@@ -56,15 +63,19 @@ class GameConfigController extends Controller
* *
* 接收前端提交的 params JSON 对象并合并至现有配置。 * 接收前端提交的 params JSON 对象并合并至现有配置。
*/ */
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse public function updateParams(UpdateGameConfigParamsRequest $request, GameConfig $gameConfig, GameRoomScopeService $roomScopeService): RedirectResponse
{ {
$request->validate([
'params' => 'required|array',
]);
// 合并参数,保留已有键,只更新传入的键 // 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? []; $current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params')); // 这里不能只读取 validated('params')。
// 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数
// 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。
$validatedParams = (array) $request->input('params', []);
$updated = array_merge($current, $validatedParams);
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
$updated['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$updated['room_ids'] = $scopeConfig['room_ids'];
if ($gameConfig->game_key === 'mystery_box') { if ($gameConfig->game_key === 'mystery_box') {
$legacyMap = [ $legacyMap = [
@@ -107,17 +118,19 @@ class GameConfigController extends Controller
} }
// 检查是否有正在开放的箱子(避免同时多个) // 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) { $targetRoomId = app(GameRoomScopeService::class)->getPrimaryRoomIdForGame('mystery_box');
if (\App\Models\MysteryBox::currentOpenBox($targetRoomId)) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']); return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
} }
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id()); \App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱']; $typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!", 'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
]); ]);
} }
@@ -126,19 +139,31 @@ class GameConfigController extends Controller
* *
* 仅在当前无进行中期次时生效,防止重复开期。 * 仅在当前无进行中期次时生效,防止重复开期。
*/ */
public function openLotteryIssue(): JsonResponse public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
{ {
if (! GameConfig::isEnabled('lottery')) { if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']); return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
} }
if (LotteryIssue::currentIssue()) { $openedRoomIds = [];
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
if (LotteryIssue::currentIssue($roomId)) {
continue;
} }
\App\Jobs\OpenLotteryIssueJob::dispatch(); \App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
$openedRoomIds[] = $roomId;
}
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']); if ($openedRoomIds === []) {
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
}
return response()->json([
'ok' => true,
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
]);
} }
/** /**
@@ -0,0 +1,168 @@
<?php
/**
* 文件功能:猜谜活动题库后台管理控制器
*
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Riddle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:统一处理猜谜活动题库的后台管理动作。
*/
class RiddleController extends Controller
{
/**
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
*/
public function index(Request $request): View
{
$typeOptions = Riddle::typeOptions();
$selectedType = trim((string) $request->query('type', ''));
$keyword = trim((string) $request->query('keyword', ''));
$idiomQuery = Riddle::query();
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
// 题型筛选只接受系统支持值,避免非法参数污染查询。
$idiomQuery->ofType($selectedType);
}
if ($keyword !== '') {
// 关键词同时匹配答案与提示,方便后台快速定位题目。
$idiomQuery->where(function ($query) use ($keyword): void {
$query->where('answer', 'like', '%'.$keyword.'%')
->orWhere('hint', 'like', '%'.$keyword.'%');
});
}
$idioms = $idiomQuery
->orderBy('type')
->orderBy('sort')
->orderBy('id')
->get();
$typeStats = Riddle::query()
->selectRaw('type, COUNT(*) as total')
->groupBy('type')
->pluck('total', 'type')
->all();
return view('admin.riddles.index', [
'idioms' => $idioms,
'typeOptions' => $typeOptions,
'selectedType' => $selectedType,
'keyword' => $keyword,
'typeStats' => $typeStats,
]);
}
/**
* 方法功能:创建新的猜谜活动题目。
*/
public function store(Request $request): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 新增时默认启用,便于后台批量补题后立即可用。
$data['is_active'] = $request->boolean('is_active', true);
Riddle::create($data);
$typeLabel = Riddle::labelForType($data['type']);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "{$typeLabel}题目已添加!");
}
/**
* 方法功能:更新已有题目内容与题型。
*/
public function update(Request $request, Riddle $idiom): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 编辑时显式按复选框结果落库,避免旧状态残留。
$data['is_active'] = $request->boolean('is_active');
$idiom->update($data);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$idiom->answer}」已更新!");
}
/**
* 方法功能:通过 AJAX 切换题目的启用状态。
*/
public function toggle(Riddle $idiom): JsonResponse
{
// 开关按钮只变更启用状态,不改动其他题库字段。
$idiom->update(['is_active' => ! $idiom->is_active]);
return response()->json([
'ok' => true,
'is_active' => $idiom->is_active,
'message' => $idiom->is_active ? "{$idiom->answer}」已启用" : "{$idiom->answer}」已禁用",
]);
}
/**
* 方法功能:删除指定题目。
*/
public function destroy(Request $request, Riddle $idiom): RedirectResponse
{
$answer = $idiom->answer;
$idiom->delete();
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$answer}」已删除!");
}
/**
* 方法功能:校验后台题库保存载荷。
*
* @return array{type:string,answer:string,hint:string,sort:int}
*/
private function validateRiddlePayload(Request $request): array
{
return $request->validate([
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
'answer' => ['required', 'string', 'max:120'],
'hint' => ['required', 'string', 'max:255'],
'sort' => ['required', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
}
/**
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
*
* @return array<string, string>
*/
private function buildIndexFilters(Request $request): array
{
$filters = [];
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
if ($type !== '') {
$filters['type'] = $type;
}
if ($keyword !== '') {
$filters['keyword'] = $keyword;
}
return $filters;
}
}
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:后台座驾独立管理控制器。
*
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRideRequest;
use App\Http\Requests\UpdateRideRequest;
use App\Models\Ride;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 后台座驾管理控制器
* 负责独立 rides 表的后台管理流程。
*/
class RideController extends Controller
{
/**
* 显示座驾管理列表页。
*/
public function index(): View
{
$rides = Ride::query()
->orderBy('sort_order')
->orderBy('id')
->get();
return view('admin.rides.index', compact('rides'));
}
/**
* 新增座驾(仅 id=1 超级站长)。
*/
public function store(StoreRideRequest $request): RedirectResponse
{
$data = $request->validated();
Ride::create($data);
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!');
}
/**
* 更新座驾信息。
*/
public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse
{
$ride->update($request->validated());
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!');
}
/**
* 切换座驾上下架状态。
*/
public function toggle(Ride $ride): RedirectResponse
{
$ride->update(['is_active' => ! $ride->is_active]);
$status = $ride->is_active ? '上架' : '下架';
return redirect()->route('admin.rides.index')->with('success', "{$ride->name}」已{$status}");
}
/**
* 删除座驾(仅 id=1 超级站长)。
*/
public function destroy(Ride $ride): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$name = $ride->name;
$ride->delete();
return redirect()->route('admin.rides.index')->with('success', "{$name}」已删除。");
}
}
@@ -14,9 +14,10 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreShopItemRequest;
use App\Http\Requests\UpdateShopItemRequest;
use App\Models\ShopItem; use App\Models\ShopItem;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\View; use Illuminate\View\View;
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
/** /**
* 新增商品(仅 id=1 超级站长) * 新增商品(仅 id=1 超级站长)
*/ */
public function store(Request $request): RedirectResponse public function store(StoreShopItemRequest $request): RedirectResponse
{ {
abort_unless(Auth::id() === 1, 403); $data = $request->validated();
$data = $this->validateItem($request);
ShopItem::create($data); ShopItem::create($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!'); return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
* *
* @param ShopItem $shopItem 路由模型自动注入 * @param ShopItem $shopItem 路由模型自动注入
*/ */
public function update(Request $request, ShopItem $shopItem): RedirectResponse public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
{ {
$data = $this->validateItem($request, $shopItem); $data = $request->validated();
$shopItem->update($data); $shopItem->update($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!'); return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
return redirect()->route('admin.shop.index')->with('success', "{$name}」已删除。"); return redirect()->route('admin.shop.index')->with('success', "{$name}」已删除。");
} }
/**
* 统一验证商品表单(新增/编辑共用)
*
* @return array<string, mixed>
*/
private function validateItem(Request $request, ?ShopItem $item = null): array
{
return $request->validate([
'name' => 'required|string|max:100',
'slug' => ['required', 'string', 'max:100',
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
],
'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,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
'charm_bonus' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
}
} }
@@ -457,8 +457,9 @@ class AdminCommandController extends Controller
abort(403, '仅站长可查看私信'); abort(403, '仅站长可查看私信');
} }
// 查询最近 50 条悄悄话(发送或接收) // 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
$messages = Message::where('is_secret', true) $messages = Message::where('is_secret', true)
->where('from_user', 'not like', '系统%')
->where(function ($q) use ($username) { ->where(function ($q) use ($username) {
$q->where('from_user', $username) $q->where('from_user', $username)
->orWhere('to_user', $username); ->orWhere('to_user', $username);
@@ -524,6 +525,13 @@ class AdminCommandController extends Controller
'font_color' => '#b91c1c', 'font_color' => '#b91c1c',
'action' => '', 'action' => '',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '📢 公屏公告',
'message' => strip_tags($content),
'icon' => '📢',
'color' => '#b91c1c',
'duration' => 10000,
],
]; ];
$this->chatState->pushMessage($roomId, $msg); $this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg)); broadcast(new MessageSent($roomId, $msg));
@@ -1016,7 +1024,7 @@ class AdminCommandController extends Controller
'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!", 'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
'icon' => '💰', 'icon' => '💰',
'color' => '#f59e0b', 'color' => '#f59e0b',
'duration' => 8000, 'duration' => 3000,
], ],
]; ];
$this->chatState->pushMessage($roomId, $msg); $this->chatState->pushMessage($roomId, $msg);
+23 -23
View File
@@ -20,16 +20,23 @@ use App\Models\BaccaratBet;
use App\Models\BaccaratRound; use App\Models\BaccaratRound;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService; use App\Services\BaccaratLossCoverService;
use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:提供百家乐当前局查询、下注与历史接口。
*/
class BaccaratController extends Controller class BaccaratController extends Controller
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService, private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -38,7 +45,13 @@ class BaccaratController extends Controller
public function currentRound(Request $request): JsonResponse public function currentRound(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$round = BaccaratRound::currentRound(); $roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$round = BaccaratRound::currentRound($roomId);
if (! $round) { if (! $round) {
return response()->json([ return response()->json([
@@ -98,6 +111,11 @@ class BaccaratController extends Controller
'bet_type' => 'required|in:big,small,triple', 'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1', 'amount' => 'required|integer|min:1',
]); ]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启百家乐。'], 403);
}
$config = GameConfig::forGame('baccarat')?->params ?? []; $config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100); $minBet = (int) ($config['min_bet'] ?? 100);
@@ -109,7 +127,7 @@ class BaccaratController extends Controller
$round = BaccaratRound::find($data['round_id']); $round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) { if (! $round || (int) $round->room_id !== $roomId || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']); return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
} }
@@ -176,27 +194,7 @@ class BaccaratController extends Controller
'big' => '大', 'small' => '小', default => '豹子' 'big' => '大', 'small' => '小', default => '豹子'
}; };
// 发送系统传音到聊天室,公示该用户的押注信息 $this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
$chatState = app(\App\Services\ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$roomId = $round->room_id ?? 1;
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
$content = "🎲 <b> 【百家乐】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
event(new \App\Events\MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -212,7 +210,9 @@ class BaccaratController extends Controller
*/ */
public function history(): JsonResponse public function history(): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$rounds = BaccaratRound::query() $rounds = BaccaratRound::query()
->where('room_id', $roomId)
->where('status', 'settled') ->where('status', 'settled')
->orderByDesc('id') ->orderByDesc('id')
->limit(10) ->limit(10)
+135 -1
View File
@@ -26,8 +26,10 @@ use App\Models\User;
use App\Services\AppointmentService; use App\Services\AppointmentService;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService; use App\Services\ChatUserPresenceService;
use App\Services\DailyGameProfitLeaderboardService;
use App\Services\MessageFilterService; use App\Services\MessageFilterService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
use App\Services\RideService;
use App\Services\RoomBroadcastService; use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Services\VipService; use App\Services\VipService;
@@ -62,9 +64,11 @@ class ChatController extends Controller
private readonly VipService $vipService, private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService, private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly DailyGameProfitLeaderboardService $dailyGameProfitLeaderboardService,
private readonly AppointmentService $appointmentService, private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast, private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
private readonly RideService $rideService,
) {} ) {}
/** /**
@@ -116,6 +120,8 @@ class ChatController extends Controller
// 3. 广播和初始化欢迎(仅限初次进入) // 3. 广播和初始化欢迎(仅限初次进入)
$newbieEffect = null; $newbieEffect = null;
$initialRideEffect = null;
$initialRideEffectOptions = null;
$initialPresenceTheme = null; $initialPresenceTheme = null;
$initialWelcomeMessage = null; $initialWelcomeMessage = null;
$initialWelcomeMessages = []; $initialWelcomeMessages = [];
@@ -192,6 +198,8 @@ class ChatController extends Controller
} }
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。 // 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
if (! $ridePresencePayload) {
[$text, $color] = $this->broadcast->buildEntryBroadcast($user); [$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join'); $vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
@@ -229,6 +237,48 @@ class ChatController extends Controller
} }
} }
if ($ridePresencePayload) {
$rideWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '座驾播报',
'to_user' => '大家',
'content' => "<span style=\"color:#0f766e;font-weight:bold;\">{$ridePresencePayload['ride_icon']} {$ridePresencePayload['identity_text']} · {$ridePresencePayload['welcome_text']}</span>",
'is_secret' => false,
'font_color' => '#0f766e',
'action' => 'ride_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'ride_presence',
'ride_key' => $ridePresencePayload['ride_key'],
'ride_name' => $ridePresencePayload['ride_name'],
'effect_title' => $ridePresencePayload['effect_title'],
'effect_user_info' => $ridePresencePayload['effect_user_info'],
'sent_at' => now()->toDateTimeString(),
];
// 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
$this->chatState->pushMessage($id, $rideWelcomeMsg);
broadcast(new MessageSent($id, $rideWelcomeMsg));
broadcast(new \App\Events\EffectBroadcast(
$id,
$ridePresencePayload['ride_key'],
$user->username,
effectTitle: $ridePresencePayload['effect_title'],
rideName: $ridePresencePayload['ride_name'],
effectUserInfo: $ridePresencePayload['effect_user_info'],
))->toOthers();
$initialRideEffect = $ridePresencePayload['ride_key'];
$initialRideEffectOptions = [
'effect_title' => $ridePresencePayload['effect_title'],
'effect_user_info' => $ridePresencePayload['effect_user_info'],
'ride_name' => $ridePresencePayload['ride_name'],
'operator' => $user->username,
];
$initialWelcomeMessages[] = $rideWelcomeMsg;
}
}
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染 // 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的 // 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0); $allHistory = $this->chatState->getNewMessages($id, 0);
@@ -314,6 +364,8 @@ class ChatController extends Controller
'user' => $user, 'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user), 'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect, 'newbieEffect' => $newbieEffect,
'initialRideEffect' => $initialRideEffect,
'initialRideEffectOptions' => $initialRideEffectOptions,
'initialPresenceTheme' => $initialPresenceTheme, 'initialPresenceTheme' => $initialPresenceTheme,
'initialWelcomeMessage' => $initialWelcomeMessage, 'initialWelcomeMessage' => $initialWelcomeMessage,
'initialWelcomeMessages' => $initialWelcomeMessages, 'initialWelcomeMessages' => $initialWelcomeMessages,
@@ -322,6 +374,7 @@ class ChatController extends Controller
'pendingDivorce' => $pendingDivorceData, 'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap, 'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true), 'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
'dailyGameProfitLeaders' => $this->dailyGameProfitLeaderboardService->topThree(),
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(), 'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user), 'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
]); ]);
@@ -449,6 +502,17 @@ class ChatController extends Controller
$messageData = array_merge($messageData, $imagePayload); $messageData = array_merge($messageData, $imagePayload);
} }
// 欢迎动作:增加右下角弹窗通知(内容含发送者信息)
if (($data['action'] ?? '') === '欢迎') {
$messageData['toast_notification'] = [
'title' => '👋 欢迎',
'message' => strip_tags($pureContent),
'icon' => '👋',
'color' => '#e11d48',
'duration' => 3000,
];
}
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观 // 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user); $decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations); $messageData = array_merge($messageData, $decorations);
@@ -1364,7 +1428,7 @@ class ChatController extends Controller
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!", 'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰', 'icon' => '💰',
'color' => '#f59e0b', 'color' => '#f59e0b',
'duration' => 8000, 'duration' => 3000,
], ],
]; ];
@@ -1404,6 +1468,76 @@ class ChatController extends Controller
return null; return null;
} }
/**
* 拍一拍:用户通过 /拍一拍 命令向所选对象发送拍一拍通知。
*/
public function pat(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再使用拍一拍。')) {
return $response;
}
// 0. 检查用户是否被禁言
$muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) {
$ttl = Redis::ttl($muteKey);
$minutes = ceil($ttl / 60);
return response()->json([
'status' => 'error',
'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。",
], 403);
}
$targetUser = $request->input('target_user', '');
if (empty($targetUser) || $targetUser === '大家') {
return response()->json([
'status' => 'error',
'message' => '请选择一个聊天对象(不能为大家)进行拍一拍。',
], 422);
}
// 检查目标是否在线
$isOnline = Redis::hexists("room:{$id}:users", $targetUser);
if (! $isOnline) {
return response()->json([
'status' => 'error',
'message' => "{$targetUser}】目前已离开聊天室或不在线。",
], 200);
}
// 不能拍自己
if ($targetUser === $user->username) {
return response()->json([
'status' => 'error',
'message' => '不能拍自己哦~',
], 422);
}
// 获取发送者头像
$headface = $user->usersf ?: '1.gif';
$headSrc = str_starts_with($headface, 'storage/') ? '/'.$headface : '/images/headface/'.$headface;
// 构造展示文本
$displayText = "{$user->username} 拍了拍 {$targetUser}";
// 广播到房间
broadcast(new \App\Events\UserPat(
roomId: $id,
fromUser: $user->username,
targetUser: $targetUser,
displayText: $displayText,
fromUserHeadface: $headSrc,
));
return response()->json([
'status' => 'success',
'message' => $displayText,
]);
}
/** /**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。 * 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/ */
@@ -185,9 +185,10 @@ class DailySignInController extends Controller
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。'; .',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
} }
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" ' $quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;' .'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;">' .'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>'; .'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 ' return '【'.e($user->username).'】完成今日签到,连续签到 '
+7 -1
View File
@@ -43,6 +43,11 @@ class EarnController extends Controller
*/ */
public function claimVideoReward(Request $request): JsonResponse public function claimVideoReward(Request $request): JsonResponse
{ {
return response()->json([
'success' => false,
'message' => '看视频赚钱功能已关闭。',
]);
/** @var User $user */ /** @var User $user */
$user = Auth::user(); $user = Auth::user();
@@ -99,9 +104,10 @@ class EarnController extends Controller
// 6. 广播全服系统消息 // 6. 广播全服系统消息
if ($roomId > 0) { if ($roomId > 0) {
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" ' $promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;' .'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;' .'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>'; .'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [ $sysMsg = [
+92 -16
View File
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\FishingService; use App\Services\FishingService;
use App\Services\GameRoomScopeService;
use App\Services\ShopService; use App\Services\ShopService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Services\VipService; use App\Services\VipService;
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/**
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
*/
class FishingController extends Controller class FishingController extends Controller
{ {
/**
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
*/
public function __construct( public function __construct(
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly VipService $vipService, private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService, private readonly ShopService $shopService,
private readonly FishingService $fishingService, private readonly FishingService $fishingService,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
@@ -63,6 +72,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403); return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
} }
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 检查冷却时间(Redis TTL // 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}"; $cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) { if (Redis::exists($cooldownKey)) {
@@ -75,6 +88,14 @@ class FishingController extends Controller
], 429); ], 429);
} }
$tokenKey = "fishing:token:{$user->id}";
if (Redis::exists($tokenKey)) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
}
// 2. 检查金币是否足够 // 2. 检查金币是否足够
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); $cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) { if (($user->jjb ?? 0) < $cost) {
@@ -84,7 +105,35 @@ class FishingController extends Controller
], 422); ], 422);
} }
// 3. 扣除金币 // 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenTtl = $waitTime + 13;
$tokenPayload = json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]);
// 原子占用本次抛竿 token,避免多标签页自动钓鱼互相覆盖令牌。
$reserved = Redis::command('set', [$tokenKey, $tokenPayload, 'EX', $tokenTtl, 'NX']);
if (! $reserved) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
return response()->json([
'status' => 'error',
'message' => '钓鱼状态同步中,请稍后重试。',
'retry_after' => 3,
], 409);
}
try {
// token 占用成功后才扣金币,确保重复抛竿不会多扣费用。
$this->currencyService->change( $this->currencyService->change(
$user, 'gold', -$cost, $user, 'gold', -$cost,
CurrencySource::FISHING_COST, CurrencySource::FISHING_COST,
@@ -92,26 +141,18 @@ class FishingController extends Controller
$id, $id,
); );
$user->refresh(); $user->refresh();
} catch (\Throwable $exception) {
// 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。
Redis::del($tokenKey);
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲) throw $exception;
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); }
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]));
// 5. 生成随机浮漂坐标(百分比,避开边缘) // 4. 生成随机浮漂坐标(百分比,避开边缘)
$bobberX = rand(15, 85); // 左右 15%~85% $bobberX = rand(15, 85); // 左右 15%~85%
$bobberY = rand(20, 65); // 上下 20%~65% $bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡 // 5. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user); $autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([ return response()->json([
@@ -128,6 +169,37 @@ class FishingController extends Controller
]); ]);
} }
/**
* 恢复已有钓鱼会话,避免刷新页面后丢失前端内存里的收竿令牌。
*/
private function restoreActiveFishingSessionResponse(User $user, string $tokenKey): ?JsonResponse
{
$stored = json_decode((string) Redis::get($tokenKey), true);
if (! is_array($stored) || empty($stored['token'])) {
Redis::del($tokenKey);
return null;
}
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
$waitTime = max(0, (int) ($stored['wait_time'] ?? 0) - $elapsed);
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
'status' => 'success',
'message' => '已恢复正在进行的钓鱼,请等待本次收竿。',
'wait_time' => $waitTime,
'bobber_x' => rand(15, 85),
'bobber_y' => rand(20, 65),
'token' => (string) $stored['token'],
'auto_fishing' => $autoFishingMinutes > 0,
'auto_fishing_minutes_left' => $autoFishingMinutes,
'cost' => 0,
'jjb' => $user->jjb,
'restored' => true,
]);
}
/** /**
* 收竿 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。 * 收竿 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
* *
@@ -142,6 +214,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401); return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
} }
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待) // 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}"; $tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey); $storedJson = Redis::get($tokenKey);
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Models\FortuneLog; use App\Models\FortuneLog;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/**
* 类功能:提供神秘占卜状态、抽签和历史接口。
*/
class FortuneTellingController extends Controller class FortuneTellingController extends Controller
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
return response()->json(['enabled' => false]); return response()->json(['enabled' => false]);
} }
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['enabled' => false]);
}
$user = $request->user(); $user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? []; $config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']); return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
} }
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘占卜。'], 403);
}
$user = $request->user(); $user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? []; $config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
*/ */
public function history(Request $request): JsonResponse public function history(Request $request): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['history' => []]);
}
$logs = FortuneLog::query() $logs = FortuneLog::query()
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->orderByDesc('id') ->orderByDesc('id')
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent; use App\Events\GomokuMovedEvent;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\GomokuGame; use App\Models\GomokuGame;
use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService; use App\Services\GomokuAiService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:处理五子棋创建、加入与对局过程接口。
*/
class GomokuController extends Controller class GomokuController extends Controller
{ {
public function __construct( public function __construct(
private readonly GomokuAiService $ai, private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
@@ -58,6 +63,10 @@ class GomokuController extends Controller
$user = $request->user(); $user = $request->user();
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
}
// PvP:检查是否已在等待/对局中(一次只能参与一场) // PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query() $activeGame = GomokuGame::query()
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
+138 -22
View File
@@ -17,21 +17,28 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\HorseBet; use App\Models\HorseBet;
use App\Models\HorseRace; use App\Models\HorseRace;
use App\Services\ChatStateService; use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:赛马竞猜前台控制器
*
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
*/
class HorseRaceController extends Controller class HorseRaceController extends Controller
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -44,7 +51,12 @@ class HorseRaceController extends Controller
return response()->json(['message' => '未登录', 'status' => 'error'], 401); return response()->json(['message' => '未登录', 'status' => 'error'], 401);
} }
$race = HorseRace::currentRace(); $roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['race' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$race = $this->resolveCurrentRaceState(HorseRace::currentRace($roomId));
if (! $race) { if (! $race) {
return response()->json([ return response()->json([
@@ -139,6 +151,11 @@ class HorseRaceController extends Controller
'horse_id' => 'required|integer|min:1', 'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1', 'amount' => 'required|integer|min:1',
]); ]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启赛马竞猜。'], 403);
}
$config = GameConfig::forGame('horse_racing')?->params ?? []; $config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100); $minBet = (int) ($config['min_bet'] ?? 100);
@@ -150,7 +167,7 @@ class HorseRaceController extends Controller
$race = HorseRace::find($data['race_id']); $race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) { if (! $race || (int) $race->room_id !== $roomId || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']); return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
} }
@@ -203,23 +220,7 @@ class HorseRaceController extends Controller
'status' => 'pending', 'status' => 'pending',
]); ]);
$chatState = app(ChatStateService::class); $this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -235,7 +236,9 @@ class HorseRaceController extends Controller
*/ */
public function history(): JsonResponse public function history(): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$races = HorseRace::query() $races = HorseRace::query()
->where('room_id', $roomId)
->where('status', 'settled') ->where('status', 'settled')
->orderByDesc('id') ->orderByDesc('id')
->limit(10) ->limit(10)
@@ -264,6 +267,119 @@ class HorseRaceController extends Controller
return response()->json(['history' => $history]); return response()->json(['history' => $history]);
} }
/**
* 自愈当前场次状态,避免线上遗漏结算时长期卡在 running。
*/
private function resolveCurrentRaceState(?HorseRace $race): ?HorseRace
{
if (! $race || $race->status !== 'running') {
return $race;
}
if (! $this->shouldRecoverStaleRunningRace($race)) {
return $race;
}
$race = $this->prepareRunningRaceForSettlement($race);
if (! $race || $race->status !== 'running' || ! $race->winner_horse_id) {
return $race;
}
// 线上若漏消费 CloseHorseRaceJob,这里同步补做一次结算,避免界面一直显示“跑马中”。
app()->call([new \App\Jobs\CloseHorseRaceJob($race), 'handle']);
return HorseRace::currentRace((int) $race->room_id);
}
/**
* 判断 running 场次是否已经超过合理比赛时长,需要请求侧补偿收尾。
*/
private function shouldRecoverStaleRunningRace(HorseRace $race): bool
{
if (! $race->race_starts_at) {
return false;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = max(1, (int) ($config['race_duration'] ?? 30));
$recoveryGraceSeconds = 5;
return $race->race_starts_at->lte(now()->subSeconds($raceDuration + $recoveryGraceSeconds));
}
/**
* 为超时 running 场次补齐缺失赛果字段,确保后续结算任务可以安全执行。
*/
private function prepareRunningRaceForSettlement(HorseRace $race): ?HorseRace
{
if ($race->winner_horse_id && $race->race_ends_at) {
return $race->fresh();
}
$horses = $this->normalizeRaceHorses($race->horses);
$winnerHorseId = $race->winner_horse_id ?: $this->resolveStaleRunningWinnerId($race, $horses);
if (! $winnerHorseId) {
return $race;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$seedPool = (int) ($config['seed_pool'] ?? 0);
// 线上补偿场景下以当前下注快照补齐统计,确保本次请求内的结算口径与正常流程一致。
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = $seedPool + (int) HorseBet::query()->where('race_id', $race->id)->sum('amount');
HorseRace::query()
->where('id', $race->id)
->where('status', 'running')
->update([
'winner_horse_id' => $winnerHorseId,
'race_ends_at' => $race->race_ends_at ?? now(),
'total_bets' => $totalBets,
'total_pool' => $totalPool,
]);
return $race->fresh();
}
/**
* 为异常滞留的 running 场次推导一个稳定冠军,避免多次请求得到不同结算结果。
*
* @param array<int, array{id:int,name:string,emoji:string}> $horses
*/
private function resolveStaleRunningWinnerId(HorseRace $race, array $horses): ?int
{
if ($horses === []) {
return null;
}
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->map(fn ($pool) => (int) $pool)
->toArray();
$candidateIds = array_map(
fn (array $horse): int => (int) $horse['id'],
$horses,
);
usort($candidateIds, function (int $leftId, int $rightId) use ($horsePools): int {
$leftPool = (int) ($horsePools[$leftId] ?? 0);
$rightPool = (int) ($horsePools[$rightId] ?? 0);
if ($leftPool === $rightPool) {
return $leftId <=> $rightId;
}
return $rightPool <=> $leftPool;
});
return $candidateIds[0] ?? null;
}
/** /**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。 * 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
* *
+20 -2
View File
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\LotteryIssue; use App\Models\LotteryIssue;
use App\Models\LotteryTicket; use App\Models\LotteryTicket;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService; use App\Services\LotteryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
/**
* 类功能:提供双色球当前期、购票和历史记录接口。
*/
class LotteryController extends Controller class LotteryController extends Controller
{ {
public function __construct( public function __construct(
private readonly LotteryService $lottery, private readonly LotteryService $lottery,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。 * 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/ */
public function current(): JsonResponse public function current(Request $request): JsonResponse
{ {
if (! GameConfig::isEnabled('lottery')) { if (! GameConfig::isEnabled('lottery')) {
return response()->json(['enabled' => false]); return response()->json(['enabled' => false]);
} }
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue(); $roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['enabled' => false, 'message' => '当前房间未开启双色球彩票。'], 403);
}
$issue = LotteryIssue::currentIssue($roomId) ?? LotteryIssue::latestIssue($roomId);
if (! $issue) { if (! $issue) {
return response()->json(['enabled' => true, 'issue' => null]); return response()->json(['enabled' => true, 'issue' => null]);
@@ -90,6 +101,11 @@ class LotteryController extends Controller
*/ */
public function buy(Request $request): JsonResponse public function buy(Request $request): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启双色球彩票。'], 403);
}
$request->validate([ $request->validate([
'numbers' => 'required|array|min:1', 'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3', 'numbers.*.reds' => 'required|array|size:3',
@@ -132,7 +148,9 @@ class LotteryController extends Controller
*/ */
public function history(): JsonResponse public function history(): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
$issues = LotteryIssue::query() $issues = LotteryIssue::query()
->where('room_id', $roomId)
->where('status', 'settled') ->where('status', 'settled')
->latest() ->latest()
->limit(20) ->limit(20)
+25 -11
View File
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
use App\Models\MysteryBox; use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim; use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
*/
class MysteryBoxController extends Controller class MysteryBoxController extends Controller
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。 * 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/ */
public function status(): JsonResponse public function status(Request $request): JsonResponse
{ {
if (! GameConfig::isEnabled('mystery_box')) { if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]); return response()->json(['active' => false]);
} }
$box = MysteryBox::currentOpenBox(); $roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox($roomId);
if (! $box) { if (! $box) {
return response()->json(['active' => false]); return response()->json(['active' => false]);
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
} }
$user = $request->user(); $user = $request->user();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
return DB::transaction(function () use ($user, $passcode): JsonResponse { if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘箱子。'], 403);
}
return DB::transaction(function () use ($user, $passcode, $roomId): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发) // 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query() $box = MysteryBox::query()
->where('room_id', $roomId)
->where('passcode', $passcode) ->where('passcode', $passcode)
->where('status', 'open') ->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName(); $typeName = $box->typeName();
if ($reward >= 0) { if ($reward >= 0) {
$content = "{$emoji}【神秘箱子】开箱播报:恭喜{$username} 抢到了神秘{$typeName}" $content = "{$emoji}{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
.'获得 💰'.number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399'; $color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else { } else {
$content = "☠️【神秘箱子】《黑化陷阱》haha{$username} 中了神秘黑化箱的陷阱!" $content = "☠️ {$username}踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$color = '#f87171'; $color = '#f87171';
} }
$msg = [ $msg = [
'id' => $this->chatState->nextMessageId(1), 'id' => $this->chatState->nextMessageId((int) $box->room_id),
'room_id' => 1, 'room_id' => (int) $box->room_id,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$this->chatState->pushMessage(1, $msg); $this->chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
} }
} }
+4 -16
View File
@@ -26,6 +26,7 @@ use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope; use App\Models\RedPacketEnvelope;
use App\Models\User; use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\GameBetBroadcastService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry; use App\Support\PositionPermissionRegistry;
@@ -58,6 +59,7 @@ class RedPacketController extends Controller
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -357,23 +359,9 @@ class RedPacketController extends Controller
type: $envelopeType, type: $envelopeType,
)); ));
// 在聊天室发送领取播报(所有人可见) // 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币'; $typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰'; $this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
$claimedMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $claimedMsg);
broadcast(new MessageSent($roomId, $claimedMsg));
SaveMessageJob::dispatch($claimedMsg);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb'; $balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField; $balanceNow = $user->fresh()->$balanceField;
@@ -0,0 +1,269 @@
<?php
/**
* 文件功能:猜谜活动控制器
*
* 负责兼容现有 idiom-quiz 路由,同时支持猜成语与脑筋急转弯
* 两类题型的开题、答题与当前回合查询。
*/
namespace App\Http\Controllers;
use App\Events\RiddleGameAnswered;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Services\RiddleGameService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理猜谜活动开题、答题和当前回合读取。
*/
class RiddleQuizController extends Controller
{
/**
* 方法功能:注入猜谜活动所需的服务。
*/
public function __construct(
private readonly RiddleGameService $riddleGameService,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 方法功能:管理员手动为指定房间与题型发起一轮猜谜活动。
*/
public function start(Request $request): JsonResponse
{
$user = Auth::user();
// 仅站长或具备后台职务的管理用户可手动开题。
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
}
$roomId = (int) $request->input('room_id', 0);
// 兼容后台新字段 quiz_type 与旧字段 type,两边都允许触发手动出题。
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 猜谜活动总开关关闭时,直接返回明确提示,避免误报成“题库为空”。
if (! GameConfig::isEnabled(Riddle::TYPE_IDIOM)) {
return response()->json([
'status' => 'error',
'message' => '猜谜活动未开启,请先到游戏管理中开启后再出题。',
], 400);
}
// 后台手动出题允许覆盖当前同题型回合,避免管理员还要先人工结束上一题。
$this->riddleGameService->endActiveRoundsForRoom($roomId, $quizType);
$round = $this->riddleGameService->startRound($roomId, $quizType);
if (! $round) {
if (! $this->riddleGameService->pickRandomQuestion($quizType)) {
return response()->json(['status' => 'error', 'message' => '当前题型题库中没有可用题目,请先在后台添加。'], 400);
}
return response()->json(['status' => 'error', 'message' => '当前题型暂时无法出题,请检查游戏配置与参与房间设置。'], 400);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:提交当前猜谜活动回合的答案。
*/
public function answer(Request $request): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$roundId = (int) $request->input('round_id');
$roomId = (int) $request->input('room_id');
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
$userAnswer = trim((string) $request->input('answer', ''));
if ($roundId <= 0 || $roomId <= 0 || $userAnswer === '') {
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
}
$round = RiddleGameRound::with('idiom')->find($roundId);
if (! $round || $round->room_id !== $roomId || $round->quiz_type !== $quizType) {
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
// 判题前先做超时结算,避免用户继续抢答无效回合。
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 答案对比忽略空格与大小写,减少正常输入误判。
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', (string) $round->idiom?->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
return response()->json([
'status' => 'error',
'message' => '答案不正确,再想想!',
]);
}
$lockKey = "riddle:answer_lock:{$roundId}";
if (! Redis::setnx($lockKey, 1)) {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
Redis::expire($lockKey, 10);
// 抢答成功后立即封盘,确保并发请求读到统一状态。
$round->update([
'status' => 'answered',
'winner_id' => $user->id,
'winner_username' => $user->username,
'ended_at' => now(),
]);
if ($round->reward_gold > 0) {
$this->currencyService->change(
$user,
'gold',
$round->reward_gold,
\App\Enums\CurrencySource::GAME_REWARD,
$this->riddleGameService->buildRewardDescription($round),
$roomId,
);
}
if ($round->reward_exp > 0) {
// 经验奖励仍沿用现有字段,避免引入额外奖励服务改动。
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
$user->save();
}
broadcast(new RiddleGameAnswered(
roomId: $roomId,
roundId: $round->id,
quizType: $round->quiz_type,
answer: (string) $round->idiom?->answer,
winnerUsername: $user->username,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$quizTypeLabel = $this->riddleGameService->getQuizTypeLabel($round->quiz_type);
$resultMsg = [
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🎉 【猜谜活动·{$quizTypeLabel}{$user->username} 率先答对「{$round->idiom?->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => 'idiom_result',
'winner_username' => $user->username,
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'quiz_answer' => (string) $round->idiom?->answer,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'idiom_answer' => (string) $round->idiom?->answer,
'idiom_result_reward_gold' => $round->reward_gold,
'idiom_result_reward_exp' => $round->reward_exp,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $resultMsg);
Redis::del($lockKey);
return response()->json([
'status' => 'success',
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'answer' => (string) $round->idiom?->answer,
'quiz_answer' => (string) $round->idiom?->answer,
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:查询当前房间指定题型的进行中回合。
*/
public function current(Request $request): JsonResponse
{
$roomId = (int) $request->input('room_id', 0);
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
$round = $this->riddleGameService->findActiveRound($roomId, $quizType);
if (! $round) {
return response()->json(['status' => 'success', 'data' => null]);
}
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
/**
* 文件功能:聊天室座驾前台接口控制器。
*
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Http\Requests\BuyRideRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Ride;
use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\RideService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* 聊天室座驾控制器
* 负责前台座驾页面的数据读取与购买操作。
*/
class RideController extends Controller
{
/**
* 构造座驾控制器依赖。
*/
public function __construct(
private readonly RideService $rideService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取座驾页面需要的商品、当前座驾和购买记录。
*/
public function items(): JsonResponse
{
$user = Auth::user();
return response()->json([
'items' => $this->rideService->activeItems()
->map(fn (Ride $item) => $this->rideService->formatItem($item))
->values(),
'current_ride' => $this->rideService->formatCurrentRide($user),
'purchases' => $this->rideService->purchaseRecords($user),
'user_jjb' => $user->jjb ?? 0,
]);
}
/**
* 购买座驾并返回最新金币和当前座驾状态。
*/
public function buy(BuyRideRequest $request): JsonResponse
{
$user = Auth::user();
$roomId = (int) $request->integer('room_id');
$room = Room::query()->findOrFail($roomId);
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403);
}
$item = Ride::query()->findOrFail((int) $request->integer('item_id'));
$result = $this->rideService->buy($user, $item, $roomId);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
$this->pushRidePurchaseNotice($user, $item, $roomId);
return response()->json([
'status' => 'success',
'message' => $result['message'],
'current_ride' => $result['current_ride'] ?? null,
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
'jjb' => $user->fresh()->jjb,
]);
}
/**
* 向当前房间广播座驾购买成功通知,方便其他用户快速打开座驾页面。
*/
private function pushRidePurchaseNotice(User $user, Ride $item, int $roomId): void
{
$button = '<button onclick="openRideModal()">购买座驾</button>';
$content = sprintf(
'🚀 【座驾】 <b>%s</b> 购买了 <b>%s</b>,有效期 <b>%d 天</b>,排面已安排!%s',
e($user->username),
e($item->name),
(int) $item->duration_days,
$button,
);
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#0f766e',
'action' => 'ride_purchase',
'sent_at' => now()->toDateTimeString(),
];
// 购买通知需要写入房间消息缓存、实时广播并落库,刷新后仍可追溯。
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
}
}
+5 -1
View File
@@ -40,7 +40,11 @@ class ShopController extends Controller
public function items(): JsonResponse public function items(): JsonResponse
{ {
$user = Auth::user(); $user = Auth::user();
$items = ShopItem::active()->map(fn ($item) => [ $items = ShopItem::query()
->where('is_active', true)
->orderBy('sort_order')
->get()
->map(fn ($item) => [
'id' => $item->id, 'id' => $item->id,
'name' => $item->name, 'name' => $item->name,
'slug' => $item->slug, 'slug' => $item->slug,
+33 -13
View File
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\SlotMachineLog; use App\Models\SlotMachineLog;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:提供老虎机信息查询、转动和个人记录接口。
*/
class SlotMachineController extends Controller class SlotMachineController extends Controller
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {} ) {}
/** /**
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
return response()->json(['enabled' => false]); return response()->json(['enabled' => false]);
} }
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['enabled' => false]);
}
$config = GameConfig::forGame('slot_machine')?->params ?? []; $config = GameConfig::forGame('slot_machine')?->params ?? [];
$user = $request->user(); $user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0); $dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
return response()->json(['ok' => false, 'message' => '老虎机未开放。']); return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
} }
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启老虎机。'], 403);
}
$config = GameConfig::forGame('slot_machine')?->params ?? []; $config = GameConfig::forGame('slot_machine')?->params ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100); $cost = (int) ($config['cost_per_spin'] ?? 100);
$dailyLimit = (int) ($config['daily_limit'] ?? 0); $dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -100,7 +115,7 @@ class SlotMachineController extends Controller
} }
} }
return DB::transaction(function () use ($user, $cost, $config): JsonResponse { return DB::transaction(function () use ($user, $cost, $config, $roomId): JsonResponse {
// ① 扣费 // ① 扣费
$this->currency->change( $this->currency->change(
$user, $user,
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
if ($resultType === 'jackpot') { if ($resultType === 'jackpot') {
// 三个7:全服公屏广播 // 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost); $this->broadcastJackpot($user->username, $payout, $cost, $roomId);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) { } elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知 // 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost; $net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币'; $content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content); $this->broadcastPersonal($user->username, $content, $roomId);
} elseif ($resultType === 'curse') { } elseif ($resultType === 'curse') {
// 诅咒:通知本人 // 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!'; $content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content); $this->broadcastPersonal($user->username, $content, $roomId);
} }
$user->refresh(); $user->refresh();
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
*/ */
public function history(Request $request): JsonResponse public function history(Request $request): JsonResponse
{ {
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['history' => []]);
}
$logs = SlotMachineLog::query() $logs = SlotMachineLog::query()
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->orderByDesc('id') ->orderByDesc('id')
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
/** /**
* 三个7全服公屏广播。 * 三个7全服公屏广播。
*/ */
private function broadcastJackpot(string $username, int $payout, int $cost): void private function broadcastJackpot(string $username, int $payout, int $cost, int $roomId): void
{ {
$net = $payout - $cost; $net = $payout - $cost;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!" $content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!'; .'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [ $msg = [
'id' => $this->chatState->nextMessageId(1), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => 1, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$this->chatState->pushMessage(1, $msg); $this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
} }
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
* @param string $toUsername 接收用户名 * @param string $toUsername 接收用户名
* @param string $content 消息内容 * @param string $content 消息内容
*/ */
private function broadcastPersonal(string $toUsername, string $content): void private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
{ {
$msg = [ $msg = [
'id' => $this->chatState->nextMessageId(1), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => 1, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => $toUsername, 'to_user' => $toUsername,
'content' => $content, 'content' => $content,
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
} }
} }
+24 -2
View File
@@ -21,6 +21,7 @@ use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateProfileRequest;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\AchievementService;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService; use App\Services\ChatUserPresenceService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
@@ -58,6 +59,7 @@ class UserController extends Controller
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService, private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly AchievementService $achievementService,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
) {} ) {}
@@ -159,6 +161,8 @@ class UserController extends Controller
'expires_at' => $signIdentity->expires_at?->toIso8601String(), 'expires_at' => $signIdentity->expires_at?->toIso8601String(),
] : null, ] : null,
]; ];
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。 // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
$canViewNetworkInfo = $operator $canViewNetworkInfo = $operator
@@ -303,19 +307,37 @@ class UserController extends Controller
} }
/** /**
* 保存聊天室屏蔽禁音偏好。 * 保存聊天室屏蔽禁音与字号偏好。
*/ */
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{ {
$user = Auth::user(); $user = Auth::user();
$data = $request->validated(); $data = $request->validated();
$existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : [];
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
->map(function (string $sender): string {
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
return $sender === '猜谜活动' ? '猜成语' : $sender;
})
->unique()
->values()
->all();
$preferences = [ $preferences = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。 // 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])), 'blocked_system_senders' => $blockedSystemSenders,
'sound_muted' => (bool) $data['sound_muted'], 'sound_muted' => (bool) $data['sound_muted'],
]; ];
// 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。
$fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null
? (int) $data['font_size']
: ($existingPreferences['font_size'] ?? null);
if ($fontSize !== null) {
$preferences['font_size'] = (int) $fontSize;
}
$user->update([ $user->update([
'chat_preferences' => $preferences, 'chat_preferences' => $preferences,
]); ]);
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:前台座驾购买请求验证。
*
* 校验用户购买座驾时传入的座驾与房间上下文,避免未进房直接购买聊天室座驾。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 座驾购买请求
* 负责校验座驾 ID 与当前房间 ID。
*/
class BuyRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许购买座驾。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取座驾购买请求验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'item_id' => ['required', 'integer', 'exists:rides,id'],
'room_id' => ['required', 'integer', 'exists:rooms,id'],
];
}
/**
* 获取座驾购买请求中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'item_id.required' => '请选择要购买的座驾。',
'item_id.exists' => '座驾不存在或已被删除。',
'room_id.required' => '请先进入聊天室后再购买座驾。',
'room_id.exists' => '当前房间不存在。',
];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:后台新增座驾请求验证。
*
* 校验座驾独立模块的名称、特效 key、价格、使用天数、欢迎语和上下架状态。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台新增座驾请求
* 负责新增座驾时的权限与字段校验。
*/
class StoreRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许新增座驾。
*/
public function authorize(): bool
{
return $this->user()?->id === 1;
}
/**
* 获取新增座驾验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')],
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'duration_days' => ['required', 'integer', 'min:1'],
'welcome_message' => ['nullable', 'string', 'max:255'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取新增座驾中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:后台新增商店商品请求验证。
*
* 统一校验商店商品字段。
*/
namespace App\Http\Requests;
use App\Models\ShopItem;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台新增商店商品请求
* 负责新增商品时的权限与字段校验。
*/
class StoreShopItemRequest extends FormRequest
{
/**
* 判断当前用户是否允许新增商品。
*/
public function authorize(): bool
{
return $this->user()?->id === 1;
}
/**
* 获取新增商品验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'type' => ['required', Rule::in($this->allowedTypes())],
'duration_days' => ['nullable', 'integer', 'min:0'],
'duration_minutes' => ['nullable', 'integer', 'min:0'],
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
'charm_bonus' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取允许后台配置的商品类型。
*
* @return array<int, string>
*/
protected function allowedTypes(): array
{
return [
'instant',
'duration',
'one_time',
'ring',
'auto_fishing',
ShopItem::TYPE_SIGN_REPAIR,
'msg_bubble',
'msg_name_color',
'msg_text_color',
'avatar_frame',
];
}
}
@@ -2,7 +2,7 @@
/** /**
* 文件功能:聊天室偏好设置验证器 * 文件功能:聊天室偏好设置验证器
* 负责校验用户提交的屏蔽播报禁音配置。 * 负责校验用户提交的屏蔽播报禁音与聊天室字号配置。
*/ */
namespace App\Http\Requests; namespace App\Http\Requests;
@@ -12,7 +12,7 @@ use Illuminate\Validation\Rule;
/** /**
* 聊天室偏好设置验证器 * 聊天室偏好设置验证器
* 仅允许提交白名单内的屏蔽项布尔型禁音状态。 * 仅允许提交白名单内的屏蔽项布尔型禁音状态与合法字号
*/ */
class UpdateChatPreferencesRequest extends FormRequest class UpdateChatPreferencesRequest extends FormRequest
{ {
@@ -35,9 +35,10 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders' => ['nullable', 'array'], 'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [ 'blocked_system_senders.*' => [
'string', 'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']), Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票']),
], ],
'sound_muted' => ['required', 'boolean'], 'sound_muted' => ['required', 'boolean'],
'font_size' => ['nullable', 'integer', 'min:10', 'max:30'],
]; ];
} }
@@ -53,6 +54,9 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。', 'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
'sound_muted.required' => '请传入禁音状态。', 'sound_muted.required' => '请传入禁音状态。',
'sound_muted.boolean' => '禁音状态格式无效。', 'sound_muted.boolean' => '禁音状态格式无效。',
'font_size.integer' => '聊天室字号格式无效。',
'font_size.min' => '聊天室字号不能小于 10。',
'font_size.max' => '聊天室字号不能大于 30。',
]; ];
} }
} }
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:保存游戏参数请求校验
*
* 统一校验后台“游戏管理”页提交的 params 结构,
* 并在所有游戏共用的房间范围字段上执行归一化。
*/
namespace App\Http\Requests;
use App\Services\GameRoomScopeService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:约束后台游戏参数保存请求的公共结构。
*/
class UpdateGameConfigParamsRequest extends FormRequest
{
/**
* 判断当前请求是否允许执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'params' => ['required', 'array'],
'params.room_scope_mode' => ['nullable', 'in:all,single,multiple'],
'params.room_ids' => ['nullable', 'array'],
'params.room_ids.*' => ['integer', 'exists:rooms,id'],
];
}
/**
* 自定义错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'params.required' => '缺少游戏参数数据。',
'params.array' => '游戏参数格式无效。',
'params.room_scope_mode.in' => '参与房间模式无效。',
'params.room_ids.array' => '参与房间列表格式无效。',
'params.room_ids.*.integer' => '参与房间编号格式无效。',
'params.room_ids.*.exists' => '所选房间不存在,请刷新页面后重试。',
];
}
/**
* 在校验前先把房间范围字段归一化,兼容单值与旧字段。
*/
protected function prepareForValidation(): void
{
$params = (array) $this->input('params', []);
$roomScopeService = app(GameRoomScopeService::class);
$scopeConfig = $roomScopeService->getScopeConfigForParams($params);
$params['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$params['room_ids'] = $scopeConfig['room_ids'];
$this->merge([
'params' => $params,
]);
}
/**
* 校验通过后补充“单选/多选至少选择一个房间”的约束。
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$params = (array) $this->input('params', []);
$roomMode = (string) ($params['room_scope_mode'] ?? GameRoomScopeService::MODE_SINGLE);
$roomIds = (array) ($params['room_ids'] ?? []);
if (in_array($roomMode, [GameRoomScopeService::MODE_SINGLE, GameRoomScopeService::MODE_MULTIPLE], true) && $roomIds === []) {
$validator->errors()->add('params.room_ids', '单选/多选房间模式下,请至少选择一个房间。');
}
if ($roomMode === GameRoomScopeService::MODE_SINGLE && count($roomIds) > 1) {
$validator->errors()->add('params.room_ids', '单选房间模式下只能选择一个房间。');
}
});
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:后台更新座驾请求验证。
*
* 校验座驾编辑时的唯一标识、价格、使用天数和欢迎语配置。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台更新座驾请求
* 负责编辑座驾时的权限与字段校验。
*/
class UpdateRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许编辑座驾。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取更新座驾验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$ride = $this->route('ride');
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')->ignore($ride?->id)],
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')->ignore($ride?->id)],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'duration_days' => ['required', 'integer', 'min:1'],
'welcome_message' => ['nullable', 'string', 'max:255'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取更新座驾中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
@@ -0,0 +1,75 @@
<?php
/**
* 文件功能:后台更新商店商品请求验证。
*
* 统一校验商店商品编辑字段。
*/
namespace App\Http\Requests;
use App\Models\ShopItem;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台更新商店商品请求
* 负责编辑商品时的权限与字段校验。
*/
class UpdateShopItemRequest extends FormRequest
{
/**
* 判断当前用户是否允许编辑商品。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取更新商品验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$shopItem = $this->route('shopItem');
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')->ignore($shopItem?->id)],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'type' => ['required', Rule::in($this->allowedTypes())],
'duration_days' => ['nullable', 'integer', 'min:0'],
'duration_minutes' => ['nullable', 'integer', 'min:0'],
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
'charm_bonus' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取允许后台配置的商品类型。
*
* @return array<int, string>
*/
protected function allowedTypes(): array
{
return [
'instant',
'duration',
'one_time',
'ring',
'auto_fishing',
ShopItem::TYPE_SIGN_REPAIR,
'msg_bubble',
'msg_name_color',
'msg_text_color',
'avatar_frame',
];
}
}
+3 -8
View File
@@ -290,12 +290,8 @@ class AiBaccaratBetJob implements ShouldQueue
*/ */
private function broadcastPassMessage(User $user, int $roomId, string $reason): void private function broadcastPassMessage(User $user, int $roomId, string $reason): void
{ {
if (empty($reason)) {
$reason = '风大雨大,保本最大,这把我决定观望一下!';
}
$chatState = app(ChatStateService::class); $chatState = app(ChatStateService::class);
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 本局选择挂机观望:✨ <br/><span style='color:#666;'>[🤖 策略分析] {$reason}</span>"; $content = "🎲 【百家乐】 {$user->username} 本局选择挂机观望";
$msg = [ $msg = [
'id' => $chatState->nextMessageId($roomId), 'id' => $chatState->nextMessageId($roomId),
@@ -335,9 +331,8 @@ class AiBaccaratBetJob implements ShouldQueue
$chatState = app(ChatStateService::class); $chatState = app(ChatStateService::class);
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子']; $labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$label = $labelMap[$betType] ?? $betType; $label = $labelMap[$betType] ?? $betType;
// AI 下注播报统一压成单行,避免游戏通知卡片出现多行正文挤占高度。
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:'; $content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount).' 金币)';
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}</span>";
$msg = [ $msg = [
'id' => $chatState->nextMessageId($roomId), 'id' => $chatState->nextMessageId($roomId),
+9 -6
View File
@@ -28,6 +28,9 @@ use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
/**
* 类功能:完成一局百家乐的开奖、派奖与通知。
*/
class CloseBaccaratRoundJob implements ShouldQueue class CloseBaccaratRoundJob implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -227,7 +230,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
return; return;
} }
$roomId = 1; $roomId = (int) $round->room_id;
$roundResultLabel = $round->resultLabel(); $roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) { foreach ($participantSettlements as $settlement) {
@@ -309,11 +312,11 @@ class CloseBaccaratRoundJob implements ShouldQueue
$detail = $detailParts ? ' '.implode(' ', $detailParts) : ''; $detail = $detailParts ? ' '.implode(' ', $detailParts) : '';
$content = "🎲 【百家乐】第 #{$round->id} 局开奖{$diceStr} 总点 {$round->total_points} {$resultText}{$payoutText}{$detail}"; $content = "🎲 第 #{$round->id} 局开奖{$diceStr} {$round->total_points} 点,{$resultText}{$payoutText}{$detail}";
$msg = [ $msg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId((int) $round->room_id),
'room_id' => 1, 'room_id' => (int) $round->room_id,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -322,8 +325,8 @@ class CloseBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $msg); $chatState->pushMessage((int) $round->room_id, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent((int) $round->room_id, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏) // 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
+9 -6
View File
@@ -26,6 +26,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:完成一场赛马竞猜的派奖与结果广播。
*/
class CloseHorseRaceJob implements ShouldQueue class CloseHorseRaceJob implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -181,7 +184,7 @@ class CloseHorseRaceJob implements ShouldQueue
return; return;
} }
$roomId = 1; $roomId = (int) $race->room_id;
$winnerName = $this->resolveWinnerHorseName($race); $winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) { foreach ($participantSettlements as $settlement) {
@@ -243,11 +246,11 @@ class CloseHorseRaceJob implements ShouldQueue
? '共派发 💰'.number_format($totalPayout).' 金币' ? '共派发 💰'.number_format($totalPayout).' 金币'
: '本场无人获奖'; : '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}"; $content = "🏆 第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}";
$msg = [ $msg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => 1, 'room_id' => (int) $race->room_id,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -256,8 +259,8 @@ class CloseHorseRaceJob implements ShouldQueue
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $msg); $chatState->pushMessage((int) $race->room_id, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
} }
+5 -2
View File
@@ -23,6 +23,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/**
* 类功能:按房间投放神秘箱子并广播暗号。
*/
class DropMysteryBoxJob implements ShouldQueue class DropMysteryBoxJob implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -80,6 +83,7 @@ class DropMysteryBoxJob implements ShouldQueue
// 创建箱子记录 // 创建箱子记录
$box = MysteryBox::create([ $box = MysteryBox::create([
'room_id' => $targetRoom,
'box_type' => $this->boxType, 'box_type' => $this->boxType,
'passcode' => $passcode, 'passcode' => $passcode,
'reward_min' => $rewardMin, 'reward_min' => $rewardMin,
@@ -94,8 +98,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName(); $typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统'; $source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}【神秘箱子】{$typeName}{$source}投放了一个神秘箱子!" $content = "{$emoji} {$typeName}{$source}投放,暗号「{$passcode}」,限时 {$claimWindow} 秒。";
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$msg = [ $msg = [
'id' => $chatState->nextMessageId($targetRoom), 'id' => $chatState->nextMessageId($targetRoom),
+7 -4
View File
@@ -18,6 +18,9 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:关闭已超时的神秘箱子并广播过期提醒。
*/
class ExpireMysteryBoxJob implements ShouldQueue class ExpireMysteryBoxJob implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -49,8 +52,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
// 公屏广播过期通知 // 公屏广播过期通知
$msg = [ $msg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId((int) $box->room_id),
'room_id' => 1, 'room_id' => (int) $box->room_id,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~", 'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
@@ -60,8 +63,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $msg); $chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
} }
} }
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一局新的百家乐押注回合。
*/
class OpenBaccaratRoundJob implements ShouldQueue class OpenBaccaratRoundJob implements ShouldQueue
{ {
use Queueable; use Queueable;
/**
* 构造开局任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/** /**
* 最大重试次数。 * 最大重试次数。
*/ */
@@ -44,7 +56,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60); $betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
// 防止重复开局(如果上一局还在押注中则跳过) // 防止重复开局(如果上一局还在押注中则跳过)
if (BaccaratRound::currentRound()) { if (BaccaratRound::currentRound($this->roomId)) {
return; return;
} }
@@ -53,6 +65,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
// 创建新局次 // 创建新局次
$round = BaccaratRound::create([ $round = BaccaratRound::create([
'room_id' => $this->roomId,
'status' => 'betting', 'status' => 'betting',
'bet_opens_at' => $now, 'bet_opens_at' => $now,
'bet_closes_at' => $closesAt, 'bet_closes_at' => $closesAt,
@@ -77,10 +90,10 @@ class OpenBaccaratRoundJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" ' .'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">' .'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>'; .'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡 1:{$bigRate} · 💥豹子 1:{$tripleRate}☠️ {$killText}庄家收割".$quickOpenButton; $content = "🎲 第 #{$round->id} 局开局:{$betSeconds}下注{$minBet}~{$maxBet} 金币,🔵/🟡 1:{$bigRate},💥 1:{$tripleRate}☠️ {$killText} 点收割".$quickOpenButton;
$msg = [ $msg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId($this->roomId),
'room_id' => 1, 'room_id' => $this->roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -89,8 +102,8 @@ class OpenBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(), 'sent_at' => $now->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $msg); $chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务 // 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一场新的赛马竞猜回合。
*/
class OpenHorseRaceJob implements ShouldQueue class OpenHorseRaceJob implements ShouldQueue
{ {
use Queueable; use Queueable;
/**
* 构造开赛任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/** /**
* 最大重试次数。 * 最大重试次数。
*/ */
@@ -41,7 +53,7 @@ class OpenHorseRaceJob implements ShouldQueue
} }
// 防止重复开赛(上一场还在进行中) // 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) { if (HorseRace::currentRace($this->roomId)) {
return; return;
} }
@@ -60,6 +72,7 @@ class OpenHorseRaceJob implements ShouldQueue
// 创建新场次 // 创建新场次
$race = HorseRace::create([ $race = HorseRace::create([
'room_id' => $this->roomId,
'status' => 'betting', 'status' => 'betting',
'bet_opens_at' => $now, 'bet_opens_at' => $now,
'bet_closes_at' => $closesAt, 'bet_closes_at' => $closesAt,
@@ -79,11 +92,11 @@ class OpenHorseRaceJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" ' .'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">' .'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>'; .'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton; $content = "🐎 第 #{$race->id} 场开赛:{$horseList}{$betSeconds} 秒下注,".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$msg = [ $msg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId($this->roomId),
'room_id' => 1, 'room_id' => $this->roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -92,8 +105,8 @@ class OpenHorseRaceJob implements ShouldQueue
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(), 'sent_at' => $now->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $msg); $chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务 // 押注截止后触发跑马 & 结算任务
+15 -2
View File
@@ -19,10 +19,22 @@ use App\Models\LotteryIssue;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间创建一条新的双色球期次。
*/
class OpenLotteryIssueJob implements ShouldQueue class OpenLotteryIssueJob implements ShouldQueue
{ {
use Queueable; use Queueable;
/**
* 构造开期任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/** /**
* 最大重试次数。 * 最大重试次数。
*/ */
@@ -38,7 +50,7 @@ class OpenLotteryIssueJob implements ShouldQueue
} }
// 已有进行中的期次则跳过 // 已有进行中的期次则跳过
if (LotteryIssue::currentIssue()) { if (LotteryIssue::currentIssue($this->roomId)) {
return; return;
} }
@@ -56,7 +68,8 @@ class OpenLotteryIssueJob implements ShouldQueue
$closeAt = $drawAt->copy()->subMinutes($stopMinutes); $closeAt = $drawAt->copy()->subMinutes($stopMinutes);
LotteryIssue::create([ LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(), 'room_id' => $this->roomId,
'issue_no' => LotteryIssue::nextIssueNo($this->roomId),
'status' => 'open', 'status' => 'open',
'pool_amount' => 0, 'pool_amount' => 0,
'carry_amount' => 0, 'carry_amount' => 0,
+14 -8
View File
@@ -22,6 +22,12 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:赛马跑马动画广播与结算衔接任务
*
* 负责在押注截止后推进 running 流程、广播实时进度,
* 并在同一条任务链中补齐赛果与触发最终结算,避免线上状态滞留。
*/
class RunHorseRaceJob implements ShouldQueue class RunHorseRaceJob implements ShouldQueue
{ {
use Queueable; use Queueable;
@@ -72,18 +78,18 @@ class RunHorseRaceJob implements ShouldQueue
)); ));
$startMsg = [ $startMsg = [
'id' => $chatState->nextMessageId(1), 'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => 1, 'room_id' => (int) $race->room_id,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id}押注截止!马匹已进入跑道,比赛开始!参赛阵容{$horseList}", 'content' => "🏇 第 #{$race->id}比赛开始{$horseList}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#16a34a', 'font_color' => '#16a34a',
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$chatState->pushMessage(1, $startMsg); $chatState->pushMessage((int) $race->room_id, $startMsg);
broadcast(new MessageSent(1, $startMsg)); broadcast(new MessageSent((int) $race->room_id, $startMsg));
SaveMessageJob::dispatch($startMsg); SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? []; $config = GameConfig::forGame('horse_racing')?->params ?? [];
@@ -126,7 +132,7 @@ class RunHorseRaceJob implements ShouldQueue
} }
// 广播当前帧进度 // 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions))); broadcast(new HorseRaceProgress($race->id, (int) $race->room_id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) { if ($finished) {
break; break;
@@ -156,8 +162,8 @@ class RunHorseRaceJob implements ShouldQueue
'total_pool' => $totalPool, 'total_pool' => $totalPool,
]); ]);
// 触发结算任务 // 在同一条队列任务里直接完成结算,避免线上出现“已跑完但 Close 任务未继续消费”的断链。
CloseHorseRaceJob::dispatch($race->fresh()); app()->call([new CloseHorseRaceJob($race->fresh()), 'handle']);
} }
/** /**
+1
View File
@@ -50,6 +50,7 @@ class SaveMessageJob implements ShouldQueue
'image_path' => $this->messageData['image_path'] ?? null, 'image_path' => $this->messageData['image_path'] ?? null,
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null, 'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
'image_original_name' => $this->messageData['image_original_name'] ?? null, 'image_original_name' => $this->messageData['image_original_name'] ?? null,
'retention_type' => Message::resolveRetentionType($this->messageData),
// 恢复 Carbon 时间对象 // 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']), 'sent_at' => Carbon::parse($this->messageData['sent_at']),
]); ]);
+16 -2
View File
@@ -124,6 +124,9 @@ class TriggerHolidayEventJob implements ShouldQueue
$now = now(); $now = now();
$scheduledFor = $this->manual ? $now->copy() : $event->send_at; $scheduledFor = $this->manual ? $now->copy() : $event->send_at;
$expiresAt = $this->manual
? $now->copy()->addMinutes($event->expire_minutes)
: $scheduledFor?->copy()->addMinutes($event->expire_minutes);
if (! $this->manual) { if (! $this->manual) {
// 定时触发只允许处理真正到期且仍处于 pending 的模板。 // 定时触发只允许处理真正到期且仍处于 pending 的模板。
@@ -131,12 +134,23 @@ class TriggerHolidayEventJob implements ShouldQueue
return null; return null;
} }
$validScheduledFor = $scheduleService->skipExpiredOccurrences($event, $now);
if ($validScheduledFor === null || ! $validScheduledFor->equalTo($scheduledFor)) {
// 漏跑且已过期的批次只推进模板,不生成领取批次和聊天室公告。
$event->update([
'send_at' => $validScheduledFor,
'status' => $validScheduledFor ? 'pending' : 'completed',
]);
return null;
}
$nextSendAt = $scheduleService->advanceAfterTrigger($event); $nextSendAt = $scheduleService->advanceAfterTrigger($event);
$event->update([ $event->update([
'send_at' => $nextSendAt, 'send_at' => $nextSendAt,
'status' => $nextSendAt ? 'pending' : 'completed', 'status' => $nextSendAt ? 'pending' : 'completed',
'triggered_at' => $now, 'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'expires_at' => $expiresAt,
'claimed_count' => 0, 'claimed_count' => 0,
'claimed_amount' => 0, 'claimed_amount' => 0,
]); ]);
@@ -163,7 +177,7 @@ class TriggerHolidayEventJob implements ShouldQueue
'repeat_type' => $event->repeat_type, 'repeat_type' => $event->repeat_type,
'scheduled_for' => $scheduledFor, 'scheduled_for' => $scheduledFor,
'triggered_at' => $now, 'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'expires_at' => $expiresAt,
'status' => 'active', 'status' => 'active',
'audience_count' => 0, 'audience_count' => 0,
'claimed_count' => 0, 'claimed_count' => 0,
+14 -5
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存百家乐局次数据并提供当前局查询能力。
*/
class BaccaratRound extends Model class BaccaratRound extends Model
{ {
protected $fillable = [ protected $fillable = [
'room_id',
'dice1', 'dice2', 'dice3', 'dice1', 'dice2', 'dice3',
'total_points', 'result', 'status', 'total_points', 'result', 'status',
'bet_opens_at', 'bet_closes_at', 'settled_at', 'bet_opens_at', 'bet_closes_at', 'settled_at',
@@ -36,6 +40,7 @@ class BaccaratRound extends Model
'bet_opens_at' => 'datetime', 'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime', 'bet_closes_at' => 'datetime',
'settled_at' => 'datetime', 'settled_at' => 'datetime',
'room_id' => 'integer',
'dice1' => 'integer', 'dice1' => 'integer',
'dice2' => 'integer', 'dice2' => 'integer',
'dice3' => 'integer', 'dice3' => 'integer',
@@ -104,12 +109,16 @@ class BaccaratRound extends Model
/** /**
* 查询当前正在进行的局次(状态为 betting 且未截止)。 * 查询当前正在进行的局次(状态为 betting 且未截止)。
*/ */
public static function currentRound(): ?static public static function currentRound(?int $roomId = null): ?static
{ {
return static::query() $query = static::query()
->where('status', 'betting') ->where('status', 'betting')
->where('bet_closes_at', '>', now()) ->where('bet_closes_at', '>', now());
->latest()
->first(); if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
} }
} }
+16 -5
View File
@@ -16,9 +16,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:赛马竞猜局次模型
*
* 负责描述赛马场次的生命周期、参赛马匹、下注汇总与派奖计算,
* 并为控制器和队列任务提供当前场次、赔率与奖池算法支持。
*/
class HorseRace extends Model class HorseRace extends Model
{ {
protected $fillable = [ protected $fillable = [
'room_id',
'status', 'status',
'bet_opens_at', 'bet_opens_at',
'bet_closes_at', 'bet_closes_at',
@@ -42,6 +49,7 @@ class HorseRace extends Model
'race_starts_at' => 'datetime', 'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime', 'race_ends_at' => 'datetime',
'settled_at' => 'datetime', 'settled_at' => 'datetime',
'room_id' => 'integer',
'horses' => 'array', 'horses' => 'array',
'winner_horse_id' => 'integer', 'winner_horse_id' => 'integer',
'total_bets' => 'integer', 'total_bets' => 'integer',
@@ -69,12 +77,15 @@ class HorseRace extends Model
/** /**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。 * 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/ */
public static function currentRace(): ?static public static function currentRace(?int $roomId = null): ?static
{ {
return static::query() $query = static::query()->whereIn('status', ['betting', 'running']);
->whereIn('status', ['betting', 'running'])
->latest() if ($roomId !== null) {
->first(); $query->where('room_id', $roomId);
}
return $query->latest()->first();
} }
/** /**
+29 -9
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存双色球期次数据并提供按房间查询能力。
*/
class LotteryIssue extends Model class LotteryIssue extends Model
{ {
protected $fillable = [ protected $fillable = [
'room_id',
'issue_no', 'issue_no',
'status', 'status',
'red1', 'red2', 'red3', 'blue', 'red1', 'red2', 'red3', 'blue',
@@ -38,6 +42,7 @@ class LotteryIssue extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'room_id' => 'integer',
'is_super_issue' => 'boolean', 'is_super_issue' => 'boolean',
'pool_amount' => 'integer', 'pool_amount' => 'integer',
'carry_amount' => 'integer', 'carry_amount' => 'integer',
@@ -71,29 +76,44 @@ class LotteryIssue extends Model
/** /**
* 获取当前正在购票的期次(status=open)。 * 获取当前正在购票的期次(status=open)。
*/ */
public static function currentIssue(): ?static public static function currentIssue(?int $roomId = null): ?static
{ {
return static::query()->where('status', 'open')->latest()->first(); $query = static::query()->where('status', 'open');
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
} }
/** /**
* 获取最新一期(不论状态)。 * 获取最新一期(不论状态)。
*/ */
public static function latestIssue(): ?static public static function latestIssue(?int $roomId = null): ?static
{ {
return static::query()->latest()->first(); $query = static::query();
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
} }
/** /**
* 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。 * 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。
*/ */
public static function nextIssueNo(): string public static function nextIssueNo(?int $roomId = null): string
{ {
$year = now()->year; $year = now()->year;
$last = static::query() $query = static::query()->whereYear('created_at', $year);
->whereYear('created_at', $year)
->latest() if ($roomId !== null) {
->first(); $query->where('room_id', $roomId);
}
$last = $query->latest()->first();
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1; $seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
+115
View File
@@ -20,6 +20,120 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class Message extends Model class Message extends Model
{ {
public const RETENTION_USER_CHAT = 'user_chat';
public const RETENTION_SYSTEM_NOTICE = 'system_notice';
public const RETENTION_GAME_NOTICE = 'game_notice';
public const RETENTION_EPHEMERAL_NOTICE = 'ephemeral_notice';
/**
* 可按过期策略清理的消息保留类型。
*
* @return array<int, string>
*/
public static function purgableRetentionTypes(): array
{
return [
self::RETENTION_GAME_NOTICE,
self::RETENTION_EPHEMERAL_NOTICE,
];
}
/**
* 根据广播消息载荷推断数据库保留类型。
*
* @param array<string, mixed> $messageData 聊天室消息载荷
*/
public static function resolveRetentionType(array $messageData): string
{
$explicitType = (string) ($messageData['retention_type'] ?? '');
if (in_array($explicitType, [
self::RETENTION_USER_CHAT,
self::RETENTION_SYSTEM_NOTICE,
self::RETENTION_GAME_NOTICE,
self::RETENTION_EPHEMERAL_NOTICE,
], true)) {
return $explicitType;
}
$fromUser = (string) ($messageData['from_user'] ?? '');
$action = (string) ($messageData['action'] ?? '');
$messageType = (string) ($messageData['message_type'] ?? 'text');
if (self::isEphemeralNotice($fromUser, $action)) {
return self::RETENTION_EPHEMERAL_NOTICE;
}
if (self::isGameNotice($fromUser, $action, $messageType, $messageData)) {
return self::RETENTION_GAME_NOTICE;
}
if (self::isSystemNotice($fromUser)) {
return self::RETENTION_SYSTEM_NOTICE;
}
return self::RETENTION_USER_CHAT;
}
/**
* 判断消息是否属于可短期保留的进出场类通知。
*/
public static function isEphemeralNotice(string $fromUser, string $action = ''): bool
{
return in_array($fromUser, ['进出播报', '座驾播报'], true)
|| in_array($action, ['system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp'], true);
}
/**
* 判断消息是否属于游戏或玩法通知。
*
* @param array<string, mixed> $messageData 聊天室消息载荷
*/
public static function isGameNotice(string $fromUser, string $action, string $messageType = 'text', array $messageData = []): bool
{
$gameSenders = ['钓鱼播报', '星海小博士'];
$gameActions = [
'fishing_result',
'idiom_result',
'riddle_result',
'ride_purchase',
];
if (in_array($fromUser, $gameSenders, true) || in_array($action, $gameActions, true)) {
return true;
}
if (isset($messageData['toast_notification'])) {
$title = (string) data_get($messageData, 'toast_notification.title', '');
return str_contains($title, '下注')
|| str_contains($title, '赛马')
|| str_contains($title, '百家乐')
|| str_contains($title, '双色球')
|| str_contains($title, '红包')
|| str_contains($title, '结算');
}
return in_array($messageType, ['game_notice'], true);
}
/**
* 判断消息是否来自系统发送者。
*/
public static function isSystemNotice(string $fromUser): bool
{
return in_array($fromUser, [
'系统',
'系统公告',
'系统传音',
'系统播报',
'送花播报',
'AI小班长',
], true);
}
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
@@ -37,6 +151,7 @@ class Message extends Model
'image_path', 'image_path',
'image_thumb_path', 'image_thumb_path',
'image_original_name', 'image_original_name',
'retention_type',
'sent_at', 'sent_at',
]; ];
+14 -5
View File
@@ -17,9 +17,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 类功能:保存神秘箱子投放记录并提供当前箱子查询能力。
*/
class MysteryBox extends Model class MysteryBox extends Model
{ {
protected $fillable = [ protected $fillable = [
'room_id',
'box_type', 'box_type',
'passcode', 'passcode',
'reward_min', 'reward_min',
@@ -35,6 +39,7 @@ class MysteryBox extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'room_id' => 'integer',
'reward_min' => 'integer', 'reward_min' => 'integer',
'reward_max' => 'integer', 'reward_max' => 'integer',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
@@ -64,13 +69,17 @@ class MysteryBox extends Model
/** /**
* 当前可领取(open 状态 + 未过期)的箱子。 * 当前可领取(open 状态 + 未过期)的箱子。
*/ */
public static function currentOpenBox(): ?static public static function currentOpenBox(?int $roomId = null): ?static
{ {
return static::query() $query = static::query()
->where('status', 'open') ->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
->latest()
->first(); if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
} }
// ─── 工具方法 ──────────────────────────────────────────────────── // ─── 工具方法 ────────────────────────────────────────────────────
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* 文件功能:猜谜活动题库模型
*
* 对应 idioms 表,统一承载成语题与脑筋急转弯题目。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 类功能:统一管理猜谜活动的题目、答案、提示与题型。
*/
class Riddle extends Model
{
/**
* 属性功能:显式绑定历史题库表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idioms';
/**
* 常量功能:声明成语题题型标识。
*/
public const TYPE_IDIOM = 'idiom';
/**
* 常量功能:声明脑筋急转弯题型标识。
*/
public const TYPE_BRAIN_TEASER = 'brain_teaser';
/**
* 方法功能:声明允许批量赋值的题库字段。
*
* @var array<int, string>
*/
protected $fillable = [
'type',
'answer',
'hint',
'is_active',
'sort',
];
/**
* 方法功能:定义题库字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort' => 'integer',
];
}
/**
* 方法功能:返回系统支持的全部题型。
*
* @return array<int, string>
*/
public static function supportedTypes(): array
{
return [
self::TYPE_IDIOM,
self::TYPE_BRAIN_TEASER,
];
}
/**
* 方法功能:判断给定题型是否属于系统支持范围。
*/
public static function isSupportedType(string $type): bool
{
return in_array($type, self::supportedTypes(), true);
}
/**
* 方法功能:根据题型返回面向用户的中文名称。
*/
public static function labelForType(string $type): string
{
return match ($type) {
self::TYPE_BRAIN_TEASER => '脑筋急转弯',
default => '猜成语',
};
}
/**
* 方法功能:返回后台表单可直接使用的题型键值对。
*
* @return array<string, string>
*/
public static function typeOptions(): array
{
return collect(self::supportedTypes())
->mapWithKeys(fn (string $type): array => [$type => self::labelForType($type)])
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
public static function activityLabelForType(string $type): string
{
return '猜谜活动·'.self::labelForType($type);
}
/**
* 方法功能:按题型筛选题库记录。
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', self::isSupportedType($type) ? $type : self::TYPE_IDIOM);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:猜谜活动回合模型
*
* 每次出题对应一个回合,记录题型、题目、状态、奖励和获胜者。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录猜谜活动每一轮的题型、奖励与结算状态。
*/
class RiddleGameRound extends Model
{
/**
* 属性功能:显式绑定历史回合表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idiom_game_rounds';
/**
* 方法功能:声明可批量赋值的回合字段。
*
* @var array<int, string>
*/
protected $fillable = [
'room_id',
'idiom_id',
'quiz_type',
'status',
'reward_gold',
'reward_exp',
'winner_id',
'winner_username',
'started_at',
'ended_at',
];
/**
* 方法功能:定义回合字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'idiom_id' => 'integer',
'reward_gold' => 'integer',
'reward_exp' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
/**
* 方法功能:关联本回合对应的猜谜题目。
*/
public function idiom(): BelongsTo
{
return $this->belongsTo(Riddle::class);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:聊天室座驾模型。
*
* 对应 rides 表,保存座驾名称、特效 key、价格、使用天数、欢迎语与上下架状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 聊天室座驾模型
* 负责提供座驾定义、全屏特效 key 和购买记录关系。
*/
class Ride extends Model
{
protected $fillable = [
'name', 'slug', 'effect_key', 'icon', 'description', 'price',
'duration_days', 'welcome_message', 'sort_order', 'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* 获取座驾对应的所有购买记录。
*/
public function purchases(): HasMany
{
return $this->hasMany(UserRidePurchase::class);
}
/**
* 获取座驾全屏特效 key。
*/
public function rideKey(): string
{
return $this->effect_key;
}
/**
* 获取所有上架座驾。
*
* @return Collection<int, self>
*/
public static function active(): Collection
{
return static::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id')
->get();
}
}
+10
View File
@@ -15,6 +15,8 @@ class ShopItem extends Model
{ {
public const TYPE_SIGN_REPAIR = 'sign_repair'; public const TYPE_SIGN_REPAIR = 'sign_repair';
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
protected $table = 'shop_items'; protected $table = 'shop_items';
protected $fillable = [ protected $fillable = [
@@ -51,6 +53,14 @@ class ShopItem extends Model
return $this->type === self::TYPE_SIGN_REPAIR; return $this->type === self::TYPE_SIGN_REPAIR;
} }
/**
* 是否为个人装扮(气泡、颜色、头像框等)。
*/
public function isDecoration(): bool
{
return in_array($this->type, self::DECORATION_TYPES, true);
}
/** /**
* 是否为特效类商品(instant durationslug once_ week_ 开头) * 是否为特效类商品(instant durationslug once_ week_ 开头)
*/ */
+16
View File
@@ -261,6 +261,22 @@ class User extends Authenticatable
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date'); return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
} }
/**
* 关联:用户已解锁和进行中的成就记录。
*/
public function achievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'user_id')->latest('achieved_at');
}
/**
* 关联:用户各成就的最新进度快照。
*/
public function achievementProgress(): HasMany
{
return $this->hasMany(UserAchievementProgress::class, 'user_id')->latest('last_scanned_at');
}
/** /**
* 关联:用户全部身份徽章。 * 关联:用户全部身份徽章。
*/ */
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:用户成就解锁记录模型。
*
* 保存每个用户在固定成就目录中的进度快照、达成时间与通知状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就记录字段、类型转换与用户关联。
*/
class UserAchievement extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'achieved_at',
'notified_at',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'progress_value' => 'integer',
'achieved_at' => 'datetime',
'notified_at' => 'datetime',
'metadata' => 'array',
];
}
/**
* 关联:成就记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:用户成就进度模型。
*
* 保存用户在每个固定成就上的最新进度快照,解锁状态由 user_achievements 单独记录。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就进度字段、类型转换与用户关联。
*/
class UserAchievementProgress extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementProgressFactory> */
use HasFactory;
/**
* 对应的数据表名。
*
* @var string
*/
protected $table = 'user_achievement_progress';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'threshold_value',
'last_scanned_at',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'progress_value' => 'integer',
'threshold_value' => 'integer',
'last_scanned_at' => 'datetime',
];
}
/**
* 关联:进度记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:用户座驾购买记录模型。
*
* 对应 user_ride_purchases 表,追踪用户座驾购买、续期、替换和过期状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 用户座驾购买记录模型
* 负责连接用户与座驾,并判断当前记录是否仍有效。
*/
class UserRidePurchase extends Model
{
protected $fillable = [
'user_id', 'ride_id', 'status', 'price_paid', 'expires_at', 'used_at',
];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
/**
* 获取购买记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 获取购买记录对应座驾。
*/
public function ride(): BelongsTo
{
return $this->belongsTo(Ride::class);
}
/**
* 判断座驾购买记录是否仍然有效。
*/
public function isAlive(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
return true;
}
}
+442
View File
@@ -0,0 +1,442 @@
<?php
/**
* 文件功能:用户成就扫描与授予服务。
*
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\BaccaratBet;
use App\Models\DailySignIn;
use App\Models\GomokuGame;
use App\Models\HorseBet;
use App\Models\LotteryTicket;
use App\Models\Marriage;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\PositionDutyLog;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\SlotMachineLog;
use App\Models\User;
use App\Models\UserAchievement;
use App\Models\UserAchievementProgress;
use App\Models\UserCurrencyLog;
use App\Models\UserPosition;
use App\Support\AchievementCatalog;
use Illuminate\Support\Collection;
/**
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
*/
class AchievementService
{
/**
* 创建成就服务依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 扫描单个用户的所有固定成就。
*
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
*/
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
{
$progress = $this->progressForUser($user);
$checked = 0;
$unlocked = 0;
$updated = 0;
foreach (AchievementCatalog::definitions() as $definition) {
$checked++;
$value = (int) ($progress[$definition['metric']] ?? 0);
$achievement = UserAchievement::query()
->where('user_id', $user->id)
->where('achievement_key', $definition['key'])
->first();
if ($dryRun) {
if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) {
$unlocked++;
}
continue;
}
$this->storeProgress($user, $definition, $value);
if (! $achievement) {
$achievement = UserAchievement::query()->create([
'user_id' => $user->id,
'achievement_key' => $definition['key'],
'progress_value' => $value,
'metadata' => ['threshold' => $definition['threshold']],
]);
$updated++;
} elseif ($achievement->progress_value !== $value) {
$achievement->forceFill(['progress_value' => $value])->save();
$updated++;
}
if ($value < $definition['threshold'] || $achievement->achieved_at) {
continue;
}
$achievement->forceFill([
'progress_value' => $value,
'achieved_at' => now(),
'metadata' => ['threshold' => $definition['threshold']],
])->save();
$unlocked++;
if ($notify) {
$this->notifyUnlocked($user, $achievement, $definition);
}
}
return [
'checked' => $checked,
'unlocked' => $unlocked,
'updated' => $updated,
'dry_run' => $dryRun,
];
}
/**
* 批量扫描用户成就。
*
* @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool}
*/
public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array
{
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
foreach ($users as $user) {
$result = $this->scanUser($user, $notify, $dryRun);
$summary['users']++;
$summary['checked'] += $result['checked'];
$summary['unlocked'] += $result['unlocked'];
$summary['updated'] += $result['updated'];
}
return $summary;
}
/**
* 组装用户成就展示数据。
*
* @return array{categories: array<string, string>, achievements: Collection<int, array<string, mixed>>, unlocked_count: int, total_count: int}
*/
public function displayForUser(User $user): array
{
$progress = $this->progressForUser($user);
$records = UserAchievement::query()
->where('user_id', $user->id)
->get()
->keyBy('achievement_key');
$achievements = collect(AchievementCatalog::definitions())
->sortBy('sort')
->map(function (array $definition) use ($progress, $records): array {
$record = $records->get($definition['key']);
$value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0));
$threshold = (int) $definition['threshold'];
return [
...$definition,
'progress_value' => $value,
'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100,
'achieved_at' => $record?->achieved_at,
'unlocked' => (bool) $record?->achieved_at,
];
})
->values();
return [
'categories' => AchievementCatalog::categories(),
'achievements' => $achievements,
'unlocked_count' => $achievements->where('unlocked', true)->count(),
'total_count' => $achievements->count(),
];
}
/**
* 读取用户最近解锁成就。
*
* @return Collection<int, array<string, mixed>>
*/
public function recentUnlockedForUser(User $user, int $limit = 5): Collection
{
return UserAchievement::query()
->where('user_id', $user->id)
->whereNotNull('achieved_at')
->latest('achieved_at')
->limit($limit)
->get()
->map(function (UserAchievement $achievement): array {
$definition = AchievementCatalog::find($achievement->achievement_key);
return [
'key' => $achievement->achievement_key,
'name' => $definition['name'] ?? $achievement->achievement_key,
'icon' => $definition['icon'] ?? '🏅',
'description' => $definition['description'] ?? '',
'achieved_at' => $achievement->achieved_at?->toDateTimeString(),
];
});
}
/**
* 读取用户资料卡使用的成就摘要。
*
* @return array{unlocked_count: int, total_count: int, recent: array<int, array<string, mixed>>}
*/
public function profileSummaryForUser(User $user): array
{
return [
'unlocked_count' => (int) UserAchievement::query()
->where('user_id', $user->id)
->whereNotNull('achieved_at')
->count(),
'total_count' => count(AchievementCatalog::definitions()),
'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(),
];
}
/**
* 聚合单个用户所有成就进度。
*
* @return array<string, int>
*/
public function progressForUser(User $user): array
{
$username = (string) $user->username;
return [
'chat_messages' => $this->chatMessageCount($username),
'welcome_messages' => $this->welcomeMessageCount($username),
'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(),
'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'),
'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(),
'exp_gain' => $this->currencyGain($user->id, 'exp'),
'gold_gain' => $this->currencyGain($user->id, 'gold'),
'charm_gain' => $this->currencyGain($user->id, 'charm'),
'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb),
'bank_balance' => max(0, (int) $user->bank_jjb),
'game_gold_won' => $this->gameGoldWon($user->id),
'game_gold_lost' => $this->gameGoldLost($user->id),
'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(),
'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(),
'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(),
'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(),
'gomoku_wins' => $this->gomokuWinCount($user->id),
'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value),
'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value),
'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(),
'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(),
'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(),
'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'),
'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value),
'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value),
'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(),
'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60),
'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(),
];
}
/**
* 统计普通用户聊天消息数量。
*/
private function chatMessageCount(string $username): int
{
return (int) Message::query()
->where('from_user', $username)
->whereIn('message_type', ['text', 'image', 'expired_image'])
->where(function ($query) {
$query->where('retention_type', Message::RETENTION_USER_CHAT)
->orWhereNull('retention_type');
})
->count();
}
/**
* 统计用户发出的欢迎动作次数。
*/
private function welcomeMessageCount(string $username): int
{
return (int) Message::query()
->where('from_user', $username)
->where('action', '欢迎')
->count();
}
/**
* 统计指定货币的累计正向获得量。
*/
private function currencyGain(int $userId, string $currency): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', $currency)
->where('amount', '>', 0)
->sum('amount');
}
/**
* 统计指定流水来源次数。
*/
private function currencySourceCount(int $userId, string $source): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('source', $source)
->count();
}
/**
* 统计用户通过游戏相关流水累计赢取的金币。
*/
private function gameGoldWon(int $userId): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', 'gold')
->where('amount', '>', 0)
->whereIn('source', $this->gameWinSources())
->sum('amount');
}
/**
* 统计用户在游戏相关流水中累计输掉或消耗的金币。
*/
private function gameGoldLost(int $userId): int
{
return abs((int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', 'gold')
->where('amount', '<', 0)
->whereIn('source', $this->gameLossSources())
->sum('amount'));
}
/**
* 返回游戏赢钱来源,用于游戏赢取类成就聚合。
*
* @return array<int, string>
*/
private function gameWinSources(): array
{
return [
CurrencySource::BACCARAT_WIN->value,
CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
CurrencySource::HORSE_WIN->value,
CurrencySource::LOTTERY_WIN->value,
CurrencySource::SLOT_WIN->value,
CurrencySource::FISHING_GAIN->value,
CurrencySource::MYSTERY_BOX->value,
CurrencySource::GOMOKU_WIN->value,
CurrencySource::GAME_REWARD->value,
];
}
/**
* 返回游戏输钱来源,用于游戏输钱类成就聚合。
*
* @return array<int, string>
*/
private function gameLossSources(): array
{
return [
CurrencySource::BACCARAT_BET->value,
CurrencySource::HORSE_BET->value,
CurrencySource::LOTTERY_BUY->value,
CurrencySource::SLOT_SPIN->value,
CurrencySource::SLOT_CURSE->value,
CurrencySource::FISHING_COST->value,
CurrencySource::FORTUNE_COST->value,
CurrencySource::GOMOKU_ENTRY_FEE->value,
CurrencySource::MYSTERY_BOX_TRAP->value,
];
}
/**
* 统计五子棋胜利次数。
*/
private function gomokuWinCount(int $userId): int
{
return (int) GomokuGame::query()
->where('status', 'finished')
->where(function ($query) use ($userId) {
$query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1))
->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2));
})
->count();
}
/**
* 写入用户成就进度快照。
*
* @param array<string, mixed> $definition 成就定义
*/
private function storeProgress(User $user, array $definition, int $value): void
{
UserAchievementProgress::query()->updateOrCreate(
[
'user_id' => $user->id,
'achievement_key' => $definition['key'],
],
[
'progress_value' => $value,
'threshold_value' => (int) $definition['threshold'],
'last_scanned_at' => now(),
],
);
}
/**
* 给用户推送成就解锁通知。
*
* @param array<string, mixed> $definition 成就定义
*/
private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void
{
if ($achievement->notified_at) {
return;
}
$roomId = (int) ($user->room_id ?: 1);
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => $user->username,
'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
'is_secret' => true,
'font_color' => '#ca8a04',
'action' => 'achievement_unlocked',
'retention_type' => Message::RETENTION_SYSTEM_NOTICE,
'toast_notification' => [
'title' => '🏅 成就解锁',
'message' => "{$definition['icon']} {$definition['name']}",
'icon' => '🏅',
'color' => '#ca8a04',
'duration' => 3000,
],
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
$achievement->forceFill(['notified_at' => now()])->save();
}
}
+3 -2
View File
@@ -400,7 +400,8 @@ class BaccaratLossCoverService
} }
if ($compensableCount > 0) { if ($compensableCount > 0) {
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>'; // 聊天消息内的按钮使用相对字号,跟随用户在底部工具栏选择的聊天字号。
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>';
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}{$button}"; $content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}{$button}";
} else { } else {
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。"; $content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
@@ -446,7 +447,7 @@ class BaccaratLossCoverService
$formattedAmount = number_format($amount); $formattedAmount = number_format($amount);
$button = $event->status === 'claimable' $button = $event->status === 'claimable'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>' ? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>'
: ''; : '';
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。 // 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
@@ -0,0 +1,103 @@
<?php
/**
* 文件功能:每日游戏净盈利前三榜读服务
*
* 聚合百家乐与赛马当天金币流水,给聊天室顶部悬浮榜提供轻量数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\UserCurrencyLog;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* 类功能:查询百家乐与赛马每日净盈利前三用户。
*/
class DailyGameProfitLeaderboardService
{
/**
* 每日榜单固定称号。
*/
private const TITLES = [
1 => '金库爆破王',
2 => '马桌双修财神',
3 => '金币收割机',
];
/**
* 参与净盈利统计的游戏流水来源。
*/
private const GAME_PROFIT_SOURCES = [
CurrencySource::BACCARAT_BET,
CurrencySource::BACCARAT_WIN,
CurrencySource::HORSE_BET,
CurrencySource::HORSE_WIN,
];
/**
* 获取指定日期的游戏净盈利前三榜。
*
* @return Collection<int, object{rank:int,title:string,user_id:int,username:string,headface_url:string,net_profit:int}>
*/
public function topThree(?string $date = null): Collection
{
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
$cacheKey = 'daily_game_profit_leaderboard:v2:'.$statsDate->toDateString();
return Cache::remember($cacheKey, 300, function () use ($statsDate) {
$rangeStart = $statsDate;
$rangeEnd = $statsDate->addDay();
return UserCurrencyLog::query()
->join('users', 'users.id', '=', 'user_currency_logs.user_id')
->where('user_currency_logs.currency', 'gold')
->whereIn('user_currency_logs.source', array_map(
fn (CurrencySource $source): string => $source->value,
self::GAME_PROFIT_SOURCES
))
->where('user_currency_logs.created_at', '>=', $rangeStart)
->where('user_currency_logs.created_at', '<', $rangeEnd)
->where('users.username', '!=', 'AI小班长')
->groupBy('user_currency_logs.user_id', 'users.username', 'users.usersf')
->havingRaw('SUM(user_currency_logs.amount) > 0')
->orderByRaw('SUM(user_currency_logs.amount) DESC')
->orderBy('user_currency_logs.user_id')
->limit(3)
->selectRaw('user_currency_logs.user_id, users.username, users.usersf, SUM(user_currency_logs.amount) as net_profit')
->get()
->values()
->map(function (object $row, int $index): object {
$rank = $index + 1;
return (object) [
'rank' => $rank,
'title' => self::TITLES[$rank],
'user_id' => (int) $row->user_id,
'username' => (string) $row->username,
'headface_url' => $this->resolveHeadfaceUrl((string) ($row->usersf ?: '1.gif')),
'net_profit' => (int) $row->net_profit,
];
});
});
}
/**
* 解析榜单头像地址。
*/
private function resolveHeadfaceUrl(string $headface): string
{
if (str_starts_with($headface, 'storage/')) {
return '/'.$headface;
}
return '/images/headface/'.strtolower($headface);
}
}
+9 -5
View File
@@ -11,6 +11,7 @@
* (会员加成:+经验X+金币Y * (会员加成:+经验X+金币Y
* *
* @author ChatRoom Laravel * @author ChatRoom Laravel
*
* @version 1.2.0 * @version 1.2.0
*/ */
@@ -28,9 +29,7 @@ class FishingService
private readonly VipService $vipService, private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService, private readonly ShopService $shopService,
) ) {}
{
}
/** /**
* 处理收竿逻辑:计算结果、发放积分并全服广播。 * 处理收竿逻辑:计算结果、发放积分并全服广播。
@@ -38,6 +37,7 @@ class FishingService
* @param User $user 收竿的用户实体 * @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID * @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签) * @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @return array{emoji:string,message:string,exp:int,jjb:int,base_exp:int,base_jjb:int,bonus_exp:int,bonus_jjb:int}
*/ */
public function processCatch(User $user, int $roomId, bool $isAi = false): array public function processCatch(User $user, int $roomId, bool $isAi = false): array
{ {
@@ -94,14 +94,17 @@ class FishingService
$promoTag = ''; $promoTag = '';
if (! $isAi) { if (! $isAi) {
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user); $autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
// 公屏消息内的促销标签使用相对字号,避免覆盖用户在聊天室选择的字号。
$promoTag = $autoFishingMinutesLeft > 0 $promoTag = $autoFishingMinutesLeft > 0
? ' <span onclick="window.openShopModal&&window.openShopModal()" ' ? ' <span onclick="window.openShopModal&&window.openShopModal()" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;' .'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
. 'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;' .'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>' .'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: ''; : '';
} }
// 广播结果时额外带上统一动作标记和钓鱼者用户名,
// 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。
$sysMsg = [ $sysMsg = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
@@ -110,7 +113,8 @@ class FishingService
'content' => "{$result['emoji']}{$user->username}{$finalMessage}{$promoTag}", 'content' => "{$result['emoji']}{$user->username}{$finalMessage}{$promoTag}",
'is_secret' => false, 'is_secret' => false,
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a', 'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
'action' => '', 'action' => 'fishing_result',
'fishing_username' => $user->username,
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* 文件功能:游戏下注与奖励公屏右下角通知广播服务
*
* 统一处理百家乐、赛马、双色球等游戏下注或奖励领取成功后的公屏消息、
* 右下角 Toast 通知载荷和异步落库,避免各玩法重复拼装广播结构。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
/**
* 类功能:为游戏下注和奖励领取成功事件生成并广播全员可见通知。
*/
class GameBetBroadcastService
{
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 广播百家乐下注成功通知。
*/
public function baccarat(int $roomId, string $username, int $amount, string $betLabel): void
{
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🎲 <b>【百家乐】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨",
fontColor: '#d97706',
toastTitle: '🎲 有人下注百家乐',
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$betLabel}",
toastIcon: '🎲',
toastColor: '#d97706',
toastActorUsername: $username,
);
}
/**
* 广播赛马下注成功通知。
*/
public function horseRace(int $roomId, string $username, int $amount, string $horseName): void
{
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🐎 <b>【赛马】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨",
fontColor: '#d97706',
toastTitle: '🐎 有人下注赛马',
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$horseName}",
toastIcon: '🐎',
toastColor: '#d97706',
toastActorUsername: $username,
);
}
/**
* 广播双色球购票成功通知。
*/
public function lottery(int $roomId, string $username, string $issueNo, string $numbersLabel, int $ticketCount): void
{
$moreText = $ticketCount > 1 ? "{$ticketCount} 注号码" : '';
$this->pushBetMessage(
roomId: $roomId,
content: "🎟️ 【{$username}】购买 {$issueNo}{$numbersLabel} {$moreText}",
fontColor: '#dc2626',
toastTitle: '🎟️ 有人购买双色球',
toastMessage: "<b>{$username}</b> 购买 {$issueNo}{$numbersLabel} {$moreText}",
toastIcon: '🎟️',
toastColor: '#dc2626',
action: '大声宣告',
toastActorUsername: $username,
);
}
/**
* 广播红包领取成功通知。
*/
public function redPacketClaimed(int $roomId, string $username, int $amount, string $type): void
{
$typeLabel = $type === 'exp' ? '经验' : '金币';
$typeIcon = $type === 'exp' ? '✨' : '💰';
$toastColor = $type === 'exp' ? '#6d28d9' : '#d97706';
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🧧 <b>{$username}</b> 抢到了 <b>{$formattedAmount}</b> {$typeLabel}礼包!{$typeIcon}",
fontColor: $toastColor,
toastTitle: '🧧 有人领取红包',
toastMessage: "<b>{$username}</b> 抢到 <b>{$formattedAmount}</b> {$typeLabel}礼包",
toastIcon: '🧧',
toastColor: $toastColor,
toastActorUsername: $username,
skipToastForActor: true,
);
}
/**
* 推送带右下角通知载荷的公屏游戏消息。
*/
private function pushBetMessage(
int $roomId,
string $content,
string $fontColor,
string $toastTitle,
string $toastMessage,
string $toastIcon,
string $toastColor,
string $action = '',
?string $toastActorUsername = null,
bool $skipToastForActor = false,
): void {
$toastNotification = [
'title' => $toastTitle,
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 3000,
];
if ($toastActorUsername !== null) {
// 记录触发人用于前端去重,避免本人同时看到本地到账提示和公屏领取提示。
$toastNotification['actor_username'] = $toastActorUsername;
$toastNotification['skip_for_actor'] = $skipToastForActor;
}
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $fontColor,
'action' => $action,
'sent_at' => now()->toDateTimeString(),
'toast_notification' => $toastNotification,
];
// 下注通知必须进房间 Presence 频道,确保当前房间所有在线人员都能看到右下角提示。
$this->chatState->pushMessage($roomId, $message);
event(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
/**
* 文件功能:游戏房间范围配置服务
*
* 统一解析所有游戏的 room_scope_mode room_ids 配置,
* 供后台保存、调度任务、前台准入校验和公共回合查询复用。
*/
namespace App\Services;
use App\Models\GameConfig;
use App\Models\User;
use Illuminate\Http\Request;
/**
* 类功能:统一管理所有游戏的房间范围读取与房间判定。
*/
class GameRoomScopeService
{
/**
* 房间模式常量:全部房间。
*/
public const MODE_ALL = 'all';
/**
* 房间模式常量:单选房间。
*/
public const MODE_SINGLE = 'single';
/**
* 房间模式常量:多选房间。
*/
public const MODE_MULTIPLE = 'multiple';
/**
* 支持的房间模式列表。
*
* @var array<int, string>
*/
public const SUPPORTED_MODES = [
self::MODE_ALL,
self::MODE_SINGLE,
self::MODE_MULTIPLE,
];
/**
* 构造房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 归一化房间模式。
*/
public function normalizeRoomScopeMode(?string $mode, string $default = self::MODE_SINGLE): string
{
$normalizedMode = (string) $mode;
if (! in_array($normalizedMode, self::SUPPORTED_MODES, true)) {
return $default;
}
return $normalizedMode;
}
/**
* 把原始房间数组归一化为去重后的整型数组。
*
* @return array<int, int>
*/
public function normalizeRoomIds(mixed $roomIds, array $default = [1]): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
$normalizedRoomIds = collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
if ($normalizedRoomIds === []) {
return $default;
}
return $normalizedRoomIds;
}
/**
* params 数组中解析房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForParams(array $params, array $defaultRoomIds = [1]): array
{
if (
! array_key_exists('room_scope_mode', $params)
&& ! array_key_exists('room_ids', $params)
&& ! array_key_exists('room_id', $params)
) {
return [
'room_scope_mode' => self::MODE_ALL,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
$roomScopeMode = $this->normalizeRoomScopeMode(
mode: (string) ($params['room_scope_mode'] ?? self::MODE_SINGLE),
default: self::MODE_SINGLE,
);
$roomIds = $this->normalizeRoomIds(
roomIds: $params['room_ids'] ?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : []),
default: $defaultRoomIds,
);
return [
'room_scope_mode' => $roomScopeMode,
'room_ids' => $roomIds,
];
}
/**
* 读取指定游戏当前配置中的房间范围。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$params = GameConfig::forGame($gameKey)?->params ?? [];
return $this->getScopeConfigForParams($params, $defaultRoomIds);
}
/**
* 获取指定游戏真正生效的房间 ID 列表。
*
* @return array<int, int>
*/
public function getScopedRoomIdsForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$scopeConfig = $this->getScopeConfigForGame($gameKey, $defaultRoomIds);
if ($scopeConfig['room_scope_mode'] === self::MODE_ALL) {
return $this->resolveAllAvailableRoomIds($defaultRoomIds);
}
return $scopeConfig['room_ids'];
}
/**
* 获取指定游戏的首选房间。
*/
public function getPrimaryRoomIdForGame(string $gameKey, int $fallback = 1): int
{
$roomIds = $this->getScopedRoomIdsForGame($gameKey, [$fallback]);
return $roomIds[0] ?? $fallback;
}
/**
* 判断某个房间是否在指定游戏允许范围内。
*/
public function isRoomAllowedForGame(string $gameKey, int $roomId, array $defaultRoomIds = [1]): bool
{
return in_array($roomId, $this->getScopedRoomIdsForGame($gameKey, $defaultRoomIds), true);
}
/**
* 从请求或在线状态解析当前操作房间。
*/
public function resolveRequestRoomId(Request $request, ?User $user = null, int $fallback = 1): int
{
$requestedRoomId = (int) $request->integer('room_id', 0);
if ($requestedRoomId > 0) {
return $requestedRoomId;
}
return $this->resolveUserRoomId($user ?? $request->user(), $fallback);
}
/**
* 从用户在线房间或用户资料中推断当前房间。
*/
public function resolveUserRoomId(?User $user, int $fallback = 1): int
{
if (! $user) {
return $fallback;
}
$activeRoomIds = $this->chatState->getUserRooms($user->username);
if ($activeRoomIds !== []) {
return (int) $activeRoomIds[0];
}
$profileRoomId = (int) ($user->room_id ?? 0);
return $profileRoomId > 0 ? $profileRoomId : $fallback;
}
/**
* 返回通用后台复用的默认房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function defaultScopeConfig(array $defaultRoomIds = [1]): array
{
return [
'room_scope_mode' => self::MODE_SINGLE,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
/**
* 在“全部房间”模式下解析当前可用房间。
*
* @return array<int, int>
*/
private function resolveAllAvailableRoomIds(array $defaultRoomIds = [1]): array
{
$roomIds = \App\Models\Room::query()
->orderBy('id')
->pluck('id')
->map(fn (mixed $roomId): int => (int) $roomId)
->all();
return $roomIds !== [] ? $roomIds : $defaultRoomIds;
}
}
@@ -45,6 +45,39 @@ class HolidayEventScheduleService
$currentSendAt = CarbonImmutable::instance($event->send_at); $currentSendAt = CarbonImmutable::instance($event->send_at);
return $this->nextOccurrenceAfter($event, $currentSendAt);
}
/**
* 跳过已经超过领取窗口的历史计划点。
*/
public function skipExpiredOccurrences(HolidayEvent $event, CarbonInterface $reference): ?CarbonImmutable
{
if ($event->send_at === null) {
return null;
}
$candidate = CarbonImmutable::instance($event->send_at);
$referenceTime = CarbonImmutable::instance($reference);
$expireMinutes = max(0, (int) $event->expire_minutes);
while ($candidate->addMinutes($expireMinutes)->lessThanOrEqualTo($referenceTime)) {
// 历史批次的领取窗口已经结束,只推进调度指针,不能补发金币。
$candidate = $this->nextOccurrenceAfter($event, $candidate);
if ($candidate === null) {
return null;
}
}
return $candidate;
}
/**
* 计算指定计划点之后的下一次触发时间。
*/
private function nextOccurrenceAfter(HolidayEvent $event, CarbonImmutable $currentSendAt): ?CarbonImmutable
{
return match ($event->repeat_type) { return match ($event->repeat_type) {
'daily' => $currentSendAt->addDay(), 'daily' => $currentSendAt->addDay(),
'weekly' => $currentSendAt->addWeek(), 'weekly' => $currentSendAt->addWeek(),
+19 -14
View File
@@ -22,11 +22,16 @@ use App\Models\LotteryTicket;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/**
* 类功能:负责双色球购票、开奖、滚存与房间广播。
*/
class LotteryService class LotteryService
{ {
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
// ─── 购票 ───────────────────────────────────────────────────────── // ─── 购票 ─────────────────────────────────────────────────────────
@@ -49,7 +54,8 @@ class LotteryService
throw new \RuntimeException('双色球彩票游戏未开启'); throw new \RuntimeException('双色球彩票游戏未开启');
} }
$issue = LotteryIssue::currentIssue(); $roomId = $this->roomScopeService->resolveUserRoomId($user);
$issue = LotteryIssue::currentIssue($roomId);
if (! $issue || ! $issue->isOpen()) { if (! $issue || ! $issue->isOpen()) {
throw new \RuntimeException('当前无正在进行的期次,或已停售'); throw new \RuntimeException('当前无正在进行的期次,或已停售');
} }
@@ -134,8 +140,7 @@ class LotteryService
// 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票) // 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票)
$firstTicket = $tickets[0]; $firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel(); $numsStr = $firstTicket->numbersLabel();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : ''; $this->betBroadcastService->lottery((int) $issue->room_id, $user->username, $issue->issue_no, $numsStr, $buyCount);
$this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}】豪掷千金,购买了当前 #{$issue->issue_no} 期双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!");
return $tickets; return $tickets;
} }
@@ -364,7 +369,8 @@ class LotteryService
} }
$newIssue = LotteryIssue::create([ $newIssue = LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(), 'room_id' => (int) $prevIssue->room_id,
'issue_no' => LotteryIssue::nextIssueNo((int) $prevIssue->room_id),
'status' => 'open', 'status' => 'open',
'pool_amount' => $carryAmount + $injectAmount, 'pool_amount' => $carryAmount + $injectAmount,
'carry_amount' => $carryAmount, 'carry_amount' => $carryAmount,
@@ -444,9 +450,9 @@ class LotteryService
$detailStr = $details ? ' '.implode(' | ', $details) : ''; $detailStr = $details ? ' '.implode(' | ', $details) : '';
$content = "🎟️ 【双色球 {$issue->issue_no} 开奖{$drawNums} {$line1}{$detailStr}"; $content = "🎟️ 第 #{$issue->issue_no} 期开奖{$drawNums} {$line1}{$detailStr}";
$this->pushSystemMessage($content); $this->pushSystemMessage($content, (int) $issue->room_id);
// 触发微信机器人消息推送 (彩票开奖) // 触发微信机器人消息推送 (彩票开奖)
try { try {
@@ -463,20 +469,19 @@ class LotteryService
private function broadcastSuperIssue(LotteryIssue $issue): void private function broadcastSuperIssue(LotteryIssue $issue): void
{ {
$pool = number_format($issue->pool_amount); $pool = number_format($issue->pool_amount);
$content = "🎊🎟️ 【双色球超级期预警】{$issue->issue_no} 期已连续 {$issue->no_winner_streak} 期无一等奖" $content = "🎊 #{$issue->issue_no}超级期:已连续 {$issue->no_winner_streak} 期无一等奖,奖池 💰 {$pool}{$issue->draw_at?->format('H:i')} 开奖。";
."当前奖池 💰 {$pool} 金币,系统已追加注入!今日 {$issue->draw_at?->format('H:i')} 开奖,赶紧购票!";
$this->pushSystemMessage($content); $this->pushSystemMessage($content, (int) $issue->room_id);
} }
/** /**
* 向公屏发送系统消息。 * 向公屏发送系统消息。
*/ */
private function pushSystemMessage(string $content): void private function pushSystemMessage(string $content, int $roomId): void
{ {
$msg = [ $msg = [
'id' => $this->chatState->nextMessageId(1), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => 1, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $content, 'content' => $content,
@@ -485,8 +490,8 @@ class LotteryService
'action' => '大声宣告', 'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$this->chatState->pushMessage(1, $msg); $this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent(1, $msg)); broadcast(new MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg); \App\Jobs\SaveMessageJob::dispatch($msg);
} }
} }
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* 文件功能:猜谜活动回合服务
*
* 统一处理题型兼容、房间范围、自动出题、超时结算与公屏公告,
* 避免控制器与定时任务各自维护一套猜谜活动逻辑。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Events\RiddleGameStarted;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Models\Room;
/**
* 类功能:提供猜谜活动的配置读取、出题、过期结算与公告能力。
*/
class RiddleGameService
{
/**
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 方法功能:读取指定题型的完整配置,并兼容旧版平铺参数。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
public function getTypeConfig(?string $quizType = null): array
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame(Riddle::TYPE_IDIOM) ?? GameConfig::forGame($normalizedQuizType);
$params = $config?->params ?? [];
$typeConfig = (array) (($params['type_configs'] ?? [])[$normalizedQuizType] ?? []);
$sharedRoomIds = $this->normalizeRoomIds(
$params['room_ids']
?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : [])
);
$roomMode = (string) ($params['room_scope_mode'] ?? ($typeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
$roomIds = $sharedRoomIds !== []
? $sharedRoomIds
: $this->normalizeRoomIds($typeConfig['room_ids'] ?? [1]);
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($typeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($typeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($typeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($typeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => $roomIds,
];
}
/**
* 方法功能:读取题目有效时长配置,单位分钟。
*/
public function getExpireMinutes(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['expire_minutes'];
}
/**
* 方法功能:读取自动出题间隔配置,单位分钟。
*/
public function getAutoStartInterval(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['auto_start_interval'];
}
/**
* 方法功能:读取答题奖励配置。
*
* @return array{reward_gold:int,reward_exp:int}
*/
public function getRewardConfig(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
return [
'reward_gold' => $typeConfig['reward_gold'],
'reward_exp' => $typeConfig['reward_exp'],
];
}
/**
* 方法功能:将外部传入的题型归一化为系统支持值。
*/
public function normalizeQuizType(?string $quizType): string
{
$normalizedType = trim((string) $quizType);
return Riddle::isSupportedType($normalizedType)
? $normalizedType
: Riddle::TYPE_IDIOM;
}
/**
* 方法功能:返回题型对应的中文名称。
*/
public function getQuizTypeLabel(string $quizType): string
{
return Riddle::labelForType($this->normalizeQuizType($quizType));
}
/**
* 方法功能:读取自动出题的房间范围模式。
*/
public function getRoomScopeMode(?string $quizType = null): string
{
return $this->getTypeConfig($quizType)['room_mode'];
}
/**
* 方法功能:读取自动出题允许覆盖的房间列表。
*
* @return array<int, int>
*/
public function getScopedRoomIds(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
$mode = $typeConfig['room_mode'];
$configuredRoomIds = $typeConfig['room_ids'];
if ($mode === 'all') {
return Room::query()->orderBy('id')->pluck('id')->map(fn (mixed $id): int => (int) $id)->all();
}
if ($mode === 'single') {
return array_slice($configuredRoomIds !== [] ? $configuredRoomIds : [1], 0, 1);
}
return $configuredRoomIds !== [] ? $configuredRoomIds : [1];
}
/**
* 方法功能:判断指定回合是否已经超过有效时长。
*/
public function isRoundExpired(RiddleGameRound $round): bool
{
$expireMinutes = $this->getExpireMinutes($round->quiz_type);
if ($expireMinutes <= 0) {
return false;
}
if (! in_array($round->status, ['pending', 'active'], true)) {
return false;
}
if (! $round->started_at) {
return false;
}
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
}
/**
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
*/
public function expireRound(RiddleGameRound $round, bool $announce = true): bool
{
if (! $this->isRoundExpired($round)) {
return false;
}
$round->loadMissing('idiom');
// 已过期回合统一落为 ended,防止继续答题或阻塞新开题。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
if ($announce) {
$this->pushExpiredRoundMessage($round);
}
return true;
}
/**
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
*/
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true, ?string $quizType = null): int
{
$expiredCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::with('idiom')
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use ($announce, &$expiredCount): void {
if ($this->expireRound($round, $announce)) {
$expiredCount++;
}
});
return $expiredCount;
}
/**
* 方法功能:手动结束指定房间指定题型的所有进行中回合。
*/
public function endActiveRoundsForRoom(int $roomId, ?string $quizType = null): int
{
$endedCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::query()
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use (&$endedCount): void {
// 手动出题覆盖旧题时,直接结束旧回合,不再额外发超时公告。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
$endedCount++;
});
return $endedCount;
}
/**
* 方法功能:为指定房间和题型创建一轮新题。
*/
public function startRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
if (! $this->isGameEnabled($normalizedQuizType)) {
return null;
}
// 先清理同房间同题型的过期回合,避免旧记录卡住新题。
$this->expireActiveRoundsForRoom($roomId, true, $normalizedQuizType);
if ($this->findActiveRound($roomId, $normalizedQuizType)) {
return null;
}
$idiom = $this->pickRandomQuestion($normalizedQuizType);
if (! $idiom) {
return null;
}
$rewardConfig = $this->getRewardConfig($normalizedQuizType);
// 新回合显式记录 quiz_type,保证房间与题型维度都能独立判定。
$round = RiddleGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
'quiz_type' => $normalizedQuizType,
'status' => 'active',
'reward_gold' => $rewardConfig['reward_gold'],
'reward_exp' => $rewardConfig['reward_exp'],
'started_at' => now(),
]);
$round->setRelation('idiom', $idiom);
$this->broadcastStartedRound($round);
return $round;
}
/**
* 方法功能:按配置范围自动为各房间各题型尝试开题。
*/
public function autoStartEligibleRounds(): int
{
$startedCount = 0;
foreach (Riddle::supportedTypes() as $quizType) {
$interval = $this->getAutoStartInterval($quizType);
if ($interval <= 0) {
continue;
}
foreach ($this->getScopedRoomIds($quizType) as $roomId) {
// 房间与题型维度独立结算过期回合,互不干扰。
$this->expireActiveRoundsForRoom($roomId, true, $quizType);
if ($this->findActiveRound($roomId, $quizType)) {
continue;
}
if (! $this->hasReachedAutoStartInterval($roomId, $quizType, $interval)) {
continue;
}
if (! $this->pickRandomQuestion($quizType)) {
continue;
}
if ($this->startRound($roomId, $quizType)) {
$startedCount++;
}
}
}
return $startedCount;
}
/**
* 方法功能:查询指定房间指定题型的进行中回合。
*/
public function findActiveRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
return RiddleGameRound::query()
->with('idiom')
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->whereIn('status', ['pending', 'active'])
->first();
}
/**
* 方法功能:随机抽取一条启用中的题目。
*/
public function pickRandomQuestion(?string $quizType = null): ?Riddle
{
return Riddle::query()
->where('type', $this->normalizeQuizType($quizType))
->where('is_active', true)
->inRandomOrder()
->first();
}
/**
* 方法功能:生成答题奖励日志文案。
*/
public function buildRewardDescription(RiddleGameRound $round): string
{
$quizTypeLabel = $this->getQuizTypeLabel($round->quiz_type);
return "猜谜活动{$quizTypeLabel}答对「{$round->idiom?->answer}」奖励";
}
/**
* 方法功能:向公屏推送回合超时公告。
*/
public function pushExpiredRoundMessage(RiddleGameRound $round): void
{
$answer = $round->idiom?->answer ?? '未知答案';
$quizTitle = Riddle::activityLabelForType($round->quiz_type);
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏳ 【{$quizTitle}】第 #{$round->id} 题已超时结束!正确答案:{$answer}",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'quiz_answer' => $answer,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:广播新回合开始事件并同步写入公屏消息。
*/
public function broadcastStartedRound(RiddleGameRound $round): void
{
$round->loadMissing('idiom');
broadcast(new RiddleGameStarted(
roomId: $round->room_id,
quizType: $round->quiz_type,
hint: $round->idiom?->hint ?? '',
roundId: $round->id,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $this->buildStartMessage($round->quiz_type, $round->id, $round->idiom?->hint ?? ''),
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_hint' => $round->idiom?->hint ?? '',
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'idiom_game_round_id' => $round->id,
'idiom_reward_gold' => $round->reward_gold,
'idiom_reward_exp' => $round->reward_exp,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:判断指定房间指定题型是否已到自动开题间隔。
*/
private function hasReachedAutoStartInterval(int $roomId, string $quizType, int $interval): bool
{
$lastRound = RiddleGameRound::query()
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->latest()
->first();
if (! $lastRound) {
return true;
}
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
return ! $lastTime || $lastTime->diffInMinutes(now()) >= $interval;
}
/**
* 方法功能:把 room_ids 配置归一化为整型数组。
*
* @return array<int, int>
*/
private function normalizeRoomIds(mixed $roomIds): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
return collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
private function buildStartMessage(string $quizType, int $roundId, string $hint): string
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$quizLabel = $this->getQuizTypeLabel($normalizedQuizType);
$icon = $normalizedQuizType === Riddle::TYPE_BRAIN_TEASER ? '🧠' : '🧩';
return "{$icon} 【猜谜活动·{$quizLabel}】第 #{$roundId} 题开始!题面:{$hint}";
}
/**
* 方法功能:判断猜谜活动总开关是否处于启用状态。
*/
private function isGameEnabled(?string $quizType = null): bool
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame($normalizedQuizType) ?? GameConfig::forGame(Riddle::TYPE_IDIOM);
return (bool) $config?->enabled;
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* 文件功能:聊天室座驾业务服务。
*
* 统一管理座驾商品列表、购买续期、当前激活座驾、购买记录和入场欢迎语载荷。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\Ride;
use App\Models\User;
use App\Models\UserRidePurchase;
use App\Support\ChatContentSanitizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 聊天室座驾服务
* 负责通过 rides user_ride_purchases 完成座驾购买、续期、替换与进房展示。
*/
class RideService
{
/**
* 构造座驾服务依赖。
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
* 获取全部上架座驾商品。
*
* @return Collection<int, Ride>
*/
public function activeItems(): Collection
{
return Ride::active();
}
/**
* 格式化座驾商品,供前端页面直接渲染。
*
* @return array<string, mixed>
*/
public function formatItem(Ride $item): array
{
return [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'ride_key' => $item->rideKey(),
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'duration_days' => (int) ($item->duration_days ?? 0),
'welcome_message' => $item->welcome_message,
];
}
/**
* 获取用户当前有效座驾,若已过期则自动标记为 expired。
*/
public function currentRide(User $user): ?UserRidePurchase
{
$purchase = UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->where('status', 'active')
->orderByDesc('expires_at')
->first();
if (! $purchase) {
return null;
}
if ($purchase->expires_at && $purchase->expires_at->isPast()) {
// 过期座驾必须及时落库,避免后续进房继续播放旧特效。
$purchase->update(['status' => 'expired']);
return null;
}
return $purchase;
}
/**
* 格式化用户当前座驾。
*
* @return array<string, mixed>|null
*/
public function formatCurrentRide(User $user): ?array
{
$purchase = $this->currentRide($user);
if (! $purchase || ! $purchase->ride) {
return null;
}
return $this->formatPurchase($purchase);
}
/**
* 获取用户最近座驾购买记录。
*
* @return array<int, array<string, mixed>>
*/
public function purchaseRecords(User $user, int $limit = 20): array
{
return UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->latest()
->limit($limit)
->get()
->map(fn (UserRidePurchase $purchase) => $this->formatPurchase($purchase))
->values()
->all();
}
/**
* 购买座驾:同款续期,不同款替换旧座驾且不退款。
*
* @return array{ok:bool, message:string, current_ride?:array<string, mixed>}
*/
public function buy(User $user, Ride $item, ?int $roomId = null): array
{
if (! $item->is_active) {
return ['ok' => false, 'message' => '该座驾暂未上架。'];
}
$days = (int) ($item->duration_days ?? 0);
if ($days <= 0) {
return ['ok' => false, 'message' => '该座驾使用天数配置异常,请联系管理员。'];
}
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
}
$purchased = DB::transaction(function () use ($user, $item, $days, $roomId): bool {
$now = Carbon::now();
// 先清理已过期的 active 座驾,避免旧状态影响替换判断。
UserRidePurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', $now)
->update(['status' => 'expired']);
$activeRide = UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->where('status', 'active')
->orderByDesc('expires_at')
->first();
$balanceAfter = $this->currencyService->deductGoldIfEnough(
$user,
(int) $item->price,
CurrencySource::RIDE_BUY,
"购买聊天室座驾:{$item->name}",
$roomId,
);
if ($balanceAfter === null) {
return false;
}
if ($activeRide && (int) $activeRide->ride_id === (int) $item->id) {
$baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now)
? $activeRide->expires_at
: $now;
// 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。
$activeRide->update(['status' => 'cancelled']);
UserRidePurchase::create([
'user_id' => $user->id,
'ride_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $baseTime->copy()->addDays($days),
]);
return true;
}
if ($activeRide) {
// 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。
$activeRide->update(['status' => 'cancelled']);
}
UserRidePurchase::create([
'user_id' => $user->id,
'ride_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $now->copy()->addDays($days),
]);
return true;
});
if (! $purchased) {
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->fresh()->jjb} 金币。"];
}
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。",
'current_ride' => $this->formatCurrentRide($user->fresh()),
];
}
/**
* 构建进房座驾欢迎语与特效载荷。
*
* @return array<string, string>|null
*/
public function buildPresencePayload(User $user): ?array
{
$purchase = $this->currentRide($user);
$item = $purchase?->ride;
$rideKey = $item?->rideKey();
if (! $purchase || ! $item || ! $rideKey) {
return null;
}
$template = trim((string) ($item->welcome_message ?: '【{name}】驾驶【{ride}】震撼入场,全场请注意!'));
$rendered = strtr($template, [
'{name}' => $user->username,
'{ride}' => $item->name,
]);
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
$effectUserInfo = "用户 {$user->username} · {$identitySummary['inline']}";
return [
'ride_key' => $rideKey,
'ride_name' => $item->name,
'ride_icon' => (string) ($item->icon ?? '🚘'),
'effect_title' => "乘坐【{$item->name}】闪亮登场",
'effect_user_info' => $effectUserInfo,
'identity_text' => ChatContentSanitizer::htmlText($identitySummary['inline']),
'welcome_text' => ChatContentSanitizer::htmlText($rendered),
];
}
/**
* 格式化单条座驾购买记录。
*
* @return array<string, mixed>
*/
private function formatPurchase(UserRidePurchase $purchase): array
{
$item = $purchase->ride;
return [
'id' => $purchase->id,
'status' => $purchase->status,
'price_paid' => (int) $purchase->price_paid,
'expires_at' => $purchase->expires_at?->toDateTimeString(),
'used_at' => $purchase->used_at?->toDateTimeString(),
'created_at' => $purchase->created_at?->toDateTimeString(),
'item' => $item ? $this->formatItem($item) : null,
];
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class ShopService
*/ */
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
{ {
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) { if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR && !$item->isDecoration()) {
return ['ok' => false, 'message' => '该商品暂不支持批量购买。']; return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
} }
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* 文件功能:聊天室固定成就目录。
*
* 第一版成就规则全部写在代码里,避免过早引入后台规则引擎。
*/
namespace App\Support;
/**
* 类功能:集中提供成就定义、分类与展示文案。
*/
class AchievementCatalog
{
/**
* 返回全部成就定义。
*
* @return array<string, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}>
*/
public static function definitions(): array
{
$definitions = [
['key' => 'chat_first_message', 'category' => 'chat', 'name' => '初来乍到', 'icon' => '💬', 'description' => '发送第一条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1, 'sort' => 10],
['key' => 'chat_100_messages', 'category' => 'chat', 'name' => '百句达人', 'icon' => '🗣️', 'description' => '累计发送 100 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100, 'sort' => 20],
['key' => 'chat_500_messages', 'category' => 'chat', 'name' => '话题熟客', 'icon' => '📢', 'description' => '累计发送 500 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 500, 'sort' => 30],
['key' => 'chat_1000_messages', 'category' => 'chat', 'name' => '千句常驻', 'icon' => '📣', 'description' => '累计发送 1000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1000, 'sort' => 40],
['key' => 'chat_5000_messages', 'category' => 'chat', 'name' => '五千热聊', 'icon' => '🔥', 'description' => '累计发送 5000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 5000, 'sort' => 50],
['key' => 'chat_10000_messages', 'category' => 'chat', 'name' => '万句元老', 'icon' => '🏛️', 'description' => '累计发送 10000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 10000, 'sort' => 60],
['key' => 'chat_50000_messages', 'category' => 'chat', 'name' => '五万传声', 'icon' => '📡', 'description' => '累计发送 50000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 50000, 'sort' => 70],
['key' => 'chat_100000_messages', 'category' => 'chat', 'name' => '十万回响', 'icon' => '🌌', 'description' => '累计发送 100000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100000, 'sort' => 80],
['key' => 'chat_welcome_10', 'category' => 'chat', 'name' => '迎新助手', 'icon' => '🙋', 'description' => '累计欢迎他人 10 次', 'metric' => 'welcome_messages', 'threshold' => 10, 'sort' => 90],
['key' => 'chat_welcome_50', 'category' => 'chat', 'name' => '欢迎达人', 'icon' => '👋', 'description' => '累计欢迎他人 50 次', 'metric' => 'welcome_messages', 'threshold' => 50, 'sort' => 100],
['key' => 'chat_welcome_100', 'category' => 'chat', 'name' => '迎宾队长', 'icon' => '🎉', 'description' => '累计欢迎他人 100 次', 'metric' => 'welcome_messages', 'threshold' => 100, 'sort' => 110],
['key' => 'chat_welcome_500', 'category' => 'chat', 'name' => '满堂迎客', 'icon' => '🏮', 'description' => '累计欢迎他人 500 次', 'metric' => 'welcome_messages', 'threshold' => 500, 'sort' => 120],
['key' => 'signin_total_1', 'category' => 'sign_in', 'name' => '首次打卡', 'icon' => '☀️', 'description' => '累计签到 1 天', 'metric' => 'total_sign_ins', 'threshold' => 1, 'sort' => 130],
['key' => 'signin_total_7', 'category' => 'sign_in', 'name' => '一周到场', 'icon' => '🗓️', 'description' => '累计签到 7 天', 'metric' => 'total_sign_ins', 'threshold' => 7, 'sort' => 140],
['key' => 'signin_total_30', 'category' => 'sign_in', 'name' => '月度出勤', 'icon' => '📆', 'description' => '累计签到 30 天', 'metric' => 'total_sign_ins', 'threshold' => 30, 'sort' => 150],
['key' => 'signin_total_100', 'category' => 'sign_in', 'name' => '百日足迹', 'icon' => '👣', 'description' => '累计签到 100 天', 'metric' => 'total_sign_ins', 'threshold' => 100, 'sort' => 160],
['key' => 'signin_total_365', 'category' => 'sign_in', 'name' => '年度常客', 'icon' => '🏅', 'description' => '累计签到 365 天', 'metric' => 'total_sign_ins', 'threshold' => 365, 'sort' => 170],
['key' => 'signin_3_streak', 'category' => 'sign_in', 'name' => '三日连到', 'icon' => '✅', 'description' => '连续签到 3 天', 'metric' => 'sign_in_streak', 'threshold' => 3, 'sort' => 180],
['key' => 'signin_7_streak', 'category' => 'sign_in', 'name' => '七日不断', 'icon' => '☑️', 'description' => '连续签到 7 天', 'metric' => 'sign_in_streak', 'threshold' => 7, 'sort' => 190],
['key' => 'signin_15_streak', 'category' => 'sign_in', 'name' => '半月不断', 'icon' => '🌙', 'description' => '连续签到 15 天', 'metric' => 'sign_in_streak', 'threshold' => 15, 'sort' => 200],
['key' => 'signin_30_streak', 'category' => 'sign_in', 'name' => '月度全勤', 'icon' => '📅', 'description' => '连续签到 30 天', 'metric' => 'sign_in_streak', 'threshold' => 30, 'sort' => 210],
['key' => 'signin_60_streak', 'category' => 'sign_in', 'name' => '双月坚守', 'icon' => '🔥', 'description' => '连续签到 60 天', 'metric' => 'sign_in_streak', 'threshold' => 60, 'sort' => 220],
['key' => 'signin_100_streak', 'category' => 'sign_in', 'name' => '百日坚持', 'icon' => '💯', 'description' => '连续签到 100 天', 'metric' => 'sign_in_streak', 'threshold' => 100, 'sort' => 230],
['key' => 'signin_365_streak', 'category' => 'sign_in', 'name' => '全年不断', 'icon' => '🏆', 'description' => '连续签到 365 天', 'metric' => 'sign_in_streak', 'threshold' => 365, 'sort' => 240],
['key' => 'signin_makeup_used', 'category' => 'sign_in', 'name' => '补签救场', 'icon' => '🧩', 'description' => '使用过 1 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 1, 'sort' => 250],
['key' => 'signin_makeup_5', 'category' => 'sign_in', 'name' => '补签老手', 'icon' => '🪄', 'description' => '累计使用 5 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 5, 'sort' => 260],
['key' => 'signin_makeup_20', 'category' => 'sign_in', 'name' => '断线重连', 'icon' => '🔁', 'description' => '累计使用 20 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 20, 'sort' => 270],
['key' => 'growth_exp_10000', 'category' => 'growth', 'name' => '小有所成', 'icon' => '✨', 'description' => '累计获得 10000 经验', 'metric' => 'exp_gain', 'threshold' => 10000, 'sort' => 210],
['key' => 'growth_gold_100000', 'category' => 'growth', 'name' => '金币新贵', 'icon' => '💰', 'description' => '累计获得 100000 金币', 'metric' => 'gold_gain', 'threshold' => 100000, 'sort' => 220],
['key' => 'growth_charm_1000', 'category' => 'growth', 'name' => '魅力初显', 'icon' => '🌸', 'description' => '累计获得 1000 魅力', 'metric' => 'charm_gain', 'threshold' => 1000, 'sort' => 230],
['key' => 'growth_assets_1000000', 'category' => 'growth', 'name' => '百万身家', 'icon' => '💎', 'description' => '金币资产达到 1000000', 'metric' => 'gold_assets', 'threshold' => 1000000, 'sort' => 240],
['key' => 'growth_assets_10000000', 'category' => 'growth', 'name' => '千万富豪', 'icon' => '👑', 'description' => '金币资产达到 10000000', 'metric' => 'gold_assets', 'threshold' => 10000000, 'sort' => 250],
['key' => 'growth_assets_100000000', 'category' => 'growth', 'name' => '亿级资产', 'icon' => '🏆', 'description' => '金币资产达到 100000000', 'metric' => 'gold_assets', 'threshold' => 100000000, 'sort' => 260],
['key' => 'growth_bank_500000', 'category' => 'growth', 'name' => '存款达人', 'icon' => '🏦', 'description' => '银行存款达到 500000 金币', 'metric' => 'bank_balance', 'threshold' => 500000, 'sort' => 270],
['key' => 'growth_bank_1000000', 'category' => 'growth', 'name' => '百万存款', 'icon' => '🏧', 'description' => '银行存款达到 1000000 金币', 'metric' => 'bank_balance', 'threshold' => 1000000, 'sort' => 280],
['key' => 'growth_bank_10000000', 'category' => 'growth', 'name' => '金库存户', 'icon' => '🔐', 'description' => '银行存款达到 10000000 金币', 'metric' => 'bank_balance', 'threshold' => 10000000, 'sort' => 290],
['key' => 'game_baccarat_20', 'category' => 'game', 'name' => '百家乐入门', 'icon' => '🎲', 'description' => '累计参与百家乐下注 20 次', 'metric' => 'baccarat_bets', 'threshold' => 20, 'sort' => 310],
['key' => 'game_horse_20', 'category' => 'game', 'name' => '赛马看客', 'icon' => '🐎', 'description' => '累计参与赛马下注 20 次', 'metric' => 'horse_bets', 'threshold' => 20, 'sort' => 320],
['key' => 'game_lottery_20', 'category' => 'game', 'name' => '双色球常客', 'icon' => '🎟️', 'description' => '累计购买双色球 20 注', 'metric' => 'lottery_tickets', 'threshold' => 20, 'sort' => 330],
['key' => 'game_slot_20', 'category' => 'game', 'name' => '老虎机试手', 'icon' => '🎰', 'description' => '累计转动老虎机 20 次', 'metric' => 'slot_spins', 'threshold' => 20, 'sort' => 340],
['key' => 'game_gomoku_win', 'category' => 'game', 'name' => '五子棋首胜', 'icon' => '♟️', 'description' => '获得 1 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 1, 'sort' => 350],
['key' => 'game_fishing_20', 'category' => 'game', 'name' => '垂钓小能手', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 20 次', 'metric' => 'fishing_times', 'threshold' => 20, 'sort' => 360],
['key' => 'game_riddle_win', 'category' => 'game', 'name' => '猜谜破题', 'icon' => '🧠', 'description' => '成功答对 1 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 1, 'sort' => 370],
['key' => 'game_win_1000', 'category' => 'game', 'name' => '小赚一笔', 'icon' => '🪙', 'description' => '游戏累计赢取 1000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000, 'sort' => 380],
['key' => 'game_win_10000', 'category' => 'game', 'name' => '手气渐热', 'icon' => '💵', 'description' => '游戏累计赢取 10000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000, 'sort' => 390],
['key' => 'game_win_100000', 'category' => 'game', 'name' => '十万进账', 'icon' => '💰', 'description' => '游戏累计赢取 100000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000, 'sort' => 400],
['key' => 'game_win_1000000', 'category' => 'game', 'name' => '百万赢家', 'icon' => '🏆', 'description' => '游戏累计赢取 1000000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000000, 'sort' => 410],
['key' => 'game_win_10000000', 'category' => 'game', 'name' => '千万胜手', 'icon' => '👑', 'description' => '游戏累计赢取 10000000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000000, 'sort' => 420],
['key' => 'game_loss_1000', 'category' => 'game', 'name' => '小输当练', 'icon' => '🧾', 'description' => '游戏累计输掉 1000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000, 'sort' => 430],
['key' => 'game_loss_10000', 'category' => 'game', 'name' => '万金试炼', 'icon' => '📉', 'description' => '游戏累计输掉 10000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000, 'sort' => 440],
['key' => 'game_loss_100000', 'category' => 'game', 'name' => '十万学费', 'icon' => '🎒', 'description' => '游戏累计输掉 100000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000, 'sort' => 450],
['key' => 'game_loss_1000000', 'category' => 'game', 'name' => '百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 1000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000000, 'sort' => 460],
['key' => 'game_loss_10000000', 'category' => 'game', 'name' => '千万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 10000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000000, 'sort' => 470],
['key' => 'social_red_packet_sent', 'category' => 'social', 'name' => '慷慨发包', 'icon' => '🧧', 'description' => '发送过 1 次红包', 'metric' => 'red_packets_sent', 'threshold' => 1, 'sort' => 410],
['key' => 'social_red_packet_claimed', 'category' => 'social', 'name' => '手气不错', 'icon' => '🙌', 'description' => '领取过 1 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 1, 'sort' => 420],
['key' => 'social_married', 'category' => 'social', 'name' => '情定聊天室', 'icon' => '💍', 'description' => '完成一次结婚', 'metric' => 'marriages', 'threshold' => 1, 'sort' => 430],
['key' => 'social_intimacy_1000', 'category' => 'social', 'name' => '亲密搭档', 'icon' => '💞', 'description' => '婚姻亲密度达到 1000', 'metric' => 'marriage_intimacy', 'threshold' => 1000, 'sort' => 440],
['key' => 'social_gift_sent', 'category' => 'social', 'name' => '赠礼之友', 'icon' => '🎁', 'description' => '送出过 1 次礼物', 'metric' => 'gifts_sent', 'threshold' => 1, 'sort' => 450],
['key' => 'social_gift_received', 'category' => 'social', 'name' => '人气收礼', 'icon' => '💐', 'description' => '收到过 1 次礼物', 'metric' => 'gifts_received', 'threshold' => 1, 'sort' => 460],
['key' => 'duty_first_position', 'category' => 'duty', 'name' => '首次任命', 'icon' => '🎖️', 'description' => '获得过 1 次职务任命', 'metric' => 'positions', 'threshold' => 1, 'sort' => 510],
['key' => 'duty_60_minutes', 'category' => 'duty', 'name' => '勤务一小时', 'icon' => '⏱️', 'description' => '累计值班 60 分钟', 'metric' => 'duty_minutes', 'threshold' => 60, 'sort' => 520],
['key' => 'duty_admin_action', 'category' => 'duty', 'name' => '管理出手', 'icon' => '🛡️', 'description' => '执行过 1 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1, 'sort' => 530],
];
$definitions = array_merge($definitions, self::extendedTierDefinitions());
return collect($definitions)->keyBy('key')->all();
}
/**
* 返回长期运营需要的扩展阶梯成就。
*
* @return array<int, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int}>
*/
private static function extendedTierDefinitions(): array
{
return [
['key' => 'chat_2000_messages', 'category' => 'chat', 'name' => '两千连珠', 'icon' => '🧵', 'description' => '累计发送 2000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 2000, 'sort' => 45],
['key' => 'chat_20000_messages', 'category' => 'chat', 'name' => '两万谈资', 'icon' => '🛰️', 'description' => '累计发送 20000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 20000, 'sort' => 65],
['key' => 'chat_200000_messages', 'category' => 'chat', 'name' => '二十万长谈', 'icon' => '🌠', 'description' => '累计发送 200000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 200000, 'sort' => 85],
['key' => 'chat_300000_messages', 'category' => 'chat', 'name' => '三十万星河', 'icon' => '🌌', 'description' => '累计发送 300000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 300000, 'sort' => 86],
['key' => 'chat_welcome_1000', 'category' => 'chat', 'name' => '千次迎客', 'icon' => '🎊', 'description' => '累计欢迎他人 1000 次', 'metric' => 'welcome_messages', 'threshold' => 1000, 'sort' => 121],
['key' => 'chat_welcome_3000', 'category' => 'chat', 'name' => '迎宾长明灯', 'icon' => '🏵️', 'description' => '累计欢迎他人 3000 次', 'metric' => 'welcome_messages', 'threshold' => 3000, 'sort' => 122],
['key' => 'signin_total_60', 'category' => 'sign_in', 'name' => '两月足迹', 'icon' => '📍', 'description' => '累计签到 60 天', 'metric' => 'total_sign_ins', 'threshold' => 60, 'sort' => 155],
['key' => 'signin_total_180', 'category' => 'sign_in', 'name' => '半年到场', 'icon' => '🧭', 'description' => '累计签到 180 天', 'metric' => 'total_sign_ins', 'threshold' => 180, 'sort' => 165],
['key' => 'signin_total_730', 'category' => 'sign_in', 'name' => '两年常驻', 'icon' => '🏕️', 'description' => '累计签到 730 天', 'metric' => 'total_sign_ins', 'threshold' => 730, 'sort' => 171],
['key' => 'signin_total_1000', 'category' => 'sign_in', 'name' => '千日留名', 'icon' => '📜', 'description' => '累计签到 1000 天', 'metric' => 'total_sign_ins', 'threshold' => 1000, 'sort' => 172],
['key' => 'signin_180_streak', 'category' => 'sign_in', 'name' => '半年不断', 'icon' => '🧱', 'description' => '连续签到 180 天', 'metric' => 'sign_in_streak', 'threshold' => 180, 'sort' => 241],
['key' => 'signin_730_streak', 'category' => 'sign_in', 'name' => '两年不断', 'icon' => '🗻', 'description' => '连续签到 730 天', 'metric' => 'sign_in_streak', 'threshold' => 730, 'sort' => 242],
['key' => 'signin_makeup_50', 'category' => 'sign_in', 'name' => '时光修补匠', 'icon' => '🧵', 'description' => '累计使用 50 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 50, 'sort' => 271],
['key' => 'growth_exp_50000', 'category' => 'growth', 'name' => '经验老练', 'icon' => '🌟', 'description' => '累计获得 50000 经验', 'metric' => 'exp_gain', 'threshold' => 50000, 'sort' => 211],
['key' => 'growth_exp_100000', 'category' => 'growth', 'name' => '十万经验', 'icon' => '🎓', 'description' => '累计获得 100000 经验', 'metric' => 'exp_gain', 'threshold' => 100000, 'sort' => 212],
['key' => 'growth_exp_500000', 'category' => 'growth', 'name' => '经验厚积', 'icon' => '📚', 'description' => '累计获得 500000 经验', 'metric' => 'exp_gain', 'threshold' => 500000, 'sort' => 213],
['key' => 'growth_exp_1000000', 'category' => 'growth', 'name' => '百万经验', 'icon' => '🏫', 'description' => '累计获得 1000000 经验', 'metric' => 'exp_gain', 'threshold' => 1000000, 'sort' => 214],
['key' => 'growth_gold_500000', 'category' => 'growth', 'name' => '半百万进账', 'icon' => '💴', 'description' => '累计获得 500000 金币', 'metric' => 'gold_gain', 'threshold' => 500000, 'sort' => 221],
['key' => 'growth_gold_1000000', 'category' => 'growth', 'name' => '百万进账', 'icon' => '💵', 'description' => '累计获得 1000000 金币', 'metric' => 'gold_gain', 'threshold' => 1000000, 'sort' => 222],
['key' => 'growth_gold_5000000', 'category' => 'growth', 'name' => '五百万进账', 'icon' => '💶', 'description' => '累计获得 5000000 金币', 'metric' => 'gold_gain', 'threshold' => 5000000, 'sort' => 223],
['key' => 'growth_gold_10000000', 'category' => 'growth', 'name' => '千万进账', 'icon' => '💷', 'description' => '累计获得 10000000 金币', 'metric' => 'gold_gain', 'threshold' => 10000000, 'sort' => 224],
['key' => 'growth_gold_100000000', 'category' => 'growth', 'name' => '亿级进账', 'icon' => '🪙', 'description' => '累计获得 100000000 金币', 'metric' => 'gold_gain', 'threshold' => 100000000, 'sort' => 225],
['key' => 'growth_charm_5000', 'category' => 'growth', 'name' => '魅力上扬', 'icon' => '🌺', 'description' => '累计获得 5000 魅力', 'metric' => 'charm_gain', 'threshold' => 5000, 'sort' => 231],
['key' => 'growth_charm_10000', 'category' => 'growth', 'name' => '万点魅力', 'icon' => '💐', 'description' => '累计获得 10000 魅力', 'metric' => 'charm_gain', 'threshold' => 10000, 'sort' => 232],
['key' => 'growth_charm_50000', 'category' => 'growth', 'name' => '魅力满堂', 'icon' => '🪷', 'description' => '累计获得 50000 魅力', 'metric' => 'charm_gain', 'threshold' => 50000, 'sort' => 233],
['key' => 'growth_charm_100000', 'category' => 'growth', 'name' => '十万魅力', 'icon' => '👒', 'description' => '累计获得 100000 魅力', 'metric' => 'charm_gain', 'threshold' => 100000, 'sort' => 234],
['key' => 'growth_assets_5000000', 'category' => 'growth', 'name' => '五百万身家', 'icon' => '💍', 'description' => '金币资产达到 5000000', 'metric' => 'gold_assets', 'threshold' => 5000000, 'sort' => 245],
['key' => 'growth_assets_50000000', 'category' => 'growth', 'name' => '五千万资产', 'icon' => '🏦', 'description' => '金币资产达到 50000000', 'metric' => 'gold_assets', 'threshold' => 50000000, 'sort' => 255],
['key' => 'growth_assets_500000000', 'category' => 'growth', 'name' => '五亿资产', 'icon' => '🏛️', 'description' => '金币资产达到 500000000', 'metric' => 'gold_assets', 'threshold' => 500000000, 'sort' => 261],
['key' => 'growth_assets_1000000000', 'category' => 'growth', 'name' => '十亿传说', 'icon' => '🚀', 'description' => '金币资产达到 1000000000', 'metric' => 'gold_assets', 'threshold' => 1000000000, 'sort' => 262],
['key' => 'growth_bank_5000000', 'category' => 'growth', 'name' => '五百万存款', 'icon' => '🧱', 'description' => '银行存款达到 5000000 金币', 'metric' => 'bank_balance', 'threshold' => 5000000, 'sort' => 285],
['key' => 'growth_bank_50000000', 'category' => 'growth', 'name' => '五千万金库', 'icon' => '🏦', 'description' => '银行存款达到 50000000 金币', 'metric' => 'bank_balance', 'threshold' => 50000000, 'sort' => 291],
['key' => 'growth_bank_100000000', 'category' => 'growth', 'name' => '亿级金库', 'icon' => '🔒', 'description' => '银行存款达到 100000000 金币', 'metric' => 'bank_balance', 'threshold' => 100000000, 'sort' => 292],
['key' => 'game_baccarat_100', 'category' => 'game', 'name' => '百局百家乐', 'icon' => '🎲', 'description' => '累计参与百家乐下注 100 次', 'metric' => 'baccarat_bets', 'threshold' => 100, 'sort' => 311],
['key' => 'game_baccarat_500', 'category' => 'game', 'name' => '百家乐熟手', 'icon' => '🃏', 'description' => '累计参与百家乐下注 500 次', 'metric' => 'baccarat_bets', 'threshold' => 500, 'sort' => 312],
['key' => 'game_baccarat_1000', 'category' => 'game', 'name' => '千局庄闲', 'icon' => '🎴', 'description' => '累计参与百家乐下注 1000 次', 'metric' => 'baccarat_bets', 'threshold' => 1000, 'sort' => 313],
['key' => 'game_horse_100', 'category' => 'game', 'name' => '百场赛马', 'icon' => '🏇', 'description' => '累计参与赛马下注 100 次', 'metric' => 'horse_bets', 'threshold' => 100, 'sort' => 321],
['key' => 'game_horse_500', 'category' => 'game', 'name' => '马场熟客', 'icon' => '🎠', 'description' => '累计参与赛马下注 500 次', 'metric' => 'horse_bets', 'threshold' => 500, 'sort' => 322],
['key' => 'game_horse_1000', 'category' => 'game', 'name' => '千场观赛', 'icon' => '🏁', 'description' => '累计参与赛马下注 1000 次', 'metric' => 'horse_bets', 'threshold' => 1000, 'sort' => 323],
['key' => 'game_lottery_100', 'category' => 'game', 'name' => '百注双色球', 'icon' => '🎟️', 'description' => '累计购买双色球 100 注', 'metric' => 'lottery_tickets', 'threshold' => 100, 'sort' => 331],
['key' => 'game_lottery_500', 'category' => 'game', 'name' => '彩池常客', 'icon' => '🔵', 'description' => '累计购买双色球 500 注', 'metric' => 'lottery_tickets', 'threshold' => 500, 'sort' => 332],
['key' => 'game_lottery_1000', 'category' => 'game', 'name' => '千注追梦', 'icon' => '🔴', 'description' => '累计购买双色球 1000 注', 'metric' => 'lottery_tickets', 'threshold' => 1000, 'sort' => 333],
['key' => 'game_slot_100', 'category' => 'game', 'name' => '百转老虎机', 'icon' => '🎰', 'description' => '累计转动老虎机 100 次', 'metric' => 'slot_spins', 'threshold' => 100, 'sort' => 341],
['key' => 'game_slot_500', 'category' => 'game', 'name' => '转轮熟手', 'icon' => '⚙️', 'description' => '累计转动老虎机 500 次', 'metric' => 'slot_spins', 'threshold' => 500, 'sort' => 342],
['key' => 'game_slot_1000', 'category' => 'game', 'name' => '千转不歇', 'icon' => '🔔', 'description' => '累计转动老虎机 1000 次', 'metric' => 'slot_spins', 'threshold' => 1000, 'sort' => 343],
['key' => 'game_gomoku_5_wins', 'category' => 'game', 'name' => '五子五胜', 'icon' => '⚫', 'description' => '获得 5 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 5, 'sort' => 351],
['key' => 'game_gomoku_20_wins', 'category' => 'game', 'name' => '棋盘强手', 'icon' => '⚪', 'description' => '获得 20 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 20, 'sort' => 352],
['key' => 'game_gomoku_100_wins', 'category' => 'game', 'name' => '百胜棋手', 'icon' => '♟️', 'description' => '获得 100 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 100, 'sort' => 353],
['key' => 'game_fishing_100', 'category' => 'game', 'name' => '百竿垂钓', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 100 次', 'metric' => 'fishing_times', 'threshold' => 100, 'sort' => 361],
['key' => 'game_fishing_500', 'category' => 'game', 'name' => '鱼塘熟手', 'icon' => '🐟', 'description' => '累计抛竿钓鱼 500 次', 'metric' => 'fishing_times', 'threshold' => 500, 'sort' => 362],
['key' => 'game_fishing_1000', 'category' => 'game', 'name' => '千竿钓客', 'icon' => '🐠', 'description' => '累计抛竿钓鱼 1000 次', 'metric' => 'fishing_times', 'threshold' => 1000, 'sort' => 363],
['key' => 'game_riddle_10_wins', 'category' => 'game', 'name' => '十题小成', 'icon' => '🧠', 'description' => '成功答对 10 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 10, 'sort' => 371],
['key' => 'game_riddle_50_wins', 'category' => 'game', 'name' => '破题熟手', 'icon' => '💡', 'description' => '成功答对 50 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 50, 'sort' => 372],
['key' => 'game_riddle_200_wins', 'category' => 'game', 'name' => '谜面克星', 'icon' => '📘', 'description' => '成功答对 200 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 200, 'sort' => 373],
['key' => 'game_win_5000', 'category' => 'game', 'name' => '五千到手', 'icon' => '🪙', 'description' => '游戏累计赢取 5000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000, 'sort' => 385],
['key' => 'game_win_50000', 'category' => 'game', 'name' => '五万好运', 'icon' => '💵', 'description' => '游戏累计赢取 50000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000, 'sort' => 395],
['key' => 'game_win_500000', 'category' => 'game', 'name' => '半百万赢家', 'icon' => '💰', 'description' => '游戏累计赢取 500000 金币', 'metric' => 'game_gold_won', 'threshold' => 500000, 'sort' => 405],
['key' => 'game_win_5000000', 'category' => 'game', 'name' => '五百万胜手', 'icon' => '🏆', 'description' => '游戏累计赢取 5000000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000000, 'sort' => 415],
['key' => 'game_win_50000000', 'category' => 'game', 'name' => '五千万战绩', 'icon' => '👑', 'description' => '游戏累计赢取 50000000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000000, 'sort' => 421],
['key' => 'game_win_100000000', 'category' => 'game', 'name' => '亿级赢家', 'icon' => '🌟', 'description' => '游戏累计赢取 100000000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000000, 'sort' => 422],
['key' => 'game_loss_5000', 'category' => 'game', 'name' => '五千试水', 'icon' => '🧾', 'description' => '游戏累计输掉 5000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000, 'sort' => 435],
['key' => 'game_loss_50000', 'category' => 'game', 'name' => '五万起伏', 'icon' => '📉', 'description' => '游戏累计输掉 50000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000, 'sort' => 445],
['key' => 'game_loss_500000', 'category' => 'game', 'name' => '半百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 500000 金币', 'metric' => 'game_gold_lost', 'threshold' => 500000, 'sort' => 455],
['key' => 'game_loss_5000000', 'category' => 'game', 'name' => '五百万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 5000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000000, 'sort' => 465],
['key' => 'game_loss_50000000', 'category' => 'game', 'name' => '五千万风浪', 'icon' => '🌪️', 'description' => '游戏累计输掉 50000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000000, 'sort' => 471],
['key' => 'game_loss_100000000', 'category' => 'game', 'name' => '亿级沉浮', 'icon' => '🕳️', 'description' => '游戏累计输掉 100000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000000, 'sort' => 472],
['key' => 'social_red_packet_sent_10', 'category' => 'social', 'name' => '十次发包', 'icon' => '🧧', 'description' => '累计发送 10 次红包', 'metric' => 'red_packets_sent', 'threshold' => 10, 'sort' => 411],
['key' => 'social_red_packet_sent_50', 'category' => 'social', 'name' => '红包常客', 'icon' => '🎁', 'description' => '累计发送 50 次红包', 'metric' => 'red_packets_sent', 'threshold' => 50, 'sort' => 412],
['key' => 'social_red_packet_sent_100', 'category' => 'social', 'name' => '百包散财', 'icon' => '🏮', 'description' => '累计发送 100 次红包', 'metric' => 'red_packets_sent', 'threshold' => 100, 'sort' => 413],
['key' => 'social_red_packet_claimed_10', 'category' => 'social', 'name' => '十次手气', 'icon' => '🙌', 'description' => '累计领取 10 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 10, 'sort' => 421],
['key' => 'social_red_packet_claimed_50', 'category' => 'social', 'name' => '抢包熟手', 'icon' => '🫴', 'description' => '累计领取 50 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 50, 'sort' => 422],
['key' => 'social_red_packet_claimed_100', 'category' => 'social', 'name' => '百包入手', 'icon' => '🧧', 'description' => '累计领取 100 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 100, 'sort' => 423],
['key' => 'social_intimacy_5000', 'category' => 'social', 'name' => '亲密升温', 'icon' => '💞', 'description' => '婚姻亲密度达到 5000', 'metric' => 'marriage_intimacy', 'threshold' => 5000, 'sort' => 441],
['key' => 'social_intimacy_10000', 'category' => 'social', 'name' => '万点亲密', 'icon' => '💕', 'description' => '婚姻亲密度达到 10000', 'metric' => 'marriage_intimacy', 'threshold' => 10000, 'sort' => 442],
['key' => 'social_intimacy_50000', 'category' => 'social', 'name' => '情深五万', 'icon' => '💖', 'description' => '婚姻亲密度达到 50000', 'metric' => 'marriage_intimacy', 'threshold' => 50000, 'sort' => 443],
['key' => 'social_gift_sent_10', 'category' => 'social', 'name' => '十礼相赠', 'icon' => '🎁', 'description' => '累计送出 10 次礼物', 'metric' => 'gifts_sent', 'threshold' => 10, 'sort' => 451],
['key' => 'social_gift_sent_50', 'category' => 'social', 'name' => '赠礼熟手', 'icon' => '🎀', 'description' => '累计送出 50 次礼物', 'metric' => 'gifts_sent', 'threshold' => 50, 'sort' => 452],
['key' => 'social_gift_sent_100', 'category' => 'social', 'name' => '百礼往来', 'icon' => '💝', 'description' => '累计送出 100 次礼物', 'metric' => 'gifts_sent', 'threshold' => 100, 'sort' => 453],
['key' => 'social_gift_received_10', 'category' => 'social', 'name' => '十礼入怀', 'icon' => '💐', 'description' => '累计收到 10 次礼物', 'metric' => 'gifts_received', 'threshold' => 10, 'sort' => 461],
['key' => 'social_gift_received_50', 'category' => 'social', 'name' => '人气渐盛', 'icon' => '🌹', 'description' => '累计收到 50 次礼物', 'metric' => 'gifts_received', 'threshold' => 50, 'sort' => 462],
['key' => 'social_gift_received_100', 'category' => 'social', 'name' => '百礼人气', 'icon' => '🌷', 'description' => '累计收到 100 次礼物', 'metric' => 'gifts_received', 'threshold' => 100, 'sort' => 463],
['key' => 'duty_3_positions', 'category' => 'duty', 'name' => '多职历练', 'icon' => '🎖️', 'description' => '累计获得 3 次职务任命', 'metric' => 'positions', 'threshold' => 3, 'sort' => 511],
['key' => 'duty_10_positions', 'category' => 'duty', 'name' => '十任履历', 'icon' => '📌', 'description' => '累计获得 10 次职务任命', 'metric' => 'positions', 'threshold' => 10, 'sort' => 512],
['key' => 'duty_300_minutes', 'category' => 'duty', 'name' => '勤务五小时', 'icon' => '⏱️', 'description' => '累计值班 300 分钟', 'metric' => 'duty_minutes', 'threshold' => 300, 'sort' => 521],
['key' => 'duty_600_minutes', 'category' => 'duty', 'name' => '勤务十小时', 'icon' => '🕰️', 'description' => '累计值班 600 分钟', 'metric' => 'duty_minutes', 'threshold' => 600, 'sort' => 522],
['key' => 'duty_3000_minutes', 'category' => 'duty', 'name' => '值班老手', 'icon' => '📋', 'description' => '累计值班 3000 分钟', 'metric' => 'duty_minutes', 'threshold' => 3000, 'sort' => 523],
['key' => 'duty_10000_minutes', 'category' => 'duty', 'name' => '万分钟勤务', 'icon' => '🏢', 'description' => '累计值班 10000 分钟', 'metric' => 'duty_minutes', 'threshold' => 10000, 'sort' => 524],
['key' => 'duty_10_admin_actions', 'category' => 'duty', 'name' => '十次管理', 'icon' => '🛡️', 'description' => '累计执行 10 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 10, 'sort' => 531],
['key' => 'duty_100_admin_actions', 'category' => 'duty', 'name' => '百次管理', 'icon' => '⚖️', 'description' => '累计执行 100 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 100, 'sort' => 532],
['key' => 'duty_1000_admin_actions', 'category' => 'duty', 'name' => '千次执勤', 'icon' => '🏛️', 'description' => '累计执行 1000 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1000, 'sort' => 533],
];
}
/**
* 返回成就分类标题。
*
* @return array<string, string>
*/
public static function categories(): array
{
return [
'chat' => '聊天',
'sign_in' => '签到',
'growth' => '成长',
'game' => '游戏',
'social' => '社交',
'duty' => '职务',
];
}
/**
* 根据 key 获取单个成就定义。
*
* @return array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}|null
*/
public static function find(string $key): ?array
{
return self::definitions()[$key] ?? null;
}
}
+26 -14
View File
@@ -1,5 +1,10 @@
<?php <?php
/**
* 文件功能:Laravel 应用启动配置。
* 负责注册路由、中间件别名、代理信任规则与全局异常响应格式。
*/
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -38,8 +43,12 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->redirectGuestsTo('/'); $middleware->redirectGuestsTo('/');
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$isChatAjaxRequest = static function (Request $request): bool { $isJsonSessionRequest = static function (Request $request): bool {
return $request->expectsJson() && $request->is( if ($request->expectsJson() || $request->ajax()) {
return true;
}
return $request->is(
'room/*/send', 'room/*/send',
'room/*/heartbeat', 'room/*/heartbeat',
'room/*/leave', 'room/*/leave',
@@ -51,25 +60,28 @@ return Application::configure(basePath: dirname(__DIR__))
); );
}; };
// 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向 $expiredSessionResponse = static function () {
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) {
if ($isChatAjaxRequest($request)) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
'message' => '页面已过期,请刷新后重试。', 'code' => 'SESSION_EXPIRED',
'message' => '登录状态已失效,请刷新页面后重新登录。',
'reload' => true,
'login_url' => route('home'),
], 419); ], 419);
};
// CSRF token 失效通常意味着页面还停留在旧会话里;JSON 请求统一返回业务提示,避免泄露框架异常堆栈。
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
if ($isJsonSessionRequest($request)) {
return $expiredSessionResponse();
} }
}); });
// Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException // Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException
// 这里补一层兜底,确保聊天接口始终返回稳定 JSON,而不是默认 HTML 错误页 // 这里补一层兜底,确保接口始终返回稳定 JSON,而不是默认异常结构
$exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isChatAjaxRequest) { $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
if ($e->getStatusCode() === 419 && $isChatAjaxRequest($request)) { if ($e->getStatusCode() === 419 && $isJsonSessionRequest($request)) {
return response()->json([ return $expiredSessionResponse();
'status' => 'error',
'message' => '页面已过期,请刷新后重试。',
], 419);
} }
}); });
})->create(); })->create();
+32 -1
View File
@@ -1,5 +1,35 @@
<?php <?php
$normalizeReverbAllowedOrigins = static function (?string $rawOrigins): array {
if ($rawOrigins === null || trim($rawOrigins) === '') {
return ['*'];
}
$normalizedOrigins = [];
foreach (explode(',', $rawOrigins) as $origin) {
$candidate = trim($origin);
if ($candidate === '') {
continue;
}
if ($candidate === '*') {
return ['*'];
}
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
$host = parse_url('http://'.$candidate, PHP_URL_HOST);
}
$normalizedOrigins[] = is_string($host) && $host !== '' ? $host : $candidate;
}
return array_values(array_unique($normalizedOrigins));
};
return [ return [
/* /*
@@ -82,7 +112,8 @@ return [
'scheme' => env('REVERB_SCHEME', 'https'), 'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https', 'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
], ],
'allowed_origins' => ['*'], // Reverb 内部按 Origin 的主机名比对,这里统一转成 host,避免把完整 URL 写进 .env 后被误拒绝。
'allowed_origins' => $normalizeReverbAllowedOrigins(env('REVERB_ALLOWED_ORIGIN')),
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
@@ -0,0 +1,36 @@
<?php
/**
* 文件功能:用户成就测试工厂。
*
* 为成就相关 Feature Test 快速生成解锁或进度记录。
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\UserAchievement;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 类功能:生成用户成就模型的默认测试数据。
*
* @extends Factory<UserAchievement>
*/
class UserAchievementFactory extends Factory
{
/**
* 定义模型的默认测试状态。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'achievement_key' => 'chat_first_message',
'progress_value' => 1,
'metadata' => ['threshold' => 1],
];
}
}
@@ -0,0 +1,37 @@
<?php
/**
* 文件功能:用户成就进度测试工厂。
*
* 为成就进度相关测试生成默认记录。
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\UserAchievementProgress;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 类功能:生成用户成就进度模型的默认测试数据。
*
* @extends Factory<UserAchievementProgress>
*/
class UserAchievementProgressFactory extends Factory
{
/**
* 定义模型的默认测试状态。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'achievement_key' => 'chat_first_message',
'progress_value' => 0,
'threshold_value' => 1,
'last_scanned_at' => now(),
];
}
}
@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:创建猜成语题库表
*
* 存储成语题目及答案,管理员可在后台增删改。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idioms 表。
*/
public function up(): void
{
Schema::create('idioms', function (Blueprint $table) {
$table->id();
$table->string('answer', 50)->comment('成语答案');
$table->string('hint', 255)->comment('谜语线索提示');
$table->boolean('is_active')->default(true)->comment('是否启用');
$table->unsignedSmallInteger('sort')->default(0)->comment('排序');
$table->timestamps();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idioms');
}
};
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:创建猜成语游戏回合表
*
* 每次出题对应一个回合,记录答题状态、奖励和获胜者。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idiom_game_rounds 表。
*/
public function up(): void
{
Schema::create('idiom_game_rounds', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('room_id')->comment('游戏所在房间 ID');
$table->unsignedBigInteger('idiom_id')->comment('当前题目 ID');
$table->string('status', 20)->default('pending')->comment('状态:pending/active/answered/ended');
$table->integer('reward_gold')->default(0)->comment('答对奖励金币');
$table->integer('reward_exp')->default(0)->comment('答对奖励经验');
$table->unsignedBigInteger('winner_id')->nullable()->comment('答对用户 ID');
$table->string('winner_username', 50)->nullable()->comment('答对用户名');
$table->timestamp('started_at')->nullable()->comment('开始答题时间');
$table->timestamp('ended_at')->nullable()->comment('结束答题时间');
$table->timestamps();
$table->foreign('idiom_id')->references('id')->on('idioms')->onDelete('cascade');
$table->index('status');
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idiom_game_rounds');
}
};
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:将猜成语数据结构升级为猜谜活动通用结构
*
* 为题库增加题型字段,为回合增加 quiz_type 与复合索引,
* 兼容既有“猜成语”数据并为脑筋急转弯题型预留能力。
*/
use App\Models\Riddle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:执行表结构升级并补齐历史数据默认值。
*/
public function up(): void
{
Schema::table('idioms', function (Blueprint $table): void {
if (! Schema::hasColumn('idioms', 'type')) {
$table->string('type', 30)->default(Riddle::TYPE_IDIOM)->after('id')->comment('题型:idiom/brain_teaser');
}
});
// 历史成语题默认归类到 idiom,保证旧数据无需人工修复。
DB::table('idioms')
->whereNull('type')
->orWhere('type', '')
->update(['type' => Riddle::TYPE_IDIOM]);
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
if (! Schema::hasColumn('idiom_game_rounds', 'quiz_type')) {
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM)->after('idiom_id')->comment('回合题型:idiom/brain_teaser');
}
});
// 历史回合默认按成语题处理,确保旧记录仍可正常展示与过期结算。
DB::table('idiom_game_rounds')
->whereNull('quiz_type')
->orWhere('quiz_type', '')
->update(['quiz_type' => Riddle::TYPE_IDIOM]);
Schema::table('idioms', function (Blueprint $table): void {
$table->index(['type', 'is_active'], 'idioms_type_is_active_index');
});
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->index(['room_id', 'quiz_type', 'status'], 'idiom_rounds_room_type_status_index');
$table->index(['room_id', 'quiz_type', 'id'], 'idiom_rounds_room_type_id_index');
});
}
/**
* 方法功能:回滚猜谜活动通用结构升级。
*/
public function down(): void
{
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->dropIndex('idiom_rounds_room_type_status_index');
$table->dropIndex('idiom_rounds_room_type_id_index');
$table->dropColumn('quiz_type');
});
Schema::table('idioms', function (Blueprint $table): void {
$table->dropIndex('idioms_type_is_active_index');
$table->dropColumn('type');
});
}
};
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:为公共回合型游戏补充 room_id 字段
*
* 让百家乐、赛马、彩票和神秘箱子可以按房间独立开局、查询与广播。
*/
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('baccarat_rounds', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('horse_races', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('horse_races', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
}
};
@@ -0,0 +1,122 @@
<?php
/**
* 文件功能:创建聊天室座驾独立数据表。
*
* 座驾定义和用户座驾购买记录独立于商店模块,支持后台单独配置价格、使用天数和欢迎语。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:创建 rides user_ride_purchases 并预置默认座驾。
*/
public function up(): void
{
Schema::create('rides', function (Blueprint $table) {
$table->id();
$table->string('name', 100)->comment('座驾名称');
$table->string('slug', 100)->unique()->comment('座驾唯一标识,格式 ride_key');
$table->string('effect_key', 50)->unique()->comment('全屏特效 key');
$table->string('icon', 20)->default('🚘')->comment('座驾图标');
$table->text('description')->nullable()->comment('座驾说明');
$table->unsignedInteger('price')->default(0)->comment('购买价格');
$table->unsignedInteger('duration_days')->default(7)->comment('使用天数');
$table->string('welcome_message', 255)->nullable()->comment('入场欢迎语模板');
$table->unsignedInteger('sort_order')->default(0)->comment('排序权重');
$table->boolean('is_active')->default(true)->comment('是否上架');
$table->timestamps();
});
Schema::create('user_ride_purchases', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('ride_id')->constrained('rides')->cascadeOnDelete();
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active')->comment('座驾状态');
$table->unsignedInteger('price_paid')->default(0)->comment('实际支付金币');
$table->timestamp('expires_at')->nullable()->comment('到期时间');
$table->timestamp('used_at')->nullable()->comment('首次使用时间');
$table->timestamps();
$table->index(['user_id', 'status', 'expires_at']);
});
$this->seedDefaultRides();
}
/**
* 方法功能:删除座驾独立数据表。
*/
public function down(): void
{
Schema::dropIfExists('user_ride_purchases');
Schema::dropIfExists('rides');
}
/**
* 方法功能:写入当前默认四个全屏座驾。
*/
private function seedDefaultRides(): void
{
$now = now();
$rides = [
[
'name' => '歼-35隐身战机',
'slug' => 'ride_j35',
'effect_key' => 'j35',
'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。',
'icon' => '🛩️',
'price' => 18888,
'duration_days' => 7,
'sort_order' => 80,
'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!',
],
[
'name' => '99A主战坦克',
'slug' => 'ride_99a',
'effect_key' => '99a',
'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。',
'icon' => '🛡️',
'price' => 18888,
'duration_days' => 7,
'sort_order' => 81,
'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!',
],
[
'name' => '东风-5C战略导弹',
'slug' => 'ride_df5c',
'effect_key' => 'df5c',
'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。',
'icon' => '🚀',
'price' => 28888,
'duration_days' => 7,
'sort_order' => 82,
'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!',
],
[
'name' => '福建舰航母',
'slug' => 'ride_fujian',
'effect_key' => 'fujian',
'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。',
'icon' => '⚓',
'price' => 28888,
'duration_days' => 7,
'sort_order' => 83,
'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!',
],
];
foreach ($rides as $ride) {
DB::table('rides')->insert($ride + [
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
};
@@ -0,0 +1,52 @@
<?php
/**
* 文件功能:清理旧版商店座驾数据。
*
* 如果环境曾跑过“座驾复用商店”的旧迁移,本迁移会移除商店里的座驾字段和残留记录。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:删除 shop_items 中旧座驾记录并移除欢迎语字段。
*/
public function up(): void
{
DB::table('shop_items')->where('slug', 'like', 'ride_%')->delete();
if (DB::getDriverName() === 'mysql' && $this->shopItemTypeContainsRide()) {
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
}
if (Schema::hasColumn('shop_items', 'welcome_message')) {
Schema::table('shop_items', function (Blueprint $table) {
$table->dropColumn('welcome_message');
});
}
}
/**
* 方法功能:清理迁移不恢复旧商店座驾结构。
*/
public function down(): void
{
//
}
/**
* 方法功能:判断当前 shop_items.type 枚举是否包含旧座驾类型。
*/
private function shopItemTypeContainsRide(): bool
{
$column = DB::selectOne("SHOW COLUMNS FROM `shop_items` LIKE 'type'");
return $column && str_contains((string) $column->Type, "'ride'");
}
};
@@ -0,0 +1,31 @@
<?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('messages', function (Blueprint $table) {
$table->string('retention_type', 30)
->default('user_chat')
->index()
->comment('消息保留类型:user_chat/system_notice/game_notice/ephemeral_notice');
});
}
/**
* 回滚迁移:移除聊天消息保留类型字段。
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->dropColumn('retention_type');
});
}
};
@@ -0,0 +1,36 @@
<?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::create('user_achievements', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->string('achievement_key', 80)->comment('成就唯一标识');
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度快照');
$table->timestamp('achieved_at')->nullable()->index()->comment('达成时间');
$table->timestamp('notified_at')->nullable()->comment('通知时间');
$table->json('metadata')->nullable()->comment('成就解锁附加信息');
$table->timestamps();
$table->unique(['user_id', 'achievement_key']);
$table->index(['achievement_key', 'achieved_at']);
});
}
/**
* 回滚迁移:删除用户成就解锁记录表。
*/
public function down(): void
{
Schema::dropIfExists('user_achievements');
}
};
@@ -0,0 +1,34 @@
<?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::create('user_achievement_progress', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->string('achievement_key', 80)->comment('成就唯一标识');
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度值');
$table->unsignedBigInteger('threshold_value')->default(0)->comment('达成门槛快照');
$table->timestamp('last_scanned_at')->nullable()->index()->comment('最近扫描时间');
$table->timestamps();
$table->unique(['user_id', 'achievement_key']);
});
}
/**
* 回滚迁移:删除用户成就进度快照表。
*/
public function down(): void
{
Schema::dropIfExists('user_achievement_progress');
}
};
+39
View File
@@ -32,6 +32,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统每隔一段时间自动开一局,玩家在倒计时内押注大/小/豹子,骰子结果决定胜负。', 'description' => '系统每隔一段时间自动开一局,玩家在倒计时内押注大/小/豹子,骰子结果决定胜负。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single', // 参与房间模式
'room_ids' => [1], // 参与房间列表
'interval_minutes' => 2, // 多少分钟开一局 'interval_minutes' => 2, // 多少分钟开一局
'bet_window_seconds' => 60, // 每局押注窗口(秒) 'bet_window_seconds' => 60, // 每局押注窗口(秒)
'min_bet' => 100, // 最低押注金币 'min_bet' => 100, // 最低押注金币
@@ -51,6 +53,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币转动老虎机,三列图案匹配可获得不同倍率奖励,三个7大奖全服广播。', 'description' => '消耗金币转动老虎机,三列图案匹配可获得不同倍率奖励,三个7大奖全服广播。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'cost_per_spin' => 100, // 每次旋转消耗 'cost_per_spin' => 100, // 每次旋转消耗
'house_edge_percent' => 15, // 庄家边际(% 'house_edge_percent' => 15, // 庄家边际(%
'daily_limit' => 100, // 每日最多转动次数(0=不限) 'daily_limit' => 100, // 每日最多转动次数(0=不限)
@@ -70,6 +74,8 @@ class GameConfigSeeder extends Seeder
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。', 'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'auto_drop_enabled' => false, // 是否自动定时投放 'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时) 'auto_interval_hours' => 2, // 自动投放间隔(小时)
'claim_window_seconds' => 60, // 领取窗口(秒) 'claim_window_seconds' => 60, // 领取窗口(秒)
@@ -91,6 +97,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统定期举办赛马,用户在倒计时内下注,按注池赔率结算,跑马过程 WebSocket 实时播报。', 'description' => '系统定期举办赛马,用户在倒计时内下注,按注池赔率结算,跑马过程 WebSocket 实时播报。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 30, // 多少分钟一场 'interval_minutes' => 30, // 多少分钟一场
'bet_window_seconds' => 90, // 押注窗口(秒) 'bet_window_seconds' => 90, // 押注窗口(秒)
'race_duration' => 30, // 跑马动画时长(秒) 'race_duration' => 30, // 跑马动画时长(秒)
@@ -110,6 +118,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一次免费占卜,系统生成玄学签文并赋予当日加成效果(幸运/倒霉)。额外占卜消耗金币。', 'description' => '每日一次免费占卜,系统生成玄学签文并赋予当日加成效果(幸运/倒霉)。额外占卜消耗金币。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'free_count_per_day' => 1, // 每日免费次数 'free_count_per_day' => 1, // 每日免费次数
'extra_cost' => 500, // 额外次数消耗金币 'extra_cost' => 500, // 额外次数消耗金币
'buff_duration_hours' => 24, // 加成效果持续时间 'buff_duration_hours' => 24, // 加成效果持续时间
@@ -128,6 +138,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。', 'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5, // 每次抛竿消耗金币 'fishing_cost' => 5, // 每次抛竿消耗金币
'fishing_wait_min' => 8, // 浮漂等待最短秒数 'fishing_wait_min' => 8, // 浮漂等待最短秒数
'fishing_wait_max' => 15, // 浮漂等待最长秒数 'fishing_wait_max' => 15, // 浮漂等待最长秒数
@@ -143,6 +155,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。', 'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。',
'enabled' => false, 'enabled' => false,
'params' => [ 'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
// ── 开奖时间 ── // ── 开奖时间 ──
'draw_hour' => 20, // 每天几点开奖(24小时制) 'draw_hour' => 20, // 每天几点开奖(24小时制)
'draw_minute' => 0, // 几分开奖 'draw_minute' => 0, // 几分开奖
@@ -169,6 +183,31 @@ class GameConfigSeeder extends Seeder
'super_issue_inject' => 20000, // 超级期系统注入金额上限 'super_issue_inject' => 20000, // 超级期系统注入金额上限
], ],
], ],
// ─── 五子棋 ───────────────────────────────────────────────
[
'game_key' => 'gomoku',
'name' => '五子棋',
'icon' => '♟️',
'description' => 'PvP 对战/人机对战,房间内随时发起邀请,超时或认输均自动结算。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'pvp_reward' => 200, // PvP 胜利奖励金币
'pvp_invite_timeout' => 30, // PvP 邀请超时(秒)
'pvp_move_timeout' => 45, // 每步落子超时(秒)
'pvp_ready_timeout' => 20, // 对局准备超时(秒)
'pve_fee_level_1' => 50, // AI简单 入场费
'pve_reward_level_1' => 100, // AI简单 胜利奖励
'pve_fee_level_2' => 100, // AI普通 入场费
'pve_reward_level_2' => 200, // AI普通 胜利奖励
'pve_fee_level_3' => 200, // AI困难 入场费
'pve_reward_level_3' => 400, // AI困难 胜利奖励
'pve_fee_level_4' => 500, // AI专家 入场费
'pve_reward_level_4' => 1000, // AI专家 胜利奖励
],
],
]; ];
foreach ($games as $game) { foreach ($games as $game) {
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* 文件功能:猜谜活动旧 Seeder 兼容入口
*
* 兼容仍然使用 `IdiomSeeder` 名称的旧命令与旧文档,
* 实际执行逻辑委托给新的 RiddleSeeder。
*/
namespace Database\Seeders;
/**
* 类功能:兼容旧的 IdiomSeeder 调用入口。
*/
class IdiomSeeder extends RiddleSeeder
{
/**
* 方法功能:复用新的猜谜活动题库填充逻辑。
*/
public function run(): void
{
parent::run();
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
/**
* 文件功能:猜谜活动题库填充器
*
* 初始化猜谜活动配置、成语题库与脑筋急转弯题库数据。
* 使用 updateOrCreate 确保重复执行不影响已有数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Database\Seeders;
use App\Models\GameConfig;
use App\Models\Riddle;
use Illuminate\Database\Seeder;
/**
* 类功能:初始化猜谜活动配置、成语题库与脑筋急转弯题库。
*/
class RiddleSeeder extends Seeder
{
/**
* 填充猜谜活动配置与题库。
*/
public function run(): void
{
// ── 游戏配置(已存在则更新为统一猜谜活动结构) ──
GameConfig::updateOrCreate(
['game_key' => 'idiom'],
[
'name' => '猜谜活动',
'icon' => '🧩',
'description' => '管理员手动出题或系统定时自动出题,支持成语题与脑筋急转弯题,第一个答对的用户获得金币和经验奖励。',
'enabled' => false,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [1],
'type_configs' => [
Riddle::TYPE_IDIOM => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
Riddle::TYPE_BRAIN_TEASER => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
],
],
],
);
// ── 题库数据 ──
$idioms = [
['answer' => '画蛇添足', 'hint' => '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语'],
['answer' => '守株待兔', 'hint' => '🧩 农夫不干活,天天蹲树桩旁等天上掉馅饼。猜一成语'],
['answer' => '掩耳盗铃', 'hint' => '🧩 小偷以为捂住自己耳朵,别人就听不见铃铛响了。猜一成语'],
['answer' => '亡羊补牢', 'hint' => '🧩 羊圈破了个洞,羊跑了几只才想起修。猜一成语'],
['answer' => '刻舟求剑', 'hint' => '🧩 船上做了个记号就能在江里找回剑?猜一成语'],
['answer' => '叶公好龙', 'hint' => '🧩 家里到处画龙雕龙,真龙来了却吓得屁滚尿流。猜一成语'],
['answer' => '狐假虎威', 'hint' => '🧩 狐狸走在老虎前面,小动物们到底怕谁?猜一成语'],
['answer' => '井底之蛙', 'hint' => '🧩 住在井里,却以为天空只有井口那么大。猜一成语'],
['answer' => '对牛弹琴', 'hint' => '🧩 弹了一首好曲子,听众却在低头吃草。猜一成语'],
['answer' => '杯弓蛇影', 'hint' => '🧩 酒杯里有个弯弯曲曲的东西在动,吓得大病一场。猜一成语'],
['answer' => '鹤立鸡群', 'hint' => '🧩 一只长腿白鸟混进了院子里的小黄鸡中。猜一成语'],
['answer' => '画龙点睛', 'hint' => '🧩 最后两笔点上后,墙上的龙竟然飞走了。猜一成语'],
['answer' => '鸡飞蛋打', 'hint' => '🧩 偷鸡不成,竹篮打水一场空。猜一成语'],
['answer' => '马到成功', 'hint' => '🧩 战旗一挥,马蹄刚踏出去就赢了。猜一成语'],
['answer' => '虎头蛇尾', 'hint' => '🧩 开头气势如虹,结尾却草草收场。猜一成语'],
['answer' => '龙飞凤舞', 'hint' => '🧩 王羲之喝醉了,笔下的字好像要飞起来。猜一成语'],
['answer' => '鸡犬不宁', 'hint' => '🧩 闹得天翻地覆,连院子里的小动物都不得安生。猜一成语'],
['answer' => '狼吞虎咽', 'hint' => '🧩 饿了三天的壮汉看到一碗面。猜一成语'],
['answer' => '鱼目混珠', 'hint' => '🧩 地摊上有人拿玻璃球当夜明珠卖。猜一成语'],
['answer' => '鼠目寸光', 'hint' => '🧩 只看得到眼前一寸的路,走远就迷路。猜一成语'],
['answer' => '九牛一毛', 'hint' => '🧩 亿万富翁丢了一分钱,连弯腰捡都懒得捡。猜一成语'],
['answer' => '如鱼得水', 'hint' => '🧩 刘备说:有了诸葛亮,就像什么回到了什么里?猜一成语'],
['answer' => '鸟语花香', 'hint' => '🧩 春天来了,你能听到什么、闻到什么?猜一成语'],
['answer' => '风花雪月', 'hint' => '🧩 才子佳人写的诗,看起来很美,其实没什么实际内容。猜一成语'],
['answer' => '山清水秀', 'hint' => '🧩 桂林漓江边上,你能看到什么颜色?猜一成语'],
['answer' => '水落石出', 'hint' => '🧩 水位下降后,河床下的东西藏不住了。猜一成语'],
['answer' => '火中取栗', 'hint' => '🧩 猫爪子被烫伤了,猴子却在旁边偷笑吃栗子。猜一成语'],
['answer' => '石破天惊', 'hint' => '🧩 一块石头裂开,伴随着一声巨响,所有人都惊呆了。猜一成语'],
['answer' => '翻天覆地', 'hint' => '🧩 孙悟空大闹天宫后,凌霄宝殿变成了什么样?猜一成语'],
['answer' => '开天辟地', 'hint' => '🧩 盘古拿着斧头,对着混沌用力一劈。猜一成语'],
['answer' => '惊天动地', 'hint' => '🧩 汶川大地震那天,连天上的云都在颤抖。猜一成语'],
['answer' => '花好月圆', 'hint' => '🧩 婚礼请柬上最常见的四个字祝福。猜一成语'],
['answer' => '冰清玉洁', 'hint' => '🧩 她的品格像冬天的什么和深山里的什么?猜一成语'],
['answer' => '海阔天空', 'hint' => '🧩 走出小县城,来到大都市,才发现世界有多大。猜一成语'],
['answer' => '雪中送炭', 'hint' => '🧩 大冬天你最需要什么?有人偏偏就送来了什么。猜一成语'],
['answer' => '锦上添花', 'hint' => '🧩 已经够漂亮了,还要再点缀一下。猜一成语'],
['answer' => '落井下石', 'hint' => '🧩 有人掉坑里了,你不救也就算了,还往下面扔砖头。猜一成语'],
['answer' => '纸上谈兵', 'hint' => '🧩 赵括说起兵法头头是道,上了战场却一败涂地。猜一成语'],
['answer' => '胸有成竹', 'hint' => '🧩 画家文同还没下笔,心里已经有了完整的竹子。猜一成语'],
['answer' => '一帆风顺', 'hint' => '🧩 出海前最想听到的一句祝福。猜一成语'],
['answer' => '水到渠成', 'hint' => '🧩 不用刻意挖渠,水自然会找到它的路。猜一成语'],
['answer' => '百发百中', 'hint' => '🧩 养由基站在百步之外射柳叶,箭无虚发。猜一成语'],
['answer' => '一鸣惊人', 'hint' => '🧩 齐威王三年不上朝不理政,一出手就震惊了各国。猜一成语'],
['answer' => '对答如流', 'hint' => '🧩 老师提问,他不用思考就说出答案,像江水一样不停。猜一成语'],
['answer' => '顺手牵羊', 'hint' => '🧩 路过别人家门口,看到一只羊没人看管……猜一成语'],
['answer' => '勇往直前', 'hint' => '🧩 前面是刀山火海,他眼睛都不眨一下继续走。猜一成语'],
['answer' => '百折不挠', 'hint' => '🧩 被摔倒一百次,第一百零一次依然站起来。猜一成语'],
['answer' => '持之以恒', 'hint' => '🧩 水滴不断地滴在石头上,千年后石头被滴穿了。猜一成语'],
['answer' => '知己知彼', 'hint' => '🧩 孙子兵法说:了解自己又了解对方,百战不殆。猜一成语'],
['answer' => '四面楚歌', 'hint' => '🧩 项羽被包围在垓下,四面八方都传来熟悉的歌声。猜一成语'],
['answer' => '草木皆兵', 'hint' => '🧩 淝水之战中,苻坚看到山上的草和树,都以为是敌军。猜一成语'],
['answer' => '一箭双雕', 'hint' => '🧩 长孙晟一箭射出去,两只大鸟应声落地。猜一成语'],
['answer' => '背水一战', 'hint' => '🧩 韩信把军队放在河边列阵,断了所有人的退路。猜一成语'],
['answer' => '声东击西', 'hint' => '🧩 明明要打左边,却装作全力进攻右边。猜一成语'],
['answer' => '调虎离山', 'hint' => '🧩 想占老虎的老窝,得先把老虎引出去。猜一成语'],
['answer' => '空城计', 'hint' => '🧩 诸葛亮大开城门,在城楼上弹琴,敌军反而不敢进城。猜一成语'],
['answer' => '缓兵之计', 'hint' => '🧩 打不过怎么办?先假装谈判争取时间。猜一成语'],
['answer' => '卧薪尝胆', 'hint' => '🧩 越王勾践每天睡在柴堆上,还要舔一口苦胆。猜一成语'],
['answer' => '三顾茅庐', 'hint' => '🧩 刘备为了请一个人出山,大冬天跑了三趟。猜一成语'],
['answer' => '望梅止渴', 'hint' => '🧩 曹操说前面有片梅林,士兵们嘴里都开始流口水了。猜一成语'],
['answer' => '七步成诗', 'hint' => '🧩 曹植被亲哥哥逼着在很短的时间里写诗保命。猜一成语'],
['answer' => '才高八斗', 'hint' => '🧩 谢灵运说:天下才华共一石,曹植独占八斗。猜一成语'],
['answer' => '入木三分', 'hint' => '🧩 王羲之在木板上写字,墨迹渗入木头三分深。猜一成语'],
['answer' => '一字千金', 'hint' => '🧩 吕不韦悬赏:谁能给《吕氏春秋》改动一个字,赏千金。猜一成语'],
['answer' => '一诺千金', 'hint' => '🧩 季布的一句话,比黄金千两还贵重。猜一成语'],
['answer' => '半途而废', 'hint' => '🧩 走到半山腰觉得累了,就转身下山了。猜一成语'],
['answer' => '实事求是', 'hint' => '🧩 不夸大不缩小,是什么就是什么。猜一成语'],
['answer' => '量力而行', 'hint' => '🧩 蚂蚁想搬走大象?先掂量掂量自己有几斤几两。猜一成语'],
['answer' => '自相矛盾', 'hint' => '🧩 楚国人夸自己的矛能刺穿任何盾,又夸自己的盾什么都刺不穿。猜一成语'],
['answer' => '买椟还珠', 'hint' => '🧩 花大价钱买了精美盒子,却把里面的珍珠还给了老板。猜一成语'],
['answer' => '杞人忧天', 'hint' => '🧩 有个怪人天天担心天会塌下来,地会陷下去。猜一成语'],
['answer' => '画饼充饥', 'hint' => '🧩 饿了看着墙上画的大饼,假装自己吃饱了。猜一成语'],
['answer' => '空中楼阁', 'hint' => '🧩 没有地基,没有支柱,一座房子浮在云端。猜一成语'],
['answer' => '异想天开', 'hint' => '🧩 有人想在天上种田,海里摘星星。猜一成语'],
['answer' => '千方百计', 'hint' => '🧩 为了达到目的,把所有能想到的办法都用上了。猜一成语'],
['answer' => '不计其数', 'hint' => '🧩 海边的沙子有多少?天上的星星有多少?猜一成语'],
['answer' => '成千上万', 'hint' => '🧩 体育场里坐满了人,放眼望去黑压压一片。猜一成语'],
['answer' => '独一无二', 'hint' => '🧩 世界上没有第二个一模一样的你。猜一成语'],
['answer' => '举世闻名', 'hint' => '🧩 地球上有几个人,就有几个人知道他。猜一成语'],
['answer' => '名不虚传', 'hint' => '🧩 听说很好吃,亲自尝了一口,果然名不虚传。不对,我说的就是~猜一成语'],
['answer' => '名副其实', 'hint' => '🧩 大家都叫他「神算子」,他算卦确实从没错过。猜一成语'],
['answer' => '引人入胜', 'hint' => '🧩 这本书太好看了,一翻开就停不下来,像被吸进去了一样。猜一成语'],
['answer' => '身临其境', 'hint' => '🧩 VR眼镜里的世界太真实了,好像自己真的在里面。猜一成语'],
['answer' => '迫不及待', 'hint' => '🧩 快递到了,鞋都没穿就跑下楼去拿。猜一成语'],
['answer' => '争先恐后', 'hint' => '🧩 超市大减价,大门一开所有人都在往前冲。猜一成语'],
['answer' => '如履薄冰', 'hint' => '🧩 每一步都小心翼翼,生怕脚下突然裂开。猜一成语'],
['answer' => '小心翼翼', 'hint' => '🧩 手里捧着一个装满水的气球,大气都不敢喘。猜一成语'],
['answer' => '心花怒放', 'hint' => '🧩 听到被录取的消息,心里像有千万朵花同时绽放。猜一成语'],
['answer' => '欢天喜地', 'hint' => '🧩 过年了,小孩拿到压岁钱在大街上又蹦又跳。猜一成语'],
['answer' => '眉开眼笑', 'hint' => '🧩 嘴角上扬,眼角弯弯,整张脸都在表达开心。猜一成语'],
['answer' => '手舞足蹈', 'hint' => '🧩 听到最喜欢的歌,身体不由自主地跟着节奏动起来。猜一成语'],
['answer' => '兴高采烈', 'hint' => '🧩 中彩票后,他整个人像打了鸡血一样。猜一成语'],
['answer' => '得意忘形', 'hint' => '🧩 考了第一名就开始翘尾巴,连走路姿势都不一样了。猜一成语'],
['answer' => '怒气冲天', 'hint' => '🧩 他气得头顶冒烟,火焰快要烧到天花板了。猜一成语'],
['answer' => '火冒三丈', 'hint' => '🧩 听到这个消息,他脑袋上的火苗蹿得比房子还高。猜一成语'],
['answer' => '心急如焚', 'hint' => '🧩 等结果的那几分钟,心脏像放在火上烤。猜一成语'],
['answer' => '愁眉苦脸', 'hint' => '🧩 眉头拧成麻花,嘴角向下弯,整张脸写满了不开心。猜一成语'],
['answer' => '泪如雨下', 'hint' => '🧩 他哭得比外面的倾盆大雨还要猛。猜一成语'],
['answer' => '目瞪口呆', 'hint' => '🧩 看到 UFO 从头顶飞过,他张大了嘴巴一句话也说不出来。猜一成语'],
['answer' => '惊弓之鸟', 'hint' => '🧩 被弓箭射过一次的鸟,听到弓弦声就吓得乱飞。猜一成语'],
['answer' => '千钧一发', 'hint' => '🧩 一万斤的重物吊在一根头发丝上,随时会断。猜一成语'],
['answer' => '愚公移山', 'hint' => '🧩 九十岁老头发誓要搬走门口的两座大山,子子孙孙无穷匮也。猜一成语'],
];
foreach ($idioms as $index => $idiom) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_IDIOM,
'answer' => $idiom['answer'],
],
[
'hint' => $idiom['hint'],
'is_active' => true,
'sort' => $index + 1,
],
);
}
// 新增脑筋急转弯题库,供猜谜活动切换题型时直接使用。
$brainTeasers = [
['answer' => '影子', 'hint' => '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?'],
['answer' => '回声', 'hint' => '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?'],
['answer' => '镜子', 'hint' => '🧠 你哭它也哭,你笑它也笑,但它永远不会先动,是什么?'],
['answer' => '口罩', 'hint' => '🧠 戴在脸上不是面具,遮住口鼻保健康,是什么?'],
['answer' => '手套', 'hint' => '🧠 五个小兄弟住两套房,冬天最爱穿,是什么?'],
['answer' => '袜子', 'hint' => '🧠 一对好朋友,天天躲鞋里,是什么?'],
['answer' => '鞋带', 'hint' => '🧠 两条细长蛇,天天趴鞋上,不打结鞋就跑,是什么?'],
['answer' => '雨伞', 'hint' => '🧠 下雨天开花,晴天就收起,是什么?'],
['answer' => '帽子', 'hint' => '🧠 不长头发却总爱站在头顶,是什么?'],
['answer' => '围巾', 'hint' => '🧠 冬天挂脖子,既不是项链也不是绳子,是什么?'],
['answer' => '口红', 'hint' => '🧠 不是彩笔,却常在嘴上画颜色,是什么?'],
['answer' => '牙刷', 'hint' => '🧠 头上长毛,天天进嘴里干活,是什么?'],
['answer' => '牙膏', 'hint' => '🧠 白白一条小胖虫,挤出来给牙刷帮忙,是什么?'],
['answer' => '肥皂', 'hint' => '🧠 越洗越瘦,越搓泡泡越多,是什么?'],
['answer' => '毛巾', 'hint' => '🧠 洗完脸后最爱找它抱一抱,是什么?'],
['answer' => '梳子', 'hint' => '🧠 一排小牙齿,不吃饭,只理头发,是什么?'],
['answer' => '吹风机', 'hint' => '🧠 会吹热风的小机器,洗完头总请它帮忙,是什么?'],
['answer' => '指甲刀', 'hint' => '🧠 身子很小嘴巴很硬,专门啃手指头,是什么?'],
['answer' => '钥匙', 'hint' => '🧠 个子不大本事大,能把锁头嘴巴打开,是什么?'],
['answer' => '锁', 'hint' => '🧠 一张铁嘴不吃饭,钥匙一来才张口,是什么?'],
['answer' => '门铃', 'hint' => '🧠 客人到门口,不敲门先叫唤,是什么?'],
['answer' => '电梯', 'hint' => '🧠 关上门就上下跑,不是汽车不上路,是什么?'],
['answer' => '楼梯', 'hint' => '🧠 一节一节往上走,不会动却能送人上楼,是什么?'],
['answer' => '窗户', 'hint' => '🧠 墙上开个洞,白天爱看风景,晚上爱看月亮,是什么?'],
['answer' => '窗帘', 'hint' => '🧠 白天拉开,晚上关上,帮房间遮眼睛,是什么?'],
['answer' => '镜框', 'hint' => '🧠 不会照人,却总抱着照片或镜子,是什么?'],
['answer' => '桌子', 'hint' => '🧠 四条腿不会走,肚皮平平能放东西,是什么?'],
['answer' => '椅子', 'hint' => '🧠 有脚不走路,专门让人坐,是什么?'],
['answer' => '沙发', 'hint' => '🧠 胖胖软软客厅王,大家累了都爱躺,是什么?'],
['answer' => '床', 'hint' => '🧠 白天安安静静,晚上最忙,是什么?'],
['answer' => '枕头', 'hint' => '🧠 软绵绵的小山包,睡觉时总垫在头下,是什么?'],
['answer' => '被子', 'hint' => '🧠 白天叠成豆腐块,晚上张开抱住你,是什么?'],
['answer' => '闹钟', 'hint' => '🧠 不会说早安,却总把你从梦里拽出来,是什么?'],
['answer' => '日历', 'hint' => '🧠 每过一天就瘦一张,是什么?'],
['answer' => '时钟', 'hint' => '🧠 三根兄弟赛跑,一圈一圈不停歇,是什么?'],
['answer' => '手机', 'hint' => '🧠 不长嘴巴却能说话,不长耳朵却能听见,是什么?'],
['answer' => '电话', 'hint' => '🧠 两地相隔很远,也能贴耳说悄悄话,是什么?'],
['answer' => '电视', 'hint' => '🧠 小小方盒子,里面天天演大戏,是什么?'],
['answer' => '遥控器', 'hint' => '🧠 不用走过去,按按它就能让电视听话,是什么?'],
['answer' => '电脑', 'hint' => '🧠 肚子里装知识,手指一敲就干活,是什么?'],
['answer' => '键盘', 'hint' => '🧠 一排排小方块,不是钢琴也能打字,是什么?'],
['answer' => '鼠标', 'hint' => '🧠 名字像老鼠,却最怕猫,天天趴桌上,是什么?'],
['answer' => '耳机', 'hint' => '🧠 一左一右挂耳边,音乐只给你一个人听,是什么?'],
['answer' => '音箱', 'hint' => '🧠 肚里藏着喇叭,最会放大声音,是什么?'],
['answer' => '充电器', 'hint' => '🧠 手机饿了它喂饭,是什么?'],
['answer' => '电池', 'hint' => '🧠 个子小,电量大,很多机器靠它活,是什么?'],
['answer' => '电灯', 'hint' => '🧠 太阳下班它上岗,是什么?'],
['answer' => '灯泡', 'hint' => '🧠 玻璃肚里藏火苗,黑夜一亮像白天,是什么?'],
['answer' => '蜡烛', 'hint' => '🧠 有泪不会哭,有火不会叫,越烧越短,是什么?'],
['answer' => '火柴', 'hint' => '🧠 头戴红帽子,脾气特别火,一擦就冒火星,是什么?'],
['answer' => '打火机', 'hint' => '🧠 小盒子脾气爆,拇指一按就出火,是什么?'],
['answer' => '冰箱', 'hint' => '🧠 肚子大又冷,专门帮食物避暑,是什么?'],
['answer' => '空调', 'hint' => '🧠 夏天送凉风,冬天送暖风,挂在墙上最勤快,是什么?'],
['answer' => '风扇', 'hint' => '🧠 没有翅膀也会转,专给人送风,是什么?'],
['answer' => '洗衣机', 'hint' => '🧠 不长手,却特别会洗衣服,是什么?'],
['answer' => '熨斗', 'hint' => '🧠 衣服皱巴巴,它一来就服服帖帖,是什么?'],
['answer' => '微波炉', 'hint' => '🧠 剩饭剩菜进去转几圈,就又热乎了,是什么?'],
['answer' => '电饭煲', 'hint' => '🧠 白米进去,香饭出来,是什么?'],
['answer' => '锅', 'hint' => '🧠 黑脸大肚子,天天在灶台上唱歌,是什么?'],
['answer' => '筷子', 'hint' => '🧠 两个瘦兄弟,合作夹饭菜,是什么?'],
['answer' => '勺子', 'hint' => '🧠 有个圆脑袋,喝汤最拿手,是什么?'],
['answer' => '碗', 'hint' => '🧠 圆圆小肚皮,最爱装米饭和汤,是什么?'],
['answer' => '盘子', 'hint' => '🧠 扁扁一张脸,端菜最稳,是什么?'],
['answer' => '杯子', 'hint' => '🧠 不会说话却总装水,是什么?'],
['answer' => '水壶', 'hint' => '🧠 肚子能装水,嘴巴细细长长,是什么?'],
['answer' => '吸管', 'hint' => '🧠 不用嘴碰杯,也能把饮料送进肚,是什么?'],
['answer' => '铅笔', 'hint' => '🧠 身子细细穿木衣,肚里黑黑会写字,是什么?'],
['answer' => '橡皮', 'hint' => '🧠 不会写字专会擦,哪里写错它就上,是什么?'],
['answer' => '尺子', 'hint' => '🧠 身子直直会量长短,还能帮人画直线,是什么?'],
['answer' => '书包', 'hint' => '🧠 不会走路却天天背着书上学,是什么?'],
['answer' => '课本', 'hint' => '🧠 不会说话却肚子里全是知识,是什么?'],
['answer' => '黑板', 'hint' => '🧠 一张大黑脸,粉笔天天在上面写字,是什么?'],
['answer' => '粉笔', 'hint' => '🧠 白白瘦瘦,最爱在黑板上留下痕迹,是什么?'],
['answer' => '粉笔擦', 'hint' => '🧠 不会写字却专门抹掉黑板上的字,是什么?'],
['answer' => '书签', 'hint' => '🧠 个子小小住书里,帮你记住看到哪一页,是什么?'],
['answer' => '放大镜', 'hint' => '🧠 小东西一到它眼前,立刻显得很大,是什么?'],
['answer' => '望远镜', 'hint' => '🧠 明明站在原地,却能看见很远的东西,是什么?'],
['answer' => '相机', 'hint' => '🧠 不会画画却能把风景留住,是什么?'],
['answer' => '照片', 'hint' => '🧠 不会动不会说,却能把昨天留下来,是什么?'],
['answer' => '地图', 'hint' => '🧠 不出门也能带你认识世界,是什么?'],
['answer' => '地球仪', 'hint' => '🧠 一个蓝色大圆球,抱着全世界,是什么?'],
['answer' => '篮球', 'hint' => '🧠 穿着橙色外衣,最爱往框里钻,是什么?'],
['answer' => '足球', 'hint' => '🧠 用脚最喜欢的圆朋友,是什么?'],
['answer' => '羽毛球', 'hint' => '🧠 头圆圆,尾巴白白,飞起来像小鸟,是什么?'],
['answer' => '乒乓球', 'hint' => '🧠 白白小圆豆,桌上跳来跳去,是什么?'],
['answer' => '跳绳', 'hint' => '🧠 一根长线会转圈,小朋友最爱跳过去,是什么?'],
['answer' => '秋千', 'hint' => '🧠 坐上去前后飞,却飞不离原地,是什么?'],
['answer' => '风筝', 'hint' => '🧠 长着尾巴在天上飞,线一松就跑,是什么?'],
['answer' => '气球', 'hint' => '🧠 胖胖肚子装着气,手一松就想上天,是什么?'],
['answer' => '雪人', 'hint' => '🧠 冬天站院里,太阳一晒就瘦,是什么?'],
['answer' => '彩虹', 'hint' => '🧠 雨过天晴天上挂着七色桥,是什么?'],
['answer' => '云', 'hint' => '🧠 白天像棉花,风一吹就变样,是什么?'],
['answer' => '雾', 'hint' => '🧠 没下雨却湿漉漉,早晨最爱挡路,是什么?'],
['answer' => '霜', 'hint' => '🧠 不是雪却白在草上,太阳一出就化,是什么?'],
['answer' => '露珠', 'hint' => '🧠 清晨叶子上挂着一颗颗小珍珠,是什么?'],
['answer' => '月亮', 'hint' => '🧠 白天看不清,晚上天上挂银盘,是什么?'],
['answer' => '星星', 'hint' => '🧠 白天藏起来,晚上眨眼睛,是什么?'],
['answer' => '太阳', 'hint' => '🧠 白天值班最勤快,大家都靠它发光发热,是什么?'],
['answer' => '雷', 'hint' => '🧠 看不见摸不着,却能在天上大声打鼓,是什么?'],
['answer' => '闪电', 'hint' => '🧠 先看到一道亮鞭子,再听见轰隆隆,是什么?'],
];
foreach ($brainTeasers as $index => $brainTeaser) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_BRAIN_TEASER,
'answer' => $brainTeaser['answer'],
],
[
'hint' => $brainTeaser['hint'],
'is_active' => true,
'sort' => 1000 + $index + 1,
],
);
}
}
}
+143 -12
View File
@@ -9,6 +9,22 @@ NC='\033[0m'
PROJECT_ROOT="/www/wwwroot/chat.ay.lc" # <--- 确认这里的路径是否正确 PROJECT_ROOT="/www/wwwroot/chat.ay.lc" # <--- 确认这里的路径是否正确
export COMPOSER_ALLOW_SUPERUSER=1 export COMPOSER_ALLOW_SUPERUSER=1
SUPERVISORCTL_BIN=""
PHP_BIN=""
for candidate in /usr/bin/supervisorctl /usr/local/bin/supervisorctl /www/server/panel/pyenv/bin/supervisorctl; do
if [ -x "$candidate" ]; then
SUPERVISORCTL_BIN="$candidate"
break
fi
done
for candidate in /www/server/php/84/bin/php /usr/bin/php /usr/local/bin/php; do
if [ -x "$candidate" ]; then
PHP_BIN="$candidate"
break
fi
done
echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} 🚀 Laravel 稳健更新脚本 (带严格检查) ${NC}" echo -e "${BLUE} 🚀 Laravel 稳健更新脚本 (带严格检查) ${NC}"
@@ -16,14 +32,73 @@ echo -e "${BLUE}========================================${NC}"
cd "$PROJECT_ROOT" || { echo -e "${RED}❌ 无法进入项目目录:$PROJECT_ROOT${NC}"; exit 1; } cd "$PROJECT_ROOT" || { echo -e "${RED}❌ 无法进入项目目录:$PROJECT_ROOT${NC}"; exit 1; }
if [ -z "$PHP_BIN" ]; then
echo -e "${RED}❌ 未找到可用 PHP 可执行文件,请确认服务器已安装 PHP 8.4。${NC}"
exit 1
fi
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
echo -e "${BLUE}使用 PHP$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
frontend_build_required() {
local file="$1"
case "$file" in
resources/js/*|resources/css/*|resources/views/*.blade.php|resources/views/*/*.blade.php|resources/views/*/*/*.blade.php|resources/views/*/*/*/*.blade.php)
return 0
;;
package.json|package-lock.json|vite.config.*|tailwind.config.*|postcss.config.*)
return 0
;;
esac
return 1
}
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突) # 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
echo -e "${YELLOW}[1/7] 拉取代码...${NC}" echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
BEFORE_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
git checkout -- composer.lock package-lock.json 2>/dev/null || true git checkout -- composer.lock package-lock.json 2>/dev/null || true
git fetch origin && git pull origin master git fetch origin && git pull origin master
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
AFTER_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
if [ -n "$BEFORE_REV" ] && [ -n "$AFTER_REV" ] && [ "$BEFORE_REV" = "$AFTER_REV" ]; then
echo -e "${GREEN}✅ 当前代码已是最新版本,本次没有可升级内容,跳过后续部署步骤。${NC}"
exit 0
fi
FRONTEND_BUILD_NEEDED=0
FRONTEND_CHANGED_FILES=""
MIGRATION_NEEDED=0
NEW_MIGRATION_FILES=""
if [ -z "$BEFORE_REV" ] || [ -z "$AFTER_REV" ]; then
FRONTEND_BUILD_NEEDED=1
FRONTEND_CHANGED_FILES="无法识别更新前后版本,保险起见执行构建"
MIGRATION_NEEDED=1
NEW_MIGRATION_FILES="无法识别更新前后版本,保险起见执行迁移检查"
elif [ "$BEFORE_REV" != "$AFTER_REV" ]; then
while IFS= read -r changed_file; do
if frontend_build_required "$changed_file"; then
FRONTEND_BUILD_NEEDED=1
FRONTEND_CHANGED_FILES="${FRONTEND_CHANGED_FILES}${changed_file}"$'\n'
fi
done < <(git diff --name-only "$BEFORE_REV" "$AFTER_REV")
while IFS=$'\t' read -r change_status changed_file extra_path; do
if [ "$change_status" = "A" ]; then
case "$changed_file" in
database/migrations/*.php)
MIGRATION_NEEDED=1
NEW_MIGRATION_FILES="${NEW_MIGRATION_FILES}${changed_file}"$'\n'
;;
esac
fi
done < <(git diff --name-status "$BEFORE_REV" "$AFTER_REV")
fi
# 2. Composer Install (关键检查点) # 2. Composer Install (关键检查点)
echo -e "${YELLOW}[2/7] 安装依赖 (Composer)...${NC}" echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction
COMPOSER_EXIT_CODE=$? COMPOSER_EXIT_CODE=$?
@@ -58,7 +133,7 @@ composer dump-autoload --no-dev --optimize --classmap-authoritative --no-interac
if [ $? -ne 0 ]; then echo -e "${RED}❌ Composer autoload 重建失败${NC}"; exit 1; fi if [ $? -ne 0 ]; then echo -e "${RED}❌ Composer autoload 重建失败${NC}"; exit 1; fi
# 用当前 PHP 直接加载 Laravel autoload,提前暴露 vendor 缺文件 / 权限 / autoload 缓存问题 # 用当前 PHP 直接加载 Laravel autoload,提前暴露 vendor 缺文件 / 权限 / autoload 缓存问题
php -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;" "$PHP_BIN" -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;"
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${RED}========================================${NC}" echo -e "${RED}========================================${NC}"
echo -e "${RED} ❌ 致命错误:PHP 无法加载 vendor/autoload.php ${NC}" echo -e "${RED} ❌ 致命错误:PHP 无法加载 vendor/autoload.php ${NC}"
@@ -68,27 +143,83 @@ if [ $? -ne 0 ]; then
fi fi
# 3. 前端构建 # 3. 前端构建
echo -e "${YELLOW}[3/7] 前端构建 (npm run build)...${NC}" if [ "$FRONTEND_BUILD_NEEDED" -eq 1 ]; then
echo -e "${YELLOW}[3/8] 检测到前端构建输入变更,开始前端构建 (npm run build)...${NC}"
if [ -n "$FRONTEND_CHANGED_FILES" ]; then
echo -e "${BLUE}触发构建的文件:${NC}"
printf '%s\n' "$FRONTEND_CHANGED_FILES"
fi
npm run build npm run build
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
echo -e "${GREEN}✅ 前端资源构建完成。${NC}" echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
else
echo -e "${GREEN}[3/8] 未检测到前端构建输入变更,跳过 npm run build。${NC}"
fi
# 4. 数据库迁移 # 4. 数据库迁移
echo -e "${YELLOW}[5/7] 数据库迁移...${NC}" if [ "$MIGRATION_NEEDED" -eq 1 ]; then
php artisan migrate --force echo -e "${YELLOW}[4/8] 检测到新增迁移文件,执行数据库迁移...${NC}"
if [ -n "$NEW_MIGRATION_FILES" ]; then
echo -e "${BLUE}新增迁移文件:${NC}"
printf '%s\n' "$NEW_MIGRATION_FILES"
fi
"$PHP_BIN" artisan migrate --force
if [ $? -ne 0 ]; then echo -e "${RED}❌ 数据库迁移失败${NC}"; exit 1; fi
else
echo -e "${GREEN}[4/8] 未检测到新增迁移文件,跳过数据库迁移。${NC}"
fi
# 5. 优化 # 5. 优化
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}" echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
# 注意:optimize 命令内部已经包含了 config:cache, route:cache, event:cache,此处无须多余处理 # 注意:optimize 命令内部已经包含了 config:cache, route:cache, event:cache,此处无须多余处理
php artisan optimize:clear && php artisan optimize && php artisan view:cache "$PHP_BIN" artisan optimize:clear && "$PHP_BIN" artisan optimize && "$PHP_BIN" artisan view:cache
# 6. 重启 Horizon / 队列进程 # 6. 重启 Horizon / 队列进程
echo -e "${YELLOW}[6/8] 重启 Horizon...${NC}" echo -e "${YELLOW}[6/8] 重启 Horizon...${NC}"
php artisan horizon:terminate "$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
if [ -n "$SUPERVISORCTL_BIN" ]; then
"$SUPERVISORCTL_BIN" restart horizon >/dev/null 2>&1 || "$SUPERVISORCTL_BIN" restart horizon:* >/dev/null 2>&1 || true
fi
# 7. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存 # 7. 重启 Reverb,确保长驻 WebSocket 进程加载最新代码和配置
echo -e "${YELLOW}[7/8] 重载 PHP-FPM...${NC}" echo -e "${YELLOW}[7/8] 重启 Reverb...${NC}"
REVERB_RESTARTED=0
REVERB_CAN_AUTO_RESTART=0
SUPERVISOR_REVERB_TARGET=""
# 先探测是否存在可自动拉起 Reverb 的进程管理器,避免在纯手工启动场景下执行 reverb:restart 后把聊天室停掉。
if [ -n "$SUPERVISORCTL_BIN" ]; then
if "$SUPERVISORCTL_BIN" status reverb >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb"
REVERB_CAN_AUTO_RESTART=1
elif "$SUPERVISORCTL_BIN" status reverb:* >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb:*"
REVERB_CAN_AUTO_RESTART=1
fi
fi
# Laravel 官方文档说明:reverb:restart 适合在 Supervisor 等进程管理器托管下使用。
if [ "$REVERB_CAN_AUTO_RESTART" -eq 1 ]; then
"$PHP_BIN" artisan reverb:restart >/dev/null 2>&1
if [ $? -eq 0 ]; then
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已执行 php artisan reverb:restart。${NC}"
fi
# 若线上通过 Supervisor 托管 reverb:start,再补一次显式 restart,尽量确保进程被拉起。
if [ -n "$SUPERVISOR_REVERB_TARGET" ]; then
"$SUPERVISORCTL_BIN" restart "$SUPERVISOR_REVERB_TARGET" >/dev/null 2>&1 || true
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已尝试重启 Supervisor 托管的 Reverb 进程。${NC}"
fi
else
echo -e "${YELLOW}⚠️ 未检测到 Reverb 进程管理器,已跳过 reverb:restart,避免把手工启动的聊天室长连接服务停掉。${NC}"
echo -e "${YELLOW}⚠️ 若当前服务器是手工执行 php artisan reverb:start,请在部署完成后手动重启该进程。${NC}"
fi
# 8. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存
echo -e "${YELLOW}[8/8] 重载 PHP-FPM...${NC}"
PHP_FPM_RELOADED=0 PHP_FPM_RELOADED=0
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
for svc in php-fpm php-fpm-84 php-fpm-83 php-fpm-82 php84-php-fpm php83-php-fpm php82-php-fpm; do for svc in php-fpm php-fpm-84 php-fpm-83 php-fpm-82 php84-php-fpm php83-php-fpm php82-php-fpm; do
@@ -110,8 +241,8 @@ else
echo -e "${YELLOW}⚠️ 未识别到 PHP-FPM 服务,请在面板中手动重启当前站点使用的 PHP。${NC}" echo -e "${YELLOW}⚠️ 未识别到 PHP-FPM 服务,请在面板中手动重启当前站点使用的 PHP。${NC}"
fi fi
# 6. 权限 (针对宝塔或Nginx+FPM环境的修正) # 权限修正(针对宝塔或 Nginx + FPM 环境
echo -e "${YELLOW}[8/8] 修复权限...${NC}" echo -e "${YELLOW}[后处理] 修复权限...${NC}"
# 将所有文件所属权变更为 Web 运行用户(如 www),防止 root 权限导致框架日志或缓存写入失败 # 将所有文件所属权变更为 Web 运行用户(如 www),防止 root 权限导致框架日志或缓存写入失败
chown -R www:www . chown -R www:www .
# 默认读写执行权限 # 默认读写执行权限
@@ -0,0 +1,14 @@
{
"name": "chatroom-ride-development",
"version": "0.2.0",
"description": "聊天室座驾开发插件,沉淀独立座驾模块、金币流水、特效注册、命名规则和测试清单。",
"interface": {
"displayName": "Chatroom Ride Development"
},
"skills": [
{
"name": "chatroom-ride-development",
"path": "skills/chatroom-ride-development/SKILL.md"
}
]
}

Some files were not shown because too many files have changed in this diff Show More