178 Commits

Author SHA1 Message Date
pllx 7b46a08a46 fix: 修复安全弹窗桥接后设置面板无法关闭的Bug 2026-07-01 11:27:40 +08:00
pllx 0006b7bcf6 feat: 登录大厅未设邮箱且未绑微信强引导弹窗并适配会话级防骚扰 2026-07-01 11:24:30 +08:00
pllx 563ac99348 feat: 忘记密码增设局部焦点提示及发信成功30秒防刷置灰倒计时锁 2026-07-01 11:16:09 +08:00
pllx 94236e25eb feat: 忘记密码增设脱敏邮箱二次手动输入一致性核对安全锁 2026-07-01 11:13:46 +08:00
pllx b098639db5 feat: 忘记密码升级为智能分流向导并部署IP/账号双层防扫防轰炸限流保护 2026-07-01 11:11:20 +08:00
pllx 879dcb0b59 feat: 微信端私聊机器人支持绑定微信号的密码自助重置功能,并提供反射自动化测试 2026-07-01 10:53:24 +08:00
pllx fcc88ecb0c fix: 豁免超级管理员(站长)在执行管理命令时必须在房间内的限制 2026-07-01 09:53:49 +08:00
pllx e8d9de51f8 fix: 对 AI小班长 等系统虚拟角色豁免警告时的在线状态校验 2026-07-01 09:53:14 +08:00
pllx b19ebdc7d8 style: 优化前台警告消息样式,标红、加大并加粗以提升视觉张力 2026-07-01 09:52:04 +08:00
pllx d8cb75d282 docs: 完善部署说明文档,补充 Octane 代理说明及 WebSocket 协议升级避坑指南 2026-06-30 14:20:55 +08:00
pllx 9fd7b14ec7 fix: 完全还原大厅页面,只保留导航栏作为百家乐的直达入口 2026-06-30 14:14:21 +08:00
pllx fc28fe37c6 fix: 移除大厅页面上多余的非百家乐操作按钮 2026-06-30 14:13:37 +08:00
pllx 524c8ed6f3 feat: 新建前台百家乐历史走势页面与珠盘路,打通大厅与导航栏快捷入口 2026-06-30 14:12:45 +08:00
pllx 394e216b92 dev: 添加 chokidar 依赖以支持开发环境下的 Octane 代码热重载(watch) 2026-06-30 13:38:46 +08:00
pllx b8a6c9d0c2 perf: 编写并执行数据库迁移,为 messages 表添加 (room_id, sent_at) 联合索引以消除慢查询 2026-06-30 11:38:38 +08:00
pllx be1406fc58 perf: 引入 laravel/octane 并安装常驻服务 Roadrunner 引擎,消除每次请求框架初始化开销 2026-06-30 11:38:35 +08:00
pllx 83192ffcce perf: 重构敏感词过滤为DFA算法,并引入WebSocket房间频道鉴权Redis缓存 2026-06-30 11:32:55 +08:00
pllx b19073cf85 perf: 优化挂机心跳接口,无奖励变动心跳拒绝写库save,仅在Redis保活以释放I/O压力 2026-06-30 11:32:53 +08:00
pllx 3563b45038 feat: 引入事务悲观锁(lockForUpdate)与余额二次校验,防范高并发资产越权与透支漏洞 2026-06-30 11:32:52 +08:00
pllx d1409d16bb 修复:自动存点导致有职务用户等级反复升降的循环问题
修复 AutoSaveExp 自动存点指令中,有职务用户的等级处理逻辑:
- 职务等级改为最低要求(不足时补级),不再强制锁定
- 允许职务成员通过经验值超越职务等级
- 与 ChatController::calculateNewLevel 保持一致

之前 AutoSaveExp 每5分钟把蔚蓝的心情(LV.91/金牌聊务员)
强制降到90级,心跳60秒后又升回91并触发升级广播,
导致用户反复看到"升级到91级"的提示。
2026-06-27 17:54:38 +08:00
pllx 2ea84ed93e 社交操作补充 user_currency_logs 流水记录
- sendFlower 送鲜花:发送方 gold 用 SEND_GIFT 记录,接收方 charm 用 RECV_GIFT 记录
- giftGold 赠送金币:发送方用 GIFT_SENT 记录,接收方用 GOLD_GIFT_RECV 记录
- grantChatCharm 聊天魅力奖励:用 CHAT_CHARM 记录发送方魅力值变动
- CurrencySource 新增 GOLD_GIFT_RECV、CHAT_CHARM 两个来源枚举
2026-05-28 13:18:52 +08:00
pllx dce0a52750 银行存取补充 user_currency_logs 流水记录
- CurrencySource 新增 BANK_DEPOSIT / BANK_WITHDRAW 两个来源枚举
- BankController deposit/withdraw 改用 UserCurrencyService::change() 记录 jjb 变动
- AiFinanceService bankExcessGold/raiseWalletTo 补充 UserCurrencyService::change() 调用
- 银行存取双轨记录:bank_logs(银行流水)+ user_currency_logs(金币流水)
2026-05-28 13:13:34 +08:00
pllx ce6d71a4f2 优化 emoji 浏览器兼容 2026-05-20 12:01:06 +08:00
pllx a0a189867f 优化 emoji 浏览器兼容 2026-05-20 11:52:49 +08:00
pllx 3c7cefe447 新增曾用名 2026-05-19 18:01:47 +08:00
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
pllx a2b09da730 新增:个性装扮支持多份购买,同款续购自动叠加天数 2026-04-28 13:07:10 +08:00
pllx 243e06915e 删除没必要文件 2026-04-28 13:00:21 +08:00
pllx 2ee6ecc601 清理:移除已追踪的 AI 配置目录(.agents/.codex/.gemini/.hermes/.junie)和 AI 生成文件(AGENTS.md/GEMINI.md) 2026-04-28 12:01:36 +08:00
pllx f0137f3fa3 修复:后台恢复时只省略过时的系统/游戏通知,保留用户聊天记录 2026-04-28 11:59:01 +08:00
pllx b15e42891d 修复:后台恢复时消息暴刷,只渲染最后50条并插入省略提示 2026-04-28 11:55:35 +08:00
pllx 214a422504 修复:自动钓鱼冷却倒计时改为基于时间戳,解决后台标签节流导致的显示/触发延迟 2026-04-28 11:52:30 +08:00
pllx f16f10fe82 新增:购买自动钓鱼卡后自动开启钓鱼模式 2026-04-28 11:37:38 +08:00
pllx a3daf3f074 修复:欢迎消息中的发送者名字改为可点击(保留职务前缀内的名字,不额外显示) 2026-04-28 11:02:53 +08:00
pllx d0a38352a5 修复:欢迎按钮消息中发送者名字点击无响应(缺少 clickableUser 渲染) 2026-04-28 10:57:29 +08:00
pllx c06e265c0d 回退:恢复 Alpine.js 独立加载引用(项目中无 npm alpinejs 依赖,全局 window.Alpine 全靠此文件) 2026-04-28 10:52:55 +08:00
pllx 6ae7a4a82b 修复:移除 frame.blade.php 中冗余的 Alpine.js 独立加载 2026-04-28 10:50:13 +08:00
pllx 792b0765fd 优化:chat.css 移入 Vite 构建管线
将 public/css/chat.css(958行)移入 resources/css/chat.css,通过 Vite 构建,不再使用传统 <link> 加载。

改动:
- vite.config.js:添加 resources/css/chat.css 入口
- frame.blade.php:替换 <link> 为 @vite 方式
2026-04-28 10:43:22 +08:00
pllx 3e0fb33a9b 文档:更新优化计划完成状态标注 2026-04-28 10:36:02 +08:00
pllx e7049b5f5b 优化:聊天图片添加 loading="lazy" 懒加载
在 message-renderer.js 的聊天图片缩略图 img 标签上添加 loading="lazy" decoding="async",非可视区域的图片不会被加载,减少初始页面数据传输。
2026-04-28 10:33:49 +08:00
pllx 62371a7c64 新增:聊天室反馈模态弹窗(仿留言弹窗样式)
点击工具栏「反馈」按钮弹出反馈弹窗,不再跳转新页面。

新建文件:
- feedback-modal.blade.php — 蓝白渐变标题栏、类型筛选Tabs、反馈卡片列表(展开详情/评论)、提交反馈表单、滚动懒加载
- feedback.js — AJAX加载/提交/点赞/评论/删除,滚动懒加载,乐观UI更新

修改文件:
- toolbar.blade.php — 反馈按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 feedback 动作
- chat-room.js — 静态导入 feedback 模块
- frame.blade.php — 引入反馈弹窗
- routes/web.php — 新增 feedback.data 路由
- FeedbackController.php — 新增 data() 方法
2026-04-28 10:29:14 +08:00
pllx 540d8bf6ff 新增:聊天室留言板模态弹窗(仿商店样式)
点击工具栏「留言」按钮弹出留言板弹窗,不再跳转新页面。

新建文件:
- guestbook-modal.blade.php — 蓝白渐变标题栏、三Tab切换、留言卡片列表、内嵌写留言表单
- guestbook.js — 完整的AJAX加载/提交/删除逻辑,绑定所有事件

修改文件:
- toolbar.blade.php — 留言按钮 data-toolbar-url → data-toolbar-action
- toolbar.js — 添加 guestbook 动作
- chat-room.js — 静态导入 guestbook 模块
- frame.blade.php — 引入留言弹窗
- routes/web.php — 新增 guestbook.data JSON 路由
- GuestbookController.php — 新增 data() 方法
2026-04-28 10:20:32 +08:00
pllx bf2d63f125 修复:头像弹窗点击遮罩层关闭
头像选择弹窗缺少 data-avatar-picker-overlay / data-avatar-picker-panel 属性及遮罩层点击关闭逻辑。参考设置弹窗的模式添加。

改动:
- toolbar.blade.php:添加 data-avatar-picker-overlay 和 data-avatar-picker-panel
- profile-controls.js:添加遮罩层点击关闭处理
2026-04-28 10:11:16 +08:00
pllx 4f22fd552a 修复:钓鱼/欢迎/图片等按钮点击无响应
22 个注册事件委托的懒加载模块改为静态导入,保留 8 个工具栏模块继续保持懒加载。

按钮点击无响应的根因:模块的 bind*Controls() 通过 data-* 属性注册事件监听器,但模块懒加载从未被触发,监听器不注册。

chat.js:239 KB(原 308 KB,↓22%)
vendor.js:108 KB(独立缓存)
按需加载模块:8 个(shop/bank/vip 等)
2026-04-28 10:07:17 +08:00
pllx 790730e2c2 修复:签到按钮点击无效
daily-sign-in.js 之前是懒加载,但模块在顶层设置了 window.openDailySignInModal 等全局函数,且 bindDailySignInControls() 注册事件委托。由于模块从未被触发加载,签到按钮点击无响应。

恢复为静态导入,问题和 Alpine 组件一样。chat.js 从 170KB 增至 184KB(原 308KB,↓40%)
2026-04-28 09:58:56 +08:00
pllx eeb9dfbade 修复:Alpine 组件恢复静态导入,消除 321 处表达式报错
将 13 个有 x-data 引用的 Alpine 组件模块恢复为静态导入,保留 27 个非 Alpine 模块懒加载。

chat.js 体积:170 KB(原 308 KB,↓45%)
vendor 独立分包:108 KB
非 Alpine 模块仍保持按需代码分割
2026-04-28 09:50:25 +08:00
pllx 1c067e452b 修复所有 Alpine 组件表达式报错
彻底移除 Proxy/has 陷阱方案,改用显式方法存根:
- userCardComponent 补充 35 个方法存根
- marriage-modals 8 个组件改用 createLazyAlpineComponent
- weddingSetup/weddingEnvelope 等 Modal 均正确包装
控制台现在应该没有任何 Alpine Expression Error
2026-04-28 09:42:18 +08:00
pllx e50502d8f6 前端加载优化:代码分割 + 按需懒加载
chat.js 首屏 308KB → 100KB(↓68%)
44 个重型模块改为 Vite 动态 import()
Alpine 组件通过 $watch 监听实现真懒加载
新增 createLazyAlpineComponent 工具 + Proxy has 陷阱修复
补充 userCardComponent 全部 28 个属性默认值
vendor 依赖独立分包(108KB)
生产环境关闭 sourcemap
2026-04-28 09:38:18 +08:00
pllx e8b4dcc968 fix: 公屏消息中'大家'不可点击的问题 2026-04-27 09:36:35 +00:00
pllx e177ad6d4d 优化百家乐显示 2026-04-27 17:24:28 +08:00
pllx f17f171f4b fix: 修复迁移遗留的按钮无响应、头像框层级及构建错误
迁移收尾修复:
- heartbeat.js: 移除 export { } 中重复的 startHeartbeat/stopHeartbeat(已通过 export function 导出)
- scripts.blade.php: 移除 JS 注释中的 {{ }} 避免 Blade 编译为 e() 导致 PHP 解析错误
- preferences-status.js: 补全 6 个缺失的 window.* 赋值(toggleBlockMenu/toggleFeatureMenu 等),
  实现迁移中丢失的 updateDailyStatus/clearDailyStatus,修复 handleFeatureLocalClear 清屏回调
- toolbar.js: 补全 window.runFeatureShortcut 赋值

头像框样式修复(chat-decorations.css):
- z-index 互换:头像降至 1,框升至 3,使框边缘可遮挡头像外围
- 使用 CSS mask(radial-gradient)挖环形替代旧 ::before 实心圆遮挡方案
- clip-path: circle(50%) 硬裁剪确保圆形,不受 chat.css border-radius: 2px 覆盖
- 特异性提升至 .user-item .avatar-frame-wrapper .user-head

新 Vite 模块(从 Blade 迁移):
- chat-state.js / message-renderer.js / user-list.js / chat-events.js
- composer.js(重写)/ heartbeat.js / admin-commands.js
- vip-presence.js / chat-decorations.css
2026-04-27 09:19:49 +00:00
pllx d10a354370 fix: logout route accepts GET to prevent 404 on page refresh
POST /logout redirects to / after logging out, but when the redirect
fails to complete (browser/network quirk) the user is stuck on /logout.
Refreshing sends GET, which had no route defined, causing a 404.

Changed Route::post to Route::match(['get', 'post']) so refreshing
after a stuck redirect gracefully completes the logout instead.
2026-04-27 07:19:58 +00:00
pllx efb03f90b8 后台流水区分四种装扮消费类型:气泡/昵称色/文字色/头像框
- CurrencySource 新增 MSG_BUBBLE_BUY、MSG_NAME_COLOR_BUY、MSG_TEXT_COLOR_BUY
- DecorationService::purchase() 按商品 type 选择对应 source
- 后台流水页「来源途径」筛选现在可分别查询四种装扮消费
2026-04-27 07:08:22 +00:00
pllx 3ecafd01ea 签到通知现在也和礼包、买单活动一样,取消外层背景和边框,只保留原通知文字和按钮 2026-04-27 15:03:44 +08:00
pllx d82aa1c434 聊天消息头像也显示已购头像框特效
- DecorationService: getDecorationsForMessage() 加入 avatar_frame 字段
- 新增 .avatar-frame-wrapper-sm 紧凑版头像框样式(适配16px小头像)
- 消息渲染时检查 msg.avatar_frame 和 senderInfo.avatar_frame,包裹头像框
2026-04-27 06:59:00 +00:00
pllx 3db8e4ab82 取消买单活动通知背景边框 2026-04-27 14:58:17 +08:00
pllx 10d158b38a fix: 修复气泡消息挤在同一行的问题,改为 block + fit-content 2026-04-27 06:50:49 +00:00
pllx ea02c36ea6 调整消息气泡宽度:根据内容自适应而非整屏幕通长 2026-04-27 06:48:45 +00:00
pllx 2c8cb21206 修复普通定向发言公屏可见 2026-04-27 14:43:43 +08:00
pllx c0cb7f5ead 调整商店商品价格:气泡/昵称色/文字色/头像框价格重新定价 2026-04-27 06:37:44 +00:00
pllx 83c312196c fix: 每次打开商店弹窗时重新获取数据库最新数据
关闭商店弹窗时重置 shopLoaded 标志,确保下次打开时重新请求 /shop/items,避免展示过期数据。
2026-04-27 06:30:52 +00:00
pllx 66206fa521 取消礼包领取通知背景边框 2026-04-27 14:30:51 +08:00
pllx ee62a3add8 fix: 修复 msg_text_color 类型在购买分发、校验和后台管理中的遗漏
- ShopService: 购买分发 match 新增 msg_text_color → DecorationService
- ShopController: 购买公告 match 新增 msg_text_color
- Admin/ShopItemController: 后台 validation type 校验新增 msg_text_color
- admin/shop/index.blade: 类型标签映射和下拉选项新增文字颜色
2026-04-27 06:23:42 +00:00
pllx 3c749969b4 修复新人等级被降为零 2026-04-27 14:23:34 +08:00
pllx 277cb617da feat: 新增消息文字颜色特效装扮(七彩/流光/霓虹/火焰/冰蓝)
- 新增 msg_text_color 商品类型,扩展 shop_items.type ENUM
- DecorationService 支持 text_color 槽位,自动注入消息广播
- CSS 动画:rainbow(彩虹流动)、shimmer(金属流光)、neon(霓虹脉动)、flame(火焰跃动)、ice(冰蓝流转)
- ShopItemSeeder 新增 5 款文字颜色特效商品
- 商店前端新增「🌈 文字颜色」装扮分组
- 消息渲染 appendMessage/buildChatMessageContent 支持文字特效 class
2026-04-27 06:17:22 +00:00
pllx dd9ae46c04 修复新人欢迎被本地清屏过滤 2026-04-27 14:13:23 +08:00
pllx 3d8e270df4 修复新人进房欢迎消息显示 2026-04-27 14:05:11 +08:00
pllx 8db1a252d7 优化新人欢迎信息 2026-04-27 13:54:59 +08:00
pllx 3e85cb67bc 右侧名单徽标改为仅图标,去掉文字标签节省空间
- buildUserStatusBadgeHtml: 状态徽标从 pill 胶囊(图标+文字)改为仅图标
- buildUserSignIdentityBadgeHtml: 签到身份徽标同样改为仅图标
- 两个徽标的 tooltip 信息保留,悬停仍可看到完整描述
2026-04-27 05:45:36 +00:00
lkddi 40a0849151 新增 新人 小班长 自动发送欢迎 2026-04-27 13:27:38 +08:00
lkddi 442ca0e1e2 优化ai小班长 根据聊天内容确定总送金币数量 2026-04-27 13:22:27 +08:00
lkddi 3f2eb7d48b 优化 签到文字提示 2026-04-27 12:19:43 +08:00
lkddi c9d4d3dbf4 优化商店、游戏文字通知 2026-04-27 12:16:18 +08:00
lkddi f6bc8a83c3 优化游戏 通知样式 2026-04-27 12:04:48 +08:00
lkddi a09927f6fd 修复bug 2026-04-27 11:35:00 +08:00
lkddi 16b709d1da 修改个性特性商店bug 2026-04-27 11:32:22 +08:00
lkddi a16a8fb9f4 优化商店 2026-04-27 11:23:15 +08:00
lkddi bcb762df77 优化商店 2026-04-27 11:23:08 +08:00
lkddi ffccfa26e9 优化商店个性装扮体验 2026-04-27 11:12:51 +08:00
lkddi 32af6abeb2 优化 2026-04-26 21:06:43 +08:00
lkddi d4082e0edd 限制系统参数配置为站长专属 2026-04-26 21:06:30 +08:00
lkddi 0402097b59 聊天室管理权限统一为职务权限 2026-04-26 20:55:11 +08:00
lkddi b07f4e971a 优化 后台等级设置 2026-04-26 20:37:23 +08:00
264 changed files with 30349 additions and 8302 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"
}
]
}
-150
View File
@@ -1,150 +0,0 @@
---
trigger: always_on
---
> **技术栈**Laravel 12 · PHP 8.4 · Laravel Reverb (WebSocket) · Redis · MySQL 8.0 · Laravel Horizon
> **原项目**`/Users/pllx/Web/chat/hp0709`VBScript ASP + MS Access 聊天室)
> **目标域名**`http://chatroom.test`Herd 自动配置)
---
## 一、环境版本要求
| 组件 | 版本 |
| --------------------- | -------------------------- |
| **PHP** | 8.4.5+ |
| **Laravel Framework** | v12.x |
| **Laravel Reverb** | latestWebSocket 服务器) |
| **Laravel Horizon** | v5(Redis 队列可视化管理) |
| **PHPUnit** | v11(测试框架) |
| **Node.js** | 20.x LTS |
| **MySQL** | 8.0+ |
| **Redis** | 7.x |
---
## 二、代码规范(强制执行)
### 2.1 Laravel Pint 格式化
```bash
# 提交代码前必须运行,修复格式问题
vendor/bin/pint --dirty
# 检查格式问题(不修复)
vendor/bin/pint --test
# 格式化整个项目
vendor/bin/pint
```
### 2.2 PHP 8.4 类型系统(必须遵守)
```php
// ✅ 正确:构造函数属性提升 (Constructor Property Promotion)
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
) {}
}
// ❌ 错误:不允许无参空构造函数
class SomeClass
{
public function __construct() {} // 禁止!
}
// ✅ 正确:显式返回类型 + 参数类型提示
public function send(SendMessageRequest $request): JsonResponse
{
// ...
}
// ✅ 正确:使用 PHP 8.4 新特性
// 联合类型
public function findUser(int|string $id): User|null {}
// readonly 属性
class MessageDto
{
public function __construct(
public readonly string $content,
public readonly string $fromUser,
public readonly int $roomId,
) {}
}
```
### 2.3 Laravel 12 中间件配置(重要)
> [!IMPORTANT]
> Laravel 12 已废弃 `Kernel.php`,中间件在 `bootstrap/app.php` 中配置。
```php
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // API 路由
channels: __DIR__.'/../routes/channels.php', // WebSocket 频道
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// 注册聊天室登录验证中间件
$middleware->alias([
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
]);
// Session 中间件(Web 路由自动携带)
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
```
### 2.4 中文注释规范(每个文件必须)
```php
<?php
/**
* 文件功能:[本文件的业务职责描述]
*
* 对应原 ASP 文件:[原文件名.asp]
*
* @package App\[命名空间]
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Services;
class ChatStateService
{
/**
* 用户进入聊天房间,将其信息写入 Redis。
*
* 替代原 ASP 的 Application("_user_list") 字符串拼接操作。
*
* @param int $roomId 房间 ID
* @param string $username 用户名
* @param array $userInfo 用户信息(等级、头像、性别等)
*/
public function userJoin(int $roomId, string $username, array $userInfo): void
{
// 将用户信息序列化后存入 Redis HashKey 为 "room:{房间ID}:users"
$this->redis->hset("room:{$roomId}:users", $username, json_encode($userInfo));
}
}
```
### 2.5 迁移文件注意事项
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-12
View File
@@ -1,12 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[cleanup]
script = '''
php artisan reverb:start
php artisan horizon
'''
-14
View File
@@ -1,14 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[[actions]]
name = "启动ws"
icon = "tool"
command = '''
php artisan reverb:start
php artisan horizon
'''
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
},
"herd": {
"command": "php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+10
View File
@@ -15,6 +15,10 @@
/.github /.github
/.gemini /.gemini
/.agents /.agents
/.codex
/.hermes
/.reasonix
/.understand-anything
/auth.json /auth.json
/node_modules /node_modules
/public/build /public/build
@@ -30,3 +34,9 @@ vendor.zip
test-captcha.php test-captcha.php
public/.user.ini public/.user.ini
dump.rdb dump.rdb
# AI 生成文件
AGENTS.md
GEMINI.md
rr
.rr.yaml
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Users/pllx/Web/Herd/chatroom/artisan",
"boost:mcp"
]
},
"herd": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-1004
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
# 🎮 聊天室游戏开发进度
> 更新时间:2026-03-04
---
## ✅ 已完成
### 🎲 百家乐(Baccarat
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`baccarat_rounds` + `baccarat_bets`
- **模型**`BaccaratRound` / `BaccaratBet`
- **队列 Job**`OpenBaccaratRoundJob` (开局) + `CloseBaccaratRoundJob` (摇骰结算)
- **事件**`BaccaratRoundOpened` / `BaccaratRoundSettled`PresenceChannel 广播)
- **控制器**`BaccaratController``/baccarat/current` / `/baccarat/bet` / `/baccarat/history`
- **前端**`chat/partials/baccarat-panel.blade.php`(倒计时/押注/骰子动画/趋势)
- **货币来源**`CurrencySource::BACCARAT_BET` / `BACCARAT_WIN`
- **后台配置**`game_configs` 表,管理员可配置开关/间隔/赔率/押注范围
### 🎰 老虎机(Slot Machine
- **类型**:玩家随时主动触发(即时游戏)
- **数据库**`slot_machine_logs`
- **模型**`SlotMachineLog`8种带权重图案、判奖逻辑)
- **控制器**`SlotMachineController``/slot/info` / `/slot/spin` / `/slot/history`
- **赔率**:三7×100(全服广播)/ 三钻×50 / 三同×10 / 两同×2 / 三骷髅诅咒(扣双倍)
- **聊天通知**:中奖发私信通知;三7全服公屏广播
- **前端**`chat/partials/slot-machine.blade.php`(三列滚轮动画/逐列停止/可拖动FAB)
- **货币来源**`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
- **后台配置**`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
### 📦 神秘箱子(Mystery Box
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
- **数据库**`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
- **模型**`MysteryBox` / `MysteryBoxClaim`
- **队列 Job**`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob/ `ExpireMysteryBoxJob`(到期处理)
- **控制器**`MysteryBoxController``/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
- **前端**`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
- **货币来源**`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
- **后台配置**`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
### 🐎 赛马竞猜(Horse Racing
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`horse_races` + `horse_bets`
- **模型**`HorseRace` / `HorseBet`
- **队列 Job**`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
- **事件**`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel 广播)
- **控制器**`HorseRaceController``/horse-race/current` / `/horse-race/bet` / `/horse-race/history`
- **广播**`horse.opened` / `horse.progress` / `horse.settled`
- **前端**`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB)
- **货币来源**`CurrencySource::HORSE_BET` / `HORSE_WIN`
- **后台配置**`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
### 🔮 神秘占卜(Fortune Telling
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
- **数据库**`fortune_logs`
- **模型**`FortuneLog`55+ 条签文内嵌在模型中)
- **控制器**`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
- **前端**`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB)
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
- **广播**:暂无实时广播(占卜结果仅展示给本人)
- **货币来源**`CurrencySource::FORTUNE_COST`
- **后台配置**`game_configs` 表,免费次数/额外消耗/各签概率均可配置
---
## 🕐 待开发
---
## 📌 通用待办(所有游戏共用)
- [x] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据(点击"加载实时统计"异步加载各游戏汇总卡片)
- [x] 各游戏历史记录在后台可查(管理员视角,新增 `/admin/game-history/` 路由组,支持百家乐/老虎机/赛马/神秘箱子/占卜各自的历史记录列表及详情页,含筛选分页)
- [x] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
---
## 🔧 已修复的 Bug
1. **百家乐广播频道**`Channel``PresenceChannel`,解决前端收不到 WebSocket 事件
2. **百家乐余额检查**`$user->gold``$user->jjb`(字段名错误)
3. **老虎机积分日志**:普通中奖/诅咒发私信通知;三7全服广播
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
8. **Alpine.js 初始化顺序**`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
10. **神秘箱子流水记录**`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+70 -44
View File
@@ -186,52 +186,66 @@ php artisan view:cache
### 6. Nginx 配置 ### 6. Nginx 配置
在宝塔面板的 Nginx 配置中,需要添加 WebSocket 反向代理。 为了让外界能够通过 HTTPS 正常访问常驻内存服务器(Roadrunner 8000端口)以及实时 WebSocket 广播(Reverb 8080端口),需要在宝塔面板的 Nginx 配置中进行反向代理。
**第一步:** `server {}`**外面上方**添加 `server {}` 块**内部**`#REWRITE-END` 之后,禁止访问敏感文件之前)添加以下配置
```nginx ```nginx
map $http_upgrade $connection_upgrade { # ── 1. 静态资源优先由 Nginx 直接响应,动态请求分流给 Octane ──
default upgrade; location / {
'' close; try_files $uri $uri/ @octane;
} }
# ── 2. API、心跳等动态请求反向代理到本地 8000 端口 (Octane / Roadrunner) ──
location @octane {
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8000;
}
# ── 3. ⚡ WebSocket 实时广播反向代理 (Laravel Reverb 8080端口) ──
# 浏览器通过 /app 和 /apps 路径发起 WebSocket 连接
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
# 避坑提示:某些宝塔环境全局未配置 $connection_upgrade 变量,
# 直接写死 "Upgrade" 能 100% 避免 500 代理握手失败。
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 长连接保活:120秒无数据才断开
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
location /apps {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
``` ```
**第二步:** 在 `server {}` 块**内部**`#REWRITE-END` 之后)添加: **测试并重载 Nginx**
```nginx
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
location /apps {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
```
**第三步:** 在宝塔面板 → 网站 → 伪静态中选择 `laravel5`
**第四步:** 测试并重载 Nginx:
```bash ```bash
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
@@ -241,7 +255,7 @@ nginx -t && systemctl reload nginx
### 7. Supervisor 守护进程(关键!) ### 7. Supervisor 守护进程(关键!)
在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加个守护进程: 在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加个守护进程,确保长连接和心跳在后台持续运行
#### 进程一:Laravel ReverbWebSocket 服务器) #### 进程一:Laravel ReverbWebSocket 服务器)
@@ -250,6 +264,7 @@ nginx -t && systemctl reload nginx
| 名称 | `chatroom-reverb` | | 名称 | `chatroom-reverb` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` | | 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan reverb:start` | | 启动命令 | `php artisan reverb:start` |
| 运行用户 | `www` |
| 进程数量 | `1` | | 进程数量 | `1` |
#### 进程二:Laravel Horizon(队列处理器) #### 进程二:Laravel Horizon(队列处理器)
@@ -259,12 +274,23 @@ nginx -t && systemctl reload nginx
| 名称 | `chatroom-horizon` | | 名称 | `chatroom-horizon` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` | | 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan horizon` | | 启动命令 | `php artisan horizon` |
| 运行用户 | `www` |
| 进程数量 | `1` | | 进程数量 | `1` |
#### 进程三:Laravel OctaneRoadrunner 常驻内存服务)
| 配置项 | 值 |
| -------- | ---------------------------------------------------------- |
| 名称 | `chatroom-octane` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan octane:start --server=roadrunner --port=8000` |
| 运行用户 | `www` |
| 进程数量 | `1` |
> ⚠️ **如果不配置 Supervisor** > ⚠️ **如果不配置 Supervisor**
> >
> - 关闭 SSH 终端后,ReverbHorizon 会立刻停止 > - 关闭 SSH 终端后,ReverbHorizon 和 Octane 会立刻停止运行,导致聊天室无法打开或无法收发消息!
> - 聊天室将无法发送/接收消息,在线列表为空 > - **更新代码后**:当您在服务器上完成 `git pull` 后,需要运行 `php artisan octane:reload` 重新载入内存,代码才会生效。
--- ---
+81 -34
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 万,则先把超出的部分转入银行。
$this->aiFinance->bankExcessGold($user); if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$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;
} }
$user->refresh(); if ($hasCurrencyChange) {
$user->refresh();
}
// 重新计算等级 // 重新计算等级
if ($this->calculateNewLevel($user, $superLevel)) { if ($this->calculateNewLevel($user, $superLevel)) {
@@ -149,39 +165,68 @@ 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');
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
// 模拟玩家等待时间 if ($fishingConfig?->enabled) {
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); $cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
$waitTime = rand($waitMin, $waitMax); if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids // 模拟玩家等待时间
$activeRoomIds = $this->chatState->getAllActiveRoomIds(); $waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; $waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
$waitTime = rand($waitMin, $waitMax);
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
}
} }
} }
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。 // 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
$this->aiFinance->bankExcessGold($user); if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$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 = [];
@@ -264,7 +309,9 @@ class AiHeartbeatCommand extends Command
$content = '【'.e($user->username).'】完成今日签到,连续签到 ' $content = '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'; .$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('签到播报', $content, '#0f766e'); $this->broadcastSystemMessage('系统传音', $content, '#0f766e');
return true;
} }
/** /**
+54 -15
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. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化 // - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -178,11 +211,17 @@ class AutoSaveExp extends Command
$activeUP = $user->activePosition; // 已在 refresh 后加载 $activeUP = $user->activePosition; // 已在 refresh 后加载
if ($activeUP?->position) { if ($activeUP?->position) {
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖 // 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
$requiredLevel = (int) $activeUP->position->level; $requiredLevel = (int) $activeUP->position->level;
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) { if ($requiredLevel > 0 && $user->user_level < $requiredLevel) {
$user->user_level = $requiredLevel; $user->user_level = $requiredLevel;
} }
// 职务成员也可以按经验超越职务等级
$expLevel = Sysparam::calculateLevel($user->exp_num);
if ($expLevel > $user->user_level && $expLevel < $superLevel) {
$user->user_level = $expLevel;
$leveledUp = ($expLevel > $oldLevel);
}
} elseif ($oldLevel < $superLevel) { } elseif ($oldLevel < $superLevel) {
// 普通用户:按经验计算并更新等级 // 普通用户:按经验计算并更新等级
$newLevel = Sysparam::calculateLevel($user->exp_num); $newLevel = Sysparam::calculateLevel($user->exp_num);
@@ -241,7 +280,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', // 草绿色
@@ -107,6 +107,44 @@ class ConsumeWechatMessages extends Command
$this->info("收到潜在绑定请求: {$content} from {$fromUser}"); $this->info("收到潜在绑定请求: {$content} from {$fromUser}");
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService); $this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
} }
// 微信密码重置逻辑:必须是私聊
if (! $isChatroom && str_contains($content, '重置密码')) {
$this->info("收到微信密码重置请求 from {$fromUser}");
$this->handlePasswordResetRequest($fromUser, $apiService);
}
}
/**
* 处理微信端的自助密码重置请求
*/
protected function handlePasswordResetRequest(string $wxid, WechatBotApiService $apiService): void
{
// 检索绑定了该微信号的聊天室用户
$user = User::where('wxid', $wxid)->first();
if (! $user) {
$apiService->sendTextMessage(
$wxid,
"❌ 密码重置失败:您当前的微信号尚未绑定任何聊天室账号。\n\n"
.'请先登录聊天室大厅,在个人设置中获取 6 位绑定码,然后再在微信中向我发送“BD-绑定码”(例如: BD-123456)进行绑定。'
);
return;
}
// 随机生成 8 位新随机密码并更新
$newPassword = \Illuminate\Support\Str::random(8);
$user->password = \Illuminate\Support\Facades\Hash::make($newPassword);
$user->save();
$successMsg = "🎉 密码重置成功!\n"
."本小助手已为您绑定的聊天室账号 [{$user->username}] 重新生成了密码:\n\n"
."{$newPassword}\n\n"
.'温馨提示:请使用此随机密码登录聊天室,并尽快在个人偏好设置中将其修改为您的常用密码。';
$apiService->sendTextMessage($wxid, $successMsg);
$this->info("微信用户 [{$user->username}] 已通过微信机器人成功重置了密码");
} }
/** /**
+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();
}
}
+44
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';
@@ -143,6 +146,36 @@ enum CurrencySource: string
/** 查看别人隐藏信息扣费 */ /** 查看别人隐藏信息扣费 */
case USER_INFO_REVEAL = 'user_info_reveal'; case USER_INFO_REVEAL = 'user_info_reveal';
/** 购买消息装扮消耗(气泡,扣除金币) */
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
/** 购买昵称颜色装扮消耗(扣除金币) */
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
/** 购买文字颜色装扮消耗(扣除金币) */
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
case MSG_DECORATION_BUY = 'msg_decoration_buy';
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward';
/** 收到用户赠送的金币 */
case GOLD_GIFT_RECV = 'gold_gift_recv';
/** 聊天互动魅力奖励 */
case CHAT_CHARM = 'chat_charm';
/** 存入银行(jjb → bank_jjb */
case BANK_DEPOSIT = 'bank_deposit';
/** 从银行取款(bank_jjb → jjb */
case BANK_WITHDRAW = 'bank_withdraw';
/** /**
* 返回该来源的中文名称,用于后台统计展示。 * 返回该来源的中文名称,用于后台统计展示。
*/ */
@@ -156,6 +189,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 => '每日签到',
@@ -190,6 +224,16 @@ enum CurrencySource: string
self::GOMOKU_REFUND => '五子棋入场费返还', self::GOMOKU_REFUND => '五子棋入场费返还',
self::VIDEO_REWARD => '看视频奖励', self::VIDEO_REWARD => '看视频奖励',
self::USER_INFO_REVEAL => '信息查看付费', self::USER_INFO_REVEAL => '信息查看付费',
self::MSG_BUBBLE_BUY => '消息气泡购买',
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜谜活动奖励',
self::GOLD_GIFT_RECV => '收到赠送金币',
self::CHAT_CHARM => '聊天魅力奖励',
self::BANK_DEPOSIT => '存入银行',
self::BANK_WITHDRAW => '银行取款',
}; };
} }
} }
+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,
+5 -7
View File
@@ -26,9 +26,9 @@ class MessageSent implements ShouldBroadcastNow
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
/** /**
* Create a new event instance. * 创建消息广播事件实例。
* *
* @param int $roomId 房间ID * @param int $roomId 房间 ID
* @param array $message 发送的消息数据 * @param array $message 发送的消息数据
*/ */
public function __construct( public function __construct(
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
/** /**
* 获取消息应广播到的频道。 * 获取消息应广播到的频道。
* *
* 公共消息走房间 Presence 频道; * 公共消息和普通定向发言走房间 Presence 频道;
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。 * 悄悄话只发给发送方与接收方的私有用户频道。
* *
* @return array<int, \Illuminate\Broadcasting\Channel> * @return array<int, \Illuminate\Broadcasting\Channel>
*/ */
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
*/ */
private function shouldBroadcastPrivately(): bool private function shouldBroadcastPrivately(): bool
{ {
$toUser = trim((string) ($this->message['to_user'] ?? '')); return (bool) ($this->message['is_secret'] ?? false);
return $toUser !== '' && $toUser !== '大家';
} }
/** /**
+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}",
];
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
/** /**
* 文件功能:用户被踢出房间广播事件 * 文件功能:用户被踢出房间广播事件
* *
* 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。 * 管理员踢出/封禁用户时触发,前端监听后强制该用户跳转至大厅。
* *
* @author ChatRoom Laravel * @author ChatRoom Laravel
* *
+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($roomId);
$openedRoomIds[] = $roomId;
} }
\App\Jobs\OpenLotteryIssueJob::dispatch(); if ($openedRoomIds === []) {
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
}
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']); return response()->json([
'ok' => true,
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
]);
} }
/** /**
@@ -0,0 +1,77 @@
<?php
/**
* 文件功能:后台等级经验阈值配置控制器
*
* sysparam 表中的 levelexp 配置拆分为独立后台页面,
* 以列表模式维护每一级所需的累计经验值。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateLevelExpConfigRequest;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
/**
* 类功能:负责展示和保存等级经验阈值列表。
*/
class LevelExpConfigController extends Controller
{
/**
* 方法功能:注入系统参数缓存同步服务。
*/
public function __construct(
private readonly ChatStateService $chatState
) {}
/**
* 方法功能:显示等级经验阈值列表页。
*/
public function index(): View
{
$rawThresholds = Sysparam::getLevelExpThresholds();
$maxLevel = (int) Sysparam::getValue('maxlevel', '99');
$thresholds = collect($rawThresholds)
->values()
->map(fn (int $exp, int $index): array => [
'level' => $index + 1,
'exp' => $exp,
'increment' => $index === 0 ? $exp : $exp - $rawThresholds[$index - 1],
]);
return view('admin.level-exp-configs.index', [
'thresholds' => $thresholds,
'maxLevel' => $maxLevel,
]);
}
/**
* 方法功能:保存等级经验阈值配置,并同步刷新缓存。
*/
public function update(UpdateLevelExpConfigRequest $request): RedirectResponse
{
$thresholds = $request->validated('thresholds');
// 将列表页提交的阈值重新拼成兼容旧逻辑的逗号字符串。
$body = implode(',', $thresholds);
Sysparam::updateOrCreate(
['alias' => 'levelexp'],
[
'body' => $body,
'guidetxt' => '按列表逐级维护升级所需的累计经验阈值',
]
);
// 同步更新 Redis / Cache,确保前台经验等级计算即时生效。
$this->chatState->setSysParam('levelexp', $body);
Sysparam::clearCache('levelexp');
return redirect()->route('admin.level-exp-configs.index')->with('success', '等级经验阈值已保存并生效!');
}
}
@@ -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,'.ShopItem::TYPE_SIGN_REPAIR,
'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',
]);
}
} }
@@ -60,6 +60,14 @@ class SystemController extends Controller
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。 // 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
$data = $request->only($this->editableSystemAliases()); $data = $request->only($this->editableSystemAliases());
if (array_key_exists('maxlevel', $data)) {
$normalizedMaxLevel = max(1, (int) $data['maxlevel']);
// 管理员级别始终跟随最高等级 + 1,避免两个配置页出现口径漂移。
$data['maxlevel'] = (string) $normalizedMaxLevel;
$data['superlevel'] = (string) ($normalizedMaxLevel + 1);
}
foreach ($data as $alias => $body) { foreach ($data as $alias => $body) {
$normalizedBody = (string) $body; $normalizedBody = (string) $body;
@@ -88,7 +96,7 @@ class SystemController extends Controller
return SysParam::query() return SysParam::query()
->orderBy('id') ->orderBy('id')
->pluck('alias') ->pluck('alias')
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias)) ->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
->values() ->values()
->all(); ->all();
} }
@@ -104,4 +112,21 @@ class SystemController extends Controller
return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']); return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']);
} }
/**
* 判断参数是否已经迁移到独立配置页。
*/
private function isDedicatedAlias(string $alias): bool
{
return in_array($alias, [
'levelexp',
'level_warn',
'level_mute',
'level_kick',
'level_announcement',
'level_ban',
'level_banip',
'level_freeze',
], true);
}
} }
@@ -241,10 +241,10 @@ class UserManagerController extends Controller
abort(403, '权限不足:无法删除同级或高级账号!'); abort(403, '权限不足:无法删除同级或高级账号!');
} }
// 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀 // 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10'); $targetUser->loadMissing('activePosition.position');
if ($targetUser->user_level >= $levelKick) { if ($targetUser->id === 1 || $targetUser->activePosition?->position) {
abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。'); abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。');
} }
$targetUser->delete(); $targetUser->delete();
+132 -31
View File
@@ -4,7 +4,7 @@
* 文件功能:管理员聊天室实时命令控制器 * 文件功能:管理员聊天室实时命令控制器
* *
* 提供管理员在聊天室内对用户执行的管理操作: * 提供管理员在聊天室内对用户执行的管理操作:
* 警告(=J)、踢出(=T)、禁言(=B)冻结(=Y)、查看私信(=S)、职务公屏讲话。 * 警告(=J)、踢出(=T)、禁言(=B)封号、封IP、查看私信(=S)、职务公屏讲话。
* *
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP * 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
* *
@@ -278,14 +278,14 @@ class AdminCommandController extends Controller
} }
/** /**
* 冻结用户账号=Y 理由) * 封禁用户账号
* *
* 用户账号状态设为冻结,禁止登录 * 目标账号设为封禁状态,并将其从当前在线房间中移出
* *
* @param Request $request 请求对象,需包含 username, reason * @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果 * @return JsonResponse 操作结果
*/ */
public function freeze(Request $request): JsonResponse public function ban(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'username' => 'required|string', 'username' => 'required|string',
@@ -301,12 +301,12 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403); return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
} }
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则')); $reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername); $safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin); $operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。 // 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_FREEZE, '冻结'); $authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁');
if (! $authorization['ok']) { if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403); return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
} }
@@ -316,37 +316,30 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403); return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
} }
// 冻结用户账号(将等级设为 -1 表示冻结)
$target = $authorization['target']; $target = $authorization['target'];
$target->user_level = -1; $target->user_level = -1;
$target->save(); $target->save();
// 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
$this->pushTargetToastMessage( $this->pushTargetToastMessage(
roomId: (int) $roomId, roomId: $roomId,
targetUsername: $targetUsername, targetUsername: $targetUsername,
content: "🧊 {$operatorDisplay}冻结你的账号。原因:{$reason}", content: " {$operatorDisplay}封禁你的账号。原因:{$reason}",
title: '🧊 账号已冻结', title: ' 账号已封禁',
toastMessage: "{$operatorDisplay}冻结你的账号。<br>原因:{$reason}", toastMessage: "{$operatorDisplay}封禁你的账号。<br>原因:{$reason}",
color: '#3b82f6', color: '#991b1b',
icon: '🧊', icon: '',
); );
// 从所有房间移除 $this->removeUserFromAllRooms($targetUsername);
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave($rid, $targetUsername);
}
// 广播冻结消息
$msg = [ $msg = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统传音', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => "🧊 {$operatorDisplay}冻结 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}", 'content' => " {$operatorDisplay}封禁 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false, 'is_secret' => false,
'font_color' => '#dc2626', 'font_color' => '#991b1b',
'action' => '', 'action' => '',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
@@ -354,10 +347,93 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg)); broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg); SaveMessageJob::dispatch($msg);
// 广播踢出事件 broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}"));
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}"));
return response()->json(['status' => 'success', 'message' => "冻结 {$targetUsername} 的账号"]); return response()->json(['status' => 'success', 'message' => "封禁 {$targetUsername} 的账号"]);
}
/**
* 封禁用户 IP。
*
* 将目标账号设为封禁状态并把最近登录 IP 写入黑名单。
*
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function banIp(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:200',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BANIP, '封禁 IP ');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
$target = $authorization['target'];
$targetIp = (string) ($target->last_ip ?? '');
if ($targetIp !== '') {
Redis::sadd('banned_ips', $targetIp);
}
$target->user_level = -1;
$target->save();
$this->pushTargetToastMessage(
roomId: $roomId,
targetUsername: $targetUsername,
content: "🌐 {$operatorDisplay} 已封禁你的 IP 并冻结账号。原因:{$reason}",
title: '🌐 IP 已封禁',
toastMessage: "{$operatorDisplay} 已封禁你的 IP 并冻结账号。<br>原因:{$reason}",
color: '#7c2d12',
icon: '🌐',
);
$this->removeUserFromAllRooms($targetUsername);
$ipSuffix = $targetIp !== '' ? "IP{$targetIp}" : '(未记录有效 IP';
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🌐 {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的 IP 并冻结账号。原因:{$reason} {$ipSuffix}",
'is_secret' => false,
'font_color' => '#9a3412',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "IP 已被封禁:{$reason}"));
return response()->json([
'status' => 'success',
'message' => $targetIp !== '' ? "已封禁 {$targetUsername} 的 IP 与账号" : "已封禁 {$targetUsername} 的账号,但未记录可封禁 IP",
]);
} }
/** /**
@@ -381,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);
@@ -448,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));
@@ -515,8 +599,8 @@ class AdminCommandController extends Controller
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令']; return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
} }
// 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。 // 超级管理员(站长 id = 1)豁免必须进房的限制,允许全局管理;普通管理员必须在房间内方可管理
if (! $this->chatState->isUserInRoom($roomId, $operator->username)) { if ($operator->id !== 1 && ! $this->chatState->isUserInRoom($roomId, $operator->username)) {
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令']; return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
} }
@@ -530,6 +614,11 @@ class AdminCommandController extends Controller
*/ */
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
{ {
// 系统虚拟机器人账号豁免在线状态校验,允许测试与交互警告
if (in_array($targetUsername, ['AI小班长', '星海小博士'])) {
return ['ok' => true, 'message' => '校验通过'];
}
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) { if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作']; return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
} }
@@ -555,7 +644,7 @@ class AdminCommandController extends Controller
string $permissionCode, string $permissionCode,
string $actionLabel, string $actionLabel,
): array { ): array {
if (! $this->positionPermissionService->hasPermission($admin, $permissionCode)) { if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"]; return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
} }
@@ -583,6 +672,18 @@ class AdminCommandController extends Controller
return ['ok' => true, 'message' => '校验通过', 'target' => $target]; return ['ok' => true, 'message' => '校验通过', 'target' => $target];
} }
/**
* 将目标用户从当前在线的全部房间中移除。
*/
private function removeUserFromAllRooms(string $targetUsername): void
{
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave((int) $rid, $targetUsername);
}
}
/** /**
* 判断操作者是否可以按职务位阶处理目标用户。 * 判断操作者是否可以按职务位阶处理目标用户。
* *
@@ -928,7 +1029,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);
+69 -30
View File
@@ -19,17 +19,25 @@ use App\Enums\CurrencySource;
use App\Models\BaccaratBet; use App\Models\BaccaratBet;
use App\Models\BaccaratRound; use App\Models\BaccaratRound;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\User;
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 +46,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 +112,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,13 +128,13 @@ 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' => '当前不在下注时间内。']);
} }
$user = $request->user(); $user = $request->user();
// 检查用户金币余额(金币字段为 jjb // 快速过滤(非锁
if (($user->jjb ?? 0) < $data['amount']) { if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']); return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
} }
@@ -124,10 +143,21 @@ class BaccaratController extends Controller
$lossCoverService = $this->lossCoverService; $lossCoverService = $this->lossCoverService;
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse { return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
// 幂等:同一局只能下一注 // 1. 悲观锁锁定用户行
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
// 2. 锁保护下二次校验余额
if ((int) $lockedUser->jjb < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
// 3. 幂等:同一局只能下一注
$existing = BaccaratBet::query() $existing = BaccaratBet::query()
->where('round_id', $round->id) ->where('round_id', $round->id)
->where('user_id', $user->id) ->where('user_id', $lockedUser->id)
->lockForUpdate() ->lockForUpdate()
->exists(); ->exists();
@@ -135,9 +165,9 @@ class BaccaratController extends Controller
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']); return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
} }
// 扣除金币 // 4. 扣除金币,传入锁定的用户实例
$currency->change( $currency->change(
$user, $lockedUser,
'gold', 'gold',
-$data['amount'], -$data['amount'],
CurrencySource::BACCARAT_BET, CurrencySource::BACCARAT_BET,
@@ -149,16 +179,19 @@ class BaccaratController extends Controller
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。 // 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
$lossCoverEvent = $lossCoverService->findEventForBetTime(now()); $lossCoverEvent = $lossCoverService->findEventForBetTime(now());
// 写入下注记录 // 5. 写入下注记录
$bet = BaccaratBet::create([ $bet = BaccaratBet::create([
'round_id' => $round->id, 'round_id' => $round->id,
'user_id' => $user->id, 'user_id' => $lockedUser->id,
'loss_cover_event_id' => $lossCoverEvent?->id, 'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $data['bet_type'], 'bet_type' => $data['bet_type'],
'amount' => $data['amount'], 'amount' => $data['amount'],
'status' => 'pending', 'status' => 'pending',
]); ]);
// 同步修改 Auth 内存实例的金币
$user->setAttribute('jjb', $lockedUser->jjb);
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。 // 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
$lossCoverService->registerBet($bet); $lossCoverService->registerBet($bet);
@@ -176,27 +209,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 +225,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)
@@ -220,4 +235,28 @@ class BaccaratController extends Controller
return response()->json(['history' => $rounds]); return response()->json(['history' => $rounds]);
} }
/**
* 前台百家乐历史走势与开奖页面。
*/
public function historyPage(Request $request): \Illuminate\View\View
{
// 1. 各选项的历史分布统计
$summary = [
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
'result_dist' => BaccaratRound::query()
->where('status', 'settled')
->select('result', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('result')
->pluck('cnt', 'result'),
];
// 2. 分页拉取所有已结算的局次
$rounds = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
->paginate(30);
return view('rooms.baccarat-history', compact('rounds', 'summary'));
}
} }
+82 -26
View File
@@ -13,9 +13,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\BankLog; use App\Models\BankLog;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\UserCurrencyService;
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;
@@ -26,6 +28,10 @@ use Illuminate\Support\Facades\DB;
*/ */
class BankController extends Controller class BankController extends Controller
{ {
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/** /**
* 查询银行余额及最近20条流水记录 * 查询银行余额及最近20条流水记录
*/ */
@@ -99,6 +105,7 @@ class BankController extends Controller
$amount = $request->integer('amount'); $amount = $request->integer('amount');
$user = Auth::user(); $user = Auth::user();
// 快速过滤(非锁),降低非法请求穿透到数据库的概率
if (($user->jjb ?? 0) < $amount) { if (($user->jjb ?? 0) < $amount) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
@@ -106,25 +113,49 @@ class BankController extends Controller
]); ]);
} }
DB::transaction(function () use ($user, $amount): void { try {
$user->decrement('jjb', $amount); DB::transaction(function () use ($user, $amount): void {
$user->increment('bank_jjb', $amount); // 1. 强制在数据库层面对用户行数据加写锁(X锁)
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
BankLog::create([ // 2. 在锁保护下安全校验最新余额
'user_id' => $user->id, if ((int) $lockedUser->jjb < $amount) {
'type' => 'deposit', throw new \Exception('流通金币不足!当前余额 '.(int) $lockedUser->jjb." 枚,无法存入 {$amount} 枚。");
'amount' => $amount, }
'balance_after' => $user->fresh()->bank_jjb,
// 3. 执行资产扣除,将已加锁的 lockedUser 传给 change 方法
$this->currencyService->change($lockedUser, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
// 4. 增加银行余额
$lockedUser->increment('bank_jjb', $amount);
// 5. 写入银行流水记录
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'deposit',
'amount' => $amount,
'balance_after' => $lockedUser->fresh()->bank_jjb,
]);
// 6. 同步 Auth 内存状态,保障同生命周期内其他地方拿到的是正确数据
$user->setAttribute('jjb', $lockedUser->jjb);
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
});
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
]); ]);
}); }
$fresh = $user->fresh();
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => "成功存入 {$amount} 枚金币!", 'message' => "成功存入 {$amount} 枚金币!",
'jjb' => $fresh->jjb, 'jjb' => $user->jjb,
'bank_jjb' => $fresh->bank_jjb, 'bank_jjb' => $user->bank_jjb,
]); ]);
} }
@@ -142,6 +173,7 @@ class BankController extends Controller
$amount = $request->integer('amount'); $amount = $request->integer('amount');
$user = Auth::user(); $user = Auth::user();
// 快速过滤(非锁)
if (($user->bank_jjb ?? 0) < $amount) { if (($user->bank_jjb ?? 0) < $amount) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
@@ -149,25 +181,49 @@ class BankController extends Controller
]); ]);
} }
DB::transaction(function () use ($user, $amount): void { try {
$user->decrement('bank_jjb', $amount); DB::transaction(function () use ($user, $amount): void {
$user->increment('jjb', $amount); // 1. 强制在数据库层面对用户行数据加写锁(X锁)
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
BankLog::create([ // 2. 校验银行存款是否足够
'user_id' => $user->id, if ((int) $lockedUser->bank_jjb < $amount) {
'type' => 'withdraw', throw new \Exception('银行余额不足!当前存款 '.($lockedUser->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。");
'amount' => $amount, }
'balance_after' => $user->fresh()->bank_jjb,
// 3. 扣除银行存款
$lockedUser->decrement('bank_jjb', $amount);
// 4. 增加流通金币并记录划转日志
$this->currencyService->change($lockedUser, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
// 5. 记录银行账户流水
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'withdraw',
'amount' => $amount,
'balance_after' => $lockedUser->fresh()->bank_jjb,
]);
// 6. 同步 Auth 内存状态
$user->setAttribute('jjb', $lockedUser->jjb);
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
});
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
]); ]);
}); }
$fresh = $user->fresh();
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => "成功取出 {$amount} 枚金币!", 'message' => "成功取出 {$amount} 枚金币!",
'jjb' => $fresh->jjb, 'jjb' => $user->jjb,
'bank_jjb' => $fresh->bank_jjb, 'bank_jjb' => $user->bank_jjb,
]); ]);
} }
+7 -4
View File
@@ -85,19 +85,22 @@ class ChatBotController extends Controller
$reply = $result['reply']; $reply = $result['reply'];
// 检查 AI 是否决定给用户发金币 // 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额]
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) { if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply); $aiGoldAmount = (int) $matches[1];
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
$reply = trim($reply); $reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1'); $maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000'); $maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
// 校验 AI 给出的金额在合理范围内
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id; $redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
$dailyCount = (int) Redis::get($redisKey); $dailyCount = (int) Redis::get($redisKey);
if ($dailyCount < $maxDailyRewards) { if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。 // 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) { if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
+229 -54
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,15 +120,24 @@ class ChatController extends Controller
// 3. 广播和初始化欢迎(仅限初次进入) // 3. 广播和初始化欢迎(仅限初次进入)
$newbieEffect = null; $newbieEffect = null;
$initialRideEffect = null;
$initialRideEffectOptions = null;
$initialPresenceTheme = null; $initialPresenceTheme = null;
$initialWelcomeMessage = null; $initialWelcomeMessage = null;
$initialWelcomeMessages = [];
if (! $isAlreadyInRoom) { if (! $isAlreadyInRoom) {
// 广播 UserJoined 事件,通知房间内的其他人 // 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告 // 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
if (! $user->has_received_new_gift) { // 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
$user->refresh();
$isNewbie = ! $user->has_received_new_gift;
if ($isNewbie) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水 // 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change( $this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id, $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
@@ -142,55 +155,127 @@ class ChatController extends Controller
'font_color' => '#b91c1c', 'font_color' => '#b91c1c',
'action' => '', 'action' => '',
'welcome_user' => $user->username, 'welcome_user' => $user->username,
'welcome_kind' => 'newbie_bonus',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
]; ];
$this->chatState->pushMessage($id, $newbieMsg); $this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg)); broadcast(new MessageSent($id, $newbieMsg));
SaveMessageJob::dispatch($newbieMsg);
$initialWelcomeMessages[] = $newbieMsg;
// 广播烟花特效给此时已在房间的其他用户 // 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers(); broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花 // 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks'; $newbieEffect = 'fireworks';
}
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报 // AI小班长发送欢迎消息,附带聊天室简单介绍
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条 $aiWelcomeTemplates = [
$this->chatState->removeOldWelcomeMessages($id, $user->username); "🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你!",
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
];
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
$aiWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => 'AI小班长',
'to_user' => '大家',
'content' => $aiWelcomeContent,
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'welcome_user' => $user->username,
'welcome_kind' => 'ai_newbie_welcome',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $aiWelcomeMsg);
broadcast(new MessageSent($id, $aiWelcomeMsg));
SaveMessageJob::dispatch($aiWelcomeMsg);
$initialWelcomeMessages[] = $aiWelcomeMsg;
}
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。 // 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
[$text, $color] = $this->broadcast->buildEntryBroadcast($user); $ridePresencePayload = $this->rideService->buildPresencePayload($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join'); if (! $ridePresencePayload) {
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
$generalWelcomeMsg = [ $generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id), 'id' => $this->chatState->nextMessageId($id),
'room_id' => $id, 'room_id' => $id,
'from_user' => '进出播报', 'from_user' => '进出播报',
'to_user' => '大家', 'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>", 'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'is_secret' => false, 'is_secret' => false,
'font_color' => $color, 'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence', 'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username, 'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(), 'welcome_kind' => 'entry_broadcast',
]; 'sent_at' => now()->toDateTimeString(),
];
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。 // 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) { if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload); $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload; $initialPresenceTheme = $vipPresencePayload;
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
}
} }
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。 if ($ridePresencePayload) {
$initialWelcomeMessage = $generalWelcomeMsg; $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, $generalWelcomeMsg); // 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示 $this->chatState->pushMessage($id, $rideWelcomeMsg);
broadcast(new MessageSent($id, $generalWelcomeMsg)); 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'];
if (! empty($vipPresencePayload['presence_effect'])) { $initialRideEffectOptions = [
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers(); 'effect_title' => $ridePresencePayload['effect_title'],
'effect_user_info' => $ridePresencePayload['effect_user_info'],
'ride_name' => $ridePresencePayload['ride_name'],
'operator' => $user->username,
];
$initialWelcomeMessages[] = $rideWelcomeMsg;
} }
} }
@@ -213,8 +298,8 @@ class ChatController extends Controller
return $fromUser === $username || $toUser === $username; return $fromUser === $username || $toUser === $username;
} }
// 对特定人说话:只显示发给自己或自己发出的(含系统通知) // 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
return $fromUser === $username || $toUser === $username; return true;
})); }));
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次) // 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
@@ -279,13 +364,17 @@ 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,
'historyMessages' => $historyMessages, 'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData, 'pendingProposal' => $pendingProposalData,
'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),
]); ]);
@@ -413,6 +502,21 @@ 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(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations);
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条) // 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData); $this->chatState->pushMessage($id, $messageData);
@@ -515,15 +619,20 @@ class ChatController extends Controller
$superLevel = (int) Sysparam::getValue('superlevel', '100'); $superLevel = (int) Sysparam::getValue('superlevel', '100');
$leveledUp = $this->calculateNewLevel($user, $superLevel); $leveledUp = $this->calculateNewLevel($user, $superLevel);
$user->save(); // 存点入库 $hasChanges = $leveledUp || ($canGainReward && ($actualExpGain > 0 || $actualJjbGain > 0));
if ($hasChanges) {
$user->save(); // 存点入库
// 将新的等级与资产状态反馈给当前用户的在线名单上,确保别人查看到的也是最准确等级
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
} else {
// 无变动时,仅在 Redis 层面保活该用户的在线状态,拒绝写库并减少 Redis HSET 负荷
$this->chatState->refreshAlive($id, $user->username);
}
// 手动心跳存点:同步更新在职用户的勤务时长 // 手动心跳存点:同步更新在职用户的勤务时长
$this->tickDutyLog($user, $id); $this->tickDutyLog($user, $id);
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
// 4. 如果突破境界,向全房系统喊话广播! // 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) { if ($leveledUp) {
// 生成炫酷广播消息发向该频道 // 生成炫酷广播消息发向该频道
@@ -987,12 +1096,9 @@ class ChatController extends Controller
]); ]);
} }
// 扣除金币、增加对方魅力 // 扣除金币、增加对方魅力(含流水日志)
$user->jjb = ($user->jjb ?? 0) - $totalCost; $this->currencyService->change($user, 'gold', -$totalCost, CurrencySource::SEND_GIFT, "送花:送给 {$toUsername} {$count} 份【{$gift->name}】,魅力 +{$totalCharm}", $roomId);
$user->save(); $this->currencyService->change($toUser, 'charm', $totalCharm, CurrencySource::RECV_GIFT, "收到 {$user->username} 赠送的 {$count} 份【{$gift->name}", $roomId);
$toUser->meili = ($toUser->meili ?? 0) + $totalCharm;
$toUser->save();
// 构建礼物图片 URL // 构建礼物图片 URL
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : ''; $giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
@@ -1024,8 +1130,8 @@ class ChatController extends Controller
'status' => 'success', 'status' => 'success',
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}", 'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
'data' => [ 'data' => [
'my_jjb' => $user->jjb, 'my_jjb' => $user->fresh()->jjb,
'target_charm' => $toUser->meili, 'target_charm' => $toUser->fresh()->meili,
], ],
]); ]);
} }
@@ -1078,9 +1184,8 @@ class ChatController extends Controller
return; return;
} }
// 发送者获得魅力 // 发送者获得魅力(含流水日志)
$sender->meili = ($sender->meili ?? 0) + $charmGain; $this->currencyService->change($sender, 'charm', $charmGain, CurrencySource::CHAT_CHARM, "{$toUsername} 聊天获得魅力奖励");
$sender->save();
// 更新 Redis 计数器(1 小时过期) // 更新 Redis 计数器(1 小时过期)
Redis::incrby($capKey, $charmGain); Redis::incrby($capKey, $charmGain);
@@ -1303,9 +1408,9 @@ class ChatController extends Controller
]); ]);
} }
// 执行转账(直接操作字段,与 sendFlower 保持一致风格 // 执行转账(含流水日志
$sender->decrement('jjb', $amount); $this->currencyService->change($sender, 'gold', -$amount, CurrencySource::GIFT_SENT, "赠送金币给 {$toName}", $roomId);
$receiver->increment('jjb', $amount); $this->currencyService->change($receiver, 'gold', $amount, CurrencySource::GOLD_GIFT_RECV, "收到 {$sender->username} 赠送的金币", $roomId);
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。 // 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
$giftMsg = [ $giftMsg = [
@@ -1324,7 +1429,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,
], ],
]; ];
@@ -1364,6 +1469,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,
]);
}
/** /**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。 * 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/ */
@@ -154,7 +154,7 @@ class DailySignInController extends Controller
$message = [ $message = [
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '签到播报', 'from_user' => '系统传音',
'to_user' => '大家', 'to_user' => '大家',
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays), 'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
'is_secret' => false, 'is_secret' => false,
@@ -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 = [
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
return view('feedback.index', compact('feedbacks', 'myVotedIds')); return view('feedback.index', compact('feedbacks', 'myVotedIds'));
} }
/**
* 获取反馈第一页数据(JSON API
* 供聊天室模态弹窗使用,格式与 loadMore 一致
*
* @param Request $request type 筛选参数
*/
public function data(Request $request): JsonResponse
{
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $items->pluck('id'))
->pluck('feedback_id')
->toArray();
return response()->json([
'items' => $this->formatItems($items, $myVotedIds),
'last_id' => $items->last()?->id ?? 0,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/** /**
* 懒加载更多反馈(JSON API * 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion * 支持按类型筛选(bug / suggestion
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
*/ */
private function formatItem(FeedbackItem $item, bool $voted): array private function formatItem(FeedbackItem $item, bool $voted): array
{ {
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $item->user_id === $user->id;
return [ return [
'id' => $item->id, 'id' => $item->id,
'type' => $item->type, 'type' => $item->type,
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
'username' => $item->username, 'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(), 'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted, 'voted' => $voted,
'is_owner' => $isOwner,
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [ 'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id, 'id' => $r->id,
'username' => $r->username, 'username' => $r->username,
+93 -17
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,34 +105,54 @@ class FishingController extends Controller
], 422); ], 422);
} }
// 3. 扣除金币 // 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); $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')); $waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax); $waitTime = rand($waitMin, $waitMax);
$token = Str::random(32); $token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}"; $tokenTtl = $waitTime + 13;
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲 $tokenPayload = json_encode([
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
'token' => $token, 'token' => $token,
'cast_at' => time(), 'cast_at' => time(),
'wait_time' => $waitTime, 'wait_time' => $waitTime,
])); ]);
// 5. 生成随机浮漂坐标(百分比,避开边缘) // 原子占用本次抛竿 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(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
} catch (\Throwable $exception) {
// 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。
Redis::del($tokenKey);
throw $exception;
}
// 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')
+25 -1
View File
@@ -24,17 +24,23 @@ 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\Models\User;
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 +64,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) {
@@ -83,13 +93,27 @@ class GomokuController extends Controller
return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse { return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse {
// PvE 扣除入场费 // PvE 扣除入场费
if ($entryFee > 0) { if ($entryFee > 0) {
// 1. 悲观锁锁定用户行
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
// 2. 二次确认金币是否足够
if ((int) $lockedUser->jjb < $entryFee) {
return response()->json(['ok' => false, 'message' => '金币不足,无法加入游戏对局。']);
}
$this->currency->change( $this->currency->change(
$user, $lockedUser,
'gold', 'gold',
-$entryFee, -$entryFee,
CurrencySource::GOMOKU_ENTRY_FEE, CurrencySource::GOMOKU_ENTRY_FEE,
"五子棋 AI 对战入场费(难度{$data['ai_level']}", "五子棋 AI 对战入场费(难度{$data['ai_level']}",
); );
// 同步修改 Auth 内存实例的金币
$user->setAttribute('jjb', $lockedUser->jjb);
} }
$timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60); $timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60);
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
use App\Models\Guestbook; use App\Models\Guestbook;
use App\Models\User; use App\Models\User;
use App\Services\MessageFilterService; use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
return back()->with('success', '该行留言已被抹除。'); return back()->with('success', '该行留言已被抹除。');
} }
/**
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
*/
public function data(Request $request): JsonResponse
{
$tab = $request->input('tab', 'public');
$page = (int) $request->input('page', 1);
$user = Auth::user();
$query = Guestbook::query()->orderByDesc('id');
if ($tab === 'inbox') {
$query->where('towho', $user->username);
} elseif ($tab === 'outbox') {
$query->where('who', $user->username);
} else {
$query->where(function ($q) use ($user) {
$q->where('secret', 0)
->orWhere('who', $user->username)
->orWhere('towho', $user->username);
});
}
$perPage = 15;
$total = $query->count();
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
$items = $messages->map(function ($msg) use ($user) {
$isSecret = (bool) $msg->secret;
$isToMe = $msg->towho === $user->username;
$isFromMe = $msg->who === $user->username;
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
return [
'id' => $msg->id,
'who' => $msg->who,
'towho' => $msg->towho ?: '',
'secret' => $isSecret,
'text_body' => $msg->text_body,
'post_time' => $msg->post_time?->diffForHumans() ?? '',
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
'is_to_me' => $isToMe,
'is_from_me' => $isFromMe,
'can_delete' => $canDelete,
'who_avatar' => mb_substr($msg->who, 0, 1),
];
});
// 获取所有用户名列表(供发信选择器使用)
$users = User::where('username', '!=', $user->username)
->orderBy('username')
->pluck('username');
return response()->json([
'ok' => true,
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'has_more' => ($page * $perPage) < $total,
'users' => $users,
'tab' => $tab,
]);
}
} }
+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);
} }
} }
@@ -19,6 +19,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@@ -37,6 +38,64 @@ class PasswordResetController extends Controller
]); ]);
} }
/**
* 账号检测接口:根据昵称检测是否绑定微信或邮箱,并提供分流依据(支持 IP 防扫限流)。
*/
public function checkAccount(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|max:100',
]);
$username = trim((string) $request->input('username'));
$ip = $request->ip();
// IP 防扫描节流限制:每个 IP 每 1 分钟最多请求 5 次
$ipKey = 'pw-check:ip:'.$ip;
if (RateLimiter::tooManyAttempts($ipKey, 5)) {
$seconds = RateLimiter::availableIn($ipKey);
return response()->json([
'status' => 'error',
'message' => "账号检测请求过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
RateLimiter::hit($ipKey, 60);
$user = User::query()->where('username', $username)->first();
if (! $user) {
return response()->json([
'status' => 'not_found',
'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。',
]);
}
$hasEmail = ! empty($user->email);
$hasWechat = ! empty($user->wxid);
// 对邮箱地址进行安全脱敏(如 pllx@ay.lc -> p***x@ay.lc
$maskedEmail = '';
if ($hasEmail) {
$parts = explode('@', $user->email);
$name = $parts[0] ?? '';
$domain = $parts[1] ?? '';
$len = strlen($name);
if ($len <= 2) {
$maskedEmail = substr($name, 0, 1).'*'.'@'.$domain;
} else {
$maskedEmail = substr($name, 0, 1).str_repeat('*', $len - 2).substr($name, -1).'@'.$domain;
}
}
return response()->json([
'status' => 'success',
'username' => $user->username,
'has_email' => $hasEmail,
'has_wechat' => $hasWechat,
'masked_email' => $maskedEmail,
]);
}
/** /**
* 发送邮箱找回密码链接。 * 发送邮箱找回密码链接。
*/ */
@@ -49,7 +108,49 @@ class PasswordResetController extends Controller
], 403); ], 403);
} }
$email = trim((string) $request->string('email')); $inputEmail = trim((string) $request->input('email', ''));
$username = trim((string) $request->input('username', ''));
$ip = $request->ip();
$user = User::query()->where('username', $username)->first();
if (! $user || empty($user->email)) {
return response()->json([
'status' => 'error',
'message' => '找不到绑定了邮箱的账号。',
], 422);
}
// 强行双向比对核对(忽略大小写和前后空白)
if (strcasecmp($user->email, $inputEmail) !== 0) {
return response()->json([
'status' => 'error',
'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。',
], 422);
}
$email = $user->email;
// 1. IP 级别发信限流:限制单个 IP 每分钟最多请求 2 次
$ipKey = 'pw-email:ip:'.$ip;
if (RateLimiter::tooManyAttempts($ipKey, 2)) {
$seconds = RateLimiter::availableIn($ipKey);
return response()->json([
'status' => 'error',
'message' => "发送验证链接过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
// 2. 账号邮箱级别冷却锁:同一个邮箱每 3 分钟限发 1 次,防止狂刷邮件轰炸他人
$targetKey = 'pw-email:target:'.md5($email);
if (RateLimiter::tooManyAttempts($targetKey, 1)) {
$seconds = RateLimiter::availableIn($targetKey);
return response()->json([
'status' => 'error',
'message' => "该账号申请重置链接过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
// 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。 // 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。
if (User::query()->where('email', $email)->count() > 1) { if (User::query()->where('email', $email)->count() > 1) {
@@ -59,6 +160,10 @@ class PasswordResetController extends Controller
], 422); ], 422);
} }
// 记录发信请求频率
RateLimiter::hit($ipKey, 60);
RateLimiter::hit($targetKey, 180);
$status = Password::sendResetLink(['email' => $email]); $status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) { if ($status === Password::RESET_LINK_SENT) {
+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);
}
}
+43 -13
View File
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast; use App\Events\EffectBroadcast;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Models\Room; use App\Models\Room;
use App\Models\ShopItem; use App\Models\ShopItem;
use App\Models\UserPurchase; use App\Models\UserPurchase;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\DecorationService;
use App\Services\ShopService; use App\Services\ShopService;
use App\Support\ChatContentSanitizer; use App\Support\ChatContentSanitizer;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -38,19 +40,23 @@ 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()
'id' => $item->id, ->where('is_active', true)
'name' => $item->name, ->orderBy('sort_order')
'slug' => $item->slug, ->get()
'description' => $item->description, ->map(fn ($item) => [
'icon' => $item->icon, 'id' => $item->id,
'price' => $item->price, 'name' => $item->name,
'type' => $item->type, 'slug' => $item->slug,
'duration_days' => $item->duration_days, 'description' => $item->description,
'duration_minutes' => $item->duration_minutes, 'icon' => $item->icon,
'intimacy_bonus' => $item->intimacy_bonus, 'price' => $item->price,
'charm_bonus' => $item->charm_bonus, 'type' => $item->type,
]); 'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
]);
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR); $signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
@@ -73,6 +79,8 @@ class ShopController extends Controller
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user), 'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user), 'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
'sign_repair_card_item' => $signRepairCard, 'sign_repair_card_item' => $signRepairCard,
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
]); ]);
} }
@@ -121,6 +129,27 @@ class ShopController extends Controller
'total_price' => $result['total_price'] ?? $item->price, 'total_price' => $result['total_price'] ?? $item->price,
]; ];
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
if ($item->type === 'auto_fishing') {
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
}
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
if (! empty($result['slot'])) {
$response['slot'] = $result['slot'];
$response['style'] = $result['style'];
$response['expires_at'] = $result['expires_at'];
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
$freshUser = $user->fresh();
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
}
}
// ── 单次特效卡:广播给指定用户或全员 ──────────────────────── // ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) { if (isset($result['play_effect'])) {
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员 $recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
@@ -205,6 +234,7 @@ class ShopController extends Controller
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?", 'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!", 'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!", ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1",
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。", default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
}; };
+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);
} }
} }
+36 -174
View File
@@ -2,13 +2,8 @@
/** /**
* 文件功能:用户中心与管理控制器 * 文件功能:用户中心与管理控制器
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP * 接管原版 USERinfo.ASP, USERSET.ASP chpasswd.asp。
* * 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
* 权限等级通过 sysparam 表动态配置:
* level_kick - 踢人所需等级
* level_mute - 禁言所需等级
* level_ban - 封号所需等级
* level_banip - 封IP所需等级
* *
* @author ChatRoom Laravel * @author ChatRoom Laravel
* *
@@ -18,25 +13,24 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Events\UserStatusUpdated; use App\Events\UserStatusUpdated;
use App\Http\Requests\ChangePasswordRequest; use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\RevealProfileInfoRequest; use App\Http\Requests\RevealProfileInfoRequest;
use App\Http\Requests\UpdateChatPreferencesRequest; use App\Http\Requests\UpdateChatPreferencesRequest;
use App\Http\Requests\UpdateDailyStatusRequest; use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
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\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
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;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/** /**
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。 * 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
@@ -65,6 +59,8 @@ 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,
) {} ) {}
/** /**
@@ -89,6 +85,7 @@ class UserController extends Controller
$activePosition = $targetUser->activePosition?->load('position.department')->position; $activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [ $data = [
'username' => $targetUser->username, 'username' => $targetUser->username,
'previous_name' => $targetUser->previous_name,
'sex' => match ((int) $targetUser->sex) { 'sex' => match ((int) $targetUser->sex) {
1 => '男', 2 => '女', default => '' 1 => '男', 2 => '女', default => ''
}, },
@@ -165,10 +162,13 @@ 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(level_banip)或踢人以上权限的管理,可以查看IP和归属地 // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
$levelBanIp = (int) Sysparam::getValue('level_banip', '15'); $canViewNetworkInfo = $operator
if ($operator && $operator->user_level >= $levelBanIp) { && ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP));
if ($canViewNetworkInfo) {
$data['first_ip'] = $targetUser->first_ip; $data['first_ip'] = $targetUser->first_ip;
// last_ip 目前定义为『上次登录IP』(取数据库 previous_ip // last_ip 目前定义为『上次登录IP』(取数据库 previous_ip
$data['last_ip'] = $targetUser->previous_ip; $data['last_ip'] = $targetUser->previous_ip;
@@ -308,19 +308,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,
]); ]);
@@ -415,7 +433,7 @@ class UserController extends Controller
/** /**
* 生成微信绑定代码 * 生成微信绑定代码
*/ */
public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse public function generateWechatCode(Request $request): JsonResponse
{ {
$user = \Illuminate\Support\Facades\Auth::user(); $user = \Illuminate\Support\Facades\Auth::user();
if (! $user) { if (! $user) {
@@ -435,7 +453,7 @@ class UserController extends Controller
/** /**
* 取消绑定微信 * 取消绑定微信
*/ */
public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse public function unbindWechat(Request $request): JsonResponse
{ {
$user = \Illuminate\Support\Facades\Auth::user(); $user = \Illuminate\Support\Facades\Auth::user();
if (! $user) { if (! $user) {
@@ -451,162 +469,6 @@ class UserController extends Controller
]); ]);
} }
/**
* 通用权限校验:检查操作者是否有权操作目标用户
*
* @param object $operator 操作者
* @param string $targetUsername 目标用户名
* @param int $roomId 房间ID
* @param string $levelKey sysparam中的等级键名(如 level_kick
* @param string $actionName 操作名称(用于错误提示)
* @return array{room: Room, target: User}|JsonResponse
*/
private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse
{
$room = Room::findOrFail($roomId);
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
// 鉴权:操作者要是房间房主或达到所需等级
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 防误伤:不能操作等级 >= 自己的人
if ($targetUser->user_level >= $operator->user_level) {
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}"], 403);
}
return ['room' => $room, 'target' => $targetUser];
}
/**
* 踢出房间 (对应 KILLUSER.ASP)
* 所需等级由 sysparam level_kick 配置
*/
public function kick(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
if ($result instanceof JsonResponse) {
return $result;
}
// 广播踢出事件
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
}
/**
* 禁言 (对应原版限制功能)
* 所需等级由 sysparam level_mute 配置
* 禁言信息存入 RedisTTL 到期自动解除
*/
public function mute(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
$duration = (int) $request->input('duration', 5);
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
if ($result instanceof JsonResponse) {
return $result;
}
// 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
'operator' => $operator->username,
'reason' => '管理员禁言',
'until' => now()->addMinutes($duration)->toDateTimeString(),
]));
// 广播禁言事件
broadcast(new UserMuted($roomId, $username, $duration));
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
}
/**
* 封号(禁止登录)
* 所需等级由 sysparam level_ban 配置
* 将用户等级设为 -1 表示封禁
*/
public function ban(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
if ($result instanceof JsonResponse) {
return $result;
}
// 封号:设置等级为 -1
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
}
/**
* 封IP(记录IP到黑名单并踢出)
* 所需等级由 sysparam level_banip 配置
*/
public function banIp(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
if ($result instanceof JsonResponse) {
return $result;
}
$targetIp = $result['target']->last_ip;
if ($targetIp) {
// 将IP加入 Redis 黑名单(永久)
Redis::sadd('banned_ips', $targetIp);
}
// 同时封号
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
$ipInfo = $targetIp ? "IP: {$targetIp}" : '(未记录IP';
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}"]);
}
/** /**
* 判断操作者是否可以免费查看目标用户经验、金币与魅力。 * 判断操作者是否可以免费查看目标用户经验、金币与魅力。
*/ */
+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' => '当前房间不存在。',
];
}
}
@@ -32,6 +32,7 @@ class SendPasswordResetLinkRequest extends FormRequest
{ {
return [ return [
'email' => ['required', 'email', 'max:255'], 'email' => ['required', 'email', 'max:255'],
'username' => ['required', 'string', 'max:100'],
]; ];
} }
@@ -43,9 +44,10 @@ class SendPasswordResetLinkRequest extends FormRequest
public function messages(): array public function messages(): array
{ {
return [ return [
'email.required' => '请输入绑定账号的邮箱地址。', 'email.required' => '请输入绑定邮箱地址以进行二次确认。',
'email.email' => '邮箱格式不正确,请重新输入。', 'email.email' => '邮箱格式不正确,请重新输入。',
'email.max' => '邮箱长度不能超过 255 个字符。', 'email.max' => '邮箱长度不能超过 255 个字符。',
'username.required' => '用户昵称参数缺失。',
]; ];
} }
} }
+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', '单选房间模式下只能选择一个房间。');
}
});
}
}
@@ -0,0 +1,148 @@
<?php
/**
* 文件功能:校验后台等级经验阈值配置请求
*
* 约束管理员以列表模式提交的每级经验值,
* 确保阈值为正整数且严格递增。
*/
namespace App\Http\Requests;
use App\Models\Sysparam;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:验证等级经验阈值列表的结构与数值合法性。
*/
class UpdateLevelExpConfigRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台鉴权的用户提交该请求。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:预处理输入,过滤空行并统一转成整数序列。
*/
protected function prepareForValidation(): void
{
$thresholds = collect($this->input('thresholds', []))
->map(fn ($value): string => trim((string) $value))
->filter(fn (string $value): bool => $value !== '')
->values()
->all();
$this->merge([
'thresholds' => $thresholds,
]);
}
/**
* 方法功能:返回等级经验阈值表单的校验规则。
*
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
public function rules(): array
{
return [
'thresholds' => ['required', 'array', 'min:1', $this->strictlyIncreasingRule(), $this->maxLevelLimitRule()],
'thresholds.*' => ['required', 'integer', 'min:1'],
];
}
/**
* 方法功能:返回中文校验错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'thresholds.required' => '请至少配置一个等级经验阈值。',
'thresholds.array' => '等级经验阈值提交格式不正确。',
'thresholds.min' => '请至少保留一个等级经验阈值。',
'thresholds.*.required' => '等级经验阈值不能为空。',
'thresholds.*.integer' => '等级经验阈值必须是整数。',
'thresholds.*.min' => '等级经验阈值必须大于 0。',
];
}
/**
* 方法功能:自定义校验阈值必须严格递增。
*/
private function strictlyIncreasingRule(): ValidationRule
{
return new class implements ValidationRule
{
/**
* 方法功能:执行严格递增校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
$previous = null;
foreach ($value as $index => $threshold) {
if (! is_numeric($threshold)) {
continue;
}
$current = (int) $threshold;
// 每一级累计经验必须大于前一级,避免等级计算出现倒挂。
if ($previous !== null && $current <= $previous) {
$fail('等级经验阈值必须按等级从小到大严格递增,第 '.($index + 1).' 级配置不正确。');
return;
}
$previous = $current;
}
}
};
}
/**
* 方法功能:校验等级阈值数量不能超过用户最高可达等级。
*/
private function maxLevelLimitRule(): ValidationRule
{
return new class((int) Sysparam::getValue('maxlevel', '99')) implements ValidationRule
{
/**
* 方法功能:构造数量上限校验器。
*/
public function __construct(
private readonly int $maxLevel
) {}
/**
* 方法功能:执行阈值数量与最高等级的上限校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value) || $this->maxLevel < 1) {
return;
}
// 阈值行数对应可升级的等级数,不能超过用户最高可达等级。
if (count($value) > $this->maxLevel) {
$fail('等级经验阈值数量不能超过用户最高可达等级,请先提高最高等级或删除多余等级。');
}
}
};
}
}
@@ -31,6 +31,7 @@ class UpdateProfileRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'previous_name' => ['nullable', 'string', 'max:50'],
'sex' => ['required', 'in:0,1,2'], 'sex' => ['required', 'in:0,1,2'],
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif 'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
'sign' => ['nullable', 'string', 'max:255'], 'sign' => ['nullable', 'string', 'max:255'],
+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),
+10 -7
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) {
@@ -284,7 +287,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
*/ */
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
{ {
$diceStr = "{$round->dice1}》《{$round->dice2}》《{$round->dice3}"; $diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
$resultText = match ($round->result) { $resultText = match ($round->result) {
'big' => "🔵 大({$round->total_points} 点)", 'big' => "🔵 大({$round->total_points} 点)",
@@ -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);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏) // 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
+11 -8
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) {
@@ -213,7 +216,7 @@ class CloseHorseRaceJob implements ShouldQueue
'to_user' => $username, 'to_user' => $username,
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。", 'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true, 'is_secret' => true,
'font_color' => '#f59e0b', 'font_color' => '#16a34a',
'action' => '', 'action' => '',
'sent_at' => now()->toDateTimeString(), 'sent_at' => now()->toDateTimeString(),
'toast_notification' => [ 'toast_notification' => [
@@ -243,21 +246,21 @@ 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,
'is_secret' => false, 'is_secret' => false,
'font_color' => '#f59e0b', 'font_color' => '#16a34a',
'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 下注任务
+20 -7
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,21 +92,21 @@ 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,
'is_secret' => false, 'is_secret' => false,
'font_color' => '#f59e0b', 'font_color' => '#16a34a',
'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,
+15 -9
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' => '#336699', '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();
} }
// ─── 工具方法 ──────────────────────────────────────────────────── // ─── 工具方法 ────────────────────────────────────────────────────
+1
View File
@@ -58,6 +58,7 @@ class PositionAuthorityLog extends Model
'warn' => '警告', 'warn' => '警告',
'kick' => '踢出', 'kick' => '踢出',
'mute' => '禁言', 'mute' => '禁言',
'ban' => '封号',
'banip' => '封锁IP', 'banip' => '封锁IP',
'other' => '其他', 'other' => '其他',
]; ];
+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();
}
}
+16
View File
@@ -139,4 +139,20 @@ class Room extends Model
|| $user->username === $this->master || $user->username === $this->master
|| $user->user_level >= $superLevel; || $user->user_level >= $superLevel;
} }
/**
* 模型的 booted 方法。
*
* 在房间被更新或删除时,同步清理 Redis 上的 Presence Channel 鉴权缓存。
*/
protected static function booted(): void
{
static::saved(function (Room $room) {
\Illuminate\Support\Facades\Cache::forget("room:meta:{$room->id}");
});
static::deleted(function (Room $room) {
\Illuminate\Support\Facades\Cache::forget("room:meta:{$room->id}");
});
}
} }
+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_ 开头)
*/ */
+5 -1
View File
@@ -15,6 +15,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/**
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
*/
class Sysparam extends Model class Sysparam extends Model
{ {
/** @var string 表名 */ /** @var string 表名 */
@@ -74,7 +77,8 @@ class Sysparam extends Model
// 不超过最大等级 // 不超过最大等级
$maxLevel = (int) static::getValue('maxlevel', '99'); $maxLevel = (int) static::getValue('maxlevel', '99');
return min($level, $maxLevel); // 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
return max(1, min($level, $maxLevel));
} }
/** /**
+18
View File
@@ -45,6 +45,7 @@ class User extends Authenticatable
*/ */
protected $fillable = [ protected $fillable = [
'username', 'username',
'previous_name',
'password', 'password',
'email', 'email',
'sex', 'sex',
@@ -109,6 +110,7 @@ class User extends Authenticatable
'q3_time' => 'datetime', 'q3_time' => 'datetime',
'has_received_new_gift' => 'boolean', 'has_received_new_gift' => 'boolean',
'chat_preferences' => 'array', 'chat_preferences' => 'array',
'active_decorations' => 'array',
'daily_status_expires_at' => 'datetime', 'daily_status_expires_at' => 'datetime',
]; ];
} }
@@ -260,6 +262,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);
}
}

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