Compare commits
72 Commits
v2.0.2
...
06864a9cec
| Author | SHA1 | Date | |
|---|---|---|---|
| 06864a9cec | |||
| 622bc94377 | |||
| 575e92e03f | |||
| 522eea72f6 | |||
| fc7930046d | |||
| 7ba7b34ca7 | |||
| 3eaf37a648 | |||
| 221f629ec2 | |||
| 18acd7d890 | |||
| 09a2b0d85f | |||
| b60f3615c1 | |||
| 363c45a140 | |||
| 181cc6a0b0 | |||
| 3c95478097 | |||
| 45ce8b2b2d | |||
| 50b050c4bc | |||
| 6748fbc44e | |||
| 449894e3e5 | |||
| 5173275a92 | |||
| ee56792beb | |||
| 02ed8ea319 | |||
| 2bebc78e82 | |||
| 4fe4155ec0 | |||
| c640a31302 | |||
| 6ae452c4b9 | |||
| 1607f57e3c | |||
| 3672140987 | |||
| 092b51cd95 | |||
| fe3e74b5f8 | |||
| 192259f0a4 | |||
| a50055deaf | |||
| 578f587941 | |||
| fb4a7171f4 | |||
| dc9c09c722 | |||
| 317dfd04d7 | |||
| 1192fe5bdb | |||
| e0679b164e | |||
| 82d762d070 | |||
| 5962d6d2b3 | |||
| 2f9b2eed64 | |||
| 434f2b8e0f | |||
| 9bc085cb7d | |||
| f13cfe4bc1 | |||
| cd1621f497 | |||
| 3973b7770c | |||
| 0847877ce2 | |||
| b886d98d8c | |||
| 4ff62e29bd | |||
| 461c6a6f56 | |||
| 1850a5f4e9 | |||
| 0850004d39 | |||
| df96b56ab0 | |||
| 495efdf9e0 | |||
| 0dd85879af | |||
| 64434516d7 | |||
| e155a0e3d0 | |||
| d6e8a64ce3 | |||
| abb5512222 | |||
| 55fd770fdd | |||
| 4fb78eaca9 | |||
| 05ec4a72b7 | |||
| f3d883b5ed | |||
| 96e0e21f8b | |||
| c8adbff78e | |||
| aa6046d89b | |||
| cdec289740 | |||
| d63aeef45b | |||
| 2be7e6caef | |||
| a2b09da730 | |||
| 243e06915e | |||
| 2ee6ecc601 | |||
| f0137f3fa3 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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** | latest(WebSocket 服务器) |
|
||||
| **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 Hash,Key 为 "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
|
||||
@@ -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
|
||||
'''
|
||||
@@ -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
|
||||
'''
|
||||
@@ -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
|
||||
@@ -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 服务,可被用于 CSWSH(Cross-Site WebSocket Hijacking)攻击,窃取聊天消息。
|
||||
|
||||
**方案:**
|
||||
```php
|
||||
// config/reverb.php
|
||||
'allowed_origins' => [
|
||||
env('APP_URL', 'http://chatroom.test'),
|
||||
// 如果有多个域名,手动列出
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 4. Reverb WebSocket 启用 TLS(WSS)
|
||||
|
||||
**当前:** `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 Octane(Swoole / 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 静态资源分发**。
|
||||
>
|
||||
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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`(毛玻璃弹窗)
|
||||
@@ -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>
|
||||
@@ -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 => '猜谜活动奖励',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动开始广播事件
|
||||
*
|
||||
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间广播新一轮猜谜活动题目。
|
||||
*/
|
||||
class RiddleGameStarted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 方法功能:构造新回合广播事件载荷。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $quizType,
|
||||
public readonly string $hint,
|
||||
public readonly int $roundId,
|
||||
public readonly int $rewardGold = 0,
|
||||
public readonly int $rewardExp = 0,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:声明广播频道。
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:声明广播数据。
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
|
||||
|
||||
return [
|
||||
'round_id' => $this->roundId,
|
||||
'quiz_type' => $this->quizType,
|
||||
'quiz_type_label' => $quizTypeLabel,
|
||||
'quiz_round_id' => $this->roundId,
|
||||
'quiz_hint' => $this->hint,
|
||||
'quiz_reward_gold' => $this->rewardGold,
|
||||
'quiz_reward_exp' => $this->rewardExp,
|
||||
'hint' => $this->hint,
|
||||
'reward_gold' => $this->rewardGold,
|
||||
'reward_exp' => $this->rewardExp,
|
||||
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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)
|
||||
|
||||
@@ -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).'】完成今日签到,连续签到 '
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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' => '当前房间不存在。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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', '单选房间模式下只能选择一个房间。');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 下注任务
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 押注截止后触发跑马 & 结算任务
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ─── 工具方法 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 或 duration,slug 以 once_ 或 week_ 开头)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>'
|
||||
: '';
|
||||
|
||||
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
};
|
||||
+73
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+62
@@ -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'");
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动旧 Seeder 兼容入口
|
||||
*
|
||||
* 兼容仍然使用 `IdiomSeeder` 名称的旧命令与旧文档,
|
||||
* 实际执行逻辑委托给新的 RiddleSeeder。
|
||||
*/
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
/**
|
||||
* 类功能:兼容旧的 IdiomSeeder 调用入口。
|
||||
*/
|
||||
class IdiomSeeder extends RiddleSeeder
|
||||
{
|
||||
/**
|
||||
* 方法功能:复用新的猜谜活动题库填充逻辑。
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
parent::run();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/* ── 底部输入工具栏 ─────────────────────────────── */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user