Compare commits

..

401 Commits

Author SHA1 Message Date
lkddi 2a4d2c5e1b Add avatar prototype preview 2026-04-11 22:23:37 +08:00
lkddi 0a764a3a86 优化会员显示 2026-04-11 17:18:31 +08:00
lkddi f91772b019 优化跑马页面 2026-04-11 16:58:28 +08:00
lkddi abc05de86e Fix duplicate mystery box config fields 2026-04-11 16:31:13 +08:00
lkddi 37c175289c Refine horse race pool and quick entry 2026-04-11 16:27:04 +08:00
lkddi b02a789264 Fix rooms page Alpine body data 2026-04-11 16:14:11 +08:00
lkddi 44db0d7853 Fix horse race seed pool payouts 2026-04-11 16:11:00 +08:00
lkddi cc1dd017ce Ensure self join message is rendered 2026-04-11 15:58:38 +08:00
lkddi ca1c7e66c5 去除管理员登录提示 2026-04-11 15:57:20 +08:00
lkddi f6fb5aab78 Fix self welcome message rendering 2026-04-11 15:54:25 +08:00
lkddi 4eba9dfc12 Add VIP presence themes and custom greetings 2026-04-11 15:44:30 +08:00
lkddi 9fb7710079 优化 钓鱼会员加提提示! 2026-04-11 14:22:22 +08:00
lkddi a21695e326 优化 钓鱼会员加提提示! 2026-04-11 14:13:52 +08:00
lkddi a7fe908c1c 增加会员中心 导航 2026-04-11 13:50:25 +08:00
lkddi f1d94b18b2 优化 2026-04-11 13:45:10 +08:00
lkddi 632c9e5a93 优化 2026-04-11 13:34:15 +08:00
lkddi 6af789dd83 优化个人中心页面 2026-04-11 13:30:32 +08:00
lkddi 12fd0558d9 个人页面增加 会员显示 2026-04-11 13:24:15 +08:00
lkddi c30b518105 个人页面增加 会员显示 2026-04-11 13:20:37 +08:00
lkddi c2a2b4818e 个人页面增加 会员显示 2026-04-11 13:14:05 +08:00
lkddi 56b24901c6 会员钓鱼 加成提醒! 2026-04-11 12:56:22 +08:00
lkddi 7087c22259 修复bug 2026-04-11 12:19:08 +08:00
lkddi 746116d325 feat: add vip payment and member center 2026-04-11 12:01:52 +08:00
lkddi db26820544 优化:微信群内管理员上线播报仅针对拥有职务的用户,并前置显示部门及职务名称 2026-04-03 14:05:04 +08:00
lkddi 3488ad0605 特性: 重新开启底部工具栏看视频赚钱(赚钱)入口按钮 2026-04-03 13:57:26 +08:00
lkddi 659e562208 测试: 完成游戏娱乐模块 (Gomoku, HorseRace, Lottery 等) 功能全量联调测试与代码格式化 2026-04-03 13:55:36 +08:00
lkddi d47f9c5360 chore: 暂时隐藏工具栏「赚钱」入口 2026-04-03 10:55:36 +08:00
lkddi 540793c152 feat: 看视频奖励改用 UserCurrencyService 写日志,新增 VIDEO_REWARD 枚举 2026-04-03 10:50:29 +08:00
lkddi 3aa2402808 feat: 每日观看上限改为 3 次,消息改用变量 2026-04-02 18:46:20 +08:00
lkddi b0b77640f6 fix: 前端 systemUsers 加入系统播报,修正消息渲染格式 2026-04-02 18:43:29 +08:00
lkddi fb1e4402dc fix: 赚钱广播改为系统播报风格,白名单同步更新 2026-04-02 18:41:01 +08:00
lkddi 97e32572cf feat: 新增看视频赚金币功能
- 在右侧导航新增「赚钱」入口(娱乐下方)
- 新增 earn-panel 弹窗:风格与商店一致,800px 宽度
- 集成 FluidPlayer + VAST 广告(ExoClick)
- 动态倒计时:实时监听视频 duration/currentTime
- VAST 失败时自动回退保底视频,20s 超时保底放行
- 修复 AbortError:idle 时 video 不预播放,仅提供 fallback source
- 删除不支持的 player.on('error') 调用
- 所有 overlay 改用绝对定位居中,修复 Alpine x-show 破坏 flex 问题
- EarnController:Redis 每日 10 次限额 + 冷却防刷
- 领取成功后广播全服系统消息(含金币+经验+快捷入口标签)
- 移除神秘盒子相关 UI 代码
2026-04-02 18:35:54 +08:00
lkddi b4d6e0e23b feat: 支持上传及查看高清原图自定义头像 2026-04-02 17:07:24 +08:00
lkddi caf4742dd8 修复:移除前端对 headface 属性的强制小写转换,避免自定义上传头像(带有大小写字符)出现404问题 2026-04-02 17:01:13 +08:00
lkddi c7142efa99 修复微信机器人好友上线通知由于好友模型失效导致的无法通知的问题 2026-04-02 16:46:00 +08:00
lkddi c4edda8b4e 特性:优化注册与改名卡逻辑,在触发敏感词或拦截重名时明确提示具体是触发了哪个词汇 2026-04-02 16:38:17 +08:00
lkddi 63292ab810 优化:注册与登录拦截处支持针对后台管理的永久禁用黑名单词汇采用“模糊匹配”(只要包含该词汇即拦截) 2026-04-02 16:35:58 +08:00
lkddi 2786c8e7bf 优化:调整注册/登录时的用户名长度验证,采用实际显示宽度(英文占1宽度,汉字占2宽度),限制最短4个字母(或2个汉字) 2026-04-02 16:32:03 +08:00
lkddi ecfed9bf6b 优化:后台大盘用户列表增加微信绑定状态展示并支持排序,优化整体表格排版(不换行、时间简写) 2026-04-02 16:28:35 +08:00
lkddi a562ecca72 修复聊天室离开播报:显式点击离开按钮时绕过队列防抖,同步发送离开广播,解决本地无队列运行时播报丢失的问题 2026-04-02 16:21:35 +08:00
lkddi fa5e37f003 feat: 增加发送微信群内自定义公告功能,并优化离线防抖与自我播报过滤机制
- 后台微信机器人增加群内独立公告的分发推送模块
- 聊天室系统引入3秒离线延迟(防抖)防重复播报
- 优化聊天界面消息拉取过滤自身的欢迎或离场广播
- 管理员登录时的烟花特效同步至用户当前的前端显示
2026-04-02 16:07:40 +08:00
lkddi e36b779a4a fix(baccarat): 解决AI接口耗时导致AI小班长在封盘后仍然下注并报错的问题 2026-04-02 15:49:32 +08:00
lkddi 66451c189e fix(wechat): 屏蔽无人参与的百家乐空局通知,防止无效消息刷屏群聊 2026-04-02 15:46:01 +08:00
lkddi 310e8bc07d feat(wechat): 增加微信全局通知免打扰时间配置,避免夜间打扰用户 2026-04-02 15:44:05 +08:00
lkddi f04512ac3f fix(wechat): 回滚微信群扫码绑定,强制要求私聊,避免无法下发个人通知的潜在问题 2026-04-02 15:38:26 +08:00
lkddi a24c8280c9 feat(wechat): 完善微信群绑定安全组网约束,要求只允许在指定管理群内进行扫码验证绑定 2026-04-02 15:37:49 +08:00
lkddi 9857797b80 feat(wechat): 开启在微信群内直接发送验证码绑定自身账号的支持 2026-04-02 15:37:20 +08:00
lkddi 870855d99c fix(wechat): 去掉多余重复的标题拼接以保持文案精简 2026-04-02 15:16:34 +08:00
lkddi 039c32ecf4 fix(wechat): 移除发送队列中已废弃的全局总开关判断导致消息被丢弃的问题 2026-04-02 15:14:17 +08:00
lkddi 08498c97d0 chore(deploy): 增加 Horizon 自动平滑重启指令 2026-04-02 15:02:12 +08:00
lkddi fc57f97c9e feat(wechat): 微信机器人全链路集成与稳定性修复
- 新增:管理员后台的微信机器人双向收发参数设置页面及扫码绑定能力。
- 新增:WechatBotApiService 与 KafkaConsumerService 模块打通过往僵尸进程导致的拒绝连接问题。
- 新增:下发所有群发/私聊通知时统一带上「[和平聊吧]」标注前缀。
- 优化:前端个人中心绑定逻辑支持一键生成及复制动态口令。
- 修复:闭环联调修补各个模型中产生的变量警告如 stdClass 对象获取等异常预警。
2026-04-02 14:56:51 +08:00
lkddi 8a809e3cc0 修复:移除 AI 预测强制指定的 response_format JSON 约束,解决国内开源模型(GLM、StepFun)通过第三方代理调用时静默返回空串无法解析的问题 2026-04-02 13:39:45 +08:00
lkddi 0a192c4f33 修复:更新部署脚本自动接管文件所属权为 www,彻底解决生产环境中框架因 root 权限导致的无法读写缓存与日志问题 2026-04-02 13:34:02 +08:00
lkddi 426695e410 修复(BaccaratAI):优化Prompt脱敏规避大模型道德审查,并新增正则降级匹配以兼容未输出JSON的情况 2026-04-02 10:27:59 +08:00
lkddi 69e41fbbd9 优化:完善百家乐AI决策,增加历史记录上下文、底部仓位预留与智能观望广播功能 2026-04-02 10:21:20 +08:00
lkddi 3a460b9ac6 优化:登录页面长时间停留导致 CSRF 失效时自动显示中文提示并刷新 2026-04-02 09:10:49 +08:00
lkddi f0d92b21be feat: 增加百家乐下注公屏播报通知 2026-03-28 22:07:12 +08:00
lkddi a3edb7538a build(deploy): 按需调整缓存自动构建逻辑(追加 optimize) 2026-03-28 21:54:59 +08:00
lkddi 5d4a0dd00f build(deploy): 优化部署脚本,每次部署先清理缓存再重建优化缓存 2026-03-28 21:54:24 +08:00
lkddi a69a20ee1e chore(百家乐): 提高 AI 小班长强退休息的连输阈值(3次 -> 10次) 2026-03-28 21:50:03 +08:00
lkddi 08c854222e fix(百家乐): 精简 AI小班长发言的下注标签,解决括号嵌套问题 2026-03-28 21:44:50 +08:00
lkddi 7bb7f1f4fd feat(百家乐): AI小班长下注后在聊天室发送普通聊天消息
- 下注成功后调用 broadcastBetMessage() 向聊天室广播
- 消息格式:「🤖 AI分析 小班长投了 N 金币,压【大/小/豹子】,大家加油!🎲」
- 发送者 AI小班长,发送对象 大家,action=说(普通聊天)
- AI 预测时显示「🤖 AI分析」标签,本地兜底时显示「📊路单统计」
2026-03-28 21:38:34 +08:00
lkddi aa760b14a2 fix(百家乐AI预测): ai_usage_logs 使用 AI小班长真实 user_id
- 新增 resolveAiUserId() 按 username 查询 AI小班长 ID(惰性/缓存)
- 原先硬编码 null,改为正确关联到 AI小班长用户记录
2026-03-28 21:28:14 +08:00
lkddi 2e252eb70e fix(百家乐AI预测): user_id 改为 null 修复外键约束报错
- ai_usage_logs.user_id 外键引用 users.id,0 不合法应为 null
- 顺便修复 prepend() 后的 IDE 类型推断 lint 警告
2026-03-28 21:26:44 +08:00
lkddi d16626d121 feat(百家乐AI预测): 实现多厂商自动故障转移
- predict() 改为遍历所有已启用厂商,与 AiChatService 保持一致
- 首选 glm-5.1-free,失败后自动按 sort_order 切换下一个厂商
- 所有厂商均失败才返回 null 回退本地路单决策
- 每次调用成功/失败均写入日志,便于追踪
2026-03-28 21:15:49 +08:00
lkddi 3bfc0e358f feat(百家乐): 记录AI小班长每局决策日志到 daily 日志文件
- 新增 Log::channel('daily')->info() 记录:局次ID、决策来源(AI预测/本地兜底)、AI预测结果、最终下注方向、下注金额、路单序列
2026-03-28 21:11:44 +08:00
lkddi 3814ea5e85 fix(百家乐): 连输惩罚冷却从1小时缩短为10分钟 2026-03-28 21:04:35 +08:00
lkddi c9a569fc42 feat(百家乐AI预测): 指定使用 glm-5.1-free 模型,回退默认
- BaccaratPredictionService 新增 PREFERRED_MODEL 常量(glm-5.1-free)
- predict() 优先通过 findByModel() 找到指定模型,找不到再用 getDefault()
- AiProviderConfig 新增 findByModel() 静态方法(按模型名称查找已启用配置)
- 经实测:dmxapi/glm-5.1-free 返回正常,耗时约 1.6~7s,格式完全正确
2026-03-28 20:59:50 +08:00
lkddi 348f4e0fe0 fix(百家乐AI预测): 兼容推理型模型(StepFun/DeepSeek-R1)
- max_tokens 从 10 调整到 1000,避免推理模型因 finish_reason:length 截断
- content 为 null 时从 reasoning 字段正则提取预测关键词作为兜底
- 经 openrouter/stepfun-step-3.5-flash 实测验证通过
2026-03-28 20:53:42 +08:00
lkddi 887fc5c7ef feat(百家乐): AI小班长改用AI接口预测路单走势下注
- 新增 BaccaratPredictionService,调用 AI 厂商(OpenAI 兼容协议)
  根据近期 20 局路单给出预测(大/小/豹子)
- AiBaccaratBetJob 优先使用 AI 预测结果;
  AI 不可用(超时/无配置)时自动回退本地路单统计决策
- 复用 AiProviderConfig 多厂商配置与故障转移逻辑
- AI 调用结果写入 ai_usage_logs(action=baccarat_predict)
2026-03-28 20:35:43 +08:00
lkddi e515a1429c 优化:将五子棋后台管理配置项的英文键名映射为中文说明 2026-03-28 20:22:34 +08:00
lkddi 7bcb9b126b 修复:AI小班长押注未更新押注人数并丢失全局下注池广播的问题 2026-03-28 18:06:48 +08:00
lkddi 043be04187 优化:百家乐押注面板在未下注时隐藏顶部统计框,避免和按钮内容双重显示 2026-03-28 18:02:27 +08:00
lkddi e5fca206f0 优化:移除百家乐前台面板顶部下注池累计金额,仅展示押注人数 2026-03-28 17:53:49 +08:00
lkddi 91b9a6bcef 优化:百家乐押注面板显示押注人数,今日排行榜修正金币净收益统计 2026-03-28 17:38:59 +08:00
lkddi 63f9a174ed fix: 在全局金币流水页面移除用户ID和房间ID的显示 2026-03-28 17:27:06 +08:00
lkddi b4f62ca6b9 fix: 金币流水列表类型栏位图标与文字保持同行不换行 2026-03-28 17:26:37 +08:00
lkddi 8f850e651e fix: 美化全局金币流水页面筛选表单的UI样式 2026-03-28 17:24:31 +08:00
lkddi 08df13bfc7 fix: 调整全局金币流水页面表格的标题及用户名列不允许换行 2026-03-28 17:22:11 +08:00
lkddi 60bafe7bc4 feat: 在管理后台针对superlevel级别用户新增全局金币流水查询页面 2026-03-28 17:20:33 +08:00
lkddi f0618aad4b feat: 在后台管理添加AI小班长钓鱼触发概率配置 2026-03-28 17:15:09 +08:00
lkddi 8fcccf72a5 feat(baccarat): 实现百家乐实时下注人数统计功能
- 新增 BaccaratPoolUpdated 事件,用于通过 WebSocket 广播实时下注数据更新
- 增加数据库迁移以在 baccarat_rounds 表中添加对应的下注人数统计字段
- 更新 BaccaratRound 模型以及 BaccaratController,支持实时下注统计更新与 WebSocket 事件分发
- 更新前端 chat.js 以及 baccarat-panel.blade.php,利用 Alpine.js 和 Echo 接收事件并动态渲染 "大"、"小"、"豹子" 的实时下注计数
2026-03-28 17:02:10 +08:00
lkddi a68e82107e feat: 实现 AI 钓鱼与百家乐游戏的参与逻辑,并支持后台面板配置开关 2026-03-26 11:49:36 +08:00
lkddi 532dc20a2d fix(ai): 去除服务端收到大模型对话请求时对用户提问的二次重复广播 2026-03-26 11:25:38 +08:00
lkddi 65bd8894c9 chore: ignore redis dump.rdb 2026-03-26 11:17:35 +08:00
lkddi 4d60893dbe feat(ai): 将小班长升级为完全独立的实体用户并支持随机金币发放及持续在线刷级,设定为女兵人设并使用自定义头像 2026-03-26 11:15:11 +08:00
lkddi c13bb5f35c fix(UI): 使聊天记录中的 AI小班长 名称支持点击以快速回复 2026-03-26 09:45:03 +08:00
lkddi ed04b2d4b9 feat(AI): 新增小班长随机赠送金币福利功能,支持 [ACTION:GIVE_GOLD] 拦截与全服广播 2026-03-26 09:34:28 +08:00
lkddi bb505d7508 style: pint格式化UserController 2026-03-21 16:41:57 +08:00
lkddi cbc4d3b7d0 fix: 银行存款可见性改为动态读取超管等级(superlevel)而非固定id=1 2026-03-21 16:40:37 +08:00
lkddi e42dc5fbfa feat: 银行存款名片仅超管/本人可见具体金额,其余显示星号 2026-03-21 16:39:10 +08:00
lkddi 78a682e0ab feat: 银行弹窗UI重构并增加存款排行榜功能 2026-03-21 09:50:46 +08:00
lkddi 60cec0276b feat: 名片支持展示存款信息并适配弹窗宽度 2026-03-21 08:29:29 +08:00
lkddi 7bf9d18b33 补充金币发放/赠送接口的中文验证提示语,防止前端显示 validation.max.numeric 2026-03-18 21:54:20 +08:00
lkddi 5f4abc5152 放大金币发放/赠送的最大额度至 999999999;同步后端允许平级用户互相执行管理操作 2026-03-18 21:53:05 +08:00
lkddi 4139949405 放开特权用户平级管理操作:允许同等级(如100级对100级)互相执行管理操作 2026-03-18 21:49:35 +08:00
lkddi 363a0145d9 记录所有人在线时长:允许 user_position_id 为空,移除记录日志时的职务判断 2026-03-18 21:44:53 +08:00
lkddi 36cc934f7a 修复后台在职登录日志视图:修复在线时长显示截断与仅单页求和的 Bug 2026-03-18 21:40:31 +08:00
lkddi 72bcb73351 修复后台在职登录日志统计:计算所有在职记录之和,而非仅计算当前分页 2026-03-18 21:36:12 +08:00
lkddi c9cab898c2 勤务榜只统计已关闭记录(whereNotNull logout_at);清理今日重复坏数据 2026-03-18 21:31:44 +08:00
lkddi f3579ae9fe 修复勤务日榜时长膨胀:重建session时用now()而非旧in_time,补updated_at刷新防误关,视图标签改为所有 2026-03-18 21:17:02 +08:00
lkddi 42beed5c93 修复勤务日榜在线时长:CASE WHEN 实时算 open session 时长;关闭 stale 日志时补算 duration_seconds 2026-03-18 21:03:36 +08:00
lkddi afd02b38e3 修复手机端双触发用户信息弹窗:名单item补touchend双击检测,消息名字加事件委托 2026-03-18 20:58:33 +08:00
lkddi b63b709032 修复手机端双触发弹窗:名单 item 和消息名字均支持 touchend double-tap 弹出用户名片 2026-03-18 20:51:57 +08:00
lkddi 340cbe8784 赠金币通知:发送者/接收者在包厢窗口显示,其他人在公屏显示(利用 to_user 路由机制) 2026-03-18 20:46:18 +08:00
lkddi d7a575d8c8 新增银行功能:存取金币、流水记录、PC/手机端双入口;迁移 bank_jjb 字段和 bank_logs 表 2026-03-18 20:31:19 +08:00
lkddi 6c4183e175 删除管理操作区私信按钮 2026-03-18 20:20:31 +08:00
lkddi 0ca028f73d 新增赠送金币功能:任意用户可从自己余额赠送金币给他人,成功后聊天室系统传音广播;职务奖励金币移入管理区,删除管理区私信按钮 2026-03-18 20:12:17 +08:00
lkddi c7063e02c2 礼包过期后3秒自动关闭弹窗 2026-03-17 21:29:39 +08:00
lkddi 75f25150c3 修复欢迎语 action 未生效:action select 加隐藏 option[value=欢迎] 2026-03-17 21:26:57 +08:00
lkddi 5b065fdcce 欢迎语:渲染为蓝色边框公告样式(低于系统公告),发送前临时设 action=欢迎 2026-03-17 21:24:31 +08:00
lkddi ca415cceef 欢迎语:加部门职务姓名前缀,点选后自动发送 2026-03-17 21:19:38 +08:00
lkddi 46fde766e5 新增欢迎语快捷按钮:职务人员/id=1可见,10条预设语,自动填入输入框 2026-03-17 21:12:14 +08:00
lkddi 630a3a6dde 删除分屏选项:移除 HTML 控件、JS 函数、CSS 规则 2026-03-17 21:02:05 +08:00
lkddi 0ce969ef69 修复手机名单抽屉滚动:height:80vh 给 flex 子项确定高度,滚动生效 2026-03-17 20:59:06 +08:00
lkddi ad754a704e 修复房间列表在线人数不准:房间Tab每30秒自动刷新+懒清理掉线僵尸记录 2026-03-17 20:54:43 +08:00
lkddi 7d984ebe64 百家乐结算页:10秒后自动关闭,显示倒计时,手动关闭可取消 2026-03-17 20:46:48 +08:00
lkddi c8ebbc750e 百家乐后台统计:新增会员输掉金币总数卡片 2026-03-17 20:35:15 +08:00
lkddi 4927e815b5 修复手机端名单抽屉滚动失效问题:打通 flex 高度约束链,启用 overflow-y:auto 2026-03-17 20:30:59 +08:00
lkddi 7804adc54a 新增掉线自动结算命令并修复跨天日志归零问题
- 新建 CloseStaleDutyLogs 命令:每 15 分钟扫描无心跳开放日志自动关闭
- 注册调度 duty:close-stale-logs everyFifteenMinutes
- 修复 closeDutyLog:跨天遗留日志保留 duration_seconds,不再硬归零
2026-03-17 20:27:04 +08:00
lkddi ef9a8ed0b6 修复手机端名单:单击/双击用户名均关闭抽屉,引入互斥定时器防止双击时抽屉提前关闭 2026-03-17 17:59:21 +08:00
lkddi 1181162219 feat(mobile): 优化手机端浮动按钮样式与布局
- 浮动按钮改为半透明磨砂玻璃效果(白底 + backdrop-filter blur)
- 按钮改为上下竖排排列
- 按钮位置调整到屏幕中间偏上(35%)
- 手机端隐藏房间介绍和输入栏动作/字色/字号/禁音/分屏控件
- 抽屉改为从顶部向下滑入
2026-03-17 17:51:17 +08:00
lkddi 35a80279e6 feat: 聊天室手机端自适应
- 新增 mobile-drawer.blade.php:手机端浮动按钮 + 工具菜单抽屉 + 名单抽屉(独立维护)
- frame.blade.php:手机端代码改为 @include 引入
- chat.css:添加 @media (max-width: 640px) 响应式样式
  - 隐藏桌面端工具条和右侧名单面板
  - 浮动按钮样式(位于屏幕中间偏右)
  - 抽屉组件从顶部向下展开
  - 手机端隐藏房间介绍、输入栏动作/字色/字号/禁音/分屏控件
  - 现有 modal 弹窗 max-width 自适应修复
- scripts.blade.php:重构 renderUserList 提取 _renderUserListToContainer
  - 修复代码损坏残留,补回 setAction/scrollToBottom/autoScrollEl
2026-03-17 17:49:14 +08:00
lkddi bb63cc12c3 功能:/guide 使用说明页面新增娱乐游戏板块
- 动态读取 game_configs 表,仅展示已启用的游戏
- 新增「🎮 娱乐游戏」板块,包含8款游戏的规则和关键参数
  (钓鱼、老虎机、百家乐、赛马竞猜、神秘箱子、神秘占卜、双色球彩票、五子棋)
- 金币用途列表动态遍历已启用游戏
- 右侧导航加入「娱乐游戏」入口(按游戏开启状态条件渲染)
2026-03-16 16:04:22 +08:00
lkddi 91597e6b2c 修复:彩票/五子棋广播消息中用户名支持单击双击交互
- 修复彩票购买明细页「中奖等级」列始终显示「等待开奖」的问题
  原因:判断条件误用了不存在的 'drawn' 状态,已改为 'settled'

- 系统传音广播消息中的【用户名】现在支持单击(切换发言对象)
  和双击(查看名片),与普通消息行为一致

- 新增 isGameLabel() 函数,通过游戏名前缀匹配 + 含空格检测,
  防止【五子棋】【双色球 第N期 开奖】等标签被误识别为用户名
2026-03-16 15:43:27 +08:00
lkddi 4cf7ef1bd1 修复:Alpine.js 改为本地加载,解决部分用户无法访问 CDN 导致 Alpine 未定义的问题 2026-03-15 17:08:13 +08:00
lkddi c2293f96cb 修复:统一使用 window.Alpine 防止 defer 加载时 Alpine 未定义报错 2026-03-15 17:05:33 +08:00
lkddi 51aa3931b9 送金币弹窗:彻底修复按钮蓝色背景失效变紫的Bug,将背景色移回静态style 2026-03-12 17:39:10 +08:00
lkddi 1328b3d8cb 送金币弹窗:修复因 Alpine.js 动态 :style 覆盖导致蓝色按钮背景透明丢失的问题 2026-03-12 17:36:29 +08:00
lkddi aa7a389ab2 送金币弹窗:确认发放按钮颜色改为蓝底白字渐变 2026-03-12 17:34:24 +08:00
lkddi 6400cb51ca 送金币弹窗:将操作按钮彻底改为发放礼包弹窗的同款样式(#d97706 和半透明底色,尺寸缩小,圆角8px) 2026-03-12 17:32:14 +08:00
lkddi 30d0e386fd 送金币弹窗:优化按钮配色对比度,确认按钮改为实心翠绿色,取消按钮改为半透明深色 2026-03-12 17:29:53 +08:00
lkddi 1b5f185a03 送金币弹窗:彻底重构UI,采用仿礼包弹窗的高级渐变、毛玻璃与居中排版风格 2026-03-12 17:27:35 +08:00
lkddi 29493b4fee 送金币弹窗:按钮改为全宽大圆角居中,发放记录改为卡片式 2026-03-12 17:19:16 +08:00
lkddi cc28a27ab0 送金币弹窗:确认发放按钮固定橙色白字+立体阴影按压效果 2026-03-12 17:15:42 +08:00
lkddi 6817e8e5cd 送金币弹窗:确认发放按钮禁用状态改为灰色背景,激活状态橙色 pill 按钮 2026-03-12 17:10:03 +08:00
lkddi 21111aecf5 送金币弹窗:确认发放按钮改为礼包风格大圆角 pill 按钮 2026-03-12 17:05:48 +08:00
lkddi 32ca130f90 送金币弹窗:确认发放按钮改为渐变圆角实心按钮,加悬浮上移效果 2026-03-12 17:03:25 +08:00
lkddi daeef0af0b 五子棋:通知文本去掉多余的 🌟 前缀 2026-03-12 17:02:19 +08:00
lkddi 4a759802dc 五子棋:通知改用「系统传音」去掉「大声宣告说:」前缀,与赛马/百家乐风格一致 2026-03-12 16:55:55 +08:00
lkddi a225609cea 五子棋通知:统一为【五子棋】标题格式,与赛马通知风格一致 2026-03-12 16:47:40 +08:00
lkddi b2e54aafdb 五子棋:AI 获胜(玩家输局)时也向聊天室发送系统广播通知 2026-03-12 16:44:16 +08:00
lkddi a8bed5de36 重构彩票面板:调整按钮布局,恢复购物车(无清空全部按钮),清空选号同步清空购物车 2026-03-12 16:39:31 +08:00
lkddi d6d246ee63 彩票购票:清除按钮同时清空购物车 2026-03-12 16:06:47 +08:00
lkddi cfd5345e93 彩票购票:修复清除按钮点击失效的问题 2026-03-12 16:05:24 +08:00
lkddi 02816fbb03 彩票购票:优化购票按钮样式增加立体点击感 2026-03-12 16:03:40 +08:00
lkddi 925ac68f20 彩票购票成功增加全站系统广播 2026-03-12 16:01:27 +08:00
lkddi 106dc7f852 五子棋胜利发送系统公告,金币流水增加前缀 2026-03-12 15:59:24 +08:00
lkddi 5b70ccd51f 调整五子棋难度级别为2~5 2026-03-12 15:55:41 +08:00
lkddi cb3481b269 优化五子棋初级 AI 难度策略 2026-03-12 15:54:41 +08:00
lkddi 78564e2a1d feat: 增加自定义头像上传、自动压缩与自动清理功能,统一全站头像路径读取逻辑 2026-03-12 15:26:54 +08:00
lkddi ec95d69e92 style: 移除右侧用户列表顶部的空白2px内边距 2026-03-12 13:36:26 +08:00
lkddi 4b6eca953d style: 修复聊天室右侧房间列表中长名称截断过早及人数换行的问题 2026-03-12 13:32:35 +08:00
lkddi e8b21096a0 fix: 修复由于彻底移除酷库面板导致的JS切换Tab报错 2026-03-12 13:24:47 +08:00
lkddi d36da26c44 fix: 修复右侧列表切换Tab时由于表情包被移除导致的JS报错 2026-03-12 13:23:36 +08:00
lkddi 16498a4657 refactor: 仅彻底移除废弃的聊天室表情图片贴图,保留酷库动作功能 2026-03-12 13:20:26 +08:00
lkddi eab300851a refactor: 移除聊天室右侧废弃的酷库表情与贴图相关代码及资源 2026-03-12 13:19:26 +08:00
lkddi 10cd89f9f9 style: 调整聊天室右侧名单列表整体宽度 2026-03-12 13:17:10 +08:00
lkddi a6b80eadc3 chore: 清理由于测试渲染产生的临时文件 2026-03-12 12:42:02 +08:00
lkddi d827c8a1df fix: 修复后台求婚记录列表未显示已成功和已离婚记录的问题 2026-03-12 12:33:28 +08:00
lkddi 909d578547 fix: 修复风云榜今日榜单路由方法名错误 2026-03-12 09:47:06 +08:00
lkddi dde080f69b feat: 移动端折叠导航栏优化 2026-03-12 09:41:17 +08:00
lkddi 0ab0483603 feat: 完成独立的邀请与达人榜系统架构 2026-03-12 09:33:38 +08:00
lkddi af1d1c5ace fix(chat): 修正双色球统计开奖期数及奖池时使用了错误的 status 条件 2026-03-12 09:06:24 +08:00
lkddi 4606888b0c fix(chat): 修正双色球明细花费显示为 0 的问题 2026-03-12 09:00:48 +08:00
lkddi a14761c498 fix(chat): 修正彩票视图中显示期号与号码球的对应字段名 2026-03-12 08:58:42 +08:00
lkddi f614e07b8f fix(chat): 修正五子棋查询状态字段为 mode 2026-03-12 08:53:23 +08:00
lkddi 289b79affe feat(chat): 增加五子棋的后台历史记录查阅面板与统计展示 2026-03-12 08:52:33 +08:00
lkddi a6b0c24b66 fix(chat): 修正彩票历史记录及明细页中 prize_pool / tickets_count 引用的字段名错误 2026-03-12 08:50:23 +08:00
lkddi 9e1e5fb7db feat(chat): 完善后台彩票游戏的历史总览、期号列表及单期购买明细页面 2026-03-12 08:48:30 +08:00
lkddi 246d89fef6 fix(chat): 修复五子棋配置 Seeder 的字段报错,撤销错误的表结构修改 2026-03-12 08:39:02 +08:00
lkddi b7f2dae847 fix(chat): 添加 game_configs 表缺失的 type 字段的迁移文件 2026-03-12 08:36:55 +08:00
lkddi 1c42f05e20 feat(chat): 完善五子棋功能,包含AI对战、PvP邀请、断线重连及界面美化 2026-03-12 08:35:21 +08:00
lkddi b9c703b755 重构:将聊天室所有原生弹窗替换为全局弹窗,公告增加发送者与时间
- 将设公告、公屏讲话、全员清屏按钮弹窗改为使用 window.chatDialog 全局弹窗
- 所有弹窗改用 .then() 回调注册事件,避免 async/await 行为不一致问题
- 公告内容末尾追加「——发送者 MM-dd HH:mm」,无需新增数据库字段
- 前端编辑公告时自动剥离末尾元信息,用户仅编辑纯内容
- 修复 red-packet-panel.blade.php 中 3 处原生 alert() 残留
- 修复 shop-panel.blade.php 中购买确认 confirm() 原生弹窗残留
2026-03-12 07:33:32 +08:00
lkddi f1062b34d2 修复:支持腾讯云 EdgeOne EO-Client-IP 头部,重构中间件真实IP获取优先级 2026-03-12 07:16:32 +08:00
lkddi 174ee8241d 重构:提取 calculateNewLevel() 私有方法,增加在职职务等级保护逻辑 2026-03-12 06:52:40 +08:00
lkddi 529a59551c 修复(chat): 新增真实 IP 获取中间件及重构用户 IP 轨迹追踪逻辑
- 新增 CloudflareProxies 前置中间件,强制解析 CDN 透传的 CF-Connecting-IP 与 X-Real-IP 并在底层接管,修复 Nginx 代理造成的全局 IP 同化 (127.0.0.1) 问题
- 修改 User 模型,新增 migration 以补全真正的 previous_ip 储存通道
- 修改 AuthController 登录逻辑,在覆写 last_ip 前实现向 previous_ip 的自动历史快照备份
- 修改 UserController API 返回逻辑,实现 first_ip、last_ip(上次)以及 login_ip(本次)的三轨分离
- 更新 user-actions.blade.php 管理员视野面板,同步增加并校验“首次IP”、“上次IP”、“本次IP”三级字段映射的准确性
2026-03-09 11:53:58 +08:00
lkddi 89122773af 部署(chat): 更新 deploy_update.sh 目录权限设置 2026-03-09 11:31:45 +08:00
lkddi bfb1a3bca4 重构(chat): 聊天室 Partials 第二阶段分类拆分及修复红包弹窗隐藏 Bug
- 完成对 scripts.blade.php 中非核心业务逻辑(钓鱼游戏、AI机器人、系统全局公告)的深度抽象隔离
- 修复抢红包逻辑中 setInterval 缺失时间参数(1000)引发浏览器前端主线程挂起的重度阻塞问题
- 修复 lottery-panel 组件结尾漏写 </div> 导致的连锁级渲染树崩溃(该崩溃导致红包节点被意外当作隐藏后代节点渲染,造成彻底不可见)
- 对相关模板规范代码结构,执行 Laravel Pint 格式化并提交
2026-03-09 11:30:11 +08:00
lkddi 28d9f9ee96 修复:将 position_authority_logs.user_position_id 改为可空,修复超管发放奖励时报约束违反错误 2026-03-06 16:49:02 +08:00
lkddi a562564e88 优化:AI 对话上下文轮数从 10 降至 4,减少 token 输入量 2026-03-06 03:40:26 +08:00
lkddi ca639ddd37 修复:AI 接口测试改用 GET /v1/models,毫秒级响应,避免 Cloudflare 524 超时
原方案发起真实推理请求(需 16~20s),经 Cloudflare 代理时触发超时。
改为查询模型列表端点(毫秒级),同时验证连通性和 API Key 有效性,
并显示该厂商的可用模型列表(兼容 Ollama / OpenAI 格式)。
2026-03-06 03:35:11 +08:00
lkddi 318eb6f234 新增:AI 接口连通性测试功能;修复:Ollama 超时问题
- 后台 AI 厂商列表新增「 测试」按钮,实时验证接口连通性
- 显示响应耗时(含冷启动)和模型返回内容
- AiChatService 请求超时从 30s 调整为 120s(兼容 Ollama 本地冷启动)
- 测试接口超时设为 60s
2026-03-06 03:29:13 +08:00
lkddi 6c9db806ae 同步:更新 composer.lock 至 Composer 2.9.5 格式(对齐服务器版本) 2026-03-05 11:52:21 +08:00
lkddi 148c91a61c 修复:部署脚本拉取前自动重置 lock 文件,防止服务器环境差异造成合并冲突 2026-03-05 11:49:45 +08:00
lkddi 5864478ae0 更新:部署脚本新增前端构建步骤 npm run build(步骤3/7) 2026-03-05 11:48:07 +08:00
lkddi 202b55a489 新增:生产环境一键部署更新脚本 deploy_update.sh
包含以下步骤:
  1. git pull 拉取最新代码
  2. composer install 安装依赖(失败则中止)
  3. 清理配置/缓存/视图缓存
  4. 数据库迁移 (--force)
  5. 生产环境配置/路由/视图缓存优化
  6. 修复 storage 和 bootstrap/cache 权限
2026-03-05 11:35:50 +08:00
lkddi 67bea9375f 配置:.gitignore 新增排除 public/.user.ini 2026-03-05 11:23:15 +08:00
lkddi f80b83aee8 修复:移除 x-collapse 指令(未加载插件导致 Alpine.js 崩溃) 2026-03-04 15:55:41 +08:00
lkddi 500b7c718e 优化:彩票面板按钮样式升级(对齐百家乐风格)
- 机选/清除/加入 三按钮:border-radius:10px,更大内边距,hover 效果
- 加入购物车按钮:选号完成时金色渐变 + shadow,未选满时灰化禁用
- 确认购买按钮:border-radius:12px,红色渐变,shadow 层次感,购买中灰化
- 底部操作栏按钮:圆角胶囊(border-radius:20px),机选金色渐变 + hover 上移动效
2026-03-04 15:54:43 +08:00
lkddi 79672a38ec 修复:双色球面板显示位置(左上角 → 屏幕居中)
将 x-show 与 display:flex 拆分到两层 div,
与 baccarat-panel 结构保持一致:
  外层 div: x-data / x-show / x-cloak
  次层 div: position:fixed + display:flex 居中(含 Alpine transition)
  内层 div: 面板卡片内容(width:480px)

原写法 x-show 会将 display:flex 覆盖为 display:block,导致 flex 居中失效
2026-03-04 15:50:58 +08:00
lkddi b13861c869 新增:双色球彩票后台管理(阶段三)
🎛️ 后台游戏配置页
  - lottery 参数标签完整配置(14个参数分组展示)
    开奖时间/购票限制/奖池分配/固定小奖/超级期
  - 双色球专属手动操作区(仿神秘箱子风格)
     当前期次状态展示(实时加载)
     手动开新期(含确认弹窗)
     强制立即开奖(含二次确认防误触)

🔌 后台接口
  - POST /admin/lottery/open-issue  手动开期
  - POST /admin/lottery/force-draw  强制开奖
  - GameConfigController 新增两个 JsonResponse 方法

📋 全局开关
  - 与所有现有游戏一致,后台 toggle 即时生效(60s缓存刷新)
  - 默认关闭,管理员开启后调度器自动接管
2026-03-04 15:47:09 +08:00
lkddi 4114571040 新增:双色球彩票前台 UI(阶段二)
🎟️ lottery-panel.blade.php 彩票面板
  - 红球 1~12(12宫格选3)/ 蓝球 1~6(骰子点数选1)
  - 购物车机制:可加入多注后一次性购买
  - 机选按钮(单注/3注)
  - 本期我的购票记录展示(含中奖标记)
  - 最近8期历史开奖号码表格
  - 规则折叠说明(奖级表格)
  - 停售/已开奖状态自动切换
  - 内联购票结果提示(3秒自动消失)

🎮 游戏大厅集成
  - game-hall 注入 lottery 开关状态
  - GAME_HALL_GAMES 追加双色球卡片(动态展示奖池/倒计时/超级期徽章)
  - frame.blade.php 引入 lottery-panel

🗺️ 路由 /games/enabled 已含 lottery 键
2026-03-04 15:41:57 +08:00
lkddi a788a0022a 清理:删除错误路径生成的多余 LotteryService 文件 2026-03-04 15:38:11 +08:00
lkddi 27371fe321 新增:双色球彩票系统后端基础(阶段一)
📦 数据库
  - lottery_issues(期次表)
  - lottery_tickets(购票记录表)
  - lottery_pool_logs(奖池流水表,透明展示)

🔩 核心组件
  - LotteryIssue / LotteryTicket / LotteryPoolLog 完整 Model
  - LotteryService:购票/机选/开奖/奖池派发/滚存/超级期预热/公屏广播
  - LotteryController:current/buy/quickPick/history/my 五个接口
  - DrawLotteryJob(每日定时开奖)/ OpenLotteryIssueJob(初始化首期)

💰 货币日志
  - CurrencySource 新增 LOTTERY_BUY / LOTTERY_WIN
  - 所有金币变动均通过 UserCurrencyService::change() 记录流水

🗓️ 调度器
  - 每分钟检查停售/开奖时机
  - 每日 18:00 超级期预热广播

🔧 配置
  - GameConfigSeeder 追加 lottery 默认配置(默认关闭)
  - /games/enabled 接口追加 lottery 开关状态
  - 新增 /lottery/* 路由组(auth 保护)
2026-03-04 15:38:02 +08:00
lkddi b30be5c053 修复:赛马开局延迟 30 秒,避免与百家乐同时广播公屏
两个游戏都由每分钟调度器触发,条件同时满足时会在同一秒发公屏,
互相干扰且用户体验混乱。

解决方案:OpenHorseRaceJob::dispatch()->delay(30s)
- 百家乐:整分钟触发(:00)
- 赛马:整分钟 +30 秒触发(:30)
两个游戏公屏广播自然错开半分钟
2026-03-04 15:06:06 +08:00
lkddi 040dbdef3c 优化:全站金币图标由 🪙(银灰色)统一替换为 💰(金黄色)
🪙 在多数平台/字体上渲染为银灰色,与「金币」语义不符;
💰 各平台均渲染为金黄色,更直观传达金币概念。

涉及文件(43处):
- app/Jobs:百家乐、赛马结算广播
- app/Http/Controllers:管理员命令、红包、老虎机、神秘箱子
- app/Listeners
- resources/views:聊天室各游戏面板、商店、toolbar、后台页面等
2026-03-04 15:00:02 +08:00
lkddi 349eb5a338 优化:百家乐开局公告新增赔率说明
- 动态读取 big_rate / triple_rate / kill_points 配置
- 公告格式变更为:
  「…赔率:🔵大/🟡小 1:1 · 💥豹子 1:24(☠️ 3或18点庄家收割)」
2026-03-04 14:51:01 +08:00
lkddi 1c53acbd1b 优化:百家乐结算公告新增各用户输赢明细
- 结算时同步收集 winners(中奖用户+金额)和 losers(未中用户-金额)
- 公屏广播消息末尾附加:
  🏆 中奖:甲+2,000、乙+1,000 🪙
  😔 未中:丙-500、丁-1,000
- 单方向最多显示 10 人,防止消息过长
- 顺手修正豹子结果文本中 dice1 重复的 bug(dice2、dice3 显示错误)
2026-03-04 14:41:07 +08:00
lkddi 16cbb32f35 优化:设置弹窗提示改为百家乐内联卡片风格,3s 后自动淡出
- 新增 showInlineMsg() 函数:成功显示绿色卡片,失败显示红色卡片,3s 后自动淡出
- 修改密码区增加 #pwd-inline-msg 提示块(校验/成功/失败均在弹窗内显示,不遮挡操作)
- 保存资料区增加 #settings-inline-msg 提示块(紧贴保存按钮上方)
- 移除 chatDialog.alert() 弹窗交互,全部改为内联状态卡片
2026-03-04 14:35:18 +08:00
lkddi bcaaa527d4 修复:chatDialog.alert() 第三参数改为颜色值,修正标题栏和按钮背景色
之前误将第三个 color 参数传入 emoji(⚠️🔒 等),
导致 background 被设为无效值,标题栏变白色、文字和按钮不可见。

全部改为正确 HEX 颜色值:
- 提示/警告 → #d97706(琥珀橙)
- 成功      → #16a34a(绿色)
- 失败/错误 → #dc2626(红色)
- 网络错误  → #6b7280(灰色)
- 开发中    → #78716c(石灰灰)
2026-03-04 14:33:24 +08:00
lkddi 2b990942c0 修复:设置弹窗遮挡全局 chatDialog 按钮的问题
overflow-y:auto 会在 CSS 中创建新的堆叠上下文(stacking context),
导致即使全局弹窗 z-index 更高,在视觉上依然被 overflow 容器裁切,
造成「确定」按钮被弹窗底部遮挡无法点击。

修复方案:
- 外层容器移除 overflow-y:auto,改为 display:flex + flex-direction:column
- 标题栏加 flex-shrink:0 固定高度不被压缩
- 内容区单独加 overflow-y:auto + flex:1 保留滚动能力
- 外层遮罩 z-index 从 9999 提升至 10000(仍低于全局弹窗的 999999)
2026-03-04 14:24:11 +08:00
lkddi f867e912e9 修复:设置弹窗所有提示改用全局 chatDialog,替换原生 alert()
- savePassword():修改密码成功/失败/校验提示全部改为 window.chatDialog.alert()
- saveSettings():保存资料成功/失败提示改为 window.chatDialog.alert()
- sendEmailCode():发送验证码相关提示改为 window.chatDialog.alert()
- 工具栏「银行」按钮的 alert 也一并改为 chatDialog
2026-03-04 14:19:14 +08:00
lkddi b62a9f6240 功能:后台游戏历史记录查询中心 + 游戏管理页实时统计
- 新增 GameHistoryController,提供各游戏历史记录查询接口
  - 百家乐:局次列表 + 单局下注明细(含结果分布统计)
  - 老虎机:转动记录含图案分布,支持结果类型/玩家名筛选
  - 赛马:场次列表 + 单场下注明细(含马匹信息展示)
  - 神秘箱子:投放/领取历史,支持箱子类型/领取状态筛选
  - 神秘占卜:签文等级分布统计 + 历史记录,支持等级/玩家名筛选
- 新增 /admin/game-history/ 路由组(stats + 各游戏历史 + 单局详情共9条路由)
- 游戏管理页(/admin/game-configs)优化:
  - 每个游戏卡片新增「📋 历史记录」直达按钮
  - 新增「📊 加载实时统计」按钮,AJAX 异步拉取并展示各游戏汇总卡片
- 更新 GAMES_TODO.md,标记通用待办已完成
2026-03-03 23:40:31 +08:00
lkddi f45483bcba 功能更新与UI优化:游戏图标移除、用户名片修复、婚礼红包界面重设计
- 移除聊天室右下角浮动游戏图标(占卜、百家乐、赛马、老虎机)
- 用户名片按钮区:修复已婚/已好友时按钮换行问题,统一单行显示
- 婚礼红包弹窗:重设计为喜庆鲜红背景,领取按钮改为圆形米黄样式
- 新增婚礼红包恢复接口(/wedding/pending-envelopes),刷新后自动恢复领取按钮
- 修复 Alpine :style 字符串覆盖静态 style 导致圆形按钮失效的问题
- 撤职后用户等级改为根据经验值重新计算,不再无条件重置为1
- 管理员修改用户经验值后自动重算等级,有职务用户等级锁定
- 娱乐大厅钓鱼游戏按钮直接调用 startFishing() 简化操作流程
- 新增赛马、占卜、百家乐游戏及相关后端逻辑
2026-03-03 23:19:59 +08:00
lkddi 602dcd7cf1 feat: 神秘箱子系统完整实现 + 婚姻状态弹窗 + 工具栏优化
## 新功能
- 神秘箱子系统(MysteryBox)完整实现:
  - 新增 MysteryBox / MysteryBoxClaim 模型及迁移文件
  - DropMysteryBoxJob / ExpireMysteryBoxJob 队列作业
  - MysteryBoxController(/mystery-box/status + /mystery-box/claim)
  - 支持三种类型:普通箱(500~2000金)/ 稀有箱(5000~20000金)/ 黑化箱(陷阱扣200~1000金)
  - 调度器自动投放 + 管理员手动投放
  - CurrencySource 新增 MYSTERY_BOX / MYSTERY_BOX_TRAP 枚举

- 婚姻状态弹窗(工具栏「婚姻」按钮):
  - 工具栏「呼叫」改为「婚姻」,点击打开婚姻状态弹窗
  - 动态渲染三种状态:单身 / 求婚中 / 已婚
  - 被求婚方可直接「答应 / 婉拒」;已婚可申请离婚(含二次确认)

## 优化修复
- frame.blade.php:Alpine.js CDN 补加 defer,修复所有组件初始化报错
- scripts.blade.php:神秘箱子暗号主动拦截(不依赖轮询),领取成功后弹 chatDialog 展示结果,更新金币余额
- MysteryBoxController:claim() 时 change() 补传 room_id 记录来源房间
- 后台游戏管理页(game-configs):投放箱子按钮颜色修复;弹窗替换为 window.adminDialog
- admin/layouts:新增全局 adminDialog 弹窗组件(替代原生 alert/confirm)
- baccarat-panel:FAB 拖动重构为 Alpine.js baccaratFab() 组件,与 slotFab 一致
- GAMES_TODO.md:神秘箱子移入已完成区,补全修复记录
2026-03-03 19:29:43 +08:00
lkddi 40fcce2db3 功能:好友面板昵称后显示在线离线状态
后端:
- ChatStateService 新增 getAllOnlineUsernames(),跨房间聚合在线用户名
- FriendController::index() 为每位好友/待回加用户附加 is_online 字段
- 在线好友自动排在列表前面

前端:
- 昵称后显示 🟢 在线 /  离线 徽标
- .fp-status-online 绿底绿字,.fp-status-offline 灰底灰字
2026-03-03 17:42:47 +08:00
lkddi 36fbc9982c 修复:Alpine.js 改为同步加载,修复 Windows 用户双击名字时 Alpine is not defined 报错
- 移除 defer 属性,确保 Alpine 在 DOM 可交互前完成初始化
- 版本号从模糊 @3.x.x 锁定为 @3.14.8,避免 CDN 解析歧义
2026-03-03 17:17:47 +08:00
lkddi 8b5fbd7e91 清理:迁移文件移除钓鱼 sysparam 参数(已迁移至 game_configs) 2026-03-03 17:09:55 +08:00
lkddi 0fd4f51b5e 优化:百家乐骰子悬浮按钮支持拖拽移动,位置记忆 localStorage 2026-03-03 17:00:19 +08:00
lkddi 9f5d213d99 优化:自动钓鱼停止按钮改为可拖拽悬浮,位置持久化到 localStorage 2026-03-03 16:56:10 +08:00
lkddi 03ec3a9fbb 功能:钓鱼游戏后台管理系统
一、钓鱼全局开关
- 钓鱼纳入 GameConfig(game_key=fishing),游戏管理页可一键开关
- cast() 接口加开关校验,关闭时返回 403 友好提示
- GameConfigSeeder 新增 fishing 配置(含4个参数)

二、钓鱼事件数据库化
- 新建 fishing_events 表(emoji/name/message/exp/jjb/weight/is_active/sort)
- FishingEvent 模型含 rollOne() 加权随机方法
- FishingEventSeeder 填充7条初始事件(经验降低、金币提升)
- FishingController::randomFishResult() 改为读数据库事件

三、钓鱼参数迁移至 GameConfig
- fishing_cost/wait_min/wait_max/cooldown 改为 GameConfig::param() 读取
- 保留 Sysparam fallback 兼容旧数据

四、后台管理页面
- 新建 FishingEventController(CRUD + AJAX toggle)
- 新建 admin/fishing/index.blade.php(事件列表+概率显示+编辑弹窗)
- 侧边栏「游戏管理」下方新增「🎣 钓鱼事件」入口
- 游戏管理视图 gameParamLabels 新增钓鱼参数标签
2026-03-03 16:46:36 +08:00
lkddi 783afe0677 重构:运维工具迁移为独立页面,侧边栏新增「运维工具」菜单
- 新建 OpsController,承接四项运维操作
- 新建 admin/ops/index.blade.php 独立页面(卡片式布局)
- admin 路由改为 /admin/ops/* -> admin.ops.*
- 侧边栏「AI 厂商配置」下方新增「🛠️ 运维工具」菜单入口
- SystemController 移除运维方法,职责回归纯参数配置
- system/edit 移除内嵌运维块,页面保持简洁
2026-03-03 15:07:36 +08:00
lkddi adb9f157e6 功能:后台系统配置页新增「运维工具」面板(仅 id=1 可见)
- 应用缓存清理:config:clear + cache:clear
- 路由缓存清理:route:clear
- 视图缓存清理:view:clear
- 幽灵在线清理:扫描并清空所有房间 Redis 在线名单

所有操作均有确认弹窗,执行结果 Flash 提示反馈。
后端 abort(403) 双重校验,非超管无法访问接口。
2026-03-03 15:00:54 +08:00
lkddi b03de378b0 工具:新增 room:clear-online-cache 命令,用于清理房间幽灵在线脏数据 2026-03-03 14:57:28 +08:00
lkddi 5b51754c58 修复:切换房间时旧房间在线记录残留导致「幽灵在线」人数统计虚高
进入新房间 init() 时,先扫描 Redis 将用户从其他所有房间移除,
再写入新房间,确保每个用户同时只存在于一个房间的在线名单中。

根因:直接跳转 URL 切换房间时浏览器不触发 leave 接口,
旧房间的 Redis hash 记录永久残留,导致计数虚高。
2026-03-03 14:51:38 +08:00
lkddi 154d9ca8a2 修复:房间在线人数改用 ChatStateService::getRoomUsers() 确保统计逻辑与名单一致 2026-03-03 14:48:22 +08:00
lkddi 4324633f82 功能:右侧「房间」面板显示所有房间在线人数,点击可切换房间
- ChatController 新增 roomsOnlineStatus() 接口
- GET /rooms/online-status 返回所有房间名称+Redis 实时在线人数
- 右侧面板房间列表动态渲染:当前房间高亮蓝色,有人数绿色徽标,空房间灰色
- 点击其他房间直接跳转,当前房间禁止点击并标注「当前」
- 切换到「房间」Tab 时自动触发拉取
2026-03-03 14:46:22 +08:00
lkddi ad91c4420a 修复:工具栏「反馈」按钮路由名错误(feedback → feedback.index) 2026-03-03 14:41:33 +08:00
lkddi a41e701fed 功能:后台房间管理新增「创建房间」功能
- RoomManagerController 新增 store() 方法,含房间名唯一校验、默认值设置
- 路由增加 POST /admin/rooms -> admin.rooms.store
- 视图增加「+ 新增房间」折叠表单(仅 id=1 超管可见)
- 补充 Flash 成功/错误提示展示
- 原有编辑/删除功能保持不变
2026-03-03 14:36:09 +08:00
lkddi fdb500c3dd 优化:自动钓鱼卡标签改为柔和灰紫色;工具栏「提议」按钮改为「反馈」
- FishingController: 钓鱼播报内「自动钓鱼卡」标签从高饱和紫色渐变改为低调灰紫底色+深紫字,减少视觉刺激
- toolbar.blade.php: 「提议(待开发)」→「反馈」,链接至 feedback 路由(新标签页打开)
2026-03-03 14:30:09 +08:00
lkddi 9b6ebbedb3 修复:腾讯 EdgeCDN HTTPS 回源 HTTP 导致的 Mixed Content 错误
配置 trustProxies(at: '*'),让 Laravel 信任 CDN 转发的
X-Forwarded-Proto: https 请求头,url()/route() 自动生成 https:// 链接,
解决 CDN 接入后登录表单请求被浏览器 Mixed Content 策略拦截的问题。
2026-03-03 13:45:35 +08:00
lkddi e21f049643 修复:勤务日榜在线时长统计虚高(142小时)+ UI文字调整
Bug修复:
- closeDutyLog 增加 whereDate 限制,只关闭今日日志,历史遗留记录置0,避免跨天时长被计入榜单
- tickDutyLog(ChatController/AutoSaveExp)找不到今日开放日志时不再盲目新建,避免同一 login_at 产生几十条重复记录后 SUM 叠加导致虚假142小时
- AppointmentService 撤职时 closeDutyLog 同步增加今日/历史遗留区分处理

UI调整:
- 登录页版权文字「飘落的流星」→「流星」
- 后台布局标题「飘落流星 控制台」→「控制台」
- 后台侧边栏移除非超管查看各模块时的「(只读)」标注
2026-03-01 22:55:55 +08:00
lkddi 6fa42b90d5 功能:站长礼包系统(金币/经验双类型)+ 后台用户编辑权限收紧(仅 id=1 超管)
新增功能:
- 礼包系统:superlevel 站长可发 888 数量 10 份礼包,支持金币/经验双类型
- 发包前三按钮选择(金币礼包 / 经验礼包 / 取消),使用 chatBanner 弹窗
- 聊天室系统公告含「立即抢包」按钮,金币红色/经验紫色配色区分
- WebSocket 实时推送红包弹窗卡片至所有在线用户
- Redis LPOP 原子分发 + 数据库 unique 约束防重领,并发安全
- 弹窗打开自动拉取服务端最新状态(剩余数量/已领/过期实时刷新)
- 新增 GET /red-packet/{id}/status 状态查询接口
- 新增 CurrencySource::RED_PACKET_RECV / RED_PACKET_RECV_EXP 枚举
安全加固:
- 后台用户编辑/强杀按钮仅 id=1 超管可见(前端隐藏 + 后端 403 双重拦截)
2026-03-01 22:20:54 +08:00
lkddi ed195bb5f4 新增 GAMES_TODO.md:记录游戏开发进度(百家乐/老虎机已完成,神秘箱子/赛马/占卜待开发) 2026-03-01 21:15:17 +08:00
lkddi 148947781a 老虎机三项修复:①来源label已有枚举(刷新即显中文) ②普通中奖/诅咒向本人发私聊通知+三7全服广播 ③FAB按钮支持拖动+位置localStorage持久化 2026-03-01 21:10:41 +08:00
lkddi 9359184e38 新增老虎机游戏:①slot_machine_logs表+模型(8种权重图案/判奖) ②SlotMachineController(扣费/随机/赔付/诅咒/三7全服广播) ③前台面板(三列滚轮动画/逐列停止/赔率说明/历史记录) ④CurrencySource三个枚举 2026-03-01 21:00:21 +08:00
lkddi dfa7278184 修复星海小博士随机事件金币/经验不记日志:改走UserCurrencyService.change(),新增CurrencySource::AUTO_EVENT枚举 2026-03-01 20:54:15 +08:00
lkddi 955aec6b73 百家乐结算UI大改:①骰子改数字方块(跨平台兼容,白底深字带弹出动画) ②未中奖卡片:😔+红渐变+显示你押了X开了X+损失金额 ③中奖卡片:🎉+绿渐变+金币数字大字 ④结果标签按大小豹子庄家变色 2026-03-01 20:48:38 +08:00
lkddi 04ab62c988 修复百家乐下注余额检查:gold→jjb,与UserCurrencyService字段映射一致 2026-03-01 20:44:26 +08:00
lkddi 39d36578fd 百家乐押注按钮改为对象式:style绑定,参照求婚弹窗风格:未选择时灰色+不可点击,正常时紫色渐变+阴影 2026-03-01 20:43:02 +08:00
lkddi 4ffc4abff4 修复百家乐/节日福利广播频道错误:Channel→PresenceChannel,与前端Echo.join()订阅的presence-room.1频道一致 2026-03-01 20:40:34 +08:00
lkddi 37b1595709 百家乐修复:①页面加载时检查进行中的局并显示FAB ②FAB点击同时恢复倒计时 ③解决刷新页面/错过WebSocket而看不到下注入口的问题 2026-03-01 20:31:45 +08:00
lkddi ff28775635 新增百家乐游戏:①数据库表+模型 ②OpenBaccaratRoundJob开局(广播+公屏) ③CloseBaccaratRoundJob结算(摇骰+赔付+CAS防并发) ④BaccaratController下注接口 ⑤前端弹窗(倒计时/骰子动画/历史趋势) ⑥调度器每分钟检查开局 ⑦GameConfig管控开关 2026-03-01 20:25:09 +08:00
lkddi 8a74bfd639 新增游戏管理系统:①game_configs表+模型(forGame/isEnabled/param静态方法) ②GameConfigSeeder初始化5款游戏参数 ③后台卡片式管理页(开关+参数表单) ④侧边栏菜单「游戏管理」 2026-03-01 20:17:18 +08:00
lkddi 8c99e1fad7 后台侧边栏菜单新增「节日福利」入口(婚姻管理之后) 2026-03-01 20:08:46 +08:00
lkddi c5fe9faf94 新增节日福利系统:①数据库表+模型 ②TriggerHolidayEventJob队列任务(在线用户筛选/金额分配/WebSocket广播) ③后台管理页面(列表/创建/手动触发) ④前台领取弹窗+WebSocket监听 ⑤定时调度每分钟扫描 ⑥CurrencySource补充HOLIDAY_BONUS 2026-03-01 20:06:53 +08:00
lkddi a37b04aca0 修复红包领取三重问题:①getOnlineUserIds 兼容旧版用户(fallback数据库查询) ②聊天领取按钮用全局Map替代内嵌JSON避免HTML属性破坏 ③doClaim改判 data.ok 而非不存在的 data.status 2026-03-01 19:36:44 +08:00
lkddi 23fca927d5 升级红包领取按钮:深色外框+内部金色实心按钮,仿同意离婚按钮质感 2026-03-01 19:31:52 +08:00
lkddi 392b1b06bb 修复婚礼红包领取:①ChatController userJoin 写入 user_id ②WeddingService 从 room:1:users Hash 读在线用户 ③新郎新娘也可领红包 ④删除结婚弹窗冗余的'举办婚礼'按钮 ⑤升级红包领取按钮为橙色渐变样式 2026-03-01 19:27:28 +08:00
lkddi 0990a13c2e 修复弹窗闪烁:添加 [x-cloak] CSS 规则 + 删除重复的 chat:marriage-accepted 监听器 2026-03-01 19:22:40 +08:00
lkddi 68c4ca7a96 结婚/婚礼/离婚通知持久化:新增事件监听器自动写入聊天消息数据库,用户重新登录后可在历史记录中看到通知 2026-03-01 19:16:27 +08:00
lkddi eefdae93fe 简化婚礼流程:去掉立即/定时选择,同意结婚后直接立即举办婚礼 2026-03-01 19:11:29 +08:00
lkddi e9a41995be 修复婚礼红包弹窗:①名字字段兼容 user.username/groom_name 双格式 ②领取路由修正为 /wedding/ceremony/{id}/claim 2026-03-01 19:08:59 +08:00
lkddi e81887034c 修复 WeddingCeremony 模型:补全 fillable/casts 及关联关系,解决批量赋值报错 2026-03-01 19:05:52 +08:00
lkddi 84a4b42f31 离婚流程全面升级:①发起方专属确认弹窗(含对方拒绝后果+魅力/金币惩罚实时值)②被申请方三选弹窗(同意/不同意/稍后)③不同意=强制离婚申请人赔一半金币④所有惩罚数值从后台实时查询 2026-03-01 19:02:43 +08:00
lkddi 9b55b5558b 完善婚姻系统:①离婚弹窗展示魅力惩罚警告 ②婚礼档位强制必选(移除无选项默认第一档)③婚礼消息含领取红包按钮 ④AppendSystemMessage全局函数(支持HTML) 2026-03-01 18:49:11 +08:00
lkddi 6b32fe38c8 特性:离婚全屏公告弹窗(暗色阴郁风格+断裂心形动效)+ 先雷电后下雨双特效;弹窗触发重构为Alpine数据访问 2026-03-01 18:39:02 +08:00
lkddi 87d91db1ee 特性:婚礼结成弹窗触发全员双倍礼花特效,粉金配色浪漫爆炸,持续12秒 2026-03-01 18:35:08 +08:00
lkddi 1e5d11929e 修复:结婚弹窗及公屏公告显示 undefined 的问题,对齐后端事件字段名 user.username/partner.username 2026-03-01 18:31:57 +08:00
lkddi 00231e0836 修复:reject() 事务闭包未 return 导致函数返回 null 违反类型声明的类型报错 2026-03-01 18:29:19 +08:00
lkddi a60a2c8173 修正:强制离婚间隔允许设置为0,解除后台HTML5表单效验使得修改其它参数时因其值为0导致拦截保存失败的问题 2026-03-01 18:27:19 +08:00
lkddi a9f395994b 修复:离婚冷静期配置项被错误写入系统参数表而非婚姻参数配置表的问题 2026-03-01 18:20:40 +08:00
lkddi 73c78ee6d7 特性:支持在后台配置结婚离婚冷静期规则,并优化冷却时间文本提示;修复全局的离婚公告事件对象接收名称不匹配问题 2026-03-01 18:15:37 +08:00
lkddi d7c6e0e7a8 变更:求婚及离婚弹窗在有效期内重新刷新必现,并移除全局提示框的点击背景蒙层关闭功能强制操作 2026-03-01 18:08:50 +08:00
lkddi 5bcbf74dfc 新增:在用户名片面板展现「协议离婚」按钮及相关的交互弹窗提示 2026-03-01 18:02:47 +08:00
lkddi 52c252f525 变更:修复求婚同意消息未收到问题,重构求婚流程支持直接选婚礼档位 2026-03-01 17:53:43 +08:00
lkddi b7ded61523 变更:创建新的独立迁移文件更新 user_purchases 的 ENUM
考虑到修改已执行过的迁移文件无法在生产环境复用,
现回退对 2026_03_01_145034... 的修改,
并专门创建一个新的迁移 2026_03_01_173619_update_user_purchases_status_enum.php
解决 used_pending/lost 枚举缺失问题,方便线上直接 migrate。
2026-03-01 17:36:41 +08:00
lkddi 919f0e30b5 修复:求婚时报错 user_purchases.status 字段由于未定义 ENUM 而截断的问题
在婚姻系统迁移 add_frozen_jjb_and_marriage_fields_to_marriages_table 中,
原来只在注释写了「扩展 user_purchases.status」,但漏了写执行语句。
目前已补上,执行 ALTER TABLE user_purchases MODIFY COLUMN status
扩展支持了 'used_pending' 和 'lost' 两个状态,确保求婚中和被拒绝时的状态能正确流转。
2026-03-01 17:35:03 +08:00
lkddi 420efbc093 UI修复:求婚弹窗双按钮对齐,完美复刻「加好友大卡片」风格
- 彻底修复  字符串形式  覆盖内联  导致按钮形变的 bug,改用对象格式动态绑定样式,实现平滑继承
- 匹配大卡片公共弹窗按钮的标准尺寸:取消灰色边框,增加按钮高度 (padding: 10px 0, border-radius: 8px)
- 强制等分按钮宽度 (flex: 1)
- 根据金币充足/不足状态及点击状态,准确反映颜色、阴影及禁用态鼠标指针
2026-03-01 17:31:56 +08:00
lkddi b6188ce2c3 UI优化:求婚弹窗戒指改为居中展示,双按钮等宽参照名片风格
- 「选择求婚戒指」→「赠送的求婚戒」
- 移除选择网格,改为居中展示第一枚戒指(粉色卡片)
- 底部双按钮与好友名片操作栏完全统一:
  padding:7px 10px; border-radius:5px; font-size:12px;
  flex:1 等宽,gap:6px
2026-03-01 17:28:28 +08:00
lkddi be2d02cb8f UI:求婚弹窗底部改为「取消」+「确认求婚」横排双按钮
参照用户名片操作栏按钮风格:
  取消:灰色边框底,hover 加深
  确认求婚:粉玫瑰渐变,禁用态灰色
  两按钮等宽 flex:1 横排排列
2026-03-01 17:24:29 +08:00
lkddi 050aec1db4 优化:婚礼费用提示内嵌弹窗,移除多余的二次确认弹窗
在求婚弹窗底部(戒指列表与按钮之间)内嵌费用提示面板:
   金币充足:绿色背景,显示最低费用和当前余额
  ⚠️ 金币不足:红色背景提示,说明可先求婚再准备金币

移除 doPropose() 里的 chatDialog.confirm 二次确认,
点击'确认求婚'按钮直接发送,流程更顺畅。
2026-03-01 17:22:57 +08:00
lkddi c53cd7784a UI重设计:求婚弹窗全面升级为浪漫高端风格
- 背景:深紫玫瑰色磨砂遮罩(backdrop-filter blur)
- 封面区:深玫瑰→粉红渐变,大 💍 图标投影,
  对象名用磨砂胶囊标签显示
- 弹窗入场动画:opacity+scale 过渡
- 戒指卡片:选中态渐变背景+粉色阴影+右上角✓勾
  未选中态浅灰底,悬停有过渡
- 无戒指:粉色虚线框+💔图标+直接跳商店按钮
- 确认按钮:三段深玫瑰渐变+红粉阴影,禁用态灰色
2026-03-01 17:21:48 +08:00
lkddi 9ccc0b379d 优化:求婚前提示最低婚礼费用并检查金币余额
点击「送出求婚」前弹出确认弹窗:
  💍 确认向【XXX】发出求婚吗?
  📋 婚礼费用说明:
    • 婚礼最低费用:🪙 5,888 金币
    • 您当前金币:🪙 XXX 金币
  ⚠️ 戒指一旦送出即消耗,对方拒绝则戒指遗失。

金币不足时:直接拦截并弹 alert 说明,不发出请求
金币充足时:需确认后才发出求婚请求

同时在 chatContext 注入 userJjb 和 minWeddingCost
2026-03-01 17:19:27 +08:00
lkddi 9c4598ab66 修复:所有婚姻弹窗无法显示的 bug
根因:外层容器 style='display:none' 写死,
Alpine x-show 把内层改为 flex,但外层 CSS 始终覆盖,
导致求婚弹窗、收婚弹窗、结婚成功弹窗、婚礼设置弹窗、
婚礼红包弹窗一律无法显示。

修复方案:将 show 状态的 x-show 移到外层容器,
内层固定显示(position:fixed + flex),去掉冲突的 display:none。
同时补充 x-cloak 防止页面加载时闪烁。
2026-03-01 17:17:14 +08:00
lkddi d703309a34 修复:当前用户未设性别时求婚按钮静默消失的问题
根因:lkddi 的 sex=0(未设置),mySex 为空字符串,
导致 && mySex 判断为 false,求婚按钮被隐藏无任何提示。

修复:
1. 将 lkddi.sex 更新为 1(男)
2. 新增「未设置性别」提示块:
   - 当前用户未设性别 + 对方有性别 + 对方未婚 时显示
   - 灰色虚线样式,hover 提示「请到个人资料页设置性别后即可求婚」
   - 不再静默隐藏,避免用户困惑
2026-03-01 17:12:23 +08:00
lkddi c8c1943f85 功能:商店购买其他商品类型时广播公屏通知
周卡 / 改名卡 / 求婚戒指 / 自动钓鱼卡购买后
各自向聊天室公屏(系统传音,紫色)广播欢迎语:
  📅 周卡:「登录时将自动触发!」
  🎫 一次性道具:「购买了XXX道具!」
  💍 戒指:「不知道打算送给谁呢?」
  🎣 钓鱼卡:「开启了X小时的自动钓鱼模式!」
单次特效卡仍由原 play_effect 分支广播(橙色)
2026-03-01 17:04:11 +08:00
lkddi bf001a6cf6 优化:商店周卡/道具/戒指/钓鱼卡购买前弹确认窗口,购买成功后 Toast 提示
- 点击购买按钮 → 弹出 chatDialog.confirm 确认窗口
  「确认花费 🪙 X 金币购买【XXX】吗?」
- 确认后才调用 buyItem;取消则不执行
- 购买成功后:showShopToast「 XXX 购买成功!」
- 商店保持打开(不再 close)让用户看到分组标题徽章更新
2026-03-01 16:58:29 +08:00
lkddi c72309aa16 优化:周卡分组标题显示当前已激活的特效名称
购买了全屏特效(周卡)后,商店「📅 周卡」分组标题旁
显示绿色徽章「 已激活:XXX」,与自动钓鱼卡的剩余时间
徽章风格统一。
2026-03-01 16:55:12 +08:00
lkddi fc4c0c543e 优化:自动钓鱼卡剩余时间徽章移至分组标题旁
商品图标上去掉紫色时间徽章(避免每张卡都显示同值造成误解);
改为在「🎣 自动钓鱼卡」分组标题后方统一显示「 剩余 X 小时」紫色标签,
仅持有有效卡时出现。
2026-03-01 16:52:59 +08:00
lkddi 759fb6deae 功能:后台新增商店管理页面(站长菜单)
- 路由:GET/POST/PUT/PATCH/DELETE /admin/shop
- 控制器:Admin/ShopItemController(index/store/update/toggle/destroy)
- 视图:admin/shop/index.blade.php
  - 表格展示所有商品(名称/类型色标/价格/有效期/排序/状态)
  - Alpine.js 弹窗新增/编辑(支持全字段)
  - 上下架一键切换(PATCH toggle)
  - 删除按键(含二次确认)
- 侧边栏:VIP 下方新增「🛒 商店管理」链接
- 权限:superlevel 可查看/编辑;id=1 可新增/删除
2026-03-01 16:47:34 +08:00
lkddi 0ea6ea206c 安全:服务端校验钓鱼等待时间,防止前端篡改 wait_time
cast() 将 {token, cast_at, wait_time} 以 JSON 存入 Redis;
reel() 在验 token 后额外校验:
  elapsed = now() - cast_at >= wait_time - 1(含1秒容差)
  未满足则返回422「鱼还没上钩,别急!」

即使用户通过 DevTools 将 wait_time 改为 0,
服务端仍按实际 wait_time 拒绝过早的收竿请求。
2026-03-01 16:33:32 +08:00
lkddi 168bc002f9 营销:自动钓鱼卡用户收竿消息末尾附加购买推广标签
使用自动钓鱼卡时,钓鱼播报消息末尾显示紫色小标签
「🎣 自动钓鱼卡」,其他玩家点击可直接打开商店购买。

* 仅检测到用户当前持有有效自动钓鱼卡时才附加
* 通过 onclick=window.openShopModal() 触发商店弹窗
2026-03-01 16:30:15 +08:00
lkddi 303c5e2a60 功能:自动钓鱼卡持续循环钓鱼
有自动钓鱼卡时:
- 点一次「钓鱼」自动循环:抛竿→收竿→冷却→抛竿...
- 冷却期间按钮显示倒计时「 冷却 Xs」
- 屏幕右下角显示「🛑 停止自动钓鱼」悬浮按钮
- 点击停止或卡到期后自动退出循环
- 出错时也自动停止循环
2026-03-01 16:26:15 +08:00
lkddi bd1e247fcf 优化:浮漂下沉动画延长至 1.5s,视觉更自然 2026-03-01 16:22:42 +08:00
lkddi 03e7f260b2 功能:自动钓鱼卡新增 72小时卡(15000金币) 2026-03-01 16:20:34 +08:00
lkddi 63679a622f 功能:随机浮漂钓鱼防挂机 + 商店自动钓鱼卡
核心变更:
1. FishingController 重写
   - cast(): 生成随机浮漂坐标(x/y%) + 一次性 token
   - reel(): 必须携带 token 才能收竿(防脚本绕过)
   - 检测自动钓鱼卡剩余时间并返回给前端

2. 前端钓鱼逻辑重写
   - 抛竿后显示随机位置 🪝 浮漂动画(全屏飘动)
   - 鱼上钩时浮漂「下沉」动画,8秒内点击浮漂才能收竿
   - 超时未点击:鱼跑了,token 也失效
   - 持有自动钓鱼卡:自动点击,紫色提示剩余时间

3. 商店新增「🎣 自动钓鱼卡」分组
   - 3档:2h(800金)/8h(2500金)/24h(6000金)
   - 图标徽章显示剩余有效时间(紫色)
   - 购买后即时激活,无需手动操作

4. 数据库
   - shop_items.type 加 auto_fishing 枚举
   - shop_items.duration_minutes 新字段(分钟精度)
   - Seeder 写入 3 张卡数据

防挂机原理:按钮 → 浮漂随机位置,脚本无法固定坐标点击
2026-03-01 16:19:45 +08:00
lkddi e0c15b437e 修复:求婚按钮异性判断 - 统一 sex 字段格式
根因:sex 字段数据库存整数(0/1/2),但前后端判断混用了
字符串('男'/'女')导致比较永远错误。

修复三处:
1. UserController::show() - sex 返回统一转字符串(1→'男' 2→'女' 其他→'')
2. frame.blade.php - chatContext.userSex 注入时同样转字符串
3. MarriageService::propose() - 后端性别校验改用整数(1/2)比较

逻辑链路:
- 未设置性别(sex=0) → '' → x-show && userInfo.sex 为'' falsy → 按钮隐藏 ✓
- 同性(如两个男) → '男'==='男' → !== 为false → 按钮隐藏 ✓
- 异性(男+女) → '男'!=='女' → 按钮显示 ✓
2026-03-01 16:04:32 +08:00
lkddi 954a078d63 修复:自动存点不再覆盖有职务用户的等级
AutoSaveExp::processUser() 第3步升降级逻辑重构:
- 有在职职务的用户:等级强制锁定为 position.level
  (防止自动存点按经验值降低/覆盖职务对应等级)
- 管理员(>= superLevel):不变动
- 普通用户:照旧按经验自动升降级

同时在 refresh() 后加 load('activePosition.position')
确保职务关联数据已就绪。
2026-03-01 16:00:10 +08:00
lkddi 9139108744 修复:求婚按钮异性判断,mySex 存入 Alpine data 避免 x-show 内 window 访问失效 2026-03-01 15:56:12 +08:00
lkddi 5cf87391b6 优化:商店分组排序调整,改名卡(道具)移至戒指后面 2026-03-01 15:52:26 +08:00
lkddi f9312475d0 优化:商店浮窗宽度 520→800px,网格 2列→4列 2026-03-01 15:49:38 +08:00
lkddi 3132f013b7 修复:无戒指时点确定改为打开商店浮窗而非新标签页 2026-03-01 15:47:01 +08:00
lkddi 4a9730c38d 功能:浮窗商店同步加「💍 求婚戒指」分组
toolbar.blade.php renderShop 补充:
- ring 类型分组(存入背包,求婚时消耗)
- 图标持有数量红色徽章
- 卡片下方亲密度/魅力加成标注
- 购买按钮走现有 buyItem 流程(后端 buyRing 处理)
2026-03-01 15:45:13 +08:00
lkddi 29e43507ac 功能:商店完善戒指板块
迁移:
- 2026_03_01_153959:shop_items 增加 intimacy_bonus/charm_bonus 字段

Seeder(RingItemsSeeder):
- 银质戒指 500金  亲密+10 魅力+30
- 黄金戒指 2000金 亲密+30 魅力+80
- 红宝石戒指 8000金 亲密+80 魅力+200
- 钻石戒指 30000金 亲密+200 魅力+500
- 传说神戒 100000金 亲密+500 魅力+1000

ShopService:
- buyItem() 分支加 ring 类型
- buyRing():扣金币 + 写入 active UserPurchase(背包持有)

ShopController::items():
- 返回 intimacy_bonus/charm_bonus
- 统计 ring_counts(各戒指持有数量)

shop-panel.blade.php:
- 新增「💍 求婚戒指」分组(排在最后)
- 图标右上角红色数字徽章(持有时)
- 卡片下方显示亲密度/魅力加成
- 购买按钮与现有逻辑复用
2026-03-01 15:42:25 +08:00
lkddi 1f33013216 优化:求婚前先检查戒指库存,无戒指则引导购买
openProposeModal() 改为 async:
1. 先调 /marriage/rings 检查背包
2. 无戒指 → 弹确认框 → 同意则新窗口打开 /shop
3. 有戒指 → 直接传入弹窗(openWithRings),避免二次请求

marriageProposeModal 新增 openWithRings(username, rings)
方法,接收预加载列表,无 loading 状态直接展示。
2026-03-01 15:38:52 +08:00
lkddi e5a35779f8 修复:UserPurchase 模型补充 item() 关联别名
婚姻系统 whereHas('item') / with('item') 需要此方法,
原模型只有 shopItem(),现在加 item() 作为别名指向同一外键。
2026-03-01 15:36:22 +08:00
lkddi e20f94fe17 修复:求婚限制异性(前端隐藏按钮 + 后端拦截校验)
前端(user-actions.blade.php):
- 求婚按钮增加三重条件:对方未婚 + 双方性别均已填写 + 性别不同

后端(MarriageService::propose):
- 增加异性校验:两方性别必须为「男/女」且不同
- 报错:只有男女双方才能互相求婚

frame.blade.php:
- chatContext 注入 userSex(当前用户性别)供前端判断
2026-03-01 15:34:36 +08:00
lkddi 877fd1935f 功能:婚姻系统第12步(前端交互)
chat.js:
- 监听婚姻全局广播(MarriageAccepted/Divorced/WeddingCelebration)
- initMarriagePrivateChannel() 监听私人频道
  (求婚/拒绝/过期/离婚申请/红包领取)

frame.blade.php:
- chatContext.marriage 注入所有婚姻 API URL
- 引入 marriage-modals.blade.php 弹窗组件

marriage-modals.blade.php(新建):
- 求婚弹窗(选戒指→求婚)
- 收到求婚弹窗(接受/拒绝)
- 结婚成功公告弹窗(可跳转婚礼设置)
- 婚礼设置弹窗(档位/支付方式/立即OR定时)
- 婚礼红包领取弹窗
- 所有 WebSocket 事件处理

user-actions.blade.php:
- 名片加「💍 求婚」按钮(对方未婚时)
- 名片加「💑 已婚状态」标签(对方已婚时)
- fetchUser 同步拉取对方婚姻状态

MarriageController:
- targetStatus 返回增加 status/partner_name/marriage_id
- myRings 返回增加 status/intimacy_bonus/charm_bonus
2026-03-01 15:31:07 +08:00
lkddi 37af4ba975 修复:婚姻管理总览页 Tailwind v4 动态类无法构建问题
- 「婚礼档位」按钮改用 style 内联颜色(bg-pink-600 未被扫描)
- 快捷入口4张卡片 hover 颜色改为完整静态类名
  (Tailwind v4 无法扫描动态拼接 bg-xx / text-xx)
- npm run build 验证已生效
2026-03-01 15:23:35 +08:00
lkddi 143601c251 功能:婚姻系统第11步(Horizon Jobs + 定时任务)
5个 Job:
- ExpireMarriageProposals:每5分钟扫描超时求婚(广播通知)
- TriggerScheduledWeddings:每5分钟触发定时婚礼(广播庆典)
- AutoExpireDivorces:每小时处理离婚超时自动解除
- ExpireWeddingEnvelopes:每小时清理过期红包
- ProcessMarriageIntimacy:每日00:05全量亲密度时间奖励

console.php 注册5个 Schedule
2026-03-01 15:16:46 +08:00
lkddi d2797d5b59 功能:婚姻系统第9步(后台管理页面)
Admin/MarriageManagerController:
- index() 总览统计卡片
- list() 婚姻列表(筛选/强制离婚/取消求婚)
- proposals() 求婚记录
- ceremonies() 婚礼红包记录
- claimDetail() 红包领取明细
- intimacyLogs() 亲密度日志(来源筛选)
- configs/updateConfigs 参数配置(批量保存)
- tiers/updateTier 婚礼档位管理

Views(7个页面):admin/marriages/{index|list|configs|tiers|ceremonies|claim-detail|proposals|intimacy-logs}
侧边栏:superlevel 区块新增「💒 婚姻管理」入口
2026-03-01 15:15:03 +08:00
lkddi 4f49fb7ce8 功能:婚姻系统第8&10步(Controllers + Events + 路由)
- MarriageController:propose/accept/reject/divorce/confirmDivorce/status
- WeddingController:tiers/setup(立即触发)/claim/envelopeStatus
- 8个 WebSocket Events:
  Marriage{Proposed|Accepted|Rejected|Expired|Divorced|DivorceRequested}
  WeddingCelebration / EnvelopeClaimed
- 前台路由:marriage.* + wedding.*
- 后台路由:admin.marriages.*(superlevel 层)
2026-03-01 15:09:33 +08:00
lkddi 384cf8e078 功能:婚姻系统第7步(WeddingService)
- setup():验证余额、立即/定时扣款或冻结
- trigger():获取在线用户、随机红包分配、写入 claims
- claim():领取红包、金币入账(乐观锁防并发重复领)
- distributeRedPacket():二倍均值算法,总和精确等于 total
- refundCeremony():在线为0时退还冻结金币
2026-03-01 15:04:49 +08:00
lkddi 2d07b032d9 功能:婚姻系统第4-6步(Services + Models)
Step 4 - MarriageConfigService:
- 带60min Cache 的配置读取/写入
- 支持单项/分组/全量读取,管理员保存后自动清缓存

Step 5 - MarriageIntimacyService:
- 亲密度增加 + 日志写入 + 等级自动更新
- Redis 每日上限计数器(各来源独立控制)
- onFlowerSent/onPrivateChat/onlineTick 接入点方法
- dailyBatch 批量处理(Horizon Job 用)

Step 6 - MarriageService(核心业务):
- propose/accept/reject/divorce/confirmDivorce/forceDissolve
- 所有金币魅力通过 UserCurrencyService 统一记账
- 冷静期检查/超时处理/强制离婚金币全转对方

Models 改良(Marriage/MarriageConfig/MarriageIntimacyLog)
2026-03-01 15:03:34 +08:00
lkddi 11dcb03924 功能:婚姻系统第1-3步(枚举/迁移/Seeder)
Step 1 - 枚举扩展:
- 新增 IntimacySource 枚举(7种亲密度来源)
- CurrencySource 追加7个婚姻相关来源

Step 2 - 数据库迁移(6张表):
- marriage_configs(约30条可配置参数)
- marriage_intimacy_logs(亲密度变更日志)
- wedding_tiers(5档婚礼配置)
- wedding_ceremonies(婚礼仪式记录)
- wedding_envelope_claims(红包领取记录)
- marriages 表改良(新增全部业务字段)
- users.frozen_jjb(定时婚礼金币冻结)
- shop_items.type 枚举添加 ring 类型

Step 3 - Seeder:
- 28条婚姻参数默认配置
- 5个婚礼档位
- 3种戒指道具(银/金/钻)
2026-03-01 14:56:47 +08:00
lkddi 73badefcc5 文档:用户名禁词管理标记为已完成 2026-03-01 14:17:20 +08:00
lkddi 477bba3003 修复:批量添加禁用词 UTF-8 编码错误
- 新增 sanitizeUtf8() 私有方法:
  去除 BOM、零宽字符、非法 UTF-8 字节、控制字符
  防止从聊天/文档复制时混入隐藏字符导致 json_encode 失败
- batchStore() 先净化 words 和 reason 输入
- preg_split 加 /u 修饰符(完整 Unicode 支持)
- 响应加 JSON_UNESCAPED_UNICODE 防中文被转义
2026-03-01 14:10:13 +08:00
lkddi f114c6b168 修复:新增独立迁移将 reserved_until 改为 nullable
上次 add_type_reason 迁移已在生产跑过(无 change),
导致 permanent 类型插入 NULL 时报 1048 错误。
新建专用迁移用 DB::statement ALTER TABLE 直接生效,
绕过 doctrine/dbal ->change() 的潜在兼容问题。
2026-03-01 14:07:42 +08:00
lkddi 211075b77c 修复:reserved_until 列允许 NULL(permanent 禁用词无到期时间)
- 迁移文件补充 ->nullable()->change() 使 reserved_until 兼容 permanent 类型
- Tinker 直接执行 ALTER TABLE 修复已运行的迁移(无需回滚)
2026-03-01 14:06:35 +08:00
lkddi 632a4240c4 功能:禁用词管理支持批量添加
- 新增 ForbiddenUsernameController::batchStore()
  支持换行、逗号、中文逗号、空格多种分隔格式
  自动去重、跳过已存在词语、忽略超长词
  返回成功数/跳过数详细提示
- 新增路由 POST /admin/forbidden-usernames/batch
- View 新增卡片加「单个/批量」两 Tab 切换
  批量 Tab 使用 textarea 多行输入
2026-03-01 14:04:28 +08:00
lkddi fc495ccceb 功能:禁用用户名管理(永久禁词列表)
数据库:
- 新增迁移 username_blacklist 表加 type/reason 列
  type: temp(改名30天保留)| permanent(管理员永久禁用)
  reason: 禁用原因备注(最长100字符)

核心逻辑:
- UsernameBlacklist::isBlocked() 同时拦截两种类型
  也包含 isReserved() 兼容旧调用
  增加 scopePermanent()/scopeTemp() 查询作用域
- AuthController 注册时加 isBlocked() 拦截
  禁词/保留期内均不可注册
- ShopService::useRenameCard() 已有 isReserved() 调用
  因已改用 isBlocked() 别名,无需修改

后台:
- ForbiddenUsernameController:index/store/update/destroy
- 路由:/admin/forbidden-usernames(chat.site_owner 中间件)
- 视图:admin/forbidden-usernames/index.blade.php
  新增表单、关键词搜索、分页、行内编辑原因、删除
- 侧边栏加「🚫 禁用用户名」入口(仅站长可见)
2026-03-01 14:00:38 +08:00
lkddi 312b92a81d 文档/调整:好友面板完成,更新 DEVELOPMENT.md
- DEVELOPMENT.md:好友系统 [ ] → [x],补充功能细节
- toolbar.blade.php:好友按钮移至「呼叫」后(用户调整位置)
2026-03-01 13:51:27 +08:00
lkddi 8120058948 重构:好友面板独立为 friend-panel.blade.php
- 新建 resources/views/chat/partials/friend-panel.blade.php
  包含完整的 style / HTML / JS
  结构完全干净,无嵌套错误
- toolbar.blade.php:
  恢复至干净基础版本(回滚损坏内容)
  添加「好友」按钮(openFriendPanel)
  通过 @include('chat.partials.friend-panel') 引入面板
- FriendController::index() 返回 sub_time 和 pending 列表
2026-03-01 13:47:51 +08:00
lkddi 4ced484419 功能:好友列表面板
后端(FriendController::index):
- 返回 sub_time 添加时间
- 新增 pending 列表(对方加了我但我未回加)
  包含用户信息 + added_at(对方添加我的时间)

前端(toolbar.blade.php):
- 工具栏顶部加「好友」按钮(openFriendPanel)
- 好友弹窗面板(#friend-panel):
  ① 搜索栏:输入用户名 Enter/按钮添加好友
  ② 「我关注的好友」列表:头像/用户名/互相徽章/
     添加时间/删除按钮
  ③ 「对方已加我,待我回加」列表:头像/用户名/
     对方添加时间/回加按钮
  ④ 面板顶部提示区(成功/失败消息)
- 所有添加/删除调用与双击用户卡片完全相同的接口
  (/friend/{username}/add、/friend/{username}/remove)
2026-03-01 13:38:30 +08:00
lkddi 48b31e7cff 修复:管理员进房烟花无声问题(AudioContext suspended)
根本原因:管理员进房特效在 800ms 后自动触发,
此时用户尚未与新页面交互,浏览器的 AudioContext
处于 suspended 状态,之前代码同步调用 resume()
但未 await 其 Promise,导致音频节点创建后无法出声。

修复方式:
- play() 和 ding() 均改为先检查 ctx.state
- 若为 suspended,用 ctx.resume().then(...) 链式执行
- resolver 成功后真正创建音频节点并播放
- 若浏览器拒绝 resume(无用户手势),catch 静默处理

此修复使所有自动触发的音效(进房烟花、任命公告等)
在 AudioContext 未激活时也能正确播放。
2026-03-01 13:32:00 +08:00
lkddi 58b63fa8d3 功能:大卡片/小卡片弹出时播放叮咚通知音
effect-sounds.js:
- 新增 ding() 函数:A5(880Hz) + E5(659Hz) 两音叮咚
  每音含基音×2.76铃铛泛音,快冲击+铃铛式衰减
  自动检查 chat_sound_muted 禁音标志
- 导出 ding 至返回对象,底部暴露 window.chatSound = {ding}

toast-notification.blade.php:
- chatToast.show() 中 appendChild 后调用 window.chatSound.ding()

scripts.blade.php:
- chatBanner.show() 开头调用 window.chatSound.ding()
2026-03-01 13:28:19 +08:00
lkddi dac7750fe1 功能:特效音效三项优化 + 禁音开关
音效改进(effect-sounds.js):
1. 雷电 - 三层合成更贴近真实:
   ①放电啪声(带通噪声 ~50ms)
   ②低频轰鸣(120→38Hz 扫频,快冲击 2s 衰减)
   ③极低频滚动余韵(55→22Hz,缓慢堆积 3.6s 长衰减)
2. 下雨 - 音量 0.40→0.15,时长与视觉效果统一(8000ms)
3. 下雪 - 移除风声,只保留五声音阶铃音(C/E/G/C)
   铃音加第二泛音(×2.76倍频)模拟真实铃铛共鸣感
   8次随机铃声分布在 10 秒内

禁音开关:
- input-bar.blade.php:悄悄话旁新增「🔇 禁音」复选框
- scripts.blade.php:toggleSoundMute() 函数,
  localStorage chat_sound_muted 持久化,
  DOMContentLoaded 恢复复选框状态
- effect-sounds.js:play() 先检查 chat_sound_muted 标志
2026-03-01 13:19:24 +08:00
lkddi 1d7aa636a0 功能:4种全屏特效增加 Web Audio API 实时合成音效
新建 public/js/effects/effect-sounds.js:
- 雷电:低频白噪声爆裂 + 雷鸣渐衰(10次,与视觉同步)
- 烟花:发射滑音(200→700Hz)+ 带通噪声爆炸(9轮)
- 下雨:双层带通白噪声(1200Hz+3500Hz)持续淡入淡出
- 下雪:4000Hz+高频风声 + 五声音阶轻柔铃音(5次随机)
- 所有音效纯 Web Audio API 合成,无外部音频文件
- 旧 AudioContext 若被 suspended 自动 resume

effect-manager.js:
- play() 调用 EffectSounds.play(type) 同步触发音效
- _cleanup() 调用 EffectSounds.stop() 兜底停止

frame.blade.php:effect-sounds.js 在 effect-manager 前引入
2026-03-01 13:07:36 +08:00
lkddi 2947f0f741 功能:用户列表增加在线状态列,支持点击排序
- UserManagerController 注入 ChatStateService,从 Redis 聚合
  所有活跃房间在线用户名(跨房间去重)
- 排序白名单加入 'online',在线排序用 orderByRaw CASE WHEN 虚拟列
  desc = 在线用户优先显示,asc = 离线用户优先
- 视图表头加「在线 ↕」可排序列(绿色高亮箭头)
- 每行显示绿色实心点+「在线」/灰点+「离线」小徽章
- my-duty-logs 分页已有 paginate(30)+withQueryString+links(),无需改动
2026-03-01 12:54:34 +08:00
lkddi 0dff79dd51 修复:6列统计卡片改用 inline style,规避 Tailwind 未编译 grid-cols-6 2026-03-01 12:26:23 +08:00
lkddi 769632dea8 优化:履职记录统计卡片改为固定6列一行,紧凑尺寸 2026-03-01 12:24:15 +08:00
lkddi 855f169516 功能:后台「我的履职记录」页面
- 侧边栏「我的履职记录」链接,位于「任命管理」上方
- 路由:GET /admin/my-duty-logs → appointments.my-duty-logs
- 控制器:AppointmentController::myDutyLogs()
  支持按操作类型、日期范围筛选,分页,withQueryString()
- 视图:admin/appointments/my-duty-logs.blade.php
  顶部 6 格汇总统计(奖励/踢出/禁言/警告/任命/撤职)
  每张卡片可点击快速按类型筛选
  表格显示:操作时间、类型 Badge、操作对象、所属部门·职务、金币金额、备注
2026-03-01 12:22:13 +08:00
lkddi f4de31f92b 优化:奖励消息发送者显示「部门·职务」
$positionName 由单一职务名改为「部门名 · 职务名」格式,
例如:「生产部 · 系长」,无部门时退化为仅显示职务名,
超管保持「超级管理员」不变。
公告文案、私信、Toast 通知均同步更新。
2026-03-01 12:17:16 +08:00
lkddi 3d7b86f06d 功能:奖励发放聊天室公告 + 右下角 Toast 通知卡片
后端(AdminCommandController::reward):
- 新增聊天室公开公告消息(系统公告,所有在场用户可见)
- 接收者私信附带 toast_notification 字段触发前端小卡片
- 公告文案:「🪙 [职务人] 向 [目标] 发放了 [N] 枚奖励金币!」

前端:
- 新建 chat/partials/toast-notification.blade.php:
  全局右下角 Toast 组件,window.chatToast.show() API
  支持 title/message/icon/color/duration/action 配置
  多条 Toast 从右下角向上堆叠,独立计时、独立关闭
- chat:message 事件监听中检测 toast_notification 字段,
  自动弹出右下角通知卡片(仅接收方可见)
- showFriendToast 迁移至 window.chatToast.show(),
  删除 80 行旧实现,代码量净减
- frame.blade.php 引入新 partial

DEVELOPMENT.md:
- 新增 §7.9 chatToast 完整文档(API、使用场景、迁移说明)
- 原 chatBanner 章节编号改为 §7.10
2026-03-01 12:15:18 +08:00
lkddi 2ae3d83349 优化:确认发放按钮圆角 6px→20px,视觉更圆润 2026-03-01 12:08:05 +08:00
lkddi 9da0d83914 优化:确认发放按钮风格改为与全局弹窗一致
- 输入框+按钮回到同一行(align-items:stretch)
- 按钮完全复刻 global-dialog 确认按钮样式:
  padding:9px, border-radius:6px, font-size:13px, font-weight:bold
- 背景色 #f59e0b(与弹窗标题栏橙色对应)
- :style 仅控制 opacity,background 两分支都明确写入
2026-03-01 12:07:12 +08:00
lkddi 5180526821 优化:确认发放按钮改为全宽独立一行,风格同弹窗按钮
输入框独占一行,按钮在输入框下方全宽显示:
- 宽度 100%,高度 48px,字号 16px,字间距 2px
- 琥珀橙色 #f59e0b,与弹窗头部色调呼应
- 禁用时 opacity:0.45,启用时 box-shadow 投影
- 符合截图中弹窗确定按钮的视觉风格
2026-03-01 12:04:05 +08:00
lkddi 0d693eef5f 优化:输入框与确认按钮等高(align-items:stretch)
flex 容器改为 align-items:stretch,按钮去掉固定 height
改为 align-self:stretch,自动撑满与输入框相同高度,
视觉上两者完全对齐。
2026-03-01 12:01:53 +08:00
lkddi 4207528043 修复:确认发放按钮背景色始终通过 :style 注入
将 background 从 static style 移入 :style 绑定,
两种状态(启用/禁用)均显式包含 background,
彻底避免 Alpine :style 动态绑定覆盖静态 style background 的问题。

按钮颜色:橙红渐变 #ea580c→#dc2626
- 启用:opacity:1 + box-shadow 投影
- 禁用:opacity:0.4 + no-shadow(未输入金额时)
2026-03-01 12:00:03 +08:00
lkddi 96c472bfb9 修复:弹窗额度4列布局+确认按钮背景色
1. 4列布局:x-show 与 display:grid 分离到两层 div,
   避免 Alpine x-show 显示时把 display:grid 覆盖为 block

2. 确认按钮::style 改为始终返回 opacity 值而非空字符串,
   避免 Alpine :style 绑定空值时清除静态 style 的 background,
   按钮现为橙红渐变(#ea580c→#dc2626)+红色投影,
   禁用状态 opacity:0.45 降亮+cursor:not-allowed
2026-03-01 11:58:08 +08:00
lkddi 4ba5a88fc2 优化:奖励弹窗加宽+4列额度+最近10条记录+确认按钮醒目
- 弹窗宽度 320→520px(max-width:95vw 自适应)
- 额度四格改为一行4列(单次上限/单日上限/今日已发/剩余额度)
- 确认按钮改为橙红渐变+投影,视觉更突出
- 输入框下方显示最近10条发放记录(目标/金额/时间)
- 发放成功后实时在历史列表头部插入新纪录
- rewardQuota 接口统一返回 recent_rewards(最近10条)
2026-03-01 11:55:29 +08:00
lkddi 21cabb08c9 功能:奖励金币改为独立弹窗(展示额度信息)
- 点击「送金币」按钮打开独立弹窗,不再内联在用户名片中
- 弹窗展示 4 格额度信息:单次上限、单日上限、今日已发、剩余额度
- 新增 GET /command/reward-quota 接口(rewardQuota 方法)
  返回当前操作人实时额度,超管返回全部不限
- 发放成功后页面内实时更新今日已发/剩余额度,无需刷新
- 移除原内联奖励面板,action 改为调用全局 openRewardModal()
2026-03-01 11:50:12 +08:00
lkddi 3d30d7e811 功能:id=1 超管无需职务即可发放奖励金币
前端(frame.blade.php):
- Auth::id()===1 时 myMaxReward=-1(不限),送金币按钮始终显示

后端(AdminCommandController::reward):
- isSuperAdmin=true 时跳过职务检查和①②③④限额校验
- 全局接收次数⑤对超管同样生效(防止刷奖励)
- 履职记录 user_position_id 允许 null(超管无职务时)
- 发放备注改用 positionName 变量(超管显示「超级管理员」)

顺带修复:②操作人单日累计统计之前误查 target_user_id,
已改为正确的 user_id(操作人自己今日已发总额)
2026-03-01 11:43:51 +08:00
lkddi cc1278ffcb 修复:openUserCard 剥除消息中的装饰括号避免 404
问题:部分自动动作/系统消息用「【username】」格式显示用户名,
      双击时把「【」前缀一并传给 openUserCard,导致:
      GET /user/【lkddi → 404

修复:在 openUserCard 入口统一用正则清洗 【】[]
      等装饰字符,再传给 fetchUser 查询。
2026-03-01 11:42:13 +08:00
lkddi 8dcf23d7e4 修复:单次上限三态逻辑(null=不限/-1=不限/0=禁止/正整数=有上限)
frame.blade.php:
- max_reward=null → myMaxReward=-1(不限,有权限)
- max_reward=0   → myMaxReward=0(禁止发放)
- max_reward=N   → myMaxReward=N(有具体上限)

user-actions.blade.php:
- 按钮显示条件:myMaxReward !== 0(-1 和正整数都显示)
- 面板上限文字:-1 显示「不限」,正整数显示具体数值
- sendReward 校验:0=禁止阻断,-1=不限跳过上限校验,N=有上限
- 输入框 :max:-1 时上限 999999(实际不限),N 时上限 N
2026-03-01 11:39:28 +08:00
lkddi 57f515e2eb 修复:去掉多余的 @section 导致的 section 嵌套报错
将 inlinePatch script 块迁移回 @section('content') 内部,
彻底移除 @push/@endpush/@section('scripts') 等无效指令。

根本原因:admin layout 无 @yield('scripts'),在 @endsection 后
再开 @push 或 @section 会触发 'Cannot end a section' 异常。
2026-03-01 11:30:23 +08:00
lkddi 89d93c92ed 功能:职务列表内联编辑 + 全局奖励配置自动保存
职务列表三列内联编辑(失焦/回车自动保存,无需打开编辑弹窗):
- 人数上限:PATCH max_persons
- 单次上限:PATCH max_reward
- 单日上限:PATCH daily_reward_limit
保存成功显示短暂绿色 ✓,失败显示红色错误提示

全局奖励接收次数配置改为 AJAX 自动保存,失焦/回车触发,
无需保存按钮(原表单已移除)

新增接口:
- PATCH /admin/positions/{position}/patch(quickPatch)
- POST  /admin/positions/reward-config(saveRewardConfig,兼容 JSON + 重定向)
2026-03-01 11:28:15 +08:00
lkddi baaa7087b0 功能:全局奖励接收次数上限(职务管理页配置)
新增全局 sysparam 配置 reward_recipient_daily_max:
- 控制每位用户单日内从所有职务持有者处累计接收奖励的最高次数
- 0 = 不限制

后端变更:
- PositionController::saveRewardConfig() 保存配置
- POST admin/positions/reward-config 路由
- AdminCommandController::reward() 新增第④层校验:
  全局次数上限(优先级低于职务级别的 recipient_daily_limit)

视图变更:
- 职务管理页顶部加橙色配置卡片(行内表单,即改即存)
- 显示当前全局配置值
2026-03-01 11:22:02 +08:00
lkddi a145c6fc0a 修复+改进:职务奖励次数上限和后台列表展示
修复:
- recipient_daily_limit 统计逻辑修正:移除 user_id 过滤,
  改为统计今日所有职务持有者对同一接收者的累计发放次数
  (上限含义:该用户今日最多从所有职务人员处收到 N 次奖励)

改进:
- 后台职务列表新增「单日上限」列显示 daily_reward_limit
- 「奖励上限」列改名为「单次上限」更准确
- 两列均支持 null(不限)/ 0(禁止)/ 数值 三种状态区分显示
2026-03-01 11:16:22 +08:00
lkddi 41d4acdd72 修复:职务编辑表单中 0 值被 || '' 误转为空字符串
openEdit() 中三个奖励字段从 OR 运算符改为显式 null 检查:
- max_reward: pos.max_reward !== null ...
- daily_reward_limit 同上
- recipient_daily_limit 同上
- max_persons 改用 JS ?? 空值合并运算符

根本原因:0 在 JS 中是 falsy,pos.max_reward || '' 会将 0 变成 '',
提交到后端后 nullable 规则将空字符串解析为 null 覆盖掉用户的 0 设置。
2026-03-01 11:11:57 +08:00
lkddi ff57afe388 功能:职务奖励金币发放系统
数据库:
- positions 新增 daily_reward_limit(单日累计上限)
- positions 新增 recipient_daily_limit(同一接收者每日次数上限)

后端:
- CurrencySource::POSITION_REWARD 新枚举值
- AdminCommandController::reward() 三层限额校验
  ① 单次上限 ② 单日累计上限 ③ 同一接收者每日次数
  写履职记录(PositionAuthorityLog)+ UserCurrencyService
  聊天室悄悄话通知接收者
- POST /command/reward 路由注册

前端(user-actions.blade.php):
- 名片按钮行 2+1 布局(加好友/送礼物/送金币)
- 送金币仅在 myMaxReward>0 时显示(职务持有者)
- 内联奖励金币面板:金额输入 + 确认发放 + 说明文字
- sendReward() 前端校验 + API 调用 + chatDialog 反馈

后台(positions/index):
- 编辑表单新增两个奖励限额字段
- PositionController 验证规则同步更新
2026-03-01 11:09:29 +08:00
lkddi 476499832f 功能:勤务台榜单新增管理操作次数 + 奖励金币次数统计
DutyHallController:
- 新增 position_authority_logs 关联查询
- 统计管理操作次数(warn/kick/mute/banip/other,排除人事任免)
- 统计奖励金币次数及累计金额(action_type=reward)
- 时间范围统一过滤(日/周/月/总)
- 合并两表数据到榜单 Collection

duty-hall/index.blade.php:
- 表格扩展为 6 列:名次、成员、在线时长、登录次数、管理操作、奖励金币
- 奖励金币栏 hover 显示次数+总金额 tooltip
- 移动端显示紧凑卡片(管理/奖励只在 >0 时显示)
- 底部图例说明各列含义
2026-03-01 10:51:48 +08:00
lkddi 5a7d1565e5 修复:channels.php 移除 int 类型提示,改用强转比较防兼容性问题
int $id 类型提示在某些 PHP 版本下对字符串参数可能失败,
统一改为 (int) 强转比较,与 App.Models.User.{id} 写法一致。
2026-03-01 01:54:19 +08:00
lkddi 7bae5e56ff 修复:私有频道改用数字 ID,解决中文用户名导致 Pusher 频道名非法
错误原因:Pusher 频道名只允许 [a-zA-Z0-9_\-=@,.],
中文用户名(如「超级舞魅」)用于 private-user.{username} 导致
PusherException: Invalid channel name。

修复方案(改用数字 ID):
- FriendAdded/FriendRemoved 构造加 toUserId 参数
- broadcastOn() 改为 PrivateChannel('user.' . $toUserId)
- FriendController 传入 $target->id / $targetUser->id
- channels.php 鉴权改为 'user.{id}',核对 $user->id 数字相等
- frame.blade.php chatContext 加 userId
- scripts.blade.php Echo.private 改用 userId 订阅
2026-03-01 01:41:04 +08:00
lkddi a44a9ce242 修复:回加好友成功后大卡片自动关闭
quickFriendAction 是 async 函数,await 完成后
检查按钮文字是否为 '',1.5 秒后调用 close() 关闭 banner。
2026-03-01 01:33:41 +08:00
lkddi f951ec428d 重构:聊天室所有 alert() 改为 window.chatDialog.alert()
scripts.blade.php 全部 21 处原生 alert() 替换:
- 成功类 → chatDialog.alert(..., '提示', '#16a34a')
- 失败/错误类 → chatDialog.alert(..., '操作失败', '#cc4444')
- 网络异常类 → chatDialog.alert(..., '网络异常', '#cc4444')
- 连接断开/踢出 → chatDialog.alert(..., '连接警告', '#b45309')
- 一般提示 → chatDialog.alert(..., '提示', '#336699')

DEVELOPMENT.md 新增 §7.9 window.chatBanner 使用文档
2026-03-01 01:32:20 +08:00
lkddi 5c53b8cf2f 功能:window.chatBanner 全局大卡片公共组件
前端:
- window.chatBanner.show(options) 全局 API,完全自定义:
  icon/title/name/body/sub/gradient/titleColor/autoClose/buttons
- window.chatBanner.close(id) 关闭指定 banner
- showFriendBanner / showAppointmentBanner 均改用 chatBanner 实现
- setupBannerNotification() 监听私有+房间频道的 BannerNotification 事件

后端:
- BannerNotification 事件(ShouldBroadcastNow),支持 user/room 双目标
- BannerBroadcastController(仅超级管理员路由,三层中间件保护)
- 内容字段 strip_tags 净化防 XSS,按钮 action 白名单校验

安全:
- window.chatBanner.show() 被人控制台调用只影响自己,无法推给他人
- HTTP 入口 POST /admin/banner/broadcast 仅超管可访问
2026-03-01 01:28:23 +08:00
lkddi 0f0691d037 修复:FriendAdded/FriendRemoved 改为 ShouldBroadcastNow
ShouldBroadcast 走队列(异步),不保证及时广播;
ShouldBroadcastNow 不走队列,与 MessageSent 一致,立即推送到 Reverb。
这是大卡弹窗收不到的真实原因。
2026-03-01 01:21:33 +08:00
lkddi 7985a9b0d7 修复:FriendAdded/FriendRemoved 加 broadcastAs() 修复私有频道事件名不匹配
前端 .listen('.FriendAdded') 匹配的是短名 FriendAdded,
但默认广播名是 App\Events\FriendAdded(全类名),导致监听器永远不触发。
加 broadcastAs() 返回短名后两端匹配,弹窗可正常弹出。
2026-03-01 01:13:50 +08:00
lkddi 779179af01 功能:好友添加通知改为居中大卡弹窗(同任命公告风格)
FriendAdded:
- 互相好友 → 绿色渐变大卡 + '你们现在互为好友 🎊',5秒自动消失
- 单向添加 → 蓝绿渐变大卡 + [ 回加好友] + [稍后再说] 按钮,手动关闭

FriendRemoved:保留右下角 Toast 通知

效果复用 appoint-pop 弹出动画关键帧
2026-03-01 01:09:37 +08:00
lkddi d60a225368 修复:好友悄悄话链接 href 出现字面 \'#\' 导致404问题
PHP 双引号字符串里单引号不需要反斜杠转义,
\'#\' 会原样输出 \'#\' 而非 '#',导致 href 跳转到错误 URL。
改为直接写 '#' 即可。
2026-03-01 01:05:47 +08:00
lkddi 212f7a0096 功能:好友悄悄话内嵌快捷操作链接
后端:
- notifyOnlineUser 生成带内联 <a> 标签的内容
- added 未互相 → 嵌入 ' 回加好友' 链接
- removed 互相  → 嵌入 '🗑️ 同步移除' 链接
- 链接调用全局 quickFriendAction(act, username, el)

前端:
- 新增 window.quickFriendAction() 全局函数
- 防重复点击(dataset.done 标记)
- 成功后更新链接文字 ' 已回加' / ' 已移除',不刷新页面
2026-03-01 01:03:10 +08:00
lkddi cc16f89bbe 修复:好友悄悄话文案根据互相状态精确区分
- notifyOnlineUser 加 $mutual 参数
- added + mutual=false → '但你还没有添加对方为好友'
- added + mutual=true  → '你们现在互为好友 🎉'
- removed + mutual=true  → '你的好友列表中仍保留对方'
- removed + mutual=false → '已将你从他的好友列表移除'
- 删除操作悄悄话改为灰色 (#6b7280),语义更准确
2026-03-01 00:59:10 +08:00
lkddi 3c2038e8fe 优化:好友通知弹窗根据互相状态显示不同内容
FriendAdded 事件:
- 新增 hasAddedBack 字段(B 是否已回加 A)
- Toast:已互相好友 → '你们现在互为好友 🎉'
- Toast:未回加 → '但你还没有添加对方为好友' + [ 回加] 一键操作按钮

FriendRemoved 事件:
- 新增 hadAddedBack 字段(之前是否互相好友)
- Toast:之前互相好友 → 提示 + [🗑️ 同步移除] 一键操作按钮
- Toast:单向好友 → 简单通知,无操作按钮

Toast 改进:
- 右上角 × 关闭按钮
- 快捷操作按钮支持 fetch 直接请求
- 完成后显示结果并自动关闭,延时改为 8 秒
2026-03-01 00:54:10 +08:00
lkddi 700ab9def4 feat: 好友系统全实现
后端:
- FriendController:add/remove/status/index 四个接口
- FriendAdded / FriendRemoved 广播事件(私有频道)
- channels.php 注册 user.{username} 私有频道鉴权
- routes/web.php 注册好友路由
- ChatController::init() 修复 DutyLog 在 return 后执行的 bug
- ChatController::notifyFriendsOnline() 上线时悄悄话通知好友

前端:
- user-actions:写私信 → 加好友/删好友按钮(动态状态)
- toggleFriend() 方法 + fetchUser 后加载好友状态
- scripts:监听私有频道 FriendAdded/FriendRemoved
- showFriendToast() 右下角浮窗通知(5秒自动消失)
- global-dialog 加 fdSlideIn 动画
2026-03-01 00:48:51 +08:00
lkddi 8853d08e5a 文档:DEVELOPMENT.md 补充 7.8 全局弹窗系统使用指南
记录 window.chatDialog.alert/confirm 的 API、使用示例、
Alpine.js 组件内的代理用法及颜色速查表,
并声明聊天室内禁止使用浏览器原生弹窗的规范
2026-03-01 00:36:54 +08:00
lkddi 7ec0904c5c 重构:全局自定义弹窗系统 window.chatDialog
- 新增 chat/partials/global-dialog.blade.php(全局弹窗 HTML + JS)
- 提供 chatDialog.alert() 和 chatDialog.confirm() 两个异步 API
- Alpine.js userCardComponent 的 $alert/$confirm 代理到全局 API
- toolbar 离开按钮统一改用 chatDialog.confirm(),移除独立 leave-confirm-modal
- 支持动态标题颜色、淡入动画,兼容 Chrome/Edge/Firefox
2026-03-01 00:34:11 +08:00
lkddi e2ae4b34b3 修复:Chrome 离开按钮 confirm 弹窗闪烁 → 自定义 HTML 弹窗
- 移除原生 confirm(),改为自定义 #leave-confirm-modal 弹窗
- 红色渐变标题栏,取消/确定离开两个按钮
- 点击遮罩可关闭,不触发任何浏览器原生对话框机制
2026-03-01 00:29:00 +08:00
lkddi f0cbcfa949 修复:Alpine.js userInfo.position_history 初始 undefined 导致 length 报错
- userInfo 初始值加 position_history: [],防止挂载时 undefined.length
- x-text 和 x-if 里加可选链 ?.length ?? 0 双重兜底
2026-03-01 00:23:08 +08:00
lkddi 91b569ffd3 修复:离开按钮 confirm 弹窗在 Chrome 闪烁消失
将 beforeunload 改为 pagehide 事件:
- pagehide 在页面关闭/刷新时触发,但不会弹原生「离开网站」确认框
- 与原生 confirm() 不产生冲突,Chrome/Edge 行为一致
- leaveRoom() 设 _manualLeave 标记,pagehide 里不重复发 beacon
2026-03-01 00:18:46 +08:00
lkddi 0f5b8a4f52 修复:Chrome 点击离开时出现原生"离开网站"弹窗闪烁
主动调用 remove 移除 beforeunload 监听后再导航,
Chrome 不再触发原生确认框,Edge 行为不变
2026-03-01 00:16:34 +08:00
lkddi 1caaec5601 修复:关闭浏览器时 leave 不触发导致勤务日志不结算
- 新增 sendLeaveBeacon(),使用 navigator.sendBeacon 发送 leave 请求
- beforeunload 事件:关闭标签/浏览器/刷新均自动结算
- visibilitychange 事件:切到后台 30 秒后自动结算,切回来取消
- sendBeacon 比 fetch 更可靠,浏览器关闭时也能确保请求发出
2026-03-01 00:12:47 +08:00
lkddi 94414057e6 修复:User 模型 fillable 缺少 in_time/out_time,导致进房时间静默写入失败 2026-03-01 00:10:44 +08:00
lkddi 76fd17c727 功能:存点时自动同步在职用户勤务日志
- heartbeat 手动存点:调用 tickDutyLog()
- AutoSaveExp 自动存点:调用 tickDutyLog()
- 逻辑:今日已有开放日志则刷新 duration_seconds,无则新建(login_at 取 in_time 进房时间)
- 修复:TIMESTAMPDIFF 结果用 GREATEST(0, ...) 防 unsigned 溢出
- 修复:database.php MySQL 连接加 timezone=+08:00,与 PHP Asia/Shanghai 时区对齐
2026-03-01 00:04:59 +08:00
lkddi 5f30220609 feat: 任命/撤销通知系统 + 用户名片UI优化
- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
2026-02-28 23:44:38 +08:00
lkddi a599047cf0 文档:恢复原项目参考路径(仅作功能参照,不做数据迁移) 2026-02-28 14:14:20 +08:00
lkddi 95dd259913 文档:清理旧 ASP/数据导入相关内容,重写注意事项为全新项目视角 2026-02-28 14:13:00 +08:00
lkddi ff097cce3c 文档:在 DEVELOPMENT.md 补充积分流水系统完整开发者使用指南 2026-02-28 14:06:55 +08:00
lkddi aeffb8e4d4 整理:合并零散迁移文件,36个简化为24个纯建表迁移
- users 表:吸收 s_color类型变更/sign/question/answer/vip_level_id/has_received_new_gift
- rooms 表:吸收 visit_num、announcement
- sysparam 表:吸收全部 seed(99级经验/权限等级/钓鱼/魅力/排行榜,直接写最终值)
- 新增 create_shop_tables(shop_items+user_purchases+username_blacklist+默认商品)
- 新增 create_user_currency_logs_table(积分流水表含完整索引)
- 删除 14 个已吸收的 add_column / seed 零散迁移
2026-02-28 14:03:04 +08:00
lkddi 27b52da0e5 修复:积分流水统计页改用后台 admin layout 2026-02-28 13:55:35 +08:00
lkddi 5233a485eb 功能:后台侧边栏新增「积分流水统计」菜单入口 2026-02-28 13:54:47 +08:00
lkddi 7643740eda 功能:今日风云榜独立页 /leaderboard/today,导航新增「今日榜」按钮 2026-02-28 13:50:51 +08:00
lkddi 0662901b1b 修复:后台积分统计页面 layout 改为 layouts.app 2026-02-28 13:41:50 +08:00
lkddi 72d23af335 功能:ChatController 新人礼包 6666 金币接入积分流水,记录 newbie_bonus 来源 2026-02-28 13:34:40 +08:00
lkddi 1eb58ea331 功能:排行榜页面新增今日三榜(今日经验/金币/魅力)及个人日志入口 2026-02-28 12:50:15 +08:00
lkddi 0c5e218aa8 功能:新增用户积分流水系统
- 新建 user_currency_logs 流水表 (Migration)
- App\Enums\CurrencySource 来源枚举(可扩展)
- App\Models\UserCurrencyLog 流水模型
- App\Services\UserCurrencyService 统一积分变更服务
- FishingController:抛竿/收竿接入流水记录
- AutoSaveExp:自动存点接入流水记录
- Admin/UserManagerController:管理员调整接入流水记录
- LeaderboardController:新增今日三榜(经验/金币/魅力)+ 个人流水日志页
- Admin/CurrencyStatsController:后台活动统计页
- views:新增个人日志页、后台统计页;排行榜新增今日榜数据传递
- routes:新增个人日志路由 /my/currency-logs、后台路由 /admin/currency-stats
2026-02-28 12:49:26 +08:00
lkddi 3f5d0e9539 功能:自动存点通知实现滚动替换,新消息到来时自动删除旧的通知,保持包厢窗口整洁 2026-02-28 11:56:42 +08:00
lkddi ffe35c048d 修复:历史消息服务端过滤,只加载与当前用户相关的记录,避免他人私聊和系统通知混入包厢窗 2026-02-28 11:54:29 +08:00
lkddi 2219d7e26e 修复:增强 Flexbox 布局约束,防止过长的历史消息打破 100vh 将底部输入框挤出屏幕 2026-02-28 11:29:56 +08:00
lkddi 0ff64d2737 修复:增强 scripts.blade.php 的 JS 健壮性,解决因 DOM 元素缺失导致的执行中断及变量未初始化问题 2026-02-28 11:22:18 +08:00
lkddi 28d402d204 修复:重写本地清屏逻辑,使用 localStorage 记录拉取游标,避免进房带历史功能导致清屏失效 2026-02-28 11:20:34 +08:00
lkddi 9a98bdfbe6 修复:聊天室初次加载时附带历史消息,解决因网络延迟错失入场欢迎语的问题 2026-02-28 11:17:09 +08:00
lkddi cb2e962116 优化:与AI聊天不再阻塞全局发言锁,允许在AI思考期间继续在公屏聊天 2026-02-28 11:12:51 +08:00
lkddi 7bbc4c18d7 优化:AI聊天机器人知道对方的名字,并且连接超时不再抛出底层的cURL长代码错误 2026-02-27 17:50:08 +08:00
lkddi e7436e7898 修复:与AI聊天或其他特定错误拦截后,发送消息按钮永久失灵的问题 2026-02-27 17:45:18 +08:00
lkddi 4ef95eaa27 新增:新人首次入住聊天室大礼包自动发放功能(6666金币 + 满场烟花 + 公屏欢迎) 2026-02-27 17:21:33 +08:00
lkddi efc4dfd752 修复:聊天室界面的送鱼按钮 Alpine.js 语法错误导致发言被卡住的问题 2026-02-27 17:04:12 +08:00
lkddi 3ad67a1610 优化:修改单次特效卡的公屏广播文案,避免让大家误以为是赠送了道具卡实体 2026-02-27 17:00:26 +08:00
lkddi 4fe3c1eed9 修复:商店购买单次特效卡并指定给别人时,购买者自己也必须能看到特效播放 2026-02-27 16:56:57 +08:00
lkddi b170724f3f 修复:后台布局移除 Tailwind CDN,改用 Vite 原生编译产物避免控制台警告 2026-02-27 16:50:37 +08:00
lkddi 8b18c7159f 修复:后台布局文件缺少 csrf-token meta 标签,导致 AJAX 请求取不到 token 报 JS TypeError 拦截发送 2026-02-27 16:49:00 +08:00
lkddi aa9a9318f5 重构:将后台编辑用户 AJAX 提交方法移入 Alpine data 组件内部,彻底解决作用域和数据获取问题 2026-02-27 16:47:03 +08:00
lkddi 2c5d4cedea 修复:后台编辑用户时弹窗里的数据为空(移除了不小心造成的 td x-data 孤立作用域) 2026-02-27 16:44:11 +08:00
lkddi 39d03d30a8 修复:后台编辑用户弹窗改为直接传 Alpine $data,避免 querySelector 找到错误的 x-data 元素导致网络异常 2026-02-27 16:41:57 +08:00
lkddi e7440e5e84 修复:后台编辑用户 AJAX 请求加入 _method=PUT,解决 Laravel 路由 404 导致的「网络异常」 2026-02-27 16:38:36 +08:00
lkddi f37530fa0e UI:聊天消息移除硬编码 font-size,统一继承用户设置的字体大小 2026-02-27 16:33:40 +08:00
lkddi 43956d286e 修复:礼物系统消息字段名改为 from_user/to_user/sent_at 与前端 appendMessage() 匹配,触发金色边框样式 2026-02-27 16:30:41 +08:00
lkddi 157aee3812 修复:confirmGift null错误(先保存item再关弹框);MessageSent改为ShouldBroadcastNow立即广播;修复route()引号冲突 2026-02-27 16:26:16 +08:00
lkddi 6a8ba4fbc8 功能:单次特效卡支持赠送——送礼弹框、广播给指定用户/全员、公屏系统消息、购买后关闭商店展示特效 2026-02-27 16:19:21 +08:00
lkddi 1e2c304754 UI: 商店弹窗改为蓝白风格,与现有设置弹窗保持一致 2026-02-27 16:09:10 +08:00
lkddi 8ac540c65b 重构:商店从右侧 Tab 移至工具栏按钮弹窗,新增 2 列网格卡片布局 2026-02-27 16:06:15 +08:00
lkddi 9c8f7b1a95 UI: 商店面板重新设计——紧凑卡片、渐变配色、悬浮特效、绝对定位适配窄侧边栏 2026-02-27 16:02:22 +08:00
lkddi 7fb86bfe21 Feat: 商店功能完整实现(单次特效卡888/周卡8888/改名卡5000,含购买、周卡覆盖、改名黑名单) 2026-02-27 15:57:12 +08:00
lkddi c52998671b Fix: 修复火箭未爆炸bug(动态计算初速度确保必然到达目标高度) 2026-02-27 15:38:21 +08:00
lkddi 9147dc0d01 Feat: 后台用户列表ID列增加点击排序功能 2026-02-27 15:33:44 +08:00
lkddi a5e4c5f46f Fix: 排序链接改用request()辅助函数,修复Blade模板中 2026-02-27 15:32:48 +08:00
lkddi b366c9888f Feat: 后台用户列表增加金币/魅力列,表头支持点击排序(等级/经验/金币/魅力) 2026-02-27 15:31:29 +08:00
lkddi 6d73e50bff Fix: 后台用户编辑改为AJAX提交,消除302重定向,弹窗内显示成功/失败通知 2026-02-27 15:29:25 +08:00
lkddi ba4f9113ae Fix: 登录页浏览器推荐改为简洁单行小字,去掉黄色方框 2026-02-27 14:56:09 +08:00
749 changed files with 61830 additions and 2005 deletions
+4
View File
@@ -144,3 +144,7 @@ class ChatStateService
}
}
```
### 2.5 迁移文件注意事项
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
@@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+20
View File
@@ -0,0 +1,20 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
},
"herd": {
"command": "php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+2
View File
@@ -24,3 +24,5 @@ Homestead.yaml
Thumbs.db
vendor.zip
test-captcha.php
public/.user.ini
dump.rdb
+256
View File
@@ -0,0 +1,256 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+20
View File
@@ -0,0 +1,20 @@
{
"mcpServers": {
"laravel-boost": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Users/pllx/Web/Herd/chatroom/artisan",
"boost:mcp"
]
},
"herd": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -0,0 +1,129 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+256
View File
@@ -0,0 +1,256 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+699 -222
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
# 🎮 聊天室游戏开发进度
> 更新时间:2026-03-04
---
## ✅ 已完成
### 🎲 百家乐(Baccarat
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`baccarat_rounds` + `baccarat_bets`
- **模型**`BaccaratRound` / `BaccaratBet`
- **队列 Job**`OpenBaccaratRoundJob` (开局) + `CloseBaccaratRoundJob` (摇骰结算)
- **事件**`BaccaratRoundOpened` / `BaccaratRoundSettled`PresenceChannel 广播)
- **控制器**`BaccaratController``/baccarat/current` / `/baccarat/bet` / `/baccarat/history`
- **前端**`chat/partials/baccarat-panel.blade.php`(倒计时/押注/骰子动画/趋势)
- **货币来源**`CurrencySource::BACCARAT_BET` / `BACCARAT_WIN`
- **后台配置**`game_configs` 表,管理员可配置开关/间隔/赔率/押注范围
### 🎰 老虎机(Slot Machine
- **类型**:玩家随时主动触发(即时游戏)
- **数据库**`slot_machine_logs`
- **模型**`SlotMachineLog`8种带权重图案、判奖逻辑)
- **控制器**`SlotMachineController``/slot/info` / `/slot/spin` / `/slot/history`
- **赔率**:三7×100(全服广播)/ 三钻×50 / 三同×10 / 两同×2 / 三骷髅诅咒(扣双倍)
- **聊天通知**:中奖发私信通知;三7全服公屏广播
- **前端**`chat/partials/slot-machine.blade.php`(三列滚轮动画/逐列停止/可拖动FAB)
- **货币来源**`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
- **后台配置**`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
### 📦 神秘箱子(Mystery Box
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
- **数据库**`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
- **模型**`MysteryBox` / `MysteryBoxClaim`
- **队列 Job**`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob/ `ExpireMysteryBoxJob`(到期处理)
- **控制器**`MysteryBoxController``/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
- **前端**`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
- **货币来源**`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
- **后台配置**`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
### 🐎 赛马竞猜(Horse Racing
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`horse_races` + `horse_bets`
- **模型**`HorseRace` / `HorseBet`
- **队列 Job**`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
- **事件**`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel 广播)
- **控制器**`HorseRaceController``/horse-race/current` / `/horse-race/bet` / `/horse-race/history`
- **广播**`horse.opened` / `horse.progress` / `horse.settled`
- **前端**`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB)
- **货币来源**`CurrencySource::HORSE_BET` / `HORSE_WIN`
- **后台配置**`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
### 🔮 神秘占卜(Fortune Telling
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
- **数据库**`fortune_logs`
- **模型**`FortuneLog`55+ 条签文内嵌在模型中)
- **控制器**`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
- **前端**`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB)
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
- **广播**:暂无实时广播(占卜结果仅展示给本人)
- **货币来源**`CurrencySource::FORTUNE_COST`
- **后台配置**`game_configs` 表,免费次数/额外消耗/各签概率均可配置
---
## 🕐 待开发
---
## 📌 通用待办(所有游戏共用)
- [x] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据(点击"加载实时统计"异步加载各游戏汇总卡片)
- [x] 各游戏历史记录在后台可查(管理员视角,新增 `/admin/game-history/` 路由组,支持百家乐/老虎机/赛马/神秘箱子/占卜各自的历史记录列表及详情页,含筛选分页)
- [x] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
---
## 🔧 已修复的 Bug
1. **百家乐广播频道**`Channel``PresenceChannel`,解决前端收不到 WebSocket 事件
2. **百家乐余额检查**`$user->gold``$user->jjb`(字段名错误)
3. **老虎机积分日志**:普通中奖/诅咒发私信通知;三7全服广播
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
8. **Alpine.js 初始化顺序**`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
10. **神秘箱子流水记录**`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
+256
View File
@@ -0,0 +1,256 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* 文件功能:AI小班长专属极轻量心跳模拟器
*
* 专门用于让无法通过浏览器发送真实心跳的 AI实体用户
* 也能够完美触原有的法发经验/金币逻辑以及触发随机事件(Autoact)。
* 每分钟由 Laravel Scheduler 调用。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
class AiHeartbeatCommand extends Command
{
/**
* Artisan 指令名称
*/
protected $signature = 'chatroom:ai-heartbeat';
/**
* 指令描述
*/
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
public function handle(): int
{
// 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS;
}
// 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return Command::SUCCESS;
}
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
}
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
}
$user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) {
$this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change(
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
$user->refresh();
// 重新计算等级
if ($this->calculateNewLevel($user, $superLevel)) {
$leveledUp = true;
}
// 广播随机事件
$this->broadcastSystemMessage(
'星海小博士',
$autoEvent->renderText($user->username),
match ($autoEvent->event_type) {
'good' => '#16a34a',
'bad' => '#dc2626',
default => '#7c3aed',
}
);
}
}
// 6. 如果由于心跳或事件导致了升级,广播升级消息
if ($leveledUp) {
$this->broadcastSystemMessage(
'系统传音',
"🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'#d97706',
'大声宣告'
);
}
// 7. 钓鱼小游戏随机参与逻辑
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if ($user->jjb >= $cost) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
// 模拟玩家等待时间
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
}
}
return Command::SUCCESS;
}
/**
* 计算并更新用户等级
*/
private function calculateNewLevel(User $user, int $superLevel): bool
{
$oldLevel = $user->user_level;
if ($oldLevel >= $superLevel) {
return false; // 管理员不自动升降级
}
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$user->save();
return $newLevel > $oldLevel;
}
return false;
}
/**
* 解析配置的奖励范围,如 "1" "1-5"
*/
private function parseRewardValue(string $raw): int
{
$raw = trim($raw);
if (str_contains($raw, '-')) {
[$min, $max] = explode('-', $raw, 2);
return rand((int) $min, (int) $max);
}
return (int) $raw;
}
/**
* 往所有活跃房间发送系统广播消息
*/
private function broadcastSystemMessage(string $fromUser, string $content, string $color, string $action = ''): void
{
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
if (empty($activeRoomIds)) {
$activeRoomIds = [1];
}
foreach ($activeRoomIds as $roomId) {
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $fromUser,
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $color,
'action' => $action,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
}
}
}
+130 -35
View File
@@ -9,20 +9,24 @@
* 3. 在聊天室内推送"系统为你自动存点"提示
* 4. 若用户等级提升,向全频道广播恭喜消息
*
* @package App\Console\Commands
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\PositionDutyLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class AutoSaveExp extends Command
@@ -43,6 +47,7 @@ class AutoSaveExp extends Command
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
@@ -61,13 +66,14 @@ class AutoSaveExp extends Command
// 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 从 Redis 扫描所有在线房间
$roomMap = $this->scanOnlineRooms();
if (empty($roomMap)) {
$this->info('当前没有在线用户,跳过存点。');
return Command::SUCCESS;
}
@@ -116,11 +122,11 @@ class AutoSaveExp extends Command
/**
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
*
* @param string $username 用户名
* @param int $roomId 所在房间ID
* @param string $username 用户名
* @param int $roomId 所在房间ID
* @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值
* @param int $superLevel 管理员等级阈值
*/
private function processUser(
string $username,
@@ -134,30 +140,53 @@ class AutoSaveExp extends Command
return;
}
// 1. 发放经验奖励(支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
// 2. 发放金币奖励(支持 VIP 倍率)
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$actualJjbGain = 0;
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
}
// 3. 自动升降级(管理员不参与
$oldLevel = $user->user_level;
// 2. 通过统一积分服务发放奖励(原子写入 + 流水记录
if ($actualExpGain > 0) {
$this->currencyService->change(
$user, 'exp', $actualExpGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
if ($actualJjbGain > 0) {
$this->currencyService->change(
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
$user->load('activePosition.position'); // 确保职务及职位关联已加载
// 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
// - 管理员(>= superLevel):不变动
// - 普通用户:按经验计算等级,支持升降级
$oldLevel = $user->user_level;
$leveledUp = false;
if ($oldLevel < $superLevel) {
$activeUP = $user->activePosition; // 已在 refresh 后加载
if ($activeUP?->position) {
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
$requiredLevel = (int) $activeUP->position->level;
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
$user->user_level = $requiredLevel;
}
} elseif ($oldLevel < $superLevel) {
// 普通用户:按经验计算并更新等级
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$leveledUp = ($newLevel > $oldLevel);
$leveledUp = ($newLevel > $oldLevel);
}
}
@@ -166,19 +195,27 @@ class AutoSaveExp extends Command
// 4. 若升级,向全频道广播升级消息
if ($leveledUp) {
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'is_secret' => false,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
// 触发微信机器人私聊通知 (等级提升)
try {
$wechatService = app(\App\Services\WechatBot\WechatNotificationService::class);
$wechatService->notifyLevelChange($user, $oldLevel, $newLevel);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot level change notification failed', ['error' => $e->getMessage()]);
}
}
// 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示
@@ -190,26 +227,29 @@ class AutoSaveExp extends Command
$gainParts[] = "金币+{$actualJjbGain}";
}
$jjbDisplay = $user->jjb ?? 0;
$gainStr = ! empty($gainParts) ? ' 本次获得:' . implode('', $gainParts) : '';
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode('', $gainParts) : '';
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$noticeMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username, // 定向推送给本人
'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username, // 定向推送给本人
'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'font_color' => '#16a34a', // 草绿色
'action' => '',
'sent_at' => now()->toDateTimeString(),
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $noticeMsg);
broadcast(new MessageSent($roomId, $noticeMsg));
// 6. 同步更新在职用户的勤务时长
$this->tickDutyLog($user, $roomId);
}
/**
@@ -220,16 +260,71 @@ class AutoSaveExp extends Command
* - 随机范围:如 "3-10" 返回 [3, 10] 之间的随机整数
*
* @param string $raw 原始配置字符串
* @return int
*/
private function parseRewardValue(string $raw): int
{
$raw = trim($raw);
if (str_contains($raw, '-')) {
[$min, $max] = explode('-', $raw, 2);
return rand((int) $min, (int) $max);
}
return (int) $raw;
}
/**
* 自动存点时同步更新或创建在职用户的勤务日志。
*
* 逻辑同 ChatController::tickDutyLog
* 1. 无在职职务 跳过
* 2. 今日已有开放日志 刷新 duration_seconds
* 3. 今日无日志 新建,login_at user->in_time(进房时间)
*
* @param \App\Models\User $user refresh 的用户实例
* @param int $roomId 所在房间 ID
*/
private function tickDutyLog(User $user, int $roomId): void
{
// 无论有无职务,均记录在线流水
$activeUP = $user->activePosition;
// ① 今日未关闭的开放日志 → 刷新时长
$openLog = PositionDutyLog::query()
->where('user_id', $user->id)
->whereNull('logout_at')
->whereDate('login_at', today())
->first();
if ($openLog) {
DB::table('position_duty_logs')
->where('id', $openLog->id)
->update([
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
'updated_at' => now(),
]);
return;
}
// ② 今日无开放日志 → 新建
// 若今日已有已关闭的日志(是重建场景),必须用 now(),防止重用旧 in_time 累积膨胀
$hasClosedToday = PositionDutyLog::query()
->where('user_id', $user->id)
->whereDate('login_at', today())
->whereNotNull('logout_at')
->exists();
$loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
? $user->in_time
: now();
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP?->id,
'login_at' => $loginAt,
'ip_address' => '0.0.0.0',
'room_id' => $roomId,
]);
}
}
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:清理房间在线名单的 Redis 缓存
* 用于清除历史遗留的「幽灵在线」脏数据
*
* 执行方式:php artisan room:clear-online-cache
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class ClearRoomOnlineCache extends Command
{
/**
* Artisan 命令名称
*
* @var string
*/
protected $signature = 'room:clear-online-cache';
/**
* 命令描述
*
* @var string
*/
protected $description = '清空所有房间的 Redis 在线名单(清除幽灵在线脏数据)';
/**
* 执行命令
*
* 扫描所有 room:*:users Redis Key 并删除,让用户重新进房时写入干净数据。
*/
public function handle(): int
{
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$cleaned = 0;
$this->info("Redis 前缀:\"{$prefix}\"");
$this->info('开始扫描 room:*:users ...');
do {
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
// 去掉 Redis 前缀,还原为 Laravel Facade 使用的短 Key
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
Redis::del($shortKey);
$this->line(" ✓ 已清除:{$shortKey}");
$cleaned++;
}
} while ($cursor !== '0');
$this->info("完成!共清理 {$cleaned} 个房间的在线名单。");
$this->info('用户下次进房会重新写入正确数据,人数从 0 开始准确累计。');
return self::SUCCESS;
}
}
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:自动关闭掉线职务日志指令
*
* 15 分钟由 Laravel Scheduler 调用,扫描 position_duty_logs 表中:
* - logout_at IS NULL(尚未结算的开放日志)
* - updated_at 超过 15 分钟未刷新(说明心跳已中断,用户已掉线/关闭浏览器)
*
* 对此类日志写入 logout_at = NOW(),保留 duration_seconds 现有值不清零,
* 确保累计时长计算准确,不因掉线而永久悬空。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Models\PositionDutyLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CloseStaleDutyLogs extends Command
{
/**
* Artisan 指令名称
*/
protected $signature = 'duty:close-stale-logs';
/**
* 指令描述(在 artisan list 中显示)
*/
protected $description = '自动关闭 15 分钟内无心跳的开放职务日志(解决掉线不结算问题)';
/**
* 指令入口:将长时间无心跳刷新的开放日志判定为掉线,写入 logout_at 完成结算。
*
* 判定标准:updated_at 超过 15 分钟(心跳间隔约 30 秒,5 分钟自动存点最长 5 分钟,
* 留足 10 分钟容差,总计 15 分钟无刷新即认为掉线)
*/
public function handle(): int
{
// 15 分钟无心跳 = 掉线判定阈值
$threshold = now()->subMinutes(15);
// 批量关闭符合条件的开放日志,保留现有 duration_seconds
$affected = PositionDutyLog::query()
->whereNull('logout_at')
->where('updated_at', '<=', $threshold)
->update([
'logout_at' => DB::raw('NOW()'),
// 补算最终在线时长,避免日榜 SUM 使用过时的旧值
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
]);
if ($affected > 0) {
$this->info("共关闭 {$affected} 条掉线职务日志。");
} else {
$this->info('无需处理,无掉线日志。');
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,155 @@
<?php
/**
* 文件功能:微信机器人 Kafka 消费命令
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Models\User;
use App\Services\WechatBot\KafkaConsumerService;
use App\Services\WechatBot\WechatBotApiService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ConsumeWechatMessages extends Command
{
/**
* @var string
*/
protected $signature = 'wechat-bot:consume';
/**
* @var string
*/
protected $description = '消费 Kafka 微信机器人消息(守护进程)';
protected KafkaConsumerService $kafkaService;
public function __construct(KafkaConsumerService $kafkaService)
{
parent::__construct();
$this->kafkaService = $kafkaService;
}
public function handle(): int
{
$this->info('正在启动微信机器人 Kafka 消费者...');
$consumer = $this->kafkaService->createConsumer();
if (! $consumer) {
$this->error('Kafka 配置不完整或加载失败,请在后台检查机器人设置。');
return self::FAILURE;
}
$this->info('消费者已启动,等待消息...');
$apiService = new WechatBotApiService;
while (true) {
try {
$messageJson = $consumer->consume();
if ($messageJson) {
$rawJson = $messageJson->getValue();
$this->info('--> 收到新的 Kafka 消息 (Raw Length: '.strlen($rawJson).')');
$messages = $this->kafkaService->parseKafkaMessage($rawJson);
if (empty($messages)) {
$this->info('--> 解析后:无匹配的 AddMsgs 内容');
}
foreach ($messages as $msg) {
try {
$this->processMessage($msg, $apiService);
} catch (\Exception $e) {
Log::error('处理单条微信消息失败', [
'error' => $e->getMessage(),
'msg' => $msg,
]);
}
}
$consumer->ack($messageJson);
}
} catch (\Exception $e) {
Log::error('Kafka 消费异常', ['error' => $e->getMessage()]);
// 延迟重试避免死循环 CPU 空转
sleep(2);
}
}
return self::SUCCESS;
}
/**
* 处理单条消息逻辑
*/
protected function processMessage(array $msg, WechatBotApiService $apiService): void
{
// 仅处理文本消息 (msg_type = 1)
if ($msg['msg_type'] != 1) {
return;
}
$content = trim($msg['content']);
$fromUser = $msg['from_user'];
$isChatroom = $msg['is_chatroom'];
// 绑定逻辑:必须是私聊(防止在群内绑定导致未来系统无法直接通过私聊推送个人通知)
if (! $isChatroom && preg_match('/^BD-\d{6}$/i', $content)) {
$this->info("收到潜在绑定请求: {$content} from {$fromUser}");
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
}
}
/**
* 处理账号绑定请求
*/
protected function handleBindRequest(string $code, string $wxid, WechatBotApiService $apiService): void
{
$cacheKey = 'wechat_bind_code:'.$code;
$username = Cache::get($cacheKey);
if (! $username) {
$apiService->sendTextMessage($wxid, '❌ 绑定失败:该验证码无效或已过有效期(5分钟)。请在个人中心重新生成。');
return;
}
$user = User::where('username', $username)->first();
if (! $user) {
$apiService->sendTextMessage($wxid, '❌ 绑定失败:找不到对应的用户账号。');
return;
}
// 判断该微信号是否已经被其他用户绑定(防止碰撞或安全隐患)
$existing = User::where('wxid', $wxid)->where('id', '!=', $user->id)->first();
if ($existing) {
$apiService->sendTextMessage($wxid, "❌ 绑定失败:当前微信号已经被其他账号 [{$existing->username}] 绑定。请先解绑后再试。");
return;
}
$user->wxid = $wxid;
$user->save();
// 验证成功后立即销毁验证码
Cache::forget($cacheKey);
$this->info("用户 [{$username}] 成功绑定微信: {$wxid}");
$successMsg = "🎉 绑定成功!\n"
."您已成功绑定聊天室账号:[{$username}]。\n"
.'现在您可以接收重要系统通知了。';
$apiService->sendTextMessage($wxid, $successMsg);
}
}
@@ -0,0 +1,78 @@
<?php
/**
* 文件功能:测试发送微信机器人消息
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Models\SysParam;
use App\Services\WechatBot\WechatBotApiService;
use Illuminate\Console\Command;
class WechatBotTestSend extends Command
{
/**
* @var string
*/
protected $signature = 'wechat-bot:test-send';
/**
* @var string
*/
protected $description = '测试发送一条消息给管理员设定的微信群群 wxid';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('开始测试微信机器人发送...');
$param = SysParam::where('alias', 'wechat_bot_config')->first();
if (! $param || empty($param->body)) {
$this->error('错误:未找到 wechat_bot_config 配置,请先在后台保存一次配置。');
return self::FAILURE;
}
$config = json_decode($param->body, true);
$targetWxid = $config['group_notify']['target_wxid'] ?? '';
if (empty($targetWxid)) {
$this->error('错误:请于后台填写【目标微信群 Wxid】。');
return self::FAILURE;
}
if (empty($config['api']['bot_key'] ?? '')) {
$this->error('错误:未配置【机器人 Key (必需)】,API请求将被拒绝(返回该链接不存在)。');
return self::FAILURE;
}
$service = new WechatBotApiService;
$this->info("发送目标: {$targetWxid}");
$this->info('发送 API Base: '.($config['api']['base_url'] ?? ''));
$message = "【系统连通性测试】\n发送时间:".now()->format('Y-m-d H:i:s')."\n如果您看到了这条消息,说明 ChatRoom 通知全站群发接口配置正确!";
$result = $service->sendTextMessage($targetWxid, $message);
if ($result['success']) {
$this->info('✅ 发送成功!');
return self::SUCCESS;
} else {
$this->error('❌ 发送失败:'.($result['error'] ?? '未知错误'));
$this->warn('如果提示『该链接不存在』代表您的基础API URL 或接入 Key 有误。');
return self::FAILURE;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
/**
* 文件功能:积分来源活动枚举
* 集中管理所有合法的 source 标识值,新增活动只需在此加一行常量,数据库字段无需任何变更。
* 对应数据表:user_currency_logs.sourcevarchar 字段,非 ENUM,可自由扩展)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Enums;
enum CurrencySource: string
{
/** 自动存点(Horizon 定时任务,每5分钟给在线用户加经验/金币) */
case AUTO_SAVE = 'auto_save';
/** 钓鱼收竿奖励(获得经验或金币) */
case FISHING_GAIN = 'fishing_gain';
/** 钓鱼抛竿消耗(扣除金币) */
case FISHING_COST = 'fishing_cost';
/** 送出礼物(送方扣金币) */
case SEND_GIFT = 'send_gift';
/** 收到礼物(收方魅力增加) */
case RECV_GIFT = 'recv_gift';
/** 新人礼包(首次登录赠送金币) */
case NEWBIE_BONUS = 'newbie_bonus';
/** 商城购买消耗(扣除金币) */
case SHOP_BUY = 'shop_buy';
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
case ADMIN_ADJUST = 'admin_adjust';
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
case POSITION_REWARD = 'position_reward';
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
/** 赠人玫瑰(用户或AI对外发放金币红包) */
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
// ─── 婚姻系统 ────────────────────────────────────────────────
/** 结婚魅力加成(双方各获得,由戒指档次决定) */
case MARRY_CHARM = 'marry_charm';
/** 离婚魅力惩罚(协议/强制/超时自动)*/
case DIVORCE_CHARM = 'divorce_charm';
/** 购买戒指(gold 消耗,由 ShopService 代理) */
case RING_BUY = 'ring_buy';
/** 戒指消失记录(求婚被拒/超时,金额=0,仅存档) */
case RING_LOST = 'ring_lost';
/** 发送婚礼红包(扣除金币) */
case WEDDING_ENV_SEND = 'wedding_env_send';
/** 领取婚礼红包(收入金币) */
case WEDDING_ENV_RECV = 'wedding_env_recv';
/** 强制离婚财产转移(付出方为负,接收方为正) */
case FORCED_DIVORCE_TRANSFER = 'forced_divorce_transfer';
/** 节日福利红包(管理员设置的定时金币福利) */
case HOLIDAY_BONUS = 'holiday_bonus';
/** 百家乐下注消耗(扣除金币) */
case BACCARAT_BET = 'baccarat_bet';
/** 百家乐中奖赔付(收入金币,含本金返还) */
case BACCARAT_WIN = 'baccarat_win';
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
case AUTO_EVENT = 'auto_event';
/** 老虎机转动消耗金币 */
case SLOT_SPIN = 'slot_spin';
/** 老虎机中奖赔付(含本金返还) */
case SLOT_WIN = 'slot_win';
/** 老虎机诅咒额外扣除 */
case SLOT_CURSE = 'slot_curse';
/** 领取礼包红包——金币(用户抢到金币礼包时收入) */
case RED_PACKET_RECV = 'red_packet_recv';
/** 领取礼包红包——经验(用户抢到经验礼包时收入) */
case RED_PACKET_RECV_EXP = 'red_packet_recv_exp';
/** 神秘箱子——领取奖励(普通箱/稀有箱,正数金币) */
case MYSTERY_BOX = 'mystery_box';
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
/** 赛马竞猜——下注消耗(扣除金币) */
case HORSE_BET = 'horse_bet';
/** 赛马竞猜——中奖赔付(收入金币,含本金返还) */
case HORSE_WIN = 'horse_win';
/** 神秘占卜——额外次数消耗(扣除金币) */
case FORTUNE_COST = 'fortune_cost';
/** 双色球购票消耗(每注扣除 ticket_price 金币) */
case LOTTERY_BUY = 'lottery_buy';
/** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */
case LOTTERY_WIN = 'lottery_win';
/** 五子棋 PvP 对战入场费(PvE 欻入场费) */
case GOMOKU_ENTRY_FEE = 'gomoku_entry_fee';
/** 五子棋对战胜利奖励(PvP/PvE 获胜时收入) */
case GOMOKU_WIN = 'gomoku_win';
/** 五子棋 PvE 入场费返还(平局时返还) */
case GOMOKU_REFUND = 'gomoku_refund';
/** 看视频赚金币与经验奖励 */
case VIDEO_REWARD = 'video_reward';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
public function label(): string
{
return match ($this) {
self::AUTO_SAVE => '自动存点',
self::FISHING_GAIN => '钓鱼奖励',
self::FISHING_COST => '钓鱼消耗',
self::SEND_GIFT => '送出礼物',
self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
self::DIVORCE_CHARM => '离婚魅力惩罚',
self::RING_BUY => '购买戒指',
self::RING_LOST => '戒指消失',
self::WEDDING_ENV_SEND => '发送婚礼红包',
self::WEDDING_ENV_RECV => '领取婚礼红包',
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
self::HOLIDAY_BONUS => '节日福利',
self::BACCARAT_BET => '百家乐下注',
self::BACCARAT_WIN => '百家乐赢钱',
self::AUTO_EVENT => '随机事件(星海小博士)',
self::SLOT_SPIN => '老虎机转动',
self::SLOT_WIN => '老虎机中奖',
self::SLOT_CURSE => '老虎机诅咒',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
self::HORSE_BET => '赛马下注',
self::HORSE_WIN => '赛马赢钱',
self::FORTUNE_COST => '神秘占卜消耗',
self::LOTTERY_BUY => '双色球购票',
self::LOTTERY_WIN => '双色球中奖',
self::GOMOKU_ENTRY_FEE => '五子棋入场费',
self::GOMOKU_WIN => '五子棋获胜奖励',
self::GOMOKU_REFUND => '五子棋入场费返还',
self::VIDEO_REWARD => '看视频奖励',
};
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* 文件功能:婚姻亲密度来源枚举
*
* 集中管理所有合法的亲密度增减来源标识,写入 marriage_intimacy_logs.source。
* 新增来源只需在此加一行,数据库字段无需变更(VARCHAR 类型)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Enums;
enum IntimacySource: string
{
/** 每日时间奖励(Horizon 00:00 定时任务) */
case DAILY_TIME = 'daily_time';
/** 双方同时在线(AutoSaveJob 每分钟检测) */
case ONLINE_TOGETHER = 'online_together';
/** 收到伴侣送花 */
case RECV_FLOWER = 'recv_flower';
/** 向伴侣送花 */
case SEND_FLOWER = 'send_flower';
/** 发送私聊消息(每2条 +1) */
case PRIVATE_CHAT = 'private_chat';
/** 结婚时戒指初始亲密度加成(一次性) */
case WEDDING_BONUS = 'wedding_bonus';
/** 管理员手动调整 */
case ADMIN_ADJUST = 'admin_adjust';
/**
* 返回该来源的中文名称(后台统计展示用)。
*/
public function label(): string
{
return match ($this) {
self::DAILY_TIME => '每日时间奖励',
self::ONLINE_TOGETHER => '双方同时在线',
self::RECV_FLOWER => '收到伴侣送花',
self::SEND_FLOWER => '向伴侣送花',
self::PRIVATE_CHAT => '私聊消息',
self::WEDDING_BONUS => '结婚戒指加成',
self::ADMIN_ADJUST => '管理员调整',
};
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:任命公告广播事件
* 任命操作成功后向对应聊天室 PresenceChannel 推送任命消息,
* 前端接收后展示全屏礼花动画和隆重公告弹窗。
*
* @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 AppointmentAnnounced implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构建任命公告事件
*
* @param int $roomId 广播目标房间 ID
* @param string $targetUsername 被任命用户名
* @param string $positionIcon 职务图标
* @param string $positionName 职务名称
* @param string $departmentName 所属部门名称
* @param string $operatorName 任命人用户名
*/
public function __construct(
public readonly int $roomId,
public readonly string $targetUsername,
public readonly string $positionIcon,
public readonly string $positionName,
public readonly string $departmentName,
public readonly string $operatorName,
public readonly string $type = 'appoint', // appoint | revoke
) {}
/**
* 广播至目标房间的 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 [
'type' => $this->type,
'target_username' => $this->targetUsername,
'position_icon' => $this->positionIcon,
'position_name' => $this->positionName,
'department_name' => $this->departmentName,
'operator_name' => $this->operatorName,
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:百家乐押注人数实时广播事件
*
* 当有用户成功下注时,向房间内所有用户广播最新的
* 各选项下注总人次,供前端实时更新面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\BaccaratRound;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BaccaratPoolUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param BaccaratRound $round 本局信息
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 广播至房间公共频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .baccarat.pool_updated)。
*/
public function broadcastAs(): string
{
return 'baccarat.pool_updated';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->round->id,
'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small,
'bet_count_triple' => $this->round->bet_count_triple,
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:百家乐开局广播事件
*
* 新局开始时广播给房间所有用户,携带局次 ID 和下注截止时间,
* 前端收到后展示倒计时下注面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\BaccaratRound;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param BaccaratRound $round 本局信息
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 广播至房间公共频道。
*
* @return array<Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .baccarat.opened)。
*/
public function broadcastAs(): string
{
return 'baccarat.opened';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->round->id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:百家乐结算广播事件
*
* 开奖后广播骰子结果和获奖类型,前端播放骰子动画,
* 并显示用户是否中奖及赔付金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\BaccaratRound;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param BaccaratRound $round 已结算的局次
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 广播至房间公共频道。
*
* @return array<Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .baccarat.settled)。
*/
public function broadcastAs(): string
{
return 'baccarat.settled';
}
/**
* 广播数据:骰子点数 + 开奖结果 + 统计。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->round->id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
'result_label' => $this->round->resultLabel(),
'total_payout' => $this->round->total_payout,
'bet_count' => $this->round->bet_count,
];
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:通用大卡片通知广播事件
*
* 可向指定用户(私有频道)或房间所有人(Presence 频道)推送全屏大卡片通知。
* 前端通过 window.chatBanner.show(options) 渲染,支持完全自定义。
*
* 使用示例(后端):
*
* // 推给单个用户
* broadcast(new BannerNotification(
* target: 'user',
* targetId: 'lkddi',
* options: [
* 'icon' => '💚📩',
* 'title' => '好友申请',
* 'name' => 'lkddi1',
* 'body' => '将你加为好友了!',
* 'gradient' => ['#1e3a5f', '#1d4ed8', '#0891b2'],
* 'autoClose' => 0,
* 'buttons' => [
* ['label' => ' 回加好友', 'color' => '#10b981', 'action' => 'add_friend', 'actionData' => 'lkddi1'],
* ['label' => '稍后再说', 'color' => 'rgba(255,255,255,0.15)', 'action' => 'close'],
* ],
* ]
* ));
*
* // 推给整个房间
* broadcast(new BannerNotification(target: 'room', targetId: 1, options: [...] ));
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BannerNotification implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造通用大卡片通知事件。
*
* @param string $target 推送目标类型:'user'(私有频道)| 'room'(房间全员)
* @param string|int $targetId 目标 ID:用户名(user)或 房间 IDroom
* @param array<string, mixed> $options 前端 chatBanner.show() 选项(详见文件顶部注释)
*/
public function __construct(
public readonly string $target,
public readonly string|int $targetId,
public readonly array $options = [],
) {}
/**
* 根据 $target 决定广播到私有频道还是 Presence 频道。
*/
public function broadcastOn(): Channel
{
return match ($this->target) {
'user' => new PrivateChannel('user.'.$this->targetId),
'room' => new PresenceChannel('room.'.$this->targetId),
default => new PrivateChannel('user.'.$this->targetId),
};
}
/**
* 指定广播事件名称,供前端 .listen('.BannerNotification') 匹配。
*/
public function broadcastAs(): string
{
return 'BannerNotification';
}
/**
* 广播负载:传递完整的 options 给前端渲染。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'target' => $this->target,
'target_id' => $this->targetId,
'options' => $this->options,
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:开发日志发布广播事件
* 当管理员发布新的开发日志并勾选"通知大厅"时触发
* 广播至 Room ID=1(星光大厅)的 presence 频道
* 前端监听此事件并在聊天消息区显示系统通知(含可点击的查看详情链接)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\DevChangelog;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChangelogPublished implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:传入触发通知的日志对象
*
* @param DevChangelog $changelog 刚发布的开发日志
*/
public function __construct(
public readonly DevChangelog $changelog,
) {}
/**
* 广播频道:仅向 Room 1(星光大厅)的 presence 频道广播
* 复用现有聊天室频道机制,无需额外配置
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
// 固定广播至 Room ID = 1 的大厅频道
new PresenceChannel('room.1'),
];
}
/**
* 广播事件名称(前端 .listen('ChangelogPublished', ...) 监听此名称)
*/
public function broadcastAs(): string
{
return 'ChangelogPublished';
}
/**
* 广播携带的数据(前端可直接访问)
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'version' => $this->changelog->version,
'title' => $this->changelog->title,
'type' => $this->changelog->type,
'type_label' => $this->changelog->type_label,
// 前端点击后跳转的目标 URL,自动锚定至对应版本
'url' => url('/changelog').'#v'.$this->changelog->version,
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChatBotToggled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public array $user,
public bool $isOnline
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('chat.system'),
];
}
}
+17 -11
View File
@@ -3,12 +3,12 @@
/**
* 文件功能:聊天室全屏特效广播事件
*
* 管理员触发烟花/下雨/雷电等特效后,
* 通过 WebSocket 广播给房间内所有在线用户,前端收到后播放对应 Canvas 动画
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者(target_username null 则全员播放)
*
* @package App\Events
* @author ChatRoom Laravel
* @version 1.0.0
*
* @version 2.0.0
*/
namespace App\Events;
@@ -26,19 +26,23 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 支持的特效类型列表(用于校验)
*/
public const TYPES = ['fireworks', 'rain', 'lightning'];
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow'];
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning
* @param string $operator 触发特效的管理员用户名
* @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning / snow
* @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言
*/
public function __construct(
public readonly int $roomId,
public readonly string $type,
public readonly string $operator,
public readonly ?string $targetUsername = null,
public readonly ?string $giftMessage = null,
) {}
/**
@@ -49,20 +53,22 @@ class EffectBroadcast implements ShouldBroadcastNow
public function broadcastOn(): array
{
return [
new PresenceChannel('room.' . $this->roomId),
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据:特效类型操作者
* 广播数据:特效类型操作者、目标用户、赠言
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'type' => $this->type,
'type' => $this->type,
'operator' => $this->operator,
'target_username' => $this->targetUsername, // null = 全员
'gift_message' => $this->giftMessage,
];
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:婚礼红包领取成功事件(广播至领取者私人频道)
*
* 触发时机:WeddingController::claim() 成功后广播,前端展示到账 Toast。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class EnvelopeClaimed implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $ceremonyId 婚礼仪式 ID
*/
public function __construct(
public readonly User $claimer,
public readonly int $amount,
public readonly int $ceremonyId,
) {}
/**
* 广播至领取者私人频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->claimer->id)];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'ceremony_id' => $this->ceremonyId,
'amount' => $this->amount,
'message' => "🎉 成功领取 {$this->amount} 金币婚礼红包!",
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'envelope.claimed';
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:好友添加广播事件
*
* 当用户 A 添加用户 B 为好友时,向 B 的私有频道广播此事件。
* 频道名使用数字 IDuser.{id}),避免中文用户名导致 Pusher 频道名验证失败。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FriendAdded implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造好友添加事件。
*
* @param string $fromUsername 发起添加的用户名(A
* @param string $toUsername 被添加的用户名(B,用于消息显示)
* @param int $toUserId 被添加用户的数字 ID(用于私有频道,避免中文名非法)
* @param bool $hasAddedBack B 是否已将 A 加为好友(互相添加=true
*/
public function __construct(
public readonly string $fromUsername,
public readonly string $toUsername,
public readonly int $toUserId,
public readonly bool $hasAddedBack = false,
) {}
/**
* 广播到被添加用户的私有频道(用数字 ID 命名,避免中文频道名不合法)。
*/
public function broadcastOn(): Channel
{
return new PrivateChannel('user.'.$this->toUserId);
}
/**
* 指定广播事件名称(短名),供前端 listen('.FriendAdded') 匹配。
*/
public function broadcastAs(): string
{
return 'FriendAdded';
}
/**
* 广播负载:包含发起人信息和互相好友状态,供前端弹窗使用。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'from_username' => $this->fromUsername,
'to_username' => $this->toUsername,
'type' => 'friend_added',
'has_added_back' => $this->hasAddedBack,
];
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:好友删除广播事件
*
* 当用户 A 删除用户 B 为好友时,向 B 的私有频道广播此事件。
* 频道名使用数字 IDuser.{id}),避免中文用户名导致 Pusher 频道名验证失败。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FriendRemoved implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造好友删除事件。
*
* @param string $fromUsername 发起删除的用户名(A
* @param string $toUsername 被删除的用户名(B,用于消息显示)
* @param int $toUserId 被删除用户的数字 ID(用于私有频道,避免中文名非法)
* @param bool $hadAddedBack B 之前是否也将 A 加为好友(互相好友=true
*/
public function __construct(
public readonly string $fromUsername,
public readonly string $toUsername,
public readonly int $toUserId,
public readonly bool $hadAddedBack = false,
) {}
/**
* 广播到被删除用户的私有频道(用数字 ID 命名,避免中文频道名不合法)。
*/
public function broadcastOn(): Channel
{
return new PrivateChannel('user.'.$this->toUserId);
}
/**
* 指定广播事件名称(短名),供前端 listen('.FriendRemoved') 匹配。
*/
public function broadcastAs(): string
{
return 'FriendRemoved';
}
/**
* 广播负载:包含发起人信息和之前互相好友状态,供前端弹窗使用。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'from_username' => $this->fromUsername,
'to_username' => $this->toUsername,
'type' => 'friend_removed',
'had_added_back' => $this->hadAddedBack,
];
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* 文件功能:五子棋对局结束广播事件
*
* 对局结束(胜负/平局/认输/超时)时广播两个频道:
* 1. 私有对局频道:通知双方结算并关闭棋盘
* 2. 房间公共频道:广播战报消息
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuFinishedEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 当前对局
* @param string $winnerName 胜者用户名(平局时为空字符串)
* @param string $loserName 败者用户名(平局时为空字符串)
* @param string $reason 结束原因:win | draw | resign | timeout
*/
public function __construct(
public readonly GomokuGame $game,
public readonly string $winnerName,
public readonly string $loserName,
public readonly string $reason,
) {}
/**
* 同时广播至对局私有频道 + 房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("gomoku.{$this->game->id}"),
new PresenceChannel("room.{$this->game->room_id}"),
];
}
/**
* 广播事件名(前端监听 .gomoku.finished)。
*/
public function broadcastAs(): string
{
return 'gomoku.finished';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'game_id' => $this->game->id,
'winner' => $this->game->winner,
'winner_name' => $this->winnerName,
'loser_name' => $this->loserName,
'reason' => $this->reason,
'reward_gold' => $this->game->reward_gold,
'mode' => $this->game->mode,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:五子棋对战邀请广播事件
*
* 玩家发起对战邀请时广播至房间 Presence 频道,
* 前端在聊天消息流中渲染「接受挑战」按钮。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuInviteEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 对局记录
* @param string $inviterName 发起者用户名
*/
public function __construct(
public readonly GomokuGame $game,
public readonly string $inviterName,
) {}
/**
* 广播至对应房间频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel("room.{$this->game->room_id}")];
}
/**
* 广播事件名(前端监听 .gomoku.invite)。
*/
public function broadcastAs(): string
{
return 'gomoku.invite';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'game_id' => $this->game->id,
'inviter_name' => $this->inviterName,
'expires_at' => $this->game->invite_expires_at?->toIso8601String(),
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:五子棋落子广播事件
*
* 每次玩家(或 AI)落子后通过私有对局频道广播,
* 双方前端实时更新棋盘显示并切换行棋方。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\GomokuGame;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GomokuMovedEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param GomokuGame $game 当前对局
* @param int $row 落子行(0-14
* @param int $col 落子列(0-14
* @param int $color 落子颜色(1= 2=白)
*/
public function __construct(
public readonly GomokuGame $game,
public readonly int $row,
public readonly int $col,
public readonly int $color,
) {}
/**
* 广播至对局私有频道(仅双方可见)。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel("gomoku.{$this->game->id}")];
}
/**
* 广播事件名(前端监听 .gomoku.moved)。
*/
public function broadcastAs(): string
{
return 'gomoku.moved';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'game_id' => $this->game->id,
'row' => $this->row,
'col' => $this->col,
'color' => $this->color,
'current_turn' => $this->game->current_turn,
'board' => $this->game->board,
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:节日福利开始广播事件
*
* 管理员配置的节日活动到达触发时间后,由 TriggerHolidayEventJob 触发,
* 通过 Reverb WebSocket 广播给房间内所有在线用户,
* 前端收到后弹出领取弹窗和公屏系统消息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HolidayEvent;
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 节日活动实例
*/
public function __construct(
public readonly HolidayEvent $event,
) {}
/**
* 广播至房间公共频道(所有在线用户均可收到)。
*
* @return array<Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.1'),
];
}
/**
* 广播事件名。
*/
public function broadcastAs(): string
{
return 'holiday.started';
}
/**
* 广播数据:供前端构建弹窗和公屏消息。
*
* @return array<string, mixed>
*/
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(),
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:赛马开赛广播事件
*
* 新场次开始押注时广播给房间所有用户,携带场次 ID、
* 参赛马匹信息和押注截止时间,前端展示倒计时押注面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 本场信息
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.opened)。
*/
public function broadcastAs(): string
{
return 'horse.opened';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->race->id,
'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* 文件功能:赛马进行中实时进度广播事件
*
* 跑马过程中每隔1秒广播各马匹当前进度(0~100%),
* 前端据此实时更新赛道动画。
*
* @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 HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $raceId 场次 ID
* @param array<int, int> $positions 各马匹进度 [horse_id => progress(0~100)]
* @param bool $finished 是否已到终点
* @param int|null $leaderId 当前领跑马匹 ID
*/
public function __construct(
public readonly int $raceId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.progress)。
*/
public function broadcastAs(): string
{
return 'horse.progress';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->raceId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* 文件功能:赛马结算广播事件
*
* 跑马结束後广播赛果(获胜马匹、赔付金额等)给房间所有用户,
* 前端收到后展示结算面板并更新中奖信息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 已结算的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.settled)。
*/
public function broadcastAs(): string
{
return 'horse.settled';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
// 找出获胜马匹的名称
$horses = $this->race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === $this->race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').' '.($horse['name'] ?? '');
break;
}
}
return [
'race_id' => $this->race->id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool,
'settled_at' => $this->race->settled_at?->toIso8601String(),
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:结婚公告事件(广播至全房间)
*
* 触发时机:求婚被接受,正式结婚后广播。
* 前端收到后展示全屏烟花特效 + 婚礼设置弹窗(仅婚姻双方)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageAccepted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
*/
public function __construct(
public readonly Marriage $marriage,
) {}
/**
* 广播至当前所有房间(PresenceChannel room.*)。
* 使用大厅房间 ID=1,若业务支持多房间可扩展。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']);
return [
'marriage_id' => $this->marriage->id,
'user' => $this->marriage->user?->only(['id', 'username', 'headface']),
'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']),
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
'married_at' => $this->marriage->married_at,
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.accepted';
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* 文件功能:协议离婚申请通知事件(广播至对方私人频道)
*
* 触发时机:一方申请协议离婚后广播,对方收到 Banner 含确认/拒绝按钮。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageDivorceRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
*/
public function __construct(
public readonly Marriage $marriage,
) {}
/**
* 广播至对方私人频道(divorcer 的对方)。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
// 离婚申请方的对方
$targetId = $this->marriage->user_id === $this->marriage->divorcer_id
? $this->marriage->partner_id
: $this->marriage->user_id;
return [new PrivateChannel('user.'.$targetId)];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$this->marriage->load(['user:id,username', 'partner:id,username']);
$divorcerUsername = $this->marriage->user_id === $this->marriage->divorcer_id
? $this->marriage->user?->username
: $this->marriage->partner?->username;
// 读取协议离婚魅力惩罚供前端展示
$penalty = (int) \App\Models\MarriageConfig::where('key', 'divorce_mutual_charm')->value('value');
// 读取强制离婚魅力惩罚(被拒=强制离婚时申请方受此惩罚)
$forcedPenalty = (int) \App\Models\MarriageConfig::where('key', 'divorce_forced_charm')->value('value');
return [
'marriage_id' => $this->marriage->id,
'divorcer_username' => $divorcerUsername,
'initiator_name' => $divorcerUsername, // 前端兼容字段
'timeout_hours' => 72,
'requested_at' => $this->marriage->divorce_requested_at,
'mutual_charm_penalty' => $penalty, // 协议离婚双方各扣魅力
'forced_charm_penalty' => $forcedPenalty, // 不同意→强制,申请方受此惩罚
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.divorce_requested';
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:离婚公告事件(广播至全房间)
*
* 触发时机:协议/强制/自动离婚完成后广播。
* 强制离婚时额外显示财产转移信息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageDivorced implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
* @param string $divorceType 离婚类型(mutual|forced|auto|admin
*/
public function __construct(
public readonly Marriage $marriage,
public readonly string $divorceType,
) {}
/**
* 广播至全房间。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$this->marriage->load(['user:id,username', 'partner:id,username']);
return [
'user_username' => $this->marriage->user?->username,
'partner_username' => $this->marriage->partner?->username,
'divorce_type' => $this->divorceType,
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.divorced';
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:求婚超时失效事件(广播至求婚方私人频道)
*
* 触发时机:Horizon Job ExpireMarriageProposals 扫描到超时求婚后广播。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageExpired implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
*/
public function __construct(
public readonly Marriage $marriage,
) {}
/**
* 广播至求婚方私人频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->marriage->user_id)];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'partner_username' => $this->marriage->partner?->username,
'ring_name' => $this->marriage->ringItem?->name,
'message' => '求婚已超时失效,戒指已消失。',
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.expired';
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:求婚事件(广播至被求婚方私人频道)
*
* 触发时机:MarriageController::propose() 成功后广播。
* B 上线时前端订阅频道立即收到,展示求婚 Banner 弹窗。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageProposed implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
* @param User $proposer 求婚方
* @param User $target 被求婚方
*/
public function __construct(
public readonly Marriage $marriage,
public readonly User $proposer,
public readonly User $target,
) {}
/**
* 广播至被求婚方私人频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->target->id)];
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'marriage_id' => $this->marriage->id,
'proposer' => [
'username' => $this->proposer->username,
'headface' => $this->proposer->headface,
'user_level' => $this->proposer->user_level,
],
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
'expires_at' => $this->marriage->expires_at,
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.proposed';
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:求婚被拒事件(广播至求婚方私人频道)
*
* 触发时机:对方拒绝求婚,戒指消失后广播。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MarriageRejected implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param Marriage $marriage 婚姻记录
*/
public function __construct(
public readonly Marriage $marriage,
) {}
/**
* 广播至求婚方私人频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->marriage->user_id)];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'partner_username' => $this->marriage->partner?->username,
'ring_name' => $this->marriage->ringItem?->name,
'message' => '对方拒绝了您的求婚,戒指已消失。',
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'marriage.rejected';
}
}
+2 -2
View File
@@ -12,11 +12,11 @@ namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
*
* 触发时机:RedPacketController::claim() 成功后广播,
* 前端收到后弹出 Toast 通知展示到账金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RedPacketClaimed implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $envelopeId 红包 ID
*/
public function __construct(
public readonly User $claimer,
public readonly int $amount,
public readonly int $envelopeId,
) {}
/**
* 广播至领取者私有频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->claimer->id)];
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'envelope_id' => $this->envelopeId,
'amount' => $this->amount,
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'red-packet.claimed';
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* 文件功能:礼包红包发出事件(广播至房间所有用户)
*
* 触发时机:AdminCommandController::sendRedPacket() 成功后广播,
* 前端接收后显示红包卡片弹窗,并在聊天窗口追加系统公告。
*
* @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 RedPacketSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param int $envelopeId 红包 ID
* @param string $senderUsername 发包人用户名
* @param int $totalAmount 总金额(金币)
* @param int $totalCount 总份数
* @param int $expireSeconds 过期秒数(用于前端倒计时)
*/
public function __construct(
public readonly int $roomId,
public readonly int $envelopeId,
public readonly string $senderUsername,
public readonly int $totalAmount,
public readonly int $totalCount,
public readonly int $expireSeconds,
public readonly string $type = 'gold',
) {}
/**
* 广播至房间 Presence 频道(所有在线用户均可收到)。
*
* @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 [
'envelope_id' => $this->envelopeId,
'sender_username' => $this->senderUsername,
'total_amount' => $this->totalAmount,
'total_count' => $this->totalCount,
'expire_seconds' => $this->expireSeconds,
'type' => $this->type,
];
}
/** 自定义事件名称(前端监听时使用)。 */
public function broadcastAs(): string
{
return 'red-packet.sent';
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:婚礼庆典事件(广播至全房间)
*
* 触发时机:婚礼红包触发分发后广播。
* 前端收到后:播放烟花特效 + 婚礼音效 + 展示红包弹窗(含领取按钮)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\Marriage;
use App\Models\WeddingCeremony;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WeddingCelebration implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param WeddingCeremony $ceremony 婚礼仪式记录
* @param Marriage $marriage 婚姻记录
*/
public function __construct(
public readonly WeddingCeremony $ceremony,
public readonly Marriage $marriage,
) {}
/**
* 广播至全房间。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播数据(前端据此展示红包弹窗及新人信息)。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']);
return [
'ceremony_id' => $this->ceremony->id,
'tier_name' => $this->ceremony->tier?->name ?? '婚礼',
'tier_icon' => $this->ceremony->tier?->icon ?? '🎊',
'total_amount' => $this->ceremony->total_amount,
'expires_at' => $this->ceremony->expires_at,
'user' => $this->marriage->user?->only(['id', 'username', 'headface']),
'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']),
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
];
}
/** 广播事件名称。 */
public function broadcastAs(): string
{
return 'wedding.celebration';
}
}
@@ -46,8 +46,76 @@ class AiProviderController extends Controller
{
$providers = AiProviderConfig::orderBy('sort_order')->get();
$chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1';
$chatbotMaxGold = Sysparam::getValue('chatbot_max_gold', '5000');
$chatbotMaxDailyRewards = Sysparam::getValue('chatbot_max_daily_rewards', '1');
$chatbotFishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
$chatbotFishingChance = Sysparam::getValue('chatbot_fishing_chance', '5');
$chatbotBaccaratEnabled = Sysparam::getValue('chatbot_baccarat_enabled', '0') === '1';
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled'));
return view('admin.ai-providers.index', compact(
'providers', 'chatbotEnabled', 'chatbotMaxGold',
'chatbotMaxDailyRewards', 'chatbotFishingEnabled', 'chatbotFishingChance', 'chatbotBaccaratEnabled'
));
}
/**
* 保存全局设置
*/
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'chatbot_max_gold' => 'required|integer|min:1',
'chatbot_max_daily_rewards' => 'required|integer|min:1',
'chatbot_fishing_enabled' => 'required|in:0,1',
'chatbot_fishing_chance' => 'required|integer|min:1|max:100',
'chatbot_baccarat_enabled' => 'required|in:0,1',
]);
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_gold'],
[
'body' => (string) $data['chatbot_max_gold'],
'guidetxt' => '单次最高发放金币金额',
]
);
Sysparam::clearCache('chatbot_max_gold');
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_daily_rewards'],
[
'body' => (string) $data['chatbot_max_daily_rewards'],
'guidetxt' => '每个用户单日最多获得金币次数',
]
);
Sysparam::clearCache('chatbot_max_daily_rewards');
Sysparam::updateOrCreate(
['alias' => 'chatbot_fishing_enabled'],
[
'body' => $data['chatbot_fishing_enabled'],
'guidetxt' => 'AI 参与钓鱼游戏开关',
]
);
Sysparam::clearCache('chatbot_fishing_enabled');
Sysparam::updateOrCreate(
['alias' => 'chatbot_fishing_chance'],
[
'body' => (string) $data['chatbot_fishing_chance'],
'guidetxt' => 'AI 钓鱼抛竿概率 (每分钟)',
]
);
Sysparam::clearCache('chatbot_fishing_chance');
Sysparam::updateOrCreate(
['alias' => 'chatbot_baccarat_enabled'],
[
'body' => $data['chatbot_baccarat_enabled'],
'guidetxt' => 'AI 参与百家乐游戏开关',
]
);
Sysparam::clearCache('chatbot_baccarat_enabled');
return back()->with('success', '全局设置保存成功!');
}
/**
@@ -192,14 +260,161 @@ class AiProviderController extends Controller
Sysparam::clearCache('chatbot_enabled');
$status = $newValue === '1' ? '开启' : '关闭';
$isEnabled = $newValue === '1';
// 确保 AI 实体账号存在
$user = \App\Models\User::firstOrCreate(
['username' => 'AI小班长'],
[
'password' => \Illuminate\Support\Facades\Hash::make(\Illuminate\Support\Str::random(16)),
'user_level' => 10,
'sex' => 0, // 女性
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'jjb' => 1000000,
'sign' => '本群首席智慧小管家',
]
);
// 防止后期头像变动,强制更新到最新女生头像
if (! str_contains($user->usersf ?? '', 'ai_bot_cn_girl.png')) {
$user->update([
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'sex' => 0,
]);
}
$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' => '',
];
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
// 像真实的玩家一样,对全网活跃房间进行高调进出场播报
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
if (empty($activeRoomIds)) {
$activeRoomIds = [1]; // 兜底
}
// 把 AI 实体挂名到一个主房间,即可被 app/Console/Commands/AutoSaveExp.php 扫描发经验
$mainRoomId = $activeRoomIds[0];
if ($isEnabled) {
$this->chatState->userJoin($mainRoomId, $user->username, $userData);
} else {
// 清理可能存在的所有房间的残留挂名
foreach ($activeRoomIds as $rId) {
$this->chatState->userLeave($rId, $user->username);
}
}
foreach ($activeRoomIds as $roomId) {
$content = $isEnabled
? '<span style="color: #9333ea; font-weight: bold;">🤖 【AI小班长】 迈着整齐的步伐进入了房间,随时为您服务!</span>'
: '<span style="color: #9ca3af; font-weight: bold;">🤖 【AI小班长】 去休息啦,大家聊得开心!</span>';
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#9333ea',
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $botMsg);
broadcast(new \App\Events\MessageSent($roomId, $botMsg));
\App\Jobs\SaveMessageJob::dispatch($botMsg);
}
return response()->json([
'status' => 'success',
'message' => "聊天机器人已{$status}",
'enabled' => $newValue === '1',
'enabled' => $isEnabled,
]);
}
/**
* 测试指定 AI 厂商的接口连通性
*
* 通过 GET /v1/models 检查端点可达性与 API Key 有效性,毫秒级响应,
* 不触发模型推理,避免经 Cloudflare 代理时因推理耗时导致 524 超时。
*
* @param int $id 厂商配置 ID
* @return JsonResponse 测试结果(含可用模型列表)
*/
public function testConnection(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
$apiKey = $provider->getDecryptedApiKey();
$base = rtrim($provider->api_endpoint, '/');
// 拼接 /v1/models 端点(检查连通性,不触发推理)
$modelsUrl = str_ends_with($base, '/v1')
? $base.'/models'
: $base.'/v1/models';
$startTime = microtime(true);
try {
$response = \Illuminate\Support\Facades\Http::withToken($apiKey)
->timeout(10)
->get($modelsUrl);
$ms = (int) ((microtime(true) - $startTime) * 1000);
if (! $response->successful()) {
return response()->json([
'ok' => false,
'message' => "HTTP {$response->status()}{$response->body()}",
'ms' => $ms,
]);
}
$data = $response->json();
// 提取可用模型列表(兼容 Ollama 和 OpenAI 格式)
$models = collect($data['models'] ?? $data['data'] ?? [])
->pluck('id')
->filter()
->values()
->toArray();
$modelList = count($models) > 0
? implode('、', array_slice($models, 0, 5)).(count($models) > 5 ? ' 等' : '')
: $provider->model;
return response()->json([
'ok' => true,
'message' => "接口连通正常,可用模型:{$modelList}",
'ms' => $ms,
'models' => $models,
]);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
$ms = (int) ((microtime(true) - $startTime) * 1000);
return response()->json([
'ok' => false,
'message' => '连接失败:'.$e->getMessage(),
'ms' => $ms,
]);
}
}
/**
* 删除 AI 厂商配置
*
@@ -0,0 +1,241 @@
<?php
/**
* 文件功能:后台任命管理控制器
* 管理员可以在此查看所有在职人员、进行新增任命和撤销职务
* 任命/撤销通过 AppointmentService 执行,权限日志自动记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\AppointmentAnnounced;
use App\Http\Controllers\Controller;
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 任命管理主列表(当前全部在职人员)
*/
public function index(): View
{
// 所有在职记录(按部门 rank 倒序、职务 rank 倒序)
$activePositions = UserPosition::query()
->where('is_active', true)
->with([
'user',
'position.department',
'appointedBy',
])
->join('positions', 'user_positions.position_id', '=', 'positions.id')
->join('departments', 'positions.department_id', '=', 'departments.id')
->orderByDesc('departments.rank')
->orderByDesc('positions.rank')
->select('user_positions.*')
->get();
// 部门+职务(供新增任命弹窗下拉选择,带在职人数统计)
$departments = Department::with([
'positions' => fn ($q) => $q->withCount('activeUserPositions')->ordered(),
])->ordered()->get();
return view('admin.appointments.index', compact('activePositions', 'departments'));
}
/**
* 执行新增任命
* 管理员在后台直接任命用户,操作人为当前登录管理员
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:255',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$targetPosition = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $targetPosition, $request->remark);
if ($result['ok']) {
// 向所有当前有人在线的聊天室广播礼花公告(后台操作人不在聊天室内)
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 撤销职务
*/
public function revoke(Request $request, UserPosition $userPosition): RedirectResponse
{
$operator = Auth::user();
$target = $userPosition->user;
// 撤销前先记录职务信息(撤销后关联就断了)
$userPosition->load('position.department');
$posIcon = $userPosition->position?->icon ?? '🎖️';
$posName = $userPosition->position?->name ?? '';
$deptName = $userPosition->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
if ($result['ok']) {
// 向所有活跃房间广播撤销公告
if ($posName) {
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 查看某任职记录的在职登录日志
*/
public function dutyLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department', 'appointedBy']);
$logs = $userPosition->dutyLogs()
->orderByDesc('login_at')
->paginate(30);
// 计算该任职记录的所有在线时长总和(而非当前页)
$totalSeconds = $userPosition->dutyLogs()->sum('duration_seconds');
return view('admin.appointments.duty-logs', compact('userPosition', 'logs', 'totalSeconds'));
}
/**
* 查看某任职记录的权限操作日志
*/
public function authorityLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department']);
$logs = $userPosition->authorityLogs()
->with(['targetUser', 'targetPosition'])
->orderByDesc('created_at')
->paginate(30);
return view('admin.appointments.authority-logs', compact('userPosition', 'logs'));
}
/**
* 历史任职记录(全部 is_active=false 的记录)
*/
public function history(): View
{
$history = UserPosition::query()
->where('is_active', false)
->with(['user', 'position.department', 'appointedBy', 'revokedBy'])
->orderByDesc('revoked_at')
->paginate(30);
return view('admin.appointments.history', compact('history'));
}
/**
* 我的履职记录:展示当前登录者自己所有的权限操作记录
*
* 不限于某一任职周期,展示全部历史操作,支持按操作类型和日期筛选。
*/
public function myDutyLogs(Request $request): View
{
$user = Auth::user();
$query = \App\Models\PositionAuthorityLog::where('user_id', $user->id)
->with(['targetUser:id,username', 'targetPosition:id,name', 'userPosition.position.department']);
// 按操作类型筛选
if ($request->filled('type')) {
$query->where('action_type', $request->type);
}
// 按日期范围筛选
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$logs = $query->orderByDesc('created_at')->paginate(30)->withQueryString();
// 汇总统计
$summary = \App\Models\PositionAuthorityLog::where('user_id', $user->id)
->selectRaw('action_type, COUNT(*) as total, COALESCE(SUM(amount),0) as amount_sum')
->groupBy('action_type')
->get()
->keyBy('action_type');
return view('admin.appointments.my-duty-logs', compact('logs', 'summary', 'user'));
}
/**
* 搜索用户(供任命弹窗 Ajax 快速查找)
*/
public function searchUsers(Request $request): JsonResponse
{
$keyword = $request->input('q', '');
$users = User::query()
->where('id', '!=', 1) // 排除超级管理员
->where('username', 'like', "%{$keyword}%")
->whereDoesntHave('activePosition') // 排除已有职务的用户
->select('id', 'username', 'user_level')
->limit(10)
->get();
return response()->json($users);
}
}
@@ -52,10 +52,12 @@ class AutoactController extends Controller
/**
* 更新事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Autoact $autoact): RedirectResponse
{
$event = Autoact::findOrFail($id);
$event = $autoact;
$data = $request->validate([
'text_body' => 'required|string|max:500',
@@ -71,26 +73,29 @@ class AutoactController extends Controller
/**
* 切换事件启用/禁用状态
*
* @param Autoact $autoact 路由模型自动注入
*/
public function toggle(int $id): JsonResponse
public function toggle(Autoact $autoact): JsonResponse
{
$event = Autoact::findOrFail($id);
$event->enabled = ! $event->enabled;
$event->save();
$autoact->enabled = ! $autoact->enabled;
$autoact->save();
return response()->json([
'status' => 'success',
'enabled' => $event->enabled,
'message' => $event->enabled ? '已启用' : '已禁用',
'enabled' => $autoact->enabled,
'message' => $autoact->enabled ? '已启用' : '已禁用',
]);
}
/**
* 删除事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Autoact $autoact): RedirectResponse
{
Autoact::findOrFail($id)->delete();
$autoact->delete();
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
}
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:管理员大卡片通知广播控制器
*
* 仅超级管理员(chat.level:super 中间件保护)可调用此接口,
* 通过 BannerNotification 事件向指定用户或房间推送自定义大卡通知。
*
* 安全保证:
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
* - 普通用户无权访问此接口,无法伪造对他人的广播
* - options 中的用户输入字段在后端经过 strip_tags 清洗
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\BannerNotification;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BannerBroadcastController extends Controller
{
/**
* 向指定目标广播大卡片通知。
*
* 请求参数:
* - target: 'user' | 'room'
* - target_id: 用户名 房间 ID
* - options: window.chatBanner.show() 参数相同的对象
* - icon, title, name, body, sub, gradient(array), titleColor, autoClose, buttons(array)
*/
public function send(Request $request): JsonResponse
{
$validated = $request->validate([
'target' => ['required', 'in:user,room'],
'target_id' => ['required'],
'options' => ['required', 'array'],
'options.icon' => ['nullable', 'string', 'max:20'],
'options.title' => ['nullable', 'string', 'max:50'],
'options.name' => ['nullable', 'string', 'max:100'],
'options.body' => ['nullable', 'string', 'max:500'],
'options.sub' => ['nullable', 'string', 'max:200'],
'options.gradient' => ['nullable', 'array', 'max:5'],
'options.titleColor' => ['nullable', 'string', 'max:30'],
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
'options.buttons' => ['nullable', 'array', 'max:4'],
]);
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
$opts = $validated['options'];
foreach (['title', 'name', 'body', 'sub'] as $field) {
if (isset($opts[$field])) {
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
}
}
// 按钮 label 不允许 HTML
if (! empty($opts['buttons'])) {
$opts['buttons'] = array_map(function ($btn) {
$btn['label'] = strip_tags($btn['label'] ?? '');
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
// action 只允许预定义值,防止注入任意 JS
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
? $btn['action'] : 'close';
return $btn;
}, $opts['buttons']);
}
broadcast(new BannerNotification(
target: $validated['target'],
targetId: $validated['target_id'],
options: $opts,
));
return response()->json(['status' => 'success', 'message' => '广播已发送']);
}
}
@@ -0,0 +1,191 @@
<?php
/**
* 文件功能:后台开发日志管理控制器(仅 id=1 超级管理员可访问)
* 提供开发日志的 CRUD 功能,发布时可选择向 Room 1 大厅广播通知
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\ChangelogPublished;
use App\Http\Controllers\Controller;
use App\Jobs\SaveMessageJob;
use App\Models\DevChangelog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/**
* 后台日志列表(含草稿)
*/
public function index(): View
{
$logs = DevChangelog::orderByDesc('created_at')->paginate(20);
return view('admin.changelog.index', compact('logs'));
}
/**
* 新增日志表单页(预填今日日期)
*/
public function create(): View
{
// 预填今日日期为版本号
$todayVersion = now()->format('Y-m-d');
return view('admin.changelog.form', [
'log' => null,
'todayVersion' => $todayVersion,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => true,
]);
}
/**
* 保存新日志
* 若勾选"立即发布",则记录 published_at 并可选向大厅广播通知
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
$notifyChat = (bool) ($data['notify_chat'] ?? false);
/** @var DevChangelog $log */
$log = DevChangelog::create([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
// 只有同时勾选"通知"才记录 notify_chat,否则置 false
'notify_chat' => $isPublished && $notifyChat,
// 首次发布时记录发布时间
'published_at' => $isPublished ? Carbon::now() : null,
]);
// 如果发布且勾选了"通知大厅用户",则触发 WebSocket 广播 + 持久化到消息库
if ($isPublished && $notifyChat) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志创建成功!'.($isPublished ? '(已发布)' : '(草稿已保存)'));
}
/**
* 编辑日志表单页
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function edit(DevChangelog $changelog): View
{
return view('admin.changelog.form', [
'log' => $changelog,
'todayVersion' => $changelog->version,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => false,
]);
}
/**
* 更新日志内容(编辑操作不更新 published_at,不触发通知)
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function update(Request $request, DevChangelog $changelog): RedirectResponse
{
$log = $changelog;
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
// 如果从草稿切换为发布,记录首次发布时间
$publishedAt = $log->published_at;
if ($isPublished && ! $log->is_published) {
$publishedAt = Carbon::now();
} elseif (! $isPublished) {
// 从发布退回草稿,清除发布时间
$publishedAt = null;
}
$log->update([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
'published_at' => $publishedAt,
]);
// 若勾选了「通知大厅用户」且当前已发布,则广播通知 + 持久化到消息库
$notifyChat = (bool) ($data['notify_chat'] ?? false);
if ($notifyChat && $isPublished) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志已更新!');
}
/**
* 删除日志
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function destroy(DevChangelog $changelog): RedirectResponse
{
$changelog->delete();
return redirect()->route('admin.changelogs.index')
->with('success', '日志已删除。');
}
/**
* 将版本更新通知持久化为 Room 1 系统消息
* 确保用户重进聊天室时仍能在历史消息中看到该通知
*
* @param DevChangelog $log 已发布的日志
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
}
@@ -0,0 +1,70 @@
<?php
/**
* 文件功能:用户金币/积分流水日志查询
* 对应超级管理员级别的查询页面。可以按用户、增减、货币类型等筛选所有的账目流动。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\UserCurrencyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CurrencyLogController extends Controller
{
/**
* 显示流水日志列表
* 支持多条件检索,仅 superlevel 以及上可以访问此页面
*/
public function index(Request $request): View
{
$query = UserCurrencyLog::query()->with('user');
// 查询条件过滤
if ($request->filled('username')) {
$query->where('username', 'like', '%'.$request->input('username').'%');
}
if ($request->filled('currency')) {
$query->where('currency', $request->input('currency'));
}
if ($request->filled('source')) {
$query->where('source', $request->input('source'));
}
if ($request->filled('remark')) {
$query->where('remark', 'like', '%'.$request->input('remark').'%');
}
if ($request->filled('direction')) {
if ($request->input('direction') === 'in') {
$query->where('amount', '>', 0);
} elseif ($request->input('direction') === 'out') {
$query->where('amount', '<', 0);
}
}
if ($request->filled('date_start')) {
$query->whereDate('created_at', '>=', $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->whereDate('created_at', '<=', $request->input('date_end'));
}
// 默认按时间倒序
$logs = $query->latest('id')->paginate(50)->withQueryString();
$allSources = CurrencySource::cases();
return view('admin.currency-logs.index', compact('logs', 'allSources'));
}
}
@@ -0,0 +1,70 @@
<?php
/**
* 文件功能:后台积分活动统计控制器
* 展示今日(或指定日期)各来源活动产生的经验/金币/魅力统计,以及今日净流通量。
* 仅限 superlevel 以上管理员访问。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
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
{
/**
* 注入积分统计服务
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示指定日期的积分活动统计(默认今日)。
*/
public function index(Request $request): View
{
// 日期选择(默认今日)
$date = $request->input('date', today()->toDateString());
// 各来源活动产出统计(按 source + currency 分组汇总)
$stats = $this->currencyService->activityStats($date);
// 按货币类型分组,方便视图展示
$statsByType = $stats->groupBy('currency')->map(
fn ($rows) => $rows->keyBy('source')
);
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
$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, // 净增量
];
}
// 所有已知来源(供视图展示缺失来源的空行)
$allSources = CurrencySource::cases();
return view('admin.currency-stats.index', compact(
'date', 'stats', 'statsByType', 'netFlow', 'allSources',
));
}
}
@@ -0,0 +1,91 @@
<?php
/**
* 文件功能:后台部门管理控制器
* 提供部门的 CRUD 功能(增删改查)
* 部门是职务的上级分类,包含位阶、颜色、描述等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
/**
* 部门列表页
*/
public function index(): View
{
$departments = Department::withCount(['positions'])
->orderBy('sort_order')
->orderByDesc('rank')
->get();
return view('admin.departments.index', compact('departments'));
}
/**
* 创建部门
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name',
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
Department::create($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】创建成功!");
}
/**
* 更新部门
*/
public function update(Request $request, Department $department): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name,'.$department->id,
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
$department->update($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】更新成功!");
}
/**
* 删除部门(级联删除职务)
*/
public function destroy(Department $department): RedirectResponse
{
// 检查是否有在职人员
$activeMemberCount = $department->positions()
->whereHas('activeUserPositions')
->count();
if ($activeMemberCount > 0) {
return redirect()->route('admin.departments.index')
->with('error', "部门【{$department->name}】下尚有在职人员,请先撤销所有职务后再删除。");
}
$department->delete();
return redirect()->route('admin.departments.index')->with('success', "部门【{$department->name}】已删除!");
}
}
@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:后台用户反馈管理控制器(仅 id=1 超级管理员可访问)
* 提供反馈列表查看、处理状态修改、官方回复功能
* 侧边栏显示待处理数量徽标
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackManagerController extends Controller
{
/**
* 后台反馈列表页(支持类型+状态筛选)
*
* @param Request $request type/status 筛选参数
*/
public function index(Request $request): View
{
$type = $request->input('type');
$status = $request->input('status');
$query = FeedbackItem::with(['replies'])
->orderByDesc('created_at');
// 按类型筛选
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
// 按状态筛选
if ($status && array_key_exists($status, FeedbackItem::STATUS_CONFIG)) {
$query->ofStatus($status);
}
$feedbacks = $query->paginate(20)->withQueryString();
// 待处理数量(用于侧边栏徽标)
$pendingCount = FeedbackItem::pending()->count();
return view('admin.feedback.index', [
'feedbacks' => $feedbacks,
'pendingCount' => $pendingCount,
'statusConfig' => FeedbackItem::STATUS_CONFIG,
'typeConfig' => FeedbackItem::TYPE_CONFIG,
'currentType' => $type,
'currentStatus' => $status,
]);
}
/**
* 更新反馈处理状态和官方回复(Ajax + 表单双模式)
* Ajax 返回 JSON,普通表单提交返回重定向
*
* @param Request $request status/admin_remark 字段
* @param int $id 反馈 ID
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'status' => 'required|in:'.implode(',', array_keys(FeedbackItem::STATUS_CONFIG)),
'admin_remark' => 'nullable|string|max:2000',
]);
$feedback->update([
'status' => $data['status'],
'admin_remark' => $data['admin_remark'] ?? $feedback->admin_remark,
]);
// 如果有新的官方回复内容,同时写入 feedback_replies(带 is_admin 标记)
if (! empty($data['admin_remark']) && $data['admin_remark'] !== $feedback->getOriginal('admin_remark')) {
DB::transaction(function () use ($feedback, $data): void {
FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => 1,
'username' => '🛡️ 开发者',
'content' => $data['admin_remark'],
'is_admin' => true,
]);
$feedback->increment('replies_count');
});
}
// Ajax 请求返回 JSON
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'new_status' => $data['status'],
'status_label' => FeedbackItem::STATUS_CONFIG[$data['status']]['icon'].' '.FeedbackItem::STATUS_CONFIG[$data['status']]['label'],
'status_color' => FeedbackItem::STATUS_CONFIG[$data['status']]['color'],
]);
}
return redirect()->route('admin.feedback.index')
->with('success', '反馈状态已更新!');
}
}
@@ -0,0 +1,108 @@
<?php
/**
* 文件功能:钓鱼事件后台管理控制器
* 提供钓鱼事件的列表展示、创建、编辑、删除、启用/禁用功能
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FishingEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FishingEventController extends Controller
{
/**
* 显示所有钓鱼事件列表
*/
public function index(): View
{
$events = FishingEvent::orderBy('sort')->orderBy('id')->get();
$totalWeight = $events->where('is_active', true)->sum('weight');
return view('admin.fishing.index', compact('events', 'totalWeight'));
}
/**
* 创建新钓鱼事件
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active', true);
FishingEvent::create($data);
return redirect()->route('admin.fishing.index')->with('success', '钓鱼事件已添加!');
}
/**
* 更新钓鱼事件
*
* @param FishingEvent $fishing 路由模型绑定
*/
public function update(Request $request, FishingEvent $fishing): RedirectResponse
{
$data = $request->validate([
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active');
$fishing->update($data);
return redirect()->route('admin.fishing.index')->with('success', "事件「{$fishing->name}」已更新!");
}
/**
* 切换事件启用/禁用状态(AJAX
*
* @param FishingEvent $fishing 路由模型绑定
*/
public function toggle(FishingEvent $fishing): JsonResponse
{
$fishing->update(['is_active' => ! $fishing->is_active]);
return response()->json([
'ok' => true,
'is_active' => $fishing->is_active,
'message' => $fishing->is_active ? "{$fishing->name}」已启用" : "{$fishing->name}」已禁用",
]);
}
/**
* 删除钓鱼事件
*
* @param FishingEvent $fishing 路由模型绑定
*/
public function destroy(FishingEvent $fishing): RedirectResponse
{
$name = $fishing->name;
$fishing->delete();
return redirect()->route('admin.fishing.index')->with('success', "事件「{$name}」已删除!");
}
}
@@ -0,0 +1,204 @@
<?php
/**
* 文件功能:禁用用户名管理控制器(站长专用)
*
* 管理 username_blacklist 表中 type=permanent 的永久禁用词列表。
* 包含:国家领导人名称、攻击性词汇、违禁词等不允许注册或改名的词语。
*
* 用户在注册(AuthController)和改名(ShopService::useRenameCard)时均会经过该表检测。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UsernameBlacklist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ForbiddenUsernameController extends Controller
{
/**
* 分页列出所有永久禁用词。
* 支持关键词模糊搜索(GET ?q=xxx)。
*/
public function index(Request $request): \Illuminate\View\View
{
$q = $request->query('q', '');
$items = UsernameBlacklist::permanent()
->when($q, fn ($query) => $query->where('username', 'like', "%{$q}%"))
->orderByDesc('created_at')
->paginate(20)
->withQueryString();
return view('admin.forbidden-usernames.index', [
'items' => $items,
'q' => $q,
]);
}
/**
* 新增一条永久禁用词。
*
* @param Request $request 请求体:username(必填),reason(选填)
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:50'],
'reason' => ['nullable', 'string', 'max:100'],
], [
'username.required' => '禁用词不能为空。',
'username.max' => '禁用词最长50字符。',
]);
$username = trim($validated['username']);
// 已存在同名的永久记录则不重复插入
$exists = UsernameBlacklist::permanent()
->where('username', $username)
->exists();
if ($exists) {
return response()->json(['status' => 'error', 'message' => '该词语已在永久禁用列表中。'], 422);
}
UsernameBlacklist::create([
'username' => $username,
'type' => 'permanent',
'reserved_until' => null,
'reason' => $validated['reason'] ?? null,
'created_at' => Carbon::now(),
]);
return response()->json(['status' => 'success', 'message' => "{$username}」已加入永久禁用列表。"]);
}
/**
* 批量添加永久禁用词。
*
* 接受多行文本或逗号分隔的词语列表,自动去重并过滤已存在者。
* 返回成功添加数量和跳过数量。
*
* @param Request $request 请求体:words(换行/逗号分隔),reason(选填,共用)
*/
public function batchStore(Request $request): JsonResponse
{
$validated = $request->validate([
'words' => ['required', 'string'],
'reason' => ['nullable', 'string', 'max:100'],
], [
'words.required' => '请输入至少一个词语。',
]);
// 净化输入:去除非法 UTF-8 字节(零宽字符、BOM、控制字符等),防止 json_encode 失败
$rawInput = $this->sanitizeUtf8($validated['words']);
$reason = $this->sanitizeUtf8(trim($validated['reason'] ?? ''));
// 支持换行、逗号、中文逗号、空格分隔
$rawWords = preg_split('/[\r\n,\s]+/u', $rawInput);
// 过滤空串、超长词、去重
$words = collect($rawWords)
->map(fn ($w) => trim($w))
->filter(fn ($w) => $w !== '' && mb_strlen($w) <= 50)
->unique()
->values();
if ($words->isEmpty()) {
return response()->json(['status' => 'error', 'message' => '没有有效的词语,请检查输入。'], 422);
}
// 批量查询已存在的词(一次查询)
$existing = UsernameBlacklist::permanent()
->whereIn('username', $words->all())
->pluck('username')
->flip();
$now = Carbon::now();
$added = 0;
$rows = [];
foreach ($words as $word) {
if ($existing->has($word)) {
continue;
}
$rows[] = [
'username' => $word,
'type' => 'permanent',
'reserved_until' => null,
'reason' => $reason ?: null,
'created_at' => $now,
];
$added++;
}
if (! empty($rows)) {
UsernameBlacklist::insert($rows);
}
$skipped = $words->count() - $added;
$msg = "成功添加 {$added} 个词语".($skipped > 0 ? ",跳过 {$skipped} 个(已存在)" : '').'。';
return response()->json(['status' => 'success', 'message' => $msg, 'added' => $added], 200, [], JSON_UNESCAPED_UNICODE);
}
/**
* 净化字符串,移除非法 UTF-8 字节及常见控制/零宽字符。
*
* @param string $str 待净化字符串
* @return string 合法的 UTF-8 字符串
*/
private function sanitizeUtf8(string $str): string
{
// 去除 BOM
$str = str_replace("\xEF\xBB\xBF", '', $str);
// 去除零宽字符(零宽空格、零宽不连字等)
$str = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}\x{00AD}]/u', '', $str);
// 转换为合法 UTF-8,忽略非法字节
$str = mb_convert_encoding($str, 'UTF-8', 'UTF-8');
// 保底:去除控制字符(保留换行 \r\n)
$str = preg_replace('/[^\P{C}\r\n]+/u', '', $str);
return $str;
}
/**
* 更新指定禁用词的原因备注。
*
* @param int $id 记录 ID
* @param Request $request 请求体:reason
*/
public function update(Request $request, int $id): JsonResponse
{
$item = UsernameBlacklist::permanent()->findOrFail($id);
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:100'],
]);
$item->update(['reason' => $validated['reason']]);
return response()->json(['status' => 'success', 'message' => '备注已更新。']);
}
/**
* 删除指定永久禁用词。
*
* @param int $id 记录 ID
*/
public function destroy(int $id): JsonResponse
{
$item = UsernameBlacklist::permanent()->findOrFail($id);
$name = $item->username;
$item->delete();
return response()->json(['status' => 'success', 'message' => "{$name}」已从永久禁用列表移除。"]);
}
}
@@ -0,0 +1,166 @@
<?php
/**
* 文件功能:游戏配置后台管理控制器
*
* 管理员可在此页面统一管理所有娱乐游戏的开关状态和核心参数。
* 每个游戏的参数说明通过前端渲染,后台只做通用 JSON 存储。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class GameConfigController extends Controller
{
/**
* 游戏管理总览页面。
*/
public function index(): View
{
$games = GameConfig::orderBy('id')->get();
return view('admin.game-configs.index', compact('games'));
}
/**
* 切换游戏开启/关闭状态。
*/
public function toggle(GameConfig $gameConfig): JsonResponse
{
$gameConfig->update(['enabled' => ! $gameConfig->enabled]);
$gameConfig->clearCache();
return response()->json([
'ok' => true,
'enabled' => $gameConfig->enabled,
'message' => $gameConfig->enabled
? "{$gameConfig->name}」已开启"
: "{$gameConfig->name}」已关闭",
]);
}
/**
* 保存游戏核心参数。
*
* 接收前端提交的 params JSON 对象并合并至现有配置。
*/
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
{
$request->validate([
'params' => 'required|array',
]);
// 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params'));
if ($gameConfig->game_key === 'mystery_box') {
$legacyMap = [
'min_reward' => 'normal_reward_min',
'max_reward' => 'normal_reward_max',
'rare_min_reward' => 'rare_reward_min',
'rare_max_reward' => 'rare_reward_max',
];
foreach ($legacyMap as $legacyKey => $newKey) {
if (! array_key_exists($newKey, $updated) && array_key_exists($legacyKey, $updated)) {
$updated[$newKey] = $updated[$legacyKey];
}
unset($updated[$legacyKey]);
}
}
$gameConfig->update(['params' => $updated]);
$gameConfig->clearCache();
return back()->with('success', "{$gameConfig->name}」参数已保存!");
}
/**
* 管理员手动投放神秘箱子。
*
* 立即分发 DropMysteryBoxJob 到队列,由 Horizon 执行箱子投放和公屏广播。
*/
public function dropMysteryBox(Request $request): JsonResponse
{
if (! \App\Models\GameConfig::isEnabled('mystery_box')) {
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放,请先开启。']);
}
$boxType = $request->input('box_type', 'normal');
if (! in_array($boxType, ['normal', 'rare', 'trap'], true)) {
return response()->json(['ok' => false, 'message' => '无效的箱子类型。']);
}
// 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
]);
}
/**
* 手动开启新一期双色球彩票。
*
* 仅在当前无进行中期次时生效,防止重复开期。
*/
public function openLotteryIssue(): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
}
if (LotteryIssue::currentIssue()) {
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
}
\App\Jobs\OpenLotteryIssueJob::dispatch();
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
}
/**
* 管理员强制立即开奖(测试专用)。
*
* 将当前 open closed 期次直接投入开奖队列。
*/
public function forceLotteryDraw(LotteryService $lottery): JsonResponse
{
$issue = LotteryIssue::query()
->whereIn('status', ['open', 'closed'])
->latest()
->first();
if (! $issue) {
return response()->json(['ok' => false, 'message' => '当前无可开奖的期次。']);
}
// 强制将状态改为 closed
$issue->update(['status' => 'closed']);
\App\Jobs\DrawLotteryJob::dispatch($issue->fresh());
return response()->json(['ok' => true, 'message' => "✅ 开奖任务已入队,第 {$issue->issue_no} 期将就绪开奖!"]);
}
}
@@ -0,0 +1,317 @@
<?php
/**
* 文件功能:游戏历史记录后台查询控制器
*
* 提供百家乐、老虎机、赛马竞猜、神秘箱子、神秘占卜各游戏
* 的历史记录查询页面及统计摘要接口,供管理员查阅。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\FortuneLog;
use App\Models\GomokuGame;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Models\MysteryBox;
use App\Models\SlotMachineLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class GameHistoryController extends Controller
{
/**
* 各游戏实时统计摘要(JSON 接口,供 game-configs 首页加载)。
*/
public function stats(): JsonResponse
{
// 百家乐:最近30天
$baccarat = [
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
'total_bets' => BaccaratBet::query()->count(),
'total_payout' => BaccaratRound::query()->where('status', 'settled')->sum('total_payout'),
'today_rounds' => BaccaratRound::query()->where('status', 'settled')->whereDate('settled_at', today())->count(),
];
// 老虎机
$slot = [
'total_spins' => SlotMachineLog::query()->count(),
'total_cost' => SlotMachineLog::query()->sum('cost'),
'total_payout' => SlotMachineLog::query()->sum('payout'),
'jackpot_count' => SlotMachineLog::query()->where('result_type', 'jackpot')->count(),
'today_spins' => SlotMachineLog::query()->whereDate('created_at', today())->count(),
];
// 赛马
$horse = [
'total_races' => HorseRace::query()->where('status', 'settled')->count(),
'total_bets' => HorseBet::query()->count(),
'total_pool' => HorseRace::query()->where('status', 'settled')->sum('total_pool'),
'today_races' => HorseRace::query()->where('status', 'settled')->whereDate('settled_at', today())->count(),
];
// 神秘箱子
$mysteryBox = [
'total_dropped' => MysteryBox::query()->count(),
'total_claimed' => MysteryBox::query()->where('status', 'claimed')->count(),
'total_expired' => MysteryBox::query()->where('status', 'expired')->count(),
'today_dropped' => MysteryBox::query()->whereDate('created_at', today())->count(),
];
// 占卜
$fortune = [
'total_times' => FortuneLog::query()->count(),
'jackpot_count' => FortuneLog::query()->where('grade', 'jackpot')->count(),
'curse_count' => FortuneLog::query()->where('grade', 'curse')->count(),
'today_times' => FortuneLog::query()->whereDate('created_at', today())->count(),
];
// 彩票
$lottery = [
'total_issues' => LotteryIssue::query()->count(),
'total_bets' => LotteryTicket::query()->count(),
'total_pool' => LotteryIssue::query()->where('status', 'settled')->sum('pool_amount'),
'today_issues' => LotteryIssue::query()->whereDate('created_at', today())->count(),
];
// 五子棋
$gomoku = [
'total_games' => GomokuGame::query()->count(),
'pvp_count' => GomokuGame::query()->where('mode', 'pvp')->count(),
'pve_count' => GomokuGame::query()->where('mode', 'pve')->count(),
'today_games' => GomokuGame::query()->whereDate('created_at', today())->count(),
];
return response()->json([
'baccarat' => $baccarat,
'slot' => $slot,
'horse' => $horse,
'mystery_box' => $mysteryBox,
'fortune' => $fortune,
'lottery' => $lottery,
'gomoku' => $gomoku,
]);
}
/**
* 百家乐历史记录页面(局次列表,支持分页)。
*/
public function baccarat(Request $request): View
{
// 各局统计摘要
$summary = [
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
'total_bets' => BaccaratBet::query()->count(),
'total_payout' => (int) BaccaratRound::query()->where('status', 'settled')->sum('total_payout'),
// 会员实际输掉的金币:所有已结算落败注单的押注金额合计
'total_lost' => (int) BaccaratBet::query()->where('status', 'lost')->sum('amount'),
'result_dist' => BaccaratRound::query()
->where('status', 'settled')
->select('result', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('result')
->pluck('cnt', 'result'),
];
$rounds = BaccaratRound::query()
->latest()
->paginate(20);
return view('admin.game-history.baccarat', compact('rounds', 'summary'));
}
/**
* 百家乐单局下注明细。
*/
public function baccaratRound(BaccaratRound $round): View
{
$bets = $round->bets()->with('user')->latest()->paginate(30);
return view('admin.game-history.baccarat-round', compact('round', 'bets'));
}
/**
* 老虎机历史记录页面(支持按结果类型筛选/分页)。
*/
public function slot(Request $request): View
{
// 统计摘要
$summary = [
'total_spins' => SlotMachineLog::query()->count(),
'total_cost' => (int) SlotMachineLog::query()->sum('cost'),
'total_payout' => (int) SlotMachineLog::query()->sum('payout'),
'net_income' => (int) SlotMachineLog::query()->sum('cost') - (int) SlotMachineLog::query()->sum('payout'),
'result_dist' => SlotMachineLog::query()
->select('result_type', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('result_type')
->pluck('cnt', 'result_type'),
];
$query = SlotMachineLog::query()->with('user')->latest();
// 按结果类型筛选
if ($request->filled('result_type')) {
$query->where('result_type', $request->input('result_type'));
}
// 按用户名筛选
if ($request->filled('username')) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('username', 'like', '%'.$request->input('username').'%');
});
}
$logs = $query->paginate(30)->withQueryString();
return view('admin.game-history.slot', compact('logs', 'summary'));
}
/**
* 赛马竞猜历史记录页面(场次列表,支持分页)。
*/
public function horse(Request $request): View
{
$summary = [
'total_races' => HorseRace::query()->where('status', 'settled')->count(),
'total_bets' => HorseBet::query()->count(),
'total_pool' => (int) HorseRace::query()->sum('total_pool'),
];
$races = HorseRace::query()
->latest()
->paginate(20);
return view('admin.game-history.horse', compact('races', 'summary'));
}
/**
* 赛马单场下注明细。
*/
public function horseRace(HorseRace $race): View
{
$bets = $race->bets()->with('user')->latest()->paginate(30);
return view('admin.game-history.horse-race', compact('race', 'bets'));
}
/**
* 神秘箱子历史记录(投放/领取列表,支持分页和类型筛选)。
*/
public function mysteryBox(Request $request): View
{
$summary = [
'total_dropped' => MysteryBox::query()->count(),
'total_claimed' => MysteryBox::query()->where('status', 'claimed')->count(),
'total_expired' => MysteryBox::query()->where('status', 'expired')->count(),
'type_dist' => MysteryBox::query()
->select('box_type', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('box_type')
->pluck('cnt', 'box_type'),
];
$query = MysteryBox::query()->with(['claim.user'])->latest();
if ($request->filled('box_type')) {
$query->where('box_type', $request->input('box_type'));
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$boxes = $query->paginate(20)->withQueryString();
return view('admin.game-history.mystery-box', compact('boxes', 'summary'));
}
/**
* 神秘占卜历史记录(支持按用户/签文等级筛选,分页)。
*/
public function fortune(Request $request): View
{
$summary = [
'total_times' => FortuneLog::query()->count(),
'grade_dist' => FortuneLog::query()
->select('grade', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('grade')
->pluck('cnt', 'grade'),
'total_cost' => (int) FortuneLog::query()->sum('cost'),
'free_count' => FortuneLog::query()->where('is_free', true)->count(),
];
$query = FortuneLog::query()->with('user')->latest();
if ($request->filled('grade')) {
$query->where('grade', $request->input('grade'));
}
if ($request->filled('username')) {
$query->whereHas('user', function ($q) use ($request) {
$q->where('username', 'like', '%'.$request->input('username').'%');
});
}
$logs = $query->paginate(30)->withQueryString();
return view('admin.game-history.fortune', compact('logs', 'summary'));
}
/**
* 双色球彩票历史记录页面(期号列表,支持分页)。
*/
public function lottery(Request $request): View
{
$summary = [
'total_issues' => LotteryIssue::query()->count(),
'total_tickets' => LotteryTicket::query()->count(),
'total_pool' => (int) LotteryIssue::query()->sum('pool_amount'),
'drawn_count' => LotteryIssue::query()->where('status', 'settled')->count(),
];
$issues = LotteryIssue::query()
->latest()
->paginate(20);
return view('admin.game-history.lottery', compact('issues', 'summary'));
}
/**
* 双色球单期购买明细与中奖详情。
*/
public function lotteryIssue(LotteryIssue $issue): View
{
$tickets = $issue->tickets()->with('user')->latest()->paginate(30);
return view('admin.game-history.lottery-issue', compact('issue', 'tickets'));
}
/**
* 五子棋历史记录页面。
*/
public function gomoku(Request $request): View
{
$summary = [
'total_games' => GomokuGame::query()->count(),
'pvp_count' => GomokuGame::query()->where('mode', 'pvp')->count(),
'pve_count' => GomokuGame::query()->where('mode', 'pve')->count(),
'completed' => GomokuGame::query()->where('status', 'finished')->count(),
'today_games' => GomokuGame::query()->whereDate('created_at', today())->count(),
];
$games = GomokuGame::query()
->with(['playerBlack', 'playerWhite'])
->latest()
->paginate(30);
return view('admin.game-history.gomoku', compact('games', 'summary'));
}
}
@@ -0,0 +1,151 @@
<?php
/**
* 文件功能:节日福利后台管理控制器
*
* 管理员可在此创建、编辑、删除节日福利活动,
* 也可手动立即触发活动,以及查看领取明细。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\TriggerHolidayEventJob;
use App\Models\HolidayEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HolidayEventController extends Controller
{
/**
* 节日福利活动列表页。
*/
public function index(): View
{
$events = HolidayEvent::query()
->orderByDesc('send_at')
->paginate(20);
return view('admin.holiday-events.index', compact('events'));
}
/**
* 新建活动表单页。
*/
public function create(): View
{
return view('admin.holiday-events.create');
}
/**
* 保存新活动。
*/
public function store(Request $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);
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
}
/**
* 编辑活动表单页。
*/
public function edit(HolidayEvent $holidayEvent): View
{
return view('admin.holiday-events.edit', ['event' => $holidayEvent]);
}
/**
* 更新活动。
*/
public function update(Request $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);
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
}
/**
* 切换活动启用/禁用状态。
*/
public function toggle(HolidayEvent $holidayEvent): JsonResponse
{
$holidayEvent->update(['enabled' => ! $holidayEvent->enabled]);
return response()->json([
'ok' => true,
'enabled' => $holidayEvent->enabled,
'message' => $holidayEvent->enabled ? '已启用' : '已禁用',
]);
}
/**
* 手动立即触发活动(管理员操作)。
*/
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
{
if ($holidayEvent->status !== 'pending') {
return back()->with('error', '只有待触发状态的活动才能手动触发。');
}
// 设置触发时间为当前,立即入队
$holidayEvent->update(['send_at' => now()]);
TriggerHolidayEventJob::dispatch($holidayEvent);
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
}
/**
* 删除活动。
*/
public function destroy(HolidayEvent $holidayEvent): RedirectResponse
{
$holidayEvent->delete();
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
}
}
@@ -0,0 +1,248 @@
<?php
/**
* 文件功能:后台婚姻系统管理控制器
*
* 提供总览统计、婚姻/求婚明细查询、婚礼档位管理、
* 参数配置、亲密度日志审计、强制离婚等管理操作。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Marriage;
use App\Models\MarriageIntimacyLog;
use App\Models\WeddingCeremony;
use App\Models\WeddingEnvelopeClaim;
use App\Models\WeddingTier;
use App\Services\MarriageConfigService;
use App\Services\MarriageService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarriageManagerController extends Controller
{
public function __construct(
private readonly MarriageConfigService $config,
private readonly MarriageService $marriageService,
) {}
/**
* 婚姻管理总览(统计卡片 + 最近记录)。
*/
public function index(): View
{
$stats = [
'total_married' => Marriage::where('status', 'married')->count(),
'total_pending' => Marriage::where('status', 'pending')->count(),
'total_divorced' => Marriage::where('status', 'divorced')->count(),
'total_weddings' => WeddingCeremony::whereIn('status', ['active', 'completed'])->count(),
'total_envelopes' => WeddingEnvelopeClaim::sum('amount'),
'claimed_amount' => WeddingEnvelopeClaim::where('claimed', true)->sum('amount'),
];
$recentMarriages = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
->where('status', 'married')
->orderByDesc('married_at')
->limit(10)
->get();
$recentDivorces = Marriage::with(['user:id,username', 'partner:id,username'])
->where('status', 'divorced')
->orderByDesc('divorced_at')
->limit(8)
->get();
return view('admin.marriages.index', compact('stats', 'recentMarriages', 'recentDivorces'));
}
/**
* 婚姻列表(支持按状态/用户名筛选)。
*/
public function list(Request $request): View
{
$query = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
->orderByDesc('id');
if ($status = $request->get('status')) {
$query->where('status', $status);
}
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->whereHas('user', fn ($u) => $u->where('username', 'like', "%{$search}%"))
->orWhereHas('partner', fn ($u) => $u->where('username', 'like', "%{$search}%"));
});
}
$marriages = $query->paginate(20)->withQueryString();
return view('admin.marriages.list', compact('marriages'));
}
/**
* 求婚记录列表(含 pending/expired/rejected)。
*/
public function proposals(Request $request): View
{
$proposals = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
->orderByDesc('proposed_at')
->paginate(20)
->withQueryString();
return view('admin.marriages.proposals', compact('proposals'));
}
/**
* 婚礼红包记录。
*/
public function ceremonies(Request $request): View
{
$ceremonies = WeddingCeremony::with([
'marriage.user:id,username',
'marriage.partner:id,username',
'tier:id,name,tier,icon',
])
->orderByDesc('id')
->paginate(20)
->withQueryString();
return view('admin.marriages.ceremonies', compact('ceremonies'));
}
/**
* 红包领取明细(某场婚礼)。
*/
public function claimDetail(WeddingCeremony $ceremony): View
{
$ceremony->load(['marriage.user:id,username', 'marriage.partner:id,username', 'tier:id,name,icon']);
$claims = WeddingEnvelopeClaim::with('user:id,username,headface')
->where('ceremony_id', $ceremony->id)
->orderBy('amount', 'desc')
->paginate(30);
return view('admin.marriages.claim-detail', compact('ceremony', 'claims'));
}
/**
* 亲密度日志列表(支持按用户筛选)。
*/
public function intimacyLogs(Request $request): View
{
$query = MarriageIntimacyLog::with(['marriage.user:id,username', 'marriage.partner:id,username'])
->orderByDesc('id');
if ($search = $request->get('search')) {
$query->whereHas('marriage', function ($q) use ($search) {
$q->whereHas('user', fn ($u) => $u->where('username', 'like', "%{$search}%"))
->orWhereHas('partner', fn ($u) => $u->where('username', 'like', "%{$search}%"));
});
}
if ($source = $request->get('source')) {
$query->where('source', $source);
}
$logs = $query->paginate(30)->withQueryString();
return view('admin.marriages.intimacy-logs', compact('logs'));
}
/**
* 参数配置页面(读取所有分组配置)。
*/
public function configs(): View
{
$groups = $this->config->allGrouped();
return view('admin.marriages.configs', compact('groups'));
}
/**
* 批量保存参数配置。
*/
public function updateConfigs(Request $request): RedirectResponse
{
$data = $request->validate([
'configs' => 'required|array',
'configs.*' => 'required|integer',
]);
$this->config->batchSet($data['configs']);
return redirect()->route('admin.marriages.configs')->with('success', '婚姻参数配置已保存!');
}
/**
* 婚礼档位配置页面。
*/
public function tiers(): View
{
$tiers = WeddingTier::orderBy('tier')->get();
return view('admin.marriages.tiers', compact('tiers'));
}
/**
* 更新婚礼档位。
*/
public function updateTier(Request $request, WeddingTier $tier): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:30',
'icon' => 'required|string|max:20',
'amount' => 'required|integer|min:1',
'description' => 'nullable|string|max:100',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active', true);
$tier->update($data);
return redirect()->route('admin.marriages.tiers')->with('success', "档位【{$tier->name}】已更新!");
}
/**
* 管理员强制离婚。
*/
public function forceDissolve(Request $request, Marriage $marriage): RedirectResponse
{
$data = $request->validate([
'admin_note' => 'required|string|max:200',
]);
if ($marriage->status !== 'married') {
return back()->with('error', '该婚姻不是已婚状态,无法操作。');
}
$admin = $request->user();
$result = $this->marriageService->forceDissolve($marriage, $admin, true);
// 写入管理员备注
$marriage->update(['admin_note' => $data['admin_note']]);
$msg = $result['ok'] ? '强制离婚已完成。' : $result['message'];
$type = $result['ok'] ? 'success' : 'error';
return back()->with($type, $msg);
}
/**
* 管理员取消求婚(释放戒指 退还状态 active)。
*/
public function cancelProposal(Request $request, Marriage $marriage): RedirectResponse
{
if ($marriage->status !== 'pending') {
return back()->with('error', '该求婚不是进行中状态,无法取消。');
}
$this->marriageService->expireProposal($marriage);
$marriage->update(['admin_note' => '管理员手动取消求婚:'.($request->input('reason', ''))]);
return back()->with('success', '求婚已取消,戒指标记遗失。');
}
}
@@ -0,0 +1,108 @@
<?php
/**
* 文件功能:运维工具控制器
* 提供缓存清理、路由清理、视图清理、房间在线名单清理等一键运维操作
* id=1 超管可访问
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\View\View;
class OpsController extends Controller
{
/**
* 运维工具主页
*/
public function index(): View
{
if (Auth::id() !== 1) {
abort(403, '无权限操作');
}
return view('admin.ops.index');
}
/**
* 清理应用缓存(config:clear + cache:clear
*/
public function clearCache(): RedirectResponse
{
if (Auth::id() !== 1) {
abort(403, '无权限操作');
}
Artisan::call('config:clear');
Artisan::call('cache:clear');
return redirect()->route('admin.ops.index')
->with('ops_success', '✅ 应用缓存已清除(config:clear + cache:clear');
}
/**
* 清理路由缓存(route:clear
*/
public function clearRoutes(): RedirectResponse
{
if (Auth::id() !== 1) {
abort(403, '无权限操作');
}
Artisan::call('route:clear');
return redirect()->route('admin.ops.index')
->with('ops_success', '✅ 路由缓存已清除(route:clear');
}
/**
* 清理视图缓存(view:clear
*/
public function clearViews(): RedirectResponse
{
if (Auth::id() !== 1) {
abort(403, '无权限操作');
}
Artisan::call('view:clear');
return redirect()->route('admin.ops.index')
->with('ops_success', '✅ 视图缓存已清除(view:clear');
}
/**
* 清理所有房间 Redis 在线名单(清除幽灵在线脏数据)
*/
public function clearRoomOnline(): RedirectResponse
{
if (Auth::id() !== 1) {
abort(403, '无权限操作');
}
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$cleaned = 0;
do {
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
// 去掉前缀,还原为 Laravel Facade 使用的短 Key
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
Redis::del($shortKey);
$cleaned++;
}
} while ($cursor !== '0');
return redirect()->route('admin.ops.index')
->with('ops_success', "✅ 已清理 {$cleaned} 个房间的在线名单(幽灵在线已清除)");
}
}
@@ -0,0 +1,175 @@
<?php
/**
* 文件功能:后台职务管理控制器
* 提供职务的 CRUD 功能,包含任命权限白名单(多选 position_appoint_limits)的同步
* 职务属于部门,包含等级、图标、人数上限、奖励上限等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\Sysparam;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PositionController extends Controller
{
/**
* 职务列表页
*/
public function index(): View
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
// 全局奖励接收次数上限(0 = 不限)
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
}
/**
* 创建职务(同时同步任命白名单)
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position = Position::create($data);
// 同步任命白名单(有选则写,没选则清空=无任命权)
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
}
/**
* 快速补丁:仅更新职务的数值限额字段(内联编辑专用)
*
* 允许修改的字段:max_persons / max_reward / daily_reward_limit。
* 只接受 JSON AJAX 请求,只更新提交的字段,其余字段保持不变。
*
* @param Position $position 目标职务
*/
public function quickPatch(Request $request, Position $position): \Illuminate\Http\JsonResponse
{
$data = $request->validate([
'max_persons' => 'sometimes|nullable|integer|min:1|max:9999',
'max_reward' => 'sometimes|nullable|integer|min:0|max:999999',
'daily_reward_limit' => 'sometimes|nullable|integer|min:0|max:999999',
]);
// 用 fill+save 确保 null 值(不限)也能正确写入
$position->fill($data)->save();
return response()->json(['status' => 'success']);
}
/**
* 保存全局奖励金币接收次数上限
*
* 控制每位用户单日内可从所有职务持有者处累计接收奖励的最高次数。
* 0 表示不限制,保存到 sysparam 表中(key: reward_recipient_daily_max)。
*/
public function saveRewardConfig(Request $request): \Illuminate\Http\JsonResponse|RedirectResponse
{
$request->validate([
'reward_recipient_daily_max' => 'required|integer|min:0|max:9999',
]);
$value = (string) $request->integer('reward_recipient_daily_max');
Sysparam::updateOrCreate(
['alias' => 'reward_recipient_daily_max'],
[
'body' => $value,
'guidetxt' => '用户单日最多接收奖励金币次数(0=不限,统计所有职务持有者的发放总次数)',
]
);
Sysparam::clearCache('reward_recipient_daily_max');
$label = $value === '0' ? '不限' : "{$value}";
// AJAX 请求返回 JSON,普通表单提交返回重定向
if ($request->expectsJson()) {
return response()->json(['status' => 'success', 'message' => "全局接收次数上限已更新为:{$label}"]);
}
return redirect()->route('admin.positions.index')
->with('success', "全局接收次数上限已更新为:{$label}");
}
/**
* 更新职务(含任命白名单同步)
*/
public function update(Request $request, Position $position): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】更新成功!");
}
/**
* 删除职务(有在职人员时拒绝)
*/
public function destroy(Position $position): RedirectResponse
{
if ($position->isFull() || $position->activeUserPositions()->exists()) {
return redirect()->route('admin.positions.index')
->with('error', "职务【{$position->name}】尚有在职人员,请先撤销后再删除。");
}
$position->delete();
return redirect()->route('admin.positions.index')->with('success', "职务【{$position->name}】已删除!");
}
}
@@ -2,11 +2,12 @@
/**
* 文件功能:后台房间管理控制器
* 管理员可查看、编辑房间信息(名称、介绍、公告等)
* 管理员可新增、编辑、删除房间信息(名称、介绍、公告等)
* 系统房间(room_keep = true)不允许删除
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* @version 1.1.0
*/
namespace App\Http\Controllers\Admin;
@@ -30,12 +31,36 @@ class RoomManagerController extends Controller
}
/**
* 更新房间信息
* 新增房间
*/
public function update(Request $request, int $id): RedirectResponse
public function store(Request $request): RedirectResponse
{
$room = Room::findOrFail($id);
$data = $request->validate([
'room_name' => 'required|string|max:100|unique:rooms,room_name',
'room_des' => 'nullable|string|max:500',
'room_owner' => 'nullable|string|max:50',
'permit_level' => 'required|integer|min:0|max:15',
'door_open' => 'required|boolean',
], [
'room_name.unique' => '房间名称已存在,请换一个名称。',
]);
// 设置新建房间的默认值
$data['room_keep'] = false; // 新建房间均为非系统房间,可删除
$data['build_time'] = now();
$room = Room::create($data);
return redirect()->route('admin.rooms.index')->with('success', "房间「{$room->room_name}」新建成功!");
}
/**
* 更新房间信息
*
* @param Room $room 路由模型自动注入
*/
public function update(Request $request, Room $room): RedirectResponse
{
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
@@ -47,22 +72,23 @@ class RoomManagerController extends Controller
$room->update($data);
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 信息已更新!");
return redirect()->route('admin.rooms.index')->with('success', "房间{$room->room_name}信息已更新!");
}
/**
* 删除房间(系统房间)
* 删除房间(系统房间不允许删除
*
* @param Room $room 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
if ($room->room_keep) {
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
}
$name = $room->room_name;
$room->delete();
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 已删除!");
return redirect()->route('admin.rooms.index')->with('success', "房间{$name}已删除!");
}
}
@@ -0,0 +1,113 @@
<?php
/**
* 文件功能:后台商店商品管理控制器(站长功能)
*
* 提供商店商品的查看、编辑、切换上下架、删除等 CRUD 功能。
* superlevel 及以上可访问,id=1 超级站长才能新增/删除。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ShopItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ShopItemController extends Controller
{
/**
* 商品列表页(所有 superlevel 以上可查看)
*/
public function index(): View
{
$items = ShopItem::orderBy('sort_order')->orderBy('id')->get();
return view('admin.shop.index', compact('items'));
}
/**
* 新增商品(仅 id=1 超级站长)
*/
public function store(Request $request): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$data = $this->validateItem($request);
ShopItem::create($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
}
/**
* 更新商品信息
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function update(Request $request, ShopItem $shopItem): RedirectResponse
{
$data = $this->validateItem($request, $shopItem);
$shopItem->update($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
}
/**
* 切换商品上下架状态
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function toggle(ShopItem $shopItem): RedirectResponse
{
$shopItem->update(['is_active' => ! $shopItem->is_active]);
$status = $shopItem->is_active ? '上架' : '下架';
return redirect()->route('admin.shop.index')->with('success', "{$shopItem->name}」已{$status}");
}
/**
* 删除商品(仅 id=1 超级站长)
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function destroy(ShopItem $shopItem): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$name = $shopItem->name;
$shopItem->delete();
return redirect()->route('admin.shop.index')->with('success', "{$name}」已删除。");
}
/**
* 统一验证商品表单(新增/编辑共用)
*
* @return array<string, mixed>
*/
private function validateItem(Request $request, ?ShopItem $item = null): array
{
return $request->validate([
'name' => 'required|string|max:100',
'slug' => ['required', 'string', 'max:100',
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
],
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
'charm_bonus' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
}
}
@@ -65,7 +65,7 @@ class SmtpController extends Controller
public function test(Request $request): RedirectResponse
{
$request->validate([
'test_email' => 'required|email'
'test_email' => 'required|email',
]);
$testEmail = $request->input('test_email');
@@ -78,7 +78,7 @@ class SmtpController extends Controller
return redirect()->route('admin.smtp.edit')->with('success', "测试邮件已成功发送至 {$testEmail},请注意查收。");
} catch (\Throwable $e) {
return redirect()->route('admin.smtp.edit')->with('error', "测试发出失败,原因:" . $e->getMessage());
return redirect()->route('admin.smtp.edit')->with('error', '测试发出失败,原因:'.$e->getMessage());
}
}
}
@@ -3,10 +3,11 @@
/**
* 文件功能:系统参数配置控制器
* (替代原版 VIEWSYS.ASP / SetSYS.ASP)
* 运维工具已迁移至 OpsController
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* @version 1.1.0
*/
namespace App\Http\Controllers\Admin;
@@ -11,8 +11,11 @@
namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,7 +26,15 @@ use Illuminate\View\View;
class UserManagerController extends Controller
{
/**
* 显示拥护列表及搜索
* 注入统一积分服务和聊天室状态服务
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
private readonly ChatStateService $chatState,
) {}
/**
* 显示用户列表及搜索(支持按等级/经验/金币/魅力/在线状态排序)
*/
public function index(Request $request): View
{
@@ -33,34 +44,68 @@ class UserManagerController extends Controller
$query->where('username', 'like', '%'.$request->input('username').'%');
}
// 分页获取用户
$users = $query->orderBy('id', 'desc')->paginate(20);
// 从 Redis 获取所有在线用户名(跨所有房间去重)
$onlineUsernames = collect();
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
$onlineUsernames = $onlineUsernames->merge(array_keys($this->chatState->getRoomUsers($roomId)));
}
$onlineUsernames = $onlineUsernames->unique()->values();
// 排序:允许的字段白名单,防止 SQL 注入
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online', 'wxid'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
if ($sortBy === 'online') {
// 用虚拟列排序:在线用户标记为 1,离线为 0;desc = 在线优先
if ($onlineUsernames->isNotEmpty()) {
$placeholders = implode(',', array_fill(0, $onlineUsernames->count(), '?'));
$query->orderByRaw(
"CASE WHEN username IN ({$placeholders}) THEN 1 ELSE 0 END {$sortDir}",
$onlineUsernames->toArray(),
);
}
$query->orderBy('id', 'desc'); // 二级排序
} else {
$query->orderBy($sortBy, $sortDir);
}
$users = $query
->with(['activePosition.position.department', 'vipLevel'])
->paginate(20)
->withQueryString();
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
return view('admin.users.index', compact('users', 'vipLevels'));
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
}
/**
* 修改用户资料、等级或密码 (AJAX 或表单)
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
if ($currentUser->id !== 1) {
if ($request->wantsJson()) {
return response()->json(['status' => 'error', 'message' => '仅超级管理员(id=1)可编辑用户信息。'], 403);
}
abort(403, '仅超级管理员(id=1)可编辑用户信息。');
}
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
// 管理员级别 = 最高等级 + 1,后台编辑最高可设到管理员级别
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
$validated = $request->validate([
'sex' => 'sometimes|integer|in:0,1,2',
'user_level' => "sometimes|integer|min:0|max:{$adminLevel}",
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
@@ -71,28 +116,56 @@ class UserManagerController extends Controller
'hy_time' => 'sometimes|nullable|date',
]);
// 如果传了且没超权,直接赋予
if (isset($validated['user_level'])) {
if ($currentUser->id !== $targetUser->id) {
// 修改别人:只有真正的创始人 (ID=1) 才能修改别人的等级
if ($currentUser->id !== 1) {
return response()->json(['status' => 'error', 'message' => '权限越界:只有星系创始人(站长)才能调整其他用户的行政等级!'], 403);
}
}
$targetUser->user_level = $validated['user_level'];
}
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
if (isset($validated['exp_num'])) {
$targetUser->exp_num = $validated['exp_num'];
// 计算差值并通过统一服务记录流水(管理员手动调整)
$expDiff = $validated['exp_num'] - ($targetUser->exp_num ?? 0);
if ($expDiff !== 0) {
$this->currencyService->change(
$targetUser, 'exp', $expDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整经验",
);
$targetUser->refresh();
}
// 调整经验后重新计算等级(有职务用户锁定职务等级,无职务用户按经验重算)
$targetUser->load('activePosition.position');
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
if ($targetUser->activePosition?->position) {
// 有在职职务:等级锁定为职务级,不受经验影响
$lockedLevel = (int) $targetUser->activePosition->position->level;
if ($lockedLevel > 0 && $targetUser->user_level !== $lockedLevel) {
$targetUser->user_level = $lockedLevel;
}
} elseif ($targetUser->user_level < $superLevel) {
// 无职务普通用户:按经验重算等级(不超过满级阈值)
$newLevel = \App\Models\Sysparam::calculateLevel($targetUser->exp_num ?? 0);
$safeLevel = max(1, min($newLevel, $superLevel - 1));
$targetUser->user_level = $safeLevel;
}
}
if (isset($validated['jjb'])) {
$targetUser->jjb = $validated['jjb'];
$jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0);
if ($jjbDiff !== 0) {
$this->currencyService->change(
$targetUser, 'gold', $jjbDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整金币",
);
$targetUser->refresh();
}
}
if (isset($validated['meili'])) {
$targetUser->meili = $validated['meili'];
$meiliDiff = $validated['meili'] - ($targetUser->meili ?? 0);
if ($meiliDiff !== 0) {
$this->currencyService->change(
$targetUser, 'charm', $meiliDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整魅力",
);
$targetUser->refresh();
}
}
if (array_key_exists('qianming', $validated)) {
$targetUser->qianming = $validated['qianming'];
@@ -124,12 +197,19 @@ class UserManagerController extends Controller
/**
* 物理删除杀封用户
*
* @param User $user 路由模型自动注入
*/
public function destroy(Request $request, int $id): RedirectResponse
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可删除用户
if ($currentUser->id !== 1) {
abort(403, '仅超级管理员(id=1)可删除用户。');
}
// 越权防护:不允许删除同级或更高等级的账号
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
abort(403, '权限不足:无法删除同级或高级账号!');
+72 -39
View File
@@ -20,6 +20,32 @@ use Illuminate\View\View;
class VipController extends Controller
{
/**
* 会员主题支持的特效下拉选项。
*
* @var array<string, string>
*/
private const EFFECT_LABELS = [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
];
/**
* 会员主题支持的横幅风格下拉选项。
*
* @var array<string, string>
*/
private const BANNER_STYLE_LABELS = [
'aurora' => '鎏光星幕',
'storm' => '雷霆风暴',
'royal' => '王者金辉',
'cosmic' => '星穹幻彩',
'farewell' => '告别暮光',
];
/**
* 会员等级管理列表页
*/
@@ -27,7 +53,11 @@ class VipController extends Controller
{
$levels = VipLevel::orderBy('sort_order')->get();
return view('admin.vip.index', compact('levels'));
return view('admin.vip.index', [
'levels' => $levels,
'effectOptions' => self::EFFECT_LABELS,
'bannerStyleOptions' => self::BANNER_STYLE_LABELS,
]);
}
/**
@@ -35,22 +65,7 @@ class VipController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
// 将文本框的多行模板转为 JSON 数组
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data = $this->validatedPayload($request);
VipLevel::create($data);
@@ -60,27 +75,13 @@ class VipController extends Controller
/**
* 更新会员等级
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level = $vip;
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data = $this->validatedPayload($request);
$level->update($data);
@@ -90,12 +91,11 @@ class VipController extends Controller
/**
* 删除会员等级(关联用户的 vip_level_id 会自动置 null
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level->delete();
$vip->delete();
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
}
@@ -120,4 +120,37 @@ class VipController extends Controller
return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE);
}
/**
* 统一整理后台提交的会员等级主题配置数据。
*
* @return array<string, mixed>
*/
private function validatedPayload(Request $request): array
{
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
'join_effect' => 'required|in:none,fireworks,rain,lightning,snow',
'leave_effect' => 'required|in:none,fireworks,rain,lightning,snow',
'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'allow_custom_messages' => 'nullable|boolean',
]);
// 将多行文本框内容转为 JSON 数组,便于后续随机抽取模板。
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$data['allow_custom_messages'] = $request->boolean('allow_custom_messages');
return $data;
}
}
@@ -0,0 +1,88 @@
<?php
/**
* 文件功能:后台 VIP 支付配置控制器
* 用于管理聊天室对接 NovaLink 支付中心所需的开关、地址、App Key App Secret
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateVipPaymentConfigRequest;
use App\Models\SysParam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class VipPaymentConfigController extends Controller
{
/**
* 构造函数注入聊天室状态服务
*
* @param ChatStateService $chatState 系统参数缓存同步服务
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示 VIP 支付配置页
*/
public function edit(): View
{
$aliases = array_keys($this->fieldDescriptions());
// 仅读取 VIP 支付专属配置,避免与系统参数页重复展示。
$params = SysParam::query()
->whereIn('alias', $aliases)
->pluck('body', 'alias')
->toArray();
return view('admin.vip-payment.config', [
'params' => $params,
'descriptions' => $this->fieldDescriptions(),
]);
}
/**
* 保存 VIP 支付配置并刷新缓存
*
* @param UpdateVipPaymentConfigRequest $request 已校验的后台配置请求
*/
public function update(UpdateVipPaymentConfigRequest $request): RedirectResponse
{
$data = $request->validated();
$descriptions = $this->fieldDescriptions();
foreach ($descriptions as $alias => $guidetxt) {
$body = (string) ($data[$alias] ?? '');
// 写入数据库并同步描述文案,确保后续后台与缓存读取一致。
SysParam::updateOrCreate(
['alias' => $alias],
['body' => $body, 'guidetxt' => $guidetxt]
);
$this->chatState->setSysParam($alias, $body);
SysParam::clearCache($alias);
}
return redirect()->route('admin.vip-payment.edit')->with('success', 'VIP 支付配置已成功保存。');
}
/**
* 返回 VIP 支付字段说明文案
*
* @return array<string, string>
*/
private function fieldDescriptions(): array
{
return [
'vip_payment_enabled' => 'VIP 在线支付开关(1=开启,0=关闭)',
'vip_payment_base_url' => 'NovaLink 支付中心地址(例如 https://novalink.test',
'vip_payment_app_key' => 'NovaLink 支付中心 App Key',
'vip_payment_app_secret' => 'NovaLink 支付中心 App Secret',
'vip_payment_timeout' => '调用支付中心超时时间(秒)',
];
}
}
@@ -0,0 +1,70 @@
<?php
/**
* 文件功能:后台会员购买日志控制器
* 负责展示聊天室 VIP 在线支付订单列表,并支持按用户、状态、订单号和日期筛选
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\VipPaymentOrder;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VipPaymentLogController extends Controller
{
/**
* 显示会员购买日志列表
*
* @param Request $request 当前查询请求
*/
public function index(Request $request): View
{
$query = VipPaymentOrder::query()->with(['user:id,username', 'vipLevel:id,name,color,icon']);
if ($request->filled('username')) {
$username = (string) $request->input('username');
// 通过用户关联模糊匹配用户名,便于后台快速定位某个会员订单。
$query->whereHas('user', function ($builder) use ($username): void {
$builder->where('username', 'like', '%'.$username.'%');
});
}
if ($request->filled('status')) {
$query->where('status', (string) $request->input('status'));
}
if ($request->filled('order_no')) {
$keyword = (string) $request->input('order_no');
$query->where(function ($builder) use ($keyword): void {
$builder->where('order_no', 'like', '%'.$keyword.'%')
->orWhere('merchant_order_no', 'like', '%'.$keyword.'%')
->orWhere('payment_order_no', 'like', '%'.$keyword.'%')
->orWhere('provider_trade_no', 'like', '%'.$keyword.'%');
});
}
if ($request->filled('date_start')) {
$query->whereDate('created_at', '>=', (string) $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->whereDate('created_at', '<=', (string) $request->input('date_end'));
}
$logs = $query->latest('id')->paginate(30)->withQueryString();
return view('admin.vip-payment-logs.index', [
'logs' => $logs,
'statusOptions' => [
'created' => '待创建',
'pending' => '待支付',
'paid' => '已支付',
'closed' => '已关闭',
'failed' => '失败',
],
]);
}
}
@@ -0,0 +1,159 @@
<?php
/**
* 文件功能:微信机器人配置控制器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SysParam;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WechatBotController extends Controller
{
/**
* 显示微信机器人配置表单
*/
public function edit(): View
{
// 从 SysParam 获取配置,若不存在赋予默认空 JSON
$param = SysParam::firstOrCreate(
['alias' => 'wechat_bot_config'],
[
'body' => json_encode([
'kafka' => [
'brokers' => '',
'topic' => '',
'group_id' => 'chatroom_wechat_bot',
'bot_wxid' => '',
],
'api' => [
'base_url' => '',
'bot_key' => '',
],
'global_notify' => [
'start_time' => '08:00',
'end_time' => '22:00',
],
'group_notify' => [
'target_wxid' => '',
'toggle_admin_online' => false,
'toggle_baccarat_result' => false,
'toggle_lottery_result' => false,
],
'personal_notify' => [
'toggle_friend_online' => false,
'toggle_spouse_online' => false,
'toggle_level_change' => false,
],
]),
'guidetxt' => '微信机器人全站配置(包含群聊推送和私聊推送开关及Kafka连接)',
]
);
$config = json_decode($param->body, true);
return view('admin.wechat_bot.edit', compact('config'));
}
/**
* 更新微信机器人配置
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'kafka_brokers' => 'nullable|string',
'kafka_topic' => 'nullable|string',
'kafka_group_id' => 'nullable|string',
'kafka_bot_wxid' => 'nullable|string',
'api_base_url' => 'nullable|string',
'api_bot_key' => 'nullable|string',
'qrcode_image' => 'nullable|image|max:2048',
'global_start_time' => 'nullable|string',
'global_end_time' => 'nullable|string',
'group_target_wxid' => 'nullable|string',
'toggle_admin_online' => 'nullable|boolean',
'toggle_baccarat_result' => 'nullable|boolean',
'toggle_lottery_result' => 'nullable|boolean',
'toggle_friend_online' => 'nullable|boolean',
'toggle_spouse_online' => 'nullable|boolean',
'toggle_level_change' => 'nullable|boolean',
]);
$param = SysParam::where('alias', 'wechat_bot_config')->first();
$oldConfig = $param ? (json_decode($param->body, true) ?? []) : [];
$qrcodePath = $oldConfig['api']['qrcode_image'] ?? '';
if ($request->hasFile('qrcode_image')) {
// 删除旧图
if ($qrcodePath && \Illuminate\Support\Facades\Storage::disk('public')->exists($qrcodePath)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($qrcodePath);
}
$qrcodePath = $request->file('qrcode_image')->store('wechat', 'public');
}
$config = [
'kafka' => [
'brokers' => $validated['kafka_brokers'] ?? '',
'topic' => $validated['kafka_topic'] ?? '',
'group_id' => $validated['kafka_group_id'] ?? 'chatroom_wechat_bot',
'bot_wxid' => $validated['kafka_bot_wxid'] ?? '',
],
'api' => [
'base_url' => $validated['api_base_url'] ?? '',
'bot_key' => $validated['api_bot_key'] ?? '',
'qrcode_image' => $qrcodePath,
],
'global_notify' => [
'start_time' => $validated['global_start_time'] ?? '',
'end_time' => $validated['global_end_time'] ?? '',
],
'group_notify' => [
'target_wxid' => $validated['group_target_wxid'] ?? '',
'toggle_admin_online' => $validated['toggle_admin_online'] ?? false,
'toggle_baccarat_result' => $validated['toggle_baccarat_result'] ?? false,
'toggle_lottery_result' => $validated['toggle_lottery_result'] ?? false,
],
'personal_notify' => [
'toggle_friend_online' => $validated['toggle_friend_online'] ?? false,
'toggle_spouse_online' => $validated['toggle_spouse_online'] ?? false,
'toggle_level_change' => $validated['toggle_level_change'] ?? false,
],
];
if ($param) {
$param->update(['body' => json_encode($config)]);
SysParam::clearCache('wechat_bot_config');
}
return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。');
}
/**
* 发送群内公告
*/
public function sendAnnouncement(Request $request, \App\Services\WechatBot\WechatNotificationService $wechatService): RedirectResponse
{
$validated = $request->validate([
'announcement_content' => 'required|string|max:1000',
], [
'announcement_content.required' => '请输入公告内容',
'announcement_content.max' => '公告内容太长,不能超过1000字',
]);
try {
$wechatService->sendCustomGroupAnnouncement($validated['announcement_content']);
return back()->with('success', '群公告已通过微信机器人发送成功!(消息已进入队列)');
} catch (\Exception $e) {
return back()->withInput()->withErrors(['announcement_content' => $e->getMessage()]);
}
}
}
+276 -6
View File
@@ -15,12 +15,15 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -33,6 +36,7 @@ class AdminCommandController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
@@ -386,12 +390,12 @@ class AdminCommandController extends Controller
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:fireworks,rain,lightning,snow',
'type' => 'required|in:fireworks,rain,lightning,snow',
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$type = $request->input('type');
$admin = Auth::user();
$roomId = $request->input('room_id');
$type = $request->input('type');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 仅 superlevel 等级可触发特效
@@ -405,6 +409,272 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
}
/**
* 职务奖励金币(凭空发放,无需扣操作者余额)
*
* 三层限额校验:
* 1. amount position.max_reward (单次上限)
* 2. 今日累计发放 + amount position.daily_reward_limit (操作人单日累计上限)
* 3. 今日对同一接收者发放次数 < position.recipient_daily_limit(同一接收者每日次数限)
*
* 成功后:
* - 通过 UserCurrencyService 给接收者增加金币
* - 写入 PositionAuthorityLogaction_type=reward,记录到履职记录)
* - 向房间发送悄悄话通知接收者
*
* @param Request $request 需包含 username, room_id, amount
*/
public function reward(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'amount' => 'required|integer|min:1|max:999999999',
], [
'amount.max' => '单次发放金币不能超过 999999999',
'amount.min' => '发放金币至少为 1',
'amount.integer' => '金币数量必须是整数',
'amount.required' => '请输入要发放的金币数量',
]);
$admin = Auth::user();
$roomId = (int) $request->input('room_id');
$amount = (int) $request->input('amount');
$targetUsername = $request->input('username');
// 不能给自己发放
if ($admin->username === $targetUsername) {
return response()->json(['status' => 'error', 'message' => '不能给自己发放奖励'], 422);
}
// 目标用户必须存在
$target = User::where('username', $targetUsername)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
// id=1 超级管理员:无需职务,无限额限制
$isSuperAdmin = $admin->id === 1;
$userPosition = null;
$position = null;
if (! $isSuperAdmin) {
// ① 必须有在职职务
$userPosition = $admin->activePosition;
if (! $userPosition) {
return response()->json(['status' => 'error', 'message' => '你当前没有在职职务,无权发放奖励'], 403);
}
$position = $userPosition->position;
// 职务 max_reward = 0 表示禁止,null 表示不限,正整数表示有上限
if ($position?->max_reward === 0) {
return response()->json(['status' => 'error', 'message' => '你的职务未配置奖励权限'], 403);
}
// ② 单次上限校验(max_reward > 0 时才限制,null = 不限)
if ($position->max_reward && $amount > $position->max_reward) {
return response()->json([
'status' => 'error',
'message' => "单次奖励上限为 {$position->max_reward} 金币,请调整金额",
], 422);
}
// ③ 操作人单日累计上限校验
if ($position->daily_reward_limit) {
$todayTotal = PositionAuthorityLog::where('user_id', $admin->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->sum('amount');
if ($todayTotal + $amount > $position->daily_reward_limit) {
$remaining = max(0, $position->daily_reward_limit - $todayTotal);
return response()->json([
'status' => 'error',
'message' => "今日剩余可发放额度为 {$remaining} 金币,超出单日上限({$position->daily_reward_limit}",
], 422);
}
}
// ④ 职务级别:接收者每日次数上限
if ($position->recipient_daily_limit) {
$recipientCount = PositionAuthorityLog::where('target_user_id', $target->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->count();
if ($recipientCount >= $position->recipient_daily_limit) {
return response()->json([
'status' => 'error',
'message' => "{$targetUsername} 今日已由全体职务人员累计发放 {$recipientCount} 次奖励,已达每日上限({$position->recipient_daily_limit}",
], 422);
}
}
}
// ⑤ 全局系统级别:每位用户单日最多接收奖励次数(所有操作人通用,含超管)
$globalMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
if ($globalMax > 0) {
$globalCount = PositionAuthorityLog::where('target_user_id', $target->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->count();
if ($globalCount >= $globalMax) {
return response()->json([
'status' => 'error',
'message' => "{$targetUsername} 今日已累计接收 {$globalCount} 次奖励,已达全局每日上限({$globalMax}",
], 422);
}
}
// 发放金币(通过 UserCurrencyService 原子性更新 + 写流水)
// 组合「部门 · 职务」显示名,超管特殊处理
if ($isSuperAdmin) {
$positionName = '超级管理员';
} elseif ($position) {
$deptName = $position->department?->name;
$positionName = $deptName ? "{$deptName} · {$position->name}" : $position->name;
} else {
$positionName = '职务';
}
$this->currencyService->change(
$target,
'gold',
$amount,
CurrencySource::POSITION_REWARD,
"{$admin->username}{$positionName})职务奖励",
$roomId,
);
// 写履职记录(PositionAuthorityLog;超管无职务时 user_position_id 留 null
PositionAuthorityLog::create([
'user_id' => $admin->id,
'user_position_id' => $userPosition?->id,
'action_type' => 'reward',
'target_user_id' => $target->id,
'amount' => $amount,
'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}",
]);
// ① 聊天室公开公告(所有在场用户可见)
$publicMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "💰 <b>{$admin->username}</b>{$positionName})向 <b>{$targetUsername}</b> 发放了 <b>{$amount}</b> 枚奖励金币!",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $publicMsg);
broadcast(new MessageSent($roomId, $publicMsg));
SaveMessageJob::dispatch($publicMsg);
// ② 接收者私信(含 toast_notification 触发右下角小卡片)
$freshJjb = $target->fresh()->jjb;
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => "🎁 <b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币奖励!当前金币:{$freshJjb} 枚。",
'is_secret' => true,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 前端 toast-notification 组件识别此字段,弹出右下角通知卡片
'toast_notification' => [
'title' => '💰 奖励金币到账',
'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
'icon' => '💰',
'color' => '#f59e0b',
'duration' => 8000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
'status' => 'success',
'message' => "已向 {$targetUsername} 发放 {$amount} 金币奖励 🎉",
]);
}
/**
* 查询当前操作人的奖励额度信息(供发放弹窗展示)
*
* 返回字段:
* - max_once: 单次上限(null = 不限)
* - daily_limit: 单日发放总额上限(null = 不限)
* - today_sent: 今日已发放总额
* - daily_remaining: 今日剩余可发放额度(null = 不限)
*/
public function rewardQuota(): \Illuminate\Http\JsonResponse
{
$admin = Auth::user();
$isSuperAdmin = $admin->id === 1;
// 最近 10 条本人发放记录(含目标用户名)
$recent = PositionAuthorityLog::with('targetUser:id,username')
->where('user_id', $admin->id)
->where('action_type', 'reward')
->latest()
->limit(10)
->get()
->map(fn ($log) => [
'target' => $log->targetUser?->username ?? '未知',
'amount' => $log->amount,
'created_at' => $log->created_at?->format('m-d H:i'),
]);
if ($isSuperAdmin) {
return response()->json([
'max_once' => null,
'daily_limit' => null,
'today_sent' => (int) PositionAuthorityLog::where('user_id', $admin->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->sum('amount'),
'daily_remaining' => null,
'recent_rewards' => $recent,
]);
}
$position = $admin->activePosition?->position;
if (! $position) {
return response()->json([
'max_once' => 0,
'daily_limit' => null,
'today_sent' => 0,
'daily_remaining' => null,
'recent_rewards' => $recent,
]);
}
// 今日已发放总额
$todaySent = (int) PositionAuthorityLog::where('user_id', $admin->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->sum('amount');
$dailyLimit = $position->daily_reward_limit;
$remaining = $dailyLimit !== null ? max(0, $dailyLimit - $todaySent) : null;
return response()->json([
'max_once' => $position->max_reward,
'daily_limit' => $dailyLimit,
'today_sent' => $todaySent,
'daily_remaining' => $remaining,
'recent_rewards' => $recent,
]);
}
/**
* 权限检查:管理员是否可对目标用户执行指定操作
*
@@ -429,9 +699,9 @@ class AdminCommandController extends Controller
return false;
}
// 目标用户等级必须低于操作者
// 目标用户等级不能高于操作者(允许平级互相操作)
$target = User::where('username', $targetUsername)->first();
if ($target && $target->user_level >= $admin->user_level) {
if ($target && $target->user_level > $admin->user_level) {
return false;
}
@@ -17,7 +17,7 @@ class VerificationController extends Controller
public function sendEmailCode(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email'
'email' => 'required|email',
]);
$email = $request->input('email');
@@ -27,23 +27,24 @@ class VerificationController extends Controller
if (SysParam::where('alias', 'smtp_enabled')->value('body') !== '1') {
return response()->json([
'status' => 'error',
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。'
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。',
], 403);
}
// 2. 检查是否有频率限制(同一用户或同一邮箱,60秒只允许发1次)
$throttleKey = 'email_throttle_' . $user->id;
$throttleKey = 'email_throttle_'.$user->id;
if (Cache::has($throttleKey)) {
$ttl = Cache::ttl($throttleKey);
return response()->json([
'status' => 'error',
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。"
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。",
], 429);
}
// 3. 生成 6 位随机验证码并缓存,有效期 5 分钟
$code = mt_rand(100000, 999999);
$codeKey = 'email_verify_code_' . $user->id . '_' . $email;
$codeKey = 'email_verify_code_'.$user->id.'_'.$email;
Cache::put($codeKey, $code, now()->addMinutes(5));
// 设置频率锁,过期时间 60 秒
@@ -57,14 +58,15 @@ class VerificationController extends Controller
return response()->json([
'status' => 'success',
'message' => '验证码已发送,请注意查收邮件。'
'message' => '验证码已发送,请注意查收邮件。',
]);
} catch (\Throwable $e) {
// 如果发信失败,主动接触频率限制锁方便用户下一次立重试
Cache::forget($throttleKey);
return response()->json([
'status' => 'error',
'message' => '邮件系统发送异常,请稍后再试: ' . $e->getMessage()
'message' => '邮件系统发送异常,请稍后再试: '.$e->getMessage(),
], 500);
}
}
+61 -4
View File
@@ -11,10 +11,11 @@
namespace App\Http\Controllers;
use App\Http\Requests\LoginRequest;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UsernameBlacklist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Models\Sysparam;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
@@ -38,7 +39,15 @@ class AuthController extends Controller
if ($user) {
// 用户存在,验证密码
if (Hash::check($password, $user->password)) {
$passwordMatches = false;
try {
$passwordMatches = Hash::check($password, $user->password);
} catch (\RuntimeException $e) {
// Hash::check() in Laravel 11/12 throws if the hash isn't a valid bcrypt string
$passwordMatches = false;
}
if ($passwordMatches) {
// Bcrypt 验证通过
// 检测是否被封禁 (后台管理员级别获得豁免权,防止误把自己关在门外)
@@ -99,6 +108,26 @@ class AuthController extends Controller
return response()->json(['status' => 'error', 'message' => '您所在的 IP 地址已被管理员封禁,禁止注册新账号。'], 403);
}
// 检测用户名是否在禁用词列表(永久禁用 或 改名临时保留期内)
if ($blockingRecord = UsernameBlacklist::getBlockingRecord($username)) {
$reason = '';
if ($blockingRecord->type === 'permanent') {
$reason = "(包含违禁敏感词:{$blockingRecord->username}";
}
return response()->json(['status' => 'error', 'message' => "该用户名已被系统禁止注册{$reason},请更换其他名称。"], 422);
}
// --- 提取邀请人 Cookie ---
$inviterIdCookie = $request->cookie('inviter_id');
$inviterId = null;
if ($inviterIdCookie && is_numeric($inviterIdCookie)) {
// 简单校验邀请人是否存在,防止脏数据
if (User::where('id', $inviterIdCookie)->exists()) {
$inviterId = (int) $inviterIdCookie;
}
}
$newUser = User::create([
'username' => $username,
'password' => Hash::make($password),
@@ -107,10 +136,16 @@ class AuthController extends Controller
'user_level' => 1, // 默认普通用户等级
'sex' => $sex,
'usersf' => '1.gif', // 默认头像
'inviter_id' => $inviterId, // 记录邀请人
]);
$this->performLogin($newUser, $ip);
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
if ($inviterId) {
\Illuminate\Support\Facades\Cookie::queue(\Illuminate\Support\Facades\Cookie::forget('inviter_id'));
}
return response()->json(['status' => 'success', 'message' => '注册并登录成功!']);
}
@@ -123,9 +158,10 @@ class AuthController extends Controller
// 递增访问次数
$user->increment('visit_num');
// 更新最后登录IP和时间
// 更新最后登录IP和时间(同时将旧IP转移到 previous_ip 作上次登录记录)
$user->update([
'previous_ip' => $user->last_ip,
'last_ip' => $ip,
'log_time' => now(),
'in_time' => now(),
@@ -137,6 +173,16 @@ class AuthController extends Controller
'sdate' => now(),
'uuname' => $user->username,
]);
// 触发微信机器人消息推送 (登录上线类)
try {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyAdminOnline($user);
$wechatService->notifyFriendsOnline($user);
$wechatService->notifySpouseOnline($user);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot presence notification failed', ['error' => $e->getMessage()]);
}
}
/**
@@ -151,6 +197,17 @@ class AuthController extends Controller
'out_time' => now(),
'out_info' => '正常退出了聊天室',
]);
// [NEW] 同步清除该用户在所有房间的在线状态和心跳,确保其如果马上重登,能触发全新入场欢迎
try {
$chatState = app(\App\Services\ChatStateService::class);
$roomIds = $chatState->getUserRooms($user->username);
foreach ($roomIds as $roomId) {
$chatState->userLeave($roomId, $user->username);
}
} catch (\Exception $e) {
// 忽略清理缓存时发生的异常
}
}
Auth::logout();
+201
View File
@@ -0,0 +1,201 @@
<?php
/**
* 文件功能:百家乐前台下注控制器
*
* 提供用户在聊天室内下注的 API 接口:
* - 查询当前局次信息
* - 提交下注(扣除金币 + 写入下注记录)
* - 查询本人在当前局的下注状态
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 获取当前进行中的局次信息(前端轮询或开局事件后调用)。
*/
public function currentRound(Request $request): JsonResponse
{
$round = BaccaratRound::currentRound();
if (! $round) {
return response()->json(['round' => null]);
}
$user = $request->user();
$myBet = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->first();
return response()->json([
'round' => [
'id' => $round->id,
'status' => $round->status,
'bet_closes_at' => $round->bet_closes_at->toIso8601String(),
'seconds_left' => max(0, (int) now()->diffInSeconds($round->bet_closes_at, false)),
'total_bet_big' => $round->total_bet_big,
'total_bet_small' => $round->total_bet_small,
'total_bet_triple' => $round->total_bet_triple,
'bet_count_big' => $round->bet_count_big,
'bet_count_small' => $round->bet_count_small,
'bet_count_triple' => $round->bet_count_triple,
'my_bet' => $myBet ? [
'bet_type' => $myBet->bet_type,
'amount' => $myBet->amount,
] : null,
],
]);
}
/**
* 用户提交下注。
*
* 同一局每人限下一注(后台强制幂等)。
* 下注成功后立即扣除金币,结算时中奖者才返还本金+赔付。
*/
public function bet(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('baccarat')) {
return response()->json(['ok' => false, 'message' => '百家乐游戏当前未开启。']);
}
$data = $request->validate([
'round_id' => 'required|integer|exists:baccarat_rounds,id',
'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1',
]);
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
}
$round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
$user = $request->user();
// 检查用户金币余额(金币字段为 jjb)
if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
$currency = $this->currency;
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->lockForUpdate()
->exists();
if ($existing) {
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
}
// 扣除金币
$currency->change(
$user,
'gold',
-$data['amount'],
CurrencySource::BACCARAT_BET,
"百家乐 #{$round->id}".match ($data['bet_type']) {
'big' => '大', 'small' => '小', default => '豹子'
},
);
// 写入下注记录
BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'bet_type' => $data['bet_type'],
'amount' => $data['amount'],
'status' => 'pending',
]);
// 更新局次汇总统计
$field = 'total_bet_'.$data['bet_type'];
$countField = 'bet_count_'.$data['bet_type'];
$round->increment($field, $data['amount']);
$round->increment($countField);
$round->increment('bet_count');
// 广播各选项的最新押注人数
event(new \App\Events\BaccaratPoolUpdated($round));
$betLabel = match ($data['bet_type']) {
'big' => '大', 'small' => '小', default => '豹子'
};
// 发送系统传音到聊天室,公示该用户的押注信息
$chatState = app(\App\Services\ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$roomId = $round->room_id ?? 1;
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
$content = "🌟 🎲 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
event(new \App\Events\MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$betLabel}{$data['amount']} 金币,等待开奖!",
'amount' => $data['amount'],
'bet_type' => $data['bet_type'],
]);
});
}
/**
* 查询最近5局的历史记录(前端展示趋势)。
*/
public function history(): JsonResponse
{
$rounds = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
->get(['id', 'dice1', 'dice2', 'dice3', 'total_points', 'result', 'settled_at']);
return response()->json(['history' => $rounds]);
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* 文件功能:银行控制器
*
* 提供存款、取款、余额查询三个接口,金币在流通账户(jjb)
* 与银行账户(bank_jjb)之间互转,所有操作记录到 bank_logs。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\BankLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class BankController extends Controller
{
/**
* 查询银行余额及最近20条流水记录
*/
public function info(): JsonResponse
{
$user = Auth::user();
$logs = BankLog::where('user_id', $user->id)
->latest()
->limit(20)
->get(['type', 'amount', 'balance_after', 'created_at']);
return response()->json([
'status' => 'success',
'jjb' => $user->jjb ?? 0,
'bank_jjb' => $user->bank_jjb ?? 0,
'logs' => $logs,
]);
}
/**
* 查询银行存款排行榜 (分页显示)
*/
public function ranking(Request $request): JsonResponse
{
$direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
$users = \App\Models\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) {
// 提供必要的前端展示字段
return [
'id' => $u->id,
'username' => $u->username,
'bank_jjb' => $u->bank_jjb,
'sex' => $u->sex,
'usersf' => $u->usersf,
'user_level' => $u->user_level,
'headfaceUrl' => $u->headfaceUrl,
];
}),
'pagination' => [
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
'total' => $users->total(),
],
]);
}
/**
* 存款:从流通金币(jjb)转入银行(bank_jjb
*
* 请求参数:amount(正整数)
*/
public function deposit(Request $request): JsonResponse
{
$request->validate([
'amount' => 'required|integer|min:1|max:9999999',
]);
$amount = $request->integer('amount');
$user = Auth::user();
if (($user->jjb ?? 0) < $amount) {
return response()->json([
'status' => 'error',
'message' => '流通金币不足!当前余额 '.($user->jjb ?? 0)." 枚,无法存入 {$amount} 枚。",
]);
}
DB::transaction(function () use ($user, $amount): void {
$user->decrement('jjb', $amount);
$user->increment('bank_jjb', $amount);
BankLog::create([
'user_id' => $user->id,
'type' => 'deposit',
'amount' => $amount,
'balance_after' => $user->fresh()->bank_jjb,
]);
});
$fresh = $user->fresh();
return response()->json([
'status' => 'success',
'message' => "成功存入 {$amount} 枚金币!",
'jjb' => $fresh->jjb,
'bank_jjb' => $fresh->bank_jjb,
]);
}
/**
* 取款:从银行(bank_jjb)转回流通金币(jjb
*
* 请求参数:amount(正整数)
*/
public function withdraw(Request $request): JsonResponse
{
$request->validate([
'amount' => 'required|integer|min:1|max:9999999',
]);
$amount = $request->integer('amount');
$user = Auth::user();
if (($user->bank_jjb ?? 0) < $amount) {
return response()->json([
'status' => 'error',
'message' => '银行余额不足!当前存款 '.($user->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。",
]);
}
DB::transaction(function () use ($user, $amount): void {
$user->decrement('bank_jjb', $amount);
$user->increment('jjb', $amount);
BankLog::create([
'user_id' => $user->id,
'type' => 'withdraw',
'amount' => $amount,
'balance_after' => $user->fresh()->bank_jjb,
]);
});
$fresh = $user->fresh();
return response()->json([
'status' => 'success',
'message' => "成功取出 {$amount} 枚金币!",
'jjb' => $fresh->jjb,
'bank_jjb' => $fresh->bank_jjb,
]);
}
}
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:开发日志前台控制器
* 对应独立页面 /changelog,展示已发布的版本更新日志
* 支持懒加载(IntersectionObserver + 游标分页)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\DevChangelog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/** 每次加载的条数 */
private const PAGE_SIZE = 10;
/**
* 更新日志列表页(SSR首屏)
* 预加载最新 10 条已发布日志
*/
public function index(): View
{
$changelogs = DevChangelog::published()
->limit(self::PAGE_SIZE)
->get();
return view('changelog.index', compact('changelogs'));
}
/**
* 懒加载更多日志(JSON API
* 游标分页:传入已加载的最后一条 ID,返回更旧的 10
*
* @param Request $request after_id 参数
*/
public function loadMoreChangelogs(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$items = DevChangelog::published()
->after($afterId)
->limit(self::PAGE_SIZE)
->get();
$data = $items->map(fn (DevChangelog $log) => [
'id' => $log->id,
'version' => $log->version,
'title' => $log->title,
'type_label' => $log->type_label,
'type_color' => $log->type_color,
'content_html' => $log->content_html,
'summary' => $log->summary,
'published_at' => $log->published_at?->format('Y-m-d'),
]);
return response()->json([
'items' => $data,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
}
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:聊天室内快速任命/撤销控制器
* 供有职务的管理员在聊天室用户名片弹窗中快速任命或撤销目标用户的职务。
* 权限校验委托给 AppointmentService,本控制器只做请求解析和返回 JSON。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatAppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取可用职务列表(供名片弹窗下拉选择)
* 返回操作人有权限任命的职务
*/
public function positions(): JsonResponse
{
$operator = Auth::user();
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
$operatorPosition = $operator->activePosition?->position;
$query = Position::query()
->with('department')
->orderByDesc('rank');
// 仅有具体职务(非 superlevel 直通)的操作人才限制 rank 范围
if ($operatorPosition && $operator->user_level < $superLevel) {
$query->where('rank', '<', $operatorPosition->rank);
}
$positions = $query->get()->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'icon' => $p->icon,
'rank' => $p->rank,
'department' => $p->department?->name,
]);
return response()->json(['status' => 'success', 'positions' => $positions]);
}
/**
* 快速任命:将目标用户任命为指定职务
* 成功后向操作人所在聊天室广播任命公告
*/
public function appoint(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$position = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $position, $request->remark);
// 任命成功后广播礼花通知:优先用前端传来的 room_id,否则从 Redis 查操作人所在房间
if ($result['ok']) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $position->icon ?? '🎖️',
positionName: $position->name,
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 快速撤销:撤销目标用户当前的职务
*/
public function revoke(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
// 撤销前先取目标当前职务信息(撤销后就查不到了)
$activeUp = $target->activePosition?->load('position.department');
$posIcon = $activeUp?->position?->icon ?? '🎖️';
$posName = $activeUp?->position?->name ?? '';
$deptName = $activeUp?->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
// 撤销成功后广播通知到聊天室
if ($result['ok'] && $posName) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
}
+75 -17
View File
@@ -13,14 +13,17 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Sysparam;
use App\Services\AiChatService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
class ChatBotController extends Controller
{
@@ -30,6 +33,7 @@ class ChatBotController extends Controller
public function __construct(
private readonly AiChatService $aiChat,
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
@@ -58,36 +62,90 @@ class ChatBotController extends Controller
], 403);
}
$aiUser = \App\Models\User::where('username', 'AI小班长')->first();
if ($aiUser) {
$aiUser->increment('exp_num', 1);
}
$user = Auth::user();
$message = $request->input('message');
$roomId = $request->input('room_id');
try {
// 先广播用户的提问消息
$userMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $user->username,
'to_user' => 'AI小班长',
'content' => $message,
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $userMsg);
broadcast(new MessageSent($roomId, $userMsg));
SaveMessageJob::dispatch($userMsg);
$result = $this->aiChat->chat($user->id, $message, $roomId);
$reply = $result['reply'];
// 检查 AI 是否决定给用户发金币
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
$reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
$dailyCount = (int) Redis::get($redisKey);
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
if ($aiUser && $aiUser->jjb >= $goldAmount) {
Redis::incr($redisKey);
Redis::expire($redisKey, 86400); // 缓存 24 小时
// 真实扣除 AI 金币
$this->currencyService->change(
$aiUser,
'gold',
-$goldAmount,
CurrencySource::GIFT_SENT,
"赏赐给 {$user->username} 的金币福利",
$roomId
);
// 给用户发放金币
$this->currencyService->change(
$user,
'gold',
$goldAmount,
CurrencySource::AI_GIFT,
'AI小班长发善心赠送的金币福利',
$roomId
);
// 发送全场大广播
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => "🤖 听闻小萌新哭穷,本班长看你骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
'is_secret' => false,
'font_color' => '#d97706', // 橙色醒目
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
} else {
// 如果余额不足
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
}
} else {
// 如果已经领过了,修改回复提醒
$reply .= "\n\n(系统提示:你今天已经领过金币福利啦,把机会留给其他人吧!)";
}
}
// 广播 AI 回复消息
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => $result['reply'],
'content' => $reply,
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '',
+658 -66
View File
@@ -11,31 +11,47 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\FriendRequest;
use App\Models\Gift;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class ChatController extends Controller
{
/**
* 构造聊天室核心控制器所需依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
) {}
/**
@@ -51,13 +67,38 @@ class ChatController extends Controller
// 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
// 0. 判断是否已经是当前房间在线状态
$hasKey = $this->chatState->isUserInRoom($id, $user->username);
// 增强校验:判断心跳是否还存在。如果遇到没有启动队列任务的情况,离线任务未能清理脏数据,心跳必定过期。
$isHeartbeatAlive = (bool) \Illuminate\Support\Facades\Redis::exists("room:{$id}:alive:{$user->username}");
// 如果虽然在名单里,但心跳早已丢失(可能直接关浏览器且队列未跑),视为全新进房
if ($hasKey && ! $isHeartbeatAlive) {
$this->chatState->userLeave($id, $user->username); // 强制洗净状态
$hasKey = false;
}
$isAlreadyInRoom = $hasKey;
// 1. 先将用户从其他所有房间的在线名单中移除(切换房间时旧记录自动清理)
// 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题
$oldRoomIds = $this->chatState->getUserRooms($user->username);
foreach ($oldRoomIds as $oldRoomId) {
if ($oldRoomId !== $id) {
$this->chatState->userLeave($oldRoomId, $user->username);
}
}
// 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,
@@ -65,43 +106,224 @@ class ChatController extends Controller
'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, $userData);
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
// 2. 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 广播和初始化欢迎(仅限初次进入)
$newbieEffect = null;
$initialPresenceTheme = null;
$initialWelcomeMessage = null;
// 3. 管理员(superlevel)进入时:触发全房间烟花特效 + 公屏欢迎公告
if ($user->user_level >= $superLevel) {
// 广播烟花特效给所有在线用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
if (! $isAlreadyInRoom) {
// 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 发送欢迎公告消息(使用系统公告样式)
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 <b>{$user->username}</b> 驾临本聊天室!请各位文明聊天!",
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
}
}
// 4. 获取历史消息用于初次渲染
// TODO: 可在前端通过请求另外的接口拉取历史记录,或者直接在这里 attach
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0);
$username = $user->username;
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
$toUser = $msg['to_user'] ?? '';
$fromUser = $msg['from_user'] ?? '';
$isSecret = ! empty($msg['is_secret']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
if (! $isAlreadyInRoom) {
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 8. 好友上线通知:向此房间内在线的好友推送慧慧话
$this->notifyFriendsOnline($id, $user->username);
}
// 9. 检查是否有未处理的求婚
$pendingProposal = \App\Models\Marriage::with(['user', 'ringItem'])
->where('partner_id', $user->id)
->where('status', 'pending')
->first();
$pendingProposalData = null;
if ($pendingProposal) {
$pendingProposalData = [
'marriage_id' => $pendingProposal->id,
'proposer_name' => $pendingProposal->user?->username ?? '',
'ring_name' => $pendingProposal->ringItem?->name ?? '',
'ring_icon' => $pendingProposal->ringItem?->icon ?? '',
'expires_at' => $pendingProposal->expires_at?->diffForHumans() ?? '',
];
}
// 10. 检查是否有未处理的协议离婚请求(对方发起的)
$pendingDivorce = \App\Models\Marriage::with(['user', 'partner'])
->where('status', 'married')
->where('divorce_type', 'mutual')
->whereNotNull('divorcer_id')
->where('divorcer_id', '!=', $user->id)
->where(function ($q) use ($user) {
$q->where('user_id', $user->id)->orWhere('partner_id', $user->id);
})
->first();
$pendingDivorceData = null;
if ($pendingDivorce) {
$initiator = $pendingDivorce->user_id === $pendingDivorce->divorcer_id ? $pendingDivorce->user : $pendingDivorce->partner;
$pendingDivorceData = [
'marriage_id' => $pendingDivorce->id,
'initiator_name' => $initiator?->username ?? '',
];
}
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'initialPresenceTheme' => $initialPresenceTheme,
'initialWelcomeMessage' => $initialWelcomeMessage,
'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData,
'pendingDivorce' => $pendingDivorceData,
]);
}
/**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
*
* @param int $roomId 当前房间 ID
* @param string $username 上线的用户名
*/
private function notifyFriendsOnline(int $roomId, string $username): void
{
// 获取所有把我加为好友的人(他们是将我加为好友的关注者)
$friendUsernames = FriendRequest::where('towho', $username)->pluck('who');
if ($friendUsernames->isEmpty()) {
return;
}
// 当前房间在线用户列表
$onlineUsers = $this->chatState->getRoomUsers($roomId);
foreach ($friendUsernames as $friendName) {
// 好友就在这个房间里,才发通知
if (! isset($onlineUsers[$friendName])) {
continue;
}
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $friendName,
'content' => "🟢 你的好友 <b>{$username}</b> 上线啊!",
'is_secret' => true,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
}
}
/**
* 发送消息 (等同于原版 NEWSAY.ASP)
*
@@ -124,6 +346,20 @@ class ChatController extends Controller
], 403);
}
// 0.5 检查接收方是否在线(防幽灵消息)
$toUser = $data['to_user'] ?? '大家';
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '系统播报', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
// Redis 保存的在线列表
$isOnline = Redis::hexists("room:{$id}:users", $toUser);
if (! $isOnline) {
// 使用 200 状态码,避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed
return response()->json([
'status' => 'error',
'message' => "{$toUser}】目前已离开聊天室或不在线,消息未发出。",
], 200);
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
@@ -210,21 +446,16 @@ class ChatController extends Controller
// 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级
// 管理员(superlevel 及以上)不参与自动升降级,等级由后台手动设置
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$oldLevel = $user->user_level;
$leveledUp = false;
if ($oldLevel < $superLevel) {
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$leveledUp = ($newLevel > $oldLevel);
}
}
$leveledUp = $this->calculateNewLevel($user, $superLevel);
$user->save(); // 存点入库
// 手动心跳存点:同步更新在职用户的勤务时长
$this->tickDutyLog($user, $id);
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -233,6 +464,8 @@ class ChatController extends Controller
'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 ?? '',
]);
// 4. 如果突破境界,向全房系统喊话广播!
@@ -263,22 +496,61 @@ class ChatController extends Controller
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 应用经验/金币变化(不低于 0
if ($autoEvent->exp_change !== 0) {
$user->exp_num = max(0, $user->exp_num + $autoEvent->exp_change);
// 计算会员倍率加成(仅正向奖励有效
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
$finalExp = $autoEvent->exp_change > 0 ? (int) round($autoEvent->exp_change * $expMul) : $autoEvent->exp_change;
$finalJjb = $autoEvent->jjb_change > 0 ? (int) round($autoEvent->jjb_change * $jjbMul) : $autoEvent->jjb_change;
$bonusExp = ($autoEvent->exp_change > 0 && $finalExp > $autoEvent->exp_change) ? $finalExp - $autoEvent->exp_change : 0;
$bonusJjb = ($autoEvent->jjb_change > 0 && $finalJjb > $autoEvent->jjb_change) ? $finalJjb - $autoEvent->jjb_change : 0;
// 经验变化:通过 UserCurrencyService 写日志
if ($finalExp !== 0) {
$this->currencyService->change(
$user,
'exp',
$finalExp,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
);
}
if ($autoEvent->jjb_change !== 0) {
$user->jjb = max(0, ($user->jjb ?? 0) + $autoEvent->jjb_change);
// 金币变化:通过 UserCurrencyService 写日志
if ($finalJjb !== 0) {
$this->currencyService->change(
$user,
'gold',
$finalJjb,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
);
}
$user->save();
// 重新从数据库读取最新属性(service 已原子更新,需刷新本地对象)
$user->refresh();
// 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级)
if ($user->user_level < $superLevel) {
$recalcLevel = Sysparam::calculateLevel($user->exp_num);
if ($recalcLevel !== $user->user_level && $recalcLevel < $superLevel) {
$user->user_level = $recalcLevel;
$user->save();
}
if ($this->calculateNewLevel($user, $superLevel)) {
$leveledUp = true; // 随机事件触发了升级,补充标记以便广播
$user->save();
}
// 构建会员额外加成文案(参考钓鱼系统,确保不弄错)
$bonusParts = [];
if ($bonusExp > 0) {
$bonusParts[] = "+经验{$bonusExp}";
}
if ($bonusJjb > 0) {
$bonusParts[] = "+金币{$bonusJjb}";
}
$eventContent = $autoEvent->renderText($user->username);
if (! empty($bonusParts)) {
$eventContent .= ''.$user->vipName().'追加:'.implode('', $bonusParts).'';
}
// 广播随机事件消息到聊天室
@@ -287,12 +559,12 @@ class ChatController extends Controller
'room_id' => $id,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => $autoEvent->renderText($user->username),
'content' => $eventContent,
'is_secret' => false,
'font_color' => match ($autoEvent->event_type) {
'good' => '#16a34a', // 绿色(好运)
'bad' => '#dc2626', // 红色(坏运)
default => '#7c3aed', // 紫色(中性)
'bad' => '#dc2626', // 红色(坏运)
default => '#7c3aed', // 紫色(中性)
},
'action' => '',
'sent_at' => now()->toDateTimeString(),
@@ -306,10 +578,10 @@ class ChatController extends Controller
// 确定用户称号:管理员 > VIP 名称 > 普通会员
$title = '普通会员';
if ($user->user_level >= $superLevel) {
$title = '管理员';
} elseif ($user->isVip()) {
if ($user->isVip()) {
$title = $user->vipName() ?: '会员';
} elseif ($user->user_level >= $superLevel) {
$title = '管理员';
}
return response()->json([
@@ -328,6 +600,32 @@ class ChatController extends Controller
]);
}
/**
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
*
* 使用 ChatStateService::getRoomUsers() 保证与名单逻辑完全一致。
* 返回 [{ id, name, online, permit_level, door_open }] 数组。
*/
public function roomsOnlineStatus(): JsonResponse
{
$rooms = Room::orderBy('id')->get(['id', 'room_name', 'permit_level', 'door_open']);
$data = $rooms->map(function (Room $room) {
// 与名单/心跳使用完全相同的方式读取在线人数
$onlineCount = count($this->chatState->getRoomUsers($room->id));
return [
'id' => $room->id,
'name' => $room->room_name,
'online' => $onlineCount,
'permit_level' => $room->permit_level ?? 0,
'door_open' => (bool) $room->door_open,
];
});
return response()->json(['rooms' => $data]);
}
/**
* 离开房间 (等同于原版 LEAVE.ASP)
*
@@ -340,17 +638,23 @@ class ChatController extends Controller
return response()->json(['status' => 'error'], 401);
}
// 1. 从 Redis 删除该用户
$this->chatState->userLeave($id, $user->username);
$leaveTime = microtime(true);
$isExplicit = strval($request->query('explicit')) === '1';
// 记录退出时间和退出信息
$user->update([
'out_time' => now(),
'out_info' => "正常退出了房间",
]);
if ($isExplicit) {
// 人工显式点击“离开”,不再进行浏览器刷新的防抖,直接同步执行清算和播报。
// 这对本地没有开启 Queue Worker 的环境尤为重要,能保证大家立刻看到消息。
// 为了防止 ProcessUserLeave 中的时间对比失败,我们直接删掉 join_time 表示彻底离线。
\Illuminate\Support\Facades\Redis::del("room:{$id}:join_time:{$user->username}");
// 2. 广播通知他人
broadcast(new UserLeft($id, $user->username))->toOthers();
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, $leaveTime);
dispatch_sync($job);
} else {
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime
// Job 中就不会执行完整的离线播报和注销流程
\App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3));
}
return response()->json(['status' => 'success']);
}
@@ -398,9 +702,12 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
}
// 更新用户头像
$user->usersf = $headface;
$user->save();
// 更新前如为自定义头像,将其从磁盘删除,节约空间
if ($user->usersf !== $headface) {
$user->deleteCustomAvatar();
$user->usersf = $headface;
$user->save();
}
// 将新头像同步到 Redis 在线用户列表中(所有房间)
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
@@ -425,6 +732,76 @@ class ChatController extends Controller
]);
}
/**
* 上传自定义头像
*/
public function uploadAvatar(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:6144',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '未登录'], 401);
}
$file = $request->file('file');
try {
$manager = new ImageManager(new Driver);
// 生成相对路径
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
$originalFilename = 'custom_'.$user->id.'_'.time().'_original.'.$file->extension();
$path = 'avatars/'.$filename;
$originalPath = 'avatars/'.$originalFilename;
// 1. 处理原图:限制最大宽度为 1280 以免过大,保存原比例高清大图
$originalImage = $manager->read($file);
$originalImage->scaleDown(width: 1280);
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
// 2. 处理缩略图:裁剪正方形并压缩为 112x112
$thumbImage = $manager->read($file);
$thumbImage->cover(112, 112);
Storage::disk('public')->put($path, (string) $thumbImage->encode());
$dbValue = 'storage/'.$path;
// 更新前如为自定义头像,将其从磁盘删除,节约空间
if ($user->usersf !== $dbValue) {
$user->deleteCustomAvatar();
$user->usersf = $dbValue;
$user->save();
}
// 同步 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,
]);
}
return response()->json([
'status' => 'success',
'message' => '头像上传成功!',
'headface' => $user->headface,
]);
} catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500);
}
}
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
@@ -446,7 +823,9 @@ class ChatController extends Controller
'announcement' => 'required|string|max:500',
]);
$room->announcement = $request->input('announcement');
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
$room->announcement = trim($request->input('announcement'))
.' ——'.$user->username.' '.now()->format('m-d H:i');
$room->save();
// 广播公告更新到所有在线用户
@@ -542,7 +921,7 @@ class ChatController extends Controller
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -578,7 +957,7 @@ class ChatController extends Controller
private function grantChatCharm(mixed $sender, string $toUsername): void
{
// 系统用户不参与魅力计算
$systemNames = ['大家', '系统传音', '系统公告', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
$systemNames = ['大家', '系统传音', '系统公告', '系统播报', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
if (in_array($toUsername, $systemNames)) {
return;
}
@@ -648,4 +1027,217 @@ class ChatController extends Controller
return max(0, (int) $value);
}
/**
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
* 在用户退出房间或心跳超时时调用
*
* @param int $userId 用户 ID
*/
private function closeDutyLog(int $userId): void
{
// 将今日开放日志关闭并结算实际时长
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->whereDate('login_at', today())
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
]);
// 关闭历史遗留的跨天未关闭日志(login_at 非今日)
// 保留最后一次心跳刷新的 duration_seconds,确保已积累时长不丢失
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->whereDate('login_at', '<', today())
->update([
'logout_at' => DB::raw('login_at + INTERVAL duration_seconds SECOND'),
]);
}
/**
* 存点时同步更新或创建在职用户的勤务日志。
*
* 逻辑:
* 1. 用户无在职职务 跳过
* 2. 今日已有开放日志(无 logout_at)→ 刷新 duration_seconds(实时时长)
* 3. 今日无任何日志 新建,login_at user->in_time(进房时间),保证时长不丢失
*
* @param \App\Models\User $user 当前用户(必须已 fresh/refresh
* @param int $roomId 所在房间 ID
*/
private function tickDutyLog(User $user, int $roomId): void
{
// 无论有无职务,均记录在线流水
$activeUP = $user->activePosition;
// ① 优先找今日未关闭的开放日志,直接刷新时长
$openLog = PositionDutyLog::query()
->where('user_id', $user->id)
->whereNull('logout_at')
->whereDate('login_at', today())
->first();
if ($openLog) {
DB::table('position_duty_logs')
->where('id', $openLog->id)
->update([
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
// DB::table raw update 不自动刷 updated_at,必须手动设置,
// 否则 CloseStaleDutyLogs 会误判此 session 为掉线而提前关闭。
'updated_at' => now(),
]);
return;
}
// ② 若今日已有「已关闭」的日志段,说明是 CloseStaleDutyLogs 关闭后重建:
// 必须用 now() 作为 login_at,防止重用旧的 in_time(如今日 00:00)导致
// 每次重建的 duration_seconds 都从午夜算起,累加成等差数列(产生 249h 等异常值)。
// 只有今日首次创建(无任何历史日志段)时,才用 in_time 保留真实进房时刻。
$hasClosedToday = PositionDutyLog::query()
->where('user_id', $user->id)
->whereDate('login_at', today())
->whereNotNull('logout_at')
->exists();
$loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
? $user->in_time
: now();
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP?->id,
'login_at' => $loginAt,
'ip_address' => request()->ip(),
'room_id' => $roomId,
]);
}
/**
* 根据经验值重新计算用户等级,申升减级均会直接修改 $user->user_level。
*
* PHP 对象引用传递,方法内对 $user 的修改会直接反映到调用方。
* 本方法不负责 save(),由调用方决定何时落库。
*
* @param \App\Models\User $user 当前用户模型
* @param int $superLevel 管理员等级阈值(达到后不参与自动升降级)
* @return bool 是否发生了升级(true = 等级提升)
*/
private function calculateNewLevel(\App\Models\User $user, int $superLevel): bool
{
// 管理员等级由后台手动维护,不参与自动升降级
if ($user->user_level >= $superLevel) {
return false;
}
$newLevel = Sysparam::calculateLevel($user->exp_num);
// 等级无变化,或计算结果达到管理员阈值(异常情况),均跳过
if ($newLevel === $user->user_level || $newLevel >= $superLevel) {
return false;
}
$isLeveledUp = $newLevel > $user->user_level;
// 在职职务成员:等级保护逻辑
$activeUP = $user->activePosition;
if ($activeUP) {
$positionLevel = $activeUP->position->level ?? 0;
// 职务要求高于当前等级 → 强制补级到职务最低要求
if ($positionLevel > $user->user_level) {
$user->user_level = $positionLevel;
return true; // 等级提升,调用方需保存并广播
}
// 降级 且 降后等级低于职务要求 → 阻止
if (! $isLeveledUp && $newLevel < $positionLevel) {
return false;
}
}
// PHP 对象引用传递,这里对 $user->user_level 的修改将直接反映到调用方
$user->user_level = $newLevel;
return $isLeveledUp;
}
/**
* 用户间赠送金币(任何登录用户均可调用)
*
* 从自己的余额中扣除指定金额,转入对方账户,
* 并在房间内通过「系统传音」广播一条赠送提示。
*/
public function giftGold(Request $request): JsonResponse
{
$request->validate([
'to_user' => 'required|string',
'room_id' => 'required|integer',
'amount' => 'required|integer|min:1|max:999999999',
], [
'amount.max' => '单次赠送金币不能超过 999999999',
'amount.min' => '单次赠送金币至少为 1',
'amount.integer' => '金币数量必须是整数',
'amount.required' => '请输入要赠送的金币数量',
]);
$sender = Auth::user();
$toName = $request->input('to_user');
$roomId = $request->integer('room_id');
$amount = $request->integer('amount');
// 不能给自己转账
if ($toName === $sender->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
}
// 查目标用户
$receiver = User::where('username', $toName)->first();
if (! $receiver) {
return response()->json(['status' => 'error', 'message' => '用户不存在']);
}
// 余额校验
if (($sender->jjb ?? 0) < $amount) {
return response()->json([
'status' => 'error',
'message' => '金币不足!您当前余额 '.($sender->jjb ?? 0)." 金币,无法赠送 {$amount} 金币。",
]);
}
// 执行转账(直接操作字段,与 sendFlower 保持一致风格)
$sender->decrement('jjb', $amount);
$receiver->increment('jjb', $amount);
// 广播一条消息:发送者/接收者路由到 say2(下方包厢),其他人路由到 say1(公屏)
// 原理:前端 isRelatedToMe = isMe || to_user===me → say2;否则 → say1
$giftMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $sender->username,
'to_user' => $toName,
'content' => "悄悄赠送给你 {$amount} 金币!💝",
'is_secret' => false,
'font_color' => '#b45309',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $giftMsg);
broadcast(new MessageSent($roomId, $giftMsg));
SaveMessageJob::dispatch($giftMsg);
return response()->json([
'status' => 'success',
'message' => "赠送成功!已向 {$toName} 赠送 {$amount} 金币。",
'data' => [
'my_jjb' => $sender->fresh()->jjb,
'target_jjb' => $receiver->fresh()->jjb,
],
]);
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* 文件功能:勤务台页面控制器
* 左侧五个子菜单:任职列表、日榜、周榜、月榜、总榜
* 路由:GET /duty-hall?tab=roster|day|week|month|all
*
* 榜单统计三项指标:
* 1. 在线时长 position_duty_logs.duration_seconds 合计
* 2. 管理操作次数 position_authority_logs 非任免类操作次数(warn/kick/mute/banip/other
* 3. 奖励金币次数 position_authority_logs WHERE action_type='reward' 的次数及累计金额
*
* @author ChatRoom Laravel
*
* @version 1.2.0
*/
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\PositionAuthorityLog;
use App\Models\PositionDutyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DutyHallController extends Controller
{
/**
* 勤务台主页(根据 tab 切换内容)
*/
public function index(Request $request): View
{
$tab = $request->input('tab', 'roster');
// ── 任职列表:按部门→职务展示全部(含空缺) ──────────────────
$currentStaff = null;
if ($tab === 'roster') {
$currentStaff = Department::query()
->with([
'positions' => fn ($q) => $q->orderByDesc('rank'),
'positions.activeUserPositions.user',
])
->orderByDesc('rank')
->get();
}
// ── 日/周/月/总榜:三项指标综合排行 ─────────────────────────
$leaderboard = null;
if (in_array($tab, ['day', 'week', 'month', 'all'])) {
// ① 在线时长(position_duty_logs
$dutyQuery = PositionDutyLog::query()
->selectRaw('user_id, SUM(duration_seconds) as total_seconds, COUNT(*) as checkin_count')
// 只统计有离开时间的已完结记录,open session 不计入(防止实时计算偏差)
->whereNotNull('logout_at');
// ② 管理操作(position_authority_logs,排除任命/撤销等人事操作)
$authQuery = PositionAuthorityLog::query()
->selectRaw('
user_id,
COUNT(*) as admin_count,
SUM(CASE WHEN action_type = \'reward\' THEN 1 ELSE 0 END) as reward_count,
SUM(CASE WHEN action_type = \'reward\' THEN COALESCE(amount, 0) ELSE 0 END) as reward_total
')
->whereNotIn('action_type', ['appoint', 'revoke']);
// 按时间段同步过滤两张表
match ($tab) {
'day' => [
$dutyQuery->whereDate('login_at', today()),
$authQuery->whereDate('created_at', today()),
],
'week' => [
$dutyQuery->whereBetween('login_at', [now()->startOfWeek(), now()->endOfWeek()]),
$authQuery->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]),
],
'month' => [
$dutyQuery->whereYear('login_at', now()->year)->whereMonth('login_at', now()->month),
$authQuery->whereYear('created_at', now()->year)->whereMonth('created_at', now()->month),
],
'all' => null, // 不限制时间
};
// 执行查询
$dutyRows = $dutyQuery
->groupBy('user_id')
->orderByDesc('total_seconds')
->limit(20)
->with('user')
->get();
// 管理操作数据(按 user_id 索引,方便后续合并)
$authMap = $authQuery
->groupBy('user_id')
->get()
->keyBy('user_id');
// 合并两表数据:为每条勤务记录附加管理操作指标
$leaderboard = $dutyRows->map(function ($row) use ($authMap) {
$auth = $authMap->get($row->user_id);
$row->admin_count = (int) ($auth?->admin_count ?? 0);
$row->reward_count = (int) ($auth?->reward_count ?? 0);
$row->reward_total = (int) ($auth?->reward_total ?? 0);
return $row;
});
}
// 各榜标签配置
$tabs = [
'roster' => ['label' => '任职列表', 'icon' => '🏛️'],
'day' => ['label' => '日榜', 'icon' => '☀️'],
'week' => ['label' => '周榜', 'icon' => '📆'],
'month' => ['label' => '月榜', 'icon' => '🗓️'],
'all' => ['label' => '总榜', 'icon' => '🏆'],
];
return view('duty-hall.index', compact(
'tab',
'tabs',
'currentStaff',
'leaderboard',
));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 文件功能:用户赚取金币与经验(观看视频广告等任务)控制器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
class EarnController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* @var int 每日观看最大次数限制
*/
private int $maxDailyLimit = 3;
/**
* @var int 每次领奖后至少需要的冷却时间(秒)
*/
private int $cooldownSeconds = 5;
/**
* 申领看视频的奖励
* 成功看完视频后前端发起此请求。
* 为防止刷包,必须加上每日总次数及短时频率限制。
*/
public function claimVideoReward(Request $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$userId = $user->id;
$dateKey = now()->format('Y-m-d');
$dailyCountKey = "earn_video:count:{$userId}:{$dateKey}";
$cooldownKey = "earn_video:cooldown:{$userId}";
// 1. 检查冷却时间
if (Redis::exists($cooldownKey)) {
return response()->json([
'success' => false,
'message' => '操作过快,请稍后再试。',
]);
}
// 2. 检查每日最大次数
$todayCount = (int) Redis::get($dailyCountKey);
if ($todayCount >= $this->maxDailyLimit) {
return response()->json([
'success' => false,
'message' => "今日视频收益次数已达上限(每天最多{$this->maxDailyLimit}次),请明天再来。",
]);
}
// 3. 增加今日次数计数
$newCount = Redis::incr($dailyCountKey);
if ($newCount === 1) {
Redis::expire($dailyCountKey, 86400 * 2);
}
// 4. 配置:单次 5000 金币,500 经验
$rewardCoins = 5000;
$rewardExp = 500;
$roomId = (int) $request->input('room_id', 0);
// 参照钓鱼逻辑:通过 UserCurrencyService 写日志并变更金币/经验
$this->currencyService->change(
$user, 'gold', $rewardCoins, CurrencySource::VIDEO_REWARD,
"看视频赚取金币(第{$newCount}次)", $roomId,
);
$this->currencyService->change(
$user, 'exp', $rewardExp, CurrencySource::VIDEO_REWARD,
"看视频赚取经验(第{$newCount}次)", $roomId,
);
// 刷新模型以获取 service 原子更新后的最新字段值
$user->refresh();
// 5. 设置冷却时间
Redis::setex($cooldownKey, $this->cooldownSeconds, 1);
// 6. 广播全服系统消息
if ($roomId > 0) {
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统播报',
'to_user' => '大家',
'content' => "👍 【{$user->username}】刚刚看视频赚取了 {$rewardCoins} 金币 + {$rewardExp} 经验!{$promoTag}",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
}
$remainingToday = $this->maxDailyLimit - $newCount;
return response()->json([
'success' => true,
'message' => "观看完毕!获得 {$rewardCoins} 金币 + {$rewardExp} 经验。今日还可观看 {$remainingToday} 次。",
'new_jjb' => $user->jjb,
'level_up' => false,
'new_level_name' => '',
]);
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
/**
* 文件功能:用户反馈前台控制器
* 对应独立页面 /feedback,处理用户提交 Bug报告/功能建议、
* 赞同(Toggle)、补充评论、删除等操作
* 所有写操作均需登录(chat.auth 中间件保护)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use App\Models\FeedbackVote;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackController extends Controller
{
/** 每次懒加载的条数 */
private const PAGE_SIZE = 10;
/**
* 用户反馈列表页(SSR首屏)
* 预加载按赞同数倒序的 10 条反馈
*/
public function index(): View
{
$feedbacks = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->limit(self::PAGE_SIZE)
->get();
// 当前用户已赞同的反馈 ID 集合(前端切换按钮状态用)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $feedbacks->pluck('id'))
->pluck('feedback_id')
->toArray();
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion
*
* @param Request $request after_id / type 筛选参数
*/
public function loadMore(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->where('id', '<', $afterId)
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
// 当前用户已赞同的 ID(用于切换按钮状态)
$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),
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 提交新反馈(Bug报告或功能建议)
*
* @param Request $request type/title/content 字段
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|in:bug,suggestion',
'title' => 'required|string|max:200',
'content' => 'required|string|max:2000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
$item = FeedbackItem::create([
'user_id' => $user->id,
'username' => $user->username,
'type' => $data['type'],
'title' => $data['title'],
'content' => $data['content'],
'status' => 'pending',
]);
return response()->json([
'status' => 'success',
'message' => '反馈已提交,感谢您的贡献!',
'item' => $this->formatItem($item, false),
]);
}
/**
* 赞同/取消赞同反馈(Toggle 操作)
* 每人每条只能赞同一次,再次点击则取消
* 使用数据库事务保证 votes_count 冗余字段与记录一致
*
* @param int $id 反馈 ID
*/
public function vote(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$userId = Auth::id();
// 不能赞同自己提交的反馈
if ($feedback->user_id === $userId) {
return response()->json([
'status' => 'error',
'message' => '不能赞同自己的反馈',
], 422);
}
$voted = false;
DB::transaction(function () use ($feedback, $userId, &$voted): void {
$existing = FeedbackVote::where('feedback_id', $feedback->id)
->where('user_id', $userId)
->first();
if ($existing) {
// 已赞同 → 取消赞同
$existing->delete();
$feedback->decrement('votes_count');
$voted = false;
} else {
// 未赞同 → 新增赞同
FeedbackVote::create([
'feedback_id' => $feedback->id,
'user_id' => $userId,
]);
$feedback->increment('votes_count');
$voted = true;
}
});
return response()->json([
'status' => 'success',
'voted' => $voted,
'votes_count' => $feedback->fresh()->votes_count,
]);
}
/**
* 提交补充评论
* id=1 管理员的回复自动标记 is_admin=true(前台特殊展示)
*
* @param Request $request content 字段
* @param int $id 反馈 ID
*/
public function reply(Request $request, int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'content' => 'required|string|max:1000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
/** @var FeedbackReply $reply */
$reply = null;
DB::transaction(function () use ($feedback, $data, $user, &$reply): void {
$reply = FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => $user->id,
'username' => $user->username,
'content' => $data['content'],
'is_admin' => $user->id === 1,
]);
$feedback->increment('replies_count');
});
return response()->json([
'status' => 'success',
'message' => '评论已提交',
'reply' => [
'id' => $reply->id,
'username' => $reply->username,
'content' => $reply->content,
'is_admin' => $reply->is_admin,
'created_at' => $reply->created_at->diffForHumans(),
],
]);
}
/**
* 删除反馈
* 普通用户:仅24小时内可删除自己的反馈
* 管理员(id=1):任意时间可删除任意反馈
*
* @param int $id 反馈 ID
*/
public function destroy(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $feedback->user_id === $user->id;
$isAdmin = $user->id === 1;
if (! $isOwner && ! $isAdmin) {
return response()->json(['status' => 'error', 'message' => '无权删除'], 403);
}
if ($isOwner && ! $isAdmin && ! $feedback->is_within_24_hours) {
return response()->json([
'status' => 'error',
'message' => '超过 24 小时的反馈无法删除',
], 422);
}
// 级联删除关联的赞同记录和评论记录
DB::transaction(function () use ($feedback): void {
FeedbackVote::where('feedback_id', $feedback->id)->delete();
FeedbackReply::where('feedback_id', $feedback->id)->delete();
$feedback->delete();
});
return response()->json(['status' => 'success', 'message' => '已删除']);
}
// ═══════════════ 私有辅助方法 ═══════════════
/**
* 格式化单条反馈数据(供 JSON 返回给前端)
*
* @param FeedbackItem $item 反馈实例
* @param bool $voted 当前用户是否已赞同
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
return [
'id' => $item->id,
'type' => $item->type,
'type_label' => $item->type_label,
'title' => $item->title,
'content' => $item->content,
'status' => $item->status,
'status_label' => $item->status_label,
'status_color' => $item->status_config['color'],
'admin_remark' => $item->admin_remark,
'votes_count' => $item->votes_count,
'replies_count' => $item->replies_count,
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
])->values()->toArray(),
];
}
/**
* 批量格式化反馈数据集合
*
* @param \Illuminate\Support\Collection<int, FeedbackItem> $items
* @param array<int> $myVotedIds 当前用户已赞同的 ID 列表
*/
private function formatItems(\Illuminate\Support\Collection $items, array $myVotedIds): array
{
return $items->map(fn (FeedbackItem $item) => $this->formatItem(
$item,
in_array($item->id, $myVotedIds)
))->values()->toArray();
}
}
+94 -117
View File
@@ -2,34 +2,52 @@
/**
* 文件功能:钓鱼小游戏控制器
* 复刻原版 ASP 聊天室 diaoyu/ 目录下的钓鱼功能
* 简化掉鱼竿道具系统,用 Redis 控制冷却,随机奖惩经验/金币
*
* 新增随机浮漂点击防挂机机制:
* - 抛竿时生成一次性 token 和随机浮漂坐标(百分比),返回给前端
* - 前端显示漂浮动画,等待下沉后用户点击,将 token 随收竿请求提交
* - 若持有有效「自动钓鱼卡」,前端直接自动点击,无需手动操作
* - 服务端验证 token 有效性,防止脚本直接调用收竿接口
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* @version 2.0.0
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\FishingService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
private readonly FishingService $fishingService,
) {}
/**
* 抛竿 检查冷却和金币,扣除金币,返回随机等待时间
* 抛竿 检查冷却和金币,扣除金币,生成浮漂 token 和随机坐标。
*
* 返回:
* wait_time 等待秒数(前端倒数后触发下沉动画)
* bobber_x/y 浮漂随机位置(0-100 百分比)
* token 本次钓鱼唯一令牌(收竿时必须携带)
* auto_fishing 是否持有有效自动钓鱼卡(前端据此自动点击)
*
* @param int $id 房间ID
*/
@@ -40,6 +58,11 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 检查钓鱼全局开关
if (! GameConfig::isEnabled('fishing')) {
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
// 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
@@ -53,7 +76,7 @@ class FishingController extends Controller
}
// 2. 检查金币是否足够
$cost = (int) Sysparam::getValue('fishing_cost', '5');
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) {
return response()->json([
'status' => 'error',
@@ -62,28 +85,53 @@ class FishingController extends Controller
}
// 3. 扣除金币
$user->jjb = max(0, ($user->jjb ?? 0) - $cost);
$user->save();
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 设置"正在钓鱼"标记(防止重复抛竿,30秒后自动过期
Redis::setex("fishing:active:{$user->id}", 30, time());
// 5. 计算随机等待时间
$waitMin = (int) Sysparam::getValue('fishing_wait_min', '8');
$waitMax = (int) Sysparam::getValue('fishing_wait_max', '15');
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]));
// 5. 生成随机浮漂坐标(百分比,避开边缘)
$bobberX = rand(15, 85); // 左右 15%~85%
$bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
'status' => 'success',
'message' => "已花费 {$cost} 金币,鱼竿已抛出!等待鱼儿上钩...",
'wait_time' => $waitTime,
'bobber_x' => $bobberX,
'bobber_y' => $bobberY,
'token' => $token,
'auto_fishing' => $autoFishingMinutes > 0,
'auto_fishing_minutes_left' => $autoFishingMinutes,
'cost' => $cost,
'jjb' => $user->jjb,
]);
}
/**
* 收竿 随机计算钓鱼结果,更新经验/金币,广播到聊天室
* 收竿 验证浮漂 token随机计算钓鱼结果,更新经验/金币,广播到聊天室
*
* 必须携带 token(从抛竿接口获取),否则判定为非法收竿。
*
* @param int $id 房间ID
*/
@@ -94,123 +142,52 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 1. 检查是否有"正在钓鱼"标记
$activeKey = "fishing:active:{$user->id}";
if (! Redis::exists($activeKey)) {
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
$clientToken = $request->input('token', '');
if (! $storedJson) {
return response()->json([
'status' => 'error',
'message' => '您还没有抛竿,或者鱼已经跑了!',
'message' => '鱼儿跑了!浮漂已超时,请重新抛竿。',
], 422);
}
// 清除钓鱼标记
Redis::del($activeKey);
$stored = json_decode($storedJson, true);
// 校验 token 一致性
if (($stored['token'] ?? '') !== $clientToken) {
return response()->json([
'status' => 'error',
'message' => '令牌无效,请重新抛竿。',
], 422);
}
// 校验服务端时间:距抛竿必须已过 wait_time 秒(允许 ±1s 误差)
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
$required = (int) ($stored['wait_time'] ?? 0);
if ($elapsed < $required - 1) {
return response()->json([
'status' => 'error',
'message' => '鱼还没上钩,别急!',
], 422);
}
// 清除 token(一次性)
Redis::del($tokenKey);
// 2. 设置冷却时间
$cooldown = (int) Sysparam::getValue('fishing_cooldown', '300');
$cooldown = (int) (GameConfig::param('fishing', 'fishing_cooldown') ?? Sysparam::getValue('fishing_cooldown', '300'));
Redis::setex("fishing:cd:{$user->id}", $cooldown, time());
// 3. 随机决定钓鱼结果
$result = $this->randomFishResult();
// 4. 更新用户经验和金币(正向奖励按 VIP 倍率加成,负面惩罚不变)
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
if ($result['exp'] !== 0) {
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
$user->exp_num = max(0, ($user->exp_num ?? 0) + $finalExp);
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
$user->jjb = max(0, ($user->jjb ?? 0) + $finalJjb);
}
$user->save();
// 5. 广播钓鱼结果到聊天室
$sysMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $sysMsg);
broadcast(new MessageSent($id, $sysMsg));
// 3. 随机决定钓鱼结果并广播(直接调用服务)
$result = $this->fishingService->processCatch($user, $id, false);
return response()->json([
'status' => 'success',
'result' => $result,
'exp_num' => $user->exp_num,
'jjb' => $user->jjb,
'cooldown_seconds' => $cooldown, // 前端自动钓鱼卡循环等待用
]);
}
/**
* 随机钓鱼结果(复刻原版概率分布)
*
* @return array{emoji: string, message: string, exp: int, jjb: int}
*/
private function randomFishResult(): array
{
$roll = rand(1, 100);
// 概率分布(总计 100%
// 1-15: 大鲨鱼 (+100exp, +20金)
// 16-30: 娃娃鱼 (+0exp, +30金)
// 31-50: 大草鱼 (+50exp)
// 51-70: 小鲤鱼 (+50exp, +10金)
// 71-85: 落水 (-50exp)
// 86-95: 被打 (-20exp, -3金)
// 96-100:大丰收 (+150exp, +50金)
return match (true) {
$roll <= 15 => [
'emoji' => '🦈',
'message' => '钓到一条大鲨鱼!增加经验100、金币20',
'exp' => 100,
'jjb' => 20,
],
$roll <= 30 => [
'emoji' => '🐟',
'message' => '钓到一条娃娃鱼,到集市卖得30个金币',
'exp' => 0,
'jjb' => 30,
],
$roll <= 50 => [
'emoji' => '🐠',
'message' => '钓到一只大草鱼,吃下增加经验50',
'exp' => 50,
'jjb' => 0,
],
$roll <= 70 => [
'emoji' => '🐡',
'message' => '钓到一条小鲤鱼,增加经验50、金币10',
'exp' => 50,
'jjb' => 10,
],
$roll <= 85 => [
'emoji' => '💧',
'message' => '鱼没钓到,摔到河里经验减少50',
'exp' => -50,
'jjb' => 0,
],
$roll <= 95 => [
'emoji' => '👊',
'message' => '偷钓鱼塘被主人发现,一阵殴打!经验减少20、金币减少3',
'exp' => -20,
'jjb' => -3,
],
default => [
'emoji' => '🎉',
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+150,金币+50!',
'exp' => 150,
'jjb' => 50,
],
};
}
}
@@ -0,0 +1,166 @@
<?php
/**
* 文件功能:神秘占卜前台控制器
*
* 提供用户每日占卜功能:
* - 查询今日占卜状态(已占卜/未占卜/剩余次数)
* - 执行占卜(免费或付费)
* - 查询占卜历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 查询今日占卜状态(用于面板初始化和刷新)。
*/
public function todayStatus(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
$todayCount = FortuneLog::todayCount($user->id);
$todayLatest = FortuneLog::todayLatest($user->id);
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$hasFreeLeft = $freeUsed < $freeCount;
return response()->json([
'enabled' => true,
'today_count' => $todayCount,
'free_count' => $freeCount,
'free_used' => $freeUsed,
'has_free_left' => $hasFreeLeft,
'extra_cost' => $extraCost,
'latest' => $todayLatest ? [
'grade' => $todayLatest->grade,
'grade_label' => $todayLatest->gradeLabel(),
'grade_color' => $todayLatest->gradeColor(),
'text' => $todayLatest->text,
'buff_desc' => $todayLatest->buff_desc,
'created_at' => $todayLatest->created_at->format('H:i'),
] : null,
]);
}
/**
* 执行一次占卜。
*
* 免费次数用完后每次消耗 extra_cost 金币。
*/
public function tell(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
// 判断今日免费次数是否已用完
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$isFree = $freeUsed < $freeCount;
$cost = $isFree ? 0 : $extraCost;
// 检查余额
if (! $isFree && ($user->jjb ?? 0) < $cost) {
return response()->json(['ok' => false, 'message' => "金币不足,额外占卜需要 {$cost} 金币。"]);
}
// 扣费
if (! $isFree && $cost > 0) {
$this->currency->change(
$user,
'gold',
-$cost,
CurrencySource::FORTUNE_COST,
'神秘占卜额外次数消耗',
);
}
// 抽签
$grade = FortuneLog::rollGrade($config);
$fortune = FortuneLog::rollFortune($grade);
// 记录
$log = FortuneLog::create([
'user_id' => $user->id,
'grade' => $grade,
'text' => $fortune['text'],
'buff_desc' => $fortune['buff_desc'] ?? null,
'is_free' => $isFree,
'cost' => $cost,
'fortune_date' => today(),
]);
return response()->json([
'ok' => true,
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'is_free' => $isFree,
'cost' => $cost,
]);
}
/**
* 查询近20条个人占卜历史记录。
*/
public function history(Request $request): JsonResponse
{
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
->limit(20)
->get(['grade', 'text', 'buff_desc', 'is_free', 'cost', 'fortune_date', 'created_at'])
->map(fn ($log) => [
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'cost' => $log->cost,
'date' => $log->fortune_date->format('m-d'),
'time' => $log->created_at->format('H:i'),
]);
return response()->json(['history' => $logs]);
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
/**
* 文件功能:好友系统控制器
*
* 处理聊天室内的好友关系管理:
* 1. 添加好友(addFriend
* 2. 删除好友(removeFriend
* 3. 查询与指定用户的好友关系(status)
* 4. 查询当前用户的好友列表(index
*
* 好友关系模型:单向存储,互相添加才构成双向好友。
* 使用原版 friend_requests 表(字段:who / towho / sub_time)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\FriendAdded;
use App\Events\FriendRemoved;
use App\Models\FriendRequest;
use App\Models\User;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class FriendController extends Controller
{
/**
* 注入 Redis 状态服务,用于推送悄悄话通知。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 查询当前用户与目标用户的好友关系状态。
*
* 返回:
* - is_friend: 当前用户是否已将对方加为好友
* - mutual: 是否互相添加(双向好友)
*
* @param string $username 目标用户名
*/
public function status(string $username): JsonResponse
{
$me = Auth::user();
// 我是否已将对方加为好友
$iAdded = FriendRequest::where('who', $me->username)
->where('towho', $username)
->exists();
// 对方是否也将我加为好友
$theyAdded = FriendRequest::where('who', $username)
->where('towho', $me->username)
->exists();
return response()->json([
'is_friend' => $iAdded,
'mutual' => $iAdded && $theyAdded,
]);
}
/**
* 添加好友。
*
* 流程:
* 1. 校验目标用户存在、且不是自己
* 2. 检查是否已经添加过
* 3. 写入 friend_requests 记录
* 4. 检查是否互相好友(B 是否已将 A 加为好友)
* 5. 广播 FriendAdded 事件通知对方(携带互相状态)
* 6. 若对方在线,向对方发送正确的悄悄话
*
* @param string $username 目标用户名
*/
public function addFriend(Request $request, string $username): JsonResponse
{
$me = Auth::user();
// 不能加自己
if ($me->username === $username) {
return response()->json(['status' => 'error', 'message' => '不能将自己加为好友'], 422);
}
// 检查目标用户是否存在
$target = User::where('username', $username)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
// 是否已添加
$exists = FriendRequest::where('who', $me->username)->where('towho', $username)->exists();
if ($exists) {
return response()->json(['status' => 'error', 'message' => '已是好友,无需重复添加'], 422);
}
// 写入好友关系(A → B
FriendRequest::create([
'who' => $me->username,
'towho' => $username,
'sub_time' => now(),
]);
// 检查 B 是否已将 A 加为好友(互相好友判断)
$hasAddedBack = FriendRequest::where('who', $username)
->where('towho', $me->username)
->exists();
// 广播给对方(仅对方可见),携带是否已回加的状态;用数字 ID 作为频道名,避免中文名
broadcast(new FriendAdded($me->username, $username, $target->id, $hasAddedBack));
// 若对方在线,推送聊天区悄悄话(文案根据互相状态区分)
$this->notifyOnlineUser($username, $me->username, 'added', $request->input('room_id'), $hasAddedBack);
return response()->json([
'status' => 'success',
'message' => '已成功添加 '.$username.' 为好友 🎉',
]);
}
/**
* 删除好友。
*
* 流程:
* 1. 删除 friend_requests 中「我 对方」的记录
* 2. 检查对方是否也将我加为好友(之前是否互相)
* 3. 广播 FriendRemoved 事件通知对方
* 4. 若对方在线,向对方发送悄悄话
*
* @param string $username 目标用户名
*/
public function removeFriend(Request $request, string $username): JsonResponse
{
$me = Auth::user();
$deleted = FriendRequest::where('who', $me->username)
->where('towho', $username)
->delete();
if (! $deleted) {
return response()->json(['status' => 'error', 'message' => '好友关系不存在'], 404);
}
// 检查 B 之前是否也将 A 加为好友(删除前的互相状态)
$hadAddedBack = FriendRequest::where('who', $username)
->where('towho', $me->username)
->exists();
// 查询目标用户 ID(用于私有频道,避免中文名非法)
$targetUser = User::where('username', $username)->first();
// 广播给对方,携带之前的互相好友状态;用数字 ID 避免中文频道名
broadcast(new FriendRemoved($me->username, $username, $targetUser?->id ?? 0, $hadAddedBack));
// 若对方在线,推送聊天区悄悄话(文案根据互相状态区分)
$this->notifyOnlineUser($username, $me->username, 'removed', $request->input('room_id'), $hadAddedBack);
return response()->json([
'status' => 'success',
'message' => '已将 '.$username.' 从好友列表移除',
]);
}
/**
* 获取当前用户的完整好友数据,供好友面板使用。
*
* 返回两个列表:
* - friends:我已添加的好友(含互相状态、添加时间)
* - pending:对方已加我但我还未加对方的(含对方添加我的时间)
*/
public function index(): JsonResponse
{
$me = Auth::user();
// ── 我添加的好友及添加时间 ──
$myRows = FriendRequest::where('who', $me->username)
->get(['towho', 'sub_time'])
->keyBy('towho');
// ── 把我加了的人(用于互相判断 + pending 列表)──
$addedMeRows = FriendRequest::where('towho', $me->username)
->get(['who', 'sub_time'])
->keyBy('who');
$myAddedNames = $myRows->keys();
$addedMeNames = $addedMeRows->keys();
// ── 查询全局在线用户(所有房间合并)──
$onlineUsernames = collect($this->chatState->getAllOnlineUsernames());
// 我添加的好友详情
$friends = User::whereIn('username', $myAddedNames)
->get(['username', 'usersf', 'user_level', 'sex'])
->map(function ($u) use ($myRows, $addedMeNames, $onlineUsernames) {
$row = $myRows->get($u->username);
return [
'username' => $u->username,
'headface' => $u->headface,
'user_level' => $u->user_level,
'sex' => $u->sex,
'mutual' => $addedMeNames->contains($u->username), // 是否互相添加
'sub_time' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
];
})
->sortByDesc('is_online') // 在线好友排在前面
->values();
// 对方加了我但我还未加的(pending)
$pendingNames = $addedMeNames->diff($myAddedNames);
$pending = User::whereIn('username', $pendingNames)
->get(['username', 'usersf', 'user_level', 'sex'])
->map(function ($u) use ($addedMeRows, $onlineUsernames) {
$row = $addedMeRows->get($u->username);
return [
'username' => $u->username,
'headface' => $u->headface,
'user_level' => $u->user_level,
'sex' => $u->sex,
'added_at' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
];
})
->sortByDesc('is_online')
->values();
return response()->json([
'status' => 'success',
'friends' => $friends,
'pending' => $pending,
]);
}
/**
* 若目标用户在线,向其发送系统悄悄话通知。
*
* 根据 $action $mutual 显示不同文案,避免「你们已是好友」的误导提示。
*
* @param string $targetUsername 接收通知的用户名
* @param string $fromUsername 发起操作的用户名
* @param string $action 'added' | 'removed' | 'online'
* @param int|null $roomId 当前房间 ID
* @param bool $mutual 是否互相好友(added: B 是否已回加;removed: 之前是否互相)
*/
private function notifyOnlineUser(
string $targetUsername,
string $fromUsername,
string $action,
?int $roomId = null,
bool $mutual = false,
): void {
if (! $roomId) {
return;
}
// 检查对方是否在该房间在线
$onlineUsers = $this->chatState->getRoomUsers($roomId);
if (! isset($onlineUsers[$targetUsername])) {
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>";
$content = match ($action) {
'added' => $mutual
? "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
: "💚 <b>{$fromUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
'removed' => $mutual
? "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
: "💔 <b>{$fromUsername}</b> 已将你从他的好友列表移除。",
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
default => '',
};
if (! $content) {
return;
}
// 删除相关用灰色,其他用绿色
$fontColor = $action === 'removed' ? '#6b7280' : '#16a34a';
// 构建系统悄悄话消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => $fontColor,
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new \App\Events\MessageSent($roomId, $msg));
}
}
+609
View File
@@ -0,0 +1,609 @@
<?php
/**
* 文件功能:五子棋对战前台控制器
*
* 提供 PvP(随机对战)和 PvE(人机对战)两种模式的完整 API
* - 创建对局(支持两种模式)
* - 加入 PvP 对战
* - 落子(自动触发 AI 回应)
* - 认输
* - 取消邀请
* - 获取对局状态
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\GomokuFinishedEvent;
use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
) {}
/**
* 创建对局。
*
* 支持两种模式:
* - pvp: 广播邀请通知,等待其他玩家加入
* - pve: 立即开局与 AI 对战(需支付入场费)
*/
public function create(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('gomoku')) {
return response()->json(['ok' => false, 'message' => '五子棋当前未开启。']);
}
$data = $request->validate([
'mode' => 'required|in:pvp,pve',
'room_id' => 'required|integer|exists:rooms,id',
'ai_level' => 'required_if:mode,pve|nullable|integer|min:1|max:4',
]);
$user = $request->user();
// PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
$q->where('player_black_id', $user->id)
->orWhere('player_white_id', $user->id);
})
->whereIn('status', ['waiting', 'playing'])
->first();
if ($activeGame) {
return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局,请先完成或取消。']);
}
// PvE:扣除入场费
$entryFee = 0;
if ($data['mode'] === 'pve') {
$entryFee = $this->getPveEntryFee((int) $data['ai_level']);
if ($entryFee > 0 && ($user->jjb ?? 0) < $entryFee) {
return response()->json(['ok' => false, 'message' => "金币不足,此难度需 {$entryFee} 金币入场费。"]);
}
}
return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse {
// PvE 扣除入场费
if ($entryFee > 0) {
$this->currency->change(
$user,
'gold',
-$entryFee,
CurrencySource::GOMOKU_ENTRY_FEE,
"五子棋 AI 对战入场费(难度{$data['ai_level']}",
);
}
$timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60);
$game = GomokuGame::create([
'mode' => $data['mode'],
'room_id' => $data['room_id'],
'player_black_id' => $user->id,
'ai_level' => $data['mode'] === 'pve' ? ($data['ai_level'] ?? 1) : null,
'status' => $data['mode'] === 'pve' ? 'playing' : 'waiting',
'board' => GomokuGame::emptyBoard(),
'current_turn' => 1,
'entry_fee' => $entryFee,
'invite_expires_at' => $data['mode'] === 'pvp' ? now()->addSeconds($timeout) : null,
'started_at' => $data['mode'] === 'pve' ? now() : null,
]);
// PvP:广播邀请通知至房间
if ($data['mode'] === 'pvp') {
broadcast(new GomokuInviteEvent($game, $user->username));
}
return response()->json([
'ok' => true,
'game_id' => $game->id,
'message' => $data['mode'] === 'pvp'
? '已发起对战邀请,等待其他玩家加入…'
: '对局已开始,您执黑棋先手!',
]);
});
}
/**
* 加入 PvP 对战(白棋方)。
*/
public function join(Request $request, GomokuGame $game): JsonResponse
{
$user = $request->user();
if ($game->status !== 'waiting') {
return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']);
}
if ($game->player_black_id === $user->id) {
return response()->json(['ok' => false, 'message' => '不能加入自己发起的对局。']);
}
if ($game->invite_expires_at && now()->isAfter($game->invite_expires_at)) {
$game->update(['status' => 'cancelled']);
return response()->json(['ok' => false, 'message' => '该邀请已超时,请重新发起。']);
}
// 检查接受方是否已在其他对局中
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
$q->where('player_black_id', $user->id)
->orWhere('player_white_id', $user->id);
})
->whereIn('status', ['waiting', 'playing'])
->first();
if ($activeGame) {
return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局。']);
}
$game->update([
'player_white_id' => $user->id,
'status' => 'playing',
'started_at' => now(),
]);
return response()->json([
'ok' => true,
'game_id' => $game->id,
'message' => '已成功加入对战!您执白棋。',
]);
}
/**
* 落子。
*
* PvP 模式:验证轮次后广播落子。
* PvE 模式:玩家落子后,自动计算 AI 落点并一并返回。
*/
public function move(Request $request, GomokuGame $game): JsonResponse
{
$user = $request->user();
if ($game->status !== 'playing') {
return response()->json(['ok' => false, 'message' => '对局未在进行中。']);
}
$data = $request->validate([
'row' => 'required|integer|min:0|max:14',
'col' => 'required|integer|min:0|max:14',
]);
$row = (int) $data['row'];
$col = (int) $data['col'];
$board = $game->board;
// 坐标已被占用
if (GomokuGame::isOccupied($board, $row, $col)) {
return response()->json(['ok' => false, 'message' => '该位置已有棋子。']);
}
// PvP:验证是否轮到该玩家
if ($game->mode === 'pvp') {
if (! $game->belongsToUser($user->id)) {
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
}
if (! $game->isUserTurn($user->id)) {
return response()->json(['ok' => false, 'message' => '当前不是您的回合。']);
}
} else {
// PvE:只允许黑棋玩家操作
if ($game->player_black_id !== $user->id) {
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
}
if ($game->current_turn !== 1) {
return response()->json(['ok' => false, 'message' => 'AI 正在思考,请等待。']);
}
}
return DB::transaction(function () use ($game, $row, $col, $board, $user): JsonResponse {
// 玩家落子
$playerColor = $game->mode === 'pvp' ? $game->colorOf($user->id) : 1;
$board = GomokuGame::placeStone($board, $row, $col, $playerColor);
// 记录落子历史
$history = $game->moves_history ?? [];
$history[] = ['row' => $row, 'col' => $col, 'color' => $playerColor, 'at' => now()->toIso8601String()];
// 判断玩家是否胜利
if (GomokuGame::checkWin($board, $row, $col, $playerColor)) {
return $this->finishGame($game, $board, $history, $playerColor, 'win', $user);
}
// 判断平局
if (GomokuGame::isBoardFull($board)) {
return $this->finishGame($game, $board, $history, 0, 'draw', $user);
}
// 切换回合
$nextTurn = $playerColor === 1 ? 2 : 1;
$game->update([
'board' => $board,
'current_turn' => $nextTurn,
'moves_history' => $history,
]);
// PvP:广播落子事件
if ($game->mode === 'pvp') {
broadcast(new GomokuMovedEvent($game->fresh(), $row, $col, $playerColor));
return response()->json(['ok' => true, 'moved' => compact('row', 'col')]);
}
// PvEAI 落子
$aiMove = $this->ai->think($board, $game->ai_level ?? 1);
$aiRow = $aiMove['row'];
$aiCol = $aiMove['col'];
$aiColor = 2;
$board = GomokuGame::placeStone($board, $aiRow, $aiCol, $aiColor);
$history[] = ['row' => $aiRow, 'col' => $aiCol, 'color' => $aiColor, 'at' => now()->toIso8601String()];
// 判断 AI 是否胜利
if (GomokuGame::checkWin($board, $aiRow, $aiCol, $aiColor)) {
return $this->finishGame($game, $board, $history, $aiColor, 'win', $user);
}
// 再次检查平局(AI 落子后)
if (GomokuGame::isBoardFull($board)) {
return $this->finishGame($game, $board, $history, 0, 'draw', $user);
}
// AI 落子后切换回玩家回合
$game->update([
'board' => $board,
'current_turn' => 1,
'moves_history' => $history,
]);
return response()->json([
'ok' => true,
'moved' => ['row' => $row, 'col' => $col],
'ai_moved' => ['row' => $aiRow, 'col' => $aiCol],
]);
});
}
/**
* 认输(当前玩家主动认输,对手获胜)。
*/
public function resign(Request $request, GomokuGame $game): JsonResponse
{
$user = $request->user();
if (! in_array($game->status, ['playing', 'waiting'])) {
return response()->json(['ok' => false, 'message' => '对局已结束。']);
}
if (! $game->belongsToUser($user->id)) {
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
}
// 认输者对应颜色,胜方为另一色
$resignColor = $game->colorOf($user->id);
$winnerColor = $resignColor === 1 ? 2 : 1;
return DB::transaction(function () use ($game, $winnerColor, $user): JsonResponse {
return $this->finishGame($game, $game->board, $game->moves_history ?? [], $winnerColor, 'resign', $user);
});
}
/**
* 取消 PvP 邀请(发起者主动取消,或超时后被调用)。
*/
public function cancel(Request $request, GomokuGame $game): JsonResponse
{
$user = $request->user();
if ($game->status !== 'waiting') {
return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']);
}
if ($game->player_black_id !== $user->id) {
return response()->json(['ok' => false, 'message' => '只有发起者可取消邀请。']);
}
$game->update(['status' => 'cancelled']);
return response()->json(['ok' => true, 'message' => '邀请已取消。']);
}
/**
* 获取对局当前状态(用于前端重连同步)。
*/
public function state(Request $request, GomokuGame $game): JsonResponse
{
$user = $request->user();
if (! $game->belongsToUser($user->id) && $game->mode === 'pvp') {
return response()->json(['ok' => false, 'message' => '无权访问该对局。']);
}
return response()->json([
'ok' => true,
'game_id' => $game->id,
'mode' => $game->mode,
'status' => $game->status,
'board' => $game->board,
'current_turn' => $game->current_turn,
'winner' => $game->winner,
'your_color' => $game->colorOf($user->id),
'ai_level' => $game->ai_level,
'reward_gold' => $game->reward_gold,
]);
}
// ─── 私有工具方法 ─────────────────────────────────────────────────
/**
* 结算对局:更新状态、发放奖励、广播事件。
*
* @param GomokuGame $game 当前对局
* @param array $board 最终棋盘
* @param array $history 落子历史
* @param int $winnerColor 胜方颜色(0=平局)
* @param string $reason 结束原因(win/draw/resign
* @param \App\Models\User $currentUser 当前操作用户(用于加载用户名)
*/
private function finishGame(
GomokuGame $game,
array $board,
array $history,
int $winnerColor,
string $reason,
mixed $currentUser
): JsonResponse {
$rewardGold = 0;
$winnerName = '';
$loserName = '';
// 加载对局玩家信息
$game->load('playerBlack', 'playerWhite');
if ($winnerColor === 0) {
// 平局
$winnerName = '';
$loserName = '';
// PvE 平局:返还入场费
if ($game->mode === 'pve' && $game->entry_fee > 0) {
$this->currency->change(
$game->playerBlack,
'gold',
$game->entry_fee,
CurrencySource::GOMOKU_REFUND,
'五子棋 AI 平局返还入场费',
);
}
} else {
// 有胜负
$rewardGold = $this->calculateReward($game, $winnerColor);
if ($game->mode === 'pvp') {
$winnerUser = $winnerColor === 1 ? $game->playerBlack : $game->playerWhite;
$loserUser = $winnerColor === 1 ? $game->playerWhite : $game->playerBlack;
$winnerName = $winnerUser?->username ?? '';
$loserName = $loserUser?->username ?? '';
// 将英文 reason 转为友好的中文后缀
$reasonText = match ($reason) {
'resign' => '(认输)',
'timeout' => '(超时)',
default => '',
};
// 发放 PvP 胜利奖励给获胜玩家
if ($winnerUser && $rewardGold > 0) {
$this->currency->change(
$winnerUser,
'gold',
$rewardGold,
CurrencySource::GOMOKU_WIN,
"五子棋:击败 {$loserName}{$reasonText}",
);
}
} else {
// PvE 模式:winnerColor=1 代表玩家胜
if ($winnerColor === 1) {
$winnerName = $game->playerBlack->username ?? '';
$loserName = "AI(难度{$game->ai_level}";
if ($rewardGold > 0) {
$this->currency->change(
$game->playerBlack,
'gold',
$rewardGold,
CurrencySource::GOMOKU_WIN,
"五子棋:击败 AI(难度{$game->ai_level}",
);
}
} else {
// AI 获胜:入场费已扣,无返还
$winnerName = "AI(难度{$game->ai_level}";
$loserName = $game->playerBlack->username ?? '';
}
}
}
$game->update([
'status' => 'finished',
'board' => $board,
'moves_history' => $history,
'winner' => $winnerColor,
'reward_gold' => $rewardGold,
'finished_at' => now(),
]);
// 广播对局结束事件给参与对局的双方
broadcast(new GomokuFinishedEvent($game->fresh(), $winnerName, $loserName, $reason));
// 有胜负,均向房间广播系统通知
if ($winnerColor !== 0) {
if ($game->mode === 'pvp') {
// PvP:胜方玩家获奖通知
$reasonText = match ($reason) {
'resign' => '(认输)',
default => '',
};
$text = "♟️ 【五子棋】玩家对战结果!恭喜玩家【{$winnerName}】击败了【{$loserName}{$reasonText},赢得 {$rewardGold} 金币!";
} elseif ($winnerColor === 1) {
// PvE:玩家获胜
$text = "♟️ 【五子棋】棋神降临!恭喜玩家【{$winnerName}】在人机对战(难度{$game->ai_level})中击败 AI,赢得 {$rewardGold} 金币!";
} else {
// PvEAI 获胜(玩家输了)
$text = "♟️ 【五子棋】AI 大获全胜!玩家【{$loserName}】在人机对战(难度{$game->ai_level})中不敌 AI,再接再厉!";
}
$this->broadcastSystemMessage($game->room_id, $text);
}
return response()->json([
'ok' => true,
'finished' => true,
'winner' => $winnerColor,
'winner_name' => $winnerName,
'reason' => $reason,
'reward_gold' => $rewardGold,
]);
}
/**
* 发送系统房间广播。
*
* @param int $roomId 房间ID
* @param string $content 广播内容
*/
private function broadcastSystemMessage(int $roomId, string $content): void
{
$chatState = app(\App\Services\ChatStateService::class);
$messageData = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706', // 琥珀橙色
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $messageData);
broadcast(new \App\Events\MessageSent($roomId, $messageData));
\App\Jobs\SaveMessageJob::dispatch($messageData);
}
/**
* 根据对局模式和获胜方计算奖励金币。
*
* @param GomokuGame $game 对局
* @param int $winnerColor 胜方颜色
*/
private function calculateReward(GomokuGame $game, int $winnerColor): int
{
if ($game->mode === 'pvp') {
// PvP 胜利奖励从游戏配置读取
return (int) GameConfig::param('gomoku', 'pvp_reward', 80);
}
// PvEAI 胜利无奖励
if ($winnerColor !== 1) {
return 0;
}
// 按难度从游戏配置读取胜利奖励
$key = match ((int) $game->ai_level) {
1 => 'pve_easy_reward',
2 => 'pve_normal_reward',
3 => 'pve_hard_reward',
default => 'pve_expert_reward',
};
$defaults = ['pve_easy_reward' => 20, 'pve_normal_reward' => 50, 'pve_hard_reward' => 120, 'pve_expert_reward' => 300];
return (int) GameConfig::param('gomoku', $key, $defaults[$key]);
}
/**
* 根据 AI 难度获取 PvE 入场费。
*
* @param int $aiLevel AI 难度(1-4
*/
private function getPveEntryFee(int $aiLevel): int
{
// 从游戏配置读取各难度入场费,支持后台实时调整
$key = match ($aiLevel) {
1 => 'pve_easy_fee',
2 => 'pve_normal_fee',
3 => 'pve_hard_fee',
default => 'pve_expert_fee',
};
$defaults = ['pve_easy_fee' => 0, 'pve_normal_fee' => 10, 'pve_hard_fee' => 30, 'pve_expert_fee' => 80];
return (int) GameConfig::param('gomoku', $key, $defaults[$key]);
}
/**
* 查询当前用户是否有进行中的对局(重进页面时用于恢复)。
*
* 返回对局基础信息,包含模式、棋盘状态与双方用户名,
* 让前端弹出「继续 / 认输」选择。
*/
public function active(Request $request): JsonResponse
{
$user = $request->user();
$game = GomokuGame::query()
->where(function ($q) use ($user) {
$q->where('player_black_id', $user->id)
->orWhere('player_white_id', $user->id);
})
->whereIn('status', ['waiting', 'playing'])
->with('playerBlack', 'playerWhite')
->latest()
->first();
if (! $game) {
return response()->json(['ok' => true, 'has_active' => false]);
}
// 对阵双方用户名
$blackName = $game->playerBlack->username ?? '黑棋';
$whiteName = $game->mode === 'pve'
? ('AI(难度'.$game->ai_level.'')
: ($game->playerWhite?->username ?? '等待中…');
return response()->json([
'ok' => true,
'has_active' => true,
'game_id' => $game->id,
'mode' => $game->mode,
'status' => $game->status,
'ai_level' => $game->ai_level,
'your_color' => $game->colorOf($user->id),
'board' => $game->board,
'current_turn' => $game->current_turn,
'black_name' => $blackName,
'white_name' => $whiteName,
]);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* 文件功能:节日福利前台领取控制器
*
* 用户通过聊天室内弹窗点击"立即领取"调用此接口,
* 完成金币入账并返回领取结果。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
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,
) {}
/**
* 用户领取节日福利红包。
*
* holiday_claims 中查找当前用户的待领取记录,
* 入账金币并更新活动统计数据。
*/
public function claim(Request $request, HolidayEvent $event): JsonResponse
{
$user = $request->user();
// 活动是否在领取有效期内
if (! $event->isClaimable()) {
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
}
// 查找该用户的领取记录(批量插入时已生成)
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('user_id', $user->id)
->lockForUpdate()
->first();
if (! $claim) {
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
}
// 防止重复领取(claimed_at 为 null 表示未领取)
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
// 金币入账
$this->currency->change(
$user,
'gold',
$claim->amount,
CurrencySource::HOLIDAY_BONUS,
"节日福利:{$event->name}",
);
// 更新活动统计(只在首次领取时)
HolidayEvent::query()
->where('id', $event->id)
->increment('claimed_amount', $claim->amount);
// 删除领取记录(以此标记"已领取",防止重复调用)
$claim->delete();
// 检查是否已全部领完
if ($event->max_claimants > 0) {
$remaining = HolidayClaim::where('event_id', $event->id)->count();
if ($remaining === 0) {
$event->update(['status' => 'completed']);
}
}
return response()->json([
'ok' => true,
'message' => "🎉 恭喜!已领取 {$claim->amount} 金币!",
'amount' => $claim->amount,
]);
});
}
/**
* 查询当前用户在指定活动中的待领取状态。
*/
public function status(Request $request, HolidayEvent $event): JsonResponse
{
$user = $request->user();
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('user_id', $user->id)
->first();
return response()->json([
'claimable' => $claim !== null && $event->isClaimable(),
'amount' => $claim?->amount ?? 0,
'expires_at' => $event->expires_at?->toIso8601String(),
]);
}
}
@@ -0,0 +1,246 @@
<?php
/**
* 文件功能:赛马竞猜前台控制器
*
* 提供用户在聊天室内参与赛马的 API 接口:
* - 查询当前场次信息(马匹、注池、赔率)
* - 提交下注(扣除金币 + 写入下注记录)
* - 查询本人下注状态
* - 查询最近历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 获取当前进行中的场次信息(前端轮询或事件触发后调用)。
*/
public function currentRace(Request $request): JsonResponse
{
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
}
$user = $request->user();
$myBet = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->first();
// 计算各马匹当前注额
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->toArray();
$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;
return [
'id' => $horse['id'],
'name' => $horse['name'],
'emoji' => $horse['emoji'],
'pool' => $horsePool,
'odds' => $odds,
];
}, $horses);
return response()->json([
'race' => [
'id' => $race->id,
'status' => $race->status,
'bet_closes_at' => $race->bet_closes_at?->toIso8601String(),
'seconds_left' => $race->status === 'betting'
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
: 0,
'horses' => $horsesWithBets,
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
'my_bet' => $myBet ? [
'horse_id' => $myBet->horse_id,
'amount' => $myBet->amount,
] : null,
],
]);
}
/**
* 用户提交下注。
*
* 同一场每人限下一注,下注成功后立即扣除金币。
*/
public function bet(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('horse_racing')) {
return response()->json(['ok' => false, 'message' => '赛马竞猜当前未开启。']);
}
$data = $request->validate([
'race_id' => 'required|integer|exists:horse_races,id',
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
}
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
// 验证马匹 ID 是否有效
$horses = $race->horses ?? [];
$validIds = array_column($horses, 'id');
if (! in_array($data['horse_id'], $validIds, true)) {
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
}
$user = $request->user();
$currency = $this->currency;
// 校验余额
if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
return DB::transaction(function () use ($user, $race, $data, $currency, $horses): JsonResponse {
// 幂等:同一场只能下一注
$existing = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->lockForUpdate()
->exists();
if ($existing) {
return response()->json(['ok' => false, 'message' => '本场您已下注,请等待开奖。']);
}
// 找出马匹名称
$horseName = '';
foreach ($horses as $horse) {
if ((int) $horse['id'] === (int) $data['horse_id']) {
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
// 扣除金币
$currency->change(
$user,
'gold',
-$data['amount'],
CurrencySource::HORSE_BET,
"赛马 #{$race->id} 押注 {$horseName}",
);
// 写入下注记录
HorseBet::create([
'race_id' => $race->id,
'user_id' => $user->id,
'horse_id' => $data['horse_id'],
'amount' => $data['amount'],
'status' => 'pending',
]);
$chatState = app(ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$content = "🌟 🐎 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$horseName}{$data['amount']} 金币,等待开跑!",
'amount' => $data['amount'],
'horse_id' => $data['horse_id'],
]);
});
}
/**
* 查询最近10场历史记录(前端展示胜负趋势)。
*/
public function history(): JsonResponse
{
$races = HorseRace::query()
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
->get(['id', 'horses', 'winner_horse_id', 'total_pool', 'total_bets', 'settled_at']);
// 转换获胜马匹名称
$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'] ?? '');
break;
}
}
return [
'id' => $race->id,
'winner_id' => $race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $race->total_pool,
'total_bets' => $race->total_bets,
'settled_at' => $race->settled_at?->toDateTimeString(),
];
});
return response()->json(['history' => $history]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
class InviteController extends Controller
{
/**
* 处理邀请链接跳转
*
* @param int $inviter_id 邀请人ID
*/
public function handle(Request $request, int $inviter_id)
{
// 查找邀请人是否存在
$inviter = User::find($inviter_id);
if ($inviter) {
// 将邀请人ID记录到 Cookie 中,有效期7天(7 * 24 * 60 = 10080 分钟)
// 确保Cookie仅通过 HTTP 访问且作用于全站
Cookie::queue('inviter_id', $inviter->id, 10080);
}
// 重定向回聊天室首页进行注册/登录
return redirect()->route('home');
}
/**
* 独立展示邀请全站排行榜页面
*/
public function leaderboard()
{
// 邀请达人榜 (Top 50)
$topInviters = User::withCount('invitees')
->with(['activePosition.position.department'])
->having('invitees_count', '>', 0)
->orderByDesc('invitees_count')
->limit(50)
->get();
return view('invite.leaderboard', compact('topInviters'));
}
}
+65 -6
View File
@@ -3,6 +3,7 @@
/**
* 文件功能:全局风云排行榜控制器
* 各种维度(等级、经验、交友币、魅力)的前20名抓取与缓存展示。
* 新增今日榜:显示今天经验成长、今日金币获得、今日魅力增长最多的用户。
*
* @author ChatRoom Laravel
*
@@ -12,26 +13,33 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class LeaderboardController extends Controller
{
/**
* 渲染排行榜主视角
* 注入积分统计服务(用于今日榜单数据查询)
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 渲染排行榜主视角(包含累计榜 + 今日榜)
*/
public function index(): View
{
// 缓存 15 分钟,防止每秒几百个人看排行榜把数据库扫死
// 选用 remember 则在过期时自动执行闭包查询并重置缓存
$ttl = 60 * 15;
// 管理员等级阈值,排行榜中隐藏管理员
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
// 排行榜显示人数(后台可配置)
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
// ── 累计榜(15分钟缓存)──────────────────────────────
$ttl = 60 * 15;
// 1. 境界榜 (以 user_level 为尊)
$topLevels = Cache::remember('leaderboard:top_levels', $ttl, function () use ($superLevel, $topN) {
return User::select('id', 'username', 'usersf', 'user_level', 'sex')
@@ -76,6 +84,57 @@ class LeaderboardController extends Controller
->get();
});
return view('leaderboard.index', compact('topLevels', 'topExp', 'topWealth', 'topCharm'));
// ── 今日榜(5分钟缓存,数据来自 user_currency_logs 流水表)──
$todayTtl = 60 * 5;
$today = today()->toDateString();
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
);
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
);
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('charm', $topN, $today)
);
return view('leaderboard.index', compact(
'topLevels', 'topExp', 'topWealth', 'topCharm',
));
}
/**
* 今日风云榜独立页(经验/金币/魅力今日排行)
*/
public function todayIndex(): View
{
$todayTtl = 60 * 5;
$today = today()->toDateString();
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
);
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
);
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('charm', $topN, $today)
);
return view('leaderboard.today', compact('todayExp', 'todayGold', 'todayCharm'));
}
/**
* 用户个人流水日志页(查询自己的经验/金币/魅力操作历史)
*/
public function myLogs(): View
{
$user = auth()->user();
$currency = request('currency');
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* 文件功能:双色球彩票 HTTP 控制器
*
* 提供前端所需的四个 API 接口:
* - current() : 当期状态 + 奖池 + 我的购票列表
* - buy() : 购买一注或多注(支持机选)
* - history() : 历史期次列表
* - my() : 我的全部购票记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['enabled' => false]);
}
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
if (! $issue) {
return response()->json(['enabled' => true, 'issue' => null]);
}
$myTickets = LotteryTicket::query()
->where('issue_id', $issue->id)
->where('user_id', Auth::id())
->orderBy('id')
->get()
->map(fn ($t) => [
'id' => $t->id,
'numbers' => $t->numbersLabel(),
'red1' => $t->red1,
'red2' => $t->red2,
'red3' => $t->red3,
'blue' => $t->blue,
'is_quick' => $t->is_quick_pick,
'prize_level' => $t->prize_level,
'payout' => $t->payout,
]);
return response()->json([
'enabled' => true,
'is_open' => $issue->isOpen(),
'issue' => [
'id' => $issue->id,
'issue_no' => $issue->issue_no,
'status' => $issue->status,
'pool_amount' => $issue->pool_amount,
'is_super_issue' => $issue->is_super_issue,
'no_winner_streak' => $issue->no_winner_streak,
'seconds_left' => $issue->secondsUntilDraw(),
'draw_at' => $issue->draw_at?->toDateTimeString(),
'sell_closes_at' => $issue->sell_closes_at?->toDateTimeString(),
'red1' => $issue->red1,
'red2' => $issue->red2,
'red3' => $issue->red3,
'blue' => $issue->blue,
],
'my_tickets' => $myTickets,
'my_ticket_count' => $myTickets->count(),
]);
}
/**
* 购票接口:支持自选和机选,支持一次购买多注。
*/
public function buy(Request $request): JsonResponse
{
$request->validate([
'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3',
'numbers.*.reds.*' => 'required|integer|min:1|max:12',
'numbers.*.blue' => 'required|integer|min:1|max:6',
'quick_pick' => 'boolean',
]);
try {
$tickets = $this->lottery->buyTickets(
user: Auth::user(),
numbers: $request->input('numbers'),
quickPick: (bool) $request->input('quick_pick', false),
);
return response()->json([
'status' => 'success',
'message' => '购票成功!共 '.count($tickets).' 注',
'count' => count($tickets),
]);
} catch (\RuntimeException $e) {
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 422);
}
}
/**
* 机选号码接口(仅生成号码,不扣费,供前端展示后确认购买)。
*/
public function quickPick(Request $request): JsonResponse
{
$count = min((int) $request->input('count', 1), 10);
return response()->json([
'numbers' => $this->lottery->quickPick($count),
]);
}
/**
* 历史期次列表。
*/
public function history(): JsonResponse
{
$issues = LotteryIssue::query()
->where('status', 'settled')
->latest()
->limit(20)
->get()
->map(fn ($i) => [
'issue_no' => $i->issue_no,
'red1' => $i->red1,
'red2' => $i->red2,
'red3' => $i->red3,
'blue' => $i->blue,
'pool_amount' => $i->pool_amount,
'payout_amount' => $i->payout_amount,
'total_tickets' => $i->total_tickets,
'is_super_issue' => $i->is_super_issue,
'no_winner_streak' => $i->no_winner_streak,
'draw_at' => $i->draw_at?->toDateTimeString(),
]);
return response()->json(['issues' => $issues]);
}
/**
* 我的购票记录(跨期次)。
*/
public function my(): JsonResponse
{
$tickets = LotteryTicket::query()
->where('user_id', Auth::id())
->with('issue:id,issue_no,status,red1,red2,red3,blue,draw_at')
->latest()
->limit(50)
->get()
->map(fn ($t) => [
'issue_no' => $t->issue?->issue_no,
'status' => $t->issue?->status,
'numbers' => $t->numbersLabel(),
'prize_level' => $t->prize_level,
'payout' => $t->payout,
'is_quick' => $t->is_quick_pick,
'created_at' => $t->created_at->toDateTimeString(),
]);
return response()->json(['tickets' => $tickets]);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
/**
* 文件功能:前台婚姻控制器
*
* 处理求婚、接受/拒绝、查询婚姻状态、申请离婚等前台操作。
* 所有操作通过 MarriageService 执行,Events 负责广播。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\MarriageAccepted;
use App\Events\MarriageDivorced;
use App\Events\MarriageDivorceRequested;
use App\Events\MarriageProposed;
use App\Events\MarriageRejected;
use App\Models\Marriage;
use App\Models\UserPurchase;
use App\Services\MarriageConfigService;
use App\Services\MarriageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MarriageController extends Controller
{
public function __construct(
private readonly MarriageService $marriage,
private readonly MarriageConfigService $config,
) {}
/**
* 获取离婚相关惩罚配置(供前端展示风险提示)。
* 返回协议离婚魅力惩罚、强制离婚魅力惩罚及各冷静期天数。
*/
public function divorceConfig(): JsonResponse
{
return response()->json([
'mutual_charm_penalty' => (int) $this->config->get('divorce_mutual_charm', 100),
'forced_charm_penalty' => (int) $this->config->get('divorce_forced_charm', 300),
'mutual_cooldown_days' => (int) $this->config->get('divorce_mutual_cooldown', 70),
'forced_cooldown_days' => (int) $this->config->get('divorce_forced_cooldown', 90),
]);
}
/**
* 获取当前用户的婚姻状态(名片/用户列表用)。
*/
public function status(Request $request): JsonResponse
{
$user = $request->user();
$marriage = Marriage::currentFor($user->id);
if (! $marriage) {
return response()->json(['married' => false]);
}
$marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']);
return response()->json([
'married' => $marriage->status === 'married',
'status' => $marriage->status,
'marriage' => [
'id' => $marriage->id,
'user' => $marriage->user,
'partner' => $marriage->partner,
'ring' => $marriage->ringItem?->only(['name', 'icon']),
'intimacy' => $marriage->intimacy,
'level' => $marriage->level,
'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level),
'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level),
'married_at' => $marriage->married_at?->toDateString(),
'days' => $marriage->married_at?->diffInDays(now()),
'proposed_at' => $marriage->proposed_at,
'expires_at' => $marriage->expires_at,
'divorce_type' => $marriage->divorce_type,
'divorcer_id' => $marriage->divorcer_id,
],
]);
}
/**
* 查询目标用户的婚姻信息(用于双击名片展示)。
*/
public function targetStatus(Request $request): JsonResponse
{
$request->validate(['username' => 'required|string']);
$target = \App\Models\User::where('username', $request->username)->firstOrFail();
$marriage = Marriage::query()
->where('status', 'married')
->where(function ($q) use ($target) {
$q->where('user_id', $target->id)->orWhere('partner_id', $target->id);
})
->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon'])
->first();
if (! $marriage) {
return response()->json(['married' => false, 'marriage' => ['status' => 'none']]);
}
$partner = $marriage->user_id === $target->id ? $marriage->partner : $marriage->user;
return response()->json([
'married' => true,
'marriage' => [
'status' => $marriage->status,
'marriage_id' => $marriage->id,
'partner_name' => $partner?->username,
'is_my_partner' => $partner?->id === $request->user()?->id,
'ring' => $marriage->ringItem?->only(['name', 'icon']),
'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level),
'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level),
'days' => $marriage->married_at?->diffInDays(now()),
'intimacy' => $marriage->intimacy,
],
]);
}
/**
* 发起求婚。
*/
public function propose(Request $request): JsonResponse
{
$data = $request->validate([
'target_username' => 'required|string',
'ring_purchase_id' => 'required|integer',
'wedding_tier_id' => 'nullable|integer',
]);
$proposer = $request->user();
$target = \App\Models\User::where('username', $data['target_username'])->first();
if (! $target) {
return response()->json(['ok' => false, 'message' => '用户不存在。'], 404);
}
$result = $this->marriage->propose($proposer, $target, $data['ring_purchase_id'], $data['wedding_tier_id'] ?? null);
if ($result['ok']) {
$marriage = Marriage::find($result['marriage_id']);
// 广播给被求婚方(私人频道)
broadcast(new MarriageProposed($marriage, $proposer, $target));
}
return response()->json($result);
}
/**
* 获取当前用户持有的有效戒指列表(求婚前选择用)。
*/
public function myRings(Request $request): JsonResponse
{
$rings = UserPurchase::query()
->where('user_id', $request->user()->id)
->where('status', 'active')
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
->with('item:id,name,slug,icon,price,intimacy_bonus,charm_bonus')
->get()
->map(fn ($p) => [
'purchase_id' => $p->id,
'name' => $p->item->name,
'icon' => $p->item->icon,
'slug' => $p->item->slug,
'intimacy_bonus' => (int) ($p->item->intimacy_bonus ?? 0),
'charm_bonus' => (int) ($p->item->charm_bonus ?? 0),
]);
return response()->json(['status' => 'success', 'rings' => $rings]);
}
/**
* 接受求婚。
*/
public function accept(Request $request, Marriage $marriage): JsonResponse
{
$result = $this->marriage->accept($marriage, $request->user());
if ($result['ok']) {
$marriage->refresh();
// 广播全房间结婚公告
broadcast(new MarriageAccepted($marriage));
}
return response()->json($result);
}
/**
* 拒绝求婚。
*/
public function reject(Request $request, Marriage $marriage): JsonResponse
{
$result = $this->marriage->reject($marriage, $request->user());
if ($result['ok']) {
broadcast(new MarriageRejected($marriage));
}
return response()->json($result);
}
/**
* 申请离婚(协议或强制)。
*/
public function divorce(Request $request, Marriage $marriage): JsonResponse
{
$type = $request->input('type', 'mutual'); // mutual | forced
$result = $this->marriage->divorce($marriage, $request->user(), $type);
if ($result['ok']) {
$marriage->refresh();
if ($marriage->status === 'divorced') {
broadcast(new MarriageDivorced($marriage, $type));
} else {
// 协议离婚:通知对方
broadcast(new MarriageDivorceRequested($marriage));
}
}
return response()->json($result);
}
/**
* 确认协议离婚。
*/
public function confirmDivorce(Request $request, Marriage $marriage): JsonResponse
{
$result = $this->marriage->confirmDivorce($marriage, $request->user());
if ($result['ok']) {
$marriage->refresh();
broadcast(new MarriageDivorced($marriage, 'mutual'));
}
return response()->json($result);
}
/**
* 拒绝协议离婚申请(被申请方选择不同意 = 视为强制离婚)。
* 申请人赔偿一半金币给对方,婚姻以 forced 类型解除。
*/
public function rejectDivorce(Request $request, Marriage $marriage): JsonResponse
{
$result = $this->marriage->rejectDivorce($marriage, $request->user());
if ($result['ok']) {
$marriage->refresh();
broadcast(new MarriageDivorced($marriage, 'forced'));
}
return response()->json($result);
}
}
@@ -0,0 +1,175 @@
<?php
/**
* 文件功能:神秘箱子前台控制器
*
* 提供神秘箱子相关接口:
* - /mystery-box/status 查询当前可领取的箱子(给前端轮询)
* - /mystery-box/claim 用户发送暗号领取箱子
*
* 领取流程:
* 1. 用户在聊天框发送暗号(前端拦截后调用此接口)
* 2. 验证暗号匹配、箱子未过期、未已领取
* 3. 随机奖励金额(trap=扣,其余=加)
* 4. 写货币流水日志
* 5. 公屏广播结果(中奖/踩雷)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox();
if (! $box) {
return response()->json(['active' => false]);
}
// 计算剩余时间
$secondsLeft = $box->expires_at ? max(0, now()->diffInSeconds($box->expires_at, false)) : null;
return response()->json([
'active' => true,
'box_id' => $box->id,
'box_type' => $box->box_type,
'type_name' => $box->typeName(),
'type_emoji' => $box->typeEmoji(),
'passcode' => $box->passcode,
'seconds_left' => $secondsLeft,
]);
}
/**
* 用户用暗号领取箱子。
*/
public function claim(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放。']);
}
$passcode = strtoupper(trim((string) $request->input('passcode', '')));
if ($passcode === '') {
return response()->json(['ok' => false, 'message' => '请输入暗号。']);
}
$user = $request->user();
return DB::transaction(function () use ($user, $passcode): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query()
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->lockForUpdate()
->first();
if (! $box) {
return response()->json(['ok' => false, 'message' => '暗号不正确,或箱子已被领走/已过期。']);
}
// ① 随机奖励金额
$reward = $box->rollReward();
// ② 货币变更
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
$remark = $reward >= 0
? "神秘箱子【{$box->typeName()}】奖励"
: '神秘箱子【黑化箱】陷阱扣除';
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
// ③ 写领取记录 + 更新箱子状态
MysteryBoxClaim::create([
'mystery_box_id' => $box->id,
'user_id' => $user->id,
'reward_amount' => $reward,
]);
$box->update(['status' => 'claimed']);
// ④ 公屏广播结果
$user->refresh();
$this->broadcastResult($box, $user->username, $reward);
return response()->json([
'ok' => true,
'reward' => $reward,
'balance' => $user->jjb ?? 0,
'message' => $reward >= 0
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
: '☠️ 中了黑化陷阱!扣除 '.abs($reward).' 金币!',
]);
});
}
/**
* 公屏广播开箱结果。
*
* @param MysteryBox $box 箱子实例
* @param string $username 领取者用户名
* @param int $reward 奖励金额(正/负)
*/
private function broadcastResult(MysteryBox $box, string $username, int $reward): void
{
$emoji = $box->typeEmoji();
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【黑化陷阱】haha!【{$username}】 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $color,
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,374 @@
<?php
/**
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\RedPacketClaimed;
use App\Events\RedPacketSent;
use App\Jobs\SaveMessageJob;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RedPacketController extends Controller
{
/** 礼包固定总数量 */
private const TOTAL_AMOUNT = 8888;
/** 礼包固定份数 */
private const TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 300;
/**
* 构造函数:注入依赖服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* superlevel 站长凭空发出礼包。
*
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
*
* @param Request $request 需包含 room_id typegold / exp
*/
public function send(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:gold,exp',
]);
$user = Auth::user();
$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);
}
// 检查该用户在此房间是否有进行中的红包(防止刷包)
$activeExists = RedPacketEnvelope::query()
->where('sender_id', $user->id)
->where('room_id', $roomId)
->where('status', 'active')
->where('expires_at', '>', now())
->exists();
if ($activeExists) {
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
}
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
// 货币展示文案
$typeLabel = $type === 'exp' ? '经验' : '金币';
$typeIcon = $type === 'exp' ? '✨' : '💰';
$btnBg = $type === 'exp'
? 'linear-gradient(135deg,#7c3aed,#4f46e5)'
: 'linear-gradient(135deg,#dc2626,#ea580c)';
// 事务:创建红包记录 + Redis 写入分额
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): 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,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
]);
// 将拆分好的数量序列存入 Redis(List,LPOP 抢红包)
$key = "red_packet:{$envelope->id}:amounts";
foreach ($amounts as $amt) {
\Illuminate\Support\Facades\Redis::rpush($key, $amt);
}
// 多留 60s,确保领完后仍可回查
\Illuminate\Support\Facades\Redis::expire($key, self::EXPIRE_SECONDS + 60);
return $envelope;
});
// 广播系统公告,含可点击「立即抢包」按钮
// 注意这里不能死命传 self::EXPIRE_SECONDS,因为这句话会被存入数据库的历史记录。我们需要在取出来的时候能根据发包时间动态变化!
// 啊等等!由于这条消息是直接静态写入 `chat_messages` 内容里的,这就意味着如果在这里计算,存进去的还是 300。
// 所以我们还是传 `self::EXPIRE_SECONDS` 作为总寿命,在前端逻辑里利用 `Date.now()` 和消息的 `sent_at` 来算出真实剩余倒计时更为严谨!
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
.$envelope->id
.',\''.$user->username.'\','
.self::TOTAL_AMOUNT.','
.self::TOTAL_COUNT.','
.self::EXPIRE_SECONDS
.',\''.$type.'\''
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
.'color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:12px;font-weight:bold;'
.'box-shadow:0 2px 6px rgba(0,0,0,0.3);">'.$typeIcon.' 立即抢包</button>';
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播红包事件(触发前端弹出红包卡片)
broadcast(new RedPacketSent(
roomId: $roomId,
envelopeId: $envelope->id,
senderUsername: $user->username,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
));
return response()->json([
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
]);
}
/**
* 查询礼包当前状态(弹窗打开时实时刷新用)。
*
* 返回:剩余份数、是否已过期、当前用户是否已领取。
*
* @param int $envelopeId 红包 ID
*/
public function status(int $envelopeId): JsonResponse
{
$envelope = RedPacketEnvelope::find($envelopeId);
if (! $envelope) {
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
}
$user = Auth::user();
$isExpired = $envelope->expires_at->isPast();
$remainingCount = $envelope->remainingCount();
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
->where('user_id', $user->id)
->exists();
// 若已过期但 status 尚未同步,顺手更新为 expired
if ($isExpired && $envelope->status === 'active') {
$envelope->update(['status' => 'expired']);
}
return response()->json([
'status' => 'success',
'remaining_count' => $remainingCount,
'total_count' => $envelope->total_count,
'envelope_status' => $isExpired ? 'expired' : $envelope->status,
'is_expired' => $isExpired,
'has_claimed' => $hasClaimed,
'type' => $envelope->type ?? 'gold',
]);
}
/**
* 用户抢礼包(先到先得)。
*
* 使用 Redis LPOP 原子操作获取数量,再写入数据库流水。
* 重复领取通过 unique 约束保障幂等性。
* 按红包 type 字段决定入账金币还是经验。
*
* @param Request $request 需包含 room_id
* @param int $envelopeId 红包 ID
*/
public function claim(Request $request, int $envelopeId): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
]);
$user = Auth::user();
$roomId = (int) $request->input('room_id');
// 加载红包记录
$envelope = RedPacketEnvelope::find($envelopeId);
if (! $envelope) {
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
}
// 检查红包是否可领
if (! $envelope->isClaimable()) {
return response()->json(['status' => 'error', 'message' => '红包已抢完或已过期'], 422);
}
// 检查是否已领取过
$alreadyClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
->where('user_id', $user->id)
->exists();
if ($alreadyClaimed) {
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 从 Redis 原子 POP 一份数量
$redisKey = "red_packet:{$envelopeId}:amounts";
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
if ($amount === null || $amount === false) {
return response()->json(['status' => 'error', 'message' => '礼包已被抢完!'], 422);
}
$amount = (int) $amount;
// 兼容旧记录(type 字段可能为 null)
$envelopeType = $envelope->type ?? 'gold';
// 事务:写领取记录 + 更新统计 + 货币入账
try {
DB::transaction(function () use ($envelope, $user, $amount, $roomId, $envelopeType): void {
// 写领取记录(unique 约束保障不重复)
RedPacketClaim::create([
'envelope_id' => $envelope->id,
'user_id' => $user->id,
'username' => $user->username,
'amount' => $amount,
'claimed_at' => now(),
]);
// 更新红包统计
$envelope->increment('claimed_count');
$envelope->increment('claimed_amount', $amount);
// 若已全部领完,关闭红包
$envelope->refresh();
if ($envelope->claimed_count >= $envelope->total_count) {
$envelope->update(['status' => 'completed']);
}
// 按类型入账(金币或经验)
if ($envelopeType === 'exp') {
$this->currencyService->change(
$user,
'exp',
$amount,
CurrencySource::RED_PACKET_RECV_EXP,
"抢到礼包 {$amount} 经验(红包#{$envelope->id}",
$roomId,
);
} else {
$this->currencyService->change(
$user,
'gold',
$amount,
CurrencySource::RED_PACKET_RECV,
"抢到礼包 {$amount} 金币(红包#{$envelope->id}",
$roomId,
);
}
});
} catch (UniqueConstraintViolationException) {
// 并发重复领取:将数量放回 Redis(补偿)
\Illuminate\Support\Facades\Redis::rpush($redisKey, $amount);
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 广播领取事件(给自己的私有频道,前端弹 Toast)
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
// 在聊天室发送领取播报(所有人可见)
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
$claimedMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $claimedMsg);
broadcast(new MessageSent($roomId, $claimedMsg));
SaveMessageJob::dispatch($claimedMsg);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField;
return response()->json([
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
/**
* 随机拆分礼包数量。
*
* 使用「二倍均值法」:每次随机数量不超过剩余均值的 2 倍,
* 保证每份至少 1 且总额精确等于 totalAmount。
*
* @param int $total 总数量
* @param int $count 份数
* @return int[] 每份数量数组
*/
private function splitAmount(int $total, int $count): array
{
$amounts = [];
$remaining = $total;
for ($i = 1; $i < $count; $i++) {
$leftCount = $count - $i;
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
$max = max(1, $max);
$amount = random_int(1, $max);
$amounts[] = $amount;
$remaining -= $amount;
}
// 最后一份为剩余全部
$amounts[] = $remaining;
// 打乱顺序,避免后来者必得少
shuffle($amounts);
return $amounts;
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* 文件功能:商店控制器
* 提供商品列表查询、商品购买(含赠送特效广播)、改名卡使用 三个接口
*/
namespace App\Http\Controllers;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Models\ShopItem;
use App\Services\ShopService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ShopController extends Controller
{
/**
* 注入商店服务
*/
public function __construct(
private readonly ShopService $shopService,
) {}
/**
* 获取商店商品列表及当前用户状态
*
* 返回字段:items(商品列表)、user_jjb(当前金币)、
* active_week_effect(当前周卡)、has_rename_card(是否持有改名卡)
*/
public function items(): JsonResponse
{
$user = Auth::user();
$items = ShopItem::active()->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'type' => $item->type,
'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
]);
// 统计背包中各戒指持有数量
$ringCounts = \App\Models\UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
->selectRaw('shop_item_id, count(*) as qty')
->groupBy('shop_item_id')
->pluck('qty', 'shop_item_id')
->toArray();
return response()->json([
'items' => $items,
'user_jjb' => $user->jjb ?? 0,
'active_week_effect' => $this->shopService->getActiveWeekEffect($user),
'has_rename_card' => $this->shopService->hasRenameCard($user),
'ring_counts' => $ringCounts,
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
]);
}
/**
* 购买商品
*
* 单次特效卡额外支持:
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
* - message 公屏赠言(可选)
*
* @param Request $request item_id, recipient?, message?
*/
public function buy(Request $request): JsonResponse
{
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
$item = ShopItem::find($request->item_id);
if (! $item->is_active) {
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
}
$result = $this->shopService->buyItem(Auth::user(), $item);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
$response = ['status' => 'success', 'message' => $result['message']];
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) {
$user = Auth::user();
$roomId = (int) $request->room_id;
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
$message = trim($request->input('message', ''));
// recipient 为空或 "all" 表示全员
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
// 广播特效事件(全员频道)
broadcast(new EffectBroadcast(
roomId: $roomId,
type: $result['play_effect'],
operator: $user->username,
targetUsername: $targetUsername,
giftMessage: $message ?: null,
))->toOthers();
// 同时前端也需要播放(自己也要看到)
$response['play_effect'] = $result['play_effect'];
$response['target_username'] = $targetUsername;
$response['gift_message'] = $message ?: null;
// 公屏系统消息
if ($roomId > 0) {
$icons = [
'fireworks' => '🎆',
'rain' => '🌧',
'lightning' => '⚡',
'snow' => '❄️',
];
// 赠礼消息文案(改成"为XX触发了一场特效"
$icon = $icons[$result['play_effect']] ?? '✨';
$toStr = $targetUsername ? "{$targetUsername}" : '全体聊友';
$remarkPart = $message ? "{$message}" : '';
$sysContent = "{$icon} {$user->username}{$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
$sysMsgEvent = new MessageSent(
roomId: $roomId,
message: [
'id' => 0,
'room_id' => $roomId,
'from_user' => '系统传音', // 触发金色左边框样式(已有处理分支)
'to_user' => '大家',
'content' => $sysContent,
'font_color' => '#cc6600',
'sent_at' => now()->format('H:i:s'),
'is_secret' => false,
'action' => null,
]
);
broadcast($sysMsgEvent);
}
} else {
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = '';
if ($item->type === 'auto_fishing') {
$mins = (int) ($item->duration_minutes ?? 0);
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
}
// 根据商品类型生成不同通知文案
$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}」。",
};
broadcast(new MessageSent(
roomId: $roomId,
message: [
'id' => 0,
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $sysContent,
'font_color' => '#7c3aed',
'sent_at' => now()->format('H:i:s'),
'is_secret' => false,
'action' => null,
]
));
}
}
// 返回最新金币余额
$response['jjb'] = Auth::user()->fresh()->jjb;
return response()->json($response);
}
/**
* 使用改名卡修改昵称
*
* @param Request $request new_name
*/
public function rename(Request $request): JsonResponse
{
$request->validate([
'new_name' => 'required|string|min:1|max:10',
]);
$result = $this->shopService->useRenameCard(Auth::user(), $request->new_name);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
return response()->json(['status' => 'success', 'message' => $result['message']]);
}
}
@@ -0,0 +1,288 @@
<?php
/**
* 文件功能:老虎机游戏前台控制器
*
* 提供老虎机转动 API
* - 检查游戏开关、每日限制
* - 扣除金币、生成三列图案
* - 判断结果、赔付金币、写流水
* - 三个7时全服公屏广播
* - 返回结果供前端播放动画
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\SlotMachineLog;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SlotMachineController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
) {}
/**
* 获取老虎机配置信息(图案表、赔率、今日剩余次数)。
*/
public function info(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('slot_machine')) {
return response()->json(['enabled' => false]);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
$usedToday = 0;
if ($dailyLimit > 0) {
$usedToday = SlotMachineLog::query()
->where('user_id', $user->id)
->whereDate('created_at', today())
->count();
}
return response()->json([
'enabled' => true,
'cost_per_spin' => (int) ($config['cost_per_spin'] ?? 100),
'daily_limit' => $dailyLimit,
'used_today' => $usedToday,
'remaining' => $dailyLimit > 0 ? max(0, $dailyLimit - $usedToday) : null,
'symbols' => collect(SlotMachineLog::symbols())->map(fn ($s) => $s['emoji']),
]);
}
/**
* 执行一次转动。
*
* 流程:检查可玩 扣费 摇号 赔付 写日志 全服广播(三7)→ 返回结果
*/
public function spin(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('slot_machine')) {
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100);
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
$user = $request->user();
// 金币余额检查
if (($user->jjb ?? 0) < $cost) {
return response()->json(['ok' => false, 'message' => "金币不足,每次转动需 {$cost} 金币。"]);
}
// 每日次数限制检查
if ($dailyLimit > 0) {
$usedToday = SlotMachineLog::query()
->where('user_id', $user->id)
->whereDate('created_at', today())
->count();
if ($usedToday >= $dailyLimit) {
return response()->json(['ok' => false, 'message' => "今日已转动 {$dailyLimit} 次,明日再来!"]);
}
}
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
// ① 扣费
$this->currency->change(
$user,
'gold',
-$cost,
CurrencySource::SLOT_SPIN,
'老虎机转动消耗',
);
// ② 摇号
$r1 = SlotMachineLog::randomSymbol();
$r2 = SlotMachineLog::randomSymbol();
$r3 = SlotMachineLog::randomSymbol();
$resultType = SlotMachineLog::judgeResult($r1, $r2, $r3);
$symbols = SlotMachineLog::symbols();
// ③ 计算赔付金额
$payout = $this->calcPayout($resultType, $cost, $config);
// ④ 赔付金币
if ($payout > 0) {
$this->currency->change(
$user,
'gold',
$payout,
CurrencySource::SLOT_WIN,
"老虎机 {$resultType} 中奖",
);
} elseif ($resultType === 'curse' && ($config['curse_enabled'] ?? true)) {
// 诅咒:再扣一倍本金
$cursePenalty = $cost;
if (($user->jjb ?? 0) >= $cursePenalty) {
$this->currency->change(
$user,
'gold',
-$cursePenalty,
CurrencySource::SLOT_CURSE,
'老虎机三骷髅诅咒额外扣除',
);
$payout = -$cursePenalty; // 净损失 = 本金+惩罚
}
}
// ⑤ 写游戏日志
$resultLabel = SlotMachineLog::resultLabel($resultType);
SlotMachineLog::create([
'user_id' => $user->id,
'reel1' => $r1,
'reel2' => $r2,
'reel3' => $r3,
'result_type' => $resultType,
'cost' => $cost,
'payout' => $payout,
]);
// ⑥ 广播通知
$e1 = $symbols[$r1]['emoji'];
$e2 = $symbols[$r2]['emoji'];
$e3 = $symbols[$r3]['emoji'];
if ($resultType === 'jackpot') {
// 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content);
} elseif ($resultType === 'curse') {
// 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content);
}
$user->refresh();
return response()->json([
'ok' => true,
'reels' => [$r1, $r2, $r3],
'emojis' => [
$symbols[$r1]['emoji'],
$symbols[$r2]['emoji'],
$symbols[$r3]['emoji'],
],
'result_type' => $resultType,
'result_label' => SlotMachineLog::resultLabel($resultType),
'payout' => $payout, // 净变化(正=赢,负=额外亏)
'net_change' => $payout - $cost, // 相对本金的盈亏(debug 用)
'balance' => $user->jjb ?? 0,
]);
});
}
/**
* 查询最近10条个人记录。
*/
public function history(Request $request): JsonResponse
{
$logs = SlotMachineLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
->limit(10)
->get(['id', 'reel1', 'reel2', 'reel3', 'result_type', 'cost', 'payout', 'created_at']);
$symbols = SlotMachineLog::symbols();
return response()->json([
'history' => $logs->map(fn ($l) => [
'emojis' => [$symbols[$l->reel1]['emoji'], $symbols[$l->reel2]['emoji'], $symbols[$l->reel3]['emoji']],
'result_label' => SlotMachineLog::resultLabel($l->result_type),
'payout' => $l->payout,
'created_at' => $l->created_at->format('H:i'),
]),
]);
}
/**
* 计算赔付金额(赢时返还 = 本金 × 赔率)。
*
* @return int 正数=赢得金额(含本金返还),0=不赔付
*/
private function calcPayout(string $resultType, int $cost, array $config): int
{
$multiplier = match ($resultType) {
'jackpot' => (int) ($config['jackpot_payout'] ?? 100),
'triple_gem' => (int) ($config['triple_payout'] ?? 50),
'triple' => (int) ($config['same_payout'] ?? 10),
'pair' => (int) ($config['pair_payout'] ?? 2),
default => 0,
};
return $multiplier > 0 ? $cost * $multiplier : 0;
}
/**
* 三个7全服公屏广播。
*/
private function broadcastJackpot(string $username, int $payout, int $cost): void
{
$net = $payout - $cost;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 向特定用户发送聊天室私人系统通知(仅该用户可见)。
*
* @param string $toUsername 接收用户名
* @param string $content 消息内容
*/
private function broadcastPersonal(string $toUsername, string $content): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => $toUsername,
'content' => $content,
'is_secret' => true,
'font_color' => '#f59e0b',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}

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