74 Commits

Author SHA1 Message Date
pllx 06864a9cec 完善座驾插件验收规则 2026-04-30 11:18:59 +08:00
pllx 622bc94377 精简座驾动画标题名称 2026-04-30 11:15:37 +08:00
pllx 575e92e03f 精简座驾文字播报身份信息 2026-04-30 11:14:53 +08:00
pllx 522eea72f6 避免座驾进房重复欢迎 2026-04-30 11:12:50 +08:00
pllx fc7930046d 统一座驾播报用户信息 2026-04-30 11:08:39 +08:00
pllx 7ba7b34ca7 补充座驾动画标题用户信息 2026-04-30 11:07:46 +08:00
pllx 3eaf37a648 修复座驾与会员入场重复展示 2026-04-30 11:03:09 +08:00
pllx 221f629ec2 优化座驾特效入场标题 2026-04-30 10:29:11 +08:00
pllx 18acd7d890 优化座驾开发插件规范 2026-04-30 10:10:08 +08:00
pllx 09a2b0d85f 删除座驾旧方案空迁移 2026-04-30 10:05:19 +08:00
pllx b60f3615c1 补齐座驾购买金币流水 2026-04-30 10:02:59 +08:00
pllx 363c45a140 避免测试清空登录会话 2026-04-30 09:58:18 +08:00
pllx 181cc6a0b0 改为独立座驾模块 2026-04-30 09:55:20 +08:00
pllx 3c95478097 新增聊天室座驾系统 2026-04-30 09:40:50 +08:00
pllx 45ce8b2b2d 调整聊天输入栏工具字号 2026-04-30 09:07:10 +08:00
pllx 50b050c4bc 修复聊天室字号偏好和游戏通知显示 2026-04-29 18:27:32 +08:00
pllx 6748fbc44e 修复自动钓鱼状态与播报屏蔽范围 2026-04-29 16:54:18 +08:00
pllx 449894e3e5 修复赛马编号通知卡片识别 2026-04-29 16:07:26 +08:00
pllx 5173275a92 恢复游戏通知原始正文样式 2026-04-29 15:58:41 +08:00
pllx ee56792beb 统一猜谜答对通知配色 2026-04-29 15:50:58 +08:00
pllx 02ed8ea319 精简AI百家乐下注通知文案 2026-04-29 15:48:33 +08:00
pllx 2bebc78e82 补充游戏开奖通知编号文案 2026-04-29 15:45:07 +08:00
pllx 4fe4155ec0 修复钓鱼通知与游戏配置保存问题 2026-04-29 15:23:32 +08:00
pllx c640a31302 完善游戏通知文案与屏蔽逻辑 2026-04-29 15:06:01 +08:00
pllx 6ae452c4b9 补充猜谜活动旧Seeder兼容入口 2026-04-29 14:44:57 +08:00
pllx 1607f57e3c 支持所有游戏按房间范围配置和运行 2026-04-29 14:37:28 +08:00
pllx 3672140987 统一聊天室游戏通知胶囊样式 2026-04-29 14:35:52 +08:00
pllx 092b51cd95 拆分猜谜活动后台配置视图 2026-04-29 13:39:41 +08:00
pllx fe3e74b5f8 重构猜谜活动并统一聊天室答题通知 2026-04-29 13:35:20 +08:00
pllx 192259f0a4 调整聊天室名单排序搜索同排布局 2026-04-29 12:01:03 +08:00
pllx a50055deaf 修复赛马线上卡在跑马中状态 2026-04-29 11:42:49 +08:00
pllx 578f587941 拆分猜成语独立屏蔽通知类型 2026-04-29 11:35:14 +08:00
pllx fb4a7171f4 修复猜成语出题消息线上不显示问题 2026-04-29 11:25:57 +08:00
pllx dc9c09c722 修复 Reverb 配置缓存时的函数重定义 2026-04-29 11:18:08 +08:00
pllx 317dfd04d7 修复聊天室在线名单初始化与 Reverb 来源校验 2026-04-29 11:15:24 +08:00
pllx 1192fe5bdb 优化升级 2026-04-29 10:59:35 +08:00
pllx e0679b164e 修复升级bug 2026-04-29 10:52:44 +08:00
pllx 82d762d070 完善部署脚本的 Reverb 重启流程 2026-04-29 10:39:07 +08:00
pllx 5962d6d2b3 完善猜成语过期与答题记录逻辑 2026-04-29 10:32:12 +08:00
pllx 2f9b2eed64 修复拍一拍消息重复显示的问题,按发送者/被拍者路由到包厢,其他用户路由到公屏 2026-04-29 09:40:40 +08:00
pllx 434f2b8e0f fix(idiom): 后台出题按钮无效,管理后台布局缺少 @stack('scripts') 2026-04-29 00:14:41 +08:00
pllx 9bc085cb7d refactor(idiom): 将游戏配置和题库移到 IdiomSeeder,迁移只建表 2026-04-29 00:10:01 +08:00
pllx f13cfe4bc1 feat(idiom): 答对提示的用户名可点击打开用户名片 2026-04-28 23:58:04 +08:00
pllx cd1621f497 feat(idiom): disable answer button after question is solved 2026-04-28 23:53:05 +08:00
pllx 3973b7770c fix(idiom): split display - winner sees in private, others in public 2026-04-28 23:51:16 +08:00
pllx 0847877ce2 feat(idiom): global toast + chat message on answer result 2026-04-28 23:49:27 +08:00
pllx b886d98d8c feat(idiom): add scheduled auto-start task (everyMinute) 2026-04-28 23:48:32 +08:00
pllx 4ff62e29bd feat: 猜成语游戏 - 完整题库、管理后台、答题弹窗
- 创建 idioms 表(102条谜语式成语题库)和 idiom_game_rounds 表
- 后台成语管理页面:增删改题目 + 游戏参数(金币/经验/间隔)内联设置 + 出题按钮
- IdiomQuizController:出题/答题/当前回合查询,Redis 防并发抢答
- IdiomGameStarted / IdiomGameAnswered 广播事件
- 前端答题弹窗模块:聊天消息带【答题】按钮,点击弹出输入框
- GameConfig 注册 idiom 游戏,由 admin.game-configs 统一管理开关
2026-04-28 23:42:48 +08:00
pllx 461c6a6f56 fix: 弹窗点击遮罩层(外部区域)即可关闭 2026-04-28 23:18:26 +08:00
pllx 1850a5f4e9 fix: 选择斜杠命令后自动清除输入框中的 /
- 统一在 selectCommand 公共入口清理输入
- 新增 /签到 命令,自动完成今日签到
2026-04-28 23:12:30 +08:00
pllx 0850004d39 feat: 斜杠命令新增 /签到,自动完成今日签到 2026-04-28 23:10:00 +08:00
pllx df96b56ab0 feat: 斜杠命令新增 /查看资料,直接打开用户名片 2026-04-28 23:01:40 +08:00
pllx 495efdf9e0 feat: 新增 /拍一拍 功能 + 斜杠命令菜单
- 输入框输入 / 弹出命令菜单,当前支持 /拍一拍
- 选择对象后输入 /拍一拍 发送拍一拍通知
- 所有在线用户屏幕抖动 + 正常聊天样式显示消息
- 命令注册表可扩展,后续新增命令只需 push 到数组
2026-04-28 22:59:16 +08:00
pllx 0dd85879af 增加51统计 2026-04-28 14:49:33 +08:00
pllx 64434516d7 视频赚钱按钮改为提示已关闭 2026-04-28 14:45:37 +08:00
pllx e155a0e3d0 恢复视频赚钱路由(只保留控制器关闭逻辑) 2026-04-28 14:43:25 +08:00
pllx d6e8a64ce3 停用看视频赚钱功能(注释路由+控制器返回关闭) 2026-04-28 14:42:41 +08:00
pllx abb5512222 feat: 欢迎语增加右下角弹窗通知;禁止对「大家」发送欢迎 2026-04-28 14:40:27 +08:00
pllx 55fd770fdd 取消看视频赚钱 2026-04-28 14:32:39 +08:00
pllx 4fb78eaca9 feat: 功能菜单增加留言、反馈快捷入口 2026-04-28 14:28:52 +08:00
pllx 05ec4a72b7 feat: 公屏公告同时弹右下角通知(所有人可见) 2026-04-28 14:14:48 +08:00
pllx f3d883b5ed fix: 刷新后屏蔽项虽恢复但已有消息未隐藏 2026-04-28 14:08:15 +08:00
pllx 96e0e21f8b fix: 屏蔽列表刷新后不恢复(缺少从服务端读取的分支逻辑) 2026-04-28 14:04:07 +08:00
pllx c8adbff78e 安全优化 2026-04-28 13:57:15 +08:00
pllx aa6046d89b fix: 弹窗消息改用真正的换行符而非字面量 \n 2026-04-28 13:23:26 +08:00
pllx cdec289740 fix: 对话框 textContent 改为 innerText 支持换行;后端允许装扮批量购买 2026-04-28 13:16:24 +08:00
pllx d63aeef45b feat: 装扮购买增加数量输入弹窗(参照补签卡样式) 2026-04-28 13:12:56 +08:00
pllx 2be7e6caef 优化:去掉装饰品数量弹窗,改为确认对话框中提示叠加天数说明 2026-04-28 13:10:15 +08:00
pllx a2b09da730 新增:个性装扮支持多份购买,同款续购自动叠加天数 2026-04-28 13:07:10 +08:00
pllx 243e06915e 删除没必要文件 2026-04-28 13:00:21 +08:00
pllx 2ee6ecc601 清理:移除已追踪的 AI 配置目录(.agents/.codex/.gemini/.hermes/.junie)和 AI 生成文件(AGENTS.md/GEMINI.md) 2026-04-28 12:01:36 +08:00
pllx f0137f3fa3 修复:后台恢复时只省略过时的系统/游戏通知,保留用户聊天记录 2026-04-28 11:59:01 +08:00
pllx b15e42891d 修复:后台恢复时消息暴刷,只渲染最后50条并插入省略提示 2026-04-28 11:55:35 +08:00
pllx 214a422504 修复:自动钓鱼冷却倒计时改为基于时间戳,解决后台标签节流导致的显示/触发延迟 2026-04-28 11:52:30 +08:00
145 changed files with 12356 additions and 3354 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "chatroom-local-marketplace",
"interface": {
"displayName": "Chatroom Local Plugins"
},
"plugins": [
{
"name": "chatroom-ride-development",
"source": {
"source": "local",
"path": "./plugins/chatroom-ride-development"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
-150
View File
@@ -1,150 +0,0 @@
---
trigger: always_on
---
> **技术栈**Laravel 12 · PHP 8.4 · Laravel Reverb (WebSocket) · Redis · MySQL 8.0 · Laravel Horizon
> **原项目**`/Users/pllx/Web/chat/hp0709`VBScript ASP + MS Access 聊天室)
> **目标域名**`http://chatroom.test`Herd 自动配置)
---
## 一、环境版本要求
| 组件 | 版本 |
| --------------------- | -------------------------- |
| **PHP** | 8.4.5+ |
| **Laravel Framework** | v12.x |
| **Laravel Reverb** | latestWebSocket 服务器) |
| **Laravel Horizon** | v5(Redis 队列可视化管理) |
| **PHPUnit** | v11(测试框架) |
| **Node.js** | 20.x LTS |
| **MySQL** | 8.0+ |
| **Redis** | 7.x |
---
## 二、代码规范(强制执行)
### 2.1 Laravel Pint 格式化
```bash
# 提交代码前必须运行,修复格式问题
vendor/bin/pint --dirty
# 检查格式问题(不修复)
vendor/bin/pint --test
# 格式化整个项目
vendor/bin/pint
```
### 2.2 PHP 8.4 类型系统(必须遵守)
```php
// ✅ 正确:构造函数属性提升 (Constructor Property Promotion)
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
) {}
}
// ❌ 错误:不允许无参空构造函数
class SomeClass
{
public function __construct() {} // 禁止!
}
// ✅ 正确:显式返回类型 + 参数类型提示
public function send(SendMessageRequest $request): JsonResponse
{
// ...
}
// ✅ 正确:使用 PHP 8.4 新特性
// 联合类型
public function findUser(int|string $id): User|null {}
// readonly 属性
class MessageDto
{
public function __construct(
public readonly string $content,
public readonly string $fromUser,
public readonly int $roomId,
) {}
}
```
### 2.3 Laravel 12 中间件配置(重要)
> [!IMPORTANT]
> Laravel 12 已废弃 `Kernel.php`,中间件在 `bootstrap/app.php` 中配置。
```php
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // API 路由
channels: __DIR__.'/../routes/channels.php', // WebSocket 频道
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// 注册聊天室登录验证中间件
$middleware->alias([
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
]);
// Session 中间件(Web 路由自动携带)
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
```
### 2.4 中文注释规范(每个文件必须)
```php
<?php
/**
* 文件功能:[本文件的业务职责描述]
*
* 对应原 ASP 文件:[原文件名.asp]
*
* @package App\[命名空间]
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Services;
class ChatStateService
{
/**
* 用户进入聊天房间,将其信息写入 Redis。
*
* 替代原 ASP 的 Application("_user_list") 字符串拼接操作。
*
* @param int $roomId 房间 ID
* @param string $username 用户名
* @param array $userInfo 用户信息(等级、头像、性别等)
*/
public function userJoin(int $roomId, string $username, array $userInfo): void
{
// 将用户信息序列化后存入 Redis HashKey 为 "room:{房间ID}:users"
$this->redis->hset("room:{$roomId}:users", $username, json_encode($userInfo));
}
}
```
### 2.5 迁移文件注意事项
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-12
View File
@@ -1,12 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[cleanup]
script = '''
php artisan reverb:start
php artisan horizon
'''
-14
View File
@@ -1,14 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[[actions]]
name = "启动ws"
icon = "tool"
command = '''
php artisan reverb:start
php artisan horizon
'''
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
},
"herd": {
"command": "php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+6
View File
@@ -15,6 +15,8 @@
/.github
/.gemini
/.agents
/.codex
/.hermes
/auth.json
/node_modules
/public/build
@@ -30,3 +32,7 @@ vendor.zip
test-captcha.php
public/.user.ini
dump.rdb
# AI 生成文件
AGENTS.md
GEMINI.md
@@ -1,335 +0,0 @@
# 🛡️ 聊天室项目 — 安全与访问速度优化规划方案
> **项目路径:** `/Users/pllx/Web/Herd/chatroom`
> **技术栈:** Laravel 12 + PHP 8.4 + Redis + MySQL + Reverb (WebSocket) + TailwindCSS 4 + Vite
> **检查日期:** 2026-04-27
---
## 一、安全优化(🔴高危 / 🟡中危 / 🟢低危)
### 🔴 1. 关闭 APP_DEBUG(生产环境)
**当前:** `.env``APP_DEBUG=true`
**风险:** 生产环境开启 DEBUG 会在报错时泄露数据库密码、Redis 密码、Reverb 密钥等敏感信息。
**方案:**
```
# .env 生产环境改为
APP_DEBUG=false
```
---
### 🔴 2. 启用 Session 加密
**当前:** `SESSION_ENCRYPT=false`
**风险:** Session 数据以明文存储在 Redis 中,若 Redis 被入侵或存在 SSRF,用户身份数据全部泄露。
**方案:**
```
# .env 添加
SESSION_ENCRYPT=true
```
---
### 🔴 3. 限制 Reverb WebSocket 允许源(Allowed Origins
**当前:** `config/reverb.php``'allowed_origins' => ['*']`
**风险:** 任何第三方网站均可连接你的 WebSocket 服务,可被用于 CSWSHCross-Site WebSocket Hijacking)攻击,窃取聊天消息。
**方案:**
```php
// config/reverb.php
'allowed_origins' => [
env('APP_URL', 'http://chatroom.test'),
// 如果有多个域名,手动列出
],
```
---
### 🔴 4. Reverb WebSocket 启用 TLSWSS
**当前:** `REVERB_SCHEME=http`WebSocket 走明文 HTTP
**风险:** 所有聊天消息、用户在线状态等实时数据明文传输,可被中间人攻击窃听。
**方案:**
```
# .env 生产环境
REVERB_SCHEME=https
REVERB_PORT=443 # 或 8443
# 并在 reverb.php 中配置 TLS 证书路径
```
---
### 🔴 5. 设置 Session Cookie Secure 标志
**当前:** `SESSION_SECURE_COOKIE` 未设置(null
**风险:** 在 HTTPS 下,未标记 Secure 的 Cookie 仍可能被非 HTTPS 连接泄露。
**方案:**
```
# .env 生产环境
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
```
---
### 🟡 6. 加强登录安全策略
**当前状况:**
- 存在验证码(mews/captcha)✅
- 登录有 `throttle:chat-login` 限流 ✅
- 自动注册(用户名+密码即可注册)⚡ 双刃剑
- MD5 老密码兼容 ✅ 会自动升级为 Bcrypt
**优化方案:**
| 项目 | 建议 |
|------|------|
| 登录失败锁定 | 同一 IP 5 次失败后临时锁定 15 分钟,后端实现 |
| 密码强度 | 最低 6 位,建议增加 min:6 验证或至少含数字/字母 |
| 管理员登录 2FA | id=1 站长登录时增加二次验证(如邮箱验证码) |
| 验证码频率 | 同一 IP 每天最多注册 3 个账号,防恶意注册 |
---
### 🟡 7. 敏感字段防止 Mass Assignment
**当前:** User 模型的 `$fillable` 中包含 `user_level``jjb``meili``bank_jjb``exp_num` 等金钱/权限字段。
**风险:** 如果其他地方调用了 `User::create($request->all())``User::update($request->all())` 且未使用 FormRequest 过滤,可能导致权限提升或刷币。
**方案:**
- 将 `user_level``jjb``meili``bank_jjb``exp_num` 等敏感字段移出 `$fillable`
- 仅在特定 Service 中使用 `forceFill()` 并加日志审计
---
### 🟡 8. XSS 输出转义检查
**当前:** 消息内容 `content` 限制 500 字符 ✅,但需确认前端渲染时是否正确转义 HTML。
**需要检查的点:**
- [ ] 聊天消息在前端如何渲染?(`innerHTML` 还是 `textContent`?)
- [ ] 用户签名(sign)字段是否转义?
- [ ] 房间公告是否转义?
- [ ] 用户头像路径是否校验?(当前有基本校验)
**建议加固:**
- 前端渲染消息一律使用 `textContent` 或 Vue/React 自动转义
- 如果必须支持 HTML 表情/颜色,使用白名单 sanitizer(如 DOMPurify
---
### 🟡 9. 管理员操作审计加强
**当前:** 已有 `PositionAuthorityLog``AdminLog` 记录 ✅
**建议:**
- 所有金币/积分操作必须有完整的前后对比日志
- 敏感操作(封号、解封、改权限)推送微信通知给站长
---
### 🟡 10. 隐藏管理员入口路径
**当前:** `/lkddi` 作为管理员登录入口(隐藏路径),但路径硬编码在 `routes/web.php` 中。
**风险:** 任何能阅读源码或通过路径扫描的人都能发现。
**方案(可选):**
- 改为通过环境变量配置:`ADMIN_LOGIN_PATH=lkddi`
- 增加 IP 白名单限制:仅站长 IP 可访问 `/admin/*`
---
### 🟢 11. 其他安全改进
| 项目 | 说明 |
|------|------|
| CSP Header | 添加 Content-Security-Policy HTTP 头,限制脚本执行来源 |
| X-Frame-Options | 添加 DENY/SAMEORIGIN 防止点击劫持 |
| Reverb 消息大小上限 | 当前 10KB,建议根据业务适当降低 |
| 依赖安全扫描 | 定期运行 `composer audit` 检查 Laravel 及第三方包漏洞 |
| 文件上传安全 | 自定义头像上传已限制图片类型 ✅,但建议增加文件内容校验 |
---
## 二、访问速度优化(🔥高优 / ⚡中优 / 💡低优)
### 🔥 1. 启用 Laravel OPcache
**当前环境:** 通过 Laravel Herd 运行(PHP-FPM),未启用 OPcache。
**影响:** 每个 PHP 请求都要重新编译框架文件,浪费大量 CPU。
**方案(Mac/Linux 生产环境):**
```ini
; php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0
opcache.validate_timestamps=0 ; 生产环境关闭文件修改检查
```
---
### 🔥 2. 数据库查询优化
**当前状况:**
- 使用 Redis 缓存,但部分页面可能直接查询 MySQL
- 存在多个游戏(百家乐、赛马、彩票、五子棋等),每次查询都走数据库
**优化方案:**
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 排行榜缓存 | `->remember(60)` 缓存排行榜结果 60 秒 | 🔥 |
| 在线人数缓存 | 当前使用 Redis 实时维护 ✅,保持现状 | - |
| 房间列表缓存 | `Room::all()` 结果缓存到 Redis | 🔥 |
| 游戏配置缓存 | `GameConfig::isEnabled()` 结果缓存 10 秒 | ⚡ |
| 慢查询日志 | 启用 MySQL slow_query_log,定位慢 SQL | ⚡ |
---
### 🔥 3. 静态资源 CDN 加速
**当前:** 所有静态资源(CSS、JS、图片)直接从源服务器加载。
**方案:**
| 资源类型 | 方案 |
|----------|------|
| Vite 构建产物(CSS/JS | 上传到 CDN(阿里云 OSS+CDN / CloudFlare R2 |
| 头像图片 | 启用单独域名或 CDN,添加长期缓存头 |
| 聊天背景图 | 使用 CDN 分发 12 张背景图 |
| Reverb WS 连接 | 通过 CDN/反向代理(如 Nginx)代理 WSS 连接 |
**当前已做:** `.htaccess` 中已对 Vite 构建产物设置 31536000 秒缓存 ✅
**改进:** 将 `public/build/` 下的资源部署到 CDN。
---
### 🔥 4. Reverb WebSocket 优化
**当前:** Reverb 单节点运行,HTTP 协议,端口 8080。
**优化方案:**
| 措施 | 说明 |
|------|------|
| 升级为 WSS | 启用 TLS,避免被运营商劫持/限速 |
| 水平扩展 | 启用 Reverb Scaling(通过 Redis 发布订阅,见 `reverb.php` 配置) |
| Nginx 反向代理 | 用 Nginx 代理 WSS,可同时处理 HTTP 静态资源 |
| 心跳优化 | 当前 `ping_interval=60s`,可考虑适当延长 |
---
### ⚡ 5. Laravel 应用层优化
| 措施 | 说明 | 优先级 |
|------|------|--------|
| 路由缓存 | `php artisan route:cache` — 减少路由注册开销 | ⚡ |
| 配置缓存 | `php artisan config:cache` — 减少 config 加载 | ⚡ |
| 事件缓存 | `php artisan event:cache` — L12 原生支持 | ⚡ |
| 视图缓存 | `php artisan view:cache` — Blade 编译缓存 | ⚡ |
| 模型预加载 | 检查 N+1 查询,使用 `->with()` | ⚡ |
**⚠️ 注意:** `php artisan optimize` 已在 Laravel 12 中被移除,应单独执行以上四个命令。
---
### ⚡ 6. Redis 优化
**当前:** 单机单实例 Redis,承载 Session、Cache、Queue、Reverb Scaling 全部功能。
**建议:**
- 生产环境建议至少 2 个 Redis 实例:一个用于 Session/Cache(可随时清),一个用于 Queue(需持久化)
- Reverb Scaling 发布订阅建议单独连接
- 为 Redis 设置 `maxmemory``maxmemory-policy allkeys-lru` 防止内存溢出
---
### ⚡ 7. 前端加载优化
| 措施 | 说明 |
|------|------|
| JS 代码分割 | Vite 动态 import 拆分大 JS 文件 |
| 懒加载 | 游戏模块(百家乐、赛马等)按需加载 |
| 图片懒加载 | 头像、礼物图片使用 `loading="lazy"` |
| Alpine.js 轻量化 | 当前已使用 Alpine.js ✅ 但避免过多 watcher |
---
### 💡 8. 考虑 Laravel Octane(长期规划)
**说明:** Laravel OctaneSwoole / RoadRunner)将应用常驻内存,消除框架启动开销,可带来 10-30 倍并发性能提升。
**条件:** 需要确保代码无静态变量状态污染,适合用户量增长后的升级。
---
## 三、实施优先级建议
### 第一阶段(紧急 · 1-2 天)🔴🔥
| # | 任务 | 预估工时 |
|---|------|---------|
| 1 | 关闭 `APP_DEBUG` | 5 分钟 |
| 2 | 启用 `SESSION_ENCRYPT` | 5 分钟 |
| 3 | 限制 Reverb `allowed_origins` | 10 分钟 |
| 4 | 配置 Route/Config/Event/View 缓存 | 30 分钟 |
| 5 | 排行榜、房间列表等 Redis 缓存 | 1 小时 |
### 第二阶段(重要 · 3-5 天)🟡⚡
| # | 任务 | 预估工时 |
|---|------|---------|
| 6 | 敏感字段移出 `$fillable` | 1 小时 |
| 7 | 登录失败锁定 + 注册频率限制 | 2 小时 |
| 8 | 数据库慢查询分析与索引优化 | 2 小时 |
| 9 | 前端 JS 懒加载与代码分割 | 3 小时 |
| 10 | OPcache 配置 | 30 分钟 |
### 第三阶段(完善 · 1-2 周)🟢💡
| # | 任务 | 预估工时 |
|---|------|---------|
| 11 | Reverb WSS + Nginx 反向代理 | 2 小时 |
| 12 | 管理员 2FA 验证 | 4 小时 |
| 13 | CDN 部署静态资源 | 1 天 |
| 14 | Content-Security-Policy 等安全头 | 1 小时 |
| 15 | 生产环境 Redis 分实例部署 | 2 小时 |
| 16 | 评估 Laravel Octane 迁移 | 2-3 天 |
---
## 四、检查清单工具
部署到生产环境前可使用以下命令快速检查:
```bash
# Laravel 安全检查
php artisan about # 查看环境配置
php artisan route:list # 查看所有路由(确认无暴露的管理路径)
# Composer 安全审计
composer audit
# 缓存优化
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan view:cache
# 依赖更新
composer update --no-dev -o
```
---
> **总结:** 该项目整体架构设计良好(Redis + Reverb 实时通信 + Alpine.js 轻量前端),
> 主要安全短板集中在**生产环境配置**(DEBUG 未关、Session 未加密、WebSocket 无 TLS)和**部分敏感字段保护**。
> 速度优化则聚焦于**缓存策略**和**CDN 静态资源分发**。
>
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-20
View File
@@ -1,20 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Users/pllx/Web/Herd/chatroom/artisan",
"boost:mcp"
]
},
"herd": {
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
"args": [
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
],
"env": {
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
}
}
}
}
@@ -1,129 +0,0 @@
---
name: tailwindcss-development
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
-1004
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
# 🎮 聊天室游戏开发进度
> 更新时间:2026-03-04
---
## ✅ 已完成
### 🎲 百家乐(Baccarat
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`baccarat_rounds` + `baccarat_bets`
- **模型**`BaccaratRound` / `BaccaratBet`
- **队列 Job**`OpenBaccaratRoundJob` (开局) + `CloseBaccaratRoundJob` (摇骰结算)
- **事件**`BaccaratRoundOpened` / `BaccaratRoundSettled`PresenceChannel 广播)
- **控制器**`BaccaratController``/baccarat/current` / `/baccarat/bet` / `/baccarat/history`
- **前端**`chat/partials/baccarat-panel.blade.php`(倒计时/押注/骰子动画/趋势)
- **货币来源**`CurrencySource::BACCARAT_BET` / `BACCARAT_WIN`
- **后台配置**`game_configs` 表,管理员可配置开关/间隔/赔率/押注范围
### 🎰 老虎机(Slot Machine
- **类型**:玩家随时主动触发(即时游戏)
- **数据库**`slot_machine_logs`
- **模型**`SlotMachineLog`8种带权重图案、判奖逻辑)
- **控制器**`SlotMachineController``/slot/info` / `/slot/spin` / `/slot/history`
- **赔率**:三7×100(全服广播)/ 三钻×50 / 三同×10 / 两同×2 / 三骷髅诅咒(扣双倍)
- **聊天通知**:中奖发私信通知;三7全服公屏广播
- **前端**`chat/partials/slot-machine.blade.php`(三列滚轮动画/逐列停止/可拖动FAB)
- **货币来源**`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
- **后台配置**`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
### 📦 神秘箱子(Mystery Box
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
- **数据库**`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
- **模型**`MysteryBox` / `MysteryBoxClaim`
- **队列 Job**`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob/ `ExpireMysteryBoxJob`(到期处理)
- **控制器**`MysteryBoxController``/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
- **前端**`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
- **货币来源**`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
- **后台配置**`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
### 🐎 赛马竞猜(Horse Racing
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`horse_races` + `horse_bets`
- **模型**`HorseRace` / `HorseBet`
- **队列 Job**`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
- **事件**`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel 广播)
- **控制器**`HorseRaceController``/horse-race/current` / `/horse-race/bet` / `/horse-race/history`
- **广播**`horse.opened` / `horse.progress` / `horse.settled`
- **前端**`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB)
- **货币来源**`CurrencySource::HORSE_BET` / `HORSE_WIN`
- **后台配置**`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
### 🔮 神秘占卜(Fortune Telling
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
- **数据库**`fortune_logs`
- **模型**`FortuneLog`55+ 条签文内嵌在模型中)
- **控制器**`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
- **前端**`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB)
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
- **广播**:暂无实时广播(占卜结果仅展示给本人)
- **货币来源**`CurrencySource::FORTUNE_COST`
- **后台配置**`game_configs` 表,免费次数/额外消耗/各签概率均可配置
---
## 🕐 待开发
---
## 📌 通用待办(所有游戏共用)
- [x] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据(点击"加载实时统计"异步加载各游戏汇总卡片)
- [x] 各游戏历史记录在后台可查(管理员视角,新增 `/admin/game-history/` 路由组,支持百家乐/老虎机/赛马/神秘箱子/占卜各自的历史记录列表及详情页,含筛选分页)
- [x] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
---
## 🔧 已修复的 Bug
1. **百家乐广播频道**`Channel``PresenceChannel`,解决前端收不到 WebSocket 事件
2. **百家乐余额检查**`$user->gold``$user->jjb`(字段名错误)
3. **老虎机积分日志**:普通中奖/诅咒发私信通知;三7全服广播
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
8. **Alpine.js 初始化顺序**`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
10. **神秘箱子流水记录**`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
-256
View File
@@ -1,256 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.5
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- phpunit/phpunit (PHPUNIT) - v11
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
## Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
## Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
```
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
## Comments
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
## PHPDoc Blocks
- Add useful array shape type definitions when appropriate.
=== herd rules ===
# Laravel Herd
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
## Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
## Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines>
+8
View File
@@ -35,6 +35,9 @@ enum CurrencySource: string
/** 商城购买消耗(扣除金币) */
case SHOP_BUY = 'shop_buy';
/** 购买聊天室座驾消耗(扣除金币) */
case RIDE_BUY = 'ride_buy';
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
case ADMIN_ADJUST = 'admin_adjust';
@@ -158,6 +161,9 @@ enum CurrencySource: string
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -171,6 +177,7 @@ enum CurrencySource: string
self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买',
self::RIDE_BUY => '座驾购买',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到',
@@ -210,6 +217,7 @@ enum CurrencySource: string
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜谜活动奖励',
};
}
}
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐押注人数变化。
*/
class BaccaratPoolUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small,
'bet_count_triple' => $this->round->bet_count_triple,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐开局事件。
*/
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐结算结果。
*/
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
+14 -2
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
*/
class EffectBroadcast implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 支持的特效类型列表(用于校验)
*/
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
* @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言
* @param string|null $effectTitle 特效画面标题
* @param string|null $rideName 座驾名称
* @param string|null $effectUserInfo 特效画面用户身份信息
*/
public function __construct(
public readonly int $roomId,
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
public readonly string $operator,
public readonly ?string $targetUsername = null,
public readonly ?string $giftMessage = null,
public readonly ?string $effectTitle = null,
public readonly ?string $rideName = null,
public readonly ?string $effectUserInfo = null,
) {}
/**
@@ -70,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
'operator' => $this->operator,
'target_username' => $this->targetUsername, // null = 全员
'gift_message' => $this->giftMessage,
'effect_title' => $this->effectTitle,
'ride_name' => $this->rideName,
'effect_user_info' => $this->effectUserInfo,
];
}
}
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播赛马开局事件。
*/
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -58,6 +61,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
{
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
+6 -1
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间持续广播赛马进度。
*/
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function __construct(
public readonly int $raceId,
public readonly int $roomId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->roomId)];
}
/**
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
{
return [
'race_id' => $this->raceId,
'room_id' => $this->roomId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
+2 -1
View File
@@ -46,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -94,6 +94,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool,
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:猜谜活动答题结果广播事件
*
* 用户答对题目时广播,通知房间内所有用户结果。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播猜谜活动答题结果。
*/
class RiddleGameAnswered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造答题成功广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly int $roundId,
public readonly string $quizType,
public readonly string $answer,
public readonly string $winnerUsername,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_answer' => $this->answer,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'quiz_round_ended_id' => $this->roundId,
'answer' => $this->answer,
'winner_username' => $this->winnerUsername,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:猜谜活动开始广播事件
*
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播新一轮猜谜活动题目。
*/
class RiddleGameStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造新回合广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly string $quizType,
public readonly string $hint,
public readonly int $roundId,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_hint' => $this->hint,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'hint' => $this->hint,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:用户"拍一拍"广播事件
*
* 用户输入 /拍一拍 用户名 后触发,通过 WebSocket 广播给房间内所有用户,
* 前端显示 "XXX拍了拍XXX" 消息并触发屏幕抖动动画。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserPat implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $fromUser 拍人的用户
* @param string $targetUser 被拍的用户
* @param string $displayText 前端展示文本,如 "流星 拍了拍 张三"
*/
public function __construct(
public readonly int $roomId,
public readonly string $fromUser,
public readonly string $targetUser,
public readonly string $displayText,
public readonly ?string $fromUserHeadface = null,
) {}
/**
* 广播频道:向房间内所有在线用户推送
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'from_user' => $this->fromUser,
'target_user' => $this->targetUser,
'display_text' => $this->displayText,
'from_user_headface' => $this->fromUserHeadface,
];
}
}
@@ -14,14 +14,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateGameConfigParamsRequest;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\Room;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
*/
class GameConfigController extends Controller
{
/**
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
public function index(): View
{
$games = GameConfig::orderBy('id')->get();
$availableRooms = Room::query()->orderBy('id')->get();
return view('admin.game-configs.index', compact('games'));
return view('admin.game-configs.index', compact('games', 'availableRooms'));
}
/**
@@ -56,15 +63,19 @@ class GameConfigController extends Controller
*
* 接收前端提交的 params JSON 对象并合并至现有配置。
*/
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
public function updateParams(UpdateGameConfigParamsRequest $request, GameConfig $gameConfig, GameRoomScopeService $roomScopeService): RedirectResponse
{
$request->validate([
'params' => 'required|array',
]);
// 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params'));
// 这里不能只读取 validated('params')。
// 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数
// 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。
$validatedParams = (array) $request->input('params', []);
$updated = array_merge($current, $validatedParams);
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
$updated['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$updated['room_ids'] = $scopeConfig['room_ids'];
if ($gameConfig->game_key === 'mystery_box') {
$legacyMap = [
@@ -107,17 +118,19 @@ class GameConfigController extends Controller
}
// 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) {
$targetRoomId = app(GameRoomScopeService::class)->getPrimaryRoomIdForGame('mystery_box');
if (\App\Models\MysteryBox::currentOpenBox($targetRoomId)) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
]);
}
@@ -126,19 +139,31 @@ class GameConfigController extends Controller
*
* 仅在当前无进行中期次时生效,防止重复开期。
*/
public function openLotteryIssue(): JsonResponse
public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
}
if (LotteryIssue::currentIssue()) {
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
$openedRoomIds = [];
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
if (LotteryIssue::currentIssue($roomId)) {
continue;
}
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
$openedRoomIds[] = $roomId;
}
\App\Jobs\OpenLotteryIssueJob::dispatch();
if ($openedRoomIds === []) {
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
}
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
return response()->json([
'ok' => true,
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
]);
}
/**
@@ -0,0 +1,168 @@
<?php
/**
* 文件功能:猜谜活动题库后台管理控制器
*
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Riddle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:统一处理猜谜活动题库的后台管理动作。
*/
class RiddleController extends Controller
{
/**
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
*/
public function index(Request $request): View
{
$typeOptions = Riddle::typeOptions();
$selectedType = trim((string) $request->query('type', ''));
$keyword = trim((string) $request->query('keyword', ''));
$idiomQuery = Riddle::query();
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
// 题型筛选只接受系统支持值,避免非法参数污染查询。
$idiomQuery->ofType($selectedType);
}
if ($keyword !== '') {
// 关键词同时匹配答案与提示,方便后台快速定位题目。
$idiomQuery->where(function ($query) use ($keyword): void {
$query->where('answer', 'like', '%'.$keyword.'%')
->orWhere('hint', 'like', '%'.$keyword.'%');
});
}
$idioms = $idiomQuery
->orderBy('type')
->orderBy('sort')
->orderBy('id')
->get();
$typeStats = Riddle::query()
->selectRaw('type, COUNT(*) as total')
->groupBy('type')
->pluck('total', 'type')
->all();
return view('admin.riddles.index', [
'idioms' => $idioms,
'typeOptions' => $typeOptions,
'selectedType' => $selectedType,
'keyword' => $keyword,
'typeStats' => $typeStats,
]);
}
/**
* 方法功能:创建新的猜谜活动题目。
*/
public function store(Request $request): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 新增时默认启用,便于后台批量补题后立即可用。
$data['is_active'] = $request->boolean('is_active', true);
Riddle::create($data);
$typeLabel = Riddle::labelForType($data['type']);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "{$typeLabel}题目已添加!");
}
/**
* 方法功能:更新已有题目内容与题型。
*/
public function update(Request $request, Riddle $idiom): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 编辑时显式按复选框结果落库,避免旧状态残留。
$data['is_active'] = $request->boolean('is_active');
$idiom->update($data);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$idiom->answer}」已更新!");
}
/**
* 方法功能:通过 AJAX 切换题目的启用状态。
*/
public function toggle(Riddle $idiom): JsonResponse
{
// 开关按钮只变更启用状态,不改动其他题库字段。
$idiom->update(['is_active' => ! $idiom->is_active]);
return response()->json([
'ok' => true,
'is_active' => $idiom->is_active,
'message' => $idiom->is_active ? "{$idiom->answer}」已启用" : "{$idiom->answer}」已禁用",
]);
}
/**
* 方法功能:删除指定题目。
*/
public function destroy(Request $request, Riddle $idiom): RedirectResponse
{
$answer = $idiom->answer;
$idiom->delete();
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$answer}」已删除!");
}
/**
* 方法功能:校验后台题库保存载荷。
*
* @return array{type:string,answer:string,hint:string,sort:int}
*/
private function validateRiddlePayload(Request $request): array
{
return $request->validate([
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
'answer' => ['required', 'string', 'max:120'],
'hint' => ['required', 'string', 'max:255'],
'sort' => ['required', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
}
/**
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
*
* @return array<string, string>
*/
private function buildIndexFilters(Request $request): array
{
$filters = [];
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
if ($type !== '') {
$filters['type'] = $type;
}
if ($keyword !== '') {
$filters['keyword'] = $keyword;
}
return $filters;
}
}
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:后台座驾独立管理控制器。
*
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRideRequest;
use App\Http\Requests\UpdateRideRequest;
use App\Models\Ride;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 后台座驾管理控制器
* 负责独立 rides 表的后台管理流程。
*/
class RideController extends Controller
{
/**
* 显示座驾管理列表页。
*/
public function index(): View
{
$rides = Ride::query()
->orderBy('sort_order')
->orderBy('id')
->get();
return view('admin.rides.index', compact('rides'));
}
/**
* 新增座驾(仅 id=1 超级站长)。
*/
public function store(StoreRideRequest $request): RedirectResponse
{
$data = $request->validated();
Ride::create($data);
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!');
}
/**
* 更新座驾信息。
*/
public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse
{
$ride->update($request->validated());
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!');
}
/**
* 切换座驾上下架状态。
*/
public function toggle(Ride $ride): RedirectResponse
{
$ride->update(['is_active' => ! $ride->is_active]);
$status = $ride->is_active ? '上架' : '下架';
return redirect()->route('admin.rides.index')->with('success', "{$ride->name}」已{$status}");
}
/**
* 删除座驾(仅 id=1 超级站长)。
*/
public function destroy(Ride $ride): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$name = $ride->name;
$ride->delete();
return redirect()->route('admin.rides.index')->with('success', "{$name}」已删除。");
}
}
@@ -14,9 +14,10 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreShopItemRequest;
use App\Http\Requests\UpdateShopItemRequest;
use App\Models\ShopItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
/**
* 新增商品(仅 id=1 超级站长)
*/
public function store(Request $request): RedirectResponse
public function store(StoreShopItemRequest $request): RedirectResponse
{
abort_unless(Auth::id() === 1, 403);
$data = $this->validateItem($request);
$data = $request->validated();
ShopItem::create($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
*
* @param ShopItem $shopItem 路由模型自动注入
*/
public function update(Request $request, ShopItem $shopItem): RedirectResponse
public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
{
$data = $this->validateItem($request, $shopItem);
$data = $request->validated();
$shopItem->update($data);
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
return redirect()->route('admin.shop.index')->with('success', "{$name}」已删除。");
}
/**
* 统一验证商品表单(新增/编辑共用)
*
* @return array<string, mixed>
*/
private function validateItem(Request $request, ?ShopItem $item = null): array
{
return $request->validate([
'name' => 'required|string|max:100',
'slug' => ['required', 'string', 'max:100',
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
],
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
'charm_bonus' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
}
}
@@ -524,6 +524,13 @@ class AdminCommandController extends Controller
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '📢 公屏公告',
'message' => strip_tags($content),
'icon' => '📢',
'color' => '#b91c1c',
'duration' => 10000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
+20 -2
View File
@@ -20,16 +20,21 @@ use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供百家乐当前局查询、下注与历史接口。
*/
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -38,7 +43,13 @@ class BaccaratController extends Controller
public function currentRound(Request $request): JsonResponse
{
$user = $request->user();
$round = BaccaratRound::currentRound();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$round = BaccaratRound::currentRound($roomId);
if (! $round) {
return response()->json([
@@ -98,6 +109,11 @@ class BaccaratController extends Controller
'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启百家乐。'], 403);
}
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -109,7 +125,7 @@ class BaccaratController extends Controller
$round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) {
if (! $round || (int) $round->room_id !== $roomId || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -212,7 +228,9 @@ class BaccaratController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$rounds = BaccaratRound::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
+159 -28
View File
@@ -28,6 +28,7 @@ use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RideService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
@@ -65,6 +66,7 @@ class ChatController extends Controller
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService,
private readonly RideService $rideService,
) {}
/**
@@ -116,6 +118,8 @@ class ChatController extends Controller
// 3. 广播和初始化欢迎(仅限初次进入)
$newbieEffect = null;
$initialRideEffect = null;
$initialRideEffectOptions = null;
$initialPresenceTheme = null;
$initialWelcomeMessage = null;
$initialWelcomeMessages = [];
@@ -192,40 +196,84 @@ class ChatController extends Controller
}
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
if (! $ridePresencePayload) {
[$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' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'entry_broadcast',
'sent_at' => now()->toDateTimeString(),
];
$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' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'entry_broadcast',
'sent_at' => now()->toDateTimeString(),
];
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
}
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$initialWelcomeMessages[] = $generalWelcomeMsg;
if ($ridePresencePayload) {
$rideWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '座驾播报',
'to_user' => '大家',
'content' => "<span style=\"color:#0f766e;font-weight:bold;\">{$ridePresencePayload['ride_icon']} {$ridePresencePayload['identity_text']} · {$ridePresencePayload['welcome_text']}</span>",
'is_secret' => false,
'font_color' => '#0f766e',
'action' => 'ride_presence',
'welcome_user' => $user->username,
'welcome_kind' => 'ride_presence',
'ride_key' => $ridePresencePayload['ride_key'],
'ride_name' => $ridePresencePayload['ride_name'],
'effect_title' => $ridePresencePayload['effect_title'],
'effect_user_info' => $ridePresencePayload['effect_user_info'],
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
$this->chatState->pushMessage($id, $rideWelcomeMsg);
broadcast(new MessageSent($id, $rideWelcomeMsg));
broadcast(new \App\Events\EffectBroadcast(
$id,
$ridePresencePayload['ride_key'],
$user->username,
effectTitle: $ridePresencePayload['effect_title'],
rideName: $ridePresencePayload['ride_name'],
effectUserInfo: $ridePresencePayload['effect_user_info'],
))->toOthers();
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
$initialRideEffect = $ridePresencePayload['ride_key'];
$initialRideEffectOptions = [
'effect_title' => $ridePresencePayload['effect_title'],
'effect_user_info' => $ridePresencePayload['effect_user_info'],
'ride_name' => $ridePresencePayload['ride_name'],
'operator' => $user->username,
];
$initialWelcomeMessages[] = $rideWelcomeMsg;
}
}
@@ -314,6 +362,8 @@ class ChatController extends Controller
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'initialRideEffect' => $initialRideEffect,
'initialRideEffectOptions' => $initialRideEffectOptions,
'initialPresenceTheme' => $initialPresenceTheme,
'initialWelcomeMessage' => $initialWelcomeMessage,
'initialWelcomeMessages' => $initialWelcomeMessages,
@@ -449,6 +499,17 @@ class ChatController extends Controller
$messageData = array_merge($messageData, $imagePayload);
}
// 欢迎动作:增加右下角弹窗通知(内容含发送者信息)
if (($data['action'] ?? '') === '欢迎') {
$messageData['toast_notification'] = [
'title' => '👋 欢迎',
'message' => strip_tags($pureContent),
'icon' => '👋',
'color' => '#e11d48',
'duration' => 8000,
];
}
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations);
@@ -1404,6 +1465,76 @@ class ChatController extends Controller
return null;
}
/**
* 拍一拍:用户通过 /拍一拍 命令向所选对象发送拍一拍通知。
*/
public function pat(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再使用拍一拍。')) {
return $response;
}
// 0. 检查用户是否被禁言
$muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) {
$ttl = Redis::ttl($muteKey);
$minutes = ceil($ttl / 60);
return response()->json([
'status' => 'error',
'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。",
], 403);
}
$targetUser = $request->input('target_user', '');
if (empty($targetUser) || $targetUser === '大家') {
return response()->json([
'status' => 'error',
'message' => '请选择一个聊天对象(不能为大家)进行拍一拍。',
], 422);
}
// 检查目标是否在线
$isOnline = Redis::hexists("room:{$id}:users", $targetUser);
if (! $isOnline) {
return response()->json([
'status' => 'error',
'message' => "{$targetUser}】目前已离开聊天室或不在线。",
], 200);
}
// 不能拍自己
if ($targetUser === $user->username) {
return response()->json([
'status' => 'error',
'message' => '不能拍自己哦~',
], 422);
}
// 获取发送者头像
$headface = $user->usersf ?: '1.gif';
$headSrc = str_starts_with($headface, 'storage/') ? '/'.$headface : '/images/headface/'.$headface;
// 构造展示文本
$displayText = "{$user->username} 拍了拍 {$targetUser}";
// 广播到房间
broadcast(new \App\Events\UserPat(
roomId: $id,
fromUser: $user->username,
targetUser: $targetUser,
displayText: $displayText,
fromUserHeadface: $headSrc,
));
return response()->json([
'status' => 'success',
'message' => $displayText,
]);
}
/**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/
@@ -185,9 +185,10 @@ class DailySignInController extends Controller
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
}
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
+7 -1
View File
@@ -43,6 +43,11 @@ class EarnController extends Controller
*/
public function claimVideoReward(Request $request): JsonResponse
{
return response()->json([
'success' => false,
'message' => '看视频赚钱功能已关闭。',
]);
/** @var User $user */
$user = Auth::user();
@@ -99,9 +104,10 @@ class EarnController extends Controller
// 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;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [
+93 -17
View File
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\FishingService;
use App\Services\GameRoomScopeService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
/**
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
*/
class FishingController extends Controller
{
/**
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
private readonly FishingService $fishingService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -63,6 +72,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
@@ -75,6 +88,14 @@ class FishingController extends Controller
], 429);
}
$tokenKey = "fishing:token:{$user->id}";
if (Redis::exists($tokenKey)) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
}
// 2. 检查金币是否足够
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) {
@@ -84,34 +105,54 @@ class FishingController extends Controller
], 422);
}
// 3. 扣除金币
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
// 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
$tokenTtl = $waitTime + 13;
$tokenPayload = json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]));
]);
// 5. 生成随机浮漂坐标(百分比,避开边缘)
// 原子占用本次抛竿 token,避免多标签页自动钓鱼互相覆盖令牌。
$reserved = Redis::command('set', [$tokenKey, $tokenPayload, 'EX', $tokenTtl, 'NX']);
if (! $reserved) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
return response()->json([
'status' => 'error',
'message' => '钓鱼状态同步中,请稍后重试。',
'retry_after' => 3,
], 409);
}
try {
// token 占用成功后才扣金币,确保重复抛竿不会多扣费用。
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
} catch (\Throwable $exception) {
// 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。
Redis::del($tokenKey);
throw $exception;
}
// 4. 生成随机浮漂坐标(百分比,避开边缘)
$bobberX = rand(15, 85); // 左右 15%~85%
$bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡
// 5. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
@@ -128,6 +169,37 @@ class FishingController extends Controller
]);
}
/**
* 恢复已有钓鱼会话,避免刷新页面后丢失前端内存里的收竿令牌。
*/
private function restoreActiveFishingSessionResponse(User $user, string $tokenKey): ?JsonResponse
{
$stored = json_decode((string) Redis::get($tokenKey), true);
if (! is_array($stored) || empty($stored['token'])) {
Redis::del($tokenKey);
return null;
}
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
$waitTime = max(0, (int) ($stored['wait_time'] ?? 0) - $elapsed);
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
'status' => 'success',
'message' => '已恢复正在进行的钓鱼,请等待本次收竿。',
'wait_time' => $waitTime,
'bobber_x' => rand(15, 85),
'bobber_y' => rand(20, 65),
'token' => (string) $stored['token'],
'auto_fishing' => $autoFishingMinutes > 0,
'auto_fishing_minutes_left' => $autoFishingMinutes,
'cost' => 0,
'jjb' => $user->jjb,
'restored' => true,
]);
}
/**
* 收竿 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
*
@@ -142,6 +214,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:提供神秘占卜状态、抽签和历史接口。
*/
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘占卜。'], 403);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['history' => []]);
}
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理五子棋创建、加入与对局过程接口。
*/
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -58,6 +63,10 @@ class GomokuController extends Controller
$user = $request->user();
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
}
// PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
+139 -6
View File
@@ -23,15 +23,23 @@ use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:赛马竞猜前台控制器
*
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
*/
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,7 +52,12 @@ class HorseRaceController extends Controller
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$race = HorseRace::currentRace();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['race' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$race = $this->resolveCurrentRaceState(HorseRace::currentRace($roomId));
if (! $race) {
return response()->json([
@@ -139,6 +152,11 @@ class HorseRaceController extends Controller
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启赛马竞猜。'], 403);
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -150,7 +168,7 @@ class HorseRaceController extends Controller
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
if (! $race || (int) $race->room_id !== $roomId || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -207,8 +225,8 @@ class HorseRaceController extends Controller
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -217,8 +235,8 @@ class HorseRaceController extends Controller
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
event(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
@@ -235,7 +253,9 @@ class HorseRaceController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$races = HorseRace::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -264,6 +284,119 @@ class HorseRaceController extends Controller
return response()->json(['history' => $history]);
}
/**
* 自愈当前场次状态,避免线上遗漏结算时长期卡在 running。
*/
private function resolveCurrentRaceState(?HorseRace $race): ?HorseRace
{
if (! $race || $race->status !== 'running') {
return $race;
}
if (! $this->shouldRecoverStaleRunningRace($race)) {
return $race;
}
$race = $this->prepareRunningRaceForSettlement($race);
if (! $race || $race->status !== 'running' || ! $race->winner_horse_id) {
return $race;
}
// 线上若漏消费 CloseHorseRaceJob,这里同步补做一次结算,避免界面一直显示“跑马中”。
app()->call([new \App\Jobs\CloseHorseRaceJob($race), 'handle']);
return HorseRace::currentRace((int) $race->room_id);
}
/**
* 判断 running 场次是否已经超过合理比赛时长,需要请求侧补偿收尾。
*/
private function shouldRecoverStaleRunningRace(HorseRace $race): bool
{
if (! $race->race_starts_at) {
return false;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = max(1, (int) ($config['race_duration'] ?? 30));
$recoveryGraceSeconds = 5;
return $race->race_starts_at->lte(now()->subSeconds($raceDuration + $recoveryGraceSeconds));
}
/**
* 为超时 running 场次补齐缺失赛果字段,确保后续结算任务可以安全执行。
*/
private function prepareRunningRaceForSettlement(HorseRace $race): ?HorseRace
{
if ($race->winner_horse_id && $race->race_ends_at) {
return $race->fresh();
}
$horses = $this->normalizeRaceHorses($race->horses);
$winnerHorseId = $race->winner_horse_id ?: $this->resolveStaleRunningWinnerId($race, $horses);
if (! $winnerHorseId) {
return $race;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$seedPool = (int) ($config['seed_pool'] ?? 0);
// 线上补偿场景下以当前下注快照补齐统计,确保本次请求内的结算口径与正常流程一致。
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = $seedPool + (int) HorseBet::query()->where('race_id', $race->id)->sum('amount');
HorseRace::query()
->where('id', $race->id)
->where('status', 'running')
->update([
'winner_horse_id' => $winnerHorseId,
'race_ends_at' => $race->race_ends_at ?? now(),
'total_bets' => $totalBets,
'total_pool' => $totalPool,
]);
return $race->fresh();
}
/**
* 为异常滞留的 running 场次推导一个稳定冠军,避免多次请求得到不同结算结果。
*
* @param array<int, array{id:int,name:string,emoji:string}> $horses
*/
private function resolveStaleRunningWinnerId(HorseRace $race, array $horses): ?int
{
if ($horses === []) {
return null;
}
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->map(fn ($pool) => (int) $pool)
->toArray();
$candidateIds = array_map(
fn (array $horse): int => (int) $horse['id'],
$horses,
);
usort($candidateIds, function (int $leftId, int $rightId) use ($horsePools): int {
$leftPool = (int) ($horsePools[$leftId] ?? 0);
$rightPool = (int) ($horsePools[$rightId] ?? 0);
if ($leftPool === $rightPool) {
return $leftId <=> $rightId;
}
return $rightPool <=> $leftPool;
});
return $candidateIds[0] ?? null;
}
/**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
*
+20 -2
View File
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:提供双色球当前期、购票和历史记录接口。
*/
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
public function current(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['enabled' => false]);
}
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['enabled' => false, 'message' => '当前房间未开启双色球彩票。'], 403);
}
$issue = LotteryIssue::currentIssue($roomId) ?? LotteryIssue::latestIssue($roomId);
if (! $issue) {
return response()->json(['enabled' => true, 'issue' => null]);
@@ -90,6 +101,11 @@ class LotteryController extends Controller
*/
public function buy(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启双色球彩票。'], 403);
}
$request->validate([
'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3',
@@ -132,7 +148,9 @@ class LotteryController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
$issues = LotteryIssue::query()
->where('room_id', $roomId)
->where('status', 'settled')
->latest()
->limit(20)
+25 -11
View File
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
*/
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
public function status(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox($roomId);
if (! $box) {
return response()->json(['active' => false]);
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
}
$user = $request->user();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
return DB::transaction(function () use ($user, $passcode): JsonResponse {
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘箱子。'], 403);
}
return DB::transaction(function () use ($user, $passcode, $roomId): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query()
->where('room_id', $roomId)
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【神秘箱子】开箱播报:恭喜{$username} 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$content = "{$emoji}{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【神秘箱子】《黑化陷阱》haha{$username} 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$content = "☠️ {$username}踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,267 @@
<?php
/**
* 文件功能:猜谜活动控制器
*
* 负责兼容现有 idiom-quiz 路由,同时支持猜成语与脑筋急转弯
* 两类题型的开题、答题与当前回合查询。
*/
namespace App\Http\Controllers;
use App\Events\RiddleGameAnswered;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Services\RiddleGameService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理猜谜活动开题、答题和当前回合读取。
*/
class RiddleQuizController extends Controller
{
/**
* 方法功能:注入猜谜活动所需的服务。
*/
public function __construct(
private readonly RiddleGameService $riddleGameService,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 方法功能:管理员手动为指定房间与题型发起一轮猜谜活动。
*/
public function start(Request $request): JsonResponse
{
$user = Auth::user();
// 仅站长或具备后台职务的管理用户可手动开题。
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
}
$roomId = (int) $request->input('room_id', 0);
// 兼容后台新字段 quiz_type 与旧字段 type,两边都允许触发手动出题。
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 猜谜活动总开关关闭时,直接返回明确提示,避免误报成“题库为空”。
if (! GameConfig::isEnabled(Riddle::TYPE_IDIOM)) {
return response()->json([
'status' => 'error',
'message' => '猜谜活动未开启,请先到游戏管理中开启后再出题。',
], 400);
}
// 后台手动出题允许覆盖当前同题型回合,避免管理员还要先人工结束上一题。
$this->riddleGameService->endActiveRoundsForRoom($roomId, $quizType);
$round = $this->riddleGameService->startRound($roomId, $quizType);
if (! $round) {
if (! $this->riddleGameService->pickRandomQuestion($quizType)) {
return response()->json(['status' => 'error', 'message' => '当前题型题库中没有可用题目,请先在后台添加。'], 400);
}
return response()->json(['status' => 'error', 'message' => '当前题型暂时无法出题,请检查游戏配置与参与房间设置。'], 400);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:提交当前猜谜活动回合的答案。
*/
public function answer(Request $request): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$roundId = (int) $request->input('round_id');
$roomId = (int) $request->input('room_id');
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
$userAnswer = trim((string) $request->input('answer', ''));
if ($roundId <= 0 || $roomId <= 0 || $userAnswer === '') {
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
}
$round = RiddleGameRound::with('idiom')->find($roundId);
if (! $round || $round->room_id !== $roomId || $round->quiz_type !== $quizType) {
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
// 判题前先做超时结算,避免用户继续抢答无效回合。
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 答案对比忽略空格与大小写,减少正常输入误判。
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', (string) $round->idiom?->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
return response()->json([
'status' => 'error',
'message' => '答案不正确,再想想!',
]);
}
$lockKey = "riddle:answer_lock:{$roundId}";
if (! Redis::setnx($lockKey, 1)) {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
Redis::expire($lockKey, 10);
// 抢答成功后立即封盘,确保并发请求读到统一状态。
$round->update([
'status' => 'answered',
'winner_id' => $user->id,
'winner_username' => $user->username,
'ended_at' => now(),
]);
if ($round->reward_gold > 0) {
$this->currencyService->change(
$user,
'gold',
$round->reward_gold,
\App\Enums\CurrencySource::GAME_REWARD,
$this->riddleGameService->buildRewardDescription($round),
$roomId,
);
}
if ($round->reward_exp > 0) {
// 经验奖励仍沿用现有字段,避免引入额外奖励服务改动。
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
$user->save();
}
broadcast(new RiddleGameAnswered(
roomId: $roomId,
roundId: $round->id,
quizType: $round->quiz_type,
answer: (string) $round->idiom?->answer,
winnerUsername: $user->username,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$quizTypeLabel = $this->riddleGameService->getQuizTypeLabel($round->quiz_type);
$resultMsg = [
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🎉 【猜谜活动·{$quizTypeLabel}{$user->username} 率先答对「{$round->idiom?->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => 'idiom_result',
'winner_username' => $user->username,
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'quiz_answer' => (string) $round->idiom?->answer,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'idiom_answer' => (string) $round->idiom?->answer,
'idiom_result_reward_gold' => $round->reward_gold,
'idiom_result_reward_exp' => $round->reward_exp,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $resultMsg);
Redis::del($lockKey);
return response()->json([
'status' => 'success',
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'answer' => (string) $round->idiom?->answer,
'quiz_answer' => (string) $round->idiom?->answer,
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:查询当前房间指定题型的进行中回合。
*/
public function current(Request $request): JsonResponse
{
$roomId = (int) $request->input('room_id', 0);
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
$round = $this->riddleGameService->findActiveRound($roomId, $quizType);
if (! $round) {
return response()->json(['status' => 'success', 'data' => null]);
}
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* 文件功能:聊天室座驾前台接口控制器。
*
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
*/
namespace App\Http\Controllers;
use App\Http\Requests\BuyRideRequest;
use App\Models\Ride;
use App\Models\Room;
use App\Services\ChatStateService;
use App\Services\RideService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* 聊天室座驾控制器
* 负责前台座驾页面的数据读取与购买操作。
*/
class RideController extends Controller
{
/**
* 构造座驾控制器依赖。
*/
public function __construct(
private readonly RideService $rideService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取座驾页面需要的商品、当前座驾和购买记录。
*/
public function items(): JsonResponse
{
$user = Auth::user();
return response()->json([
'items' => $this->rideService->activeItems()
->map(fn (Ride $item) => $this->rideService->formatItem($item))
->values(),
'current_ride' => $this->rideService->formatCurrentRide($user),
'purchases' => $this->rideService->purchaseRecords($user),
'user_jjb' => $user->jjb ?? 0,
]);
}
/**
* 购买座驾并返回最新金币和当前座驾状态。
*/
public function buy(BuyRideRequest $request): JsonResponse
{
$user = Auth::user();
$roomId = (int) $request->integer('room_id');
$room = Room::query()->findOrFail($roomId);
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403);
}
$item = Ride::query()->findOrFail((int) $request->integer('item_id'));
$result = $this->rideService->buy($user, $item, $roomId);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
return response()->json([
'status' => 'success',
'message' => $result['message'],
'current_ride' => $result['current_ride'] ?? null,
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
'jjb' => $user->fresh()->jjb,
]);
}
}
+17 -13
View File
@@ -40,19 +40,23 @@ class ShopController extends Controller
public function items(): JsonResponse
{
$user = Auth::user();
$items = ShopItem::active()->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'type' => $item->type,
'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
]);
$items = ShopItem::query()
->where('is_active', true)
->orderBy('sort_order')
->get()
->map(fn ($item) => [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'type' => $item->type,
'duration_days' => $item->duration_days,
'duration_minutes' => $item->duration_minutes,
'intimacy_bonus' => $item->intimacy_bonus,
'charm_bonus' => $item->charm_bonus,
]);
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
+33 -13
View File
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\SlotMachineLog;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供老虎机信息查询、转动和个人记录接口。
*/
class SlotMachineController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['enabled' => false]);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启老虎机。'], 403);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100);
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -100,7 +115,7 @@ class SlotMachineController extends Controller
}
}
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
return DB::transaction(function () use ($user, $cost, $config, $roomId): JsonResponse {
// ① 扣费
$this->currency->change(
$user,
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
if ($resultType === 'jackpot') {
// 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost);
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
} elseif ($resultType === 'curse') {
// 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
}
$user->refresh();
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['history' => []]);
}
$logs = SlotMachineLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
/**
* 三个7全服公屏广播。
*/
private function broadcastJackpot(string $username, int $payout, int $cost): void
private function broadcastJackpot(string $username, int $payout, int $cost, int $roomId): void
{
$net = $payout - $cost;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
* @param string $toUsername 接收用户名
* @param string $content 消息内容
*/
private function broadcastPersonal(string $toUsername, string $content): void
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => $toUsername,
'content' => $content,
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
broadcast(new MessageSent(1, $msg));
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+20 -2
View File
@@ -303,19 +303,37 @@ class UserController extends Controller
}
/**
* 保存聊天室屏蔽禁音偏好。
* 保存聊天室屏蔽禁音与字号偏好。
*/
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : [];
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
->map(function (string $sender): string {
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
return $sender === '猜谜活动' ? '猜成语' : $sender;
})
->unique()
->values()
->all();
$preferences = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
'blocked_system_senders' => $blockedSystemSenders,
'sound_muted' => (bool) $data['sound_muted'],
];
// 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。
$fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null
? (int) $data['font_size']
: ($existingPreferences['font_size'] ?? null);
if ($fontSize !== null) {
$preferences['font_size'] = (int) $fontSize;
}
$user->update([
'chat_preferences' => $preferences,
]);
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:前台座驾购买请求验证。
*
* 校验用户购买座驾时传入的座驾与房间上下文,避免未进房直接购买聊天室座驾。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 座驾购买请求
* 负责校验座驾 ID 与当前房间 ID。
*/
class BuyRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许购买座驾。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取座驾购买请求验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'item_id' => ['required', 'integer', 'exists:rides,id'],
'room_id' => ['required', 'integer', 'exists:rooms,id'],
];
}
/**
* 获取座驾购买请求中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'item_id.required' => '请选择要购买的座驾。',
'item_id.exists' => '座驾不存在或已被删除。',
'room_id.required' => '请先进入聊天室后再购买座驾。',
'room_id.exists' => '当前房间不存在。',
];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:后台新增座驾请求验证。
*
* 校验座驾独立模块的名称、特效 key、价格、使用天数、欢迎语和上下架状态。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台新增座驾请求
* 负责新增座驾时的权限与字段校验。
*/
class StoreRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许新增座驾。
*/
public function authorize(): bool
{
return $this->user()?->id === 1;
}
/**
* 获取新增座驾验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')],
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'duration_days' => ['required', 'integer', 'min:1'],
'welcome_message' => ['nullable', 'string', 'max:255'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取新增座驾中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:后台新增商店商品请求验证。
*
* 统一校验商店商品字段。
*/
namespace App\Http\Requests;
use App\Models\ShopItem;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台新增商店商品请求
* 负责新增商品时的权限与字段校验。
*/
class StoreShopItemRequest extends FormRequest
{
/**
* 判断当前用户是否允许新增商品。
*/
public function authorize(): bool
{
return $this->user()?->id === 1;
}
/**
* 获取新增商品验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'type' => ['required', Rule::in($this->allowedTypes())],
'duration_days' => ['nullable', 'integer', 'min:0'],
'duration_minutes' => ['nullable', 'integer', 'min:0'],
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
'charm_bonus' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取允许后台配置的商品类型。
*
* @return array<int, string>
*/
protected function allowedTypes(): array
{
return [
'instant',
'duration',
'one_time',
'ring',
'auto_fishing',
ShopItem::TYPE_SIGN_REPAIR,
'msg_bubble',
'msg_name_color',
'msg_text_color',
'avatar_frame',
];
}
}
@@ -2,7 +2,7 @@
/**
* 文件功能:聊天室偏好设置验证器
* 负责校验用户提交的屏蔽播报禁音配置。
* 负责校验用户提交的屏蔽播报禁音与聊天室字号配置。
*/
namespace App\Http\Requests;
@@ -12,7 +12,7 @@ use Illuminate\Validation\Rule;
/**
* 聊天室偏好设置验证器
* 仅允许提交白名单内的屏蔽项布尔型禁音状态。
* 仅允许提交白名单内的屏蔽项布尔型禁音状态与合法字号
*/
class UpdateChatPreferencesRequest extends FormRequest
{
@@ -35,9 +35,10 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [
'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票']),
],
'sound_muted' => ['required', 'boolean'],
'font_size' => ['nullable', 'integer', 'min:10', 'max:30'],
];
}
@@ -53,6 +54,9 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
'sound_muted.required' => '请传入禁音状态。',
'sound_muted.boolean' => '禁音状态格式无效。',
'font_size.integer' => '聊天室字号格式无效。',
'font_size.min' => '聊天室字号不能小于 10。',
'font_size.max' => '聊天室字号不能大于 30。',
];
}
}
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:保存游戏参数请求校验
*
* 统一校验后台“游戏管理”页提交的 params 结构,
* 并在所有游戏共用的房间范围字段上执行归一化。
*/
namespace App\Http\Requests;
use App\Services\GameRoomScopeService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:约束后台游戏参数保存请求的公共结构。
*/
class UpdateGameConfigParamsRequest extends FormRequest
{
/**
* 判断当前请求是否允许执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'params' => ['required', 'array'],
'params.room_scope_mode' => ['nullable', 'in:all,single,multiple'],
'params.room_ids' => ['nullable', 'array'],
'params.room_ids.*' => ['integer', 'exists:rooms,id'],
];
}
/**
* 自定义错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'params.required' => '缺少游戏参数数据。',
'params.array' => '游戏参数格式无效。',
'params.room_scope_mode.in' => '参与房间模式无效。',
'params.room_ids.array' => '参与房间列表格式无效。',
'params.room_ids.*.integer' => '参与房间编号格式无效。',
'params.room_ids.*.exists' => '所选房间不存在,请刷新页面后重试。',
];
}
/**
* 在校验前先把房间范围字段归一化,兼容单值与旧字段。
*/
protected function prepareForValidation(): void
{
$params = (array) $this->input('params', []);
$roomScopeService = app(GameRoomScopeService::class);
$scopeConfig = $roomScopeService->getScopeConfigForParams($params);
$params['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$params['room_ids'] = $scopeConfig['room_ids'];
$this->merge([
'params' => $params,
]);
}
/**
* 校验通过后补充“单选/多选至少选择一个房间”的约束。
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$params = (array) $this->input('params', []);
$roomMode = (string) ($params['room_scope_mode'] ?? GameRoomScopeService::MODE_SINGLE);
$roomIds = (array) ($params['room_ids'] ?? []);
if (in_array($roomMode, [GameRoomScopeService::MODE_SINGLE, GameRoomScopeService::MODE_MULTIPLE], true) && $roomIds === []) {
$validator->errors()->add('params.room_ids', '单选/多选房间模式下,请至少选择一个房间。');
}
if ($roomMode === GameRoomScopeService::MODE_SINGLE && count($roomIds) > 1) {
$validator->errors()->add('params.room_ids', '单选房间模式下只能选择一个房间。');
}
});
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:后台更新座驾请求验证。
*
* 校验座驾编辑时的唯一标识、价格、使用天数和欢迎语配置。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台更新座驾请求
* 负责编辑座驾时的权限与字段校验。
*/
class UpdateRideRequest extends FormRequest
{
/**
* 判断当前用户是否允许编辑座驾。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取更新座驾验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$ride = $this->route('ride');
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')->ignore($ride?->id)],
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')->ignore($ride?->id)],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'duration_days' => ['required', 'integer', 'min:1'],
'welcome_message' => ['nullable', 'string', 'max:255'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取更新座驾中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
'duration_days.min' => '使用天数至少为 1 天。',
];
}
}
@@ -0,0 +1,75 @@
<?php
/**
* 文件功能:后台更新商店商品请求验证。
*
* 统一校验商店商品编辑字段。
*/
namespace App\Http\Requests;
use App\Models\ShopItem;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 后台更新商店商品请求
* 负责编辑商品时的权限与字段校验。
*/
class UpdateShopItemRequest extends FormRequest
{
/**
* 判断当前用户是否允许编辑商品。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取更新商品验证规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$shopItem = $this->route('shopItem');
return [
'name' => ['required', 'string', 'max:100'],
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')->ignore($shopItem?->id)],
'icon' => ['required', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'price' => ['required', 'integer', 'min:0'],
'type' => ['required', Rule::in($this->allowedTypes())],
'duration_days' => ['nullable', 'integer', 'min:0'],
'duration_minutes' => ['nullable', 'integer', 'min:0'],
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
'charm_bonus' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* 获取允许后台配置的商品类型。
*
* @return array<int, string>
*/
protected function allowedTypes(): array
{
return [
'instant',
'duration',
'one_time',
'ring',
'auto_fishing',
ShopItem::TYPE_SIGN_REPAIR,
'msg_bubble',
'msg_name_color',
'msg_text_color',
'avatar_frame',
];
}
}
+3 -8
View File
@@ -290,12 +290,8 @@ class AiBaccaratBetJob implements ShouldQueue
*/
private function broadcastPassMessage(User $user, int $roomId, string $reason): void
{
if (empty($reason)) {
$reason = '风大雨大,保本最大,这把我决定观望一下!';
}
$chatState = app(ChatStateService::class);
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 本局选择挂机观望:✨ <br/><span style='color:#666;'>[🤖 策略分析] {$reason}</span>";
$content = "🎲 【百家乐】 {$user->username} 本局选择挂机观望";
$msg = [
'id' => $chatState->nextMessageId($roomId),
@@ -335,9 +331,8 @@ class AiBaccaratBetJob implements ShouldQueue
$chatState = app(ChatStateService::class);
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$label = $labelMap[$betType] ?? $betType;
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:';
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}</span>";
// AI 下注播报统一压成单行,避免游戏通知卡片出现多行正文挤占高度。
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount).' 金币)';
$msg = [
'id' => $chatState->nextMessageId($roomId),
+9 -6
View File
@@ -28,6 +28,9 @@ use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:完成一局百家乐的开奖、派奖与通知。
*/
class CloseBaccaratRoundJob implements ShouldQueue
{
use Queueable;
@@ -227,7 +230,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $round->room_id;
$roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) {
@@ -309,11 +312,11 @@ class CloseBaccaratRoundJob implements ShouldQueue
$detail = $detailParts ? ' '.implode(' ', $detailParts) : '';
$content = "🎲 【百家乐】第 #{$round->id} 局开奖{$diceStr} 总点 {$round->total_points} {$resultText}{$payoutText}{$detail}";
$content = "🎲 第 #{$round->id} 局开奖{$diceStr} {$round->total_points} 点,{$resultText}{$payoutText}{$detail}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $round->room_id),
'room_id' => (int) $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -322,8 +325,8 @@ class CloseBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $round->room_id, $msg);
broadcast(new MessageSent((int) $round->room_id, $msg));
SaveMessageJob::dispatch($msg);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
+9 -6
View File
@@ -26,6 +26,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
/**
* 类功能:完成一场赛马竞猜的派奖与结果广播。
*/
class CloseHorseRaceJob implements ShouldQueue
{
use Queueable;
@@ -181,7 +184,7 @@ class CloseHorseRaceJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $race->room_id;
$winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) {
@@ -243,11 +246,11 @@ class CloseHorseRaceJob implements ShouldQueue
? '共派发 💰'.number_format($totalPayout).' 金币'
: '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}";
$content = "🏆 第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -256,8 +259,8 @@ class CloseHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
broadcast(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
+5 -2
View File
@@ -23,6 +23,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
/**
* 类功能:按房间投放神秘箱子并广播暗号。
*/
class DropMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -80,6 +83,7 @@ class DropMysteryBoxJob implements ShouldQueue
// 创建箱子记录
$box = MysteryBox::create([
'room_id' => $targetRoom,
'box_type' => $this->boxType,
'passcode' => $passcode,
'reward_min' => $rewardMin,
@@ -94,8 +98,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}【神秘箱子】{$typeName}{$source}投放了一个神秘箱子!"
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$content = "{$emoji} {$typeName}{$source}投放,暗号「{$passcode}」,限时 {$claimWindow} 秒。";
$msg = [
'id' => $chatState->nextMessageId($targetRoom),
+7 -4
View File
@@ -18,6 +18,9 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:关闭已超时的神秘箱子并广播过期提醒。
*/
class ExpireMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -49,8 +52,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
// 公屏广播过期通知
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
@@ -60,8 +63,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一局新的百家乐押注回合。
*/
class OpenBaccaratRoundJob implements ShouldQueue
{
use Queueable;
/**
* 构造开局任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -44,7 +56,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
// 防止重复开局(如果上一局还在押注中则跳过)
if (BaccaratRound::currentRound()) {
if (BaccaratRound::currentRound($this->roomId)) {
return;
}
@@ -53,6 +65,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
// 创建新局次
$round = BaccaratRound::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -77,10 +90,10 @@ class OpenBaccaratRoundJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡 1:{$bigRate} · 💥豹子 1:{$tripleRate}☠️ {$killText}庄家收割".$quickOpenButton;
$content = "🎲 第 #{$round->id} 局开局:{$betSeconds}下注{$minBet}~{$maxBet} 金币,🔵/🟡 1:{$bigRate},💥 1:{$tripleRate}☠️ {$killText} 点收割".$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -89,8 +102,8 @@ class OpenBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一场新的赛马竞猜回合。
*/
class OpenHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 构造开赛任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -41,7 +53,7 @@ class OpenHorseRaceJob implements ShouldQueue
}
// 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) {
if (HorseRace::currentRace($this->roomId)) {
return;
}
@@ -60,6 +72,7 @@ class OpenHorseRaceJob implements ShouldQueue
// 创建新场次
$race = HorseRace::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -79,11 +92,11 @@ class OpenHorseRaceJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$content = "🐎 第 #{$race->id} 场开赛:{$horseList}{$betSeconds} 秒下注,".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -92,8 +105,8 @@ class OpenHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务
+15 -2
View File
@@ -19,10 +19,22 @@ use App\Models\LotteryIssue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间创建一条新的双色球期次。
*/
class OpenLotteryIssueJob implements ShouldQueue
{
use Queueable;
/**
* 构造开期任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -38,7 +50,7 @@ class OpenLotteryIssueJob implements ShouldQueue
}
// 已有进行中的期次则跳过
if (LotteryIssue::currentIssue()) {
if (LotteryIssue::currentIssue($this->roomId)) {
return;
}
@@ -56,7 +68,8 @@ class OpenLotteryIssueJob implements ShouldQueue
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => $this->roomId,
'issue_no' => LotteryIssue::nextIssueNo($this->roomId),
'status' => 'open',
'pool_amount' => 0,
'carry_amount' => 0,
+14 -8
View File
@@ -22,6 +22,12 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:赛马跑马动画广播与结算衔接任务
*
* 负责在押注截止后推进 running 流程、广播实时进度,
* 并在同一条任务链中补齐赛果与触发最终结算,避免线上状态滞留。
*/
class RunHorseRaceJob implements ShouldQueue
{
use Queueable;
@@ -72,18 +78,18 @@ class RunHorseRaceJob implements ShouldQueue
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id}押注截止!马匹已进入跑道,比赛开始!参赛阵容{$horseList}",
'content' => "🏇 第 #{$race->id}比赛开始{$horseList}",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $startMsg);
broadcast(new MessageSent(1, $startMsg));
$chatState->pushMessage((int) $race->room_id, $startMsg);
broadcast(new MessageSent((int) $race->room_id, $startMsg));
SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
@@ -126,7 +132,7 @@ class RunHorseRaceJob implements ShouldQueue
}
// 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
broadcast(new HorseRaceProgress($race->id, (int) $race->room_id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) {
break;
@@ -156,8 +162,8 @@ class RunHorseRaceJob implements ShouldQueue
'total_pool' => $totalPool,
]);
// 触发结算任务
CloseHorseRaceJob::dispatch($race->fresh());
// 在同一条队列任务里直接完成结算,避免线上出现“已跑完但 Close 任务未继续消费”的断链。
app()->call([new CloseHorseRaceJob($race->fresh()), 'handle']);
}
/**
+14 -5
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存百家乐局次数据并提供当前局查询能力。
*/
class BaccaratRound extends Model
{
protected $fillable = [
'room_id',
'dice1', 'dice2', 'dice3',
'total_points', 'result', 'status',
'bet_opens_at', 'bet_closes_at', 'settled_at',
@@ -36,6 +40,7 @@ class BaccaratRound extends Model
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'dice1' => 'integer',
'dice2' => 'integer',
'dice3' => 'integer',
@@ -104,12 +109,16 @@ class BaccaratRound extends Model
/**
* 查询当前正在进行的局次(状态为 betting 且未截止)。
*/
public static function currentRound(): ?static
public static function currentRound(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'betting')
->where('bet_closes_at', '>', now())
->latest()
->first();
->where('bet_closes_at', '>', now());
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
}
+16 -5
View File
@@ -16,9 +16,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:赛马竞猜局次模型
*
* 负责描述赛马场次的生命周期、参赛马匹、下注汇总与派奖计算,
* 并为控制器和队列任务提供当前场次、赔率与奖池算法支持。
*/
class HorseRace extends Model
{
protected $fillable = [
'room_id',
'status',
'bet_opens_at',
'bet_closes_at',
@@ -42,6 +49,7 @@ class HorseRace extends Model
'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'horses' => 'array',
'winner_horse_id' => 'integer',
'total_bets' => 'integer',
@@ -69,12 +77,15 @@ class HorseRace extends Model
/**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/
public static function currentRace(): ?static
public static function currentRace(?int $roomId = null): ?static
{
return static::query()
->whereIn('status', ['betting', 'running'])
->latest()
->first();
$query = static::query()->whereIn('status', ['betting', 'running']);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
+29 -9
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存双色球期次数据并提供按房间查询能力。
*/
class LotteryIssue extends Model
{
protected $fillable = [
'room_id',
'issue_no',
'status',
'red1', 'red2', 'red3', 'blue',
@@ -38,6 +42,7 @@ class LotteryIssue extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_super_issue' => 'boolean',
'pool_amount' => 'integer',
'carry_amount' => 'integer',
@@ -71,29 +76,44 @@ class LotteryIssue extends Model
/**
* 获取当前正在购票的期次(status=open)。
*/
public static function currentIssue(): ?static
public static function currentIssue(?int $roomId = null): ?static
{
return static::query()->where('status', 'open')->latest()->first();
$query = static::query()->where('status', 'open');
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 获取最新一期(不论状态)。
*/
public static function latestIssue(): ?static
public static function latestIssue(?int $roomId = null): ?static
{
return static::query()->latest()->first();
$query = static::query();
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。
*/
public static function nextIssueNo(): string
public static function nextIssueNo(?int $roomId = null): string
{
$year = now()->year;
$last = static::query()
->whereYear('created_at', $year)
->latest()
->first();
$query = static::query()->whereYear('created_at', $year);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
$last = $query->latest()->first();
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
+14 -5
View File
@@ -17,9 +17,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 类功能:保存神秘箱子投放记录并提供当前箱子查询能力。
*/
class MysteryBox extends Model
{
protected $fillable = [
'room_id',
'box_type',
'passcode',
'reward_min',
@@ -35,6 +39,7 @@ class MysteryBox extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'reward_min' => 'integer',
'reward_max' => 'integer',
'expires_at' => 'datetime',
@@ -64,13 +69,17 @@ class MysteryBox extends Model
/**
* 当前可领取(open 状态 + 未过期)的箱子。
*/
public static function currentOpenBox(): ?static
public static function currentOpenBox(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->latest()
->first();
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
// ─── 工具方法 ────────────────────────────────────────────────────
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* 文件功能:猜谜活动题库模型
*
* 对应 idioms 表,统一承载成语题与脑筋急转弯题目。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 类功能:统一管理猜谜活动的题目、答案、提示与题型。
*/
class Riddle extends Model
{
/**
* 属性功能:显式绑定历史题库表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idioms';
/**
* 常量功能:声明成语题题型标识。
*/
public const TYPE_IDIOM = 'idiom';
/**
* 常量功能:声明脑筋急转弯题型标识。
*/
public const TYPE_BRAIN_TEASER = 'brain_teaser';
/**
* 方法功能:声明允许批量赋值的题库字段。
*
* @var array<int, string>
*/
protected $fillable = [
'type',
'answer',
'hint',
'is_active',
'sort',
];
/**
* 方法功能:定义题库字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort' => 'integer',
];
}
/**
* 方法功能:返回系统支持的全部题型。
*
* @return array<int, string>
*/
public static function supportedTypes(): array
{
return [
self::TYPE_IDIOM,
self::TYPE_BRAIN_TEASER,
];
}
/**
* 方法功能:判断给定题型是否属于系统支持范围。
*/
public static function isSupportedType(string $type): bool
{
return in_array($type, self::supportedTypes(), true);
}
/**
* 方法功能:根据题型返回面向用户的中文名称。
*/
public static function labelForType(string $type): string
{
return match ($type) {
self::TYPE_BRAIN_TEASER => '脑筋急转弯',
default => '猜成语',
};
}
/**
* 方法功能:返回后台表单可直接使用的题型键值对。
*
* @return array<string, string>
*/
public static function typeOptions(): array
{
return collect(self::supportedTypes())
->mapWithKeys(fn (string $type): array => [$type => self::labelForType($type)])
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
public static function activityLabelForType(string $type): string
{
return '猜谜活动·'.self::labelForType($type);
}
/**
* 方法功能:按题型筛选题库记录。
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', self::isSupportedType($type) ? $type : self::TYPE_IDIOM);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:猜谜活动回合模型
*
* 每次出题对应一个回合,记录题型、题目、状态、奖励和获胜者。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录猜谜活动每一轮的题型、奖励与结算状态。
*/
class RiddleGameRound extends Model
{
/**
* 属性功能:显式绑定历史回合表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idiom_game_rounds';
/**
* 方法功能:声明可批量赋值的回合字段。
*
* @var array<int, string>
*/
protected $fillable = [
'room_id',
'idiom_id',
'quiz_type',
'status',
'reward_gold',
'reward_exp',
'winner_id',
'winner_username',
'started_at',
'ended_at',
];
/**
* 方法功能:定义回合字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'idiom_id' => 'integer',
'reward_gold' => 'integer',
'reward_exp' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
/**
* 方法功能:关联本回合对应的猜谜题目。
*/
public function idiom(): BelongsTo
{
return $this->belongsTo(Riddle::class);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:聊天室座驾模型。
*
* 对应 rides 表,保存座驾名称、特效 key、价格、使用天数、欢迎语与上下架状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 聊天室座驾模型
* 负责提供座驾定义、全屏特效 key 和购买记录关系。
*/
class Ride extends Model
{
protected $fillable = [
'name', 'slug', 'effect_key', 'icon', 'description', 'price',
'duration_days', 'welcome_message', 'sort_order', 'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* 获取座驾对应的所有购买记录。
*/
public function purchases(): HasMany
{
return $this->hasMany(UserRidePurchase::class);
}
/**
* 获取座驾全屏特效 key。
*/
public function rideKey(): string
{
return $this->effect_key;
}
/**
* 获取所有上架座驾。
*
* @return Collection<int, self>
*/
public static function active(): Collection
{
return static::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id')
->get();
}
}
+10
View File
@@ -15,6 +15,8 @@ class ShopItem extends Model
{
public const TYPE_SIGN_REPAIR = 'sign_repair';
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
protected $table = 'shop_items';
protected $fillable = [
@@ -51,6 +53,14 @@ class ShopItem extends Model
return $this->type === self::TYPE_SIGN_REPAIR;
}
/**
* 是否为个人装扮(气泡、颜色、头像框等)。
*/
public function isDecoration(): bool
{
return in_array($this->type, self::DECORATION_TYPES, true);
}
/**
* 是否为特效类商品(instant durationslug once_ week_ 开头)
*/
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:用户座驾购买记录模型。
*
* 对应 user_ride_purchases 表,追踪用户座驾购买、续期、替换和过期状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 用户座驾购买记录模型
* 负责连接用户与座驾,并判断当前记录是否仍有效。
*/
class UserRidePurchase extends Model
{
protected $fillable = [
'user_id', 'ride_id', 'status', 'price_paid', 'expires_at', 'used_at',
];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
/**
* 获取购买记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 获取购买记录对应座驾。
*/
public function ride(): BelongsTo
{
return $this->belongsTo(Ride::class);
}
/**
* 判断座驾购买记录是否仍然有效。
*/
public function isAlive(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
return true;
}
}
+3 -2
View File
@@ -400,7 +400,8 @@ class BaccaratLossCoverService
}
if ($compensableCount > 0) {
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>';
// 聊天消息内的按钮使用相对字号,跟随用户在底部工具栏选择的聊天字号。
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>';
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}{$button}";
} else {
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
@@ -446,7 +447,7 @@ class BaccaratLossCoverService
$formattedAmount = number_format($amount);
$button = $event->status === 'claimable'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>'
: '';
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
+33 -10
View File
@@ -52,12 +52,14 @@ class DecorationService
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
*
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
*
* @param User $user 购买用户
* @param ShopItem $item 装扮商品
* @param int $quantity 购买份数
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
*/
public function purchase(User $user, ShopItem $item): array
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
{
// 根据商品类型映射到对应槽位
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
@@ -65,14 +67,29 @@ class DecorationService
return ['ok' => false, 'message' => '未知装扮类型'];
}
$totalPrice = $item->price * $quantity;
// 校验金币余额
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
if ($user->jjb < $totalPrice) {
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
}
// 计算过期时间(至少 1 天)
$days = max(1, (int) ($item->duration_days ?? 1));
$expiresAt = Carbon::now()->addDays($days);
$totalDays = $days * $quantity;
// 检查同一槽位是否已激活相同样式 → 叠加天数
$decorations = $this->getActiveDecorations($user);
$isSameActive = ! empty($decorations[$slot])
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
if ($isSameActive) {
// 在现有到期时间上追加天数
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
$expiresAt = $existingExpires->copy()->addDays($totalDays);
} else {
$expiresAt = Carbon::now()->addDays($totalDays);
}
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
$source = match ($item->type) {
@@ -84,11 +101,11 @@ class DecorationService
};
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
// ① 通过统一积分服务扣除金币(含流水记录)
$this->currencyService->change(
$user, 'gold', -$item->price, $source,
"购买装扮:{$item->name}{$days}天)"
$user, 'gold', -$totalPrice, $source,
"购买装扮:{$item->name}{$totalDays}天)"
);
// ② 写入购买记录(用于后台统计与用户回溯)
@@ -96,11 +113,11 @@ class DecorationService
'user_id' => $user->id,
'shop_item_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'price_paid' => $totalPrice,
'expires_at' => $expiresAt,
]);
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加
$decorations = $this->getActiveDecorations($user);
$decorations[$slot] = [
'style' => $item->slug,
@@ -113,13 +130,19 @@ class DecorationService
// 重新读取最新余额,避免缓存脏数据
$balanceAfter = (int) $user->fresh()->jjb;
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
$displayDays = $isSameActive
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
: $totalDays;
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
'balance_after' => $balanceAfter,
'slot' => $slot,
'style' => $item->slug,
'expires_at' => $expiresAt->toIso8601String(),
'quantity' => $quantity,
];
}
+24 -20
View File
@@ -11,6 +11,7 @@
* (会员加成:+经验X+金币Y
*
* @author ChatRoom Laravel
*
* @version 1.2.0
*/
@@ -24,20 +25,19 @@ use App\Models\User;
class FishingService
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
)
{
}
private readonly ShopService $shopService,
) {}
/**
* 处理收竿逻辑:计算结果、发放积分并全服广播。
*
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @return array{emoji:string,message:string,exp:int,jjb:int,base_exp:int,base_jjb:int,bonus_exp:int,bonus_jjb:int}
*/
public function processCatch(User $user, int $roomId, bool $isAi = false): array
{
@@ -54,11 +54,11 @@ class FishingService
if ($result['exp'] !== 0) {
// 当经验为 正数 则可使用会员翻倍,负数则不
$finalExp = $result['exp'] > 0 ? (int)round($result['exp'] * $expMul) : $result['exp'];
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int)round($result['jjb'] * $jjbMul) : $result['jjb'];
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
}
// 4. 计算会员额外加成部分
@@ -92,16 +92,19 @@ class FishingService
// 8. 广播钓鱼结果到聊天室
$promoTag = '';
if (!$isAi) {
if (! $isAi) {
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
// 公屏消息内的促销标签使用相对字号,避免覆盖用户在聊天室选择的字号。
$promoTag = $autoFishingMinutesLeft > 0
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
. '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>'
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
}
// 广播结果时额外带上统一动作标记和钓鱼者用户名,
// 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
@@ -110,7 +113,8 @@ class FishingService
'content' => "{$result['emoji']}{$user->username}{$finalMessage}{$promoTag}",
'is_secret' => false,
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
'action' => '',
'action' => 'fishing_result',
'fishing_username' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
@@ -154,7 +158,7 @@ class FishingService
return $baseMessage;
}
return $baseMessage . '' . $user->vipName() . '追加:' . implode('', $bonusParts) . '';
return $baseMessage.''.$user->vipName().'追加:'.implode('', $bonusParts).'';
}
/**
@@ -168,7 +172,7 @@ class FishingService
{
$event = FishingEvent::rollOne();
if (!$event) {
if (! $event) {
return [
'emoji' => '🐟',
'message' => '钓到一条小鱼,获得金币10',
@@ -180,8 +184,8 @@ class FishingService
return [
'emoji' => $event->emoji,
'message' => $event->message,
'exp' => (int)$event->exp,
'jjb' => (int)$event->jjb,
'exp' => (int) $event->exp,
'jjb' => (int) $event->jjb,
];
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
/**
* 文件功能:游戏房间范围配置服务
*
* 统一解析所有游戏的 room_scope_mode room_ids 配置,
* 供后台保存、调度任务、前台准入校验和公共回合查询复用。
*/
namespace App\Services;
use App\Models\GameConfig;
use App\Models\User;
use Illuminate\Http\Request;
/**
* 类功能:统一管理所有游戏的房间范围读取与房间判定。
*/
class GameRoomScopeService
{
/**
* 房间模式常量:全部房间。
*/
public const MODE_ALL = 'all';
/**
* 房间模式常量:单选房间。
*/
public const MODE_SINGLE = 'single';
/**
* 房间模式常量:多选房间。
*/
public const MODE_MULTIPLE = 'multiple';
/**
* 支持的房间模式列表。
*
* @var array<int, string>
*/
public const SUPPORTED_MODES = [
self::MODE_ALL,
self::MODE_SINGLE,
self::MODE_MULTIPLE,
];
/**
* 构造房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 归一化房间模式。
*/
public function normalizeRoomScopeMode(?string $mode, string $default = self::MODE_SINGLE): string
{
$normalizedMode = (string) $mode;
if (! in_array($normalizedMode, self::SUPPORTED_MODES, true)) {
return $default;
}
return $normalizedMode;
}
/**
* 把原始房间数组归一化为去重后的整型数组。
*
* @return array<int, int>
*/
public function normalizeRoomIds(mixed $roomIds, array $default = [1]): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
$normalizedRoomIds = collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
if ($normalizedRoomIds === []) {
return $default;
}
return $normalizedRoomIds;
}
/**
* params 数组中解析房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForParams(array $params, array $defaultRoomIds = [1]): array
{
if (
! array_key_exists('room_scope_mode', $params)
&& ! array_key_exists('room_ids', $params)
&& ! array_key_exists('room_id', $params)
) {
return [
'room_scope_mode' => self::MODE_ALL,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
$roomScopeMode = $this->normalizeRoomScopeMode(
mode: (string) ($params['room_scope_mode'] ?? self::MODE_SINGLE),
default: self::MODE_SINGLE,
);
$roomIds = $this->normalizeRoomIds(
roomIds: $params['room_ids'] ?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : []),
default: $defaultRoomIds,
);
return [
'room_scope_mode' => $roomScopeMode,
'room_ids' => $roomIds,
];
}
/**
* 读取指定游戏当前配置中的房间范围。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$params = GameConfig::forGame($gameKey)?->params ?? [];
return $this->getScopeConfigForParams($params, $defaultRoomIds);
}
/**
* 获取指定游戏真正生效的房间 ID 列表。
*
* @return array<int, int>
*/
public function getScopedRoomIdsForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$scopeConfig = $this->getScopeConfigForGame($gameKey, $defaultRoomIds);
if ($scopeConfig['room_scope_mode'] === self::MODE_ALL) {
return $this->resolveAllAvailableRoomIds($defaultRoomIds);
}
return $scopeConfig['room_ids'];
}
/**
* 获取指定游戏的首选房间。
*/
public function getPrimaryRoomIdForGame(string $gameKey, int $fallback = 1): int
{
$roomIds = $this->getScopedRoomIdsForGame($gameKey, [$fallback]);
return $roomIds[0] ?? $fallback;
}
/**
* 判断某个房间是否在指定游戏允许范围内。
*/
public function isRoomAllowedForGame(string $gameKey, int $roomId, array $defaultRoomIds = [1]): bool
{
return in_array($roomId, $this->getScopedRoomIdsForGame($gameKey, $defaultRoomIds), true);
}
/**
* 从请求或在线状态解析当前操作房间。
*/
public function resolveRequestRoomId(Request $request, ?User $user = null, int $fallback = 1): int
{
$requestedRoomId = (int) $request->integer('room_id', 0);
if ($requestedRoomId > 0) {
return $requestedRoomId;
}
return $this->resolveUserRoomId($user ?? $request->user(), $fallback);
}
/**
* 从用户在线房间或用户资料中推断当前房间。
*/
public function resolveUserRoomId(?User $user, int $fallback = 1): int
{
if (! $user) {
return $fallback;
}
$activeRoomIds = $this->chatState->getUserRooms($user->username);
if ($activeRoomIds !== []) {
return (int) $activeRoomIds[0];
}
$profileRoomId = (int) ($user->room_id ?? 0);
return $profileRoomId > 0 ? $profileRoomId : $fallback;
}
/**
* 返回通用后台复用的默认房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function defaultScopeConfig(array $defaultRoomIds = [1]): array
{
return [
'room_scope_mode' => self::MODE_SINGLE,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
/**
* 在“全部房间”模式下解析当前可用房间。
*
* @return array<int, int>
*/
private function resolveAllAvailableRoomIds(array $defaultRoomIds = [1]): array
{
$roomIds = \App\Models\Room::query()
->orderBy('id')
->pluck('id')
->map(fn (mixed $roomId): int => (int) $roomId)
->all();
return $roomIds !== [] ? $roomIds : $defaultRoomIds;
}
}
+18 -13
View File
@@ -22,11 +22,15 @@ use App\Models\LotteryTicket;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* 类功能:负责双色球购票、开奖、滚存与房间广播。
*/
class LotteryService
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
// ─── 购票 ─────────────────────────────────────────────────────────
@@ -49,7 +53,8 @@ class LotteryService
throw new \RuntimeException('双色球彩票游戏未开启');
}
$issue = LotteryIssue::currentIssue();
$roomId = $this->roomScopeService->resolveUserRoomId($user);
$issue = LotteryIssue::currentIssue($roomId);
if (! $issue || ! $issue->isOpen()) {
throw new \RuntimeException('当前无正在进行的期次,或已停售');
}
@@ -135,7 +140,7 @@ class LotteryService
$firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : '';
$this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}豪掷千金,购买了当前 #{$issue->issue_no}双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!");
$this->pushSystemMessage("🎟️ 【{$user->username}购买 {$issue->issue_no}{$numsStr} {$moreStr}", (int) $issue->room_id);
return $tickets;
}
@@ -364,7 +369,8 @@ class LotteryService
}
$newIssue = LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => (int) $prevIssue->room_id,
'issue_no' => LotteryIssue::nextIssueNo((int) $prevIssue->room_id),
'status' => 'open',
'pool_amount' => $carryAmount + $injectAmount,
'carry_amount' => $carryAmount,
@@ -444,9 +450,9 @@ class LotteryService
$detailStr = $details ? ' '.implode(' | ', $details) : '';
$content = "🎟️ 【双色球 {$issue->issue_no} 开奖{$drawNums} {$line1}{$detailStr}";
$content = "🎟️ 第 #{$issue->issue_no} 期开奖{$drawNums} {$line1}{$detailStr}";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
// 触发微信机器人消息推送 (彩票开奖)
try {
@@ -463,20 +469,19 @@ class LotteryService
private function broadcastSuperIssue(LotteryIssue $issue): void
{
$pool = number_format($issue->pool_amount);
$content = "🎊🎟️ 【双色球超级期预警】{$issue->issue_no} 期已连续 {$issue->no_winner_streak} 期无一等奖"
."当前奖池 💰 {$pool} 金币,系统已追加注入!今日 {$issue->draw_at?->format('H:i')} 开奖,赶紧购票!";
$content = "🎊 #{$issue->issue_no}超级期:已连续 {$issue->no_winner_streak} 期无一等奖,奖池 💰 {$pool}{$issue->draw_at?->format('H:i')} 开奖。";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
}
/**
* 向公屏发送系统消息。
*/
private function pushSystemMessage(string $content): void
private function pushSystemMessage(string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -485,8 +490,8 @@ class LotteryService
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
}
}
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* 文件功能:猜谜活动回合服务
*
* 统一处理题型兼容、房间范围、自动出题、超时结算与公屏公告,
* 避免控制器与定时任务各自维护一套猜谜活动逻辑。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Events\RiddleGameStarted;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Models\Room;
/**
* 类功能:提供猜谜活动的配置读取、出题、过期结算与公告能力。
*/
class RiddleGameService
{
/**
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 方法功能:读取指定题型的完整配置,并兼容旧版平铺参数。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
public function getTypeConfig(?string $quizType = null): array
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame(Riddle::TYPE_IDIOM) ?? GameConfig::forGame($normalizedQuizType);
$params = $config?->params ?? [];
$typeConfig = (array) (($params['type_configs'] ?? [])[$normalizedQuizType] ?? []);
$sharedRoomIds = $this->normalizeRoomIds(
$params['room_ids']
?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : [])
);
$roomMode = (string) ($params['room_scope_mode'] ?? ($typeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
$roomIds = $sharedRoomIds !== []
? $sharedRoomIds
: $this->normalizeRoomIds($typeConfig['room_ids'] ?? [1]);
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($typeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($typeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($typeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($typeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => $roomIds,
];
}
/**
* 方法功能:读取题目有效时长配置,单位分钟。
*/
public function getExpireMinutes(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['expire_minutes'];
}
/**
* 方法功能:读取自动出题间隔配置,单位分钟。
*/
public function getAutoStartInterval(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['auto_start_interval'];
}
/**
* 方法功能:读取答题奖励配置。
*
* @return array{reward_gold:int,reward_exp:int}
*/
public function getRewardConfig(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
return [
'reward_gold' => $typeConfig['reward_gold'],
'reward_exp' => $typeConfig['reward_exp'],
];
}
/**
* 方法功能:将外部传入的题型归一化为系统支持值。
*/
public function normalizeQuizType(?string $quizType): string
{
$normalizedType = trim((string) $quizType);
return Riddle::isSupportedType($normalizedType)
? $normalizedType
: Riddle::TYPE_IDIOM;
}
/**
* 方法功能:返回题型对应的中文名称。
*/
public function getQuizTypeLabel(string $quizType): string
{
return Riddle::labelForType($this->normalizeQuizType($quizType));
}
/**
* 方法功能:读取自动出题的房间范围模式。
*/
public function getRoomScopeMode(?string $quizType = null): string
{
return $this->getTypeConfig($quizType)['room_mode'];
}
/**
* 方法功能:读取自动出题允许覆盖的房间列表。
*
* @return array<int, int>
*/
public function getScopedRoomIds(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
$mode = $typeConfig['room_mode'];
$configuredRoomIds = $typeConfig['room_ids'];
if ($mode === 'all') {
return Room::query()->orderBy('id')->pluck('id')->map(fn (mixed $id): int => (int) $id)->all();
}
if ($mode === 'single') {
return array_slice($configuredRoomIds !== [] ? $configuredRoomIds : [1], 0, 1);
}
return $configuredRoomIds !== [] ? $configuredRoomIds : [1];
}
/**
* 方法功能:判断指定回合是否已经超过有效时长。
*/
public function isRoundExpired(RiddleGameRound $round): bool
{
$expireMinutes = $this->getExpireMinutes($round->quiz_type);
if ($expireMinutes <= 0) {
return false;
}
if (! in_array($round->status, ['pending', 'active'], true)) {
return false;
}
if (! $round->started_at) {
return false;
}
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
}
/**
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
*/
public function expireRound(RiddleGameRound $round, bool $announce = true): bool
{
if (! $this->isRoundExpired($round)) {
return false;
}
$round->loadMissing('idiom');
// 已过期回合统一落为 ended,防止继续答题或阻塞新开题。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
if ($announce) {
$this->pushExpiredRoundMessage($round);
}
return true;
}
/**
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
*/
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true, ?string $quizType = null): int
{
$expiredCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::with('idiom')
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use ($announce, &$expiredCount): void {
if ($this->expireRound($round, $announce)) {
$expiredCount++;
}
});
return $expiredCount;
}
/**
* 方法功能:手动结束指定房间指定题型的所有进行中回合。
*/
public function endActiveRoundsForRoom(int $roomId, ?string $quizType = null): int
{
$endedCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::query()
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use (&$endedCount): void {
// 手动出题覆盖旧题时,直接结束旧回合,不再额外发超时公告。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
$endedCount++;
});
return $endedCount;
}
/**
* 方法功能:为指定房间和题型创建一轮新题。
*/
public function startRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
if (! $this->isGameEnabled($normalizedQuizType)) {
return null;
}
// 先清理同房间同题型的过期回合,避免旧记录卡住新题。
$this->expireActiveRoundsForRoom($roomId, true, $normalizedQuizType);
if ($this->findActiveRound($roomId, $normalizedQuizType)) {
return null;
}
$idiom = $this->pickRandomQuestion($normalizedQuizType);
if (! $idiom) {
return null;
}
$rewardConfig = $this->getRewardConfig($normalizedQuizType);
// 新回合显式记录 quiz_type,保证房间与题型维度都能独立判定。
$round = RiddleGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
'quiz_type' => $normalizedQuizType,
'status' => 'active',
'reward_gold' => $rewardConfig['reward_gold'],
'reward_exp' => $rewardConfig['reward_exp'],
'started_at' => now(),
]);
$round->setRelation('idiom', $idiom);
$this->broadcastStartedRound($round);
return $round;
}
/**
* 方法功能:按配置范围自动为各房间各题型尝试开题。
*/
public function autoStartEligibleRounds(): int
{
$startedCount = 0;
foreach (Riddle::supportedTypes() as $quizType) {
$interval = $this->getAutoStartInterval($quizType);
if ($interval <= 0) {
continue;
}
foreach ($this->getScopedRoomIds($quizType) as $roomId) {
// 房间与题型维度独立结算过期回合,互不干扰。
$this->expireActiveRoundsForRoom($roomId, true, $quizType);
if ($this->findActiveRound($roomId, $quizType)) {
continue;
}
if (! $this->hasReachedAutoStartInterval($roomId, $quizType, $interval)) {
continue;
}
if (! $this->pickRandomQuestion($quizType)) {
continue;
}
if ($this->startRound($roomId, $quizType)) {
$startedCount++;
}
}
}
return $startedCount;
}
/**
* 方法功能:查询指定房间指定题型的进行中回合。
*/
public function findActiveRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
return RiddleGameRound::query()
->with('idiom')
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->whereIn('status', ['pending', 'active'])
->first();
}
/**
* 方法功能:随机抽取一条启用中的题目。
*/
public function pickRandomQuestion(?string $quizType = null): ?Riddle
{
return Riddle::query()
->where('type', $this->normalizeQuizType($quizType))
->where('is_active', true)
->inRandomOrder()
->first();
}
/**
* 方法功能:生成答题奖励日志文案。
*/
public function buildRewardDescription(RiddleGameRound $round): string
{
$quizTypeLabel = $this->getQuizTypeLabel($round->quiz_type);
return "猜谜活动{$quizTypeLabel}答对「{$round->idiom?->answer}」奖励";
}
/**
* 方法功能:向公屏推送回合超时公告。
*/
public function pushExpiredRoundMessage(RiddleGameRound $round): void
{
$answer = $round->idiom?->answer ?? '未知答案';
$quizTitle = Riddle::activityLabelForType($round->quiz_type);
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏳ 【{$quizTitle}】第 #{$round->id} 题已超时结束!正确答案:{$answer}",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'quiz_answer' => $answer,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:广播新回合开始事件并同步写入公屏消息。
*/
public function broadcastStartedRound(RiddleGameRound $round): void
{
$round->loadMissing('idiom');
broadcast(new RiddleGameStarted(
roomId: $round->room_id,
quizType: $round->quiz_type,
hint: $round->idiom?->hint ?? '',
roundId: $round->id,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $this->buildStartMessage($round->quiz_type, $round->id, $round->idiom?->hint ?? ''),
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_hint' => $round->idiom?->hint ?? '',
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'idiom_game_round_id' => $round->id,
'idiom_reward_gold' => $round->reward_gold,
'idiom_reward_exp' => $round->reward_exp,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:判断指定房间指定题型是否已到自动开题间隔。
*/
private function hasReachedAutoStartInterval(int $roomId, string $quizType, int $interval): bool
{
$lastRound = RiddleGameRound::query()
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->latest()
->first();
if (! $lastRound) {
return true;
}
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
return ! $lastTime || $lastTime->diffInMinutes(now()) >= $interval;
}
/**
* 方法功能:把 room_ids 配置归一化为整型数组。
*
* @return array<int, int>
*/
private function normalizeRoomIds(mixed $roomIds): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
return collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
private function buildStartMessage(string $quizType, int $roundId, string $hint): string
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$quizLabel = $this->getQuizTypeLabel($normalizedQuizType);
$icon = $normalizedQuizType === Riddle::TYPE_BRAIN_TEASER ? '🧠' : '🧩';
return "{$icon} 【猜谜活动·{$quizLabel}】第 #{$roundId} 题开始!题面:{$hint}";
}
/**
* 方法功能:判断猜谜活动总开关是否处于启用状态。
*/
private function isGameEnabled(?string $quizType = null): bool
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame($normalizedQuizType) ?? GameConfig::forGame(Riddle::TYPE_IDIOM);
return (bool) $config?->enabled;
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* 文件功能:聊天室座驾业务服务。
*
* 统一管理座驾商品列表、购买续期、当前激活座驾、购买记录和入场欢迎语载荷。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\Ride;
use App\Models\User;
use App\Models\UserRidePurchase;
use App\Support\ChatContentSanitizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 聊天室座驾服务
* 负责通过 rides user_ride_purchases 完成座驾购买、续期、替换与进房展示。
*/
class RideService
{
/**
* 构造座驾服务依赖。
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
* 获取全部上架座驾商品。
*
* @return Collection<int, Ride>
*/
public function activeItems(): Collection
{
return Ride::active();
}
/**
* 格式化座驾商品,供前端页面直接渲染。
*
* @return array<string, mixed>
*/
public function formatItem(Ride $item): array
{
return [
'id' => $item->id,
'name' => $item->name,
'slug' => $item->slug,
'ride_key' => $item->rideKey(),
'description' => $item->description,
'icon' => $item->icon,
'price' => $item->price,
'duration_days' => (int) ($item->duration_days ?? 0),
'welcome_message' => $item->welcome_message,
];
}
/**
* 获取用户当前有效座驾,若已过期则自动标记为 expired。
*/
public function currentRide(User $user): ?UserRidePurchase
{
$purchase = UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->where('status', 'active')
->orderByDesc('expires_at')
->first();
if (! $purchase) {
return null;
}
if ($purchase->expires_at && $purchase->expires_at->isPast()) {
// 过期座驾必须及时落库,避免后续进房继续播放旧特效。
$purchase->update(['status' => 'expired']);
return null;
}
return $purchase;
}
/**
* 格式化用户当前座驾。
*
* @return array<string, mixed>|null
*/
public function formatCurrentRide(User $user): ?array
{
$purchase = $this->currentRide($user);
if (! $purchase || ! $purchase->ride) {
return null;
}
return $this->formatPurchase($purchase);
}
/**
* 获取用户最近座驾购买记录。
*
* @return array<int, array<string, mixed>>
*/
public function purchaseRecords(User $user, int $limit = 20): array
{
return UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->latest()
->limit($limit)
->get()
->map(fn (UserRidePurchase $purchase) => $this->formatPurchase($purchase))
->values()
->all();
}
/**
* 购买座驾:同款续期,不同款替换旧座驾且不退款。
*
* @return array{ok:bool, message:string, current_ride?:array<string, mixed>}
*/
public function buy(User $user, Ride $item, ?int $roomId = null): array
{
if (! $item->is_active) {
return ['ok' => false, 'message' => '该座驾暂未上架。'];
}
$days = (int) ($item->duration_days ?? 0);
if ($days <= 0) {
return ['ok' => false, 'message' => '该座驾使用天数配置异常,请联系管理员。'];
}
if ($user->jjb < $item->price) {
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
}
$purchased = DB::transaction(function () use ($user, $item, $days, $roomId): bool {
$now = Carbon::now();
// 先清理已过期的 active 座驾,避免旧状态影响替换判断。
UserRidePurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', $now)
->update(['status' => 'expired']);
$activeRide = UserRidePurchase::query()
->with('ride')
->where('user_id', $user->id)
->where('status', 'active')
->orderByDesc('expires_at')
->first();
$balanceAfter = $this->currencyService->deductGoldIfEnough(
$user,
(int) $item->price,
CurrencySource::RIDE_BUY,
"购买聊天室座驾:{$item->name}",
$roomId,
);
if ($balanceAfter === null) {
return false;
}
if ($activeRide && (int) $activeRide->ride_id === (int) $item->id) {
$baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now)
? $activeRide->expires_at
: $now;
// 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。
$activeRide->update(['status' => 'cancelled']);
UserRidePurchase::create([
'user_id' => $user->id,
'ride_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $baseTime->copy()->addDays($days),
]);
return true;
}
if ($activeRide) {
// 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。
$activeRide->update(['status' => 'cancelled']);
}
UserRidePurchase::create([
'user_id' => $user->id,
'ride_id' => $item->id,
'status' => 'active',
'price_paid' => $item->price,
'expires_at' => $now->copy()->addDays($days),
]);
return true;
});
if (! $purchased) {
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->fresh()->jjb} 金币。"];
}
return [
'ok' => true,
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。",
'current_ride' => $this->formatCurrentRide($user->fresh()),
];
}
/**
* 构建进房座驾欢迎语与特效载荷。
*
* @return array<string, string>|null
*/
public function buildPresencePayload(User $user): ?array
{
$purchase = $this->currentRide($user);
$item = $purchase?->ride;
$rideKey = $item?->rideKey();
if (! $purchase || ! $item || ! $rideKey) {
return null;
}
$template = trim((string) ($item->welcome_message ?: '【{name}】驾驶【{ride}】震撼入场,全场请注意!'));
$rendered = strtr($template, [
'{name}' => $user->username,
'{ride}' => $item->name,
]);
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
$effectUserInfo = "用户 {$user->username} · {$identitySummary['inline']}";
return [
'ride_key' => $rideKey,
'ride_name' => $item->name,
'ride_icon' => (string) ($item->icon ?? '🚘'),
'effect_title' => "乘坐【{$item->name}】闪亮登场",
'effect_user_info' => $effectUserInfo,
'identity_text' => ChatContentSanitizer::htmlText($identitySummary['inline']),
'welcome_text' => ChatContentSanitizer::htmlText($rendered),
];
}
/**
* 格式化单条座驾购买记录。
*
* @return array<string, mixed>
*/
private function formatPurchase(UserRidePurchase $purchase): array
{
$item = $purchase->ride;
return [
'id' => $purchase->id,
'status' => $purchase->status,
'price_paid' => (int) $purchase->price_paid,
'expires_at' => $purchase->expires_at?->toDateTimeString(),
'used_at' => $purchase->used_at?->toDateTimeString(),
'created_at' => $purchase->created_at?->toDateTimeString(),
'item' => $item ? $this->formatItem($item) : null,
];
}
}
+5 -5
View File
@@ -30,7 +30,7 @@ class ShopService
*/
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
{
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) {
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR && !$item->isDecoration()) {
return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
}
@@ -49,10 +49,10 @@ class ShopService
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
// ── 个人装扮购买(委托给 DecorationService)───────────────
'msg_bubble' => $this->decorationService->purchase($user, $item),
'msg_name_color' => $this->decorationService->purchase($user, $item),
'msg_text_color' => $this->decorationService->purchase($user, $item),
'avatar_frame' => $this->decorationService->purchase($user, $item),
'msg_bubble' => $this->decorationService->purchase($user, $item, $quantity),
'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity),
'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity),
'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity),
default => ['ok' => false, 'message' => '未知商品类型'],
};
}
+32 -1
View File
@@ -1,5 +1,35 @@
<?php
$normalizeReverbAllowedOrigins = static function (?string $rawOrigins): array {
if ($rawOrigins === null || trim($rawOrigins) === '') {
return ['*'];
}
$normalizedOrigins = [];
foreach (explode(',', $rawOrigins) as $origin) {
$candidate = trim($origin);
if ($candidate === '') {
continue;
}
if ($candidate === '*') {
return ['*'];
}
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
$host = parse_url('http://'.$candidate, PHP_URL_HOST);
}
$normalizedOrigins[] = is_string($host) && $host !== '' ? $host : $candidate;
}
return array_values(array_unique($normalizedOrigins));
};
return [
/*
@@ -82,7 +112,8 @@ return [
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
// Reverb 内部按 Origin 的主机名比对,这里统一转成 host,避免把完整 URL 写进 .env 后被误拒绝。
'allowed_origins' => $normalizeReverbAllowedOrigins(env('REVERB_ALLOWED_ORIGIN')),
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:创建猜成语题库表
*
* 存储成语题目及答案,管理员可在后台增删改。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idioms 表。
*/
public function up(): void
{
Schema::create('idioms', function (Blueprint $table) {
$table->id();
$table->string('answer', 50)->comment('成语答案');
$table->string('hint', 255)->comment('谜语线索提示');
$table->boolean('is_active')->default(true)->comment('是否启用');
$table->unsignedSmallInteger('sort')->default(0)->comment('排序');
$table->timestamps();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idioms');
}
};
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:创建猜成语游戏回合表
*
* 每次出题对应一个回合,记录答题状态、奖励和获胜者。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idiom_game_rounds 表。
*/
public function up(): void
{
Schema::create('idiom_game_rounds', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('room_id')->comment('游戏所在房间 ID');
$table->unsignedBigInteger('idiom_id')->comment('当前题目 ID');
$table->string('status', 20)->default('pending')->comment('状态:pending/active/answered/ended');
$table->integer('reward_gold')->default(0)->comment('答对奖励金币');
$table->integer('reward_exp')->default(0)->comment('答对奖励经验');
$table->unsignedBigInteger('winner_id')->nullable()->comment('答对用户 ID');
$table->string('winner_username', 50)->nullable()->comment('答对用户名');
$table->timestamp('started_at')->nullable()->comment('开始答题时间');
$table->timestamp('ended_at')->nullable()->comment('结束答题时间');
$table->timestamps();
$table->foreign('idiom_id')->references('id')->on('idioms')->onDelete('cascade');
$table->index('status');
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idiom_game_rounds');
}
};
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:将猜成语数据结构升级为猜谜活动通用结构
*
* 为题库增加题型字段,为回合增加 quiz_type 与复合索引,
* 兼容既有“猜成语”数据并为脑筋急转弯题型预留能力。
*/
use App\Models\Riddle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:执行表结构升级并补齐历史数据默认值。
*/
public function up(): void
{
Schema::table('idioms', function (Blueprint $table): void {
if (! Schema::hasColumn('idioms', 'type')) {
$table->string('type', 30)->default(Riddle::TYPE_IDIOM)->after('id')->comment('题型:idiom/brain_teaser');
}
});
// 历史成语题默认归类到 idiom,保证旧数据无需人工修复。
DB::table('idioms')
->whereNull('type')
->orWhere('type', '')
->update(['type' => Riddle::TYPE_IDIOM]);
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
if (! Schema::hasColumn('idiom_game_rounds', 'quiz_type')) {
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM)->after('idiom_id')->comment('回合题型:idiom/brain_teaser');
}
});
// 历史回合默认按成语题处理,确保旧记录仍可正常展示与过期结算。
DB::table('idiom_game_rounds')
->whereNull('quiz_type')
->orWhere('quiz_type', '')
->update(['quiz_type' => Riddle::TYPE_IDIOM]);
Schema::table('idioms', function (Blueprint $table): void {
$table->index(['type', 'is_active'], 'idioms_type_is_active_index');
});
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->index(['room_id', 'quiz_type', 'status'], 'idiom_rounds_room_type_status_index');
$table->index(['room_id', 'quiz_type', 'id'], 'idiom_rounds_room_type_id_index');
});
}
/**
* 方法功能:回滚猜谜活动通用结构升级。
*/
public function down(): void
{
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->dropIndex('idiom_rounds_room_type_status_index');
$table->dropIndex('idiom_rounds_room_type_id_index');
$table->dropColumn('quiz_type');
});
Schema::table('idioms', function (Blueprint $table): void {
$table->dropIndex('idioms_type_is_active_index');
$table->dropColumn('type');
});
}
};
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:为公共回合型游戏补充 room_id 字段
*
* 让百家乐、赛马、彩票和神秘箱子可以按房间独立开局、查询与广播。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移。
*/
public function up(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('horse_races', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('horse_races', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
}
};
@@ -0,0 +1,122 @@
<?php
/**
* 文件功能:创建聊天室座驾独立数据表。
*
* 座驾定义和用户座驾购买记录独立于商店模块,支持后台单独配置价格、使用天数和欢迎语。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:创建 rides user_ride_purchases 并预置默认座驾。
*/
public function up(): void
{
Schema::create('rides', function (Blueprint $table) {
$table->id();
$table->string('name', 100)->comment('座驾名称');
$table->string('slug', 100)->unique()->comment('座驾唯一标识,格式 ride_key');
$table->string('effect_key', 50)->unique()->comment('全屏特效 key');
$table->string('icon', 20)->default('🚘')->comment('座驾图标');
$table->text('description')->nullable()->comment('座驾说明');
$table->unsignedInteger('price')->default(0)->comment('购买价格');
$table->unsignedInteger('duration_days')->default(7)->comment('使用天数');
$table->string('welcome_message', 255)->nullable()->comment('入场欢迎语模板');
$table->unsignedInteger('sort_order')->default(0)->comment('排序权重');
$table->boolean('is_active')->default(true)->comment('是否上架');
$table->timestamps();
});
Schema::create('user_ride_purchases', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('ride_id')->constrained('rides')->cascadeOnDelete();
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active')->comment('座驾状态');
$table->unsignedInteger('price_paid')->default(0)->comment('实际支付金币');
$table->timestamp('expires_at')->nullable()->comment('到期时间');
$table->timestamp('used_at')->nullable()->comment('首次使用时间');
$table->timestamps();
$table->index(['user_id', 'status', 'expires_at']);
});
$this->seedDefaultRides();
}
/**
* 方法功能:删除座驾独立数据表。
*/
public function down(): void
{
Schema::dropIfExists('user_ride_purchases');
Schema::dropIfExists('rides');
}
/**
* 方法功能:写入当前默认四个全屏座驾。
*/
private function seedDefaultRides(): void
{
$now = now();
$rides = [
[
'name' => '歼-35隐身战机',
'slug' => 'ride_j35',
'effect_key' => 'j35',
'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。',
'icon' => '🛩️',
'price' => 18888,
'duration_days' => 7,
'sort_order' => 80,
'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!',
],
[
'name' => '99A主战坦克',
'slug' => 'ride_99a',
'effect_key' => '99a',
'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。',
'icon' => '🛡️',
'price' => 18888,
'duration_days' => 7,
'sort_order' => 81,
'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!',
],
[
'name' => '东风-5C战略导弹',
'slug' => 'ride_df5c',
'effect_key' => 'df5c',
'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。',
'icon' => '🚀',
'price' => 28888,
'duration_days' => 7,
'sort_order' => 82,
'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!',
],
[
'name' => '福建舰航母',
'slug' => 'ride_fujian',
'effect_key' => 'fujian',
'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。',
'icon' => '⚓',
'price' => 28888,
'duration_days' => 7,
'sort_order' => 83,
'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!',
],
];
foreach ($rides as $ride) {
DB::table('rides')->insert($ride + [
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
};
@@ -0,0 +1,52 @@
<?php
/**
* 文件功能:清理旧版商店座驾数据。
*
* 如果环境曾跑过“座驾复用商店”的旧迁移,本迁移会移除商店里的座驾字段和残留记录。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:删除 shop_items 中旧座驾记录并移除欢迎语字段。
*/
public function up(): void
{
DB::table('shop_items')->where('slug', 'like', 'ride_%')->delete();
if (DB::getDriverName() === 'mysql' && $this->shopItemTypeContainsRide()) {
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'");
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
}
if (Schema::hasColumn('shop_items', 'welcome_message')) {
Schema::table('shop_items', function (Blueprint $table) {
$table->dropColumn('welcome_message');
});
}
}
/**
* 方法功能:清理迁移不恢复旧商店座驾结构。
*/
public function down(): void
{
//
}
/**
* 方法功能:判断当前 shop_items.type 枚举是否包含旧座驾类型。
*/
private function shopItemTypeContainsRide(): bool
{
$column = DB::selectOne("SHOW COLUMNS FROM `shop_items` LIKE 'type'");
return $column && str_contains((string) $column->Type, "'ride'");
}
};
+39
View File
@@ -32,6 +32,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统每隔一段时间自动开一局,玩家在倒计时内押注大/小/豹子,骰子结果决定胜负。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single', // 参与房间模式
'room_ids' => [1], // 参与房间列表
'interval_minutes' => 2, // 多少分钟开一局
'bet_window_seconds' => 60, // 每局押注窗口(秒)
'min_bet' => 100, // 最低押注金币
@@ -51,6 +53,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币转动老虎机,三列图案匹配可获得不同倍率奖励,三个7大奖全服广播。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'cost_per_spin' => 100, // 每次旋转消耗
'house_edge_percent' => 15, // 庄家边际(%
'daily_limit' => 100, // 每日最多转动次数(0=不限)
@@ -70,6 +74,8 @@ class GameConfigSeeder extends Seeder
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时)
'claim_window_seconds' => 60, // 领取窗口(秒)
@@ -91,6 +97,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统定期举办赛马,用户在倒计时内下注,按注池赔率结算,跑马过程 WebSocket 实时播报。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 30, // 多少分钟一场
'bet_window_seconds' => 90, // 押注窗口(秒)
'race_duration' => 30, // 跑马动画时长(秒)
@@ -110,6 +118,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一次免费占卜,系统生成玄学签文并赋予当日加成效果(幸运/倒霉)。额外占卜消耗金币。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'free_count_per_day' => 1, // 每日免费次数
'extra_cost' => 500, // 额外次数消耗金币
'buff_duration_hours' => 24, // 加成效果持续时间
@@ -128,6 +138,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5, // 每次抛竿消耗金币
'fishing_wait_min' => 8, // 浮漂等待最短秒数
'fishing_wait_max' => 15, // 浮漂等待最长秒数
@@ -143,6 +155,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
// ── 开奖时间 ──
'draw_hour' => 20, // 每天几点开奖(24小时制)
'draw_minute' => 0, // 几分开奖
@@ -169,6 +183,31 @@ class GameConfigSeeder extends Seeder
'super_issue_inject' => 20000, // 超级期系统注入金额上限
],
],
// ─── 五子棋 ───────────────────────────────────────────────
[
'game_key' => 'gomoku',
'name' => '五子棋',
'icon' => '♟️',
'description' => 'PvP 对战/人机对战,房间内随时发起邀请,超时或认输均自动结算。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'pvp_reward' => 200, // PvP 胜利奖励金币
'pvp_invite_timeout' => 30, // PvP 邀请超时(秒)
'pvp_move_timeout' => 45, // 每步落子超时(秒)
'pvp_ready_timeout' => 20, // 对局准备超时(秒)
'pve_fee_level_1' => 50, // AI简单 入场费
'pve_reward_level_1' => 100, // AI简单 胜利奖励
'pve_fee_level_2' => 100, // AI普通 入场费
'pve_reward_level_2' => 200, // AI普通 胜利奖励
'pve_fee_level_3' => 200, // AI困难 入场费
'pve_reward_level_3' => 400, // AI困难 胜利奖励
'pve_fee_level_4' => 500, // AI专家 入场费
'pve_reward_level_4' => 1000, // AI专家 胜利奖励
],
],
];
foreach ($games as $game) {
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* 文件功能:猜谜活动旧 Seeder 兼容入口
*
* 兼容仍然使用 `IdiomSeeder` 名称的旧命令与旧文档,
* 实际执行逻辑委托给新的 RiddleSeeder。
*/
namespace Database\Seeders;
/**
* 类功能:兼容旧的 IdiomSeeder 调用入口。
*/
class IdiomSeeder extends RiddleSeeder
{
/**
* 方法功能:复用新的猜谜活动题库填充逻辑。
*/
public function run(): void
{
parent::run();
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
/**
* 文件功能:猜谜活动题库填充器
*
* 初始化猜谜活动配置、成语题库与脑筋急转弯题库数据。
* 使用 updateOrCreate 确保重复执行不影响已有数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Database\Seeders;
use App\Models\GameConfig;
use App\Models\Riddle;
use Illuminate\Database\Seeder;
/**
* 类功能:初始化猜谜活动配置、成语题库与脑筋急转弯题库。
*/
class RiddleSeeder extends Seeder
{
/**
* 填充猜谜活动配置与题库。
*/
public function run(): void
{
// ── 游戏配置(已存在则更新为统一猜谜活动结构) ──
GameConfig::updateOrCreate(
['game_key' => 'idiom'],
[
'name' => '猜谜活动',
'icon' => '🧩',
'description' => '管理员手动出题或系统定时自动出题,支持成语题与脑筋急转弯题,第一个答对的用户获得金币和经验奖励。',
'enabled' => false,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [1],
'type_configs' => [
Riddle::TYPE_IDIOM => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
Riddle::TYPE_BRAIN_TEASER => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
],
],
],
);
// ── 题库数据 ──
$idioms = [
['answer' => '画蛇添足', 'hint' => '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语'],
['answer' => '守株待兔', 'hint' => '🧩 农夫不干活,天天蹲树桩旁等天上掉馅饼。猜一成语'],
['answer' => '掩耳盗铃', 'hint' => '🧩 小偷以为捂住自己耳朵,别人就听不见铃铛响了。猜一成语'],
['answer' => '亡羊补牢', 'hint' => '🧩 羊圈破了个洞,羊跑了几只才想起修。猜一成语'],
['answer' => '刻舟求剑', 'hint' => '🧩 船上做了个记号就能在江里找回剑?猜一成语'],
['answer' => '叶公好龙', 'hint' => '🧩 家里到处画龙雕龙,真龙来了却吓得屁滚尿流。猜一成语'],
['answer' => '狐假虎威', 'hint' => '🧩 狐狸走在老虎前面,小动物们到底怕谁?猜一成语'],
['answer' => '井底之蛙', 'hint' => '🧩 住在井里,却以为天空只有井口那么大。猜一成语'],
['answer' => '对牛弹琴', 'hint' => '🧩 弹了一首好曲子,听众却在低头吃草。猜一成语'],
['answer' => '杯弓蛇影', 'hint' => '🧩 酒杯里有个弯弯曲曲的东西在动,吓得大病一场。猜一成语'],
['answer' => '鹤立鸡群', 'hint' => '🧩 一只长腿白鸟混进了院子里的小黄鸡中。猜一成语'],
['answer' => '画龙点睛', 'hint' => '🧩 最后两笔点上后,墙上的龙竟然飞走了。猜一成语'],
['answer' => '鸡飞蛋打', 'hint' => '🧩 偷鸡不成,竹篮打水一场空。猜一成语'],
['answer' => '马到成功', 'hint' => '🧩 战旗一挥,马蹄刚踏出去就赢了。猜一成语'],
['answer' => '虎头蛇尾', 'hint' => '🧩 开头气势如虹,结尾却草草收场。猜一成语'],
['answer' => '龙飞凤舞', 'hint' => '🧩 王羲之喝醉了,笔下的字好像要飞起来。猜一成语'],
['answer' => '鸡犬不宁', 'hint' => '🧩 闹得天翻地覆,连院子里的小动物都不得安生。猜一成语'],
['answer' => '狼吞虎咽', 'hint' => '🧩 饿了三天的壮汉看到一碗面。猜一成语'],
['answer' => '鱼目混珠', 'hint' => '🧩 地摊上有人拿玻璃球当夜明珠卖。猜一成语'],
['answer' => '鼠目寸光', 'hint' => '🧩 只看得到眼前一寸的路,走远就迷路。猜一成语'],
['answer' => '九牛一毛', 'hint' => '🧩 亿万富翁丢了一分钱,连弯腰捡都懒得捡。猜一成语'],
['answer' => '如鱼得水', 'hint' => '🧩 刘备说:有了诸葛亮,就像什么回到了什么里?猜一成语'],
['answer' => '鸟语花香', 'hint' => '🧩 春天来了,你能听到什么、闻到什么?猜一成语'],
['answer' => '风花雪月', 'hint' => '🧩 才子佳人写的诗,看起来很美,其实没什么实际内容。猜一成语'],
['answer' => '山清水秀', 'hint' => '🧩 桂林漓江边上,你能看到什么颜色?猜一成语'],
['answer' => '水落石出', 'hint' => '🧩 水位下降后,河床下的东西藏不住了。猜一成语'],
['answer' => '火中取栗', 'hint' => '🧩 猫爪子被烫伤了,猴子却在旁边偷笑吃栗子。猜一成语'],
['answer' => '石破天惊', 'hint' => '🧩 一块石头裂开,伴随着一声巨响,所有人都惊呆了。猜一成语'],
['answer' => '翻天覆地', 'hint' => '🧩 孙悟空大闹天宫后,凌霄宝殿变成了什么样?猜一成语'],
['answer' => '开天辟地', 'hint' => '🧩 盘古拿着斧头,对着混沌用力一劈。猜一成语'],
['answer' => '惊天动地', 'hint' => '🧩 汶川大地震那天,连天上的云都在颤抖。猜一成语'],
['answer' => '花好月圆', 'hint' => '🧩 婚礼请柬上最常见的四个字祝福。猜一成语'],
['answer' => '冰清玉洁', 'hint' => '🧩 她的品格像冬天的什么和深山里的什么?猜一成语'],
['answer' => '海阔天空', 'hint' => '🧩 走出小县城,来到大都市,才发现世界有多大。猜一成语'],
['answer' => '雪中送炭', 'hint' => '🧩 大冬天你最需要什么?有人偏偏就送来了什么。猜一成语'],
['answer' => '锦上添花', 'hint' => '🧩 已经够漂亮了,还要再点缀一下。猜一成语'],
['answer' => '落井下石', 'hint' => '🧩 有人掉坑里了,你不救也就算了,还往下面扔砖头。猜一成语'],
['answer' => '纸上谈兵', 'hint' => '🧩 赵括说起兵法头头是道,上了战场却一败涂地。猜一成语'],
['answer' => '胸有成竹', 'hint' => '🧩 画家文同还没下笔,心里已经有了完整的竹子。猜一成语'],
['answer' => '一帆风顺', 'hint' => '🧩 出海前最想听到的一句祝福。猜一成语'],
['answer' => '水到渠成', 'hint' => '🧩 不用刻意挖渠,水自然会找到它的路。猜一成语'],
['answer' => '百发百中', 'hint' => '🧩 养由基站在百步之外射柳叶,箭无虚发。猜一成语'],
['answer' => '一鸣惊人', 'hint' => '🧩 齐威王三年不上朝不理政,一出手就震惊了各国。猜一成语'],
['answer' => '对答如流', 'hint' => '🧩 老师提问,他不用思考就说出答案,像江水一样不停。猜一成语'],
['answer' => '顺手牵羊', 'hint' => '🧩 路过别人家门口,看到一只羊没人看管……猜一成语'],
['answer' => '勇往直前', 'hint' => '🧩 前面是刀山火海,他眼睛都不眨一下继续走。猜一成语'],
['answer' => '百折不挠', 'hint' => '🧩 被摔倒一百次,第一百零一次依然站起来。猜一成语'],
['answer' => '持之以恒', 'hint' => '🧩 水滴不断地滴在石头上,千年后石头被滴穿了。猜一成语'],
['answer' => '知己知彼', 'hint' => '🧩 孙子兵法说:了解自己又了解对方,百战不殆。猜一成语'],
['answer' => '四面楚歌', 'hint' => '🧩 项羽被包围在垓下,四面八方都传来熟悉的歌声。猜一成语'],
['answer' => '草木皆兵', 'hint' => '🧩 淝水之战中,苻坚看到山上的草和树,都以为是敌军。猜一成语'],
['answer' => '一箭双雕', 'hint' => '🧩 长孙晟一箭射出去,两只大鸟应声落地。猜一成语'],
['answer' => '背水一战', 'hint' => '🧩 韩信把军队放在河边列阵,断了所有人的退路。猜一成语'],
['answer' => '声东击西', 'hint' => '🧩 明明要打左边,却装作全力进攻右边。猜一成语'],
['answer' => '调虎离山', 'hint' => '🧩 想占老虎的老窝,得先把老虎引出去。猜一成语'],
['answer' => '空城计', 'hint' => '🧩 诸葛亮大开城门,在城楼上弹琴,敌军反而不敢进城。猜一成语'],
['answer' => '缓兵之计', 'hint' => '🧩 打不过怎么办?先假装谈判争取时间。猜一成语'],
['answer' => '卧薪尝胆', 'hint' => '🧩 越王勾践每天睡在柴堆上,还要舔一口苦胆。猜一成语'],
['answer' => '三顾茅庐', 'hint' => '🧩 刘备为了请一个人出山,大冬天跑了三趟。猜一成语'],
['answer' => '望梅止渴', 'hint' => '🧩 曹操说前面有片梅林,士兵们嘴里都开始流口水了。猜一成语'],
['answer' => '七步成诗', 'hint' => '🧩 曹植被亲哥哥逼着在很短的时间里写诗保命。猜一成语'],
['answer' => '才高八斗', 'hint' => '🧩 谢灵运说:天下才华共一石,曹植独占八斗。猜一成语'],
['answer' => '入木三分', 'hint' => '🧩 王羲之在木板上写字,墨迹渗入木头三分深。猜一成语'],
['answer' => '一字千金', 'hint' => '🧩 吕不韦悬赏:谁能给《吕氏春秋》改动一个字,赏千金。猜一成语'],
['answer' => '一诺千金', 'hint' => '🧩 季布的一句话,比黄金千两还贵重。猜一成语'],
['answer' => '半途而废', 'hint' => '🧩 走到半山腰觉得累了,就转身下山了。猜一成语'],
['answer' => '实事求是', 'hint' => '🧩 不夸大不缩小,是什么就是什么。猜一成语'],
['answer' => '量力而行', 'hint' => '🧩 蚂蚁想搬走大象?先掂量掂量自己有几斤几两。猜一成语'],
['answer' => '自相矛盾', 'hint' => '🧩 楚国人夸自己的矛能刺穿任何盾,又夸自己的盾什么都刺不穿。猜一成语'],
['answer' => '买椟还珠', 'hint' => '🧩 花大价钱买了精美盒子,却把里面的珍珠还给了老板。猜一成语'],
['answer' => '杞人忧天', 'hint' => '🧩 有个怪人天天担心天会塌下来,地会陷下去。猜一成语'],
['answer' => '画饼充饥', 'hint' => '🧩 饿了看着墙上画的大饼,假装自己吃饱了。猜一成语'],
['answer' => '空中楼阁', 'hint' => '🧩 没有地基,没有支柱,一座房子浮在云端。猜一成语'],
['answer' => '异想天开', 'hint' => '🧩 有人想在天上种田,海里摘星星。猜一成语'],
['answer' => '千方百计', 'hint' => '🧩 为了达到目的,把所有能想到的办法都用上了。猜一成语'],
['answer' => '不计其数', 'hint' => '🧩 海边的沙子有多少?天上的星星有多少?猜一成语'],
['answer' => '成千上万', 'hint' => '🧩 体育场里坐满了人,放眼望去黑压压一片。猜一成语'],
['answer' => '独一无二', 'hint' => '🧩 世界上没有第二个一模一样的你。猜一成语'],
['answer' => '举世闻名', 'hint' => '🧩 地球上有几个人,就有几个人知道他。猜一成语'],
['answer' => '名不虚传', 'hint' => '🧩 听说很好吃,亲自尝了一口,果然名不虚传。不对,我说的就是~猜一成语'],
['answer' => '名副其实', 'hint' => '🧩 大家都叫他「神算子」,他算卦确实从没错过。猜一成语'],
['answer' => '引人入胜', 'hint' => '🧩 这本书太好看了,一翻开就停不下来,像被吸进去了一样。猜一成语'],
['answer' => '身临其境', 'hint' => '🧩 VR眼镜里的世界太真实了,好像自己真的在里面。猜一成语'],
['answer' => '迫不及待', 'hint' => '🧩 快递到了,鞋都没穿就跑下楼去拿。猜一成语'],
['answer' => '争先恐后', 'hint' => '🧩 超市大减价,大门一开所有人都在往前冲。猜一成语'],
['answer' => '如履薄冰', 'hint' => '🧩 每一步都小心翼翼,生怕脚下突然裂开。猜一成语'],
['answer' => '小心翼翼', 'hint' => '🧩 手里捧着一个装满水的气球,大气都不敢喘。猜一成语'],
['answer' => '心花怒放', 'hint' => '🧩 听到被录取的消息,心里像有千万朵花同时绽放。猜一成语'],
['answer' => '欢天喜地', 'hint' => '🧩 过年了,小孩拿到压岁钱在大街上又蹦又跳。猜一成语'],
['answer' => '眉开眼笑', 'hint' => '🧩 嘴角上扬,眼角弯弯,整张脸都在表达开心。猜一成语'],
['answer' => '手舞足蹈', 'hint' => '🧩 听到最喜欢的歌,身体不由自主地跟着节奏动起来。猜一成语'],
['answer' => '兴高采烈', 'hint' => '🧩 中彩票后,他整个人像打了鸡血一样。猜一成语'],
['answer' => '得意忘形', 'hint' => '🧩 考了第一名就开始翘尾巴,连走路姿势都不一样了。猜一成语'],
['answer' => '怒气冲天', 'hint' => '🧩 他气得头顶冒烟,火焰快要烧到天花板了。猜一成语'],
['answer' => '火冒三丈', 'hint' => '🧩 听到这个消息,他脑袋上的火苗蹿得比房子还高。猜一成语'],
['answer' => '心急如焚', 'hint' => '🧩 等结果的那几分钟,心脏像放在火上烤。猜一成语'],
['answer' => '愁眉苦脸', 'hint' => '🧩 眉头拧成麻花,嘴角向下弯,整张脸写满了不开心。猜一成语'],
['answer' => '泪如雨下', 'hint' => '🧩 他哭得比外面的倾盆大雨还要猛。猜一成语'],
['answer' => '目瞪口呆', 'hint' => '🧩 看到 UFO 从头顶飞过,他张大了嘴巴一句话也说不出来。猜一成语'],
['answer' => '惊弓之鸟', 'hint' => '🧩 被弓箭射过一次的鸟,听到弓弦声就吓得乱飞。猜一成语'],
['answer' => '千钧一发', 'hint' => '🧩 一万斤的重物吊在一根头发丝上,随时会断。猜一成语'],
['answer' => '愚公移山', 'hint' => '🧩 九十岁老头发誓要搬走门口的两座大山,子子孙孙无穷匮也。猜一成语'],
];
foreach ($idioms as $index => $idiom) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_IDIOM,
'answer' => $idiom['answer'],
],
[
'hint' => $idiom['hint'],
'is_active' => true,
'sort' => $index + 1,
],
);
}
// 新增脑筋急转弯题库,供猜谜活动切换题型时直接使用。
$brainTeasers = [
['answer' => '影子', 'hint' => '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?'],
['answer' => '回声', 'hint' => '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?'],
['answer' => '镜子', 'hint' => '🧠 你哭它也哭,你笑它也笑,但它永远不会先动,是什么?'],
['answer' => '口罩', 'hint' => '🧠 戴在脸上不是面具,遮住口鼻保健康,是什么?'],
['answer' => '手套', 'hint' => '🧠 五个小兄弟住两套房,冬天最爱穿,是什么?'],
['answer' => '袜子', 'hint' => '🧠 一对好朋友,天天躲鞋里,是什么?'],
['answer' => '鞋带', 'hint' => '🧠 两条细长蛇,天天趴鞋上,不打结鞋就跑,是什么?'],
['answer' => '雨伞', 'hint' => '🧠 下雨天开花,晴天就收起,是什么?'],
['answer' => '帽子', 'hint' => '🧠 不长头发却总爱站在头顶,是什么?'],
['answer' => '围巾', 'hint' => '🧠 冬天挂脖子,既不是项链也不是绳子,是什么?'],
['answer' => '口红', 'hint' => '🧠 不是彩笔,却常在嘴上画颜色,是什么?'],
['answer' => '牙刷', 'hint' => '🧠 头上长毛,天天进嘴里干活,是什么?'],
['answer' => '牙膏', 'hint' => '🧠 白白一条小胖虫,挤出来给牙刷帮忙,是什么?'],
['answer' => '肥皂', 'hint' => '🧠 越洗越瘦,越搓泡泡越多,是什么?'],
['answer' => '毛巾', 'hint' => '🧠 洗完脸后最爱找它抱一抱,是什么?'],
['answer' => '梳子', 'hint' => '🧠 一排小牙齿,不吃饭,只理头发,是什么?'],
['answer' => '吹风机', 'hint' => '🧠 会吹热风的小机器,洗完头总请它帮忙,是什么?'],
['answer' => '指甲刀', 'hint' => '🧠 身子很小嘴巴很硬,专门啃手指头,是什么?'],
['answer' => '钥匙', 'hint' => '🧠 个子不大本事大,能把锁头嘴巴打开,是什么?'],
['answer' => '锁', 'hint' => '🧠 一张铁嘴不吃饭,钥匙一来才张口,是什么?'],
['answer' => '门铃', 'hint' => '🧠 客人到门口,不敲门先叫唤,是什么?'],
['answer' => '电梯', 'hint' => '🧠 关上门就上下跑,不是汽车不上路,是什么?'],
['answer' => '楼梯', 'hint' => '🧠 一节一节往上走,不会动却能送人上楼,是什么?'],
['answer' => '窗户', 'hint' => '🧠 墙上开个洞,白天爱看风景,晚上爱看月亮,是什么?'],
['answer' => '窗帘', 'hint' => '🧠 白天拉开,晚上关上,帮房间遮眼睛,是什么?'],
['answer' => '镜框', 'hint' => '🧠 不会照人,却总抱着照片或镜子,是什么?'],
['answer' => '桌子', 'hint' => '🧠 四条腿不会走,肚皮平平能放东西,是什么?'],
['answer' => '椅子', 'hint' => '🧠 有脚不走路,专门让人坐,是什么?'],
['answer' => '沙发', 'hint' => '🧠 胖胖软软客厅王,大家累了都爱躺,是什么?'],
['answer' => '床', 'hint' => '🧠 白天安安静静,晚上最忙,是什么?'],
['answer' => '枕头', 'hint' => '🧠 软绵绵的小山包,睡觉时总垫在头下,是什么?'],
['answer' => '被子', 'hint' => '🧠 白天叠成豆腐块,晚上张开抱住你,是什么?'],
['answer' => '闹钟', 'hint' => '🧠 不会说早安,却总把你从梦里拽出来,是什么?'],
['answer' => '日历', 'hint' => '🧠 每过一天就瘦一张,是什么?'],
['answer' => '时钟', 'hint' => '🧠 三根兄弟赛跑,一圈一圈不停歇,是什么?'],
['answer' => '手机', 'hint' => '🧠 不长嘴巴却能说话,不长耳朵却能听见,是什么?'],
['answer' => '电话', 'hint' => '🧠 两地相隔很远,也能贴耳说悄悄话,是什么?'],
['answer' => '电视', 'hint' => '🧠 小小方盒子,里面天天演大戏,是什么?'],
['answer' => '遥控器', 'hint' => '🧠 不用走过去,按按它就能让电视听话,是什么?'],
['answer' => '电脑', 'hint' => '🧠 肚子里装知识,手指一敲就干活,是什么?'],
['answer' => '键盘', 'hint' => '🧠 一排排小方块,不是钢琴也能打字,是什么?'],
['answer' => '鼠标', 'hint' => '🧠 名字像老鼠,却最怕猫,天天趴桌上,是什么?'],
['answer' => '耳机', 'hint' => '🧠 一左一右挂耳边,音乐只给你一个人听,是什么?'],
['answer' => '音箱', 'hint' => '🧠 肚里藏着喇叭,最会放大声音,是什么?'],
['answer' => '充电器', 'hint' => '🧠 手机饿了它喂饭,是什么?'],
['answer' => '电池', 'hint' => '🧠 个子小,电量大,很多机器靠它活,是什么?'],
['answer' => '电灯', 'hint' => '🧠 太阳下班它上岗,是什么?'],
['answer' => '灯泡', 'hint' => '🧠 玻璃肚里藏火苗,黑夜一亮像白天,是什么?'],
['answer' => '蜡烛', 'hint' => '🧠 有泪不会哭,有火不会叫,越烧越短,是什么?'],
['answer' => '火柴', 'hint' => '🧠 头戴红帽子,脾气特别火,一擦就冒火星,是什么?'],
['answer' => '打火机', 'hint' => '🧠 小盒子脾气爆,拇指一按就出火,是什么?'],
['answer' => '冰箱', 'hint' => '🧠 肚子大又冷,专门帮食物避暑,是什么?'],
['answer' => '空调', 'hint' => '🧠 夏天送凉风,冬天送暖风,挂在墙上最勤快,是什么?'],
['answer' => '风扇', 'hint' => '🧠 没有翅膀也会转,专给人送风,是什么?'],
['answer' => '洗衣机', 'hint' => '🧠 不长手,却特别会洗衣服,是什么?'],
['answer' => '熨斗', 'hint' => '🧠 衣服皱巴巴,它一来就服服帖帖,是什么?'],
['answer' => '微波炉', 'hint' => '🧠 剩饭剩菜进去转几圈,就又热乎了,是什么?'],
['answer' => '电饭煲', 'hint' => '🧠 白米进去,香饭出来,是什么?'],
['answer' => '锅', 'hint' => '🧠 黑脸大肚子,天天在灶台上唱歌,是什么?'],
['answer' => '筷子', 'hint' => '🧠 两个瘦兄弟,合作夹饭菜,是什么?'],
['answer' => '勺子', 'hint' => '🧠 有个圆脑袋,喝汤最拿手,是什么?'],
['answer' => '碗', 'hint' => '🧠 圆圆小肚皮,最爱装米饭和汤,是什么?'],
['answer' => '盘子', 'hint' => '🧠 扁扁一张脸,端菜最稳,是什么?'],
['answer' => '杯子', 'hint' => '🧠 不会说话却总装水,是什么?'],
['answer' => '水壶', 'hint' => '🧠 肚子能装水,嘴巴细细长长,是什么?'],
['answer' => '吸管', 'hint' => '🧠 不用嘴碰杯,也能把饮料送进肚,是什么?'],
['answer' => '铅笔', 'hint' => '🧠 身子细细穿木衣,肚里黑黑会写字,是什么?'],
['answer' => '橡皮', 'hint' => '🧠 不会写字专会擦,哪里写错它就上,是什么?'],
['answer' => '尺子', 'hint' => '🧠 身子直直会量长短,还能帮人画直线,是什么?'],
['answer' => '书包', 'hint' => '🧠 不会走路却天天背着书上学,是什么?'],
['answer' => '课本', 'hint' => '🧠 不会说话却肚子里全是知识,是什么?'],
['answer' => '黑板', 'hint' => '🧠 一张大黑脸,粉笔天天在上面写字,是什么?'],
['answer' => '粉笔', 'hint' => '🧠 白白瘦瘦,最爱在黑板上留下痕迹,是什么?'],
['answer' => '粉笔擦', 'hint' => '🧠 不会写字却专门抹掉黑板上的字,是什么?'],
['answer' => '书签', 'hint' => '🧠 个子小小住书里,帮你记住看到哪一页,是什么?'],
['answer' => '放大镜', 'hint' => '🧠 小东西一到它眼前,立刻显得很大,是什么?'],
['answer' => '望远镜', 'hint' => '🧠 明明站在原地,却能看见很远的东西,是什么?'],
['answer' => '相机', 'hint' => '🧠 不会画画却能把风景留住,是什么?'],
['answer' => '照片', 'hint' => '🧠 不会动不会说,却能把昨天留下来,是什么?'],
['answer' => '地图', 'hint' => '🧠 不出门也能带你认识世界,是什么?'],
['answer' => '地球仪', 'hint' => '🧠 一个蓝色大圆球,抱着全世界,是什么?'],
['answer' => '篮球', 'hint' => '🧠 穿着橙色外衣,最爱往框里钻,是什么?'],
['answer' => '足球', 'hint' => '🧠 用脚最喜欢的圆朋友,是什么?'],
['answer' => '羽毛球', 'hint' => '🧠 头圆圆,尾巴白白,飞起来像小鸟,是什么?'],
['answer' => '乒乓球', 'hint' => '🧠 白白小圆豆,桌上跳来跳去,是什么?'],
['answer' => '跳绳', 'hint' => '🧠 一根长线会转圈,小朋友最爱跳过去,是什么?'],
['answer' => '秋千', 'hint' => '🧠 坐上去前后飞,却飞不离原地,是什么?'],
['answer' => '风筝', 'hint' => '🧠 长着尾巴在天上飞,线一松就跑,是什么?'],
['answer' => '气球', 'hint' => '🧠 胖胖肚子装着气,手一松就想上天,是什么?'],
['answer' => '雪人', 'hint' => '🧠 冬天站院里,太阳一晒就瘦,是什么?'],
['answer' => '彩虹', 'hint' => '🧠 雨过天晴天上挂着七色桥,是什么?'],
['answer' => '云', 'hint' => '🧠 白天像棉花,风一吹就变样,是什么?'],
['answer' => '雾', 'hint' => '🧠 没下雨却湿漉漉,早晨最爱挡路,是什么?'],
['answer' => '霜', 'hint' => '🧠 不是雪却白在草上,太阳一出就化,是什么?'],
['answer' => '露珠', 'hint' => '🧠 清晨叶子上挂着一颗颗小珍珠,是什么?'],
['answer' => '月亮', 'hint' => '🧠 白天看不清,晚上天上挂银盘,是什么?'],
['answer' => '星星', 'hint' => '🧠 白天藏起来,晚上眨眼睛,是什么?'],
['answer' => '太阳', 'hint' => '🧠 白天值班最勤快,大家都靠它发光发热,是什么?'],
['answer' => '雷', 'hint' => '🧠 看不见摸不着,却能在天上大声打鼓,是什么?'],
['answer' => '闪电', 'hint' => '🧠 先看到一道亮鞭子,再听见轰隆隆,是什么?'],
];
foreach ($brainTeasers as $index => $brainTeaser) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_BRAIN_TEASER,
'answer' => $brainTeaser['answer'],
],
[
'hint' => $brainTeaser['hint'],
'is_active' => true,
'sort' => 1000 + $index + 1,
],
);
}
}
}
+75 -12
View File
@@ -9,6 +9,22 @@ NC='\033[0m'
PROJECT_ROOT="/www/wwwroot/chat.ay.lc" # <--- 确认这里的路径是否正确
export COMPOSER_ALLOW_SUPERUSER=1
SUPERVISORCTL_BIN=""
PHP_BIN=""
for candidate in /usr/bin/supervisorctl /usr/local/bin/supervisorctl /www/server/panel/pyenv/bin/supervisorctl; do
if [ -x "$candidate" ]; then
SUPERVISORCTL_BIN="$candidate"
break
fi
done
for candidate in /www/server/php/84/bin/php /usr/bin/php /usr/local/bin/php; do
if [ -x "$candidate" ]; then
PHP_BIN="$candidate"
break
fi
done
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} 🚀 Laravel 稳健更新脚本 (带严格检查) ${NC}"
@@ -16,14 +32,22 @@ echo -e "${BLUE}========================================${NC}"
cd "$PROJECT_ROOT" || { echo -e "${RED}❌ 无法进入项目目录:$PROJECT_ROOT${NC}"; exit 1; }
if [ -z "$PHP_BIN" ]; then
echo -e "${RED}❌ 未找到可用 PHP 可执行文件,请确认服务器已安装 PHP 8.4。${NC}"
exit 1
fi
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
echo -e "${BLUE}使用 PHP$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
echo -e "${YELLOW}[1/7] 拉取代码...${NC}"
echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
git checkout -- composer.lock package-lock.json 2>/dev/null || true
git fetch origin && git pull origin master
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
# 2. Composer Install (关键检查点)
echo -e "${YELLOW}[2/7] 安装依赖 (Composer)...${NC}"
echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction
COMPOSER_EXIT_CODE=$?
@@ -58,7 +82,7 @@ composer dump-autoload --no-dev --optimize --classmap-authoritative --no-interac
if [ $? -ne 0 ]; then echo -e "${RED}❌ Composer autoload 重建失败${NC}"; exit 1; fi
# 用当前 PHP 直接加载 Laravel autoload,提前暴露 vendor 缺文件 / 权限 / autoload 缓存问题
php -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;"
"$PHP_BIN" -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;"
if [ $? -ne 0 ]; then
echo -e "${RED}========================================${NC}"
echo -e "${RED} ❌ 致命错误:PHP 无法加载 vendor/autoload.php ${NC}"
@@ -68,27 +92,66 @@ if [ $? -ne 0 ]; then
fi
# 3. 前端构建
echo -e "${YELLOW}[3/7] 前端构建 (npm run build)...${NC}"
echo -e "${YELLOW}[3/8] 前端构建 (npm run build)...${NC}"
npm run build
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
# 4. 数据库迁移
echo -e "${YELLOW}[5/7] 数据库迁移...${NC}"
php artisan migrate --force
echo -e "${YELLOW}[4/8] 数据库迁移...${NC}"
"$PHP_BIN" artisan migrate --force
# 5. 优化
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
# 注意:optimize 命令内部已经包含了 config:cache, route:cache, event:cache,此处无须多余处理
php artisan optimize:clear && php artisan optimize && php artisan view:cache
"$PHP_BIN" artisan optimize:clear && "$PHP_BIN" artisan optimize && "$PHP_BIN" artisan view:cache
# 6. 重启 Horizon / 队列进程
echo -e "${YELLOW}[6/8] 重启 Horizon...${NC}"
php artisan horizon:terminate
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
if [ -n "$SUPERVISORCTL_BIN" ]; then
"$SUPERVISORCTL_BIN" restart horizon >/dev/null 2>&1 || "$SUPERVISORCTL_BIN" restart horizon:* >/dev/null 2>&1 || true
fi
# 7. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存
echo -e "${YELLOW}[7/8] 重载 PHP-FPM...${NC}"
# 7. 重启 Reverb,确保长驻 WebSocket 进程加载最新代码和配置
echo -e "${YELLOW}[7/8] 重启 Reverb...${NC}"
REVERB_RESTARTED=0
REVERB_CAN_AUTO_RESTART=0
SUPERVISOR_REVERB_TARGET=""
# 先探测是否存在可自动拉起 Reverb 的进程管理器,避免在纯手工启动场景下执行 reverb:restart 后把聊天室停掉。
if [ -n "$SUPERVISORCTL_BIN" ]; then
if "$SUPERVISORCTL_BIN" status reverb >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb"
REVERB_CAN_AUTO_RESTART=1
elif "$SUPERVISORCTL_BIN" status reverb:* >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb:*"
REVERB_CAN_AUTO_RESTART=1
fi
fi
# Laravel 官方文档说明:reverb:restart 适合在 Supervisor 等进程管理器托管下使用。
if [ "$REVERB_CAN_AUTO_RESTART" -eq 1 ]; then
"$PHP_BIN" artisan reverb:restart >/dev/null 2>&1
if [ $? -eq 0 ]; then
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已执行 php artisan reverb:restart。${NC}"
fi
# 若线上通过 Supervisor 托管 reverb:start,再补一次显式 restart,尽量确保进程被拉起。
if [ -n "$SUPERVISOR_REVERB_TARGET" ]; then
"$SUPERVISORCTL_BIN" restart "$SUPERVISOR_REVERB_TARGET" >/dev/null 2>&1 || true
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已尝试重启 Supervisor 托管的 Reverb 进程。${NC}"
fi
else
echo -e "${YELLOW}⚠️ 未检测到 Reverb 进程管理器,已跳过 reverb:restart,避免把手工启动的聊天室长连接服务停掉。${NC}"
echo -e "${YELLOW}⚠️ 若当前服务器是手工执行 php artisan reverb:start,请在部署完成后手动重启该进程。${NC}"
fi
# 8. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存
echo -e "${YELLOW}[8/8] 重载 PHP-FPM...${NC}"
PHP_FPM_RELOADED=0
if command -v systemctl >/dev/null 2>&1; then
for svc in php-fpm php-fpm-84 php-fpm-83 php-fpm-82 php84-php-fpm php83-php-fpm php82-php-fpm; do
@@ -110,8 +173,8 @@ else
echo -e "${YELLOW}⚠️ 未识别到 PHP-FPM 服务,请在面板中手动重启当前站点使用的 PHP。${NC}"
fi
# 6. 权限 (针对宝塔或Nginx+FPM环境的修正)
echo -e "${YELLOW}[8/8] 修复权限...${NC}"
# 权限修正(针对宝塔或 Nginx + FPM 环境
echo -e "${YELLOW}[后处理] 修复权限...${NC}"
# 将所有文件所属权变更为 Web 运行用户(如 www),防止 root 权限导致框架日志或缓存写入失败
chown -R www:www .
# 默认读写执行权限
@@ -0,0 +1,14 @@
{
"name": "chatroom-ride-development",
"version": "0.2.0",
"description": "聊天室座驾开发插件,沉淀独立座驾模块、金币流水、特效注册、命名规则和测试清单。",
"interface": {
"displayName": "Chatroom Ride Development"
},
"skills": [
{
"name": "chatroom-ride-development",
"path": "skills/chatroom-ride-development/SKILL.md"
}
]
}
@@ -0,0 +1,109 @@
---
name: chatroom-ride-development
description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_<key> 独立座驾、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。"
---
# Chatroom Ride Development
## 适用场景
- 新增或修改聊天室独立座驾。
- 新增 `resources/js/effects/<key>.js` 全屏座驾特效。
- 调整座驾购买、续期、替换、入场欢迎语或后台价格/天数配置。
- 排查座驾进房后特效不播放、欢迎语不显示、购买记录不正确的问题。
- 排查座驾购买没有金币流水、后台座驾列表为空、前台座驾弹窗为空的问题。
## 必须遵守
- 座驾是独立模块:必须使用 `rides``user_ride_purchases`,不要写入 `shop_items``user_purchases`
- 商店模块只管理普通商店商品;不要给 `ShopItem` 增加 `TYPE_RIDE``isRide()``rideKey()` 或座驾欢迎语字段。
- 座驾 slug 固定为 `ride_<effect_key>`
- `<effect_key>` 必须同时出现在:
- `rides.slug`
- `rides.effect_key`
- `Ride::rideKey()` 可解析结果
- `EffectBroadcast::TYPES`
- `resources/js/effects/effect-manager.js`
- `resources/js/effects/effect-sounds.js`
- `resources/js/effects/<effect_key>.js`
- 新增座驾默认通过迁移或 Seeder 写入 `rides`,字段至少包含名称、slug、`effect_key`、图标、价格、`duration_days`、排序和 `welcome_message`
- `welcome_message` 支持 `{name}``{ride}`,输出前必须转义,不能直接信任后台输入。
- 当前版本只允许用户同时拥有一个 active 座驾;同款续购叠加有效期,不同款替换旧座驾并把旧记录置为 `cancelled`
- 用户购买记录必须写入 `user_ride_purchases`,不要复用商店购买记录。
- 座驾购买扣金币必须走 `UserCurrencyService::deductGoldIfEnough()`,来源使用 `CurrencySource::RIDE_BUY`,并写入 `user_currency_logs`
- 座驾购买接口必须携带房间 ID,并把 `room_id` 传给金币流水,方便后台按房间追溯。
- 测试中禁止使用 `Redis::flushall()` 清理聊天室状态;只允许清理 `room:*` 相关 key,避免把本机登录 session 一起删掉。
## 模块边界
- 前台接口:`GET /rides/items``POST /rides/buy`
- 后台入口:`GET /admin/rides`,页面文件为 `resources/views/admin/rides/index.blade.php`
- 前台弹窗脚本:`resources/js/chat-room/ride-controls.js`
- 业务服务:`app/Services/RideService.php`
- 数据模型:`app/Models/Ride.php``app/Models/UserRidePurchase.php`
- 请求验证:`BuyRideRequest``StoreRideRequest``UpdateRideRequest`
## 已验收的进房展示规则
- 用户有有效座驾时,座驾优先级最高:
- 只发送一条 `座驾播报` 文字消息。
- 不再发送普通 `进出播报`
- 不再播放会员进场横幅或会员全屏特效。
- 座驾全屏特效播放范围是当前房间内所有在线用户:
- 进房用户本人通过 `initialRideEffect` 本地播放。
- 其他在线用户通过 `EffectBroadcast``room.{roomId}` PresenceChannel 播放。
- 不设置 `target_username`,表示当前房间全员可见。
- 座驾文字播报口径:
- 显示 `部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`
- 不在身份行前面显示 `用户 <用户名>`,因为后面的欢迎语模板已经包含 `{name}`
- 示例:`🚀 部门 办公厅 · 职务 🎖️ 技术总监 · 会员 👑 至尊会员 · 【流星】乘【东风-5C战略导弹】点火升空...`
- 座驾动画 HUD 标题口径:
- 第一行用户身份信息显示 `用户 <用户名> · 部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`
- 第二行标题只显示 `乘坐【<座驾名称>】闪亮登场`,不要重复用户名。
- `effect_user_info` 供动画 HUD 第一行使用,`effect_title` 供动画 HUD 第二行使用,`identity_text` 只供文字播报使用。
- 前端改动后如果浏览器仍显示旧动画标题,必须运行 `npm run build` 或确认 Vite dev server 已刷新,避免加载旧的 `public/build` 资源。
## 新增座驾步骤
1. 新增全屏特效文件:`resources/js/effects/<effect_key>.js`
2. 在 `effect-manager.js` 注册模块加载和启动分支。
3. 在 `effect-sounds.js` 注册音效启动分支。
4. 在 `EffectBroadcast::TYPES` 加入 `<effect_key>`
5. 在迁移或 Seeder 中新增 `rides` 记录,slug 使用 `ride_<effect_key>``effect_key` 使用 `<effect_key>`
6. 确认后台 `座驾管理` 可以配置价格、使用天数、欢迎语、上下架状态和排序。
7. 确认 `RideService::buy()` 购买时写入 `user_ride_purchases``user_currency_logs`
8. 更新座驾相关 PHPUnit 测试,至少覆盖列表、余额不足、购买成功、金币流水、续期、替换和进房触发。
## 金币流水要求
- 购买成功必须在 `user_currency_logs` 中写入:
- `currency = gold`
- `amount = -座驾价格`
- `source = ride_buy`
- `remark = 购买聊天室座驾:<座驾名称>`
- `room_id = 当前房间 ID`
- 余额不足时不能写 `user_ride_purchases`,也不能写 `user_currency_logs`
- 不要用 `$user->decrement('jjb', ...)` 直接扣金币。
## 旧方案清理要求
- 不要恢复 `add_ride_fields_to_shop_items` 这类把座驾挂到商店表的迁移。
- 允许保留清理迁移,用于删除旧环境中 `shop_items.slug like ride_%``shop_items.type = ride``shop_items.welcome_message` 残留。
- 修改座驾模块后,检查 `rg 'TYPE_RIDE|ShopItem::rideKey|RideItemController|复用商店购买记录'`,确认没有旧方案引用。
## 验证清单
- `node --check resources/js/effects/<effect_key>.js`
- `node --check resources/js/effects/effect-manager.js`
- `node --check resources/js/effects/effect-sounds.js`
- `php artisan test --compact tests/Feature/RideControllerTest.php`
- 有进房逻辑变更时运行相关 `ChatControllerTest` 过滤用例。
- 涉及商店边界时运行 `php artisan test --compact tests/Feature/ShopControllerTest.php`
- 修改 PHP 后运行 `vendor/bin/pint --dirty --format=agent`
## 特别注意
- 如果从 stash 恢复昨天的座驾特效,必须确认 untracked 父提交中的新特效文件也已恢复,不能只恢复已跟踪文件。
- `99a` 这种以数字开头的 key 在 JS 对象字面量里必须加引号。
- 新座驾的展示名可以是中文,但 effect key 必须保持小写短横线/数字/字母风格,避免前后端匹配失败。
- 本项目 PHP 文件要求类和方法上方有中文 DocBlock;新增座驾相关 PHP 文件时必须补齐。
+2 -2
View File
@@ -216,7 +216,7 @@ a:hover {
}
.msg-line .msg-time {
font-size: 9px;
font-size: 0.72em;
color: #999;
}
@@ -245,7 +245,7 @@ a:hover {
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
font-size: 0.9em;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+26 -2
View File
@@ -7,6 +7,30 @@
* @version 1.0.0
*/
/*
拍一拍屏幕抖动动画
*/
@keyframes chat-shake {
0%, 100% { transform: translateX(0); }
10%, 50%, 90% { transform: translateX(-4px); }
30%, 70% { transform: translateX(4px); }
}
.chat-shake {
animation: chat-shake 0.4s ease-in-out;
}
/*
斜杠命令菜单
*/
.slash-command-menu::-webkit-scrollbar {
width: 6px;
}
.slash-command-menu::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
[x-cloak] {
display: none !important;
@@ -216,7 +240,7 @@ a:hover {
}
.msg-line .msg-time {
font-size: 9px;
font-size: 0.72em;
color: #999;
}
@@ -245,7 +269,7 @@ a:hover {
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
font-size: 0.9em;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+35 -1
View File
@@ -139,6 +139,10 @@ const _shop = createLazyModule(
() => import("./chat-room/shop-controls.js"),
(mod) => mod.bindShopControls()
);
const _ride = createLazyModule(
() => import("./chat-room/ride-controls.js"),
(mod) => mod.bindRideControls()
);
const _compactShop = createLazyModule(
() => import("./chat-room/compact-shop-panel.js"),
(mod) => mod.bindCompactShopPanelControls()
@@ -216,7 +220,7 @@ import { initChatImageLightboxEvents, closeChatImageLightbox, openChatImageLight
import { bindRoomStatusControls, normalizeRoomStatus, renderRoomStatusRow, renderRoomsOnlineStatus, renderRoomsOnlineStatusToContainer, resolveRoomUrl } from "./chat-room/rooms.js";
import { bindChatRightPanelControls } from "./chat-room/right-panel.js";
import { bindChatImageUploadControl } from "./chat-room/image-upload.js";
import { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
import { applyFontSize, bindChatFontSizeControl, CHAT_DEFAULT_FONT_SIZE, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js";
import { bindChatBanner } from "./chat-room/banner.js";
import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js";
@@ -288,9 +292,22 @@ import { leaveRoom, notifyExpiredLeave, saveExp, startHeartbeat, stopHeartbeat }
import { bindToolbarControls, runFeatureShortcut, runToolbarAction } from "./chat-room/toolbar.js";
import { bindChatInitialStateControls } from "./chat-room/initial-state.js";
// 拍一拍模块
import "./chat-room/pat.js";
// 猜成语游戏模块
import "./chat-room/riddle-quiz.js";
import { bindIdiomQuizControls } from "./chat-room/riddle-quiz.js";
// 斜杠命令菜单
import { bindSlashCommands, registerSlashCommand } from "./chat-room/slash-commands.js";
if (typeof window !== "undefined") {
bindInstantHoverTooltip();
// 初始化斜杠命令菜单
bindSlashCommands();
// 保留聚合入口,懒加载模块通过按需动态导入自动初始化。
window.ChatRoomTools = {
// ── 静态核心模块(直接引用) ────────────────
@@ -379,6 +396,13 @@ if (typeof window !== "undefined") {
renderShop: (...args) => _shop.wrap('renderShop')(...args),
showShopToast: (...args) => _shop.wrap('showShopToast')(...args),
submitRename: (...args) => _shop.wrap('submitRename')(...args),
bindRideControls: (...args) => _ride.wrap('bindRideControls')(...args),
buyRide: (...args) => _ride.wrap('buyRide')(...args),
closeRideModal: (...args) => _ride.wrap('closeRideModal')(...args),
fetchRideData: (...args) => _ride.wrap('fetchRideData')(...args),
loadRides: (...args) => _ride.wrap('loadRides')(...args),
openRideModal: (...args) => _ride.wrap('openRideModal')(...args),
renderRides: (...args) => _ride.wrap('renderRides')(...args),
bindCompactShopPanelControls: (...args) => _compactShop.wrap('bindCompactShopPanelControls')(...args),
buyCompactShopItem: (...args) => _compactShop.wrap('buyCompactShopItem')(...args),
closeCompactRenameModal: (...args) => _compactShop.wrap('closeCompactRenameModal')(...args),
@@ -448,6 +472,7 @@ if (typeof window !== "undefined") {
sendToChatBot,
applyFontSize,
bindChatFontSizeControl,
CHAT_DEFAULT_FONT_SIZE,
CHAT_FONT_SIZE_STORAGE_KEY,
restoreChatFontSize,
bindChatImageUploadControl,
@@ -469,6 +494,7 @@ if (typeof window !== "undefined") {
bindWelcomeMenuControls,
toggleWelcomeMenu,
bindAdminMenuControls,
registerSlashCommand,
bindBaccaratEvents,
bindBaccaratLossCoverAdminControls,
closeAdminBaccaratLossCoverModal,
@@ -613,6 +639,12 @@ if (typeof window !== "undefined") {
window.renderShop = (...args) => _shop.wrap('renderShop')(...args);
window.showShopToast = (...args) => _shop.wrap('showShopToast')(...args);
window.submitRename = (...args) => _shop.wrap('submitRename')(...args);
window.buyRide = (...args) => _ride.wrap('buyRide')(...args);
window.closeRideModal = (...args) => _ride.wrap('closeRideModal')(...args);
window.fetchRideData = (...args) => _ride.wrap('fetchRideData')(...args);
window.loadRides = (...args) => _ride.wrap('loadRides')(...args);
window.openRideModal = (...args) => _ride.wrap('openRideModal')(...args);
window.renderRides = (...args) => _ride.wrap('renderRides')(...args);
window.closeAvatarPicker = (...args) => _profile.wrap('closeAvatarPicker')(...args);
window.closeSettingsModal = (...args) => _profile.wrap('closeSettingsModal')(...args);
window.copyWechatBindCode = (...args) => _profile.wrap('copyWechatBindCode')(...args);
@@ -680,6 +712,7 @@ if (typeof window !== "undefined") {
window.loadFeedbackData = loadFeedbackData;
window.loadMoreFeedback = loadMoreFeedback;
window.bindFeedbackControls = bindFeedbackControls;
window.registerSlashCommand = registerSlashCommand;
// ── Alpine 组件(静态导入,Blade 中 x-data 引用时同步可用) ──
window.userCardComponent = userCardComponent;
@@ -767,4 +800,5 @@ if (typeof window !== "undefined") {
bindChatBotControls();
bindGuestbookControls();
bindFeedbackControls();
bindIdiomQuizControls();
}
+13
View File
@@ -46,6 +46,19 @@ export function bindAdminMenuControls() {
return;
}
const effectPreviewButton = event.target.closest("[data-chat-admin-effect-preview]");
if (effectPreviewButton) {
event.preventDefault();
const effect = effectPreviewButton.getAttribute("data-chat-admin-effect-preview") || "";
const menu = document.getElementById("admin-menu");
if (menu) {
menu.style.display = "none";
}
// 预览按钮仅在当前浏览器播放,方便测试新特效时不打扰房间其他用户。
window.EffectManager?.play?.(effect);
return;
}
if (event.target.closest("[data-chat-admin-menu]")) {
event.stopPropagation();
}
+75 -9
View File
@@ -7,6 +7,8 @@ import { enqueueChatMessage } from "./message-renderer.js";
// ── 事件注册标记 ──
let chatEventsBound = false;
let chatWebSocketInitRetryTimer = null;
const GOMOKU_INVITE_BUTTON_FONT_SIZE = "0.82em";
// ── 辅助函数 ──
function csrf() {
@@ -21,9 +23,40 @@ function getState() {
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (chatWebSocketInitRetryTimer) {
window.clearTimeout(chatWebSocketInitRetryTimer);
chatWebSocketInitRetryTimer = null;
}
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
return;
}
// chat.js 会在模块末尾才把 initChat 挂到 window;若这里抢先执行,稍后自动补一次初始化。
chatWebSocketInitRetryTimer = window.setTimeout(() => {
chatWebSocketInitRetryTimer = null;
initChatWebSocket();
}, 100);
}
/**
* 在 DOM 已就绪时立即执行回调,避免 Vite 模块晚于 DOMContentLoaded 执行时漏掉初始化。
*
* @param {() => void} callback 页面就绪后的回调
* @returns {void}
*/
function runWhenDomReady(callback) {
if (typeof callback !== "function") {
return;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
return;
}
callback();
}
// ── 禁言逻辑 ──
@@ -203,13 +236,13 @@ function setupGomokuInviteListener() {
const acceptBtn = isSelf
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:12px;
border-radius:12px; background:#336699; color:#fff; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
@@ -277,8 +310,8 @@ export function bindChatEvents() {
}
chatEventsBound = true;
// WebSocket 初始化
document.addEventListener("DOMContentLoaded", initChatWebSocket);
// 页面已完成解析时立刻补做初始化,确保 Presence 连接不会因为错过 DOMContentLoaded 而丢失。
runWhenDomReady(initChatWebSocket);
// chat:here — Presence 初始用户列表
window.addEventListener("chat:here", (e) => {
@@ -370,8 +403,8 @@ export function bindChatEvents() {
window.showVipPresenceBanner(msg);
}
// 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片
if (msg.toast_notification && msg.to_user === window.chatContext?.username) {
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
const t = msg.toast_notification;
window.chatToast?.show({
title: t.title || "通知",
@@ -449,16 +482,49 @@ export function bindChatEvents() {
const target = e.detail?.target_username;
const operator = e.detail?.operator;
const myName = window.chatContext?.username;
const effectOptions = {
effect_title: e.detail?.effect_title,
effect_user_info: e.detail?.effect_user_info,
ride_name: e.detail?.ride_name,
operator,
};
if (type && typeof EffectManager !== "undefined") {
if (!target || target === myName || operator === myName) {
EffectManager.play(type);
EffectManager.play(type, effectOptions);
}
}
});
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
document.addEventListener("DOMContentLoaded", () => {
// chat:pat — 拍一拍事件
window.addEventListener("chat:pat", (e) => {
const { from_user, target_user, display_text, from_user_headface } = e.detail || {};
if (!display_text) return;
if (typeof window.appendPatMessage === "function") {
window.appendPatMessage(display_text, from_user_headface, from_user, target_user);
}
if (typeof window.triggerPatShake === "function") {
window.triggerPatShake();
}
});
// chat:idiom-started — 猜成语出题
window.addEventListener("chat:idiom-started", (e) => {
if (typeof window.handleRiddleGameStarted === "function") {
window.handleRiddleGameStarted(e);
}
});
// chat:idiom-answered — 猜成语答题结果
window.addEventListener("chat:idiom-answered", (e) => {
if (typeof window.handleRiddleGameAnswered === "function") {
window.handleRiddleGameAnswered(e);
}
});
// Echo 级监听器同样要兼容“脚本加载时页面已完成”的场景。
runWhenDomReady(() => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
+2 -2
View File
@@ -1,7 +1,7 @@
// 聊天室共享运行时状态,桥接 Blade 闭包作用域与 Vite 模块。
// 所有需要跨模块共享的可变状态集中在此管理,通过 window.chatState 访问。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "猜成语", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
export const PUBLIC_MESSAGE_NODE_LIMIT = 600;
@@ -9,7 +9,7 @@ export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
export const HEARTBEAT_INTERVAL = 60 * 1000;
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "座驾播报", "系统", "欢迎", "系统播报", "神秘箱子"];
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
export const ACTION_TEXT_MAP = {
+12
View File
@@ -177,6 +177,18 @@ async function sendMessage(e) {
const composerState = collectChatComposerState();
const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState;
// 拦截 /拍一拍 命令:使用当前选中的聊天对象
if (content && typeof window.isPatCommand === "function" && window.isPatCommand(content)) {
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
if (typeof window.executePat === "function") {
await window.executePat();
}
return;
}
if (!content && !selectedImage) {
contentInput?.focus();
if (state) {

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