473 Commits

Author SHA1 Message Date
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
lkddi e69bceeb77 调整用户流水筛选并统一分页中文文案 2026-04-26 18:24:55 +08:00
lkddi 00270b3904 统一后台列表页样式并调整站长菜单 2026-04-26 18:10:37 +08:00
lkddi 61cfc2091c 优化积分流水筛选与用户管理样式 2026-04-26 17:54:24 +08:00
lkddi af772350c9 统一用户信息付费查看 2026-04-26 11:31:46 +08:00
lkddi f0269c7c17 迁移聊天动作选择入口 2026-04-25 19:52:03 +08:00
lkddi 17d1885efc 迁移在线名单提示气泡脚本 2026-04-25 19:50:28 +08:00
lkddi c8a9c92b56 迁移聊天室底部滚动工具 2026-04-25 19:47:26 +08:00
lkddi 62bd92c1c6 迁移聊天室滚屏状态入口 2026-04-25 19:45:15 +08:00
lkddi 0ac12364bb 迁移聊天室本地清屏入口 2026-04-25 19:38:58 +08:00
lkddi 36ac9d090b 迁移聊天室禁音入口 2026-04-25 19:36:37 +08:00
lkddi 7fc40eba32 迁移聊天室菜单显示逻辑 2026-04-25 19:33:06 +08:00
lkddi aecabde44c 收口房间在线列表渲染 2026-04-25 19:29:38 +08:00
lkddi 4cdcaa537f 迁移聊天消息安全工具 2026-04-25 19:27:39 +08:00
lkddi 891e18e83f 迁移婚礼弹窗组件脚本 2026-04-25 19:22:13 +08:00
lkddi 2e8bfb61c2 迁移离婚弹窗组件脚本 2026-04-25 19:20:33 +08:00
lkddi be0052119f 迁移婚姻通知弹窗组件 2026-04-25 19:18:21 +08:00
lkddi 5b6f687db6 迁移求婚弹窗组件脚本 2026-04-25 19:15:52 +08:00
lkddi e53f2f5d9d 迁移婚姻广播弹窗脚本 2026-04-25 19:13:40 +08:00
lkddi ee4abdff85 迁移婚姻弹窗全局入口 2026-04-25 19:11:07 +08:00
lkddi 925f2498c5 迁移聊天室上下文注入脚本 2026-04-25 19:05:56 +08:00
lkddi 6f779edb91 迁移奖励金币弹窗脚本 2026-04-25 19:00:41 +08:00
lkddi 8d038c698f 迁移用户名片弹窗脚本 2026-04-25 18:56:30 +08:00
lkddi 7966c0f662 迁移五子棋主面板脚本 2026-04-25 18:50:05 +08:00
lkddi 1f1c329085 迁移赛马主面板脚本 2026-04-25 18:33:08 +08:00
lkddi 0953e03b73 迁移百家乐主面板脚本 2026-04-25 18:30:29 +08:00
lkddi 9ba18315cc 迁移紧凑商店面板脚本 2026-04-25 18:27:48 +08:00
lkddi 8e57ad8a45 迁移礼包红包脚本 2026-04-25 18:22:27 +08:00
lkddi a5c43383e1 迁移百家乐事件脚本 2026-04-25 18:18:41 +08:00
lkddi 63f6dc7106 迁移赛马事件脚本 2026-04-25 18:17:11 +08:00
lkddi 48d646d723 迁移五子棋外部入口脚本 2026-04-25 18:14:07 +08:00
lkddi d5f9cf7371 迁移赛马悬浮按钮脚本 2026-04-25 18:13:00 +08:00
lkddi 414dc52a3b 迁移百家乐悬浮按钮脚本 2026-04-25 15:03:33 +08:00
lkddi 3bbde9b4dd 迁移婚姻状态脚本 2026-04-25 15:00:04 +08:00
lkddi b622053bc2 迁移商店弹窗脚本 2026-04-25 14:56:04 +08:00
lkddi 66a9e8ad23 迁移头像设置脚本 2026-04-25 14:51:07 +08:00
lkddi 585a6fbf5f 迁移节日福利脚本 2026-04-25 14:47:07 +08:00
lkddi 9f61dcc619 迁移钓鱼游戏脚本 2026-04-25 14:43:33 +08:00
lkddi dac8adfc5a 迁移会员中心脚本 2026-04-25 14:40:54 +08:00
lkddi 8a690ac40d 迁移赚钱面板脚本 2026-04-25 14:25:07 +08:00
lkddi c7b8ba956b 迁移双色球彩票脚本 2026-04-25 14:22:59 +08:00
lkddi e6a686233f 迁移神秘占卜脚本 2026-04-25 14:18:00 +08:00
lkddi 55d3dd43ba 迁移老虎机游戏脚本 2026-04-25 14:16:13 +08:00
lkddi fdd20917a4 迁移娱乐大厅脚本 2026-04-25 14:12:48 +08:00
lkddi a6bc6c61c5 迁移百家乐买单活动脚本 2026-04-25 14:09:11 +08:00
lkddi f8fb849714 迁移聊天室初始状态脚本 2026-04-25 14:04:42 +08:00
lkddi 43d613bfb1 迁移游戏延迟初始化脚本 2026-04-25 14:03:15 +08:00
lkddi a766f2a9c5 迁移好友通知监听脚本 2026-04-25 14:02:04 +08:00
lkddi 14511b6230 迁移奖励金币弹窗入口 2026-04-25 14:00:07 +08:00
lkddi 136240e5e1 迁移AI小班长聊天脚本 2026-04-25 13:58:40 +08:00
lkddi ed2d60a24e 迁移用户名快捷操作脚本 2026-04-25 13:57:12 +08:00
lkddi 1e9f6673cc 迁移任命公告事件脚本 2026-04-25 13:55:47 +08:00
lkddi a5c2022422 迁移聊天室大卡片通知脚本 2026-04-25 13:54:00 +08:00
lkddi d4a6a799fe 迁移聊天室Toast通知脚本 2026-04-25 13:51:50 +08:00
lkddi e3a3f6a596 迁移聊天室全局弹窗脚本 2026-04-25 13:48:39 +08:00
lkddi 35ee91530b 迁移邮箱找回密码脚本 2026-04-25 13:46:41 +08:00
lkddi de7c79f0de 迁移首页登录脚本 2026-04-25 13:45:34 +08:00
lkddi 19e50944d9 迁移前台基础页面脚本 2026-04-25 13:43:55 +08:00
lkddi 4d337a2e8f 迁移后台全局弹窗脚本 2026-04-25 13:40:58 +08:00
lkddi e876e76ec7 迁移用户编辑弹窗脚本 2026-04-25 13:39:08 +08:00
lkddi 35cbfedeb7 迁移职务内联保存脚本 2026-04-25 13:38:00 +08:00
lkddi b7af9330c4 迁移节日活动表单脚本 2026-04-25 13:36:51 +08:00
lkddi cedc787586 迁移AI厂商后台事件 2026-04-25 13:35:20 +08:00
lkddi 7900145ba9 迁移站长登录验证码脚本 2026-04-25 13:33:15 +08:00
lkddi c3229f870a 迁移后台输入选中事件 2026-04-25 13:32:16 +08:00
lkddi 4352919889 收口后台表单确认事件 2026-04-25 13:31:18 +08:00
lkddi 90d2fcc14a 迁移节日活动后台事件 2026-04-25 13:29:22 +08:00
lkddi a62e7ff41f 迁移运维工具确认事件 2026-04-25 13:26:30 +08:00
lkddi 4815d201f6 迁移房间后台表单事件 2026-04-25 13:25:27 +08:00
lkddi 4751a5578e 迁移游戏配置手动操作 2026-04-25 13:23:20 +08:00
lkddi 85a419fe53 迁移游戏配置统计与开关 2026-04-25 13:21:45 +08:00
lkddi d875dd08ec 收口后台删除确认事件 2026-04-25 13:19:13 +08:00
lkddi 2d4593c597 迁移签到规则后台脚本 2026-04-25 13:17:58 +08:00
lkddi 91ddfbb408 迁移钓鱼事件后台脚本 2026-04-25 13:16:23 +08:00
lkddi ed7ff81321 迁移自动事件后台开关 2026-04-25 13:14:20 +08:00
lkddi bb401b8940 迁移房间列表降级事件 2026-04-25 13:12:27 +08:00
lkddi f54757414d 优化自动钓鱼 2026-04-25 12:15:51 +08:00
lkddi d46fe85c70 优化自动钓鱼 2026-04-25 12:04:19 +08:00
lkddi 0623120c00 迁移节日福利领取事件 2026-04-25 11:06:31 +08:00
lkddi ed484c9235 迁移五子棋邀请按钮事件 2026-04-25 11:05:15 +08:00
lkddi 6cb63a98e2 迁移消息用户名点击委托 2026-04-25 11:03:40 +08:00
lkddi c44c8198d2 收口彩票弹窗悬停样式 2026-04-25 11:01:51 +08:00
lkddi 992615b7be 迁移彩票选号悬停样式 2026-04-25 11:00:38 +08:00
lkddi a0aaceecc3 迁移老虎机说明悬停样式 2026-04-25 10:57:50 +08:00
lkddi c7ad87a767 迁移赛马下注输入聚焦样式 2026-04-25 10:56:49 +08:00
lkddi 2180fa5ff4 保留浮层内部点击状态 2026-04-25 10:55:57 +08:00
lkddi 8bf913c568 迁移彩票底部关闭事件 2026-04-25 10:54:28 +08:00
lkddi 22981802ca 迁移彩票顶部关闭事件 2026-04-25 10:53:36 +08:00
lkddi 9cc10d56a7 迁移红包弹窗按钮事件 2026-04-25 10:51:46 +08:00
lkddi 8cdf8492fb 迁移游戏面板关闭事件 2026-04-25 10:50:35 +08:00
lkddi e9a3310649 迁移婚礼红包领取事件 2026-04-25 10:48:05 +08:00
lkddi db545aa32e 迁移游戏大厅关闭事件 2026-04-25 10:46:36 +08:00
lkddi 086a46dda6 迁移买单前台弹窗事件 2026-04-25 10:44:46 +08:00
lkddi 5823028276 迁移签到补签点击事件 2026-04-25 10:42:42 +08:00
lkddi 21a727a693 迁移聊天表单提交事件 2026-04-25 10:41:20 +08:00
lkddi ef434f0703 迁移头像选择点击事件 2026-04-25 10:40:01 +08:00
lkddi 5938976360 补充补签聊天室播报 2026-04-25 10:38:59 +08:00
lkddi 0cfb43183a 收口手机抽屉事件范围 2026-04-25 10:37:44 +08:00
lkddi 990b939a3a 迁移在线名单点击事件 2026-04-25 10:36:59 +08:00
lkddi 4f869eb6ea 收口房间列表悬停事件 2026-04-25 10:33:48 +08:00
lkddi 20317f4699 迁移房间列表跳转事件 2026-04-25 10:32:25 +08:00
lkddi 83fff919a5 迁移婚姻商店入口事件 2026-04-25 10:21:17 +08:00
lkddi 95484681c5 迁移快捷好友操作事件 2026-04-25 10:20:21 +08:00
lkddi a847dad00a 补充前端交互边界注释 2026-04-25 10:17:51 +08:00
lkddi e341f2d1a7 迁移商店面板改名按钮事件 2026-04-25 10:17:00 +08:00
lkddi 318aeba684 迁移功能菜单快捷入口 2026-04-25 10:16:14 +08:00
lkddi 4b1d47e96c 补充前端模块状态说明注释 2026-04-25 10:14:20 +08:00
lkddi 8886f5c690 迁移每日签到弹窗事件绑定 2026-04-25 10:13:23 +08:00
lkddi c8bc81f961 补充前端状态和安全边界注释 2026-04-25 10:10:47 +08:00
lkddi 283793bc1c 迁移全局弹窗键盘事件 2026-04-25 10:09:56 +08:00
lkddi 84b3624d59 补充好友和图片预览说明注释 2026-04-25 10:07:36 +08:00
lkddi 74602c7a7b 补充婚姻状态事件说明注释 2026-04-25 10:06:13 +08:00
lkddi 31f53ddfbe 补充前端小模块说明注释 2026-04-25 10:05:47 +08:00
lkddi d012b3e73b 补充前端事件代理说明注释 2026-04-25 10:04:30 +08:00
lkddi 308e5690fe 迁移手机抽屉脚本到Vite模块 2026-04-25 08:21:30 +08:00
lkddi 2f09d5e2ed 补充聊天室前端关键逻辑注释 2026-04-25 08:18:01 +08:00
lkddi ac8a3b959d 迁移银行弹窗业务脚本 2026-04-25 08:16:50 +08:00
lkddi 518cc86a93 移除工具栏弹窗悬停内联事件 2026-04-25 08:11:23 +08:00
lkddi 3773cf905b 迁移婚姻弹窗基础按钮事件绑定 2026-04-25 08:10:23 +08:00
lkddi 1a750f2fe7 迁移银行弹窗基础按钮事件绑定 2026-04-25 08:08:39 +08:00
lkddi 37e4c5d4c5 迁移会员中心基础按钮事件绑定 2026-04-25 08:07:28 +08:00
lkddi 0526419dbc 迁移商店弹窗基础按钮事件绑定 2026-04-25 08:05:33 +08:00
lkddi 89f344bd29 迁移头像和设置弹窗事件绑定 2026-04-25 08:04:22 +08:00
lkddi 247283a282 迁移买单活动管理弹层脚本 2026-04-25 04:05:32 +08:00
lkddi 4df557bb9e 迁移管理菜单和钓鱼按钮事件绑定 2026-04-25 04:03:13 +08:00
lkddi 54faf8b501 补充聊天室前端入口说明注释 2026-04-25 04:00:38 +08:00
lkddi be22710424 迁移竖向工具条按钮事件绑定 2026-04-25 03:59:53 +08:00
lkddi 1db22dc5de 迁移好友面板脚本到Vite模块 2026-04-25 03:58:26 +08:00
lkddi 0310798675 迁移手机抽屉工具按钮事件绑定 2026-04-25 03:55:57 +08:00
lkddi 04ee32e4d5 迁移全局弹窗按钮事件绑定 2026-04-25 03:54:23 +08:00
lkddi ef471ec68b 迁移功能菜单和每日状态事件绑定 2026-04-25 03:53:29 +08:00
lkddi 3e525eaa36 迁移屏蔽复选框事件绑定 2026-04-25 03:50:10 +08:00
lkddi ce6f8552c1 迁移欢迎语菜单事件绑定 2026-04-25 03:49:13 +08:00
lkddi 1429dee8a6 迁移屏蔠菜单事件绑定 2026-04-25 03:45:30 +08:00
lkddi cf42071c29 迁移手机抽屉基础事件绑定 2026-04-25 03:44:04 +08:00
lkddi 10e9835530 迁移右侧面板事件绑定 2026-04-25 03:42:54 +08:00
lkddi 64a1e5d769 迁移聊天图片上传事件绑定 2026-04-25 03:41:45 +08:00
lkddi ca61dd42f7 迁移字号选择器事件绑定 2026-04-25 03:40:30 +08:00
lkddi e9c3fc989c 迁移静音开关事件绑定 2026-04-25 03:39:31 +08:00
lkddi c858f6af0c 迁移聊天室静音偏好工具 2026-04-25 03:38:27 +08:00
lkddi 2f246f9112 迁移聊天图片预览事件到Vite模块 2026-04-25 03:36:30 +08:00
lkddi f1d8d20180 迁移聊天室前端工具并优化消息渲染 2026-04-25 03:34:31 +08:00
lkddi e3cba255f9 优化聊天室特效加载与移动端性能 2026-04-25 03:34:19 +08:00
lkddi 128b52d0aa 优化聊天室首屏和在线名单性能 2026-04-25 03:14:07 +08:00
lkddi c410897231 将聊天室特效脚本纳入 Vite 打包 2026-04-25 03:02:56 +08:00
lkddi 855d031b04 收口聊天室安全边界并优化特效生命周期 2026-04-25 02:52:30 +08:00
lkddi 4d3f4f7a4b 修复手动存点通知重复显示 2026-04-25 02:50:24 +08:00
lkddi f18eefe9bc 优化存点显示 2026-04-25 02:28:18 +08:00
lkddi 8bd1dae9e1 优化存点 2026-04-25 02:24:24 +08:00
lkddi 5bfcd75442 修复游戏弹窗点击外部关闭 2026-04-25 02:15:09 +08:00
lkddi 8b15507f22 暂不显示管理员名单 2026-04-25 02:09:00 +08:00
lkddi 97c021dae2 优化ai提示词 2026-04-25 00:57:15 +08:00
lkddi 8cf5029711 优化ai小班长聊天 2026-04-25 00:42:46 +08:00
lkddi aab609f69b ai小班长增加签到 2026-04-25 00:27:08 +08:00
lkddi a0268b611f 禁用赛马接口缓存 2026-04-24 23:53:17 +08:00
lkddi fc68aaff72 增强部署脚本依赖检查 2026-04-24 23:46:53 +08:00
lkddi 32584f11d2 修复bug 2026-04-24 23:42:52 +08:00
lkddi a3b5184470 修复聊天室样式缓存问题 2026-04-24 23:40:55 +08:00
lkddi b2b91b9238 压缩状态设置弹窗布局 2026-04-24 23:29:48 +08:00
lkddi 9f8b5e7524 支持点击结束全屏特效 2026-04-24 23:15:42 +08:00
lkddi 5273b4ee4b 完善职务礼包红包默认配置 2026-04-24 23:09:32 +08:00
lkddi 4486a87326 移除补签卡默认值更新迁移 2026-04-24 22:48:14 +08:00
lkddi be9fc09d9d 新增每日签到与补签卡功能 2026-04-24 22:47:27 +08:00
lkddi 34356a26ae 完善跑马面板与控制器逻辑 2026-04-24 21:18:09 +08:00
lkddi 0f0bfef2a8 新增聊天室状态与功能快捷菜单 2026-04-24 21:17:44 +08:00
lkddi d7ec42a025 移除失效的 FluidPlayer 样式引用 2026-04-22 12:11:21 +08:00
lkddi 6c631aa495 优化 职务图标显示 2026-04-22 10:37:17 +08:00
lkddi fb96747352 优化 职务图标文字提示 2026-04-22 10:33:26 +08:00
lkddi 7c27ba0c48 优化 职务图标 文字提示 2026-04-22 10:18:49 +08:00
lkddi bef797abd5 修复在线名单职务图标显示 2026-04-22 10:10:40 +08:00
lkddi 73c6674fc4 优化节日福利列表与领取提示展示 2026-04-22 09:52:35 +08:00
lkddi b0028c515f 将用户管理操作接入职务权限体系 2026-04-21 18:00:02 +08:00
lkddi a066580014 升级节日福利年度调度与批次领取 2026-04-21 17:53:11 +08:00
lkddi 5a6446b832 后台用户编辑页接入职务任命流程 2026-04-21 17:26:52 +08:00
lkddi a17a67f533 去除任命成功的重复弹窗提示 2026-04-21 17:16:18 +08:00
lkddi fed51dda18 新增聊天室刷新同步与全员刷新功能 2026-04-21 17:14:12 +08:00
lkddi c209221bad 优化聊天室烟花特效表现与卡顿问题 2026-04-21 17:13:14 +08:00
lkddi 590b7d5b35 修复职务任命撤销弹窗显示HTML代码 2026-04-21 16:45:54 +08:00
lkddi f0769a841e 优化后台提示展示与聊天室公告样式 2026-04-21 16:43:39 +08:00
lkddi 281315d1cf 新增职务权限管理与聊天室管理权限控制 2026-04-21 16:43:17 +08:00
lkddi cfdbf387af 修复管理员登录页验证码显示不全 2026-04-21 15:48:01 +08:00
lkddi cf4006eb8b 修复后台弹窗被顶部栏遮挡问题 2026-04-21 15:47:55 +08:00
lkddi 96a449d94b 优化 红包 页面 2026-04-21 15:10:41 +08:00
lkddi 916f4c5aa6 升级 laravel12 补丁 2026-04-19 16:43:51 +08:00
lkddi d4a9389fbc 完善首页邮箱找回密码流程 2026-04-19 16:10:41 +08:00
lkddi 900c93c6c7 修复 HTTPS 资源链接生成 2026-04-19 15:15:58 +08:00
lkddi 438241e878 收紧输入渲染与后台配置权限 2026-04-19 14:43:02 +08:00
lkddi ba6406ed68 加固房间准入与消息广播边界 2026-04-19 14:42:52 +08:00
lkddi 5ce83a769d 修复认证与基础安全链路 2026-04-19 14:42:42 +08:00
lkddi bd97ed0b73 优化 ai小班长百家乐押注 2026-04-19 12:36:23 +08:00
lkddi b98ae7f94e 优化手机输入及钓鱼 2026-04-19 12:14:10 +08:00
lkddi c710d585da 优化钓鱼卡提示 2026-04-17 16:20:50 +08:00
lkddi 3afe5a4480 优化 百家乐 2026-04-17 15:33:36 +08:00
lkddi c7cb826013 优化 押注 下单提示 2026-04-17 15:30:25 +08:00
lkddi 0e8a1669b9 增加 神秘箱子的屏蔽 2026-04-17 15:27:40 +08:00
lkddi 0f4de941db 优化神秘箱子提醒 2026-04-17 15:19:58 +08:00
lkddi 4866d25df9 优化神秘箱子提醒 2026-04-17 15:18:08 +08:00
lkddi dd938ec6e7 优化百家乐、跑马 押注消息 2026-04-17 14:57:58 +08:00
lkddi 1a39ddd725 优化屏蔽,可以保存状态 2026-04-14 22:48:29 +08:00
lkddi 7255d50966 优化按钮 2026-04-14 22:43:34 +08:00
lkddi 6927a88dd3 优化提醒 2026-04-14 22:41:33 +08:00
lkddi fc9a66469a 屏蔽新增 百家乐 跑马 2026-04-14 22:31:11 +08:00
lkddi 0183de66dd 新增 屏蔽消息功能 2026-04-14 22:25:16 +08:00
lkddi b76b6559ea 赛马优化通知 2026-04-14 22:14:10 +08:00
lkddi a2e51f5668 优化百家乐提醒 2026-04-14 22:09:03 +08:00
lkddi 392f46769c 赠金币 增加右下角弹窗 2026-04-14 21:59:49 +08:00
lkddi df29da7440 赠送金币改为私聊通知 2026-04-14 21:53:36 +08:00
lkddi 762caac938 优化管理首页 2026-04-14 21:09:37 +08:00
lkddi 426d01d99b 新增管理登录页面 2026-04-14 13:43:16 +08:00
lkddi 596c7f357f 优化 你玩游戏我买单 页面 2026-04-13 17:55:00 +08:00
lkddi 2eb732642b 优化会员购买记录 2026-04-13 17:44:37 +08:00
lkddi d060e1b797 新增微信支付 2026-04-13 17:25:33 +08:00
lkddi dca43a2d0d 优化vip 2026-04-12 23:25:38 +08:00
lkddi 353aaaf6ce 优化ai小班长 2026-04-12 22:42:32 +08:00
lkddi d739fc7028 优化发送金币后自动关闭 2026-04-12 22:39:22 +08:00
lkddi c297b61493 优化 游戏金币余额显示 2026-04-12 22:31:35 +08:00
lkddi ef407a8c6e 优化ai小班长 2026-04-12 22:25:18 +08:00
lkddi f8d5a3b250 优化ai小班长 2026-04-12 21:42:55 +08:00
lkddi a4cc85b558 优化登录页面 2026-04-12 19:24:35 +08:00
lkddi bf856e18e3 优化 2026-04-12 19:06:48 +08:00
lkddi d60065ff3e 优化 2026-04-12 19:03:37 +08:00
lkddi e837d1fcd0 优化 2026-04-12 19:02:56 +08:00
lkddi 61541bfe4c 优化ai小班长 2026-04-12 18:50:41 +08:00
lkddi d6f14868fd 增加 百家乐通知后面增加 快速参与按钮 2026-04-12 18:15:47 +08:00
lkddi 4ac3311328 优化快速下注 保证至少有5个按钮 2026-04-12 18:12:53 +08:00
lkddi e5f0f28978 优化显示快速下单金额 2026-04-12 18:04:28 +08:00
lkddi 5755bea748 优化跑马显示 2026-04-12 18:01:35 +08:00
lkddi 1559e49d3d 优化下注提示 2026-04-12 17:58:03 +08:00
lkddi d52db10863 优化下注金额 2026-04-12 17:56:16 +08:00
lkddi adc89240fd 优化跑马下单按钮 2026-04-12 17:49:49 +08:00
lkddi 28cbf2b564 优化游戏 金币显示 2026-04-12 17:46:24 +08:00
lkddi e7aea014fb 优化商店 2026-04-12 17:41:27 +08:00
lkddi 87c7a8d786 修复跑马bug 2026-04-12 17:39:46 +08:00
lkddi 5b637d2c64 优化自动关闭 2026-04-12 17:34:07 +08:00
lkddi 2090250967 增加婚姻 查看已婚列表 2026-04-12 17:28:42 +08:00
lkddi 705af810a9 优化按钮 2026-04-12 17:11:12 +08:00
lkddi 77c17f87f9 优化 会员页面; 2026-04-12 17:06:38 +08:00
lkddi 1e64d2d5e2 优化管理操作按钮 2026-04-12 16:54:25 +08:00
lkddi 70cb170f2c Add new chat effects and shop items 2026-04-12 16:48:58 +08:00
lkddi 33a3e5d118 修改特效按钮 2026-04-12 16:24:48 +08:00
lkddi bc825157c9 增加会员查看 2026-04-12 16:16:23 +08:00
lkddi 9b1f2a2146 修改会员登录默认特性 2026-04-12 16:05:35 +08:00
lkddi 0899ff184c 优化会员登录提示 2026-04-12 14:32:44 +08:00
lkddi 82e29753b8 vip会员支持补差升级 2026-04-12 14:17:01 +08:00
lkddi 00b9396dea 新增聊天室发送图片功能 2026-04-12 14:04:18 +08:00
lkddi d2f08eb2dd 删除无用图片 2026-04-12 13:37:55 +08:00
lkddi 8471516fd7 更换登录页面样式 2026-04-12 13:37:22 +08:00
lkddi dee91bccca 修复跑马 不能正常显示赢后的奖励金额 2026-04-12 11:09:15 +08:00
lkddi 9b4b0ab5f3 优化跑马提示 2026-04-11 23:46:05 +08:00
lkddi f4a632a9c1 完善百家乐买单补偿自动领取与聊天室播报 2026-04-11 23:43:07 +08:00
lkddi e43dceab2c Add baccarat loss cover activity 2026-04-11 23:27:29 +08:00
lkddi dd9a8c5db8 修复悄悄话文字颜色及不能发数字0的问题 2026-04-11 22:48:15 +08:00
lkddi ff402be02f 优化 刷新页面不在重复播报 离开和登录提示 2026-04-11 22:40:42 +08:00
lkddi 0a764a3a86 优化会员显示 2026-04-11 17:18:31 +08:00
lkddi f91772b019 优化跑马页面 2026-04-11 16:58:28 +08:00
lkddi abc05de86e Fix duplicate mystery box config fields 2026-04-11 16:31:13 +08:00
lkddi 37c175289c Refine horse race pool and quick entry 2026-04-11 16:27:04 +08:00
lkddi b02a789264 Fix rooms page Alpine body data 2026-04-11 16:14:11 +08:00
lkddi 44db0d7853 Fix horse race seed pool payouts 2026-04-11 16:11:00 +08:00
lkddi cc1dd017ce Ensure self join message is rendered 2026-04-11 15:58:38 +08:00
lkddi ca1c7e66c5 去除管理员登录提示 2026-04-11 15:57:20 +08:00
lkddi f6fb5aab78 Fix self welcome message rendering 2026-04-11 15:54:25 +08:00
lkddi 4eba9dfc12 Add VIP presence themes and custom greetings 2026-04-11 15:44:30 +08:00
lkddi 9fb7710079 优化 钓鱼会员加提提示! 2026-04-11 14:22:22 +08:00
lkddi a21695e326 优化 钓鱼会员加提提示! 2026-04-11 14:13:52 +08:00
lkddi a7fe908c1c 增加会员中心 导航 2026-04-11 13:50:25 +08:00
lkddi f1d94b18b2 优化 2026-04-11 13:45:10 +08:00
lkddi 632c9e5a93 优化 2026-04-11 13:34:15 +08:00
lkddi 6af789dd83 优化个人中心页面 2026-04-11 13:30:32 +08:00
lkddi 12fd0558d9 个人页面增加 会员显示 2026-04-11 13:24:15 +08:00
lkddi c30b518105 个人页面增加 会员显示 2026-04-11 13:20:37 +08:00
lkddi c2a2b4818e 个人页面增加 会员显示 2026-04-11 13:14:05 +08:00
lkddi 56b24901c6 会员钓鱼 加成提醒! 2026-04-11 12:56:22 +08:00
lkddi 7087c22259 修复bug 2026-04-11 12:19:08 +08:00
lkddi 746116d325 feat: add vip payment and member center 2026-04-11 12:01:52 +08:00
lkddi db26820544 优化:微信群内管理员上线播报仅针对拥有职务的用户,并前置显示部门及职务名称 2026-04-03 14:05:04 +08:00
lkddi 3488ad0605 特性: 重新开启底部工具栏看视频赚钱(赚钱)入口按钮 2026-04-03 13:57:26 +08:00
lkddi 659e562208 测试: 完成游戏娱乐模块 (Gomoku, HorseRace, Lottery 等) 功能全量联调测试与代码格式化 2026-04-03 13:55:36 +08:00
lkddi d47f9c5360 chore: 暂时隐藏工具栏「赚钱」入口 2026-04-03 10:55:36 +08:00
lkddi 540793c152 feat: 看视频奖励改用 UserCurrencyService 写日志,新增 VIDEO_REWARD 枚举 2026-04-03 10:50:29 +08:00
lkddi 3aa2402808 feat: 每日观看上限改为 3 次,消息改用变量 2026-04-02 18:46:20 +08:00
lkddi b0b77640f6 fix: 前端 systemUsers 加入系统播报,修正消息渲染格式 2026-04-02 18:43:29 +08:00
lkddi fb1e4402dc fix: 赚钱广播改为系统播报风格,白名单同步更新 2026-04-02 18:41:01 +08:00
lkddi 97e32572cf feat: 新增看视频赚金币功能
- 在右侧导航新增「赚钱」入口(娱乐下方)
- 新增 earn-panel 弹窗:风格与商店一致,800px 宽度
- 集成 FluidPlayer + VAST 广告(ExoClick)
- 动态倒计时:实时监听视频 duration/currentTime
- VAST 失败时自动回退保底视频,20s 超时保底放行
- 修复 AbortError:idle 时 video 不预播放,仅提供 fallback source
- 删除不支持的 player.on('error') 调用
- 所有 overlay 改用绝对定位居中,修复 Alpine x-show 破坏 flex 问题
- EarnController:Redis 每日 10 次限额 + 冷却防刷
- 领取成功后广播全服系统消息(含金币+经验+快捷入口标签)
- 移除神秘盒子相关 UI 代码
2026-04-02 18:35:54 +08:00
lkddi b4d6e0e23b feat: 支持上传及查看高清原图自定义头像 2026-04-02 17:07:24 +08:00
lkddi caf4742dd8 修复:移除前端对 headface 属性的强制小写转换,避免自定义上传头像(带有大小写字符)出现404问题 2026-04-02 17:01:13 +08:00
lkddi c7142efa99 修复微信机器人好友上线通知由于好友模型失效导致的无法通知的问题 2026-04-02 16:46:00 +08:00
lkddi c4edda8b4e 特性:优化注册与改名卡逻辑,在触发敏感词或拦截重名时明确提示具体是触发了哪个词汇 2026-04-02 16:38:17 +08:00
lkddi 63292ab810 优化:注册与登录拦截处支持针对后台管理的永久禁用黑名单词汇采用“模糊匹配”(只要包含该词汇即拦截) 2026-04-02 16:35:58 +08:00
lkddi 2786c8e7bf 优化:调整注册/登录时的用户名长度验证,采用实际显示宽度(英文占1宽度,汉字占2宽度),限制最短4个字母(或2个汉字) 2026-04-02 16:32:03 +08:00
lkddi ecfed9bf6b 优化:后台大盘用户列表增加微信绑定状态展示并支持排序,优化整体表格排版(不换行、时间简写) 2026-04-02 16:28:35 +08:00
lkddi a562ecca72 修复聊天室离开播报:显式点击离开按钮时绕过队列防抖,同步发送离开广播,解决本地无队列运行时播报丢失的问题 2026-04-02 16:21:35 +08:00
lkddi fa5e37f003 feat: 增加发送微信群内自定义公告功能,并优化离线防抖与自我播报过滤机制
- 后台微信机器人增加群内独立公告的分发推送模块
- 聊天室系统引入3秒离线延迟(防抖)防重复播报
- 优化聊天界面消息拉取过滤自身的欢迎或离场广播
- 管理员登录时的烟花特效同步至用户当前的前端显示
2026-04-02 16:07:40 +08:00
lkddi e36b779a4a fix(baccarat): 解决AI接口耗时导致AI小班长在封盘后仍然下注并报错的问题 2026-04-02 15:49:32 +08:00
lkddi 66451c189e fix(wechat): 屏蔽无人参与的百家乐空局通知,防止无效消息刷屏群聊 2026-04-02 15:46:01 +08:00
lkddi 310e8bc07d feat(wechat): 增加微信全局通知免打扰时间配置,避免夜间打扰用户 2026-04-02 15:44:05 +08:00
lkddi f04512ac3f fix(wechat): 回滚微信群扫码绑定,强制要求私聊,避免无法下发个人通知的潜在问题 2026-04-02 15:38:26 +08:00
lkddi a24c8280c9 feat(wechat): 完善微信群绑定安全组网约束,要求只允许在指定管理群内进行扫码验证绑定 2026-04-02 15:37:49 +08:00
lkddi 9857797b80 feat(wechat): 开启在微信群内直接发送验证码绑定自身账号的支持 2026-04-02 15:37:20 +08:00
lkddi 870855d99c fix(wechat): 去掉多余重复的标题拼接以保持文案精简 2026-04-02 15:16:34 +08:00
lkddi 039c32ecf4 fix(wechat): 移除发送队列中已废弃的全局总开关判断导致消息被丢弃的问题 2026-04-02 15:14:17 +08:00
lkddi 08498c97d0 chore(deploy): 增加 Horizon 自动平滑重启指令 2026-04-02 15:02:12 +08:00
lkddi fc57f97c9e feat(wechat): 微信机器人全链路集成与稳定性修复
- 新增:管理员后台的微信机器人双向收发参数设置页面及扫码绑定能力。
- 新增:WechatBotApiService 与 KafkaConsumerService 模块打通过往僵尸进程导致的拒绝连接问题。
- 新增:下发所有群发/私聊通知时统一带上「[和平聊吧]」标注前缀。
- 优化:前端个人中心绑定逻辑支持一键生成及复制动态口令。
- 修复:闭环联调修补各个模型中产生的变量警告如 stdClass 对象获取等异常预警。
2026-04-02 14:56:51 +08:00
lkddi 8a809e3cc0 修复:移除 AI 预测强制指定的 response_format JSON 约束,解决国内开源模型(GLM、StepFun)通过第三方代理调用时静默返回空串无法解析的问题 2026-04-02 13:39:45 +08:00
lkddi 0a192c4f33 修复:更新部署脚本自动接管文件所属权为 www,彻底解决生产环境中框架因 root 权限导致的无法读写缓存与日志问题 2026-04-02 13:34:02 +08:00
lkddi 426695e410 修复(BaccaratAI):优化Prompt脱敏规避大模型道德审查,并新增正则降级匹配以兼容未输出JSON的情况 2026-04-02 10:27:59 +08:00
lkddi 69e41fbbd9 优化:完善百家乐AI决策,增加历史记录上下文、底部仓位预留与智能观望广播功能 2026-04-02 10:21:20 +08:00
lkddi 3a460b9ac6 优化:登录页面长时间停留导致 CSRF 失效时自动显示中文提示并刷新 2026-04-02 09:10:49 +08:00
lkddi f0d92b21be feat: 增加百家乐下注公屏播报通知 2026-03-28 22:07:12 +08:00
lkddi a3edb7538a build(deploy): 按需调整缓存自动构建逻辑(追加 optimize) 2026-03-28 21:54:59 +08:00
lkddi 5d4a0dd00f build(deploy): 优化部署脚本,每次部署先清理缓存再重建优化缓存 2026-03-28 21:54:24 +08:00
lkddi a69a20ee1e chore(百家乐): 提高 AI 小班长强退休息的连输阈值(3次 -> 10次) 2026-03-28 21:50:03 +08:00
lkddi 08c854222e fix(百家乐): 精简 AI小班长发言的下注标签,解决括号嵌套问题 2026-03-28 21:44:50 +08:00
lkddi 7bb7f1f4fd feat(百家乐): AI小班长下注后在聊天室发送普通聊天消息
- 下注成功后调用 broadcastBetMessage() 向聊天室广播
- 消息格式:「🤖 AI分析 小班长投了 N 金币,压【大/小/豹子】,大家加油!🎲」
- 发送者 AI小班长,发送对象 大家,action=说(普通聊天)
- AI 预测时显示「🤖 AI分析」标签,本地兜底时显示「📊路单统计」
2026-03-28 21:38:34 +08:00
lkddi aa760b14a2 fix(百家乐AI预测): ai_usage_logs 使用 AI小班长真实 user_id
- 新增 resolveAiUserId() 按 username 查询 AI小班长 ID(惰性/缓存)
- 原先硬编码 null,改为正确关联到 AI小班长用户记录
2026-03-28 21:28:14 +08:00
553 changed files with 84226 additions and 19932 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
+2
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_FORCE_HTTPS=false
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -32,6 +33,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
TRUSTED_PROXIES=127.0.0.1,::1
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
-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
+12
View File
@@ -11,6 +11,14 @@
/.phpunit.cache
/.vscode
/.zed
/.junie
/.github
/.gemini
/.agents
/.codex
/.hermes
/.reasonix
/.understand-anything
/auth.json
/node_modules
/public/build
@@ -26,3 +34,7 @@ vendor.zip
test-captcha.php
public/.user.ini
dump.rdb
# AI 生成文件
AGENTS.md
GEMINI.md
-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>
+145 -26
View File
@@ -16,15 +16,23 @@ namespace App\Console\Commands;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\AiFishingJob;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\DailySignIn;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AiFinanceService;
use App\Services\ChatStateService;
use App\Services\SignInService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
/**
* 定时模拟 AI小班长心跳,并同步维护其常规存款理财行为。
*/
class AiHeartbeatCommand extends Command
{
/**
@@ -37,37 +45,60 @@ class AiHeartbeatCommand extends Command
*/
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
/**
* 注入聊天室状态、VIP、积分与 AI 资金调度服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly AiFinanceService $aiFinance,
private readonly SignInService $signInService,
) {
parent::__construct();
}
/**
* 执行 AI小班长单次心跳,并处理奖励、随机事件与资金调度。
*/
public function handle(): int
{
$startedAt = microtime(true);
// 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS;
}
$config = $this->heartbeatConfig();
// 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return Command::SUCCESS;
}
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$this->aiFinance->bankExcessGold($user);
}
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
if ($this->performDailySignIn($user)) {
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
$user->refresh();
}
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
$expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
}
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
$jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
@@ -75,30 +106,35 @@ class AiHeartbeatCommand extends Command
}
$user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$superLevel = (int) $config['superlevel'];
$leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
$eventChance = (int) $config['auto_event_chance'];
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
$hasCurrencyChange = false;
// 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) {
$this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
$hasCurrencyChange = true;
}
if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change(
$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)) {
@@ -129,35 +165,68 @@ class AiHeartbeatCommand extends Command
}
// 7. 钓鱼小游戏随机参与逻辑
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if ($user->jjb >= $cost) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
$fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
$fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
$fishingConfig = GameConfig::forGame('fishing');
// 模拟玩家等待时间
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
if ($fishingConfig?->enabled) {
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
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();
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
// 模拟玩家等待时间
$waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
$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 万,则把超出的部分重新转回银行。
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 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'),
];
}
/**
* 计算并更新用户等级
*/
@@ -195,6 +264,56 @@ class AiHeartbeatCommand extends Command
return (int) $raw;
}
/**
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
*/
private function performDailySignIn(User $user): bool
{
// 先检查今日是否已签,避免每分钟都调用事务
$alreadySigned = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', today())
->exists();
if ($alreadySigned) {
return false;
}
// 获取活跃房间作为签到归属(默认房间 1)
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
$dailySignIn = $this->signInService->claim($user, $roomId);
// 仅当本次心跳实际完成签到时才广播(幂等保护)
if (! $dailySignIn->wasRecentlyCreated) {
return false;
}
$rewardParts = [];
if ($dailySignIn->gold_reward > 0) {
$rewardParts[] = $dailySignIn->gold_reward.' 金币';
}
if ($dailySignIn->exp_reward > 0) {
$rewardParts[] = $dailySignIn->exp_reward.' 经验';
}
if ($dailySignIn->charm_reward > 0) {
$rewardParts[] = $dailySignIn->charm_reward.' 魅力';
}
$rewardText = $rewardParts === [] ? '签到记录' : implode(' + ', $rewardParts);
$identityText = $dailySignIn->identity_badge_name
? ',获得身份 '.e($dailySignIn->identity_badge_name)
: '';
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
return true;
}
/**
* 往所有活跃房间发送系统广播消息
*/
+68 -17
View File
@@ -20,12 +20,15 @@ use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
@@ -46,6 +49,7 @@ class AutoSaveExp extends Command
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
@@ -63,6 +67,8 @@ class AutoSaveExp extends Command
*/
public function handle(): int
{
$startedAt = microtime(true);
// 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
@@ -79,15 +85,22 @@ class AutoSaveExp extends Command
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
$totalProcessed = 0;
$usersByUsername = $this->preloadOnlineUsers($roomMap);
foreach ($roomMap as $roomId => $usernames) {
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++;
}
}
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。");
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
return Command::SUCCESS;
}
@@ -106,7 +119,7 @@ class AutoSaveExp extends Command
$roomMap = [];
// 从数据库取出所有房间 ID
$roomIds = \App\Models\Room::pluck('id');
$roomIds = Room::pluck('id');
foreach ($roomIds as $roomId) {
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
@@ -119,27 +132,46 @@ class AutoSaveExp extends Command
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 string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值
*/
private function processUser(
string $username,
User $user,
int $roomId,
string $expGainRaw,
string $jjbGainRaw,
int $superLevel
): void {
$user = User::where('username', $username)->first();
if (! $user) {
return;
}
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user);
@@ -163,8 +195,11 @@ class AutoSaveExp extends Command
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
$user->load('activePosition.position'); // 确保职务及职位关联已加载
if ($actualExpGain > 0 || $actualJjbGain > 0) {
// 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系。
$user->refresh();
$user->load(['activePosition.position.department', 'vipLevel']);
}
// 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -176,11 +211,17 @@ class AutoSaveExp extends Command
$activeUP = $user->activePosition; // 已在 refresh 后加载
if ($activeUP?->position) {
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
// 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
$requiredLevel = (int) $activeUP->position->level;
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
if ($requiredLevel > 0 && $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) {
// 普通用户:按经验计算并更新等级
$newLevel = Sysparam::calculateLevel($user->exp_num);
@@ -208,6 +249,14 @@ class AutoSaveExp extends Command
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
// 触发微信机器人私聊通知 (等级提升)
try {
$wechatService = app(\App\Services\WechatBot\WechatNotificationService::class);
$wechatService->notifyLevelChange($user, $oldLevel, $newLevel);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot level change notification failed', ['error' => $e->getMessage()]);
}
}
// 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示
@@ -221,15 +270,17 @@ class AutoSaveExp extends Command
$jjbDisplay = $user->jjb ?? 0;
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode('', $gainParts) : '';
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
// 格式:⏰ 自动存点 · 部门 X · 职务 Y · 会员 Z · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$content = "⏰ 自动存点 · {$identitySummary['inline']} · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$noticeMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username, // 定向推送给本人
'to_user' => $user->username, // 定向推送给本人
'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'font_color' => '#16a34a', // 草绿色
@@ -0,0 +1,155 @@
<?php
/**
* 文件功能:微信机器人 Kafka 消费命令
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Models\User;
use App\Services\WechatBot\KafkaConsumerService;
use App\Services\WechatBot\WechatBotApiService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ConsumeWechatMessages extends Command
{
/**
* @var string
*/
protected $signature = 'wechat-bot:consume';
/**
* @var string
*/
protected $description = '消费 Kafka 微信机器人消息(守护进程)';
protected KafkaConsumerService $kafkaService;
public function __construct(KafkaConsumerService $kafkaService)
{
parent::__construct();
$this->kafkaService = $kafkaService;
}
public function handle(): int
{
$this->info('正在启动微信机器人 Kafka 消费者...');
$consumer = $this->kafkaService->createConsumer();
if (! $consumer) {
$this->error('Kafka 配置不完整或加载失败,请在后台检查机器人设置。');
return self::FAILURE;
}
$this->info('消费者已启动,等待消息...');
$apiService = new WechatBotApiService;
while (true) {
try {
$messageJson = $consumer->consume();
if ($messageJson) {
$rawJson = $messageJson->getValue();
$this->info('--> 收到新的 Kafka 消息 (Raw Length: '.strlen($rawJson).')');
$messages = $this->kafkaService->parseKafkaMessage($rawJson);
if (empty($messages)) {
$this->info('--> 解析后:无匹配的 AddMsgs 内容');
}
foreach ($messages as $msg) {
try {
$this->processMessage($msg, $apiService);
} catch (\Exception $e) {
Log::error('处理单条微信消息失败', [
'error' => $e->getMessage(),
'msg' => $msg,
]);
}
}
$consumer->ack($messageJson);
}
} catch (\Exception $e) {
Log::error('Kafka 消费异常', ['error' => $e->getMessage()]);
// 延迟重试避免死循环 CPU 空转
sleep(2);
}
}
return self::SUCCESS;
}
/**
* 处理单条消息逻辑
*/
protected function processMessage(array $msg, WechatBotApiService $apiService): void
{
// 仅处理文本消息 (msg_type = 1)
if ($msg['msg_type'] != 1) {
return;
}
$content = trim($msg['content']);
$fromUser = $msg['from_user'];
$isChatroom = $msg['is_chatroom'];
// 绑定逻辑:必须是私聊(防止在群内绑定导致未来系统无法直接通过私聊推送个人通知)
if (! $isChatroom && preg_match('/^BD-\d{6}$/i', $content)) {
$this->info("收到潜在绑定请求: {$content} from {$fromUser}");
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
}
}
/**
* 处理账号绑定请求
*/
protected function handleBindRequest(string $code, string $wxid, WechatBotApiService $apiService): void
{
$cacheKey = 'wechat_bind_code:'.$code;
$username = Cache::get($cacheKey);
if (! $username) {
$apiService->sendTextMessage($wxid, '❌ 绑定失败:该验证码无效或已过有效期(5分钟)。请在个人中心重新生成。');
return;
}
$user = User::where('username', $username)->first();
if (! $user) {
$apiService->sendTextMessage($wxid, '❌ 绑定失败:找不到对应的用户账号。');
return;
}
// 判断该微信号是否已经被其他用户绑定(防止碰撞或安全隐患)
$existing = User::where('wxid', $wxid)->where('id', '!=', $user->id)->first();
if ($existing) {
$apiService->sendTextMessage($wxid, "❌ 绑定失败:当前微信号已经被其他账号 [{$existing->username}] 绑定。请先解绑后再试。");
return;
}
$user->wxid = $wxid;
$user->save();
// 验证成功后立即销毁验证码
Cache::forget($cacheKey);
$this->info("用户 [{$username}] 成功绑定微信: {$wxid}");
$successMsg = "🎉 绑定成功!\n"
."您已成功绑定聊天室账号:[{$username}]。\n"
.'现在您可以接收重要系统通知了。';
$apiService->sendTextMessage($wxid, $successMsg);
}
}
+101 -13
View File
@@ -3,8 +3,8 @@
/**
* 文件功能:定期清理聊天记录 Artisan 命令
*
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。
* 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息
* 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
*
* @author ChatRoom Laravel
*
@@ -17,7 +17,12 @@ use App\Models\Message;
use App\Models\Sysparam;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* 定期清理聊天记录命令
* 负责删除过期文本消息,并额外回收聊天图片文件。
*/
class PurgeOldMessages extends Command
{
/**
@@ -26,7 +31,8 @@ class PurgeOldMessages extends Command
* @var string
*/
protected $signature = 'messages:purge
{--days= : 覆盖默认保留天数}
{--days= : 覆盖通知消息默认保留天数}
{--image-days=3 : 聊天图片单独保留天数}
{--dry-run : 仅预览不实际删除}';
/**
@@ -34,7 +40,7 @@ class PurgeOldMessages extends Command
*
* @var string
*/
protected $description = '清理超过指定天数的聊天记录(保留天数由 sysparam message_retention_days 配置,默认 30';
protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
/**
* 执行命令
@@ -43,29 +49,32 @@ class PurgeOldMessages extends Command
*/
public function handle(): int
{
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
// 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
$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'));
$cutoff = Carbon::now()->subDays($days);
$isDryRun = $this->option('dry-run');
// 统计待清理数量
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
$this->cleanupExpiredImages($imageDays, $isDryRun);
$expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
$totalCount = (clone $expiredNoticeQuery)->count();
if ($totalCount === 0) {
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理");
$this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留");
return self::SUCCESS;
}
if ($isDryRun) {
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}");
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}");
return self::SUCCESS;
}
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}...");
$this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}...");
$this->info(" 待清理数量:{$totalCount}");
// 分批删除,每批 1000 条,避免长时间锁表
@@ -73,7 +82,7 @@ class PurgeOldMessages extends Command
$batchSize = 1000;
do {
$batch = Message::where('sent_at', '<', $cutoff)
$batch = $this->expiredNoticeQuery($cutoff)
->limit($batchSize)
->delete();
@@ -84,8 +93,87 @@ class PurgeOldMessages extends Command
}
} while ($batch === $batchSize);
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。");
$this->info("✅ 清理完成!共删除 {$deleted}游戏/临时通知,用户聊天记录未删除");
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']);
});
});
});
}
/**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/
private function cleanupExpiredImages(int $imageDays, bool $isDryRun): void
{
$imageCutoff = Carbon::now()->subDays($imageDays);
$query = Message::query()
->where('message_type', 'image')
->where('sent_at', '<', $imageCutoff)
->where(function ($builder) {
$builder->whereNotNull('image_path')->orWhereNotNull('image_thumb_path');
});
$totalCount = (clone $query)->count();
if ($totalCount === 0) {
$this->line("🖼️ 没有超过 {$imageDays} 天的聊天图片需要清理。");
return;
}
if ($isDryRun) {
$this->warn("🔍 [预览模式] 将清理 {$totalCount} 条超过 {$imageDays} 天的聊天图片(截止 {$imageCutoff->toDateTimeString()}");
return;
}
$processed = 0;
$query->orderBy('id')->chunkById(200, function ($messages) use (&$processed) {
foreach ($messages as $message) {
$paths = array_values(array_filter([
$message->image_path,
$message->image_thumb_path,
]));
// 先删物理文件,再把数据库消息降级成“图片已过期”占位,避免出现坏图。
if ($paths !== []) {
Storage::disk('public')->delete($paths);
}
$placeholder = trim((string) $message->content);
$placeholder = $placeholder !== '' ? $placeholder.' [图片已过期]' : '[图片已过期]';
$message->forceFill([
'content' => $placeholder,
'message_type' => 'expired_image',
'image_path' => null,
'image_thumb_path' => null,
'image_original_name' => null,
])->save();
$processed++;
}
});
$this->info("🖼️ 已清理 {$processed} 条超过 {$imageDays} 天的聊天图片。");
}
}
@@ -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();
}
}
@@ -0,0 +1,78 @@
<?php
/**
* 文件功能:测试发送微信机器人消息
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Models\SysParam;
use App\Services\WechatBot\WechatBotApiService;
use Illuminate\Console\Command;
class WechatBotTestSend extends Command
{
/**
* @var string
*/
protected $signature = 'wechat-bot:test-send';
/**
* @var string
*/
protected $description = '测试发送一条消息给管理员设定的微信群群 wxid';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('开始测试微信机器人发送...');
$param = SysParam::where('alias', 'wechat_bot_config')->first();
if (! $param || empty($param->body)) {
$this->error('错误:未找到 wechat_bot_config 配置,请先在后台保存一次配置。');
return self::FAILURE;
}
$config = json_decode($param->body, true);
$targetWxid = $config['group_notify']['target_wxid'] ?? '';
if (empty($targetWxid)) {
$this->error('错误:请于后台填写【目标微信群 Wxid】。');
return self::FAILURE;
}
if (empty($config['api']['bot_key'] ?? '')) {
$this->error('错误:未配置【机器人 Key (必需)】,API请求将被拒绝(返回该链接不存在)。');
return self::FAILURE;
}
$service = new WechatBotApiService;
$this->info("发送目标: {$targetWxid}");
$this->info('发送 API Base: '.($config['api']['base_url'] ?? ''));
$message = "【系统连通性测试】\n发送时间:".now()->format('Y-m-d H:i:s')."\n如果您看到了这条消息,说明 ChatRoom 通知全站群发接口配置正确!";
$result = $service->sendTextMessage($targetWxid, $message);
if ($result['success']) {
$this->info('✅ 发送成功!');
return self::SUCCESS;
} else {
$this->error('❌ 发送失败:'.($result['error'] ?? '未知错误'));
$this->warn('如果提示『该链接不存在』代表您的基础API URL 或接入 Key 有误。');
return self::FAILURE;
}
}
}
+60 -1
View File
@@ -35,12 +35,18 @@ enum CurrencySource: string
/** 商城购买消耗(扣除金币) */
case SHOP_BUY = 'shop_buy';
/** 购买聊天室座驾消耗(扣除金币) */
case RIDE_BUY = 'ride_buy';
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
case ADMIN_ADJUST = 'admin_adjust';
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
case POSITION_REWARD = 'position_reward';
/** 每日签到奖励(连续签到按规则发放) */
case SIGN_IN = 'sign_in';
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
@@ -48,7 +54,6 @@ enum CurrencySource: string
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
@@ -84,6 +89,9 @@ enum CurrencySource: string
/** 百家乐中奖赔付(收入金币,含本金返还) */
case BACCARAT_WIN = 'baccarat_win';
/** 百家乐买单活动补偿领取(收入金币) */
case BACCARAT_LOSS_COVER_CLAIM = 'baccarat_loss_cover_claim';
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
case AUTO_EVENT = 'auto_event';
@@ -132,6 +140,42 @@ enum CurrencySource: string
/** 五子棋 PvE 入场费返还(平局时返还) */
case GOMOKU_REFUND = 'gomoku_refund';
/** 看视频赚金币与经验奖励 */
case VIDEO_REWARD = 'video_reward';
/** 查看别人隐藏信息扣费 */
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';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -145,8 +189,10 @@ enum CurrencySource: string
self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买',
self::RIDE_BUY => '座驾购买(金币)',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
@@ -159,6 +205,7 @@ enum CurrencySource: string
self::HOLIDAY_BONUS => '节日福利',
self::BACCARAT_BET => '百家乐下注',
self::BACCARAT_WIN => '百家乐赢钱',
self::BACCARAT_LOSS_COVER_CLAIM => '百家乐买单活动补偿',
self::AUTO_EVENT => '随机事件(星海小博士)',
self::SLOT_SPIN => '老虎机转动',
self::SLOT_WIN => '老虎机中奖',
@@ -175,6 +222,18 @@ enum CurrencySource: string
self::GOMOKU_ENTRY_FEE => '五子棋入场费',
self::GOMOKU_WIN => '五子棋获胜奖励',
self::GOMOKU_REFUND => '五子棋入场费返还',
self::VIDEO_REWARD => '看视频奖励',
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\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐押注人数变化。
*/
class BaccaratPoolUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
*/
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 [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small,
'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\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐开局事件。
*/
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
*/
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 [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'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\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐结算结果。
*/
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
*/
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 [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:聊天室浏览器刷新请求广播事件
*
* 仅供站长触发“刷新全员”命令时使用,
* 向当前房间所有在线用户广播前端刷新指令。
*
* @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 BrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录房间与操作者信息。
*/
public function __construct(
public readonly int $roomId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:当前聊天室 PresenceChannel。
*
* @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 [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
+17 -1
View File
@@ -20,6 +20,10 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 开发日志发布广播事件
* 负责把更新日志的安全展示字段广播给大厅聊天室。
*/
class ChangelogPublished implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -67,8 +71,20 @@ class ChangelogPublished implements ShouldBroadcastNow
'title' => $this->changelog->title,
'type' => $this->changelog->type,
'type_label' => $this->changelog->type_label,
// 同步提供已转义字段,便于前端在 innerHTML 场景下直接复用安全文本。
'safe_version' => e((string) $this->changelog->version),
'safe_title' => e((string) $this->changelog->title),
'safe_type_label' => e((string) $this->changelog->type_label),
// 前端点击后跳转的目标 URL,自动锚定至对应版本
'url' => url('/changelog').'#v'.$this->changelog->version,
'url' => $this->buildDetailUrl(),
];
}
/**
* 生成广播使用的更新日志详情地址,并编码版本锚点避免 href 注入。
*/
private function buildDetailUrl(): string
{
return route('changelog.index').'#v'.rawurlencode((string) $this->changelog->version);
}
}
+16 -3
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室全屏特效广播事件
*
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者target_username null 则全员播放)
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见
*
* @author ChatRoom Laravel
*
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
*/
class EffectBroadcast implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 支持的特效类型列表(用于校验)
*/
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow'];
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning / snow
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
* @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言
* @param string|null $effectTitle 特效画面标题
* @param string|null $rideName 座驾名称
* @param string|null $effectUserInfo 特效画面用户身份信息
*/
public function __construct(
public readonly int $roomId,
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
public readonly string $operator,
public readonly ?string $targetUsername = null,
public readonly ?string $giftMessage = null,
public readonly ?string $effectTitle = null,
public readonly ?string $rideName = null,
public readonly ?string $effectUserInfo = null,
) {}
/**
@@ -59,6 +68,7 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 广播数据:特效类型、操作者、目标用户、赠言
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
*
* @return array<string, mixed>
*/
@@ -69,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
'operator' => $this->operator,
'target_username' => $this->targetUsername, // null = 全员
'gift_message' => $this->giftMessage,
'effect_title' => $this->effectTitle,
'ride_name' => $this->rideName,
'effect_user_info' => $this->effectUserInfo,
];
}
}
+18 -12
View File
@@ -14,22 +14,25 @@
namespace App\Events;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向房间广播节日福利发放批次开始事件。
*/
class HolidayEventStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HolidayEvent $event 节日活动实例
* @param HolidayEventRun $run 节日福利发放批次
*/
public function __construct(
public readonly HolidayEvent $event,
public readonly HolidayEventRun $run,
) {}
/**
@@ -60,15 +63,18 @@ class HolidayEventStarted implements ShouldBroadcastNow
public function broadcastWith(): array
{
return [
'event_id' => $this->event->id,
'name' => $this->event->name,
'description' => $this->event->description,
'total_amount' => $this->event->total_amount,
'max_claimants' => $this->event->max_claimants,
'distribute_type' => $this->event->distribute_type,
'fixed_amount' => $this->event->fixed_amount,
'claimed_count' => $this->event->claimed_count,
'expires_at' => $this->event->expires_at?->toIso8601String(),
'run_id' => $this->run->id,
'event_id' => $this->run->holiday_event_id,
'name' => $this->run->event_name,
'description' => $this->run->event_description,
'total_amount' => $this->run->total_amount,
'max_claimants' => $this->run->max_claimants,
'distribute_type' => $this->run->distribute_type,
'fixed_amount' => $this->run->fixed_amount,
'claimed_count' => $this->run->claimed_count,
'expires_at' => $this->run->expires_at?->toIso8601String(),
'scheduled_for' => $this->run->scheduled_for?->toIso8601String(),
'repeat_type' => $this->run->repeat_type,
];
}
}
+6 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播赛马开局事件。
*/
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -58,7 +61,9 @@ class HorseRaceOpened implements ShouldBroadcastNow
{
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
+6 -1
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间持续广播赛马进度。
*/
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function __construct(
public readonly int $raceId,
public readonly int $roomId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->roomId)];
}
/**
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
{
return [
'race_id' => $this->raceId,
'room_id' => $this->roomId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
+31 -2
View File
@@ -13,6 +13,8 @@
namespace App\Events;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
@@ -20,6 +22,12 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:赛马结算广播事件
*
* 向房间公共频道广播最终赛果,并附带前端展示个人奖金所需的
* 奖池分配参数,避免结算弹窗只能显示固定的 0 金币。
*/
class HorseRaceSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -56,6 +64,24 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastWith(): array
{
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
// 统计各马匹总下注,为前端还原个人分奖金额提供基础参数。
$horsePools = HorseBet::query()
->where('race_id', $this->race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->map(fn ($pool) => (int) $pool)
->toArray();
$winnerPool = (int) ($horsePools[$this->race->winner_horse_id] ?? 0);
$distributablePool = (int) round(
HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool)
);
// 找出获胜马匹的名称
$horses = $this->race->horses ?? [];
$winnerName = '未知';
@@ -68,9 +94,12 @@ class HorseRaceSettled implements ShouldBroadcastNow
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $this->race->total_pool,
'total_pool' => (int) $this->race->total_pool,
'winner_pool' => $winnerPool,
'distributable_pool' => $distributablePool,
'settled_at' => $this->race->settled_at?->toIso8601String(),
];
}
+56 -4
View File
@@ -10,20 +10,25 @@
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:根据消息可见范围选择广播频道。
*/
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
* 创建消息广播事件实例。
*
* @param int $roomId 房间ID
* @param int $roomId 房间 ID
* @param array $message 发送的消息数据
*/
public function __construct(
@@ -32,14 +37,25 @@ class MessageSent implements ShouldBroadcastNow
) {}
/**
* Get the channels the event should broadcast on.
* 获取消息应广播到的频道。
*
* 聊天消息广播至包含在线状态管理的 PresenceChannel。
* 公共消息和普通定向发言走房间 Presence 频道;
* 悄悄话只发给发送方与接收方的私有用户频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
if ($this->shouldBroadcastPrivately()) {
$privateChannels = [];
foreach ($this->resolveVisibleUserIds() as $userId) {
$privateChannels[] = new PrivateChannel('user.'.$userId);
}
return $privateChannels;
}
return [
new PresenceChannel('room.'.$this->roomId),
];
@@ -56,4 +72,40 @@ class MessageSent implements ShouldBroadcastNow
'message' => $this->message,
];
}
/**
* 判断当前消息是否应仅广播给特定用户。
*/
private function shouldBroadcastPrivately(): bool
{
return (bool) ($this->message['is_secret'] ?? false);
}
/**
* 解析本条消息真正可见的用户 ID 列表。
*
* @return array<int, int>
*/
private function resolveVisibleUserIds(): array
{
$userIds = [];
$fromUser = trim((string) ($this->message['from_user'] ?? ''));
if ($fromUser !== '') {
$senderId = User::query()->where('username', $fromUser)->value('id');
if ($senderId !== null) {
$userIds[] = (int) $senderId;
}
}
$toUser = trim((string) ($this->message['to_user'] ?? ''));
if ($toUser !== '' && $toUser !== '大家') {
$receiverId = User::query()->where('username', $toUser)->value('id');
if ($receiverId !== null) {
$userIds[] = (int) $receiverId;
}
}
return array_values(array_unique($userIds));
}
}
+29 -5
View File
@@ -1,10 +1,10 @@
<?php
/**
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
* 文件功能:红包领取成功广播事件(广播至房间与领取者私有频道)
*
* 触发时机:RedPacketController::claim() 成功后广播,
* 前端收到后弹出 Toast 通知展示到账金额
* 房间内在线用户收到后实时刷新剩余份数,领取者本人可同步收到到账提示
*
* @author ChatRoom Laravel
*
@@ -15,11 +15,18 @@ namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:广播礼包被领取后的实时状态
*
* 统一向房间频道推送剩余份数变化,同时向领取者私有频道推送到账结果,
* 让红包弹窗与用户提示保持一致。
*/
class RedPacketClaimed implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -28,32 +35,49 @@ class RedPacketClaimed implements ShouldBroadcastNow
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $envelopeId 红包 ID
* @param int $roomId 房间 ID
* @param int $remainingCount 剩余份数
* @param string $type 红包类型
*/
public function __construct(
public readonly User $claimer,
public readonly int $amount,
public readonly int $envelopeId,
public readonly int $roomId,
public readonly int $remainingCount,
public readonly string $type = 'gold',
) {}
/**
* 广播至领取者私有频道。
* 广播至房间频道与领取者私有频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->claimer->id)];
return [
new PresenceChannel('room.'.$this->roomId),
new PrivateChannel('user.'.$this->claimer->id),
];
}
/**
* 广播领取结果与剩余份数。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$typeLabel = $this->type === 'exp' ? '经验' : '金币';
return [
'envelope_id' => $this->envelopeId,
'claimer_id' => $this->claimer->id,
'claimer_username' => $this->claimer->username,
'amount' => $this->amount,
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
'remaining_count' => $this->remainingCount,
'type' => $this->type,
'message' => "🧧 成功抢到 {$this->amount} {$typeLabel}礼包!",
];
}
+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}",
];
}
}
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:用户定向页面刷新广播事件
*
* 在任命或撤销职务成功后,向目标用户私有频道推送刷新指令,
* 确保对方页面上的权限按钮与职务状态及时同步。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定用户广播页面刷新请求。
*/
class UserBrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录目标用户与刷新说明。
*/
public function __construct(
public readonly int $targetUserId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:目标用户私有频道。
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('user.'.$this->targetUserId);
}
/**
* 广播数据:供前端展示提示并执行刷新。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
/**
* 文件功能:用户被踢出房间广播事件
*
* 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。
* 管理员踢出/封禁用户时触发,前端监听后强制该用户跳转至大厅。
*
* @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,
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?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 UserStatusUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造聊天室用户状态变更广播事件。
*
* @param int $roomId 房间 ID
* @param string $username 状态变更用户昵称
* @param array<string, mixed> $user 最新在线名单载荷
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
public readonly array $user,
) {}
/**
* 获取广播频道。
*
* @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 [
'username' => $this->username,
'user' => $this->user,
];
}
}
@@ -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'));
}
}
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:后台隐藏登录控制器
*
* 仅提供站长独立登录入口,登录成功后直接进入后台控制台,
* 不经过聊天室首页与“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminLoginRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* 类功能:处理站长隐藏登录页展示与登录提交。
*/
class AdminAuthController extends Controller
{
/**
* 隐藏登录入口后缀。
*/
private const LOGIN_SUFFIX = 'lkddi';
/**
* 站长账号固定主键。
*/
private const SITE_OWNER_ID = 1;
/**
* 显示站长隐藏登录页面。
*/
public function create(Request $request): View|RedirectResponse
{
// 已通过隐藏入口登录的站长再次访问时,直接回后台首页
if (Auth::id() === self::SITE_OWNER_ID && $request->session()->get('admin_login_via_hidden')) {
return redirect()->route('admin.dashboard');
}
return view('admin.auth.login', [
'loginSuffix' => self::LOGIN_SUFFIX,
]);
}
/**
* 处理站长隐藏登录请求。
*/
public function store(AdminLoginRequest $request): RedirectResponse
{
$validated = $request->validated();
$siteOwner = User::query()->find(self::SITE_OWNER_ID);
// 只有 id=1 的站长账号允许通过该入口进入后台
if (! $siteOwner || $siteOwner->username !== $validated['username']) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['username' => '该入口仅限站长账号使用。']);
}
if (! $this->passwordMatches($siteOwner, $validated['password'])) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['password' => '账号或密码错误。']);
}
// 若当前已有其他账号占用会话,先退出后再切换为站长会话
if (Auth::check() && Auth::id() !== $siteOwner->id) {
Auth::logout();
}
Auth::login($siteOwner);
$request->session()->regenerate();
$request->session()->put('admin_login_via_hidden', true);
// 复用主登录的会话登记逻辑,保证后台入口也会更新登录痕迹
$this->recordAdminLogin($siteOwner, (string) $request->ip());
return redirect()->route('admin.dashboard')->with('success', '站长后台登录成功。');
}
/**
* 校验站长密码,兼容旧库 MD5 并自动升级为 bcrypt。
*/
private function passwordMatches(User $siteOwner, string $plainPassword): bool
{
try {
if (Hash::check($plainPassword, $siteOwner->password)) {
return true;
}
} catch (\RuntimeException $exception) {
// 旧库非 bcrypt 密码会在这里抛异常,后续继续走 MD5 兼容逻辑
}
if (md5($plainPassword) !== $siteOwner->password) {
return false;
}
// 兼容老密码登录成功后,立即升级为 Laravel 默认哈希
$siteOwner->forceFill([
'password' => Hash::make($plainPassword),
])->save();
return true;
}
/**
* 记录站长通过隐藏入口登录后的访问痕迹。
*/
private function recordAdminLogin(User $siteOwner, string $ip): void
{
// 登录成功后补齐访问次数、IP 与时间,保持与前台登录统计一致
$siteOwner->increment('visit_num');
$siteOwner->update([
'previous_ip' => $siteOwner->last_ip,
'last_ip' => $ip,
'log_time' => now(),
'in_time' => now(),
]);
\App\Models\IpLog::create([
'ip' => $ip,
'sdate' => now(),
'uuname' => $siteOwner->username,
]);
try {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyAdminOnline($siteOwner);
$wechatService->notifyFriendsOnline($siteOwner);
$wechatService->notifySpouseOnline($siteOwner);
} catch (\Exception $exception) {
// 机器人通知异常不影响站长进入后台,但需要落日志便于排查
Log::error('Hidden admin login notification failed', ['error' => $exception->getMessage()]);
}
}
}
@@ -20,6 +20,7 @@ use App\Http\Controllers\Controller;
use App\Models\AiProviderConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,6 +34,7 @@ class AiProviderController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
@@ -283,19 +285,8 @@ class AiProviderController extends Controller
]);
}
$userData = [
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
// 机器人在线载荷也统一走聊天室展示服务,避免名单字段口径逐步漂移。
$userData = $this->chatUserPresenceService->build($user);
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:百家乐买单活动后台控制器
*
* 提供聊天室管理员在输入框上方快捷创建活动、
* 查看当前活动并手动结束活动的 JSON 接口。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
use App\Models\BaccaratLossCoverEvent;
use App\Services\BaccaratLossCoverService;
use App\Services\PositionPermissionService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:处理聊天室顶部快捷入口创建与结束百家乐买单活动。
*/
class BaccaratLossCoverEventController extends Controller
{
/**
* 注入百家乐买单活动服务。
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* 创建新的百家乐买单活动。
*/
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权创建买单活动。',
], 403);
}
try {
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
} catch (\RuntimeException $exception) {
return response()->json([
'ok' => false,
'message' => $exception->getMessage(),
], 422);
}
return response()->json([
'ok' => true,
'message' => "活动「{$event->title}」已创建成功。",
'event_id' => $event->id,
]);
}
/**
* 手动结束或取消一场百家乐买单活动。
*/
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权结束买单活动。',
], 403);
}
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
return response()->json([
'ok' => true,
'message' => '活动状态已更新。',
'status' => $event->status,
]);
}
}
@@ -9,7 +9,7 @@
* 安全保证:
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
* - 普通用户无权访问此接口,无法伪造对他人的广播
* - options 中的用户输入字段在后端经过 strip_tags 清洗
* - options 中的用户输入字段在后端统一降级为纯文本 / 白名单样式值
*
* @author ChatRoom Laravel
*
@@ -23,6 +23,9 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:安全地下发大卡片广播消息。
*/
class BannerBroadcastController extends Controller
{
/**
@@ -46,23 +49,39 @@ class BannerBroadcastController extends Controller
'options.body' => ['nullable', 'string', 'max:500'],
'options.sub' => ['nullable', 'string', 'max:200'],
'options.gradient' => ['nullable', 'array', 'max:5'],
'options.gradient.*' => ['nullable', 'string', 'max:30'],
'options.titleColor' => ['nullable', 'string', 'max:30'],
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
'options.buttons' => ['nullable', 'array', 'max:4'],
'options.buttons.*.label' => ['nullable', 'string', 'max:30'],
'options.buttons.*.color' => ['nullable', 'string', 'max:30'],
'options.buttons.*.action' => ['nullable', 'string', 'max:20'],
]);
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
// 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。
$opts = $validated['options'];
foreach (['title', 'name', 'body', 'sub'] as $field) {
if (isset($opts[$field])) {
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
$opts[$field] = $this->sanitizeBannerText($opts[$field]);
}
}
// 按钮 label 不允许 HTML
if (isset($opts['titleColor'])) {
$opts['titleColor'] = $this->sanitizeCssValue($opts['titleColor'], '#fde68a');
}
if (! empty($opts['gradient'])) {
$opts['gradient'] = array_values(array_map(
fn ($color) => $this->sanitizeCssValue($color, '#4f46e5'),
$opts['gradient']
));
}
// 按钮 label 与颜色都只允许安全文本 / 颜色值。
if (! empty($opts['buttons'])) {
$opts['buttons'] = array_map(function ($btn) {
$btn['label'] = strip_tags($btn['label'] ?? '');
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
$btn['label'] = $this->sanitizeBannerText($btn['label'] ?? '');
$btn['color'] = $this->sanitizeCssValue($btn['color'] ?? '#10b981', '#10b981');
// action 只允许预定义值,防止注入任意 JS
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
? $btn['action'] : 'close';
@@ -79,4 +98,38 @@ class BannerBroadcastController extends Controller
return response()->json(['status' => 'success', 'message' => '广播已发送']);
}
/**
* Banner 文案净化为安全纯文本。
*/
private function sanitizeBannerText(?string $text): string
{
return trim(strip_tags((string) $text));
}
/**
* 清洗颜色 / 渐变等 CSS 值,阻断样式属性注入。
*/
private function sanitizeCssValue(?string $value, string $default): string
{
$sanitized = strtolower(trim((string) $value));
if ($sanitized === '' || preg_match('/(?:javascript|expression|url\s*\(|data:|var\s*\()/i', $sanitized)) {
return $default;
}
$allowedPatterns = [
'/^#[0-9a-f]{3,8}$/i',
'/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
'/^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
'/^(?:white|black|red|blue|green|gray|grey|yellow|orange|pink|purple|teal|cyan|indigo|amber|emerald|transparent|currentcolor)$/i',
];
foreach ($allowedPatterns as $allowedPattern) {
if (preg_match($allowedPattern, $sanitized)) {
return $sanitized;
}
}
return $default;
}
}
@@ -174,18 +174,29 @@ class ChangelogController extends Controller
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
// 广播文案允许保留安全链接,但标题与版本号必须先做 HTML 转义,避免系统消息被拼成恶意标签。
$safeTypeLabel = e(DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新');
$safeVersion = e((string) $log->version);
$safeTitle = e((string) $log->title);
$detailUrl = e($this->buildChangelogDetailUrl($log));
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'content' => "📢 【版本更新 {$safeTypeLabel}】v{$safeVersion}{$safeTitle}》— <a href=\"{$detailUrl}\" target=\"_blank\" rel=\"noopener\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
/**
* 生成开发日志详情链接,并对版本片段做 URL 编码,避免广播 href 被注入额外属性。
*/
private function buildChangelogDetailUrl(DevChangelog $log): string
{
return route('changelog.index').'#v'.rawurlencode((string) $log->version);
}
}
@@ -17,6 +17,9 @@ use App\Models\UserCurrencyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:提供后台全局金币/积分流水查询与多条件筛选。
*/
class CurrencyLogController extends Controller
{
/**
@@ -26,6 +29,12 @@ class CurrencyLogController extends Controller
public function index(Request $request): View
{
$query = UserCurrencyLog::query()->with('user');
$allSources = CurrencySource::cases();
$allowedSources = collect($allSources)->map(fn (CurrencySource $source) => $source->value)->all();
$selectedSources = collect($request->array('sources'))
->filter(fn (string $source) => in_array($source, $allowedSources, true))
->values()
->all();
// 查询条件过滤
if ($request->filled('username')) {
@@ -36,8 +45,8 @@ class CurrencyLogController extends Controller
$query->where('currency', $request->input('currency'));
}
if ($request->filled('source')) {
$query->where('source', $request->input('source'));
if ($selectedSources !== []) {
$query->whereIn('source', $selectedSources);
}
if ($request->filled('remark')) {
@@ -63,8 +72,6 @@ class CurrencyLogController extends Controller
// 默认按时间倒序
$logs = $query->latest('id')->paginate(50)->withQueryString();
$allSources = CurrencySource::cases();
return view('admin.currency-logs.index', compact('logs', 'allSources'));
return view('admin.currency-logs.index', compact('logs', 'allSources', 'selectedSources'));
}
}
@@ -14,11 +14,13 @@ namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\UserCurrencyLog;
use App\Services\UserCurrencyService;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:展示后台积分流水统计与指定日期净流通数据。
*/
class CurrencyStatsController extends Controller
{
/**
@@ -45,20 +47,7 @@ class CurrencyStatsController extends Controller
);
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '>', 0)
->sum('amount');
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '<', 0)
->sum('amount');
$netFlow[$currency] = [
'in' => $totalIn,
'out' => abs($totalOut),
'net' => $totalIn + $totalOut, // 净增量
];
}
$netFlow = $this->currencyService->netFlowStats($date);
// 所有已知来源(供视图展示缺失来源的空行)
$allSources = CurrencySource::cases();
@@ -13,18 +13,37 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService;
use Illuminate\View\View;
/**
* 类功能:负责后台首页仪表盘的汇总统计展示。
*/
class DashboardController extends Controller
{
/**
* 注入聊天室状态服务,供仪表盘读取实时在线数据。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示后台首页与全局统计
*/
public function index(): View
{
$onlineUsernames = collect();
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
// 使用在线名单服务的懒清理结果,保证统计口径与聊天室在线列表一致。
$onlineUsernames = $onlineUsernames->merge(array_keys($this->chatState->getRoomUsers($roomId)));
}
$stats = [
'total_users' => User::count(),
'total_rooms' => Room::count(),
'online_users' => $onlineUsernames->unique()->count(),
// 更多统计指标以后再发掘
];
@@ -14,14 +14,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateGameConfigParamsRequest;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\Room;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
*/
class GameConfigController extends Controller
{
/**
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
public function index(): View
{
$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,36 @@ class GameConfigController extends Controller
*
* 接收前端提交的 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 ?? [];
$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') {
$legacyMap = [
'min_reward' => 'normal_reward_min',
'max_reward' => 'normal_reward_max',
'rare_min_reward' => 'rare_reward_min',
'rare_max_reward' => 'rare_reward_max',
];
foreach ($legacyMap as $legacyKey => $newKey) {
if (! array_key_exists($newKey, $updated) && array_key_exists($legacyKey, $updated)) {
$updated[$newKey] = $updated[$legacyKey];
}
unset($updated[$legacyKey]);
}
}
$gameConfig->update(['params' => $updated]);
$gameConfig->clearCache();
@@ -90,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' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
]);
}
@@ -109,19 +139,31 @@ class GameConfigController extends Controller
*
* 仅在当前无进行中期次时生效,防止重复开期。
*/
public function openLotteryIssue(): JsonResponse
public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
}
if (LotteryIssue::currentIssue()) {
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
$openedRoomIds = [];
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),
]);
}
/**
@@ -14,15 +14,27 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreHolidayEventRequest;
use App\Http\Requests\UpdateHolidayEventRequest;
use App\Jobs\TriggerHolidayEventJob;
use App\Models\HolidayEvent;
use App\Services\HolidayEventScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:管理节日福利模板的后台增删改查与手动触发操作。
*/
class HolidayEventController extends Controller
{
/**
* 注入节日福利调度计算服务。
*/
public function __construct(
private readonly HolidayEventScheduleService $scheduleService,
) {}
/**
* 节日福利活动列表页。
*/
@@ -46,30 +58,9 @@ class HolidayEventController extends Controller
/**
* 保存新活动。
*/
public function store(Request $request): RedirectResponse
public function store(StoreHolidayEventRequest $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'total_amount' => 'required|integer|min:1',
'max_claimants' => 'required|integer|min:0',
'distribute_type' => 'required|in:random,fixed',
'min_amount' => 'nullable|integer|min:1',
'max_amount' => 'nullable|integer|min:1',
'fixed_amount' => 'nullable|integer|min:1',
'send_at' => 'required|date',
'expire_minutes' => 'required|integer|min:1|max:1440',
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
'cron_expr' => 'nullable|string|max:100',
'target_type' => 'required|in:all,vip,level',
'target_value' => 'nullable|string|max:50',
'enabled' => 'boolean',
]);
$data['status'] = 'pending';
$data['enabled'] = $request->boolean('enabled', true);
HolidayEvent::create($data);
HolidayEvent::create($this->buildPayload($request->validated(), true));
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
}
@@ -85,26 +76,9 @@ class HolidayEventController extends Controller
/**
* 更新活动。
*/
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
public function update(UpdateHolidayEventRequest $request, HolidayEvent $holidayEvent): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'total_amount' => 'required|integer|min:1',
'max_claimants' => 'required|integer|min:0',
'distribute_type' => 'required|in:random,fixed',
'min_amount' => 'nullable|integer|min:1',
'max_amount' => 'nullable|integer|min:1',
'fixed_amount' => 'nullable|integer|min:1',
'send_at' => 'required|date',
'expire_minutes' => 'required|integer|min:1|max:1440',
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
'cron_expr' => 'nullable|string|max:100',
'target_type' => 'required|in:all,vip,level',
'target_value' => 'nullable|string|max:50',
]);
$holidayEvent->update($data);
$holidayEvent->update($this->buildPayload($request->validated()));
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
}
@@ -128,13 +102,12 @@ class HolidayEventController extends Controller
*/
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
{
if ($holidayEvent->status !== 'pending') {
return back()->with('error', '只有待触发状态的活动才能手动触发。');
if (! $holidayEvent->enabled || $holidayEvent->status === 'cancelled') {
return back()->with('error', '当前活动未启用或已取消,不能立即触发。');
}
// 设置触发时间为当前,立即入队
$holidayEvent->update(['send_at' => now()]);
TriggerHolidayEventJob::dispatch($holidayEvent);
// 立即触发只生成临时批次,不覆盖年度锚点或下次计划时间。
TriggerHolidayEventJob::dispatch($holidayEvent, true);
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
}
@@ -148,4 +121,54 @@ class HolidayEventController extends Controller
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
}
/**
* 组装节日福利模板的可持久化字段。
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function buildPayload(array $data, bool $isCreating = false): array
{
$payload = $data;
// 创建与编辑都统一回收无效字段,避免模板状态互相污染。
if (($payload['distribute_type'] ?? 'random') === 'random') {
$payload['fixed_amount'] = null;
} else {
$payload['min_amount'] = 1;
$payload['max_amount'] = null;
}
if (($payload['target_type'] ?? 'all') !== 'level') {
$payload['target_value'] = null;
}
if (($payload['repeat_type'] ?? 'once') !== 'cron') {
$payload['cron_expr'] = null;
}
if (($payload['repeat_type'] ?? 'once') === 'yearly') {
$payload['send_at'] = $this->scheduleService
->resolveNextConfiguredSendAt($payload)
->toDateTimeString();
} else {
$payload['schedule_month'] = null;
$payload['schedule_day'] = null;
$payload['schedule_time'] = null;
$payload['duration_days'] = 1;
$payload['daily_occurrences'] = 1;
$payload['occurrence_interval_minutes'] = null;
}
// 每次保存模板时,都让系统按新配置重新进入待触发状态。
$payload['status'] = 'pending';
$payload['enabled'] = (bool) ($payload['enabled'] ?? true);
$payload['triggered_at'] = null;
$payload['expires_at'] = null;
$payload['claimed_count'] = 0;
$payload['claimed_amount'] = 0;
return $payload;
}
}
@@ -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', '等级经验阈值已保存并生效!');
}
}
@@ -16,10 +16,15 @@ use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\Sysparam;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:负责后台职务资料、任命白名单与聊天室权限配置的维护。
*/
class PositionController extends Controller
{
/**
@@ -29,16 +34,25 @@ class PositionController extends Controller
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->with('appointablePositions')->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
$allPositions = Position::with('department')->ordered()->get();
// 全局奖励接收次数上限(0 = 不限)
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
$positionPermissions = PositionPermissionRegistry::groupedDefinitions();
$permissionLabels = PositionPermissionRegistry::labelMap();
return view('admin.positions.index', compact(
'departments',
'allPositions',
'globalRecipientDailyMax',
'positionPermissions',
'permissionLabels',
));
}
/**
@@ -56,13 +70,20 @@ class PositionController extends Controller
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
'red_packet_count' => 'nullable|integer|min:1|max:100',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
$position = Position::create($data);
@@ -144,13 +165,20 @@ class PositionController extends Controller
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
'red_packet_count' => 'nullable|integer|min:1|max:100',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
@@ -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;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreShopItemRequest;
use App\Http\Requests\UpdateShopItemRequest;
use App\Models\ShopItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
/**
* 新增商品(仅 id=1 超级站长)
*/
public function store(Request $request): RedirectResponse
public function store(StoreShopItemRequest $request): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$data = $this->validateItem($request);
$data = $request->validated();
ShopItem::create($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
*
* @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);
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 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',
'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',
]);
}
}
@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:后台签到奖励规则管理控制器
*
* 提供连续签到奖励档位的列表、新增、编辑、启停和删除功能。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SaveSignInRewardRuleRequest;
use App\Models\SignInRewardRule;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
/**
* 类功能:管理后台每日签到奖励规则。
*/
class SignInRewardRuleController extends Controller
{
/**
* 方法功能:展示签到奖励规则列表。
*/
public function index(): View
{
$rules = SignInRewardRule::query()
->orderBy('sort_order')
->orderBy('streak_days')
->get();
return view('admin.sign-in-rules.index', compact('rules'));
}
/**
* 方法功能:新增签到奖励规则。
*/
public function store(SaveSignInRewardRuleRequest $request): RedirectResponse
{
SignInRewardRule::query()->create($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。');
}
/**
* 方法功能:更新签到奖励规则。
*/
public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->update($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。');
}
/**
* 方法功能:切换签到奖励规则启用状态。
*/
public function toggle(SignInRewardRule $signInRewardRule): JsonResponse
{
$signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]);
return response()->json([
'ok' => true,
'is_enabled' => $signInRewardRule->is_enabled,
'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。',
]);
}
/**
* 方法功能:删除签到奖励规则。
*/
public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->delete();
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。');
}
/**
* 方法功能:整理后台表单提交的数据。
*
* @return array<string, mixed>
*/
private function payload(SaveSignInRewardRuleRequest $request): array
{
$data = $request->validated();
$data['is_enabled'] = $request->boolean('is_enabled');
foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) {
$data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
}
return $data;
}
}
+77 -13
View File
@@ -17,28 +17,37 @@ use App\Models\SysParam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* 类功能:后台通用系统参数配置控制器
* 仅允许维护低敏公共参数,站长专属敏感配置需走各自独立页面。
*/
class SystemController extends Controller
{
/**
* 构造函数注入聊天室状态服务
*/
public function __construct(
private readonly ChatStateService $chatState
) {}
/**
* 显示全局参数配置表单
* 显示通用系统参数配置表单
*/
public function edit(): View
{
// 读取数据库中最新的参数 (剔除专属模块已接管的配置,避免重复显示)
$params = SysParam::whereNotIn('alias', ['chatbot_enabled'])
->where('alias', 'not like', 'smtp_%')
->get()->pluck('body', 'alias')->toArray();
$editableAliases = $this->editableSystemAliases();
// 为后台界面准备的文案对照 (可动态化或硬编码)
$descriptions = SysParam::whereNotIn('alias', ['chatbot_enabled'])
->where('alias', 'not like', 'smtp_%')
->get()->pluck('guidetxt', 'alias')->toArray();
// 通用系统页仅加载白名单字段,避免站长专属配置被普通高管查看。
$systemParams = SysParam::query()
->whereIn('alias', $editableAliases)
->orderBy('id')
->get(['alias', 'body', 'guidetxt']);
$params = $systemParams->pluck('body', 'alias')->all();
$descriptions = $systemParams->pluck('guidetxt', 'alias')->all();
return view('admin.system.edit', compact('params', 'descriptions'));
}
@@ -48,16 +57,27 @@ class SystemController extends Controller
*/
public function update(Request $request): RedirectResponse
{
$data = $request->except(['_token', '_method']);
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
$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) {
$normalizedBody = (string) $body;
SysParam::updateOrCreate(
['alias' => $alias],
['body' => $body]
['body' => $normalizedBody]
);
// 写入 Cache 保证极速读取
$this->chatState->setSysParam($alias, $body);
// 仅对白名单字段同步缓存,杜绝越权请求覆盖站长专属配置。
$this->chatState->setSysParam($alias, $normalizedBody);
// 同时清除 Sysparam 模型的内部缓存
SysParam::clearCache($alias);
@@ -65,4 +85,48 @@ class SystemController extends Controller
return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!');
}
/**
* 获取通用系统页允许维护的参数别名白名单
*
* @return array<int, string>
*/
private function editableSystemAliases(): array
{
return SysParam::query()
->orderBy('id')
->pluck('alias')
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
->values()
->all();
}
/**
* 判断参数是否属于站长专属敏感配置
*/
private function isSensitiveAlias(string $alias): bool
{
if (Str::startsWith($alias, ['smtp_', 'vip_payment_', 'wechat_bot_', 'chatbot_'])) {
return true;
}
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);
}
}
@@ -12,8 +12,15 @@
namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Events\AppointmentAnnounced;
use App\Events\UserBrowserRefreshRequested;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateManagedUserRequest;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -23,6 +30,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
/**
* 类功能:负责后台用户列表展示、资料编辑与删除操作。
*/
class UserManagerController extends Controller
{
/**
@@ -31,6 +41,7 @@ class UserManagerController extends Controller
public function __construct(
private readonly UserCurrencyService $currencyService,
private readonly ChatStateService $chatState,
private readonly AppointmentService $appointmentService,
) {}
/**
@@ -52,7 +63,7 @@ class UserManagerController extends Controller
$onlineUsernames = $onlineUsernames->unique()->values();
// 排序:允许的字段白名单,防止 SQL 注入
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online'];
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online', 'wxid'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
@@ -77,8 +88,12 @@ class UserManagerController extends Controller
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
// 职务下拉选项(复用任命系统中的部门与职务数据)
$departments = Department::with([
'positions' => fn ($positionQuery) => $positionQuery->ordered(),
])->ordered()->get();
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
return view('admin.users.index', compact('users', 'vipLevels', 'departments', 'sortBy', 'sortDir', 'onlineUsernames'));
}
/**
@@ -86,10 +101,11 @@ class UserManagerController extends Controller
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, User $user): JsonResponse|RedirectResponse
public function update(UpdateManagedUserRequest $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = $user;
$currentUser = Auth::user();
$responseMessages = [];
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
if ($currentUser->id !== 1) {
@@ -104,17 +120,7 @@ class UserManagerController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
$validated = $request->validate([
'sex' => 'sometimes|integer|in:0,1,2',
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
'qianming' => 'sometimes|nullable|string|max:255',
'headface' => 'sometimes|string|max:50',
'password' => 'nullable|string|min:6',
'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id',
'hy_time' => 'sometimes|nullable|date',
]);
$validated = $request->validated();
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
@@ -188,11 +194,31 @@ class UserManagerController extends Controller
$targetUser->save();
if ($request->wantsJson()) {
return response()->json(['status' => 'success', 'message' => '用户资料已强行更新完毕!']);
if (array_key_exists('position_id', $validated)) {
$positionSyncResult = $this->syncUserPosition(
operator: $currentUser,
targetUser: $targetUser,
targetPositionId: $validated['position_id'],
);
if (! $positionSyncResult['ok']) {
return response()->json(['status' => 'error', 'message' => $positionSyncResult['message']], 422);
}
if (! empty($positionSyncResult['message'])) {
$responseMessages[] = $positionSyncResult['message'];
}
}
return back()->with('success', '用户资料已更新!');
if ($request->wantsJson()) {
$message = array_merge(['用户资料已强行更新完毕!'], $responseMessages);
return response()->json(['status' => 'success', 'message' => implode(' ', $message)]);
}
$message = array_merge(['用户资料已更新!'], $responseMessages);
return back()->with('success', implode(' ', $message));
}
/**
@@ -215,14 +241,111 @@ class UserManagerController extends Controller
abort(403, '权限不足:无法删除同级或高级账号!');
}
// 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10');
if ($targetUser->user_level >= $levelKick) {
abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。');
// 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。
$targetUser->loadMissing('activePosition.position');
if ($targetUser->id === 1 || $targetUser->activePosition?->position) {
abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。');
}
$targetUser->delete();
return back()->with('success', '目标已被物理删除。');
}
/**
* 方法功能:同步后台编辑页选择的目标职务。
*
* @return array{ok: bool, message: string}
*/
private function syncUserPosition(User $operator, User $targetUser, ?int $targetPositionId): array
{
$currentAssignment = $this->appointmentService->getActivePosition($targetUser);
$currentPositionId = $currentAssignment?->position_id;
if ($targetPositionId === $currentPositionId) {
return ['ok' => true, 'message' => ''];
}
if ($targetPositionId === null) {
if (! $currentAssignment) {
return ['ok' => true, 'message' => ''];
}
$result = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
if (! $result['ok']) {
return $result;
}
$this->broadcastRevokedPosition($operator, $targetUser, $currentAssignment);
return ['ok' => true, 'message' => '用户职务已撤销。'];
}
$targetPosition = Position::with('department')->findOrFail($targetPositionId);
if ($currentAssignment) {
$revokeResult = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
if (! $revokeResult['ok']) {
return $revokeResult;
}
}
$appointResult = $this->appointmentService->appoint($operator, $targetUser, $targetPosition, '后台用户管理编辑');
if (! $appointResult['ok']) {
return $appointResult;
}
$this->broadcastAppointedPosition($operator, $targetUser, $targetPosition);
return ['ok' => true, 'message' => "用户职务已更新为【{$targetPosition->name}】。"];
}
/**
* 方法功能:广播后台任命成功后的公告与目标用户刷新事件。
*/
private function broadcastAppointedPosition(User $operator, User $targetUser, Position $targetPosition): void
{
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $targetUser->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
broadcast(new UserBrowserRefreshRequested(
targetUserId: $targetUser->id,
operator: $operator->username,
reason: '你的职务已发生变更,页面权限正在同步更新。',
));
}
/**
* 方法功能:广播后台撤销职务后的公告与目标用户刷新事件。
*/
private function broadcastRevokedPosition(User $operator, User $targetUser, UserPosition $currentAssignment): void
{
$currentAssignment->loadMissing('position.department');
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $targetUser->username,
positionIcon: $currentAssignment->position?->icon ?? '🎖️',
positionName: $currentAssignment->position?->name ?? '',
departmentName: $currentAssignment->position?->department?->name ?? '',
operatorName: $operator->username,
type: 'revoke',
));
}
broadcast(new UserBrowserRefreshRequested(
targetUserId: $targetUser->id,
operator: $operator->username,
reason: '你的职务已被撤销,页面权限正在同步更新。',
));
}
}
+140 -33
View File
@@ -13,21 +13,124 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\VipLevel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 后台 VIP 会员等级管理控制器
* 负责会员等级维护,以及查看各等级下的会员名单。
*/
class VipController extends Controller
{
/**
* 会员主题支持的特效下拉选项。
*
* @var array<string, string>
*/
private const EFFECT_LABELS = [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
'sakura' => '樱花飘落',
'meteors' => '流星',
'gold-rain' => '金币雨',
'hearts' => '爱心飘落',
'confetti' => '彩带庆典',
'fireflies' => '萤火虫',
];
/**
* 会员主题支持的横幅风格下拉选项。
*
* @var array<string, string>
*/
private const BANNER_STYLE_LABELS = [
'aurora' => '鎏光星幕',
'storm' => '雷霆风暴',
'royal' => '王者金辉',
'cosmic' => '星穹幻彩',
'farewell' => '告别暮光',
];
/**
* 会员等级管理列表页
*/
public function index(): View
{
$levels = VipLevel::orderBy('sort_order')->get();
$levels = VipLevel::query()
->withCount('users')
->orderBy('sort_order')
->get();
return view('admin.vip.index', compact('levels'));
return view('admin.vip.index', [
'levels' => $levels,
'effectOptions' => self::EFFECT_LABELS,
'bannerStyleOptions' => self::BANNER_STYLE_LABELS,
]);
}
/**
* 查看某个会员等级下的会员名单。
*
* @param Request $request 当前筛选请求
* @param VipLevel $vip 当前会员等级
*/
public function members(Request $request, VipLevel $vip): View
{
$query = User::query()->where('vip_level_id', $vip->id);
$now = now();
if ($request->filled('keyword')) {
$keyword = trim((string) $request->input('keyword'));
// 支持后台按用户名快速筛选某个等级下的会员。
$query->where('username', 'like', '%'.$keyword.'%');
}
if ($request->input('status') === 'active') {
// 当前有效会员:永久会员或到期时间仍在未来。
$query->where(function ($builder) use ($now): void {
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
});
}
if ($request->input('status') === 'expired') {
// 已过期会员:到期时间存在且已经早于当前时间。
$query->whereNotNull('hy_time')->where('hy_time', '<=', $now);
}
$members = $query
->select(['id', 'username', 'sex', 'vip_level_id', 'hy_time', 'created_at'])
->orderByRaw('CASE WHEN hy_time IS NULL THEN 0 WHEN hy_time > ? THEN 1 ELSE 2 END', [$now])
->orderByRaw('hy_time IS NULL DESC')
->orderByDesc('hy_time')
->orderBy('username')
->paginate(20)
->withQueryString();
$totalAssignedCount = User::query()
->where('vip_level_id', $vip->id)
->count();
$activeCount = User::query()
->where('vip_level_id', $vip->id)
->where(function ($builder) use ($now): void {
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
})
->count();
return view('admin.vip.members', [
'vip' => $vip,
'members' => $members,
'totalAssignedCount' => $totalAssignedCount,
'activeCount' => $activeCount,
]);
}
/**
@@ -35,22 +138,7 @@ class VipController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
// 将文本框的多行模板转为 JSON 数组
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data = $this->validatedPayload($request);
VipLevel::create($data);
@@ -66,21 +154,7 @@ class VipController extends Controller
{
$level = $vip;
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data = $this->validatedPayload($request);
$level->update($data);
@@ -119,4 +193,37 @@ class VipController extends Controller
return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE);
}
/**
* 统一整理后台提交的会员等级主题配置数据。
*
* @return array<string, mixed>
*/
private function validatedPayload(Request $request): array
{
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
'join_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
'leave_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'allow_custom_messages' => 'nullable|boolean',
]);
// 将多行文本框内容转为 JSON 数组,便于后续随机抽取模板。
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data['allow_custom_messages'] = $request->boolean('allow_custom_messages');
return $data;
}
}
@@ -0,0 +1,88 @@
<?php
/**
* 文件功能:后台 VIP 支付配置控制器
* 用于管理聊天室对接 NovaLink 支付中心所需的开关、地址、App Key App Secret
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateVipPaymentConfigRequest;
use App\Models\SysParam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class VipPaymentConfigController extends Controller
{
/**
* 构造函数注入聊天室状态服务
*
* @param ChatStateService $chatState 系统参数缓存同步服务
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示 VIP 支付配置页
*/
public function edit(): View
{
$aliases = array_keys($this->fieldDescriptions());
// 仅读取 VIP 支付专属配置,避免与系统参数页重复展示。
$params = SysParam::query()
->whereIn('alias', $aliases)
->pluck('body', 'alias')
->toArray();
return view('admin.vip-payment.config', [
'params' => $params,
'descriptions' => $this->fieldDescriptions(),
]);
}
/**
* 保存 VIP 支付配置并刷新缓存
*
* @param UpdateVipPaymentConfigRequest $request 已校验的后台配置请求
*/
public function update(UpdateVipPaymentConfigRequest $request): RedirectResponse
{
$data = $request->validated();
$descriptions = $this->fieldDescriptions();
foreach ($descriptions as $alias => $guidetxt) {
$body = (string) ($data[$alias] ?? '');
// 写入数据库并同步描述文案,确保后续后台与缓存读取一致。
SysParam::updateOrCreate(
['alias' => $alias],
['body' => $body, 'guidetxt' => $guidetxt]
);
$this->chatState->setSysParam($alias, $body);
SysParam::clearCache($alias);
}
return redirect()->route('admin.vip-payment.edit')->with('success', 'VIP 支付配置已成功保存。');
}
/**
* 返回 VIP 支付字段说明文案
*
* @return array<string, string>
*/
private function fieldDescriptions(): array
{
return [
'vip_payment_enabled' => 'VIP 在线支付开关(1=开启,0=关闭)',
'vip_payment_base_url' => 'NovaLink 支付中心地址(例如 https://novalink.test',
'vip_payment_app_key' => 'NovaLink 支付中心 App Key',
'vip_payment_app_secret' => 'NovaLink 支付中心 App Secret',
'vip_payment_timeout' => '调用支付中心超时时间(秒)',
];
}
}
@@ -0,0 +1,70 @@
<?php
/**
* 文件功能:后台会员购买日志控制器
* 负责展示聊天室 VIP 在线支付订单列表,并支持按用户、状态、订单号和日期筛选
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\VipPaymentOrder;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VipPaymentLogController extends Controller
{
/**
* 显示会员购买日志列表
*
* @param Request $request 当前查询请求
*/
public function index(Request $request): View
{
$query = VipPaymentOrder::query()->with(['user:id,username', 'vipLevel:id,name,color,icon']);
if ($request->filled('username')) {
$username = (string) $request->input('username');
// 通过用户关联模糊匹配用户名,便于后台快速定位某个会员订单。
$query->whereHas('user', function ($builder) use ($username): void {
$builder->where('username', 'like', '%'.$username.'%');
});
}
if ($request->filled('status')) {
$query->where('status', (string) $request->input('status'));
}
if ($request->filled('order_no')) {
$keyword = (string) $request->input('order_no');
$query->where(function ($builder) use ($keyword): void {
$builder->where('order_no', 'like', '%'.$keyword.'%')
->orWhere('merchant_order_no', 'like', '%'.$keyword.'%')
->orWhere('payment_order_no', 'like', '%'.$keyword.'%')
->orWhere('provider_trade_no', 'like', '%'.$keyword.'%');
});
}
if ($request->filled('date_start')) {
$query->whereDate('created_at', '>=', (string) $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->whereDate('created_at', '<=', (string) $request->input('date_end'));
}
$logs = $query->latest('id')->paginate(30)->withQueryString();
return view('admin.vip-payment-logs.index', [
'logs' => $logs,
'statusOptions' => [
'created' => '待创建',
'pending' => '待支付',
'paid' => '已支付',
'closed' => '已关闭',
'failed' => '失败',
],
]);
}
}
@@ -0,0 +1,159 @@
<?php
/**
* 文件功能:微信机器人配置控制器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SysParam;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WechatBotController extends Controller
{
/**
* 显示微信机器人配置表单
*/
public function edit(): View
{
// 从 SysParam 获取配置,若不存在赋予默认空 JSON
$param = SysParam::firstOrCreate(
['alias' => 'wechat_bot_config'],
[
'body' => json_encode([
'kafka' => [
'brokers' => '',
'topic' => '',
'group_id' => 'chatroom_wechat_bot',
'bot_wxid' => '',
],
'api' => [
'base_url' => '',
'bot_key' => '',
],
'global_notify' => [
'start_time' => '08:00',
'end_time' => '22:00',
],
'group_notify' => [
'target_wxid' => '',
'toggle_admin_online' => false,
'toggle_baccarat_result' => false,
'toggle_lottery_result' => false,
],
'personal_notify' => [
'toggle_friend_online' => false,
'toggle_spouse_online' => false,
'toggle_level_change' => false,
],
]),
'guidetxt' => '微信机器人全站配置(包含群聊推送和私聊推送开关及Kafka连接)',
]
);
$config = json_decode($param->body, true);
return view('admin.wechat_bot.edit', compact('config'));
}
/**
* 更新微信机器人配置
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'kafka_brokers' => 'nullable|string',
'kafka_topic' => 'nullable|string',
'kafka_group_id' => 'nullable|string',
'kafka_bot_wxid' => 'nullable|string',
'api_base_url' => 'nullable|string',
'api_bot_key' => 'nullable|string',
'qrcode_image' => 'nullable|image|max:2048',
'global_start_time' => 'nullable|string',
'global_end_time' => 'nullable|string',
'group_target_wxid' => 'nullable|string',
'toggle_admin_online' => 'nullable|boolean',
'toggle_baccarat_result' => 'nullable|boolean',
'toggle_lottery_result' => 'nullable|boolean',
'toggle_friend_online' => 'nullable|boolean',
'toggle_spouse_online' => 'nullable|boolean',
'toggle_level_change' => 'nullable|boolean',
]);
$param = SysParam::where('alias', 'wechat_bot_config')->first();
$oldConfig = $param ? (json_decode($param->body, true) ?? []) : [];
$qrcodePath = $oldConfig['api']['qrcode_image'] ?? '';
if ($request->hasFile('qrcode_image')) {
// 删除旧图
if ($qrcodePath && \Illuminate\Support\Facades\Storage::disk('public')->exists($qrcodePath)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($qrcodePath);
}
$qrcodePath = $request->file('qrcode_image')->store('wechat', 'public');
}
$config = [
'kafka' => [
'brokers' => $validated['kafka_brokers'] ?? '',
'topic' => $validated['kafka_topic'] ?? '',
'group_id' => $validated['kafka_group_id'] ?? 'chatroom_wechat_bot',
'bot_wxid' => $validated['kafka_bot_wxid'] ?? '',
],
'api' => [
'base_url' => $validated['api_base_url'] ?? '',
'bot_key' => $validated['api_bot_key'] ?? '',
'qrcode_image' => $qrcodePath,
],
'global_notify' => [
'start_time' => $validated['global_start_time'] ?? '',
'end_time' => $validated['global_end_time'] ?? '',
],
'group_notify' => [
'target_wxid' => $validated['group_target_wxid'] ?? '',
'toggle_admin_online' => $validated['toggle_admin_online'] ?? false,
'toggle_baccarat_result' => $validated['toggle_baccarat_result'] ?? false,
'toggle_lottery_result' => $validated['toggle_lottery_result'] ?? false,
],
'personal_notify' => [
'toggle_friend_online' => $validated['toggle_friend_online'] ?? false,
'toggle_spouse_online' => $validated['toggle_spouse_online'] ?? false,
'toggle_level_change' => $validated['toggle_level_change'] ?? false,
],
];
if ($param) {
$param->update(['body' => json_encode($config)]);
SysParam::clearCache('wechat_bot_config');
}
return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。');
}
/**
* 发送群内公告
*/
public function sendAnnouncement(Request $request, \App\Services\WechatBot\WechatNotificationService $wechatService): RedirectResponse
{
$validated = $request->validate([
'announcement_content' => 'required|string|max:1000',
], [
'announcement_content.required' => '请输入公告内容',
'announcement_content.max' => '公告内容太长,不能超过1000字',
]);
try {
$wechatService->sendCustomGroupAnnouncement($validated['announcement_content']);
return back()->with('success', '群公告已通过微信机器人发送成功!(消息已进入队列)');
} catch (\Exception $e) {
return back()->withInput()->withErrors(['announcement_content' => $e->getMessage()]);
}
}
}
+535 -100
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
*
@@ -16,19 +16,29 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\BrowserRefreshRequested;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\ChatContentSanitizer;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Validation\Rule;
/**
* 类功能:处理聊天室内的实时管理命令与部分职务奖励操作。
*/
class AdminCommandController extends Controller
{
/**
@@ -37,6 +47,7 @@ class AdminCommandController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -57,12 +68,25 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '请注意言行');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 权限检查(等级由 level_warn 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_warn', '5')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_WARN, '警告');
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);
}
// 广播警告消息
@@ -71,7 +95,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⚠️ 管理员 <b>{$admin->username}</b> 警告 <b>{$targetUsername}</b>{$reason}",
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -81,6 +105,17 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 给被警告用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "⚠️ {$operatorDisplay} 警告了你:{$reason}",
title: '⚠️ 收到警告',
toastMessage: "{$operatorDisplay} 警告了你:{$reason}",
color: '#f59e0b',
icon: '⚠️',
);
return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]);
}
@@ -102,14 +137,38 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
// 权限检查(等级由 level_kick 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$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_KICK, '踢出');
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);
}
// 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}",
title: '🚫 已被踢出',
toastMessage: "{$operatorDisplay} 已将你踢出聊天室。<br>原因:{$reason}",
color: '#ef4444',
icon: '🚫',
);
// 从 Redis 在线列表移除
$this->chatState->userLeave($roomId, $targetUsername);
@@ -119,7 +178,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🚫 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -153,12 +212,26 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$duration = $request->input('duration');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 权限检查(等级由 level_mute 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_mute', '8')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$duration = $request->input('duration');
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_MUTE, '禁言');
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);
}
// 设置 Redis 禁言标记,TTL 自动过期
@@ -171,7 +244,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🔇 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -181,26 +254,38 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。",
title: '🔇 已被禁言',
toastMessage: "{$operatorDisplay} 已将你禁言 <b>{$duration}</b> 分钟。",
color: '#6366f1',
icon: '🔇',
);
// 广播禁言事件(前端禁用输入框)
broadcast(new \App\Events\UserMuted(
roomId: $roomId,
username: $targetUsername,
muteTime: $duration,
operator: $admin->username,
message: "{$operatorLabel} 已将 [{$targetUsername}] 禁言 {$duration} 分钟。",
operator: $operatorLabel,
));
return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]);
}
/**
* 冻结用户账号=Y 理由)
* 封禁用户账号
*
* 用户账号状态设为冻结,禁止登录
* 目标账号设为封禁状态,并将其从当前在线房间中移出
*
* @param Request $request 请求对象,需包含 username, reason
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function freeze(Request $request): JsonResponse
public function ban(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
@@ -210,37 +295,51 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
// 权限检查(等级由 level_freeze 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_freeze', '14')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 冻结用户账号(将等级设为 -1 表示冻结)
$target = User::where('username', $targetUsername)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁');
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'];
$target->user_level = -1;
$target->save();
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave($rid, $targetUsername);
}
$this->pushTargetToastMessage(
roomId: $roomId,
targetUsername: $targetUsername,
content: "{$operatorDisplay} 已封禁你的账号。原因:{$reason}",
title: '⛔ 账号已封禁',
toastMessage: "{$operatorDisplay} 已封禁你的账号。<br>原因:{$reason}",
color: '#991b1b',
icon: '⛔',
);
$this->removeUserFromAllRooms($targetUsername);
// 广播冻结消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🧊 管理员 <b>{$admin->username}</b> 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
'content' => "{$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'font_color' => '#991b1b',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
@@ -248,10 +347,93 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $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",
]);
}
/**
@@ -275,8 +457,9 @@ class AdminCommandController extends Controller
abort(403, '仅站长可查看私信');
}
// 查询最近 50 条悄悄话(发送或接收)
// 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
$messages = Message::where('is_secret', true)
->where('from_user', 'not like', '系统%')
->where(function ($q) use ($username) {
$q->where('from_user', $username)
->orWhere('to_user', $username);
@@ -301,9 +484,10 @@ class AdminCommandController extends Controller
}
/**
* 站长公屏讲话
* 聊天室公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
* id=1 站长仍然拥有完整兜底权限。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
@@ -316,26 +500,38 @@ class AdminCommandController extends Controller
]);
$admin = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
}
$roomId = $request->input('room_id');
$content = $request->input('content');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 广播站长公告
$content = ChatContentSanitizer::htmlText($request->input('content'));
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告”
$publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin));
$publisherUsername = ChatContentSanitizer::htmlText($admin->username);
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 站长 <b>{$admin->username}</b> 讲话{$content}",
'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告{$content}",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '📢 公屏公告',
'message' => strip_tags($content),
'icon' => '📢',
'color' => '#b91c1c',
'duration' => 10000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
@@ -344,6 +540,184 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'success', 'message' => '公告已发送']);
}
/**
* 生成公屏公告发布者身份标签。
*
* 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。
*/
private function buildAnnouncementPublisherLabel(User $user): string
{
return $this->buildOperatorIdentityLabel($user);
}
/**
* 生成操作者的身份标签。
*
* 有在职职务时统一显示为“部门·职务”,无在职职务的 id=1 兜底显示“站长”。
*/
private function buildOperatorIdentityLabel(User $user): string
{
$position = $user->activePosition?->position;
if ($position) {
$departmentName = (string) ($position->department?->name ?? '');
return $departmentName ? "{$departmentName}·{$position->name}" : $position->name;
}
if ($user->id === 1) {
return '站长';
}
return '管理员';
}
/**
* 生成操作者在聊天室文案中的完整展示文本。
*/
private function buildOperatorDisplayHtml(User $user): string
{
$identityLabel = e($this->buildOperatorIdentityLabel($user));
$username = e($user->username);
return "<b>{$identityLabel}</b> <b>{$username}</b>";
}
/**
* 校验操作者是否可在指定房间执行聊天室管理命令。
*
* @return array{ok: bool, message: string, room?: Room}
*/
private function authorizeManagedRoom(int $roomId, User $operator): array
{
$room = Room::query()->find($roomId);
if (! $room) {
return ['ok' => false, 'message' => '房间不存在'];
}
if (! $room->canUserEnter($operator)) {
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
}
// 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。
if (! $this->chatState->isUserInRoom($roomId, $operator->username)) {
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
}
return ['ok' => true, 'message' => '校验通过', 'room' => $room];
}
/**
* 校验目标用户是否仍在线于当前房间。
*
* @return array{ok: bool, message: string}
*/
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
{
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 校验聊天室用户管理动作是否可执行。
*
* 规则:
* - id=1 站长始终放行
* - 其他人必须拥有对应职务权限
* - 不能操作自己
* - 不能处理 user_level 高于自己的用户
* - 不能处理部门位阶或职务位阶高于自己的用户
*
* @return array{ok: bool, message: string, target?: User}
*/
private function authorizeModerationAction(
User $admin,
string $targetUsername,
string $permissionCode,
string $actionLabel,
): array {
if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
}
if ($admin->username === $targetUsername) {
return ['ok' => false, 'message' => '不能对自己执行该操作'];
}
$target = User::query()
->where('username', $targetUsername)
->with('activePosition.position.department')
->first();
if (! $target) {
return ['ok' => false, 'message' => '用户不存在'];
}
if (! $this->canModerateTargetByDutyRank($admin, $target)) {
return ['ok' => false, 'message' => '不能处理职务高于自己的用户'];
}
if ($admin->id !== 1 && $target->user_level > $admin->user_level) {
return ['ok' => false, 'message' => '不能处理等级高于自己的用户'];
}
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);
}
}
/**
* 判断操作者是否可以按职务位阶处理目标用户。
*
* 规则:
* - 先比较部门 rank
* - 部门相同再比较职务 rank
* - 对方没有在职职务时视为可处理
* - 同职务平级允许操作,仅禁止处理更高职务
*/
private function canModerateTargetByDutyRank(User $admin, User $target): bool
{
if ($admin->id === 1) {
return true;
}
$adminPosition = $admin->activePosition?->load('position.department')->position;
if (! $adminPosition) {
return false;
}
$targetPosition = $target->activePosition?->position;
if (! $targetPosition) {
return true;
}
$adminDepartmentRank = (int) ($adminPosition->department?->rank ?? 0);
$targetDepartmentRank = (int) ($targetPosition->department?->rank ?? 0);
if ($adminDepartmentRank > $targetDepartmentRank) {
return true;
}
if ($adminDepartmentRank < $targetDepartmentRank) {
return false;
}
return (int) $adminPosition->rank >= (int) $targetPosition->rank;
}
/**
* 管理员全员清屏
*
@@ -360,12 +734,15 @@ class AdminCommandController extends Controller
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$roomId = (int) $request->input('room_id');
// 改为按职务权限控制聊天室顶部“清屏”按钮。
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
}
// 需要站长权限才能全员清屏
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 清除 Redis 中该房间的消息缓存
@@ -378,10 +755,49 @@ class AdminCommandController extends Controller
}
/**
* 管理员触发全屏特效(烟花/下雨/雷电)
* 站长触发当前房间全员刷新页面。
*
* 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件,
* 适用于功能更新后强制让前端重新拉取最新页面状态。
*/
public function refreshAll(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:100',
]);
$admin = Auth::user();
if ((int) $admin->id !== 1) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403);
}
$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', ''));
// 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。
broadcast(new BrowserRefreshRequested(
roomId: $roomId,
operator: $admin->username,
reason: $reason,
));
return response()->json([
'status' => 'success',
'message' => '已通知当前房间所有在线用户刷新页面',
]);
}
/**
* 管理员触发全屏特效。
*
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
* superlevel 等级管理员可触发。
* 拥有 room.fullscreen_effect 权限的职务可触发。
*
* @param Request $request 请求对象,需包含 room_id, type
* @return JsonResponse 操作结果
@@ -390,21 +806,23 @@ class AdminCommandController extends Controller
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:fireworks,rain,lightning,snow',
'type' => ['required', 'string', Rule::in(EffectBroadcast::TYPES)],
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$roomId = (int) $request->input('room_id');
$type = $request->input('type');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
}
// 仅 superlevel 等级可触发特效
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 广播特效事件给房间内所有在线用户
broadcast(new \App\Events\EffectBroadcast($roomId, $type, $admin->username));
broadcast(new EffectBroadcast($roomId, $type, $admin->username));
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
}
@@ -441,6 +859,10 @@ class AdminCommandController extends Controller
$roomId = (int) $request->input('room_id');
$amount = (int) $request->input('amount');
$targetUsername = $request->input('username');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 不能给自己发放
if ($admin->username === $targetUsername) {
@@ -453,12 +875,21 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// id=1 超级管理员:无需职务,无限额限制
$isSuperAdmin = $admin->id === 1;
$userPosition = null;
$position = null;
if (! $isSuperAdmin) {
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
}
// ① 必须有在职职务
$userPosition = $admin->activePosition;
if (! $userPosition) {
@@ -593,7 +1024,7 @@ class AdminCommandController extends Controller
'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
'icon' => '💰',
'color' => '#f59e0b',
'duration' => 8000,
'duration' => 3000,
],
];
$this->chatState->pushMessage($roomId, $msg);
@@ -676,35 +1107,39 @@ class AdminCommandController extends Controller
}
/**
* 权限检查:管理员是否可对目标用户执行指定操作
*
* 根据 sysparam 中配置的等级门槛判断权限。
*
* @param User $admin 管理员用户
* @param string $targetUsername 目标用户名
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn
* @param string $defaultLevel 默认等级值
* @return bool 是否有权限
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
*/
private function canExecute(User $admin, string $targetUsername, string $levelKey, string $defaultLevel = '5'): bool
{
// 必须达到该操作所需的最低等级
$requiredLevel = (int) Sysparam::getValue($levelKey, $defaultLevel);
if ($admin->user_level < $requiredLevel) {
return false;
}
private function pushTargetToastMessage(
int $roomId,
string $targetUsername,
string $content,
string $title,
string $toastMessage,
string $color,
string $icon,
): void {
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => $color,
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 复用现有聊天 toast 机制,在右下角弹出操作结果提示。
'toast_notification' => [
'title' => $title,
'message' => $toastMessage,
'icon' => $icon,
'color' => $color,
'duration' => 10000,
],
];
// 不能操作自己
if ($admin->username === $targetUsername) {
return false;
}
// 目标用户等级不能高于操作者(允许平级互相操作)
$target = User::where('username', $targetUsername)->first();
if ($target && $target->user_level > $admin->user_level) {
return false;
}
return true;
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+46 -7
View File
@@ -20,6 +20,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理聊天室前台登录、自动注册与退出登录。
*/
class AuthController extends Controller
{
/**
@@ -39,7 +42,15 @@ class AuthController extends Controller
if ($user) {
// 用户存在,验证密码
if (Hash::check($password, $user->password)) {
$passwordMatches = false;
try {
$passwordMatches = Hash::check($password, $user->password);
} catch (\RuntimeException $e) {
// Hash::check() in Laravel 11/12 throws if the hash isn't a valid bcrypt string
$passwordMatches = false;
}
if ($passwordMatches) {
// Bcrypt 验证通过
// 检测是否被封禁 (后台管理员级别获得豁免权,防止误把自己关在门外)
@@ -53,7 +64,7 @@ class AuthController extends Controller
}
}
$this->performLogin($user, $ip);
$this->performLogin($user, $ip, $request);
return response()->json(['status' => 'success', 'message' => '登录成功']);
}
@@ -75,7 +86,7 @@ class AuthController extends Controller
}
}
$this->performLogin($user, $ip);
$this->performLogin($user, $ip, $request);
return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']);
}
@@ -101,8 +112,13 @@ class AuthController extends Controller
}
// 检测用户名是否在禁用词列表(永久禁用 或 改名临时保留期内)
if (UsernameBlacklist::isBlocked($username)) {
return response()->json(['status' => 'error', 'message' => '该用户名已被系统禁止注册,请更换其他名称。'], 422);
if ($blockingRecord = UsernameBlacklist::getBlockingRecord($username)) {
$reason = '';
if ($blockingRecord->type === 'permanent') {
$reason = "(包含违禁敏感词:{$blockingRecord->username}";
}
return response()->json(['status' => 'error', 'message' => "该用户名已被系统禁止注册{$reason},请更换其他名称。"], 422);
}
// --- 提取邀请人 Cookie ---
@@ -126,7 +142,7 @@ class AuthController extends Controller
'inviter_id' => $inviterId, // 记录邀请人
]);
$this->performLogin($newUser, $ip);
$this->performLogin($newUser, $ip, $request);
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
if ($inviterId) {
@@ -139,9 +155,11 @@ class AuthController extends Controller
/**
* 执行实际的登录操作并记录时间、IP 等。
*/
private function performLogin(User $user, string $ip): void
private function performLogin(User $user, string $ip, Request $request): void
{
Auth::login($user);
// 登录成功后立即轮换 session id,阻断会话固定攻击。
$request->session()->regenerate();
// 递增访问次数
$user->increment('visit_num');
@@ -160,6 +178,16 @@ class AuthController extends Controller
'sdate' => now(),
'uuname' => $user->username,
]);
// 触发微信机器人消息推送 (登录上线类)
try {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyAdminOnline($user);
$wechatService->notifyFriendsOnline($user);
$wechatService->notifySpouseOnline($user);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot presence notification failed', ['error' => $e->getMessage()]);
}
}
/**
@@ -174,6 +202,17 @@ class AuthController extends Controller
'out_time' => now(),
'out_info' => '正常退出了聊天室',
]);
// [NEW] 同步清除该用户在所有房间的在线状态和心跳,确保其如果马上重登,能触发全新入场欢迎
try {
$chatState = app(\App\Services\ChatStateService::class);
$roomIds = $chatState->getUserRooms($user->username);
foreach ($roomIds as $roomId) {
$chatState->userLeave($roomId, $user->username);
}
} catch (\Exception $e) {
// 忽略清理缓存时发生的异常
}
}
Auth::logout();
+51 -7
View File
@@ -19,15 +19,24 @@ use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供百家乐当前局查询、下注与历史接口。
*/
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {}
/**
@@ -35,18 +44,32 @@ class BaccaratController extends Controller
*/
public function currentRound(Request $request): JsonResponse
{
$round = BaccaratRound::currentRound();
$user = $request->user();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $round) {
return response()->json(['round' => null]);
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$round = BaccaratRound::currentRound($roomId);
if (! $round) {
return response()->json([
'round' => null,
// 即使当前无局次,也返回最新金币余额,供前端每次打开弹窗时刷新右上角显示。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
$user = $request->user();
$myBet = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->first();
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
return response()->json([
'round' => [
'id' => $round->id,
@@ -59,11 +82,15 @@ class BaccaratController extends Controller
'bet_count_big' => $round->bet_count_big,
'bet_count_small' => $round->bet_count_small,
'bet_count_triple' => $round->bet_count_triple,
'min_bet' => $minBet,
'max_bet' => $maxBet,
'my_bet' => $myBet ? [
'bet_type' => $myBet->bet_type,
'amount' => $myBet->amount,
] : null,
],
// 返回当前用户最新金币,前端每次打开弹窗都可同步右上角余额。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
@@ -84,6 +111,11 @@ class BaccaratController extends Controller
'bet_type' => 'required|in:big,small,triple',
'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 ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -95,7 +127,7 @@ class BaccaratController extends Controller
$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' => '当前不在下注时间内。']);
}
@@ -107,8 +139,9 @@ class BaccaratController extends Controller
}
$currency = $this->currency;
$lossCoverService = $this->lossCoverService;
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
@@ -131,15 +164,22 @@ class BaccaratController extends Controller
},
);
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
// 写入下注记录
BaccaratBet::create([
$bet = BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $data['bet_type'],
'amount' => $data['amount'],
'status' => 'pending',
]);
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
$lossCoverService->registerBet($bet);
// 更新局次汇总统计
$field = 'total_bet_'.$data['bet_type'];
$countField = 'bet_count_'.$data['bet_type'];
@@ -154,6 +194,8 @@ class BaccaratController extends Controller
'big' => '大', 'small' => '小', default => '豹子'
};
$this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$betLabel}{$data['amount']} 金币,等待开奖!",
@@ -168,7 +210,9 @@ class BaccaratController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$rounds = BaccaratRound::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -0,0 +1,141 @@
<?php
/**
* 文件功能:百家乐买单活动前台控制器
*
* 提供活动摘要、历史记录以及用户领取补偿的接口,
* 供娱乐大厅弹窗与聊天室系统消息按钮调用。
*/
namespace App\Http\Controllers;
use App\Models\BaccaratLossCoverEvent;
use App\Services\BaccaratLossCoverService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BaccaratLossCoverController extends Controller
{
/**
* 注入百家乐买单活动服务。
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
) {}
/**
* 返回当前最值得关注的一次活动摘要。
*/
public function summary(Request $request): JsonResponse
{
$event = BaccaratLossCoverEvent::query()
->with(['creator:id,username'])
->whereIn('status', $this->summaryStatuses($request))
->orderByRaw($this->summaryStatusOrder($request))
->orderByDesc('starts_at')
->first();
$record = null;
if ($event) {
$record = $event->records()->where('user_id', $request->user()->id)->first();
}
return response()->json([
'event' => $event ? $this->transformEvent($event, $record) : null,
]);
}
/**
* 返回最近的活动列表以及当前用户的领取记录。
*/
public function history(Request $request): JsonResponse
{
$events = BaccaratLossCoverEvent::query()
->with(['creator:id,username', 'records' => function ($query) use ($request) {
$query->where('user_id', $request->user()->id);
}])
->latest('starts_at')
->limit(20)
->get()
->map(function (BaccaratLossCoverEvent $event) {
$record = $event->records->first();
return $this->transformEvent($event, $record);
});
return response()->json([
'events' => $events,
]);
}
/**
* 领取指定活动的补偿金币。
*/
public function claim(Request $request, BaccaratLossCoverEvent $event): JsonResponse
{
$result = $this->lossCoverService->claim($event, $request->user());
return response()->json($result);
}
/**
* 将活动与个人记录整理为前端更容易消费的结构。
*/
private function transformEvent(BaccaratLossCoverEvent $event, mixed $record): array
{
return [
'id' => $event->id,
'title' => $event->title,
'description' => $event->description,
'status' => $event->status,
'status_label' => $event->statusLabel(),
'starts_at' => $event->starts_at?->toIso8601String(),
'ends_at' => $event->ends_at?->toIso8601String(),
'claim_deadline_at' => $event->claim_deadline_at?->toIso8601String(),
'participant_count' => $event->participant_count,
'compensable_user_count' => $event->compensable_user_count,
'total_loss_amount' => $event->total_loss_amount,
'total_claimed_amount' => $event->total_claimed_amount,
'creator_username' => $event->creator?->username ?? '管理员',
'my_record' => $record ? [
'total_bet_amount' => $record->total_bet_amount,
'total_win_payout' => $record->total_win_payout,
'total_loss_amount' => $record->total_loss_amount,
'compensation_amount' => $record->compensation_amount,
'claim_status' => $record->claim_status,
'claim_status_label' => $record->claimStatusLabel(),
'claimed_amount' => $record->claimed_amount,
'claimed_at' => $record->claimed_at?->toIso8601String(),
] : null,
];
}
/**
* 按调用场景返回活动摘要允许出现的状态集合。
*
* “当前活动”页签只展示未开始、进行中或结算中的活动,
* 避免把已结束但仍可领取的历史活动误显示在当前页签里。
*
* @return list<string>
*/
private function summaryStatuses(Request $request): array
{
if ($request->string('scene')->toString() === 'overview') {
return ['active', 'settlement_pending', 'scheduled'];
}
return ['active', 'settlement_pending', 'claimable', 'scheduled'];
}
/**
* 按调用场景生成活动状态排序规则。
*/
private function summaryStatusOrder(Request $request): string
{
if ($request->string('scene')->toString() === 'overview') {
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'scheduled' THEN 2 ELSE 3 END";
}
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'claimable' THEN 2 WHEN 'scheduled' THEN 3 ELSE 4 END";
}
}
+40 -6
View File
@@ -13,14 +13,25 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\BankLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理银行余额、存取款和存款排行展示。
*/
class BankController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 查询银行余额及最近20条流水记录
*/
@@ -45,20 +56,27 @@ class BankController extends Controller
*/
public function ranking(Request $request): JsonResponse
{
/** @var User $operator */
$operator = Auth::user();
$direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
$users = \App\Models\User::where('bank_jjb', '>', 0)
$users = User::where('bank_jjb', '>', 0)
->orderBy('bank_jjb', $direction)
->paginate(20, ['id', 'username', 'bank_jjb', 'sex', 'usersf', 'user_level']);
return response()->json([
'status' => 'success',
'ranking' => $users->map(function ($u) {
// 提供必要的前端展示字段
'ranking' => $users->map(function (User $u) use ($operator) {
$canViewBalance = $this->canViewBankBalance($operator, $u);
// 提供必要的前端展示字段,普通用户查看别人存款时只返回星号,防止前端绕过遮罩。
return [
'id' => $u->id,
'username' => $u->username,
'bank_jjb' => $u->bank_jjb,
'bank_jjb' => $canViewBalance ? ($u->bank_jjb ?? 0) : '******',
'bank_jjb_masked' => ! $canViewBalance,
'can_reveal' => ! $canViewBalance,
'reveal_cost' => UserController::INFO_REVEAL_COST,
'sex' => $u->sex,
'usersf' => $u->usersf,
'user_level' => $u->user_level,
@@ -95,7 +113,8 @@ class BankController extends Controller
}
DB::transaction(function () use ($user, $amount): void {
$user->decrement('jjb', $amount);
$this->currencyService->change($user, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
$user->increment('bank_jjb', $amount);
BankLog::create([
@@ -139,7 +158,8 @@ class BankController extends Controller
DB::transaction(function () use ($user, $amount): void {
$user->decrement('bank_jjb', $amount);
$user->increment('jjb', $amount);
$this->currencyService->change($user, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
BankLog::create([
'user_id' => $user->id,
@@ -158,4 +178,18 @@ class BankController extends Controller
'bank_jjb' => $fresh->bank_jjb,
]);
}
/**
* 判断操作者是否可以免费查看目标用户银行存款。
*/
private function canViewBankBalance(User $operator, User $targetUser): bool
{
if ($operator->id === $targetUser->id) {
return true;
}
$superLevel = (int) Sysparam::getValue('superlevel', '100');
return (int) $operator->user_level >= $superLevel;
}
}
@@ -13,6 +13,9 @@
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Events\MessageSent;
use App\Events\UserBrowserRefreshRequested;
use App\Jobs\SaveMessageJob;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
@@ -92,7 +95,25 @@ class ChatAppointmentController extends Controller
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
// 给被任命用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $target->username,
content: "✨ <b>{$operator->username}</b> 已任命你为 {$position->icon} {$position->name}",
title: '✨ 职务任命通知',
toastMessage: "<b>{$operator->username}</b> 已任命你为 <b>{$position->icon} {$position->name}</b>。",
color: '#a855f7',
icon: '✨',
);
}
// 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已发生变更,页面权限正在同步更新。',
));
}
return response()->json([
@@ -136,7 +157,25 @@ class ChatAppointmentController extends Controller
operatorName: $operator->username,
type: 'revoke',
));
// 给被撤职用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $target->username,
content: "📋 <b>{$operator->username}</b> 已撤销你的 {$posIcon} {$posName} 职务。",
title: '📋 职务变动通知',
toastMessage: "<b>{$operator->username}</b> 已撤销你的 <b>{$posIcon} {$posName}</b> 职务。",
color: '#6b7280',
icon: '📋',
);
}
// 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已被撤销,页面权限正在同步更新。',
));
}
return response()->json([
@@ -144,4 +183,41 @@ class ChatAppointmentController extends Controller
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
*/
private function pushTargetToastMessage(
int $roomId,
string $targetUsername,
string $content,
string $title,
string $toastMessage,
string $color,
string $icon,
): void {
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => $color,
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 复用现有聊天 toast 机制,在右下角弹出职务变动提示。
'toast_notification' => [
'title' => $title,
'message' => $toastMessage,
'icon' => $icon,
'color' => $color,
'duration' => 10000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+23 -7
View File
@@ -18,6 +18,7 @@ use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Sysparam;
use App\Services\AiChatService;
use App\Services\AiFinanceService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -25,6 +26,9 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 处理用户与 AI小班长的对话、金币福利与上下文清理。
*/
class ChatBotController extends Controller
{
/**
@@ -34,6 +38,7 @@ class ChatBotController extends Controller
private readonly AiChatService $aiChat,
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly AiFinanceService $aiFinance,
) {}
/**
@@ -51,8 +56,12 @@ class ChatBotController extends Controller
$request->validate([
'message' => 'required|string|max:2000',
'room_id' => 'required|integer',
'is_secret' => 'nullable|boolean',
]);
// 私聊模式:AI 回复也走悄悄话,仅发言人和 AI 可见
$isSecret = (bool) $request->input('is_secret', false);
// 检查全局开关
$enabled = Sysparam::getValue('chatbot_enabled', '0');
if ($enabled !== '1') {
@@ -76,21 +85,25 @@ class ChatBotController extends Controller
$reply = $result['reply'];
// 检查 AI 是否决定给用户发金币
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额]
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
$aiGoldAmount = (int) $matches[1];
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
$reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
// 校验 AI 给出的金额在合理范围内
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
$dailyCount = (int) Redis::get($redisKey);
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
if ($aiUser && $aiUser->jjb >= $goldAmount) {
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
Redis::incr($redisKey);
Redis::expire($redisKey, 86400); // 缓存 24 小时
@@ -129,6 +142,9 @@ class ChatBotController extends Controller
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
// 福利发放完成后,若手上金币仍高于 100 万,则把超出的部分回存银行。
$this->aiFinance->bankExcessGold($aiUser);
} else {
// 如果余额不足
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
@@ -139,14 +155,14 @@ class ChatBotController extends Controller
}
}
// 广播 AI 回复消息
// 广播 AI 回复消息(私聊模式下仅发言人与 AI 可见)
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => $reply,
'is_secret' => false,
'is_secret' => $isSecret,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
<?php
/**
* 文件功能:前台每日签到控制器
*
* 提供签到状态查询、领取奖励、刷新在线名单载荷和聊天室签到通知。
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ClaimDailySignInRequest;
use App\Http\Requests\DailySignInCalendarRequest;
use App\Http\Requests\MakeupDailySignInRequest;
use App\Models\DailySignIn;
use App\Models\User;
use App\Models\UserIdentityBadge;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\SignInService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:处理前台用户每日签到状态与领取奖励流程。
*/
class DailySignInController extends Controller
{
/**
* 构造每日签到控制器依赖。
*/
public function __construct(
private readonly SignInService $signInService,
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $presenceService,
) {}
/**
* 方法功能:查询当前用户今日签到状态和奖励预览。
*/
public function status(): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$status = $this->signInService->status($user);
return response()->json([
'status' => 'success',
'data' => $this->formatStatusPayload($user, $status),
]);
}
/**
* 方法功能:查询指定月份的签到日历与补签卡状态。
*/
public function calendar(DailySignInCalendarRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
return response()->json([
'status' => 'success',
'data' => $this->signInService->calendar($user, $request->validated('month')),
]);
}
/**
* 方法功能:领取今日签到奖励并同步聊天室在线名单。
*/
public function claim(ClaimDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->claim($user, $roomId);
if (! $dailySignIn->wasRecentlyCreated) {
return response()->json([
'status' => 'error',
'message' => '今日已签到,请明天再来。',
'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn),
], 422);
}
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
$this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId);
}
return response()->json([
'status' => 'success',
'message' => $this->buildSuccessMessage($dailySignIn),
'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload),
]);
}
/**
* 方法功能:使用补签卡补签历史漏签日期。
*/
public function makeup(MakeupDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId);
$refreshedSignIn = $dailySignIn->fresh();
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
$currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0);
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
$this->broadcastSignInNotice($freshUser, $refreshedSignIn, $roomId, $currentStreakDays);
}
return response()->json([
'status' => 'success',
'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。',
'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays),
]);
}
/**
* 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。
*
* @param array<string, mixed> $presencePayload
*/
private function refreshOnlinePresence(User $user, array $presencePayload): void
{
foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) {
// 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
}
/**
* 方法功能:向当前聊天室广播签到成功通知。
*/
private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId, ?int $currentStreakDays = null): void
{
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
'is_secret' => false,
'font_color' => '#0f766e',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
}
/**
* 方法功能:生成聊天室内的签到播报内容。
*/
private function buildNoticeContent(User $user, DailySignIn $dailySignIn, ?int $currentStreakDays = null): string
{
$rewardText = $this->buildRewardText($dailySignIn);
$identityText = $dailySignIn->identity_badge_name
? ',获得身份 '.e($dailySignIn->identity_badge_name)
: '';
if ($dailySignIn->is_makeup) {
$signInDate = $dailySignIn->sign_in_date?->format('Y-m-d') ?? '漏签日期';
$streakDays = $currentStreakDays ?? (int) $dailySignIn->streak_days;
return '【'.e($user->username).'】使用补签卡补签 '.$signInDate
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
}
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton;
}
/**
* 方法功能:生成本机签到成功提示文案。
*/
private function buildSuccessMessage(DailySignIn $dailySignIn): string
{
return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。';
}
/**
* 方法功能:按实际签到奖励快照生成奖励描述。
*/
private function buildRewardText(DailySignIn $dailySignIn): string
{
$items = [];
if ($dailySignIn->gold_reward > 0) {
$items[] = $dailySignIn->gold_reward.' 金币';
}
if ($dailySignIn->exp_reward > 0) {
$items[] = $dailySignIn->exp_reward.' 经验';
}
if ($dailySignIn->charm_reward > 0) {
$items[] = $dailySignIn->charm_reward.' 魅力';
}
return $items === [] ? '签到记录' : implode(' + ', $items);
}
/**
* 方法功能:格式化状态查询响应载荷。
*
* @param array<string, mixed> $status
* @return array<string, mixed>
*/
private function formatStatusPayload(User $user, array $status): array
{
return [
'signed_today' => $status['signed_today'],
'can_claim' => $status['can_claim'],
'current_streak_days' => $status['current_streak_days'],
'claimable_streak_days' => $status['claimable_streak_days'],
'preview_rule' => $status['matched_rule']?->only([
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
]),
'identity' => $this->formatIdentityPayload($status['current_identity']),
'user' => [
'jjb' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到领取响应载荷。
*
* @param array<string, mixed>|null $presencePayload
* @return array<string, mixed>
*/
private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array
{
$identity = $user->currentSignInIdentity();
return [
'sign_in' => [
'id' => $dailySignIn->id,
'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(),
'is_makeup' => (bool) $dailySignIn->is_makeup,
'streak_days' => (int) $dailySignIn->streak_days,
'gold_reward' => (int) $dailySignIn->gold_reward,
'exp_reward' => (int) $dailySignIn->exp_reward,
'charm_reward' => (int) $dailySignIn->charm_reward,
],
'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days,
'identity' => $this->formatIdentityPayload($identity),
'presence' => $presencePayload ?? $this->presenceService->build($user),
'user' => [
'jjb' => (int) $user->jjb,
'gold' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到身份数据供前端展示。
*
* @return array<string, mixed>|null
*/
private function formatIdentityPayload(?UserIdentityBadge $identity): ?array
{
if ($identity === null) {
return null;
}
return [
'key' => $identity->badge_code,
'label' => $identity->badge_name,
'name' => $identity->badge_name,
'icon' => $identity->badge_icon ?? '✅',
'color' => $identity->badge_color ?? '#0f766e',
'expires_at' => $identity->expires_at?->toIso8601String(),
'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0),
];
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 文件功能:用户赚取金币与经验(观看视频广告等任务)控制器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
class EarnController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* @var int 每日观看最大次数限制
*/
private int $maxDailyLimit = 3;
/**
* @var int 每次领奖后至少需要的冷却时间(秒)
*/
private int $cooldownSeconds = 5;
/**
* 申领看视频的奖励
* 成功看完视频后前端发起此请求。
* 为防止刷包,必须加上每日总次数及短时频率限制。
*/
public function claimVideoReward(Request $request): JsonResponse
{
return response()->json([
'success' => false,
'message' => '看视频赚钱功能已关闭。',
]);
/** @var User $user */
$user = Auth::user();
$userId = $user->id;
$dateKey = now()->format('Y-m-d');
$dailyCountKey = "earn_video:count:{$userId}:{$dateKey}";
$cooldownKey = "earn_video:cooldown:{$userId}";
// 1. 检查冷却时间
if (Redis::exists($cooldownKey)) {
return response()->json([
'success' => false,
'message' => '操作过快,请稍后再试。',
]);
}
// 2. 检查每日最大次数
$todayCount = (int) Redis::get($dailyCountKey);
if ($todayCount >= $this->maxDailyLimit) {
return response()->json([
'success' => false,
'message' => "今日视频收益次数已达上限(每天最多{$this->maxDailyLimit}次),请明天再来。",
]);
}
// 3. 增加今日次数计数
$newCount = Redis::incr($dailyCountKey);
if ($newCount === 1) {
Redis::expire($dailyCountKey, 86400 * 2);
}
// 4. 配置:单次 5000 金币,500 经验
$rewardCoins = 5000;
$rewardExp = 500;
$roomId = (int) $request->input('room_id', 0);
// 参照钓鱼逻辑:通过 UserCurrencyService 写日志并变更金币/经验
$this->currencyService->change(
$user, 'gold', $rewardCoins, CurrencySource::VIDEO_REWARD,
"看视频赚取金币(第{$newCount}次)", $roomId,
);
$this->currencyService->change(
$user, 'exp', $rewardExp, CurrencySource::VIDEO_REWARD,
"看视频赚取经验(第{$newCount}次)", $roomId,
);
// 刷新模型以获取 service 原子更新后的最新字段值
$user->refresh();
// 5. 设置冷却时间
Redis::setex($cooldownKey, $this->cooldownSeconds, 1);
// 6. 广播全服系统消息
if ($roomId > 0) {
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统播报',
'to_user' => '大家',
'content' => "👍 【{$user->username}】刚刚看视频赚取了 {$rewardCoins} 金币 + {$rewardExp} 经验!{$promoTag}",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
}
$remainingToday = $this->maxDailyLimit - $newCount;
return response()->json([
'success' => true,
'message' => "观看完毕!获得 {$rewardCoins} 金币 + {$rewardExp} 经验。今日还可观看 {$remainingToday} 次。",
'new_jjb' => $user->jjb,
'level_up' => false,
'new_level_name' => '',
]);
}
}
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
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
* 支持按类型筛选(bug / suggestion
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $item->user_id === $user->id;
return [
'id' => $item->id,
'type' => $item->type,
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'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) => [
'id' => $r->id,
'username' => $r->username,
+93 -17
View File
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\FishingService;
use App\Services\GameRoomScopeService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
/**
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
*/
class FishingController extends Controller
{
/**
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
private readonly FishingService $fishingService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -63,6 +72,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
@@ -75,6 +88,14 @@ class FishingController extends Controller
], 429);
}
$tokenKey = "fishing:token:{$user->id}";
if (Redis::exists($tokenKey)) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
}
// 2. 检查金币是否足够
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) {
@@ -84,34 +105,54 @@ class FishingController extends Controller
], 422);
}
// 3. 扣除金币
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
// 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
$tokenTtl = $waitTime + 13;
$tokenPayload = json_encode([
'token' => $token,
'cast_at' => time(),
'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%
$bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡
// 5. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
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,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
*
@@ -142,6 +214,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:提供神秘占卜状态、抽签和历史接口。
*/
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
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();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
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();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
*/
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()
->where('user_id', $request->user()->id)
->orderByDesc('id')
+9 -8
View File
@@ -267,19 +267,20 @@ class FriendController extends Controller
return;
}
// 根据操作类型和互相状态生成不同文案(含内联快捷操作链接)
// 根据操作类型和互相状态生成不同文案(含前端代理快捷操作链接)
$btnStyle = 'font-weight:bold;text-decoration:underline;margin-left:6px;';
$btnAdd = "<a href='#' onclick=\"quickFriendAction('add','{$fromUsername}',this);return false;\" style='color:#16a34a;{$btnStyle}'> 回加好友</a>";
$btnRemove = "<a href='#' onclick=\"quickFriendAction('remove','{$fromUsername}',this);return false;\" style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
$safeUsername = e($fromUsername);
$btnAdd = "<a href='#' data-quick-friend-action='add' data-quick-friend-username='{$safeUsername}' style='color:#16a34a;{$btnStyle}'> 回加好友</a>";
$btnRemove = "<a href='#' data-quick-friend-action='remove' data-quick-friend-username='{$safeUsername}' style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
$content = match ($action) {
'added' => $mutual
? "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
: "💚 <b>{$fromUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
? "💚 <b>{$safeUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
: "💚 <b>{$safeUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
'removed' => $mutual
? "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
: "💔 <b>{$fromUsername}</b> 已将你从他的好友列表移除。",
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
? "💔 <b>{$safeUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
: "💔 <b>{$safeUsername}</b> 已将你从他的好友列表移除。",
'online' => "🟢 你的好友 <b>{$safeUsername}</b> 上线啦!",
default => '',
};
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理五子棋创建、加入与对局过程接口。
*/
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -58,6 +63,10 @@ class GomokuController extends Controller
$user = $request->user();
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
}
// PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
use App\Models\Guestbook;
use App\Models\User;
use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
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,
]);
}
}
+62 -38
View File
@@ -15,14 +15,20 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理节日福利批次的前台领取与状态查询。
*/
class HolidayController extends Controller
{
/**
* 注入用户金币服务。
*/
public function __construct(
private readonly UserCurrencyService $currency,
) {}
@@ -30,56 +36,72 @@ class HolidayController extends Controller
/**
* 用户领取节日福利红包。
*
* holiday_claims 中查找当前用户的待领取记录,
* 入账金币并更新活动统计数据。
* holiday_claims 中查找当前用户在指定批次下的待领取记录,
* 入账金币并更新批次统计数据。
*/
public function claim(Request $request, HolidayEvent $event): JsonResponse
public function claim(Request $request, HolidayEventRun $run): JsonResponse
{
$user = $request->user();
// 活动是否在领取有效期内
if (! $event->isClaimable()) {
// 批次是否在领取有效期内
if (! $run->isClaimable()) {
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
}
// 查找该用户的领取记录(批量插入时已生成)
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('user_id', $user->id)
->lockForUpdate()
->first();
return DB::transaction(function () use ($run, $user): JsonResponse {
/** @var HolidayEventRun|null $lockedRun */
$lockedRun = HolidayEventRun::query()
->whereKey($run->id)
->lockForUpdate()
->first();
if (! $claim) {
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
}
if (! $lockedRun || ! $lockedRun->isClaimable()) {
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
}
// 防止重复领取(claimed_at 为 null 表示未领取)
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
// 金币入账
/** @var HolidayClaim|null $claim */
$claim = HolidayClaim::query()
->where('run_id', $lockedRun->id)
->where('user_id', $user->id)
->lockForUpdate()
->first();
if (! $claim) {
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
}
// claimed_at 不为空代表本轮已领过,直接返回幂等提示。
if ($claim->claimed_at !== null) {
return response()->json([
'ok' => false,
'message' => '您已领取过本轮福利。',
'amount' => $claim->amount,
]);
}
// 金币入账。
$this->currency->change(
$user,
'gold',
$claim->amount,
CurrencySource::HOLIDAY_BONUS,
"节日福利:{$event->name}",
"节日福利:{$lockedRun->event_name}",
);
// 更新活动统计(只在首次领取时)
HolidayEvent::query()
->where('id', $event->id)
->increment('claimed_amount', $claim->amount);
// 领取成功后只更新 claimed_at,不再删除记录,便于幂等和历史追踪。
$claim->update(['claimed_at' => now()]);
// 删除领取记录(以此标记"已领取",防止重复调用)
$claim->delete();
// 批次领取统计按成功领取次数累计。
$lockedRun->increment('claimed_count');
$lockedRun->increment('claimed_amount', $claim->amount);
// 检查是否已全部领完
if ($event->max_claimants > 0) {
$remaining = HolidayClaim::where('event_id', $event->id)->count();
if ($remaining === 0) {
$event->update(['status' => 'completed']);
}
$remainingPendingClaims = HolidayClaim::query()
->where('run_id', $lockedRun->id)
->whereNull('claimed_at')
->count();
if ($remainingPendingClaims === 0) {
$lockedRun->update(['status' => 'completed']);
}
return response()->json([
@@ -91,21 +113,23 @@ class HolidayController extends Controller
}
/**
* 查询当前用户在指定活动中的待领取状态。
* 查询当前用户在指定批次中的待领取状态。
*/
public function status(Request $request, HolidayEvent $event): JsonResponse
public function status(Request $request, HolidayEventRun $run): JsonResponse
{
$user = $request->user();
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('run_id', $run->id)
->where('user_id', $user->id)
->first();
return response()->json([
'claimable' => $claim !== null && $event->isClaimable(),
'claimable' => $claim !== null && $claim->claimed_at === null && $run->isClaimable(),
'claimed' => $claim?->claimed_at !== null,
'amount' => $claim?->amount ?? 0,
'expires_at' => $event->expires_at?->toIso8601String(),
'status' => $run->status,
'expires_at' => $run->expires_at?->toIso8601String(),
]);
}
}
+236 -26
View File
@@ -20,15 +20,25 @@ use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:赛马竞猜前台控制器
*
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
*/
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {}
/**
@@ -36,13 +46,26 @@ class HorseRaceController extends Controller
*/
public function currentRace(Request $request): JsonResponse
{
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
$user = $request->user();
if (! $user) {
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$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) {
return response()->json([
'race' => null,
// 即使当前无赛马场次,也返回最新金币余额,供前端打开弹窗时刷新显示。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
$user = $request->user();
$myBet = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
@@ -51,6 +74,7 @@ class HorseRaceController extends Controller
// 计算各马匹当前注额
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$horsePools = HorseBet::query()
->where('race_id', $race->id)
@@ -59,23 +83,36 @@ class HorseRaceController extends Controller
->pluck('pool', 'horse_id')
->toArray();
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
// 计算实时赔率
$horses = $race->horses ?? [];
$horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) {
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
$totalPool = array_sum(array_values($horsePools));
$netPool = $totalPool * (1 - $houseTake / 100);
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
$horses = $this->normalizeRaceHorses($race->horses);
$horsesWithBets = array_map(function (array $horse) use ($horsePools, $oddsMap) {
$horseId = (int) $horse['id'];
$horsePool = (int) ($horsePools[$horseId] ?? 0);
$odds = $horsePool > 0 ? ($oddsMap[$horseId] ?? null) : null;
return [
'id' => $horse['id'],
'name' => $horse['name'],
'emoji' => $horse['emoji'],
'id' => $horseId,
'name' => (string) $horse['name'],
'emoji' => (string) $horse['emoji'],
'pool' => $horsePool,
'odds' => $odds,
];
}, $horses);
// 押注阶段实时总池 = 当前记录的基础池(通常为种子池)+ 实时下注总额;
// 跑马/结算阶段 total_pool 已写回最终值,不能再重复叠加下注额。
$basePool = $race->status === 'betting'
? max((int) $race->total_pool, $seedPool)
: (int) $race->total_pool;
$displayTotalPool = $race->status === 'betting'
? $basePool + array_sum(array_values($horsePools))
: $basePool;
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
return response()->json([
'race' => [
'id' => $race->id,
@@ -85,12 +122,16 @@ class HorseRaceController extends Controller
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
: 0,
'horses' => $horsesWithBets,
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
'total_pool' => $displayTotalPool,
'min_bet' => $minBet,
'max_bet' => $maxBet,
'my_bet' => $myBet ? [
'horse_id' => $myBet->horse_id,
'amount' => $myBet->amount,
] : null,
],
// 返回当前用户最新金币,确保弹窗右上角余额每次打开都以服务端最新值为准。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
@@ -110,6 +151,11 @@ class HorseRaceController extends Controller
'horse_id' => '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 ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -121,12 +167,12 @@ class HorseRaceController extends Controller
$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' => '当前不在下注时间内。']);
}
// 验证马匹 ID 是否有效
$horses = $race->horses ?? [];
$horses = $this->normalizeRaceHorses($race->horses);
$validIds = array_column($horses, 'id');
if (! in_array($data['horse_id'], $validIds, true)) {
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
@@ -154,12 +200,7 @@ class HorseRaceController extends Controller
// 找出马匹名称
$horseName = '';
foreach ($horses as $horse) {
if ((int) $horse['id'] === (int) $data['horse_id']) {
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$horseName = $this->resolveHorseDisplayName($horses, (int) $data['horse_id']);
// 扣除金币
$currency->change(
@@ -179,6 +220,8 @@ class HorseRaceController extends Controller
'status' => 'pending',
]);
$this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$horseName}{$data['amount']} 金币,等待开跑!",
@@ -193,7 +236,9 @@ class HorseRaceController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$races = HorseRace::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -202,9 +247,9 @@ class HorseRaceController extends Controller
// 转换获胜马匹名称
$history = $races->map(function ($race) {
$winnerName = '未知';
foreach (($race->horses ?? []) as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
foreach ($this->normalizeRaceHorses($race->horses) as $horse) {
if ((int) $horse['id'] === (int) $race->winner_horse_id) {
$winnerName = (string) $horse['emoji'].(string) $horse['name'];
break;
}
}
@@ -221,4 +266,169 @@ class HorseRaceController extends Controller
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;
}
/**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
*
* @return array<int, array{id:int,name:string,emoji:string}>
*/
private function normalizeRaceHorses(mixed $horses): array
{
if (! is_array($horses)) {
return [];
}
$normalizedHorses = [];
foreach ($horses as $index => $horse) {
if (! is_array($horse)) {
continue;
}
$horseId = isset($horse['id']) && is_numeric($horse['id'])
? (int) $horse['id']
: $index + 1;
$horseName = trim((string) ($horse['name'] ?? ''));
if ($horseName === '') {
$horseName = '未知马匹';
}
$normalizedHorses[] = [
'id' => $horseId,
'name' => $horseName,
'emoji' => (string) ($horse['emoji'] ?? '🐎'),
];
}
return $normalizedHorses;
}
/**
* 根据马匹编号返回展示名称,供系统播报与下注回执共用。
*
* @param array<int, array{id:int,name:string,emoji:string}> $horses
*/
private function resolveHorseDisplayName(array $horses, int $horseId): string
{
foreach ($horses as $horse) {
if ((int) ($horse['id'] ?? 0) === $horseId) {
return (string) ($horse['emoji'] ?? '🐎').(string) ($horse['name'] ?? '未知马匹');
}
}
return '🐎未知马匹';
}
}
+13 -2
View File
@@ -12,11 +12,15 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* 类功能:展示全站排行榜、今日排行榜与用户个人积分流水记录。
*/
class LeaderboardController extends Controller
{
/**
@@ -133,8 +137,15 @@ class LeaderboardController extends Controller
$user = auth()->user();
$currency = request('currency');
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
$direction = in_array(request('direction'), ['income', 'expense'], true) ? request('direction') : null;
$sourceOptions = CurrencySource::cases();
$allowedSources = collect($sourceOptions)->map(fn (CurrencySource $source) => $source->value)->all();
$selectedSources = collect(request()->array('sources'))
->filter(fn (string $source) => in_array($source, $allowedSources, true))
->values()
->all();
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days, $direction, $selectedSources);
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days', 'direction', 'sourceOptions', 'selectedSources'));
}
}
+20 -2
View File
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:提供双色球当前期、购票和历史记录接口。
*/
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
public function current(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
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) {
return response()->json(['enabled' => true, 'issue' => null]);
@@ -90,6 +101,11 @@ class LotteryController extends Controller
*/
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([
'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3',
@@ -132,7 +148,9 @@ class LotteryController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
$issues = LotteryIssue::query()
->where('room_id', $roomId)
->where('status', 'settled')
->latest()
->limit(20)
+25 -2
View File
@@ -46,6 +46,29 @@ class MarriageController extends Controller
]);
}
/**
* 获取全站已婚列表(按亲密度或结婚时间排序)。
*/
public function list(Request $request): JsonResponse
{
$marriages = Marriage::query()
->where('status', 'married')
->with(['user:id,username,usersf,sex', 'partner:id,username,usersf,sex', 'ringItem:id,name,icon'])
->orderByDesc('intimacy')
->orderByDesc('married_at')
->paginate(20);
return response()->json([
'status' => 'success',
'data' => $marriages->items(),
'pagination' => [
'current_page' => $marriages->currentPage(),
'last_page' => $marriages->lastPage(),
'total' => $marriages->total(),
],
]);
}
/**
* 获取当前用户的婚姻状态(名片/用户列表用)。
*/
@@ -58,7 +81,7 @@ class MarriageController extends Controller
return response()->json(['married' => false]);
}
$marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']);
$marriage->load(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,slug,icon']);
return response()->json([
'married' => $marriage->status === 'married',
@@ -95,7 +118,7 @@ class MarriageController extends Controller
->where(function ($q) use ($target) {
$q->where('user_id', $target->id)->orWhere('partner_id', $target->id);
})
->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon'])
->with(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,icon'])
->first();
if (! $marriage) {
+25 -11
View File
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
*/
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
public function status(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
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) {
return response()->json(['active' => false]);
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
}
$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()
->where('room_id', $roomId)
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【开箱播报】恭喜{$username} 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$content = "{$emoji}{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【黑化陷阱】haha{$username} 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$content = "☠️ {$username}踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,153 @@
<?php
/**
* 文件功能:前台邮箱找回密码控制器
*
* 提供独立的找回密码页、发送邮箱重置链接、展示重置页以及提交新密码功能。
*/
namespace App\Http\Controllers;
use App\Http\Requests\ResetPasswordRequest;
use App\Http\Requests\SendPasswordResetLinkRequest;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
/**
* 类功能:处理首页邮箱找回密码的完整流程。
*/
class PasswordResetController extends Controller
{
/**
* 展示独立的邮箱找回密码页面。
*/
public function create(): View
{
return view('password-forgot', [
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
'smtpEnabled' => $this->isPasswordResetMailEnabled(),
]);
}
/**
* 发送邮箱找回密码链接。
*/
public function storeLink(SendPasswordResetLinkRequest $request): JsonResponse
{
if (! $this->isPasswordResetMailEnabled()) {
return response()->json([
'status' => 'error',
'message' => '系统暂未开启邮箱发信服务,当前无法通过邮箱找回密码。',
], 403);
}
$email = trim((string) $request->string('email'));
// 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。
if (User::query()->where('email', $email)->count() > 1) {
return response()->json([
'status' => 'error',
'message' => '该邮箱绑定了多个账号,暂不支持自助找回,请联系管理员处理。',
], 422);
}
$status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) {
return response()->json([
'status' => 'success',
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。链接 60 分钟内有效,请注意查收。',
]);
}
if ($status === Password::RESET_THROTTLED) {
return response()->json([
'status' => 'error',
'message' => '发送过于频繁,请稍后再试。',
], 429);
}
if ($status === Password::INVALID_USER) {
return response()->json([
'status' => 'success',
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。请检查收件箱与垃圾邮件箱。',
]);
}
return response()->json([
'status' => 'error',
'message' => '找回密码邮件发送失败,请稍后重试。',
], 500);
}
/**
* 展示独立的重置密码页面。
*/
public function edit(Request $request, string $token): View
{
return view('password-reset', [
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
'token' => $token,
'email' => (string) $request->query('email', ''),
]);
}
/**
* 提交新的登录密码并完成重置。
*/
public function update(ResetPasswordRequest $request): RedirectResponse
{
$credentials = $request->validated();
$status = Password::reset(
$credentials,
function (User $user, #[\SensitiveParameter] string $password): void {
// 重置成功后同步刷新 remember_token,避免旧设备继续沿用旧令牌。
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect()->route('home')->with('status', '密码已重置成功,请使用原昵称和新密码重新登录。');
}
return back()
->withInput($request->except('password', 'password_confirmation'))
->withErrors([
'email' => $this->resolveResetFailureMessage($status),
]);
}
/**
* 判断系统是否已开启邮箱发信服务。
*/
private function isPasswordResetMailEnabled(): bool
{
return Sysparam::where('alias', 'smtp_enabled')->value('body') === '1';
}
/**
* Laravel 密码重置状态码转换为中文错误提示。
*/
private function resolveResetFailureMessage(string $status): string
{
return match ($status) {
Password::INVALID_TOKEN => '重置链接无效或已过期,请重新申请邮箱找回。',
Password::INVALID_USER => '该邮箱未绑定可重置的账号,请确认后再试。',
default => '密码重置失败,请重新获取重置链接后再试。',
};
}
}
+107 -41
View File
@@ -4,7 +4,8 @@
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - config():读取当前职务的默认礼包数量与份数
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
@@ -23,22 +24,30 @@ use App\Events\RedPacketSent;
use App\Jobs\SaveMessageJob;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\GameBetBroadcastService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理聊天室礼包的发包、查状态与抢包流程
*
* 负责礼包主记录创建、Redis 拆包金额管理、领取入账以及实时广播。
*/
class RedPacketController extends Controller
{
/** 礼包固定总数量 */
private const TOTAL_AMOUNT = 888;
/** 礼包默认总数量 */
private const DEFAULT_TOTAL_AMOUNT = 8888;
/** 礼包固定份数 */
private const TOTAL_COUNT = 10;
/** 礼包默认份数 */
private const DEFAULT_TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 300;
@@ -49,12 +58,38 @@ class RedPacketController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
private readonly GameBetBroadcastService $betBroadcastService,
) {}
/**
* superlevel 站长凭空发出礼包。
* 获取当前用户可发出礼包默认配置
*
* 不扣发包人自身货币,888 数量凭空发出分 10
* 聊天室发包弹窗打开时调用,确保页面展示与最终发包数量同源
*/
public function config(): JsonResponse
{
$user = Auth::user();
// 仅拥有礼包红包权限的在职职务可以读取发包配置。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
}
$redPacketConfig = $this->redPacketConfigForUser($user);
return response()->json([
'status' => 'success',
'amount' => $redPacketConfig['amount'],
'count' => $redPacketConfig['count'],
'expire_seconds' => self::EXPIRE_SECONDS,
]);
}
/**
* 拥有权限的职务用户凭空发出礼包。
*
* 不扣发包人自身货币,礼包总量和份数读取当前在职职务配置。
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
*
* @param Request $request 需包含 room_id typegold / exp
@@ -70,12 +105,15 @@ class RedPacketController extends Controller
$roomId = (int) $request->input('room_id');
$type = $request->input('type'); // 'gold' 或 'exp'
// 权限校验:仅 superlevel 可发礼包
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
// 改为按职务权限码控制礼包发放。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
}
$redPacketConfig = $this->redPacketConfigForUser($user);
$totalAmount = $redPacketConfig['amount'];
$totalCount = $redPacketConfig['count'];
// 检查该用户在此房间是否有进行中的红包(防止刷包)
$activeExists = RedPacketEnvelope::query()
->where('sender_id', $user->id)
@@ -88,8 +126,8 @@ class RedPacketController extends Controller
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
}
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于职务配置总量
$amounts = $this->splitAmount($totalAmount, $totalCount);
// 货币展示文案
$typeLabel = $type === 'exp' ? '经验' : '金币';
@@ -99,15 +137,15 @@ class RedPacketController extends Controller
: 'linear-gradient(135deg,#dc2626,#ea580c)';
// 事务:创建红包记录 + Redis 写入分额
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts, $totalAmount, $totalCount): RedPacketEnvelope {
// 创建红包主记录(凭空发出,不扣发包人货币)
$envelope = RedPacketEnvelope::create([
'sender_id' => $user->id,
'sender_username' => $user->username,
'room_id' => $roomId,
'type' => $type,
'total_amount' => self::TOTAL_AMOUNT,
'total_count' => self::TOTAL_COUNT,
'total_amount' => $totalAmount,
'total_count' => $totalCount,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
@@ -132,8 +170,8 @@ class RedPacketController extends Controller
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
.$envelope->id
.',\''.$user->username.'\','
.self::TOTAL_AMOUNT.','
.self::TOTAL_COUNT.','
.$totalAmount.','
.$totalCount.','
.self::EXPIRE_SECONDS
.',\''.$type.'\''
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
@@ -145,7 +183,7 @@ class RedPacketController extends Controller
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>{$totalAmount}</b> {$typeLabel}的礼包!共 {$totalCount} 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
'action' => '',
@@ -160,15 +198,15 @@ class RedPacketController extends Controller
roomId: $roomId,
envelopeId: $envelope->id,
senderUsername: $user->username,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
totalAmount: $totalAmount,
totalCount: $totalCount,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
));
return response()->json([
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
'message' => "🧧 {$typeLabel}礼包已发出!{$totalAmount} {$typeLabel} · {$totalCount}",
]);
}
@@ -307,26 +345,23 @@ class RedPacketController extends Controller
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 广播领取事件(给自己的私有频道,前端弹 Toast)
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
// 重新读取红包统计,确保广播与响应使用的是最新剩余份数。
$envelope->refresh();
$remainingCount = $envelope->remainingCount();
// 在聊天室发送领取播报(所有人可见)
// 广播领取事件:房间内所有在线用户实时刷新剩余份数,领取者本人同步收到到账通知。
broadcast(new RedPacketClaimed(
claimer: $user,
amount: $amount,
envelopeId: $envelope->id,
roomId: $envelope->room_id,
remainingCount: $remainingCount,
type: $envelopeType,
));
// 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
$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);
$this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField;
@@ -335,6 +370,7 @@ class RedPacketController extends Controller
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'remaining_count' => $remainingCount,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
@@ -371,4 +407,34 @@ class RedPacketController extends Controller
return $amounts;
}
/**
* 按当前在职职务解析礼包红包配置。
*
* @return array{amount: int, count: int}
*/
private function redPacketConfigForUser(User $user): array
{
$position = $user->activePosition?->position;
$amount = (int) ($position?->red_packet_amount ?? self::DEFAULT_TOTAL_AMOUNT);
$count = (int) ($position?->red_packet_count ?? self::DEFAULT_TOTAL_COUNT);
if ($amount < 1) {
$amount = self::DEFAULT_TOTAL_AMOUNT;
}
if ($count < 1 || $count > 100) {
$count = self::DEFAULT_TOTAL_COUNT;
}
if ($amount < $count) {
$amount = self::DEFAULT_TOTAL_AMOUNT;
$count = self::DEFAULT_TOTAL_COUNT;
}
return [
'amount' => $amount,
'count' => $count,
];
}
}
@@ -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);
}
}
+102 -34
View File
@@ -9,8 +9,14 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\UserPurchase;
use App\Services\ChatStateService;
use App\Services\DecorationService;
use App\Services\ShopService;
use App\Support\ChatContentSanitizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -22,6 +28,7 @@ class ShopController extends Controller
*/
public function __construct(
private readonly ShopService $shopService,
private readonly ChatStateService $chatState,
) {}
/**
@@ -33,22 +40,28 @@ class ShopController extends Controller
public function items(): JsonResponse
{
$user = Auth::user();
$items = ShopItem::active()->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'type' => $item->type,
'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
]);
$items = ShopItem::query()
->where('is_active', true)
->orderBy('sort_order')
->get()
->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'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);
// 统计背包中各戒指持有数量
$ringCounts = \App\Models\UserPurchase::query()
$ringCounts = UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
@@ -64,6 +77,10 @@ class ShopController extends Controller
'has_rename_card' => $this->shopService->hasRenameCard($user),
'ring_counts' => $ringCounts,
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
'sign_repair_card_item' => $signRepairCard,
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
]);
}
@@ -74,36 +91,75 @@ class ShopController extends Controller
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
* - message 公屏赠言(可选)
*
* @param Request $request item_id, recipient?, message?
* @param Request $request item_id, recipient?, message?, quantity?
*/
public function buy(Request $request): JsonResponse
{
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
$request->validate([
'item_id' => 'required|integer|exists:shop_items,id',
'room_id' => 'required|integer|exists:rooms,id',
'recipient' => 'nullable|string|max:50',
'message' => 'nullable|string|max:120',
'quantity' => 'nullable|integer|min:1|max:99',
]);
$user = Auth::user();
$roomId = (int) $request->input('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 = ShopItem::find($request->item_id);
if (! $item->is_active) {
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
}
$result = $this->shopService->buyItem(Auth::user(), $item);
$quantity = (int) $request->input('quantity', 1);
$result = $this->shopService->buyItem($user, $item, $quantity);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
$response = ['status' => 'success', 'message' => $result['message']];
$response = [
'status' => 'success',
'message' => $result['message'],
'quantity' => $result['quantity'] ?? 1,
'total_price' => $result['total_price'] ?? $item->price,
];
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
if ($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'])) {
$user = Auth::user();
$roomId = (int) $request->room_id;
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
$message = trim($request->input('message', ''));
$message = ChatContentSanitizer::htmlText($request->input('message', ''));
// recipient 为空或 "all" 表示全员
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
// 广播特效事件(全员频道)
// 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
broadcast(new EffectBroadcast(
roomId: $roomId,
type: $result['play_effect'],
@@ -124,12 +180,20 @@ class ShopController extends Controller
'rain' => '🌧',
'lightning' => '⚡',
'snow' => '❄️',
'sakura' => '🌸',
'meteors' => '🌠',
'gold-rain' => '🪙',
'hearts' => '💖',
'confetti' => '🎊',
'fireflies' => '✨',
];
// 赠礼消息文案(改成"为XX触发了一场特效"
$icon = $icons[$result['play_effect']] ?? '✨';
$toStr = $targetUsername ? "{$targetUsername}" : '全体聊友';
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$toStr = $safeTargetUsername ? "{$safeTargetUsername}" : '全体聊友';
$remarkPart = $message ? "{$message}" : '';
$sysContent = "{$icon} {$user->username}{$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
$sysContent = "{$icon} {$safeBuyer}{$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
$sysMsgEvent = new MessageSent(
@@ -150,9 +214,6 @@ class ShopController extends Controller
}
} else {
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = '';
@@ -161,13 +222,20 @@ class ShopController extends Controller
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
}
// 自动钓鱼卡购买通知要真正归属到“钓鱼播报”,这样前端屏蔽规则才能直接命中。
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
// 根据商品类型生成不同通知文案
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$sysContent = match ($item->type) {
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
default => "🛒{$user->username}】购买了「{$item->name}」。",
'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1",
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
};
broadcast(new MessageSent(
@@ -175,7 +243,7 @@ class ShopController extends Controller
message: [
'id' => 0,
'room_id' => $roomId,
'from_user' => '系统传音',
'from_user' => $broadcastFromUser,
'to_user' => '大家',
'content' => $sysContent,
'font_color' => '#7c3aed',
@@ -188,7 +256,7 @@ class ShopController extends Controller
}
// 返回最新金币余额
$response['jjb'] = Auth::user()->fresh()->jjb;
$response['jjb'] = $user->fresh()->jjb;
return response()->json($response);
}
+33 -13
View File
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\SlotMachineLog;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供老虎机信息查询、转动和个人记录接口。
*/
class SlotMachineController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
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 ?? [];
$user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
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 ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100);
$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(
$user,
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
if ($resultType === 'jackpot') {
// 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost);
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
} elseif ($resultType === 'curse') {
// 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
}
$user->refresh();
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
*/
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()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
/**
* 三个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;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
* @param string $toUsername 接收用户名
* @param string $content 消息内容
*/
private function broadcastPersonal(string $toUsername, string $content): void
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => $toUsername,
'content' => $content,
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
broadcast(new MessageSent(1, $msg));
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+268 -148
View File
@@ -2,13 +2,8 @@
/**
* 文件功能:用户中心与管理控制器
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP
*
* 权限等级通过 sysparam 表动态配置:
* level_kick - 踢人所需等级
* level_mute - 禁言所需等级
* level_ban - 封号所需等级
* level_banip - 封IP所需等级
* 接管原版 USERinfo.ASP, USERSET.ASP chpasswd.asp。
* 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
*
* @author ChatRoom Laravel
*
@@ -17,21 +12,57 @@
namespace App\Http\Controllers;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Enums\CurrencySource;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\RevealProfileInfoRequest;
use App\Http\Requests\UpdateChatPreferencesRequest;
use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AchievementService;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
*/
class UserController extends Controller
{
/**
* 查看别人隐藏信息的单次扣费金额。
*/
public const INFO_REVEAL_COST = 1000;
/**
* 可付费查看的信息字段和中文名称。
*/
private const REVEALABLE_INFO_LABELS = [
'exp_num' => '经验',
'jjb' => '金币',
'bank_jjb' => '存款',
'meili' => '魅力',
];
/**
* 构造用户控制器依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly UserCurrencyService $currencyService,
private readonly AchievementService $achievementService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* 查看其他用户资料片 (对应 USERinfo.ASP)
*/
@@ -40,14 +71,26 @@ class UserController extends Controller
$targetUser = User::where('username', $username)->firstOrFail();
$operator = Auth::user();
// 探测原图
$headfaceOriginal = $targetUser->headfaceUrl;
if (str_starts_with((string) $targetUser->headface, 'storage/')) {
$info = pathinfo($targetUser->headface);
$origPath = $info['dirname'].'/'.$info['filename'].'_original.'.($info['extension'] ?? 'jpg');
if (\Illuminate\Support\Facades\Storage::disk('public')->exists(substr($origPath, 8))) {
$headfaceOriginal = '/'.$origPath;
}
}
// 基础公开信息
$activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [
'username' => $targetUser->username,
'previous_name' => $targetUser->previous_name,
'sex' => match ((int) $targetUser->sex) {
1 => '男', 2 => '女', default => ''
},
'headface' => $targetUser->headface,
'headface_original' => $headfaceOriginal,
'usersf' => $targetUser->usersf,
'user_level' => $targetUser->user_level,
'qianming' => $targetUser->qianming,
@@ -57,23 +100,33 @@ class UserController extends Controller
'position_name' => $activePosition?->name ?? '',
'position_icon' => $activePosition?->icon ?? '',
'department_name' => $activePosition?->department?->name ?? '',
'department_rank' => (int) ($activePosition?->department?->rank ?? 0),
'position_rank' => (int) ($activePosition?->rank ?? 0),
];
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
if ($operator && ($operator->user_level >= $targetUser->user_level || $operator->id === $targetUser->id)) {
$data['exp_num'] = $targetUser->exp_num ?? 0;
$data['jjb'] = $targetUser->jjb ?? 0;
$data['meili'] = $targetUser->meili ?? 0;
}
// 经验、金币、魅力卡片始终展示;等级不足时只显示星号,不隐藏字段本身。
$canViewAssetNumbers = $operator && $this->canViewAssetNumbers($operator, $targetUser);
$data['exp_num'] = $canViewAssetNumbers ? ($targetUser->exp_num ?? 0) : '******';
$data['jjb'] = $canViewAssetNumbers ? ($targetUser->jjb ?? 0) : '******';
$data['meili'] = $canViewAssetNumbers ? ($targetUser->meili ?? 0) : '******';
$data['asset_numbers_masked'] = ! $canViewAssetNumbers;
$data['asset_numbers_can_reveal'] = ! $canViewAssetNumbers;
$data['asset_numbers_reveal_cost'] = self::INFO_REVEAL_COST;
$data['asset_reveal_user_id'] = $targetUser->id;
// 银行存款:只有超级管理员(user_level >= superlevel)或本人才能查看具体金额,其余一律显示星号
if ($operator) {
$isSelf = $operator->id === $targetUser->id;
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$isSuperAdmin = $operator->user_level >= $superLevel;
$data['bank_jjb'] = ($isSelf || $isSuperAdmin)
? ($targetUser->bank_jjb ?? 0)
: '******';
$canViewBankBalance = $isSelf || $isSuperAdmin;
// 名片里的别人存款默认只返回星号,真实金额必须通过付费查看接口当次获取。
$data['bank_jjb'] = $canViewBankBalance ? ($targetUser->bank_jjb ?? 0) : '******';
$data['bank_jjb_masked'] = ! $canViewBankBalance;
$data['bank_jjb_can_reveal'] = ! $canViewBankBalance;
$data['bank_jjb_reveal_cost'] = self::INFO_REVEAL_COST;
$data['bank_reveal_user_id'] = $targetUser->id;
}
// 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接
@@ -95,10 +148,27 @@ class UserController extends Controller
])
->values()
->all();
$data['vip']['Name'] = $targetUser->vipName();
$data['vip']['Icon'] = $targetUser->vipIcon();
$signIdentity = $targetUser->currentSignInIdentity();
$latestSignIn = $targetUser->dailySignIns()->first();
$data['sign_in'] = [
'streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
'identity' => $signIdentity ? [
'key' => $signIdentity->badge_code,
'label' => $signIdentity->badge_name,
'icon' => $signIdentity->badge_icon ?? '✅',
'color' => $signIdentity->badge_color ?? '#0f766e',
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
] : null,
];
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
// 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
$canViewNetworkInfo = $operator
&& ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP));
if ($canViewNetworkInfo) {
$data['first_ip'] = $targetUser->first_ip;
// last_ip 目前定义为『上次登录IP』(取数据库 previous_ip
$data['last_ip'] = $targetUser->previous_ip;
@@ -148,6 +218,54 @@ class UserController extends Controller
]);
}
/**
* 付费查看用户资料中被星号隐藏的经验、金币、存款或魅力。
*/
public function revealInfo(RevealProfileInfoRequest $request): JsonResponse
{
/** @var User $operator */
$operator = Auth::user();
$targetUser = User::query()
->select(['id', 'username', 'user_level', 'exp_num', 'jjb', 'bank_jjb', 'meili'])
->findOrFail($request->integer('user_id'));
$asset = (string) $request->string('asset');
$assetLabel = self::REVEALABLE_INFO_LABELS[$asset];
$charged = false;
$operatorGold = (int) $operator->jjb;
if (! $this->canViewProfileInfo($operator, $targetUser, $asset)) {
$operatorGold = $this->currencyService->deductGoldIfEnough(
user: $operator,
amount: self::INFO_REVEAL_COST,
source: CurrencySource::USER_INFO_REVEAL,
remark: "查看 {$targetUser->username}{$assetLabel}",
);
if ($operatorGold === null) {
return response()->json([
'status' => 'error',
'message' => '金币不足,查看'.$assetLabel.'需要 '.self::INFO_REVEAL_COST.' 金币。',
]);
}
$charged = true;
}
return response()->json([
'status' => 'success',
'message' => $charged
? '已扣除 '.self::INFO_REVEAL_COST.' 金币,'.$assetLabel.'已显示。'
: $assetLabel.'已显示。',
'user_id' => $targetUser->id,
'username' => $targetUser->username,
'asset' => $asset,
'value' => $targetUser->{$asset} ?? 0,
'charged' => $charged,
'reveal_cost' => self::INFO_REVEAL_COST,
'jjb' => $operatorGold,
]);
}
/**
* 修改个人资料 (对应 USERSET.ASP)
*/
@@ -189,6 +307,98 @@ class UserController extends Controller
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
}
/**
* 保存聊天室屏蔽、禁音与字号偏好。
*/
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{
$user = Auth::user();
$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 = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => $blockedSystemSenders,
'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([
'chat_preferences' => $preferences,
]);
return response()->json([
'status' => 'success',
'message' => '聊天室偏好已保存。',
'data' => $preferences,
]);
}
/**
* 保存聊天室当日状态,并同步当前在线名单显示。
*/
public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$roomId = (int) $data['room_id'];
// 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。
if (! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json([
'status' => 'error',
'message' => '请先进入聊天室后再设置状态。',
], 422);
}
if ($data['action'] === 'clear') {
$user->update([
'daily_status_key' => null,
'daily_status_expires_at' => null,
]);
} else {
// 状态有效期固定维持到当天结束,次日自动失效。
$user->update([
'daily_status_key' => $data['status_key'],
'daily_status_expires_at' => now()->endOfDay(),
]);
}
$user->refresh();
$presencePayload = $this->chatUserPresenceService->build($user);
$roomIds = $this->chatState->getUserRooms($user->username);
foreach ($roomIds as $activeRoomId) {
// 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
return response()->json([
'status' => 'success',
'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。',
'data' => [
'status' => $this->chatUserPresenceService->currentDailyStatus($user),
],
]);
}
/**
* 修改密码 (对应 chpasswd.asp)
*/
@@ -221,158 +431,68 @@ 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
public function generateWechatCode(Request $request): 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);
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '未登录']);
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
$code = 'BD-'.mt_rand(100000, 999999);
\Illuminate\Support\Facades\Cache::put('wechat_bind_code:'.$code, $user->username, 300); // 5分钟有效
// 防误伤:不能操作等级 >= 自己的人
if ($targetUser->user_level >= $operator->user_level) {
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}"], 403);
}
return ['room' => $room, 'target' => $targetUser];
return response()->json([
'status' => 'success',
'code' => $code,
'message' => '生成成功',
]);
}
/**
* 踢出房间 (对应 KILLUSER.ASP)
* 所需等级由 sysparam level_kick 配置
* 取消绑定微信
*/
public function kick(Request $request, string $username): JsonResponse
public function unbindWechat(Request $request): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '未登录']);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
if ($result instanceof JsonResponse) {
return $result;
}
$user->wxid = null;
$user->save();
// 广播踢出事件
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
return response()->json([
'status' => 'success',
'message' => '解绑成功',
]);
}
/**
* 禁言 (对应原版限制功能)
* 所需等级由 sysparam level_mute 配置
* 禁言信息存入 RedisTTL 到期自动解除
* 判断操作者是否可以免费查看目标用户经验、金币与魅力。
*/
public function mute(Request $request, string $username): JsonResponse
private function canViewAssetNumbers(User $operator, User $targetUser): bool
{
$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} 分钟。"]);
return $operator->id === $targetUser->id
|| (int) $operator->user_level >= (int) $targetUser->user_level;
}
/**
* 封号(禁止登录)
* 所需等级由 sysparam level_ban 配置
* 将用户等级设为 -1 表示封禁
* 判断操作者是否可以免费查看指定资料信息。
*/
public function ban(Request $request, string $username): JsonResponse
private function canViewProfileInfo(User $operator, User $targetUser, string $asset): bool
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
if ($asset !== 'bank_jjb') {
return $this->canViewAssetNumbers($operator, $targetUser);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
if ($result instanceof JsonResponse) {
return $result;
if ($operator->id === $targetUser->id) {
return true;
}
// 封号:设置等级为 -1
$result['target']->user_level = -1;
$result['target']->save();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 踢出聊天室
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}"]);
return (int) $operator->user_level >= $superLevel;
}
}
@@ -0,0 +1,214 @@
<?php
/**
* 文件功能:前台会员中心控制器
* 负责展示会员等级、权益说明、当前会员状态、用户购买记录与会员个性化进退场设置
*/
namespace App\Http\Controllers;
use App\Http\Requests\UpdateVipPresenceSettingsRequest;
use App\Models\Sysparam;
use App\Models\VipLevel;
use App\Models\VipPaymentOrder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VipCenterController extends Controller
{
/**
* 显示会员中心页面
*
* @param Request $request 当前请求对象
*/
public function index(Request $request): View|\Illuminate\Http\JsonResponse
{
$user = $request->user();
// 会员等级按后台排序字段展示,方便用户对比不同档位权益。
$vipLevels = VipLevel::query()
->withCount('users')
->orderByDesc('sort_order')
->orderBy('id')
->get();
// 仅展示当前用户自己的购买记录,避免泄露其他会员订单信息。
$paymentLogs = $this->buildPaymentLogs($user->id);
$paidOrders = VipPaymentOrder::query()
->where('user_id', $user->id)
->where('status', 'paid')
->count();
$totalAmount = (float) VipPaymentOrder::query()
->where('user_id', $user->id)
->where('status', 'paid')
->sum('amount');
$vipPaymentEnabled = Sysparam::getValue('vip_payment_enabled', '0') === '1';
$effectOptions = [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
'sakura' => '樱花飘落',
'meteors' => '流星',
'gold-rain' => '金币雨',
'hearts' => '爱心飘落',
'confetti' => '彩带庆典',
'fireflies' => '萤火虫',
];
$bannerStyleOptions = [
'aurora' => '鎏光星幕',
'storm' => '雷霆风暴',
'royal' => '王者金辉',
'cosmic' => '星穹幻彩',
'farewell' => '告别暮光',
];
if ($request->expectsJson()) {
$data = [
'user' => [
'id' => $user->id,
'username' => $user->username,
'is_vip' => $user->isVip(),
'vip_name' => $user->vipName(),
'hy_time' => $user->hy_time?->format('Y-m-d H:i'),
'vip_level_id' => $user->vip_level_id,
'can_customize' => $user->canCustomizeVipPresence(),
'custom_join_message' => $user->custom_join_message,
'custom_leave_message' => $user->custom_leave_message,
'custom_join_effect' => $user->custom_join_effect,
'custom_leave_effect' => $user->custom_leave_effect,
'vip_level' => $user->vipLevel ? [
'id' => $user->vipLevel->id,
'name' => $user->vipLevel->name,
'icon' => $user->vipLevel->icon,
'color' => $user->vipLevel->color,
'join_effect' => $user->vipLevel->joinEffectKey(),
'join_banner' => $user->vipLevel->joinBannerStyleKey(),
'leave_effect' => $user->vipLevel->leaveEffectKey(),
'leave_banner' => $user->vipLevel->leaveBannerStyleKey(),
'join_templates' => $user->vipLevel->join_templates_array,
'leave_templates' => $user->vipLevel->leave_templates_array,
] : null,
],
'vipLevels' => $vipLevels->map(function ($vip) use ($user) {
$isCurrent = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id;
$isHigher = $user->isVip() ? $vip->isHigherThan($user->vip_level_id) : true;
$isLower = $user->isVip() && ! $isCurrent && ! $isHigher;
return [
'id' => $vip->id,
'name' => $vip->name,
'icon' => $vip->icon,
'color' => $vip->color,
'price' => (float) $vip->price,
'upgrade_price' => $user->isVip() ? (float) $vip->getUpgradePrice($user->vip_level_id) : (float) $vip->price,
'duration_days' => $vip->duration_days,
'exp_multiplier' => $vip->exp_multiplier,
'jjb_multiplier' => $vip->jjb_multiplier,
'description' => $vip->description,
'is_current' => $isCurrent,
'is_higher' => $isHigher,
'is_lower' => $isLower,
];
}), 'paymentLogs' => $paymentLogs->items(),
'vipPaymentEnabled' => $vipPaymentEnabled,
'paidOrders' => $paidOrders,
'totalAmount' => $totalAmount,
'effectOptions' => $effectOptions,
'bannerStyleOptions' => $bannerStyleOptions,
];
return response()->json([
'status' => 'success',
'data' => $data,
]);
}
return view('vip.center', [
'user' => $user,
'vipLevels' => $vipLevels,
'paymentLogs' => $paymentLogs,
'vipPaymentEnabled' => $vipPaymentEnabled,
'paidOrders' => $paidOrders,
'totalAmount' => $totalAmount,
'effectOptions' => $effectOptions,
'bannerStyleOptions' => $bannerStyleOptions,
]);
}
/**
* 保存会员个人自定义欢迎语与离开语。
*/
public function updatePresenceSettings(UpdateVipPresenceSettingsRequest $request): RedirectResponse|\Illuminate\Http\JsonResponse
{
$user = $request->user();
// 只有有效会员且当前等级允许自定义时,才允许保存专属语句。
if (! $user->canCustomizeVipPresence()) {
if ($request->expectsJson()) {
return response()->json([
'status' => 'error',
'message' => '当前会员等级暂不支持自定义欢迎语和离开语。',
], 403);
}
return redirect()
->route('vip.center')
->with('error', '当前会员等级暂不支持自定义欢迎语和离开语。');
}
$data = $request->validated();
// 空字符串统一转成 null,避免数据库保存无意义空白值。
$user->update([
'custom_join_message' => $this->sanitizeNullableMessage($data['custom_join_message'] ?? null),
'custom_leave_message' => $this->sanitizeNullableMessage($data['custom_leave_message'] ?? null),
'custom_join_effect' => $data['custom_join_effect'] ?? null,
'custom_leave_effect' => $data['custom_leave_effect'] ?? null,
]);
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'message' => '设置已保存。',
]);
}
return redirect()
->route('vip.center')
->with('success', '设置已保存。');
}
/**
* 构建当前用户的购买记录分页数据
*
* @param int $userId 当前登录用户 ID
*/
private function buildPaymentLogs(int $userId): LengthAwarePaginator
{
return VipPaymentOrder::query()
->with('vipLevel:id,name,color,icon')
->where('user_id', $userId)
->latest('id')
->paginate(10)
->withQueryString();
}
/**
* 将可空文案统一整理为数据库可保存的字符串。
*/
private function sanitizeNullableMessage(?string $message): ?string
{
$message = trim((string) $message);
return $message === '' ? null : $message;
}
}
@@ -0,0 +1,132 @@
<?php
/**
* 文件功能:前台 VIP 支付控制器
* 负责用户发起 VIP 支付、接收同步回调、接收异步通知并驱动本地会员开通
*/
namespace App\Http\Controllers;
use App\Http\Requests\CreateVipPaymentOrderRequest;
use App\Models\VipLevel;
use App\Services\VipPaymentCenterClient;
use App\Services\VipPaymentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use RuntimeException;
/**
* 前台 VIP 支付控制器
* 负责接收用户选择的支付渠道,下发支付中心订单并处理回调结果。
*/
class VipPaymentController extends Controller
{
/**
* 构造函数
*
* @param VipPaymentService $vipPaymentService 本地 VIP 支付服务
* @param VipPaymentCenterClient $paymentCenterClient 远端支付中心客户端
*/
public function __construct(
private readonly VipPaymentService $vipPaymentService,
private readonly VipPaymentCenterClient $paymentCenterClient,
) {}
/**
* 创建 VIP 支付订单并跳转到 NovaLink 支付页
*
* @param CreateVipPaymentOrderRequest $request 已校验的购买请求
*/
public function store(CreateVipPaymentOrderRequest $request): RedirectResponse
{
if (! $this->paymentCenterClient->isEnabled() || ! $this->paymentCenterClient->hasValidConfig()) {
return redirect()->route('vip.center')->with('error', 'VIP 支付暂未开放,请联系管理员完成后台配置。');
}
$vipLevel = VipLevel::query()->findOrFail((int) $request->validated('vip_level_id'));
$provider = (string) $request->validated('provider');
try {
// 先创建本地订单,再向支付中心发起下单,确保回调时有本地单据可追踪。
$vipPaymentOrder = $this->vipPaymentService->createLocalOrder($request->user(), $vipLevel, $provider);
$remoteOrder = $this->vipPaymentService->createRemoteOrder($vipPaymentOrder);
$payUrl = (string) ($remoteOrder['pay_url'] ?? '');
if ($payUrl === '') {
throw new RuntimeException('支付中心未返回可用的支付地址。');
}
return redirect()->away($payUrl);
} catch (\Throwable $exception) {
return redirect()->route('vip.center')->with('error', '创建 VIP 支付订单失败:'.$exception->getMessage());
}
}
/**
* 处理支付完成后的同步回调
*
* @param Request $request 支付中心跳转返回参数
*/
public function handleReturn(Request $request): RedirectResponse
{
$payload = $request->all();
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($request->string('payment_order_no')->toString())
?? $this->vipPaymentService->findByMerchantOrderNo($request->string('merchant_order_no')->toString());
if (! $vipPaymentOrder) {
return redirect()->route('vip.center')->with('error', '未找到对应的 VIP 支付订单,请稍后在后台核对。');
}
$this->vipPaymentService->recordSyncReturn($vipPaymentOrder, $payload);
try {
// 同步回调只做页面回跳,但这里补查一次可让用户尽快看到最终结果。
$vipPaymentOrder = $this->vipPaymentService->syncRemoteStatus($vipPaymentOrder);
} catch (\Throwable $exception) {
return redirect()->route('vip.center')->with('error', '支付结果正在确认中,请稍后刷新查看。');
}
if ($vipPaymentOrder->isVipOpened()) {
return redirect()->route('vip.center')->with('success', 'VIP 支付成功,会员已开通。');
}
return redirect()->route('vip.center')->with('success', '支付页面已返回,系统正在确认支付结果,请稍后刷新查看。');
}
/**
* 接收 NovaLink 支付中心的异步通知
*
* @param Request $request 支付中心回调请求
*/
public function notify(Request $request): Response
{
$rawBody = $request->getContent();
$signature = (string) $request->header('X-Payment-Signature', '');
if (! $this->paymentCenterClient->isValidWebhookSignature($signature, $rawBody)) {
return response('invalid signature', 401);
}
$payload = $request->json()->all();
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($payload['payment_order_no'] ?? null)
?? $this->vipPaymentService->findByMerchantOrderNo($payload['merchant_order_no'] ?? null);
if (! $vipPaymentOrder) {
return response('order not found', 404);
}
if (($payload['status'] ?? '') !== 'paid') {
return response('ignored', 200);
}
try {
// 异步回调才是最终支付成功依据,这里完成幂等开通 VIP 的核心逻辑。
$this->vipPaymentService->markOrderAsPaid($vipPaymentOrder, $payload, 'async');
return response('success', 200);
} catch (\Throwable $exception) {
return response('error: '.$exception->getMessage(), 500);
}
}
}
+83 -19
View File
@@ -1,42 +1,106 @@
<?php
/**
* 文件功能:在可信代理场景下解析客户端真实 IP。
*
* 仅当当前请求明确来自配置中的反向代理 / CDN 节点时,
* 才会采信其透传的真实客户端 IP 头,避免外部客户端伪造来源。
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* 类功能:为可信代理请求恢复真实客户端 IP。
*/
class CloudflareProxies
{
/**
* 文件功能:强制信任并解析 CDN 传导的真实客户端 IP。
* 解决 Herd 环境 / Nginx 本地反代时,丢失 X-Forwarded-For 导致全员 IP 变成 127.0.0.1 的问题。
* 处理进入应用的请求,并在可信代理场景下覆写客户端 IP。
*/
public function handle(Request $request, Closure $next): Response
{
// 优先采纳 Cloudflare 的 CF-Connecting-IP
if ($request->hasHeader('CF-Connecting-IP')) {
$realIp = $request->header('CF-Connecting-IP');
}
// 腾讯云 EdgeOne CDN 自定义回源头部(后台配置名:EO-Client-IP
elseif ($request->hasHeader('EO-Client-IP')) {
$realIp = $request->header('EO-Client-IP');
}
// 其他国内 CDN 厂商(阿里云 DCDN 等)通用头部
elseif ($request->hasHeader('X-Real-IP')) {
$realIp = $request->header('X-Real-IP');
}
// 最后兜底:取 X-Forwarded-For 最左边第一个(真实客户端)IP
// 格式为 "真实客户端, CDN节点1, CDN节点2"
elseif ($request->hasHeader('X-Forwarded-For')) {
$realIp = trim(explode(',', $request->header('X-Forwarded-For'))[0]);
}
$realIp = $this->resolveTrustedClientIp($request);
if (! empty($realIp)) {
// 仅在确认上游代理可信且透传 IP 合法时,才覆写 request()->ip() 的来源。
$request->server->set('REMOTE_ADDR', $realIp);
$request->headers->set('X-Forwarded-For', $realIp);
}
return $next($request);
}
/**
* 从可信代理头中解析真实客户端 IP。
*/
private function resolveTrustedClientIp(Request $request): ?string
{
$remoteAddress = (string) $request->server->get('REMOTE_ADDR', '');
if (! $this->isTrustedProxy($remoteAddress)) {
return null;
}
foreach (['CF-Connecting-IP', 'EO-Client-IP', 'X-Real-IP'] as $headerName) {
$resolvedIp = $this->sanitizeIp($request->header($headerName));
if ($resolvedIp !== null) {
return $resolvedIp;
}
}
return $this->extractForwardedForIp($request->header('X-Forwarded-For'));
}
/**
* 判断当前请求是否来自受信代理节点。
*/
private function isTrustedProxy(string $remoteAddress): bool
{
if ($this->sanitizeIp($remoteAddress) === null) {
return false;
}
$trustedProxies = config('app.trusted_proxies', ['127.0.0.1', '::1']);
foreach ($trustedProxies as $trustedProxy) {
$trustedProxy = trim((string) $trustedProxy);
if ($trustedProxy !== '' && IpUtils::checkIp($remoteAddress, $trustedProxy)) {
return true;
}
}
return false;
}
/**
* X-Forwarded-For 头中提取最左侧的合法 IP。
*/
private function extractForwardedForIp(?string $forwardedFor): ?string
{
if (! is_string($forwardedFor) || $forwardedFor === '') {
return null;
}
foreach (explode(',', $forwardedFor) as $candidateIp) {
$resolvedIp = $this->sanitizeIp($candidateIp);
if ($resolvedIp !== null) {
return $resolvedIp;
}
}
return null;
}
/**
* 校验并标准化 IP 文本。
*/
private function sanitizeIp(?string $ip): ?string
{
$normalizedIp = trim((string) $ip);
return filter_var($normalizedIp, FILTER_VALIDATE_IP) ? $normalizedIp : null;
}
}
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:后台签到奖励规则保存请求校验
*
* 集中校验连续签到天数、奖励数值与身份徽章配置。
*/
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:校验后台新增和更新签到奖励规则的表单数据。
*/
class SaveSignInRewardRuleRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台权限中间件的管理员继续校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:返回签到奖励规则表单的校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$ruleId = $this->route('signInRewardRule')?->id;
return [
'streak_days' => [
'required',
'integer',
'min:1',
'max:3650',
Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId),
],
'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'identity_badge_code' => ['nullable', 'string', 'max:50'],
'identity_badge_name' => ['nullable', 'string', 'max:50'],
'identity_badge_icon' => ['nullable', 'string', 'max:120'],
'identity_badge_color' => ['nullable', 'string', 'max:20'],
'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'],
'sort_order' => ['required', 'integer', 'min:0', 'max:999999'],
'is_enabled' => ['nullable', 'boolean'],
];
}
/**
* 方法功能:返回签到奖励规则表单的中文字段名。
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'streak_days' => '连续签到天数',
'gold_reward' => '金币奖励',
'exp_reward' => '经验奖励',
'charm_reward' => '魅力奖励',
'identity_badge_name' => '身份名称',
'identity_duration_days' => '身份有效天数',
];
}
}
@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:后台用户资料更新请求校验
*/
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:集中校验后台用户编辑弹窗提交的资料字段。
*/
class UpdateManagedUserRequest extends FormRequest
{
/**
* 方法功能:允许已通过路由中间件的后台用户继续执行校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:返回后台用户编辑表单的校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'sex' => ['sometimes', 'integer', 'in:0,1,2'],
'exp_num' => ['sometimes', 'integer', 'min:0'],
'jjb' => ['sometimes', 'integer', 'min:0'],
'meili' => ['sometimes', 'integer', 'min:0'],
'qianming' => ['sometimes', 'nullable', 'string', 'max:255'],
'position_id' => ['sometimes', 'nullable', 'integer', 'exists:positions,id'],
'headface' => ['sometimes', 'string', 'max:50'],
'password' => ['nullable', 'string', 'min:6'],
'vip_level_id' => ['sometimes', 'nullable', 'integer', 'exists:vip_levels,id'],
'hy_time' => ['sometimes', 'nullable', 'date'],
];
}
/**
* 方法功能:返回后台用户编辑表单的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'position_id.exists' => '所选职务不存在,请重新选择。',
];
}
}
@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:后台 VIP 支付配置保存请求
* 负责校验聊天室接入 NovaLink 支付中心所需的后台配置项
*/
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVipPaymentConfigRequest extends FormRequest
{
/**
* 判断当前请求是否允许执行
*/
public function authorize(): bool
{
return true;
}
/**
* 获取表单校验规则
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'vip_payment_enabled' => ['required', 'in:0,1'],
'vip_payment_base_url' => ['nullable', 'url', 'max:255', 'required_if:vip_payment_enabled,1'],
'vip_payment_app_key' => ['nullable', 'string', 'max:100', 'required_if:vip_payment_enabled,1'],
'vip_payment_app_secret' => ['nullable', 'string', 'max:255', 'required_if:vip_payment_enabled,1'],
'vip_payment_timeout' => ['nullable', 'integer', 'min:3', 'max:30'],
];
}
/**
* 获取中文错误提示
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'vip_payment_enabled.required' => '请先选择是否启用 VIP 支付',
'vip_payment_base_url.required_if' => '启用 VIP 支付时,支付中心地址不能为空',
'vip_payment_base_url.url' => '支付中心地址格式不正确',
'vip_payment_app_key.required_if' => '启用 VIP 支付时,App Key 不能为空',
'vip_payment_app_secret.required_if' => '启用 VIP 支付时,App Secret 不能为空',
'vip_payment_timeout.integer' => '请求超时时间必须是整数',
'vip_payment_timeout.min' => '请求超时时间不能小于 3 秒',
'vip_payment_timeout.max' => '请求超时时间不能大于 30 秒',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:站长隐藏登录请求验证器
*
* 仅校验站长登录页提交的账号、密码与验证码字段,
* 不参与聊天室前台“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验站长隐藏登录表单。
*/
class AdminLoginRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续处理。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取站长隐藏登录所需的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:1'],
'captcha' => ['required', 'captcha'],
];
}
/**
* 获取验证失败时展示的中文提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'username.required' => '必须填写站长账号。',
'password.required' => '必须填写登录密码。',
'password.min' => '登录密码格式不正确。',
'captcha.required' => '必须填写验证码。',
'captcha.captcha' => '验证码不正确。',
];
}
}
+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' => '当前房间不存在。',
];
}
}

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