232 Commits
Author SHA1 Message Date
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
331 changed files with 46717 additions and 17464 deletions
@@ -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 静态资源分发**。
>
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
@@ -18,10 +18,12 @@ 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;
@@ -49,6 +51,7 @@ class AiHeartbeatCommand extends Command
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly AiFinanceService $aiFinance,
private readonly SignInService $signInService,
) {
parent::__construct();
}
@@ -72,6 +75,9 @@ class AiHeartbeatCommand extends Command
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
$this->aiFinance->bankExcessGold($user);
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
$this->performDailySignIn($user);
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
@@ -213,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');
}
/**
* 往所有活跃房间发送系统广播消息
*/
+7 -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. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -229,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),
+28 -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 胜利奖励
@@ -138,6 +140,24 @@ enum CurrencySource: string
/** 看视频赚金币与经验奖励 */
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';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -153,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 => '结婚魅力加成',
@@ -183,6 +204,12 @@ enum CurrencySource: string
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,
];
}
}
+2 -1
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室全屏特效广播事件
*
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者target_username null 则全员播放)
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见
*
* @author ChatRoom Laravel
*
@@ -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,
];
}
}
+5 -7
View File
@@ -26,9 +26,9 @@ 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(
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
/**
* 获取消息应广播到的频道。
*
* 公共消息走房间 Presence 频道;
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
* 公共消息和普通定向发言走房间 Presence 频道;
* 悄悄话只发给发送方与接收方的私有用户频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
*/
private function shouldBroadcastPrivately(): bool
{
$toUser = trim((string) ($this->message['to_user'] ?? ''));
return $toUser !== '' && $toUser !== '大家';
return (bool) ($this->message['is_secret'] ?? false);
}
/**
@@ -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,
];
}
}
@@ -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));
@@ -13,9 +13,14 @@ 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
{
/**
@@ -23,6 +28,7 @@ class BaccaratLossCoverEventController extends Controller
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -30,6 +36,13 @@ class BaccaratLossCoverEventController extends Controller
*/
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) {
@@ -51,6 +64,13 @@ class BaccaratLossCoverEventController extends Controller
*/
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([
@@ -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();
@@ -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;
}
}
@@ -60,6 +60,14 @@ class SystemController extends Controller
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
$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;
@@ -88,7 +96,7 @@ class SystemController extends Controller
return SysParam::query()
->orderBy('id')
->pluck('alias')
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias))
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
->values()
->all();
}
@@ -104,4 +112,21 @@ class SystemController extends Controller
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,
) {}
/**
@@ -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: '你的职务已被撤销,页面权限正在同步更新。',
));
}
}
+456 -112
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,21 +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
{
/**
@@ -39,6 +47,7 @@ class AdminCommandController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -59,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);
}
// 广播警告消息
@@ -73,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' => '',
@@ -87,9 +109,9 @@ class AdminCommandController extends Controller
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "⚠️ 管理员 <b>{$admin->username}</b> 警告了你:{$reason}",
content: "⚠️ {$operatorDisplay} 警告了你:{$reason}",
title: '⚠️ 收到警告',
toastMessage: "<b>{$admin->username}</b> 警告了你:{$reason}",
toastMessage: "{$operatorDisplay} 警告了你:{$reason}",
color: '#f59e0b',
icon: '⚠️',
);
@@ -115,21 +137,34 @@ 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_kick 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
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_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: "🚫 管理员 <b>{$admin->username}</b> 已将你踢出聊天室。原因:{$reason}",
content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}",
title: '🚫 已被踢出',
toastMessage: "<b>{$admin->username}</b> 已将你踢出聊天室。<br>原因:{$reason}",
toastMessage: "{$operatorDisplay} 已将你踢出聊天室。<br>原因:{$reason}",
color: '#ef4444',
icon: '🚫',
);
@@ -143,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' => '',
@@ -177,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 自动过期
@@ -195,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' => '',
@@ -209,9 +258,9 @@ class AdminCommandController extends Controller
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🔇 管理员 <b>{$admin->username}</b> 已将你禁言 {$duration} 分钟。",
content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。",
title: '🔇 已被禁言',
toastMessage: "<b>{$admin->username}</b> 已将你禁言 <b>{$duration}</b> 分钟。",
toastMessage: "{$operatorDisplay} 已将你禁言 <b>{$duration}</b> 分钟。",
color: '#6366f1',
icon: '🔇',
);
@@ -221,21 +270,22 @@ class AdminCommandController extends Controller
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',
@@ -245,48 +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();
// 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
roomId: $roomId,
targetUsername: $targetUsername,
content: "🧊 管理员 <b>{$admin->username}</b> 已冻结你的账号。原因:{$reason}",
title: '🧊 账号已冻结',
toastMessage: "<b>{$admin->username}</b> 已冻结你的账号。<br>原因:{$reason}",
color: '#3b82f6',
icon: '🧊',
content: "{$operatorDisplay} 已封禁你的账号。原因:{$reason}",
title: ' 账号已封禁',
toastMessage: "{$operatorDisplay} 已封禁你的账号。<br>原因:{$reason}",
color: '#991b1b',
icon: '',
);
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave($rid, $targetUsername);
}
$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(),
];
@@ -294,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",
]);
}
/**
@@ -347,9 +483,10 @@ class AdminCommandController extends Controller
}
/**
* 站长公屏讲话
* 聊天室公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
* id=1 站长仍然拥有完整兜底权限。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
@@ -362,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' => '',
@@ -390,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;
}
/**
* 管理员全员清屏
*
@@ -406,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 中该房间的消息缓存
@@ -423,11 +746,50 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'success', 'message' => '已执行全员清屏']);
}
/**
* 站长触发当前房间全员刷新页面。
*
* 仅允许 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 操作结果
@@ -440,13 +802,15 @@ class AdminCommandController extends Controller
]);
$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);
}
// 广播特效事件给房间内所有在线用户
@@ -487,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) {
@@ -499,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) {
@@ -721,39 +1098,6 @@ class AdminCommandController extends Controller
]);
}
/**
* 权限检查:管理员是否可对目标用户执行指定操作
*
* 根据 sysparam 中配置的等级门槛判断权限。
*
* @param User $admin 管理员用户
* @param string $targetUsername 目标用户名
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn
* @param string $defaultLevel 默认等级值
* @return bool 是否有权限
*/
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;
}
// 不能操作自己
if ($admin->username === $targetUsername) {
return false;
}
// 目标用户等级不能高于操作者(允许平级互相操作)
$target = User::where('username', $targetUsername)->first();
if ($target && $target->user_level > $admin->user_level) {
return false;
}
return true;
}
/**
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
*/
+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;
}
}
@@ -14,6 +14,7 @@ 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;
@@ -106,6 +107,13 @@ class ChatAppointmentController extends Controller
icon: '✨',
);
}
// 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已发生变更,页面权限正在同步更新。',
));
}
return response()->json([
@@ -161,6 +169,13 @@ class ChatAppointmentController extends Controller
icon: '📋',
);
}
// 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已被撤销,页面权限正在同步更新。',
));
}
return response()->json([
+13 -6
View File
@@ -56,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') {
@@ -81,19 +85,22 @@ 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);
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
@@ -148,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(),
+145 -66
View File
@@ -25,10 +25,15 @@ 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;
@@ -52,12 +57,14 @@ 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,
) {}
/**
@@ -102,21 +109,7 @@ 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));
@@ -125,13 +118,20 @@ class ChatController extends Controller
$newbieEffect = null;
$initialPresenceTheme = null;
$initialWelcomeMessage = null;
$initialWelcomeMessages = [];
if (! $isAlreadyInRoom) {
// 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
if (! $user->has_received_new_gift) {
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
$user->refresh();
$isNewbie = ! $user->has_received_new_gift;
if ($isNewbie) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
@@ -149,21 +149,47 @@ class ChatController extends Controller
'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;
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 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);
@@ -179,6 +205,7 @@ class ChatController extends Controller
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'entry_broadcast',
'sent_at' => now()->toDateTimeString(),
];
@@ -190,6 +217,7 @@ class ChatController extends Controller
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
@@ -220,8 +248,8 @@ class ChatController extends Controller
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
return true;
}));
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
@@ -278,7 +306,9 @@ class ChatController extends Controller
];
}
// 渲染主聊天框架视图
// 渲染主聊天框架视图前,先计算当前用户的聊天室顶部管理权限。
$roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);
return view('chat.frame', [
'room' => $room,
'user' => $user,
@@ -286,9 +316,14 @@ class ChatController extends Controller
'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),
]);
}
@@ -355,6 +390,10 @@ class ChatController extends Controller
$user = Auth::user();
$imagePayload = null;
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
return $response;
}
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) {
@@ -410,6 +449,10 @@ class ChatController extends Controller
$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);
@@ -519,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) {
@@ -647,6 +679,7 @@ class ChatController extends Controller
} elseif ($user->user_level >= $superLevel) {
$title = '管理员';
}
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
return response()->json([
'status' => 'success',
@@ -657,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,
@@ -799,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([
@@ -865,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([
@@ -892,7 +914,8 @@ class ChatController extends Controller
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
* 需要当前在职职务拥有 room.announcement 权限,
* id=1 站长始终允许操作。
*
* @param int $id 房间ID
*/
@@ -901,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();
// 广播公告更新到所有在线用户
@@ -922,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' => '',
@@ -964,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' => '不能给自己送花哦~']);
@@ -981,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;
@@ -1004,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' => '',
@@ -1278,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' => '不能给自己赠送哦~']);
@@ -1289,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([
@@ -1315,7 +1361,7 @@ class ChatController extends Controller
// 接收方收到消息时,在右下角弹到账提示卡片。
'toast_notification' => [
'title' => '💰 赠金币到账',
'message' => "<b>{$sender->username}</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰',
'color' => '#f59e0b',
'duration' => 8000,
@@ -1336,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),
];
}
}
@@ -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(),
]);
}
}
+69 -17
View File
@@ -40,6 +40,10 @@ 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) {
@@ -70,15 +74,16 @@ class HorseRaceController extends Controller
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
// 计算实时赔率
$horses = $race->horses ?? [];
$horsesWithBets = array_map(function ($horse) use ($horsePools, $oddsMap) {
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
$odds = $horsePool > 0 ? ($oddsMap[$horse['id']] ?? null) : 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,
];
@@ -150,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' => '无效的马匹编号。']);
@@ -178,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(
@@ -244,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;
}
}
@@ -263,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'));
}
}
+84 -23
View File
@@ -4,7 +4,8 @@
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - config():读取当前职务的默认礼包数量与份数
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
@@ -23,9 +24,11 @@ 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;
@@ -39,11 +42,11 @@ use Illuminate\Support\Facades\DB;
*/
class RedPacketController extends Controller
{
/** 礼包固定总数量 */
private const TOTAL_AMOUNT = 8888;
/** 礼包默认总数量 */
private const DEFAULT_TOTAL_AMOUNT = 8888;
/** 礼包固定份数 */
private const TOTAL_COUNT = 10;
/** 礼包默认份数 */
private const DEFAULT_TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 300;
@@ -54,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
@@ -75,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)
@@ -93,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' ? '经验' : '金币';
@@ -104,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',
@@ -137,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.';'
@@ -150,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' => '',
@@ -165,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}",
]);
}
@@ -388,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,
];
}
}
+75 -20
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'],
@@ -133,9 +185,11 @@ class ShopController extends Controller
];
// 赠礼消息文案(改成"为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(
@@ -156,9 +210,6 @@ class ShopController extends Controller
}
} else {
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = '';
@@ -171,12 +222,16 @@ class ShopController extends Controller
$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(
@@ -197,7 +252,7 @@ class ShopController extends Controller
}
// 返回最新金币余额
$response['jjb'] = Auth::user()->fresh()->jjb;
$response['jjb'] = $user->fresh()->jjb;
return response()->json($response);
}
+185 -166
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,22 +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)
*/
@@ -69,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;
}
// 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接
@@ -109,10 +147,23 @@ class UserController extends Controller
->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;
@@ -162,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)
*/
@@ -228,6 +327,55 @@ class UserController extends Controller
]);
}
/**
* 保存聊天室当日状态,并同步当前在线名单显示。
*/
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)
*/
@@ -262,7 +410,7 @@ class UserController extends Controller
/**
* 生成微信绑定代码
*/
public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse
public function generateWechatCode(Request $request): JsonResponse
{
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
@@ -282,7 +430,7 @@ class UserController extends Controller
/**
* 取消绑定微信
*/
public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse
public function unbindWechat(Request $request): JsonResponse
{
$user = \Illuminate\Support\Facades\Auth::user();
if (! $user) {
@@ -299,158 +447,29 @@ 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
private function canViewAssetNumbers(User $operator, User $targetUser): bool
{
$room = Room::findOrFail($roomId);
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
// 鉴权:操作者要是房间房主或达到所需等级
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 防误伤:不能操作等级 >= 自己的人
if ($targetUser->user_level >= $operator->user_level) {
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}"], 403);
}
return ['room' => $room, 'target' => $targetUser];
return $operator->id === $targetUser->id
|| (int) $operator->user_level >= (int) $targetUser->user_level;
}
/**
* 踢出房间 (对应 KILLUSER.ASP)
* 所需等级由 sysparam level_kick 配置
* 判断操作者是否可以免费查看指定资料信息。
*/
public function kick(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_kick', '踢出');
if ($result instanceof JsonResponse) {
return $result;
if ($operator->id === $targetUser->id) {
return true;
}
// 广播踢出事件
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
$superLevel = (int) Sysparam::getValue('superlevel', '100');
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
}
/**
* 禁言 (对应原版限制功能)
* 所需等级由 sysparam level_mute 配置
* 禁言信息存入 RedisTTL 到期自动解除
*/
public function mute(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
$duration = (int) $request->input('duration', 5);
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
if ($result instanceof JsonResponse) {
return $result;
}
// 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
'operator' => $operator->username,
'reason' => '管理员禁言',
'until' => now()->addMinutes($duration)->toDateTimeString(),
]));
// 广播禁言事件
broadcast(new UserMuted($roomId, $username, $duration));
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
}
/**
* 封号(禁止登录)
* 所需等级由 sysparam level_ban 配置
* 将用户等级设为 -1 表示封禁
*/
public function ban(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
if ($result instanceof JsonResponse) {
return $result;
}
// 封号:设置等级为 -1
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
}
/**
* 封IP(记录IP到黑名单并踢出)
* 所需等级由 sysparam level_banip 配置
*/
public function banIp(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
if ($result instanceof JsonResponse) {
return $result;
}
$targetIp = $result['target']->last_ip;
if ($targetIp) {
// 将IP加入 Redis 黑名单(永久)
Redis::sadd('banned_ips', $targetIp);
}
// 同时封号
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
$ipInfo = $targetIp ? "IP: {$targetIp}" : '(未记录IP';
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}"]);
return (int) $operator->user_level >= $superLevel;
}
}
@@ -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,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,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' => '月份格式不正确。',
];
}
}
@@ -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,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' => '要查看的信息类型无效。',
];
}
}
+2 -1
View File
@@ -62,7 +62,7 @@ class SendMessageRequest extends FormRequest
'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
'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex
'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
];
}
@@ -91,6 +91,7 @@ class SendMessageRequest extends FormRequest
'image.image' => '上传的文件必须是图片。',
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
'image.max' => '图片大小不能超过 6MB。',
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
'action.in' => '发言动作不合法,请重新选择。',
];
}
@@ -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' => '指定等级以上模式下必须填写最低等级',
];
}
}
@@ -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('等级经验阈值数量不能超过用户最高可达等级,请先提高最高等级或删除多余等级。');
}
}
};
}
}
+1 -1
View File
@@ -284,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} 点)",
+2 -2
View File
@@ -213,7 +213,7 @@ class CloseHorseRaceJob implements ShouldQueue
'to_user' => $username,
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#f59e0b',
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
@@ -252,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(),
];
+1 -1
View File
@@ -88,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(),
];
+1 -1
View File
@@ -78,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(),
];
+130 -98
View File
@@ -18,14 +18,18 @@ use App\Events\HolidayEventStarted;
use App\Events\MessageSent;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\HolidayEventScheduleService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:按模板配置生成节日福利发放批次并广播给前端。
*/
class TriggerHolidayEventJob implements ShouldQueue
{
use Queueable;
@@ -36,10 +40,12 @@ class TriggerHolidayEventJob implements ShouldQueue
public int $tries = 3;
/**
* @param HolidayEvent $event 节日活动记录
* @param HolidayEvent $event 节日活动模板
* @param bool $manual 是否为管理员手动立即触发
*/
public function __construct(
public readonly HolidayEvent $event,
public readonly bool $manual = false,
) {}
/**
@@ -47,119 +53,171 @@ class TriggerHolidayEventJob implements ShouldQueue
*/
public function handle(
ChatStateService $chatState,
UserCurrencyService $currency,
HolidayEventScheduleService $scheduleService,
): void {
$event = $this->event->fresh();
$run = $this->prepareRun($scheduleService);
// 防止重复触发
if (! $event || $event->status !== 'pending') {
if (! $run) {
return;
}
$now = now();
$expiresAt = $now->copy()->addMinutes($event->expire_minutes);
// 先标记为 active,防止并发重复触发
$updated = HolidayEvent::query()
->where('id', $event->id)
->where('status', 'pending')
->update([
'status' => 'active',
'triggered_at' => $now,
'expires_at' => $expiresAt,
]);
if (! $updated) {
return; // 已被其他进程触发
}
$event->refresh();
// 获取在线用户(满足 target_type 条件)
$onlineIds = $this->getEligibleOnlineUsers($event, $chatState);
// 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。
$onlineIds = $this->getEligibleOnlineUsers($run);
if (empty($onlineIds)) {
// 无合格在线用户,直接标记完成
$event->update(['status' => 'completed']);
$run->update(['status' => 'completed', 'audience_count' => 0]);
return;
}
// 按 max_claimants 限制人数
if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) {
// 按本批次快照中的 max_claimants 限制人数
if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) {
shuffle($onlineIds);
$onlineIds = array_slice($onlineIds, 0, $event->max_claimants);
$onlineIds = array_slice($onlineIds, 0, $run->max_claimants);
}
// 计算每人金额
$amounts = $this->distributeAmounts($event, count($onlineIds));
// 计算每人的待领取金额
$amounts = $this->distributeAmounts($run, count($onlineIds));
DB::transaction(function () use ($event, $onlineIds, $amounts, $now) {
DB::transaction(function () use ($run, $onlineIds, $amounts): void {
$claims = [];
foreach ($onlineIds as $i => $userId) {
$claims[] = [
'event_id' => $event->id,
'event_id' => $run->holiday_event_id,
'run_id' => $run->id,
'user_id' => $userId,
'amount' => $amounts[$i] ?? 0,
'claimed_at' => $now,
'claimed_at' => null,
];
}
// 批量插入领取记录
// 一次性生成本轮全部待领取记录,claimed_at 默认为 null。
HolidayClaim::insert($claims);
$run->update(['audience_count' => count($claims)]);
});
// 广播全房间 WebSocket 事件
broadcast(new HolidayEventStarted($event->refresh()));
// 广播本轮发放批次,前端将基于 run_id 领取。
broadcast(new HolidayEventStarted($run->fresh()));
// 向聊天室追加系统消息(写入 Redis + 落库)
$this->pushSystemMessage($event, count($onlineIds), $chatState);
// 处理重复活动(计算下次触发时间)
$this->scheduleNextRepeat($event);
// 向聊天室追加系统公告,提醒用户点击弹窗领取。
$this->pushSystemMessage($run, count($onlineIds), $chatState);
}
/**
* 获取满足条件的在线用户 ID 列表
* 生成批次并推进模板到下一次触发时间
*/
private function prepareRun(HolidayEventScheduleService $scheduleService): ?HolidayEventRun
{
/** @var HolidayEventRun|null $run */
$run = DB::transaction(function () use ($scheduleService): ?HolidayEventRun {
/** @var HolidayEvent|null $event */
$event = HolidayEvent::query()
->whereKey($this->event->id)
->lockForUpdate()
->first();
if (! $event || ! $event->enabled || $event->status === 'cancelled') {
return null;
}
$now = now();
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
if (! $this->manual) {
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) {
return null;
}
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
$event->update([
'send_at' => $nextSendAt,
'status' => $nextSendAt ? 'pending' : 'completed',
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
'claimed_count' => 0,
'claimed_amount' => 0,
]);
} else {
// 手动立即触发只更新最后触发信息,不改动既有 send_at 锚点。
$event->update([
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
]);
}
return HolidayEventRun::query()->create([
'holiday_event_id' => $event->id,
'event_name' => $event->name,
'event_description' => $event->description,
'total_amount' => $event->total_amount,
'max_claimants' => $event->max_claimants,
'distribute_type' => $event->distribute_type,
'min_amount' => $event->min_amount,
'max_amount' => $event->max_amount,
'fixed_amount' => $event->fixed_amount,
'target_type' => $event->target_type,
'target_value' => $event->target_value,
'repeat_type' => $event->repeat_type,
'scheduled_for' => $scheduledFor,
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
'status' => 'active',
'audience_count' => 0,
'claimed_count' => 0,
'claimed_amount' => 0,
]);
});
return $run;
}
/**
* 获取满足当前批次条件的在线用户 ID 列表。
*
* @return array<int>
*/
private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array
private function getEligibleOnlineUsers(HolidayEventRun $run): array
{
try {
$key = 'room:1:users';
$users = Redis::hgetall($key);
$users = Redis::hgetall('room:1:users');
if (empty($users)) {
return [];
}
$usernames = array_keys($users);
// 根据 user_id 从 Redis value 或数据库查出 ID
$ids = [];
$fallbacks = [];
$fallbackUsernames = [];
foreach ($users as $username => $jsonInfo) {
$info = json_decode($jsonInfo, true);
if (isset($info['user_id'])) {
$ids[] = (int) $info['user_id'];
} else {
$fallbacks[] = $username;
continue;
}
$fallbackUsernames[] = $username;
}
if (! empty($fallbacks)) {
$dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all();
$ids = array_merge($ids, $dbIds);
if (! empty($fallbackUsernames)) {
$fallbackIds = User::query()
->whereIn('username', $fallbackUsernames)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
$ids = array_merge($ids, $fallbackIds);
}
$ids = array_values(array_unique($ids));
// 根据 target_type 过滤
return match ($event->target_type) {
// 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。
return match ($run->target_type) {
'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(),
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($event->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
default => $ids,
};
} catch (\Throwable) {
@@ -172,23 +230,23 @@ class TriggerHolidayEventJob implements ShouldQueue
*
* @return array<int>
*/
private function distributeAmounts(HolidayEvent $event, int $count): array
private function distributeAmounts(HolidayEventRun $run, int $count): array
{
if ($count <= 0) {
return [];
}
if ($event->distribute_type === 'fixed') {
// 定额模式:每人相同金额
$amount = $event->fixed_amount ?? (int) floor($event->total_amount / $count);
if ($run->distribute_type === 'fixed') {
// 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。
$amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count);
return array_fill(0, $count, $amount);
}
// 随机模式二倍均值算法
$total = $event->total_amount;
$min = max(1, $event->min_amount ?? 1);
$max = $event->max_amount ?? (int) ceil($total * 2 / $count);
// 随机模式沿用二倍均值算法,保证总金额恰好发完。
$total = $run->total_amount;
$min = max(1, $run->min_amount ?? 1);
$max = $run->max_amount ?? (int) ceil($total * 2 / $count);
$amounts = [];
$remaining = $total;
@@ -210,10 +268,10 @@ class TriggerHolidayEventJob implements ShouldQueue
/**
* 向聊天室推送系统公告消息并写入 Redis + 落库。
*/
private function pushSystemMessage(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void
private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void
{
$typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配';
$content = "🎊 【{$event->name}】节日福利开始啦!总奖池 💰".number_format($event->total_amount)
$typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配';
$content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount)
." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!";
$msg = [
@@ -232,30 +290,4 @@ class TriggerHolidayEventJob implements ShouldQueue
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 处理重复活动:计算下次触发时间并重置状态。
*/
private function scheduleNextRepeat(HolidayEvent $event): void
{
$nextSendAt = match ($event->repeat_type) {
'daily' => $event->send_at->copy()->addDay(),
'weekly' => $event->send_at->copy()->addWeek(),
'monthly' => $event->send_at->copy()->addMonth(),
default => null, // 'once' 或 'cron' 不自动重复
};
if ($nextSendAt) {
$event->update([
'status' => 'pending',
'send_at' => $nextSendAt,
'triggered_at' => null,
'expires_at' => null,
'claimed_count' => 0,
'claimed_amount' => 0,
]);
} else {
$event->update(['status' => 'completed']);
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:每日签到记录模型。
*
* 保存用户每天签到、连续天数、命中奖励规则与实际奖励快照。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装每日签到流水的字段类型与用户、规则关联。
*/
class DailySignIn extends Model
{
/** @use HasFactory<\Database\Factories\DailySignInFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'room_id',
'is_makeup',
'makeup_purchase_id',
'makeup_at',
'sign_in_date',
'streak_days',
'reward_rule_id',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_makeup' => 'boolean',
'makeup_purchase_id' => 'integer',
'makeup_at' => 'datetime',
'sign_in_date' => 'date',
'streak_days' => 'integer',
'reward_rule_id' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
];
}
/**
* 关联:签到记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 关联:本次签到命中的奖励规则。
*/
public function rewardRule(): BelongsTo
{
return $this->belongsTo(SignInRewardRule::class, 'reward_rule_id');
}
}
+12
View File
@@ -15,12 +15,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录用户在某个节日福利发放批次中的领取状态。
*/
class HolidayClaim extends Model
{
public $timestamps = false;
protected $fillable = [
'event_id',
'run_id',
'user_id',
'amount',
'claimed_at',
@@ -45,6 +49,14 @@ class HolidayClaim extends Model
return $this->belongsTo(HolidayEvent::class, 'event_id');
}
/**
* 关联所属发放批次。
*/
public function run(): BelongsTo
{
return $this->belongsTo(HolidayEventRun::class, 'run_id');
}
/**
* 关联领取用户。
*/
+24 -12
View File
@@ -13,11 +13,17 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:定义节日福利模板及其调度配置。
*/
class HolidayEvent extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
@@ -31,6 +37,12 @@ class HolidayEvent extends Model
'expire_minutes',
'repeat_type',
'cron_expr',
'schedule_month',
'schedule_day',
'schedule_time',
'duration_days',
'daily_occurrences',
'occurrence_interval_minutes',
'target_type',
'target_value',
'status',
@@ -57,6 +69,11 @@ class HolidayEvent extends Model
'max_amount' => 'integer',
'fixed_amount' => 'integer',
'expire_minutes' => 'integer',
'schedule_month' => 'integer',
'schedule_day' => 'integer',
'duration_days' => 'integer',
'daily_occurrences' => 'integer',
'occurrence_interval_minutes' => 'integer',
'claimed_count' => 'integer',
'claimed_amount' => 'integer',
];
@@ -71,25 +88,19 @@ class HolidayEvent extends Model
}
/**
* 判断活动是否在领取有效期内
* 本模板对应的所有发放批次
*/
public function isClaimable(): bool
public function runs(): HasMany
{
return $this->status === 'active'
&& $this->expires_at
&& $this->expires_at->isFuture();
return $this->hasMany(HolidayEventRun::class, 'holiday_event_id');
}
/**
* 判断是否还有剩余领取名额
* 判断模板是否使用年度节日调度
*/
public function hasQuota(): bool
public function usesYearlySchedule(): bool
{
if ($this->max_claimants === 0) {
return true; // 不限人数
}
return $this->claimed_count < $this->max_claimants;
return $this->repeat_type === 'yearly';
}
/**
@@ -100,6 +111,7 @@ class HolidayEvent extends Model
return static::query()
->where('status', 'pending')
->where('enabled', true)
->whereNotNull('send_at')
->where('send_at', '<=', now())
->get();
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* 文件功能:节日福利发放批次模型
*
* 记录节日福利模板的每一次实际发放批次,
* 承担领取有效期、批次统计与历史追踪职责。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:封装节日福利单次发放批次的时间、状态和领取统计。
*/
class HolidayEventRun extends Model
{
/** @use HasFactory<\Database\Factories\HolidayEventRunFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'holiday_event_id',
'event_name',
'event_description',
'total_amount',
'max_claimants',
'distribute_type',
'min_amount',
'max_amount',
'fixed_amount',
'target_type',
'target_value',
'repeat_type',
'scheduled_for',
'triggered_at',
'expires_at',
'status',
'audience_count',
'claimed_count',
'claimed_amount',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scheduled_for' => 'datetime',
'triggered_at' => 'datetime',
'expires_at' => 'datetime',
'total_amount' => 'integer',
'max_claimants' => 'integer',
'min_amount' => 'integer',
'max_amount' => 'integer',
'fixed_amount' => 'integer',
'audience_count' => 'integer',
'claimed_count' => 'integer',
'claimed_amount' => 'integer',
];
}
/**
* 关联所属节日福利模板。
*/
public function holidayEvent(): BelongsTo
{
return $this->belongsTo(HolidayEvent::class, 'holiday_event_id');
}
/**
* 关联本批次的领取记录。
*/
public function claims(): HasMany
{
return $this->hasMany(HolidayClaim::class, 'run_id');
}
/**
* 判断当前批次是否仍处于可领取状态。
*/
public function isClaimable(): bool
{
return $this->status === 'active'
&& $this->expires_at !== null
&& $this->expires_at->isFuture();
}
/**
* 查询需要被自动收尾的批次。
*/
public static function pendingToExpire(): \Illuminate\Database\Eloquent\Collection
{
return static::query()
->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->get();
}
}
+26 -1
View File
@@ -3,7 +3,8 @@
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 聊天室顶部管理权限通过 permissions JSON 字段配置
*
* @author ChatRoom Laravel
*
@@ -35,6 +36,9 @@ class Position extends Model
'daily_reward_limit',
'recipient_daily_limit',
'sort_order',
'permissions',
'red_packet_amount',
'red_packet_count',
];
/**
@@ -50,6 +54,9 @@ class Position extends Model
'daily_reward_limit' => 'integer',
'recipient_daily_limit' => 'integer',
'sort_order' => 'integer',
'permissions' => 'array',
'red_packet_amount' => 'integer',
'red_packet_count' => 'integer',
];
}
@@ -123,6 +130,24 @@ class Position extends Model
return $this->currentCount() >= $this->max_persons;
}
/**
* 判断当前职务是否拥有指定权限码。
*/
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions ?? [], true);
}
/**
* 返回当前职务的权限码列表。
*
* @return list<string>
*/
public function permissionCodes(): array
{
return array_values($this->permissions ?? []);
}
/**
* 查询范围:按位阶降序
*/
+1
View File
@@ -58,6 +58,7 @@ class PositionAuthorityLog extends Model
'warn' => '警告',
'kick' => '踢出',
'mute' => '禁言',
'ban' => '封号',
'banip' => '封锁IP',
'other' => '其他',
];
+10
View File
@@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class ShopItem extends Model
{
public const TYPE_SIGN_REPAIR = 'sign_repair';
protected $table = 'shop_items';
protected $fillable = [
@@ -41,6 +43,14 @@ class ShopItem extends Model
return $this->type === 'auto_fishing';
}
/**
* 是否为签到补签卡。
*/
public function isSignRepairCard(): bool
{
return $this->type === self::TYPE_SIGN_REPAIR;
}
/**
* 是否为特效类商品(instant durationslug once_ week_ 开头)
*/
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:每日签到奖励规则模型。
*
* 通过连续签到天数门槛配置金币、经验、魅力与身份徽章奖励。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:封装签到奖励规则字段、类型转换与命中签到记录关联。
*/
class SignInRewardRule extends Model
{
/** @use HasFactory<\Database\Factories\SignInRewardRuleFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
'is_enabled',
'sort_order',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'streak_days' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
'identity_duration_days' => 'integer',
'is_enabled' => 'boolean',
'sort_order' => 'integer',
];
}
/**
* 关联:命中过该规则的签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'reward_rule_id');
}
}
+5 -1
View File
@@ -15,6 +15,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
*/
class Sysparam extends Model
{
/** @var string 表名 */
@@ -74,7 +77,8 @@ class Sysparam extends Model
// 不超过最大等级
$maxLevel = (int) static::getValue('maxlevel', '99');
return min($level, $maxLevel);
// 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
return max(1, min($level, $maxLevel));
}
/**
+50
View File
@@ -21,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/**
* 类功能:封装聊天室用户资料、会员状态与职务关联等核心数据行为。
*/
class User extends Authenticatable
{
use HasFactory, Notifiable;
@@ -51,6 +54,8 @@ class User extends Authenticatable
'custom_join_effect',
'custom_leave_effect',
'chat_preferences',
'daily_status_key',
'daily_status_expires_at',
'user_level',
'inviter_id',
'room_id',
@@ -104,6 +109,8 @@ class User extends Authenticatable
'q3_time' => 'datetime',
'has_received_new_gift' => 'boolean',
'chat_preferences' => 'array',
'active_decorations' => 'array',
'daily_status_expires_at' => 'datetime',
];
}
@@ -246,6 +253,49 @@ class User extends Authenticatable
return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id');
}
/**
* 关联:用户每日签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
}
/**
* 关联:用户全部身份徽章。
*/
public function identityBadges(): HasMany
{
return $this->hasMany(UserIdentityBadge::class, 'user_id')->latest('acquired_at');
}
/**
* 关联:用户当前启用的签到身份徽章。
*/
public function currentSignInIdentityBadge(): HasOne
{
return $this->hasOne(UserIdentityBadge::class, 'user_id')
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
->where('is_active', true)
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->latestOfMany('acquired_at');
}
/**
* 获取当前签到身份徽章辅助方法。
*/
public function currentSignInIdentity(): ?UserIdentityBadge
{
if ($this->relationLoaded('currentSignInIdentityBadge')) {
return $this->getRelation('currentSignInIdentityBadge');
}
return $this->currentSignInIdentityBadge()->first();
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:用户身份徽章模型。
*
* 管理用户从签到等来源获得的当前身份展示标识。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户身份徽章字段、类型转换与用户关联。
*/
class UserIdentityBadge extends Model
{
/** @use HasFactory<\Database\Factories\UserIdentityBadgeFactory> */
use HasFactory;
public const SOURCE_SIGN_IN = 'sign_in';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'source',
'badge_code',
'badge_name',
'badge_icon',
'badge_color',
'acquired_at',
'expires_at',
'is_active',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'acquired_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
/**
* 关联:身份徽章所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+35
View File
@@ -45,6 +45,9 @@ class AppServiceProvider extends ServiceProvider
// 注册登录入口限流器,阻断爆破和批量注册滥用。
$this->registerAuthRateLimiters();
// 注册聊天室高频动作限流器,避免消息、购买与特效广播被脚本刷爆。
$this->registerChatActionRateLimiters();
// 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史)
Event::subscribe(SaveMarriageSystemMessage::class);
@@ -133,4 +136,36 @@ class AppServiceProvider extends ServiceProvider
return implode('|', [$scene, $username, $request->ip()]);
}
/**
* 注册聊天室内高频动作限流器。
*/
private function registerChatActionRateLimiters(): void
{
RateLimiter::for('chat-send', function (Request $request): Limit {
return Limit::perMinute(40)
->by($this->buildChatActionRateLimitKey($request, 'chat-send'));
});
RateLimiter::for('chat-shop-buy', function (Request $request): Limit {
return Limit::perMinute(20)
->by($this->buildChatActionRateLimitKey($request, 'chat-shop-buy'));
});
RateLimiter::for('chat-effect', function (Request $request): Limit {
return Limit::perMinute(6)
->by($this->buildChatActionRateLimitKey($request, 'chat-effect'));
});
}
/**
* 构造聊天室动作限流键,按场景、用户与房间隔离计数。
*/
private function buildChatActionRateLimitKey(Request $request, string $scene): string
{
$userId = (string) ($request->user()?->id ?? 'guest');
$roomId = (string) ($request->route('id') ?? $request->input('room_id', 'global'));
return implode('|', [$scene, $userId, $roomId, $request->ip()]);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* 文件功能:节日福利年度调度校验规则
*
* 负责校验 yearly 模式下的月//时间、多天与多轮次组合是否合法。
*/
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* 类功能:校验节日福利年度调度配置的组合合法性。
*/
class HolidayEventScheduleRule implements DataAwareRule, ValidationRule
{
/**
* 当前请求的全部字段。
*
* @var array<string, mixed>
*/
protected array $data = [];
/**
* 注入待校验的完整数据集。
*
* @param array<string, mixed> $data
*/
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
/**
* 运行年度调度规则校验。
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$repeatType = (string) ($this->data['repeat_type'] ?? $value ?? '');
if ($repeatType !== 'yearly') {
return;
}
$month = (int) ($this->data['schedule_month'] ?? 0);
$day = (int) ($this->data['schedule_day'] ?? 0);
$time = (string) ($this->data['schedule_time'] ?? '');
$durationDays = (int) ($this->data['duration_days'] ?? 0);
$dailyOccurrences = (int) ($this->data['daily_occurrences'] ?? 0);
$intervalMinutes = $this->data['occurrence_interval_minutes'];
if ($month < 1 || $month > 12) {
$fail('年度节日模式必须设置有效的触发月份。');
}
if ($day < 1 || $day > 31) {
$fail('年度节日模式必须设置有效的触发日期。');
}
if ($month > 0 && $day > 0 && ! checkdate($month, $day, 2024)) {
$fail('所选月份和日期不是有效的公历节日日期。');
}
if (! preg_match('/^\d{2}:\d{2}$/', $time)) {
$fail('年度节日模式必须设置首轮开始时间。');
return;
}
if ($durationDays < 1 || $durationDays > 31) {
$fail('连续发放天数必须在 1 到 31 天之间。');
}
if ($dailyOccurrences < 1 || $dailyOccurrences > 24) {
$fail('每天发送次数必须在 1 到 24 次之间。');
}
if ($dailyOccurrences > 1 && ((int) $intervalMinutes) < 1) {
$fail('同一天多轮发送时,必须设置大于 0 的间隔分钟数。');
}
[$hour, $minute] = array_map('intval', explode(':', $time));
$startMinutes = $hour * 60 + $minute;
$lastOffsetMinutes = max(0, ($dailyOccurrences - 1) * (int) ($intervalMinutes ?? 0));
if ($startMinutes + $lastOffsetMinutes >= 1440) {
$fail('同一天多轮发送的最后一轮不能跨到次日,请缩短间隔或减少次数。');
}
}
}
+15 -7
View File
@@ -88,22 +88,30 @@ class AiChatService
// 动态获取由 guide 页面提取出的最新纯文本规则
$guideRulesText = $this->getDynamicGuideRules();
// 动态获取金币福利上限,告知 AI 自行决定金额
$maxGold = (int) \App\Models\Sysparam::getValue('chatbot_max_gold', '5000');
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
背景与基础
- 本聊天室脱胎于原军中的经典“和平聊吧”,创始人是“流星”。
- 当前版本基于前沿的 PHP Laravel 12 重构。
聊天室历史与创始人故事(你必须熟记,被问到时能娓娓道来)
本聊天室的创始人是"流星"(网名),以下是他亲手写下的回忆:
2000年流星接触网络,就喜欢上了这个能结识朋友、学习知识的世界。懵懵懂懂,花开花落,走过了近六个春秋,网络终于接受了他这个朋友。六年里,从申请成功第一个 EMAIL 就感觉好极了,到后来有了自己的网站,却越来越发现,在网络这博大的知识海洋里,自己原来是那么的渺小。
流星的第一个个人网站在【上海人人网】申请,当时是个网盲,按照向导做了个简单页面。也许正是这个不成熟的"网站",给了他无尽的兴趣与力量,让他直至走进军营,仍一如既往地追求。后来在【中国人】申请了第二个个人网站,又在网上认识了一些喜欢做网页的大虾,在他们的支持下申请了第一个空间。曾尝试自己修改"江湖"程序,虽一再坚持,最终没有成功,唯一的成绩是修改了一个留言版。
由于溺爱网络,导致高考落榜。走进军营让自己释然,而上天总眷恋执着追求的人——入伍后一次偶然机会,他又重新接触了网络,开始了新的网络生活。
2004年5月,流星架设了第一个军网聊天室【梦幻之城】,版本很古老,却让他很开心。经过无数次测试与修改,第二个军网聊天室【风之恋】向大家敞开了大门。笑傲江湖就是在这时与他相识。由于硬件条件有限,笑傲江湖的【和平聊吧】在第一次更新版本时,就用了流星修改的【风之恋】。
2006年9月综合信息网普及十六军基层时,经笑傲江湖推荐、由梦蝶提供服务器,流星的聊天室在军旅之巅"北京军区"再次与大家相见。他共3次改版聊天室,一次次失败,又一次次战胜失败。第三次是当时感觉最成功的版本!现在使用的是流星重新用 PHP 语言 Laravel 12 框架重写的版本,于 2026年2月26日正式上线。
【聊天室最新版官方知识库手册(你必须掌握这些作为客服知识库)】
$guideRulesText
【发金币福利特权】
每天每个用户只能向你讨要一次金币福利100-5000枚随机)。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
每天每个用户只能向你讨要一次金币福利。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD:金额](例如:[ACTION:GIVE_GOLD:888])。金额由你根据聊天内容、对方态度和你的心情自行决定,最低 100 枚,最高 {$maxGold} 枚。讨得真诚、聊得投缘的可以多给些,敷衍了事的少给些
系统程序看到这个符号后会自动为用户发放你指定的金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
【交流要求】
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
+3 -2
View File
@@ -220,8 +220,9 @@ class ChatStateService
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
// 只清理普通进出播报,避免误删新人礼包公告和 AI 小班长新人欢迎。
$welcomeKind = $msg['welcome_kind'] ?? 'entry_broadcast';
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username && $welcomeKind === 'entry_broadcast') {
continue;
}
$filtered[] = $msgJson;
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* 文件功能:聊天室在线用户展示数据服务
* 负责统一拼装聊天室在线名单、Presence 频道与 Redis 在线状态使用的用户载荷。
*/
namespace App\Services;
use App\Models\Sysparam;
use App\Models\User;
use App\Support\ChatDailyStatusCatalog;
/**
* 类功能:统一生成聊天室在线用户与身份展示相关载荷。
*/
class ChatUserPresenceService
{
/**
* 构建聊天室在线用户载荷。
*
* @return array<string, mixed>
*/
public function build(User $user): array
{
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$user->loadMissing(['activePosition.position.department', 'vipLevel']);
$position = $user->activePosition?->position;
$payload = [
'id' => $user->id,
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'user_level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'headface_url' => $user->headfaceUrl,
'headfaceUrl' => $user->headfaceUrl,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $position?->icon ?? '',
'position_name' => $position?->name ?? '',
'department_name' => $position?->department?->name ?? '',
];
$activeStatus = $this->currentDailyStatus($user);
if ($activeStatus !== null) {
$payload['daily_status_key'] = $activeStatus['key'];
$payload['daily_status_label'] = $activeStatus['label'];
$payload['daily_status_icon'] = $activeStatus['icon'];
$payload['daily_status_group'] = $activeStatus['group'];
$payload['daily_status_expires_at'] = $activeStatus['expires_at'];
}
$signIdentity = $user->currentSignInIdentity();
if ($signIdentity !== null) {
$payload['sign_identity_key'] = $signIdentity->badge_code;
$payload['sign_identity_label'] = $signIdentity->badge_name;
$payload['sign_identity_icon'] = $signIdentity->badge_icon ?? '✅';
$payload['sign_identity_color'] = $signIdentity->badge_color ?? '#0f766e';
$payload['sign_identity_expires_at'] = $signIdentity->expires_at?->toIso8601String();
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
}
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
if (! empty($decorations['avatar_frame'])) {
$payload['avatar_frame'] = $decorations['avatar_frame'];
}
if (! empty($decorations['name_color'])) {
$payload['name_color'] = $decorations['name_color'];
}
return $payload;
}
/**
* 构建用户部门、职务与会员展示信息。
*
* @return array{
* department_name: string,
* position_icon: string,
* position_name: string,
* vip_icon: string,
* vip_name: string,
* vip_label: string,
* inline: string
* }
*/
public function buildIdentitySummary(User $user): array
{
$user->loadMissing(['activePosition.position.department', 'vipLevel']);
$position = $user->activePosition?->position;
$departmentName = $position?->department?->name ?? '无部门';
$positionIcon = $position?->icon ?? '';
$positionName = $position?->name ?? '无职务';
$vipIcon = $user->vipIcon();
$vipName = $user->vipName() ?: '普通会员';
$vipLabel = trim($vipIcon.' '.$vipName);
$positionLabel = trim($positionIcon.' '.$positionName);
return [
'department_name' => $departmentName,
'position_icon' => $positionIcon,
'position_name' => $positionName,
'vip_icon' => $vipIcon,
'vip_name' => $vipName,
'vip_label' => $vipLabel,
'inline' => "部门 {$departmentName} · 职务 {$positionLabel} · 会员 {$vipLabel}",
];
}
/**
* 读取用户当前仍然有效的当日状态。
*
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
*/
public function currentDailyStatus(User $user): ?array
{
return ChatDailyStatusCatalog::resolveActiveStatus(
$user->daily_status_key,
$user->daily_status_expires_at,
);
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
/**
* 文件功能:用户个人装扮统一管理服务
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
*
* 当前支持的装扮槽位(存储在 active_decorations key):
* - bubble : 消息气泡边框样式
* - name_color : 昵称颜色效果
* - avatar_frame: 头像装饰边框
* - text_color : 消息文字颜色特效
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\ShopItem;
use App\Models\User;
use App\Models\UserPurchase;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
*/
class DecorationService
{
/**
* 商店商品 type 装扮槽位映射。
* 以后新增装扮类型,在此加一行即可。
*/
private const TYPE_TO_SLOT = [
'msg_bubble' => 'bubble',
'msg_name_color' => 'name_color',
'avatar_frame' => 'avatar_frame',
'msg_text_color' => 'text_color',
];
/**
* @param UserCurrencyService $currencyService 统一积分变更服务
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
*
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
*
* @param User $user 购买用户
* @param ShopItem $item 装扮商品
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
*/
public function purchase(User $user, ShopItem $item): array
{
// 根据商品类型映射到对应槽位
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
if (! $slot) {
return ['ok' => false, 'message' => '未知装扮类型'];
}
// 校验金币余额
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
}
// 计算过期时间(至少 1 天)
$days = max(1, (int) ($item->duration_days ?? 1));
$expiresAt = Carbon::now()->addDays($days);
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
$source = match ($item->type) {
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
default => CurrencySource::MSG_DECORATION_BUY,
};
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
// ① 通过统一积分服务扣除金币(含流水记录)
$this->currencyService->change(
$user, 'gold', -$item->price, $source,
"购买装扮:{$item->name}{$days}天)"
);
// ② 写入购买记录(用于后台统计与用户回溯)
UserPurchase::create([
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $expiresAt,
]);
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
$decorations = $this->getActiveDecorations($user);
$decorations[$slot] = [
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
];
$user->active_decorations = $decorations;
$user->save();
});
// 重新读取最新余额,避免缓存脏数据
$balanceAfter = (int) $user->fresh()->jjb;
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
'balance_after' => $balanceAfter,
'slot' => $slot,
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
];
}
/**
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
*
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
* 如果清理了过期数据会自动写回 users.active_decorations。
*
* @param User $user 目标用户
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
*/
public function getActiveDecorations(User $user): array
{
$decorations = $user->active_decorations ?? [];
// 非数组(null 或格式异常)直接返回空
if (! is_array($decorations)) {
return [];
}
$now = Carbon::now();
$changed = false;
foreach ($decorations as $slot => $data) {
// 格式校验:必须包含 expires_at 字段
if (! is_array($data) || empty($data['expires_at'])) {
unset($decorations[$slot]);
$changed = true;
continue;
}
// 解析过期时间并与当前时间比较
try {
$expiresAt = Carbon::parse($data['expires_at']);
if ($expiresAt->isPast()) {
unset($decorations[$slot]);
$changed = true;
}
} catch (\Exception $e) {
// 时间格式异常也视为无效,清理之
unset($decorations[$slot]);
$changed = true;
}
}
// 有过期项被清理时写回数据库
if ($changed) {
$user->active_decorations = $decorations;
$user->save();
}
return $decorations;
}
/**
* 获取消息广播 payload 需要携带的装扮字段。
*
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
*
* @param User $user 消息发送者
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
*/
public function getDecorationsForMessage(User $user): array
{
$decorations = $this->getActiveDecorations($user);
$result = [];
if (! empty($decorations['bubble']['style'])) {
$result['msg_bubble'] = $decorations['bubble']['style'];
}
if (! empty($decorations['name_color']['style'])) {
$result['msg_name_color'] = $decorations['name_color']['style'];
}
if (! empty($decorations['text_color']['style'])) {
$result['msg_text_color'] = $decorations['text_color']['style'];
}
if (! empty($decorations['avatar_frame']['style'])) {
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
}
return $result;
}
/**
* 获取在线用户 Presence 载荷需要携带的装扮字段。
*
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
* 气泡样式仅作用于消息,不需要在用户列表中展示。
*
* @param User $user 目标用户
* @return array{avatar_frame?:string, name_color?:string}
*/
public function getDecorationsForPresence(User $user): array
{
$decorations = $this->getActiveDecorations($user);
$result = [];
if (! empty($decorations['avatar_frame']['style'])) {
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
}
if (! empty($decorations['name_color']['style'])) {
$result['name_color'] = $decorations['name_color']['style'];
}
return $result;
}
}
@@ -0,0 +1,129 @@
<?php
/**
* 文件功能:节日福利调度计算服务
*
* 统一负责节日福利模板的首次触发时间与下一次触发时间推导,
* 支持普通重复模式与年度节日高级调度。
*/
namespace App\Services;
use App\Models\HolidayEvent;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
/**
* 类功能:计算节日福利模板的下一次触发时间。
*/
class HolidayEventScheduleService
{
/**
* 根据表单配置计算模板的下一次触发时间。
*
* @param array<string, mixed> $data
*/
public function resolveNextConfiguredSendAt(array $data, ?CarbonInterface $reference = null): CarbonImmutable
{
$referenceTime = CarbonImmutable::instance($reference ?? now());
if (($data['repeat_type'] ?? 'once') !== 'yearly') {
return CarbonImmutable::parse((string) $data['send_at']);
}
return $this->findNextYearlyOccurrence($data, $referenceTime);
}
/**
* 计算模板在一次自动触发后的下一次 send_at。
*/
public function advanceAfterTrigger(HolidayEvent $event): ?CarbonImmutable
{
if ($event->send_at === null) {
return null;
}
$currentSendAt = CarbonImmutable::instance($event->send_at);
return match ($event->repeat_type) {
'daily' => $currentSendAt->addDay(),
'weekly' => $currentSendAt->addWeek(),
'monthly' => $currentSendAt->addMonth(),
'yearly' => $this->findNextYearlyOccurrence($this->extractYearlyConfig($event), $currentSendAt->addSecond()),
default => null,
};
}
/**
* 查找年度节日配置在参考时间之后的下一次触发点。
*
* @param array<string, mixed> $data
*/
public function findNextYearlyOccurrence(array $data, CarbonInterface $reference): CarbonImmutable
{
$referenceTime = CarbonImmutable::instance($reference);
foreach ([$referenceTime->year, $referenceTime->year + 1, $referenceTime->year + 2] as $year) {
foreach ($this->buildYearlyOccurrencesForYear($data, $year) as $occurrence) {
if ($occurrence->greaterThanOrEqualTo($referenceTime)) {
return $occurrence;
}
}
}
return $this->buildYearlyOccurrencesForYear($data, $referenceTime->year + 3)[0];
}
/**
* 构造指定年份内的全部年度节日触发点。
*
* @param array<string, mixed> $data
* @return array<int, CarbonImmutable>
*/
public function buildYearlyOccurrencesForYear(array $data, int $year): array
{
[$hour, $minute] = array_map('intval', explode(':', (string) $data['schedule_time']));
$baseDate = CarbonImmutable::create(
$year,
(int) $data['schedule_month'],
(int) $data['schedule_day'],
$hour,
$minute,
0,
config('app.timezone')
);
$occurrences = [];
$durationDays = max(1, (int) ($data['duration_days'] ?? 1));
$dailyOccurrences = max(1, (int) ($data['daily_occurrences'] ?? 1));
$intervalMinutes = (int) ($data['occurrence_interval_minutes'] ?? 0);
for ($dayIndex = 0; $dayIndex < $durationDays; $dayIndex++) {
$dayStart = $baseDate->addDays($dayIndex);
for ($occurrenceIndex = 0; $occurrenceIndex < $dailyOccurrences; $occurrenceIndex++) {
$occurrences[] = $dayStart->addMinutes($intervalMinutes * $occurrenceIndex);
}
}
return $occurrences;
}
/**
* 从模板模型中提取年度节日调度字段。
*
* @return array<string, mixed>
*/
private function extractYearlyConfig(HolidayEvent $event): array
{
return [
'schedule_month' => $event->schedule_month,
'schedule_day' => $event->schedule_day,
'schedule_time' => $event->schedule_time,
'duration_days' => $event->duration_days,
'daily_occurrences' => $event->daily_occurrences,
'occurrence_interval_minutes' => $event->occurrence_interval_minutes,
];
}
}
@@ -0,0 +1,86 @@
<?php
/**
* 文件功能:职务权限判断服务
* 负责把当前用户的在职职务权限转换为统一布尔判断,
* 供控制器和 Blade 视图复用。
*/
namespace App\Services;
use App\Models\User;
use App\Support\PositionPermissionRegistry;
/**
* 类功能:统一提供用户职务权限读取与校验方法。
*/
class PositionPermissionService
{
/**
* 返回当前用户拥有的全部聊天室权限码。
*
* 规则:所有用户都仅按当前在职职务的 permissions 生效。
*
* @return list<string>
*/
public function permissionsForUser(?User $user): array
{
if (! $user) {
return [];
}
$position = $user->activePosition?->position;
if (! $position) {
return [];
}
return array_values(array_intersect(
PositionPermissionRegistry::codes(),
$position->permissions ?? [],
));
}
/**
* 返回当前用户全部权限的布尔映射表。
*
* @return array<string, bool>
*/
public function permissionMapForUser(?User $user): array
{
$permissionMap = array_fill_keys(PositionPermissionRegistry::codes(), false);
foreach ($this->permissionsForUser($user) as $permission) {
$permissionMap[$permission] = true;
}
return $permissionMap;
}
/**
* 判断用户是否拥有指定权限码。
*/
public function hasPermission(?User $user, string $permission): bool
{
if (! in_array($permission, PositionPermissionRegistry::codes(), true)) {
return false;
}
return in_array($permission, $this->permissionsForUser($user), true);
}
/**
* 判断用户是否至少拥有一项指定权限。
*
* @param list<string> $permissions
*/
public function hasAnyPermission(?User $user, array $permissions): bool
{
foreach ($permissions as $permission) {
if ($this->hasPermission($user, $permission)) {
return true;
}
}
return false;
}
}
+68 -4
View File
@@ -16,16 +16,29 @@ use Illuminate\Support\Facades\DB;
class ShopService
{
/**
* @param DecorationService $decorationService 装扮服务
*/
public function __construct(
private readonly DecorationService $decorationService,
) {}
/**
* 购买商品入口:扣金币、按类型分发处理
*
* @return array{ok:bool, message:string, play_effect?:string}
* @return array{ok:bool, message:string, play_effect?:string, quantity?:int, total_price?:int}
*/
public function buyItem(User $user, ShopItem $item): array
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
{
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) {
return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
}
$totalPrice = $item->price * $quantity;
// 校验金币是否足够
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
if ($user->jjb < $totalPrice) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
}
return match ($item->type) {
@@ -34,6 +47,12 @@ class ShopService
'one_time' => $this->buyRenameCard($user, $item),
'ring' => $this->buyRing($user, $item),
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
// ── 个人装扮购买(委托给 DecorationService)───────────────
'msg_bubble' => $this->decorationService->purchase($user, $item),
'msg_name_color' => $this->decorationService->purchase($user, $item),
'msg_text_color' => $this->decorationService->purchase($user, $item),
'avatar_frame' => $this->decorationService->purchase($user, $item),
default => ['ok' => false, 'message' => '未知商品类型'],
};
}
@@ -243,6 +262,39 @@ class ShopService
];
}
/**
* 购买签到补签卡:扣金币并写入可消耗背包记录。
*
* @return array{ok:bool, message:string, quantity:int, total_price:int}
*/
public function buySignRepairCard(User $user, ShopItem $item, int $quantity = 1): array
{
$quantity = max(1, min(99, $quantity));
$totalPrice = $item->price * $quantity;
DB::transaction(function () use ($user, $item, $quantity, $totalPrice): void {
// 补签卡支持一次购买多张,扣款必须按总价执行。
$user->decrement('jjb', $totalPrice);
for ($i = 0; $i < $quantity; $i++) {
// 每张补签卡保留独立购买记录,补签时逐张消耗。
UserPurchase::create([
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
]);
}
});
return [
'ok' => true,
'message' => "🗓️ {$item->name} × {$quantity} 购买成功!可在签到日历中补签漏掉的日期。",
'quantity' => $quantity,
'total_price' => $totalPrice,
];
}
/**
* 获取用户当前激活的改名卡(是否持有未用改名卡)
*/
@@ -254,6 +306,18 @@ class ShopService
->exists();
}
/**
* 获取用户可用补签卡数量。
*/
public function getSignRepairCardCount(User $user): int
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($q) => $q->where('type', ShopItem::TYPE_SIGN_REPAIR))
->count();
}
/**
* 购买自动钓鱼卡:手刺金币,写入 active 记录,到期时间 = 现在 + duration_minutes。
*
+521
View File
@@ -0,0 +1,521 @@
<?php
/**
* 文件功能:每日签到服务。
*
* 负责查询签到状态、领取每日签到奖励、防止重复领取、刷新签到身份徽章。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\DailySignIn;
use App\Models\ShopItem;
use App\Models\SignInRewardRule;
use App\Models\User;
use App\Models\UserIdentityBadge;
use App\Models\UserPurchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 类功能:封装每日签到状态查询与领取奖励的核心业务流程。
*/
class SignInService
{
/**
* 构造每日签到服务。
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 查询用户当前签到状态与今日可领取预览。
*
* @return array{
* signed_today: bool,
* can_claim: bool,
* current_streak_days: int,
* claimable_streak_days: int,
* today_sign_in: DailySignIn|null,
* latest_sign_in: DailySignIn|null,
* matched_rule: SignInRewardRule|null,
* current_identity: UserIdentityBadge|null
* }
*/
public function status(User $user): array
{
$today = today();
$todaySignIn = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', $today)
->first();
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
$signedToday = $todaySignIn !== null;
$claimableStreakDays = $signedToday
? (int) $todaySignIn->streak_days
: $this->calculateNextStreakDays($latestSignIn, $today);
return [
'signed_today' => $signedToday,
'can_claim' => ! $signedToday,
'current_streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
'claimable_streak_days' => $claimableStreakDays,
'today_sign_in' => $todaySignIn,
'latest_sign_in' => $latestSignIn,
'matched_rule' => $this->matchRewardRule($claimableStreakDays),
'current_identity' => $user->currentSignInIdentity(),
];
}
/**
* 查询指定月份的签到日历状态。
*
* @return array<string, mixed>
*/
public function calendar(User $user, ?string $month = null): array
{
$monthStart = $month
? Carbon::createFromFormat('Y-m', $month)->startOfMonth()
: today()->startOfMonth();
$monthEnd = $monthStart->copy()->endOfMonth();
$today = today();
// 补签卡只允许修补当前自然月,查看历史月份时不再开放补签入口。
$isCurrentMonth = $monthStart->isSameMonth($today);
$signIns = DailySignIn::query()
->where('user_id', $user->id)
->whereBetween('sign_in_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->get()
->keyBy(fn (DailySignIn $signIn): string => $signIn->sign_in_date->toDateString());
$days = [];
for ($date = $monthStart->copy(); $date->lte($monthEnd); $date->addDay()) {
$dateKey = $date->toDateString();
$signIn = $signIns->get($dateKey);
$isFuture = $date->gt($today);
$days[] = [
'date' => $dateKey,
'day' => (int) $date->day,
'weekday' => (int) $date->dayOfWeek,
'signed' => $signIn !== null,
'is_today' => $date->isSameDay($today),
'is_future' => $isFuture,
'can_makeup' => $isCurrentMonth && $signIn === null && $date->lt($today),
'is_makeup' => (bool) ($signIn?->is_makeup ?? false),
'streak_days' => (int) ($signIn?->streak_days ?? 0),
'reward_text' => $signIn ? $this->buildRewardText($signIn) : '',
];
}
return [
'month' => $monthStart->format('Y-m'),
'month_label' => $monthStart->format('Y年n月'),
'prev_month' => $monthStart->copy()->subMonth()->format('Y-m'),
'next_month' => $monthStart->copy()->addMonth()->format('Y-m'),
'days' => $days,
'reward_rules' => $this->rewardRulePreview(),
'makeup_card_count' => $this->availableSignRepairCardCount($user),
'sign_repair_card_item' => $this->activeSignRepairCard()?->only(['id', 'name', 'slug', 'icon', 'price', 'description']),
];
}
/**
* 领取每日签到奖励。
*/
public function claim(User $user, ?int $roomId = null): DailySignIn
{
return DB::transaction(function () use ($user, $roomId): DailySignIn {
$today = today();
// 锁定用户行,将同一用户的并发签到串行化,避免“查无今日记录”时并发重复发奖。
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
$existingSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', $today)
->lockForUpdate()
->first();
if ($existingSignIn !== null) {
return $existingSignIn->load('rewardRule');
}
$latestSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', '<', $today)
->latest('sign_in_date')
->lockForUpdate()
->first();
$streakDays = $this->calculateNextStreakDays($latestSignIn, $today);
$rewardRule = $this->matchRewardRule($streakDays);
$dailySignIn = DailySignIn::query()->create([
'user_id' => $lockedUser->id,
'room_id' => $roomId,
'sign_in_date' => $today->toDateString(),
'streak_days' => $streakDays,
'reward_rule_id' => $rewardRule?->id,
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
'identity_badge_code' => $rewardRule?->identity_badge_code,
'identity_badge_name' => $rewardRule?->identity_badge_name,
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
'identity_badge_color' => $rewardRule?->identity_badge_color,
]);
$this->grantRewards($lockedUser, $dailySignIn, $roomId);
$this->refreshSignInIdentityBadge($lockedUser, $rewardRule, $streakDays);
return $dailySignIn->load('rewardRule');
});
}
/**
* 使用补签卡补签指定历史日期。
*/
public function makeup(User $user, string $targetDate, ?int $roomId = null): DailySignIn
{
return DB::transaction(function () use ($user, $targetDate, $roomId): DailySignIn {
$date = Carbon::parse($targetDate)->startOfDay();
if ($date->greaterThanOrEqualTo(today())) {
throw ValidationException::withMessages(['target_date' => '只能补签今天之前的漏签日期。']);
}
// 后端再次限制补签月份,避免绕过前端日历直接提交历史月份。
if (! $date->isSameMonth(today())) {
throw ValidationException::withMessages(['target_date' => '补签卡只能补签本月的未签到日期。']);
}
// 锁定用户行,保证补签卡消耗和签到记录创建不会并发重复执行。
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
$existingSignIn = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', $date)
->lockForUpdate()
->first();
if ($existingSignIn !== null) {
throw ValidationException::withMessages(['target_date' => '这一天已经签到过,不能重复补签。']);
}
$repairCard = $this->nextAvailableSignRepairCard($lockedUser);
if ($repairCard === null) {
throw ValidationException::withMessages(['target_date' => '没有可用补签卡,请先购买补签卡。']);
}
$latestBeforeTarget = DailySignIn::query()
->where('user_id', $lockedUser->id)
->whereDate('sign_in_date', '<', $date)
->latest('sign_in_date')
->lockForUpdate()
->first();
$streakDays = $this->calculateNextStreakDays($latestBeforeTarget, $date);
$dailySignIn = DailySignIn::query()->create([
'user_id' => $lockedUser->id,
'room_id' => $roomId,
'is_makeup' => true,
'makeup_purchase_id' => $repairCard->id,
'makeup_at' => now(),
'sign_in_date' => $date->toDateString(),
'streak_days' => $streakDays,
'reward_rule_id' => null,
'gold_reward' => 0,
'exp_reward' => 0,
'charm_reward' => 0,
'identity_badge_code' => null,
'identity_badge_name' => null,
'identity_badge_icon' => null,
'identity_badge_color' => null,
]);
// 补签卡与补签记录必须在同一事务里完成状态流转。
$repairCard->update(['status' => 'used', 'used_at' => now()]);
$this->recalculateFutureStreaks($lockedUser, $date);
$currentStreakDays = $this->latestStreakDays($lockedUser);
$rewardRule = $this->matchRewardRule($currentStreakDays);
$dailySignIn->update([
'reward_rule_id' => $rewardRule?->id,
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
'identity_badge_code' => $rewardRule?->identity_badge_code,
'identity_badge_name' => $rewardRule?->identity_badge_name,
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
'identity_badge_color' => $rewardRule?->identity_badge_color,
]);
$this->grantRewards(
$lockedUser,
$dailySignIn->fresh(),
$roomId,
"补签 {$date->toDateString()}:当前连续签到 {$currentStreakDays}"
);
$this->refreshIdentityFromLatestSignIn($lockedUser);
return $dailySignIn->fresh('rewardRule');
});
}
/**
* 计算下一次签到应获得的连续天数。
*/
private function calculateNextStreakDays(?DailySignIn $latestSignIn, ?Carbon $targetDate = null): int
{
$targetDate ??= today();
if ($latestSignIn === null) {
return 1;
}
$yesterday = $targetDate->copy()->subDay()->toDateString();
if ($latestSignIn->sign_in_date?->toDateString() === $yesterday) {
// 签到满一年后开启新周期,下一天重新从第 1 天计算。
if ((int) $latestSignIn->streak_days >= $this->daysInSignInYear($latestSignIn)) {
return 1;
}
return (int) $latestSignIn->streak_days + 1;
}
return 1;
}
/**
* 按签到记录所在年份计算当年天数,闰年为 366 天。
*/
private function daysInSignInYear(DailySignIn $dailySignIn): int
{
return $dailySignIn->sign_in_date?->isLeapYear() ? 366 : 365;
}
/**
* 匹配当前连续天数可用的最高奖励规则。
*/
private function matchRewardRule(int $streakDays): ?SignInRewardRule
{
return SignInRewardRule::query()
->where('is_enabled', true)
->where('streak_days', '<=', $streakDays)
->orderByDesc('streak_days')
->first();
}
/**
* 按签到记录快照发放金币、经验和魅力。
*/
private function grantRewards(User $user, DailySignIn $dailySignIn, ?int $roomId, ?string $remark = null): void
{
$remark ??= "每日签到:连续 {$dailySignIn->streak_days}";
$this->currencyService->change($user, 'gold', $dailySignIn->gold_reward, CurrencySource::SIGN_IN, $remark, $roomId);
$this->currencyService->change($user, 'exp', $dailySignIn->exp_reward, CurrencySource::SIGN_IN, $remark, $roomId);
$this->currencyService->change($user, 'charm', $dailySignIn->charm_reward, CurrencySource::SIGN_IN, $remark, $roomId);
}
/**
* 刷新用户当前签到身份徽章。
*/
private function refreshSignInIdentityBadge(User $user, ?SignInRewardRule $rewardRule, int $streakDays): void
{
UserIdentityBadge::query()
->where('user_id', $user->id)
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
->where('is_active', true)
->update(['is_active' => false]);
if ($rewardRule?->identity_badge_code === null || $rewardRule->identity_badge_name === null) {
return;
}
// 同一来源同一徽章唯一,重复命中时更新状态和连续天数快照即可。
UserIdentityBadge::query()->updateOrCreate(
[
'user_id' => $user->id,
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
'badge_code' => $rewardRule->identity_badge_code,
],
[
'badge_name' => $rewardRule->identity_badge_name,
'badge_icon' => $rewardRule->identity_badge_icon,
'badge_color' => $rewardRule->identity_badge_color,
'acquired_at' => now(),
// 身份有效期由后台规则控制,0 表示长期有效。
'expires_at' => $rewardRule->identity_duration_days > 0
? now()->addDays($rewardRule->identity_duration_days)
: null,
'is_active' => true,
'metadata' => [
'streak_days' => $streakDays,
'reward_rule_id' => $rewardRule->id,
],
]
);
}
/**
* 按日期向后重算连续天数,补齐断点后让后续日历展示同步变化。
*/
private function recalculateFutureStreaks(User $user, Carbon $fromDate): void
{
$records = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', '>=', $fromDate)
->orderBy('sign_in_date')
->lockForUpdate()
->get();
$previous = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', '<', $fromDate)
->latest('sign_in_date')
->lockForUpdate()
->first();
$previousDate = $previous?->sign_in_date;
$previousStreak = (int) ($previous?->streak_days ?? 0);
$records->each(function (DailySignIn $record) use (&$previousDate, &$previousStreak): void {
$streakDays = $previousDate?->copy()->addDay()->toDateString() === $record->sign_in_date?->toDateString()
? $previousStreak + 1
: 1;
if ((int) $record->streak_days !== $streakDays) {
// 只重算连续天数快照,不追溯变更已发放奖励流水。
$record->update(['streak_days' => $streakDays]);
}
$previousDate = $record->sign_in_date;
$previousStreak = $streakDays;
});
}
/**
* 使用最新签到记录刷新当前签到身份。
*/
private function refreshIdentityFromLatestSignIn(User $user): void
{
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
if ($latestSignIn === null) {
return;
}
$this->refreshSignInIdentityBadge($user, $this->matchRewardRule((int) $latestSignIn->streak_days), (int) $latestSignIn->streak_days);
}
/**
* 查询用户最新签到记录的连续天数。
*/
private function latestStreakDays(User $user): int
{
return (int) DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->value('streak_days');
}
/**
* 查询用户下一张可消耗的补签卡。
*/
private function nextAvailableSignRepairCard(User $user): ?UserPurchase
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
->oldest()
->lockForUpdate()
->first();
}
/**
* 查询用户可用补签卡数量。
*/
private function availableSignRepairCardCount(User $user): int
{
return UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
->count();
}
/**
* 查询当前上架的补签卡商品。
*/
private function activeSignRepairCard(): ?ShopItem
{
return ShopItem::query()
->where('type', ShopItem::TYPE_SIGN_REPAIR)
->where('is_active', true)
->orderBy('sort_order')
->first();
}
/**
* 查询启用中的签到奖励档位,供前台展示目标奖励。
*
* @return array<int, array<string, mixed>>
*/
private function rewardRulePreview(): array
{
return SignInRewardRule::query()
->where('is_enabled', true)
->orderBy('streak_days')
->get()
->map(fn (SignInRewardRule $rule): array => [
'streak_days' => (int) $rule->streak_days,
'gold_reward' => (int) $rule->gold_reward,
'exp_reward' => (int) $rule->exp_reward,
'charm_reward' => (int) $rule->charm_reward,
'identity_badge_name' => $rule->identity_badge_name,
'identity_badge_icon' => $rule->identity_badge_icon,
'identity_badge_color' => $rule->identity_badge_color,
'identity_duration_days' => (int) $rule->identity_duration_days,
])
->all();
}
/**
* 生成日历内展示的签到奖励文本。
*/
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);
}
}
+131 -9
View File
@@ -16,9 +16,14 @@ namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* 类功能:统一处理用户经验、金币与魅力变更,并记录对应流水。
*/
class UserCurrencyService
{
/**
@@ -85,6 +90,58 @@ class UserCurrencyService
});
}
/**
* 在余额充足时扣除用户流通金币,并返回扣费后的金币余额。
*
* @param User $user 被扣费用户
* @param int $amount 扣费金币数量
* @param CurrencySource $source 扣费来源
* @param string $remark 扣费备注
* @param int|null $roomId 所在房间 ID(可选)
*/
public function deductGoldIfEnough(
User $user,
int $amount,
CurrencySource $source,
string $remark = '',
?int $roomId = null,
): ?int {
if ($amount <= 0) {
return (int) $user->jjb;
}
return DB::transaction(function () use ($user, $amount, $source, $remark, $roomId): ?int {
// 付费查看属于真实消费,先锁定用户行再判断余额,避免并发点击透支金币。
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
if ((int) $lockedUser->jjb < $amount) {
return null;
}
// 扣除流通金币后写入统一流水,方便后台统计与用户追溯消费来源。
$lockedUser->decrement('jjb', $amount);
$balanceAfter = (int) $lockedUser->fresh()->jjb;
UserCurrencyLog::create([
'user_id' => $lockedUser->id,
'username' => $lockedUser->username,
'currency' => 'gold',
'amount' => -$amount,
'balance_after' => $balanceAfter,
'source' => $source->value,
'remark' => $remark,
'room_id' => $roomId,
]);
$user->setAttribute('jjb', $balanceAfter);
return $balanceAfter;
}, attempts: 3);
}
/**
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
* 每位用户仍独立走事务,单人失败不影响其他人。
@@ -110,15 +167,75 @@ class UserCurrencyService
*/
public function activityStats(?string $date = null): Collection
{
$date = $date ?? today()->toDateString();
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
return UserCurrencyLog::query()
->whereDate('created_at', $date)
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
->groupBy('source', 'currency')
->orderBy('currency')
->orderByRaw('ABS(SUM(amount)) DESC')
->get();
return Cache::remember("currency_stats:activity:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
return UserCurrencyLog::query()
->where('created_at', '>=', $rangeStart)
->where('created_at', '<', $rangeEnd)
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
->groupBy('source', 'currency')
->orderBy('currency')
->orderByRaw('ABS(SUM(amount)) DESC')
->get();
});
}
/**
* 查询某日三种货币的净流通量(流入、流出、净增)。
*
* @param string|null $date 日期字符串如 '2026-02-28',默认今日
* @return array<string, array{in:int, out:int, net:int}>
*/
public function netFlowStats(?string $date = null): array
{
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
$rows = Cache::remember("currency_stats:net_flow:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
return UserCurrencyLog::query()
->where('created_at', '>=', $rangeStart)
->where('created_at', '<', $rangeEnd)
->selectRaw('
currency,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as total_in,
ABS(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)) as total_out,
SUM(amount) as net_total
')
->groupBy('currency')
->get()
->keyBy('currency');
});
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$row = $rows->get($currency);
$netFlow[$currency] = [
'in' => (int) ($row->total_in ?? 0),
'out' => (int) ($row->total_out ?? 0),
'net' => (int) ($row->net_total ?? 0),
];
}
return $netFlow;
}
/**
* 解析统计查询的日期边界,统一复用缓存 key 与时间范围。
*
* @param string|null $date 日期字符串如 '2026-02-28'
* @return array{0:string, 1:CarbonImmutable, 2:CarbonImmutable}
*/
private function statsDateBounds(?string $date = null): array
{
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
return [
$statsDate->toDateString(),
$statsDate,
$statsDate->addDay(),
];
}
/**
@@ -165,12 +282,17 @@ class UserCurrencyService
* @param int $userId 用户 ID
* @param string|null $currency null 时返回所有货币类型
* @param int $days 查询最近多少天
* @param string|null $direction income=收入 / expense=支出 / null=全部
* @param array<int, string> $sources 来源 source 值列表,为空时不过滤
*/
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
public function userLogs(int $userId, ?string $currency = null, int $days = 7, ?string $direction = null, array $sources = []): Collection
{
return UserCurrencyLog::query()
->where('user_id', $userId)
->when($currency, fn ($q) => $q->where('currency', $currency))
->when($direction === 'income', fn ($q) => $q->where('amount', '>', 0))
->when($direction === 'expense', fn ($q) => $q->where('amount', '<', 0))
->when($sources !== [], fn ($q) => $q->whereIn('source', $sources))
->where('created_at', '>=', now()->subDays($days))
->orderByDesc('created_at')
->limit(200)
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* 文件功能:聊天室动态内容净化工具
*
* 统一处理用户输入在系统消息、公告与商城赠言中的 HTML 转义,避免动态文本注入到前端 innerHTML。
*/
namespace App\Support;
/**
* 类功能:提供聊天室系统文案中用户可控字段的安全转义能力。
*/
class ChatContentSanitizer
{
/**
* 将用户可控文本规整为可安全拼入受控 HTML 模板的字符串。
*/
public static function htmlText(?string $value): string
{
return e(trim((string) $value));
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* 文件功能:聊天室当日状态目录
* 负责集中维护聊天室状态分组、文案、图标与激活状态解析逻辑。
*/
namespace App\Support;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class ChatDailyStatusCatalog
{
/**
* 固定状态目录。
*
* @return array<int, array{group: string, items: array<int, array{key: string, label: string, icon: string}>}>
*/
public static function groupedOptions(): array
{
return [
[
'group' => '心情想法',
'items' => [
['key' => 'feeling_great', 'label' => '美滋滋', 'icon' => '🙂'],
['key' => 'heartbroken', 'label' => '裂开', 'icon' => '💔'],
['key' => 'lucky_fish', 'label' => '求锦鲤', 'icon' => '🐟'],
['key' => 'waiting_sun', 'label' => '等天晴', 'icon' => '☀️'],
['key' => 'tired', 'label' => '疲惫', 'icon' => '😮‍💨'],
['key' => 'dazed', 'label' => '发呆', 'icon' => '💭'],
['key' => 'charging', 'label' => '冲', 'icon' => '🚀'],
['key' => 'emo', 'label' => 'emo', 'icon' => '🥀'],
['key' => 'overthinking', 'label' => '胡思乱想', 'icon' => '☁️'],
['key' => 'energetic', 'label' => '元气满满', 'icon' => '✨'],
['key' => 'bot', 'label' => 'bot', 'icon' => '🤖'],
],
],
[
'group' => '工作学习',
'items' => [
['key' => 'working_hard', 'label' => '搬砖', 'icon' => '🧱'],
['key' => 'studying', 'label' => '沉迷学习', 'icon' => '📚'],
['key' => 'busy', 'label' => '忙', 'icon' => '🙆'],
['key' => 'slacking', 'label' => '摸鱼', 'icon' => '🐟'],
['key' => 'business_trip', 'label' => '出差', 'icon' => '✈️'],
['key' => 'running_home', 'label' => '飞奔回家', 'icon' => '🏃'],
['key' => 'do_not_disturb', 'label' => '勿扰模式', 'icon' => '🌙'],
],
],
[
'group' => '活动',
'items' => [
['key' => 'wandering', 'label' => '浪', 'icon' => '🌊'],
['key' => 'clocking_in', 'label' => '打卡', 'icon' => '✌️'],
['key' => 'exercising', 'label' => '运动', 'icon' => '🏃‍♂️'],
['key' => 'coffee', 'label' => '喝咖啡', 'icon' => '☕'],
['key' => 'milk_tea', 'label' => '喝奶茶', 'icon' => '🧋'],
['key' => 'eating', 'label' => '干饭', 'icon' => '🍚'],
['key' => 'parenting', 'label' => '带娃', 'icon' => '🧒'],
['key' => 'save_world', 'label' => '拯救世界', 'icon' => '🦸'],
['key' => 'selfie', 'label' => '自拍', 'icon' => '🤳'],
],
],
[
'group' => '休息',
'items' => [
['key' => 'retreat', 'label' => '闭关', 'icon' => '🧘'],
['key' => 'staying_home', 'label' => '宅', 'icon' => '🛋️'],
['key' => 'sleeping', 'label' => '睡觉', 'icon' => '💤'],
['key' => 'cat_time', 'label' => '吸猫', 'icon' => '🐈'],
['key' => 'walk_dog', 'label' => '遛狗', 'icon' => '🐕'],
['key' => 'gaming', 'label' => '玩游戏', 'icon' => '🎮'],
['key' => 'listening_music', 'label' => '听歌', 'icon' => '🎧'],
],
],
];
}
/**
* 返回全部合法状态键。
*
* @return array<int, string>
*/
public static function keys(): array
{
return array_values(array_map(
static fn (array $item): string => $item['key'],
self::flatOptions()
));
}
/**
* 根据状态键查找对应配置。
*
* @return array{key: string, label: string, icon: string, group: string}|null
*/
public static function find(?string $key): ?array
{
if ($key === null || $key === '') {
return null;
}
foreach (self::flatOptions() as $item) {
if ($item['key'] === $key) {
return $item;
}
}
return null;
}
/**
* 解析当前仍处于有效期内的状态数据。
*
* @param CarbonInterface|string|null $expiresAt 到期时间
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
*/
public static function resolveActiveStatus(?string $key, CarbonInterface|string|null $expiresAt): ?array
{
$option = self::find($key);
if ($option === null) {
return null;
}
$expiresAtCarbon = self::normalizeExpiresAt($expiresAt);
if ($expiresAtCarbon === null || $expiresAtCarbon->isPast()) {
return null;
}
return [
'key' => $option['key'],
'label' => $option['label'],
'icon' => $option['icon'],
'group' => $option['group'],
'expires_at' => $expiresAtCarbon->toIso8601String(),
];
}
/**
* 将分组目录整理为扁平列表,便于校验与查找。
*
* @return array<int, array{key: string, label: string, icon: string, group: string}>
*/
private static function flatOptions(): array
{
$flat = [];
foreach (self::groupedOptions() as $group) {
foreach ($group['items'] as $item) {
$flat[] = [
'key' => $item['key'],
'label' => $item['label'],
'icon' => $item['icon'],
'group' => $group['group'],
];
}
}
return $flat;
}
/**
* 规整状态到期时间为 Carbon 对象。
*
* @param CarbonInterface|string|null $expiresAt 原始到期时间
*/
private static function normalizeExpiresAt(CarbonInterface|string|null $expiresAt): ?CarbonInterface
{
if ($expiresAt instanceof CarbonInterface) {
return $expiresAt;
}
if ($expiresAt === null || $expiresAt === '') {
return null;
}
return Carbon::parse($expiresAt);
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* 文件功能:职务权限码注册表
* 统一维护聊天室顶部管理菜单可配置的全部权限码、
* 中文标题、说明文案与默认分配规则,便于后续继续扩展。
*/
namespace App\Support;
/**
* 类功能:集中定义职务权限元数据与默认权限策略。
*/
class PositionPermissionRegistry
{
/**
* 房间公告权限。
*/
public const ROOM_ANNOUNCEMENT = 'room.announcement';
/**
* 房间公屏讲话权限。
*/
public const ROOM_PUBLIC_BROADCAST = 'room.public_broadcast';
/**
* 房间全员清屏权限。
*/
public const ROOM_CLEAR_SCREEN = 'room.clear_screen';
/**
* 礼包红包权限。
*/
public const ROOM_RED_PACKET = 'room.red_packet';
/**
* 百家乐买单活动权限。
*/
public const ROOM_BACCARAT_LOSS_COVER = 'room.baccarat_loss_cover';
/**
* 全屏特效权限。
*/
public const ROOM_FULLSCREEN_EFFECT = 'room.fullscreen_effect';
/**
* 职务奖励金币权限。
*/
public const ROOM_REWARD = 'room.reward';
/**
* 用户警告权限。
*/
public const USER_WARN = 'room.user_warn';
/**
* 用户踢出权限。
*/
public const USER_KICK = 'room.user_kick';
/**
* 用户禁言权限。
*/
public const USER_MUTE = 'room.user_mute';
/**
* 用户封号权限。
*/
public const USER_BAN = 'room.user_ban';
/**
* 用户封IP权限。
*/
public const USER_BANIP = 'room.user_banip';
/**
* 返回全部权限定义。
*
* @return array<string, array{group: string, label: string, description: string}>
*/
public static function definitions(): array
{
return [
self::ROOM_ANNOUNCEMENT => [
'group' => '聊天室管理',
'label' => '设置公告',
'description' => '允许修改聊天室顶部滚动公告。',
],
self::ROOM_PUBLIC_BROADCAST => [
'group' => '聊天室管理',
'label' => '公屏讲话',
'description' => '允许在聊天室内发送管理员公屏讲话。',
],
self::ROOM_CLEAR_SCREEN => [
'group' => '聊天室管理',
'label' => '全员清屏',
'description' => '允许清除当前房间所有人的普通聊天记录。',
],
self::ROOM_RED_PACKET => [
'group' => '活动管理',
'label' => '礼包红包',
'description' => '允许在聊天室内发出金币或经验礼包。',
],
self::ROOM_BACCARAT_LOSS_COVER => [
'group' => '活动管理',
'label' => '买单活动',
'description' => '允许创建和结束百家乐买单活动。',
],
self::ROOM_FULLSCREEN_EFFECT => [
'group' => '全屏特效',
'label' => '全屏特效',
'description' => '允许触发聊天室内全部全屏动画特效。',
],
self::ROOM_REWARD => [
'group' => '用户管理',
'label' => '奖励金币',
'description' => '允许在额度限制内向低位阶用户发放职务金币奖励。',
],
self::USER_WARN => [
'group' => '用户管理',
'label' => '警告用户',
'description' => '允许在用户名片内对低于自身职务的用户发送警告。',
],
self::USER_KICK => [
'group' => '用户管理',
'label' => '踢出用户',
'description' => '允许在用户名片内将低于自身职务的用户踢出当前聊天室。',
],
self::USER_MUTE => [
'group' => '用户管理',
'label' => '禁言用户',
'description' => '允许在用户名片内对低于自身职务的用户执行禁言。',
],
self::USER_BAN => [
'group' => '用户管理',
'label' => '封号用户',
'description' => '允许在用户名片内封禁低于自身职务的用户账号并强制下线。',
],
self::USER_BANIP => [
'group' => '用户管理',
'label' => '封IP',
'description' => '允许在用户名片内封禁低于自身职务的用户 IP,并查看管理员网络信息。',
],
];
}
/**
* 返回全部权限码列表。
*
* @return list<string>
*/
public static function codes(): array
{
return array_keys(self::definitions());
}
/**
* 返回权限码到中文标题的映射。
*
* @return array<string, string>
*/
public static function labelMap(): array
{
$labels = [];
foreach (self::definitions() as $code => $definition) {
$labels[$code] = $definition['label'];
}
return $labels;
}
/**
* 按分组返回权限定义,供后台表单渲染。
*
* @return array<string, array<string, array{label: string, description: string}>>
*/
public static function groupedDefinitions(): array
{
$grouped = [];
foreach (self::definitions() as $code => $definition) {
$grouped[$definition['group']][$code] = [
'label' => $definition['label'],
'description' => $definition['description'],
];
}
return $grouped;
}
/**
* 将权限码数组转换为中文标题列表。
*
* @param list<string> $codes
* @return list<string>
*/
public static function summaryLabels(array $codes): array
{
$labels = self::labelMap();
return array_values(array_map(
fn (string $code): string => $labels[$code] ?? $code,
array_values(array_intersect(self::codes(), $codes))
));
}
/**
* 根据职务等级返回默认权限。
*
* 默认策略:
* - Lv.60 及以上默认拥有「设置公告」
* - Lv.97 及以上默认拥有顶部管理菜单全部权限
*
* @return list<string>
*/
public static function defaultPermissionsForLevel(int $level): array
{
if ($level >= 97) {
return self::codes();
}
if ($level >= 60) {
return [self::ROOM_ANNOUNCEMENT];
}
return [];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* 文件功能:每日签到记录测试工厂。
*
* 用于测试中快速构造用户签到流水和连续签到奖励快照。
*/
namespace Database\Factories;
use App\Models\DailySignIn;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<DailySignIn>
*/
class DailySignInFactory extends Factory
{
/**
* 定义默认每日签到测试数据。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'room_id' => null,
'sign_in_date' => today(),
'streak_days' => 1,
'reward_rule_id' => null,
'gold_reward' => 0,
'exp_reward' => 0,
'charm_reward' => 0,
'identity_badge_code' => null,
'identity_badge_name' => null,
'identity_badge_icon' => null,
'identity_badge_color' => null,
];
}
}
@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:节日福利发放批次测试工厂
*
* 用于测试中快速创建已触发的节日福利批次与历史快照。
*/
namespace Database\Factories;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<HolidayEventRun>
*/
class HolidayEventRunFactory extends Factory
{
/**
* 定义默认批次测试数据。
*
* @return array<string, mixed>
*/
public function definition(): array
{
$event = HolidayEvent::query()->create([
'name' => '节日福利测试模板',
'description' => '用于测试的节日福利模板',
'total_amount' => 6000,
'max_claimants' => 10,
'distribute_type' => 'fixed',
'min_amount' => 100,
'max_amount' => 1000,
'fixed_amount' => 600,
'send_at' => now(),
'expire_minutes' => 30,
'repeat_type' => 'once',
'target_type' => 'all',
'status' => 'pending',
'enabled' => true,
]);
return [
'holiday_event_id' => $event->id,
'event_name' => $event->name,
'event_description' => $event->description,
'total_amount' => $event->total_amount,
'max_claimants' => $event->max_claimants,
'distribute_type' => $event->distribute_type,
'min_amount' => $event->min_amount,
'max_amount' => $event->max_amount,
'fixed_amount' => $event->fixed_amount,
'target_type' => $event->target_type,
'target_value' => $event->target_value,
'repeat_type' => $event->repeat_type,
'scheduled_for' => now(),
'triggered_at' => now(),
'expires_at' => now()->addMinutes(30),
'status' => 'active',
'audience_count' => 0,
'claimed_count' => 0,
'claimed_amount' => 0,
];
}
}
@@ -0,0 +1,40 @@
<?php
/**
* 文件功能:每日签到奖励规则测试工厂。
*
* 用于测试中快速构造不同连续天数门槛的签到奖励规则。
*/
namespace Database\Factories;
use App\Models\SignInRewardRule;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SignInRewardRule>
*/
class SignInRewardRuleFactory extends Factory
{
/**
* 定义默认签到奖励规则测试数据。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'streak_days' => fake()->unique()->numberBetween(1, 30),
'gold_reward' => 100,
'exp_reward' => 10,
'charm_reward' => 0,
'identity_badge_code' => null,
'identity_badge_name' => null,
'identity_badge_icon' => null,
'identity_badge_color' => '#0f766e',
'identity_duration_days' => 0,
'is_enabled' => true,
'sort_order' => 0,
];
}
}
@@ -0,0 +1,40 @@
<?php
/**
* 文件功能:用户身份徽章测试工厂。
*
* 用于测试中快速构造签到来源或其他来源的身份徽章。
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\UserIdentityBadge;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserIdentityBadge>
*/
class UserIdentityBadgeFactory extends Factory
{
/**
* 定义默认身份徽章测试数据。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
'badge_code' => 'sign_in_beginner',
'badge_name' => '签到新星',
'badge_icon' => null,
'badge_color' => '#0f766e',
'acquired_at' => now(),
'expires_at' => null,
'is_active' => true,
'metadata' => [],
];
}
}
@@ -45,13 +45,6 @@ return new class extends Migration
['alias' => 'jjb_per_heartbeat', 'body' => '1-3', 'guidetxt' => '💰 每次心跳金币奖励(支持固定值如"1"或范围如"1-5"0关闭)', 'created_at' => $now, 'updated_at' => $now],
// ── 管理操作权限等级 ──────────────────────────────────────
['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now],
// ── 随机事件 ──────────────────────────────────────────────
['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now],
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:为职务表补充聊天室权限字段
* 使用 JSON 数组保存可扩展的职务权限码,
* 并为现有职务按等级回填一份默认权限。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移:新增 permissions 字段并回填默认权限。
*/
public function up(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->json('permissions')->nullable()->comment('聊天室权限码 JSON 数组');
});
$positions = DB::table('positions')->select(['id', 'level'])->get();
foreach ($positions as $position) {
DB::table('positions')
->where('id', $position->id)
->update([
'permissions' => json_encode(
$this->defaultPermissionsForLevel((int) $position->level),
JSON_UNESCAPED_UNICODE
),
]);
}
}
/**
* 回滚迁移:删除 permissions 字段。
*/
public function down(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->dropColumn('permissions');
});
}
/**
* 按当前等级回填默认权限。
*
* @return list<string>
*/
private function defaultPermissionsForLevel(int $level): array
{
if ($level >= 97) {
return [
'room.announcement',
'room.public_broadcast',
'room.clear_screen',
'room.red_packet',
'room.baccarat_loss_cover',
'room.fullscreen_effect',
];
}
if ($level >= 60) {
return ['room.announcement'];
}
return [];
}
};
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:节日福利发放批次表迁移
*
* 将节日福利模板的每一次实际发放拆成独立批次,
* 用于支持按年复用、连续多天、多轮次领取与历史追踪。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 holiday_event_runs 表。
*/
public function up(): void
{
Schema::create('holiday_event_runs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('holiday_event_id')->comment('所属节日福利模板 ID');
// 批次快照:避免模板后续被修改后影响历史记录展示。
$table->string('event_name', 100)->comment('活动名称快照');
$table->text('event_description')->nullable()->comment('活动描述快照');
$table->unsignedInteger('total_amount')->comment('总金币奖池快照');
$table->unsignedInteger('max_claimants')->default(0)->comment('本批次最大领取人数(0=不限)');
$table->enum('distribute_type', ['random', 'fixed'])->default('random')->comment('分配方式快照');
$table->unsignedInteger('min_amount')->default(1)->comment('随机模式最低金额快照');
$table->unsignedInteger('max_amount')->nullable()->comment('随机模式单人上限快照');
$table->unsignedInteger('fixed_amount')->nullable()->comment('定额模式单人金额快照');
$table->enum('target_type', ['all', 'vip', 'level'])->default('all')->comment('目标用户类型快照');
$table->string('target_value', 50)->nullable()->comment('目标用户条件快照');
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])->default('once')->comment('模板重复方式快照');
// 批次状态与时间。
$table->dateTime('scheduled_for')->comment('本批次原定触发时间');
$table->dateTime('triggered_at')->comment('本批次实际触发时间');
$table->dateTime('expires_at')->nullable()->comment('本批次领取截止时间');
$table->enum('status', ['active', 'completed', 'expired', 'cancelled'])->default('active')->comment('批次状态');
// 统计信息。
$table->unsignedInteger('audience_count')->default(0)->comment('本批次待领取总人数');
$table->unsignedInteger('claimed_count')->default(0)->comment('本批次已领取人数');
$table->unsignedInteger('claimed_amount')->default(0)->comment('本批次已领取金币总额');
$table->timestamps();
$table->foreign('holiday_event_id')->references('id')->on('holiday_events')->cascadeOnDelete();
$table->index(['status', 'expires_at']);
$table->index(['holiday_event_id', 'scheduled_for']);
});
}
/**
* 回滚 holiday_event_runs 表。
*/
public function down(): void
{
Schema::dropIfExists('holiday_event_runs');
}
};
@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:节日福利模板年度调度字段迁移
*
* 为节日福利模板补充按年复用、连续多天与单日多轮发送所需的调度字段。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 升级 holiday_events 表结构。
*/
public function up(): void
{
Schema::table('holiday_events', function (Blueprint $table) {
$table->tinyInteger('schedule_month')->unsigned()->nullable()->after('repeat_type')->comment('年度节日触发月份');
$table->tinyInteger('schedule_day')->unsigned()->nullable()->after('schedule_month')->comment('年度节日触发日');
$table->string('schedule_time', 5)->nullable()->after('schedule_day')->comment('年度节日首轮开始时间(HH:ii');
$table->unsignedSmallInteger('duration_days')->default(1)->after('schedule_time')->comment('连续发放天数');
$table->unsignedSmallInteger('daily_occurrences')->default(1)->after('duration_days')->comment('每天触发次数');
$table->unsignedSmallInteger('occurrence_interval_minutes')->nullable()->after('daily_occurrences')->comment('同一天多轮发送间隔(分钟)');
});
Schema::table('holiday_events', function (Blueprint $table) {
// Laravel 12 / MySQL 变更列时需要显式保留原有默认值。
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])
->default('once')
->change();
});
}
/**
* 回滚 holiday_events 的年度调度字段。
*/
public function down(): void
{
Schema::table('holiday_events', function (Blueprint $table) {
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron'])
->default('once')
->change();
$table->dropColumn([
'schedule_month',
'schedule_day',
'schedule_time',
'duration_days',
'daily_occurrences',
'occurrence_interval_minutes',
]);
});
}
};
@@ -0,0 +1,119 @@
<?php
/**
* 文件功能:节日福利领取记录批次化迁移
*
* 将领取记录与具体发放批次绑定,并为历史待领取数据补建 run 记录。
*/
use Carbon\CarbonImmutable;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 升级 holiday_claims 表并回填历史批次。
*/
public function up(): void
{
Schema::table('holiday_claims', function (Blueprint $table) {
$table->unsignedBigInteger('run_id')->nullable()->after('event_id')->comment('关联发放批次 ID');
$table->index('event_id');
});
Schema::table('holiday_claims', function (Blueprint $table) {
$table->dropUnique('uq_holiday_event_user');
$table->dateTime('claimed_at')->nullable()->change();
$table->foreign('run_id')->references('id')->on('holiday_event_runs')->cascadeOnDelete();
$table->unique(['run_id', 'user_id'], 'uq_holiday_run_user');
});
$now = CarbonImmutable::now();
$events = DB::table('holiday_events')
->whereExists(function ($query) {
$query->selectRaw('1')
->from('holiday_claims')
->whereColumn('holiday_claims.event_id', 'holiday_events.id');
})
->orderBy('id')
->get();
foreach ($events as $event) {
$audienceCount = (int) DB::table('holiday_claims')->where('event_id', $event->id)->count();
$scheduledFor = $event->triggered_at ?? $event->send_at ?? $now->toDateTimeString();
$expiresAt = $event->expires_at;
$runStatus = $expiresAt !== null && CarbonImmutable::parse($expiresAt)->isPast()
? 'expired'
: 'active';
$runId = DB::table('holiday_event_runs')->insertGetId([
'holiday_event_id' => $event->id,
'event_name' => $event->name,
'event_description' => $event->description,
'total_amount' => $event->total_amount,
'max_claimants' => $event->max_claimants,
'distribute_type' => $event->distribute_type,
'min_amount' => $event->min_amount,
'max_amount' => $event->max_amount,
'fixed_amount' => $event->fixed_amount,
'target_type' => $event->target_type,
'target_value' => $event->target_value,
'repeat_type' => $event->repeat_type,
'scheduled_for' => $scheduledFor,
'triggered_at' => $event->triggered_at ?? $scheduledFor,
'expires_at' => $expiresAt,
'status' => $runStatus,
'audience_count' => $audienceCount,
'claimed_count' => 0,
'claimed_amount' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
// 旧结构下表里剩余记录全部表示“尚未领取”,统一迁移为未领取状态。
DB::table('holiday_claims')
->where('event_id', $event->id)
->update([
'run_id' => $runId,
'claimed_at' => null,
]);
if (in_array($event->repeat_type, ['daily', 'weekly', 'monthly'], true) && $event->send_at !== null) {
$currentSendAt = CarbonImmutable::parse($event->send_at);
$nextSendAt = match ($event->repeat_type) {
'daily' => $currentSendAt->addDay(),
'weekly' => $currentSendAt->addWeek(),
'monthly' => $currentSendAt->addMonth(),
default => $currentSendAt,
};
DB::table('holiday_events')
->where('id', $event->id)
->update([
'send_at' => $nextSendAt->toDateTimeString(),
'status' => 'pending',
]);
}
}
}
/**
* 回滚 holiday_claims 的批次化关联。
*/
public function down(): void
{
Schema::table('holiday_claims', function (Blueprint $table) {
$table->dropUnique('uq_holiday_run_user');
$table->dropForeign(['run_id']);
$table->dateTime('claimed_at')->nullable(false)->change();
$table->dropColumn('run_id');
$table->dropIndex(['event_id']);
$table->unique(['event_id', 'user_id'], 'uq_holiday_event_user');
});
}
};
@@ -0,0 +1,43 @@
<?php
/**
* 文件功能:为 users 表新增聊天室当日状态字段
* 支持记录用户当天选择的状态键与到期时间。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* users 表补充聊天室当日状态字段。
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('daily_status_key', 50)
->nullable()
->after('chat_preferences')
->comment('聊天室当日状态键');
$table->dateTime('daily_status_expires_at')
->nullable()
->after('daily_status_key')
->comment('聊天室当日状态到期时间');
});
}
/**
* 回滚 users 表中的聊天室当日状态字段。
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'daily_status_key',
'daily_status_expires_at',
]);
});
}
};
@@ -0,0 +1,185 @@
<?php
/**
* 文件功能:创建每日签到奖励规则表。
*
* 规则按连续签到天数配置奖励,签到服务会匹配小于等于当前连续天数的最高启用规则。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 类功能:维护 sign_in_reward_rules 表结构与回滚逻辑。
*/
return new class extends Migration
{
/**
* 创建每日签到奖励规则表。
*/
public function up(): void
{
Schema::create('sign_in_reward_rules', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('streak_days')->unique()->comment('连续签到天数门槛');
$table->unsignedInteger('gold_reward')->default(0)->comment('金币奖励');
$table->unsignedInteger('exp_reward')->default(0)->comment('经验奖励');
$table->unsignedInteger('charm_reward')->default(0)->comment('魅力奖励');
$table->string('identity_badge_code', 50)->nullable()->comment('身份徽章编码');
$table->string('identity_badge_name', 50)->nullable()->comment('身份徽章名称');
$table->string('identity_badge_icon', 120)->nullable()->comment('身份徽章图标');
$table->string('identity_badge_color', 20)->nullable()->comment('身份徽章文字颜色');
$table->unsignedSmallInteger('identity_duration_days')->default(0)->comment('身份有效天数,0表示永久');
$table->boolean('is_enabled')->default(true)->index()->comment('是否启用');
$table->unsignedInteger('sort_order')->default(0)->index()->comment('后台排序值');
$table->timestamps();
$table->index(['is_enabled', 'streak_days'], 'idx_sign_in_rules_enabled_streak');
});
DB::table('sign_in_reward_rules')->insert($this->defaultRules());
}
/**
* 回滚:删除每日签到奖励规则表。
*/
public function down(): void
{
Schema::dropIfExists('sign_in_reward_rules');
}
/**
* 默认连续签到奖励规则。
*
* @return array<int, array<string, mixed>>
*/
private function defaultRules(): array
{
$now = now();
return [
[
'streak_days' => 1,
'gold_reward' => 100,
'exp_reward' => 10,
'charm_reward' => 0,
'identity_badge_code' => 'sign_in_new_star',
'identity_badge_name' => '签到新星',
'identity_badge_icon' => '✅',
'identity_badge_color' => '#0f766e',
'identity_duration_days' => 7,
'is_enabled' => true,
'sort_order' => 1,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 3,
'gold_reward' => 180,
'exp_reward' => 18,
'charm_reward' => 1,
'identity_badge_code' => 'sign_in_spark',
'identity_badge_name' => '三日星火',
'identity_badge_icon' => '✨',
'identity_badge_color' => '#2563eb',
'identity_duration_days' => 10,
'is_enabled' => true,
'sort_order' => 3,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 7,
'gold_reward' => 350,
'exp_reward' => 35,
'charm_reward' => 3,
'identity_badge_code' => 'sign_in_flame',
'identity_badge_name' => '七日恒星',
'identity_badge_icon' => '🔥',
'identity_badge_color' => '#dc2626',
'identity_duration_days' => 30,
'is_enabled' => true,
'sort_order' => 7,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 14,
'gold_reward' => 600,
'exp_reward' => 60,
'charm_reward' => 5,
'identity_badge_code' => 'sign_in_moon_guard',
'identity_badge_name' => '半月守望',
'identity_badge_icon' => '🌙',
'identity_badge_color' => '#7c3aed',
'identity_duration_days' => 45,
'is_enabled' => true,
'sort_order' => 14,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 30,
'gold_reward' => 1200,
'exp_reward' => 120,
'charm_reward' => 8,
'identity_badge_code' => 'sign_in_month_master',
'identity_badge_name' => '月签达人',
'identity_badge_icon' => '🏆',
'identity_badge_color' => '#ca8a04',
'identity_duration_days' => 60,
'is_enabled' => true,
'sort_order' => 30,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 60,
'gold_reward' => 2200,
'exp_reward' => 220,
'charm_reward' => 14,
'identity_badge_code' => 'sign_in_diamond',
'identity_badge_name' => '双月钻冕',
'identity_badge_icon' => '💎',
'identity_badge_color' => '#0891b2',
'identity_duration_days' => 90,
'is_enabled' => true,
'sort_order' => 60,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 100,
'gold_reward' => 4000,
'exp_reward' => 400,
'charm_reward' => 25,
'identity_badge_code' => 'sign_in_legend',
'identity_badge_name' => '百日传奇',
'identity_badge_icon' => '👑',
'identity_badge_color' => '#9333ea',
'identity_duration_days' => 180,
'is_enabled' => true,
'sort_order' => 100,
'created_at' => $now,
'updated_at' => $now,
],
[
'streak_days' => 365,
'gold_reward' => 12000,
'exp_reward' => 1200,
'charm_reward' => 100,
'identity_badge_code' => 'sign_in_year_guardian',
'identity_badge_name' => '周年守护者',
'identity_badge_icon' => '🌟',
'identity_badge_color' => '#e11d48',
'identity_duration_days' => 0,
'is_enabled' => true,
'sort_order' => 365,
'created_at' => $now,
'updated_at' => $now,
],
];
}
};
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:创建用户身份徽章表。
*
* 用于记录用户通过签到等来源获得的身份展示信息,当前阶段只由签到服务刷新 sign_in 来源。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 类功能:维护 user_identity_badges 表结构与回滚逻辑。
*/
return new class extends Migration
{
/**
* 创建用户身份徽章表。
*/
public function up(): void
{
Schema::create('user_identity_badges', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->string('source', 30)->default('sign_in')->comment('徽章来源');
$table->string('badge_code', 50)->comment('徽章编码');
$table->string('badge_name', 50)->comment('徽章名称');
$table->string('badge_icon', 120)->nullable()->comment('徽章图标');
$table->string('badge_color', 20)->nullable()->comment('徽章文字颜色');
$table->timestamp('acquired_at')->nullable()->comment('获得时间');
$table->timestamp('expires_at')->nullable()->index()->comment('过期时间,空表示长期有效');
$table->boolean('is_active')->default(true)->index()->comment('是否当前启用');
$table->json('metadata')->nullable()->comment('扩展信息');
$table->timestamps();
$table->unique(['user_id', 'source', 'badge_code'], 'uniq_user_identity_badge');
$table->index(['user_id', 'source', 'is_active'], 'idx_user_identity_badges_current');
});
}
/**
* 回滚:删除用户身份徽章表。
*/
public function down(): void
{
Schema::dropIfExists('user_identity_badges');
}
};
@@ -0,0 +1,51 @@
<?php
/**
* 文件功能:创建每日签到记录表。
*
* 每个用户每天只能签到一次,并保存当次连续天数与实际发放奖励快照。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 类功能:维护 daily_sign_ins 表结构与回滚逻辑。
*/
return new class extends Migration
{
/**
* 创建每日签到记录表。
*/
public function up(): void
{
Schema::create('daily_sign_ins', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->unsignedBigInteger('room_id')->nullable()->index()->comment('签到所在房间ID');
$table->date('sign_in_date')->comment('签到日期');
$table->unsignedInteger('streak_days')->comment('连续签到天数');
$table->foreignId('reward_rule_id')->nullable()->constrained('sign_in_reward_rules')->nullOnDelete()->comment('命中奖励规则ID');
$table->unsignedInteger('gold_reward')->default(0)->comment('实际发放金币');
$table->unsignedInteger('exp_reward')->default(0)->comment('实际发放经验');
$table->unsignedInteger('charm_reward')->default(0)->comment('实际发放魅力');
$table->string('identity_badge_code', 50)->nullable()->comment('本次刷新身份徽章编码');
$table->string('identity_badge_name', 50)->nullable()->comment('本次刷新身份徽章名称');
$table->string('identity_badge_icon', 120)->nullable()->comment('本次刷新身份徽章图标');
$table->string('identity_badge_color', 20)->nullable()->comment('本次刷新身份徽章颜色');
$table->timestamps();
$table->unique(['user_id', 'sign_in_date'], 'uniq_user_daily_sign_in');
$table->index(['sign_in_date', 'streak_days'], 'idx_daily_sign_ins_date_streak');
});
}
/**
* 回滚:删除每日签到记录表。
*/
public function down(): void
{
Schema::dropIfExists('daily_sign_ins');
}
};
@@ -0,0 +1,37 @@
<?php
/**
* 文件功能:为每日签到记录补充补签来源字段。
*
* 记录补签卡消耗的购买记录与补签时间,便于日历区分正常签到和补签。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:新增补签标记、补签卡购买记录和补签完成时间。
*/
public function up(): void
{
Schema::table('daily_sign_ins', function (Blueprint $table): void {
$table->boolean('is_makeup')->default(false)->after('room_id')->comment('是否通过补签卡补签');
$table->foreignId('makeup_purchase_id')->nullable()->after('is_makeup')->constrained('user_purchases')->nullOnDelete()->comment('消耗的补签卡购买记录ID');
$table->timestamp('makeup_at')->nullable()->after('makeup_purchase_id')->comment('补签完成时间');
});
}
/**
* 方法功能:回滚补签字段。
*/
public function down(): void
{
Schema::table('daily_sign_ins', function (Blueprint $table): void {
$table->dropConstrainedForeignId('makeup_purchase_id');
$table->dropColumn(['is_makeup', 'makeup_at']);
});
}
};
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:为商店商品类型加入签到补签卡。
*
* 允许后台商店配置补签卡,并提供一条默认上架商品。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 方法功能:扩展商品类型枚举并写入默认补签卡。
*/
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
}
DB::table('shop_items')->updateOrInsert(
['slug' => 'sign_repair_card'],
[
'name' => '补签卡',
'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。',
'icon' => '🗓️',
'price' => 10000,
'type' => 'sign_repair',
'duration_days' => 0,
'duration_minutes' => 0,
'intimacy_bonus' => 0,
'charm_bonus' => 0,
'sort_order' => 35,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]
);
}
/**
* 方法功能:回滚补签卡商品类型。
*/
public function down(): void
{
DB::table('shop_items')->where('slug', 'sign_repair_card')->delete();
if (DB::getDriverName() === 'mysql') {
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'sign_repair'");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing') NOT NULL COMMENT '道具类型'");
}
}
};
@@ -0,0 +1,44 @@
<?php
/**
* 文件功能:为职务表增加礼包红包默认配置。
*
* 每个职务可独立设置礼包红包总量和份数,发金币礼包与经验礼包时共用这组配置。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 类功能:维护 positions 表的礼包红包默认总量与份数字段。
*/
return new class extends Migration
{
/**
* 方法功能:新增职务礼包红包总量和份数字段。
*/
public function up(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->unsignedInteger('red_packet_amount')
->default(8888)
->after('permissions')
->comment('礼包红包默认总量');
$table->unsignedSmallInteger('red_packet_count')
->default(10)
->after('red_packet_amount')
->comment('礼包红包默认份数');
});
}
/**
* 方法功能:删除职务礼包红包配置字段。
*/
public function down(): void
{
Schema::table('positions', function (Blueprint $table) {
$table->dropColumn(['red_packet_amount', 'red_packet_count']);
});
}
};
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:清理已废弃的等级阈值聊天室管理权限参数
* 统一删除 level_* 权限残留,避免继续与职务权限体系并存。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 方法功能:删除旧等级权限参数。
*/
public function up(): void
{
DB::table('sysparam')
->whereIn('alias', [
'level_warn',
'level_mute',
'level_kick',
'level_announcement',
'level_ban',
'level_banip',
'level_freeze',
])
->delete();
}
/**
* 方法功能:回滚时恢复旧等级权限参数默认值。
*/
public function down(): void
{
$now = now();
foreach ([
'level_warn' => ['5', '警告所需等级'],
'level_mute' => ['50', '禁言所需等级'],
'level_kick' => ['60', '踢人所需等级'],
'level_announcement' => ['60', '设置公告所需等级'],
'level_ban' => ['80', '封号所需等级'],
'level_banip' => ['90', '封IP所需等级'],
'level_freeze' => ['14', '冻结账号所需等级'],
] as $alias => [$body, $guidetxt]) {
DB::table('sysparam')->updateOrInsert(
['alias' => $alias],
[
'body' => $body,
'guidetxt' => $guidetxt,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
};
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:将旧的冻结权限合并到封号权限
* 确保已配置 room.user_freeze 的职务在合并后自动继承 room.user_ban。
*/
use App\Models\Position;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* 方法功能:把冻结权限迁移为封号权限。
*/
public function up(): void
{
Position::query()->each(function (Position $position): void {
$permissions = array_values($position->permissions ?? []);
if (! in_array('room.user_freeze', $permissions, true)) {
return;
}
$normalizedPermissions = array_values(array_unique(array_map(
fn (string $permission): string => $permission === 'room.user_freeze' ? 'room.user_ban' : $permission,
$permissions,
)));
$position->forceFill([
'permissions' => $normalizedPermissions,
])->save();
});
}
/**
* 方法功能:回滚时把合并后的封号权限恢复为冻结权限。
*/
public function down(): void
{
Position::query()->each(function (Position $position): void {
$permissions = array_values($position->permissions ?? []);
if (! in_array('room.user_ban', $permissions, true)) {
return;
}
$normalizedPermissions = array_values(array_unique(array_map(
fn (string $permission): string => $permission === 'room.user_ban' ? 'room.user_freeze' : $permission,
$permissions,
)));
$position->forceFill([
'permissions' => $normalizedPermissions,
])->save();
});
}
};
@@ -0,0 +1,45 @@
<?php
/**
* 文件功能:用户表增加 active_decorations JSON
* 用于存储用户当前激活的个人装扮信息(气泡/昵称颜色/头像框)及过期时间。
*
* JSON 结构示例:
* {
* "bubble": {"style": "msg_bubble_golden", "expires_at": "2026-05-04T12:00:00+08:00"},
* "name_color": {"style": "msg_name_rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
* "avatar_frame":{"style": "avatar_frame_dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
* }
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 新增 active_decorations 列(可空 JSON)。
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('active_decorations')->nullable()
->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间');
});
}
/**
* 回滚:删除 active_decorations 列。
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('active_decorations');
});
}
};
@@ -0,0 +1,39 @@
<?php
/**
* 文件功能:为店铺商品类型加入个人装扮(消息气泡、昵称颜色、头像框)。
*
* 支持用户在商店购买有期限的个人装扮道具,购买后自动激活并在聊天和用户列表中展示。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 扩展 shop_items.type ENUM,加入三种装扮类型。
*/
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
}
}
/**
* 回滚:移除装扮类型值,将已有装扮商品标记为 one_time。
*/
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
// 将已有的装扮商品类型回退为 one_time,避免 ENUM 收缩报错
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` IN ('msg_bubble','msg_name_color','avatar_frame')");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
}
}
};
@@ -0,0 +1,38 @@
<?php
/**
* 文件功能:为店铺商品类型加入消息文字颜色特效装扮(msg_text_color)。
*
* 用户在商店购买文字颜色特效后,消息正文呈现动态色彩效果(七彩、流光、霓虹等)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 扩展 shop_items.type ENUM,加入 msg_text_color 类型。
*/
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
}
}
/**
* 回滚:移除 msg_text_color,将已有该类型商品标记为 one_time。
*/
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'msg_text_color'");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
}
}
};
+4 -9
View File
@@ -2,26 +2,21 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
/**
* 类功能:统一调度项目基础数据填充器。
*/
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
* 方法功能:执行系统参数和默认房间等基础数据填充。
*/
public function run(): void
{
// User::factory(10)->create();
// User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
$this->call([
SysParamSeeder::class,
RoomSeeder::class,

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