370 Commits

Author SHA1 Message Date
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
lkddi 2e252eb70e fix(百家乐AI预测): user_id 改为 null 修复外键约束报错
- ai_usage_logs.user_id 外键引用 users.id,0 不合法应为 null
- 顺便修复 prepend() 后的 IDE 类型推断 lint 警告
2026-03-28 21:26:44 +08:00
lkddi d16626d121 feat(百家乐AI预测): 实现多厂商自动故障转移
- predict() 改为遍历所有已启用厂商,与 AiChatService 保持一致
- 首选 glm-5.1-free,失败后自动按 sort_order 切换下一个厂商
- 所有厂商均失败才返回 null 回退本地路单决策
- 每次调用成功/失败均写入日志,便于追踪
2026-03-28 21:15:49 +08:00
461 changed files with 68309 additions and 16535 deletions
+12
View File
@@ -0,0 +1,12 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[cleanup]
script = '''
php artisan reverb:start
php artisan horizon
'''
+14
View File
@@ -0,0 +1,14 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[[actions]]
name = "启动ws"
icon = "tool"
command = '''
php artisan reverb:start
php artisan horizon
'''
+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
+4
View File
@@ -11,6 +11,10 @@
/.phpunit.cache
/.vscode
/.zed
/.junie
/.github
/.gemini
/.agents
/auth.json
/node_modules
/public/build
@@ -0,0 +1,335 @@
# 🛡️ 聊天室项目 — 安全与访问速度优化规划方案
> **项目路径:** `/Users/pllx/Web/Herd/chatroom`
> **技术栈:** Laravel 12 + PHP 8.4 + Redis + MySQL + Reverb (WebSocket) + TailwindCSS 4 + Vite
> **检查日期:** 2026-04-27
---
## 一、安全优化(🔴高危 / 🟡中危 / 🟢低危)
### 🔴 1. 关闭 APP_DEBUG(生产环境)
**当前:** `.env``APP_DEBUG=true`
**风险:** 生产环境开启 DEBUG 会在报错时泄露数据库密码、Redis 密码、Reverb 密钥等敏感信息。
**方案:**
```
# .env 生产环境改为
APP_DEBUG=false
```
---
### 🔴 2. 启用 Session 加密
**当前:** `SESSION_ENCRYPT=false`
**风险:** Session 数据以明文存储在 Redis 中,若 Redis 被入侵或存在 SSRF,用户身份数据全部泄露。
**方案:**
```
# .env 添加
SESSION_ENCRYPT=true
```
---
### 🔴 3. 限制 Reverb WebSocket 允许源(Allowed Origins
**当前:** `config/reverb.php``'allowed_origins' => ['*']`
**风险:** 任何第三方网站均可连接你的 WebSocket 服务,可被用于 CSWSHCross-Site WebSocket Hijacking)攻击,窃取聊天消息。
**方案:**
```php
// config/reverb.php
'allowed_origins' => [
env('APP_URL', 'http://chatroom.test'),
// 如果有多个域名,手动列出
],
```
---
### 🔴 4. Reverb WebSocket 启用 TLSWSS
**当前:** `REVERB_SCHEME=http`WebSocket 走明文 HTTP
**风险:** 所有聊天消息、用户在线状态等实时数据明文传输,可被中间人攻击窃听。
**方案:**
```
# .env 生产环境
REVERB_SCHEME=https
REVERB_PORT=443 # 或 8443
# 并在 reverb.php 中配置 TLS 证书路径
```
---
### 🔴 5. 设置 Session Cookie Secure 标志
**当前:** `SESSION_SECURE_COOKIE` 未设置(null
**风险:** 在 HTTPS 下,未标记 Secure 的 Cookie 仍可能被非 HTTPS 连接泄露。
**方案:**
```
# .env 生产环境
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
```
---
### 🟡 6. 加强登录安全策略
**当前状况:**
- 存在验证码(mews/captcha)✅
- 登录有 `throttle:chat-login` 限流 ✅
- 自动注册(用户名+密码即可注册)⚡ 双刃剑
- MD5 老密码兼容 ✅ 会自动升级为 Bcrypt
**优化方案:**
| 项目 | 建议 |
|------|------|
| 登录失败锁定 | 同一 IP 5 次失败后临时锁定 15 分钟,后端实现 |
| 密码强度 | 最低 6 位,建议增加 min:6 验证或至少含数字/字母 |
| 管理员登录 2FA | id=1 站长登录时增加二次验证(如邮箱验证码) |
| 验证码频率 | 同一 IP 每天最多注册 3 个账号,防恶意注册 |
---
### 🟡 7. 敏感字段防止 Mass Assignment
**当前:** User 模型的 `$fillable` 中包含 `user_level``jjb``meili``bank_jjb``exp_num` 等金钱/权限字段。
**风险:** 如果其他地方调用了 `User::create($request->all())``User::update($request->all())` 且未使用 FormRequest 过滤,可能导致权限提升或刷币。
**方案:**
- 将 `user_level``jjb``meili``bank_jjb``exp_num` 等敏感字段移出 `$fillable`
- 仅在特定 Service 中使用 `forceFill()` 并加日志审计
---
### 🟡 8. XSS 输出转义检查
**当前:** 消息内容 `content` 限制 500 字符 ✅,但需确认前端渲染时是否正确转义 HTML。
**需要检查的点:**
- [ ] 聊天消息在前端如何渲染?(`innerHTML` 还是 `textContent`?)
- [ ] 用户签名(sign)字段是否转义?
- [ ] 房间公告是否转义?
- [ ] 用户头像路径是否校验?(当前有基本校验)
**建议加固:**
- 前端渲染消息一律使用 `textContent` 或 Vue/React 自动转义
- 如果必须支持 HTML 表情/颜色,使用白名单 sanitizer(如 DOMPurify
---
### 🟡 9. 管理员操作审计加强
**当前:** 已有 `PositionAuthorityLog``AdminLog` 记录 ✅
**建议:**
- 所有金币/积分操作必须有完整的前后对比日志
- 敏感操作(封号、解封、改权限)推送微信通知给站长
---
### 🟡 10. 隐藏管理员入口路径
**当前:** `/lkddi` 作为管理员登录入口(隐藏路径),但路径硬编码在 `routes/web.php` 中。
**风险:** 任何能阅读源码或通过路径扫描的人都能发现。
**方案(可选):**
- 改为通过环境变量配置:`ADMIN_LOGIN_PATH=lkddi`
- 增加 IP 白名单限制:仅站长 IP 可访问 `/admin/*`
---
### 🟢 11. 其他安全改进
| 项目 | 说明 |
|------|------|
| CSP Header | 添加 Content-Security-Policy HTTP 头,限制脚本执行来源 |
| X-Frame-Options | 添加 DENY/SAMEORIGIN 防止点击劫持 |
| Reverb 消息大小上限 | 当前 10KB,建议根据业务适当降低 |
| 依赖安全扫描 | 定期运行 `composer audit` 检查 Laravel 及第三方包漏洞 |
| 文件上传安全 | 自定义头像上传已限制图片类型 ✅,但建议增加文件内容校验 |
---
## 二、访问速度优化(🔥高优 / ⚡中优 / 💡低优)
### 🔥 1. 启用 Laravel OPcache
**当前环境:** 通过 Laravel Herd 运行(PHP-FPM),未启用 OPcache。
**影响:** 每个 PHP 请求都要重新编译框架文件,浪费大量 CPU。
**方案(Mac/Linux 生产环境):**
```ini
; php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
opcache.validate_timestamps=0 ; 生产环境关闭文件修改检查
```
---
### 🔥 2. 数据库查询优化
**当前状况:**
- 使用 Redis 缓存,但部分页面可能直接查询 MySQL
- 存在多个游戏(百家乐、赛马、彩票、五子棋等),每次查询都走数据库
**优化方案:**
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 排行榜缓存 | `->remember(60)` 缓存排行榜结果 60 秒 | 🔥 |
| 在线人数缓存 | 当前使用 Redis 实时维护 ✅,保持现状 | - |
| 房间列表缓存 | `Room::all()` 结果缓存到 Redis | 🔥 |
| 游戏配置缓存 | `GameConfig::isEnabled()` 结果缓存 10 秒 | ⚡ |
| 慢查询日志 | 启用 MySQL slow_query_log,定位慢 SQL | ⚡ |
---
### 🔥 3. 静态资源 CDN 加速
**当前:** 所有静态资源(CSS、JS、图片)直接从源服务器加载。
**方案:**
| 资源类型 | 方案 |
|----------|------|
| Vite 构建产物(CSS/JS | 上传到 CDN(阿里云 OSS+CDN / CloudFlare R2 |
| 头像图片 | 启用单独域名或 CDN,添加长期缓存头 |
| 聊天背景图 | 使用 CDN 分发 12 张背景图 |
| Reverb WS 连接 | 通过 CDN/反向代理(如 Nginx)代理 WSS 连接 |
**当前已做:** `.htaccess` 中已对 Vite 构建产物设置 31536000 秒缓存 ✅
**改进:** 将 `public/build/` 下的资源部署到 CDN。
---
### 🔥 4. Reverb WebSocket 优化
**当前:** Reverb 单节点运行,HTTP 协议,端口 8080。
**优化方案:**
| 措施 | 说明 |
|------|------|
| 升级为 WSS | 启用 TLS,避免被运营商劫持/限速 |
| 水平扩展 | 启用 Reverb Scaling(通过 Redis 发布订阅,见 `reverb.php` 配置) |
| Nginx 反向代理 | 用 Nginx 代理 WSS,可同时处理 HTTP 静态资源 |
| 心跳优化 | 当前 `ping_interval=60s`,可考虑适当延长 |
---
### ⚡ 5. Laravel 应用层优化
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 路由缓存 | `php artisan route:cache` — 减少路由注册开销 | ⚡ |
| 配置缓存 | `php artisan config:cache` — 减少 config 加载 | ⚡ |
| 事件缓存 | `php artisan event:cache` — L12 原生支持 | ⚡ |
| 视图缓存 | `php artisan view:cache` — Blade 编译缓存 | ⚡ |
| 模型预加载 | 检查 N+1 查询,使用 `->with()` | ⚡ |
**⚠️ 注意:** `php artisan optimize` 已在 Laravel 12 中被移除,应单独执行以上四个命令。
---
### ⚡ 6. Redis 优化
**当前:** 单机单实例 Redis,承载 Session、Cache、Queue、Reverb Scaling 全部功能。
**建议:**
- 生产环境建议至少 2 个 Redis 实例:一个用于 Session/Cache(可随时清),一个用于 Queue(需持久化)
- Reverb Scaling 发布订阅建议单独连接
- 为 Redis 设置 `maxmemory``maxmemory-policy allkeys-lru` 防止内存溢出
---
### ⚡ 7. 前端加载优化
| 措施 | 说明 |
|------|------|
| JS 代码分割 | Vite 动态 import 拆分大 JS 文件 |
| 懒加载 | 游戏模块(百家乐、赛马等)按需加载 |
| 图片懒加载 | 头像、礼物图片使用 `loading="lazy"` |
| Alpine.js 轻量化 | 当前已使用 Alpine.js ✅ 但避免过多 watcher |
---
### 💡 8. 考虑 Laravel Octane(长期规划)
**说明:** Laravel OctaneSwoole / RoadRunner)将应用常驻内存,消除框架启动开销,可带来 10-30 倍并发性能提升。
**条件:** 需要确保代码无静态变量状态污染,适合用户量增长后的升级。
---
## 三、实施优先级建议
### 第一阶段(紧急 · 1-2 天)🔴🔥
| # | 任务 | 预估工时 |
|---|------|---------|
| 1 | 关闭 `APP_DEBUG` | 5 分钟 |
| 2 | 启用 `SESSION_ENCRYPT` | 5 分钟 |
| 3 | 限制 Reverb `allowed_origins` | 10 分钟 |
| 4 | 配置 Route/Config/Event/View 缓存 | 30 分钟 |
| 5 | 排行榜、房间列表等 Redis 缓存 | 1 小时 |
### 第二阶段(重要 · 3-5 天)🟡⚡
| # | 任务 | 预估工时 |
|---|------|---------|
| 6 | 敏感字段移出 `$fillable` | 1 小时 |
| 7 | 登录失败锁定 + 注册频率限制 | 2 小时 |
| 8 | 数据库慢查询分析与索引优化 | 2 小时 |
| 9 | 前端 JS 懒加载与代码分割 | 3 小时 |
| 10 | OPcache 配置 | 30 分钟 |
### 第三阶段(完善 · 1-2 周)🟢💡
| # | 任务 | 预估工时 |
|---|------|---------|
| 11 | Reverb WSS + Nginx 反向代理 | 2 小时 |
| 12 | 管理员 2FA 验证 | 4 小时 |
| 13 | CDN 部署静态资源 | 1 天 |
| 14 | Content-Security-Policy 等安全头 | 1 小时 |
| 15 | 生产环境 Redis 分实例部署 | 2 小时 |
| 16 | 评估 Laravel Octane 迁移 | 2-3 天 |
---
## 四、检查清单工具
部署到生产环境前可使用以下命令快速检查:
```bash
# Laravel 安全检查
php artisan about # 查看环境配置
php artisan route:list # 查看所有路由(确认无暴露的管理路径)
# Composer 安全审计
composer audit
# 缓存优化
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache
# 依赖更新
composer update --no-dev -o
```
---
> **总结:** 该项目整体架构设计良好(Redis + Reverb 实时通信 + Alpine.js 轻量前端),
> 主要安全短板集中在**生产环境配置**(DEBUG 未关、Session 未加密、WebSocket 无 TLS)和**部分敏感字段保护**。
> 速度优化则聚焦于**缓存策略**和**CDN 静态资源分发**。
>
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
+73 -1
View File
@@ -18,13 +18,19 @@ use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\DailySignIn;
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,14 +43,22 @@ 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
{
// 1. 检查总开关
@@ -58,6 +72,12 @@ class AiHeartbeatCommand extends Command
return Command::SUCCESS;
}
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
$this->aiFinance->bankExcessGold($user);
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
$this->performDailySignIn($user);
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
@@ -133,7 +153,8 @@ class AiHeartbeatCommand extends Command
$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) {
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
@@ -155,6 +176,9 @@ class AiHeartbeatCommand extends Command
}
}
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
$this->aiFinance->bankExcessGold($user);
return Command::SUCCESS;
}
@@ -195,6 +219,54 @@ class AiHeartbeatCommand extends Command
return (int) $raw;
}
/**
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
*/
private function performDailySignIn(User $user): void
{
// 先检查今日是否已签,避免每分钟都调用事务
$alreadySigned = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', today())
->exists();
if ($alreadySigned) {
return;
}
// 获取活跃房间作为签到归属(默认房间 1)
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
$dailySignIn = $this->signInService->claim($user, $roomId);
// 仅当本次心跳实际完成签到时才广播(幂等保护)
if (! $dailySignIn->wasRecentlyCreated) {
return;
}
$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');
}
/**
* 往所有活跃房间发送系统广播消息
*/
+15 -3
View File
@@ -23,6 +23,7 @@ use App\Models\PositionDutyLog;
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;
@@ -46,6 +47,7 @@ class AutoSaveExp extends Command
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
@@ -164,7 +166,7 @@ class AutoSaveExp extends Command
);
}
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
$user->load('activePosition.position'); // 确保职务及职位关联已加载
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。
// 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -208,6 +210,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,9 +231,11 @@ 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),
@@ -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);
}
}
+69 -1
View File
@@ -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
{
/**
@@ -27,6 +32,7 @@ class PurgeOldMessages extends Command
*/
protected $signature = 'messages:purge
{--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 天前的聊天图片文件';
/**
* 执行命令
@@ -46,10 +52,13 @@ class PurgeOldMessages extends Command
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
$days = (int) ($this->option('days')
?: Sysparam::getValue('message_retention_days', '30'));
$imageDays = max(0, (int) $this->option('image-days'));
$cutoff = Carbon::now()->subDays($days);
$isDryRun = $this->option('dry-run');
$this->cleanupExpiredImages($imageDays, $isDryRun);
// 统计待清理数量
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
@@ -88,4 +97,63 @@ class PurgeOldMessages extends Command
return self::SUCCESS;
}
/**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/
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,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;
}
}
}
+36 -1
View File
@@ -41,6 +41,9 @@ enum CurrencySource: string
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
case POSITION_REWARD = 'position_reward';
/** 每日签到奖励(连续签到按规则发放) */
case SIGN_IN = 'sign_in';
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
@@ -48,7 +51,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 +86,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 +137,27 @@ 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';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -147,6 +173,7 @@ enum CurrencySource: string
self::SHOP_BUY => '商城购买',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
@@ -159,6 +186,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 +203,13 @@ 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 => '头像框购买',
};
}
}
+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);
}
}
+4 -3
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室全屏特效广播事件
*
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者target_username null 则全员播放)
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见
*
* @author ChatRoom Laravel
*
@@ -26,13 +26,13 @@ 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'];
/**
* 构造函数
*
* @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
* @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言
@@ -59,6 +59,7 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 广播数据:特效类型、操作者、目标用户、赠言
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
*
* @return array<string, mixed>
*/
+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,
];
}
}
+1
View File
@@ -59,6 +59,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
return [
'race_id' => $this->race->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),
+29 -1
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;
@@ -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 = '未知';
@@ -70,7 +96,9 @@ class HorseRaceSettled implements ShouldBroadcastNow
'race_id' => $this->race->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}礼包!",
];
}
@@ -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
*
+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,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(),
// 更多统计指标以后再发掘
];
@@ -66,6 +66,23 @@ class GameConfigController extends Controller
$current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params'));
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();
@@ -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);
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
'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',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
@@ -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()]);
}
}
}
+525 -98
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",
]);
}
/**
@@ -301,9 +483,10 @@ class AdminCommandController extends Controller
}
/**
* 站长公屏讲话
* 聊天室公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
* id=1 站长仍然拥有完整兜底权限。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
@@ -316,22 +499,27 @@ 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' => '',
@@ -344,6 +532,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 +726,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 +747,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 +798,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 +851,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 +867,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) {
@@ -676,35 +1099,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();
+48 -4
View File
@@ -19,6 +19,7 @@ use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -28,6 +29,7 @@ class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
) {}
/**
@@ -35,18 +37,26 @@ class BaccaratController extends Controller
*/
public function currentRound(Request $request): JsonResponse
{
$user = $request->user();
$round = BaccaratRound::currentRound();
if (! $round) {
return response()->json(['round' => null]);
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 +69,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),
]);
}
@@ -107,8 +121,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 +146,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 +176,28 @@ class BaccaratController extends Controller
'big' => '大', 'small' => '小', default => '豹子'
};
// 发送系统传音到聊天室,公示该用户的押注信息
$chatState = app(\App\Services\ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$roomId = $round->room_id ?? 1;
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
$content = "🎲 <b> 【百家乐】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
event(new \App\Events\MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$betLabel}{$data['amount']} 金币,等待开奖!",
@@ -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";
}
}
+30 -4
View File
@@ -14,11 +14,16 @@
namespace App\Http\Controllers;
use App\Models\BankLog;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理银行余额、存取款和存款排行展示。
*/
class BankController extends Controller
{
/**
@@ -45,20 +50,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,
@@ -158,4 +170,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(),
+394 -198
View File
@@ -14,7 +14,6 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
@@ -26,30 +25,46 @@ use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use App\Support\ChatContentSanitizer;
use App\Support\ChatDailyStatusCatalog;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
/**
* 聊天室核心控制器
* 负责进房、发言、退房、公告与聊天室内各种实时交互。
*/
class ChatController extends Controller
{
/**
* 构造聊天室核心控制器所需依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -63,12 +78,27 @@ class ChatController extends Controller
$room = Room::findOrFail($id);
$user = Auth::user();
$this->ensureUserCanEnterRoom($room, $user);
// 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 0. 判断是否已经是当前房间的在线状态
$hasKey = $this->chatState->isUserInRoom($id, $user->username);
// 增强校验:判断心跳是否还存在。如果遇到没有启动队列任务的情况,离线任务未能清理脏数据,心跳必定过期。
$isHeartbeatAlive = (bool) \Illuminate\Support\Facades\Redis::exists("room:{$id}:alive:{$user->username}");
// 如果虽然在名单里,但心跳早已丢失(可能直接关浏览器且队列未跑),视为全新进房
if ($hasKey && ! $isHeartbeatAlive) {
$this->chatState->userLeave($id, $user->username); // 强制洗净状态
$hasKey = false;
}
$isAlreadyInRoom = $hasKey;
// 1. 先将用户从其他所有房间的在线名单中移除(切换房间时旧记录自动清理)
// 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题
$oldRoomIds = $this->chatState->getUserRooms($user->username);
@@ -79,83 +109,91 @@ class ChatController extends Controller
}
// 2. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'user_id' => $user->id,
'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' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
];
$userData = $this->chatUserPresenceService->build($user);
$this->chatState->userJoin($id, $user->username, $userData);
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
// 3. 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
// 3. 广播和初始化欢迎(仅限初次进入)
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
$initialPresenceTheme = null;
$initialWelcomeMessage = null;
$initialWelcomeMessages = [];
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
if (! $isAlreadyInRoom) {
// 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
$user->refresh();
$isNewbie = ! $user->has_received_new_gift;
if ($isNewbie) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
// 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'welcome_kind' => 'newbie_bonus',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
SaveMessageJob::dispatch($newbieMsg);
$initialWelcomeMessages[] = $newbieMsg;
if ($user->user_level >= $superLevel) {
// 管理员专属:全房间烟花
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
} else {
// 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词)
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
// AI小班长发送欢迎消息,附带聊天室简单介绍
$aiWelcomeTemplates = [
"🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你",
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
];
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
$aiWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => 'AI小班长',
'to_user' => '大家',
'content' => $aiWelcomeContent,
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'welcome_user' => $user->username,
'welcome_kind' => 'ai_newbie_welcome',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $aiWelcomeMsg);
broadcast(new MessageSent($id, $aiWelcomeMsg));
SaveMessageJob::dispatch($aiWelcomeMsg);
$initialWelcomeMessages[] = $aiWelcomeMsg;
}
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
@@ -165,12 +203,30 @@ class ChatController extends Controller
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'entry_broadcast',
'sent_at' => now()->toDateTimeString(),
];
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers();
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
}
}
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
@@ -192,24 +248,26 @@ class ChatController extends Controller
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
return true;
}));
// 7. 如果用户有在职職务,开始记录这次入场的在职登录
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
if (! $isAlreadyInRoom) {
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 8. 好友上线通知:向此房间内在线的好友推送慧慧话
$this->notifyFriendsOnline($id, $user->username);
// 8. 好友上线通知:向此房间内在线的好友推送慧慧话
$this->notifyFriendsOnline($id, $user->username);
}
// 9. 检查是否有未处理的求婚
$pendingProposal = \App\Models\Marriage::with(['user', 'ringItem'])
@@ -248,18 +306,39 @@ class ChatController extends Controller
];
}
// 渲染主聊天框架视图
// 渲染主聊天框架视图前,先计算当前用户的聊天室顶部管理权限。
$roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'initialPresenceTheme' => $initialPresenceTheme,
'initialWelcomeMessage' => $initialWelcomeMessage,
'initialWelcomeMessages' => $initialWelcomeMessages,
'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData,
'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
]);
}
/**
* 校验当前用户是否允许进入指定房间。
*/
private function ensureUserCanEnterRoom(Room $room, User $user): void
{
if ($room->canUserEnter($user)) {
return;
}
abort(403, $room->entryDeniedMessage($user));
}
/**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
*
@@ -309,6 +388,11 @@ class ChatController extends Controller
{
$data = $request->validated();
$user = Auth::user();
$imagePayload = null;
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
return $response;
}
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}";
@@ -324,7 +408,7 @@ class ChatController extends Controller
// 0.5 检查接收方是否在线(防幽灵消息)
$toUser = $data['to_user'] ?? '大家';
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '系统播报', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
// Redis 保存的在线列表
$isOnline = Redis::hexists("room:{$id}:users", $toUser);
if (! $isOnline) {
@@ -336,12 +420,18 @@ class ChatController extends Controller
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
// 1. 过滤净化消息体;若本次只发图片,则允许文本内容为空。
$rawContent = (string) ($data['content'] ?? '');
$pureContent = $rawContent !== '' ? $this->filter->filter($rawContent) : '';
if ($pureContent === '' && ! $request->hasFile('image')) {
return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422);
}
// 2. 若带图片,则生成原图与缩略图并按日期目录保存。
if ($request->hasFile('image')) {
$imagePayload = $this->storeChatImage($request->file('image'), $user->id);
}
// 2. 封装消息对象
$messageData = [
'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号
@@ -352,8 +442,16 @@ class ChatController extends Controller
'is_secret' => $data['is_secret'] ?? false,
'font_color' => $data['font_color'] ?? '',
'action' => $data['action'] ?? '',
'message_type' => $imagePayload ? 'image' : 'text',
'sent_at' => now()->toDateTimeString(),
];
if ($imagePayload !== null) {
$messageData = array_merge($messageData, $imagePayload);
}
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations);
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData);
@@ -381,6 +479,39 @@ class ChatController extends Controller
return response()->json(['status' => 'success']);
}
/**
* 保存聊天图片并生成原图、缩略图两份资源。
*
* @return array<string, string>
*/
private function storeChatImage(UploadedFile $image, int $userId): array
{
$manager = new ImageManager(new Driver);
$extension = strtolower($image->extension() ?: 'jpg');
$datePath = now()->format('Y-m-d');
$basename = 'chat_'.$userId.'_'.Str::uuid();
$originalPath = "chat-images/{$datePath}/{$basename}_original.{$extension}";
$thumbPath = "chat-images/{$datePath}/{$basename}_thumb.{$extension}";
// 原图仅做缩边处理,避免超大文件直接灌进磁盘。
$originalImage = $manager->read($image);
$originalImage->scaleDown(width: 1600, height: 1600);
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
// 缩略图限制在 220px 范围内,聊天室里只展示轻量小图。
$thumbImage = $manager->read($image);
$thumbImage->scaleDown(width: 220, height: 220);
Storage::disk('public')->put($thumbPath, (string) $thumbImage->encode());
return [
'image_path' => $originalPath,
'image_thumb_path' => $thumbPath,
'image_original_name' => $image->getClientOriginalName(),
'image_url' => Storage::url($originalPath),
'image_thumb_url' => Storage::url($thumbPath),
];
}
/**
* 自动挂机存点心跳与经验升级 (新增)
* 替代原版定时 iframe 刷新的 save.asp。
@@ -431,18 +562,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $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' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
]);
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
// 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) {
@@ -472,12 +592,22 @@ class ChatController extends Controller
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 计算会员倍率加成(仅正向奖励有效)
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
$finalExp = $autoEvent->exp_change > 0 ? (int) round($autoEvent->exp_change * $expMul) : $autoEvent->exp_change;
$finalJjb = $autoEvent->jjb_change > 0 ? (int) round($autoEvent->jjb_change * $jjbMul) : $autoEvent->jjb_change;
$bonusExp = ($autoEvent->exp_change > 0 && $finalExp > $autoEvent->exp_change) ? $finalExp - $autoEvent->exp_change : 0;
$bonusJjb = ($autoEvent->jjb_change > 0 && $finalJjb > $autoEvent->jjb_change) ? $finalJjb - $autoEvent->jjb_change : 0;
// 经验变化:通过 UserCurrencyService 写日志
if ($autoEvent->exp_change !== 0) {
if ($finalExp !== 0) {
$this->currencyService->change(
$user,
'exp',
$autoEvent->exp_change,
$finalExp,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
@@ -485,11 +615,11 @@ class ChatController extends Controller
}
// 金币变化:通过 UserCurrencyService 写日志
if ($autoEvent->jjb_change !== 0) {
if ($finalJjb !== 0) {
$this->currencyService->change(
$user,
'gold',
$autoEvent->jjb_change,
$finalJjb,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
@@ -505,13 +635,27 @@ class ChatController extends Controller
$user->save();
}
// 构建会员额外加成文案(参考钓鱼系统,确保不弄错)
$bonusParts = [];
if ($bonusExp > 0) {
$bonusParts[] = "+经验{$bonusExp}";
}
if ($bonusJjb > 0) {
$bonusParts[] = "+金币{$bonusJjb}";
}
$eventContent = $autoEvent->renderText($user->username);
if (! empty($bonusParts)) {
$eventContent .= ''.$user->vipName().'追加:'.implode('', $bonusParts).'';
}
// 广播随机事件消息到聊天室
$eventMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => $autoEvent->renderText($user->username),
'content' => $eventContent,
'is_secret' => false,
'font_color' => match ($autoEvent->event_type) {
'good' => '#16a34a', // 绿色(好运)
@@ -530,11 +674,12 @@ class ChatController extends Controller
// 确定用户称号:管理员 > VIP 名称 > 普通会员
$title = '普通会员';
if ($user->user_level >= $superLevel) {
$title = '管理员';
} elseif ($user->isVip()) {
if ($user->isVip()) {
$title = $user->vipName() ?: '会员';
} elseif ($user->user_level >= $superLevel) {
$title = '管理员';
}
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
return response()->json([
'status' => 'success',
@@ -545,6 +690,11 @@ class ChatController extends Controller
'jjb_gain' => $actualJjbGain,
'user_level' => $user->user_level,
'title' => $title,
'identity_summary' => $identitySummary['inline'],
'department_name' => $identitySummary['department_name'],
'position_name' => $identitySummary['position_name'],
'vip_name' => $identitySummary['vip_name'],
'vip_icon' => $identitySummary['vip_icon'],
'leveled_up' => $leveledUp,
'is_max_level' => $user->user_level >= $superLevel,
'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null,
@@ -552,6 +702,24 @@ class ChatController extends Controller
]);
}
/**
* 处理登录失效后的离场清理。
*
* 该接口通过临时签名 URL 调用,即使会话已过期也能安全完成离场结算。
*/
public function expiredLeave(int $id, int $user): JsonResponse
{
$expiredUser = User::find($user);
if (! $expiredUser) {
return response()->json(['status' => 'error'], 404);
}
$this->dispatchImmediateLeave($id, $expiredUser, '登录失效离开了房间');
return response()->json(['status' => 'success']);
}
/**
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
*
@@ -590,59 +758,33 @@ class ChatController extends Controller
return response()->json(['status' => 'error'], 401);
}
// 1. 从 Redis 删除该用户
$this->chatState->userLeave($id, $user->username);
$leaveTime = microtime(true);
$isExplicit = strval($request->query('explicit')) === '1';
// 记录退出时间和退出信息
$user->update([
'out_time' => now(),
'out_info' => '正常退出了房间',
]);
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
$this->closeDutyLog($user->id);
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level >= $superLevel) {
// 管理员离场:系统公告
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "👋 管理员 【{$user->username}】 已离开聊天室。",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
if ($isExplicit) {
// 人工显式点击“离开”时,立即同步执行清算和播报。
$this->dispatchImmediateLeave($id, $user, '主动离开了房间');
} else {
[$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user);
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime
// Job 中就不会执行完整的离线播报和注销流程
\App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3));
}
$this->chatState->pushMessage($id, $leaveMsg);
// 3. 广播通知他人 (UserLeft 更新用户名单列表,MessageSent 更新消息记录)
broadcast(new UserLeft($id, $user->username))->toOthers();
broadcast(new MessageSent($id, $leaveMsg))->toOthers();
return response()->json(['status' => 'success']);
}
/**
* 立即执行离场清理,并跳过刷新防抖逻辑。
*/
private function dispatchImmediateLeave(int $id, User $user, string $outInfo): void
{
Redis::del("room:{$id}:join_time:{$user->username}");
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, microtime(true), $outInfo);
dispatch_sync($job);
}
/**
* 获取可用头像列表(返回 JSON
* 扫描 /public/images/headface/ 目录,返回所有可用头像文件名
@@ -695,18 +837,10 @@ class ChatController extends Controller
// 将新头像同步到 Redis 在线用户列表中(所有房间)
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 头像更新后,统一通过在线载荷服务刷新所有扩展字段,避免状态或职务字段丢失。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
@@ -722,7 +856,7 @@ class ChatController extends Controller
public function uploadAvatar(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:2048',
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:6144',
]);
$user = Auth::user();
@@ -734,17 +868,22 @@ class ChatController extends Controller
try {
$manager = new ImageManager(new Driver);
$image = $manager->read($file);
// 裁剪正方形并压缩为 112x112
$image->cover(112, 112);
// 生成相对路径
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
$originalFilename = 'custom_'.$user->id.'_'.time().'_original.'.$file->extension();
$path = 'avatars/'.$filename;
$originalPath = 'avatars/'.$originalFilename;
// 保存以高质量 JPG 或原格式
Storage::disk('public')->put($path, (string) $image->encode());
// 1. 处理原图:限制最大宽度为 1280 以免过大,保存原比例高清大图
$originalImage = $manager->read($file);
$originalImage->scaleDown(width: 1280);
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
// 2. 处理缩略图:裁剪正方形并压缩为 112x112
$thumbImage = $manager->read($file);
$thumbImage->cover(112, 112);
Storage::disk('public')->put($path, (string) $thumbImage->encode());
$dbValue = 'storage/'.$path;
@@ -756,18 +895,10 @@ class ChatController extends Controller
}
// 同步 Redis 状态
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface, // Use accessor
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 自定义头像上传成功后,同步覆盖在线名单中的全部展示字段。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
@@ -783,7 +914,8 @@ class ChatController extends Controller
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
* 需要当前在职职务拥有 room.announcement 权限,
* id=1 站长始终允许操作。
*
* @param int $id 房间ID
*/
@@ -792,19 +924,23 @@ class ChatController extends Controller
$user = Auth::user();
$room = Room::findOrFail($id);
// 权限检查:房间主人 或 等级 >= level_announcement
$requiredLevel = (int) Sysparam::getValue('level_announcement', '10');
if ($user->username !== $room->master && $user->user_level < $requiredLevel) {
// 改为统一走职务权限判断,不再给房主单独保留公告特权。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_ANNOUNCEMENT)) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
}
if (! $this->chatState->isUserInRoom($id, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
}
$request->validate([
'announcement' => 'required|string|max:500',
]);
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
$room->announcement = trim($request->input('announcement'))
.' ——'.$user->username.' '.now()->format('m-d H:i');
$announcementText = trim((string) $request->input('announcement'));
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。
$room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
$room->save();
// 广播公告更新到所有在线用户
@@ -813,7 +949,7 @@ class ChatController extends Controller
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}",
'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement),
'is_secret' => false,
'font_color' => '#cc0000',
'action' => '',
@@ -855,6 +991,10 @@ class ChatController extends Controller
$giftId = $request->integer('gift_id');
$count = $request->integer('count', 1);
if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
return $response;
}
// 不能给自己送花
if ($toUsername === $user->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']);
@@ -872,6 +1012,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']);
}
if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
return $response;
}
$totalCost = $gift->cost * $count;
$totalCharm = $gift->charm * $count;
@@ -895,12 +1039,15 @@ class ChatController extends Controller
// 广播送花消息(含图片标记,前端识别后渲染图片)
$countText = $count > 1 ? " {$count}" : '';
$safeSender = ChatContentSanitizer::htmlText($user->username);
$safeReceiver = ChatContentSanitizer::htmlText($toUsername);
$safeGiftName = ChatContentSanitizer::htmlText($gift->name);
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji}{$user->username}】 向 【{$toUsername}】 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji}{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}{$safeGiftName}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -936,7 +1083,7 @@ class ChatController extends Controller
private function grantChatCharm(mixed $sender, string $toUsername): void
{
// 系统用户不参与魅力计算
$systemNames = ['大家', '系统传音', '系统公告', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
$systemNames = ['大家', '系统传音', '系统公告', '系统播报', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
if (in_array($toUsername, $systemNames)) {
return;
}
@@ -1149,7 +1296,7 @@ class ChatController extends Controller
* 用户间赠送金币(任何登录用户均可调用)
*
* 从自己的余额中扣除指定金额,转入对方账户,
* 在房间内通过「系统传音」广播一条赠送提示
* 以私聊消息的方式仅通知赠送双方
*/
public function giftGold(Request $request): JsonResponse
{
@@ -1169,6 +1316,10 @@ class ChatController extends Controller
$roomId = $request->integer('room_id');
$amount = $request->integer('amount');
if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
return $response;
}
// 不能给自己转账
if ($toName === $sender->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
@@ -1180,6 +1331,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']);
}
if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
return $response;
}
// 余额校验
if (($sender->jjb ?? 0) < $amount) {
return response()->json([
@@ -1192,20 +1347,28 @@ class ChatController extends Controller
$sender->decrement('jjb', $amount);
$receiver->increment('jjb', $amount);
// 广播一条消息:发送者/接收者路由到 say2(下方包厢),其他人路由到 say1(公屏)
// 原理:前端 isRelatedToMe = isMe || to_user===me → say2;否则 → say1
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
$giftMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $sender->username,
'to_user' => $toName,
'content' => "悄悄赠送{$amount} 金币!💝",
'is_secret' => false,
'content' => "赠送{$amount} 金币!💝",
'is_secret' => true,
'font_color' => '#b45309',
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 接收方收到消息时,在右下角弹到账提示卡片。
'toast_notification' => [
'title' => '💰 赠金币到账',
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰',
'color' => '#f59e0b',
'duration' => 8000,
],
];
// 推入 Redis + WebSocket + 异步落库,保持与普通私聊一致的展示与历史记录行为。
$this->chatState->pushMessage($roomId, $giftMsg);
broadcast(new MessageSent($roomId, $giftMsg));
SaveMessageJob::dispatch($giftMsg);
@@ -1219,4 +1382,37 @@ class ChatController extends Controller
],
]);
}
/**
* 校验用户是否能在指定房间执行聊天动作。
*/
private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse
{
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$room = Room::query()->find($roomId);
if (! $room) {
return response()->json(['status' => 'error', 'message' => '房间不存在'], 404);
}
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json(['status' => 'error', 'message' => $message], 403);
}
return null;
}
/**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/
private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse
{
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间,无法执行该操作'], 403);
}
return null;
}
}
@@ -0,0 +1,305 @@
<?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:10px;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),
];
}
}
+133
View File
@@ -0,0 +1,133 @@
<?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
{
/** @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:10px;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,
+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 => '',
};
@@ -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(),
]);
}
}
+116 -22
View File
@@ -17,9 +17,12 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -36,13 +39,21 @@ class HorseRaceController extends Controller
*/
public function currentRace(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
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 +62,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 +71,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 +110,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),
]);
}
@@ -126,7 +155,7 @@ class HorseRaceController extends Controller
}
// 验证马匹 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 +183,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 +203,24 @@ class HorseRaceController extends Controller
'status' => 'pending',
]);
$chatState = app(ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$horseName}{$data['amount']} 金币,等待开跑!",
@@ -202,9 +244,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 +263,56 @@ class HorseRaceController extends Controller
return response()->json(['history' => $history]);
}
/**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
*
* @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'));
}
}
+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) {
@@ -147,11 +147,11 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【开箱播报恭喜 【{$username}】 抢到了神秘{$typeName}"
$content = "{$emoji}神秘箱子】开箱播报恭喜 【{$username}】 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【黑化陷阱haha!【{$username}】 中了神秘黑化箱的陷阱!"
$content = "☠️【神秘箱子】《黑化陷阱haha!【{$username}】 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$color = '#f87171';
}
@@ -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 => '密码重置失败,请重新获取重置链接后再试。',
};
}
}
+103 -25
View File
@@ -4,7 +4,8 @@
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - config():读取当前职务的默认礼包数量与份数
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
@@ -23,22 +24,29 @@ 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\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 +57,37 @@ class RedPacketController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* 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 +103,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 +124,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 +135,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 +168,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 +181,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 +196,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,8 +343,19 @@ 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' ? '经验' : '金币';
@@ -335,6 +382,7 @@ class RedPacketController extends Controller
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'remaining_count' => $remainingCount,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
@@ -371,4 +419,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,
];
}
}
+85 -21
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,
) {}
/**
@@ -47,8 +54,10 @@ class ShopController extends Controller
'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 +73,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 +87,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 +176,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 +210,6 @@ class ShopController extends Controller
}
} else {
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = '';
@@ -161,13 +218,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 +239,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 +252,7 @@ class ShopController extends Controller
}
// 返回最新金币余额
$response['jjb'] = Auth::user()->fresh()->jjb;
$response['jjb'] = $user->fresh()->jjb;
return response()->json($response);
}
+245 -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,55 @@
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\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 PositionPermissionService $positionPermissionService,
) {}
/**
* 查看其他用户资料片 (对应 USERinfo.ASP)
*/
@@ -40,6 +69,16 @@ 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 = [
@@ -48,6 +87,7 @@ class UserController extends Controller
1 => '男', 2 => '女', default => ''
},
'headface' => $targetUser->headface,
'headface_original' => $headfaceOriginal,
'usersf' => $targetUser->usersf,
'user_level' => $targetUser->user_level,
'qianming' => $targetUser->qianming,
@@ -57,23 +97,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 +145,25 @@ 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,
];
// 拥有封禁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 +213,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 +302,80 @@ class UserController extends Controller
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
}
/**
* 保存聊天室屏蔽与禁音偏好。
*/
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$preferences = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
'sound_muted' => (bool) $data['sound_muted'],
];
$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 +408,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' => '验证码不正确。',
];
}
}
@@ -0,0 +1,50 @@
<?php
/**
* 文件功能:前台每日签到请求校验
*
* 校验用户发起签到时携带的房间参数,避免脏 room_id 写入签到流水和聊天室通知。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验每日签到领取接口的请求参数。
*/
class ClaimDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录聊天室用户发起签到请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回每日签到领取参数校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回每日签到领取的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.exists' => '当前聊天室不存在,请刷新页面后再签到。',
];
}
}
@@ -0,0 +1,81 @@
<?php
/**
* 文件功能:创建 VIP 支付订单请求
* 负责校验前台用户发起 VIP 购买时提交的会员等级参数
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
/**
* 创建 VIP 支付订单请求
* 负责校验会员购买等级与支付渠道,确保下单时明确指定支付方式。
*/
class CreateVipPaymentOrderRequest extends FormRequest
{
/**
* 判断当前用户是否允许发起购买
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取字段校验规则
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'vip_level_id' => ['required', 'integer', 'exists:vip_levels,id'],
'provider' => ['required', 'string', 'in:alipay,wechat'],
];
}
/**
* 配置验证器实例。
*/
public function withValidator(Validator $validator): void
{
$validator->after(function ($validator) {
$user = $this->user();
if (! $user || ! $user->isVip()) {
return;
}
$vipLevelId = (int) $this->vip_level_id;
$targetLevel = \App\Models\VipLevel::find($vipLevelId);
if (! $targetLevel) {
return;
}
$currentLevelId = (int) $user->vip_level_id;
// 逻辑:允许续费当前等级,或购买更高等级。禁止降级。
if ($vipLevelId !== $currentLevelId && ! $targetLevel->isHigherThan($currentLevelId)) {
$validator->errors()->add('vip_level_id', '当前仅支持续费同级会员或补差价升级到更高等级,暂不支持降级购买。');
}
});
}
/**
* 获取中文错误提示
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'vip_level_id.required' => '请选择要购买的 VIP 等级',
'vip_level_id.exists' => '所选 VIP 等级不存在或已被删除',
'provider.required' => '请选择支付方式',
'provider.in' => '当前支付方式不受支持,请重新选择',
];
}
}
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:前台每日签到日历查询请求校验。
*
* 校验月份参数,供签到日历按月展示签到状态。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验用户查询签到日历时传入的月份参数。
*/
class DailySignInCalendarRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户查询自己的签到日历。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回签到日历查询规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'month' => ['nullable', 'date_format:Y-m'],
];
}
/**
* 方法功能:返回签到日历查询的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'month.date_format' => '月份格式不正确。',
];
}
}
+9 -4
View File
@@ -33,8 +33,15 @@ class LoginRequest extends FormRequest
'username' => [
'required',
'string',
'min:2',
'max:12',
function ($attribute, $value, $fail) {
$width = mb_strwidth($value, 'UTF-8');
if ($width < 4) {
$fail('用户名长度不得少于 4 个英文字母(或 2 个汉字)。');
}
if ($width > 12) {
$fail('用户名长度不得超过 12 个英文字母(或 6 个汉字)。');
}
},
// 允许中英文数字及常见符号,但严格过滤可能引起XSS/SQL注入的危险字符:< > ' "
'regex:/^[^<>\'"]+$/u',
],
@@ -53,8 +60,6 @@ class LoginRequest extends FormRequest
{
return [
'username.required' => '必须填写用户名。',
'username.min' => '用户名长度不得少于 2 个字符。',
'username.max' => '用户名长度不得超过 12 个字符。',
'username.regex' => '用户名包含非法字符(不允许使用尖括号或引号)。',
'password.required' => '必须填写密码。',
'password.min' => '密码长度不得少于 1 个字符。',
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:前台每日签到补签请求校验。
*
* 校验补签日期和房间参数,确保用户只能补签历史漏签日期。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* 类功能:校验用户在签到日历中提交的补签请求。
*/
class MakeupDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户提交补签请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回补签请求的校验规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'target_date' => ['required', 'date', 'before:today', 'after_or_equal:'.Carbon::today()->startOfMonth()->toDateString()],
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回补签请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'target_date.required' => '请选择要补签的日期。',
'target_date.date' => '补签日期格式不正确。',
'target_date.before' => '只能补签今天之前的漏签日期。',
'target_date.after_or_equal' => '补签卡只能补签本月的未签到日期。',
'room_id.exists' => '当前聊天室不存在,请刷新页面后再补签。',
];
}
}
@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:前台邮箱重置密码请求验证器
*
* 负责校验重置令牌、邮箱和新密码字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验邮箱重置密码表单提交的数据。
*/
class ResetPasswordRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 定义重置密码请求的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
];
}
/**
* 定义重置密码请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'token.required' => '重置凭证缺失,请重新从邮件中的链接进入。',
'email.required' => '邮箱不能为空。',
'email.email' => '邮箱格式不正确。',
'password.required' => '请输入新的登录密码。',
'password.min' => '新密码长度至少需要 6 位。',
'password.confirmed' => '两次输入的新密码不一致。',
];
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:用户信息付费查看请求验证器
* 校验用户点击查看别人经验、金币、存款、魅力时提交的目标与信息类型。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:验证付费查看用户隐藏信息的请求参数。
*/
class RevealProfileInfoRequest extends FormRequest
{
/**
* 授权所有已登录用户提交查看请求,具体可见性和扣费由控制器统一处理。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取付费查看信息请求的验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'asset' => ['required', 'string', Rule::in(['exp_num', 'jjb', 'bank_jjb', 'meili'])],
];
}
/**
* 获取付费查看信息请求的中文验证提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'user_id.required' => '缺少要查看的用户。',
'user_id.integer' => '要查看的用户无效。',
'user_id.exists' => '要查看的用户不存在。',
'asset.required' => '缺少要查看的信息类型。',
'asset.in' => '要查看的信息类型无效。',
];
}
}
+56 -8
View File
@@ -13,11 +13,37 @@ namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Rule;
/**
* 聊天室发言请求验证器
* 负责统一校验文本消息与图片消息的发送参数。
*/
class SendMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 允许前端提交的发言动作白名单。
*/
private const ALLOWED_ACTIONS = [
'',
'微笑',
'大笑',
'愤怒',
'哭泣',
'害羞',
'鄙视',
'得意',
'疑惑',
'同情',
'无奈',
'拳打',
'飞吻',
'偷看',
'欢迎',
];
/**
* 判断当前请求是否允许继续。
*/
public function authorize(): bool
{
@@ -25,33 +51,55 @@ class SendMessageRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回发言请求的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<int, mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:500'], // 防止超长文本炸服
'content' => ['nullable', 'required_without:image', 'string', 'max:500'], // 文本与图片至少二选一
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
'to_user' => ['nullable', 'string', 'max:50'],
'is_secret' => ['nullable', 'boolean'],
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
'action' => ['nullable', 'string', 'max:50'], // 动作(例如:微笑着说)
'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex
'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
];
}
/**
* 在校验前统一整理输入,避免首尾空白绕过白名单判断。
*/
protected function prepareForValidation(): void
{
$action = $this->input('action');
$this->merge([
'action' => is_string($action) ? trim($action) : $action,
]);
}
/**
* 返回校验失败时的中文提示。
*/
public function messages(): array
{
return [
'content.required' => '不能发送空消息。',
'content.required_without' => '文字内容和图片至少要发送一项。',
'content.max' => '发言内容不能超过 500 个字符。',
'image.required_without' => '文字内容和图片至少要发送一项。',
'image.image' => '上传的文件必须是图片。',
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
'image.max' => '图片大小不能超过 6MB。',
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
'action.in' => '发言动作不合法,请重新选择。',
];
}
/**
* 重写验证失败的处理,无论如何(就算未按 ajax 标准提交)都必须抛出 JSON,不可以触发网页重定向去走 GET 请求而引发 302 方法错误
*/
protected function failedValidation(Validator $validator)
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'status' => 'error',
@@ -0,0 +1,51 @@
<?php
/**
* 文件功能:发送邮箱找回密码链接请求验证器
*
* 负责校验独立找回密码页面提交的邮箱字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验邮箱找回密码所需的邮箱参数。
*/
class SendPasswordResetLinkRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 定义邮箱找回密码请求的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
];
}
/**
* 定义邮箱找回密码请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'email.required' => '请输入已绑定账号的邮箱地址。',
'email.email' => '邮箱格式不正确,请重新输入。',
'email.max' => '邮箱长度不能超过 255 个字符。',
];
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:百家乐买单活动创建请求
*
* 负责校验聊天室管理员在前台创建买单活动时提交的时间与文案字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBaccaratLossCoverEventRequest extends FormRequest
{
/**
* 判断当前用户是否允许提交创建请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取字段校验规则。
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'starts_at' => ['required', 'date'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'claim_deadline_at' => ['required', 'date', 'after_or_equal:ends_at'],
];
}
/**
* 获取中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'title.required' => '请输入活动标题',
'starts_at.required' => '请选择活动开始时间',
'ends_at.required' => '请选择活动结束时间',
'ends_at.after' => '活动结束时间必须晚于开始时间',
'claim_deadline_at.required' => '请选择领取截止时间',
'claim_deadline_at.after_or_equal' => '领取截止时间不能早于活动结束时间',
];
}
}
@@ -0,0 +1,100 @@
<?php
/**
* 文件功能:节日福利活动创建请求
*
* 负责校验后台创建节日福利模板时提交的奖励、调度与目标用户字段。
*/
namespace App\Http\Requests;
use App\Rules\HolidayEventScheduleRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:校验创建节日福利模板的表单数据。
*/
class StoreHolidayEventRequest extends FormRequest
{
/**
* 判断当前用户是否允许提交创建请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 预处理布尔字段,避免浏览器复选框值造成类型偏差。
*/
protected function prepareForValidation(): void
{
if ($this->has('enabled')) {
$this->merge([
'enabled' => $this->boolean('enabled'),
]);
}
}
/**
* 获取节日福利模板的字段校验规则。
*
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
public function rules(): array
{
return [
'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', Rule::in(['random', 'fixed'])],
'min_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,random'],
'max_amount' => ['nullable', 'integer', 'min:1', 'gte:min_amount'],
'fixed_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,fixed'],
'send_at' => ['nullable', 'date', Rule::requiredIf(fn (): bool => $this->input('repeat_type') !== 'yearly')],
'expire_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'repeat_type' => [
'required',
Rule::in(['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly']),
new HolidayEventScheduleRule,
],
'cron_expr' => ['nullable', 'string', 'max:100', 'required_if:repeat_type,cron'],
'schedule_month' => ['nullable', 'integer', 'between:1,12'],
'schedule_day' => ['nullable', 'integer', 'between:1,31'],
'schedule_time' => ['nullable', 'date_format:H:i'],
'duration_days' => ['nullable', 'integer', 'min:1', 'max:31'],
'daily_occurrences' => ['nullable', 'integer', 'min:1', 'max:24'],
'occurrence_interval_minutes' => ['nullable', 'integer', 'min:1', 'max:1439'],
'target_type' => ['required', Rule::in(['all', 'vip', 'level'])],
'target_value' => ['nullable', 'string', 'max:50', 'required_if:target_type,level'],
'enabled' => ['sometimes', 'boolean'],
];
}
/**
* 获取中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => '请输入活动名称',
'total_amount.required' => '请填写总金币奖池',
'max_claimants.required' => '请填写可领取人数上限',
'distribute_type.required' => '请选择分配方式',
'min_amount.required_if' => '随机分配模式下必须填写最低保底金额',
'fixed_amount.required_if' => '定额发放模式下必须填写每人固定金额',
'send_at.required' => '请选择触发时间',
'expire_minutes.required' => '请填写领取有效期',
'repeat_type.required' => '请选择重复方式',
'cron_expr.required_if' => 'CRON 模式下必须填写表达式',
'schedule_time.date_format' => '首轮开始时间格式必须为 HH:ii',
'target_type.required' => '请选择目标用户范围',
'target_value.required_if' => '指定等级以上模式下必须填写最低等级',
];
}
}
+25 -3
View File
@@ -13,10 +13,14 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
/**
* 新建聊天室请求验证器
* 负责限制建房权限并拦截危险的房间名称输入。
*/
class StoreRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 判断当前用户是否具备自建房间权限。
*/
public function authorize(): bool
{
@@ -26,24 +30,42 @@ class StoreRoomRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回建房请求的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,name'],
'name' => ['required', 'string', 'max:50', 'regex:/^[^<>]+$/u', 'unique:rooms,room_name'],
'description' => ['nullable', 'string', 'max:255'],
];
}
/**
* 在校验前整理房间输入,避免空白与危险字符绕过前端限制。
*/
protected function prepareForValidation(): void
{
$name = $this->input('name');
$description = $this->input('description');
$this->merge([
'name' => is_string($name) ? trim($name) : $name,
'description' => is_string($description) ? trim($description) : $description,
]);
}
/**
* 返回建房失败时的中文提示。
*/
public function messages(): array
{
return [
'name.required' => '必须填写房间名称。',
'name.unique' => '该房间名称已被占用。',
'name.max' => '房间名称最多 50 个字符。',
'name.regex' => '房间名称不能包含尖括号。',
];
}
}
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:聊天室偏好设置验证器
* 负责校验用户提交的屏蔽播报与禁音配置。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 聊天室偏好设置验证器
* 仅允许提交白名单内的屏蔽项与布尔型禁音状态。
*/
class UpdateChatPreferencesRequest extends FormRequest
{
/**
* 允许已登录用户保存自己的聊天室偏好。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取聊天室偏好的验证规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [
'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
],
'sound_muted' => ['required', 'boolean'],
];
}
/**
* 获取聊天室偏好的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'blocked_system_senders.array' => '屏蔽设置格式无效。',
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
'sound_muted.required' => '请传入禁音状态。',
'sound_muted.boolean' => '禁音状态格式无效。',
];
}
}
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:聊天室用户状态设置验证器
* 负责校验用户在聊天室内提交的当日状态设置与清除请求。
*/
namespace App\Http\Requests;
use App\Support\ChatDailyStatusCatalog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateDailyStatusRequest extends FormRequest
{
/**
* 允许已登录用户保存自己的当日状态。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取聊天室当日状态设置的验证规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'room_id' => ['required', 'integer', 'exists:rooms,id'],
'action' => ['required', 'string', Rule::in(['set', 'clear'])],
'status_key' => [
Rule::requiredIf(fn (): bool => $this->input('action') === 'set'),
'nullable',
'string',
Rule::in(ChatDailyStatusCatalog::keys()),
],
];
}
/**
* 获取聊天室当日状态设置的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.required' => '缺少当前房间信息。',
'room_id.integer' => '房间编号格式无效。',
'room_id.exists' => '当前房间不存在。',
'action.required' => '缺少状态操作类型。',
'action.in' => '不支持的状态操作类型。',
'status_key.required' => '请选择要设置的状态。',
'status_key.in' => '请选择系统支持的状态。',
];
}
}
@@ -0,0 +1,14 @@
<?php
/**
* 文件功能:节日福利活动更新请求
*
* 复用创建请求的字段校验规则,用于后台编辑节日福利模板。
*/
namespace App\Http\Requests;
/**
* 类功能:校验更新节日福利模板的表单数据。
*/
class UpdateHolidayEventRequest extends StoreHolidayEventRequest {}
@@ -0,0 +1,148 @@
<?php
/**
* 文件功能:校验后台等级经验阈值配置请求
*
* 约束管理员以列表模式提交的每级经验值,
* 确保阈值为正整数且严格递增。
*/
namespace App\Http\Requests;
use App\Models\Sysparam;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:验证等级经验阈值列表的结构与数值合法性。
*/
class UpdateLevelExpConfigRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台鉴权的用户提交该请求。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:预处理输入,过滤空行并统一转成整数序列。
*/
protected function prepareForValidation(): void
{
$thresholds = collect($this->input('thresholds', []))
->map(fn ($value): string => trim((string) $value))
->filter(fn (string $value): bool => $value !== '')
->values()
->all();
$this->merge([
'thresholds' => $thresholds,
]);
}
/**
* 方法功能:返回等级经验阈值表单的校验规则。
*
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
public function rules(): array
{
return [
'thresholds' => ['required', 'array', 'min:1', $this->strictlyIncreasingRule(), $this->maxLevelLimitRule()],
'thresholds.*' => ['required', 'integer', 'min:1'],
];
}
/**
* 方法功能:返回中文校验错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'thresholds.required' => '请至少配置一个等级经验阈值。',
'thresholds.array' => '等级经验阈值提交格式不正确。',
'thresholds.min' => '请至少保留一个等级经验阈值。',
'thresholds.*.required' => '等级经验阈值不能为空。',
'thresholds.*.integer' => '等级经验阈值必须是整数。',
'thresholds.*.min' => '等级经验阈值必须大于 0。',
];
}
/**
* 方法功能:自定义校验阈值必须严格递增。
*/
private function strictlyIncreasingRule(): ValidationRule
{
return new class implements ValidationRule
{
/**
* 方法功能:执行严格递增校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
$previous = null;
foreach ($value as $index => $threshold) {
if (! is_numeric($threshold)) {
continue;
}
$current = (int) $threshold;
// 每一级累计经验必须大于前一级,避免等级计算出现倒挂。
if ($previous !== null && $current <= $previous) {
$fail('等级经验阈值必须按等级从小到大严格递增,第 '.($index + 1).' 级配置不正确。');
return;
}
$previous = $current;
}
}
};
}
/**
* 方法功能:校验等级阈值数量不能超过用户最高可达等级。
*/
private function maxLevelLimitRule(): ValidationRule
{
return new class((int) Sysparam::getValue('maxlevel', '99')) implements ValidationRule
{
/**
* 方法功能:构造数量上限校验器。
*/
public function __construct(
private readonly int $maxLevel
) {}
/**
* 方法功能:执行阈值数量与最高等级的上限校验。
*
* @param Closure(string): void $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value) || $this->maxLevel < 1) {
return;
}
// 阈值行数对应可升级的等级数,不能超过用户最高可达等级。
if (count($value) > $this->maxLevel) {
$fail('等级经验阈值数量不能超过用户最高可达等级,请先提高最高等级或删除多余等级。');
}
}
};
}
}
+3 -1
View File
@@ -11,6 +11,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
@@ -33,7 +34,7 @@ class UpdateProfileRequest extends FormRequest
'sex' => ['required', 'in:0,1,2'],
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
'sign' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'email' => ['nullable', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->user()?->id)],
'question' => ['nullable', 'string', 'max:100'],
'answer' => ['nullable', 'string', 'max:100'],
];
@@ -44,6 +45,7 @@ class UpdateProfileRequest extends FormRequest
return [
'sex.in' => '性别选项无效(0=保密 1=男 2=女)。',
'headface.required' => '必须选择一个头像。',
'email.unique' => '该邮箱已被其他账号绑定,请更换一个邮箱。',
];
}
}
+32 -3
View File
@@ -11,11 +11,16 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 修改聊天室设置请求验证器
* 负责约束房间名称更新时的合法性,避免危险字符进入前端模板。
*/
class UpdateRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 判断当前请求是否允许继续。
*/
public function authorize(): bool
{
@@ -24,23 +29,47 @@ class UpdateRoomRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回修改房间设置的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,name,'.$this->route('id')],
'name' => [
'required',
'string',
'max:50',
'regex:/^[^<>]+$/u',
Rule::unique('rooms', 'room_name')->ignore($this->route('id')),
],
'description' => ['nullable', 'string', 'max:255'],
];
}
/**
* 在校验前整理更新表单,避免前后空白影响唯一性与安全判断。
*/
protected function prepareForValidation(): void
{
$name = $this->input('name');
$description = $this->input('description');
$this->merge([
'name' => is_string($name) ? trim($name) : $name,
'description' => is_string($description) ? trim($description) : $description,
]);
}
/**
* 返回房间设置更新失败时的中文提示。
*/
public function messages(): array
{
return [
'name.required' => '房间名称不能为空。',
'name.unique' => '该房间名称已存在。',
'name.regex' => '房间名称不能包含尖括号。',
];
}
}
@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:会员个性化欢迎语与离开语设置验证器
* 负责校验会员中心提交的自定义进退场文案。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVipPresenceSettingsRequest extends FormRequest
{
/**
* 判断当前登录用户是否允许提交会员个性化设置。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取会员个性化设置的验证规则。
*/
public function rules(): array
{
return [
'custom_join_message' => ['nullable', 'string', 'max:255'],
'custom_leave_message' => ['nullable', 'string', 'max:255'],
'custom_join_effect' => ['nullable', 'string', 'max:30'],
'custom_leave_effect' => ['nullable', 'string', 'max:30'],
];
}
/**
* 获取会员个性化设置的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'custom_join_message.max' => '欢迎语最多只能填写 255 个字符。',
'custom_leave_message.max' => '离开语最多只能填写 255 个字符。',
];
}
}
+287 -50
View File
@@ -5,7 +5,7 @@
*
* 在每局百家乐开启时延迟调度执行:
* 1. 检查是否存在连输休息惩罚(1小时)
* 2. 检查可用金币,确保留存底金
* 2. 检查可用金币与活动时间窗,确定本局下注额度
* 3. 调用 AI 接口预测路单走势决定下注方向(AI 不可用时回退本地决策)
* 4. 提交下注
*
@@ -17,12 +17,16 @@
namespace App\Jobs;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AiFinanceService;
use App\Services\BaccaratLossCoverService;
use App\Services\BaccaratPredictionService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -33,58 +37,67 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* 控制 AI小班长在百家乐中的下注、观望与资金调度行为。
*/
class AiBaccaratBetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 注入当前需要处理的百家乐局次。
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
public function handle(UserCurrencyService $currency): void
/**
* 执行 AI小班长本局百家乐的完整决策流程。
*/
public function handle(UserCurrencyService $currency, AiFinanceService $aiFinance): void
{
// 1. 检查总开关与游戏开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1' || ! GameConfig::isEnabled('baccarat')) {
return;
}
$round = $this->round->fresh();
if (! $round || ! $round->isBettingOpen()) {
return;
}
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return;
}
// 2. 检查连输惩罚超时
// 2. 资金管理:自动存款与领取补偿
$this->manageFinances($user, $aiFinance);
$round = $this->round->fresh();
if (! $round || ! $round->isBettingOpen()) {
return;
}
// 3. 检查连输惩罚超时
if (Redis::exists('ai_baccarat_timeout')) {
return; // 还在禁赛期
}
// 3. 检查余额与限额
// 4. 检查余额与限额
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
// 至少保留 2000 金币底仓
$availableGold = ($user->jjb ?? 0) - 2000;
if ($availableGold < $minBet) {
// 5. 查询当前是否命中“你玩游戏我买单”活动窗口。
$lossCoverService = app(BaccaratLossCoverService::class);
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
$isInLossCoverWindow = $lossCoverEvent !== null;
// 买单活动进行中时允许 AI 统筹“手上 + 银行”总资产;平时只动用当前手上的 jjb。
$bettableGold = $isInLossCoverWindow
? $aiFinance->getTotalGoldAssets($user)
: $aiFinance->getSpendableGold($user);
if ($bettableGold < $minBet) {
return; // 资金不足以支撑最小下注
}
// 下注金额:可用余额的 2% ~ 5%,并在 min_bet 和 max_bet 之间
$percent = rand(2, 5) / 100.0;
$amount = (int) round($availableGold * $percent);
$amount = max($minBet, min($amount, $maxBet));
// 如果依然大于实际 jjb (保险兜底),则放弃
if ($amount > $user->jjb) {
return;
}
// 4. 获取近期路单(最多取 20 局)
// 6. 获取近期路单和 AI 历史下注
$recentResults = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
@@ -92,48 +105,131 @@ class AiBaccaratBetJob implements ShouldQueue
->pluck('result')
->toArray();
// 5. 优先调用 AI 接口预测下注方向
$historicalBets = BaccaratBet::query()
->join('baccarat_rounds', 'baccarat_bets.round_id', '=', 'baccarat_rounds.id')
->where('baccarat_bets.user_id', $user->id)
->where('baccarat_rounds.status', 'settled')
->orderByDesc('baccarat_rounds.id')
->limit(10)
->select('baccarat_bets.*')
->get()
->map(function ($bet) {
return [
'round_id' => $bet->round_id,
'bet_type' => $bet->bet_type,
'amount' => $bet->amount,
'profit' => $bet->profit ?? 0,
];
})
->toArray();
// 7. 调用 AI 接口出具统筹策略
$predictionService = app(BaccaratPredictionService::class);
$aiPrediction = $predictionService->predict($recentResults);
$context = [
'recent_results' => $recentResults,
'available_gold' => $bettableGold,
'historical_bets' => $historicalBets,
'loss_cover_active' => $isInLossCoverWindow,
];
$aiPrediction = $predictionService->predict($context);
$decisionSource = 'ai';
$betType = $aiPrediction;
// AI 不可用时回退本地路单决策(保底逻辑)
if ($betType === null) {
$betType = 'pass';
$amount = 0;
$reason = '';
if ($aiPrediction) {
$betType = $aiPrediction['action'];
$percent = $aiPrediction['percentage'];
$reason = $aiPrediction['reason'];
// 买单活动期间不允许 AI 选择观望,避免错过“输也可返还”的活动资格。
if ($isInLossCoverWindow && $betType === 'pass') {
$decisionSource = 'local';
$betType = $this->resolveLocalBetType($recentResults, false);
$reason = trim($reason.' 买单活动进行中,本局禁止观望,已切换本地强制参战策略。');
}
if ($betType !== 'pass') {
if ($isInLossCoverWindow) {
// 买单活动进行中且金币足够时,直接按百家乐单局最高限额下注。
$amount = min($bettableGold, $maxBet);
$reason = trim($reason.' 买单活动进行中,本局按百家乐最高限额下注。');
} else {
// 非买单活动期间,限定单局最高下注不超过手头金币的 5% 以防止 AI 破产。
$percent = min(5, max(0, $percent));
$amount = (int) round($bettableGold * ($percent / 100.0));
$amount = max($minBet, min($amount, $maxBet));
if ($amount > $bettableGold) {
$amount = $bettableGold;
}
}
}
} else {
// AI 不可用时回退本地路单决策(保底逻辑)
$decisionSource = 'local';
$bigCount = count(array_filter($recentResults, fn (string $r) => $r === 'big'));
$smallCount = count(array_filter($recentResults, fn (string $r) => $r === 'small'));
// 买单活动期间,本地兜底策略同样不能返回观望。
$betType = $this->resolveLocalBetType($recentResults, ! $isInLossCoverWindow);
$strategy = rand(1, 100);
if ($strategy <= 10) {
$betType = 'triple'; // 10% 概率博豹子
} elseif ($bigCount > $smallCount) {
// 大偏热:70% 概率顺势买大,30% 逆势买小
$betType = rand(1, 100) <= 70 ? 'big' : 'small';
} elseif ($smallCount > $bigCount) {
$betType = rand(1, 100) <= 70 ? 'small' : 'big';
} else {
$betType = rand(0, 1) ? 'big' : 'small';
if ($betType !== 'pass') {
if ($isInLossCoverWindow) {
// 本地兜底策略命中买单活动时,同样优先按百家乐最高限额下注。
$amount = min($bettableGold, $maxBet);
$reason = '买单活动进行中,采用本地最高限额下注兜底策略。';
} else {
$percent = rand(2, 5) / 100.0;
$amount = (int) round($bettableGold * $percent);
$amount = max($minBet, min($amount, $maxBet));
}
}
}
// 记录 AI 小班长本次决策日志
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子', 'pass' => '观望'];
Log::channel('daily')->info('AI小班长百家乐决策', [
'round_id' => $round->id,
'decision_source' => $decisionSource === 'ai' ? 'AI接口预测' : '本地路单兜底',
'ai_prediction' => $aiPrediction ? $labelMap[$aiPrediction] : 'null(不可用)',
'final_bet' => $labelMap[$betType] ?? $betType,
'decision_source' => $decisionSource === 'ai' ? 'AI接口策略' : '本地路单兜底',
'action' => $labelMap[$betType] ?? $betType,
'bet_amount' => $amount,
'reason' => $reason,
'roadmap_20' => implode('→', array_map(fn (string $r) => $labelMap[$r] ?? $r, array_reverse($recentResults))),
]);
// 5. 执行下注 (同 BaccaratController::bet 逻辑)
DB::transaction(function () use ($user, $round, $betType, $amount, $currency) {
if ($betType === 'pass') {
// 观望时仅广播,不执行真正的金币扣除下注逻辑
$this->broadcastPassMessage($user, $round->room_id ?? 1, $reason);
return;
}
// 买单活动期间允许为本次高额下注从银行调拨;非活动期间只检查当前手上金币是否够本次下注。
$isReadyToSpend = $isInLossCoverWindow
? $aiFinance->prepareAllInSpend($user, $amount)
: $aiFinance->prepareSpend($user, $amount);
if (! $isReadyToSpend) {
return;
}
// 二次校验,防止大模型接口调用太慢导致下注时该局已关闭
$round->refresh();
if (! $round->isBettingOpen()) {
Log::channel('daily')->warning("AI小班长百家乐下注拦截:由于AI接口思考耗时,这局#{$round->id}投注已截止。");
return;
}
// 8. 执行下注 (同 BaccaratController::bet 逻辑)
DB::transaction(function () use ($user, $round, $betType, $amount, $currency, $lossCoverEvent, $lossCoverService) {
$lockedUser = User::query()->lockForUpdate()->find($user->id);
if (! $lockedUser || (int) ($lockedUser->jjb ?? 0) < $amount) {
return;
}
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->where('user_id', $lockedUser->id)
->lockForUpdate()
->exists();
@@ -143,7 +239,7 @@ class AiBaccaratBetJob implements ShouldQueue
// 扣除金币
$currency->change(
$user,
$lockedUser,
'gold',
-$amount,
CurrencySource::BACCARAT_BET,
@@ -153,14 +249,20 @@ class AiBaccaratBetJob implements ShouldQueue
);
// 写入下注记录
BaccaratBet::create([
$bet = BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'user_id' => $lockedUser->id,
'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $betType,
'amount' => $amount,
'status' => 'pending',
]);
// 命中买单活动的下注需要登记到活动聚合记录里,确保后续能正确补偿返还。
if ($lossCoverEvent) {
$lossCoverService->registerBet($bet);
}
// 更新局次汇总统计
$field = 'total_bet_'.$betType;
$countField = 'bet_count_'.$betType;
@@ -171,5 +273,140 @@ class AiBaccaratBetJob implements ShouldQueue
// 广播各选项的最新押注人数(让前台看到 AI 下注的人数增长)
event(new \App\Events\BaccaratPoolUpdated($round));
});
// 下注完成后,若手上金币仍高于 100 万,则把超出的部分继续回存银行。
$aiFinance->bankExcessGold($user);
// 下注成功后,在聊天室发送一条普通聊天消息告知大家
$this->broadcastBetMessage($user, $round->room_id ?? 1, $betType, $amount, $decisionSource);
}
/**
* 广播 AI小班长的观望文案到聊天室
*
* @param User $user AI小班长用户
* @param int $roomId 聊天室 ID
* @param string $reason 观望理由
*/
private function broadcastPassMessage(User $user, int $roomId, string $reason): void
{
if (empty($reason)) {
$reason = '风大雨大,保本最大,这把我决定观望一下!';
}
$chatState = app(ChatStateService::class);
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 本局选择挂机观望:✨ <br/><span style='color:#666;'>[🤖 策略分析] {$reason}</span>";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 广播 AI小班长本次下注情况到聊天室
*
* 以普通聊天消息形式发送,发送对象为大家。
*
* @param User $user AI小班长用户对象
* @param int $roomId 目标房间 ID
* @param string $betType 下注方向:big|small|triple
* @param int $amount 下注金额
* @param string $decisionSource 决策来源:ai | local
*/
private function broadcastBetMessage(
User $user,
int $roomId,
string $betType,
int $amount,
string $decisionSource,
): void {
$chatState = app(ChatStateService::class);
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$label = $labelMap[$betType] ?? $betType;
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:';
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}</span>";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#8b5cf6',
'action' => '动态播报',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 生成本地路单兜底下注方向。
*
* @param array<int, string> $recentResults 最近已结算局次结果
* @param bool $allowPass 是否允许返回观望
*/
private function resolveLocalBetType(array $recentResults, bool $allowPass): string
{
$bigCount = count(array_filter($recentResults, fn (string $result) => $result === 'big'));
$smallCount = count(array_filter($recentResults, fn (string $result) => $result === 'small'));
// 默认保留少量押豹子概率,维持 AI 小班长原本的下注风格。
if (rand(1, 100) <= 10) {
return 'triple';
}
if ($bigCount > $smallCount) {
return rand(1, 100) <= 70 ? 'big' : 'small';
}
if ($smallCount > $bigCount) {
return rand(1, 100) <= 70 ? 'small' : 'big';
}
// 只有非买单活动时才允许空仓;活动期间必须至少押大或押小。
if ($allowPass && rand(0, 10) === 0) {
return 'pass';
}
return rand(0, 1) === 0 ? 'big' : 'small';
}
/**
* AI 资金管理逻辑:优先领取补偿,再按“超过 100 万才存款”的规则整理持仓。
*/
private function manageFinances(User $user, AiFinanceService $aiFinance): void
{
// 1. 检查是否有“买单”活动补偿可领取 (jjb 较低时优先领取)
$lossCoverService = app(\App\Services\BaccaratLossCoverService::class);
$pendingEvents = \App\Models\BaccaratLossCoverEvent::where('status', 'claimable')->get();
foreach ($pendingEvents as $event) {
$record = \App\Models\BaccaratLossCoverRecord::where('event_id', $event->id)
->where('user_id', $user->id)
->where('claim_status', 'pending')
->first();
if ($record && $record->compensation_amount > 0) {
$lossCoverService->claim($event, $user);
Log::info("AI小班长自动领取活动补偿: Event #{$event->id}, Amount: {$record->compensation_amount}");
}
}
$aiFinance->rebalanceHoldings($user);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:AI小班长自动领取百家乐买单活动补偿任务
*
* 当买单活动进入“可领取”状态后,异步为 AI小班长检查并领取
* 本次活动中累计的补偿金币,确保金币入账和流水日志统一走正式服务。
*/
namespace App\Jobs;
use App\Models\BaccaratLossCoverEvent;
use App\Models\BaccaratLossCoverRecord;
use App\Models\User;
use App\Services\BaccaratLossCoverService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class AiClaimBaccaratLossCoverJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 3;
/**
* @param int $eventId 需要自动领取补偿的活动 ID
*/
public function __construct(
public readonly int $eventId,
) {}
/**
* 执行 AI 小班长自动领取补偿逻辑。
*/
public function handle(BaccaratLossCoverService $lossCoverService): void
{
$event = BaccaratLossCoverEvent::query()->find($this->eventId);
if (! $event) {
Log::channel('daily')->warning('AI小班长自动领取买单补偿失败:活动不存在', [
'event_id' => $this->eventId,
]);
return;
}
// 只有活动进入可领取状态后,才允许自动发起补偿领取。
if (! $event->isClaimable()) {
Log::channel('daily')->info('AI小班长自动领取买单补偿跳过:活动暂不可领取', [
'event_id' => $event->id,
'status' => $event->status,
]);
return;
}
$aiUser = User::query()->where('username', 'AI小班长')->first();
if (! $aiUser) {
Log::channel('daily')->warning('AI小班长自动领取买单补偿失败:未找到 AI 用户', [
'event_id' => $event->id,
]);
return;
}
$record = BaccaratLossCoverRecord::query()
->where('event_id', $event->id)
->where('user_id', $aiUser->id)
->first();
// 没有补偿记录或本次没有待领取金额时,直接记日志后结束。
if (! $record || $record->compensation_amount <= 0 || $record->claim_status !== 'pending') {
Log::channel('daily')->info('AI小班长自动领取买单补偿跳过:暂无待领取补偿', [
'event_id' => $event->id,
'user_id' => $aiUser->id,
'claim_status' => $record?->claim_status,
'compensation_amount' => $record?->compensation_amount,
]);
return;
}
$claimResult = $lossCoverService->claim($event, $aiUser);
// 统一记录自动领取结果,便于后续核对 AI 补偿发放情况。
Log::channel('daily')->info('AI小班长自动领取买单补偿结果', [
'event_id' => $event->id,
'user_id' => $aiUser->id,
'ok' => $claimResult['ok'],
'message' => $claimResult['message'],
'amount' => $claimResult['amount'] ?? 0,
]);
}
}
+116 -4
View File
@@ -20,6 +20,7 @@ use App\Events\MessageSent;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -49,6 +50,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
public function handle(
UserCurrencyService $currency,
ChatStateService $chatState,
BaccaratLossCoverService $lossCoverService,
): void {
$round = $this->round->fresh();
@@ -94,15 +96,19 @@ class CloseBaccaratRoundJob implements ShouldQueue
// 收集各用户输赢结果,用于公屏展示
$winners = [];
$losers = [];
$participantSettlements = [];
DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout, &$winners, &$losers) {
DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers, &$participantSettlements) {
foreach ($bets as $bet) {
/** @var \App\Models\BaccaratBet $bet */
$username = $bet->user->username ?? '匿名';
if ($result === 'kill') {
// 庄家收割:全灭无退款
$bet->update(['status' => 'lost', 'payout' => 0]);
$lossCoverService->registerSettlement($bet->fresh());
$losers[] = "{$username}-{$bet->amount}";
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
if ($username === 'AI小班长') {
$this->handleAiLoseStreak();
@@ -125,14 +131,19 @@ class CloseBaccaratRoundJob implements ShouldQueue
"百家乐 #{$this->round->id}{$bet->betTypeLabel()} 中奖",
);
$totalPayout += $payout;
$lossCoverService->registerSettlement($bet->fresh());
$winners[] = "{$username}+".number_format($payout);
// 结算提醒展示的是本局净输赢,因此要扣除下注时已经支付的本金。
$this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout);
if ($username === 'AI小班长') {
Redis::del('ai_baccarat_lose_streak'); // 赢了清空连输
}
} else {
$bet->update(['status' => 'lost', 'payout' => 0]);
$lossCoverService->registerSettlement($bet->fresh());
$losers[] = "{$username}-".number_format($bet->amount);
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
if ($username === 'AI小班长') {
$this->handleAiLoseStreak();
@@ -160,6 +171,9 @@ class CloseBaccaratRoundJob implements ShouldQueue
// ── 公屏公告 ─────────────────────────────────────────────────
$this->pushResultMessage($round, $chatState, $winners, $losers);
// ── 参与者私聊提醒 ────────────────────────────────────────────
$this->pushParticipantToastNotifications($round, $chatState, $participantSettlements);
}
/**
@@ -168,12 +182,100 @@ class CloseBaccaratRoundJob implements ShouldQueue
private function handleAiLoseStreak(): void
{
$streak = Redis::incr('ai_baccarat_lose_streak');
if ($streak >= 3) {
Redis::setex('ai_baccarat_timeout', 600, 'timeout'); // 连输次,停赛10分钟
if ($streak >= 10) {
Redis::setex('ai_baccarat_timeout', 600, 'timeout'); // 连输次,停赛10分钟
Redis::del('ai_baccarat_lose_streak');
}
}
/**
* 汇总单个参与者本局的下注、返还与净输赢金额。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function recordParticipantSettlement(array &$participantSettlements, BaccaratBet $bet, int $netChange, int $payout): void
{
if (! $bet->user) {
return;
}
$userId = (int) $bet->user->id;
$existing = $participantSettlements[$userId] ?? [
'user' => $bet->user,
'username' => $bet->user->username,
'bet_amount' => 0,
'payout' => 0,
'net_change' => 0,
];
// 同一用户若存在多条下注记录,这里统一聚合成本局总输赢。
$existing['bet_amount'] += (int) $bet->amount;
$existing['payout'] += $payout;
$existing['net_change'] += $netChange;
$participantSettlements[$userId] = $existing;
}
/**
* 向参与本局的用户发送私聊结算提示,并复用右下角 toast 通知。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function pushParticipantToastNotifications(BaccaratRound $round, ChatStateService $chatState, array $participantSettlements): void
{
if ($participantSettlements === []) {
return;
}
$roomId = 1;
$roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) {
$user = $settlement['user'];
$username = (string) $settlement['username'];
$betAmount = (int) $settlement['bet_amount'];
$netChange = (int) $settlement['net_change'];
$freshGold = (int) ($user->fresh()->jjb ?? 0);
$absNetChange = number_format(abs($netChange));
$betAmountText = number_format($betAmount);
$summaryText = $netChange > 0
? "净赢 {$absNetChange} 金币"
: ($netChange < 0 ? "净输 {$absNetChange} 金币" : '不输不赢');
$toastIcon = $netChange > 0 ? '🎉' : ($netChange < 0 ? '📉' : '🎲');
$toastColor = $netChange > 0 ? '#10b981' : ($netChange < 0 ? '#ef4444' : '#3b82f6');
$toastMessage = $netChange > 0
? "本局结果:<b>{$roundResultLabel}</b><br>你本局净赢 <b>+{$absNetChange}</b> 金币!"
: ($netChange < 0
? "本局结果:<b>{$roundResultLabel}</b><br>你本局净输 <b>-{$absNetChange}</b> 金币。"
: "本局结果:<b>{$roundResultLabel}</b><br>你本局不输不赢。");
// 写入系统私聊,确保用户刷新聊天室后仍能看到本局输赢记录。
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username,
'content' => "🎲 百家乐第 #{$round->id} 局已开奖,结果:{$roundResultLabel}。你本局下注 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#8b5cf6',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '🎲 百家乐本局结算',
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 10000,
],
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
/**
* 向公屏发送开奖结果系统消息(含各用户输赢情况)。
*
@@ -182,7 +284,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
*/
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
{
$diceStr = "{$round->dice1}】【{$round->dice2}】【{$round->dice3}";
$diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
$resultText = match ($round->result) {
'big' => "🔵 大({$round->total_points} 点)",
@@ -223,5 +325,15 @@ class CloseBaccaratRoundJob implements ShouldQueue
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
try {
if (! empty($winners) || ! empty($losers)) {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyBaccaratResult($content);
}
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot baccarat notification failed', ['error' => $e->getMessage()]);
}
}
}
+119 -12
View File
@@ -70,6 +70,7 @@ class CloseHorseRaceJob implements ShouldQueue
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$winnerId = (int) $race->winner_horse_id;
// 按马匹统计各匹下注金额
@@ -82,7 +83,7 @@ class CloseHorseRaceJob implements ShouldQueue
$totalPool = array_sum($horsePools);
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
$netPool = (int) ($totalPool * (1 - $houseTake / 100));
$distributablePool = HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool);
// 结算:遍历所有下注记录
$bets = HorseBet::query()
@@ -92,19 +93,21 @@ class CloseHorseRaceJob implements ShouldQueue
->get();
$totalPayout = 0;
$participantSettlements = [];
DB::transaction(function () use ($bets, $winnerId, $netPool, $winnerPool, $currency, &$totalPayout) {
DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout, &$participantSettlements) {
foreach ($bets as $bet) {
if ((int) $bet->horse_id !== $winnerId) {
// 未中奖(本金已在下注时扣除)
$bet->update(['status' => 'lost', 'payout' => 0]);
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
continue;
}
// 中奖:按注额比例分配净注池
if ($winnerPool > 0) {
$payout = (int) round($netPool * ($bet->amount / $winnerPool));
$payout = (int) round($distributablePool * ($bet->amount / $winnerPool));
} else {
$payout = 0;
}
@@ -121,6 +124,8 @@ class CloseHorseRaceJob implements ShouldQueue
);
}
// 结算提示需要显示本场净输赢,因此要减去下注时已支付的本金。
$this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout);
$totalPayout += $payout;
}
});
@@ -128,24 +133,111 @@ class CloseHorseRaceJob implements ShouldQueue
// 公屏公告
$this->pushResultMessage($race, $chatState, $totalPayout);
// 参与者私聊结算提醒
$this->pushParticipantToastNotifications($race, $chatState, $participantSettlements);
// 广播结算事件
broadcast(new HorseRaceSettled($race));
}
/**
* 汇总单个参与者本场的下注、返还与净输赢金额。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function recordParticipantSettlement(array &$participantSettlements, HorseBet $bet, int $netChange, int $payout): void
{
if (! $bet->user) {
return;
}
$userId = (int) $bet->user->id;
$existing = $participantSettlements[$userId] ?? [
'user' => $bet->user,
'username' => $bet->user->username,
'bet_amount' => 0,
'payout' => 0,
'net_change' => 0,
'horse_id' => (int) $bet->horse_id,
];
// 即使出现脏数据导致同一用户多笔下注,也统一汇总成本场总输赢。
$existing['bet_amount'] += (int) $bet->amount;
$existing['payout'] += $payout;
$existing['net_change'] += $netChange;
$existing['horse_id'] = (int) $bet->horse_id;
$participantSettlements[$userId] = $existing;
}
/**
* 向参与本场的用户发送私聊结算提示,并复用右下角 toast 通知。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function pushParticipantToastNotifications(HorseRace $race, ChatStateService $chatState, array $participantSettlements): void
{
if ($participantSettlements === []) {
return;
}
$roomId = 1;
$winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) {
$user = $settlement['user'];
$username = (string) $settlement['username'];
$betAmount = (int) $settlement['bet_amount'];
$netChange = (int) $settlement['net_change'];
$freshGold = (int) ($user->fresh()->jjb ?? 0);
$horseId = (int) $settlement['horse_id'];
$absNetChange = number_format(abs($netChange));
$betAmountText = number_format($betAmount);
$summaryText = $netChange > 0
? "净赢 {$absNetChange} 金币"
: ($netChange < 0 ? "净输 {$absNetChange} 金币" : '不输不赢');
$toastIcon = $netChange > 0 ? '🏇' : ($netChange < 0 ? '📉' : '🐎');
$toastColor = $netChange > 0 ? '#10b981' : ($netChange < 0 ? '#ef4444' : '#3b82f6');
$toastMessage = $netChange > 0
? "冠军:<b>{$winnerName}</b><br>你本场净赢 <b>+{$absNetChange}</b> 金币!"
: ($netChange < 0
? "冠军:<b>{$winnerName}</b><br>你本场净输 <b>-{$absNetChange}</b> 金币。"
: "冠军:<b>{$winnerName}</b><br>你本场不输不赢。");
// 写入系统私聊,方便用户在聊天历史中回看本场结算结果。
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username,
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '🏇 赛马本场结算',
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 10000,
],
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
/**
* 向公屏发送赛果系统消息。
*/
private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void
{
// 找出胜利马匹名称
$horses = $race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$winnerName = $this->resolveWinnerHorseName($race);
$payoutText = $totalPayout > 0
? '共派发 💰'.number_format($totalPayout).' 金币'
@@ -160,7 +252,7 @@ class CloseHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
@@ -168,4 +260,19 @@ class CloseHorseRaceJob implements ShouldQueue
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 解析冠军马匹的展示名称。
*/
private function resolveWinnerHorseName(HorseRace $race): string
{
$horses = $race->horses ?? [];
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
return ($horse['emoji'] ?? '').($horse['name'] ?? '');
}
}
return '未知';
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}{$typeName}{$source}投放了一个神秘箱子!"
$content = "{$emoji}神秘箱子】《{$typeName}{$source}投放了一个神秘箱子!"
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$msg = [
+5 -1
View File
@@ -73,7 +73,11 @@ class OpenBaccaratRoundJob implements ShouldQueue
}
$killText = implode('或', array_map('intval', $killPoints));
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡小 1:{$bigRate} · 💥豹子 1:{$tripleRate}(☠️ {$killText} 点庄家收割)";
$quickOpenButton = '<button type="button" '
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡小 1:{$bigRate} · 💥豹子 1:{$tripleRate}(☠️ {$killText} 点庄家收割)".$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
+8 -2
View File
@@ -50,6 +50,7 @@ class OpenHorseRaceJob implements ShouldQueue
$horseCount = (int) ($config['horse_count'] ?? 4);
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$now = now();
$closesAt = $now->copy()->addSeconds($betSeconds);
@@ -63,6 +64,7 @@ class OpenHorseRaceJob implements ShouldQueue
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
'horses' => $horses,
'total_pool' => $seedPool,
]);
// 广播开赛事件
@@ -73,7 +75,11 @@ class OpenHorseRaceJob implements ShouldQueue
fn ($h) => "{$h['emoji']}{$h['name']}",
$horses
));
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!';
$quickOpenButton = '<button type="button" '
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!'.$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
@@ -82,7 +88,7 @@ class OpenHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* 文件功能:用户离开聊天室后的异步清理与播报任务
* 负责清理在线状态、关闭勤务日志,并根据会员/管理员身份发送不同的离场提示。
*/
namespace App\Jobs;
use App\Models\PositionDutyLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\RoomBroadcastService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class ProcessUserLeave implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 构造离场处理任务实例。
*/
public function __construct(
public int $roomId,
public User $user,
public float $leaveTime,
public string $outInfo = '正常退出了房间',
) {}
/**
* 执行离场任务:清理在线状态并广播离场消息。
*/
public function handle(ChatStateService $chatState, RoomBroadcastService $broadcast): void
{
// 获取该用户最后一次进入房间的时间
$lastJoinTime = (float) Redis::get("room:{$this->roomId}:join_time:{$this->user->username}");
// 如果最后一次加入的时间 > 当前离线任务产生的时间,说明用户又刷新重新进来了
if ($lastJoinTime >= $this->leaveTime) {
return;
}
// 1. 从 Redis 删除该用户
$chatState->userLeave($this->roomId, $this->user->username);
// 记录退出时间和退出信息
$this->user->update([
'out_time' => now(),
'out_info' => $this->outInfo,
]);
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
$this->closeDutyLog($this->user->id);
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
[$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user);
$vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave');
$leaveMsg = [
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $this->user->username,
'sent_at' => now()->toDateTimeString(),
];
// 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。
if (! empty($vipPresencePayload)) {
$leaveMsg = array_merge($leaveMsg, $vipPresencePayload);
}
// 将播报存入 Redis 历史及广播
$chatState->pushMessage($this->roomId, $leaveMsg);
broadcast(new \App\Events\UserLeft($this->roomId, $this->user->username))->toOthers();
broadcast(new \App\Events\MessageSent($this->roomId, $leaveMsg))->toOthers();
// 离场特效单独发送给房间内仍在线的其他人,避免和消息播报逻辑耦死。
if (! empty($leaveMsg['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($this->roomId, $leaveMsg['presence_effect'], $this->user->username))->toOthers();
}
}
/**
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
*/
private function closeDutyLog(int $userId): void
{
// 将今日开放日志关闭并结算实际时长
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->whereDate('login_at', today())
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
]);
// 关闭历史遗留的跨天未关闭日志(login_at 非今日)
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->whereDate('login_at', '!=', today())
->update([
'logout_at' => DB::raw('DATE_ADD(DATE(login_at), INTERVAL "23:59:59" HOUR_SECOND)'),
]);
}
}
+4 -2
View File
@@ -70,6 +70,7 @@ class RunHorseRaceJob implements ShouldQueue
fn ($h) => "{$h['emoji']}{$h['name']}",
$race->horses ?? []
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
@@ -77,7 +78,7 @@ class RunHorseRaceJob implements ShouldQueue
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
'is_secret' => false,
'font_color' => '#336699',
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
@@ -87,6 +88,7 @@ class RunHorseRaceJob implements ShouldQueue
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = (int) ($config['race_duration'] ?? 30);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$horses = $race->horses ?? [];
$horseCount = count($horses);
@@ -147,7 +149,7 @@ class RunHorseRaceJob implements ShouldQueue
// 计算注池统计
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = HorseBet::query()->where('race_id', $race->id)->sum('amount');
$totalPool = $seedPool + HorseBet::query()->where('race_id', $race->id)->sum('amount');
$race->update([
'total_bets' => $totalBets,
+11 -4
View File
@@ -16,22 +16,25 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
/**
* 异步聊天消息持久化任务
* 负责把 Redis 中的聊天消息安全写入数据库归档。
*/
class SaveMessageJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
* 创建聊天消息持久化任务。
*
* @param array $messageData 包装好的消息数组
* @param array<string, mixed> $messageData 包装好的消息数组
*/
public function __construct(
public readonly array $messageData
) {}
/**
* Execute the job.
* 将缓存在 Redis 刚广播出去的消息,真实映射写入到 `messages` 数据表。
* 执行队列任务,将已广播的聊天消息写入数据库。
*/
public function handle(): void
{
@@ -43,6 +46,10 @@ class SaveMessageJob implements ShouldQueue
'is_secret' => $this->messageData['is_secret'] ?? false,
'font_color' => $this->messageData['font_color'] ?? '',
'action' => $this->messageData['action'] ?? '',
'message_type' => $this->messageData['message_type'] ?? 'text',
'image_path' => $this->messageData['image_path'] ?? null,
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
'image_original_name' => $this->messageData['image_original_name'] ?? null,
// 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']),
]);

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