Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1409d16bb | |||
| 2ea84ed93e | |||
| dce0a52750 | |||
| ce6d71a4f2 | |||
| a0a189867f | |||
| 3c7cefe447 | |||
| 74e4803bc2 | |||
| b8feab34a6 | |||
| 0c9e7baca2 | |||
| da0846c7ab | |||
| 8c1b0b0840 | |||
| 1b062f67ea | |||
| 41522393de | |||
| 645fe2a830 | |||
| 64945a973e | |||
| 725a38eac3 | |||
| 11a882bd8e | |||
| a65827c5d9 | |||
| 9b993e487c | |||
| 6225a0fb45 | |||
| b3eebd286e | |||
| fdd4f8a179 | |||
| 82dbc19319 | |||
| ffd8789e67 | |||
| ee525f049e | |||
| f354516869 | |||
| 92e3dd0cdf | |||
| 9764961519 | |||
| 4af4468fc4 | |||
| a6e50c36d7 | |||
| b21f583fe5 | |||
| 8c7b1086ff | |||
| 59a417bd10 | |||
| 0fe003a773 | |||
| 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 | |||
| b15e42891d | |||
| 214a422504 | |||
| f16f10fe82 | |||
| a3daf3f074 | |||
| d0a38352a5 | |||
| c06e265c0d | |||
| 6ae7a4a82b | |||
| 792b0765fd | |||
| 3e0fb33a9b | |||
| e7049b5f5b | |||
| 62371a7c64 | |||
| 540d8bf6ff | |||
| bf2d63f125 | |||
| 4f22fd552a | |||
| 790730e2c2 | |||
| eeb9dfbade | |||
| 1c067e452b | |||
| e50502d8f6 | |||
| e8b4dcc968 | |||
| e177ad6d4d | |||
| f17f171f4b | |||
| d10a354370 | |||
| efb03f90b8 | |||
| 3ecafd01ea | |||
| d82aa1c434 | |||
| 3db8e4ab82 | |||
| 10d158b38a | |||
| ea02c36ea6 | |||
| 2c8cb21206 | |||
| c0cb7f5ead | |||
| 83c312196c | |||
| 66206fa521 | |||
| ee62a3add8 | |||
| 3c749969b4 | |||
| 277cb617da | |||
| dd9ae46c04 | |||
| 3d8e270df4 | |||
| 8db1a252d7 | |||
| 3e85cb67bc | |||
| 40a0849151 | |||
| 442ca0e1e2 | |||
| 3f2eb7d48b | |||
| c9d4d3dbf4 | |||
| f6bc8a83c3 | |||
| a09927f6fd | |||
| 16b709d1da | |||
| a16a8fb9f4 | |||
| bcb762df77 | |||
| ffccfa26e9 |
@@ -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,10 @@
|
|||||||
/.github
|
/.github
|
||||||
/.gemini
|
/.gemini
|
||||||
/.agents
|
/.agents
|
||||||
|
/.codex
|
||||||
|
/.hermes
|
||||||
|
/.reasonix
|
||||||
|
/.understand-anything
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
@@ -30,3 +34,7 @@ vendor.zip
|
|||||||
test-captcha.php
|
test-captcha.php
|
||||||
public/.user.ini
|
public/.user.ini
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
|
||||||
|
# AI 生成文件
|
||||||
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -16,9 +16,11 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\AiFishingJob;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\Autoact;
|
use App\Models\Autoact;
|
||||||
use App\Models\DailySignIn;
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\GameConfig;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\AiFinanceService;
|
use App\Services\AiFinanceService;
|
||||||
@@ -61,11 +63,15 @@ class AiHeartbeatCommand extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
// 1. 检查总开关
|
// 1. 检查总开关
|
||||||
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$config = $this->heartbeatConfig();
|
||||||
|
|
||||||
// 2. 获取 AI 实体
|
// 2. 获取 AI 实体
|
||||||
$user = User::where('username', 'AI小班长')->first();
|
$user = User::where('username', 'AI小班长')->first();
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
@@ -73,21 +79,26 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
||||||
$this->aiFinance->bankExcessGold($user);
|
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||||||
|
$this->aiFinance->bankExcessGold($user);
|
||||||
|
}
|
||||||
|
|
||||||
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
||||||
$this->performDailySignIn($user);
|
if ($this->performDailySignIn($user)) {
|
||||||
|
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
|
||||||
|
$user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 常规心跳经验与金币发放
|
// 3. 常规心跳经验与金币发放
|
||||||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||||||
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
|
$expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
|
||||||
if ($expGain > 0) {
|
if ($expGain > 0) {
|
||||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||||||
$user->exp_num += $actualExpGain;
|
$user->exp_num += $actualExpGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
|
$jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
|
||||||
if ($jjbGain > 0) {
|
if ($jjbGain > 0) {
|
||||||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||||||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||||||
@@ -95,30 +106,35 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
// 4. 重算等级(基础心跳升级)
|
// 4. 重算等级(基础心跳升级)
|
||||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
$superLevel = (int) $config['superlevel'];
|
||||||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||||||
|
|
||||||
// 5. 随机事件触发
|
// 5. 随机事件触发
|
||||||
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
|
$eventChance = (int) $config['auto_event_chance'];
|
||||||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||||||
$autoEvent = Autoact::randomEvent();
|
$autoEvent = Autoact::randomEvent();
|
||||||
if ($autoEvent) {
|
if ($autoEvent) {
|
||||||
|
$hasCurrencyChange = false;
|
||||||
|
|
||||||
// 执行随机事件的金钱经验惩奖
|
// 执行随机事件的金钱经验惩奖
|
||||||
if ($autoEvent->exp_change !== 0) {
|
if ($autoEvent->exp_change !== 0) {
|
||||||
$this->currencyService->change(
|
$this->currencyService->change(
|
||||||
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||||
);
|
);
|
||||||
|
$hasCurrencyChange = true;
|
||||||
}
|
}
|
||||||
if ($autoEvent->jjb_change !== 0) {
|
if ($autoEvent->jjb_change !== 0) {
|
||||||
$this->currencyService->change(
|
$this->currencyService->change(
|
||||||
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||||
);
|
);
|
||||||
|
$hasCurrencyChange = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->refresh();
|
if ($hasCurrencyChange) {
|
||||||
|
$user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// 重新计算等级
|
// 重新计算等级
|
||||||
if ($this->calculateNewLevel($user, $superLevel)) {
|
if ($this->calculateNewLevel($user, $superLevel)) {
|
||||||
@@ -149,39 +165,68 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. 钓鱼小游戏随机参与逻辑
|
// 7. 钓鱼小游戏随机参与逻辑
|
||||||
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
|
$fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
|
||||||
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
|
$fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
|
||||||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
|
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
|
||||||
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
$fishingConfig = GameConfig::forGame('fishing');
|
||||||
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
|
||||||
if ($this->aiFinance->prepareSpend($user, $cost)) {
|
|
||||||
// 先扣除费用
|
|
||||||
$this->currencyService->change(
|
|
||||||
$user, 'gold', -$cost,
|
|
||||||
CurrencySource::FISHING_COST,
|
|
||||||
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 模拟玩家等待时间
|
if ($fishingConfig?->enabled) {
|
||||||
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
|
||||||
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
||||||
$waitTime = rand($waitMin, $waitMax);
|
if ($this->aiFinance->prepareSpend($user, $cost)) {
|
||||||
|
// 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
|
||||||
|
$this->currencyService->change(
|
||||||
|
$user, 'gold', -$cost,
|
||||||
|
CurrencySource::FISHING_COST,
|
||||||
|
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
// 模拟玩家等待时间
|
||||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
$waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
|
||||||
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
$waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
|
||||||
|
$waitTime = rand($waitMin, $waitMax);
|
||||||
|
|
||||||
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
||||||
|
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||||
|
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
||||||
|
|
||||||
|
AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
|
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
|
||||||
$this->aiFinance->bankExcessGold($user);
|
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||||||
|
$this->aiFinance->bankExcessGold($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||||
|
$this->info("AI心跳完成,耗时 {$elapsedMs}ms。");
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function heartbeatConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'),
|
||||||
|
'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'),
|
||||||
|
'superlevel' => Sysparam::getValue('superlevel', '100'),
|
||||||
|
'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'),
|
||||||
|
'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'),
|
||||||
|
'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'),
|
||||||
|
'fishing_cost' => Sysparam::getValue('fishing_cost', '5'),
|
||||||
|
'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'),
|
||||||
|
'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算并更新用户等级
|
* 计算并更新用户等级
|
||||||
*/
|
*/
|
||||||
@@ -222,7 +267,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
||||||
*/
|
*/
|
||||||
private function performDailySignIn(User $user): void
|
private function performDailySignIn(User $user): bool
|
||||||
{
|
{
|
||||||
// 先检查今日是否已签,避免每分钟都调用事务
|
// 先检查今日是否已签,避免每分钟都调用事务
|
||||||
$alreadySigned = DailySignIn::query()
|
$alreadySigned = DailySignIn::query()
|
||||||
@@ -231,7 +276,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($alreadySigned) {
|
if ($alreadySigned) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取活跃房间作为签到归属(默认房间 1)
|
// 获取活跃房间作为签到归属(默认房间 1)
|
||||||
@@ -242,7 +287,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
|
|
||||||
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
||||||
if (! $dailySignIn->wasRecentlyCreated) {
|
if (! $dailySignIn->wasRecentlyCreated) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rewardParts = [];
|
$rewardParts = [];
|
||||||
@@ -264,7 +309,9 @@ class AiHeartbeatCommand extends Command
|
|||||||
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||||||
|
|
||||||
$this->broadcastSystemMessage('签到播报', $content, '#0f766e');
|
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Enums\CurrencySource;
|
|||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\PositionDutyLog;
|
use App\Models\PositionDutyLog;
|
||||||
|
use App\Models\Room;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
@@ -27,6 +28,7 @@ use App\Services\ChatUserPresenceService;
|
|||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Services\VipService;
|
use App\Services\VipService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
@@ -65,6 +67,8 @@ class AutoSaveExp extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
// 读取奖励配置
|
// 读取奖励配置
|
||||||
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
||||||
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
||||||
@@ -81,15 +85,22 @@ class AutoSaveExp extends Command
|
|||||||
|
|
||||||
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
|
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
|
||||||
$totalProcessed = 0;
|
$totalProcessed = 0;
|
||||||
|
$usersByUsername = $this->preloadOnlineUsers($roomMap);
|
||||||
|
|
||||||
foreach ($roomMap as $roomId => $usernames) {
|
foreach ($roomMap as $roomId => $usernames) {
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
$user = $usersByUsername->get($username);
|
||||||
|
if (! $user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
||||||
$totalProcessed++;
|
$totalProcessed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。");
|
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||||
|
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ class AutoSaveExp extends Command
|
|||||||
$roomMap = [];
|
$roomMap = [];
|
||||||
|
|
||||||
// 从数据库取出所有房间 ID
|
// 从数据库取出所有房间 ID
|
||||||
$roomIds = \App\Models\Room::pluck('id');
|
$roomIds = Room::pluck('id');
|
||||||
|
|
||||||
foreach ($roomIds as $roomId) {
|
foreach ($roomIds as $roomId) {
|
||||||
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
|
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
|
||||||
@@ -121,27 +132,46 @@ class AutoSaveExp extends Command
|
|||||||
return $roomMap;
|
return $roomMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载所有在线用户名对应的用户资料与身份关系,避免循环内逐个查询用户和身份信息。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string>> $roomMap 在线房间与用户名映射
|
||||||
|
* @return Collection<string, User> 以用户名为键的用户集合
|
||||||
|
*/
|
||||||
|
private function preloadOnlineUsers(array $roomMap): Collection
|
||||||
|
{
|
||||||
|
$usernames = collect($roomMap)
|
||||||
|
->flatten()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($usernames->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->with(['activePosition.position.department', 'vipLevel'])
|
||||||
|
->whereIn('username', $usernames)
|
||||||
|
->get()
|
||||||
|
->keyBy('username');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
||||||
*
|
*
|
||||||
* @param string $username 用户名
|
* @param User $user 已预加载身份关系的在线用户
|
||||||
* @param int $roomId 所在房间ID
|
* @param int $roomId 所在房间ID
|
||||||
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
||||||
* @param string $jjbGainRaw 金币奖励原始配置
|
* @param string $jjbGainRaw 金币奖励原始配置
|
||||||
* @param int $superLevel 管理员等级阈值
|
* @param int $superLevel 管理员等级阈值
|
||||||
*/
|
*/
|
||||||
private function processUser(
|
private function processUser(
|
||||||
string $username,
|
User $user,
|
||||||
int $roomId,
|
int $roomId,
|
||||||
string $expGainRaw,
|
string $expGainRaw,
|
||||||
string $jjbGainRaw,
|
string $jjbGainRaw,
|
||||||
int $superLevel
|
int $superLevel
|
||||||
): void {
|
): void {
|
||||||
$user = User::where('username', $username)->first();
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
||||||
$expGain = $this->parseRewardValue($expGainRaw);
|
$expGain = $this->parseRewardValue($expGainRaw);
|
||||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||||
@@ -165,8 +195,11 @@ class AutoSaveExp extends Command
|
|||||||
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
|
if ($actualExpGain > 0 || $actualJjbGain > 0) {
|
||||||
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。
|
// 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系。
|
||||||
|
$user->refresh();
|
||||||
|
$user->load(['activePosition.position.department', 'vipLevel']);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 自动升降级逻辑
|
// 3. 自动升降级逻辑
|
||||||
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
||||||
@@ -178,11 +211,17 @@ class AutoSaveExp extends Command
|
|||||||
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
||||||
|
|
||||||
if ($activeUP?->position) {
|
if ($activeUP?->position) {
|
||||||
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
|
// 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
|
||||||
$requiredLevel = (int) $activeUP->position->level;
|
$requiredLevel = (int) $activeUP->position->level;
|
||||||
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
|
if ($requiredLevel > 0 && $user->user_level < $requiredLevel) {
|
||||||
$user->user_level = $requiredLevel;
|
$user->user_level = $requiredLevel;
|
||||||
}
|
}
|
||||||
|
// 职务成员也可以按经验超越职务等级
|
||||||
|
$expLevel = Sysparam::calculateLevel($user->exp_num);
|
||||||
|
if ($expLevel > $user->user_level && $expLevel < $superLevel) {
|
||||||
|
$user->user_level = $expLevel;
|
||||||
|
$leveledUp = ($expLevel > $oldLevel);
|
||||||
|
}
|
||||||
} elseif ($oldLevel < $superLevel) {
|
} elseif ($oldLevel < $superLevel) {
|
||||||
// 普通用户:按经验计算并更新等级
|
// 普通用户:按经验计算并更新等级
|
||||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||||
@@ -241,7 +280,7 @@ class AutoSaveExp extends Command
|
|||||||
'id' => $this->chatState->nextMessageId($roomId),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统',
|
'from_user' => '系统',
|
||||||
'to_user' => $username, // 定向推送给本人
|
'to_user' => $user->username, // 定向推送给本人
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
||||||
'font_color' => '#16a34a', // 草绿色
|
'font_color' => '#16a34a', // 草绿色
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* 文件功能:定期清理聊天记录 Artisan 命令
|
* 文件功能:定期清理聊天记录 Artisan 命令
|
||||||
*
|
*
|
||||||
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控。
|
* 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息。
|
||||||
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。
|
* 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
|
||||||
*
|
*
|
||||||
* @author ChatRoom Laravel
|
* @author ChatRoom Laravel
|
||||||
*
|
*
|
||||||
@@ -31,7 +31,7 @@ class PurgeOldMessages extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'messages:purge
|
protected $signature = 'messages:purge
|
||||||
{--days= : 覆盖默认保留天数}
|
{--days= : 覆盖通知消息默认保留天数}
|
||||||
{--image-days=3 : 聊天图片单独保留天数}
|
{--image-days=3 : 聊天图片单独保留天数}
|
||||||
{--dry-run : 仅预览不实际删除}';
|
{--dry-run : 仅预览不实际删除}';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ class PurgeOldMessages extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件';
|
protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行命令
|
* 执行命令
|
||||||
@@ -49,9 +49,9 @@ class PurgeOldMessages extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
|
// 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
|
||||||
$days = (int) ($this->option('days')
|
$days = (int) ($this->option('days')
|
||||||
?: Sysparam::getValue('message_retention_days', '30'));
|
?: Sysparam::getValue('game_message_retention_days', '30'));
|
||||||
$imageDays = max(0, (int) $this->option('image-days'));
|
$imageDays = max(0, (int) $this->option('image-days'));
|
||||||
|
|
||||||
$cutoff = Carbon::now()->subDays($days);
|
$cutoff = Carbon::now()->subDays($days);
|
||||||
@@ -59,22 +59,22 @@ class PurgeOldMessages extends Command
|
|||||||
|
|
||||||
$this->cleanupExpiredImages($imageDays, $isDryRun);
|
$this->cleanupExpiredImages($imageDays, $isDryRun);
|
||||||
|
|
||||||
// 统计待清理数量
|
$expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
|
||||||
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
|
$totalCount = (clone $expiredNoticeQuery)->count();
|
||||||
|
|
||||||
if ($totalCount === 0) {
|
if ($totalCount === 0) {
|
||||||
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理。");
|
$this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留。");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isDryRun) {
|
if ($isDryRun) {
|
||||||
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})");
|
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})...");
|
$this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})...");
|
||||||
$this->info(" 待清理数量:{$totalCount} 条");
|
$this->info(" 待清理数量:{$totalCount} 条");
|
||||||
|
|
||||||
// 分批删除,每批 1000 条,避免长时间锁表
|
// 分批删除,每批 1000 条,避免长时间锁表
|
||||||
@@ -82,7 +82,7 @@ class PurgeOldMessages extends Command
|
|||||||
$batchSize = 1000;
|
$batchSize = 1000;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$batch = Message::where('sent_at', '<', $cutoff)
|
$batch = $this->expiredNoticeQuery($cutoff)
|
||||||
->limit($batchSize)
|
->limit($batchSize)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
@@ -93,11 +93,31 @@ class PurgeOldMessages extends Command
|
|||||||
}
|
}
|
||||||
} while ($batch === $batchSize);
|
} while ($batch === $batchSize);
|
||||||
|
|
||||||
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。");
|
$this->info("✅ 清理完成!共删除 {$deleted} 条游戏/临时通知,用户聊天记录未删除。");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造过期通知清理查询,兼容新增字段前已经落库的旧通知。
|
||||||
|
*/
|
||||||
|
private function expiredNoticeQuery(Carbon $cutoff): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
return Message::query()
|
||||||
|
->where('sent_at', '<', $cutoff)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereIn('retention_type', Message::purgableRetentionTypes())
|
||||||
|
->orWhere(function ($legacyQuery) {
|
||||||
|
// 兼容迁移前默认归为 user_chat 的旧通知,避免历史游戏播报继续堆积。
|
||||||
|
$legacyQuery->where('retention_type', Message::RETENTION_USER_CHAT)
|
||||||
|
->where(function ($noticeQuery) {
|
||||||
|
$noticeQuery->whereIn('from_user', ['钓鱼播报', '星海小博士', '进出播报', '座驾播报'])
|
||||||
|
->orWhereIn('action', ['fishing_result', 'idiom_result', 'riddle_result', 'system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
|
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:扫描并补算用户成就的 Artisan 命令。
|
||||||
|
*
|
||||||
|
* 支持单用户、全量与最近活跃用户三种扫描方式,便于定时任务和后台补算复用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:通过命令行批量检查用户成就进度并写入解锁记录。
|
||||||
|
*/
|
||||||
|
class ScanAchievementsCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 命令签名。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'achievements:scan
|
||||||
|
{--user= : 指定用户 ID 或用户名}
|
||||||
|
{--all : 扫描全部用户}
|
||||||
|
{--notify : 解锁时向用户推送本人可见通知}
|
||||||
|
{--dry-run : 仅预览,不写入成就记录}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令描述。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '扫描聊天室用户成就进度并补齐解锁记录';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建命令依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行成就扫描命令。
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$notify = (bool) $this->option('notify');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
$user = $this->resolveUser((string) $this->option('user'));
|
||||||
|
if (! $user) {
|
||||||
|
$this->error('未找到指定用户。');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->achievementService->scanUser($user, $notify, $dryRun);
|
||||||
|
$this->info("已扫描用户 {$user->username}:检查 {$result['checked']} 项,解锁 {$result['unlocked']} 项,更新 {$result['updated']} 项。");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = User::query()->orderBy('id');
|
||||||
|
if (! $this->option('all')) {
|
||||||
|
// 默认只扫最近活跃用户,避免定时任务每次全表扫描。
|
||||||
|
$query->where('updated_at', '>=', now()->subDay())->limit(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
|
||||||
|
$query->chunkById(100, function ($users) use (&$summary, $notify, $dryRun): void {
|
||||||
|
$chunkSummary = $this->achievementService->scanUsers($users, $notify, $dryRun);
|
||||||
|
$summary['users'] += $chunkSummary['users'];
|
||||||
|
$summary['checked'] += $chunkSummary['checked'];
|
||||||
|
$summary['unlocked'] += $chunkSummary['unlocked'];
|
||||||
|
$summary['updated'] += $chunkSummary['updated'];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("成就扫描完成:用户 {$summary['users']} 人,检查 {$summary['checked']} 项,解锁 {$summary['unlocked']} 项,更新 {$summary['updated']} 项。");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 或用户名解析用户。
|
||||||
|
*/
|
||||||
|
private function resolveUser(string $value): ?User
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('username', $value))
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ enum CurrencySource: string
|
|||||||
/** 商城购买消耗(扣除金币) */
|
/** 商城购买消耗(扣除金币) */
|
||||||
case SHOP_BUY = 'shop_buy';
|
case SHOP_BUY = 'shop_buy';
|
||||||
|
|
||||||
|
/** 购买聊天室座驾消耗(扣除金币) */
|
||||||
|
case RIDE_BUY = 'ride_buy';
|
||||||
|
|
||||||
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
||||||
case ADMIN_ADJUST = 'admin_adjust';
|
case ADMIN_ADJUST = 'admin_adjust';
|
||||||
|
|
||||||
@@ -143,6 +146,36 @@ enum CurrencySource: string
|
|||||||
/** 查看别人隐藏信息扣费 */
|
/** 查看别人隐藏信息扣费 */
|
||||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||||
|
|
||||||
|
/** 购买消息装扮消耗(气泡,扣除金币) */
|
||||||
|
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
|
||||||
|
|
||||||
|
/** 购买昵称颜色装扮消耗(扣除金币) */
|
||||||
|
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
|
||||||
|
|
||||||
|
/** 购买文字颜色装扮消耗(扣除金币) */
|
||||||
|
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
|
||||||
|
|
||||||
|
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
|
||||||
|
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||||
|
|
||||||
|
/** 购买头像框消耗(扣除金币) */
|
||||||
|
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||||
|
|
||||||
|
/** 猜谜活动奖励 */
|
||||||
|
case GAME_REWARD = 'game_reward';
|
||||||
|
|
||||||
|
/** 收到用户赠送的金币 */
|
||||||
|
case GOLD_GIFT_RECV = 'gold_gift_recv';
|
||||||
|
|
||||||
|
/** 聊天互动魅力奖励 */
|
||||||
|
case CHAT_CHARM = 'chat_charm';
|
||||||
|
|
||||||
|
/** 存入银行(jjb → bank_jjb) */
|
||||||
|
case BANK_DEPOSIT = 'bank_deposit';
|
||||||
|
|
||||||
|
/** 从银行取款(bank_jjb → jjb) */
|
||||||
|
case BANK_WITHDRAW = 'bank_withdraw';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回该来源的中文名称,用于后台统计展示。
|
* 返回该来源的中文名称,用于后台统计展示。
|
||||||
*/
|
*/
|
||||||
@@ -156,6 +189,7 @@ enum CurrencySource: string
|
|||||||
self::RECV_GIFT => '收到礼物',
|
self::RECV_GIFT => '收到礼物',
|
||||||
self::NEWBIE_BONUS => '新人礼包',
|
self::NEWBIE_BONUS => '新人礼包',
|
||||||
self::SHOP_BUY => '商城购买',
|
self::SHOP_BUY => '商城购买',
|
||||||
|
self::RIDE_BUY => '座驾购买(金币)',
|
||||||
self::ADMIN_ADJUST => '管理员调整',
|
self::ADMIN_ADJUST => '管理员调整',
|
||||||
self::POSITION_REWARD => '职务奖励',
|
self::POSITION_REWARD => '职务奖励',
|
||||||
self::SIGN_IN => '每日签到',
|
self::SIGN_IN => '每日签到',
|
||||||
@@ -190,6 +224,16 @@ enum CurrencySource: string
|
|||||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||||
self::VIDEO_REWARD => '看视频奖励',
|
self::VIDEO_REWARD => '看视频奖励',
|
||||||
self::USER_INFO_REVEAL => '信息查看付费',
|
self::USER_INFO_REVEAL => '信息查看付费',
|
||||||
|
self::MSG_BUBBLE_BUY => '消息气泡购买',
|
||||||
|
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
|
||||||
|
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
|
||||||
|
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||||
|
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||||
|
self::GAME_REWARD => '猜谜活动奖励',
|
||||||
|
self::GOLD_GIFT_RECV => '收到赠送金币',
|
||||||
|
self::CHAT_CHARM => '聊天魅力奖励',
|
||||||
|
self::BANK_DEPOSIT => '存入银行',
|
||||||
|
self::BANK_WITHDRAW => '银行取款',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
|||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:向指定房间广播百家乐押注人数变化。
|
||||||
|
*/
|
||||||
class BaccaratPoolUpdated implements ShouldBroadcastNow
|
class BaccaratPoolUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
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 [
|
return [
|
||||||
'round_id' => $this->round->id,
|
'round_id' => $this->round->id,
|
||||||
|
'room_id' => (int) $this->round->room_id,
|
||||||
'bet_count_big' => $this->round->bet_count_big,
|
'bet_count_big' => $this->round->bet_count_big,
|
||||||
'bet_count_small' => $this->round->bet_count_small,
|
'bet_count_small' => $this->round->bet_count_small,
|
||||||
'bet_count_triple' => $this->round->bet_count_triple,
|
'bet_count_triple' => $this->round->bet_count_triple,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
|||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:向指定房间广播百家乐开局事件。
|
||||||
|
*/
|
||||||
class BaccaratRoundOpened implements ShouldBroadcastNow
|
class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
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 [
|
return [
|
||||||
'round_id' => $this->round->id,
|
'round_id' => $this->round->id,
|
||||||
|
'room_id' => (int) $this->round->room_id,
|
||||||
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
|
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
|
||||||
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
|
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
|
||||||
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
|
'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\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:向指定房间广播百家乐结算结果。
|
||||||
|
*/
|
||||||
class BaccaratRoundSettled implements ShouldBroadcastNow
|
class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
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 [
|
return [
|
||||||
'round_id' => $this->round->id,
|
'round_id' => $this->round->id,
|
||||||
|
'room_id' => (int) $this->round->room_id,
|
||||||
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
|
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
|
||||||
'total_points' => $this->round->total_points,
|
'total_points' => $this->round->total_points,
|
||||||
'result' => $this->round->result,
|
'result' => $this->round->result,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
|||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
|
||||||
|
*/
|
||||||
class EffectBroadcast implements ShouldBroadcastNow
|
class EffectBroadcast implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
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 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 $operator 触发特效的用户名(购买者)
|
||||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||||
* @param string|null $giftMessage 附带赠言
|
* @param string|null $giftMessage 附带赠言
|
||||||
|
* @param string|null $effectTitle 特效画面标题
|
||||||
|
* @param string|null $rideName 座驾名称
|
||||||
|
* @param string|null $effectUserInfo 特效画面用户身份信息
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $roomId,
|
public readonly int $roomId,
|
||||||
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
|||||||
public readonly string $operator,
|
public readonly string $operator,
|
||||||
public readonly ?string $targetUsername = null,
|
public readonly ?string $targetUsername = null,
|
||||||
public readonly ?string $giftMessage = 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,
|
'operator' => $this->operator,
|
||||||
'target_username' => $this->targetUsername, // null = 全员
|
'target_username' => $this->targetUsername, // null = 全员
|
||||||
'gift_message' => $this->giftMessage,
|
'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\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:向指定房间广播赛马开局事件。
|
||||||
|
*/
|
||||||
class HorseRaceOpened implements ShouldBroadcastNow
|
class HorseRaceOpened implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
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 [
|
return [
|
||||||
'race_id' => $this->race->id,
|
'race_id' => $this->race->id,
|
||||||
|
'room_id' => (int) $this->race->room_id,
|
||||||
'horses' => $this->race->horses,
|
'horses' => $this->race->horses,
|
||||||
'total_pool' => $this->race->total_pool,
|
'total_pool' => $this->race->total_pool,
|
||||||
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
|
'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\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:向指定房间持续广播赛马进度。
|
||||||
|
*/
|
||||||
class HorseRaceProgress implements ShouldBroadcastNow
|
class HorseRaceProgress implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $raceId,
|
public readonly int $raceId,
|
||||||
|
public readonly int $roomId,
|
||||||
public readonly array $positions,
|
public readonly array $positions,
|
||||||
public readonly bool $finished = false,
|
public readonly bool $finished = false,
|
||||||
public readonly ?int $leaderId = null,
|
public readonly ?int $leaderId = null,
|
||||||
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
{
|
{
|
||||||
return [new PresenceChannel('room.1')];
|
return [new PresenceChannel('room.'.$this->roomId)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'race_id' => $this->raceId,
|
'race_id' => $this->raceId,
|
||||||
|
'room_id' => $this->roomId,
|
||||||
'positions' => $this->positions,
|
'positions' => $this->positions,
|
||||||
'finished' => $this->finished,
|
'finished' => $this->finished,
|
||||||
'leader_id' => $this->leaderId,
|
'leader_id' => $this->leaderId,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastOn(): array
|
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 [
|
return [
|
||||||
'race_id' => $this->race->id,
|
'race_id' => $this->race->id,
|
||||||
|
'room_id' => (int) $this->race->room_id,
|
||||||
'winner_horse_id' => $this->race->winner_horse_id,
|
'winner_horse_id' => $this->race->winner_horse_id,
|
||||||
'winner_name' => $winnerName,
|
'winner_name' => $winnerName,
|
||||||
'total_pool' => (int) $this->race->total_pool,
|
'total_pool' => (int) $this->race->total_pool,
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ class MessageSent implements ShouldBroadcastNow
|
|||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new event instance.
|
* 创建消息广播事件实例。
|
||||||
*
|
*
|
||||||
* @param int $roomId 房间ID
|
* @param int $roomId 房间 ID
|
||||||
* @param array $message 发送的消息数据
|
* @param array $message 发送的消息数据
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
|
|||||||
/**
|
/**
|
||||||
* 获取消息应广播到的频道。
|
* 获取消息应广播到的频道。
|
||||||
*
|
*
|
||||||
* 公共消息走房间 Presence 频道;
|
* 公共消息和普通定向发言走房间 Presence 频道;
|
||||||
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
|
* 悄悄话只发给发送方与接收方的私有用户频道。
|
||||||
*
|
*
|
||||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||||
*/
|
*/
|
||||||
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
private function shouldBroadcastPrivately(): bool
|
private function shouldBroadcastPrivately(): bool
|
||||||
{
|
{
|
||||||
$toUser = trim((string) ($this->message['to_user'] ?? ''));
|
return (bool) ($this->message['is_secret'] ?? false);
|
||||||
|
|
||||||
return $toUser !== '' && $toUser !== '大家';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:前台用户成就展示控制器。
|
||||||
|
*
|
||||||
|
* 展示当前登录用户的成就分类、解锁状态和进度。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供“我的成就”页面数据。
|
||||||
|
*/
|
||||||
|
class AchievementController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建成就控制器依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示当前登录用户的成就总览。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->achievementService->scanUser($user);
|
||||||
|
$achievementData = $this->achievementService->displayForUser($user);
|
||||||
|
$activeTab = in_array($request->query('status'), ['unlocked', 'locked'], true)
|
||||||
|
? $request->query('status')
|
||||||
|
: 'all';
|
||||||
|
$allAchievements = $achievementData['achievements'];
|
||||||
|
|
||||||
|
// 页面 tab 只影响展示列表,不影响顶部总进度统计。
|
||||||
|
$achievementTabs = [
|
||||||
|
'all' => [
|
||||||
|
'label' => '全部',
|
||||||
|
'count' => $allAchievements->count(),
|
||||||
|
'url' => route('achievements.index'),
|
||||||
|
],
|
||||||
|
'unlocked' => [
|
||||||
|
'label' => '已完成',
|
||||||
|
'count' => $allAchievements->where('unlocked', true)->count(),
|
||||||
|
'url' => route('achievements.index', ['status' => 'unlocked']),
|
||||||
|
],
|
||||||
|
'locked' => [
|
||||||
|
'label' => '未达成',
|
||||||
|
'count' => $allAchievements->where('unlocked', false)->count(),
|
||||||
|
'url' => route('achievements.index', ['status' => 'locked']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$achievementData['achievements'] = match ($activeTab) {
|
||||||
|
'unlocked' => $allAchievements->where('unlocked', true)->values(),
|
||||||
|
'locked' => $allAchievements->where('unlocked', false)->values(),
|
||||||
|
default => $allAchievements,
|
||||||
|
};
|
||||||
|
|
||||||
|
return view('achievements.index', [
|
||||||
|
'user' => $user,
|
||||||
|
'active_tab' => $activeTab,
|
||||||
|
'achievement_tabs' => $achievementTabs,
|
||||||
|
...$achievementData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台成就记录查询控制器。
|
||||||
|
*
|
||||||
|
* 提供固定成就目录的解锁统计与用户成就记录只读查询。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Support\AchievementCatalog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:展示后台成就总览、解锁记录与按成就分组统计。
|
||||||
|
*/
|
||||||
|
class AchievementController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 展示成就记录总览。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$definitions = AchievementCatalog::definitions();
|
||||||
|
$query = UserAchievement::query()
|
||||||
|
->with('user:id,username')
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->latest('achieved_at');
|
||||||
|
|
||||||
|
if ($request->filled('username')) {
|
||||||
|
$query->whereHas('user', function ($userQuery) use ($request): void {
|
||||||
|
$userQuery->where('username', 'like', '%'.$request->string('username')->toString().'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('achievement_key')) {
|
||||||
|
$query->where('achievement_key', $request->string('achievement_key')->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = $query->paginate(30)->withQueryString();
|
||||||
|
$summary = [
|
||||||
|
'total_definitions' => count($definitions),
|
||||||
|
'unlocked_records' => UserAchievement::query()->whereNotNull('achieved_at')->count(),
|
||||||
|
'unlocked_users' => UserAchievement::query()->whereNotNull('achieved_at')->distinct('user_id')->count('user_id'),
|
||||||
|
];
|
||||||
|
$topAchievements = UserAchievement::query()
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->select('achievement_key', DB::raw('count(*) as unlocked_count'))
|
||||||
|
->groupBy('achievement_key')
|
||||||
|
->orderByDesc('unlocked_count')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('admin.achievements.index', compact('definitions', 'records', 'summary', 'topAchievements'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,20 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\UpdateGameConfigParamsRequest;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\LotteryIssue;
|
use App\Models\LotteryIssue;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\LotteryService;
|
use App\Services\LotteryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
|
||||||
|
*/
|
||||||
class GameConfigController extends Controller
|
class GameConfigController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
|
|||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
$games = GameConfig::orderBy('id')->get();
|
$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 对象并合并至现有配置。
|
* 接收前端提交的 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 ?? [];
|
$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') {
|
if ($gameConfig->game_key === 'mystery_box') {
|
||||||
$legacyMap = [
|
$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' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
|
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' => '黑化箱'];
|
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'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')) {
|
if (! GameConfig::isEnabled('lottery')) {
|
||||||
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
|
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (LotteryIssue::currentIssue()) {
|
$openedRoomIds = [];
|
||||||
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
|
|
||||||
|
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;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreShopItemRequest;
|
||||||
|
use App\Http\Requests\UpdateShopItemRequest;
|
||||||
use App\Models\ShopItem;
|
use App\Models\ShopItem;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
|
|||||||
/**
|
/**
|
||||||
* 新增商品(仅 id=1 超级站长)
|
* 新增商品(仅 id=1 超级站长)
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(StoreShopItemRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(Auth::id() === 1, 403);
|
$data = $request->validated();
|
||||||
|
|
||||||
$data = $this->validateItem($request);
|
|
||||||
ShopItem::create($data);
|
ShopItem::create($data);
|
||||||
|
|
||||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||||
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
|
|||||||
*
|
*
|
||||||
* @param ShopItem $shopItem 路由模型自动注入
|
* @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);
|
$shopItem->update($data);
|
||||||
|
|
||||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
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 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,'.ShopItem::TYPE_SIGN_REPAIR,
|
|
||||||
'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',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,8 +457,9 @@ class AdminCommandController extends Controller
|
|||||||
abort(403, '仅站长可查看私信');
|
abort(403, '仅站长可查看私信');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询最近 50 条悄悄话(发送或接收)
|
// 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
|
||||||
$messages = Message::where('is_secret', true)
|
$messages = Message::where('is_secret', true)
|
||||||
|
->where('from_user', 'not like', '系统%')
|
||||||
->where(function ($q) use ($username) {
|
->where(function ($q) use ($username) {
|
||||||
$q->where('from_user', $username)
|
$q->where('from_user', $username)
|
||||||
->orWhere('to_user', $username);
|
->orWhere('to_user', $username);
|
||||||
@@ -524,6 +525,13 @@ class AdminCommandController extends Controller
|
|||||||
'font_color' => '#b91c1c',
|
'font_color' => '#b91c1c',
|
||||||
'action' => '',
|
'action' => '',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
'toast_notification' => [
|
||||||
|
'title' => '📢 公屏公告',
|
||||||
|
'message' => strip_tags($content),
|
||||||
|
'icon' => '📢',
|
||||||
|
'color' => '#b91c1c',
|
||||||
|
'duration' => 10000,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
$this->chatState->pushMessage($roomId, $msg);
|
$this->chatState->pushMessage($roomId, $msg);
|
||||||
broadcast(new MessageSent($roomId, $msg));
|
broadcast(new MessageSent($roomId, $msg));
|
||||||
@@ -1016,7 +1024,7 @@ class AdminCommandController extends Controller
|
|||||||
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
||||||
'icon' => '💰',
|
'icon' => '💰',
|
||||||
'color' => '#f59e0b',
|
'color' => '#f59e0b',
|
||||||
'duration' => 8000,
|
'duration' => 3000,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$this->chatState->pushMessage($roomId, $msg);
|
$this->chatState->pushMessage($roomId, $msg);
|
||||||
|
|||||||
@@ -20,16 +20,23 @@ use App\Models\BaccaratBet;
|
|||||||
use App\Models\BaccaratRound;
|
use App\Models\BaccaratRound;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Services\BaccaratLossCoverService;
|
use App\Services\BaccaratLossCoverService;
|
||||||
|
use App\Services\GameBetBroadcastService;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供百家乐当前局查询、下注与历史接口。
|
||||||
|
*/
|
||||||
class BaccaratController extends Controller
|
class BaccaratController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly BaccaratLossCoverService $lossCoverService,
|
private readonly BaccaratLossCoverService $lossCoverService,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +45,13 @@ class BaccaratController extends Controller
|
|||||||
public function currentRound(Request $request): JsonResponse
|
public function currentRound(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$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) {
|
if (! $round) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -98,6 +111,11 @@ class BaccaratController extends Controller
|
|||||||
'bet_type' => 'required|in:big,small,triple',
|
'bet_type' => 'required|in:big,small,triple',
|
||||||
'amount' => 'required|integer|min:1',
|
'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 ?? [];
|
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||||
@@ -109,7 +127,7 @@ class BaccaratController extends Controller
|
|||||||
|
|
||||||
$round = BaccaratRound::find($data['round_id']);
|
$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' => '当前不在下注时间内。']);
|
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,27 +194,7 @@ class BaccaratController extends Controller
|
|||||||
'big' => '大', 'small' => '小', default => '豹子'
|
'big' => '大', 'small' => '小', default => '豹子'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送系统传音到聊天室,公示该用户的押注信息
|
$this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
|
||||||
$chatState = app(\App\Services\ChatStateService::class);
|
|
||||||
$formattedAmount = number_format($data['amount']);
|
|
||||||
$roomId = $round->room_id ?? 1;
|
|
||||||
|
|
||||||
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
|
|
||||||
$content = "🎲 <b> 【百家乐】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
|
|
||||||
$msg = [
|
|
||||||
'id' => $chatState->nextMessageId($roomId),
|
|
||||||
'room_id' => $roomId,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => $content,
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$chatState->pushMessage($roomId, $msg);
|
|
||||||
event(new \App\Events\MessageSent($roomId, $msg));
|
|
||||||
\App\Jobs\SaveMessageJob::dispatch($msg);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -212,7 +210,9 @@ class BaccaratController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function history(): JsonResponse
|
public function history(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
|
||||||
$rounds = BaccaratRound::query()
|
$rounds = BaccaratRound::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
->where('status', 'settled')
|
->where('status', 'settled')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
use App\Models\BankLog;
|
use App\Models\BankLog;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -26,6 +29,9 @@ use Illuminate\Support\Facades\DB;
|
|||||||
*/
|
*/
|
||||||
class BankController extends Controller
|
class BankController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
) {}
|
||||||
/**
|
/**
|
||||||
* 查询银行余额及最近20条流水记录
|
* 查询银行余额及最近20条流水记录
|
||||||
*/
|
*/
|
||||||
@@ -107,7 +113,8 @@ class BankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($user, $amount): void {
|
DB::transaction(function () use ($user, $amount): void {
|
||||||
$user->decrement('jjb', $amount);
|
$this->currencyService->change($user, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
|
||||||
|
|
||||||
$user->increment('bank_jjb', $amount);
|
$user->increment('bank_jjb', $amount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
@@ -151,7 +158,8 @@ class BankController extends Controller
|
|||||||
|
|
||||||
DB::transaction(function () use ($user, $amount): void {
|
DB::transaction(function () use ($user, $amount): void {
|
||||||
$user->decrement('bank_jjb', $amount);
|
$user->decrement('bank_jjb', $amount);
|
||||||
$user->increment('jjb', $amount);
|
|
||||||
|
$this->currencyService->change($user, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
|
|||||||
@@ -85,19 +85,22 @@ class ChatBotController extends Controller
|
|||||||
|
|
||||||
$reply = $result['reply'];
|
$reply = $result['reply'];
|
||||||
|
|
||||||
// 检查 AI 是否决定给用户发金币
|
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额])
|
||||||
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
|
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
|
||||||
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
|
$aiGoldAmount = (int) $matches[1];
|
||||||
|
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
|
||||||
$reply = trim($reply);
|
$reply = trim($reply);
|
||||||
|
|
||||||
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||||
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
||||||
|
|
||||||
|
// 校验 AI 给出的金额在合理范围内
|
||||||
|
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
|
||||||
|
|
||||||
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
||||||
$dailyCount = (int) Redis::get($redisKey);
|
$dailyCount = (int) Redis::get($redisKey);
|
||||||
|
|
||||||
if ($dailyCount < $maxDailyRewards) {
|
if ($dailyCount < $maxDailyRewards) {
|
||||||
$goldAmount = rand(100, $maxGold);
|
|
||||||
|
|
||||||
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
|
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
|
||||||
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
|
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ use App\Models\User;
|
|||||||
use App\Services\AppointmentService;
|
use App\Services\AppointmentService;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
use App\Services\ChatUserPresenceService;
|
use App\Services\ChatUserPresenceService;
|
||||||
|
use App\Services\DailyGameProfitLeaderboardService;
|
||||||
use App\Services\MessageFilterService;
|
use App\Services\MessageFilterService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
|
use App\Services\RideService;
|
||||||
use App\Services\RoomBroadcastService;
|
use App\Services\RoomBroadcastService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Services\VipService;
|
use App\Services\VipService;
|
||||||
@@ -62,9 +64,11 @@ class ChatController extends Controller
|
|||||||
private readonly VipService $vipService,
|
private readonly VipService $vipService,
|
||||||
private readonly \App\Services\ShopService $shopService,
|
private readonly \App\Services\ShopService $shopService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
private readonly DailyGameProfitLeaderboardService $dailyGameProfitLeaderboardService,
|
||||||
private readonly AppointmentService $appointmentService,
|
private readonly AppointmentService $appointmentService,
|
||||||
private readonly RoomBroadcastService $broadcast,
|
private readonly RoomBroadcastService $broadcast,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
|
private readonly RideService $rideService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,15 +120,24 @@ class ChatController extends Controller
|
|||||||
|
|
||||||
// 3. 广播和初始化欢迎(仅限初次进入)
|
// 3. 广播和初始化欢迎(仅限初次进入)
|
||||||
$newbieEffect = null;
|
$newbieEffect = null;
|
||||||
|
$initialRideEffect = null;
|
||||||
|
$initialRideEffectOptions = null;
|
||||||
$initialPresenceTheme = null;
|
$initialPresenceTheme = null;
|
||||||
$initialWelcomeMessage = null;
|
$initialWelcomeMessage = null;
|
||||||
|
$initialWelcomeMessages = [];
|
||||||
|
|
||||||
if (! $isAlreadyInRoom) {
|
if (! $isAlreadyInRoom) {
|
||||||
// 广播 UserJoined 事件,通知房间内的其他人
|
// 广播 UserJoined 事件,通知房间内的其他人
|
||||||
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
||||||
|
|
||||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
|
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||||
if (! $user->has_received_new_gift) {
|
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
|
||||||
|
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||||
|
|
||||||
|
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
|
||||||
|
$user->refresh();
|
||||||
|
$isNewbie = ! $user->has_received_new_gift;
|
||||||
|
if ($isNewbie) {
|
||||||
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
|
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
|
||||||
$this->currencyService->change(
|
$this->currencyService->change(
|
||||||
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
|
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
|
||||||
@@ -142,55 +155,127 @@ class ChatController extends Controller
|
|||||||
'font_color' => '#b91c1c',
|
'font_color' => '#b91c1c',
|
||||||
'action' => '',
|
'action' => '',
|
||||||
'welcome_user' => $user->username,
|
'welcome_user' => $user->username,
|
||||||
|
'welcome_kind' => 'newbie_bonus',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$this->chatState->pushMessage($id, $newbieMsg);
|
$this->chatState->pushMessage($id, $newbieMsg);
|
||||||
broadcast(new MessageSent($id, $newbieMsg));
|
broadcast(new MessageSent($id, $newbieMsg));
|
||||||
|
SaveMessageJob::dispatch($newbieMsg);
|
||||||
|
$initialWelcomeMessages[] = $newbieMsg;
|
||||||
|
|
||||||
// 广播烟花特效给此时已在房间的其他用户
|
// 广播烟花特效给此时已在房间的其他用户
|
||||||
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
|
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
|
||||||
|
|
||||||
// 传给前端,让新人自己的屏幕上也燃放烟花
|
// 传给前端,让新人自己的屏幕上也燃放烟花
|
||||||
$newbieEffect = 'fireworks';
|
$newbieEffect = 'fireworks';
|
||||||
}
|
|
||||||
|
|
||||||
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
|
// AI小班长发送欢迎消息,附带聊天室简单介绍
|
||||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
$aiWelcomeTemplates = [
|
||||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
"🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你!",
|
||||||
|
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
|
||||||
|
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
|
||||||
|
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
|
||||||
|
];
|
||||||
|
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
|
||||||
|
|
||||||
|
$aiWelcomeMsg = [
|
||||||
|
'id' => $this->chatState->nextMessageId($id),
|
||||||
|
'room_id' => $id,
|
||||||
|
'from_user' => 'AI小班长',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => $aiWelcomeContent,
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#16a34a',
|
||||||
|
'action' => '大声宣告',
|
||||||
|
'welcome_user' => $user->username,
|
||||||
|
'welcome_kind' => 'ai_newbie_welcome',
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
$this->chatState->pushMessage($id, $aiWelcomeMsg);
|
||||||
|
broadcast(new MessageSent($id, $aiWelcomeMsg));
|
||||||
|
SaveMessageJob::dispatch($aiWelcomeMsg);
|
||||||
|
$initialWelcomeMessages[] = $aiWelcomeMsg;
|
||||||
|
}
|
||||||
|
|
||||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
|
||||||
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
if (! $ridePresencePayload) {
|
||||||
|
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||||
|
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
||||||
|
|
||||||
$generalWelcomeMsg = [
|
$generalWelcomeMsg = [
|
||||||
'id' => $this->chatState->nextMessageId($id),
|
'id' => $this->chatState->nextMessageId($id),
|
||||||
'room_id' => $id,
|
'room_id' => $id,
|
||||||
'from_user' => '进出播报',
|
'from_user' => '进出播报',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => $color,
|
'font_color' => $color,
|
||||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||||
'welcome_user' => $user->username,
|
'welcome_user' => $user->username,
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'welcome_kind' => 'entry_broadcast',
|
||||||
];
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||||
if (! empty($vipPresencePayload)) {
|
if (! empty($vipPresencePayload)) {
|
||||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||||
$initialPresenceTheme = $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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
if ($ridePresencePayload) {
|
||||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
$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() 导致自己看不到自己的进场提示
|
$this->chatState->pushMessage($id, $rideWelcomeMsg);
|
||||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
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();
|
||||||
|
|
||||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
$initialRideEffect = $ridePresencePayload['ride_key'];
|
||||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
$initialRideEffectOptions = [
|
||||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
'effect_title' => $ridePresencePayload['effect_title'],
|
||||||
|
'effect_user_info' => $ridePresencePayload['effect_user_info'],
|
||||||
|
'ride_name' => $ridePresencePayload['ride_name'],
|
||||||
|
'operator' => $user->username,
|
||||||
|
];
|
||||||
|
$initialWelcomeMessages[] = $rideWelcomeMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +298,8 @@ class ChatController extends Controller
|
|||||||
return $fromUser === $username || $toUser === $username;
|
return $fromUser === $username || $toUser === $username;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
|
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
|
||||||
return $fromUser === $username || $toUser === $username;
|
return true;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
|
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
|
||||||
@@ -279,13 +364,17 @@ class ChatController extends Controller
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
||||||
'newbieEffect' => $newbieEffect,
|
'newbieEffect' => $newbieEffect,
|
||||||
|
'initialRideEffect' => $initialRideEffect,
|
||||||
|
'initialRideEffectOptions' => $initialRideEffectOptions,
|
||||||
'initialPresenceTheme' => $initialPresenceTheme,
|
'initialPresenceTheme' => $initialPresenceTheme,
|
||||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||||
|
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||||
'historyMessages' => $historyMessages,
|
'historyMessages' => $historyMessages,
|
||||||
'pendingProposal' => $pendingProposalData,
|
'pendingProposal' => $pendingProposalData,
|
||||||
'pendingDivorce' => $pendingDivorceData,
|
'pendingDivorce' => $pendingDivorceData,
|
||||||
'roomPermissionMap' => $roomPermissionMap,
|
'roomPermissionMap' => $roomPermissionMap,
|
||||||
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
|
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
|
||||||
|
'dailyGameProfitLeaders' => $this->dailyGameProfitLeaderboardService->topThree(),
|
||||||
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
|
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
|
||||||
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
|
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
|
||||||
]);
|
]);
|
||||||
@@ -413,6 +502,21 @@ class ChatController extends Controller
|
|||||||
$messageData = array_merge($messageData, $imagePayload);
|
$messageData = array_merge($messageData, $imagePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 欢迎动作:增加右下角弹窗通知(内容含发送者信息)
|
||||||
|
if (($data['action'] ?? '') === '欢迎') {
|
||||||
|
$messageData['toast_notification'] = [
|
||||||
|
'title' => '👋 欢迎',
|
||||||
|
'message' => strip_tags($pureContent),
|
||||||
|
'icon' => '👋',
|
||||||
|
'color' => '#e11d48',
|
||||||
|
'duration' => 3000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
|
||||||
|
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
|
||||||
|
$messageData = array_merge($messageData, $decorations);
|
||||||
|
|
||||||
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
||||||
$this->chatState->pushMessage($id, $messageData);
|
$this->chatState->pushMessage($id, $messageData);
|
||||||
|
|
||||||
@@ -987,12 +1091,9 @@ class ChatController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扣除金币、增加对方魅力
|
// 扣除金币、增加对方魅力(含流水日志)
|
||||||
$user->jjb = ($user->jjb ?? 0) - $totalCost;
|
$this->currencyService->change($user, 'gold', -$totalCost, CurrencySource::SEND_GIFT, "送花:送给 {$toUsername} {$count} 份【{$gift->name}】,魅力 +{$totalCharm}", $roomId);
|
||||||
$user->save();
|
$this->currencyService->change($toUser, 'charm', $totalCharm, CurrencySource::RECV_GIFT, "收到 {$user->username} 赠送的 {$count} 份【{$gift->name}】", $roomId);
|
||||||
|
|
||||||
$toUser->meili = ($toUser->meili ?? 0) + $totalCharm;
|
|
||||||
$toUser->save();
|
|
||||||
|
|
||||||
// 构建礼物图片 URL
|
// 构建礼物图片 URL
|
||||||
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
|
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
|
||||||
@@ -1024,8 +1125,8 @@ class ChatController extends Controller
|
|||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
|
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
|
||||||
'data' => [
|
'data' => [
|
||||||
'my_jjb' => $user->jjb,
|
'my_jjb' => $user->fresh()->jjb,
|
||||||
'target_charm' => $toUser->meili,
|
'target_charm' => $toUser->fresh()->meili,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1078,9 +1179,8 @@ class ChatController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送者获得魅力
|
// 发送者获得魅力(含流水日志)
|
||||||
$sender->meili = ($sender->meili ?? 0) + $charmGain;
|
$this->currencyService->change($sender, 'charm', $charmGain, CurrencySource::CHAT_CHARM, "与 {$toUsername} 聊天获得魅力奖励");
|
||||||
$sender->save();
|
|
||||||
|
|
||||||
// 更新 Redis 计数器(1 小时过期)
|
// 更新 Redis 计数器(1 小时过期)
|
||||||
Redis::incrby($capKey, $charmGain);
|
Redis::incrby($capKey, $charmGain);
|
||||||
@@ -1303,9 +1403,9 @@ class ChatController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行转账(直接操作字段,与 sendFlower 保持一致风格)
|
// 执行转账(含流水日志)
|
||||||
$sender->decrement('jjb', $amount);
|
$this->currencyService->change($sender, 'gold', -$amount, CurrencySource::GIFT_SENT, "赠送金币给 {$toName}", $roomId);
|
||||||
$receiver->increment('jjb', $amount);
|
$this->currencyService->change($receiver, 'gold', $amount, CurrencySource::GOLD_GIFT_RECV, "收到 {$sender->username} 赠送的金币", $roomId);
|
||||||
|
|
||||||
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
|
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
|
||||||
$giftMsg = [
|
$giftMsg = [
|
||||||
@@ -1324,7 +1424,7 @@ class ChatController extends Controller
|
|||||||
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||||
'icon' => '💰',
|
'icon' => '💰',
|
||||||
'color' => '#f59e0b',
|
'color' => '#f59e0b',
|
||||||
'duration' => 8000,
|
'duration' => 3000,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1364,6 +1464,76 @@ class ChatController extends Controller
|
|||||||
return null;
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
|
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class DailySignInController extends Controller
|
|||||||
$message = [
|
$message = [
|
||||||
'id' => $this->chatState->nextMessageId($roomId),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
'from_user' => '签到播报',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
|
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
@@ -185,9 +185,10 @@ class DailySignInController extends Controller
|
|||||||
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
|
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
|
||||||
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
|
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
|
||||||
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
|
.'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>';
|
.'✅ 快速签到</button>';
|
||||||
|
|
||||||
return '【'.e($user->username).'】完成今日签到,连续签到 '
|
return '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ class EarnController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function claimVideoReward(Request $request): JsonResponse
|
public function claimVideoReward(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '看视频赚钱功能已关闭。',
|
||||||
|
]);
|
||||||
|
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
@@ -99,9 +104,10 @@ class EarnController extends Controller
|
|||||||
|
|
||||||
// 6. 广播全服系统消息
|
// 6. 广播全服系统消息
|
||||||
if ($roomId > 0) {
|
if ($roomId > 0) {
|
||||||
|
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
|
||||||
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
|
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
|
||||||
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
|
.'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>';
|
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
|
||||||
|
|
||||||
$sysMsg = [
|
$sysMsg = [
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
|
|||||||
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取反馈第一页数据(JSON API)
|
||||||
|
* 供聊天室模态弹窗使用,格式与 loadMore 一致
|
||||||
|
*
|
||||||
|
* @param Request $request 含 type 筛选参数
|
||||||
|
*/
|
||||||
|
public function data(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$type = $request->input('type'); // bug|suggestion|null(全部)
|
||||||
|
|
||||||
|
$query = FeedbackItem::with(['replies'])
|
||||||
|
->orderByDesc('votes_count')
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||||
|
$query->ofType($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $query->limit(self::PAGE_SIZE)->get();
|
||||||
|
|
||||||
|
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||||
|
->whereIn('feedback_id', $items->pluck('id'))
|
||||||
|
->pluck('feedback_id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $this->formatItems($items, $myVotedIds),
|
||||||
|
'last_id' => $items->last()?->id ?? 0,
|
||||||
|
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 懒加载更多反馈(JSON API)
|
* 懒加载更多反馈(JSON API)
|
||||||
* 支持按类型筛选(bug / suggestion)
|
* 支持按类型筛选(bug / suggestion)
|
||||||
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function formatItem(FeedbackItem $item, bool $voted): array
|
private function formatItem(FeedbackItem $item, bool $voted): array
|
||||||
{
|
{
|
||||||
|
/** @var \App\Models\User $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
$isOwner = $item->user_id === $user->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
'type' => $item->type,
|
'type' => $item->type,
|
||||||
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
|
|||||||
'username' => $item->username,
|
'username' => $item->username,
|
||||||
'created_at' => $item->created_at->diffForHumans(),
|
'created_at' => $item->created_at->diffForHumans(),
|
||||||
'voted' => $voted,
|
'voted' => $voted,
|
||||||
|
'is_owner' => $isOwner,
|
||||||
|
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
|
||||||
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
||||||
'id' => $r->id,
|
'id' => $r->id,
|
||||||
'username' => $r->username,
|
'username' => $r->username,
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
|
|||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
use App\Services\FishingService;
|
use App\Services\FishingService;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\ShopService;
|
use App\Services\ShopService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Services\VipService;
|
use App\Services\VipService;
|
||||||
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
|
||||||
|
*/
|
||||||
class FishingController extends Controller
|
class FishingController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly VipService $vipService,
|
private readonly VipService $vipService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
private readonly ShopService $shopService,
|
private readonly ShopService $shopService,
|
||||||
private readonly FishingService $fishingService,
|
private readonly FishingService $fishingService,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +72,10 @@ class FishingController extends Controller
|
|||||||
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
|
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
|
||||||
|
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 检查冷却时间(Redis TTL)
|
// 1. 检查冷却时间(Redis TTL)
|
||||||
$cooldownKey = "fishing:cd:{$user->id}";
|
$cooldownKey = "fishing:cd:{$user->id}";
|
||||||
if (Redis::exists($cooldownKey)) {
|
if (Redis::exists($cooldownKey)) {
|
||||||
@@ -75,6 +88,14 @@ class FishingController extends Controller
|
|||||||
], 429);
|
], 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tokenKey = "fishing:token:{$user->id}";
|
||||||
|
if (Redis::exists($tokenKey)) {
|
||||||
|
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
|
||||||
|
if ($activeSessionResponse) {
|
||||||
|
return $activeSessionResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 检查金币是否足够
|
// 2. 检查金币是否足够
|
||||||
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
||||||
if (($user->jjb ?? 0) < $cost) {
|
if (($user->jjb ?? 0) < $cost) {
|
||||||
@@ -84,34 +105,54 @@ class FishingController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 扣除金币
|
// 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
|
||||||
$this->currencyService->change(
|
|
||||||
$user, 'gold', -$cost,
|
|
||||||
CurrencySource::FISHING_COST,
|
|
||||||
"钓鱼抛竿消耗 {$cost} 金币",
|
|
||||||
$id,
|
|
||||||
);
|
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
|
|
||||||
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
$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'));
|
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
||||||
$waitTime = rand($waitMin, $waitMax);
|
$waitTime = rand($waitMin, $waitMax);
|
||||||
$token = Str::random(32);
|
$token = Str::random(32);
|
||||||
$tokenKey = "fishing:token:{$user->id}";
|
$tokenTtl = $waitTime + 13;
|
||||||
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
|
$tokenPayload = json_encode([
|
||||||
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
|
|
||||||
Redis::setex($tokenKey, $waitTime + 13, json_encode([
|
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'cast_at' => time(),
|
'cast_at' => time(),
|
||||||
'wait_time' => $waitTime,
|
'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%
|
$bobberX = rand(15, 85); // 左右 15%~85%
|
||||||
$bobberY = rand(20, 65); // 上下 20%~65%
|
$bobberY = rand(20, 65); // 上下 20%~65%
|
||||||
|
|
||||||
// 6. 检查是否持有有效自动钓鱼卡
|
// 5. 检查是否持有有效自动钓鱼卡
|
||||||
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||||
|
|
||||||
return response()->json([
|
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,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
|
* 收竿 — 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
|
||||||
*
|
*
|
||||||
@@ -142,6 +214,10 @@ class FishingController extends Controller
|
|||||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
|
||||||
|
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
|
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
|
||||||
$tokenKey = "fishing:token:{$user->id}";
|
$tokenKey = "fishing:token:{$user->id}";
|
||||||
$storedJson = Redis::get($tokenKey);
|
$storedJson = Redis::get($tokenKey);
|
||||||
|
|||||||
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
|
|||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Models\FortuneLog;
|
use App\Models\FortuneLog;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供神秘占卜状态、抽签和历史接口。
|
||||||
|
*/
|
||||||
class FortuneTellingController extends Controller
|
class FortuneTellingController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
|
|||||||
return response()->json(['enabled' => false]);
|
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();
|
$user = $request->user();
|
||||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||||
|
|
||||||
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
|
|||||||
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
|
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();
|
$user = $request->user();
|
||||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||||
|
|
||||||
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function history(Request $request): JsonResponse
|
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()
|
$logs = FortuneLog::query()
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
|
|||||||
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
|
|||||||
use App\Events\GomokuMovedEvent;
|
use App\Events\GomokuMovedEvent;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\GomokuGame;
|
use App\Models\GomokuGame;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\GomokuAiService;
|
use App\Services\GomokuAiService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:处理五子棋创建、加入与对局过程接口。
|
||||||
|
*/
|
||||||
class GomokuController extends Controller
|
class GomokuController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GomokuAiService $ai,
|
private readonly GomokuAiService $ai,
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +63,10 @@ class GomokuController extends Controller
|
|||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
|
||||||
|
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
// PvP:检查是否已在等待/对局中(一次只能参与一场)
|
// PvP:检查是否已在等待/对局中(一次只能参与一场)
|
||||||
$activeGame = GomokuGame::query()
|
$activeGame = GomokuGame::query()
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
|
|||||||
use App\Models\Guestbook;
|
use App\Models\Guestbook;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\MessageFilterService;
|
use App\Services\MessageFilterService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', '该行留言已被抹除。');
|
return back()->with('success', '该行留言已被抹除。');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
|
||||||
|
*/
|
||||||
|
public function data(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tab = $request->input('tab', 'public');
|
||||||
|
$page = (int) $request->input('page', 1);
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$query = Guestbook::query()->orderByDesc('id');
|
||||||
|
|
||||||
|
if ($tab === 'inbox') {
|
||||||
|
$query->where('towho', $user->username);
|
||||||
|
} elseif ($tab === 'outbox') {
|
||||||
|
$query->where('who', $user->username);
|
||||||
|
} else {
|
||||||
|
$query->where(function ($q) use ($user) {
|
||||||
|
$q->where('secret', 0)
|
||||||
|
->orWhere('who', $user->username)
|
||||||
|
->orWhere('towho', $user->username);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = 15;
|
||||||
|
$total = $query->count();
|
||||||
|
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
|
||||||
|
|
||||||
|
$items = $messages->map(function ($msg) use ($user) {
|
||||||
|
$isSecret = (bool) $msg->secret;
|
||||||
|
$isToMe = $msg->towho === $user->username;
|
||||||
|
$isFromMe = $msg->who === $user->username;
|
||||||
|
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $msg->id,
|
||||||
|
'who' => $msg->who,
|
||||||
|
'towho' => $msg->towho ?: '',
|
||||||
|
'secret' => $isSecret,
|
||||||
|
'text_body' => $msg->text_body,
|
||||||
|
'post_time' => $msg->post_time?->diffForHumans() ?? '',
|
||||||
|
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
|
||||||
|
'is_to_me' => $isToMe,
|
||||||
|
'is_from_me' => $isFromMe,
|
||||||
|
'can_delete' => $canDelete,
|
||||||
|
'who_avatar' => mb_substr($msg->who, 0, 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有用户名列表(供发信选择器使用)
|
||||||
|
$users = User::where('username', '!=', $user->username)
|
||||||
|
->orderBy('username')
|
||||||
|
->pluck('username');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => $items,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'has_more' => ($page * $perPage) < $total,
|
||||||
|
'users' => $users,
|
||||||
|
'tab' => $tab,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,28 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
|
||||||
use App\Jobs\SaveMessageJob;
|
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\HorseBet;
|
use App\Models\HorseBet;
|
||||||
use App\Models\HorseRace;
|
use App\Models\HorseRace;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\GameBetBroadcastService;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:赛马竞猜前台控制器
|
||||||
|
*
|
||||||
|
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
|
||||||
|
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
|
||||||
|
*/
|
||||||
class HorseRaceController extends Controller
|
class HorseRaceController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +51,12 @@ class HorseRaceController extends Controller
|
|||||||
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
|
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) {
|
if (! $race) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -139,6 +151,11 @@ class HorseRaceController extends Controller
|
|||||||
'horse_id' => 'required|integer|min:1',
|
'horse_id' => 'required|integer|min:1',
|
||||||
'amount' => '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 ?? [];
|
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||||
@@ -150,7 +167,7 @@ class HorseRaceController extends Controller
|
|||||||
|
|
||||||
$race = HorseRace::find($data['race_id']);
|
$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' => '当前不在下注时间内。']);
|
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,23 +220,7 @@ class HorseRaceController extends Controller
|
|||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$chatState = app(ChatStateService::class);
|
$this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
|
||||||
$formattedAmount = number_format($data['amount']);
|
|
||||||
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
|
|
||||||
$msg = [
|
|
||||||
'id' => $chatState->nextMessageId(1),
|
|
||||||
'room_id' => 1,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => $content,
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$chatState->pushMessage(1, $msg);
|
|
||||||
event(new MessageSent(1, $msg));
|
|
||||||
SaveMessageJob::dispatch($msg);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -235,7 +236,9 @@ class HorseRaceController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function history(): JsonResponse
|
public function history(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
|
||||||
$races = HorseRace::query()
|
$races = HorseRace::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
->where('status', 'settled')
|
->where('status', 'settled')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
@@ -264,6 +267,119 @@ class HorseRaceController extends Controller
|
|||||||
return response()->json(['history' => $history]);
|
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\GameConfig;
|
||||||
use App\Models\LotteryIssue;
|
use App\Models\LotteryIssue;
|
||||||
use App\Models\LotteryTicket;
|
use App\Models\LotteryTicket;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\LotteryService;
|
use App\Services\LotteryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供双色球当前期、购票和历史记录接口。
|
||||||
|
*/
|
||||||
class LotteryController extends Controller
|
class LotteryController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LotteryService $lottery,
|
private readonly LotteryService $lottery,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
|
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
|
||||||
*/
|
*/
|
||||||
public function current(): JsonResponse
|
public function current(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
if (! GameConfig::isEnabled('lottery')) {
|
if (! GameConfig::isEnabled('lottery')) {
|
||||||
return response()->json(['enabled' => false]);
|
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) {
|
if (! $issue) {
|
||||||
return response()->json(['enabled' => true, 'issue' => null]);
|
return response()->json(['enabled' => true, 'issue' => null]);
|
||||||
@@ -90,6 +101,11 @@ class LotteryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function buy(Request $request): JsonResponse
|
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([
|
$request->validate([
|
||||||
'numbers' => 'required|array|min:1',
|
'numbers' => 'required|array|min:1',
|
||||||
'numbers.*.reds' => 'required|array|size:3',
|
'numbers.*.reds' => 'required|array|size:3',
|
||||||
@@ -132,7 +148,9 @@ class LotteryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function history(): JsonResponse
|
public function history(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
|
||||||
$issues = LotteryIssue::query()
|
$issues = LotteryIssue::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
->where('status', 'settled')
|
->where('status', 'settled')
|
||||||
->latest()
|
->latest()
|
||||||
->limit(20)
|
->limit(20)
|
||||||
|
|||||||
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
|
|||||||
use App\Models\MysteryBox;
|
use App\Models\MysteryBox;
|
||||||
use App\Models\MysteryBoxClaim;
|
use App\Models\MysteryBoxClaim;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
|
||||||
|
*/
|
||||||
class MysteryBoxController extends Controller
|
class MysteryBoxController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
|
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
|
||||||
*/
|
*/
|
||||||
public function status(): JsonResponse
|
public function status(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
if (! GameConfig::isEnabled('mystery_box')) {
|
if (! GameConfig::isEnabled('mystery_box')) {
|
||||||
return response()->json(['active' => false]);
|
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) {
|
if (! $box) {
|
||||||
return response()->json(['active' => false]);
|
return response()->json(['active' => false]);
|
||||||
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$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()
|
$box = MysteryBox::query()
|
||||||
|
->where('room_id', $roomId)
|
||||||
->where('passcode', $passcode)
|
->where('passcode', $passcode)
|
||||||
->where('status', 'open')
|
->where('status', 'open')
|
||||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||||
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
|
|||||||
$typeName = $box->typeName();
|
$typeName = $box->typeName();
|
||||||
|
|
||||||
if ($reward >= 0) {
|
if ($reward >= 0) {
|
||||||
$content = "{$emoji}【神秘箱子】开箱播报:恭喜 【{$username}】 抢到了神秘{$typeName}!"
|
$content = "{$emoji} 【{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
|
||||||
.'获得 💰'.number_format($reward).' 金币!';
|
|
||||||
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
||||||
} else {
|
} else {
|
||||||
$content = "☠️【神秘箱子】《黑化陷阱》haha!【{$username}】 中了神秘黑化箱的陷阱!"
|
$content = "☠️ 【{$username}】踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
|
||||||
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
|
|
||||||
$color = '#f87171';
|
$color = '#f87171';
|
||||||
}
|
}
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $this->chatState->nextMessageId(1),
|
'id' => $this->chatState->nextMessageId((int) $box->room_id),
|
||||||
'room_id' => 1,
|
'room_id' => (int) $box->room_id,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
|
|||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->chatState->pushMessage(1, $msg);
|
$this->chatState->pushMessage((int) $box->room_id, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent((int) $box->room_id, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Models\RedPacketClaim;
|
|||||||
use App\Models\RedPacketEnvelope;
|
use App\Models\RedPacketEnvelope;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\GameBetBroadcastService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Support\PositionPermissionRegistry;
|
use App\Support\PositionPermissionRegistry;
|
||||||
@@ -58,6 +59,7 @@ class RedPacketController extends Controller
|
|||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -357,23 +359,9 @@ class RedPacketController extends Controller
|
|||||||
type: $envelopeType,
|
type: $envelopeType,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 在聊天室发送领取播报(所有人可见)
|
// 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
|
||||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||||
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
|
$this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
|
||||||
$claimedMsg = [
|
|
||||||
'id' => $this->chatState->nextMessageId($roomId),
|
|
||||||
'room_id' => $roomId,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '',
|
|
||||||
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$this->chatState->pushMessage($roomId, $claimedMsg);
|
|
||||||
broadcast(new MessageSent($roomId, $claimedMsg));
|
|
||||||
SaveMessageJob::dispatch($claimedMsg);
|
|
||||||
|
|
||||||
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
||||||
$balanceNow = $user->fresh()->$balanceField;
|
$balanceNow = $user->fresh()->$balanceField;
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<?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,
|
||||||
|
'round_id' => $round->id,
|
||||||
|
'quiz_round_id' => $round->id,
|
||||||
|
'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,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:聊天室座驾前台接口控制器。
|
||||||
|
*
|
||||||
|
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Http\Requests\BuyRideRequest;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
use App\Models\Ride;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pushRidePurchaseNotice($user, $item, $roomId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => $result['message'],
|
||||||
|
'current_ride' => $result['current_ride'] ?? null,
|
||||||
|
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
|
||||||
|
'jjb' => $user->fresh()->jjb,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向当前房间广播座驾购买成功通知,方便其他用户快速打开座驾页面。
|
||||||
|
*/
|
||||||
|
private function pushRidePurchaseNotice(User $user, Ride $item, int $roomId): void
|
||||||
|
{
|
||||||
|
$button = '<button onclick="openRideModal()">购买座驾</button>';
|
||||||
|
$content = sprintf(
|
||||||
|
'🚀 【座驾】 <b>%s</b> 购买了 <b>%s</b>,有效期 <b>%d 天</b>,排面已安排!%s',
|
||||||
|
e($user->username),
|
||||||
|
e($item->name),
|
||||||
|
(int) $item->duration_days,
|
||||||
|
$button,
|
||||||
|
);
|
||||||
|
|
||||||
|
$message = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统传音',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => $content,
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#0f766e',
|
||||||
|
'action' => 'ride_purchase',
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 购买通知需要写入房间消息缓存、实时广播并落库,刷新后仍可追溯。
|
||||||
|
$this->chatState->pushMessage($roomId, $message);
|
||||||
|
broadcast(new MessageSent($roomId, $message));
|
||||||
|
SaveMessageJob::dispatch($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Events\EffectBroadcast;
|
use App\Events\EffectBroadcast;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
|
use App\Events\UserStatusUpdated;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
use App\Models\ShopItem;
|
use App\Models\ShopItem;
|
||||||
use App\Models\UserPurchase;
|
use App\Models\UserPurchase;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\DecorationService;
|
||||||
use App\Services\ShopService;
|
use App\Services\ShopService;
|
||||||
use App\Support\ChatContentSanitizer;
|
use App\Support\ChatContentSanitizer;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -38,19 +40,23 @@ class ShopController extends Controller
|
|||||||
public function items(): JsonResponse
|
public function items(): JsonResponse
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$items = ShopItem::active()->map(fn ($item) => [
|
$items = ShopItem::query()
|
||||||
'id' => $item->id,
|
->where('is_active', true)
|
||||||
'name' => $item->name,
|
->orderBy('sort_order')
|
||||||
'slug' => $item->slug,
|
->get()
|
||||||
'description' => $item->description,
|
->map(fn ($item) => [
|
||||||
'icon' => $item->icon,
|
'id' => $item->id,
|
||||||
'price' => $item->price,
|
'name' => $item->name,
|
||||||
'type' => $item->type,
|
'slug' => $item->slug,
|
||||||
'duration_days' => $item->duration_days,
|
'description' => $item->description,
|
||||||
'duration_minutes' => $item->duration_minutes,
|
'icon' => $item->icon,
|
||||||
'intimacy_bonus' => $item->intimacy_bonus,
|
'price' => $item->price,
|
||||||
'charm_bonus' => $item->charm_bonus,
|
'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);
|
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||||
|
|
||||||
@@ -73,6 +79,8 @@ class ShopController extends Controller
|
|||||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||||
'sign_repair_card_item' => $signRepairCard,
|
'sign_repair_card_item' => $signRepairCard,
|
||||||
|
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||||
|
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +129,27 @@ class ShopController extends Controller
|
|||||||
'total_price' => $result['total_price'] ?? $item->price,
|
'total_price' => $result['total_price'] ?? $item->price,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
|
||||||
|
if ($item->type === 'auto_fishing') {
|
||||||
|
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||||
|
if (! empty($result['slot'])) {
|
||||||
|
$response['slot'] = $result['slot'];
|
||||||
|
$response['style'] = $result['style'];
|
||||||
|
$response['expires_at'] = $result['expires_at'];
|
||||||
|
|
||||||
|
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||||
|
$freshUser = $user->fresh();
|
||||||
|
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||||
|
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||||
|
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||||
|
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||||
|
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||||
if (isset($result['play_effect'])) {
|
if (isset($result['play_effect'])) {
|
||||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||||
@@ -205,6 +234,7 @@ class ShopController extends Controller
|
|||||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||||
|
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
|
|||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\SlotMachineLog;
|
use App\Models\SlotMachineLog;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供老虎机信息查询、转动和个人记录接口。
|
||||||
|
*/
|
||||||
class SlotMachineController extends Controller
|
class SlotMachineController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
|
|||||||
return response()->json(['enabled' => false]);
|
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 ?? [];
|
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
||||||
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
|
|||||||
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
|
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 ?? [];
|
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||||
$cost = (int) ($config['cost_per_spin'] ?? 100);
|
$cost = (int) ($config['cost_per_spin'] ?? 100);
|
||||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
$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(
|
$this->currency->change(
|
||||||
$user,
|
$user,
|
||||||
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
|
|||||||
|
|
||||||
if ($resultType === 'jackpot') {
|
if ($resultType === 'jackpot') {
|
||||||
// 三个7:全服公屏广播
|
// 三个7:全服公屏广播
|
||||||
$this->broadcastJackpot($user->username, $payout, $cost);
|
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
|
||||||
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
|
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
|
||||||
// 普通中奖:仅向本人发送聊天室系统通知
|
// 普通中奖:仅向本人发送聊天室系统通知
|
||||||
$net = $payout - $cost;
|
$net = $payout - $cost;
|
||||||
$content = "🎰 {$resultLabel}!{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
|
$content = "🎰 {$resultLabel}!{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
|
||||||
$this->broadcastPersonal($user->username, $content);
|
$this->broadcastPersonal($user->username, $content, $roomId);
|
||||||
} elseif ($resultType === 'curse') {
|
} elseif ($resultType === 'curse') {
|
||||||
// 诅咒:通知本人
|
// 诅咒:通知本人
|
||||||
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
|
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
|
||||||
$this->broadcastPersonal($user->username, $content);
|
$this->broadcastPersonal($user->username, $content, $roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->refresh();
|
$user->refresh();
|
||||||
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function history(Request $request): JsonResponse
|
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()
|
$logs = SlotMachineLog::query()
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
|
|||||||
/**
|
/**
|
||||||
* 三个7全服公屏广播。
|
* 三个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;
|
$net = $payout - $cost;
|
||||||
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
|
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
|
||||||
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
|
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $this->chatState->nextMessageId(1),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => 1,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
|
|||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->chatState->pushMessage(1, $msg);
|
$this->chatState->pushMessage($roomId, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent($roomId, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
|
|||||||
* @param string $toUsername 接收用户名
|
* @param string $toUsername 接收用户名
|
||||||
* @param string $content 消息内容
|
* @param string $content 消息内容
|
||||||
*/
|
*/
|
||||||
private function broadcastPersonal(string $toUsername, string $content): void
|
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
|
||||||
{
|
{
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $this->chatState->nextMessageId(1),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => 1,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => $toUsername,
|
'to_user' => $toUsername,
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
|
|||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent($roomId, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use App\Http\Requests\UpdateDailyStatusRequest;
|
|||||||
use App\Http\Requests\UpdateProfileRequest;
|
use App\Http\Requests\UpdateProfileRequest;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\AchievementService;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
use App\Services\ChatUserPresenceService;
|
use App\Services\ChatUserPresenceService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
@@ -58,6 +59,7 @@ class UserController extends Controller
|
|||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ class UserController extends Controller
|
|||||||
$activePosition = $targetUser->activePosition?->load('position.department')->position;
|
$activePosition = $targetUser->activePosition?->load('position.department')->position;
|
||||||
$data = [
|
$data = [
|
||||||
'username' => $targetUser->username,
|
'username' => $targetUser->username,
|
||||||
|
'previous_name' => $targetUser->previous_name,
|
||||||
'sex' => match ((int) $targetUser->sex) {
|
'sex' => match ((int) $targetUser->sex) {
|
||||||
1 => '男', 2 => '女', default => ''
|
1 => '男', 2 => '女', default => ''
|
||||||
},
|
},
|
||||||
@@ -159,6 +162,8 @@ class UserController extends Controller
|
|||||||
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
||||||
] : null,
|
] : null,
|
||||||
];
|
];
|
||||||
|
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
|
||||||
|
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
|
||||||
|
|
||||||
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
||||||
$canViewNetworkInfo = $operator
|
$canViewNetworkInfo = $operator
|
||||||
@@ -303,19 +308,37 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存聊天室屏蔽与禁音偏好。
|
* 保存聊天室屏蔽、禁音与字号偏好。
|
||||||
*/
|
*/
|
||||||
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
|
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$data = $request->validated();
|
$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 = [
|
$preferences = [
|
||||||
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
||||||
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
|
'blocked_system_senders' => $blockedSystemSenders,
|
||||||
'sound_muted' => (bool) $data['sound_muted'],
|
'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([
|
$user->update([
|
||||||
'chat_preferences' => $preferences,
|
'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;
|
namespace App\Http\Requests;
|
||||||
@@ -12,7 +12,7 @@ use Illuminate\Validation\Rule;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 聊天室偏好设置验证器
|
* 聊天室偏好设置验证器
|
||||||
* 仅允许提交白名单内的屏蔽项与布尔型禁音状态。
|
* 仅允许提交白名单内的屏蔽项、布尔型禁音状态与合法字号。
|
||||||
*/
|
*/
|
||||||
class UpdateChatPreferencesRequest extends FormRequest
|
class UpdateChatPreferencesRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -35,9 +35,10 @@ class UpdateChatPreferencesRequest extends FormRequest
|
|||||||
'blocked_system_senders' => ['nullable', 'array'],
|
'blocked_system_senders' => ['nullable', 'array'],
|
||||||
'blocked_system_senders.*' => [
|
'blocked_system_senders.*' => [
|
||||||
'string',
|
'string',
|
||||||
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
|
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票']),
|
||||||
],
|
],
|
||||||
'sound_muted' => ['required', 'boolean'],
|
'sound_muted' => ['required', 'boolean'],
|
||||||
|
'font_size' => ['nullable', 'integer', 'min:10', 'max:30'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,9 @@ class UpdateChatPreferencesRequest extends FormRequest
|
|||||||
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
|
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
|
||||||
'sound_muted.required' => '请传入禁音状态。',
|
'sound_muted.required' => '请传入禁音状态。',
|
||||||
'sound_muted.boolean' => '禁音状态格式无效。',
|
'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', '单选房间模式下只能选择一个房间。');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'previous_name' => ['nullable', 'string', 'max:50'],
|
||||||
'sex' => ['required', 'in:0,1,2'],
|
'sex' => ['required', 'in:0,1,2'],
|
||||||
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
|
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
|
||||||
'sign' => ['nullable', 'string', 'max:255'],
|
'sign' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -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
|
private function broadcastPassMessage(User $user, int $roomId, string $reason): void
|
||||||
{
|
{
|
||||||
if (empty($reason)) {
|
|
||||||
$reason = '风大雨大,保本最大,这把我决定观望一下!';
|
|
||||||
}
|
|
||||||
|
|
||||||
$chatState = app(ChatStateService::class);
|
$chatState = app(ChatStateService::class);
|
||||||
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 本局选择挂机观望:✨ <br/><span style='color:#666;'>[🤖 策略分析] {$reason}</span>";
|
$content = "🎲 【百家乐】 {$user->username} 本局选择挂机观望";
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId($roomId),
|
'id' => $chatState->nextMessageId($roomId),
|
||||||
@@ -335,9 +331,8 @@ class AiBaccaratBetJob implements ShouldQueue
|
|||||||
$chatState = app(ChatStateService::class);
|
$chatState = app(ChatStateService::class);
|
||||||
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
|
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
|
||||||
$label = $labelMap[$betType] ?? $betType;
|
$label = $labelMap[$betType] ?? $betType;
|
||||||
|
// AI 下注播报统一压成单行,避免游戏通知卡片出现多行正文挤占高度。
|
||||||
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:';
|
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount).' 金币)';
|
||||||
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}!</span>";
|
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId($roomId),
|
'id' => $chatState->nextMessageId($roomId),
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ use Illuminate\Foundation\Queue\Queueable;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:完成一局百家乐的开奖、派奖与通知。
|
||||||
|
*/
|
||||||
class CloseBaccaratRoundJob implements ShouldQueue
|
class CloseBaccaratRoundJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
@@ -227,7 +230,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$roomId = 1;
|
$roomId = (int) $round->room_id;
|
||||||
$roundResultLabel = $round->resultLabel();
|
$roundResultLabel = $round->resultLabel();
|
||||||
|
|
||||||
foreach ($participantSettlements as $settlement) {
|
foreach ($participantSettlements as $settlement) {
|
||||||
@@ -284,7 +287,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
|
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
|
||||||
{
|
{
|
||||||
$diceStr = "《{$round->dice1}》《{$round->dice2}》《{$round->dice3}》";
|
$diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
|
||||||
|
|
||||||
$resultText = match ($round->result) {
|
$resultText = match ($round->result) {
|
||||||
'big' => "🔵 大({$round->total_points} 点)",
|
'big' => "🔵 大({$round->total_points} 点)",
|
||||||
@@ -309,11 +312,11 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
|||||||
|
|
||||||
$detail = $detailParts ? ' '.implode(' ', $detailParts) : '';
|
$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 = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId((int) $round->room_id),
|
||||||
'room_id' => 1,
|
'room_id' => (int) $round->room_id,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
@@ -322,8 +325,8 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
|||||||
'action' => '大声宣告',
|
'action' => '大声宣告',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$chatState->pushMessage(1, $msg);
|
$chatState->pushMessage((int) $round->room_id, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent((int) $round->room_id, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
|
|
||||||
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
|
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:完成一场赛马竞猜的派奖与结果广播。
|
||||||
|
*/
|
||||||
class CloseHorseRaceJob implements ShouldQueue
|
class CloseHorseRaceJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
@@ -181,7 +184,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$roomId = 1;
|
$roomId = (int) $race->room_id;
|
||||||
$winnerName = $this->resolveWinnerHorseName($race);
|
$winnerName = $this->resolveWinnerHorseName($race);
|
||||||
|
|
||||||
foreach ($participantSettlements as $settlement) {
|
foreach ($participantSettlements as $settlement) {
|
||||||
@@ -213,7 +216,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
'to_user' => $username,
|
'to_user' => $username,
|
||||||
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
|
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
|
||||||
'is_secret' => true,
|
'is_secret' => true,
|
||||||
'font_color' => '#f59e0b',
|
'font_color' => '#16a34a',
|
||||||
'action' => '',
|
'action' => '',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
'toast_notification' => [
|
'toast_notification' => [
|
||||||
@@ -243,21 +246,21 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
? '共派发 💰'.number_format($totalPayout).' 金币'
|
? '共派发 💰'.number_format($totalPayout).' 金币'
|
||||||
: '本场无人获奖';
|
: '本场无人获奖';
|
||||||
|
|
||||||
$content = "🏆 【赛马】第 #{$race->id} 场结束!冠军:{$winnerName}!{$payoutText}。";
|
$content = "🏆 第 #{$race->id} 场结束,冠军:{$winnerName}。{$payoutText}";
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId((int) $race->room_id),
|
||||||
'room_id' => 1,
|
'room_id' => (int) $race->room_id,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => '#f59e0b',
|
'font_color' => '#16a34a',
|
||||||
'action' => '大声宣告',
|
'action' => '大声宣告',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$chatState->pushMessage(1, $msg);
|
$chatState->pushMessage((int) $race->room_id, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent((int) $race->room_id, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:按房间投放神秘箱子并广播暗号。
|
||||||
|
*/
|
||||||
class DropMysteryBoxJob implements ShouldQueue
|
class DropMysteryBoxJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
@@ -80,6 +83,7 @@ class DropMysteryBoxJob implements ShouldQueue
|
|||||||
|
|
||||||
// 创建箱子记录
|
// 创建箱子记录
|
||||||
$box = MysteryBox::create([
|
$box = MysteryBox::create([
|
||||||
|
'room_id' => $targetRoom,
|
||||||
'box_type' => $this->boxType,
|
'box_type' => $this->boxType,
|
||||||
'passcode' => $passcode,
|
'passcode' => $passcode,
|
||||||
'reward_min' => $rewardMin,
|
'reward_min' => $rewardMin,
|
||||||
@@ -94,8 +98,7 @@ class DropMysteryBoxJob implements ShouldQueue
|
|||||||
$typeName = $box->typeName();
|
$typeName = $box->typeName();
|
||||||
$source = $this->droppedBy ? '管理员' : '系统';
|
$source = $this->droppedBy ? '管理员' : '系统';
|
||||||
|
|
||||||
$content = "{$emoji}【神秘箱子】《{$typeName}》{$source}投放了一个神秘箱子!"
|
$content = "{$emoji} 《{$typeName}》{$source}投放,暗号「{$passcode}」,限时 {$claimWindow} 秒。";
|
||||||
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
|
|
||||||
|
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId($targetRoom),
|
'id' => $chatState->nextMessageId($targetRoom),
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ use App\Services\ChatStateService;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:关闭已超时的神秘箱子并广播过期提醒。
|
||||||
|
*/
|
||||||
class ExpireMysteryBoxJob implements ShouldQueue
|
class ExpireMysteryBoxJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
@@ -49,8 +52,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
|
|||||||
|
|
||||||
// 公屏广播过期通知
|
// 公屏广播过期通知
|
||||||
$msg = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId((int) $box->room_id),
|
||||||
'room_id' => 1,
|
'room_id' => (int) $box->room_id,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
|
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
|
||||||
@@ -60,8 +63,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
|
|||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$chatState->pushMessage(1, $msg);
|
$chatState->pushMessage((int) $box->room_id, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent((int) $box->room_id, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:按房间开启一局新的百家乐押注回合。
|
||||||
|
*/
|
||||||
class OpenBaccaratRoundJob implements ShouldQueue
|
class OpenBaccaratRoundJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
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);
|
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
|
||||||
|
|
||||||
// 防止重复开局(如果上一局还在押注中则跳过)
|
// 防止重复开局(如果上一局还在押注中则跳过)
|
||||||
if (BaccaratRound::currentRound()) {
|
if (BaccaratRound::currentRound($this->roomId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +65,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
|
|||||||
|
|
||||||
// 创建新局次
|
// 创建新局次
|
||||||
$round = BaccaratRound::create([
|
$round = BaccaratRound::create([
|
||||||
|
'room_id' => $this->roomId,
|
||||||
'status' => 'betting',
|
'status' => 'betting',
|
||||||
'bet_opens_at' => $now,
|
'bet_opens_at' => $now,
|
||||||
'bet_closes_at' => $closesAt,
|
'bet_closes_at' => $closesAt,
|
||||||
@@ -77,10 +90,10 @@ class OpenBaccaratRoundJob implements ShouldQueue
|
|||||||
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
|
.'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;">'
|
.'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>';
|
.'快速参与</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 = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId($this->roomId),
|
||||||
'room_id' => 1,
|
'room_id' => $this->roomId,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
@@ -89,8 +102,8 @@ class OpenBaccaratRoundJob implements ShouldQueue
|
|||||||
'action' => '大声宣告',
|
'action' => '大声宣告',
|
||||||
'sent_at' => $now->toDateTimeString(),
|
'sent_at' => $now->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$chatState->pushMessage(1, $msg);
|
$chatState->pushMessage($this->roomId, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent($this->roomId, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
|
|
||||||
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
|
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
|
||||||
|
|||||||
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:按房间开启一场新的赛马竞猜回合。
|
||||||
|
*/
|
||||||
class OpenHorseRaceJob implements ShouldQueue
|
class OpenHorseRaceJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +72,7 @@ class OpenHorseRaceJob implements ShouldQueue
|
|||||||
|
|
||||||
// 创建新场次
|
// 创建新场次
|
||||||
$race = HorseRace::create([
|
$race = HorseRace::create([
|
||||||
|
'room_id' => $this->roomId,
|
||||||
'status' => 'betting',
|
'status' => 'betting',
|
||||||
'bet_opens_at' => $now,
|
'bet_opens_at' => $now,
|
||||||
'bet_closes_at' => $closesAt,
|
'bet_closes_at' => $closesAt,
|
||||||
@@ -79,21 +92,21 @@ class OpenHorseRaceJob implements ShouldQueue
|
|||||||
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
|
.'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;">'
|
.'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>';
|
.'快速参与赌马</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 = [
|
$msg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId($this->roomId),
|
||||||
'room_id' => 1,
|
'room_id' => $this->roomId,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => '#f59e0b',
|
'font_color' => '#16a34a',
|
||||||
'action' => '大声宣告',
|
'action' => '大声宣告',
|
||||||
'sent_at' => $now->toDateTimeString(),
|
'sent_at' => $now->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$chatState->pushMessage(1, $msg);
|
$chatState->pushMessage($this->roomId, $msg);
|
||||||
broadcast(new MessageSent(1, $msg));
|
broadcast(new MessageSent($this->roomId, $msg));
|
||||||
SaveMessageJob::dispatch($msg);
|
SaveMessageJob::dispatch($msg);
|
||||||
|
|
||||||
// 押注截止后触发跑马 & 结算任务
|
// 押注截止后触发跑马 & 结算任务
|
||||||
|
|||||||
@@ -19,10 +19,22 @@ use App\Models\LotteryIssue;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:按房间创建一条新的双色球期次。
|
||||||
|
*/
|
||||||
class OpenLotteryIssueJob implements ShouldQueue
|
class OpenLotteryIssueJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +68,8 @@ class OpenLotteryIssueJob implements ShouldQueue
|
|||||||
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
|
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
|
||||||
|
|
||||||
LotteryIssue::create([
|
LotteryIssue::create([
|
||||||
'issue_no' => LotteryIssue::nextIssueNo(),
|
'room_id' => $this->roomId,
|
||||||
|
'issue_no' => LotteryIssue::nextIssueNo($this->roomId),
|
||||||
'status' => 'open',
|
'status' => 'open',
|
||||||
'pool_amount' => 0,
|
'pool_amount' => 0,
|
||||||
'carry_amount' => 0,
|
'carry_amount' => 0,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ use App\Services\ChatStateService;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:赛马跑马动画广播与结算衔接任务
|
||||||
|
*
|
||||||
|
* 负责在押注截止后推进 running 流程、广播实时进度,
|
||||||
|
* 并在同一条任务链中补齐赛果与触发最终结算,避免线上状态滞留。
|
||||||
|
*/
|
||||||
class RunHorseRaceJob implements ShouldQueue
|
class RunHorseRaceJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
@@ -72,18 +78,18 @@ class RunHorseRaceJob implements ShouldQueue
|
|||||||
));
|
));
|
||||||
|
|
||||||
$startMsg = [
|
$startMsg = [
|
||||||
'id' => $chatState->nextMessageId(1),
|
'id' => $chatState->nextMessageId((int) $race->room_id),
|
||||||
'room_id' => 1,
|
'room_id' => (int) $race->room_id,
|
||||||
'from_user' => '系统传音',
|
'from_user' => '系统传音',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
|
'content' => "🏇 第 #{$race->id} 场比赛开始:{$horseList}",
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => '#336699',
|
'font_color' => '#16a34a',
|
||||||
'action' => '大声宣告',
|
'action' => '大声宣告',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
$chatState->pushMessage(1, $startMsg);
|
$chatState->pushMessage((int) $race->room_id, $startMsg);
|
||||||
broadcast(new MessageSent(1, $startMsg));
|
broadcast(new MessageSent((int) $race->room_id, $startMsg));
|
||||||
SaveMessageJob::dispatch($startMsg);
|
SaveMessageJob::dispatch($startMsg);
|
||||||
|
|
||||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
$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) {
|
if ($finished) {
|
||||||
break;
|
break;
|
||||||
@@ -156,8 +162,8 @@ class RunHorseRaceJob implements ShouldQueue
|
|||||||
'total_pool' => $totalPool,
|
'total_pool' => $totalPool,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 触发结算任务
|
// 在同一条队列任务里直接完成结算,避免线上出现“已跑完但 Close 任务未继续消费”的断链。
|
||||||
CloseHorseRaceJob::dispatch($race->fresh());
|
app()->call([new CloseHorseRaceJob($race->fresh()), 'handle']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class SaveMessageJob implements ShouldQueue
|
|||||||
'image_path' => $this->messageData['image_path'] ?? null,
|
'image_path' => $this->messageData['image_path'] ?? null,
|
||||||
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
|
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
|
||||||
'image_original_name' => $this->messageData['image_original_name'] ?? null,
|
'image_original_name' => $this->messageData['image_original_name'] ?? null,
|
||||||
|
'retention_type' => Message::resolveRetentionType($this->messageData),
|
||||||
// 恢复 Carbon 时间对象
|
// 恢复 Carbon 时间对象
|
||||||
'sent_at' => Carbon::parse($this->messageData['sent_at']),
|
'sent_at' => Carbon::parse($this->messageData['sent_at']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
|
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
|
||||||
|
$expiresAt = $this->manual
|
||||||
|
? $now->copy()->addMinutes($event->expire_minutes)
|
||||||
|
: $scheduledFor?->copy()->addMinutes($event->expire_minutes);
|
||||||
|
|
||||||
if (! $this->manual) {
|
if (! $this->manual) {
|
||||||
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
|
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
|
||||||
@@ -131,12 +134,23 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$validScheduledFor = $scheduleService->skipExpiredOccurrences($event, $now);
|
||||||
|
if ($validScheduledFor === null || ! $validScheduledFor->equalTo($scheduledFor)) {
|
||||||
|
// 漏跑且已过期的批次只推进模板,不生成领取批次和聊天室公告。
|
||||||
|
$event->update([
|
||||||
|
'send_at' => $validScheduledFor,
|
||||||
|
'status' => $validScheduledFor ? 'pending' : 'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
|
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
|
||||||
$event->update([
|
$event->update([
|
||||||
'send_at' => $nextSendAt,
|
'send_at' => $nextSendAt,
|
||||||
'status' => $nextSendAt ? 'pending' : 'completed',
|
'status' => $nextSendAt ? 'pending' : 'completed',
|
||||||
'triggered_at' => $now,
|
'triggered_at' => $now,
|
||||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
'expires_at' => $expiresAt,
|
||||||
'claimed_count' => 0,
|
'claimed_count' => 0,
|
||||||
'claimed_amount' => 0,
|
'claimed_amount' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -163,7 +177,7 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
'repeat_type' => $event->repeat_type,
|
'repeat_type' => $event->repeat_type,
|
||||||
'scheduled_for' => $scheduledFor,
|
'scheduled_for' => $scheduledFor,
|
||||||
'triggered_at' => $now,
|
'triggered_at' => $now,
|
||||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
'expires_at' => $expiresAt,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'audience_count' => 0,
|
'audience_count' => 0,
|
||||||
'claimed_count' => 0,
|
'claimed_count' => 0,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:保存百家乐局次数据并提供当前局查询能力。
|
||||||
|
*/
|
||||||
class BaccaratRound extends Model
|
class BaccaratRound extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'room_id',
|
||||||
'dice1', 'dice2', 'dice3',
|
'dice1', 'dice2', 'dice3',
|
||||||
'total_points', 'result', 'status',
|
'total_points', 'result', 'status',
|
||||||
'bet_opens_at', 'bet_closes_at', 'settled_at',
|
'bet_opens_at', 'bet_closes_at', 'settled_at',
|
||||||
@@ -36,6 +40,7 @@ class BaccaratRound extends Model
|
|||||||
'bet_opens_at' => 'datetime',
|
'bet_opens_at' => 'datetime',
|
||||||
'bet_closes_at' => 'datetime',
|
'bet_closes_at' => 'datetime',
|
||||||
'settled_at' => 'datetime',
|
'settled_at' => 'datetime',
|
||||||
|
'room_id' => 'integer',
|
||||||
'dice1' => 'integer',
|
'dice1' => 'integer',
|
||||||
'dice2' => 'integer',
|
'dice2' => 'integer',
|
||||||
'dice3' => 'integer',
|
'dice3' => 'integer',
|
||||||
@@ -104,12 +109,16 @@ class BaccaratRound extends Model
|
|||||||
/**
|
/**
|
||||||
* 查询当前正在进行的局次(状态为 betting 且未截止)。
|
* 查询当前正在进行的局次(状态为 betting 且未截止)。
|
||||||
*/
|
*/
|
||||||
public static function currentRound(): ?static
|
public static function currentRound(?int $roomId = null): ?static
|
||||||
{
|
{
|
||||||
return static::query()
|
$query = static::query()
|
||||||
->where('status', 'betting')
|
->where('status', 'betting')
|
||||||
->where('bet_closes_at', '>', now())
|
->where('bet_closes_at', '>', now());
|
||||||
->latest()
|
|
||||||
->first();
|
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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:赛马竞猜局次模型
|
||||||
|
*
|
||||||
|
* 负责描述赛马场次的生命周期、参赛马匹、下注汇总与派奖计算,
|
||||||
|
* 并为控制器和队列任务提供当前场次、赔率与奖池算法支持。
|
||||||
|
*/
|
||||||
class HorseRace extends Model
|
class HorseRace extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'room_id',
|
||||||
'status',
|
'status',
|
||||||
'bet_opens_at',
|
'bet_opens_at',
|
||||||
'bet_closes_at',
|
'bet_closes_at',
|
||||||
@@ -42,6 +49,7 @@ class HorseRace extends Model
|
|||||||
'race_starts_at' => 'datetime',
|
'race_starts_at' => 'datetime',
|
||||||
'race_ends_at' => 'datetime',
|
'race_ends_at' => 'datetime',
|
||||||
'settled_at' => 'datetime',
|
'settled_at' => 'datetime',
|
||||||
|
'room_id' => 'integer',
|
||||||
'horses' => 'array',
|
'horses' => 'array',
|
||||||
'winner_horse_id' => 'integer',
|
'winner_horse_id' => 'integer',
|
||||||
'total_bets' => 'integer',
|
'total_bets' => 'integer',
|
||||||
@@ -69,12 +77,15 @@ class HorseRace extends Model
|
|||||||
/**
|
/**
|
||||||
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
|
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
|
||||||
*/
|
*/
|
||||||
public static function currentRace(): ?static
|
public static function currentRace(?int $roomId = null): ?static
|
||||||
{
|
{
|
||||||
return static::query()
|
$query = static::query()->whereIn('status', ['betting', 'running']);
|
||||||
->whereIn('status', ['betting', 'running'])
|
|
||||||
->latest()
|
if ($roomId !== null) {
|
||||||
->first();
|
$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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:保存双色球期次数据并提供按房间查询能力。
|
||||||
|
*/
|
||||||
class LotteryIssue extends Model
|
class LotteryIssue extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'room_id',
|
||||||
'issue_no',
|
'issue_no',
|
||||||
'status',
|
'status',
|
||||||
'red1', 'red2', 'red3', 'blue',
|
'red1', 'red2', 'red3', 'blue',
|
||||||
@@ -38,6 +42,7 @@ class LotteryIssue extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'room_id' => 'integer',
|
||||||
'is_super_issue' => 'boolean',
|
'is_super_issue' => 'boolean',
|
||||||
'pool_amount' => 'integer',
|
'pool_amount' => 'integer',
|
||||||
'carry_amount' => 'integer',
|
'carry_amount' => 'integer',
|
||||||
@@ -71,29 +76,44 @@ class LotteryIssue extends Model
|
|||||||
/**
|
/**
|
||||||
* 获取当前正在购票的期次(status=open)。
|
* 获取当前正在购票的期次(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)。
|
* 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。
|
||||||
*/
|
*/
|
||||||
public static function nextIssueNo(): string
|
public static function nextIssueNo(?int $roomId = null): string
|
||||||
{
|
{
|
||||||
$year = now()->year;
|
$year = now()->year;
|
||||||
$last = static::query()
|
$query = static::query()->whereYear('created_at', $year);
|
||||||
->whereYear('created_at', $year)
|
|
||||||
->latest()
|
if ($roomId !== null) {
|
||||||
->first();
|
$query->where('room_id', $roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = $query->latest()->first();
|
||||||
|
|
||||||
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
|
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,120 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class Message extends Model
|
class Message extends Model
|
||||||
{
|
{
|
||||||
|
public const RETENTION_USER_CHAT = 'user_chat';
|
||||||
|
|
||||||
|
public const RETENTION_SYSTEM_NOTICE = 'system_notice';
|
||||||
|
|
||||||
|
public const RETENTION_GAME_NOTICE = 'game_notice';
|
||||||
|
|
||||||
|
public const RETENTION_EPHEMERAL_NOTICE = 'ephemeral_notice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可按过期策略清理的消息保留类型。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function purgableRetentionTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::RETENTION_GAME_NOTICE,
|
||||||
|
self::RETENTION_EPHEMERAL_NOTICE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据广播消息载荷推断数据库保留类型。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $messageData 聊天室消息载荷
|
||||||
|
*/
|
||||||
|
public static function resolveRetentionType(array $messageData): string
|
||||||
|
{
|
||||||
|
$explicitType = (string) ($messageData['retention_type'] ?? '');
|
||||||
|
if (in_array($explicitType, [
|
||||||
|
self::RETENTION_USER_CHAT,
|
||||||
|
self::RETENTION_SYSTEM_NOTICE,
|
||||||
|
self::RETENTION_GAME_NOTICE,
|
||||||
|
self::RETENTION_EPHEMERAL_NOTICE,
|
||||||
|
], true)) {
|
||||||
|
return $explicitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromUser = (string) ($messageData['from_user'] ?? '');
|
||||||
|
$action = (string) ($messageData['action'] ?? '');
|
||||||
|
$messageType = (string) ($messageData['message_type'] ?? 'text');
|
||||||
|
|
||||||
|
if (self::isEphemeralNotice($fromUser, $action)) {
|
||||||
|
return self::RETENTION_EPHEMERAL_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isGameNotice($fromUser, $action, $messageType, $messageData)) {
|
||||||
|
return self::RETENTION_GAME_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isSystemNotice($fromUser)) {
|
||||||
|
return self::RETENTION_SYSTEM_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::RETENTION_USER_CHAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否属于可短期保留的进出场类通知。
|
||||||
|
*/
|
||||||
|
public static function isEphemeralNotice(string $fromUser, string $action = ''): bool
|
||||||
|
{
|
||||||
|
return in_array($fromUser, ['进出播报', '座驾播报'], true)
|
||||||
|
|| in_array($action, ['system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否属于游戏或玩法通知。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $messageData 聊天室消息载荷
|
||||||
|
*/
|
||||||
|
public static function isGameNotice(string $fromUser, string $action, string $messageType = 'text', array $messageData = []): bool
|
||||||
|
{
|
||||||
|
$gameSenders = ['钓鱼播报', '星海小博士'];
|
||||||
|
$gameActions = [
|
||||||
|
'fishing_result',
|
||||||
|
'idiom_result',
|
||||||
|
'riddle_result',
|
||||||
|
'ride_purchase',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($fromUser, $gameSenders, true) || in_array($action, $gameActions, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($messageData['toast_notification'])) {
|
||||||
|
$title = (string) data_get($messageData, 'toast_notification.title', '');
|
||||||
|
|
||||||
|
return str_contains($title, '下注')
|
||||||
|
|| str_contains($title, '赛马')
|
||||||
|
|| str_contains($title, '百家乐')
|
||||||
|
|| str_contains($title, '双色球')
|
||||||
|
|| str_contains($title, '红包')
|
||||||
|
|| str_contains($title, '结算');
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($messageType, ['game_notice'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否来自系统发送者。
|
||||||
|
*/
|
||||||
|
public static function isSystemNotice(string $fromUser): bool
|
||||||
|
{
|
||||||
|
return in_array($fromUser, [
|
||||||
|
'系统',
|
||||||
|
'系统公告',
|
||||||
|
'系统传音',
|
||||||
|
'系统播报',
|
||||||
|
'送花播报',
|
||||||
|
'AI小班长',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
@@ -37,6 +151,7 @@ class Message extends Model
|
|||||||
'image_path',
|
'image_path',
|
||||||
'image_thumb_path',
|
'image_thumb_path',
|
||||||
'image_original_name',
|
'image_original_name',
|
||||||
|
'retention_type',
|
||||||
'sent_at',
|
'sent_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:保存神秘箱子投放记录并提供当前箱子查询能力。
|
||||||
|
*/
|
||||||
class MysteryBox extends Model
|
class MysteryBox extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'room_id',
|
||||||
'box_type',
|
'box_type',
|
||||||
'passcode',
|
'passcode',
|
||||||
'reward_min',
|
'reward_min',
|
||||||
@@ -35,6 +39,7 @@ class MysteryBox extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'room_id' => 'integer',
|
||||||
'reward_min' => 'integer',
|
'reward_min' => 'integer',
|
||||||
'reward_max' => 'integer',
|
'reward_max' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
@@ -64,13 +69,17 @@ class MysteryBox extends Model
|
|||||||
/**
|
/**
|
||||||
* 当前可领取(open 状态 + 未过期)的箱子。
|
* 当前可领取(open 状态 + 未过期)的箱子。
|
||||||
*/
|
*/
|
||||||
public static function currentOpenBox(): ?static
|
public static function currentOpenBox(?int $roomId = null): ?static
|
||||||
{
|
{
|
||||||
return static::query()
|
$query = static::query()
|
||||||
->where('status', 'open')
|
->where('status', 'open')
|
||||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
||||||
->latest()
|
|
||||||
->first();
|
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 TYPE_SIGN_REPAIR = 'sign_repair';
|
||||||
|
|
||||||
|
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
|
||||||
|
|
||||||
protected $table = 'shop_items';
|
protected $table = 'shop_items';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -51,6 +53,14 @@ class ShopItem extends Model
|
|||||||
return $this->type === self::TYPE_SIGN_REPAIR;
|
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_ 开头)
|
* 是否为特效类商品(instant 或 duration,slug 以 once_ 或 week_ 开头)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
|
||||||
|
*/
|
||||||
class Sysparam extends Model
|
class Sysparam extends Model
|
||||||
{
|
{
|
||||||
/** @var string 表名 */
|
/** @var string 表名 */
|
||||||
@@ -74,7 +77,8 @@ class Sysparam extends Model
|
|||||||
// 不超过最大等级
|
// 不超过最大等级
|
||||||
$maxLevel = (int) static::getValue('maxlevel', '99');
|
$maxLevel = (int) static::getValue('maxlevel', '99');
|
||||||
|
|
||||||
return min($level, $maxLevel);
|
// 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
|
||||||
|
return max(1, min($level, $maxLevel));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class User extends Authenticatable
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'username',
|
'username',
|
||||||
|
'previous_name',
|
||||||
'password',
|
'password',
|
||||||
'email',
|
'email',
|
||||||
'sex',
|
'sex',
|
||||||
@@ -109,6 +110,7 @@ class User extends Authenticatable
|
|||||||
'q3_time' => 'datetime',
|
'q3_time' => 'datetime',
|
||||||
'has_received_new_gift' => 'boolean',
|
'has_received_new_gift' => 'boolean',
|
||||||
'chat_preferences' => 'array',
|
'chat_preferences' => 'array',
|
||||||
|
'active_decorations' => 'array',
|
||||||
'daily_status_expires_at' => 'datetime',
|
'daily_status_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -260,6 +262,22 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
|
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:用户已解锁和进行中的成就记录。
|
||||||
|
*/
|
||||||
|
public function achievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievement::class, 'user_id')->latest('achieved_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:用户各成就的最新进度快照。
|
||||||
|
*/
|
||||||
|
public function achievementProgress(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievementProgress::class, 'user_id')->latest('last_scanned_at');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关联:用户全部身份徽章。
|
* 关联:用户全部身份徽章。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就解锁记录模型。
|
||||||
|
*
|
||||||
|
* 保存每个用户在固定成就目录中的进度快照、达成时间与通知状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:封装用户成就记录字段、类型转换与用户关联。
|
||||||
|
*/
|
||||||
|
class UserAchievement extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserAchievementFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许批量赋值的字段。
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_key',
|
||||||
|
'progress_value',
|
||||||
|
'achieved_at',
|
||||||
|
'notified_at',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'progress_value' => 'integer',
|
||||||
|
'achieved_at' => 'datetime',
|
||||||
|
'notified_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:成就记录所属用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就进度模型。
|
||||||
|
*
|
||||||
|
* 保存用户在每个固定成就上的最新进度快照,解锁状态由 user_achievements 单独记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:封装用户成就进度字段、类型转换与用户关联。
|
||||||
|
*/
|
||||||
|
class UserAchievementProgress extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserAchievementProgressFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对应的数据表名。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $table = 'user_achievement_progress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许批量赋值的字段。
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_key',
|
||||||
|
'progress_value',
|
||||||
|
'threshold_value',
|
||||||
|
'last_scanned_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'progress_value' => 'integer',
|
||||||
|
'threshold_value' => 'integer',
|
||||||
|
'last_scanned_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:进度记录所属用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就扫描与授予服务。
|
||||||
|
*
|
||||||
|
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
use App\Models\BaccaratBet;
|
||||||
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\GomokuGame;
|
||||||
|
use App\Models\HorseBet;
|
||||||
|
use App\Models\LotteryTicket;
|
||||||
|
use App\Models\Marriage;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Models\PositionAuthorityLog;
|
||||||
|
use App\Models\PositionDutyLog;
|
||||||
|
use App\Models\RedPacketClaim;
|
||||||
|
use App\Models\RedPacketEnvelope;
|
||||||
|
use App\Models\SlotMachineLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Models\UserAchievementProgress;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Models\UserPosition;
|
||||||
|
use App\Support\AchievementCatalog;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
|
||||||
|
*/
|
||||||
|
class AchievementService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建成就服务依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatStateService $chatState,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描单个用户的所有固定成就。
|
||||||
|
*
|
||||||
|
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
|
||||||
|
*/
|
||||||
|
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$progress = $this->progressForUser($user);
|
||||||
|
$checked = 0;
|
||||||
|
$unlocked = 0;
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
foreach (AchievementCatalog::definitions() as $definition) {
|
||||||
|
$checked++;
|
||||||
|
$value = (int) ($progress[$definition['metric']] ?? 0);
|
||||||
|
$achievement = UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('achievement_key', $definition['key'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) {
|
||||||
|
$unlocked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storeProgress($user, $definition, $value);
|
||||||
|
|
||||||
|
if (! $achievement) {
|
||||||
|
$achievement = UserAchievement::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => $definition['key'],
|
||||||
|
'progress_value' => $value,
|
||||||
|
'metadata' => ['threshold' => $definition['threshold']],
|
||||||
|
]);
|
||||||
|
$updated++;
|
||||||
|
} elseif ($achievement->progress_value !== $value) {
|
||||||
|
$achievement->forceFill(['progress_value' => $value])->save();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value < $definition['threshold'] || $achievement->achieved_at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$achievement->forceFill([
|
||||||
|
'progress_value' => $value,
|
||||||
|
'achieved_at' => now(),
|
||||||
|
'metadata' => ['threshold' => $definition['threshold']],
|
||||||
|
])->save();
|
||||||
|
$unlocked++;
|
||||||
|
|
||||||
|
if ($notify) {
|
||||||
|
$this->notifyUnlocked($user, $achievement, $definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'checked' => $checked,
|
||||||
|
'unlocked' => $unlocked,
|
||||||
|
'updated' => $updated,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量扫描用户成就。
|
||||||
|
*
|
||||||
|
* @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool}
|
||||||
|
*/
|
||||||
|
public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$result = $this->scanUser($user, $notify, $dryRun);
|
||||||
|
$summary['users']++;
|
||||||
|
$summary['checked'] += $result['checked'];
|
||||||
|
$summary['unlocked'] += $result['unlocked'];
|
||||||
|
$summary['updated'] += $result['updated'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装用户成就展示数据。
|
||||||
|
*
|
||||||
|
* @return array{categories: array<string, string>, achievements: Collection<int, array<string, mixed>>, unlocked_count: int, total_count: int}
|
||||||
|
*/
|
||||||
|
public function displayForUser(User $user): array
|
||||||
|
{
|
||||||
|
$progress = $this->progressForUser($user);
|
||||||
|
$records = UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->get()
|
||||||
|
->keyBy('achievement_key');
|
||||||
|
|
||||||
|
$achievements = collect(AchievementCatalog::definitions())
|
||||||
|
->sortBy('sort')
|
||||||
|
->map(function (array $definition) use ($progress, $records): array {
|
||||||
|
$record = $records->get($definition['key']);
|
||||||
|
$value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0));
|
||||||
|
$threshold = (int) $definition['threshold'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$definition,
|
||||||
|
'progress_value' => $value,
|
||||||
|
'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100,
|
||||||
|
'achieved_at' => $record?->achieved_at,
|
||||||
|
'unlocked' => (bool) $record?->achieved_at,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categories' => AchievementCatalog::categories(),
|
||||||
|
'achievements' => $achievements,
|
||||||
|
'unlocked_count' => $achievements->where('unlocked', true)->count(),
|
||||||
|
'total_count' => $achievements->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取用户最近解锁成就。
|
||||||
|
*
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function recentUnlockedForUser(User $user, int $limit = 5): Collection
|
||||||
|
{
|
||||||
|
return UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->latest('achieved_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function (UserAchievement $achievement): array {
|
||||||
|
$definition = AchievementCatalog::find($achievement->achievement_key);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $achievement->achievement_key,
|
||||||
|
'name' => $definition['name'] ?? $achievement->achievement_key,
|
||||||
|
'icon' => $definition['icon'] ?? '🏅',
|
||||||
|
'description' => $definition['description'] ?? '',
|
||||||
|
'achieved_at' => $achievement->achieved_at?->toDateTimeString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取用户资料卡使用的成就摘要。
|
||||||
|
*
|
||||||
|
* @return array{unlocked_count: int, total_count: int, recent: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function profileSummaryForUser(User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'unlocked_count' => (int) UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->count(),
|
||||||
|
'total_count' => count(AchievementCatalog::definitions()),
|
||||||
|
'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合单个用户所有成就进度。
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function progressForUser(User $user): array
|
||||||
|
{
|
||||||
|
$username = (string) $user->username;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'chat_messages' => $this->chatMessageCount($username),
|
||||||
|
'welcome_messages' => $this->welcomeMessageCount($username),
|
||||||
|
'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(),
|
||||||
|
'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'),
|
||||||
|
'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(),
|
||||||
|
'exp_gain' => $this->currencyGain($user->id, 'exp'),
|
||||||
|
'gold_gain' => $this->currencyGain($user->id, 'gold'),
|
||||||
|
'charm_gain' => $this->currencyGain($user->id, 'charm'),
|
||||||
|
'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb),
|
||||||
|
'bank_balance' => max(0, (int) $user->bank_jjb),
|
||||||
|
'game_gold_won' => $this->gameGoldWon($user->id),
|
||||||
|
'game_gold_lost' => $this->gameGoldLost($user->id),
|
||||||
|
'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(),
|
||||||
|
'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(),
|
||||||
|
'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(),
|
||||||
|
'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(),
|
||||||
|
'gomoku_wins' => $this->gomokuWinCount($user->id),
|
||||||
|
'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value),
|
||||||
|
'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value),
|
||||||
|
'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(),
|
||||||
|
'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(),
|
||||||
|
'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(),
|
||||||
|
'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'),
|
||||||
|
'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value),
|
||||||
|
'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value),
|
||||||
|
'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(),
|
||||||
|
'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60),
|
||||||
|
'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计普通用户聊天消息数量。
|
||||||
|
*/
|
||||||
|
private function chatMessageCount(string $username): int
|
||||||
|
{
|
||||||
|
return (int) Message::query()
|
||||||
|
->where('from_user', $username)
|
||||||
|
->whereIn('message_type', ['text', 'image', 'expired_image'])
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('retention_type', Message::RETENTION_USER_CHAT)
|
||||||
|
->orWhereNull('retention_type');
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户发出的欢迎动作次数。
|
||||||
|
*/
|
||||||
|
private function welcomeMessageCount(string $username): int
|
||||||
|
{
|
||||||
|
return (int) Message::query()
|
||||||
|
->where('from_user', $username)
|
||||||
|
->where('action', '欢迎')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定货币的累计正向获得量。
|
||||||
|
*/
|
||||||
|
private function currencyGain(int $userId, string $currency): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', $currency)
|
||||||
|
->where('amount', '>', 0)
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定流水来源次数。
|
||||||
|
*/
|
||||||
|
private function currencySourceCount(int $userId, string $source): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('source', $source)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户通过游戏相关流水累计赢取的金币。
|
||||||
|
*/
|
||||||
|
private function gameGoldWon(int $userId): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', 'gold')
|
||||||
|
->where('amount', '>', 0)
|
||||||
|
->whereIn('source', $this->gameWinSources())
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户在游戏相关流水中累计输掉或消耗的金币。
|
||||||
|
*/
|
||||||
|
private function gameGoldLost(int $userId): int
|
||||||
|
{
|
||||||
|
return abs((int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', 'gold')
|
||||||
|
->where('amount', '<', 0)
|
||||||
|
->whereIn('source', $this->gameLossSources())
|
||||||
|
->sum('amount'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回游戏赢钱来源,用于游戏赢取类成就聚合。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function gameWinSources(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CurrencySource::BACCARAT_WIN->value,
|
||||||
|
CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
|
||||||
|
CurrencySource::HORSE_WIN->value,
|
||||||
|
CurrencySource::LOTTERY_WIN->value,
|
||||||
|
CurrencySource::SLOT_WIN->value,
|
||||||
|
CurrencySource::FISHING_GAIN->value,
|
||||||
|
CurrencySource::MYSTERY_BOX->value,
|
||||||
|
CurrencySource::GOMOKU_WIN->value,
|
||||||
|
CurrencySource::GAME_REWARD->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回游戏输钱来源,用于游戏输钱类成就聚合。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function gameLossSources(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CurrencySource::BACCARAT_BET->value,
|
||||||
|
CurrencySource::HORSE_BET->value,
|
||||||
|
CurrencySource::LOTTERY_BUY->value,
|
||||||
|
CurrencySource::SLOT_SPIN->value,
|
||||||
|
CurrencySource::SLOT_CURSE->value,
|
||||||
|
CurrencySource::FISHING_COST->value,
|
||||||
|
CurrencySource::FORTUNE_COST->value,
|
||||||
|
CurrencySource::GOMOKU_ENTRY_FEE->value,
|
||||||
|
CurrencySource::MYSTERY_BOX_TRAP->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计五子棋胜利次数。
|
||||||
|
*/
|
||||||
|
private function gomokuWinCount(int $userId): int
|
||||||
|
{
|
||||||
|
return (int) GomokuGame::query()
|
||||||
|
->where('status', 'finished')
|
||||||
|
->where(function ($query) use ($userId) {
|
||||||
|
$query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1))
|
||||||
|
->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2));
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入用户成就进度快照。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $definition 成就定义
|
||||||
|
*/
|
||||||
|
private function storeProgress(User $user, array $definition, int $value): void
|
||||||
|
{
|
||||||
|
UserAchievementProgress::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => $definition['key'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'progress_value' => $value,
|
||||||
|
'threshold_value' => (int) $definition['threshold'],
|
||||||
|
'last_scanned_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给用户推送成就解锁通知。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $definition 成就定义
|
||||||
|
*/
|
||||||
|
private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void
|
||||||
|
{
|
||||||
|
if ($achievement->notified_at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomId = (int) ($user->room_id ?: 1);
|
||||||
|
$message = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统公告',
|
||||||
|
'to_user' => $user->username,
|
||||||
|
'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
|
||||||
|
'is_secret' => true,
|
||||||
|
'font_color' => '#ca8a04',
|
||||||
|
'action' => 'achievement_unlocked',
|
||||||
|
'retention_type' => Message::RETENTION_SYSTEM_NOTICE,
|
||||||
|
'toast_notification' => [
|
||||||
|
'title' => '🏅 成就解锁',
|
||||||
|
'message' => "{$definition['icon']} {$definition['name']}",
|
||||||
|
'icon' => '🏅',
|
||||||
|
'color' => '#ca8a04',
|
||||||
|
'duration' => 3000,
|
||||||
|
],
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->chatState->pushMessage($roomId, $message);
|
||||||
|
broadcast(new MessageSent($roomId, $message));
|
||||||
|
SaveMessageJob::dispatch($message);
|
||||||
|
|
||||||
|
$achievement->forceFill(['notified_at' => now()])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,9 @@ class AiChatService
|
|||||||
// 动态获取由 guide 页面提取出的最新纯文本规则
|
// 动态获取由 guide 页面提取出的最新纯文本规则
|
||||||
$guideRulesText = $this->getDynamicGuideRules();
|
$guideRulesText = $this->getDynamicGuideRules();
|
||||||
|
|
||||||
|
// 动态获取金币福利上限,告知 AI 自行决定金额
|
||||||
|
$maxGold = (int) \App\Models\Sysparam::getValue('chatbot_max_gold', '5000');
|
||||||
|
|
||||||
return <<<PROMPT
|
return <<<PROMPT
|
||||||
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"。
|
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"。
|
||||||
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
|
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
|
||||||
@@ -106,9 +109,9 @@ class AiChatService
|
|||||||
$guideRulesText
|
$guideRulesText
|
||||||
|
|
||||||
【发金币福利特权】
|
【发金币福利特权】
|
||||||
每天每个用户只能向你讨要一次金币福利(100-5000枚随机)。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
每天每个用户只能向你讨要一次金币福利。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
||||||
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]。
|
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD:金额](例如:[ACTION:GIVE_GOLD:888])。金额由你根据聊天内容、对方态度和你的心情自行决定,最低 100 枚,最高 {$maxGold} 枚。讨得真诚、聊得投缘的可以多给些,敷衍了事的少给些。
|
||||||
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
系统程序看到这个符号后会自动为用户发放你指定的金币数并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
||||||
|
|
||||||
【交流要求】
|
【交流要求】
|
||||||
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
|
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\BankLog;
|
use App\Models\BankLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,7 @@ class AiFinanceService
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,15 +111,15 @@ class AiFinanceService
|
|||||||
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
|
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
|
||||||
|
|
||||||
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
|
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
|
||||||
$lockedUser->jjb = self::AVAILABLE_GOLD_RESERVE;
|
$this->currencyService->change($lockedUser, 'gold', -$depositAmount, CurrencySource::BANK_DEPOSIT, "自动存入银行 {$depositAmount} 金币");
|
||||||
$lockedUser->bank_jjb = $bankBefore + $depositAmount;
|
|
||||||
$lockedUser->save();
|
$lockedUser->increment('bank_jjb', $depositAmount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $lockedUser->id,
|
'user_id' => $lockedUser->id,
|
||||||
'type' => 'deposit',
|
'type' => 'deposit',
|
||||||
'amount' => $depositAmount,
|
'amount' => $depositAmount,
|
||||||
'balance_after' => (int) $lockedUser->bank_jjb,
|
'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return array_values(array_filter(
|
return array_values(array_filter(
|
||||||
@@ -162,15 +166,15 @@ class AiFinanceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
|
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
|
||||||
$lockedUser->jjb = $walletGold + $withdrawAmount;
|
$this->currencyService->change($lockedUser, 'gold', $withdrawAmount, CurrencySource::BANK_WITHDRAW, "从银行自动取款 {$withdrawAmount} 金币");
|
||||||
$lockedUser->bank_jjb = $bankGold - $withdrawAmount;
|
|
||||||
$lockedUser->save();
|
$lockedUser->decrement('bank_jjb', $withdrawAmount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $lockedUser->id,
|
'user_id' => $lockedUser->id,
|
||||||
'type' => 'withdraw',
|
'type' => 'withdraw',
|
||||||
'amount' => $withdrawAmount,
|
'amount' => $withdrawAmount,
|
||||||
'balance_after' => (int) $lockedUser->bank_jjb,
|
'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (int) $lockedUser->jjb >= $targetWalletGold;
|
return (int) $lockedUser->jjb >= $targetWalletGold;
|
||||||
|
|||||||
@@ -400,7 +400,8 @@ class BaccaratLossCoverService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($compensableCount > 0) {
|
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}";
|
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}。{$button}";
|
||||||
} else {
|
} else {
|
||||||
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
|
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
|
||||||
@@ -446,7 +447,7 @@ class BaccaratLossCoverService
|
|||||||
|
|
||||||
$formattedAmount = number_format($amount);
|
$formattedAmount = number_format($amount);
|
||||||
$button = $event->status === 'claimable'
|
$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>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
|
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
|
||||||
|
|||||||
@@ -220,8 +220,9 @@ class ChatStateService
|
|||||||
|
|
||||||
foreach ($messages as $msgJson) {
|
foreach ($messages as $msgJson) {
|
||||||
$msg = json_decode($msgJson, true);
|
$msg = json_decode($msgJson, true);
|
||||||
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
|
// 只清理普通进出播报,避免误删新人礼包公告和 AI 小班长新人欢迎。
|
||||||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
|
$welcomeKind = $msg['welcome_kind'] ?? 'entry_broadcast';
|
||||||
|
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username && $welcomeKind === 'entry_broadcast') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$filtered[] = $msgJson;
|
$filtered[] = $msgJson;
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ class ChatUserPresenceService
|
|||||||
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
|
||||||
|
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
|
||||||
|
if (! empty($decorations['avatar_frame'])) {
|
||||||
|
$payload['avatar_frame'] = $decorations['avatar_frame'];
|
||||||
|
}
|
||||||
|
if (! empty($decorations['name_color'])) {
|
||||||
|
$payload['name_color'] = $decorations['name_color'];
|
||||||
|
}
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:每日游戏净盈利前三榜读服务
|
||||||
|
*
|
||||||
|
* 聚合百家乐与赛马当天金币流水,给聊天室顶部悬浮榜提供轻量数据。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:查询百家乐与赛马每日净盈利前三用户。
|
||||||
|
*/
|
||||||
|
class DailyGameProfitLeaderboardService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 每日榜单固定称号。
|
||||||
|
*/
|
||||||
|
private const TITLES = [
|
||||||
|
1 => '金库爆破王',
|
||||||
|
2 => '马桌双修财神',
|
||||||
|
3 => '金币收割机',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参与净盈利统计的游戏流水来源。
|
||||||
|
*/
|
||||||
|
private const GAME_PROFIT_SOURCES = [
|
||||||
|
CurrencySource::BACCARAT_BET,
|
||||||
|
CurrencySource::BACCARAT_WIN,
|
||||||
|
CurrencySource::HORSE_BET,
|
||||||
|
CurrencySource::HORSE_WIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期的游戏净盈利前三榜。
|
||||||
|
*
|
||||||
|
* @return Collection<int, object{rank:int,title:string,user_id:int,username:string,headface_url:string,net_profit:int}>
|
||||||
|
*/
|
||||||
|
public function topThree(?string $date = null): Collection
|
||||||
|
{
|
||||||
|
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
|
||||||
|
$cacheKey = 'daily_game_profit_leaderboard:v2:'.$statsDate->toDateString();
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, 300, function () use ($statsDate) {
|
||||||
|
$rangeStart = $statsDate;
|
||||||
|
$rangeEnd = $statsDate->addDay();
|
||||||
|
|
||||||
|
return UserCurrencyLog::query()
|
||||||
|
->join('users', 'users.id', '=', 'user_currency_logs.user_id')
|
||||||
|
->where('user_currency_logs.currency', 'gold')
|
||||||
|
->whereIn('user_currency_logs.source', array_map(
|
||||||
|
fn (CurrencySource $source): string => $source->value,
|
||||||
|
self::GAME_PROFIT_SOURCES
|
||||||
|
))
|
||||||
|
->where('user_currency_logs.created_at', '>=', $rangeStart)
|
||||||
|
->where('user_currency_logs.created_at', '<', $rangeEnd)
|
||||||
|
->where('users.username', '!=', 'AI小班长')
|
||||||
|
->groupBy('user_currency_logs.user_id', 'users.username', 'users.usersf')
|
||||||
|
->havingRaw('SUM(user_currency_logs.amount) > 0')
|
||||||
|
->orderByRaw('SUM(user_currency_logs.amount) DESC')
|
||||||
|
->orderBy('user_currency_logs.user_id')
|
||||||
|
->limit(3)
|
||||||
|
->selectRaw('user_currency_logs.user_id, users.username, users.usersf, SUM(user_currency_logs.amount) as net_profit')
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->map(function (object $row, int $index): object {
|
||||||
|
$rank = $index + 1;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'rank' => $rank,
|
||||||
|
'title' => self::TITLES[$rank],
|
||||||
|
'user_id' => (int) $row->user_id,
|
||||||
|
'username' => (string) $row->username,
|
||||||
|
'headface_url' => $this->resolveHeadfaceUrl((string) ($row->usersf ?: '1.gif')),
|
||||||
|
'net_profit' => (int) $row->net_profit,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析榜单头像地址。
|
||||||
|
*/
|
||||||
|
private function resolveHeadfaceUrl(string $headface): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($headface, 'storage/')) {
|
||||||
|
return '/'.$headface;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/images/headface/'.strtolower($headface);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户个人装扮统一管理服务
|
||||||
|
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||||||
|
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||||||
|
*
|
||||||
|
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||||||
|
* - bubble : 消息气泡边框样式
|
||||||
|
* - name_color : 昵称颜色效果
|
||||||
|
* - avatar_frame: 头像装饰边框
|
||||||
|
* - text_color : 消息文字颜色特效
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Models\ShopItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserPurchase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
|
||||||
|
*/
|
||||||
|
class DecorationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 商店商品 type → 装扮槽位映射。
|
||||||
|
* 以后新增装扮类型,在此加一行即可。
|
||||||
|
*/
|
||||||
|
private const TYPE_TO_SLOT = [
|
||||||
|
'msg_bubble' => 'bubble',
|
||||||
|
'msg_name_color' => 'name_color',
|
||||||
|
'avatar_frame' => 'avatar_frame',
|
||||||
|
'msg_text_color' => 'text_color',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||||
|
*
|
||||||
|
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||||
|
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
|
||||||
|
*
|
||||||
|
* @param User $user 购买用户
|
||||||
|
* @param ShopItem $item 装扮商品
|
||||||
|
* @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, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
// 根据商品类型映射到对应槽位
|
||||||
|
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||||
|
if (! $slot) {
|
||||||
|
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalPrice = $item->price * $quantity;
|
||||||
|
|
||||||
|
// 校验金币余额
|
||||||
|
if ($user->jjb < $totalPrice) {
|
||||||
|
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算过期时间(至少 1 天)
|
||||||
|
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||||
|
$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) {
|
||||||
|
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
|
||||||
|
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
|
||||||
|
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
|
||||||
|
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
|
||||||
|
default => CurrencySource::MSG_DECORATION_BUY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||||
|
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
|
||||||
|
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||||
|
$this->currencyService->change(
|
||||||
|
$user, 'gold', -$totalPrice, $source,
|
||||||
|
"购买装扮:{$item->name}({$totalDays}天)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||||
|
UserPurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'shop_item_id' => $item->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => $totalPrice,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加)
|
||||||
|
$decorations = $this->getActiveDecorations($user);
|
||||||
|
$decorations[$slot] = [
|
||||||
|
'style' => $item->slug,
|
||||||
|
'expires_at' => $expiresAt->toIso8601String(),
|
||||||
|
];
|
||||||
|
$user->active_decorations = $decorations;
|
||||||
|
$user->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新读取最新余额,避免缓存脏数据
|
||||||
|
$balanceAfter = (int) $user->fresh()->jjb;
|
||||||
|
|
||||||
|
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
|
||||||
|
$displayDays = $isSameActive
|
||||||
|
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
|
||||||
|
: $totalDays;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
|
||||||
|
'balance_after' => $balanceAfter,
|
||||||
|
'slot' => $slot,
|
||||||
|
'style' => $item->slug,
|
||||||
|
'expires_at' => $expiresAt->toIso8601String(),
|
||||||
|
'quantity' => $quantity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||||||
|
*
|
||||||
|
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
|
||||||
|
* 如果清理了过期数据会自动写回 users.active_decorations。
|
||||||
|
*
|
||||||
|
* @param User $user 目标用户
|
||||||
|
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
|
||||||
|
*/
|
||||||
|
public function getActiveDecorations(User $user): array
|
||||||
|
{
|
||||||
|
$decorations = $user->active_decorations ?? [];
|
||||||
|
|
||||||
|
// 非数组(null 或格式异常)直接返回空
|
||||||
|
if (! is_array($decorations)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = Carbon::now();
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
foreach ($decorations as $slot => $data) {
|
||||||
|
// 格式校验:必须包含 expires_at 字段
|
||||||
|
if (! is_array($data) || empty($data['expires_at'])) {
|
||||||
|
unset($decorations[$slot]);
|
||||||
|
$changed = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析过期时间并与当前时间比较
|
||||||
|
try {
|
||||||
|
$expiresAt = Carbon::parse($data['expires_at']);
|
||||||
|
if ($expiresAt->isPast()) {
|
||||||
|
unset($decorations[$slot]);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 时间格式异常也视为无效,清理之
|
||||||
|
unset($decorations[$slot]);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有过期项被清理时写回数据库
|
||||||
|
if ($changed) {
|
||||||
|
$user->active_decorations = $decorations;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decorations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息广播 payload 需要携带的装扮字段。
|
||||||
|
*
|
||||||
|
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color)
|
||||||
|
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
|
||||||
|
*
|
||||||
|
* @param User $user 消息发送者
|
||||||
|
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
|
||||||
|
*/
|
||||||
|
public function getDecorationsForMessage(User $user): array
|
||||||
|
{
|
||||||
|
$decorations = $this->getActiveDecorations($user);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
if (! empty($decorations['bubble']['style'])) {
|
||||||
|
$result['msg_bubble'] = $decorations['bubble']['style'];
|
||||||
|
}
|
||||||
|
if (! empty($decorations['name_color']['style'])) {
|
||||||
|
$result['msg_name_color'] = $decorations['name_color']['style'];
|
||||||
|
}
|
||||||
|
if (! empty($decorations['text_color']['style'])) {
|
||||||
|
$result['msg_text_color'] = $decorations['text_color']['style'];
|
||||||
|
}
|
||||||
|
if (! empty($decorations['avatar_frame']['style'])) {
|
||||||
|
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取在线用户 Presence 载荷需要携带的装扮字段。
|
||||||
|
*
|
||||||
|
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
|
||||||
|
* 气泡样式仅作用于消息,不需要在用户列表中展示。
|
||||||
|
*
|
||||||
|
* @param User $user 目标用户
|
||||||
|
* @return array{avatar_frame?:string, name_color?:string}
|
||||||
|
*/
|
||||||
|
public function getDecorationsForPresence(User $user): array
|
||||||
|
{
|
||||||
|
$decorations = $this->getActiveDecorations($user);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
if (! empty($decorations['avatar_frame']['style'])) {
|
||||||
|
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||||
|
}
|
||||||
|
if (! empty($decorations['name_color']['style'])) {
|
||||||
|
$result['name_color'] = $decorations['name_color']['style'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
* (会员加成:+经验X,+金币Y)
|
* (会员加成:+经验X,+金币Y)
|
||||||
*
|
*
|
||||||
* @author ChatRoom Laravel
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
* @version 1.2.0
|
* @version 1.2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,20 +25,19 @@ use App\Models\User;
|
|||||||
class FishingService
|
class FishingService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly VipService $vipService,
|
private readonly VipService $vipService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
private readonly ShopService $shopService,
|
private readonly ShopService $shopService,
|
||||||
)
|
) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理收竿逻辑:计算结果、发放积分并全服广播。
|
* 处理收竿逻辑:计算结果、发放积分并全服广播。
|
||||||
*
|
*
|
||||||
* @param User $user 收竿的用户实体
|
* @param User $user 收竿的用户实体
|
||||||
* @param int $roomId 所在房间 ID
|
* @param int $roomId 所在房间 ID
|
||||||
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
|
* @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
|
public function processCatch(User $user, int $roomId, bool $isAi = false): array
|
||||||
{
|
{
|
||||||
@@ -54,11 +54,11 @@ class FishingService
|
|||||||
|
|
||||||
if ($result['exp'] !== 0) {
|
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) {
|
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. 计算会员额外加成部分
|
// 4. 计算会员额外加成部分
|
||||||
@@ -92,16 +92,19 @@ class FishingService
|
|||||||
|
|
||||||
// 8. 广播钓鱼结果到聊天室
|
// 8. 广播钓鱼结果到聊天室
|
||||||
$promoTag = '';
|
$promoTag = '';
|
||||||
if (!$isAi) {
|
if (! $isAi) {
|
||||||
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
$autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||||
|
// 公屏消息内的促销标签使用相对字号,避免覆盖用户在聊天室选择的字号。
|
||||||
$promoTag = $autoFishingMinutesLeft > 0
|
$promoTag = $autoFishingMinutesLeft > 0
|
||||||
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
|
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
|
||||||
. 'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
|
.'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>'
|
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 广播结果时额外带上统一动作标记和钓鱼者用户名,
|
||||||
|
// 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。
|
||||||
$sysMsg = [
|
$sysMsg = [
|
||||||
'id' => $this->chatState->nextMessageId($roomId),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
@@ -110,7 +113,8 @@ class FishingService
|
|||||||
'content' => "{$result['emoji']} 【{$user->username}】{$finalMessage}{$promoTag}",
|
'content' => "{$result['emoji']} 【{$user->username}】{$finalMessage}{$promoTag}",
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
|
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
|
||||||
'action' => '',
|
'action' => 'fishing_result',
|
||||||
|
'fishing_username' => $user->username,
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -154,7 +158,7 @@ class FishingService
|
|||||||
return $baseMessage;
|
return $baseMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $baseMessage . '(' . $user->vipName() . '追加:' . implode(',', $bonusParts) . ')';
|
return $baseMessage.'('.$user->vipName().'追加:'.implode(',', $bonusParts).')';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +172,7 @@ class FishingService
|
|||||||
{
|
{
|
||||||
$event = FishingEvent::rollOne();
|
$event = FishingEvent::rollOne();
|
||||||
|
|
||||||
if (!$event) {
|
if (! $event) {
|
||||||
return [
|
return [
|
||||||
'emoji' => '🐟',
|
'emoji' => '🐟',
|
||||||
'message' => '钓到一条小鱼,获得金币10',
|
'message' => '钓到一条小鱼,获得金币10',
|
||||||
@@ -180,8 +184,8 @@ class FishingService
|
|||||||
return [
|
return [
|
||||||
'emoji' => $event->emoji,
|
'emoji' => $event->emoji,
|
||||||
'message' => $event->message,
|
'message' => $event->message,
|
||||||
'exp' => (int)$event->exp,
|
'exp' => (int) $event->exp,
|
||||||
'jjb' => (int)$event->jjb,
|
'jjb' => (int) $event->jjb,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user