Compare commits
494 Commits
2e252eb70e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 01a8e116a3 | |||
| a20bcf13be | |||
| 7b46a08a46 | |||
| 0006b7bcf6 | |||
| 563ac99348 | |||
| 94236e25eb | |||
| b098639db5 | |||
| 879dcb0b59 | |||
| fcc88ecb0c | |||
| e8d9de51f8 | |||
| b19ebdc7d8 | |||
| d8cb75d282 | |||
| 9fd7b14ec7 | |||
| fc28fe37c6 | |||
| 524c8ed6f3 | |||
| 394e216b92 | |||
| b8a6c9d0c2 | |||
| be1406fc58 | |||
| 83192ffcce | |||
| b19073cf85 | |||
| 3563b45038 | |||
| 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 | |||
| 32af6abeb2 | |||
| d4082e0edd | |||
| 0402097b59 | |||
| b07f4e971a | |||
| e69bceeb77 | |||
| 00270b3904 | |||
| 61cfc2091c | |||
| af772350c9 | |||
| f0269c7c17 | |||
| 17d1885efc | |||
| c8a9c92b56 | |||
| 62bd92c1c6 | |||
| 0ac12364bb | |||
| 36ac9d090b | |||
| 7fc40eba32 | |||
| aecabde44c | |||
| 4cdcaa537f | |||
| 891e18e83f | |||
| 2e8bfb61c2 | |||
| be0052119f | |||
| 5b6f687db6 | |||
| e53f2f5d9d | |||
| ee4abdff85 | |||
| 925f2498c5 | |||
| 6f779edb91 | |||
| 8d038c698f | |||
| 7966c0f662 | |||
| 1f1c329085 | |||
| 0953e03b73 | |||
| 9ba18315cc | |||
| 8e57ad8a45 | |||
| a5c43383e1 | |||
| 63f6dc7106 | |||
| 48d646d723 | |||
| d5f9cf7371 | |||
| 414dc52a3b | |||
| 3bbde9b4dd | |||
| b622053bc2 | |||
| 66a9e8ad23 | |||
| 585a6fbf5f | |||
| 9f61dcc619 | |||
| dac8adfc5a | |||
| 8a690ac40d | |||
| c7b8ba956b | |||
| e6a686233f | |||
| 55d3dd43ba | |||
| fdd20917a4 | |||
| a6bc6c61c5 | |||
| f8fb849714 | |||
| 43d613bfb1 | |||
| a766f2a9c5 | |||
| 14511b6230 | |||
| 136240e5e1 | |||
| ed2d60a24e | |||
| 1e9f6673cc | |||
| a5c2022422 | |||
| d4a6a799fe | |||
| e3a3f6a596 | |||
| 35ee91530b | |||
| de7c79f0de | |||
| 19e50944d9 | |||
| 4d337a2e8f | |||
| e876e76ec7 | |||
| 35cbfedeb7 | |||
| b7af9330c4 | |||
| cedc787586 | |||
| 7900145ba9 | |||
| c3229f870a | |||
| 4352919889 | |||
| 90d2fcc14a | |||
| a62e7ff41f | |||
| 4815d201f6 | |||
| 4751a5578e | |||
| 85a419fe53 | |||
| d875dd08ec | |||
| 2d4593c597 | |||
| 91ddfbb408 | |||
| ed7ff81321 | |||
| bb401b8940 | |||
| f54757414d | |||
| d46fe85c70 | |||
| 0623120c00 | |||
| ed484c9235 | |||
| 6cb63a98e2 | |||
| c44c8198d2 | |||
| 992615b7be | |||
| a0aaceecc3 | |||
| c7ad87a767 | |||
| 2180fa5ff4 | |||
| 8bf913c568 | |||
| 22981802ca | |||
| 9cc10d56a7 | |||
| 8cdf8492fb | |||
| e9a3310649 | |||
| db545aa32e | |||
| 086a46dda6 | |||
| 5823028276 | |||
| 21a727a693 | |||
| ef434f0703 | |||
| 5938976360 | |||
| 0cfb43183a | |||
| 990b939a3a | |||
| 4f869eb6ea | |||
| 20317f4699 | |||
| 83fff919a5 | |||
| 95484681c5 | |||
| a847dad00a | |||
| e341f2d1a7 | |||
| 318aeba684 | |||
| 4b1d47e96c | |||
| 8886f5c690 | |||
| c8bc81f961 | |||
| 283793bc1c | |||
| 84b3624d59 | |||
| 74602c7a7b | |||
| 31f53ddfbe | |||
| d012b3e73b | |||
| 308e5690fe | |||
| 2f09d5e2ed | |||
| ac8a3b959d | |||
| 518cc86a93 | |||
| 3773cf905b | |||
| 1a750f2fe7 | |||
| 37e4c5d4c5 | |||
| 0526419dbc | |||
| 89f344bd29 | |||
| 247283a282 | |||
| 4df557bb9e | |||
| 54faf8b501 | |||
| be22710424 | |||
| 1db22dc5de | |||
| 0310798675 | |||
| 04ee32e4d5 | |||
| ef471ec68b | |||
| 3e525eaa36 | |||
| ce6f8552c1 | |||
| 1429dee8a6 | |||
| cf42071c29 | |||
| 10e9835530 | |||
| 64a1e5d769 | |||
| ca61dd42f7 | |||
| e9c3fc989c | |||
| c858f6af0c | |||
| 2f246f9112 | |||
| f1d8d20180 | |||
| e3cba255f9 | |||
| 128b52d0aa | |||
| c410897231 | |||
| 855d031b04 | |||
| 4d3f4f7a4b | |||
| f18eefe9bc | |||
| 8bd1dae9e1 | |||
| 5bfcd75442 | |||
| 8b15507f22 | |||
| 97c021dae2 | |||
| 8cf5029711 | |||
| aab609f69b | |||
| a0268b611f | |||
| fc68aaff72 | |||
| 32584f11d2 | |||
| a3b5184470 | |||
| b2b91b9238 | |||
| 9f8b5e7524 | |||
| 5273b4ee4b | |||
| 4486a87326 | |||
| be9fc09d9d | |||
| 34356a26ae | |||
| 0f0bfef2a8 | |||
| d7ec42a025 | |||
| 6c631aa495 | |||
| fb96747352 | |||
| 7c27ba0c48 | |||
| bef797abd5 | |||
| 73c6674fc4 | |||
| b0028c515f | |||
| a066580014 | |||
| 5a6446b832 | |||
| a17a67f533 | |||
| fed51dda18 | |||
| c209221bad | |||
| 590b7d5b35 | |||
| f0769a841e | |||
| 281315d1cf | |||
| cfdbf387af | |||
| cf4006eb8b | |||
| 96a449d94b | |||
| 916f4c5aa6 | |||
| d4a9389fbc | |||
| 900c93c6c7 | |||
| 438241e878 | |||
| ba6406ed68 | |||
| 5ce83a769d | |||
| bd97ed0b73 | |||
| b98ae7f94e | |||
| c710d585da | |||
| 3afe5a4480 | |||
| c7cb826013 | |||
| 0e8a1669b9 | |||
| 0f4de941db | |||
| 4866d25df9 | |||
| dd938ec6e7 | |||
| 1a39ddd725 | |||
| 7255d50966 | |||
| 6927a88dd3 | |||
| fc9a66469a | |||
| 0183de66dd | |||
| b76b6559ea | |||
| a2e51f5668 | |||
| 392f46769c | |||
| df29da7440 | |||
| 762caac938 | |||
| 426d01d99b | |||
| 596c7f357f | |||
| 2eb732642b | |||
| d060e1b797 | |||
| dca43a2d0d | |||
| 353aaaf6ce | |||
| d739fc7028 | |||
| c297b61493 | |||
| ef407a8c6e | |||
| f8d5a3b250 | |||
| a4cc85b558 | |||
| bf856e18e3 | |||
| d60065ff3e | |||
| e837d1fcd0 | |||
| 61541bfe4c | |||
| d6f14868fd | |||
| 4ac3311328 | |||
| e5f0f28978 | |||
| 5755bea748 | |||
| 1559e49d3d | |||
| d52db10863 | |||
| adc89240fd | |||
| 28cbf2b564 | |||
| e7aea014fb | |||
| 87c7a8d786 | |||
| 5b637d2c64 | |||
| 2090250967 | |||
| 705af810a9 | |||
| 77c17f87f9 | |||
| 1e64d2d5e2 | |||
| 70cb170f2c | |||
| 33a3e5d118 | |||
| bc825157c9 | |||
| 9b1f2a2146 | |||
| 0899ff184c | |||
| 82e29753b8 | |||
| 00b9396dea | |||
| d2f08eb2dd | |||
| 8471516fd7 | |||
| dee91bccca | |||
| 9b4b0ab5f3 | |||
| f4a632a9c1 | |||
| e43dceab2c | |||
| dd9a8c5db8 | |||
| ff402be02f | |||
| 0a764a3a86 | |||
| f91772b019 | |||
| abc05de86e | |||
| 37c175289c | |||
| b02a789264 | |||
| 44db0d7853 | |||
| cc1dd017ce | |||
| ca1c7e66c5 | |||
| f6fb5aab78 | |||
| 4eba9dfc12 | |||
| 9fb7710079 | |||
| a21695e326 | |||
| a7fe908c1c | |||
| f1d94b18b2 | |||
| 632c9e5a93 | |||
| 6af789dd83 | |||
| 12fd0558d9 | |||
| c30b518105 | |||
| c2a2b4818e | |||
| 56b24901c6 | |||
| 7087c22259 | |||
| 746116d325 | |||
| db26820544 | |||
| 3488ad0605 | |||
| 659e562208 | |||
| d47f9c5360 | |||
| 540793c152 | |||
| 3aa2402808 | |||
| b0b77640f6 | |||
| fb1e4402dc | |||
| 97e32572cf | |||
| b4d6e0e23b | |||
| caf4742dd8 | |||
| c7142efa99 | |||
| c4edda8b4e | |||
| 63292ab810 | |||
| 2786c8e7bf | |||
| ecfed9bf6b | |||
| a562ecca72 | |||
| fa5e37f003 | |||
| e36b779a4a | |||
| 66451c189e | |||
| 310e8bc07d | |||
| f04512ac3f | |||
| a24c8280c9 | |||
| 9857797b80 | |||
| 870855d99c | |||
| 039c32ecf4 | |||
| 08498c97d0 | |||
| fc57f97c9e | |||
| 8a809e3cc0 | |||
| 0a192c4f33 | |||
| 426695e410 | |||
| 69e41fbbd9 | |||
| 3a460b9ac6 | |||
| f0d92b21be | |||
| a3edb7538a | |||
| 5d4a0dd00f | |||
| a69a20ee1e | |||
| 08c854222e | |||
| 7bb7f1f4fd | |||
| aa760b14a2 |
@@ -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
|
||||
@@ -3,6 +3,7 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -32,6 +33,7 @@ SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
@@ -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
|
||||
+14
@@ -11,6 +11,14 @@
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/.junie
|
||||
/.github
|
||||
/.gemini
|
||||
/.agents
|
||||
/.codex
|
||||
/.hermes
|
||||
/.reasonix
|
||||
/.understand-anything
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
@@ -26,3 +34,9 @@ vendor.zip
|
||||
test-captcha.php
|
||||
public/.user.ini
|
||||
dump.rdb
|
||||
|
||||
# AI 生成文件
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
rr
|
||||
.rr.yaml
|
||||
|
||||
@@ -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>
|
||||
@@ -186,52 +186,66 @@ php artisan view:cache
|
||||
|
||||
### 6. Nginx 配置
|
||||
|
||||
在宝塔面板的 Nginx 配置中,需要添加 WebSocket 反向代理。
|
||||
为了让外界能够通过 HTTPS 正常访问常驻内存服务器(Roadrunner 8000端口)以及实时 WebSocket 广播(Reverb 8080端口),需要在宝塔面板的 Nginx 配置中进行反向代理。
|
||||
|
||||
**第一步:** 在 `server {}` 块的**外面上方**添加:
|
||||
在 `server {}` 块**内部**(`#REWRITE-END` 之后,禁止访问敏感文件之前)添加以下配置:
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
# ── 1. 静态资源优先由 Nginx 直接响应,动态请求分流给 Octane ──
|
||||
location / {
|
||||
try_files $uri $uri/ @octane;
|
||||
}
|
||||
|
||||
# ── 2. API、心跳等动态请求反向代理到本地 8000 端口 (Octane / Roadrunner) ──
|
||||
location @octane {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Scheme $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
|
||||
# ── 3. ⚡ WebSocket 实时广播反向代理 (Laravel Reverb 8080端口) ──
|
||||
# 浏览器通过 /app 和 /apps 路径发起 WebSocket 连接
|
||||
location /app {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
# 避坑提示:某些宝塔环境全局未配置 $connection_upgrade 变量,
|
||||
# 直接写死 "Upgrade" 能 100% 避免 500 代理握手失败。
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 长连接保活:120秒无数据才断开
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /apps {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**第二步:** 在 `server {}` 块**内部**(`#REWRITE-END` 之后)添加:
|
||||
|
||||
```nginx
|
||||
location /app {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /apps {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**第三步:** 在宝塔面板 → 网站 → 伪静态中选择 `laravel5`。
|
||||
|
||||
**第四步:** 测试并重载 Nginx:
|
||||
**测试并重载 Nginx:**
|
||||
|
||||
```bash
|
||||
nginx -t && systemctl reload nginx
|
||||
@@ -241,7 +255,7 @@ nginx -t && systemctl reload nginx
|
||||
|
||||
### 7. Supervisor 守护进程(关键!)
|
||||
|
||||
在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加两个守护进程:
|
||||
在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加三个守护进程,确保长连接和心跳在后台持续运行:
|
||||
|
||||
#### 进程一:Laravel Reverb(WebSocket 服务器)
|
||||
|
||||
@@ -250,6 +264,7 @@ nginx -t && systemctl reload nginx
|
||||
| 名称 | `chatroom-reverb` |
|
||||
| 运行目录 | `/www/wwwroot/chat.ay.lc` |
|
||||
| 启动命令 | `php artisan reverb:start` |
|
||||
| 运行用户 | `www` |
|
||||
| 进程数量 | `1` |
|
||||
|
||||
#### 进程二:Laravel Horizon(队列处理器)
|
||||
@@ -259,12 +274,23 @@ nginx -t && systemctl reload nginx
|
||||
| 名称 | `chatroom-horizon` |
|
||||
| 运行目录 | `/www/wwwroot/chat.ay.lc` |
|
||||
| 启动命令 | `php artisan horizon` |
|
||||
| 运行用户 | `www` |
|
||||
| 进程数量 | `1` |
|
||||
|
||||
#### 进程三:Laravel Octane(Roadrunner 常驻内存服务)
|
||||
|
||||
| 配置项 | 值 |
|
||||
| -------- | ---------------------------------------------------------- |
|
||||
| 名称 | `chatroom-octane` |
|
||||
| 运行目录 | `/www/wwwroot/chat.ay.lc` |
|
||||
| 启动命令 | `php artisan octane:start --server=roadrunner --port=8000` |
|
||||
| 运行用户 | `www` |
|
||||
| 进程数量 | `1` |
|
||||
|
||||
> ⚠️ **如果不配置 Supervisor:**
|
||||
>
|
||||
> - 关闭 SSH 终端后,Reverb 和 Horizon 会立刻停止
|
||||
> - 聊天室将无法发送/接收消息,在线列表为空
|
||||
> - 关闭 SSH 终端后,Reverb、Horizon 和 Octane 会立刻停止运行,导致聊天室无法打开或无法收发消息!
|
||||
> - **更新代码后**:当您在服务器上完成 `git pull` 后,需要运行 `php artisan octane:reload` 重新载入内存,代码才会生效。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,15 +16,23 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\AiFishingJob;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Autoact;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\AiFinanceService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\SignInService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 定时模拟 AI小班长心跳,并同步维护其常规存款理财行为。
|
||||
*/
|
||||
class AiHeartbeatCommand extends Command
|
||||
{
|
||||
/**
|
||||
@@ -37,37 +45,60 @@ class AiHeartbeatCommand extends Command
|
||||
*/
|
||||
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
|
||||
|
||||
/**
|
||||
* 注入聊天室状态、VIP、积分与 AI 资金调度服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly AiFinanceService $aiFinance,
|
||||
private readonly SignInService $signInService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 AI小班长单次心跳,并处理奖励、随机事件与资金调度。
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
|
||||
// 1. 检查总开关
|
||||
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$config = $this->heartbeatConfig();
|
||||
|
||||
// 2. 获取 AI 实体
|
||||
$user = User::where('username', 'AI小班长')->first();
|
||||
if (! $user) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
||||
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||||
$this->aiFinance->bankExcessGold($user);
|
||||
}
|
||||
|
||||
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
||||
if ($this->performDailySignIn($user)) {
|
||||
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
|
||||
$user->refresh();
|
||||
}
|
||||
|
||||
// 3. 常规心跳经验与金币发放
|
||||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||||
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
|
||||
$expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
|
||||
if ($expGain > 0) {
|
||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||||
$user->exp_num += $actualExpGain;
|
||||
}
|
||||
|
||||
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
|
||||
$jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
|
||||
if ($jjbGain > 0) {
|
||||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||||
@@ -75,30 +106,35 @@ class AiHeartbeatCommand extends Command
|
||||
}
|
||||
|
||||
$user->save();
|
||||
$user->refresh();
|
||||
|
||||
// 4. 重算等级(基础心跳升级)
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$superLevel = (int) $config['superlevel'];
|
||||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||||
|
||||
// 5. 随机事件触发
|
||||
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
|
||||
$eventChance = (int) $config['auto_event_chance'];
|
||||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||||
$autoEvent = Autoact::randomEvent();
|
||||
if ($autoEvent) {
|
||||
$hasCurrencyChange = false;
|
||||
|
||||
// 执行随机事件的金钱经验惩奖
|
||||
if ($autoEvent->exp_change !== 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||
);
|
||||
$hasCurrencyChange = true;
|
||||
}
|
||||
if ($autoEvent->jjb_change !== 0) {
|
||||
$this->currencyService->change(
|
||||
$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)) {
|
||||
@@ -129,35 +165,68 @@ class AiHeartbeatCommand extends Command
|
||||
}
|
||||
|
||||
// 7. 钓鱼小游戏随机参与逻辑
|
||||
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
|
||||
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
|
||||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
|
||||
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
||||
if ($user->jjb >= $cost) {
|
||||
// 先扣除费用
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$cost,
|
||||
CurrencySource::FISHING_COST,
|
||||
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
||||
1,
|
||||
);
|
||||
$fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
|
||||
$fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
|
||||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
|
||||
$fishingConfig = GameConfig::forGame('fishing');
|
||||
|
||||
// 模拟玩家等待时间
|
||||
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
||||
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
||||
$waitTime = rand($waitMin, $waitMax);
|
||||
if ($fishingConfig?->enabled) {
|
||||
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
|
||||
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
||||
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();
|
||||
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
||||
// 模拟玩家等待时间
|
||||
$waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
|
||||
$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 万,则把超出的部分重新转回银行。
|
||||
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 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'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并更新用户等级
|
||||
*/
|
||||
@@ -195,6 +264,56 @@ class AiHeartbeatCommand extends Command
|
||||
return (int) $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
||||
*/
|
||||
private function performDailySignIn(User $user): bool
|
||||
{
|
||||
// 先检查今日是否已签,避免每分钟都调用事务
|
||||
$alreadySigned = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('sign_in_date', today())
|
||||
->exists();
|
||||
|
||||
if ($alreadySigned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取活跃房间作为签到归属(默认房间 1)
|
||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
|
||||
|
||||
$dailySignIn = $this->signInService->claim($user, $roomId);
|
||||
|
||||
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
||||
if (! $dailySignIn->wasRecentlyCreated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rewardParts = [];
|
||||
if ($dailySignIn->gold_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->gold_reward.' 金币';
|
||||
}
|
||||
if ($dailySignIn->exp_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->exp_reward.' 经验';
|
||||
}
|
||||
if ($dailySignIn->charm_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->charm_reward.' 魅力';
|
||||
}
|
||||
$rewardText = $rewardParts === [] ? '签到记录' : implode(' + ', $rewardParts);
|
||||
|
||||
$identityText = $dailySignIn->identity_badge_name
|
||||
? ',获得身份 '.e($dailySignIn->identity_badge_name)
|
||||
: '';
|
||||
|
||||
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||||
|
||||
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 往所有活跃房间发送系统广播消息
|
||||
*/
|
||||
|
||||
@@ -20,12 +20,15 @@ use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\PositionDutyLog;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
@@ -46,6 +49,7 @@ class AutoSaveExp extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {
|
||||
@@ -63,6 +67,8 @@ class AutoSaveExp extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
|
||||
// 读取奖励配置
|
||||
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
||||
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
||||
@@ -79,15 +85,40 @@ class AutoSaveExp extends Command
|
||||
|
||||
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
|
||||
$totalProcessed = 0;
|
||||
$usersByUsername = $this->preloadOnlineUsers($roomMap);
|
||||
|
||||
$processedUsernames = [];
|
||||
|
||||
foreach ($roomMap as $roomId => $usernames) {
|
||||
foreach ($usernames as $username) {
|
||||
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
||||
// 1. 如果本轮已经处理过该用户,跳过发放经验(防止多房间/多开重复吃经验)
|
||||
if (isset($processedUsernames[$username])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 利用 Redis 缓存锁,防止在短时间内(200秒内)重复进行自动存点奖励(防御多进程并发/重复调度)
|
||||
$cooldownKey = "user:auto_save_cooldown:{$username}";
|
||||
if (Redis::exists($cooldownKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = $usersByUsername->get($username);
|
||||
if (! $user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
||||
|
||||
// 3. 设置冷却标记,200秒后过期(本任务5分钟运行一次,200秒冷却期既防重复,又不影响下一次存点)
|
||||
Redis::setex($cooldownKey, 200, 1);
|
||||
|
||||
$processedUsernames[$username] = true;
|
||||
$totalProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。");
|
||||
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
@@ -106,40 +137,71 @@ class AutoSaveExp extends Command
|
||||
$roomMap = [];
|
||||
|
||||
// 从数据库取出所有房间 ID
|
||||
$roomIds = \App\Models\Room::pluck('id');
|
||||
$roomIds = Room::pluck('id');
|
||||
|
||||
foreach ($roomIds as $roomId) {
|
||||
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
|
||||
$usernames = Redis::hkeys("room:{$roomId}:users");
|
||||
if (! empty($usernames)) {
|
||||
$roomMap[(int) $roomId] = $usernames;
|
||||
$activeUsernames = [];
|
||||
foreach ($usernames as $username) {
|
||||
// 检查活跃标记是否存在(90秒内)
|
||||
if (Redis::exists("room:{$roomId}:alive:{$username}")) {
|
||||
$activeUsernames[] = $username;
|
||||
} else {
|
||||
// 懒清理僵尸数据
|
||||
Redis::hdel("room:{$roomId}:users", $username);
|
||||
}
|
||||
}
|
||||
if (! empty($activeUsernames)) {
|
||||
$roomMap[(int) $roomId] = $activeUsernames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
||||
* @param string $jjbGainRaw 金币奖励原始配置
|
||||
* @param int $superLevel 管理员等级阈值
|
||||
*/
|
||||
private function processUser(
|
||||
string $username,
|
||||
User $user,
|
||||
int $roomId,
|
||||
string $expGainRaw,
|
||||
string $jjbGainRaw,
|
||||
int $superLevel
|
||||
): void {
|
||||
$user = User::where('username', $username)->first();
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
||||
$expGain = $this->parseRewardValue($expGainRaw);
|
||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||
@@ -163,8 +225,11 @@ class AutoSaveExp extends Command
|
||||
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
||||
);
|
||||
}
|
||||
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
|
||||
$user->load('activePosition.position'); // 确保职务及职位关联已加载
|
||||
if ($actualExpGain > 0 || $actualJjbGain > 0) {
|
||||
// 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系。
|
||||
$user->refresh();
|
||||
$user->load(['activePosition.position.department', 'vipLevel']);
|
||||
}
|
||||
|
||||
// 3. 自动升降级逻辑
|
||||
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
||||
@@ -176,11 +241,17 @@ class AutoSaveExp extends Command
|
||||
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
||||
|
||||
if ($activeUP?->position) {
|
||||
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
|
||||
// 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
|
||||
$requiredLevel = (int) $activeUP->position->level;
|
||||
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
|
||||
if ($requiredLevel > 0 && $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) {
|
||||
// 普通用户:按经验计算并更新等级
|
||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
@@ -208,6 +279,14 @@ class AutoSaveExp extends Command
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
SaveMessageJob::dispatch($sysMsg);
|
||||
|
||||
// 触发微信机器人私聊通知 (等级提升)
|
||||
try {
|
||||
$wechatService = app(\App\Services\WechatBot\WechatNotificationService::class);
|
||||
$wechatService->notifyLevelChange($user, $oldLevel, $newLevel);
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('WechatBot level change notification failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示
|
||||
@@ -221,15 +300,17 @@ class AutoSaveExp extends Command
|
||||
$jjbDisplay = $user->jjb ?? 0;
|
||||
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode(',', $gainParts) : '';
|
||||
|
||||
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
||||
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
|
||||
|
||||
// 格式:⏰ 自动存点 · 部门 X · 职务 Y · 会员 Z · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
||||
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
|
||||
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
$content = "⏰ 自动存点 · {$identitySummary['inline']} · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
|
||||
$noticeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $username, // 定向推送给本人
|
||||
'to_user' => $user->username, // 定向推送给本人
|
||||
'content' => $content,
|
||||
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
||||
'font_color' => '#16a34a', // 草绿色
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信机器人 Kafka 消费命令
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WechatBot\KafkaConsumerService;
|
||||
use App\Services\WechatBot\WechatBotApiService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ConsumeWechatMessages extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wechat-bot:consume';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '消费 Kafka 微信机器人消息(守护进程)';
|
||||
|
||||
protected KafkaConsumerService $kafkaService;
|
||||
|
||||
public function __construct(KafkaConsumerService $kafkaService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->kafkaService = $kafkaService;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('正在启动微信机器人 Kafka 消费者...');
|
||||
|
||||
$consumer = $this->kafkaService->createConsumer();
|
||||
if (! $consumer) {
|
||||
$this->error('Kafka 配置不完整或加载失败,请在后台检查机器人设置。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('消费者已启动,等待消息...');
|
||||
|
||||
$apiService = new WechatBotApiService;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$messageJson = $consumer->consume();
|
||||
if ($messageJson) {
|
||||
$rawJson = $messageJson->getValue();
|
||||
$this->info('--> 收到新的 Kafka 消息 (Raw Length: '.strlen($rawJson).')');
|
||||
|
||||
$messages = $this->kafkaService->parseKafkaMessage($rawJson);
|
||||
|
||||
if (empty($messages)) {
|
||||
$this->info('--> 解析后:无匹配的 AddMsgs 内容');
|
||||
}
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
try {
|
||||
$this->processMessage($msg, $apiService);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('处理单条微信消息失败', [
|
||||
'error' => $e->getMessage(),
|
||||
'msg' => $msg,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$consumer->ack($messageJson);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Kafka 消费异常', ['error' => $e->getMessage()]);
|
||||
// 延迟重试避免死循环 CPU 空转
|
||||
sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条消息逻辑
|
||||
*/
|
||||
protected function processMessage(array $msg, WechatBotApiService $apiService): void
|
||||
{
|
||||
// 仅处理文本消息 (msg_type = 1)
|
||||
if ($msg['msg_type'] != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = trim($msg['content']);
|
||||
$fromUser = $msg['from_user'];
|
||||
$isChatroom = $msg['is_chatroom'];
|
||||
|
||||
// 绑定逻辑:必须是私聊(防止在群内绑定导致未来系统无法直接通过私聊推送个人通知)
|
||||
if (! $isChatroom && preg_match('/^BD-\d{6}$/i', $content)) {
|
||||
$this->info("收到潜在绑定请求: {$content} from {$fromUser}");
|
||||
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
|
||||
}
|
||||
|
||||
// 微信密码重置逻辑:必须是私聊
|
||||
if (! $isChatroom && str_contains($content, '重置密码')) {
|
||||
$this->info("收到微信密码重置请求 from {$fromUser}");
|
||||
$this->handlePasswordResetRequest($fromUser, $apiService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信端的自助密码重置请求
|
||||
*/
|
||||
protected function handlePasswordResetRequest(string $wxid, WechatBotApiService $apiService): void
|
||||
{
|
||||
// 检索绑定了该微信号的聊天室用户
|
||||
$user = User::where('wxid', $wxid)->first();
|
||||
|
||||
if (! $user) {
|
||||
$apiService->sendTextMessage(
|
||||
$wxid,
|
||||
"❌ 密码重置失败:您当前的微信号尚未绑定任何聊天室账号。\n\n"
|
||||
.'请先登录聊天室大厅,在个人设置中获取 6 位绑定码,然后再在微信中向我发送“BD-绑定码”(例如: BD-123456)进行绑定。'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机生成 8 位新随机密码并更新
|
||||
$newPassword = \Illuminate\Support\Str::random(8);
|
||||
$user->password = \Illuminate\Support\Facades\Hash::make($newPassword);
|
||||
$user->save();
|
||||
|
||||
$successMsg = "🎉 密码重置成功!\n"
|
||||
."本小助手已为您绑定的聊天室账号 [{$user->username}] 重新生成了密码:\n\n"
|
||||
."【{$newPassword}】\n\n"
|
||||
.'温馨提示:请使用此随机密码登录聊天室,并尽快在个人偏好设置中将其修改为您的常用密码。';
|
||||
|
||||
$apiService->sendTextMessage($wxid, $successMsg);
|
||||
$this->info("微信用户 [{$user->username}] 已通过微信机器人成功重置了密码");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账号绑定请求
|
||||
*/
|
||||
protected function handleBindRequest(string $code, string $wxid, WechatBotApiService $apiService): void
|
||||
{
|
||||
$cacheKey = 'wechat_bind_code:'.$code;
|
||||
$username = Cache::get($cacheKey);
|
||||
|
||||
if (! $username) {
|
||||
$apiService->sendTextMessage($wxid, '❌ 绑定失败:该验证码无效或已过有效期(5分钟)。请在个人中心重新生成。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::where('username', $username)->first();
|
||||
if (! $user) {
|
||||
$apiService->sendTextMessage($wxid, '❌ 绑定失败:找不到对应的用户账号。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断该微信号是否已经被其他用户绑定(防止碰撞或安全隐患)
|
||||
$existing = User::where('wxid', $wxid)->where('id', '!=', $user->id)->first();
|
||||
if ($existing) {
|
||||
$apiService->sendTextMessage($wxid, "❌ 绑定失败:当前微信号已经被其他账号 [{$existing->username}] 绑定。请先解绑后再试。");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->wxid = $wxid;
|
||||
$user->save();
|
||||
|
||||
// 验证成功后立即销毁验证码
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
$this->info("用户 [{$username}] 成功绑定微信: {$wxid}");
|
||||
|
||||
$successMsg = "🎉 绑定成功!\n"
|
||||
."您已成功绑定聊天室账号:[{$username}]。\n"
|
||||
.'现在您可以接收重要系统通知了。';
|
||||
|
||||
$apiService->sendTextMessage($wxid, $successMsg);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* 文件功能:定期清理聊天记录 Artisan 命令
|
||||
*
|
||||
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控。
|
||||
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。
|
||||
* 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息。
|
||||
* 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -17,7 +17,12 @@ use App\Models\Message;
|
||||
use App\Models\Sysparam;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 定期清理聊天记录命令
|
||||
* 负责删除过期文本消息,并额外回收聊天图片文件。
|
||||
*/
|
||||
class PurgeOldMessages extends Command
|
||||
{
|
||||
/**
|
||||
@@ -26,7 +31,8 @@ class PurgeOldMessages extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'messages:purge
|
||||
{--days= : 覆盖默认保留天数}
|
||||
{--days= : 覆盖通知消息默认保留天数}
|
||||
{--image-days=3 : 聊天图片单独保留天数}
|
||||
{--dry-run : 仅预览不实际删除}';
|
||||
|
||||
/**
|
||||
@@ -34,7 +40,7 @@ class PurgeOldMessages extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '清理超过指定天数的聊天记录(保留天数由 sysparam message_retention_days 配置,默认 30 天)';
|
||||
protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
@@ -43,29 +49,32 @@ class PurgeOldMessages extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
|
||||
// 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
|
||||
$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'));
|
||||
|
||||
$cutoff = Carbon::now()->subDays($days);
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
// 统计待清理数量
|
||||
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
|
||||
$this->cleanupExpiredImages($imageDays, $isDryRun);
|
||||
|
||||
$expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
|
||||
$totalCount = (clone $expiredNoticeQuery)->count();
|
||||
|
||||
if ($totalCount === 0) {
|
||||
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理。");
|
||||
$this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留。");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})");
|
||||
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})...");
|
||||
$this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})...");
|
||||
$this->info(" 待清理数量:{$totalCount} 条");
|
||||
|
||||
// 分批删除,每批 1000 条,避免长时间锁表
|
||||
@@ -73,7 +82,7 @@ class PurgeOldMessages extends Command
|
||||
$batchSize = 1000;
|
||||
|
||||
do {
|
||||
$batch = Message::where('sent_at', '<', $cutoff)
|
||||
$batch = $this->expiredNoticeQuery($cutoff)
|
||||
->limit($batchSize)
|
||||
->delete();
|
||||
|
||||
@@ -84,8 +93,87 @@ class PurgeOldMessages extends Command
|
||||
}
|
||||
} while ($batch === $batchSize);
|
||||
|
||||
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。");
|
||||
$this->info("✅ 清理完成!共删除 {$deleted} 条游戏/临时通知,用户聊天记录未删除。");
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
|
||||
*/
|
||||
private function cleanupExpiredImages(int $imageDays, bool $isDryRun): void
|
||||
{
|
||||
$imageCutoff = Carbon::now()->subDays($imageDays);
|
||||
|
||||
$query = Message::query()
|
||||
->where('message_type', 'image')
|
||||
->where('sent_at', '<', $imageCutoff)
|
||||
->where(function ($builder) {
|
||||
$builder->whereNotNull('image_path')->orWhereNotNull('image_thumb_path');
|
||||
});
|
||||
|
||||
$totalCount = (clone $query)->count();
|
||||
|
||||
if ($totalCount === 0) {
|
||||
$this->line("🖼️ 没有超过 {$imageDays} 天的聊天图片需要清理。");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn("🔍 [预览模式] 将清理 {$totalCount} 条超过 {$imageDays} 天的聊天图片(截止 {$imageCutoff->toDateTimeString()})");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$query->orderBy('id')->chunkById(200, function ($messages) use (&$processed) {
|
||||
foreach ($messages as $message) {
|
||||
$paths = array_values(array_filter([
|
||||
$message->image_path,
|
||||
$message->image_thumb_path,
|
||||
]));
|
||||
|
||||
// 先删物理文件,再把数据库消息降级成“图片已过期”占位,避免出现坏图。
|
||||
if ($paths !== []) {
|
||||
Storage::disk('public')->delete($paths);
|
||||
}
|
||||
|
||||
$placeholder = trim((string) $message->content);
|
||||
$placeholder = $placeholder !== '' ? $placeholder.' [图片已过期]' : '[图片已过期]';
|
||||
|
||||
$message->forceFill([
|
||||
'content' => $placeholder,
|
||||
'message_type' => 'expired_image',
|
||||
'image_path' => null,
|
||||
'image_thumb_path' => null,
|
||||
'image_original_name' => null,
|
||||
])->save();
|
||||
|
||||
$processed++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("🖼️ 已清理 {$processed} 条超过 {$imageDays} 天的聊天图片。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:测试发送微信机器人消息
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SysParam;
|
||||
use App\Services\WechatBot\WechatBotApiService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WechatBotTestSend extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wechat-bot:test-send';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '测试发送一条消息给管理员设定的微信群群 wxid';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('开始测试微信机器人发送...');
|
||||
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->first();
|
||||
if (! $param || empty($param->body)) {
|
||||
$this->error('错误:未找到 wechat_bot_config 配置,请先在后台保存一次配置。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$config = json_decode($param->body, true);
|
||||
$targetWxid = $config['group_notify']['target_wxid'] ?? '';
|
||||
|
||||
if (empty($targetWxid)) {
|
||||
$this->error('错误:请于后台填写【目标微信群 Wxid】。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($config['api']['bot_key'] ?? '')) {
|
||||
$this->error('错误:未配置【机器人 Key (必需)】,API请求将被拒绝(返回该链接不存在)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$service = new WechatBotApiService;
|
||||
|
||||
$this->info("发送目标: {$targetWxid}");
|
||||
$this->info('发送 API Base: '.($config['api']['base_url'] ?? ''));
|
||||
|
||||
$message = "【系统连通性测试】\n发送时间:".now()->format('Y-m-d H:i:s')."\n如果您看到了这条消息,说明 ChatRoom 通知全站群发接口配置正确!";
|
||||
|
||||
$result = $service->sendTextMessage($targetWxid, $message);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info('✅ 发送成功!');
|
||||
|
||||
return self::SUCCESS;
|
||||
} else {
|
||||
$this->error('❌ 发送失败:'.($result['error'] ?? '未知错误'));
|
||||
$this->warn('如果提示『该链接不存在』代表您的基础API URL 或接入 Key 有误。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,18 @@ enum CurrencySource: string
|
||||
/** 商城购买消耗(扣除金币) */
|
||||
case SHOP_BUY = 'shop_buy';
|
||||
|
||||
/** 购买聊天室座驾消耗(扣除金币) */
|
||||
case RIDE_BUY = 'ride_buy';
|
||||
|
||||
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
||||
case ADMIN_ADJUST = 'admin_adjust';
|
||||
|
||||
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
|
||||
case POSITION_REWARD = 'position_reward';
|
||||
|
||||
/** 每日签到奖励(连续签到按规则发放) */
|
||||
case SIGN_IN = 'sign_in';
|
||||
|
||||
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
|
||||
case AI_GIFT = 'ai_gift';
|
||||
|
||||
@@ -48,7 +54,6 @@ enum CurrencySource: string
|
||||
case GIFT_SENT = 'gift_sent';
|
||||
|
||||
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
|
||||
// case SIGN_IN = 'sign_in'; // 每日签到
|
||||
// case TASK_REWARD = 'task_reward'; // 任务奖励
|
||||
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
|
||||
|
||||
@@ -84,6 +89,9 @@ enum CurrencySource: string
|
||||
/** 百家乐中奖赔付(收入金币,含本金返还) */
|
||||
case BACCARAT_WIN = 'baccarat_win';
|
||||
|
||||
/** 百家乐买单活动补偿领取(收入金币) */
|
||||
case BACCARAT_LOSS_COVER_CLAIM = 'baccarat_loss_cover_claim';
|
||||
|
||||
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
|
||||
case AUTO_EVENT = 'auto_event';
|
||||
|
||||
@@ -132,6 +140,42 @@ enum CurrencySource: string
|
||||
/** 五子棋 PvE 入场费返还(平局时返还) */
|
||||
case GOMOKU_REFUND = 'gomoku_refund';
|
||||
|
||||
/** 看视频赚金币与经验奖励 */
|
||||
case VIDEO_REWARD = 'video_reward';
|
||||
|
||||
/** 查看别人隐藏信息扣费 */
|
||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||
|
||||
/** 购买消息装扮消耗(气泡,扣除金币) */
|
||||
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
|
||||
|
||||
/** 购买昵称颜色装扮消耗(扣除金币) */
|
||||
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
|
||||
|
||||
/** 购买文字颜色装扮消耗(扣除金币) */
|
||||
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
|
||||
|
||||
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/** 猜谜活动奖励 */
|
||||
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';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -145,8 +189,10 @@ enum CurrencySource: string
|
||||
self::RECV_GIFT => '收到礼物',
|
||||
self::NEWBIE_BONUS => '新人礼包',
|
||||
self::SHOP_BUY => '商城购买',
|
||||
self::RIDE_BUY => '座驾购买(金币)',
|
||||
self::ADMIN_ADJUST => '管理员调整',
|
||||
self::POSITION_REWARD => '职务奖励',
|
||||
self::SIGN_IN => '每日签到',
|
||||
self::AI_GIFT => 'AI赠送',
|
||||
self::GIFT_SENT => '发红包',
|
||||
self::MARRY_CHARM => '结婚魅力加成',
|
||||
@@ -159,6 +205,7 @@ enum CurrencySource: string
|
||||
self::HOLIDAY_BONUS => '节日福利',
|
||||
self::BACCARAT_BET => '百家乐下注',
|
||||
self::BACCARAT_WIN => '百家乐赢钱',
|
||||
self::BACCARAT_LOSS_COVER_CLAIM => '百家乐买单活动补偿',
|
||||
self::AUTO_EVENT => '随机事件(星海小博士)',
|
||||
self::SLOT_SPIN => '老虎机转动',
|
||||
self::SLOT_WIN => '老虎机中奖',
|
||||
@@ -175,6 +222,18 @@ enum CurrencySource: string
|
||||
self::GOMOKU_ENTRY_FEE => '五子棋入场费',
|
||||
self::GOMOKU_WIN => '五子棋获胜奖励',
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
self::USER_INFO_REVEAL => '信息查看付费',
|
||||
self::MSG_BUBBLE_BUY => '消息气泡购买',
|
||||
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
|
||||
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
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\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间广播百家乐押注人数变化。
|
||||
*/
|
||||
class BaccaratPoolUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->round->room_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'room_id' => (int) $this->round->room_id,
|
||||
'bet_count_big' => $this->round->bet_count_big,
|
||||
'bet_count_small' => $this->round->bet_count_small,
|
||||
'bet_count_triple' => $this->round->bet_count_triple,
|
||||
|
||||
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间广播百家乐开局事件。
|
||||
*/
|
||||
class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->round->room_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'room_id' => (int) $this->round->room_id,
|
||||
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
|
||||
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
|
||||
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
|
||||
|
||||
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间广播百家乐结算结果。
|
||||
*/
|
||||
class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->round->room_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'room_id' => (int) $this->round->room_id,
|
||||
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
|
||||
'total_points' => $this->round->total_points,
|
||||
'result' => $this->round->result,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室浏览器刷新请求广播事件
|
||||
*
|
||||
* 仅供站长触发“刷新全员”命令时使用,
|
||||
* 向当前房间所有在线用户广播前端刷新指令。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向房间内全部在线用户广播页面刷新指令。
|
||||
*/
|
||||
class BrowserRefreshRequested implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数:记录房间与操作者信息。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $operator,
|
||||
public readonly string $reason = '',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:当前聊天室 PresenceChannel。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:前端用于展示提示并执行刷新。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'operator' => $this->operator,
|
||||
'reason' => $this->reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 开发日志发布广播事件
|
||||
* 负责把更新日志的安全展示字段广播给大厅聊天室。
|
||||
*/
|
||||
class ChangelogPublished implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -67,8 +71,20 @@ class ChangelogPublished implements ShouldBroadcastNow
|
||||
'title' => $this->changelog->title,
|
||||
'type' => $this->changelog->type,
|
||||
'type_label' => $this->changelog->type_label,
|
||||
// 同步提供已转义字段,便于前端在 innerHTML 场景下直接复用安全文本。
|
||||
'safe_version' => e((string) $this->changelog->version),
|
||||
'safe_title' => e((string) $this->changelog->title),
|
||||
'safe_type_label' => e((string) $this->changelog->type_label),
|
||||
// 前端点击后跳转的目标 URL,自动锚定至对应版本
|
||||
'url' => url('/changelog').'#v'.$this->changelog->version,
|
||||
'url' => $this->buildDetailUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成广播使用的更新日志详情地址,并编码版本锚点避免 href 注入。
|
||||
*/
|
||||
private function buildDetailUrl(): string
|
||||
{
|
||||
return route('changelog.index').'#v'.rawurlencode((string) $this->changelog->version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 文件功能:聊天室全屏特效广播事件
|
||||
*
|
||||
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
|
||||
* 支持指定接收者(target_username 为 null 则全员播放)。
|
||||
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
|
||||
*/
|
||||
class EffectBroadcast implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
/**
|
||||
* 支持的特效类型列表(用于校验)
|
||||
*/
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow'];
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||
* @param string $operator 触发特效的用户名(购买者)
|
||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||
* @param string|null $giftMessage 附带赠言
|
||||
* @param string|null $effectTitle 特效画面标题
|
||||
* @param string|null $rideName 座驾名称
|
||||
* @param string|null $effectUserInfo 特效画面用户身份信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
public readonly string $operator,
|
||||
public readonly ?string $targetUsername = null,
|
||||
public readonly ?string $giftMessage = null,
|
||||
public readonly ?string $effectTitle = null,
|
||||
public readonly ?string $rideName = null,
|
||||
public readonly ?string $effectUserInfo = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,6 +68,7 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
|
||||
/**
|
||||
* 广播数据:特效类型、操作者、目标用户、赠言
|
||||
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -69,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
'operator' => $this->operator,
|
||||
'target_username' => $this->targetUsername, // null = 全员
|
||||
'gift_message' => $this->giftMessage,
|
||||
'effect_title' => $this->effectTitle,
|
||||
'ride_name' => $this->rideName,
|
||||
'effect_user_info' => $this->effectUserInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,25 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向房间广播节日福利发放批次开始事件。
|
||||
*/
|
||||
class HolidayEventStarted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param HolidayEvent $event 节日活动实例
|
||||
* @param HolidayEventRun $run 节日福利发放批次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HolidayEvent $event,
|
||||
public readonly HolidayEventRun $run,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -60,15 +63,18 @@ class HolidayEventStarted implements ShouldBroadcastNow
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->event->id,
|
||||
'name' => $this->event->name,
|
||||
'description' => $this->event->description,
|
||||
'total_amount' => $this->event->total_amount,
|
||||
'max_claimants' => $this->event->max_claimants,
|
||||
'distribute_type' => $this->event->distribute_type,
|
||||
'fixed_amount' => $this->event->fixed_amount,
|
||||
'claimed_count' => $this->event->claimed_count,
|
||||
'expires_at' => $this->event->expires_at?->toIso8601String(),
|
||||
'run_id' => $this->run->id,
|
||||
'event_id' => $this->run->holiday_event_id,
|
||||
'name' => $this->run->event_name,
|
||||
'description' => $this->run->event_description,
|
||||
'total_amount' => $this->run->total_amount,
|
||||
'max_claimants' => $this->run->max_claimants,
|
||||
'distribute_type' => $this->run->distribute_type,
|
||||
'fixed_amount' => $this->run->fixed_amount,
|
||||
'claimed_count' => $this->run->claimed_count,
|
||||
'expires_at' => $this->run->expires_at?->toIso8601String(),
|
||||
'scheduled_for' => $this->run->scheduled_for?->toIso8601String(),
|
||||
'repeat_type' => $this->run->repeat_type,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间广播赛马开局事件。
|
||||
*/
|
||||
class HorseRaceOpened implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->race->room_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +61,9 @@ class HorseRaceOpened implements ShouldBroadcastNow
|
||||
{
|
||||
return [
|
||||
'race_id' => $this->race->id,
|
||||
'room_id' => (int) $this->race->room_id,
|
||||
'horses' => $this->race->horses,
|
||||
'total_pool' => $this->race->total_pool,
|
||||
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
|
||||
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
|
||||
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
|
||||
|
||||
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定房间持续广播赛马进度。
|
||||
*/
|
||||
class HorseRaceProgress implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $raceId,
|
||||
public readonly int $roomId,
|
||||
public readonly array $positions,
|
||||
public readonly bool $finished = false,
|
||||
public readonly ?int $leaderId = null,
|
||||
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->roomId)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
|
||||
{
|
||||
return [
|
||||
'race_id' => $this->raceId,
|
||||
'room_id' => $this->roomId,
|
||||
'positions' => $this->positions,
|
||||
'finished' => $this->finished,
|
||||
'leader_id' => $this->leaderId,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\HorseBet;
|
||||
use App\Models\HorseRace;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
@@ -20,6 +22,12 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:赛马结算广播事件
|
||||
*
|
||||
* 向房间公共频道广播最终赛果,并附带前端展示个人奖金所需的
|
||||
* 奖池分配参数,避免结算弹窗只能显示固定的 0 金币。
|
||||
*/
|
||||
class HorseRaceSettled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -38,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
return [new PresenceChannel('room.'.$this->race->room_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +64,24 @@ class HorseRaceSettled implements ShouldBroadcastNow
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
||||
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||
|
||||
// 统计各马匹总下注,为前端还原个人分奖金额提供基础参数。
|
||||
$horsePools = HorseBet::query()
|
||||
->where('race_id', $this->race->id)
|
||||
->groupBy('horse_id')
|
||||
->selectRaw('horse_id, SUM(amount) as pool')
|
||||
->pluck('pool', 'horse_id')
|
||||
->map(fn ($pool) => (int) $pool)
|
||||
->toArray();
|
||||
|
||||
$winnerPool = (int) ($horsePools[$this->race->winner_horse_id] ?? 0);
|
||||
$distributablePool = (int) round(
|
||||
HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool)
|
||||
);
|
||||
|
||||
// 找出获胜马匹的名称
|
||||
$horses = $this->race->horses ?? [];
|
||||
$winnerName = '未知';
|
||||
@@ -68,9 +94,12 @@ class HorseRaceSettled implements ShouldBroadcastNow
|
||||
|
||||
return [
|
||||
'race_id' => $this->race->id,
|
||||
'room_id' => (int) $this->race->room_id,
|
||||
'winner_horse_id' => $this->race->winner_horse_id,
|
||||
'winner_name' => $winnerName,
|
||||
'total_pool' => $this->race->total_pool,
|
||||
'total_pool' => (int) $this->race->total_pool,
|
||||
'winner_pool' => $winnerPool,
|
||||
'distributable_pool' => $distributablePool,
|
||||
'settled_at' => $this->race->settled_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,20 +10,25 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:根据消息可见范围选择广播频道。
|
||||
*/
|
||||
class MessageSent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
* 创建消息广播事件实例。
|
||||
*
|
||||
* @param int $roomId 房间ID
|
||||
* @param int $roomId 房间 ID
|
||||
* @param array $message 发送的消息数据
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -32,14 +37,25 @@ class MessageSent implements ShouldBroadcastNow
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
* 获取消息应广播到的频道。
|
||||
*
|
||||
* 聊天消息广播至包含在线状态管理的 PresenceChannel。
|
||||
* 公共消息和普通定向发言走房间 Presence 频道;
|
||||
* 悄悄话只发给发送方与接收方的私有用户频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if ($this->shouldBroadcastPrivately()) {
|
||||
$privateChannels = [];
|
||||
|
||||
foreach ($this->resolveVisibleUserIds() as $userId) {
|
||||
$privateChannels[] = new PrivateChannel('user.'.$userId);
|
||||
}
|
||||
|
||||
return $privateChannels;
|
||||
}
|
||||
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
@@ -56,4 +72,40 @@ class MessageSent implements ShouldBroadcastNow
|
||||
'message' => $this->message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前消息是否应仅广播给特定用户。
|
||||
*/
|
||||
private function shouldBroadcastPrivately(): bool
|
||||
{
|
||||
return (bool) ($this->message['is_secret'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本条消息真正可见的用户 ID 列表。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveVisibleUserIds(): array
|
||||
{
|
||||
$userIds = [];
|
||||
|
||||
$fromUser = trim((string) ($this->message['from_user'] ?? ''));
|
||||
if ($fromUser !== '') {
|
||||
$senderId = User::query()->where('username', $fromUser)->value('id');
|
||||
if ($senderId !== null) {
|
||||
$userIds[] = (int) $senderId;
|
||||
}
|
||||
}
|
||||
|
||||
$toUser = trim((string) ($this->message['to_user'] ?? ''));
|
||||
if ($toUser !== '' && $toUser !== '大家') {
|
||||
$receiverId = User::query()->where('username', $toUser)->value('id');
|
||||
if ($receiverId !== null) {
|
||||
$userIds[] = (int) $receiverId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($userIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
|
||||
* 文件功能:红包领取成功广播事件(广播至房间与领取者私有频道)
|
||||
*
|
||||
* 触发时机:RedPacketController::claim() 成功后广播,
|
||||
* 前端收到后弹出 Toast 通知展示到账金额。
|
||||
* 房间内在线用户收到后实时刷新剩余份数,领取者本人可同步收到到账提示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -15,11 +15,18 @@ namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:广播礼包被领取后的实时状态
|
||||
*
|
||||
* 统一向房间频道推送剩余份数变化,同时向领取者私有频道推送到账结果,
|
||||
* 让红包弹窗与用户提示保持一致。
|
||||
*/
|
||||
class RedPacketClaimed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
@@ -28,32 +35,49 @@ class RedPacketClaimed implements ShouldBroadcastNow
|
||||
* @param User $claimer 领取用户
|
||||
* @param int $amount 领取金额
|
||||
* @param int $envelopeId 红包 ID
|
||||
* @param int $roomId 房间 ID
|
||||
* @param int $remainingCount 剩余份数
|
||||
* @param string $type 红包类型
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $claimer,
|
||||
public readonly int $amount,
|
||||
public readonly int $envelopeId,
|
||||
public readonly int $roomId,
|
||||
public readonly int $remainingCount,
|
||||
public readonly string $type = 'gold',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至领取者私有频道。
|
||||
* 广播至房间频道与领取者私有频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->claimer->id)];
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
new PrivateChannel('user.'.$this->claimer->id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播领取结果与剩余份数。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$typeLabel = $this->type === 'exp' ? '经验' : '金币';
|
||||
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'claimer_id' => $this->claimer->id,
|
||||
'claimer_username' => $this->claimer->username,
|
||||
'amount' => $this->amount,
|
||||
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
|
||||
'remaining_count' => $this->remainingCount,
|
||||
'type' => $this->type,
|
||||
'message' => "🧧 成功抢到 {$this->amount} {$typeLabel}礼包!",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户定向页面刷新广播事件
|
||||
*
|
||||
* 在任命或撤销职务成功后,向目标用户私有频道推送刷新指令,
|
||||
* 确保对方页面上的权限按钮与职务状态及时同步。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定用户广播页面刷新请求。
|
||||
*/
|
||||
class UserBrowserRefreshRequested implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数:记录目标用户与刷新说明。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $targetUserId,
|
||||
public readonly string $operator,
|
||||
public readonly string $reason = '',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:目标用户私有频道。
|
||||
*/
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel('user.'.$this->targetUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:供前端展示提示并执行刷新。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'operator' => $this->operator,
|
||||
'reason' => $this->reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 文件功能:用户被踢出房间广播事件
|
||||
*
|
||||
* 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。
|
||||
* 管理员踢出/封禁用户时触发,前端监听后强制该用户跳转至大厅。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
|
||||
@@ -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,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室用户状态变更广播事件
|
||||
* 负责在用户设置或清除当日状态后,实时同步当前房间在线名单展示。
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserStatusUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造聊天室用户状态变更广播事件。
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $username 状态变更用户昵称
|
||||
* @param array<string, mixed> $user 最新在线名单载荷
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $username,
|
||||
public readonly array $user,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取广播频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'username' => $this->username,
|
||||
'user' => $this->user,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台隐藏登录控制器
|
||||
*
|
||||
* 仅提供站长独立登录入口,登录成功后直接进入后台控制台,
|
||||
* 不经过聊天室首页与“登录即注册”流程。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AdminLoginRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:处理站长隐藏登录页展示与登录提交。
|
||||
*/
|
||||
class AdminAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* 隐藏登录入口后缀。
|
||||
*/
|
||||
private const LOGIN_SUFFIX = 'lkddi';
|
||||
|
||||
/**
|
||||
* 站长账号固定主键。
|
||||
*/
|
||||
private const SITE_OWNER_ID = 1;
|
||||
|
||||
/**
|
||||
* 显示站长隐藏登录页面。
|
||||
*/
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
// 已通过隐藏入口登录的站长再次访问时,直接回后台首页
|
||||
if (Auth::id() === self::SITE_OWNER_ID && $request->session()->get('admin_login_via_hidden')) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return view('admin.auth.login', [
|
||||
'loginSuffix' => self::LOGIN_SUFFIX,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理站长隐藏登录请求。
|
||||
*/
|
||||
public function store(AdminLoginRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$siteOwner = User::query()->find(self::SITE_OWNER_ID);
|
||||
|
||||
// 只有 id=1 的站长账号允许通过该入口进入后台
|
||||
if (! $siteOwner || $siteOwner->username !== $validated['username']) {
|
||||
return back()
|
||||
->withInput($request->safe()->only(['username']))
|
||||
->withErrors(['username' => '该入口仅限站长账号使用。']);
|
||||
}
|
||||
|
||||
if (! $this->passwordMatches($siteOwner, $validated['password'])) {
|
||||
return back()
|
||||
->withInput($request->safe()->only(['username']))
|
||||
->withErrors(['password' => '账号或密码错误。']);
|
||||
}
|
||||
|
||||
// 若当前已有其他账号占用会话,先退出后再切换为站长会话
|
||||
if (Auth::check() && Auth::id() !== $siteOwner->id) {
|
||||
Auth::logout();
|
||||
}
|
||||
|
||||
Auth::login($siteOwner);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->put('admin_login_via_hidden', true);
|
||||
|
||||
// 复用主登录的会话登记逻辑,保证后台入口也会更新登录痕迹
|
||||
$this->recordAdminLogin($siteOwner, (string) $request->ip());
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', '站长后台登录成功。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验站长密码,兼容旧库 MD5 并自动升级为 bcrypt。
|
||||
*/
|
||||
private function passwordMatches(User $siteOwner, string $plainPassword): bool
|
||||
{
|
||||
try {
|
||||
if (Hash::check($plainPassword, $siteOwner->password)) {
|
||||
return true;
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
// 旧库非 bcrypt 密码会在这里抛异常,后续继续走 MD5 兼容逻辑
|
||||
}
|
||||
|
||||
if (md5($plainPassword) !== $siteOwner->password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 兼容老密码登录成功后,立即升级为 Laravel 默认哈希
|
||||
$siteOwner->forceFill([
|
||||
'password' => Hash::make($plainPassword),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录站长通过隐藏入口登录后的访问痕迹。
|
||||
*/
|
||||
private function recordAdminLogin(User $siteOwner, string $ip): void
|
||||
{
|
||||
// 登录成功后补齐访问次数、IP 与时间,保持与前台登录统计一致
|
||||
$siteOwner->increment('visit_num');
|
||||
$siteOwner->update([
|
||||
'previous_ip' => $siteOwner->last_ip,
|
||||
'last_ip' => $ip,
|
||||
'log_time' => now(),
|
||||
'in_time' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\IpLog::create([
|
||||
'ip' => $ip,
|
||||
'sdate' => now(),
|
||||
'uuname' => $siteOwner->username,
|
||||
]);
|
||||
|
||||
try {
|
||||
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
|
||||
$wechatService->notifyAdminOnline($siteOwner);
|
||||
$wechatService->notifyFriendsOnline($siteOwner);
|
||||
$wechatService->notifySpouseOnline($siteOwner);
|
||||
} catch (\Exception $exception) {
|
||||
// 机器人通知异常不影响站长进入后台,但需要落日志便于排查
|
||||
Log::error('Hidden admin login notification failed', ['error' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\AiProviderConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,6 +34,7 @@ class AiProviderController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -283,19 +285,8 @@ class AiProviderController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => false,
|
||||
'position_icon' => '',
|
||||
'position_name' => '',
|
||||
];
|
||||
// 机器人在线载荷也统一走聊天室展示服务,避免名单字段口径逐步漂移。
|
||||
$userData = $this->chatUserPresenceService->build($user);
|
||||
|
||||
// 广播机器人进出事件(供前端名单增删)
|
||||
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动后台控制器
|
||||
*
|
||||
* 提供聊天室管理员在输入框上方快捷创建活动、
|
||||
* 查看当前活动并手动结束活动的 JSON 接口。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室顶部快捷入口创建与结束百家乐买单活动。
|
||||
*/
|
||||
class BaccaratLossCoverEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入百家乐买单活动服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新的百家乐买单活动。
|
||||
*/
|
||||
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
|
||||
{
|
||||
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '当前职务无权创建买单活动。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
|
||||
} catch (\RuntimeException $exception) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "活动「{$event->title}」已创建成功。",
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动结束或取消一场百家乐买单活动。
|
||||
*/
|
||||
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
|
||||
{
|
||||
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '当前职务无权结束买单活动。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '活动状态已更新。',
|
||||
'status' => $event->status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* 安全保证:
|
||||
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
|
||||
* - 普通用户无权访问此接口,无法伪造对他人的广播
|
||||
* - options 中的用户输入字段在后端经过 strip_tags 清洗
|
||||
* - options 中的用户输入字段在后端统一降级为纯文本 / 白名单样式值
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -23,6 +23,9 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 类功能:安全地下发大卡片广播消息。
|
||||
*/
|
||||
class BannerBroadcastController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -46,23 +49,39 @@ class BannerBroadcastController extends Controller
|
||||
'options.body' => ['nullable', 'string', 'max:500'],
|
||||
'options.sub' => ['nullable', 'string', 'max:200'],
|
||||
'options.gradient' => ['nullable', 'array', 'max:5'],
|
||||
'options.gradient.*' => ['nullable', 'string', 'max:30'],
|
||||
'options.titleColor' => ['nullable', 'string', 'max:30'],
|
||||
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
|
||||
'options.buttons' => ['nullable', 'array', 'max:4'],
|
||||
'options.buttons.*.label' => ['nullable', 'string', 'max:30'],
|
||||
'options.buttons.*.color' => ['nullable', 'string', 'max:30'],
|
||||
'options.buttons.*.action' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
|
||||
// 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。
|
||||
$opts = $validated['options'];
|
||||
foreach (['title', 'name', 'body', 'sub'] as $field) {
|
||||
if (isset($opts[$field])) {
|
||||
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
|
||||
$opts[$field] = $this->sanitizeBannerText($opts[$field]);
|
||||
}
|
||||
}
|
||||
// 按钮 label 不允许 HTML
|
||||
|
||||
if (isset($opts['titleColor'])) {
|
||||
$opts['titleColor'] = $this->sanitizeCssValue($opts['titleColor'], '#fde68a');
|
||||
}
|
||||
|
||||
if (! empty($opts['gradient'])) {
|
||||
$opts['gradient'] = array_values(array_map(
|
||||
fn ($color) => $this->sanitizeCssValue($color, '#4f46e5'),
|
||||
$opts['gradient']
|
||||
));
|
||||
}
|
||||
|
||||
// 按钮 label 与颜色都只允许安全文本 / 颜色值。
|
||||
if (! empty($opts['buttons'])) {
|
||||
$opts['buttons'] = array_map(function ($btn) {
|
||||
$btn['label'] = strip_tags($btn['label'] ?? '');
|
||||
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
|
||||
$btn['label'] = $this->sanitizeBannerText($btn['label'] ?? '');
|
||||
$btn['color'] = $this->sanitizeCssValue($btn['color'] ?? '#10b981', '#10b981');
|
||||
// action 只允许预定义值,防止注入任意 JS
|
||||
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
|
||||
? $btn['action'] : 'close';
|
||||
@@ -79,4 +98,38 @@ class BannerBroadcastController extends Controller
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '广播已发送']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Banner 文案净化为安全纯文本。
|
||||
*/
|
||||
private function sanitizeBannerText(?string $text): string
|
||||
{
|
||||
return trim(strip_tags((string) $text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗颜色 / 渐变等 CSS 值,阻断样式属性注入。
|
||||
*/
|
||||
private function sanitizeCssValue(?string $value, string $default): string
|
||||
{
|
||||
$sanitized = strtolower(trim((string) $value));
|
||||
if ($sanitized === '' || preg_match('/(?:javascript|expression|url\s*\(|data:|var\s*\()/i', $sanitized)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$allowedPatterns = [
|
||||
'/^#[0-9a-f]{3,8}$/i',
|
||||
'/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
|
||||
'/^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
|
||||
'/^(?:white|black|red|blue|green|gray|grey|yellow|orange|pink|purple|teal|cyan|indigo|amber|emerald|transparent|currentcolor)$/i',
|
||||
];
|
||||
|
||||
foreach ($allowedPatterns as $allowedPattern) {
|
||||
if (preg_match($allowedPattern, $sanitized)) {
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,18 +174,29 @@ class ChangelogController extends Controller
|
||||
*/
|
||||
private function saveChangelogNotification(DevChangelog $log): void
|
||||
{
|
||||
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
|
||||
$url = url('/changelog').'#v'.$log->version;
|
||||
// 广播文案允许保留安全链接,但标题与版本号必须先做 HTML 转义,避免系统消息被拼成恶意标签。
|
||||
$safeTypeLabel = e(DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新');
|
||||
$safeVersion = e((string) $log->version);
|
||||
$safeTitle = e((string) $log->title);
|
||||
$detailUrl = e($this->buildChangelogDetailUrl($log));
|
||||
|
||||
SaveMessageJob::dispatch([
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}《{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
|
||||
'content' => "📢 【版本更新 {$safeTypeLabel}】v{$safeVersion}《{$safeTitle}》— <a href=\"{$detailUrl}\" target=\"_blank\" rel=\"noopener\" class=\"underline\">点击查看详情</a>",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#7c3aed',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成开发日志详情链接,并对版本片段做 URL 编码,避免广播 href 被注入额外属性。
|
||||
*/
|
||||
private function buildChangelogDetailUrl(DevChangelog $log): string
|
||||
{
|
||||
return route('changelog.index').'#v'.rawurlencode((string) $log->version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ use App\Models\UserCurrencyLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:提供后台全局金币/积分流水查询与多条件筛选。
|
||||
*/
|
||||
class CurrencyLogController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -26,6 +29,12 @@ class CurrencyLogController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = UserCurrencyLog::query()->with('user');
|
||||
$allSources = CurrencySource::cases();
|
||||
$allowedSources = collect($allSources)->map(fn (CurrencySource $source) => $source->value)->all();
|
||||
$selectedSources = collect($request->array('sources'))
|
||||
->filter(fn (string $source) => in_array($source, $allowedSources, true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// 查询条件过滤
|
||||
if ($request->filled('username')) {
|
||||
@@ -36,8 +45,8 @@ class CurrencyLogController extends Controller
|
||||
$query->where('currency', $request->input('currency'));
|
||||
}
|
||||
|
||||
if ($request->filled('source')) {
|
||||
$query->where('source', $request->input('source'));
|
||||
if ($selectedSources !== []) {
|
||||
$query->whereIn('source', $selectedSources);
|
||||
}
|
||||
|
||||
if ($request->filled('remark')) {
|
||||
@@ -63,8 +72,6 @@ class CurrencyLogController extends Controller
|
||||
// 默认按时间倒序
|
||||
$logs = $query->latest('id')->paginate(50)->withQueryString();
|
||||
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
return view('admin.currency-logs.index', compact('logs', 'allSources'));
|
||||
return view('admin.currency-logs.index', compact('logs', 'allSources', 'selectedSources'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:展示后台积分流水统计与指定日期净流通数据。
|
||||
*/
|
||||
class CurrencyStatsController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -45,20 +47,7 @@ class CurrencyStatsController extends Controller
|
||||
);
|
||||
|
||||
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
|
||||
$netFlow = [];
|
||||
foreach (['exp', 'gold', 'charm'] as $currency) {
|
||||
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '>', 0)
|
||||
->sum('amount');
|
||||
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '<', 0)
|
||||
->sum('amount');
|
||||
$netFlow[$currency] = [
|
||||
'in' => $totalIn,
|
||||
'out' => abs($totalOut),
|
||||
'net' => $totalIn + $totalOut, // 净增量
|
||||
];
|
||||
}
|
||||
$netFlow = $this->currencyService->netFlowStats($date);
|
||||
|
||||
// 所有已知来源(供视图展示缺失来源的空行)
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
@@ -13,18 +13,37 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责后台首页仪表盘的汇总统计展示。
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入聊天室状态服务,供仪表盘读取实时在线数据。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示后台首页与全局统计
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$onlineUsernames = collect();
|
||||
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
// 使用在线名单服务的懒清理结果,保证统计口径与聊天室在线列表一致。
|
||||
$onlineUsernames = $onlineUsernames->merge(array_keys($this->chatState->getRoomUsers($roomId)));
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'total_rooms' => Room::count(),
|
||||
'online_users' => $onlineUsernames->unique()->count(),
|
||||
// 更多统计指标以后再发掘
|
||||
];
|
||||
|
||||
|
||||
@@ -14,14 +14,20 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateGameConfigParamsRequest;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\LotteryIssue;
|
||||
use App\Models\Room;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\LotteryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
|
||||
*/
|
||||
class GameConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
|
||||
public function index(): View
|
||||
{
|
||||
$games = GameConfig::orderBy('id')->get();
|
||||
$availableRooms = Room::query()->orderBy('id')->get();
|
||||
|
||||
return view('admin.game-configs.index', compact('games'));
|
||||
return view('admin.game-configs.index', compact('games', 'availableRooms'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,15 +63,36 @@ class GameConfigController extends Controller
|
||||
*
|
||||
* 接收前端提交的 params JSON 对象并合并至现有配置。
|
||||
*/
|
||||
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
|
||||
public function updateParams(UpdateGameConfigParamsRequest $request, GameConfig $gameConfig, GameRoomScopeService $roomScopeService): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'params' => 'required|array',
|
||||
]);
|
||||
|
||||
// 合并参数,保留已有键,只更新传入的键
|
||||
$current = $gameConfig->params ?? [];
|
||||
$updated = array_merge($current, $request->input('params'));
|
||||
// 这里不能只读取 validated('params')。
|
||||
// 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数
|
||||
// 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。
|
||||
$validatedParams = (array) $request->input('params', []);
|
||||
$updated = array_merge($current, $validatedParams);
|
||||
|
||||
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
|
||||
$updated['room_scope_mode'] = $scopeConfig['room_scope_mode'];
|
||||
$updated['room_ids'] = $scopeConfig['room_ids'];
|
||||
|
||||
if ($gameConfig->game_key === 'mystery_box') {
|
||||
$legacyMap = [
|
||||
'min_reward' => 'normal_reward_min',
|
||||
'max_reward' => 'normal_reward_max',
|
||||
'rare_min_reward' => 'rare_reward_min',
|
||||
'rare_max_reward' => 'rare_reward_max',
|
||||
];
|
||||
|
||||
foreach ($legacyMap as $legacyKey => $newKey) {
|
||||
if (! array_key_exists($newKey, $updated) && array_key_exists($legacyKey, $updated)) {
|
||||
$updated[$newKey] = $updated[$legacyKey];
|
||||
}
|
||||
|
||||
unset($updated[$legacyKey]);
|
||||
}
|
||||
}
|
||||
|
||||
$gameConfig->update(['params' => $updated]);
|
||||
$gameConfig->clearCache();
|
||||
@@ -90,17 +118,19 @@ class GameConfigController extends Controller
|
||||
}
|
||||
|
||||
// 检查是否有正在开放的箱子(避免同时多个)
|
||||
if (\App\Models\MysteryBox::currentOpenBox()) {
|
||||
$targetRoomId = app(GameRoomScopeService::class)->getPrimaryRoomIdForGame('mystery_box');
|
||||
|
||||
if (\App\Models\MysteryBox::currentOpenBox($targetRoomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
|
||||
}
|
||||
|
||||
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
|
||||
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
|
||||
|
||||
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
|
||||
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,19 +139,31 @@ class GameConfigController extends Controller
|
||||
*
|
||||
* 仅在当前无进行中期次时生效,防止重复开期。
|
||||
*/
|
||||
public function openLotteryIssue(): JsonResponse
|
||||
public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('lottery')) {
|
||||
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
|
||||
}
|
||||
|
||||
if (LotteryIssue::currentIssue()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
|
||||
$openedRoomIds = [];
|
||||
|
||||
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
|
||||
if (LotteryIssue::currentIssue($roomId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
|
||||
$openedRoomIds[] = $roomId;
|
||||
}
|
||||
|
||||
\App\Jobs\OpenLotteryIssueJob::dispatch();
|
||||
if ($openedRoomIds === []) {
|
||||
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,15 +14,27 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreHolidayEventRequest;
|
||||
use App\Http\Requests\UpdateHolidayEventRequest;
|
||||
use App\Jobs\TriggerHolidayEventJob;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Services\HolidayEventScheduleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:管理节日福利模板的后台增删改查与手动触发操作。
|
||||
*/
|
||||
class HolidayEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入节日福利调度计算服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly HolidayEventScheduleService $scheduleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 节日福利活动列表页。
|
||||
*/
|
||||
@@ -46,30 +58,9 @@ class HolidayEventController extends Controller
|
||||
/**
|
||||
* 保存新活动。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreHolidayEventRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['status'] = 'pending';
|
||||
$data['enabled'] = $request->boolean('enabled', true);
|
||||
|
||||
HolidayEvent::create($data);
|
||||
HolidayEvent::create($this->buildPayload($request->validated(), true));
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
|
||||
}
|
||||
@@ -85,26 +76,9 @@ class HolidayEventController extends Controller
|
||||
/**
|
||||
* 更新活动。
|
||||
*/
|
||||
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||
public function update(UpdateHolidayEventRequest $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$holidayEvent->update($data);
|
||||
$holidayEvent->update($this->buildPayload($request->validated()));
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
|
||||
}
|
||||
@@ -128,13 +102,12 @@ class HolidayEventController extends Controller
|
||||
*/
|
||||
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
if ($holidayEvent->status !== 'pending') {
|
||||
return back()->with('error', '只有待触发状态的活动才能手动触发。');
|
||||
if (! $holidayEvent->enabled || $holidayEvent->status === 'cancelled') {
|
||||
return back()->with('error', '当前活动未启用或已取消,不能立即触发。');
|
||||
}
|
||||
|
||||
// 设置触发时间为当前,立即入队
|
||||
$holidayEvent->update(['send_at' => now()]);
|
||||
TriggerHolidayEventJob::dispatch($holidayEvent);
|
||||
// 立即触发只生成临时批次,不覆盖年度锚点或下次计划时间。
|
||||
TriggerHolidayEventJob::dispatch($holidayEvent, true);
|
||||
|
||||
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
|
||||
}
|
||||
@@ -148,4 +121,54 @@ class HolidayEventController extends Controller
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装节日福利模板的可持久化字段。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(array $data, bool $isCreating = false): array
|
||||
{
|
||||
$payload = $data;
|
||||
|
||||
// 创建与编辑都统一回收无效字段,避免模板状态互相污染。
|
||||
if (($payload['distribute_type'] ?? 'random') === 'random') {
|
||||
$payload['fixed_amount'] = null;
|
||||
} else {
|
||||
$payload['min_amount'] = 1;
|
||||
$payload['max_amount'] = null;
|
||||
}
|
||||
|
||||
if (($payload['target_type'] ?? 'all') !== 'level') {
|
||||
$payload['target_value'] = null;
|
||||
}
|
||||
|
||||
if (($payload['repeat_type'] ?? 'once') !== 'cron') {
|
||||
$payload['cron_expr'] = null;
|
||||
}
|
||||
|
||||
if (($payload['repeat_type'] ?? 'once') === 'yearly') {
|
||||
$payload['send_at'] = $this->scheduleService
|
||||
->resolveNextConfiguredSendAt($payload)
|
||||
->toDateTimeString();
|
||||
} else {
|
||||
$payload['schedule_month'] = null;
|
||||
$payload['schedule_day'] = null;
|
||||
$payload['schedule_time'] = null;
|
||||
$payload['duration_days'] = 1;
|
||||
$payload['daily_occurrences'] = 1;
|
||||
$payload['occurrence_interval_minutes'] = null;
|
||||
}
|
||||
|
||||
// 每次保存模板时,都让系统按新配置重新进入待触发状态。
|
||||
$payload['status'] = 'pending';
|
||||
$payload['enabled'] = (bool) ($payload['enabled'] ?? true);
|
||||
$payload['triggered_at'] = null;
|
||||
$payload['expires_at'] = null;
|
||||
$payload['claimed_count'] = 0;
|
||||
$payload['claimed_amount'] = 0;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台等级经验阈值配置控制器
|
||||
*
|
||||
* 将 sysparam 表中的 levelexp 配置拆分为独立后台页面,
|
||||
* 以列表模式维护每一级所需的累计经验值。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateLevelExpConfigRequest;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责展示和保存等级经验阈值列表。
|
||||
*/
|
||||
class LevelExpConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入系统参数缓存同步服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:显示等级经验阈值列表页。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rawThresholds = Sysparam::getLevelExpThresholds();
|
||||
$maxLevel = (int) Sysparam::getValue('maxlevel', '99');
|
||||
|
||||
$thresholds = collect($rawThresholds)
|
||||
->values()
|
||||
->map(fn (int $exp, int $index): array => [
|
||||
'level' => $index + 1,
|
||||
'exp' => $exp,
|
||||
'increment' => $index === 0 ? $exp : $exp - $rawThresholds[$index - 1],
|
||||
]);
|
||||
|
||||
return view('admin.level-exp-configs.index', [
|
||||
'thresholds' => $thresholds,
|
||||
'maxLevel' => $maxLevel,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保存等级经验阈值配置,并同步刷新缓存。
|
||||
*/
|
||||
public function update(UpdateLevelExpConfigRequest $request): RedirectResponse
|
||||
{
|
||||
$thresholds = $request->validated('thresholds');
|
||||
|
||||
// 将列表页提交的阈值重新拼成兼容旧逻辑的逗号字符串。
|
||||
$body = implode(',', $thresholds);
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'levelexp'],
|
||||
[
|
||||
'body' => $body,
|
||||
'guidetxt' => '按列表逐级维护升级所需的累计经验阈值',
|
||||
]
|
||||
);
|
||||
|
||||
// 同步更新 Redis / Cache,确保前台经验等级计算即时生效。
|
||||
$this->chatState->setSysParam('levelexp', $body);
|
||||
Sysparam::clearCache('levelexp');
|
||||
|
||||
return redirect()->route('admin.level-exp-configs.index')->with('success', '等级经验阈值已保存并生效!');
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,15 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\Sysparam;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责后台职务资料、任命白名单与聊天室权限配置的维护。
|
||||
*/
|
||||
class PositionController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -29,16 +34,25 @@ class PositionController extends Controller
|
||||
{
|
||||
// 按部门分组展示
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
|
||||
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->with('appointablePositions')->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
// 全部职务(供任命白名单多选框使用)
|
||||
$allPositions = Position::with('department')->orderByDesc('rank')->get();
|
||||
$allPositions = Position::with('department')->ordered()->get();
|
||||
|
||||
// 全局奖励接收次数上限(0 = 不限)
|
||||
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
|
||||
|
||||
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
|
||||
$positionPermissions = PositionPermissionRegistry::groupedDefinitions();
|
||||
$permissionLabels = PositionPermissionRegistry::labelMap();
|
||||
|
||||
return view('admin.positions.index', compact(
|
||||
'departments',
|
||||
'allPositions',
|
||||
'globalRecipientDailyMax',
|
||||
'positionPermissions',
|
||||
'permissionLabels',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,13 +70,20 @@ class PositionController extends Controller
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
|
||||
'red_packet_count' => 'nullable|integer|min:1|max:100',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
|
||||
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
|
||||
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
|
||||
|
||||
$position = Position::create($data);
|
||||
|
||||
@@ -144,13 +165,20 @@ class PositionController extends Controller
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
|
||||
'red_packet_count' => 'nullable|integer|min:1|max:100',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
|
||||
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
|
||||
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
|
||||
|
||||
$position->update($data);
|
||||
$position->appointablePositions()->sync($appointableIds);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜谜活动题库后台管理控制器
|
||||
*
|
||||
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Riddle;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:统一处理猜谜活动题库的后台管理动作。
|
||||
*/
|
||||
class RiddleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$typeOptions = Riddle::typeOptions();
|
||||
$selectedType = trim((string) $request->query('type', ''));
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
|
||||
$idiomQuery = Riddle::query();
|
||||
|
||||
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
|
||||
// 题型筛选只接受系统支持值,避免非法参数污染查询。
|
||||
$idiomQuery->ofType($selectedType);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时匹配答案与提示,方便后台快速定位题目。
|
||||
$idiomQuery->where(function ($query) use ($keyword): void {
|
||||
$query->where('answer', 'like', '%'.$keyword.'%')
|
||||
->orWhere('hint', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
$idioms = $idiomQuery
|
||||
->orderBy('type')
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$typeStats = Riddle::query()
|
||||
->selectRaw('type, COUNT(*) as total')
|
||||
->groupBy('type')
|
||||
->pluck('total', 'type')
|
||||
->all();
|
||||
|
||||
return view('admin.riddles.index', [
|
||||
'idioms' => $idioms,
|
||||
'typeOptions' => $typeOptions,
|
||||
'selectedType' => $selectedType,
|
||||
'keyword' => $keyword,
|
||||
'typeStats' => $typeStats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建新的猜谜活动题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 新增时默认启用,便于后台批量补题后立即可用。
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
Riddle::create($data);
|
||||
|
||||
$typeLabel = Riddle::labelForType($data['type']);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "{$typeLabel}题目已添加!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新已有题目内容与题型。
|
||||
*/
|
||||
public function update(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$data = $this->validateRiddlePayload($request);
|
||||
|
||||
// 编辑时显式按复选框结果落库,避免旧状态残留。
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$idiom->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$idiom->answer}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Riddle $idiom): JsonResponse
|
||||
{
|
||||
// 开关按钮只变更启用状态,不改动其他题库字段。
|
||||
$idiom->update(['is_active' => ! $idiom->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $idiom->is_active,
|
||||
'message' => $idiom->is_active ? "「{$idiom->answer}」已启用" : "「{$idiom->answer}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除指定题目。
|
||||
*/
|
||||
public function destroy(Request $request, Riddle $idiom): RedirectResponse
|
||||
{
|
||||
$answer = $idiom->answer;
|
||||
$idiom->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.riddles.index', $this->buildIndexFilters($request))
|
||||
->with('success', "题目「{$answer}」已删除!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:校验后台题库保存载荷。
|
||||
*
|
||||
* @return array{type:string,answer:string,hint:string,sort:int}
|
||||
*/
|
||||
private function validateRiddlePayload(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
|
||||
'answer' => ['required', 'string', 'max:120'],
|
||||
'hint' => ['required', 'string', 'max:255'],
|
||||
'sort' => ['required', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildIndexFilters(Request $request): array
|
||||
{
|
||||
$filters = [];
|
||||
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
|
||||
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
|
||||
|
||||
if ($type !== '') {
|
||||
$filters['type'] = $type;
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$filters['keyword'] = $keyword;
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台座驾独立管理控制器。
|
||||
*
|
||||
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreRideRequest;
|
||||
use App\Http\Requests\UpdateRideRequest;
|
||||
use App\Models\Ride;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 后台座驾管理控制器
|
||||
* 负责独立 rides 表的后台管理流程。
|
||||
*/
|
||||
class RideController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示座驾管理列表页。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rides = Ride::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return view('admin.rides.index', compact('rides'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增座驾(仅 id=1 超级站长)。
|
||||
*/
|
||||
public function store(StoreRideRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
Ride::create($data);
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新座驾信息。
|
||||
*/
|
||||
public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse
|
||||
{
|
||||
$ride->update($request->validated());
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换座驾上下架状态。
|
||||
*/
|
||||
public function toggle(Ride $ride): RedirectResponse
|
||||
{
|
||||
$ride->update(['is_active' => ! $ride->is_active]);
|
||||
$status = $ride->is_active ? '上架' : '下架';
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', "「{$ride->name}」已{$status}。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除座驾(仅 id=1 超级站长)。
|
||||
*/
|
||||
public function destroy(Ride $ride): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$name = $ride->name;
|
||||
$ride->delete();
|
||||
|
||||
return redirect()->route('admin.rides.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreShopItemRequest;
|
||||
use App\Http\Requests\UpdateShopItemRequest;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
|
||||
/**
|
||||
* 新增商品(仅 id=1 超级站长)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreShopItemRequest $request): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$data = $this->validateItem($request);
|
||||
$data = $request->validated();
|
||||
ShopItem::create($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, ShopItem $shopItem): RedirectResponse
|
||||
public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
$data = $this->validateItem($request, $shopItem);
|
||||
$data = $request->validated();
|
||||
$shopItem->update($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
||||
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一验证商品表单(新增/编辑共用)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateItem(Request $request, ?ShopItem $item = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'slug' => ['required', 'string', 'max:100',
|
||||
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
|
||||
],
|
||||
'icon' => 'required|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'price' => 'required|integer|min:0',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台签到奖励规则管理控制器
|
||||
*
|
||||
* 提供连续签到奖励档位的列表、新增、编辑、启停和删除功能。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SaveSignInRewardRuleRequest;
|
||||
use App\Models\SignInRewardRule;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:管理后台每日签到奖励规则。
|
||||
*/
|
||||
class SignInRewardRuleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:展示签到奖励规则列表。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rules = SignInRewardRule::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('streak_days')
|
||||
->get();
|
||||
|
||||
return view('admin.sign-in-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:新增签到奖励规则。
|
||||
*/
|
||||
public function store(SaveSignInRewardRuleRequest $request): RedirectResponse
|
||||
{
|
||||
SignInRewardRule::query()->create($this->payload($request));
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新签到奖励规则。
|
||||
*/
|
||||
public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse
|
||||
{
|
||||
$signInRewardRule->update($this->payload($request));
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:切换签到奖励规则启用状态。
|
||||
*/
|
||||
public function toggle(SignInRewardRule $signInRewardRule): JsonResponse
|
||||
{
|
||||
$signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_enabled' => $signInRewardRule->is_enabled,
|
||||
'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除签到奖励规则。
|
||||
*/
|
||||
public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse
|
||||
{
|
||||
$signInRewardRule->delete();
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:整理后台表单提交的数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payload(SaveSignInRewardRuleRequest $request): array
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_enabled'] = $request->boolean('is_enabled');
|
||||
|
||||
foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) {
|
||||
$data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -17,28 +17,37 @@ use App\Models\SysParam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:后台通用系统参数配置控制器
|
||||
* 仅允许维护低敏公共参数,站长专属敏感配置需走各自独立页面。
|
||||
*/
|
||||
class SystemController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数注入聊天室状态服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示全局参数配置表单
|
||||
* 显示通用系统参数配置表单
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
// 读取数据库中最新的参数 (剔除专属模块已接管的配置,避免重复显示)
|
||||
$params = SysParam::whereNotIn('alias', ['chatbot_enabled'])
|
||||
->where('alias', 'not like', 'smtp_%')
|
||||
->get()->pluck('body', 'alias')->toArray();
|
||||
$editableAliases = $this->editableSystemAliases();
|
||||
|
||||
// 为后台界面准备的文案对照 (可动态化或硬编码)
|
||||
$descriptions = SysParam::whereNotIn('alias', ['chatbot_enabled'])
|
||||
->where('alias', 'not like', 'smtp_%')
|
||||
->get()->pluck('guidetxt', 'alias')->toArray();
|
||||
// 通用系统页仅加载白名单字段,避免站长专属配置被普通高管查看。
|
||||
$systemParams = SysParam::query()
|
||||
->whereIn('alias', $editableAliases)
|
||||
->orderBy('id')
|
||||
->get(['alias', 'body', 'guidetxt']);
|
||||
|
||||
$params = $systemParams->pluck('body', 'alias')->all();
|
||||
$descriptions = $systemParams->pluck('guidetxt', 'alias')->all();
|
||||
|
||||
return view('admin.system.edit', compact('params', 'descriptions'));
|
||||
}
|
||||
@@ -48,16 +57,27 @@ class SystemController extends Controller
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->except(['_token', '_method']);
|
||||
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
|
||||
$data = $request->only($this->editableSystemAliases());
|
||||
|
||||
if (array_key_exists('maxlevel', $data)) {
|
||||
$normalizedMaxLevel = max(1, (int) $data['maxlevel']);
|
||||
|
||||
// 管理员级别始终跟随最高等级 + 1,避免两个配置页出现口径漂移。
|
||||
$data['maxlevel'] = (string) $normalizedMaxLevel;
|
||||
$data['superlevel'] = (string) ($normalizedMaxLevel + 1);
|
||||
}
|
||||
|
||||
foreach ($data as $alias => $body) {
|
||||
$normalizedBody = (string) $body;
|
||||
|
||||
SysParam::updateOrCreate(
|
||||
['alias' => $alias],
|
||||
['body' => $body]
|
||||
['body' => $normalizedBody]
|
||||
);
|
||||
|
||||
// 写入 Cache 保证极速读取
|
||||
$this->chatState->setSysParam($alias, $body);
|
||||
// 仅对白名单字段同步缓存,杜绝越权请求覆盖站长专属配置。
|
||||
$this->chatState->setSysParam($alias, $normalizedBody);
|
||||
|
||||
// 同时清除 Sysparam 模型的内部缓存
|
||||
SysParam::clearCache($alias);
|
||||
@@ -65,4 +85,48 @@ class SystemController extends Controller
|
||||
|
||||
return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通用系统页允许维护的参数别名白名单
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function editableSystemAliases(): array
|
||||
{
|
||||
return SysParam::query()
|
||||
->orderBy('id')
|
||||
->pluck('alias')
|
||||
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否属于站长专属敏感配置
|
||||
*/
|
||||
private function isSensitiveAlias(string $alias): bool
|
||||
{
|
||||
if (Str::startsWith($alias, ['smtp_', 'vip_payment_', 'wechat_bot_', 'chatbot_'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否已经迁移到独立配置页。
|
||||
*/
|
||||
private function isDedicatedAlias(string $alias): bool
|
||||
{
|
||||
return in_array($alias, [
|
||||
'levelexp',
|
||||
'level_warn',
|
||||
'level_mute',
|
||||
'level_kick',
|
||||
'level_announcement',
|
||||
'level_ban',
|
||||
'level_banip',
|
||||
'level_freeze',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Events\UserBrowserRefreshRequested;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateManagedUserRequest;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPosition;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -23,6 +30,9 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责后台用户列表展示、资料编辑与删除操作。
|
||||
*/
|
||||
class UserManagerController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -31,6 +41,7 @@ class UserManagerController extends Controller
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly AppointmentService $appointmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,7 +63,7 @@ class UserManagerController extends Controller
|
||||
$onlineUsernames = $onlineUsernames->unique()->values();
|
||||
|
||||
// 排序:允许的字段白名单,防止 SQL 注入
|
||||
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online'];
|
||||
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online', 'wxid'];
|
||||
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
|
||||
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
@@ -77,8 +88,12 @@ class UserManagerController extends Controller
|
||||
|
||||
// VIP 等级选项列表(供编辑弹窗使用)
|
||||
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
|
||||
// 职务下拉选项(复用任命系统中的部门与职务数据)
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($positionQuery) => $positionQuery->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
|
||||
return view('admin.users.index', compact('users', 'vipLevels', 'departments', 'sortBy', 'sortDir', 'onlineUsernames'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,10 +101,11 @@ class UserManagerController extends Controller
|
||||
*
|
||||
* @param User $user 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, User $user): JsonResponse|RedirectResponse
|
||||
public function update(UpdateManagedUserRequest $request, User $user): JsonResponse|RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
$responseMessages = [];
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
|
||||
if ($currentUser->id !== 1) {
|
||||
@@ -104,17 +120,7 @@ class UserManagerController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'sex' => 'sometimes|integer|in:0,1,2',
|
||||
'exp_num' => 'sometimes|integer|min:0',
|
||||
'jjb' => 'sometimes|integer|min:0',
|
||||
'meili' => 'sometimes|integer|min:0',
|
||||
'qianming' => 'sometimes|nullable|string|max:255',
|
||||
'headface' => 'sometimes|string|max:50',
|
||||
'password' => 'nullable|string|min:6',
|
||||
'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id',
|
||||
'hy_time' => 'sometimes|nullable|date',
|
||||
]);
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['sex'])) {
|
||||
$targetUser->sex = $validated['sex'];
|
||||
@@ -188,11 +194,31 @@ class UserManagerController extends Controller
|
||||
|
||||
$targetUser->save();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['status' => 'success', 'message' => '用户资料已强行更新完毕!']);
|
||||
if (array_key_exists('position_id', $validated)) {
|
||||
$positionSyncResult = $this->syncUserPosition(
|
||||
operator: $currentUser,
|
||||
targetUser: $targetUser,
|
||||
targetPositionId: $validated['position_id'],
|
||||
);
|
||||
|
||||
if (! $positionSyncResult['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $positionSyncResult['message']], 422);
|
||||
}
|
||||
|
||||
if (! empty($positionSyncResult['message'])) {
|
||||
$responseMessages[] = $positionSyncResult['message'];
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', '用户资料已更新!');
|
||||
if ($request->wantsJson()) {
|
||||
$message = array_merge(['用户资料已强行更新完毕!'], $responseMessages);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => implode(' ', $message)]);
|
||||
}
|
||||
|
||||
$message = array_merge(['用户资料已更新!'], $responseMessages);
|
||||
|
||||
return back()->with('success', implode(' ', $message));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,14 +241,111 @@ class UserManagerController extends Controller
|
||||
abort(403, '权限不足:无法删除同级或高级账号!');
|
||||
}
|
||||
|
||||
// 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀
|
||||
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10');
|
||||
if ($targetUser->user_level >= $levelKick) {
|
||||
abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。');
|
||||
// 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。
|
||||
$targetUser->loadMissing('activePosition.position');
|
||||
if ($targetUser->id === 1 || $targetUser->activePosition?->position) {
|
||||
abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。');
|
||||
}
|
||||
|
||||
$targetUser->delete();
|
||||
|
||||
return back()->with('success', '目标已被物理删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:同步后台编辑页选择的目标职务。
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
private function syncUserPosition(User $operator, User $targetUser, ?int $targetPositionId): array
|
||||
{
|
||||
$currentAssignment = $this->appointmentService->getActivePosition($targetUser);
|
||||
$currentPositionId = $currentAssignment?->position_id;
|
||||
|
||||
if ($targetPositionId === $currentPositionId) {
|
||||
return ['ok' => true, 'message' => ''];
|
||||
}
|
||||
|
||||
if ($targetPositionId === null) {
|
||||
if (! $currentAssignment) {
|
||||
return ['ok' => true, 'message' => ''];
|
||||
}
|
||||
|
||||
$result = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
|
||||
if (! $result['ok']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->broadcastRevokedPosition($operator, $targetUser, $currentAssignment);
|
||||
|
||||
return ['ok' => true, 'message' => '用户职务已撤销。'];
|
||||
}
|
||||
|
||||
$targetPosition = Position::with('department')->findOrFail($targetPositionId);
|
||||
|
||||
if ($currentAssignment) {
|
||||
$revokeResult = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
|
||||
if (! $revokeResult['ok']) {
|
||||
return $revokeResult;
|
||||
}
|
||||
}
|
||||
|
||||
$appointResult = $this->appointmentService->appoint($operator, $targetUser, $targetPosition, '后台用户管理编辑');
|
||||
if (! $appointResult['ok']) {
|
||||
return $appointResult;
|
||||
}
|
||||
|
||||
$this->broadcastAppointedPosition($operator, $targetUser, $targetPosition);
|
||||
|
||||
return ['ok' => true, 'message' => "用户职务已更新为【{$targetPosition->name}】。"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播后台任命成功后的公告与目标用户刷新事件。
|
||||
*/
|
||||
private function broadcastAppointedPosition(User $operator, User $targetUser, Position $targetPosition): void
|
||||
{
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUser->username,
|
||||
positionIcon: $targetPosition->icon ?? '🎖️',
|
||||
positionName: $targetPosition->name,
|
||||
departmentName: $targetPosition->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
));
|
||||
}
|
||||
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: $targetUser->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已发生变更,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播后台撤销职务后的公告与目标用户刷新事件。
|
||||
*/
|
||||
private function broadcastRevokedPosition(User $operator, User $targetUser, UserPosition $currentAssignment): void
|
||||
{
|
||||
$currentAssignment->loadMissing('position.department');
|
||||
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUser->username,
|
||||
positionIcon: $currentAssignment->position?->icon ?? '🎖️',
|
||||
positionName: $currentAssignment->position?->name ?? '',
|
||||
departmentName: $currentAssignment->position?->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
type: 'revoke',
|
||||
));
|
||||
}
|
||||
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: $targetUser->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已被撤销,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,21 +13,124 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\VipLevel;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 后台 VIP 会员等级管理控制器
|
||||
* 负责会员等级维护,以及查看各等级下的会员名单。
|
||||
*/
|
||||
class VipController extends Controller
|
||||
{
|
||||
/**
|
||||
* 会员主题支持的特效下拉选项。
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const EFFECT_LABELS = [
|
||||
'none' => '无特效',
|
||||
'fireworks' => '烟花',
|
||||
'rain' => '下雨',
|
||||
'lightning' => '闪电',
|
||||
'snow' => '下雪',
|
||||
'sakura' => '樱花飘落',
|
||||
'meteors' => '流星',
|
||||
'gold-rain' => '金币雨',
|
||||
'hearts' => '爱心飘落',
|
||||
'confetti' => '彩带庆典',
|
||||
'fireflies' => '萤火虫',
|
||||
];
|
||||
|
||||
/**
|
||||
* 会员主题支持的横幅风格下拉选项。
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const BANNER_STYLE_LABELS = [
|
||||
'aurora' => '鎏光星幕',
|
||||
'storm' => '雷霆风暴',
|
||||
'royal' => '王者金辉',
|
||||
'cosmic' => '星穹幻彩',
|
||||
'farewell' => '告别暮光',
|
||||
];
|
||||
|
||||
/**
|
||||
* 会员等级管理列表页
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$levels = VipLevel::orderBy('sort_order')->get();
|
||||
$levels = VipLevel::query()
|
||||
->withCount('users')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return view('admin.vip.index', compact('levels'));
|
||||
return view('admin.vip.index', [
|
||||
'levels' => $levels,
|
||||
'effectOptions' => self::EFFECT_LABELS,
|
||||
'bannerStyleOptions' => self::BANNER_STYLE_LABELS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看某个会员等级下的会员名单。
|
||||
*
|
||||
* @param Request $request 当前筛选请求
|
||||
* @param VipLevel $vip 当前会员等级
|
||||
*/
|
||||
public function members(Request $request, VipLevel $vip): View
|
||||
{
|
||||
$query = User::query()->where('vip_level_id', $vip->id);
|
||||
$now = now();
|
||||
|
||||
if ($request->filled('keyword')) {
|
||||
$keyword = trim((string) $request->input('keyword'));
|
||||
|
||||
// 支持后台按用户名快速筛选某个等级下的会员。
|
||||
$query->where('username', 'like', '%'.$keyword.'%');
|
||||
}
|
||||
|
||||
if ($request->input('status') === 'active') {
|
||||
// 当前有效会员:永久会员或到期时间仍在未来。
|
||||
$query->where(function ($builder) use ($now): void {
|
||||
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->input('status') === 'expired') {
|
||||
// 已过期会员:到期时间存在且已经早于当前时间。
|
||||
$query->whereNotNull('hy_time')->where('hy_time', '<=', $now);
|
||||
}
|
||||
|
||||
$members = $query
|
||||
->select(['id', 'username', 'sex', 'vip_level_id', 'hy_time', 'created_at'])
|
||||
->orderByRaw('CASE WHEN hy_time IS NULL THEN 0 WHEN hy_time > ? THEN 1 ELSE 2 END', [$now])
|
||||
->orderByRaw('hy_time IS NULL DESC')
|
||||
->orderByDesc('hy_time')
|
||||
->orderBy('username')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
$totalAssignedCount = User::query()
|
||||
->where('vip_level_id', $vip->id)
|
||||
->count();
|
||||
|
||||
$activeCount = User::query()
|
||||
->where('vip_level_id', $vip->id)
|
||||
->where(function ($builder) use ($now): void {
|
||||
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
|
||||
})
|
||||
->count();
|
||||
|
||||
return view('admin.vip.members', [
|
||||
'vip' => $vip,
|
||||
'members' => $members,
|
||||
'totalAssignedCount' => $totalAssignedCount,
|
||||
'activeCount' => $activeCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,22 +138,7 @@ class VipController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 将文本框的多行模板转为 JSON 数组
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data = $this->validatedPayload($request);
|
||||
|
||||
VipLevel::create($data);
|
||||
|
||||
@@ -66,21 +154,7 @@ class VipController extends Controller
|
||||
{
|
||||
$level = $vip;
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data = $this->validatedPayload($request);
|
||||
|
||||
$level->update($data);
|
||||
|
||||
@@ -119,4 +193,37 @@ class VipController extends Controller
|
||||
|
||||
return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一整理后台提交的会员等级主题配置数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validatedPayload(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
'join_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
|
||||
'leave_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
|
||||
'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
|
||||
'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
|
||||
'allow_custom_messages' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 将多行文本框内容转为 JSON 数组,便于后续随机抽取模板。
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data['allow_custom_messages'] = $request->boolean('allow_custom_messages');
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 支付配置控制器
|
||||
* 用于管理聊天室对接 NovaLink 支付中心所需的开关、地址、App Key 与 App Secret
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateVipPaymentConfigRequest;
|
||||
use App\Models\SysParam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数注入聊天室状态服务
|
||||
*
|
||||
* @param ChatStateService $chatState 系统参数缓存同步服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示 VIP 支付配置页
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
$aliases = array_keys($this->fieldDescriptions());
|
||||
|
||||
// 仅读取 VIP 支付专属配置,避免与系统参数页重复展示。
|
||||
$params = SysParam::query()
|
||||
->whereIn('alias', $aliases)
|
||||
->pluck('body', 'alias')
|
||||
->toArray();
|
||||
|
||||
return view('admin.vip-payment.config', [
|
||||
'params' => $params,
|
||||
'descriptions' => $this->fieldDescriptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 VIP 支付配置并刷新缓存
|
||||
*
|
||||
* @param UpdateVipPaymentConfigRequest $request 已校验的后台配置请求
|
||||
*/
|
||||
public function update(UpdateVipPaymentConfigRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$descriptions = $this->fieldDescriptions();
|
||||
|
||||
foreach ($descriptions as $alias => $guidetxt) {
|
||||
$body = (string) ($data[$alias] ?? '');
|
||||
|
||||
// 写入数据库并同步描述文案,确保后续后台与缓存读取一致。
|
||||
SysParam::updateOrCreate(
|
||||
['alias' => $alias],
|
||||
['body' => $body, 'guidetxt' => $guidetxt]
|
||||
);
|
||||
|
||||
$this->chatState->setSysParam($alias, $body);
|
||||
SysParam::clearCache($alias);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.vip-payment.edit')->with('success', 'VIP 支付配置已成功保存。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 VIP 支付字段说明文案
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fieldDescriptions(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled' => 'VIP 在线支付开关(1=开启,0=关闭)',
|
||||
'vip_payment_base_url' => 'NovaLink 支付中心地址(例如 https://novalink.test)',
|
||||
'vip_payment_app_key' => 'NovaLink 支付中心 App Key',
|
||||
'vip_payment_app_secret' => 'NovaLink 支付中心 App Secret',
|
||||
'vip_payment_timeout' => '调用支付中心超时时间(秒)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台会员购买日志控制器
|
||||
* 负责展示聊天室 VIP 在线支付订单列表,并支持按用户、状态、订单号和日期筛选
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentLogController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示会员购买日志列表
|
||||
*
|
||||
* @param Request $request 当前查询请求
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = VipPaymentOrder::query()->with(['user:id,username', 'vipLevel:id,name,color,icon']);
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$username = (string) $request->input('username');
|
||||
|
||||
// 通过用户关联模糊匹配用户名,便于后台快速定位某个会员订单。
|
||||
$query->whereHas('user', function ($builder) use ($username): void {
|
||||
$builder->where('username', 'like', '%'.$username.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (string) $request->input('status'));
|
||||
}
|
||||
|
||||
if ($request->filled('order_no')) {
|
||||
$keyword = (string) $request->input('order_no');
|
||||
$query->where(function ($builder) use ($keyword): void {
|
||||
$builder->where('order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('merchant_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('payment_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('provider_trade_no', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('created_at', '>=', (string) $request->input('date_start'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('created_at', '<=', (string) $request->input('date_end'));
|
||||
}
|
||||
|
||||
$logs = $query->latest('id')->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.vip-payment-logs.index', [
|
||||
'logs' => $logs,
|
||||
'statusOptions' => [
|
||||
'created' => '待创建',
|
||||
'pending' => '待支付',
|
||||
'paid' => '已支付',
|
||||
'closed' => '已关闭',
|
||||
'failed' => '失败',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信机器人配置控制器
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SysParam;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WechatBotController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示微信机器人配置表单
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
// 从 SysParam 获取配置,若不存在赋予默认空 JSON
|
||||
$param = SysParam::firstOrCreate(
|
||||
['alias' => 'wechat_bot_config'],
|
||||
[
|
||||
'body' => json_encode([
|
||||
'kafka' => [
|
||||
'brokers' => '',
|
||||
'topic' => '',
|
||||
'group_id' => 'chatroom_wechat_bot',
|
||||
'bot_wxid' => '',
|
||||
],
|
||||
'api' => [
|
||||
'base_url' => '',
|
||||
'bot_key' => '',
|
||||
],
|
||||
'global_notify' => [
|
||||
'start_time' => '08:00',
|
||||
'end_time' => '22:00',
|
||||
],
|
||||
'group_notify' => [
|
||||
'target_wxid' => '',
|
||||
'toggle_admin_online' => false,
|
||||
'toggle_baccarat_result' => false,
|
||||
'toggle_lottery_result' => false,
|
||||
],
|
||||
'personal_notify' => [
|
||||
'toggle_friend_online' => false,
|
||||
'toggle_spouse_online' => false,
|
||||
'toggle_level_change' => false,
|
||||
],
|
||||
]),
|
||||
'guidetxt' => '微信机器人全站配置(包含群聊推送和私聊推送开关及Kafka连接)',
|
||||
]
|
||||
);
|
||||
|
||||
$config = json_decode($param->body, true);
|
||||
|
||||
return view('admin.wechat_bot.edit', compact('config'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新微信机器人配置
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'kafka_brokers' => 'nullable|string',
|
||||
'kafka_topic' => 'nullable|string',
|
||||
'kafka_group_id' => 'nullable|string',
|
||||
'kafka_bot_wxid' => 'nullable|string',
|
||||
'api_base_url' => 'nullable|string',
|
||||
'api_bot_key' => 'nullable|string',
|
||||
'qrcode_image' => 'nullable|image|max:2048',
|
||||
'global_start_time' => 'nullable|string',
|
||||
'global_end_time' => 'nullable|string',
|
||||
'group_target_wxid' => 'nullable|string',
|
||||
'toggle_admin_online' => 'nullable|boolean',
|
||||
'toggle_baccarat_result' => 'nullable|boolean',
|
||||
'toggle_lottery_result' => 'nullable|boolean',
|
||||
'toggle_friend_online' => 'nullable|boolean',
|
||||
'toggle_spouse_online' => 'nullable|boolean',
|
||||
'toggle_level_change' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->first();
|
||||
$oldConfig = $param ? (json_decode($param->body, true) ?? []) : [];
|
||||
|
||||
$qrcodePath = $oldConfig['api']['qrcode_image'] ?? '';
|
||||
if ($request->hasFile('qrcode_image')) {
|
||||
// 删除旧图
|
||||
if ($qrcodePath && \Illuminate\Support\Facades\Storage::disk('public')->exists($qrcodePath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($qrcodePath);
|
||||
}
|
||||
$qrcodePath = $request->file('qrcode_image')->store('wechat', 'public');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'kafka' => [
|
||||
'brokers' => $validated['kafka_brokers'] ?? '',
|
||||
'topic' => $validated['kafka_topic'] ?? '',
|
||||
'group_id' => $validated['kafka_group_id'] ?? 'chatroom_wechat_bot',
|
||||
'bot_wxid' => $validated['kafka_bot_wxid'] ?? '',
|
||||
],
|
||||
'api' => [
|
||||
'base_url' => $validated['api_base_url'] ?? '',
|
||||
'bot_key' => $validated['api_bot_key'] ?? '',
|
||||
'qrcode_image' => $qrcodePath,
|
||||
],
|
||||
'global_notify' => [
|
||||
'start_time' => $validated['global_start_time'] ?? '',
|
||||
'end_time' => $validated['global_end_time'] ?? '',
|
||||
],
|
||||
'group_notify' => [
|
||||
'target_wxid' => $validated['group_target_wxid'] ?? '',
|
||||
'toggle_admin_online' => $validated['toggle_admin_online'] ?? false,
|
||||
'toggle_baccarat_result' => $validated['toggle_baccarat_result'] ?? false,
|
||||
'toggle_lottery_result' => $validated['toggle_lottery_result'] ?? false,
|
||||
],
|
||||
'personal_notify' => [
|
||||
'toggle_friend_online' => $validated['toggle_friend_online'] ?? false,
|
||||
'toggle_spouse_online' => $validated['toggle_spouse_online'] ?? false,
|
||||
'toggle_level_change' => $validated['toggle_level_change'] ?? false,
|
||||
],
|
||||
];
|
||||
|
||||
if ($param) {
|
||||
$param->update(['body' => json_encode($config)]);
|
||||
SysParam::clearCache('wechat_bot_config');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送群内公告
|
||||
*/
|
||||
public function sendAnnouncement(Request $request, \App\Services\WechatBot\WechatNotificationService $wechatService): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'announcement_content' => 'required|string|max:1000',
|
||||
], [
|
||||
'announcement_content.required' => '请输入公告内容',
|
||||
'announcement_content.max' => '公告内容太长,不能超过1000字',
|
||||
]);
|
||||
|
||||
try {
|
||||
$wechatService->sendCustomGroupAnnouncement($validated['announcement_content']);
|
||||
|
||||
return back()->with('success', '群公告已通过微信机器人发送成功!(消息已进入队列)');
|
||||
} catch (\Exception $e) {
|
||||
return back()->withInput()->withErrors(['announcement_content' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 文件功能:管理员聊天室实时命令控制器
|
||||
*
|
||||
* 提供管理员在聊天室内对用户执行的管理操作:
|
||||
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)、站长公屏讲话。
|
||||
* 警告(=J)、踢出(=T)、禁言(=B)、封号、封IP、查看私信(=S)、职务公屏讲话。
|
||||
*
|
||||
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
|
||||
*
|
||||
@@ -16,19 +16,29 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\BrowserRefreshRequested;
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Message;
|
||||
use App\Models\PositionAuthorityLog;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室内的实时管理命令与部分职务奖励操作。
|
||||
*/
|
||||
class AdminCommandController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -37,6 +47,7 @@ class AdminCommandController extends Controller
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -57,12 +68,25 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '请注意言行');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 权限检查(等级由 level_warn 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_warn', '5')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_WARN, '警告');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播警告消息
|
||||
@@ -71,7 +95,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⚠️ 管理员 <b>{$admin->username}</b> 警告 <b>{$targetUsername}</b>:{$reason}",
|
||||
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -81,6 +105,17 @@ class AdminCommandController extends Controller
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 给被警告用户补一条私聊提示,并复用右下角 toast 通知。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "⚠️ {$operatorDisplay} 警告了你:{$reason}",
|
||||
title: '⚠️ 收到警告',
|
||||
toastMessage: "{$operatorDisplay} 警告了你:{$reason}",
|
||||
color: '#f59e0b',
|
||||
icon: '⚠️',
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]);
|
||||
}
|
||||
|
||||
@@ -102,14 +137,38 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
|
||||
// 权限检查(等级由 level_kick 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_KICK, '踢出');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}",
|
||||
title: '🚫 已被踢出',
|
||||
toastMessage: "{$operatorDisplay} 已将你踢出聊天室。<br>原因:{$reason}",
|
||||
color: '#ef4444',
|
||||
icon: '🚫',
|
||||
);
|
||||
|
||||
// 从 Redis 在线列表移除
|
||||
$this->chatState->userLeave($roomId, $targetUsername);
|
||||
|
||||
@@ -119,7 +178,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🚫 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -153,12 +212,26 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$duration = $request->input('duration');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 权限检查(等级由 level_mute 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_mute', '8')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$duration = $request->input('duration');
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_MUTE, '禁言');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 设置 Redis 禁言标记,TTL 自动过期
|
||||
@@ -171,7 +244,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🔇 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -181,26 +254,38 @@ class AdminCommandController extends Controller
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。",
|
||||
title: '🔇 已被禁言',
|
||||
toastMessage: "{$operatorDisplay} 已将你禁言 <b>{$duration}</b> 分钟。",
|
||||
color: '#6366f1',
|
||||
icon: '🔇',
|
||||
);
|
||||
|
||||
// 广播禁言事件(前端禁用输入框)
|
||||
broadcast(new \App\Events\UserMuted(
|
||||
roomId: $roomId,
|
||||
username: $targetUsername,
|
||||
muteTime: $duration,
|
||||
operator: $admin->username,
|
||||
message: "{$operatorLabel} 已将 [{$targetUsername}] 禁言 {$duration} 分钟。",
|
||||
operator: $operatorLabel,
|
||||
));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结用户账号(=Y 理由)
|
||||
* 封禁用户账号。
|
||||
*
|
||||
* 将用户账号状态设为冻结,禁止登录。
|
||||
* 将目标账号设为封禁状态,并将其从当前在线房间中移出。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 username, reason
|
||||
* @param Request $request 请求对象,需包含 username, room_id, reason
|
||||
* @return JsonResponse 操作结果
|
||||
*/
|
||||
public function freeze(Request $request): JsonResponse
|
||||
public function ban(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
@@ -210,37 +295,51 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
|
||||
// 权限检查(等级由 level_freeze 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_freeze', '14')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 冻结用户账号(将等级设为 -1 表示冻结)
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if (! $target) {
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$target = $authorization['target'];
|
||||
$target->user_level = -1;
|
||||
$target->save();
|
||||
|
||||
// 从所有房间移除
|
||||
$rooms = $this->chatState->getUserRooms($targetUsername);
|
||||
foreach ($rooms as $rid) {
|
||||
$this->chatState->userLeave($rid, $targetUsername);
|
||||
}
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "⛔ {$operatorDisplay} 已封禁你的账号。原因:{$reason}",
|
||||
title: '⛔ 账号已封禁',
|
||||
toastMessage: "{$operatorDisplay} 已封禁你的账号。<br>原因:{$reason}",
|
||||
color: '#991b1b',
|
||||
icon: '⛔',
|
||||
);
|
||||
|
||||
$this->removeUserFromAllRooms($targetUsername);
|
||||
|
||||
// 广播冻结消息
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧊 管理员 <b>{$admin->username}</b> 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
|
||||
'content' => "⛔ {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'font_color' => '#991b1b',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
@@ -248,10 +347,93 @@ class AdminCommandController extends Controller
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 广播踢出事件
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}"));
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}"));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已冻结 {$targetUsername} 的账号"]);
|
||||
return response()->json(['status' => 'success', 'message' => "已封禁 {$targetUsername} 的账号"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁用户 IP。
|
||||
*
|
||||
* 将目标账号设为封禁状态并把最近登录 IP 写入黑名单。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 username, room_id, reason
|
||||
* @return JsonResponse 操作结果
|
||||
*/
|
||||
public function banIp(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'room_id' => 'required|integer',
|
||||
'reason' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BANIP, '封禁 IP ');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$target = $authorization['target'];
|
||||
$targetIp = (string) ($target->last_ip ?? '');
|
||||
if ($targetIp !== '') {
|
||||
Redis::sadd('banned_ips', $targetIp);
|
||||
}
|
||||
|
||||
$target->user_level = -1;
|
||||
$target->save();
|
||||
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🌐 {$operatorDisplay} 已封禁你的 IP 并冻结账号。原因:{$reason}",
|
||||
title: '🌐 IP 已封禁',
|
||||
toastMessage: "{$operatorDisplay} 已封禁你的 IP 并冻结账号。<br>原因:{$reason}",
|
||||
color: '#7c2d12',
|
||||
icon: '🌐',
|
||||
);
|
||||
|
||||
$this->removeUserFromAllRooms($targetUsername);
|
||||
|
||||
$ipSuffix = $targetIp !== '' ? "(IP:{$targetIp})" : '(未记录有效 IP)';
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🌐 {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的 IP 并冻结账号。原因:{$reason} {$ipSuffix}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#9a3412',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "IP 已被封禁:{$reason}"));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $targetIp !== '' ? "已封禁 {$targetUsername} 的 IP 与账号" : "已封禁 {$targetUsername} 的账号,但未记录可封禁 IP",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,8 +457,9 @@ class AdminCommandController extends Controller
|
||||
abort(403, '仅站长可查看私信');
|
||||
}
|
||||
|
||||
// 查询最近 50 条悄悄话(发送或接收)
|
||||
// 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
|
||||
$messages = Message::where('is_secret', true)
|
||||
->where('from_user', 'not like', '系统%')
|
||||
->where(function ($q) use ($username) {
|
||||
$q->where('from_user', $username)
|
||||
->orWhere('to_user', $username);
|
||||
@@ -301,9 +484,10 @@ class AdminCommandController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长公屏讲话
|
||||
* 聊天室公屏讲话
|
||||
*
|
||||
* 站长发送全聊天室公告,以特殊样式显示。
|
||||
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
|
||||
* id=1 站长仍然拥有完整兜底权限。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 content, room_id
|
||||
* @return JsonResponse 操作结果
|
||||
@@ -316,26 +500,38 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
|
||||
}
|
||||
|
||||
$roomId = $request->input('room_id');
|
||||
$content = $request->input('content');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播站长公告
|
||||
$content = ChatContentSanitizer::htmlText($request->input('content'));
|
||||
|
||||
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告”
|
||||
$publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin));
|
||||
$publisherUsername = ChatContentSanitizer::htmlText($admin->username);
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 站长 <b>{$admin->username}</b> 讲话:{$content}",
|
||||
'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告:{$content}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'toast_notification' => [
|
||||
'title' => '📢 公屏公告',
|
||||
'message' => strip_tags($content),
|
||||
'icon' => '📢',
|
||||
'color' => '#b91c1c',
|
||||
'duration' => 10000,
|
||||
],
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
@@ -344,6 +540,189 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'success', 'message' => '公告已发送']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公屏公告发布者身份标签。
|
||||
*
|
||||
* 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。
|
||||
*/
|
||||
private function buildAnnouncementPublisherLabel(User $user): string
|
||||
{
|
||||
return $this->buildOperatorIdentityLabel($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成操作者的身份标签。
|
||||
*
|
||||
* 有在职职务时统一显示为“部门·职务”,无在职职务的 id=1 兜底显示“站长”。
|
||||
*/
|
||||
private function buildOperatorIdentityLabel(User $user): string
|
||||
{
|
||||
$position = $user->activePosition?->position;
|
||||
|
||||
if ($position) {
|
||||
$departmentName = (string) ($position->department?->name ?? '');
|
||||
|
||||
return $departmentName ? "{$departmentName}·{$position->name}" : $position->name;
|
||||
}
|
||||
|
||||
if ($user->id === 1) {
|
||||
return '站长';
|
||||
}
|
||||
|
||||
return '管理员';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成操作者在聊天室文案中的完整展示文本。
|
||||
*/
|
||||
private function buildOperatorDisplayHtml(User $user): string
|
||||
{
|
||||
$identityLabel = e($this->buildOperatorIdentityLabel($user));
|
||||
$username = e($user->username);
|
||||
|
||||
return "<b>{$identityLabel}</b> <b>{$username}</b>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验操作者是否可在指定房间执行聊天室管理命令。
|
||||
*
|
||||
* @return array{ok: bool, message: string, room?: Room}
|
||||
*/
|
||||
private function authorizeManagedRoom(int $roomId, User $operator): array
|
||||
{
|
||||
$room = Room::query()->find($roomId);
|
||||
if (! $room) {
|
||||
return ['ok' => false, 'message' => '房间不存在'];
|
||||
}
|
||||
|
||||
if (! $room->canUserEnter($operator)) {
|
||||
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
|
||||
}
|
||||
|
||||
// 超级管理员(站长 id = 1)豁免必须进房的限制,允许全局管理;普通管理员必须在房间内方可管理
|
||||
if ($operator->id !== 1 && ! $this->chatState->isUserInRoom($roomId, $operator->username)) {
|
||||
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过', 'room' => $room];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标用户是否仍在线于当前房间。
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
|
||||
{
|
||||
// 系统虚拟机器人账号豁免在线状态校验,允许测试与交互警告
|
||||
if (in_array($targetUsername, ['AI小班长', '星海小博士'])) {
|
||||
return ['ok' => true, 'message' => '校验通过'];
|
||||
}
|
||||
|
||||
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
|
||||
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验聊天室用户管理动作是否可执行。
|
||||
*
|
||||
* 规则:
|
||||
* - id=1 站长始终放行
|
||||
* - 其他人必须拥有对应职务权限
|
||||
* - 不能操作自己
|
||||
* - 不能处理 user_level 高于自己的用户
|
||||
* - 不能处理部门位阶或职务位阶高于自己的用户
|
||||
*
|
||||
* @return array{ok: bool, message: string, target?: User}
|
||||
*/
|
||||
private function authorizeModerationAction(
|
||||
User $admin,
|
||||
string $targetUsername,
|
||||
string $permissionCode,
|
||||
string $actionLabel,
|
||||
): array {
|
||||
if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
|
||||
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
|
||||
}
|
||||
|
||||
if ($admin->username === $targetUsername) {
|
||||
return ['ok' => false, 'message' => '不能对自己执行该操作'];
|
||||
}
|
||||
|
||||
$target = User::query()
|
||||
->where('username', $targetUsername)
|
||||
->with('activePosition.position.department')
|
||||
->first();
|
||||
|
||||
if (! $target) {
|
||||
return ['ok' => false, 'message' => '用户不存在'];
|
||||
}
|
||||
|
||||
if (! $this->canModerateTargetByDutyRank($admin, $target)) {
|
||||
return ['ok' => false, 'message' => '不能处理职务高于自己的用户'];
|
||||
}
|
||||
|
||||
if ($admin->id !== 1 && $target->user_level > $admin->user_level) {
|
||||
return ['ok' => false, 'message' => '不能处理等级高于自己的用户'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过', 'target' => $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目标用户从当前在线的全部房间中移除。
|
||||
*/
|
||||
private function removeUserFromAllRooms(string $targetUsername): void
|
||||
{
|
||||
$rooms = $this->chatState->getUserRooms($targetUsername);
|
||||
|
||||
foreach ($rooms as $rid) {
|
||||
$this->chatState->userLeave((int) $rid, $targetUsername);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断操作者是否可以按职务位阶处理目标用户。
|
||||
*
|
||||
* 规则:
|
||||
* - 先比较部门 rank
|
||||
* - 部门相同再比较职务 rank
|
||||
* - 对方没有在职职务时视为可处理
|
||||
* - 同职务平级允许操作,仅禁止处理更高职务
|
||||
*/
|
||||
private function canModerateTargetByDutyRank(User $admin, User $target): bool
|
||||
{
|
||||
if ($admin->id === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$adminPosition = $admin->activePosition?->load('position.department')->position;
|
||||
if (! $adminPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetPosition = $target->activePosition?->position;
|
||||
if (! $targetPosition) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$adminDepartmentRank = (int) ($adminPosition->department?->rank ?? 0);
|
||||
$targetDepartmentRank = (int) ($targetPosition->department?->rank ?? 0);
|
||||
|
||||
if ($adminDepartmentRank > $targetDepartmentRank) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($adminDepartmentRank < $targetDepartmentRank) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $adminPosition->rank >= (int) $targetPosition->rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员全员清屏
|
||||
*
|
||||
@@ -360,12 +739,15 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
// 改为按职务权限控制聊天室顶部“清屏”按钮。
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
|
||||
}
|
||||
|
||||
// 需要站长权限才能全员清屏
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 清除 Redis 中该房间的消息缓存
|
||||
@@ -378,10 +760,49 @@ class AdminCommandController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员触发全屏特效(烟花/下雨/雷电)
|
||||
* 站长触发当前房间全员刷新页面。
|
||||
*
|
||||
* 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件,
|
||||
* 适用于功能更新后强制让前端重新拉取最新页面状态。
|
||||
*/
|
||||
public function refreshAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'reason' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
if ((int) $admin->id !== 1) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', ''));
|
||||
|
||||
// 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。
|
||||
broadcast(new BrowserRefreshRequested(
|
||||
roomId: $roomId,
|
||||
operator: $admin->username,
|
||||
reason: $reason,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '已通知当前房间所有在线用户刷新页面',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员触发全屏特效。
|
||||
*
|
||||
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
|
||||
* 仅 superlevel 等级管理员可触发。
|
||||
* 仅拥有 room.fullscreen_effect 权限的职务可触发。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 room_id, type
|
||||
* @return JsonResponse 操作结果
|
||||
@@ -390,21 +811,23 @@ class AdminCommandController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'type' => 'required|in:fireworks,rain,lightning,snow',
|
||||
'type' => ['required', 'string', Rule::in(EffectBroadcast::TYPES)],
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
|
||||
}
|
||||
|
||||
// 仅 superlevel 等级可触发特效
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播特效事件给房间内所有在线用户
|
||||
broadcast(new \App\Events\EffectBroadcast($roomId, $type, $admin->username));
|
||||
broadcast(new EffectBroadcast($roomId, $type, $admin->username));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
|
||||
}
|
||||
@@ -441,6 +864,10 @@ class AdminCommandController extends Controller
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$amount = (int) $request->input('amount');
|
||||
$targetUsername = $request->input('username');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 不能给自己发放
|
||||
if ($admin->username === $targetUsername) {
|
||||
@@ -453,12 +880,21 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// id=1 超级管理员:无需职务,无限额限制
|
||||
$isSuperAdmin = $admin->id === 1;
|
||||
$userPosition = null;
|
||||
$position = null;
|
||||
|
||||
if (! $isSuperAdmin) {
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
|
||||
}
|
||||
|
||||
// ① 必须有在职职务
|
||||
$userPosition = $admin->activePosition;
|
||||
if (! $userPosition) {
|
||||
@@ -593,7 +1029,7 @@ class AdminCommandController extends Controller
|
||||
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
||||
'icon' => '💰',
|
||||
'color' => '#f59e0b',
|
||||
'duration' => 8000,
|
||||
'duration' => 3000,
|
||||
],
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
@@ -676,35 +1112,39 @@ class AdminCommandController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查:管理员是否可对目标用户执行指定操作
|
||||
*
|
||||
* 根据 sysparam 中配置的等级门槛判断权限。
|
||||
*
|
||||
* @param User $admin 管理员用户
|
||||
* @param string $targetUsername 目标用户名
|
||||
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn)
|
||||
* @param string $defaultLevel 默认等级值
|
||||
* @return bool 是否有权限
|
||||
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
|
||||
*/
|
||||
private function canExecute(User $admin, string $targetUsername, string $levelKey, string $defaultLevel = '5'): bool
|
||||
{
|
||||
// 必须达到该操作所需的最低等级
|
||||
$requiredLevel = (int) Sysparam::getValue($levelKey, $defaultLevel);
|
||||
if ($admin->user_level < $requiredLevel) {
|
||||
return false;
|
||||
}
|
||||
private function pushTargetToastMessage(
|
||||
int $roomId,
|
||||
string $targetUsername,
|
||||
string $content,
|
||||
string $title,
|
||||
string $toastMessage,
|
||||
string $color,
|
||||
string $icon,
|
||||
): void {
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $targetUsername,
|
||||
'content' => $content,
|
||||
'is_secret' => true,
|
||||
'font_color' => $color,
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
// 复用现有聊天 toast 机制,在右下角弹出操作结果提示。
|
||||
'toast_notification' => [
|
||||
'title' => $title,
|
||||
'message' => $toastMessage,
|
||||
'icon' => $icon,
|
||||
'color' => $color,
|
||||
'duration' => 10000,
|
||||
],
|
||||
];
|
||||
|
||||
// 不能操作自己
|
||||
if ($admin->username === $targetUsername) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标用户等级不能高于操作者(允许平级互相操作)
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if ($target && $target->user_level > $admin->user_level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室前台登录、自动注册与退出登录。
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -39,7 +42,15 @@ class AuthController extends Controller
|
||||
|
||||
if ($user) {
|
||||
// 用户存在,验证密码
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$passwordMatches = false;
|
||||
try {
|
||||
$passwordMatches = Hash::check($password, $user->password);
|
||||
} catch (\RuntimeException $e) {
|
||||
// Hash::check() in Laravel 11/12 throws if the hash isn't a valid bcrypt string
|
||||
$passwordMatches = false;
|
||||
}
|
||||
|
||||
if ($passwordMatches) {
|
||||
// Bcrypt 验证通过
|
||||
|
||||
// 检测是否被封禁 (后台管理员级别获得豁免权,防止误把自己关在门外)
|
||||
@@ -53,7 +64,7 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->performLogin($user, $ip);
|
||||
$this->performLogin($user, $ip, $request);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '登录成功']);
|
||||
}
|
||||
@@ -75,7 +86,7 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->performLogin($user, $ip);
|
||||
$this->performLogin($user, $ip, $request);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']);
|
||||
}
|
||||
@@ -101,8 +112,13 @@ class AuthController extends Controller
|
||||
}
|
||||
|
||||
// 检测用户名是否在禁用词列表(永久禁用 或 改名临时保留期内)
|
||||
if (UsernameBlacklist::isBlocked($username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '该用户名已被系统禁止注册,请更换其他名称。'], 422);
|
||||
if ($blockingRecord = UsernameBlacklist::getBlockingRecord($username)) {
|
||||
$reason = '';
|
||||
if ($blockingRecord->type === 'permanent') {
|
||||
$reason = "(包含违禁敏感词:{$blockingRecord->username})";
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => "该用户名已被系统禁止注册{$reason},请更换其他名称。"], 422);
|
||||
}
|
||||
|
||||
// --- 提取邀请人 Cookie ---
|
||||
@@ -126,7 +142,7 @@ class AuthController extends Controller
|
||||
'inviter_id' => $inviterId, // 记录邀请人
|
||||
]);
|
||||
|
||||
$this->performLogin($newUser, $ip);
|
||||
$this->performLogin($newUser, $ip, $request);
|
||||
|
||||
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
|
||||
if ($inviterId) {
|
||||
@@ -139,9 +155,11 @@ class AuthController extends Controller
|
||||
/**
|
||||
* 执行实际的登录操作并记录时间、IP 等。
|
||||
*/
|
||||
private function performLogin(User $user, string $ip): void
|
||||
private function performLogin(User $user, string $ip, Request $request): void
|
||||
{
|
||||
Auth::login($user);
|
||||
// 登录成功后立即轮换 session id,阻断会话固定攻击。
|
||||
$request->session()->regenerate();
|
||||
|
||||
// 递增访问次数
|
||||
$user->increment('visit_num');
|
||||
@@ -160,6 +178,16 @@ class AuthController extends Controller
|
||||
'sdate' => now(),
|
||||
'uuname' => $user->username,
|
||||
]);
|
||||
|
||||
// 触发微信机器人消息推送 (登录上线类)
|
||||
try {
|
||||
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
|
||||
$wechatService->notifyAdminOnline($user);
|
||||
$wechatService->notifyFriendsOnline($user);
|
||||
$wechatService->notifySpouseOnline($user);
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('WechatBot presence notification failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +202,17 @@ class AuthController extends Controller
|
||||
'out_time' => now(),
|
||||
'out_info' => '正常退出了聊天室',
|
||||
]);
|
||||
|
||||
// [NEW] 同步清除该用户在所有房间的在线状态和心跳,确保其如果马上重登,能触发全新入场欢迎
|
||||
try {
|
||||
$chatState = app(\App\Services\ChatStateService::class);
|
||||
$roomIds = $chatState->getUserRooms($user->username);
|
||||
foreach ($roomIds as $roomId) {
|
||||
$chatState->userLeave($roomId, $user->username);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略清理缓存时发生的异常
|
||||
}
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
|
||||
@@ -19,15 +19,25 @@ use App\Enums\CurrencySource;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\User;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use App\Services\GameBetBroadcastService;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:提供百家乐当前局查询、下注与历史接口。
|
||||
*/
|
||||
class BaccaratController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
private readonly GameBetBroadcastService $betBroadcastService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -35,18 +45,32 @@ class BaccaratController extends Controller
|
||||
*/
|
||||
public function currentRound(Request $request): JsonResponse
|
||||
{
|
||||
$round = BaccaratRound::currentRound();
|
||||
$user = $request->user();
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
|
||||
|
||||
if (! $round) {
|
||||
return response()->json(['round' => null]);
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
|
||||
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
|
||||
}
|
||||
|
||||
$round = BaccaratRound::currentRound($roomId);
|
||||
|
||||
if (! $round) {
|
||||
return response()->json([
|
||||
'round' => null,
|
||||
// 即使当前无局次,也返回最新金币余额,供前端每次打开弹窗时刷新右上角显示。
|
||||
'jjb' => (int) ($user->jjb ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$myBet = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
$maxBet = (int) ($config['max_bet'] ?? 50000);
|
||||
|
||||
return response()->json([
|
||||
'round' => [
|
||||
'id' => $round->id,
|
||||
@@ -59,11 +83,15 @@ class BaccaratController extends Controller
|
||||
'bet_count_big' => $round->bet_count_big,
|
||||
'bet_count_small' => $round->bet_count_small,
|
||||
'bet_count_triple' => $round->bet_count_triple,
|
||||
'min_bet' => $minBet,
|
||||
'max_bet' => $maxBet,
|
||||
'my_bet' => $myBet ? [
|
||||
'bet_type' => $myBet->bet_type,
|
||||
'amount' => $myBet->amount,
|
||||
] : null,
|
||||
],
|
||||
// 返回当前用户最新金币,前端每次打开弹窗都可同步右上角余额。
|
||||
'jjb' => (int) ($user->jjb ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -84,6 +112,11 @@ class BaccaratController extends Controller
|
||||
'bet_type' => 'required|in:big,small,triple',
|
||||
'amount' => 'required|integer|min:1',
|
||||
]);
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启百家乐。'], 403);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
@@ -95,24 +128,36 @@ class BaccaratController extends Controller
|
||||
|
||||
$round = BaccaratRound::find($data['round_id']);
|
||||
|
||||
if (! $round || ! $round->isBettingOpen()) {
|
||||
if (! $round || (int) $round->room_id !== $roomId || ! $round->isBettingOpen()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// 检查用户金币余额(金币字段为 jjb)
|
||||
// 快速过滤(非锁)
|
||||
if (($user->jjb ?? 0) < $data['amount']) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
|
||||
}
|
||||
|
||||
$currency = $this->currency;
|
||||
$lossCoverService = $this->lossCoverService;
|
||||
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
|
||||
// 幂等:同一局只能下一注
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
|
||||
// 1. 悲观锁锁定用户行
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
// 2. 锁保护下二次校验余额
|
||||
if ((int) $lockedUser->jjb < $data['amount']) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
|
||||
}
|
||||
|
||||
// 3. 幂等:同一局只能下一注
|
||||
$existing = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('user_id', $lockedUser->id)
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
@@ -120,9 +165,9 @@ class BaccaratController extends Controller
|
||||
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
|
||||
}
|
||||
|
||||
// 扣除金币
|
||||
// 4. 扣除金币,传入锁定的用户实例
|
||||
$currency->change(
|
||||
$user,
|
||||
$lockedUser,
|
||||
'gold',
|
||||
-$data['amount'],
|
||||
CurrencySource::BACCARAT_BET,
|
||||
@@ -131,15 +176,25 @@ class BaccaratController extends Controller
|
||||
},
|
||||
);
|
||||
|
||||
// 写入下注记录
|
||||
BaccaratBet::create([
|
||||
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
|
||||
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
|
||||
|
||||
// 5. 写入下注记录
|
||||
$bet = BaccaratBet::create([
|
||||
'round_id' => $round->id,
|
||||
'user_id' => $user->id,
|
||||
'user_id' => $lockedUser->id,
|
||||
'loss_cover_event_id' => $lossCoverEvent?->id,
|
||||
'bet_type' => $data['bet_type'],
|
||||
'amount' => $data['amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 同步修改 Auth 内存实例的金币
|
||||
$user->setAttribute('jjb', $lockedUser->jjb);
|
||||
|
||||
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
|
||||
$lossCoverService->registerBet($bet);
|
||||
|
||||
// 更新局次汇总统计
|
||||
$field = 'total_bet_'.$data['bet_type'];
|
||||
$countField = 'bet_count_'.$data['bet_type'];
|
||||
@@ -154,6 +209,8 @@ class BaccaratController extends Controller
|
||||
'big' => '大', 'small' => '小', default => '豹子'
|
||||
};
|
||||
|
||||
$this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已押注「{$betLabel}」{$data['amount']} 金币,等待开奖!",
|
||||
@@ -168,7 +225,9 @@ class BaccaratController extends Controller
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
|
||||
$rounds = BaccaratRound::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
@@ -176,4 +235,28 @@ class BaccaratController extends Controller
|
||||
|
||||
return response()->json(['history' => $rounds]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台百家乐历史走势与开奖页面。
|
||||
*/
|
||||
public function historyPage(Request $request): \Illuminate\View\View
|
||||
{
|
||||
// 1. 各选项的历史分布统计
|
||||
$summary = [
|
||||
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
|
||||
'result_dist' => BaccaratRound::query()
|
||||
->where('status', 'settled')
|
||||
->select('result', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
|
||||
->groupBy('result')
|
||||
->pluck('cnt', 'result'),
|
||||
];
|
||||
|
||||
// 2. 分页拉取所有已结算的局次
|
||||
$rounds = BaccaratRound::query()
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->paginate(30);
|
||||
|
||||
return view('rooms.baccarat-history', compact('rounds', 'summary'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动前台控制器
|
||||
*
|
||||
* 提供活动摘要、历史记录以及用户领取补偿的接口,
|
||||
* 供娱乐大厅弹窗与聊天室系统消息按钮调用。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BaccaratLossCoverController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入百家乐买单活动服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 返回当前最值得关注的一次活动摘要。
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$event = BaccaratLossCoverEvent::query()
|
||||
->with(['creator:id,username'])
|
||||
->whereIn('status', $this->summaryStatuses($request))
|
||||
->orderByRaw($this->summaryStatusOrder($request))
|
||||
->orderByDesc('starts_at')
|
||||
->first();
|
||||
|
||||
$record = null;
|
||||
if ($event) {
|
||||
$record = $event->records()->where('user_id', $request->user()->id)->first();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'event' => $event ? $this->transformEvent($event, $record) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回最近的活动列表以及当前用户的领取记录。
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$events = BaccaratLossCoverEvent::query()
|
||||
->with(['creator:id,username', 'records' => function ($query) use ($request) {
|
||||
$query->where('user_id', $request->user()->id);
|
||||
}])
|
||||
->latest('starts_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function (BaccaratLossCoverEvent $event) {
|
||||
$record = $event->records->first();
|
||||
|
||||
return $this->transformEvent($event, $record);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取指定活动的补偿金币。
|
||||
*/
|
||||
public function claim(Request $request, BaccaratLossCoverEvent $event): JsonResponse
|
||||
{
|
||||
$result = $this->lossCoverService->claim($event, $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将活动与个人记录整理为前端更容易消费的结构。
|
||||
*/
|
||||
private function transformEvent(BaccaratLossCoverEvent $event, mixed $record): array
|
||||
{
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'description' => $event->description,
|
||||
'status' => $event->status,
|
||||
'status_label' => $event->statusLabel(),
|
||||
'starts_at' => $event->starts_at?->toIso8601String(),
|
||||
'ends_at' => $event->ends_at?->toIso8601String(),
|
||||
'claim_deadline_at' => $event->claim_deadline_at?->toIso8601String(),
|
||||
'participant_count' => $event->participant_count,
|
||||
'compensable_user_count' => $event->compensable_user_count,
|
||||
'total_loss_amount' => $event->total_loss_amount,
|
||||
'total_claimed_amount' => $event->total_claimed_amount,
|
||||
'creator_username' => $event->creator?->username ?? '管理员',
|
||||
'my_record' => $record ? [
|
||||
'total_bet_amount' => $record->total_bet_amount,
|
||||
'total_win_payout' => $record->total_win_payout,
|
||||
'total_loss_amount' => $record->total_loss_amount,
|
||||
'compensation_amount' => $record->compensation_amount,
|
||||
'claim_status' => $record->claim_status,
|
||||
'claim_status_label' => $record->claimStatusLabel(),
|
||||
'claimed_amount' => $record->claimed_amount,
|
||||
'claimed_at' => $record->claimed_at?->toIso8601String(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按调用场景返回活动摘要允许出现的状态集合。
|
||||
*
|
||||
* “当前活动”页签只展示未开始、进行中或结算中的活动,
|
||||
* 避免把已结束但仍可领取的历史活动误显示在当前页签里。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function summaryStatuses(Request $request): array
|
||||
{
|
||||
if ($request->string('scene')->toString() === 'overview') {
|
||||
return ['active', 'settlement_pending', 'scheduled'];
|
||||
}
|
||||
|
||||
return ['active', 'settlement_pending', 'claimable', 'scheduled'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按调用场景生成活动状态排序规则。
|
||||
*/
|
||||
private function summaryStatusOrder(Request $request): string
|
||||
{
|
||||
if ($request->string('scene')->toString() === 'overview') {
|
||||
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'scheduled' THEN 2 ELSE 3 END";
|
||||
}
|
||||
|
||||
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'claimable' THEN 2 WHEN 'scheduled' THEN 3 ELSE 4 END";
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,25 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\BankLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理银行余额、存取款和存款排行展示。
|
||||
*/
|
||||
class BankController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询银行余额及最近20条流水记录
|
||||
*/
|
||||
@@ -45,20 +56,27 @@ class BankController extends Controller
|
||||
*/
|
||||
public function ranking(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $operator */
|
||||
$operator = Auth::user();
|
||||
$direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$users = \App\Models\User::where('bank_jjb', '>', 0)
|
||||
$users = User::where('bank_jjb', '>', 0)
|
||||
->orderBy('bank_jjb', $direction)
|
||||
->paginate(20, ['id', 'username', 'bank_jjb', 'sex', 'usersf', 'user_level']);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'ranking' => $users->map(function ($u) {
|
||||
// 提供必要的前端展示字段
|
||||
'ranking' => $users->map(function (User $u) use ($operator) {
|
||||
$canViewBalance = $this->canViewBankBalance($operator, $u);
|
||||
|
||||
// 提供必要的前端展示字段,普通用户查看别人存款时只返回星号,防止前端绕过遮罩。
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'bank_jjb' => $u->bank_jjb,
|
||||
'bank_jjb' => $canViewBalance ? ($u->bank_jjb ?? 0) : '******',
|
||||
'bank_jjb_masked' => ! $canViewBalance,
|
||||
'can_reveal' => ! $canViewBalance,
|
||||
'reveal_cost' => UserController::INFO_REVEAL_COST,
|
||||
'sex' => $u->sex,
|
||||
'usersf' => $u->usersf,
|
||||
'user_level' => $u->user_level,
|
||||
@@ -87,6 +105,7 @@ class BankController extends Controller
|
||||
$amount = $request->integer('amount');
|
||||
$user = Auth::user();
|
||||
|
||||
// 快速过滤(非锁),降低非法请求穿透到数据库的概率
|
||||
if (($user->jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
@@ -94,25 +113,49 @@ class BankController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
$user->decrement('jjb', $amount);
|
||||
$user->increment('bank_jjb', $amount);
|
||||
try {
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
// 1. 强制在数据库层面对用户行数据加写锁(X锁)
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
BankLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $user->fresh()->bank_jjb,
|
||||
// 2. 在锁保护下安全校验最新余额
|
||||
if ((int) $lockedUser->jjb < $amount) {
|
||||
throw new \Exception('流通金币不足!当前余额 '.(int) $lockedUser->jjb." 枚,无法存入 {$amount} 枚。");
|
||||
}
|
||||
|
||||
// 3. 执行资产扣除,将已加锁的 lockedUser 传给 change 方法
|
||||
$this->currencyService->change($lockedUser, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
|
||||
|
||||
// 4. 增加银行余额
|
||||
$lockedUser->increment('bank_jjb', $amount);
|
||||
|
||||
// 5. 写入银行流水记录
|
||||
BankLog::create([
|
||||
'user_id' => $lockedUser->id,
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $lockedUser->fresh()->bank_jjb,
|
||||
]);
|
||||
|
||||
// 6. 同步 Auth 内存状态,保障同生命周期内其他地方拿到的是正确数据
|
||||
$user->setAttribute('jjb', $lockedUser->jjb);
|
||||
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
});
|
||||
|
||||
$fresh = $user->fresh();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "成功存入 {$amount} 枚金币!",
|
||||
'jjb' => $fresh->jjb,
|
||||
'bank_jjb' => $fresh->bank_jjb,
|
||||
'jjb' => $user->jjb,
|
||||
'bank_jjb' => $user->bank_jjb,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -130,6 +173,7 @@ class BankController extends Controller
|
||||
$amount = $request->integer('amount');
|
||||
$user = Auth::user();
|
||||
|
||||
// 快速过滤(非锁)
|
||||
if (($user->bank_jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
@@ -137,25 +181,63 @@ class BankController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
$user->decrement('bank_jjb', $amount);
|
||||
$user->increment('jjb', $amount);
|
||||
try {
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
// 1. 强制在数据库层面对用户行数据加写锁(X锁)
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
BankLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $user->fresh()->bank_jjb,
|
||||
// 2. 校验银行存款是否足够
|
||||
if ((int) $lockedUser->bank_jjb < $amount) {
|
||||
throw new \Exception('银行余额不足!当前存款 '.($lockedUser->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。");
|
||||
}
|
||||
|
||||
// 3. 扣除银行存款
|
||||
$lockedUser->decrement('bank_jjb', $amount);
|
||||
|
||||
// 4. 增加流通金币并记录划转日志
|
||||
$this->currencyService->change($lockedUser, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
|
||||
|
||||
// 5. 记录银行账户流水
|
||||
BankLog::create([
|
||||
'user_id' => $lockedUser->id,
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $lockedUser->fresh()->bank_jjb,
|
||||
]);
|
||||
|
||||
// 6. 同步 Auth 内存状态
|
||||
$user->setAttribute('jjb', $lockedUser->jjb);
|
||||
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
});
|
||||
|
||||
$fresh = $user->fresh();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "成功取出 {$amount} 枚金币!",
|
||||
'jjb' => $fresh->jjb,
|
||||
'bank_jjb' => $fresh->bank_jjb,
|
||||
'jjb' => $user->jjb,
|
||||
'bank_jjb' => $user->bank_jjb,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断操作者是否可以免费查看目标用户银行存款。
|
||||
*/
|
||||
private function canViewBankBalance(User $operator, User $targetUser): bool
|
||||
{
|
||||
if ($operator->id === $targetUser->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
return (int) $operator->user_level >= $superLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserBrowserRefreshRequested;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use App\Services\AppointmentService;
|
||||
@@ -92,7 +95,25 @@ class ChatAppointmentController extends Controller
|
||||
departmentName: $position->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
));
|
||||
|
||||
// 给被任命用户补一条私聊提示,并复用右下角 toast 通知。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $target->username,
|
||||
content: "✨ <b>{$operator->username}</b> 已任命你为 {$position->icon} {$position->name}。",
|
||||
title: '✨ 职务任命通知',
|
||||
toastMessage: "<b>{$operator->username}</b> 已任命你为 <b>{$position->icon} {$position->name}</b>。",
|
||||
color: '#a855f7',
|
||||
icon: '✨',
|
||||
);
|
||||
}
|
||||
|
||||
// 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: (int) $target->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已发生变更,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -136,7 +157,25 @@ class ChatAppointmentController extends Controller
|
||||
operatorName: $operator->username,
|
||||
type: 'revoke',
|
||||
));
|
||||
|
||||
// 给被撤职用户补一条私聊提示,并复用右下角 toast 通知。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $target->username,
|
||||
content: "📋 <b>{$operator->username}</b> 已撤销你的 {$posIcon} {$posName} 职务。",
|
||||
title: '📋 职务变动通知',
|
||||
toastMessage: "<b>{$operator->username}</b> 已撤销你的 <b>{$posIcon} {$posName}</b> 职务。",
|
||||
color: '#6b7280',
|
||||
icon: '📋',
|
||||
);
|
||||
}
|
||||
|
||||
// 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: (int) $target->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已被撤销,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -144,4 +183,41 @@ class ChatAppointmentController extends Controller
|
||||
'message' => $result['message'],
|
||||
], $result['ok'] ? 200 : 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
|
||||
*/
|
||||
private function pushTargetToastMessage(
|
||||
int $roomId,
|
||||
string $targetUsername,
|
||||
string $content,
|
||||
string $title,
|
||||
string $toastMessage,
|
||||
string $color,
|
||||
string $icon,
|
||||
): void {
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $targetUsername,
|
||||
'content' => $content,
|
||||
'is_secret' => true,
|
||||
'font_color' => $color,
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
// 复用现有聊天 toast 机制,在右下角弹出职务变动提示。
|
||||
'toast_notification' => [
|
||||
'title' => $title,
|
||||
'message' => $toastMessage,
|
||||
'icon' => $icon,
|
||||
'color' => $color,
|
||||
'duration' => 10000,
|
||||
],
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\AiChatService;
|
||||
use App\Services\AiFinanceService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -25,6 +26,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 处理用户与 AI小班长的对话、金币福利与上下文清理。
|
||||
*/
|
||||
class ChatBotController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -34,6 +38,7 @@ class ChatBotController extends Controller
|
||||
private readonly AiChatService $aiChat,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly AiFinanceService $aiFinance,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,8 +56,12 @@ class ChatBotController extends Controller
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:2000',
|
||||
'room_id' => 'required|integer',
|
||||
'is_secret' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 私聊模式:AI 回复也走悄悄话,仅发言人和 AI 可见
|
||||
$isSecret = (bool) $request->input('is_secret', false);
|
||||
|
||||
// 检查全局开关
|
||||
$enabled = Sysparam::getValue('chatbot_enabled', '0');
|
||||
if ($enabled !== '1') {
|
||||
@@ -76,21 +85,25 @@ class ChatBotController extends Controller
|
||||
|
||||
$reply = $result['reply'];
|
||||
|
||||
// 检查 AI 是否决定给用户发金币
|
||||
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
|
||||
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
|
||||
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额])
|
||||
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
|
||||
$aiGoldAmount = (int) $matches[1];
|
||||
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
|
||||
$reply = trim($reply);
|
||||
|
||||
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
// 校验 AI 给出的金额在合理范围内
|
||||
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
|
||||
|
||||
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
||||
$dailyCount = (int) Redis::get($redisKey);
|
||||
|
||||
if ($dailyCount < $maxDailyRewards) {
|
||||
$goldAmount = rand(100, $maxGold);
|
||||
|
||||
if ($aiUser && $aiUser->jjb >= $goldAmount) {
|
||||
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
|
||||
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
|
||||
Redis::incr($redisKey);
|
||||
Redis::expire($redisKey, 86400); // 缓存 24 小时
|
||||
|
||||
@@ -129,6 +142,9 @@ class ChatBotController extends Controller
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
SaveMessageJob::dispatch($sysMsg);
|
||||
|
||||
// 福利发放完成后,若手上金币仍高于 100 万,则把超出的部分回存银行。
|
||||
$this->aiFinance->bankExcessGold($aiUser);
|
||||
} else {
|
||||
// 如果余额不足
|
||||
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
|
||||
@@ -139,14 +155,14 @@ class ChatBotController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 广播 AI 回复消息
|
||||
// 广播 AI 回复消息(私聊模式下仅发言人与 AI 可见)
|
||||
$botMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => $user->username,
|
||||
'content' => $reply,
|
||||
'is_secret' => false,
|
||||
'is_secret' => $isSecret,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台每日签到控制器
|
||||
*
|
||||
* 提供签到状态查询、领取奖励、刷新在线名单载荷和聊天室签到通知。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Http\Requests\ClaimDailySignInRequest;
|
||||
use App\Http\Requests\DailySignInCalendarRequest;
|
||||
use App\Http\Requests\MakeupDailySignInRequest;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\User;
|
||||
use App\Models\UserIdentityBadge;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\SignInService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 类功能:处理前台用户每日签到状态与领取奖励流程。
|
||||
*/
|
||||
class DailySignInController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造每日签到控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SignInService $signInService,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $presenceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前用户今日签到状态和奖励预览。
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$status = $this->signInService->status($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $this->formatStatusPayload($user, $status),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询指定月份的签到日历与补签卡状态。
|
||||
*/
|
||||
public function calendar(DailySignInCalendarRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $this->signInService->calendar($user, $request->validated('month')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:领取今日签到奖励并同步聊天室在线名单。
|
||||
*/
|
||||
public function claim(ClaimDailySignInRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$roomId = $request->validated('room_id');
|
||||
$roomId = $roomId === null ? null : (int) $roomId;
|
||||
|
||||
$dailySignIn = $this->signInService->claim($user, $roomId);
|
||||
if (! $dailySignIn->wasRecentlyCreated) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '今日已签到,请明天再来。',
|
||||
'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
|
||||
$presencePayload = $this->presenceService->build($freshUser);
|
||||
$this->refreshOnlinePresence($freshUser, $presencePayload);
|
||||
|
||||
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
|
||||
$this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $this->buildSuccessMessage($dailySignIn),
|
||||
'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:使用补签卡补签历史漏签日期。
|
||||
*/
|
||||
public function makeup(MakeupDailySignInRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$roomId = $request->validated('room_id');
|
||||
$roomId = $roomId === null ? null : (int) $roomId;
|
||||
|
||||
$dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId);
|
||||
$refreshedSignIn = $dailySignIn->fresh();
|
||||
$latestSignIn = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('sign_in_date')
|
||||
->first();
|
||||
$currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0);
|
||||
|
||||
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
|
||||
$presencePayload = $this->presenceService->build($freshUser);
|
||||
$this->refreshOnlinePresence($freshUser, $presencePayload);
|
||||
|
||||
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
|
||||
$this->broadcastSignInNotice($freshUser, $refreshedSignIn, $roomId, $currentStreakDays);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。',
|
||||
'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。
|
||||
*
|
||||
* @param array<string, mixed> $presencePayload
|
||||
*/
|
||||
private function refreshOnlinePresence(User $user, array $presencePayload): void
|
||||
{
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) {
|
||||
// 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。
|
||||
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
|
||||
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:向当前聊天室广播签到成功通知。
|
||||
*/
|
||||
private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId, ?int $currentStreakDays = null): void
|
||||
{
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#0f766e',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $message);
|
||||
broadcast(new MessageSent($roomId, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成聊天室内的签到播报内容。
|
||||
*/
|
||||
private function buildNoticeContent(User $user, DailySignIn $dailySignIn, ?int $currentStreakDays = null): string
|
||||
{
|
||||
$rewardText = $this->buildRewardText($dailySignIn);
|
||||
$identityText = $dailySignIn->identity_badge_name
|
||||
? ',获得身份 '.e($dailySignIn->identity_badge_name)
|
||||
: '';
|
||||
|
||||
if ($dailySignIn->is_makeup) {
|
||||
$signInDate = $dailySignIn->sign_in_date?->format('Y-m-d') ?? '漏签日期';
|
||||
$streakDays = $currentStreakDays ?? (int) $dailySignIn->streak_days;
|
||||
|
||||
return '【'.e($user->username).'】使用补签卡补签 '.$signInDate
|
||||
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
|
||||
}
|
||||
|
||||
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
|
||||
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
|
||||
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
|
||||
.'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
|
||||
.'✅ 快速签到</button>';
|
||||
|
||||
return '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成本机签到成功提示文案。
|
||||
*/
|
||||
private function buildSuccessMessage(DailySignIn $dailySignIn): string
|
||||
{
|
||||
return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按实际签到奖励快照生成奖励描述。
|
||||
*/
|
||||
private function buildRewardText(DailySignIn $dailySignIn): string
|
||||
{
|
||||
$items = [];
|
||||
if ($dailySignIn->gold_reward > 0) {
|
||||
$items[] = $dailySignIn->gold_reward.' 金币';
|
||||
}
|
||||
if ($dailySignIn->exp_reward > 0) {
|
||||
$items[] = $dailySignIn->exp_reward.' 经验';
|
||||
}
|
||||
if ($dailySignIn->charm_reward > 0) {
|
||||
$items[] = $dailySignIn->charm_reward.' 魅力';
|
||||
}
|
||||
|
||||
return $items === [] ? '签到记录' : implode(' + ', $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化状态查询响应载荷。
|
||||
*
|
||||
* @param array<string, mixed> $status
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatStatusPayload(User $user, array $status): array
|
||||
{
|
||||
return [
|
||||
'signed_today' => $status['signed_today'],
|
||||
'can_claim' => $status['can_claim'],
|
||||
'current_streak_days' => $status['current_streak_days'],
|
||||
'claimable_streak_days' => $status['claimable_streak_days'],
|
||||
'preview_rule' => $status['matched_rule']?->only([
|
||||
'streak_days',
|
||||
'gold_reward',
|
||||
'exp_reward',
|
||||
'charm_reward',
|
||||
'identity_badge_name',
|
||||
'identity_badge_icon',
|
||||
'identity_badge_color',
|
||||
'identity_duration_days',
|
||||
]),
|
||||
'identity' => $this->formatIdentityPayload($status['current_identity']),
|
||||
'user' => [
|
||||
'jjb' => (int) $user->jjb,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化签到领取响应载荷。
|
||||
*
|
||||
* @param array<string, mixed>|null $presencePayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array
|
||||
{
|
||||
$identity = $user->currentSignInIdentity();
|
||||
|
||||
return [
|
||||
'sign_in' => [
|
||||
'id' => $dailySignIn->id,
|
||||
'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(),
|
||||
'is_makeup' => (bool) $dailySignIn->is_makeup,
|
||||
'streak_days' => (int) $dailySignIn->streak_days,
|
||||
'gold_reward' => (int) $dailySignIn->gold_reward,
|
||||
'exp_reward' => (int) $dailySignIn->exp_reward,
|
||||
'charm_reward' => (int) $dailySignIn->charm_reward,
|
||||
],
|
||||
'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days,
|
||||
'identity' => $this->formatIdentityPayload($identity),
|
||||
'presence' => $presencePayload ?? $this->presenceService->build($user),
|
||||
'user' => [
|
||||
'jjb' => (int) $user->jjb,
|
||||
'gold' => (int) $user->jjb,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化签到身份数据供前端展示。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function formatIdentityPayload(?UserIdentityBadge $identity): ?array
|
||||
{
|
||||
if ($identity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $identity->badge_code,
|
||||
'label' => $identity->badge_name,
|
||||
'name' => $identity->badge_name,
|
||||
'icon' => $identity->badge_icon ?? '✅',
|
||||
'color' => $identity->badge_color ?? '#0f766e',
|
||||
'expires_at' => $identity->expires_at?->toIso8601String(),
|
||||
'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 文件功能:用户赚取金币与经验(观看视频广告等任务)控制器
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class EarnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @var int 每日观看最大次数限制
|
||||
*/
|
||||
private int $maxDailyLimit = 3;
|
||||
|
||||
/**
|
||||
* @var int 每次领奖后至少需要的冷却时间(秒)
|
||||
*/
|
||||
private int $cooldownSeconds = 5;
|
||||
|
||||
/**
|
||||
* 申领看视频的奖励
|
||||
* 成功看完视频后前端发起此请求。
|
||||
* 为防止刷包,必须加上每日总次数及短时频率限制。
|
||||
*/
|
||||
public function claimVideoReward(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '看视频赚钱功能已关闭。',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$userId = $user->id;
|
||||
$dateKey = now()->format('Y-m-d');
|
||||
|
||||
$dailyCountKey = "earn_video:count:{$userId}:{$dateKey}";
|
||||
$cooldownKey = "earn_video:cooldown:{$userId}";
|
||||
|
||||
// 1. 检查冷却时间
|
||||
if (Redis::exists($cooldownKey)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '操作过快,请稍后再试。',
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 检查每日最大次数
|
||||
$todayCount = (int) Redis::get($dailyCountKey);
|
||||
if ($todayCount >= $this->maxDailyLimit) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "今日视频收益次数已达上限(每天最多{$this->maxDailyLimit}次),请明天再来。",
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. 增加今日次数计数
|
||||
$newCount = Redis::incr($dailyCountKey);
|
||||
if ($newCount === 1) {
|
||||
Redis::expire($dailyCountKey, 86400 * 2);
|
||||
}
|
||||
|
||||
// 4. 配置:单次 5000 金币,500 经验
|
||||
$rewardCoins = 5000;
|
||||
$rewardExp = 500;
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
|
||||
// 参照钓鱼逻辑:通过 UserCurrencyService 写日志并变更金币/经验
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $rewardCoins, CurrencySource::VIDEO_REWARD,
|
||||
"看视频赚取金币(第{$newCount}次)", $roomId,
|
||||
);
|
||||
|
||||
$this->currencyService->change(
|
||||
$user, 'exp', $rewardExp, CurrencySource::VIDEO_REWARD,
|
||||
"看视频赚取经验(第{$newCount}次)", $roomId,
|
||||
);
|
||||
|
||||
// 刷新模型以获取 service 原子更新后的最新字段值
|
||||
$user->refresh();
|
||||
|
||||
// 5. 设置冷却时间
|
||||
Redis::setex($cooldownKey, $this->cooldownSeconds, 1);
|
||||
|
||||
// 6. 广播全服系统消息
|
||||
if ($roomId > 0) {
|
||||
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
|
||||
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
|
||||
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
|
||||
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
|
||||
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
|
||||
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "👍 【{$user->username}】刚刚看视频赚取了 {$rewardCoins} 金币 + {$rewardExp} 经验!{$promoTag}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
}
|
||||
|
||||
$remainingToday = $this->maxDailyLimit - $newCount;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "观看完毕!获得 {$rewardCoins} 金币 + {$rewardExp} 经验。今日还可观看 {$remainingToday} 次。",
|
||||
'new_jjb' => $user->jjb,
|
||||
'level_up' => false,
|
||||
'new_level_name' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
|
||||
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈第一页数据(JSON API)
|
||||
* 供聊天室模态弹窗使用,格式与 loadMore 一致
|
||||
*
|
||||
* @param Request $request 含 type 筛选参数
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->input('type'); // bug|suggestion|null(全部)
|
||||
|
||||
$query = FeedbackItem::with(['replies'])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||
$query->ofType($type);
|
||||
}
|
||||
|
||||
$items = $query->limit(self::PAGE_SIZE)->get();
|
||||
|
||||
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||
->whereIn('feedback_id', $items->pluck('id'))
|
||||
->pluck('feedback_id')
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->formatItems($items, $myVotedIds),
|
||||
'last_id' => $items->last()?->id ?? 0,
|
||||
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载更多反馈(JSON API)
|
||||
* 支持按类型筛选(bug / suggestion)
|
||||
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
|
||||
*/
|
||||
private function formatItem(FeedbackItem $item, bool $voted): array
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
$isOwner = $item->user_id === $user->id;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'type' => $item->type,
|
||||
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
|
||||
'username' => $item->username,
|
||||
'created_at' => $item->created_at->diffForHumans(),
|
||||
'voted' => $voted,
|
||||
'is_owner' => $isOwner,
|
||||
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
|
||||
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
||||
'id' => $r->id,
|
||||
'username' => $r->username,
|
||||
|
||||
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\FishingService;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\ShopService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
|
||||
*/
|
||||
class FishingController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ShopService $shopService,
|
||||
private readonly FishingService $fishingService,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -63,6 +72,10 @@ class FishingController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
|
||||
}
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
|
||||
}
|
||||
|
||||
// 1. 检查冷却时间(Redis TTL)
|
||||
$cooldownKey = "fishing:cd:{$user->id}";
|
||||
if (Redis::exists($cooldownKey)) {
|
||||
@@ -75,6 +88,14 @@ class FishingController extends Controller
|
||||
], 429);
|
||||
}
|
||||
|
||||
$tokenKey = "fishing:token:{$user->id}";
|
||||
if (Redis::exists($tokenKey)) {
|
||||
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
|
||||
if ($activeSessionResponse) {
|
||||
return $activeSessionResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查金币是否足够
|
||||
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
||||
if (($user->jjb ?? 0) < $cost) {
|
||||
@@ -84,34 +105,54 @@ class FishingController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 扣除金币
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$cost,
|
||||
CurrencySource::FISHING_COST,
|
||||
"钓鱼抛竿消耗 {$cost} 金币",
|
||||
$id,
|
||||
);
|
||||
$user->refresh();
|
||||
|
||||
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
|
||||
// 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
|
||||
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
||||
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
||||
$waitTime = rand($waitMin, $waitMax);
|
||||
$token = Str::random(32);
|
||||
$tokenKey = "fishing:token:{$user->id}";
|
||||
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
|
||||
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
|
||||
Redis::setex($tokenKey, $waitTime + 13, json_encode([
|
||||
$tokenTtl = $waitTime + 13;
|
||||
$tokenPayload = json_encode([
|
||||
'token' => $token,
|
||||
'cast_at' => time(),
|
||||
'wait_time' => $waitTime,
|
||||
]));
|
||||
]);
|
||||
|
||||
// 5. 生成随机浮漂坐标(百分比,避开边缘)
|
||||
// 原子占用本次抛竿 token,避免多标签页自动钓鱼互相覆盖令牌。
|
||||
$reserved = Redis::command('set', [$tokenKey, $tokenPayload, 'EX', $tokenTtl, 'NX']);
|
||||
if (! $reserved) {
|
||||
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
|
||||
if ($activeSessionResponse) {
|
||||
return $activeSessionResponse;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '钓鱼状态同步中,请稍后重试。',
|
||||
'retry_after' => 3,
|
||||
], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
// token 占用成功后才扣金币,确保重复抛竿不会多扣费用。
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$cost,
|
||||
CurrencySource::FISHING_COST,
|
||||
"钓鱼抛竿消耗 {$cost} 金币",
|
||||
$id,
|
||||
);
|
||||
$user->refresh();
|
||||
} catch (\Throwable $exception) {
|
||||
// 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。
|
||||
Redis::del($tokenKey);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// 4. 生成随机浮漂坐标(百分比,避开边缘)
|
||||
$bobberX = rand(15, 85); // 左右 15%~85%
|
||||
$bobberY = rand(20, 65); // 上下 20%~65%
|
||||
|
||||
// 6. 检查是否持有有效自动钓鱼卡
|
||||
// 5. 检查是否持有有效自动钓鱼卡
|
||||
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
|
||||
return response()->json([
|
||||
@@ -128,6 +169,37 @@ class FishingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复已有钓鱼会话,避免刷新页面后丢失前端内存里的收竿令牌。
|
||||
*/
|
||||
private function restoreActiveFishingSessionResponse(User $user, string $tokenKey): ?JsonResponse
|
||||
{
|
||||
$stored = json_decode((string) Redis::get($tokenKey), true);
|
||||
if (! is_array($stored) || empty($stored['token'])) {
|
||||
Redis::del($tokenKey);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
|
||||
$waitTime = max(0, (int) ($stored['wait_time'] ?? 0) - $elapsed);
|
||||
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '已恢复正在进行的钓鱼,请等待本次收竿。',
|
||||
'wait_time' => $waitTime,
|
||||
'bobber_x' => rand(15, 85),
|
||||
'bobber_y' => rand(20, 65),
|
||||
'token' => (string) $stored['token'],
|
||||
'auto_fishing' => $autoFishingMinutes > 0,
|
||||
'auto_fishing_minutes_left' => $autoFishingMinutes,
|
||||
'cost' => 0,
|
||||
'jjb' => $user->jjb,
|
||||
'restored' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收竿 — 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
|
||||
*
|
||||
@@ -142,6 +214,10 @@ class FishingController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
|
||||
}
|
||||
|
||||
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
|
||||
$tokenKey = "fishing:token:{$user->id}";
|
||||
$storedJson = Redis::get($tokenKey);
|
||||
|
||||
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\FortuneLog;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 类功能:提供神秘占卜状态、抽签和历史接口。
|
||||
*/
|
||||
class FortuneTellingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||
|
||||
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
|
||||
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
|
||||
}
|
||||
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘占卜。'], 403);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||
|
||||
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
|
||||
return response()->json(['history' => []]);
|
||||
}
|
||||
|
||||
$logs = FortuneLog::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->orderByDesc('id')
|
||||
|
||||
@@ -267,19 +267,20 @@ class FriendController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据操作类型和互相状态生成不同文案(含内联快捷操作链接)
|
||||
// 根据操作类型和互相状态生成不同文案(含前端代理快捷操作链接)
|
||||
$btnStyle = 'font-weight:bold;text-decoration:underline;margin-left:6px;';
|
||||
$btnAdd = "<a href='#' onclick=\"quickFriendAction('add','{$fromUsername}',this);return false;\" style='color:#16a34a;{$btnStyle}'>➕ 回加好友</a>";
|
||||
$btnRemove = "<a href='#' onclick=\"quickFriendAction('remove','{$fromUsername}',this);return false;\" style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
|
||||
$safeUsername = e($fromUsername);
|
||||
$btnAdd = "<a href='#' data-quick-friend-action='add' data-quick-friend-username='{$safeUsername}' style='color:#16a34a;{$btnStyle}'>➕ 回加好友</a>";
|
||||
$btnRemove = "<a href='#' data-quick-friend-action='remove' data-quick-friend-username='{$safeUsername}' style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
|
||||
|
||||
$content = match ($action) {
|
||||
'added' => $mutual
|
||||
? "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
|
||||
: "💚 <b>{$fromUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
|
||||
? "💚 <b>{$safeUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
|
||||
: "💚 <b>{$safeUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
|
||||
'removed' => $mutual
|
||||
? "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
|
||||
: "💔 <b>{$fromUsername}</b> 已将你从他的好友列表移除。",
|
||||
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
|
||||
? "💔 <b>{$safeUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
|
||||
: "💔 <b>{$safeUsername}</b> 已将你从他的好友列表移除。",
|
||||
'online' => "🟢 你的好友 <b>{$safeUsername}</b> 上线啦!",
|
||||
default => '',
|
||||
};
|
||||
|
||||
|
||||
@@ -24,17 +24,23 @@ use App\Events\GomokuInviteEvent;
|
||||
use App\Events\GomokuMovedEvent;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\GomokuGame;
|
||||
use App\Models\User;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\GomokuAiService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理五子棋创建、加入与对局过程接口。
|
||||
*/
|
||||
class GomokuController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GomokuAiService $ai,
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -58,6 +64,10 @@ class GomokuController extends Controller
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
|
||||
}
|
||||
|
||||
// PvP:检查是否已在等待/对局中(一次只能参与一场)
|
||||
$activeGame = GomokuGame::query()
|
||||
->where(function ($q) use ($user) {
|
||||
@@ -83,13 +93,27 @@ class GomokuController extends Controller
|
||||
return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse {
|
||||
// PvE 扣除入场费
|
||||
if ($entryFee > 0) {
|
||||
// 1. 悲观锁锁定用户行
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
// 2. 二次确认金币是否足够
|
||||
if ((int) $lockedUser->jjb < $entryFee) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法加入游戏对局。']);
|
||||
}
|
||||
|
||||
$this->currency->change(
|
||||
$user,
|
||||
$lockedUser,
|
||||
'gold',
|
||||
-$entryFee,
|
||||
CurrencySource::GOMOKU_ENTRY_FEE,
|
||||
"五子棋 AI 对战入场费(难度{$data['ai_level']})",
|
||||
);
|
||||
|
||||
// 同步修改 Auth 内存实例的金币
|
||||
$user->setAttribute('jjb', $lockedUser->jjb);
|
||||
}
|
||||
|
||||
$timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60);
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
|
||||
use App\Models\Guestbook;
|
||||
use App\Models\User;
|
||||
use App\Services\MessageFilterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
|
||||
|
||||
return back()->with('success', '该行留言已被抹除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$tab = $request->input('tab', 'public');
|
||||
$page = (int) $request->input('page', 1);
|
||||
$user = Auth::user();
|
||||
|
||||
$query = Guestbook::query()->orderByDesc('id');
|
||||
|
||||
if ($tab === 'inbox') {
|
||||
$query->where('towho', $user->username);
|
||||
} elseif ($tab === 'outbox') {
|
||||
$query->where('who', $user->username);
|
||||
} else {
|
||||
$query->where(function ($q) use ($user) {
|
||||
$q->where('secret', 0)
|
||||
->orWhere('who', $user->username)
|
||||
->orWhere('towho', $user->username);
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = 15;
|
||||
$total = $query->count();
|
||||
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
|
||||
|
||||
$items = $messages->map(function ($msg) use ($user) {
|
||||
$isSecret = (bool) $msg->secret;
|
||||
$isToMe = $msg->towho === $user->username;
|
||||
$isFromMe = $msg->who === $user->username;
|
||||
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
|
||||
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'who' => $msg->who,
|
||||
'towho' => $msg->towho ?: '',
|
||||
'secret' => $isSecret,
|
||||
'text_body' => $msg->text_body,
|
||||
'post_time' => $msg->post_time?->diffForHumans() ?? '',
|
||||
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
|
||||
'is_to_me' => $isToMe,
|
||||
'is_from_me' => $isFromMe,
|
||||
'can_delete' => $canDelete,
|
||||
'who_avatar' => mb_substr($msg->who, 0, 1),
|
||||
];
|
||||
});
|
||||
|
||||
// 获取所有用户名列表(供发信选择器使用)
|
||||
$users = User::where('username', '!=', $user->username)
|
||||
->orderBy('username')
|
||||
->pluck('username');
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'has_more' => ($page * $perPage) < $total,
|
||||
'users' => $users,
|
||||
'tab' => $tab,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,20 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\HolidayClaim;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理节日福利批次的前台领取与状态查询。
|
||||
*/
|
||||
class HolidayController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入用户金币服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
@@ -30,56 +36,72 @@ class HolidayController extends Controller
|
||||
/**
|
||||
* 用户领取节日福利红包。
|
||||
*
|
||||
* 从 holiday_claims 中查找当前用户的待领取记录,
|
||||
* 入账金币并更新活动统计数据。
|
||||
* 从 holiday_claims 中查找当前用户在指定批次下的待领取记录,
|
||||
* 入账金币并更新批次统计数据。
|
||||
*/
|
||||
public function claim(Request $request, HolidayEvent $event): JsonResponse
|
||||
public function claim(Request $request, HolidayEventRun $run): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 活动是否在领取有效期内
|
||||
if (! $event->isClaimable()) {
|
||||
// 批次是否在领取有效期内。
|
||||
if (! $run->isClaimable()) {
|
||||
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
}
|
||||
|
||||
// 查找该用户的领取记录(批量插入时已生成)
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
return DB::transaction(function () use ($run, $user): JsonResponse {
|
||||
/** @var HolidayEventRun|null $lockedRun */
|
||||
$lockedRun = HolidayEventRun::query()
|
||||
->whereKey($run->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $claim) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
if (! $lockedRun || ! $lockedRun->isClaimable()) {
|
||||
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
}
|
||||
|
||||
// 防止重复领取(claimed_at 为 null 表示未领取)
|
||||
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
|
||||
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
|
||||
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
|
||||
// 金币入账
|
||||
/** @var HolidayClaim|null $claim */
|
||||
$claim = HolidayClaim::query()
|
||||
->where('run_id', $lockedRun->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $claim) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
|
||||
// claimed_at 不为空代表本轮已领过,直接返回幂等提示。
|
||||
if ($claim->claimed_at !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '您已领取过本轮福利。',
|
||||
'amount' => $claim->amount,
|
||||
]);
|
||||
}
|
||||
|
||||
// 金币入账。
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
$claim->amount,
|
||||
CurrencySource::HOLIDAY_BONUS,
|
||||
"节日福利:{$event->name}",
|
||||
"节日福利:{$lockedRun->event_name}",
|
||||
);
|
||||
|
||||
// 更新活动统计(只在首次领取时)
|
||||
HolidayEvent::query()
|
||||
->where('id', $event->id)
|
||||
->increment('claimed_amount', $claim->amount);
|
||||
// 领取成功后只更新 claimed_at,不再删除记录,便于幂等和历史追踪。
|
||||
$claim->update(['claimed_at' => now()]);
|
||||
|
||||
// 删除领取记录(以此标记"已领取",防止重复调用)
|
||||
$claim->delete();
|
||||
// 批次领取统计按成功领取次数累计。
|
||||
$lockedRun->increment('claimed_count');
|
||||
$lockedRun->increment('claimed_amount', $claim->amount);
|
||||
|
||||
// 检查是否已全部领完
|
||||
if ($event->max_claimants > 0) {
|
||||
$remaining = HolidayClaim::where('event_id', $event->id)->count();
|
||||
if ($remaining === 0) {
|
||||
$event->update(['status' => 'completed']);
|
||||
}
|
||||
$remainingPendingClaims = HolidayClaim::query()
|
||||
->where('run_id', $lockedRun->id)
|
||||
->whereNull('claimed_at')
|
||||
->count();
|
||||
|
||||
if ($remainingPendingClaims === 0) {
|
||||
$lockedRun->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -91,21 +113,23 @@ class HolidayController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户在指定活动中的待领取状态。
|
||||
* 查询当前用户在指定批次中的待领取状态。
|
||||
*/
|
||||
public function status(Request $request, HolidayEvent $event): JsonResponse
|
||||
public function status(Request $request, HolidayEventRun $run): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('run_id', $run->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'claimable' => $claim !== null && $event->isClaimable(),
|
||||
'claimable' => $claim !== null && $claim->claimed_at === null && $run->isClaimable(),
|
||||
'claimed' => $claim?->claimed_at !== null,
|
||||
'amount' => $claim?->amount ?? 0,
|
||||
'expires_at' => $event->expires_at?->toIso8601String(),
|
||||
'status' => $run->status,
|
||||
'expires_at' => $run->expires_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,25 @@ use App\Enums\CurrencySource;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\HorseBet;
|
||||
use App\Models\HorseRace;
|
||||
use App\Services\GameBetBroadcastService;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:赛马竞猜前台控制器
|
||||
*
|
||||
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
|
||||
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
|
||||
*/
|
||||
class HorseRaceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
private readonly GameBetBroadcastService $betBroadcastService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -36,13 +46,26 @@ class HorseRaceController extends Controller
|
||||
*/
|
||||
public function currentRace(Request $request): JsonResponse
|
||||
{
|
||||
$race = HorseRace::currentRace();
|
||||
|
||||
if (! $race) {
|
||||
return response()->json(['race' => null]);
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
|
||||
}
|
||||
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
|
||||
return response()->json(['race' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
|
||||
}
|
||||
|
||||
$race = $this->resolveCurrentRaceState(HorseRace::currentRace($roomId));
|
||||
|
||||
if (! $race) {
|
||||
return response()->json([
|
||||
'race' => null,
|
||||
// 即使当前无赛马场次,也返回最新金币余额,供前端打开弹窗时刷新显示。
|
||||
'jjb' => (int) ($user->jjb ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$myBet = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
->where('user_id', $user->id)
|
||||
@@ -51,6 +74,7 @@ class HorseRaceController extends Controller
|
||||
// 计算各马匹当前注额
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
||||
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||
|
||||
$horsePools = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
@@ -59,23 +83,36 @@ class HorseRaceController extends Controller
|
||||
->pluck('pool', 'horse_id')
|
||||
->toArray();
|
||||
|
||||
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
|
||||
|
||||
// 计算实时赔率
|
||||
$horses = $race->horses ?? [];
|
||||
$horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) {
|
||||
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
|
||||
$totalPool = array_sum(array_values($horsePools));
|
||||
$netPool = $totalPool * (1 - $houseTake / 100);
|
||||
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
|
||||
$horses = $this->normalizeRaceHorses($race->horses);
|
||||
$horsesWithBets = array_map(function (array $horse) use ($horsePools, $oddsMap) {
|
||||
$horseId = (int) $horse['id'];
|
||||
$horsePool = (int) ($horsePools[$horseId] ?? 0);
|
||||
$odds = $horsePool > 0 ? ($oddsMap[$horseId] ?? null) : null;
|
||||
|
||||
return [
|
||||
'id' => $horse['id'],
|
||||
'name' => $horse['name'],
|
||||
'emoji' => $horse['emoji'],
|
||||
'id' => $horseId,
|
||||
'name' => (string) $horse['name'],
|
||||
'emoji' => (string) $horse['emoji'],
|
||||
'pool' => $horsePool,
|
||||
'odds' => $odds,
|
||||
];
|
||||
}, $horses);
|
||||
|
||||
// 押注阶段实时总池 = 当前记录的基础池(通常为种子池)+ 实时下注总额;
|
||||
// 跑马/结算阶段 total_pool 已写回最终值,不能再重复叠加下注额。
|
||||
$basePool = $race->status === 'betting'
|
||||
? max((int) $race->total_pool, $seedPool)
|
||||
: (int) $race->total_pool;
|
||||
$displayTotalPool = $race->status === 'betting'
|
||||
? $basePool + array_sum(array_values($horsePools))
|
||||
: $basePool;
|
||||
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
$maxBet = (int) ($config['max_bet'] ?? 100000);
|
||||
|
||||
return response()->json([
|
||||
'race' => [
|
||||
'id' => $race->id,
|
||||
@@ -85,12 +122,16 @@ class HorseRaceController extends Controller
|
||||
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
|
||||
: 0,
|
||||
'horses' => $horsesWithBets,
|
||||
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
|
||||
'total_pool' => $displayTotalPool,
|
||||
'min_bet' => $minBet,
|
||||
'max_bet' => $maxBet,
|
||||
'my_bet' => $myBet ? [
|
||||
'horse_id' => $myBet->horse_id,
|
||||
'amount' => $myBet->amount,
|
||||
] : null,
|
||||
],
|
||||
// 返回当前用户最新金币,确保弹窗右上角余额每次打开都以服务端最新值为准。
|
||||
'jjb' => (int) ($user->jjb ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -110,6 +151,11 @@ class HorseRaceController extends Controller
|
||||
'horse_id' => 'required|integer|min:1',
|
||||
'amount' => 'required|integer|min:1',
|
||||
]);
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启赛马竞猜。'], 403);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
@@ -121,12 +167,12 @@ class HorseRaceController extends Controller
|
||||
|
||||
$race = HorseRace::find($data['race_id']);
|
||||
|
||||
if (! $race || ! $race->isBettingOpen()) {
|
||||
if (! $race || (int) $race->room_id !== $roomId || ! $race->isBettingOpen()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||
}
|
||||
|
||||
// 验证马匹 ID 是否有效
|
||||
$horses = $race->horses ?? [];
|
||||
$horses = $this->normalizeRaceHorses($race->horses);
|
||||
$validIds = array_column($horses, 'id');
|
||||
if (! in_array($data['horse_id'], $validIds, true)) {
|
||||
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
|
||||
@@ -154,12 +200,7 @@ class HorseRaceController extends Controller
|
||||
|
||||
// 找出马匹名称
|
||||
$horseName = '';
|
||||
foreach ($horses as $horse) {
|
||||
if ((int) $horse['id'] === (int) $data['horse_id']) {
|
||||
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$horseName = $this->resolveHorseDisplayName($horses, (int) $data['horse_id']);
|
||||
|
||||
// 扣除金币
|
||||
$currency->change(
|
||||
@@ -179,6 +220,8 @@ class HorseRaceController extends Controller
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已押注「{$horseName}」{$data['amount']} 金币,等待开跑!",
|
||||
@@ -193,7 +236,9 @@ class HorseRaceController extends Controller
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
|
||||
$races = HorseRace::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
@@ -202,9 +247,9 @@ class HorseRaceController extends Controller
|
||||
// 转换获胜马匹名称
|
||||
$history = $races->map(function ($race) {
|
||||
$winnerName = '未知';
|
||||
foreach (($race->horses ?? []) as $horse) {
|
||||
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
|
||||
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
foreach ($this->normalizeRaceHorses($race->horses) as $horse) {
|
||||
if ((int) $horse['id'] === (int) $race->winner_horse_id) {
|
||||
$winnerName = (string) $horse['emoji'].(string) $horse['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -221,4 +266,169 @@ class HorseRaceController extends Controller
|
||||
|
||||
return response()->json(['history' => $history]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自愈当前场次状态,避免线上遗漏结算时长期卡在 running。
|
||||
*/
|
||||
private function resolveCurrentRaceState(?HorseRace $race): ?HorseRace
|
||||
{
|
||||
if (! $race || $race->status !== 'running') {
|
||||
return $race;
|
||||
}
|
||||
|
||||
if (! $this->shouldRecoverStaleRunningRace($race)) {
|
||||
return $race;
|
||||
}
|
||||
|
||||
$race = $this->prepareRunningRaceForSettlement($race);
|
||||
if (! $race || $race->status !== 'running' || ! $race->winner_horse_id) {
|
||||
return $race;
|
||||
}
|
||||
|
||||
// 线上若漏消费 CloseHorseRaceJob,这里同步补做一次结算,避免界面一直显示“跑马中”。
|
||||
app()->call([new \App\Jobs\CloseHorseRaceJob($race), 'handle']);
|
||||
|
||||
return HorseRace::currentRace((int) $race->room_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 running 场次是否已经超过合理比赛时长,需要请求侧补偿收尾。
|
||||
*/
|
||||
private function shouldRecoverStaleRunningRace(HorseRace $race): bool
|
||||
{
|
||||
if (! $race->race_starts_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$raceDuration = max(1, (int) ($config['race_duration'] ?? 30));
|
||||
$recoveryGraceSeconds = 5;
|
||||
|
||||
return $race->race_starts_at->lte(now()->subSeconds($raceDuration + $recoveryGraceSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为超时 running 场次补齐缺失赛果字段,确保后续结算任务可以安全执行。
|
||||
*/
|
||||
private function prepareRunningRaceForSettlement(HorseRace $race): ?HorseRace
|
||||
{
|
||||
if ($race->winner_horse_id && $race->race_ends_at) {
|
||||
return $race->fresh();
|
||||
}
|
||||
|
||||
$horses = $this->normalizeRaceHorses($race->horses);
|
||||
$winnerHorseId = $race->winner_horse_id ?: $this->resolveStaleRunningWinnerId($race, $horses);
|
||||
if (! $winnerHorseId) {
|
||||
return $race;
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||
|
||||
// 线上补偿场景下以当前下注快照补齐统计,确保本次请求内的结算口径与正常流程一致。
|
||||
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
|
||||
$totalPool = $seedPool + (int) HorseBet::query()->where('race_id', $race->id)->sum('amount');
|
||||
|
||||
HorseRace::query()
|
||||
->where('id', $race->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'winner_horse_id' => $winnerHorseId,
|
||||
'race_ends_at' => $race->race_ends_at ?? now(),
|
||||
'total_bets' => $totalBets,
|
||||
'total_pool' => $totalPool,
|
||||
]);
|
||||
|
||||
return $race->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为异常滞留的 running 场次推导一个稳定冠军,避免多次请求得到不同结算结果。
|
||||
*
|
||||
* @param array<int, array{id:int,name:string,emoji:string}> $horses
|
||||
*/
|
||||
private function resolveStaleRunningWinnerId(HorseRace $race, array $horses): ?int
|
||||
{
|
||||
if ($horses === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$horsePools = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
->groupBy('horse_id')
|
||||
->selectRaw('horse_id, SUM(amount) as pool')
|
||||
->pluck('pool', 'horse_id')
|
||||
->map(fn ($pool) => (int) $pool)
|
||||
->toArray();
|
||||
|
||||
$candidateIds = array_map(
|
||||
fn (array $horse): int => (int) $horse['id'],
|
||||
$horses,
|
||||
);
|
||||
|
||||
usort($candidateIds, function (int $leftId, int $rightId) use ($horsePools): int {
|
||||
$leftPool = (int) ($horsePools[$leftId] ?? 0);
|
||||
$rightPool = (int) ($horsePools[$rightId] ?? 0);
|
||||
|
||||
if ($leftPool === $rightPool) {
|
||||
return $leftId <=> $rightId;
|
||||
}
|
||||
|
||||
return $rightPool <=> $leftPool;
|
||||
});
|
||||
|
||||
return $candidateIds[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
|
||||
*
|
||||
* @return array<int, array{id:int,name:string,emoji:string}>
|
||||
*/
|
||||
private function normalizeRaceHorses(mixed $horses): array
|
||||
{
|
||||
if (! is_array($horses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalizedHorses = [];
|
||||
|
||||
foreach ($horses as $index => $horse) {
|
||||
if (! is_array($horse)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$horseId = isset($horse['id']) && is_numeric($horse['id'])
|
||||
? (int) $horse['id']
|
||||
: $index + 1;
|
||||
$horseName = trim((string) ($horse['name'] ?? ''));
|
||||
if ($horseName === '') {
|
||||
$horseName = '未知马匹';
|
||||
}
|
||||
|
||||
$normalizedHorses[] = [
|
||||
'id' => $horseId,
|
||||
'name' => $horseName,
|
||||
'emoji' => (string) ($horse['emoji'] ?? '🐎'),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalizedHorses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据马匹编号返回展示名称,供系统播报与下注回执共用。
|
||||
*
|
||||
* @param array<int, array{id:int,name:string,emoji:string}> $horses
|
||||
*/
|
||||
private function resolveHorseDisplayName(array $horses, int $horseId): string
|
||||
{
|
||||
foreach ($horses as $horse) {
|
||||
if ((int) ($horse['id'] ?? 0) === $horseId) {
|
||||
return (string) ($horse['emoji'] ?? '🐎').(string) ($horse['name'] ?? '未知马匹');
|
||||
}
|
||||
}
|
||||
|
||||
return '🐎未知马匹';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\User;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:展示全站排行榜、今日排行榜与用户个人积分流水记录。
|
||||
*/
|
||||
class LeaderboardController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -133,8 +137,15 @@ class LeaderboardController extends Controller
|
||||
$user = auth()->user();
|
||||
$currency = request('currency');
|
||||
$days = (int) request('days', 7);
|
||||
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
|
||||
$direction = in_array(request('direction'), ['income', 'expense'], true) ? request('direction') : null;
|
||||
$sourceOptions = CurrencySource::cases();
|
||||
$allowedSources = collect($sourceOptions)->map(fn (CurrencySource $source) => $source->value)->all();
|
||||
$selectedSources = collect(request()->array('sources'))
|
||||
->filter(fn (string $source) => in_array($source, $allowedSources, true))
|
||||
->values()
|
||||
->all();
|
||||
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days, $direction, $selectedSources);
|
||||
|
||||
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
|
||||
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days', 'direction', 'sourceOptions', 'selectedSources'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\LotteryIssue;
|
||||
use App\Models\LotteryTicket;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\LotteryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 类功能:提供双色球当前期、购票和历史记录接口。
|
||||
*/
|
||||
class LotteryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LotteryService $lottery,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
|
||||
*/
|
||||
public function current(): JsonResponse
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('lottery')) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
|
||||
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
|
||||
return response()->json(['enabled' => false, 'message' => '当前房间未开启双色球彩票。'], 403);
|
||||
}
|
||||
|
||||
$issue = LotteryIssue::currentIssue($roomId) ?? LotteryIssue::latestIssue($roomId);
|
||||
|
||||
if (! $issue) {
|
||||
return response()->json(['enabled' => true, 'issue' => null]);
|
||||
@@ -90,6 +101,11 @@ class LotteryController extends Controller
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前房间未开启双色球彩票。'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'numbers' => 'required|array|min:1',
|
||||
'numbers.*.reds' => 'required|array|size:3',
|
||||
@@ -132,7 +148,9 @@ class LotteryController extends Controller
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
|
||||
$issues = LotteryIssue::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('status', 'settled')
|
||||
->latest()
|
||||
->limit(20)
|
||||
|
||||
@@ -46,6 +46,29 @@ class MarriageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全站已婚列表(按亲密度或结婚时间排序)。
|
||||
*/
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$marriages = Marriage::query()
|
||||
->where('status', 'married')
|
||||
->with(['user:id,username,usersf,sex', 'partner:id,username,usersf,sex', 'ringItem:id,name,icon'])
|
||||
->orderByDesc('intimacy')
|
||||
->orderByDesc('married_at')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $marriages->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $marriages->currentPage(),
|
||||
'last_page' => $marriages->lastPage(),
|
||||
'total' => $marriages->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的婚姻状态(名片/用户列表用)。
|
||||
*/
|
||||
@@ -58,7 +81,7 @@ class MarriageController extends Controller
|
||||
return response()->json(['married' => false]);
|
||||
}
|
||||
|
||||
$marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']);
|
||||
$marriage->load(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,slug,icon']);
|
||||
|
||||
return response()->json([
|
||||
'married' => $marriage->status === 'married',
|
||||
@@ -95,7 +118,7 @@ class MarriageController extends Controller
|
||||
->where(function ($q) use ($target) {
|
||||
$q->where('user_id', $target->id)->orWhere('partner_id', $target->id);
|
||||
})
|
||||
->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon'])
|
||||
->with(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,icon'])
|
||||
->first();
|
||||
|
||||
if (! $marriage) {
|
||||
|
||||
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Models\MysteryBoxClaim;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
|
||||
*/
|
||||
class MysteryBoxController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
$box = MysteryBox::currentOpenBox();
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
$box = MysteryBox::currentOpenBox($roomId);
|
||||
|
||||
if (! $box) {
|
||||
return response()->json(['active' => false]);
|
||||
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
|
||||
|
||||
return DB::transaction(function () use ($user, $passcode): JsonResponse {
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘箱子。'], 403);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $passcode, $roomId): JsonResponse {
|
||||
// 查找匹配暗号的可领取箱子(加锁防并发)
|
||||
$box = MysteryBox::query()
|
||||
->where('room_id', $roomId)
|
||||
->where('passcode', $passcode)
|
||||
->where('status', 'open')
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
|
||||
$typeName = $box->typeName();
|
||||
|
||||
if ($reward >= 0) {
|
||||
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}!"
|
||||
.'获得 💰'.number_format($reward).' 金币!';
|
||||
$content = "{$emoji} 【{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
|
||||
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
||||
} else {
|
||||
$content = "☠️【黑化陷阱】haha!【{$username}】 中了神秘黑化箱的陷阱!"
|
||||
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
|
||||
$content = "☠️ 【{$username}】踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
|
||||
$color = '#f87171';
|
||||
}
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'id' => $this->chatState->nextMessageId((int) $box->room_id),
|
||||
'room_id' => (int) $box->room_id,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
$this->chatState->pushMessage((int) $box->room_id, $msg);
|
||||
broadcast(new MessageSent((int) $box->room_id, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台邮箱找回密码控制器
|
||||
*
|
||||
* 提供独立的找回密码页、发送邮箱重置链接、展示重置页以及提交新密码功能。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ResetPasswordRequest;
|
||||
use App\Http\Requests\SendPasswordResetLinkRequest;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 类功能:处理首页邮箱找回密码的完整流程。
|
||||
*/
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
/**
|
||||
* 展示独立的邮箱找回密码页面。
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('password-forgot', [
|
||||
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
|
||||
'smtpEnabled' => $this->isPasswordResetMailEnabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号检测接口:根据昵称检测是否绑定微信或邮箱,并提供分流依据(支持 IP 防扫限流)。
|
||||
*/
|
||||
public function checkAccount(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string|max:100',
|
||||
]);
|
||||
|
||||
$username = trim((string) $request->input('username'));
|
||||
$ip = $request->ip();
|
||||
|
||||
// IP 防扫描节流限制:每个 IP 每 1 分钟最多请求 5 次
|
||||
$ipKey = 'pw-check:ip:'.$ip;
|
||||
if (RateLimiter::tooManyAttempts($ipKey, 5)) {
|
||||
$seconds = RateLimiter::availableIn($ipKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "账号检测请求过于频繁,请在 {$seconds} 秒后重试。",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($ipKey, 60);
|
||||
|
||||
$user = User::query()->where('username', $username)->first();
|
||||
if (! $user) {
|
||||
return response()->json([
|
||||
'status' => 'not_found',
|
||||
'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。',
|
||||
]);
|
||||
}
|
||||
|
||||
$hasEmail = ! empty($user->email);
|
||||
$hasWechat = ! empty($user->wxid);
|
||||
|
||||
// 对邮箱地址进行安全脱敏(如 pllx@ay.lc -> p***x@ay.lc)
|
||||
$maskedEmail = '';
|
||||
if ($hasEmail) {
|
||||
$parts = explode('@', $user->email);
|
||||
$name = $parts[0] ?? '';
|
||||
$domain = $parts[1] ?? '';
|
||||
$len = strlen($name);
|
||||
if ($len <= 2) {
|
||||
$maskedEmail = substr($name, 0, 1).'*'.'@'.$domain;
|
||||
} else {
|
||||
$maskedEmail = substr($name, 0, 1).str_repeat('*', $len - 2).substr($name, -1).'@'.$domain;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'username' => $user->username,
|
||||
'has_email' => $hasEmail,
|
||||
'has_wechat' => $hasWechat,
|
||||
'masked_email' => $maskedEmail,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱找回密码链接。
|
||||
*/
|
||||
public function storeLink(SendPasswordResetLinkRequest $request): JsonResponse
|
||||
{
|
||||
if (! $this->isPasswordResetMailEnabled()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '系统暂未开启邮箱发信服务,当前无法通过邮箱找回密码。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$inputEmail = trim((string) $request->input('email', ''));
|
||||
$username = trim((string) $request->input('username', ''));
|
||||
$ip = $request->ip();
|
||||
|
||||
$user = User::query()->where('username', $username)->first();
|
||||
if (! $user || empty($user->email)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '找不到绑定了邮箱的账号。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 强行双向比对核对(忽略大小写和前后空白)
|
||||
if (strcasecmp($user->email, $inputEmail) !== 0) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$email = $user->email;
|
||||
|
||||
// 1. IP 级别发信限流:限制单个 IP 每分钟最多请求 2 次
|
||||
$ipKey = 'pw-email:ip:'.$ip;
|
||||
if (RateLimiter::tooManyAttempts($ipKey, 2)) {
|
||||
$seconds = RateLimiter::availableIn($ipKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "发送验证链接过于频繁,请在 {$seconds} 秒后重试。",
|
||||
], 429);
|
||||
}
|
||||
|
||||
// 2. 账号邮箱级别冷却锁:同一个邮箱每 3 分钟限发 1 次,防止狂刷邮件轰炸他人
|
||||
$targetKey = 'pw-email:target:'.md5($email);
|
||||
if (RateLimiter::tooManyAttempts($targetKey, 1)) {
|
||||
$seconds = RateLimiter::availableIn($targetKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "该账号申请重置链接过于频繁,请在 {$seconds} 秒后重试。",
|
||||
], 429);
|
||||
}
|
||||
|
||||
// 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。
|
||||
if (User::query()->where('email', $email)->count() > 1) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '该邮箱绑定了多个账号,暂不支持自助找回,请联系管理员处理。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 记录发信请求频率
|
||||
RateLimiter::hit($ipKey, 60);
|
||||
RateLimiter::hit($targetKey, 180);
|
||||
|
||||
$status = Password::sendResetLink(['email' => $email]);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。链接 60 分钟内有效,请注意查收。',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($status === Password::RESET_THROTTLED) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '发送过于频繁,请稍后再试。',
|
||||
], 429);
|
||||
}
|
||||
|
||||
if ($status === Password::INVALID_USER) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。请检查收件箱与垃圾邮件箱。',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '找回密码邮件发送失败,请稍后重试。',
|
||||
], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示独立的重置密码页面。
|
||||
*/
|
||||
public function edit(Request $request, string $token): View
|
||||
{
|
||||
return view('password-reset', [
|
||||
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
|
||||
'token' => $token,
|
||||
'email' => (string) $request->query('email', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交新的登录密码并完成重置。
|
||||
*/
|
||||
public function update(ResetPasswordRequest $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
|
||||
$status = Password::reset(
|
||||
$credentials,
|
||||
function (User $user, #[\SensitiveParameter] string $password): void {
|
||||
// 重置成功后同步刷新 remember_token,避免旧设备继续沿用旧令牌。
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
return redirect()->route('home')->with('status', '密码已重置成功,请使用原昵称和新密码重新登录。');
|
||||
}
|
||||
|
||||
return back()
|
||||
->withInput($request->except('password', 'password_confirmation'))
|
||||
->withErrors([
|
||||
'email' => $this->resolveResetFailureMessage($status),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断系统是否已开启邮箱发信服务。
|
||||
*/
|
||||
private function isPasswordResetMailEnabled(): bool
|
||||
{
|
||||
return Sysparam::where('alias', 'smtp_enabled')->value('body') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Laravel 密码重置状态码转换为中文错误提示。
|
||||
*/
|
||||
private function resolveResetFailureMessage(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
Password::INVALID_TOKEN => '重置链接无效或已过期,请重新申请邮箱找回。',
|
||||
Password::INVALID_USER => '该邮箱未绑定可重置的账号,请确认后再试。',
|
||||
default => '密码重置失败,请重新获取重置链接后再试。',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
* 文件功能:聊天室礼包(红包)控制器
|
||||
*
|
||||
* 提供两个核心接口:
|
||||
* - send() :superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
|
||||
* - config():读取当前职务的默认礼包数量与份数
|
||||
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
|
||||
* - claim() :在线用户抢礼包(先到先得,每人一份)
|
||||
*
|
||||
* 接入 UserCurrencyService 记录所有货币变动流水。
|
||||
@@ -23,22 +24,30 @@ use App\Events\RedPacketSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\RedPacketClaim;
|
||||
use App\Models\RedPacketEnvelope;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\GameBetBroadcastService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室礼包的发包、查状态与抢包流程
|
||||
*
|
||||
* 负责礼包主记录创建、Redis 拆包金额管理、领取入账以及实时广播。
|
||||
*/
|
||||
class RedPacketController extends Controller
|
||||
{
|
||||
/** 礼包固定总数量 */
|
||||
private const TOTAL_AMOUNT = 888;
|
||||
/** 礼包默认总数量 */
|
||||
private const DEFAULT_TOTAL_AMOUNT = 8888;
|
||||
|
||||
/** 礼包固定份数 */
|
||||
private const TOTAL_COUNT = 10;
|
||||
/** 礼包默认份数 */
|
||||
private const DEFAULT_TOTAL_COUNT = 10;
|
||||
|
||||
/** 礼包有效期(秒) */
|
||||
private const EXPIRE_SECONDS = 300;
|
||||
@@ -49,12 +58,38 @@ class RedPacketController extends Controller
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
private readonly GameBetBroadcastService $betBroadcastService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* superlevel 站长凭空发出礼包。
|
||||
* 获取当前用户可发出的礼包默认配置。
|
||||
*
|
||||
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
|
||||
* 聊天室发包弹窗打开时调用,确保页面展示与最终发包数量同源。
|
||||
*/
|
||||
public function config(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 仅拥有礼包红包权限的在职职务可以读取发包配置。
|
||||
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
|
||||
}
|
||||
|
||||
$redPacketConfig = $this->redPacketConfigForUser($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'amount' => $redPacketConfig['amount'],
|
||||
'count' => $redPacketConfig['count'],
|
||||
'expire_seconds' => self::EXPIRE_SECONDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拥有权限的职务用户凭空发出礼包。
|
||||
*
|
||||
* 不扣发包人自身货币,礼包总量和份数读取当前在职职务配置。
|
||||
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
|
||||
*
|
||||
* @param Request $request 需包含 room_id 和 type(gold / exp)
|
||||
@@ -70,12 +105,15 @@ class RedPacketController extends Controller
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type'); // 'gold' 或 'exp'
|
||||
|
||||
// 权限校验:仅 superlevel 可发礼包
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if ($user->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
|
||||
// 改为按职务权限码控制礼包发放。
|
||||
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
|
||||
}
|
||||
|
||||
$redPacketConfig = $this->redPacketConfigForUser($user);
|
||||
$totalAmount = $redPacketConfig['amount'];
|
||||
$totalCount = $redPacketConfig['count'];
|
||||
|
||||
// 检查该用户在此房间是否有进行中的红包(防止刷包)
|
||||
$activeExists = RedPacketEnvelope::query()
|
||||
->where('sender_id', $user->id)
|
||||
@@ -88,8 +126,8 @@ class RedPacketController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
|
||||
}
|
||||
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT)
|
||||
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于职务配置总量)
|
||||
$amounts = $this->splitAmount($totalAmount, $totalCount);
|
||||
|
||||
// 货币展示文案
|
||||
$typeLabel = $type === 'exp' ? '经验' : '金币';
|
||||
@@ -99,15 +137,15 @@ class RedPacketController extends Controller
|
||||
: 'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
// 事务:创建红包记录 + Redis 写入分额
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts, $totalAmount, $totalCount): RedPacketEnvelope {
|
||||
// 创建红包主记录(凭空发出,不扣发包人货币)
|
||||
$envelope = RedPacketEnvelope::create([
|
||||
'sender_id' => $user->id,
|
||||
'sender_username' => $user->username,
|
||||
'room_id' => $roomId,
|
||||
'type' => $type,
|
||||
'total_amount' => self::TOTAL_AMOUNT,
|
||||
'total_count' => self::TOTAL_COUNT,
|
||||
'total_amount' => $totalAmount,
|
||||
'total_count' => $totalCount,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'status' => 'active',
|
||||
@@ -132,8 +170,8 @@ class RedPacketController extends Controller
|
||||
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
|
||||
.$envelope->id
|
||||
.',\''.$user->username.'\','
|
||||
.self::TOTAL_AMOUNT.','
|
||||
.self::TOTAL_COUNT.','
|
||||
.$totalAmount.','
|
||||
.$totalCount.','
|
||||
.self::EXPIRE_SECONDS
|
||||
.',\''.$type.'\''
|
||||
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
|
||||
@@ -145,7 +183,7 @@ class RedPacketController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>{$totalAmount}</b> {$typeLabel}的礼包!共 {$totalCount} 份,先到先得,快去抢!{$btnHtml}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
|
||||
'action' => '',
|
||||
@@ -160,15 +198,15 @@ class RedPacketController extends Controller
|
||||
roomId: $roomId,
|
||||
envelopeId: $envelope->id,
|
||||
senderUsername: $user->username,
|
||||
totalAmount: self::TOTAL_AMOUNT,
|
||||
totalCount: self::TOTAL_COUNT,
|
||||
totalAmount: $totalAmount,
|
||||
totalCount: $totalCount,
|
||||
expireSeconds: self::EXPIRE_SECONDS,
|
||||
type: $type,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!{$totalAmount} {$typeLabel} · {$totalCount} 份",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -307,26 +345,23 @@ class RedPacketController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 广播领取事件(给自己的私有频道,前端弹 Toast)
|
||||
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
|
||||
// 重新读取红包统计,确保广播与响应使用的是最新剩余份数。
|
||||
$envelope->refresh();
|
||||
$remainingCount = $envelope->remainingCount();
|
||||
|
||||
// 在聊天室发送领取播报(所有人可见)
|
||||
// 广播领取事件:房间内所有在线用户实时刷新剩余份数,领取者本人同步收到到账通知。
|
||||
broadcast(new RedPacketClaimed(
|
||||
claimer: $user,
|
||||
amount: $amount,
|
||||
envelopeId: $envelope->id,
|
||||
roomId: $envelope->room_id,
|
||||
remainingCount: $remainingCount,
|
||||
type: $envelopeType,
|
||||
));
|
||||
|
||||
// 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
|
||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
|
||||
$claimedMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $claimedMsg);
|
||||
broadcast(new MessageSent($roomId, $claimedMsg));
|
||||
SaveMessageJob::dispatch($claimedMsg);
|
||||
$this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
|
||||
|
||||
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
||||
$balanceNow = $user->fresh()->$balanceField;
|
||||
@@ -335,6 +370,7 @@ class RedPacketController extends Controller
|
||||
'status' => 'success',
|
||||
'amount' => $amount,
|
||||
'type' => $envelopeType,
|
||||
'remaining_count' => $remainingCount,
|
||||
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}:{$balanceNow}。",
|
||||
]);
|
||||
}
|
||||
@@ -371,4 +407,34 @@ class RedPacketController extends Controller
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前在职职务解析礼包红包配置。
|
||||
*
|
||||
* @return array{amount: int, count: int}
|
||||
*/
|
||||
private function redPacketConfigForUser(User $user): array
|
||||
{
|
||||
$position = $user->activePosition?->position;
|
||||
$amount = (int) ($position?->red_packet_amount ?? self::DEFAULT_TOTAL_AMOUNT);
|
||||
$count = (int) ($position?->red_packet_count ?? self::DEFAULT_TOTAL_COUNT);
|
||||
|
||||
if ($amount < 1) {
|
||||
$amount = self::DEFAULT_TOTAL_AMOUNT;
|
||||
}
|
||||
|
||||
if ($count < 1 || $count > 100) {
|
||||
$count = self::DEFAULT_TOTAL_COUNT;
|
||||
}
|
||||
|
||||
if ($amount < $count) {
|
||||
$amount = self::DEFAULT_TOTAL_AMOUNT;
|
||||
$count = self::DEFAULT_TOTAL_COUNT;
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +9,14 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\DecorationService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -22,6 +28,7 @@ class ShopController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ShopService $shopService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -33,22 +40,28 @@ class ShopController extends Controller
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$items = ShopItem::active()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
$items = ShopItem::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
|
||||
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||
|
||||
// 统计背包中各戒指持有数量
|
||||
$ringCounts = \App\Models\UserPurchase::query()
|
||||
$ringCounts = UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
|
||||
@@ -64,6 +77,10 @@ class ShopController extends Controller
|
||||
'has_rename_card' => $this->shopService->hasRenameCard($user),
|
||||
'ring_counts' => $ringCounts,
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $signRepairCard,
|
||||
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -74,36 +91,75 @@ class ShopController extends Controller
|
||||
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
|
||||
* - message 公屏赠言(可选)
|
||||
*
|
||||
* @param Request $request 含 item_id, recipient?, message?
|
||||
* @param Request $request 含 item_id, recipient?, message?, quantity?
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
|
||||
$request->validate([
|
||||
'item_id' => 'required|integer|exists:shop_items,id',
|
||||
'room_id' => 'required|integer|exists:rooms,id',
|
||||
'recipient' => 'nullable|string|max:50',
|
||||
'message' => 'nullable|string|max:120',
|
||||
'quantity' => 'nullable|integer|min:1|max:99',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$room = Room::query()->findOrFail($roomId);
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买聊天室道具。'], 403);
|
||||
}
|
||||
|
||||
$item = ShopItem::find($request->item_id);
|
||||
if (! $item->is_active) {
|
||||
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
|
||||
}
|
||||
|
||||
$result = $this->shopService->buyItem(Auth::user(), $item);
|
||||
$quantity = (int) $request->input('quantity', 1);
|
||||
$result = $this->shopService->buyItem($user, $item, $quantity);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
$response = ['status' => 'success', 'message' => $result['message']];
|
||||
$response = [
|
||||
'status' => 'success',
|
||||
'message' => $result['message'],
|
||||
'quantity' => $result['quantity'] ?? 1,
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
|
||||
if ($item->type === 'auto_fishing') {
|
||||
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
}
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
$response['style'] = $result['style'];
|
||||
$response['expires_at'] = $result['expires_at'];
|
||||
|
||||
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||
$freshUser = $user->fresh();
|
||||
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
$message = trim($request->input('message', ''));
|
||||
$message = ChatContentSanitizer::htmlText($request->input('message', ''));
|
||||
|
||||
// recipient 为空或 "all" 表示全员
|
||||
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
|
||||
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
|
||||
|
||||
// 广播特效事件(全员频道)
|
||||
// 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
|
||||
broadcast(new EffectBroadcast(
|
||||
roomId: $roomId,
|
||||
type: $result['play_effect'],
|
||||
@@ -124,12 +180,20 @@ class ShopController extends Controller
|
||||
'rain' => '🌧',
|
||||
'lightning' => '⚡',
|
||||
'snow' => '❄️',
|
||||
'sakura' => '🌸',
|
||||
'meteors' => '🌠',
|
||||
'gold-rain' => '🪙',
|
||||
'hearts' => '💖',
|
||||
'confetti' => '🎊',
|
||||
'fireflies' => '✨',
|
||||
];
|
||||
// 赠礼消息文案(改成"为XX触发了一场特效")
|
||||
$icon = $icons[$result['play_effect']] ?? '✨';
|
||||
$toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友';
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$toStr = $safeTargetUsername ? "【{$safeTargetUsername}】" : '全体聊友';
|
||||
$remarkPart = $message ? " 「{$message}」" : '';
|
||||
$sysContent = "{$icon} {$user->username} 为 {$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
|
||||
$sysContent = "{$icon} {$safeBuyer} 为 {$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
|
||||
|
||||
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
|
||||
$sysMsgEvent = new MessageSent(
|
||||
@@ -150,9 +214,6 @@ class ShopController extends Controller
|
||||
}
|
||||
} else {
|
||||
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
|
||||
if ($roomId > 0) {
|
||||
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
|
||||
$fishDuration = '';
|
||||
@@ -161,13 +222,20 @@ class ShopController extends Controller
|
||||
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
|
||||
}
|
||||
|
||||
// 自动钓鱼卡购买通知要真正归属到“钓鱼播报”,这样前端屏蔽规则才能直接命中。
|
||||
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
|
||||
|
||||
// 根据商品类型生成不同通知文案
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$sysContent = match ($item->type) {
|
||||
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
||||
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
|
||||
'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
broadcast(new MessageSent(
|
||||
@@ -175,7 +243,7 @@ class ShopController extends Controller
|
||||
message: [
|
||||
'id' => 0,
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'from_user' => $broadcastFromUser,
|
||||
'to_user' => '大家',
|
||||
'content' => $sysContent,
|
||||
'font_color' => '#7c3aed',
|
||||
@@ -188,7 +256,7 @@ class ShopController extends Controller
|
||||
}
|
||||
|
||||
// 返回最新金币余额
|
||||
$response['jjb'] = Auth::user()->fresh()->jjb;
|
||||
$response['jjb'] = $user->fresh()->jjb;
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\SlotMachineLog;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\GameRoomScopeService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:提供老虎机信息查询、转动和个人记录接口。
|
||||
*/
|
||||
class SlotMachineController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly GameRoomScopeService $roomScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||
$user = $request->user();
|
||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
||||
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
|
||||
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
|
||||
}
|
||||
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前房间未开启老虎机。'], 403);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||
$cost = (int) ($config['cost_per_spin'] ?? 100);
|
||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
||||
@@ -100,7 +115,7 @@ class SlotMachineController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
|
||||
return DB::transaction(function () use ($user, $cost, $config, $roomId): JsonResponse {
|
||||
// ① 扣费
|
||||
$this->currency->change(
|
||||
$user,
|
||||
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
|
||||
|
||||
if ($resultType === 'jackpot') {
|
||||
// 三个7:全服公屏广播
|
||||
$this->broadcastJackpot($user->username, $payout, $cost);
|
||||
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
|
||||
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
|
||||
// 普通中奖:仅向本人发送聊天室系统通知
|
||||
$net = $payout - $cost;
|
||||
$content = "🎰 {$resultLabel}!{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
|
||||
$this->broadcastPersonal($user->username, $content);
|
||||
$this->broadcastPersonal($user->username, $content, $roomId);
|
||||
} elseif ($resultType === 'curse') {
|
||||
// 诅咒:通知本人
|
||||
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
|
||||
$this->broadcastPersonal($user->username, $content);
|
||||
$this->broadcastPersonal($user->username, $content, $roomId);
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
|
||||
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
|
||||
return response()->json(['history' => []]);
|
||||
}
|
||||
|
||||
$logs = SlotMachineLog::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->orderByDesc('id')
|
||||
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
|
||||
/**
|
||||
* 三个7全服公屏广播。
|
||||
*/
|
||||
private function broadcastJackpot(string $username, int $payout, int $cost): void
|
||||
private function broadcastJackpot(string $username, int $payout, int $cost, int $roomId): void
|
||||
{
|
||||
$net = $payout - $cost;
|
||||
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
|
||||
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
|
||||
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
|
||||
* @param string $toUsername 接收用户名
|
||||
* @param string $content 消息内容
|
||||
*/
|
||||
private function broadcastPersonal(string $toUsername, string $content): void
|
||||
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
|
||||
{
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => $toUsername,
|
||||
'content' => $content,
|
||||
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
/**
|
||||
* 文件功能:用户中心与管理控制器
|
||||
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP
|
||||
*
|
||||
* 权限等级通过 sysparam 表动态配置:
|
||||
* level_kick - 踢人所需等级
|
||||
* level_mute - 禁言所需等级
|
||||
* level_ban - 封号所需等级
|
||||
* level_banip - 封IP所需等级
|
||||
* 接管原版 USERinfo.ASP, USERSET.ASP 与 chpasswd.asp。
|
||||
* 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -17,21 +12,57 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\UserKicked;
|
||||
use App\Events\UserMuted;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Http\Requests\ChangePasswordRequest;
|
||||
use App\Http\Requests\RevealProfileInfoRequest;
|
||||
use App\Http\Requests\UpdateChatPreferencesRequest;
|
||||
use App\Http\Requests\UpdateDailyStatusRequest;
|
||||
use App\Http\Requests\UpdateProfileRequest;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* 查看别人隐藏信息的单次扣费金额。
|
||||
*/
|
||||
public const INFO_REVEAL_COST = 1000;
|
||||
|
||||
/**
|
||||
* 可付费查看的信息字段和中文名称。
|
||||
*/
|
||||
private const REVEALABLE_INFO_LABELS = [
|
||||
'exp_num' => '经验',
|
||||
'jjb' => '金币',
|
||||
'bank_jjb' => '存款',
|
||||
'meili' => '魅力',
|
||||
];
|
||||
|
||||
/**
|
||||
* 构造用户控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly AchievementService $achievementService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查看其他用户资料片 (对应 USERinfo.ASP)
|
||||
*/
|
||||
@@ -40,14 +71,26 @@ class UserController extends Controller
|
||||
$targetUser = User::where('username', $username)->firstOrFail();
|
||||
$operator = Auth::user();
|
||||
|
||||
// 探测原图
|
||||
$headfaceOriginal = $targetUser->headfaceUrl;
|
||||
if (str_starts_with((string) $targetUser->headface, 'storage/')) {
|
||||
$info = pathinfo($targetUser->headface);
|
||||
$origPath = $info['dirname'].'/'.$info['filename'].'_original.'.($info['extension'] ?? 'jpg');
|
||||
if (\Illuminate\Support\Facades\Storage::disk('public')->exists(substr($origPath, 8))) {
|
||||
$headfaceOriginal = '/'.$origPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 基础公开信息
|
||||
$activePosition = $targetUser->activePosition?->load('position.department')->position;
|
||||
$data = [
|
||||
'username' => $targetUser->username,
|
||||
'previous_name' => $targetUser->previous_name,
|
||||
'sex' => match ((int) $targetUser->sex) {
|
||||
1 => '男', 2 => '女', default => ''
|
||||
},
|
||||
'headface' => $targetUser->headface,
|
||||
'headface_original' => $headfaceOriginal,
|
||||
'usersf' => $targetUser->usersf,
|
||||
'user_level' => $targetUser->user_level,
|
||||
'qianming' => $targetUser->qianming,
|
||||
@@ -57,23 +100,33 @@ class UserController extends Controller
|
||||
'position_name' => $activePosition?->name ?? '',
|
||||
'position_icon' => $activePosition?->icon ?? '',
|
||||
'department_name' => $activePosition?->department?->name ?? '',
|
||||
'department_rank' => (int) ($activePosition?->department?->rank ?? 0),
|
||||
'position_rank' => (int) ($activePosition?->rank ?? 0),
|
||||
];
|
||||
|
||||
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
|
||||
if ($operator && ($operator->user_level >= $targetUser->user_level || $operator->id === $targetUser->id)) {
|
||||
$data['exp_num'] = $targetUser->exp_num ?? 0;
|
||||
$data['jjb'] = $targetUser->jjb ?? 0;
|
||||
$data['meili'] = $targetUser->meili ?? 0;
|
||||
}
|
||||
// 经验、金币、魅力卡片始终展示;等级不足时只显示星号,不隐藏字段本身。
|
||||
$canViewAssetNumbers = $operator && $this->canViewAssetNumbers($operator, $targetUser);
|
||||
$data['exp_num'] = $canViewAssetNumbers ? ($targetUser->exp_num ?? 0) : '******';
|
||||
$data['jjb'] = $canViewAssetNumbers ? ($targetUser->jjb ?? 0) : '******';
|
||||
$data['meili'] = $canViewAssetNumbers ? ($targetUser->meili ?? 0) : '******';
|
||||
$data['asset_numbers_masked'] = ! $canViewAssetNumbers;
|
||||
$data['asset_numbers_can_reveal'] = ! $canViewAssetNumbers;
|
||||
$data['asset_numbers_reveal_cost'] = self::INFO_REVEAL_COST;
|
||||
$data['asset_reveal_user_id'] = $targetUser->id;
|
||||
|
||||
// 银行存款:只有超级管理员(user_level >= superlevel)或本人才能查看具体金额,其余一律显示星号
|
||||
if ($operator) {
|
||||
$isSelf = $operator->id === $targetUser->id;
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$isSuperAdmin = $operator->user_level >= $superLevel;
|
||||
$data['bank_jjb'] = ($isSelf || $isSuperAdmin)
|
||||
? ($targetUser->bank_jjb ?? 0)
|
||||
: '******';
|
||||
$canViewBankBalance = $isSelf || $isSuperAdmin;
|
||||
|
||||
// 名片里的别人存款默认只返回星号,真实金额必须通过付费查看接口当次获取。
|
||||
$data['bank_jjb'] = $canViewBankBalance ? ($targetUser->bank_jjb ?? 0) : '******';
|
||||
$data['bank_jjb_masked'] = ! $canViewBankBalance;
|
||||
$data['bank_jjb_can_reveal'] = ! $canViewBankBalance;
|
||||
$data['bank_jjb_reveal_cost'] = self::INFO_REVEAL_COST;
|
||||
$data['bank_reveal_user_id'] = $targetUser->id;
|
||||
}
|
||||
|
||||
// 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接
|
||||
@@ -95,10 +148,27 @@ class UserController extends Controller
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$data['vip']['Name'] = $targetUser->vipName();
|
||||
$data['vip']['Icon'] = $targetUser->vipIcon();
|
||||
$signIdentity = $targetUser->currentSignInIdentity();
|
||||
$latestSignIn = $targetUser->dailySignIns()->first();
|
||||
$data['sign_in'] = [
|
||||
'streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
|
||||
'identity' => $signIdentity ? [
|
||||
'key' => $signIdentity->badge_code,
|
||||
'label' => $signIdentity->badge_name,
|
||||
'icon' => $signIdentity->badge_icon ?? '✅',
|
||||
'color' => $signIdentity->badge_color ?? '#0f766e',
|
||||
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
||||
] : null,
|
||||
];
|
||||
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
|
||||
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
|
||||
|
||||
// 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地
|
||||
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
|
||||
if ($operator && $operator->user_level >= $levelBanIp) {
|
||||
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
||||
$canViewNetworkInfo = $operator
|
||||
&& ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP));
|
||||
if ($canViewNetworkInfo) {
|
||||
$data['first_ip'] = $targetUser->first_ip;
|
||||
// last_ip 目前定义为『上次登录IP』(取数据库 previous_ip)
|
||||
$data['last_ip'] = $targetUser->previous_ip;
|
||||
@@ -148,6 +218,54 @@ class UserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费查看用户资料中被星号隐藏的经验、金币、存款或魅力。
|
||||
*/
|
||||
public function revealInfo(RevealProfileInfoRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $operator */
|
||||
$operator = Auth::user();
|
||||
$targetUser = User::query()
|
||||
->select(['id', 'username', 'user_level', 'exp_num', 'jjb', 'bank_jjb', 'meili'])
|
||||
->findOrFail($request->integer('user_id'));
|
||||
$asset = (string) $request->string('asset');
|
||||
$assetLabel = self::REVEALABLE_INFO_LABELS[$asset];
|
||||
$charged = false;
|
||||
$operatorGold = (int) $operator->jjb;
|
||||
|
||||
if (! $this->canViewProfileInfo($operator, $targetUser, $asset)) {
|
||||
$operatorGold = $this->currencyService->deductGoldIfEnough(
|
||||
user: $operator,
|
||||
amount: self::INFO_REVEAL_COST,
|
||||
source: CurrencySource::USER_INFO_REVEAL,
|
||||
remark: "查看 {$targetUser->username} 的{$assetLabel}",
|
||||
);
|
||||
|
||||
if ($operatorGold === null) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '金币不足,查看'.$assetLabel.'需要 '.self::INFO_REVEAL_COST.' 金币。',
|
||||
]);
|
||||
}
|
||||
|
||||
$charged = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $charged
|
||||
? '已扣除 '.self::INFO_REVEAL_COST.' 金币,'.$assetLabel.'已显示。'
|
||||
: $assetLabel.'已显示。',
|
||||
'user_id' => $targetUser->id,
|
||||
'username' => $targetUser->username,
|
||||
'asset' => $asset,
|
||||
'value' => $targetUser->{$asset} ?? 0,
|
||||
'charged' => $charged,
|
||||
'reveal_cost' => self::INFO_REVEAL_COST,
|
||||
'jjb' => $operatorGold,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改个人资料 (对应 USERSET.ASP)
|
||||
*/
|
||||
@@ -189,6 +307,98 @@ class UserController extends Controller
|
||||
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存聊天室屏蔽、禁音与字号偏好。
|
||||
*/
|
||||
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$data = $request->validated();
|
||||
$existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : [];
|
||||
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
|
||||
->map(function (string $sender): string {
|
||||
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
|
||||
return $sender === '猜谜活动' ? '猜成语' : $sender;
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$preferences = [
|
||||
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
||||
'blocked_system_senders' => $blockedSystemSenders,
|
||||
'sound_muted' => (bool) $data['sound_muted'],
|
||||
];
|
||||
|
||||
// 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。
|
||||
$fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null
|
||||
? (int) $data['font_size']
|
||||
: ($existingPreferences['font_size'] ?? null);
|
||||
|
||||
if ($fontSize !== null) {
|
||||
$preferences['font_size'] = (int) $fontSize;
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'chat_preferences' => $preferences,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '聊天室偏好已保存。',
|
||||
'data' => $preferences,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存聊天室当日状态,并同步当前在线名单显示。
|
||||
*/
|
||||
public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$data = $request->validated();
|
||||
$roomId = (int) $data['room_id'];
|
||||
|
||||
// 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。
|
||||
if (! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '请先进入聊天室后再设置状态。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($data['action'] === 'clear') {
|
||||
$user->update([
|
||||
'daily_status_key' => null,
|
||||
'daily_status_expires_at' => null,
|
||||
]);
|
||||
} else {
|
||||
// 状态有效期固定维持到当天结束,次日自动失效。
|
||||
$user->update([
|
||||
'daily_status_key' => $data['status_key'],
|
||||
'daily_status_expires_at' => now()->endOfDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
$presencePayload = $this->chatUserPresenceService->build($user);
|
||||
$roomIds = $this->chatState->getUserRooms($user->username);
|
||||
|
||||
foreach ($roomIds as $activeRoomId) {
|
||||
// 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。
|
||||
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
|
||||
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。',
|
||||
'data' => [
|
||||
'status' => $this->chatUserPresenceService->currentDailyStatus($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码 (对应 chpasswd.asp)
|
||||
*/
|
||||
@@ -221,158 +431,68 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用权限校验:检查操作者是否有权操作目标用户
|
||||
*
|
||||
* @param object $operator 操作者
|
||||
* @param string $targetUsername 目标用户名
|
||||
* @param int $roomId 房间ID
|
||||
* @param string $levelKey sysparam中的等级键名(如 level_kick)
|
||||
* @param string $actionName 操作名称(用于错误提示)
|
||||
* @return array{room: Room, target: User}|JsonResponse
|
||||
* 生成微信绑定代码
|
||||
*/
|
||||
private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse
|
||||
public function generateWechatCode(Request $request): JsonResponse
|
||||
{
|
||||
$room = Room::findOrFail($roomId);
|
||||
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
|
||||
|
||||
// 鉴权:操作者要是房间房主或达到所需等级
|
||||
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
|
||||
$user = \Illuminate\Support\Facades\Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '未登录']);
|
||||
}
|
||||
|
||||
$targetUser = User::where('username', $targetUsername)->first();
|
||||
if (! $targetUser) {
|
||||
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
|
||||
}
|
||||
$code = 'BD-'.mt_rand(100000, 999999);
|
||||
\Illuminate\Support\Facades\Cache::put('wechat_bind_code:'.$code, $user->username, 300); // 5分钟有效
|
||||
|
||||
// 防误伤:不能操作等级 >= 自己的人
|
||||
if ($targetUser->user_level >= $operator->user_level) {
|
||||
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}。"], 403);
|
||||
}
|
||||
|
||||
return ['room' => $room, 'target' => $targetUser];
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'code' => $code,
|
||||
'message' => '生成成功',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出房间 (对应 KILLUSER.ASP)
|
||||
* 所需等级由 sysparam level_kick 配置
|
||||
* 取消绑定微信
|
||||
*/
|
||||
public function kick(Request $request, string $username): JsonResponse
|
||||
public function unbindWechat(Request $request): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
$user = \Illuminate\Support\Facades\Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '未登录']);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
$user->wxid = null;
|
||||
$user->save();
|
||||
|
||||
// 广播踢出事件
|
||||
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '解绑成功',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁言 (对应原版限制功能)
|
||||
* 所需等级由 sysparam level_mute 配置
|
||||
* 禁言信息存入 Redis,TTL 到期自动解除
|
||||
* 判断操作者是否可以免费查看目标用户经验、金币与魅力。
|
||||
*/
|
||||
public function mute(Request $request, string $username): JsonResponse
|
||||
private function canViewAssetNumbers(User $operator, User $targetUser): bool
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$duration = (int) $request->input('duration', 5);
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60
|
||||
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
|
||||
'operator' => $operator->username,
|
||||
'reason' => '管理员禁言',
|
||||
'until' => now()->addMinutes($duration)->toDateTimeString(),
|
||||
]));
|
||||
|
||||
// 广播禁言事件
|
||||
broadcast(new UserMuted($roomId, $username, $duration));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
|
||||
return $operator->id === $targetUser->id
|
||||
|| (int) $operator->user_level >= (int) $targetUser->user_level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封号(禁止登录)
|
||||
* 所需等级由 sysparam level_ban 配置
|
||||
* 将用户等级设为 -1 表示封禁
|
||||
* 判断操作者是否可以免费查看指定资料信息。
|
||||
*/
|
||||
public function ban(Request $request, string $username): JsonResponse
|
||||
private function canViewProfileInfo(User $operator, User $targetUser, string $asset): bool
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
if ($asset !== 'bank_jjb') {
|
||||
return $this->canViewAssetNumbers($operator, $targetUser);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
if ($operator->id === $targetUser->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 封号:设置等级为 -1
|
||||
$result['target']->user_level = -1;
|
||||
$result['target']->save();
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
// 踢出聊天室
|
||||
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封IP(记录IP到黑名单并踢出)
|
||||
* 所需等级由 sysparam level_banip 配置
|
||||
*/
|
||||
public function banIp(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$targetIp = $result['target']->last_ip;
|
||||
|
||||
if ($targetIp) {
|
||||
// 将IP加入 Redis 黑名单(永久)
|
||||
Redis::sadd('banned_ips', $targetIp);
|
||||
}
|
||||
|
||||
// 同时封号
|
||||
$result['target']->user_level = -1;
|
||||
$result['target']->save();
|
||||
|
||||
// 踢出聊天室
|
||||
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
|
||||
|
||||
$ipInfo = $targetIp ? "(IP: {$targetIp})" : '(未记录IP)';
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}。"]);
|
||||
return (int) $operator->user_level >= $superLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台会员中心控制器
|
||||
* 负责展示会员等级、权益说明、当前会员状态、用户购买记录与会员个性化进退场设置
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateVipPresenceSettingsRequest;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\VipLevel;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipCenterController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示会员中心页面
|
||||
*
|
||||
* @param Request $request 当前请求对象
|
||||
*/
|
||||
public function index(Request $request): View|\Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 会员等级按后台排序字段展示,方便用户对比不同档位权益。
|
||||
$vipLevels = VipLevel::query()
|
||||
->withCount('users')
|
||||
->orderByDesc('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 仅展示当前用户自己的购买记录,避免泄露其他会员订单信息。
|
||||
$paymentLogs = $this->buildPaymentLogs($user->id);
|
||||
|
||||
$paidOrders = VipPaymentOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->count();
|
||||
|
||||
$totalAmount = (float) VipPaymentOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->sum('amount');
|
||||
|
||||
$vipPaymentEnabled = Sysparam::getValue('vip_payment_enabled', '0') === '1';
|
||||
|
||||
$effectOptions = [
|
||||
'none' => '无特效',
|
||||
'fireworks' => '烟花',
|
||||
'rain' => '下雨',
|
||||
'lightning' => '闪电',
|
||||
'snow' => '下雪',
|
||||
'sakura' => '樱花飘落',
|
||||
'meteors' => '流星',
|
||||
'gold-rain' => '金币雨',
|
||||
'hearts' => '爱心飘落',
|
||||
'confetti' => '彩带庆典',
|
||||
'fireflies' => '萤火虫',
|
||||
];
|
||||
|
||||
$bannerStyleOptions = [
|
||||
'aurora' => '鎏光星幕',
|
||||
'storm' => '雷霆风暴',
|
||||
'royal' => '王者金辉',
|
||||
'cosmic' => '星穹幻彩',
|
||||
'farewell' => '告别暮光',
|
||||
];
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
$data = [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'is_vip' => $user->isVip(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'hy_time' => $user->hy_time?->format('Y-m-d H:i'),
|
||||
'vip_level_id' => $user->vip_level_id,
|
||||
'can_customize' => $user->canCustomizeVipPresence(),
|
||||
'custom_join_message' => $user->custom_join_message,
|
||||
'custom_leave_message' => $user->custom_leave_message,
|
||||
'custom_join_effect' => $user->custom_join_effect,
|
||||
'custom_leave_effect' => $user->custom_leave_effect,
|
||||
'vip_level' => $user->vipLevel ? [
|
||||
'id' => $user->vipLevel->id,
|
||||
'name' => $user->vipLevel->name,
|
||||
'icon' => $user->vipLevel->icon,
|
||||
'color' => $user->vipLevel->color,
|
||||
'join_effect' => $user->vipLevel->joinEffectKey(),
|
||||
'join_banner' => $user->vipLevel->joinBannerStyleKey(),
|
||||
'leave_effect' => $user->vipLevel->leaveEffectKey(),
|
||||
'leave_banner' => $user->vipLevel->leaveBannerStyleKey(),
|
||||
'join_templates' => $user->vipLevel->join_templates_array,
|
||||
'leave_templates' => $user->vipLevel->leave_templates_array,
|
||||
] : null,
|
||||
],
|
||||
'vipLevels' => $vipLevels->map(function ($vip) use ($user) {
|
||||
$isCurrent = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id;
|
||||
$isHigher = $user->isVip() ? $vip->isHigherThan($user->vip_level_id) : true;
|
||||
$isLower = $user->isVip() && ! $isCurrent && ! $isHigher;
|
||||
|
||||
return [
|
||||
'id' => $vip->id,
|
||||
'name' => $vip->name,
|
||||
'icon' => $vip->icon,
|
||||
'color' => $vip->color,
|
||||
'price' => (float) $vip->price,
|
||||
'upgrade_price' => $user->isVip() ? (float) $vip->getUpgradePrice($user->vip_level_id) : (float) $vip->price,
|
||||
'duration_days' => $vip->duration_days,
|
||||
'exp_multiplier' => $vip->exp_multiplier,
|
||||
'jjb_multiplier' => $vip->jjb_multiplier,
|
||||
'description' => $vip->description,
|
||||
'is_current' => $isCurrent,
|
||||
'is_higher' => $isHigher,
|
||||
'is_lower' => $isLower,
|
||||
];
|
||||
}), 'paymentLogs' => $paymentLogs->items(),
|
||||
'vipPaymentEnabled' => $vipPaymentEnabled,
|
||||
'paidOrders' => $paidOrders,
|
||||
'totalAmount' => $totalAmount,
|
||||
'effectOptions' => $effectOptions,
|
||||
'bannerStyleOptions' => $bannerStyleOptions,
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('vip.center', [
|
||||
'user' => $user,
|
||||
'vipLevels' => $vipLevels,
|
||||
'paymentLogs' => $paymentLogs,
|
||||
'vipPaymentEnabled' => $vipPaymentEnabled,
|
||||
'paidOrders' => $paidOrders,
|
||||
'totalAmount' => $totalAmount,
|
||||
'effectOptions' => $effectOptions,
|
||||
'bannerStyleOptions' => $bannerStyleOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会员个人自定义欢迎语与离开语。
|
||||
*/
|
||||
public function updatePresenceSettings(UpdateVipPresenceSettingsRequest $request): RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 只有有效会员且当前等级允许自定义时,才允许保存专属语句。
|
||||
if (! $user->canCustomizeVipPresence()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '当前会员等级暂不支持自定义欢迎语和离开语。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('vip.center')
|
||||
->with('error', '当前会员等级暂不支持自定义欢迎语和离开语。');
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
// 空字符串统一转成 null,避免数据库保存无意义空白值。
|
||||
$user->update([
|
||||
'custom_join_message' => $this->sanitizeNullableMessage($data['custom_join_message'] ?? null),
|
||||
'custom_leave_message' => $this->sanitizeNullableMessage($data['custom_leave_message'] ?? null),
|
||||
'custom_join_effect' => $data['custom_join_effect'] ?? null,
|
||||
'custom_leave_effect' => $data['custom_leave_effect'] ?? null,
|
||||
]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '设置已保存。',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('vip.center')
|
||||
->with('success', '设置已保存。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建当前用户的购买记录分页数据
|
||||
*
|
||||
* @param int $userId 当前登录用户 ID
|
||||
*/
|
||||
private function buildPaymentLogs(int $userId): LengthAwarePaginator
|
||||
{
|
||||
return VipPaymentOrder::query()
|
||||
->with('vipLevel:id,name,color,icon')
|
||||
->where('user_id', $userId)
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可空文案统一整理为数据库可保存的字符串。
|
||||
*/
|
||||
private function sanitizeNullableMessage(?string $message): ?string
|
||||
{
|
||||
$message = trim((string) $message);
|
||||
|
||||
return $message === '' ? null : $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台 VIP 支付控制器
|
||||
* 负责用户发起 VIP 支付、接收同步回调、接收异步通知并驱动本地会员开通
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\CreateVipPaymentOrderRequest;
|
||||
use App\Models\VipLevel;
|
||||
use App\Services\VipPaymentCenterClient;
|
||||
use App\Services\VipPaymentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 前台 VIP 支付控制器
|
||||
* 负责接收用户选择的支付渠道,下发支付中心订单并处理回调结果。
|
||||
*/
|
||||
class VipPaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param VipPaymentService $vipPaymentService 本地 VIP 支付服务
|
||||
* @param VipPaymentCenterClient $paymentCenterClient 远端支付中心客户端
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly VipPaymentService $vipPaymentService,
|
||||
private readonly VipPaymentCenterClient $paymentCenterClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建 VIP 支付订单并跳转到 NovaLink 支付页
|
||||
*
|
||||
* @param CreateVipPaymentOrderRequest $request 已校验的购买请求
|
||||
*/
|
||||
public function store(CreateVipPaymentOrderRequest $request): RedirectResponse
|
||||
{
|
||||
if (! $this->paymentCenterClient->isEnabled() || ! $this->paymentCenterClient->hasValidConfig()) {
|
||||
return redirect()->route('vip.center')->with('error', 'VIP 支付暂未开放,请联系管理员完成后台配置。');
|
||||
}
|
||||
|
||||
$vipLevel = VipLevel::query()->findOrFail((int) $request->validated('vip_level_id'));
|
||||
$provider = (string) $request->validated('provider');
|
||||
|
||||
try {
|
||||
// 先创建本地订单,再向支付中心发起下单,确保回调时有本地单据可追踪。
|
||||
$vipPaymentOrder = $this->vipPaymentService->createLocalOrder($request->user(), $vipLevel, $provider);
|
||||
$remoteOrder = $this->vipPaymentService->createRemoteOrder($vipPaymentOrder);
|
||||
$payUrl = (string) ($remoteOrder['pay_url'] ?? '');
|
||||
|
||||
if ($payUrl === '') {
|
||||
throw new RuntimeException('支付中心未返回可用的支付地址。');
|
||||
}
|
||||
|
||||
return redirect()->away($payUrl);
|
||||
} catch (\Throwable $exception) {
|
||||
return redirect()->route('vip.center')->with('error', '创建 VIP 支付订单失败:'.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付完成后的同步回调
|
||||
*
|
||||
* @param Request $request 支付中心跳转返回参数
|
||||
*/
|
||||
public function handleReturn(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->all();
|
||||
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($request->string('payment_order_no')->toString())
|
||||
?? $this->vipPaymentService->findByMerchantOrderNo($request->string('merchant_order_no')->toString());
|
||||
|
||||
if (! $vipPaymentOrder) {
|
||||
return redirect()->route('vip.center')->with('error', '未找到对应的 VIP 支付订单,请稍后在后台核对。');
|
||||
}
|
||||
|
||||
$this->vipPaymentService->recordSyncReturn($vipPaymentOrder, $payload);
|
||||
|
||||
try {
|
||||
// 同步回调只做页面回跳,但这里补查一次可让用户尽快看到最终结果。
|
||||
$vipPaymentOrder = $this->vipPaymentService->syncRemoteStatus($vipPaymentOrder);
|
||||
} catch (\Throwable $exception) {
|
||||
return redirect()->route('vip.center')->with('error', '支付结果正在确认中,请稍后刷新查看。');
|
||||
}
|
||||
|
||||
if ($vipPaymentOrder->isVipOpened()) {
|
||||
return redirect()->route('vip.center')->with('success', 'VIP 支付成功,会员已开通。');
|
||||
}
|
||||
|
||||
return redirect()->route('vip.center')->with('success', '支付页面已返回,系统正在确认支付结果,请稍后刷新查看。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收 NovaLink 支付中心的异步通知
|
||||
*
|
||||
* @param Request $request 支付中心回调请求
|
||||
*/
|
||||
public function notify(Request $request): Response
|
||||
{
|
||||
$rawBody = $request->getContent();
|
||||
$signature = (string) $request->header('X-Payment-Signature', '');
|
||||
|
||||
if (! $this->paymentCenterClient->isValidWebhookSignature($signature, $rawBody)) {
|
||||
return response('invalid signature', 401);
|
||||
}
|
||||
|
||||
$payload = $request->json()->all();
|
||||
$vipPaymentOrder = $this->vipPaymentService->findByPaymentOrderNo($payload['payment_order_no'] ?? null)
|
||||
?? $this->vipPaymentService->findByMerchantOrderNo($payload['merchant_order_no'] ?? null);
|
||||
|
||||
if (! $vipPaymentOrder) {
|
||||
return response('order not found', 404);
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? '') !== 'paid') {
|
||||
return response('ignored', 200);
|
||||
}
|
||||
|
||||
try {
|
||||
// 异步回调才是最终支付成功依据,这里完成幂等开通 VIP 的核心逻辑。
|
||||
$this->vipPaymentService->markOrderAsPaid($vipPaymentOrder, $payload, 'async');
|
||||
|
||||
return response('success', 200);
|
||||
} catch (\Throwable $exception) {
|
||||
return response('error: '.$exception->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,106 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:在可信代理场景下解析客户端真实 IP。
|
||||
*
|
||||
* 仅当当前请求明确来自配置中的反向代理 / CDN 节点时,
|
||||
* 才会采信其透传的真实客户端 IP 头,避免外部客户端伪造来源。
|
||||
*/
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 类功能:为可信代理请求恢复真实客户端 IP。
|
||||
*/
|
||||
class CloudflareProxies
|
||||
{
|
||||
/**
|
||||
* 文件功能:强制信任并解析 CDN 传导的真实客户端 IP。
|
||||
* 解决 Herd 环境 / Nginx 本地反代时,丢失 X-Forwarded-For 导致全员 IP 变成 127.0.0.1 的问题。
|
||||
* 处理进入应用的请求,并在可信代理场景下覆写客户端 IP。
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 优先采纳 Cloudflare 的 CF-Connecting-IP
|
||||
if ($request->hasHeader('CF-Connecting-IP')) {
|
||||
$realIp = $request->header('CF-Connecting-IP');
|
||||
}
|
||||
// 腾讯云 EdgeOne CDN 自定义回源头部(后台配置名:EO-Client-IP)
|
||||
elseif ($request->hasHeader('EO-Client-IP')) {
|
||||
$realIp = $request->header('EO-Client-IP');
|
||||
}
|
||||
// 其他国内 CDN 厂商(阿里云 DCDN 等)通用头部
|
||||
elseif ($request->hasHeader('X-Real-IP')) {
|
||||
$realIp = $request->header('X-Real-IP');
|
||||
}
|
||||
// 最后兜底:取 X-Forwarded-For 最左边第一个(真实客户端)IP
|
||||
// 格式为 "真实客户端, CDN节点1, CDN节点2"
|
||||
elseif ($request->hasHeader('X-Forwarded-For')) {
|
||||
$realIp = trim(explode(',', $request->header('X-Forwarded-For'))[0]);
|
||||
}
|
||||
$realIp = $this->resolveTrustedClientIp($request);
|
||||
|
||||
if (! empty($realIp)) {
|
||||
// 仅在确认上游代理可信且透传 IP 合法时,才覆写 request()->ip() 的来源。
|
||||
$request->server->set('REMOTE_ADDR', $realIp);
|
||||
$request->headers->set('X-Forwarded-For', $realIp);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从可信代理头中解析真实客户端 IP。
|
||||
*/
|
||||
private function resolveTrustedClientIp(Request $request): ?string
|
||||
{
|
||||
$remoteAddress = (string) $request->server->get('REMOTE_ADDR', '');
|
||||
if (! $this->isTrustedProxy($remoteAddress)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['CF-Connecting-IP', 'EO-Client-IP', 'X-Real-IP'] as $headerName) {
|
||||
$resolvedIp = $this->sanitizeIp($request->header($headerName));
|
||||
if ($resolvedIp !== null) {
|
||||
return $resolvedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->extractForwardedForIp($request->header('X-Forwarded-For'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前请求是否来自受信代理节点。
|
||||
*/
|
||||
private function isTrustedProxy(string $remoteAddress): bool
|
||||
{
|
||||
if ($this->sanitizeIp($remoteAddress) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trustedProxies = config('app.trusted_proxies', ['127.0.0.1', '::1']);
|
||||
foreach ($trustedProxies as $trustedProxy) {
|
||||
$trustedProxy = trim((string) $trustedProxy);
|
||||
if ($trustedProxy !== '' && IpUtils::checkIp($remoteAddress, $trustedProxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 X-Forwarded-For 头中提取最左侧的合法 IP。
|
||||
*/
|
||||
private function extractForwardedForIp(?string $forwardedFor): ?string
|
||||
{
|
||||
if (! is_string($forwardedFor) || $forwardedFor === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (explode(',', $forwardedFor) as $candidateIp) {
|
||||
$resolvedIp = $this->sanitizeIp($candidateIp);
|
||||
if ($resolvedIp !== null) {
|
||||
return $resolvedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并标准化 IP 文本。
|
||||
*/
|
||||
private function sanitizeIp(?string $ip): ?string
|
||||
{
|
||||
$normalizedIp = trim((string) $ip);
|
||||
|
||||
return filter_var($normalizedIp, FILTER_VALIDATE_IP) ? $normalizedIp : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台签到奖励规则保存请求校验
|
||||
*
|
||||
* 集中校验连续签到天数、奖励数值与身份徽章配置。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:校验后台新增和更新签到奖励规则的表单数据。
|
||||
*/
|
||||
class SaveSignInRewardRuleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已通过后台权限中间件的管理员继续校验。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到奖励规则表单的校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$ruleId = $this->route('signInRewardRule')?->id;
|
||||
|
||||
return [
|
||||
'streak_days' => [
|
||||
'required',
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:3650',
|
||||
Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId),
|
||||
],
|
||||
'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'identity_badge_code' => ['nullable', 'string', 'max:50'],
|
||||
'identity_badge_name' => ['nullable', 'string', 'max:50'],
|
||||
'identity_badge_icon' => ['nullable', 'string', 'max:120'],
|
||||
'identity_badge_color' => ['nullable', 'string', 'max:20'],
|
||||
'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'],
|
||||
'sort_order' => ['required', 'integer', 'min:0', 'max:999999'],
|
||||
'is_enabled' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到奖励规则表单的中文字段名。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'streak_days' => '连续签到天数',
|
||||
'gold_reward' => '金币奖励',
|
||||
'exp_reward' => '经验奖励',
|
||||
'charm_reward' => '魅力奖励',
|
||||
'identity_badge_name' => '身份名称',
|
||||
'identity_duration_days' => '身份有效天数',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台用户资料更新请求校验
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:集中校验后台用户编辑弹窗提交的资料字段。
|
||||
*/
|
||||
class UpdateManagedUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已通过路由中间件的后台用户继续执行校验。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回后台用户编辑表单的校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'sex' => ['sometimes', 'integer', 'in:0,1,2'],
|
||||
'exp_num' => ['sometimes', 'integer', 'min:0'],
|
||||
'jjb' => ['sometimes', 'integer', 'min:0'],
|
||||
'meili' => ['sometimes', 'integer', 'min:0'],
|
||||
'qianming' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'position_id' => ['sometimes', 'nullable', 'integer', 'exists:positions,id'],
|
||||
'headface' => ['sometimes', 'string', 'max:50'],
|
||||
'password' => ['nullable', 'string', 'min:6'],
|
||||
'vip_level_id' => ['sometimes', 'nullable', 'integer', 'exists:vip_levels,id'],
|
||||
'hy_time' => ['sometimes', 'nullable', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回后台用户编辑表单的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'position_id.exists' => '所选职务不存在,请重新选择。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 支付配置保存请求
|
||||
* 负责校验聊天室接入 NovaLink 支付中心所需的后台配置项
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateVipPaymentConfigRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前请求是否允许执行
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单校验规则
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled' => ['required', 'in:0,1'],
|
||||
'vip_payment_base_url' => ['nullable', 'url', 'max:255', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_app_key' => ['nullable', 'string', 'max:100', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_app_secret' => ['nullable', 'string', 'max:255', 'required_if:vip_payment_enabled,1'],
|
||||
'vip_payment_timeout' => ['nullable', 'integer', 'min:3', 'max:30'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文错误提示
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled.required' => '请先选择是否启用 VIP 支付',
|
||||
'vip_payment_base_url.required_if' => '启用 VIP 支付时,支付中心地址不能为空',
|
||||
'vip_payment_base_url.url' => '支付中心地址格式不正确',
|
||||
'vip_payment_app_key.required_if' => '启用 VIP 支付时,App Key 不能为空',
|
||||
'vip_payment_app_secret.required_if' => '启用 VIP 支付时,App Secret 不能为空',
|
||||
'vip_payment_timeout.integer' => '请求超时时间必须是整数',
|
||||
'vip_payment_timeout.min' => '请求超时时间不能小于 3 秒',
|
||||
'vip_payment_timeout.max' => '请求超时时间不能大于 30 秒',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:站长隐藏登录请求验证器
|
||||
*
|
||||
* 仅校验站长登录页提交的账号、密码与验证码字段,
|
||||
* 不参与聊天室前台“登录即注册”流程。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:校验站长隐藏登录表单。
|
||||
*/
|
||||
class AdminLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前请求是否允许继续处理。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站长隐藏登录所需的验证规则。
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'min:1'],
|
||||
'captcha' => ['required', 'captcha'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证失败时展示的中文提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => '必须填写站长账号。',
|
||||
'password.required' => '必须填写登录密码。',
|
||||
'password.min' => '登录密码格式不正确。',
|
||||
'captcha.required' => '必须填写验证码。',
|
||||
'captcha.captcha' => '验证码不正确。',
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user