Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a4d2c5e1b | |||
| 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 | |||
| 2e252eb70e | |||
| d16626d121 | |||
| 3bfc0e358f | |||
| 3814ea5e85 | |||
| c9a569fc42 | |||
| 348f4e0fe0 | |||
| 887fc5c7ef | |||
| e515a1429c | |||
| 7bcb9b126b | |||
| 043be04187 | |||
| e5fca206f0 | |||
| 91b9a6bcef | |||
| 63f9a174ed | |||
| b4f62ca6b9 | |||
| 8f850e651e | |||
| 08df13bfc7 | |||
| 60bafe7bc4 | |||
| f0618aad4b | |||
| 8fcccf72a5 | |||
| a68e82107e | |||
| 532dc20a2d | |||
| 65bd8894c9 | |||
| 4d60893dbe | |||
| c13bb5f35c | |||
| ed04b2d4b9 | |||
| bb505d7508 | |||
| cbc4d3b7d0 | |||
| e42dc5fbfa | |||
| 78a682e0ab | |||
| 60cec0276b | |||
| 7bf9d18b33 | |||
| 5f4abc5152 | |||
| 4139949405 | |||
| 363a0145d9 | |||
| 36cc934f7a | |||
| 72bcb73351 | |||
| c9cab898c2 | |||
| f3579ae9fe | |||
| 42beed5c93 | |||
| afd02b38e3 | |||
| b63b709032 | |||
| 340cbe8784 | |||
| d7a575d8c8 | |||
| 6c4183e175 | |||
| 0ca028f73d | |||
| c7063e02c2 | |||
| 75f25150c3 | |||
| 5b065fdcce | |||
| ca415cceef | |||
| 46fde766e5 | |||
| 630a3a6dde | |||
| 0ce969ef69 | |||
| ad754a704e | |||
| 7d984ebe64 | |||
| c8ebbc750e | |||
| 4927e815b5 | |||
| 7804adc54a | |||
| ef9a8ed0b6 | |||
| 1181162219 | |||
| 35a80279e6 | |||
| bb63cc12c3 | |||
| 91597e6b2c | |||
| 4cf7ef1bd1 | |||
| c2293f96cb | |||
| 51aa3931b9 | |||
| 1328b3d8cb | |||
| aa7a389ab2 | |||
| 6400cb51ca | |||
| 30d0e386fd | |||
| 1b5f185a03 | |||
| 29493b4fee | |||
| cc28a27ab0 | |||
| 6817e8e5cd | |||
| 21111aecf5 | |||
| 32ca130f90 | |||
| daeef0af0b | |||
| 4a759802dc | |||
| a225609cea | |||
| b2e54aafdb | |||
| a8bed5de36 | |||
| d6d246ee63 | |||
| cfd5345e93 | |||
| 02816fbb03 | |||
| 925ac68f20 | |||
| 106dc7f852 | |||
| 5b70ccd51f | |||
| cb3481b269 | |||
| 78564e2a1d | |||
| ec95d69e92 | |||
| 4b6eca953d | |||
| e8b21096a0 | |||
| d36da26c44 | |||
| 16498a4657 | |||
| eab300851a | |||
| 10cd89f9f9 | |||
| a6b80eadc3 | |||
| d827c8a1df | |||
| 909d578547 | |||
| dde080f69b | |||
| 0ab0483603 | |||
| af1d1c5ace | |||
| 4606888b0c | |||
| a14761c498 | |||
| f614e07b8f | |||
| 289b79affe | |||
| a6b0c24b66 | |||
| 9e1e5fb7db | |||
| 246d89fef6 | |||
| b7f2dae847 | |||
| 1c42f05e20 | |||
| b9c703b755 | |||
| f1062b34d2 | |||
| 174ee8241d | |||
| 529a59551c | |||
| 89122773af | |||
| bfb1a3bca4 | |||
| 28d9f9ee96 | |||
| a562564e88 | |||
| ca639ddd37 | |||
| 318eb6f234 | |||
| 6c9db806ae | |||
| 148c91a61c | |||
| 5864478ae0 | |||
| 202b55a489 | |||
| 67bea9375f | |||
| f80b83aee8 | |||
| 500b7c718e | |||
| 79672a38ec | |||
| b13861c869 | |||
| 4114571040 | |||
| a788a0022a | |||
| 27371fe321 | |||
| b30be5c053 | |||
| 040dbdef3c | |||
| 349eb5a338 | |||
| 1c53acbd1b | |||
| 16cbb32f35 | |||
| bcaaa527d4 | |||
| 2b990942c0 | |||
| f867e912e9 | |||
| b62a9f6240 | |||
| f45483bcba | |||
| 602dcd7cf1 | |||
| 40fcce2db3 | |||
| 36fbc9982c | |||
| 8b5fbd7e91 | |||
| 0fd4f51b5e | |||
| 9f5d213d99 | |||
| 03ec3a9fbb | |||
| 783afe0677 | |||
| adb9f157e6 | |||
| b03de378b0 | |||
| 5b51754c58 | |||
| 154d9ca8a2 | |||
| 4324633f82 | |||
| ad91c4420a | |||
| a41e701fed | |||
| fdb500c3dd | |||
| 9b6ebbedb3 | |||
| e21f049643 | |||
| 6fa42b90d5 | |||
| ed195bb5f4 | |||
| 148947781a | |||
| 9359184e38 | |||
| dfa7278184 | |||
| 955aec6b73 | |||
| 04ab62c988 | |||
| 39d36578fd | |||
| 4ffc4abff4 | |||
| 37b1595709 | |||
| ff28775635 | |||
| 8a74bfd639 | |||
| 8c99e1fad7 | |||
| c5fe9faf94 | |||
| a37b04aca0 | |||
| 23fca927d5 | |||
| 392b1b06bb | |||
| 0990a13c2e | |||
| 68c4ca7a96 | |||
| eefdae93fe | |||
| e9a41995be | |||
| e81887034c | |||
| 84a4b42f31 | |||
| 9b55b5558b | |||
| 6b32fe38c8 | |||
| 87d91db1ee | |||
| 1e5d11929e | |||
| 00231e0836 | |||
| a60a2c8173 | |||
| a9f395994b | |||
| 73c78ee6d7 | |||
| d7c6e0e7a8 | |||
| 5bcbf74dfc | |||
| 52c252f525 | |||
| b7ded61523 | |||
| 919f0e30b5 | |||
| 420efbc093 | |||
| b6188ce2c3 | |||
| be2d02cb8f | |||
| 050aec1db4 | |||
| c53cd7784a | |||
| 9ccc0b379d | |||
| 9c4598ab66 | |||
| d703309a34 | |||
| c8c1943f85 | |||
| bf001a6cf6 | |||
| c72309aa16 | |||
| fc4c0c543e | |||
| 759fb6deae | |||
| 0ea6ea206c | |||
| 168bc002f9 | |||
| 303c5e2a60 | |||
| bd1e247fcf | |||
| 03e7f260b2 | |||
| 63679a622f | |||
| e0c15b437e | |||
| 954a078d63 | |||
| 9139108744 | |||
| 5cf87391b6 | |||
| f9312475d0 | |||
| 3132f013b7 | |||
| 4a9730c38d | |||
| 29e43507ac | |||
| 1f33013216 | |||
| e5a35779f8 | |||
| e20f94fe17 | |||
| 877fd1935f | |||
| 37af4ba975 | |||
| 143601c251 | |||
| d2797d5b59 | |||
| 4f49fb7ce8 | |||
| 384cf8e078 | |||
| 2d07b032d9 | |||
| 11dcb03924 | |||
| 73badefcc5 | |||
| 477bba3003 | |||
| f114c6b168 | |||
| 211075b77c | |||
| 632a4240c4 | |||
| fc495ccceb | |||
| 312b92a81d | |||
| 8120058948 | |||
| 4ced484419 | |||
| 48b31e7cff | |||
| 58b63fa8d3 | |||
| dac7750fe1 | |||
| 1d7aa636a0 | |||
| 2947f0f741 | |||
| 0dff79dd51 | |||
| 769632dea8 | |||
| 855f169516 | |||
| f4de31f92b | |||
| 3d7b86f06d | |||
| 2ae3d83349 | |||
| 9da0d83914 | |||
| 5180526821 | |||
| 0d693eef5f | |||
| 4207528043 | |||
| 96c472bfb9 | |||
| 4ba5a88fc2 | |||
| 21cabb08c9 | |||
| 3d30d7e811 | |||
| cc1278ffcb | |||
| 8dcf23d7e4 | |||
| 57f515e2eb | |||
| 89d93c92ed | |||
| baaa7087b0 | |||
| a145c6fc0a | |||
| 41d4acdd72 | |||
| ff57afe388 | |||
| 476499832f | |||
| 5a7d1565e5 | |||
| 7bae5e56ff | |||
| a44a9ce242 | |||
| f951ec428d | |||
| 5c53b8cf2f | |||
| 0f0691d037 | |||
| 7985a9b0d7 | |||
| 779179af01 | |||
| d60a225368 | |||
| 212f7a0096 | |||
| cc16f89bbe | |||
| 3c2038e8fe | |||
| 700ab9def4 | |||
| 8853d08e5a | |||
| 7ec0904c5c | |||
| e2ae4b34b3 | |||
| f0cbcfa949 | |||
| 91b569ffd3 | |||
| 0f5b8a4f52 | |||
| 1caaec5601 | |||
| 94414057e6 | |||
| 76fd17c727 | |||
| 5f30220609 | |||
| a599047cf0 | |||
| 95dd259913 | |||
| ff097cce3c | |||
| aeffb8e4d4 | |||
| 27b52da0e5 | |||
| 5233a485eb | |||
| 7643740eda | |||
| 0662901b1b | |||
| 72d23af335 | |||
| 1eb58ea331 | |||
| 0c5e218aa8 | |||
| 3f5d0e9539 | |||
| ffe35c048d | |||
| 2219d7e26e | |||
| 0ff64d2737 | |||
| 28d402d204 | |||
| 9a98bdfbe6 | |||
| cb2e962116 | |||
| 7bbc4c18d7 | |||
| e7436e7898 | |||
| 4ef95eaa27 | |||
| efc4dfd752 | |||
| 3ad67a1610 | |||
| 4fe3c1eed9 | |||
| b170724f3f | |||
| 8b18c7159f | |||
| aa9a9318f5 | |||
| 2c5d4cedea | |||
| 39d03d30a8 | |||
| e7440e5e84 | |||
| f37530fa0e | |||
| 43956d286e | |||
| 157aee3812 | |||
| 6a8ba4fbc8 | |||
| 1e2c304754 | |||
| 8ac540c65b | |||
| 9c8f7b1a95 | |||
| 7fb86bfe21 | |||
| c52998671b | |||
| 9147dc0d01 | |||
| a5e4c5f46f | |||
| b366c9888f | |||
| 6d73e50bff | |||
| ba4f9113ae |
@@ -144,3 +144,7 @@ class ChatStateService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 迁移文件注意事项
|
||||
|
||||
同时新建多个迁移文件时,要注意 是否有关联主键问题,主键所在表要先创建,所以迁移文件名称 要比被调用表文件名的靠前,否则执行迁移时会报错;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"herd": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
|
||||
],
|
||||
"env": {
|
||||
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -24,3 +24,5 @@ Homestead.yaml
|
||||
Thumbs.db
|
||||
vendor.zip
|
||||
test-captcha.php
|
||||
public/.user.ini
|
||||
dump.rdb
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- laravel-echo (ECHO) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
|
||||
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
|
||||
"args": [
|
||||
"/Users/pllx/Web/Herd/chatroom/artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"herd": {
|
||||
"command": "/opt/homebrew/Cellar/php/8.4.5_1/bin/php",
|
||||
"args": [
|
||||
"/Applications/Herd.app/Contents/Resources/herd-mcp.phar"
|
||||
],
|
||||
"env": {
|
||||
"SITE_PATH": "/Users/pllx/Web/Herd/chatroom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -0,0 +1,256 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- laravel-echo (ECHO) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
|
||||
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
+699
-222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
# 🎮 聊天室游戏开发进度
|
||||
|
||||
> 更新时间:2026-03-04
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成
|
||||
|
||||
### 🎲 百家乐(Baccarat)
|
||||
|
||||
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
|
||||
- **数据库**:`baccarat_rounds` + `baccarat_bets`
|
||||
- **模型**:`BaccaratRound` / `BaccaratBet`
|
||||
- **队列 Job**:`OpenBaccaratRoundJob` (开局) + `CloseBaccaratRoundJob` (摇骰结算)
|
||||
- **事件**:`BaccaratRoundOpened` / `BaccaratRoundSettled`(PresenceChannel 广播)
|
||||
- **控制器**:`BaccaratController`(`/baccarat/current` / `/baccarat/bet` / `/baccarat/history`)
|
||||
- **前端**:`chat/partials/baccarat-panel.blade.php`(倒计时/押注/骰子动画/趋势)
|
||||
- **货币来源**:`CurrencySource::BACCARAT_BET` / `BACCARAT_WIN`
|
||||
- **后台配置**:`game_configs` 表,管理员可配置开关/间隔/赔率/押注范围
|
||||
|
||||
### 🎰 老虎机(Slot Machine)
|
||||
|
||||
- **类型**:玩家随时主动触发(即时游戏)
|
||||
- **数据库**:`slot_machine_logs`
|
||||
- **模型**:`SlotMachineLog`(8种带权重图案、判奖逻辑)
|
||||
- **控制器**:`SlotMachineController`(`/slot/info` / `/slot/spin` / `/slot/history`)
|
||||
- **赔率**:三7×100(全服广播)/ 三钻×50 / 三同×10 / 两同×2 / 三骷髅诅咒(扣双倍)
|
||||
- **聊天通知**:中奖发私信通知;三7全服公屏广播
|
||||
- **前端**:`chat/partials/slot-machine.blade.php`(三列滚轮动画/逐列停止/可拖动FAB)
|
||||
- **货币来源**:`CurrencySource::SLOT_SPIN` / `SLOT_WIN` / `SLOT_CURSE`
|
||||
- **后台配置**:`game_configs` 表,可配置每次消耗/每日次数上限/各赔率
|
||||
|
||||
### 📦 神秘箱子(Mystery Box)
|
||||
|
||||
- **类型**:系统定时自动投放 + 管理员手动投放(即时广播暗号,先到先得)
|
||||
- **数据库**:`mystery_boxes`(箱子记录)+ `mystery_box_claims`(领取日志)
|
||||
- **模型**:`MysteryBox` / `MysteryBoxClaim`
|
||||
- **队列 Job**:`DropMysteryBoxJob`(投放 + 公屏广播暗号 + 派发 ExpireJob)/ `ExpireMysteryBoxJob`(到期处理)
|
||||
- **控制器**:`MysteryBoxController`(`/mystery-box/status` 状态查询 / `/mystery-box/claim` 领取)
|
||||
- **前端**:`chat/partials/mystery-box.blade.php`(5秒轮询检测 + 可拖动FAB + 快捷输入面板)
|
||||
- **领取方式**:① 聊天框直接输入暗号发送(前端拦截,不发普通消息)② 点击悬浮FAB打开面板输入
|
||||
- **箱子类型**:普通箱(500\~2000金)/ 稀有箱(5000\~20000金)/ 黑化箱(陷阱,倒扣200\~1000金)
|
||||
- **货币来源**:`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
|
||||
- **后台配置**:`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
|
||||
|
||||
### 🐎 赛马竞猜(Horse Racing)
|
||||
|
||||
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
|
||||
- **数据库**:`horse_races` + `horse_bets`
|
||||
- **模型**:`HorseRace` / `HorseBet`
|
||||
- **队列 Job**:`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
|
||||
- **事件**:`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`(PresenceChannel 广播)
|
||||
- **控制器**:`HorseRaceController`(`/horse-race/current` / `/horse-race/bet` / `/horse-race/history`)
|
||||
- **广播**:`horse.opened` / `horse.progress` / `horse.settled`
|
||||
- **前端**:`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB)
|
||||
- **货币来源**:`CurrencySource::HORSE_BET` / `HORSE_WIN`
|
||||
- **后台配置**:`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
|
||||
|
||||
### 🔮 神秘占卜(Fortune Telling)
|
||||
|
||||
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
|
||||
- **数据库**:`fortune_logs`
|
||||
- **模型**:`FortuneLog`(55+ 条签文内嵌在模型中)
|
||||
- **控制器**:`FortuneTellingController`(`/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
|
||||
- **前端**:`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB)
|
||||
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
|
||||
- **广播**:暂无实时广播(占卜结果仅展示给本人)
|
||||
- **货币来源**:`CurrencySource::FORTUNE_COST`
|
||||
- **后台配置**:`game_configs` 表,免费次数/额外消耗/各签概率均可配置
|
||||
|
||||
---
|
||||
|
||||
## 🕐 待开发
|
||||
|
||||
---
|
||||
|
||||
## 📌 通用待办(所有游戏共用)
|
||||
|
||||
- [x] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据(点击"加载实时统计"异步加载各游戏汇总卡片)
|
||||
- [x] 各游戏历史记录在后台可查(管理员视角,新增 `/admin/game-history/` 路由组,支持百家乐/老虎机/赛马/神秘箱子/占卜各自的历史记录列表及详情页,含筛选分页)
|
||||
- [x] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
|
||||
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已修复的 Bug
|
||||
|
||||
1. **百家乐广播频道**:`Channel` → `PresenceChannel`,解决前端收不到 WebSocket 事件
|
||||
2. **百家乐余额检查**:`$user->gold` → `$user->jjb`(字段名错误)
|
||||
3. **老虎机积分日志**:普通中奖/诅咒发私信通知;三7全服广播
|
||||
4. **老虎机FAB**:支持拖动 + localStorage 位置持久化
|
||||
5. **星海小博士随机事件**:改走 `UserCurrencyService.change()`,补写流水日志
|
||||
6. **百家乐结算UI**:骰子改数字方块(跨平台);中奖/未中奖卡片重设计
|
||||
7. **全部 FAB 拖动统一**:百家乐 FAB 改为 Alpine.js `baccaratFab()` 组件,与老虎机 `slotFab()` 完全一致,位置持久化存 localStorage
|
||||
8. **Alpine.js 初始化顺序**:`frame.blade.php` 中 Alpine CDN 补加 `defer`,解决所有组件 `is not defined` 错误
|
||||
9. **神秘箱子暗号领取**:改为主动尝试模式(不依赖5秒轮询),聊天框输入暗号即可触发领取;`claim()` 暗号统一转大写
|
||||
10. **神秘箱子流水记录**:`change()` 调用补上 `room_id` 参数,确保积分统计页面可按房间筛选
|
||||
11. **后台弹窗**:游戏管理页所有 `alert/confirm` 替换为全局 `window.adminDialog`(毛玻璃弹窗)
|
||||
@@ -0,0 +1,256 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- laravel-echo (ECHO) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- `public function __construct(public GitHub $github) { }`
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<!-- Explicit Return Types and Method Params -->
|
||||
```php
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user.
|
||||
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI小班长专属极轻量心跳模拟器
|
||||
*
|
||||
* 专门用于让无法通过浏览器发送真实心跳的 AI实体用户
|
||||
* 也能够完美触原有的法发经验/金币逻辑以及触发随机事件(Autoact)。
|
||||
* 每分钟由 Laravel Scheduler 调用。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Autoact;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AiHeartbeatCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Artisan 指令名称
|
||||
*/
|
||||
protected $signature = 'chatroom:ai-heartbeat';
|
||||
|
||||
/**
|
||||
* 指令描述
|
||||
*/
|
||||
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
|
||||
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// 1. 检查总开关
|
||||
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 2. 获取 AI 实体
|
||||
$user = User::where('username', 'AI小班长')->first();
|
||||
if (! $user) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 3. 常规心跳经验与金币发放
|
||||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||||
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
|
||||
if ($expGain > 0) {
|
||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||||
$user->exp_num += $actualExpGain;
|
||||
}
|
||||
|
||||
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
|
||||
if ($jjbGain > 0) {
|
||||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||||
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
$user->refresh();
|
||||
|
||||
// 4. 重算等级(基础心跳升级)
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||||
|
||||
// 5. 随机事件触发
|
||||
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
|
||||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||||
$autoEvent = Autoact::randomEvent();
|
||||
if ($autoEvent) {
|
||||
// 执行随机事件的金钱经验惩奖
|
||||
if ($autoEvent->exp_change !== 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||
);
|
||||
}
|
||||
if ($autoEvent->jjb_change !== 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||
);
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
|
||||
// 重新计算等级
|
||||
if ($this->calculateNewLevel($user, $superLevel)) {
|
||||
$leveledUp = true;
|
||||
}
|
||||
|
||||
// 广播随机事件
|
||||
$this->broadcastSystemMessage(
|
||||
'星海小博士',
|
||||
$autoEvent->renderText($user->username),
|
||||
match ($autoEvent->event_type) {
|
||||
'good' => '#16a34a',
|
||||
'bad' => '#dc2626',
|
||||
default => '#7c3aed',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 如果由于心跳或事件导致了升级,广播升级消息
|
||||
if ($leveledUp) {
|
||||
$this->broadcastSystemMessage(
|
||||
'系统传音',
|
||||
"🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!",
|
||||
'#d97706',
|
||||
'大声宣告'
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 钓鱼小游戏随机参与逻辑
|
||||
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
|
||||
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
|
||||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
|
||||
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
||||
if ($user->jjb >= $cost) {
|
||||
// 先扣除费用
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$cost,
|
||||
CurrencySource::FISHING_COST,
|
||||
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
||||
1,
|
||||
);
|
||||
|
||||
// 模拟玩家等待时间
|
||||
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
||||
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
||||
$waitTime = rand($waitMin, $waitMax);
|
||||
|
||||
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
||||
|
||||
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并更新用户等级
|
||||
*/
|
||||
private function calculateNewLevel(User $user, int $superLevel): bool
|
||||
{
|
||||
$oldLevel = $user->user_level;
|
||||
|
||||
if ($oldLevel >= $superLevel) {
|
||||
return false; // 管理员不自动升降级
|
||||
}
|
||||
|
||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
|
||||
$user->user_level = $newLevel;
|
||||
$user->save();
|
||||
|
||||
return $newLevel > $oldLevel;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置的奖励范围,如 "1" 或 "1-5"
|
||||
*/
|
||||
private function parseRewardValue(string $raw): int
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if (str_contains($raw, '-')) {
|
||||
[$min, $max] = explode('-', $raw, 2);
|
||||
|
||||
return rand((int) $min, (int) $max);
|
||||
}
|
||||
|
||||
return (int) $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 往所有活跃房间发送系统广播消息
|
||||
*/
|
||||
private function broadcastSystemMessage(string $fromUser, string $content, string $color, string $action = ''): void
|
||||
{
|
||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||
if (empty($activeRoomIds)) {
|
||||
$activeRoomIds = [1];
|
||||
}
|
||||
|
||||
foreach ($activeRoomIds as $roomId) {
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => $fromUser,
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => $color,
|
||||
'action' => $action,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
SaveMessageJob::dispatch($sysMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,24 @@
|
||||
* 3. 在聊天室内推送"系统为你自动存点"提示
|
||||
* 4. 若用户等级提升,向全频道广播恭喜消息
|
||||
*
|
||||
* @package App\Console\Commands
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\PositionDutyLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class AutoSaveExp extends Command
|
||||
@@ -43,6 +47,7 @@ class AutoSaveExp extends Command
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -61,13 +66,14 @@ class AutoSaveExp extends Command
|
||||
// 读取奖励配置
|
||||
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
||||
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
// 从 Redis 扫描所有在线房间
|
||||
$roomMap = $this->scanOnlineRooms();
|
||||
|
||||
if (empty($roomMap)) {
|
||||
$this->info('当前没有在线用户,跳过存点。');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -116,11 +122,11 @@ class AutoSaveExp extends Command
|
||||
/**
|
||||
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
||||
*
|
||||
* @param string $username 用户名
|
||||
* @param int $roomId 所在房间ID
|
||||
* @param string $username 用户名
|
||||
* @param int $roomId 所在房间ID
|
||||
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
||||
* @param string $jjbGainRaw 金币奖励原始配置
|
||||
* @param int $superLevel 管理员等级阈值
|
||||
* @param int $superLevel 管理员等级阈值
|
||||
*/
|
||||
private function processUser(
|
||||
string $username,
|
||||
@@ -134,30 +140,53 @@ class AutoSaveExp extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 发放经验奖励(支持 VIP 倍率)
|
||||
$expGain = $this->parseRewardValue($expGainRaw);
|
||||
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
||||
$expGain = $this->parseRewardValue($expGainRaw);
|
||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||||
$user->exp_num += $actualExpGain;
|
||||
|
||||
// 2. 发放金币奖励(支持 VIP 倍率)
|
||||
$jjbGain = $this->parseRewardValue($jjbGainRaw);
|
||||
$jjbGain = $this->parseRewardValue($jjbGainRaw);
|
||||
$actualJjbGain = 0;
|
||||
if ($jjbGain > 0) {
|
||||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||||
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
|
||||
}
|
||||
|
||||
// 3. 自动升降级(管理员不参与)
|
||||
$oldLevel = $user->user_level;
|
||||
// 2. 通过统一积分服务发放奖励(原子写入 + 流水记录)
|
||||
if ($actualExpGain > 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'exp', $actualExpGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
||||
);
|
||||
}
|
||||
if ($actualJjbGain > 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
||||
);
|
||||
}
|
||||
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
|
||||
$user->load('activePosition.position'); // 确保职务及职位关联已加载
|
||||
|
||||
// 3. 自动升降级逻辑
|
||||
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
||||
// - 管理员(>= superLevel):不变动
|
||||
// - 普通用户:按经验计算等级,支持升降级
|
||||
$oldLevel = $user->user_level;
|
||||
$leveledUp = false;
|
||||
|
||||
if ($oldLevel < $superLevel) {
|
||||
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
||||
|
||||
if ($activeUP?->position) {
|
||||
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
|
||||
$requiredLevel = (int) $activeUP->position->level;
|
||||
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
|
||||
$user->user_level = $requiredLevel;
|
||||
}
|
||||
} elseif ($oldLevel < $superLevel) {
|
||||
// 普通用户:按经验计算并更新等级
|
||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
|
||||
$user->user_level = $newLevel;
|
||||
$leveledUp = ($newLevel > $oldLevel);
|
||||
$leveledUp = ($newLevel > $oldLevel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,19 +195,27 @@ class AutoSaveExp extends Command
|
||||
// 4. 若升级,向全频道广播升级消息
|
||||
if ($leveledUp) {
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!",
|
||||
'is_secret' => false,
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
SaveMessageJob::dispatch($sysMsg);
|
||||
|
||||
// 触发微信机器人私聊通知 (等级提升)
|
||||
try {
|
||||
$wechatService = app(\App\Services\WechatBot\WechatNotificationService::class);
|
||||
$wechatService->notifyLevelChange($user, $oldLevel, $newLevel);
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('WechatBot level change notification failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示
|
||||
@@ -190,26 +227,29 @@ class AutoSaveExp extends Command
|
||||
$gainParts[] = "金币+{$actualJjbGain}";
|
||||
}
|
||||
$jjbDisplay = $user->jjb ?? 0;
|
||||
$gainStr = ! empty($gainParts) ? ' 本次获得:' . implode(',', $gainParts) : '';
|
||||
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode(',', $gainParts) : '';
|
||||
|
||||
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
||||
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
|
||||
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
|
||||
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
|
||||
$noticeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $username, // 定向推送给本人
|
||||
'content' => $content,
|
||||
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $username, // 定向推送给本人
|
||||
'content' => $content,
|
||||
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
||||
'font_color' => '#16a34a', // 草绿色
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $noticeMsg);
|
||||
broadcast(new MessageSent($roomId, $noticeMsg));
|
||||
|
||||
// 6. 同步更新在职用户的勤务时长
|
||||
$this->tickDutyLog($user, $roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,16 +260,71 @@ class AutoSaveExp extends Command
|
||||
* - 随机范围:如 "3-10" → 返回 [3, 10] 之间的随机整数
|
||||
*
|
||||
* @param string $raw 原始配置字符串
|
||||
* @return int
|
||||
*/
|
||||
private function parseRewardValue(string $raw): int
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if (str_contains($raw, '-')) {
|
||||
[$min, $max] = explode('-', $raw, 2);
|
||||
|
||||
return rand((int) $min, (int) $max);
|
||||
}
|
||||
|
||||
return (int) $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动存点时同步更新或创建在职用户的勤务日志。
|
||||
*
|
||||
* 逻辑同 ChatController::tickDutyLog:
|
||||
* 1. 无在职职务 → 跳过
|
||||
* 2. 今日已有开放日志 → 刷新 duration_seconds
|
||||
* 3. 今日无日志 → 新建,login_at 取 user->in_time(进房时间)
|
||||
*
|
||||
* @param \App\Models\User $user 已 refresh 的用户实例
|
||||
* @param int $roomId 所在房间 ID
|
||||
*/
|
||||
private function tickDutyLog(User $user, int $roomId): void
|
||||
{
|
||||
// 无论有无职务,均记录在线流水
|
||||
$activeUP = $user->activePosition;
|
||||
|
||||
// ① 今日未关闭的开放日志 → 刷新时长
|
||||
$openLog = PositionDutyLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('logout_at')
|
||||
->whereDate('login_at', today())
|
||||
->first();
|
||||
|
||||
if ($openLog) {
|
||||
DB::table('position_duty_logs')
|
||||
->where('id', $openLog->id)
|
||||
->update([
|
||||
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 今日无开放日志 → 新建
|
||||
// 若今日已有已关闭的日志(是重建场景),必须用 now(),防止重用旧 in_time 累积膨胀
|
||||
$hasClosedToday = PositionDutyLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('login_at', today())
|
||||
->whereNotNull('logout_at')
|
||||
->exists();
|
||||
|
||||
$loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
|
||||
? $user->in_time
|
||||
: now();
|
||||
|
||||
PositionDutyLog::create([
|
||||
'user_id' => $user->id,
|
||||
'user_position_id' => $activeUP?->id,
|
||||
'login_at' => $loginAt,
|
||||
'ip_address' => '0.0.0.0',
|
||||
'room_id' => $roomId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:清理房间在线名单的 Redis 缓存
|
||||
* 用于清除历史遗留的「幽灵在线」脏数据
|
||||
*
|
||||
* 执行方式:php artisan room:clear-online-cache
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ClearRoomOnlineCache extends Command
|
||||
{
|
||||
/**
|
||||
* Artisan 命令名称
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'room:clear-online-cache';
|
||||
|
||||
/**
|
||||
* 命令描述
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '清空所有房间的 Redis 在线名单(清除幽灵在线脏数据)';
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*
|
||||
* 扫描所有 room:*:users Redis Key 并删除,让用户重新进房时写入干净数据。
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$cursor = '0';
|
||||
$cleaned = 0;
|
||||
|
||||
$this->info("Redis 前缀:\"{$prefix}\"");
|
||||
$this->info('开始扫描 room:*:users ...');
|
||||
|
||||
do {
|
||||
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
|
||||
foreach ($keys ?? [] as $fullKey) {
|
||||
// 去掉 Redis 前缀,还原为 Laravel Facade 使用的短 Key
|
||||
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
|
||||
Redis::del($shortKey);
|
||||
$this->line(" ✓ 已清除:{$shortKey}");
|
||||
$cleaned++;
|
||||
}
|
||||
} while ($cursor !== '0');
|
||||
|
||||
$this->info("完成!共清理 {$cleaned} 个房间的在线名单。");
|
||||
$this->info('用户下次进房会重新写入正确数据,人数从 0 开始准确累计。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:自动关闭掉线职务日志指令
|
||||
*
|
||||
* 每 15 分钟由 Laravel Scheduler 调用,扫描 position_duty_logs 表中:
|
||||
* - logout_at IS NULL(尚未结算的开放日志)
|
||||
* - updated_at 超过 15 分钟未刷新(说明心跳已中断,用户已掉线/关闭浏览器)
|
||||
*
|
||||
* 对此类日志写入 logout_at = NOW(),保留 duration_seconds 现有值不清零,
|
||||
* 确保累计时长计算准确,不因掉线而永久悬空。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PositionDutyLog;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CloseStaleDutyLogs extends Command
|
||||
{
|
||||
/**
|
||||
* Artisan 指令名称
|
||||
*/
|
||||
protected $signature = 'duty:close-stale-logs';
|
||||
|
||||
/**
|
||||
* 指令描述(在 artisan list 中显示)
|
||||
*/
|
||||
protected $description = '自动关闭 15 分钟内无心跳的开放职务日志(解决掉线不结算问题)';
|
||||
|
||||
/**
|
||||
* 指令入口:将长时间无心跳刷新的开放日志判定为掉线,写入 logout_at 完成结算。
|
||||
*
|
||||
* 判定标准:updated_at 超过 15 分钟(心跳间隔约 30 秒,5 分钟自动存点最长 5 分钟,
|
||||
* 留足 10 分钟容差,总计 15 分钟无刷新即认为掉线)
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// 15 分钟无心跳 = 掉线判定阈值
|
||||
$threshold = now()->subMinutes(15);
|
||||
|
||||
// 批量关闭符合条件的开放日志,保留现有 duration_seconds
|
||||
$affected = PositionDutyLog::query()
|
||||
->whereNull('logout_at')
|
||||
->where('updated_at', '<=', $threshold)
|
||||
->update([
|
||||
'logout_at' => DB::raw('NOW()'),
|
||||
// 补算最终在线时长,避免日榜 SUM 使用过时的旧值
|
||||
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$this->info("共关闭 {$affected} 条掉线职务日志。");
|
||||
} else {
|
||||
$this->info('无需处理,无掉线日志。');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信机器人 Kafka 消费命令
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\WechatBot\KafkaConsumerService;
|
||||
use App\Services\WechatBot\WechatBotApiService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ConsumeWechatMessages extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wechat-bot:consume';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '消费 Kafka 微信机器人消息(守护进程)';
|
||||
|
||||
protected KafkaConsumerService $kafkaService;
|
||||
|
||||
public function __construct(KafkaConsumerService $kafkaService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->kafkaService = $kafkaService;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('正在启动微信机器人 Kafka 消费者...');
|
||||
|
||||
$consumer = $this->kafkaService->createConsumer();
|
||||
if (! $consumer) {
|
||||
$this->error('Kafka 配置不完整或加载失败,请在后台检查机器人设置。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('消费者已启动,等待消息...');
|
||||
|
||||
$apiService = new WechatBotApiService;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$messageJson = $consumer->consume();
|
||||
if ($messageJson) {
|
||||
$rawJson = $messageJson->getValue();
|
||||
$this->info('--> 收到新的 Kafka 消息 (Raw Length: '.strlen($rawJson).')');
|
||||
|
||||
$messages = $this->kafkaService->parseKafkaMessage($rawJson);
|
||||
|
||||
if (empty($messages)) {
|
||||
$this->info('--> 解析后:无匹配的 AddMsgs 内容');
|
||||
}
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
try {
|
||||
$this->processMessage($msg, $apiService);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('处理单条微信消息失败', [
|
||||
'error' => $e->getMessage(),
|
||||
'msg' => $msg,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$consumer->ack($messageJson);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Kafka 消费异常', ['error' => $e->getMessage()]);
|
||||
// 延迟重试避免死循环 CPU 空转
|
||||
sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条消息逻辑
|
||||
*/
|
||||
protected function processMessage(array $msg, WechatBotApiService $apiService): void
|
||||
{
|
||||
// 仅处理文本消息 (msg_type = 1)
|
||||
if ($msg['msg_type'] != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = trim($msg['content']);
|
||||
$fromUser = $msg['from_user'];
|
||||
$isChatroom = $msg['is_chatroom'];
|
||||
|
||||
// 绑定逻辑:必须是私聊(防止在群内绑定导致未来系统无法直接通过私聊推送个人通知)
|
||||
if (! $isChatroom && preg_match('/^BD-\d{6}$/i', $content)) {
|
||||
$this->info("收到潜在绑定请求: {$content} from {$fromUser}");
|
||||
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理账号绑定请求
|
||||
*/
|
||||
protected function handleBindRequest(string $code, string $wxid, WechatBotApiService $apiService): void
|
||||
{
|
||||
$cacheKey = 'wechat_bind_code:'.$code;
|
||||
$username = Cache::get($cacheKey);
|
||||
|
||||
if (! $username) {
|
||||
$apiService->sendTextMessage($wxid, '❌ 绑定失败:该验证码无效或已过有效期(5分钟)。请在个人中心重新生成。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::where('username', $username)->first();
|
||||
if (! $user) {
|
||||
$apiService->sendTextMessage($wxid, '❌ 绑定失败:找不到对应的用户账号。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断该微信号是否已经被其他用户绑定(防止碰撞或安全隐患)
|
||||
$existing = User::where('wxid', $wxid)->where('id', '!=', $user->id)->first();
|
||||
if ($existing) {
|
||||
$apiService->sendTextMessage($wxid, "❌ 绑定失败:当前微信号已经被其他账号 [{$existing->username}] 绑定。请先解绑后再试。");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->wxid = $wxid;
|
||||
$user->save();
|
||||
|
||||
// 验证成功后立即销毁验证码
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
$this->info("用户 [{$username}] 成功绑定微信: {$wxid}");
|
||||
|
||||
$successMsg = "🎉 绑定成功!\n"
|
||||
."您已成功绑定聊天室账号:[{$username}]。\n"
|
||||
.'现在您可以接收重要系统通知了。';
|
||||
|
||||
$apiService->sendTextMessage($wxid, $successMsg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:测试发送微信机器人消息
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SysParam;
|
||||
use App\Services\WechatBot\WechatBotApiService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WechatBotTestSend extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'wechat-bot:test-send';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '测试发送一条消息给管理员设定的微信群群 wxid';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('开始测试微信机器人发送...');
|
||||
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->first();
|
||||
if (! $param || empty($param->body)) {
|
||||
$this->error('错误:未找到 wechat_bot_config 配置,请先在后台保存一次配置。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$config = json_decode($param->body, true);
|
||||
$targetWxid = $config['group_notify']['target_wxid'] ?? '';
|
||||
|
||||
if (empty($targetWxid)) {
|
||||
$this->error('错误:请于后台填写【目标微信群 Wxid】。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($config['api']['bot_key'] ?? '')) {
|
||||
$this->error('错误:未配置【机器人 Key (必需)】,API请求将被拒绝(返回该链接不存在)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$service = new WechatBotApiService;
|
||||
|
||||
$this->info("发送目标: {$targetWxid}");
|
||||
$this->info('发送 API Base: '.($config['api']['base_url'] ?? ''));
|
||||
|
||||
$message = "【系统连通性测试】\n发送时间:".now()->format('Y-m-d H:i:s')."\n如果您看到了这条消息,说明 ChatRoom 通知全站群发接口配置正确!";
|
||||
|
||||
$result = $service->sendTextMessage($targetWxid, $message);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info('✅ 发送成功!');
|
||||
|
||||
return self::SUCCESS;
|
||||
} else {
|
||||
$this->error('❌ 发送失败:'.($result['error'] ?? '未知错误'));
|
||||
$this->warn('如果提示『该链接不存在』代表您的基础API URL 或接入 Key 有误。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:积分来源活动枚举
|
||||
* 集中管理所有合法的 source 标识值,新增活动只需在此加一行常量,数据库字段无需任何变更。
|
||||
* 对应数据表:user_currency_logs.source(varchar 字段,非 ENUM,可自由扩展)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CurrencySource: string
|
||||
{
|
||||
/** 自动存点(Horizon 定时任务,每5分钟给在线用户加经验/金币) */
|
||||
case AUTO_SAVE = 'auto_save';
|
||||
|
||||
/** 钓鱼收竿奖励(获得经验或金币) */
|
||||
case FISHING_GAIN = 'fishing_gain';
|
||||
|
||||
/** 钓鱼抛竿消耗(扣除金币) */
|
||||
case FISHING_COST = 'fishing_cost';
|
||||
|
||||
/** 送出礼物(送方扣金币) */
|
||||
case SEND_GIFT = 'send_gift';
|
||||
|
||||
/** 收到礼物(收方魅力增加) */
|
||||
case RECV_GIFT = 'recv_gift';
|
||||
|
||||
/** 新人礼包(首次登录赠送金币) */
|
||||
case NEWBIE_BONUS = 'newbie_bonus';
|
||||
|
||||
/** 商城购买消耗(扣除金币) */
|
||||
case SHOP_BUY = 'shop_buy';
|
||||
|
||||
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
||||
case ADMIN_ADJUST = 'admin_adjust';
|
||||
|
||||
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
|
||||
case POSITION_REWARD = 'position_reward';
|
||||
|
||||
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
|
||||
case AI_GIFT = 'ai_gift';
|
||||
|
||||
/** 赠人玫瑰(用户或AI对外发放金币红包) */
|
||||
case GIFT_SENT = 'gift_sent';
|
||||
|
||||
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
|
||||
// case SIGN_IN = 'sign_in'; // 每日签到
|
||||
// case TASK_REWARD = 'task_reward'; // 任务奖励
|
||||
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
|
||||
|
||||
// ─── 婚姻系统 ────────────────────────────────────────────────
|
||||
|
||||
/** 结婚魅力加成(双方各获得,由戒指档次决定) */
|
||||
case MARRY_CHARM = 'marry_charm';
|
||||
|
||||
/** 离婚魅力惩罚(协议/强制/超时自动)*/
|
||||
case DIVORCE_CHARM = 'divorce_charm';
|
||||
|
||||
/** 购买戒指(gold 消耗,由 ShopService 代理) */
|
||||
case RING_BUY = 'ring_buy';
|
||||
|
||||
/** 戒指消失记录(求婚被拒/超时,金额=0,仅存档) */
|
||||
case RING_LOST = 'ring_lost';
|
||||
|
||||
/** 发送婚礼红包(扣除金币) */
|
||||
case WEDDING_ENV_SEND = 'wedding_env_send';
|
||||
|
||||
/** 领取婚礼红包(收入金币) */
|
||||
case WEDDING_ENV_RECV = 'wedding_env_recv';
|
||||
|
||||
/** 强制离婚财产转移(付出方为负,接收方为正) */
|
||||
case FORCED_DIVORCE_TRANSFER = 'forced_divorce_transfer';
|
||||
|
||||
/** 节日福利红包(管理员设置的定时金币福利) */
|
||||
case HOLIDAY_BONUS = 'holiday_bonus';
|
||||
|
||||
/** 百家乐下注消耗(扣除金币) */
|
||||
case BACCARAT_BET = 'baccarat_bet';
|
||||
|
||||
/** 百家乐中奖赔付(收入金币,含本金返还) */
|
||||
case BACCARAT_WIN = 'baccarat_win';
|
||||
|
||||
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
|
||||
case AUTO_EVENT = 'auto_event';
|
||||
|
||||
/** 老虎机转动消耗金币 */
|
||||
case SLOT_SPIN = 'slot_spin';
|
||||
|
||||
/** 老虎机中奖赔付(含本金返还) */
|
||||
case SLOT_WIN = 'slot_win';
|
||||
|
||||
/** 老虎机诅咒额外扣除 */
|
||||
case SLOT_CURSE = 'slot_curse';
|
||||
|
||||
/** 领取礼包红包——金币(用户抢到金币礼包时收入) */
|
||||
case RED_PACKET_RECV = 'red_packet_recv';
|
||||
|
||||
/** 领取礼包红包——经验(用户抢到经验礼包时收入) */
|
||||
case RED_PACKET_RECV_EXP = 'red_packet_recv_exp';
|
||||
|
||||
/** 神秘箱子——领取奖励(普通箱/稀有箱,正数金币) */
|
||||
case MYSTERY_BOX = 'mystery_box';
|
||||
|
||||
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
|
||||
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
|
||||
|
||||
/** 赛马竞猜——下注消耗(扣除金币) */
|
||||
case HORSE_BET = 'horse_bet';
|
||||
|
||||
/** 赛马竞猜——中奖赔付(收入金币,含本金返还) */
|
||||
case HORSE_WIN = 'horse_win';
|
||||
|
||||
/** 神秘占卜——额外次数消耗(扣除金币) */
|
||||
case FORTUNE_COST = 'fortune_cost';
|
||||
|
||||
/** 双色球购票消耗(每注扣除 ticket_price 金币) */
|
||||
case LOTTERY_BUY = 'lottery_buy';
|
||||
|
||||
/** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */
|
||||
case LOTTERY_WIN = 'lottery_win';
|
||||
|
||||
/** 五子棋 PvP 对战入场费(PvE 欻入场费) */
|
||||
case GOMOKU_ENTRY_FEE = 'gomoku_entry_fee';
|
||||
|
||||
/** 五子棋对战胜利奖励(PvP/PvE 获胜时收入) */
|
||||
case GOMOKU_WIN = 'gomoku_win';
|
||||
|
||||
/** 五子棋 PvE 入场费返还(平局时返还) */
|
||||
case GOMOKU_REFUND = 'gomoku_refund';
|
||||
|
||||
/** 看视频赚金币与经验奖励 */
|
||||
case VIDEO_REWARD = 'video_reward';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AUTO_SAVE => '自动存点',
|
||||
self::FISHING_GAIN => '钓鱼奖励',
|
||||
self::FISHING_COST => '钓鱼消耗',
|
||||
self::SEND_GIFT => '送出礼物',
|
||||
self::RECV_GIFT => '收到礼物',
|
||||
self::NEWBIE_BONUS => '新人礼包',
|
||||
self::SHOP_BUY => '商城购买',
|
||||
self::ADMIN_ADJUST => '管理员调整',
|
||||
self::POSITION_REWARD => '职务奖励',
|
||||
self::AI_GIFT => 'AI赠送',
|
||||
self::GIFT_SENT => '发红包',
|
||||
self::MARRY_CHARM => '结婚魅力加成',
|
||||
self::DIVORCE_CHARM => '离婚魅力惩罚',
|
||||
self::RING_BUY => '购买戒指',
|
||||
self::RING_LOST => '戒指消失',
|
||||
self::WEDDING_ENV_SEND => '发送婚礼红包',
|
||||
self::WEDDING_ENV_RECV => '领取婚礼红包',
|
||||
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
|
||||
self::HOLIDAY_BONUS => '节日福利',
|
||||
self::BACCARAT_BET => '百家乐下注',
|
||||
self::BACCARAT_WIN => '百家乐赢钱',
|
||||
self::AUTO_EVENT => '随机事件(星海小博士)',
|
||||
self::SLOT_SPIN => '老虎机转动',
|
||||
self::SLOT_WIN => '老虎机中奖',
|
||||
self::SLOT_CURSE => '老虎机诅咒',
|
||||
self::RED_PACKET_RECV => '领取礼包红包(金币)',
|
||||
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
|
||||
self::MYSTERY_BOX => '神秘箱子奖励',
|
||||
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
|
||||
self::HORSE_BET => '赛马下注',
|
||||
self::HORSE_WIN => '赛马赢钱',
|
||||
self::FORTUNE_COST => '神秘占卜消耗',
|
||||
self::LOTTERY_BUY => '双色球购票',
|
||||
self::LOTTERY_WIN => '双色球中奖',
|
||||
self::GOMOKU_ENTRY_FEE => '五子棋入场费',
|
||||
self::GOMOKU_WIN => '五子棋获胜奖励',
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:婚姻亲密度来源枚举
|
||||
*
|
||||
* 集中管理所有合法的亲密度增减来源标识,写入 marriage_intimacy_logs.source。
|
||||
* 新增来源只需在此加一行,数据库字段无需变更(VARCHAR 类型)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IntimacySource: string
|
||||
{
|
||||
/** 每日时间奖励(Horizon 00:00 定时任务) */
|
||||
case DAILY_TIME = 'daily_time';
|
||||
|
||||
/** 双方同时在线(AutoSaveJob 每分钟检测) */
|
||||
case ONLINE_TOGETHER = 'online_together';
|
||||
|
||||
/** 收到伴侣送花 */
|
||||
case RECV_FLOWER = 'recv_flower';
|
||||
|
||||
/** 向伴侣送花 */
|
||||
case SEND_FLOWER = 'send_flower';
|
||||
|
||||
/** 发送私聊消息(每2条 +1) */
|
||||
case PRIVATE_CHAT = 'private_chat';
|
||||
|
||||
/** 结婚时戒指初始亲密度加成(一次性) */
|
||||
case WEDDING_BONUS = 'wedding_bonus';
|
||||
|
||||
/** 管理员手动调整 */
|
||||
case ADMIN_ADJUST = 'admin_adjust';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称(后台统计展示用)。
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DAILY_TIME => '每日时间奖励',
|
||||
self::ONLINE_TOGETHER => '双方同时在线',
|
||||
self::RECV_FLOWER => '收到伴侣送花',
|
||||
self::SEND_FLOWER => '向伴侣送花',
|
||||
self::PRIVATE_CHAT => '私聊消息',
|
||||
self::WEDDING_BONUS => '结婚戒指加成',
|
||||
self::ADMIN_ADJUST => '管理员调整',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:任命公告广播事件
|
||||
* 任命操作成功后向对应聊天室 PresenceChannel 推送任命消息,
|
||||
* 前端接收后展示全屏礼花动画和隆重公告弹窗。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AppointmentAnnounced implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构建任命公告事件
|
||||
*
|
||||
* @param int $roomId 广播目标房间 ID
|
||||
* @param string $targetUsername 被任命用户名
|
||||
* @param string $positionIcon 职务图标
|
||||
* @param string $positionName 职务名称
|
||||
* @param string $departmentName 所属部门名称
|
||||
* @param string $operatorName 任命人用户名
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $targetUsername,
|
||||
public readonly string $positionIcon,
|
||||
public readonly string $positionName,
|
||||
public readonly string $departmentName,
|
||||
public readonly string $operatorName,
|
||||
public readonly string $type = 'appoint', // appoint | revoke
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至目标房间的 PresenceChannel
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'target_username' => $this->targetUsername,
|
||||
'position_icon' => $this->positionIcon,
|
||||
'position_name' => $this->positionName,
|
||||
'department_name' => $this->departmentName,
|
||||
'operator_name' => $this->operatorName,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐押注人数实时广播事件
|
||||
*
|
||||
* 当有用户成功下注时,向房间内所有用户广播最新的
|
||||
* 各选项下注总人次,供前端实时更新面板。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BaccaratRound;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BaccaratPoolUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 本局信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .baccarat.pool_updated)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'baccarat.pool_updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'bet_count_big' => $this->round->bet_count_big,
|
||||
'bet_count_small' => $this->round->bet_count_small,
|
||||
'bet_count_triple' => $this->round->bet_count_triple,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐开局广播事件
|
||||
*
|
||||
* 新局开始时广播给房间所有用户,携带局次 ID 和下注截止时间,
|
||||
* 前端收到后展示倒计时下注面板。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BaccaratRound;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 本局信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .baccarat.opened)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'baccarat.opened';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
|
||||
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
|
||||
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐结算广播事件
|
||||
*
|
||||
* 开奖后广播骰子结果和获奖类型,前端播放骰子动画,
|
||||
* 并显示用户是否中奖及赔付金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BaccaratRound;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 已结算的局次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .baccarat.settled)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'baccarat.settled';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:骰子点数 + 开奖结果 + 统计。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
|
||||
'total_points' => $this->round->total_points,
|
||||
'result' => $this->round->result,
|
||||
'result_label' => $this->round->resultLabel(),
|
||||
'total_payout' => $this->round->total_payout,
|
||||
'bet_count' => $this->round->bet_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:通用大卡片通知广播事件
|
||||
*
|
||||
* 可向指定用户(私有频道)或房间所有人(Presence 频道)推送全屏大卡片通知。
|
||||
* 前端通过 window.chatBanner.show(options) 渲染,支持完全自定义。
|
||||
*
|
||||
* 使用示例(后端):
|
||||
*
|
||||
* // 推给单个用户
|
||||
* broadcast(new BannerNotification(
|
||||
* target: 'user',
|
||||
* targetId: 'lkddi',
|
||||
* options: [
|
||||
* 'icon' => '💚📩',
|
||||
* 'title' => '好友申请',
|
||||
* 'name' => 'lkddi1',
|
||||
* 'body' => '将你加为好友了!',
|
||||
* 'gradient' => ['#1e3a5f', '#1d4ed8', '#0891b2'],
|
||||
* 'autoClose' => 0,
|
||||
* 'buttons' => [
|
||||
* ['label' => '➕ 回加好友', 'color' => '#10b981', 'action' => 'add_friend', 'actionData' => 'lkddi1'],
|
||||
* ['label' => '稍后再说', 'color' => 'rgba(255,255,255,0.15)', 'action' => 'close'],
|
||||
* ],
|
||||
* ]
|
||||
* ));
|
||||
*
|
||||
* // 推给整个房间
|
||||
* broadcast(new BannerNotification(target: 'room', targetId: 1, options: [...] ));
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BannerNotification implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造通用大卡片通知事件。
|
||||
*
|
||||
* @param string $target 推送目标类型:'user'(私有频道)| 'room'(房间全员)
|
||||
* @param string|int $targetId 目标 ID:用户名(user)或 房间 ID(room)
|
||||
* @param array<string, mixed> $options 前端 chatBanner.show() 选项(详见文件顶部注释)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $target,
|
||||
public readonly string|int $targetId,
|
||||
public readonly array $options = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据 $target 决定广播到私有频道还是 Presence 频道。
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return match ($this->target) {
|
||||
'user' => new PrivateChannel('user.'.$this->targetId),
|
||||
'room' => new PresenceChannel('room.'.$this->targetId),
|
||||
default => new PrivateChannel('user.'.$this->targetId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定广播事件名称,供前端 .listen('.BannerNotification') 匹配。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'BannerNotification';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播负载:传递完整的 options 给前端渲染。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->target,
|
||||
'target_id' => $this->targetId,
|
||||
'options' => $this->options,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:开发日志发布广播事件
|
||||
* 当管理员发布新的开发日志并勾选"通知大厅"时触发
|
||||
* 广播至 Room ID=1(星光大厅)的 presence 频道
|
||||
* 前端监听此事件并在聊天消息区显示系统通知(含可点击的查看详情链接)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\DevChangelog;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ChangelogPublished implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数:传入触发通知的日志对象
|
||||
*
|
||||
* @param DevChangelog $changelog 刚发布的开发日志
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly DevChangelog $changelog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:仅向 Room 1(星光大厅)的 presence 频道广播
|
||||
* 复用现有聊天室频道机制,无需额外配置
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
// 固定广播至 Room ID = 1 的大厅频道
|
||||
new PresenceChannel('room.1'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名称(前端 .listen('ChangelogPublished', ...) 监听此名称)
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ChangelogPublished';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播携带的数据(前端可直接访问)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->changelog->version,
|
||||
'title' => $this->changelog->title,
|
||||
'type' => $this->changelog->type,
|
||||
'type_label' => $this->changelog->type_label,
|
||||
// 前端点击后跳转的目标 URL,自动锚定至对应版本
|
||||
'url' => url('/changelog').'#v'.$this->changelog->version,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ChatBotToggled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public array $user,
|
||||
public bool $isOnline
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new Channel('chat.system'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
/**
|
||||
* 文件功能:聊天室全屏特效广播事件
|
||||
*
|
||||
* 管理员触发烟花/下雨/雷电等特效后,
|
||||
* 通过 WebSocket 广播给房间内所有在线用户,前端收到后播放对应 Canvas 动画。
|
||||
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
|
||||
* 支持指定接收者(target_username 为 null 则全员播放)。
|
||||
*
|
||||
* @package App\Events
|
||||
* @author ChatRoom Laravel
|
||||
* @version 1.0.0
|
||||
*
|
||||
* @version 2.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
@@ -26,19 +26,23 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
/**
|
||||
* 支持的特效类型列表(用于校验)
|
||||
*/
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning'];
|
||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow'];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $type 特效类型:fireworks / rain / lightning
|
||||
* @param string $operator 触发特效的管理员用户名
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $type 特效类型:fireworks / rain / lightning / snow
|
||||
* @param string $operator 触发特效的用户名(购买者)
|
||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||
* @param string|null $giftMessage 附带赠言
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $type,
|
||||
public readonly string $operator,
|
||||
public readonly ?string $targetUsername = null,
|
||||
public readonly ?string $giftMessage = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -49,20 +53,22 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.' . $this->roomId),
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:特效类型和操作者
|
||||
* 广播数据:特效类型、操作者、目标用户、赠言
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'type' => $this->type,
|
||||
'operator' => $this->operator,
|
||||
'target_username' => $this->targetUsername, // null = 全员
|
||||
'gift_message' => $this->giftMessage,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:婚礼红包领取成功事件(广播至领取者私人频道)
|
||||
*
|
||||
* 触发时机:WeddingController::claim() 成功后广播,前端展示到账 Toast。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EnvelopeClaimed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param User $claimer 领取用户
|
||||
* @param int $amount 领取金额
|
||||
* @param int $ceremonyId 婚礼仪式 ID
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $claimer,
|
||||
public readonly int $amount,
|
||||
public readonly int $ceremonyId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至领取者私人频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->claimer->id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'ceremony_id' => $this->ceremonyId,
|
||||
'amount' => $this->amount,
|
||||
'message' => "🎉 成功领取 {$this->amount} 金币婚礼红包!",
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'envelope.claimed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:好友添加广播事件
|
||||
*
|
||||
* 当用户 A 添加用户 B 为好友时,向 B 的私有频道广播此事件。
|
||||
* 频道名使用数字 ID(user.{id}),避免中文用户名导致 Pusher 频道名验证失败。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class FriendAdded implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造好友添加事件。
|
||||
*
|
||||
* @param string $fromUsername 发起添加的用户名(A)
|
||||
* @param string $toUsername 被添加的用户名(B,用于消息显示)
|
||||
* @param int $toUserId 被添加用户的数字 ID(用于私有频道,避免中文名非法)
|
||||
* @param bool $hasAddedBack B 是否已将 A 加为好友(互相添加=true)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $fromUsername,
|
||||
public readonly string $toUsername,
|
||||
public readonly int $toUserId,
|
||||
public readonly bool $hasAddedBack = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播到被添加用户的私有频道(用数字 ID 命名,避免中文频道名不合法)。
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new PrivateChannel('user.'.$this->toUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定广播事件名称(短名),供前端 listen('.FriendAdded') 匹配。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'FriendAdded';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播负载:包含发起人信息和互相好友状态,供前端弹窗使用。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'from_username' => $this->fromUsername,
|
||||
'to_username' => $this->toUsername,
|
||||
'type' => 'friend_added',
|
||||
'has_added_back' => $this->hasAddedBack,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:好友删除广播事件
|
||||
*
|
||||
* 当用户 A 删除用户 B 为好友时,向 B 的私有频道广播此事件。
|
||||
* 频道名使用数字 ID(user.{id}),避免中文用户名导致 Pusher 频道名验证失败。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class FriendRemoved implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造好友删除事件。
|
||||
*
|
||||
* @param string $fromUsername 发起删除的用户名(A)
|
||||
* @param string $toUsername 被删除的用户名(B,用于消息显示)
|
||||
* @param int $toUserId 被删除用户的数字 ID(用于私有频道,避免中文名非法)
|
||||
* @param bool $hadAddedBack B 之前是否也将 A 加为好友(互相好友=true)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $fromUsername,
|
||||
public readonly string $toUsername,
|
||||
public readonly int $toUserId,
|
||||
public readonly bool $hadAddedBack = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播到被删除用户的私有频道(用数字 ID 命名,避免中文频道名不合法)。
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new PrivateChannel('user.'.$this->toUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定广播事件名称(短名),供前端 listen('.FriendRemoved') 匹配。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'FriendRemoved';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播负载:包含发起人信息和之前互相好友状态,供前端弹窗使用。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'from_username' => $this->fromUsername,
|
||||
'to_username' => $this->toUsername,
|
||||
'type' => 'friend_removed',
|
||||
'had_added_back' => $this->hadAddedBack,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:五子棋对局结束广播事件
|
||||
*
|
||||
* 对局结束(胜负/平局/认输/超时)时广播两个频道:
|
||||
* 1. 私有对局频道:通知双方结算并关闭棋盘
|
||||
* 2. 房间公共频道:广播战报消息
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\GomokuGame;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GomokuFinishedEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param GomokuGame $game 当前对局
|
||||
* @param string $winnerName 胜者用户名(平局时为空字符串)
|
||||
* @param string $loserName 败者用户名(平局时为空字符串)
|
||||
* @param string $reason 结束原因:win | draw | resign | timeout
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly GomokuGame $game,
|
||||
public readonly string $winnerName,
|
||||
public readonly string $loserName,
|
||||
public readonly string $reason,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 同时广播至对局私有频道 + 房间公共频道。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("gomoku.{$this->game->id}"),
|
||||
new PresenceChannel("room.{$this->game->room_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .gomoku.finished)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'gomoku.finished';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'game_id' => $this->game->id,
|
||||
'winner' => $this->game->winner,
|
||||
'winner_name' => $this->winnerName,
|
||||
'loser_name' => $this->loserName,
|
||||
'reason' => $this->reason,
|
||||
'reward_gold' => $this->game->reward_gold,
|
||||
'mode' => $this->game->mode,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:五子棋对战邀请广播事件
|
||||
*
|
||||
* 玩家发起对战邀请时广播至房间 Presence 频道,
|
||||
* 前端在聊天消息流中渲染「接受挑战」按钮。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\GomokuGame;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GomokuInviteEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param GomokuGame $game 对局记录
|
||||
* @param string $inviterName 发起者用户名
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly GomokuGame $game,
|
||||
public readonly string $inviterName,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至对应房间频道。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel("room.{$this->game->room_id}")];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .gomoku.invite)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'gomoku.invite';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'game_id' => $this->game->id,
|
||||
'inviter_name' => $this->inviterName,
|
||||
'expires_at' => $this->game->invite_expires_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:五子棋落子广播事件
|
||||
*
|
||||
* 每次玩家(或 AI)落子后通过私有对局频道广播,
|
||||
* 双方前端实时更新棋盘显示并切换行棋方。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\GomokuGame;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GomokuMovedEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param GomokuGame $game 当前对局
|
||||
* @param int $row 落子行(0-14)
|
||||
* @param int $col 落子列(0-14)
|
||||
* @param int $color 落子颜色(1=黑 2=白)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly GomokuGame $game,
|
||||
public readonly int $row,
|
||||
public readonly int $col,
|
||||
public readonly int $color,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至对局私有频道(仅双方可见)。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("gomoku.{$this->game->id}")];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .gomoku.moved)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'gomoku.moved';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'game_id' => $this->game->id,
|
||||
'row' => $this->row,
|
||||
'col' => $this->col,
|
||||
'color' => $this->color,
|
||||
'current_turn' => $this->game->current_turn,
|
||||
'board' => $this->game->board,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利开始广播事件
|
||||
*
|
||||
* 管理员配置的节日活动到达触发时间后,由 TriggerHolidayEventJob 触发,
|
||||
* 通过 Reverb WebSocket 广播给房间内所有在线用户,
|
||||
* 前端收到后弹出领取弹窗和公屏系统消息。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HolidayEventStarted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param HolidayEvent $event 节日活动实例
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HolidayEvent $event,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道(所有在线用户均可收到)。
|
||||
*
|
||||
* @return array<Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.1'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'holiday.started';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:供前端构建弹窗和公屏消息。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->event->id,
|
||||
'name' => $this->event->name,
|
||||
'description' => $this->event->description,
|
||||
'total_amount' => $this->event->total_amount,
|
||||
'max_claimants' => $this->event->max_claimants,
|
||||
'distribute_type' => $this->event->distribute_type,
|
||||
'fixed_amount' => $this->event->fixed_amount,
|
||||
'claimed_count' => $this->event->claimed_count,
|
||||
'expires_at' => $this->event->expires_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:赛马开赛广播事件
|
||||
*
|
||||
* 新场次开始押注时广播给房间所有用户,携带场次 ID、
|
||||
* 参赛马匹信息和押注截止时间,前端展示倒计时押注面板。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\HorseRace;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HorseRaceOpened implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param HorseRace $race 本场信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HorseRace $race,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .horse.opened)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'horse.opened';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'race_id' => $this->race->id,
|
||||
'horses' => $this->race->horses,
|
||||
'total_pool' => $this->race->total_pool,
|
||||
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
|
||||
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
|
||||
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:赛马进行中实时进度广播事件
|
||||
*
|
||||
* 跑马过程中每隔1秒广播各马匹当前进度(0~100%),
|
||||
* 前端据此实时更新赛道动画。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HorseRaceProgress implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $raceId 场次 ID
|
||||
* @param array<int, int> $positions 各马匹进度 [horse_id => progress(0~100)]
|
||||
* @param bool $finished 是否已到终点
|
||||
* @param int|null $leaderId 当前领跑马匹 ID
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $raceId,
|
||||
public readonly array $positions,
|
||||
public readonly bool $finished = false,
|
||||
public readonly ?int $leaderId = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .horse.progress)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'horse.progress';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'race_id' => $this->raceId,
|
||||
'positions' => $this->positions,
|
||||
'finished' => $this->finished,
|
||||
'leader_id' => $this->leaderId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:赛马结算广播事件
|
||||
*
|
||||
* 跑马结束後广播赛果(获胜马匹、赔付金额等)给房间所有用户,
|
||||
* 前端收到后展示结算面板并更新中奖信息。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\HorseRace;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HorseRaceSettled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param HorseRace $race 已结算的场次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HorseRace $race,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<\Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .horse.settled)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'horse.settled';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
// 找出获胜马匹的名称
|
||||
$horses = $this->race->horses ?? [];
|
||||
$winnerName = '未知';
|
||||
foreach ($horses as $horse) {
|
||||
if (($horse['id'] ?? 0) === $this->race->winner_horse_id) {
|
||||
$winnerName = ($horse['emoji'] ?? '').' '.($horse['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'race_id' => $this->race->id,
|
||||
'winner_horse_id' => $this->race->winner_horse_id,
|
||||
'winner_name' => $winnerName,
|
||||
'total_pool' => (int) $this->race->total_pool,
|
||||
'settled_at' => $this->race->settled_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:结婚公告事件(广播至全房间)
|
||||
*
|
||||
* 触发时机:求婚被接受,正式结婚后广播。
|
||||
* 前端收到后展示全屏烟花特效 + 婚礼设置弹窗(仅婚姻双方)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageAccepted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至当前所有房间(PresenceChannel room.*)。
|
||||
* 使用大厅房间 ID=1,若业务支持多房间可扩展。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']);
|
||||
|
||||
return [
|
||||
'marriage_id' => $this->marriage->id,
|
||||
'user' => $this->marriage->user?->only(['id', 'username', 'headface']),
|
||||
'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']),
|
||||
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
|
||||
'married_at' => $this->marriage->married_at,
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.accepted';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:协议离婚申请通知事件(广播至对方私人频道)
|
||||
*
|
||||
* 触发时机:一方申请协议离婚后广播,对方收到 Banner 含确认/拒绝按钮。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageDivorceRequested implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至对方私人频道(divorcer 的对方)。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// 离婚申请方的对方
|
||||
$targetId = $this->marriage->user_id === $this->marriage->divorcer_id
|
||||
? $this->marriage->partner_id
|
||||
: $this->marriage->user_id;
|
||||
|
||||
return [new PrivateChannel('user.'.$targetId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$this->marriage->load(['user:id,username', 'partner:id,username']);
|
||||
|
||||
$divorcerUsername = $this->marriage->user_id === $this->marriage->divorcer_id
|
||||
? $this->marriage->user?->username
|
||||
: $this->marriage->partner?->username;
|
||||
|
||||
// 读取协议离婚魅力惩罚供前端展示
|
||||
$penalty = (int) \App\Models\MarriageConfig::where('key', 'divorce_mutual_charm')->value('value');
|
||||
// 读取强制离婚魅力惩罚(被拒=强制离婚时申请方受此惩罚)
|
||||
$forcedPenalty = (int) \App\Models\MarriageConfig::where('key', 'divorce_forced_charm')->value('value');
|
||||
|
||||
return [
|
||||
'marriage_id' => $this->marriage->id,
|
||||
'divorcer_username' => $divorcerUsername,
|
||||
'initiator_name' => $divorcerUsername, // 前端兼容字段
|
||||
'timeout_hours' => 72,
|
||||
'requested_at' => $this->marriage->divorce_requested_at,
|
||||
'mutual_charm_penalty' => $penalty, // 协议离婚双方各扣魅力
|
||||
'forced_charm_penalty' => $forcedPenalty, // 不同意→强制,申请方受此惩罚
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.divorce_requested';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:离婚公告事件(广播至全房间)
|
||||
*
|
||||
* 触发时机:协议/强制/自动离婚完成后广播。
|
||||
* 强制离婚时额外显示财产转移信息。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageDivorced implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
* @param string $divorceType 离婚类型(mutual|forced|auto|admin)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
public readonly string $divorceType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至全房间。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$this->marriage->load(['user:id,username', 'partner:id,username']);
|
||||
|
||||
return [
|
||||
'user_username' => $this->marriage->user?->username,
|
||||
'partner_username' => $this->marriage->partner?->username,
|
||||
'divorce_type' => $this->divorceType,
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.divorced';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:求婚超时失效事件(广播至求婚方私人频道)
|
||||
*
|
||||
* 触发时机:Horizon Job ExpireMarriageProposals 扫描到超时求婚后广播。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageExpired implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至求婚方私人频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->marriage->user_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'partner_username' => $this->marriage->partner?->username,
|
||||
'ring_name' => $this->marriage->ringItem?->name,
|
||||
'message' => '求婚已超时失效,戒指已消失。',
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.expired';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:求婚事件(广播至被求婚方私人频道)
|
||||
*
|
||||
* 触发时机:MarriageController::propose() 成功后广播。
|
||||
* B 上线时前端订阅频道立即收到,展示求婚 Banner 弹窗。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageProposed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
* @param User $proposer 求婚方
|
||||
* @param User $target 被求婚方
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
public readonly User $proposer,
|
||||
public readonly User $target,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至被求婚方私人频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->target->id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'marriage_id' => $this->marriage->id,
|
||||
'proposer' => [
|
||||
'username' => $this->proposer->username,
|
||||
'headface' => $this->proposer->headface,
|
||||
'user_level' => $this->proposer->user_level,
|
||||
],
|
||||
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
|
||||
'expires_at' => $this->marriage->expires_at,
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.proposed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:求婚被拒事件(广播至求婚方私人频道)
|
||||
*
|
||||
* 触发时机:对方拒绝求婚,戒指消失后广播。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MarriageRejected implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Marriage $marriage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至求婚方私人频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->marriage->user_id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'partner_username' => $this->marriage->partner?->username,
|
||||
'ring_name' => $this->marriage->ringItem?->name,
|
||||
'message' => '对方拒绝了您的求婚,戒指已消失。',
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'marriage.rejected';
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,11 @@ namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageSent implements ShouldBroadcast
|
||||
class MessageSent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
|
||||
*
|
||||
* 触发时机:RedPacketController::claim() 成功后广播,
|
||||
* 前端收到后弹出 Toast 通知展示到账金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RedPacketClaimed implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param User $claimer 领取用户
|
||||
* @param int $amount 领取金额
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly User $claimer,
|
||||
public readonly int $amount,
|
||||
public readonly int $envelopeId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至领取者私有频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->claimer->id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'amount' => $this->amount,
|
||||
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'red-packet.claimed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:礼包红包发出事件(广播至房间所有用户)
|
||||
*
|
||||
* 触发时机:AdminCommandController::sendRedPacket() 成功后广播,
|
||||
* 前端接收后显示红包卡片弹窗,并在聊天窗口追加系统公告。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RedPacketSent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $roomId 房间 ID
|
||||
* @param int $envelopeId 红包 ID
|
||||
* @param string $senderUsername 发包人用户名
|
||||
* @param int $totalAmount 总金额(金币)
|
||||
* @param int $totalCount 总份数
|
||||
* @param int $expireSeconds 过期秒数(用于前端倒计时)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly int $envelopeId,
|
||||
public readonly string $senderUsername,
|
||||
public readonly int $totalAmount,
|
||||
public readonly int $totalCount,
|
||||
public readonly int $expireSeconds,
|
||||
public readonly string $type = 'gold',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间 Presence 频道(所有在线用户均可收到)。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.'.$this->roomId)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:前端渲染红包弹窗所需字段。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'envelope_id' => $this->envelopeId,
|
||||
'sender_username' => $this->senderUsername,
|
||||
'total_amount' => $this->totalAmount,
|
||||
'total_count' => $this->totalCount,
|
||||
'expire_seconds' => $this->expireSeconds,
|
||||
'type' => $this->type,
|
||||
];
|
||||
}
|
||||
|
||||
/** 自定义事件名称(前端监听时使用)。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'red-packet.sent';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:婚礼庆典事件(广播至全房间)
|
||||
*
|
||||
* 触发时机:婚礼红包触发分发后广播。
|
||||
* 前端收到后:播放烟花特效 + 婚礼音效 + 展示红包弹窗(含领取按钮)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Marriage;
|
||||
use App\Models\WeddingCeremony;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class WeddingCelebration implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param WeddingCeremony $ceremony 婚礼仪式记录
|
||||
* @param Marriage $marriage 婚姻记录
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly WeddingCeremony $ceremony,
|
||||
public readonly Marriage $marriage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至全房间。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PresenceChannel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据(前端据此展示红包弹窗及新人信息)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']);
|
||||
|
||||
return [
|
||||
'ceremony_id' => $this->ceremony->id,
|
||||
'tier_name' => $this->ceremony->tier?->name ?? '婚礼',
|
||||
'tier_icon' => $this->ceremony->tier?->icon ?? '🎊',
|
||||
'total_amount' => $this->ceremony->total_amount,
|
||||
'expires_at' => $this->ceremony->expires_at,
|
||||
'user' => $this->marriage->user?->only(['id', 'username', 'headface']),
|
||||
'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']),
|
||||
'ring' => $this->marriage->ringItem?->only(['name', 'icon']),
|
||||
];
|
||||
}
|
||||
|
||||
/** 广播事件名称。 */
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'wedding.celebration';
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,76 @@ class AiProviderController extends Controller
|
||||
{
|
||||
$providers = AiProviderConfig::orderBy('sort_order')->get();
|
||||
$chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1';
|
||||
$chatbotMaxGold = Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
$chatbotMaxDailyRewards = Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||
$chatbotFishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
|
||||
$chatbotFishingChance = Sysparam::getValue('chatbot_fishing_chance', '5');
|
||||
$chatbotBaccaratEnabled = Sysparam::getValue('chatbot_baccarat_enabled', '0') === '1';
|
||||
|
||||
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled'));
|
||||
return view('admin.ai-providers.index', compact(
|
||||
'providers', 'chatbotEnabled', 'chatbotMaxGold',
|
||||
'chatbotMaxDailyRewards', 'chatbotFishingEnabled', 'chatbotFishingChance', 'chatbotBaccaratEnabled'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存全局设置
|
||||
*/
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'chatbot_max_gold' => 'required|integer|min:1',
|
||||
'chatbot_max_daily_rewards' => 'required|integer|min:1',
|
||||
'chatbot_fishing_enabled' => 'required|in:0,1',
|
||||
'chatbot_fishing_chance' => 'required|integer|min:1|max:100',
|
||||
'chatbot_baccarat_enabled' => 'required|in:0,1',
|
||||
]);
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'chatbot_max_gold'],
|
||||
[
|
||||
'body' => (string) $data['chatbot_max_gold'],
|
||||
'guidetxt' => '单次最高发放金币金额',
|
||||
]
|
||||
);
|
||||
Sysparam::clearCache('chatbot_max_gold');
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'chatbot_max_daily_rewards'],
|
||||
[
|
||||
'body' => (string) $data['chatbot_max_daily_rewards'],
|
||||
'guidetxt' => '每个用户单日最多获得金币次数',
|
||||
]
|
||||
);
|
||||
Sysparam::clearCache('chatbot_max_daily_rewards');
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'chatbot_fishing_enabled'],
|
||||
[
|
||||
'body' => $data['chatbot_fishing_enabled'],
|
||||
'guidetxt' => 'AI 参与钓鱼游戏开关',
|
||||
]
|
||||
);
|
||||
Sysparam::clearCache('chatbot_fishing_enabled');
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'chatbot_fishing_chance'],
|
||||
[
|
||||
'body' => (string) $data['chatbot_fishing_chance'],
|
||||
'guidetxt' => 'AI 钓鱼抛竿概率 (每分钟)',
|
||||
]
|
||||
);
|
||||
Sysparam::clearCache('chatbot_fishing_chance');
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'chatbot_baccarat_enabled'],
|
||||
[
|
||||
'body' => $data['chatbot_baccarat_enabled'],
|
||||
'guidetxt' => 'AI 参与百家乐游戏开关',
|
||||
]
|
||||
);
|
||||
Sysparam::clearCache('chatbot_baccarat_enabled');
|
||||
|
||||
return back()->with('success', '全局设置保存成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,14 +260,161 @@ class AiProviderController extends Controller
|
||||
Sysparam::clearCache('chatbot_enabled');
|
||||
|
||||
$status = $newValue === '1' ? '开启' : '关闭';
|
||||
$isEnabled = $newValue === '1';
|
||||
|
||||
// 确保 AI 实体账号存在
|
||||
$user = \App\Models\User::firstOrCreate(
|
||||
['username' => 'AI小班长'],
|
||||
[
|
||||
'password' => \Illuminate\Support\Facades\Hash::make(\Illuminate\Support\Str::random(16)),
|
||||
'user_level' => 10,
|
||||
'sex' => 0, // 女性
|
||||
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
|
||||
'jjb' => 1000000,
|
||||
'sign' => '本群首席智慧小管家',
|
||||
]
|
||||
);
|
||||
|
||||
// 防止后期头像变动,强制更新到最新女生头像
|
||||
if (! str_contains($user->usersf ?? '', 'ai_bot_cn_girl.png')) {
|
||||
$user->update([
|
||||
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
|
||||
'sex' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => false,
|
||||
'position_icon' => '',
|
||||
'position_name' => '',
|
||||
];
|
||||
|
||||
// 广播机器人进出事件(供前端名单增删)
|
||||
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
|
||||
|
||||
// 像真实的玩家一样,对全网活跃房间进行高调进出场播报
|
||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||
if (empty($activeRoomIds)) {
|
||||
$activeRoomIds = [1]; // 兜底
|
||||
}
|
||||
|
||||
// 把 AI 实体挂名到一个主房间,即可被 app/Console/Commands/AutoSaveExp.php 扫描发经验
|
||||
$mainRoomId = $activeRoomIds[0];
|
||||
if ($isEnabled) {
|
||||
$this->chatState->userJoin($mainRoomId, $user->username, $userData);
|
||||
} else {
|
||||
// 清理可能存在的所有房间的残留挂名
|
||||
foreach ($activeRoomIds as $rId) {
|
||||
$this->chatState->userLeave($rId, $user->username);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRoomIds as $roomId) {
|
||||
$content = $isEnabled
|
||||
? '<span style="color: #9333ea; font-weight: bold;">🤖 【AI小班长】 迈着整齐的步伐进入了房间,随时为您服务!</span>'
|
||||
: '<span style="color: #9ca3af; font-weight: bold;">🤖 【AI小班长】 去休息啦,大家聊得开心!</span>';
|
||||
|
||||
$botMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#9333ea',
|
||||
'action' => 'system_welcome',
|
||||
'welcome_user' => $user->username,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $botMsg);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $botMsg));
|
||||
\App\Jobs\SaveMessageJob::dispatch($botMsg);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "聊天机器人已{$status}",
|
||||
'enabled' => $newValue === '1',
|
||||
'enabled' => $isEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试指定 AI 厂商的接口连通性
|
||||
*
|
||||
* 通过 GET /v1/models 检查端点可达性与 API Key 有效性,毫秒级响应,
|
||||
* 不触发模型推理,避免经 Cloudflare 代理时因推理耗时导致 524 超时。
|
||||
*
|
||||
* @param int $id 厂商配置 ID
|
||||
* @return JsonResponse 测试结果(含可用模型列表)
|
||||
*/
|
||||
public function testConnection(int $id): JsonResponse
|
||||
{
|
||||
$provider = AiProviderConfig::findOrFail($id);
|
||||
|
||||
$apiKey = $provider->getDecryptedApiKey();
|
||||
$base = rtrim($provider->api_endpoint, '/');
|
||||
|
||||
// 拼接 /v1/models 端点(检查连通性,不触发推理)
|
||||
$modelsUrl = str_ends_with($base, '/v1')
|
||||
? $base.'/models'
|
||||
: $base.'/v1/models';
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($apiKey)
|
||||
->timeout(10)
|
||||
->get($modelsUrl);
|
||||
|
||||
$ms = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => "HTTP {$response->status()}:{$response->body()}",
|
||||
'ms' => $ms,
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 提取可用模型列表(兼容 Ollama 和 OpenAI 格式)
|
||||
$models = collect($data['models'] ?? $data['data'] ?? [])
|
||||
->pluck('id')
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$modelList = count($models) > 0
|
||||
? implode('、', array_slice($models, 0, 5)).(count($models) > 5 ? ' 等' : '')
|
||||
: $provider->model;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "接口连通正常,可用模型:{$modelList}",
|
||||
'ms' => $ms,
|
||||
'models' => $models,
|
||||
]);
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
$ms = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '连接失败:'.$e->getMessage(),
|
||||
'ms' => $ms,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 AI 厂商配置
|
||||
*
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台任命管理控制器
|
||||
* 管理员可以在此查看所有在职人员、进行新增任命和撤销职务
|
||||
* 任命/撤销通过 AppointmentService 执行,权限日志自动记录
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPosition;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppointmentController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入任命服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AppointmentService $appointmentService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 任命管理主列表(当前全部在职人员)
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// 所有在职记录(按部门 rank 倒序、职务 rank 倒序)
|
||||
$activePositions = UserPosition::query()
|
||||
->where('is_active', true)
|
||||
->with([
|
||||
'user',
|
||||
'position.department',
|
||||
'appointedBy',
|
||||
])
|
||||
->join('positions', 'user_positions.position_id', '=', 'positions.id')
|
||||
->join('departments', 'positions.department_id', '=', 'departments.id')
|
||||
->orderByDesc('departments.rank')
|
||||
->orderByDesc('positions.rank')
|
||||
->select('user_positions.*')
|
||||
->get();
|
||||
|
||||
// 部门+职务(供新增任命弹窗下拉选择,带在职人数统计)
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($q) => $q->withCount('activeUserPositions')->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
return view('admin.appointments.index', compact('activePositions', 'departments'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行新增任命
|
||||
* 管理员在后台直接任命用户,操作人为当前登录管理员
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string|exists:users,username',
|
||||
'position_id' => 'required|exists:positions,id',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$operator = Auth::user();
|
||||
$target = User::where('username', $request->username)->firstOrFail();
|
||||
$targetPosition = Position::with('department')->findOrFail($request->position_id);
|
||||
|
||||
$result = $this->appointmentService->appoint($operator, $target, $targetPosition, $request->remark);
|
||||
|
||||
if ($result['ok']) {
|
||||
// 向所有当前有人在线的聊天室广播礼花公告(后台操作人不在聊天室内)
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $target->username,
|
||||
positionIcon: $targetPosition->icon ?? '🎖️',
|
||||
positionName: $targetPosition->name,
|
||||
departmentName: $targetPosition->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销职务
|
||||
*/
|
||||
public function revoke(Request $request, UserPosition $userPosition): RedirectResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$target = $userPosition->user;
|
||||
|
||||
// 撤销前先记录职务信息(撤销后关联就断了)
|
||||
$userPosition->load('position.department');
|
||||
$posIcon = $userPosition->position?->icon ?? '🎖️';
|
||||
$posName = $userPosition->position?->name ?? '';
|
||||
$deptName = $userPosition->position?->department?->name ?? '';
|
||||
|
||||
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
|
||||
|
||||
if ($result['ok']) {
|
||||
// 向所有活跃房间广播撤销公告
|
||||
if ($posName) {
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $target->username,
|
||||
positionIcon: $posIcon,
|
||||
positionName: $posName,
|
||||
departmentName: $deptName,
|
||||
operatorName: $operator->username,
|
||||
type: 'revoke',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看某任职记录的在职登录日志
|
||||
*/
|
||||
public function dutyLogs(UserPosition $userPosition): View
|
||||
{
|
||||
$userPosition->load(['user', 'position.department', 'appointedBy']);
|
||||
|
||||
$logs = $userPosition->dutyLogs()
|
||||
->orderByDesc('login_at')
|
||||
->paginate(30);
|
||||
|
||||
// 计算该任职记录的所有在线时长总和(而非当前页)
|
||||
$totalSeconds = $userPosition->dutyLogs()->sum('duration_seconds');
|
||||
|
||||
return view('admin.appointments.duty-logs', compact('userPosition', 'logs', 'totalSeconds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看某任职记录的权限操作日志
|
||||
*/
|
||||
public function authorityLogs(UserPosition $userPosition): View
|
||||
{
|
||||
$userPosition->load(['user', 'position.department']);
|
||||
|
||||
$logs = $userPosition->authorityLogs()
|
||||
->with(['targetUser', 'targetPosition'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(30);
|
||||
|
||||
return view('admin.appointments.authority-logs', compact('userPosition', 'logs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史任职记录(全部 is_active=false 的记录)
|
||||
*/
|
||||
public function history(): View
|
||||
{
|
||||
$history = UserPosition::query()
|
||||
->where('is_active', false)
|
||||
->with(['user', 'position.department', 'appointedBy', 'revokedBy'])
|
||||
->orderByDesc('revoked_at')
|
||||
->paginate(30);
|
||||
|
||||
return view('admin.appointments.history', compact('history'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的履职记录:展示当前登录者自己所有的权限操作记录
|
||||
*
|
||||
* 不限于某一任职周期,展示全部历史操作,支持按操作类型和日期筛选。
|
||||
*/
|
||||
public function myDutyLogs(Request $request): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
$query = \App\Models\PositionAuthorityLog::where('user_id', $user->id)
|
||||
->with(['targetUser:id,username', 'targetPosition:id,name', 'userPosition.position.department']);
|
||||
|
||||
// 按操作类型筛选
|
||||
if ($request->filled('type')) {
|
||||
$query->where('action_type', $request->type);
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
if ($request->filled('date_from')) {
|
||||
$query->whereDate('created_at', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->whereDate('created_at', '<=', $request->date_to);
|
||||
}
|
||||
|
||||
$logs = $query->orderByDesc('created_at')->paginate(30)->withQueryString();
|
||||
|
||||
// 汇总统计
|
||||
$summary = \App\Models\PositionAuthorityLog::where('user_id', $user->id)
|
||||
->selectRaw('action_type, COUNT(*) as total, COALESCE(SUM(amount),0) as amount_sum')
|
||||
->groupBy('action_type')
|
||||
->get()
|
||||
->keyBy('action_type');
|
||||
|
||||
return view('admin.appointments.my-duty-logs', compact('logs', 'summary', 'user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户(供任命弹窗 Ajax 快速查找)
|
||||
*/
|
||||
public function searchUsers(Request $request): JsonResponse
|
||||
{
|
||||
$keyword = $request->input('q', '');
|
||||
|
||||
$users = User::query()
|
||||
->where('id', '!=', 1) // 排除超级管理员
|
||||
->where('username', 'like', "%{$keyword}%")
|
||||
->whereDoesntHave('activePosition') // 排除已有职务的用户
|
||||
->select('id', 'username', 'user_level')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,12 @@ class AutoactController extends Controller
|
||||
|
||||
/**
|
||||
* 更新事件
|
||||
*
|
||||
* @param Autoact $autoact 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, int $id): RedirectResponse
|
||||
public function update(Request $request, Autoact $autoact): RedirectResponse
|
||||
{
|
||||
$event = Autoact::findOrFail($id);
|
||||
$event = $autoact;
|
||||
|
||||
$data = $request->validate([
|
||||
'text_body' => 'required|string|max:500',
|
||||
@@ -71,26 +73,29 @@ class AutoactController extends Controller
|
||||
|
||||
/**
|
||||
* 切换事件启用/禁用状态
|
||||
*
|
||||
* @param Autoact $autoact 路由模型自动注入
|
||||
*/
|
||||
public function toggle(int $id): JsonResponse
|
||||
public function toggle(Autoact $autoact): JsonResponse
|
||||
{
|
||||
$event = Autoact::findOrFail($id);
|
||||
$event->enabled = ! $event->enabled;
|
||||
$event->save();
|
||||
$autoact->enabled = ! $autoact->enabled;
|
||||
$autoact->save();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'enabled' => $event->enabled,
|
||||
'message' => $event->enabled ? '已启用' : '已禁用',
|
||||
'enabled' => $autoact->enabled,
|
||||
'message' => $autoact->enabled ? '已启用' : '已禁用',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除事件
|
||||
*
|
||||
* @param Autoact $autoact 路由模型自动注入
|
||||
*/
|
||||
public function destroy(int $id): RedirectResponse
|
||||
public function destroy(Autoact $autoact): RedirectResponse
|
||||
{
|
||||
Autoact::findOrFail($id)->delete();
|
||||
$autoact->delete();
|
||||
|
||||
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:管理员大卡片通知广播控制器
|
||||
*
|
||||
* 仅超级管理员(chat.level:super 中间件保护)可调用此接口,
|
||||
* 通过 BannerNotification 事件向指定用户或房间推送自定义大卡通知。
|
||||
*
|
||||
* 安全保证:
|
||||
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
|
||||
* - 普通用户无权访问此接口,无法伪造对他人的广播
|
||||
* - options 中的用户输入字段在后端经过 strip_tags 清洗
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Events\BannerNotification;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BannerBroadcastController extends Controller
|
||||
{
|
||||
/**
|
||||
* 向指定目标广播大卡片通知。
|
||||
*
|
||||
* 请求参数:
|
||||
* - target: 'user' | 'room'
|
||||
* - target_id: 用户名 或 房间 ID
|
||||
* - options: 与 window.chatBanner.show() 参数相同的对象
|
||||
* - icon, title, name, body, sub, gradient(array), titleColor, autoClose, buttons(array)
|
||||
*/
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'target' => ['required', 'in:user,room'],
|
||||
'target_id' => ['required'],
|
||||
'options' => ['required', 'array'],
|
||||
'options.icon' => ['nullable', 'string', 'max:20'],
|
||||
'options.title' => ['nullable', 'string', 'max:50'],
|
||||
'options.name' => ['nullable', 'string', 'max:100'],
|
||||
'options.body' => ['nullable', 'string', 'max:500'],
|
||||
'options.sub' => ['nullable', 'string', 'max:200'],
|
||||
'options.gradient' => ['nullable', 'array', 'max:5'],
|
||||
'options.titleColor' => ['nullable', 'string', 'max:30'],
|
||||
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
|
||||
'options.buttons' => ['nullable', 'array', 'max:4'],
|
||||
]);
|
||||
|
||||
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
|
||||
$opts = $validated['options'];
|
||||
foreach (['title', 'name', 'body', 'sub'] as $field) {
|
||||
if (isset($opts[$field])) {
|
||||
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
|
||||
}
|
||||
}
|
||||
// 按钮 label 不允许 HTML
|
||||
if (! empty($opts['buttons'])) {
|
||||
$opts['buttons'] = array_map(function ($btn) {
|
||||
$btn['label'] = strip_tags($btn['label'] ?? '');
|
||||
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
|
||||
// action 只允许预定义值,防止注入任意 JS
|
||||
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
|
||||
? $btn['action'] : 'close';
|
||||
|
||||
return $btn;
|
||||
}, $opts['buttons']);
|
||||
}
|
||||
|
||||
broadcast(new BannerNotification(
|
||||
target: $validated['target'],
|
||||
targetId: $validated['target_id'],
|
||||
options: $opts,
|
||||
));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '广播已发送']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台开发日志管理控制器(仅 id=1 超级管理员可访问)
|
||||
* 提供开发日志的 CRUD 功能,发布时可选择向 Room 1 大厅广播通知
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Events\ChangelogPublished;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\DevChangelog;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChangelogController extends Controller
|
||||
{
|
||||
/**
|
||||
* 后台日志列表(含草稿)
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$logs = DevChangelog::orderByDesc('created_at')->paginate(20);
|
||||
|
||||
return view('admin.changelog.index', compact('logs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增日志表单页(预填今日日期)
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
// 预填今日日期为版本号
|
||||
$todayVersion = now()->format('Y-m-d');
|
||||
|
||||
return view('admin.changelog.form', [
|
||||
'log' => null,
|
||||
'todayVersion' => $todayVersion,
|
||||
'typeOptions' => DevChangelog::TYPE_CONFIG,
|
||||
'isCreate' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新日志
|
||||
* 若勾选"立即发布",则记录 published_at 并可选向大厅广播通知
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => 'required|string|max:30',
|
||||
'title' => 'required|string|max:200',
|
||||
'type' => 'required|in:feature,fix,improve,other',
|
||||
'content' => 'required|string',
|
||||
'is_published' => 'nullable|boolean',
|
||||
'notify_chat' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$isPublished = (bool) ($data['is_published'] ?? false);
|
||||
$notifyChat = (bool) ($data['notify_chat'] ?? false);
|
||||
|
||||
/** @var DevChangelog $log */
|
||||
$log = DevChangelog::create([
|
||||
'version' => $data['version'],
|
||||
'title' => $data['title'],
|
||||
'type' => $data['type'],
|
||||
'content' => $data['content'],
|
||||
'is_published' => $isPublished,
|
||||
// 只有同时勾选"通知"才记录 notify_chat,否则置 false
|
||||
'notify_chat' => $isPublished && $notifyChat,
|
||||
// 首次发布时记录发布时间
|
||||
'published_at' => $isPublished ? Carbon::now() : null,
|
||||
]);
|
||||
|
||||
// 如果发布且勾选了"通知大厅用户",则触发 WebSocket 广播 + 持久化到消息库
|
||||
if ($isPublished && $notifyChat) {
|
||||
event(new ChangelogPublished($log));
|
||||
$this->saveChangelogNotification($log);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.changelogs.index')
|
||||
->with('success', '开发日志创建成功!'.($isPublished ? '(已发布)' : '(草稿已保存)'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑日志表单页
|
||||
*
|
||||
* @param DevChangelog $changelog 路由模型自动注入
|
||||
*/
|
||||
public function edit(DevChangelog $changelog): View
|
||||
{
|
||||
return view('admin.changelog.form', [
|
||||
'log' => $changelog,
|
||||
'todayVersion' => $changelog->version,
|
||||
'typeOptions' => DevChangelog::TYPE_CONFIG,
|
||||
'isCreate' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新日志内容(编辑操作不更新 published_at,不触发通知)
|
||||
*
|
||||
* @param DevChangelog $changelog 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, DevChangelog $changelog): RedirectResponse
|
||||
{
|
||||
$log = $changelog;
|
||||
|
||||
$data = $request->validate([
|
||||
'version' => 'required|string|max:30',
|
||||
'title' => 'required|string|max:200',
|
||||
'type' => 'required|in:feature,fix,improve,other',
|
||||
'content' => 'required|string',
|
||||
'is_published' => 'nullable|boolean',
|
||||
'notify_chat' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$isPublished = (bool) ($data['is_published'] ?? false);
|
||||
|
||||
// 如果从草稿切换为发布,记录首次发布时间
|
||||
$publishedAt = $log->published_at;
|
||||
if ($isPublished && ! $log->is_published) {
|
||||
$publishedAt = Carbon::now();
|
||||
} elseif (! $isPublished) {
|
||||
// 从发布退回草稿,清除发布时间
|
||||
$publishedAt = null;
|
||||
}
|
||||
|
||||
$log->update([
|
||||
'version' => $data['version'],
|
||||
'title' => $data['title'],
|
||||
'type' => $data['type'],
|
||||
'content' => $data['content'],
|
||||
'is_published' => $isPublished,
|
||||
'published_at' => $publishedAt,
|
||||
]);
|
||||
|
||||
// 若勾选了「通知大厅用户」且当前已发布,则广播通知 + 持久化到消息库
|
||||
$notifyChat = (bool) ($data['notify_chat'] ?? false);
|
||||
if ($notifyChat && $isPublished) {
|
||||
event(new ChangelogPublished($log));
|
||||
$this->saveChangelogNotification($log);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.changelogs.index')
|
||||
->with('success', '开发日志已更新!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除日志
|
||||
*
|
||||
* @param DevChangelog $changelog 路由模型自动注入
|
||||
*/
|
||||
public function destroy(DevChangelog $changelog): RedirectResponse
|
||||
{
|
||||
$changelog->delete();
|
||||
|
||||
return redirect()->route('admin.changelogs.index')
|
||||
->with('success', '日志已删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将版本更新通知持久化为 Room 1 系统消息
|
||||
* 确保用户重进聊天室时仍能在历史消息中看到该通知
|
||||
*
|
||||
* @param DevChangelog $log 已发布的日志
|
||||
*/
|
||||
private function saveChangelogNotification(DevChangelog $log): void
|
||||
{
|
||||
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
|
||||
$url = url('/changelog').'#v'.$log->version;
|
||||
|
||||
SaveMessageJob::dispatch([
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}《{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#7c3aed',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户金币/积分流水日志查询
|
||||
* 对应超级管理员级别的查询页面。可以按用户、增减、货币类型等筛选所有的账目流动。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CurrencyLogController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示流水日志列表
|
||||
* 支持多条件检索,仅 superlevel 以及上可以访问此页面
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = UserCurrencyLog::query()->with('user');
|
||||
|
||||
// 查询条件过滤
|
||||
if ($request->filled('username')) {
|
||||
$query->where('username', 'like', '%'.$request->input('username').'%');
|
||||
}
|
||||
|
||||
if ($request->filled('currency')) {
|
||||
$query->where('currency', $request->input('currency'));
|
||||
}
|
||||
|
||||
if ($request->filled('source')) {
|
||||
$query->where('source', $request->input('source'));
|
||||
}
|
||||
|
||||
if ($request->filled('remark')) {
|
||||
$query->where('remark', 'like', '%'.$request->input('remark').'%');
|
||||
}
|
||||
|
||||
if ($request->filled('direction')) {
|
||||
if ($request->input('direction') === 'in') {
|
||||
$query->where('amount', '>', 0);
|
||||
} elseif ($request->input('direction') === 'out') {
|
||||
$query->where('amount', '<', 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('created_at', '>=', $request->input('date_start'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('created_at', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// 默认按时间倒序
|
||||
$logs = $query->latest('id')->paginate(50)->withQueryString();
|
||||
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
return view('admin.currency-logs.index', compact('logs', 'allSources'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台积分活动统计控制器
|
||||
* 展示今日(或指定日期)各来源活动产生的经验/金币/魅力统计,以及今日净流通量。
|
||||
* 仅限 superlevel 以上管理员访问。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CurrencyStatsController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入积分统计服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示指定日期的积分活动统计(默认今日)。
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 日期选择(默认今日)
|
||||
$date = $request->input('date', today()->toDateString());
|
||||
|
||||
// 各来源活动产出统计(按 source + currency 分组汇总)
|
||||
$stats = $this->currencyService->activityStats($date);
|
||||
|
||||
// 按货币类型分组,方便视图展示
|
||||
$statsByType = $stats->groupBy('currency')->map(
|
||||
fn ($rows) => $rows->keyBy('source')
|
||||
);
|
||||
|
||||
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
|
||||
$netFlow = [];
|
||||
foreach (['exp', 'gold', 'charm'] as $currency) {
|
||||
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '>', 0)
|
||||
->sum('amount');
|
||||
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '<', 0)
|
||||
->sum('amount');
|
||||
$netFlow[$currency] = [
|
||||
'in' => $totalIn,
|
||||
'out' => abs($totalOut),
|
||||
'net' => $totalIn + $totalOut, // 净增量
|
||||
];
|
||||
}
|
||||
|
||||
// 所有已知来源(供视图展示缺失来源的空行)
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
return view('admin.currency-stats.index', compact(
|
||||
'date', 'stats', 'statsByType', 'netFlow', 'allSources',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台部门管理控制器
|
||||
* 提供部门的 CRUD 功能(增删改查)
|
||||
* 部门是职务的上级分类,包含位阶、颜色、描述等配置
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DepartmentController extends Controller
|
||||
{
|
||||
/**
|
||||
* 部门列表页
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$departments = Department::withCount(['positions'])
|
||||
->orderBy('sort_order')
|
||||
->orderByDesc('rank')
|
||||
->get();
|
||||
|
||||
return view('admin.departments.index', compact('departments'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建部门
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50|unique:departments,name',
|
||||
'rank' => 'required|integer|min:0|max:99',
|
||||
'color' => 'required|string|max:10',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
Department::create($data);
|
||||
|
||||
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】创建成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新部门
|
||||
*/
|
||||
public function update(Request $request, Department $department): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50|unique:departments,name,'.$department->id,
|
||||
'rank' => 'required|integer|min:0|max:99',
|
||||
'color' => 'required|string|max:10',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$department->update($data);
|
||||
|
||||
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】更新成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门(级联删除职务)
|
||||
*/
|
||||
public function destroy(Department $department): RedirectResponse
|
||||
{
|
||||
// 检查是否有在职人员
|
||||
$activeMemberCount = $department->positions()
|
||||
->whereHas('activeUserPositions')
|
||||
->count();
|
||||
|
||||
if ($activeMemberCount > 0) {
|
||||
return redirect()->route('admin.departments.index')
|
||||
->with('error', "部门【{$department->name}】下尚有在职人员,请先撤销所有职务后再删除。");
|
||||
}
|
||||
|
||||
$department->delete();
|
||||
|
||||
return redirect()->route('admin.departments.index')->with('success', "部门【{$department->name}】已删除!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台用户反馈管理控制器(仅 id=1 超级管理员可访问)
|
||||
* 提供反馈列表查看、处理状态修改、官方回复功能
|
||||
* 侧边栏显示待处理数量徽标
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\FeedbackItem;
|
||||
use App\Models\FeedbackReply;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FeedbackManagerController extends Controller
|
||||
{
|
||||
/**
|
||||
* 后台反馈列表页(支持类型+状态筛选)
|
||||
*
|
||||
* @param Request $request 含 type/status 筛选参数
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$type = $request->input('type');
|
||||
$status = $request->input('status');
|
||||
|
||||
$query = FeedbackItem::with(['replies'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// 按类型筛选
|
||||
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||
$query->ofType($type);
|
||||
}
|
||||
|
||||
// 按状态筛选
|
||||
if ($status && array_key_exists($status, FeedbackItem::STATUS_CONFIG)) {
|
||||
$query->ofStatus($status);
|
||||
}
|
||||
|
||||
$feedbacks = $query->paginate(20)->withQueryString();
|
||||
|
||||
// 待处理数量(用于侧边栏徽标)
|
||||
$pendingCount = FeedbackItem::pending()->count();
|
||||
|
||||
return view('admin.feedback.index', [
|
||||
'feedbacks' => $feedbacks,
|
||||
'pendingCount' => $pendingCount,
|
||||
'statusConfig' => FeedbackItem::STATUS_CONFIG,
|
||||
'typeConfig' => FeedbackItem::TYPE_CONFIG,
|
||||
'currentType' => $type,
|
||||
'currentStatus' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新反馈处理状态和官方回复(Ajax + 表单双模式)
|
||||
* Ajax 返回 JSON,普通表单提交返回重定向
|
||||
*
|
||||
* @param Request $request 含 status/admin_remark 字段
|
||||
* @param int $id 反馈 ID
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse|RedirectResponse
|
||||
{
|
||||
$feedback = FeedbackItem::findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'status' => 'required|in:'.implode(',', array_keys(FeedbackItem::STATUS_CONFIG)),
|
||||
'admin_remark' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$feedback->update([
|
||||
'status' => $data['status'],
|
||||
'admin_remark' => $data['admin_remark'] ?? $feedback->admin_remark,
|
||||
]);
|
||||
|
||||
// 如果有新的官方回复内容,同时写入 feedback_replies(带 is_admin 标记)
|
||||
if (! empty($data['admin_remark']) && $data['admin_remark'] !== $feedback->getOriginal('admin_remark')) {
|
||||
DB::transaction(function () use ($feedback, $data): void {
|
||||
FeedbackReply::create([
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => 1,
|
||||
'username' => '🛡️ 开发者',
|
||||
'content' => $data['admin_remark'],
|
||||
'is_admin' => true,
|
||||
]);
|
||||
$feedback->increment('replies_count');
|
||||
});
|
||||
}
|
||||
|
||||
// Ajax 请求返回 JSON
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'new_status' => $data['status'],
|
||||
'status_label' => FeedbackItem::STATUS_CONFIG[$data['status']]['icon'].' '.FeedbackItem::STATUS_CONFIG[$data['status']]['label'],
|
||||
'status_color' => FeedbackItem::STATUS_CONFIG[$data['status']]['color'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.feedback.index')
|
||||
->with('success', '反馈状态已更新!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:钓鱼事件后台管理控制器
|
||||
* 提供钓鱼事件的列表展示、创建、编辑、删除、启用/禁用功能
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\FishingEvent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FishingEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示所有钓鱼事件列表
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$events = FishingEvent::orderBy('sort')->orderBy('id')->get();
|
||||
$totalWeight = $events->where('is_active', true)->sum('weight');
|
||||
|
||||
return view('admin.fishing.index', compact('events', 'totalWeight'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新钓鱼事件
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'emoji' => 'required|string|max:10',
|
||||
'name' => 'required|string|max:100',
|
||||
'message' => 'required|string|max:255',
|
||||
'exp' => 'required|integer',
|
||||
'jjb' => 'required|integer',
|
||||
'weight' => 'required|integer|min:1|max:9999',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
FishingEvent::create($data);
|
||||
|
||||
return redirect()->route('admin.fishing.index')->with('success', '钓鱼事件已添加!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新钓鱼事件
|
||||
*
|
||||
* @param FishingEvent $fishing 路由模型绑定
|
||||
*/
|
||||
public function update(Request $request, FishingEvent $fishing): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'emoji' => 'required|string|max:10',
|
||||
'name' => 'required|string|max:100',
|
||||
'message' => 'required|string|max:255',
|
||||
'exp' => 'required|integer',
|
||||
'jjb' => 'required|integer',
|
||||
'weight' => 'required|integer|min:1|max:9999',
|
||||
'sort' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$fishing->update($data);
|
||||
|
||||
return redirect()->route('admin.fishing.index')->with('success', "事件「{$fishing->name}」已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换事件启用/禁用状态(AJAX)
|
||||
*
|
||||
* @param FishingEvent $fishing 路由模型绑定
|
||||
*/
|
||||
public function toggle(FishingEvent $fishing): JsonResponse
|
||||
{
|
||||
$fishing->update(['is_active' => ! $fishing->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_active' => $fishing->is_active,
|
||||
'message' => $fishing->is_active ? "「{$fishing->name}」已启用" : "「{$fishing->name}」已禁用",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除钓鱼事件
|
||||
*
|
||||
* @param FishingEvent $fishing 路由模型绑定
|
||||
*/
|
||||
public function destroy(FishingEvent $fishing): RedirectResponse
|
||||
{
|
||||
$name = $fishing->name;
|
||||
$fishing->delete();
|
||||
|
||||
return redirect()->route('admin.fishing.index')->with('success', "事件「{$name}」已删除!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:禁用用户名管理控制器(站长专用)
|
||||
*
|
||||
* 管理 username_blacklist 表中 type=permanent 的永久禁用词列表。
|
||||
* 包含:国家领导人名称、攻击性词汇、违禁词等不允许注册或改名的词语。
|
||||
*
|
||||
* 用户在注册(AuthController)和改名(ShopService::useRenameCard)时均会经过该表检测。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UsernameBlacklist;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ForbiddenUsernameController extends Controller
|
||||
{
|
||||
/**
|
||||
* 分页列出所有永久禁用词。
|
||||
* 支持关键词模糊搜索(GET ?q=xxx)。
|
||||
*/
|
||||
public function index(Request $request): \Illuminate\View\View
|
||||
{
|
||||
$q = $request->query('q', '');
|
||||
|
||||
$items = UsernameBlacklist::permanent()
|
||||
->when($q, fn ($query) => $query->where('username', 'like', "%{$q}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.forbidden-usernames.index', [
|
||||
'items' => $items,
|
||||
'q' => $q,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一条永久禁用词。
|
||||
*
|
||||
* @param Request $request 请求体:username(必填),reason(选填)
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'username' => ['required', 'string', 'max:50'],
|
||||
'reason' => ['nullable', 'string', 'max:100'],
|
||||
], [
|
||||
'username.required' => '禁用词不能为空。',
|
||||
'username.max' => '禁用词最长50字符。',
|
||||
]);
|
||||
|
||||
$username = trim($validated['username']);
|
||||
|
||||
// 已存在同名的永久记录则不重复插入
|
||||
$exists = UsernameBlacklist::permanent()
|
||||
->where('username', $username)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json(['status' => 'error', 'message' => '该词语已在永久禁用列表中。'], 422);
|
||||
}
|
||||
|
||||
UsernameBlacklist::create([
|
||||
'username' => $username,
|
||||
'type' => 'permanent',
|
||||
'reserved_until' => null,
|
||||
'reason' => $validated['reason'] ?? null,
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "「{$username}」已加入永久禁用列表。"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加永久禁用词。
|
||||
*
|
||||
* 接受多行文本或逗号分隔的词语列表,自动去重并过滤已存在者。
|
||||
* 返回成功添加数量和跳过数量。
|
||||
*
|
||||
* @param Request $request 请求体:words(换行/逗号分隔),reason(选填,共用)
|
||||
*/
|
||||
public function batchStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'words' => ['required', 'string'],
|
||||
'reason' => ['nullable', 'string', 'max:100'],
|
||||
], [
|
||||
'words.required' => '请输入至少一个词语。',
|
||||
]);
|
||||
|
||||
// 净化输入:去除非法 UTF-8 字节(零宽字符、BOM、控制字符等),防止 json_encode 失败
|
||||
$rawInput = $this->sanitizeUtf8($validated['words']);
|
||||
$reason = $this->sanitizeUtf8(trim($validated['reason'] ?? ''));
|
||||
|
||||
// 支持换行、逗号、中文逗号、空格分隔
|
||||
$rawWords = preg_split('/[\r\n,,\s]+/u', $rawInput);
|
||||
|
||||
// 过滤空串、超长词、去重
|
||||
$words = collect($rawWords)
|
||||
->map(fn ($w) => trim($w))
|
||||
->filter(fn ($w) => $w !== '' && mb_strlen($w) <= 50)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($words->isEmpty()) {
|
||||
return response()->json(['status' => 'error', 'message' => '没有有效的词语,请检查输入。'], 422);
|
||||
}
|
||||
|
||||
// 批量查询已存在的词(一次查询)
|
||||
$existing = UsernameBlacklist::permanent()
|
||||
->whereIn('username', $words->all())
|
||||
->pluck('username')
|
||||
->flip();
|
||||
|
||||
$now = Carbon::now();
|
||||
$added = 0;
|
||||
$rows = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if ($existing->has($word)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'username' => $word,
|
||||
'type' => 'permanent',
|
||||
'reserved_until' => null,
|
||||
'reason' => $reason ?: null,
|
||||
'created_at' => $now,
|
||||
];
|
||||
$added++;
|
||||
}
|
||||
|
||||
if (! empty($rows)) {
|
||||
UsernameBlacklist::insert($rows);
|
||||
}
|
||||
|
||||
$skipped = $words->count() - $added;
|
||||
$msg = "成功添加 {$added} 个词语".($skipped > 0 ? ",跳过 {$skipped} 个(已存在)" : '').'。';
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => $msg, 'added' => $added], 200, [], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 净化字符串,移除非法 UTF-8 字节及常见控制/零宽字符。
|
||||
*
|
||||
* @param string $str 待净化字符串
|
||||
* @return string 合法的 UTF-8 字符串
|
||||
*/
|
||||
private function sanitizeUtf8(string $str): string
|
||||
{
|
||||
// 去除 BOM
|
||||
$str = str_replace("\xEF\xBB\xBF", '', $str);
|
||||
// 去除零宽字符(零宽空格、零宽不连字等)
|
||||
$str = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}\x{00AD}]/u', '', $str);
|
||||
// 转换为合法 UTF-8,忽略非法字节
|
||||
$str = mb_convert_encoding($str, 'UTF-8', 'UTF-8');
|
||||
// 保底:去除控制字符(保留换行 \r\n)
|
||||
$str = preg_replace('/[^\P{C}\r\n]+/u', '', $str);
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定禁用词的原因备注。
|
||||
*
|
||||
* @param int $id 记录 ID
|
||||
* @param Request $request 请求体:reason
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$item = UsernameBlacklist::permanent()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$item->update(['reason' => $validated['reason']]);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '备注已更新。']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定永久禁用词。
|
||||
*
|
||||
* @param int $id 记录 ID
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$item = UsernameBlacklist::permanent()->findOrFail($id);
|
||||
$name = $item->username;
|
||||
$item->delete();
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "「{$name}」已从永久禁用列表移除。"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:游戏配置后台管理控制器
|
||||
*
|
||||
* 管理员可在此页面统一管理所有娱乐游戏的开关状态和核心参数。
|
||||
* 每个游戏的参数说明通过前端渲染,后台只做通用 JSON 存储。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\LotteryIssue;
|
||||
use App\Services\LotteryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GameConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 游戏管理总览页面。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$games = GameConfig::orderBy('id')->get();
|
||||
|
||||
return view('admin.game-configs.index', compact('games'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换游戏开启/关闭状态。
|
||||
*/
|
||||
public function toggle(GameConfig $gameConfig): JsonResponse
|
||||
{
|
||||
$gameConfig->update(['enabled' => ! $gameConfig->enabled]);
|
||||
$gameConfig->clearCache();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'enabled' => $gameConfig->enabled,
|
||||
'message' => $gameConfig->enabled
|
||||
? "「{$gameConfig->name}」已开启"
|
||||
: "「{$gameConfig->name}」已关闭",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存游戏核心参数。
|
||||
*
|
||||
* 接收前端提交的 params JSON 对象并合并至现有配置。
|
||||
*/
|
||||
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'params' => 'required|array',
|
||||
]);
|
||||
|
||||
// 合并参数,保留已有键,只更新传入的键
|
||||
$current = $gameConfig->params ?? [];
|
||||
$updated = array_merge($current, $request->input('params'));
|
||||
|
||||
if ($gameConfig->game_key === 'mystery_box') {
|
||||
$legacyMap = [
|
||||
'min_reward' => 'normal_reward_min',
|
||||
'max_reward' => 'normal_reward_max',
|
||||
'rare_min_reward' => 'rare_reward_min',
|
||||
'rare_max_reward' => 'rare_reward_max',
|
||||
];
|
||||
|
||||
foreach ($legacyMap as $legacyKey => $newKey) {
|
||||
if (! array_key_exists($newKey, $updated) && array_key_exists($legacyKey, $updated)) {
|
||||
$updated[$newKey] = $updated[$legacyKey];
|
||||
}
|
||||
|
||||
unset($updated[$legacyKey]);
|
||||
}
|
||||
}
|
||||
|
||||
$gameConfig->update(['params' => $updated]);
|
||||
$gameConfig->clearCache();
|
||||
|
||||
return back()->with('success', "「{$gameConfig->name}」参数已保存!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员手动投放神秘箱子。
|
||||
*
|
||||
* 立即分发 DropMysteryBoxJob 到队列,由 Horizon 执行箱子投放和公屏广播。
|
||||
*/
|
||||
public function dropMysteryBox(Request $request): JsonResponse
|
||||
{
|
||||
if (! \App\Models\GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放,请先开启。']);
|
||||
}
|
||||
|
||||
$boxType = $request->input('box_type', 'normal');
|
||||
|
||||
if (! in_array($boxType, ['normal', 'rare', 'trap'], true)) {
|
||||
return response()->json(['ok' => false, 'message' => '无效的箱子类型。']);
|
||||
}
|
||||
|
||||
// 检查是否有正在开放的箱子(避免同时多个)
|
||||
if (\App\Models\MysteryBox::currentOpenBox()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
|
||||
}
|
||||
|
||||
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
|
||||
|
||||
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动开启新一期双色球彩票。
|
||||
*
|
||||
* 仅在当前无进行中期次时生效,防止重复开期。
|
||||
*/
|
||||
public function openLotteryIssue(): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('lottery')) {
|
||||
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
|
||||
}
|
||||
|
||||
if (LotteryIssue::currentIssue()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
|
||||
}
|
||||
|
||||
\App\Jobs\OpenLotteryIssueJob::dispatch();
|
||||
|
||||
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员强制立即开奖(测试专用)。
|
||||
*
|
||||
* 将当前 open 或 closed 期次直接投入开奖队列。
|
||||
*/
|
||||
public function forceLotteryDraw(LotteryService $lottery): JsonResponse
|
||||
{
|
||||
$issue = LotteryIssue::query()
|
||||
->whereIn('status', ['open', 'closed'])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $issue) {
|
||||
return response()->json(['ok' => false, 'message' => '当前无可开奖的期次。']);
|
||||
}
|
||||
|
||||
// 强制将状态改为 closed
|
||||
$issue->update(['status' => 'closed']);
|
||||
\App\Jobs\DrawLotteryJob::dispatch($issue->fresh());
|
||||
|
||||
return response()->json(['ok' => true, 'message' => "✅ 开奖任务已入队,第 {$issue->issue_no} 期将就绪开奖!"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:游戏历史记录后台查询控制器
|
||||
*
|
||||
* 提供百家乐、老虎机、赛马竞猜、神秘箱子、神秘占卜各游戏
|
||||
* 的历史记录查询页面及统计摘要接口,供管理员查阅。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\FortuneLog;
|
||||
use App\Models\GomokuGame;
|
||||
use App\Models\HorseBet;
|
||||
use App\Models\HorseRace;
|
||||
use App\Models\LotteryIssue;
|
||||
use App\Models\LotteryTicket;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Models\SlotMachineLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GameHistoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* 各游戏实时统计摘要(JSON 接口,供 game-configs 首页加载)。
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
// 百家乐:最近30天
|
||||
$baccarat = [
|
||||
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
|
||||
'total_bets' => BaccaratBet::query()->count(),
|
||||
'total_payout' => BaccaratRound::query()->where('status', 'settled')->sum('total_payout'),
|
||||
'today_rounds' => BaccaratRound::query()->where('status', 'settled')->whereDate('settled_at', today())->count(),
|
||||
];
|
||||
|
||||
// 老虎机
|
||||
$slot = [
|
||||
'total_spins' => SlotMachineLog::query()->count(),
|
||||
'total_cost' => SlotMachineLog::query()->sum('cost'),
|
||||
'total_payout' => SlotMachineLog::query()->sum('payout'),
|
||||
'jackpot_count' => SlotMachineLog::query()->where('result_type', 'jackpot')->count(),
|
||||
'today_spins' => SlotMachineLog::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
// 赛马
|
||||
$horse = [
|
||||
'total_races' => HorseRace::query()->where('status', 'settled')->count(),
|
||||
'total_bets' => HorseBet::query()->count(),
|
||||
'total_pool' => HorseRace::query()->where('status', 'settled')->sum('total_pool'),
|
||||
'today_races' => HorseRace::query()->where('status', 'settled')->whereDate('settled_at', today())->count(),
|
||||
];
|
||||
|
||||
// 神秘箱子
|
||||
$mysteryBox = [
|
||||
'total_dropped' => MysteryBox::query()->count(),
|
||||
'total_claimed' => MysteryBox::query()->where('status', 'claimed')->count(),
|
||||
'total_expired' => MysteryBox::query()->where('status', 'expired')->count(),
|
||||
'today_dropped' => MysteryBox::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
// 占卜
|
||||
$fortune = [
|
||||
'total_times' => FortuneLog::query()->count(),
|
||||
'jackpot_count' => FortuneLog::query()->where('grade', 'jackpot')->count(),
|
||||
'curse_count' => FortuneLog::query()->where('grade', 'curse')->count(),
|
||||
'today_times' => FortuneLog::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
// 彩票
|
||||
$lottery = [
|
||||
'total_issues' => LotteryIssue::query()->count(),
|
||||
'total_bets' => LotteryTicket::query()->count(),
|
||||
'total_pool' => LotteryIssue::query()->where('status', 'settled')->sum('pool_amount'),
|
||||
'today_issues' => LotteryIssue::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
// 五子棋
|
||||
$gomoku = [
|
||||
'total_games' => GomokuGame::query()->count(),
|
||||
'pvp_count' => GomokuGame::query()->where('mode', 'pvp')->count(),
|
||||
'pve_count' => GomokuGame::query()->where('mode', 'pve')->count(),
|
||||
'today_games' => GomokuGame::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'baccarat' => $baccarat,
|
||||
'slot' => $slot,
|
||||
'horse' => $horse,
|
||||
'mystery_box' => $mysteryBox,
|
||||
'fortune' => $fortune,
|
||||
'lottery' => $lottery,
|
||||
'gomoku' => $gomoku,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 百家乐历史记录页面(局次列表,支持分页)。
|
||||
*/
|
||||
public function baccarat(Request $request): View
|
||||
{
|
||||
// 各局统计摘要
|
||||
$summary = [
|
||||
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
|
||||
'total_bets' => BaccaratBet::query()->count(),
|
||||
'total_payout' => (int) BaccaratRound::query()->where('status', 'settled')->sum('total_payout'),
|
||||
// 会员实际输掉的金币:所有已结算落败注单的押注金额合计
|
||||
'total_lost' => (int) BaccaratBet::query()->where('status', 'lost')->sum('amount'),
|
||||
'result_dist' => BaccaratRound::query()
|
||||
->where('status', 'settled')
|
||||
->select('result', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
|
||||
->groupBy('result')
|
||||
->pluck('cnt', 'result'),
|
||||
];
|
||||
|
||||
$rounds = BaccaratRound::query()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.game-history.baccarat', compact('rounds', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 百家乐单局下注明细。
|
||||
*/
|
||||
public function baccaratRound(BaccaratRound $round): View
|
||||
{
|
||||
$bets = $round->bets()->with('user')->latest()->paginate(30);
|
||||
|
||||
return view('admin.game-history.baccarat-round', compact('round', 'bets'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 老虎机历史记录页面(支持按结果类型筛选/分页)。
|
||||
*/
|
||||
public function slot(Request $request): View
|
||||
{
|
||||
// 统计摘要
|
||||
$summary = [
|
||||
'total_spins' => SlotMachineLog::query()->count(),
|
||||
'total_cost' => (int) SlotMachineLog::query()->sum('cost'),
|
||||
'total_payout' => (int) SlotMachineLog::query()->sum('payout'),
|
||||
'net_income' => (int) SlotMachineLog::query()->sum('cost') - (int) SlotMachineLog::query()->sum('payout'),
|
||||
'result_dist' => SlotMachineLog::query()
|
||||
->select('result_type', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
|
||||
->groupBy('result_type')
|
||||
->pluck('cnt', 'result_type'),
|
||||
];
|
||||
|
||||
$query = SlotMachineLog::query()->with('user')->latest();
|
||||
|
||||
// 按结果类型筛选
|
||||
if ($request->filled('result_type')) {
|
||||
$query->where('result_type', $request->input('result_type'));
|
||||
}
|
||||
|
||||
// 按用户名筛选
|
||||
if ($request->filled('username')) {
|
||||
$query->whereHas('user', function ($q) use ($request) {
|
||||
$q->where('username', 'like', '%'.$request->input('username').'%');
|
||||
});
|
||||
}
|
||||
|
||||
$logs = $query->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.game-history.slot', compact('logs', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛马竞猜历史记录页面(场次列表,支持分页)。
|
||||
*/
|
||||
public function horse(Request $request): View
|
||||
{
|
||||
$summary = [
|
||||
'total_races' => HorseRace::query()->where('status', 'settled')->count(),
|
||||
'total_bets' => HorseBet::query()->count(),
|
||||
'total_pool' => (int) HorseRace::query()->sum('total_pool'),
|
||||
];
|
||||
|
||||
$races = HorseRace::query()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.game-history.horse', compact('races', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛马单场下注明细。
|
||||
*/
|
||||
public function horseRace(HorseRace $race): View
|
||||
{
|
||||
$bets = $race->bets()->with('user')->latest()->paginate(30);
|
||||
|
||||
return view('admin.game-history.horse-race', compact('race', 'bets'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 神秘箱子历史记录(投放/领取列表,支持分页和类型筛选)。
|
||||
*/
|
||||
public function mysteryBox(Request $request): View
|
||||
{
|
||||
$summary = [
|
||||
'total_dropped' => MysteryBox::query()->count(),
|
||||
'total_claimed' => MysteryBox::query()->where('status', 'claimed')->count(),
|
||||
'total_expired' => MysteryBox::query()->where('status', 'expired')->count(),
|
||||
'type_dist' => MysteryBox::query()
|
||||
->select('box_type', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
|
||||
->groupBy('box_type')
|
||||
->pluck('cnt', 'box_type'),
|
||||
];
|
||||
|
||||
$query = MysteryBox::query()->with(['claim.user'])->latest();
|
||||
|
||||
if ($request->filled('box_type')) {
|
||||
$query->where('box_type', $request->input('box_type'));
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
$boxes = $query->paginate(20)->withQueryString();
|
||||
|
||||
return view('admin.game-history.mystery-box', compact('boxes', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 神秘占卜历史记录(支持按用户/签文等级筛选,分页)。
|
||||
*/
|
||||
public function fortune(Request $request): View
|
||||
{
|
||||
$summary = [
|
||||
'total_times' => FortuneLog::query()->count(),
|
||||
'grade_dist' => FortuneLog::query()
|
||||
->select('grade', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
|
||||
->groupBy('grade')
|
||||
->pluck('cnt', 'grade'),
|
||||
'total_cost' => (int) FortuneLog::query()->sum('cost'),
|
||||
'free_count' => FortuneLog::query()->where('is_free', true)->count(),
|
||||
];
|
||||
|
||||
$query = FortuneLog::query()->with('user')->latest();
|
||||
|
||||
if ($request->filled('grade')) {
|
||||
$query->where('grade', $request->input('grade'));
|
||||
}
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$query->whereHas('user', function ($q) use ($request) {
|
||||
$q->where('username', 'like', '%'.$request->input('username').'%');
|
||||
});
|
||||
}
|
||||
|
||||
$logs = $query->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.game-history.fortune', compact('logs', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 双色球彩票历史记录页面(期号列表,支持分页)。
|
||||
*/
|
||||
public function lottery(Request $request): View
|
||||
{
|
||||
$summary = [
|
||||
'total_issues' => LotteryIssue::query()->count(),
|
||||
'total_tickets' => LotteryTicket::query()->count(),
|
||||
'total_pool' => (int) LotteryIssue::query()->sum('pool_amount'),
|
||||
'drawn_count' => LotteryIssue::query()->where('status', 'settled')->count(),
|
||||
];
|
||||
|
||||
$issues = LotteryIssue::query()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.game-history.lottery', compact('issues', 'summary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 双色球单期购买明细与中奖详情。
|
||||
*/
|
||||
public function lotteryIssue(LotteryIssue $issue): View
|
||||
{
|
||||
$tickets = $issue->tickets()->with('user')->latest()->paginate(30);
|
||||
|
||||
return view('admin.game-history.lottery-issue', compact('issue', 'tickets'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 五子棋历史记录页面。
|
||||
*/
|
||||
public function gomoku(Request $request): View
|
||||
{
|
||||
$summary = [
|
||||
'total_games' => GomokuGame::query()->count(),
|
||||
'pvp_count' => GomokuGame::query()->where('mode', 'pvp')->count(),
|
||||
'pve_count' => GomokuGame::query()->where('mode', 'pve')->count(),
|
||||
'completed' => GomokuGame::query()->where('status', 'finished')->count(),
|
||||
'today_games' => GomokuGame::query()->whereDate('created_at', today())->count(),
|
||||
];
|
||||
|
||||
$games = GomokuGame::query()
|
||||
->with(['playerBlack', 'playerWhite'])
|
||||
->latest()
|
||||
->paginate(30);
|
||||
|
||||
return view('admin.game-history.gomoku', compact('games', 'summary'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利后台管理控制器
|
||||
*
|
||||
* 管理员可在此创建、编辑、删除节日福利活动,
|
||||
* 也可手动立即触发活动,以及查看领取明细。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\TriggerHolidayEventJob;
|
||||
use App\Models\HolidayEvent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HolidayEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 节日福利活动列表页。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$events = HolidayEvent::query()
|
||||
->orderByDesc('send_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.holiday-events.index', compact('events'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建活动表单页。
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.holiday-events.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新活动。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['status'] = 'pending';
|
||||
$data['enabled'] = $request->boolean('enabled', true);
|
||||
|
||||
HolidayEvent::create($data);
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑活动表单页。
|
||||
*/
|
||||
public function edit(HolidayEvent $holidayEvent): View
|
||||
{
|
||||
return view('admin.holiday-events.edit', ['event' => $holidayEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新活动。
|
||||
*/
|
||||
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$holidayEvent->update($data);
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换活动启用/禁用状态。
|
||||
*/
|
||||
public function toggle(HolidayEvent $holidayEvent): JsonResponse
|
||||
{
|
||||
$holidayEvent->update(['enabled' => ! $holidayEvent->enabled]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'enabled' => $holidayEvent->enabled,
|
||||
'message' => $holidayEvent->enabled ? '已启用' : '已禁用',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动立即触发活动(管理员操作)。
|
||||
*/
|
||||
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
if ($holidayEvent->status !== 'pending') {
|
||||
return back()->with('error', '只有待触发状态的活动才能手动触发。');
|
||||
}
|
||||
|
||||
// 设置触发时间为当前,立即入队
|
||||
$holidayEvent->update(['send_at' => now()]);
|
||||
TriggerHolidayEventJob::dispatch($holidayEvent);
|
||||
|
||||
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除活动。
|
||||
*/
|
||||
public function destroy(HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
$holidayEvent->delete();
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台婚姻系统管理控制器
|
||||
*
|
||||
* 提供总览统计、婚姻/求婚明细查询、婚礼档位管理、
|
||||
* 参数配置、亲密度日志审计、强制离婚等管理操作。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Marriage;
|
||||
use App\Models\MarriageIntimacyLog;
|
||||
use App\Models\WeddingCeremony;
|
||||
use App\Models\WeddingEnvelopeClaim;
|
||||
use App\Models\WeddingTier;
|
||||
use App\Services\MarriageConfigService;
|
||||
use App\Services\MarriageService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MarriageManagerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MarriageConfigService $config,
|
||||
private readonly MarriageService $marriageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 婚姻管理总览(统计卡片 + 最近记录)。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$stats = [
|
||||
'total_married' => Marriage::where('status', 'married')->count(),
|
||||
'total_pending' => Marriage::where('status', 'pending')->count(),
|
||||
'total_divorced' => Marriage::where('status', 'divorced')->count(),
|
||||
'total_weddings' => WeddingCeremony::whereIn('status', ['active', 'completed'])->count(),
|
||||
'total_envelopes' => WeddingEnvelopeClaim::sum('amount'),
|
||||
'claimed_amount' => WeddingEnvelopeClaim::where('claimed', true)->sum('amount'),
|
||||
];
|
||||
|
||||
$recentMarriages = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
|
||||
->where('status', 'married')
|
||||
->orderByDesc('married_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recentDivorces = Marriage::with(['user:id,username', 'partner:id,username'])
|
||||
->where('status', 'divorced')
|
||||
->orderByDesc('divorced_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return view('admin.marriages.index', compact('stats', 'recentMarriages', 'recentDivorces'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 婚姻列表(支持按状态/用户名筛选)。
|
||||
*/
|
||||
public function list(Request $request): View
|
||||
{
|
||||
$query = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($status = $request->get('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($search = $request->get('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('user', fn ($u) => $u->where('username', 'like', "%{$search}%"))
|
||||
->orWhereHas('partner', fn ($u) => $u->where('username', 'like', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
$marriages = $query->paginate(20)->withQueryString();
|
||||
|
||||
return view('admin.marriages.list', compact('marriages'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 求婚记录列表(含 pending/expired/rejected)。
|
||||
*/
|
||||
public function proposals(Request $request): View
|
||||
{
|
||||
$proposals = Marriage::with(['user:id,username', 'partner:id,username', 'ringItem:id,name,icon'])
|
||||
->orderByDesc('proposed_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.marriages.proposals', compact('proposals'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 婚礼红包记录。
|
||||
*/
|
||||
public function ceremonies(Request $request): View
|
||||
{
|
||||
$ceremonies = WeddingCeremony::with([
|
||||
'marriage.user:id,username',
|
||||
'marriage.partner:id,username',
|
||||
'tier:id,name,tier,icon',
|
||||
])
|
||||
->orderByDesc('id')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.marriages.ceremonies', compact('ceremonies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包领取明细(某场婚礼)。
|
||||
*/
|
||||
public function claimDetail(WeddingCeremony $ceremony): View
|
||||
{
|
||||
$ceremony->load(['marriage.user:id,username', 'marriage.partner:id,username', 'tier:id,name,icon']);
|
||||
|
||||
$claims = WeddingEnvelopeClaim::with('user:id,username,headface')
|
||||
->where('ceremony_id', $ceremony->id)
|
||||
->orderBy('amount', 'desc')
|
||||
->paginate(30);
|
||||
|
||||
return view('admin.marriages.claim-detail', compact('ceremony', 'claims'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 亲密度日志列表(支持按用户筛选)。
|
||||
*/
|
||||
public function intimacyLogs(Request $request): View
|
||||
{
|
||||
$query = MarriageIntimacyLog::with(['marriage.user:id,username', 'marriage.partner:id,username'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($search = $request->get('search')) {
|
||||
$query->whereHas('marriage', function ($q) use ($search) {
|
||||
$q->whereHas('user', fn ($u) => $u->where('username', 'like', "%{$search}%"))
|
||||
->orWhereHas('partner', fn ($u) => $u->where('username', 'like', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
if ($source = $request->get('source')) {
|
||||
$query->where('source', $source);
|
||||
}
|
||||
|
||||
$logs = $query->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.marriages.intimacy-logs', compact('logs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置页面(读取所有分组配置)。
|
||||
*/
|
||||
public function configs(): View
|
||||
{
|
||||
$groups = $this->config->allGrouped();
|
||||
|
||||
return view('admin.marriages.configs', compact('groups'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存参数配置。
|
||||
*/
|
||||
public function updateConfigs(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'configs' => 'required|array',
|
||||
'configs.*' => 'required|integer',
|
||||
]);
|
||||
|
||||
$this->config->batchSet($data['configs']);
|
||||
|
||||
return redirect()->route('admin.marriages.configs')->with('success', '婚姻参数配置已保存!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 婚礼档位配置页面。
|
||||
*/
|
||||
public function tiers(): View
|
||||
{
|
||||
$tiers = WeddingTier::orderBy('tier')->get();
|
||||
|
||||
return view('admin.marriages.tiers', compact('tiers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新婚礼档位。
|
||||
*/
|
||||
public function updateTier(Request $request, WeddingTier $tier): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:30',
|
||||
'icon' => 'required|string|max:20',
|
||||
'amount' => 'required|integer|min:1',
|
||||
'description' => 'nullable|string|max:100',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active', true);
|
||||
$tier->update($data);
|
||||
|
||||
return redirect()->route('admin.marriages.tiers')->with('success', "档位【{$tier->name}】已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员强制离婚。
|
||||
*/
|
||||
public function forceDissolve(Request $request, Marriage $marriage): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'admin_note' => 'required|string|max:200',
|
||||
]);
|
||||
|
||||
if ($marriage->status !== 'married') {
|
||||
return back()->with('error', '该婚姻不是已婚状态,无法操作。');
|
||||
}
|
||||
|
||||
$admin = $request->user();
|
||||
$result = $this->marriageService->forceDissolve($marriage, $admin, true);
|
||||
|
||||
// 写入管理员备注
|
||||
$marriage->update(['admin_note' => $data['admin_note']]);
|
||||
|
||||
$msg = $result['ok'] ? '强制离婚已完成。' : $result['message'];
|
||||
$type = $result['ok'] ? 'success' : 'error';
|
||||
|
||||
return back()->with($type, $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员取消求婚(释放戒指 → 退还状态 active)。
|
||||
*/
|
||||
public function cancelProposal(Request $request, Marriage $marriage): RedirectResponse
|
||||
{
|
||||
if ($marriage->status !== 'pending') {
|
||||
return back()->with('error', '该求婚不是进行中状态,无法取消。');
|
||||
}
|
||||
|
||||
$this->marriageService->expireProposal($marriage);
|
||||
$marriage->update(['admin_note' => '管理员手动取消求婚:'.($request->input('reason', ''))]);
|
||||
|
||||
return back()->with('success', '求婚已取消,戒指标记遗失。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:运维工具控制器
|
||||
* 提供缓存清理、路由清理、视图清理、房间在线名单清理等一键运维操作
|
||||
* 仅 id=1 超管可访问
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OpsController extends Controller
|
||||
{
|
||||
/**
|
||||
* 运维工具主页
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
if (Auth::id() !== 1) {
|
||||
abort(403, '无权限操作');
|
||||
}
|
||||
|
||||
return view('admin.ops.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理应用缓存(config:clear + cache:clear)
|
||||
*/
|
||||
public function clearCache(): RedirectResponse
|
||||
{
|
||||
if (Auth::id() !== 1) {
|
||||
abort(403, '无权限操作');
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('cache:clear');
|
||||
|
||||
return redirect()->route('admin.ops.index')
|
||||
->with('ops_success', '✅ 应用缓存已清除(config:clear + cache:clear)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理路由缓存(route:clear)
|
||||
*/
|
||||
public function clearRoutes(): RedirectResponse
|
||||
{
|
||||
if (Auth::id() !== 1) {
|
||||
abort(403, '无权限操作');
|
||||
}
|
||||
|
||||
Artisan::call('route:clear');
|
||||
|
||||
return redirect()->route('admin.ops.index')
|
||||
->with('ops_success', '✅ 路由缓存已清除(route:clear)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理视图缓存(view:clear)
|
||||
*/
|
||||
public function clearViews(): RedirectResponse
|
||||
{
|
||||
if (Auth::id() !== 1) {
|
||||
abort(403, '无权限操作');
|
||||
}
|
||||
|
||||
Artisan::call('view:clear');
|
||||
|
||||
return redirect()->route('admin.ops.index')
|
||||
->with('ops_success', '✅ 视图缓存已清除(view:clear)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有房间 Redis 在线名单(清除幽灵在线脏数据)
|
||||
*/
|
||||
public function clearRoomOnline(): RedirectResponse
|
||||
{
|
||||
if (Auth::id() !== 1) {
|
||||
abort(403, '无权限操作');
|
||||
}
|
||||
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
$cursor = '0';
|
||||
$cleaned = 0;
|
||||
|
||||
do {
|
||||
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
|
||||
foreach ($keys ?? [] as $fullKey) {
|
||||
// 去掉前缀,还原为 Laravel Facade 使用的短 Key
|
||||
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
|
||||
Redis::del($shortKey);
|
||||
$cleaned++;
|
||||
}
|
||||
} while ($cursor !== '0');
|
||||
|
||||
return redirect()->route('admin.ops.index')
|
||||
->with('ops_success', "✅ 已清理 {$cleaned} 个房间的在线名单(幽灵在线已清除)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台职务管理控制器
|
||||
* 提供职务的 CRUD 功能,包含任命权限白名单(多选 position_appoint_limits)的同步
|
||||
* 职务属于部门,包含等级、图标、人数上限、奖励上限等配置
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\Sysparam;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PositionController extends Controller
|
||||
{
|
||||
/**
|
||||
* 职务列表页
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// 按部门分组展示
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
// 全部职务(供任命白名单多选框使用)
|
||||
$allPositions = Position::with('department')->orderByDesc('rank')->get();
|
||||
|
||||
// 全局奖励接收次数上限(0 = 不限)
|
||||
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
|
||||
|
||||
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建职务(同时同步任命白名单)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'department_id' => 'required|exists:departments,id',
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'rank' => 'required|integer|min:0|max:99',
|
||||
'level' => 'required|integer|min:1|max:100',
|
||||
'max_persons' => 'nullable|integer|min:1',
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
|
||||
$position = Position::create($data);
|
||||
|
||||
// 同步任命白名单(有选则写,没选则清空=无任命权)
|
||||
$position->appointablePositions()->sync($appointableIds);
|
||||
|
||||
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速补丁:仅更新职务的数值限额字段(内联编辑专用)
|
||||
*
|
||||
* 允许修改的字段:max_persons / max_reward / daily_reward_limit。
|
||||
* 只接受 JSON AJAX 请求,只更新提交的字段,其余字段保持不变。
|
||||
*
|
||||
* @param Position $position 目标职务
|
||||
*/
|
||||
public function quickPatch(Request $request, Position $position): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'max_persons' => 'sometimes|nullable|integer|min:1|max:9999',
|
||||
'max_reward' => 'sometimes|nullable|integer|min:0|max:999999',
|
||||
'daily_reward_limit' => 'sometimes|nullable|integer|min:0|max:999999',
|
||||
]);
|
||||
|
||||
// 用 fill+save 确保 null 值(不限)也能正确写入
|
||||
$position->fill($data)->save();
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存全局奖励金币接收次数上限
|
||||
*
|
||||
* 控制每位用户单日内可从所有职务持有者处累计接收奖励的最高次数。
|
||||
* 0 表示不限制,保存到 sysparam 表中(key: reward_recipient_daily_max)。
|
||||
*/
|
||||
public function saveRewardConfig(Request $request): \Illuminate\Http\JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'reward_recipient_daily_max' => 'required|integer|min:0|max:9999',
|
||||
]);
|
||||
|
||||
$value = (string) $request->integer('reward_recipient_daily_max');
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'reward_recipient_daily_max'],
|
||||
[
|
||||
'body' => $value,
|
||||
'guidetxt' => '用户单日最多接收奖励金币次数(0=不限,统计所有职务持有者的发放总次数)',
|
||||
]
|
||||
);
|
||||
|
||||
Sysparam::clearCache('reward_recipient_daily_max');
|
||||
|
||||
$label = $value === '0' ? '不限' : "{$value} 次";
|
||||
|
||||
// AJAX 请求返回 JSON,普通表单提交返回重定向
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['status' => 'success', 'message' => "全局接收次数上限已更新为:{$label}"]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.positions.index')
|
||||
->with('success', "全局接收次数上限已更新为:{$label}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新职务(含任命白名单同步)
|
||||
*/
|
||||
public function update(Request $request, Position $position): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'department_id' => 'required|exists:departments,id',
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'nullable|string|max:10',
|
||||
'rank' => 'required|integer|min:0|max:99',
|
||||
'level' => 'required|integer|min:1|max:100',
|
||||
'max_persons' => 'nullable|integer|min:1',
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
|
||||
$position->update($data);
|
||||
$position->appointablePositions()->sync($appointableIds);
|
||||
|
||||
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】更新成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除职务(有在职人员时拒绝)
|
||||
*/
|
||||
public function destroy(Position $position): RedirectResponse
|
||||
{
|
||||
if ($position->isFull() || $position->activeUserPositions()->exists()) {
|
||||
return redirect()->route('admin.positions.index')
|
||||
->with('error', "职务【{$position->name}】尚有在职人员,请先撤销后再删除。");
|
||||
}
|
||||
|
||||
$position->delete();
|
||||
|
||||
return redirect()->route('admin.positions.index')->with('success', "职务【{$position->name}】已删除!");
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
/**
|
||||
* 文件功能:后台房间管理控制器
|
||||
* 管理员可查看、编辑房间信息(名称、介绍、公告等)
|
||||
* 管理员可新增、编辑、删除房间信息(名称、介绍、公告等)
|
||||
* 系统房间(room_keep = true)不允许删除
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @version 1.1.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
@@ -30,12 +31,36 @@ class RoomManagerController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新房间信息
|
||||
* 新增房间
|
||||
*/
|
||||
public function update(Request $request, int $id): RedirectResponse
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$room = Room::findOrFail($id);
|
||||
$data = $request->validate([
|
||||
'room_name' => 'required|string|max:100|unique:rooms,room_name',
|
||||
'room_des' => 'nullable|string|max:500',
|
||||
'room_owner' => 'nullable|string|max:50',
|
||||
'permit_level' => 'required|integer|min:0|max:15',
|
||||
'door_open' => 'required|boolean',
|
||||
], [
|
||||
'room_name.unique' => '房间名称已存在,请换一个名称。',
|
||||
]);
|
||||
|
||||
// 设置新建房间的默认值
|
||||
$data['room_keep'] = false; // 新建房间均为非系统房间,可删除
|
||||
$data['build_time'] = now();
|
||||
|
||||
$room = Room::create($data);
|
||||
|
||||
return redirect()->route('admin.rooms.index')->with('success', "房间「{$room->room_name}」新建成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新房间信息
|
||||
*
|
||||
* @param Room $room 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, Room $room): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'room_name' => 'required|string|max:100',
|
||||
'room_des' => 'nullable|string|max:500',
|
||||
@@ -47,22 +72,23 @@ class RoomManagerController extends Controller
|
||||
|
||||
$room->update($data);
|
||||
|
||||
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 信息已更新!");
|
||||
return redirect()->route('admin.rooms.index')->with('success', "房间「{$room->room_name}」信息已更新!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除房间(非系统房间)
|
||||
* 删除房间(系统房间不允许删除)
|
||||
*
|
||||
* @param Room $room 路由模型自动注入
|
||||
*/
|
||||
public function destroy(int $id): RedirectResponse
|
||||
public function destroy(Room $room): RedirectResponse
|
||||
{
|
||||
$room = Room::findOrFail($id);
|
||||
|
||||
if ($room->room_keep) {
|
||||
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
|
||||
}
|
||||
|
||||
$name = $room->room_name;
|
||||
$room->delete();
|
||||
|
||||
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 已删除!");
|
||||
return redirect()->route('admin.rooms.index')->with('success', "房间「{$name}」已删除!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台商店商品管理控制器(站长功能)
|
||||
*
|
||||
* 提供商店商品的查看、编辑、切换上下架、删除等 CRUD 功能。
|
||||
* 仅 superlevel 及以上可访问,id=1 超级站长才能新增/删除。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ShopItemController extends Controller
|
||||
{
|
||||
/**
|
||||
* 商品列表页(所有 superlevel 以上可查看)
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$items = ShopItem::orderBy('sort_order')->orderBy('id')->get();
|
||||
|
||||
return view('admin.shop.index', compact('items'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增商品(仅 id=1 超级站长)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$data = $this->validateItem($request);
|
||||
ShopItem::create($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商品信息
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
$data = $this->validateItem($request, $shopItem);
|
||||
$shopItem->update($data);
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换商品上下架状态
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function toggle(ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
$shopItem->update(['is_active' => ! $shopItem->is_active]);
|
||||
$status = $shopItem->is_active ? '上架' : '下架';
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', "「{$shopItem->name}」已{$status}。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商品(仅 id=1 超级站长)
|
||||
*
|
||||
* @param ShopItem $shopItem 路由模型自动注入
|
||||
*/
|
||||
public function destroy(ShopItem $shopItem): RedirectResponse
|
||||
{
|
||||
abort_unless(Auth::id() === 1, 403);
|
||||
|
||||
$name = $shopItem->name;
|
||||
$shopItem->delete();
|
||||
|
||||
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一验证商品表单(新增/编辑共用)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validateItem(Request $request, ?ShopItem $item = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'slug' => ['required', 'string', 'max:100',
|
||||
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
|
||||
],
|
||||
'icon' => 'required|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'price' => 'required|integer|min:0',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
|
||||
'duration_days' => 'nullable|integer|min:0',
|
||||
'duration_minutes' => 'nullable|integer|min:0',
|
||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
||||
'charm_bonus' => 'nullable|integer|min:0',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class SmtpController extends Controller
|
||||
public function test(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'test_email' => 'required|email'
|
||||
'test_email' => 'required|email',
|
||||
]);
|
||||
|
||||
$testEmail = $request->input('test_email');
|
||||
@@ -78,7 +78,7 @@ class SmtpController extends Controller
|
||||
|
||||
return redirect()->route('admin.smtp.edit')->with('success', "测试邮件已成功发送至 {$testEmail},请注意查收。");
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.smtp.edit')->with('error', "测试发出失败,原因:" . $e->getMessage());
|
||||
return redirect()->route('admin.smtp.edit')->with('error', '测试发出失败,原因:'.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
/**
|
||||
* 文件功能:系统参数配置控制器
|
||||
* (替代原版 VIEWSYS.ASP / SetSYS.ASP)
|
||||
* 运维工具已迁移至 OpsController
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @version 1.1.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,7 +26,15 @@ use Illuminate\View\View;
|
||||
class UserManagerController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示拥护列表及搜索
|
||||
* 注入统一积分服务和聊天室状态服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示用户列表及搜索(支持按等级/经验/金币/魅力/在线状态排序)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
@@ -33,34 +44,68 @@ class UserManagerController extends Controller
|
||||
$query->where('username', 'like', '%'.$request->input('username').'%');
|
||||
}
|
||||
|
||||
// 分页获取用户
|
||||
$users = $query->orderBy('id', 'desc')->paginate(20);
|
||||
// 从 Redis 获取所有在线用户名(跨所有房间去重)
|
||||
$onlineUsernames = collect();
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
$onlineUsernames = $onlineUsernames->merge(array_keys($this->chatState->getRoomUsers($roomId)));
|
||||
}
|
||||
$onlineUsernames = $onlineUsernames->unique()->values();
|
||||
|
||||
// 排序:允许的字段白名单,防止 SQL 注入
|
||||
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id', 'online', 'wxid'];
|
||||
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
|
||||
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if ($sortBy === 'online') {
|
||||
// 用虚拟列排序:在线用户标记为 1,离线为 0;desc = 在线优先
|
||||
if ($onlineUsernames->isNotEmpty()) {
|
||||
$placeholders = implode(',', array_fill(0, $onlineUsernames->count(), '?'));
|
||||
$query->orderByRaw(
|
||||
"CASE WHEN username IN ({$placeholders}) THEN 1 ELSE 0 END {$sortDir}",
|
||||
$onlineUsernames->toArray(),
|
||||
);
|
||||
}
|
||||
$query->orderBy('id', 'desc'); // 二级排序
|
||||
} else {
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
}
|
||||
|
||||
$users = $query
|
||||
->with(['activePosition.position.department', 'vipLevel'])
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
// VIP 等级选项列表(供编辑弹窗使用)
|
||||
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
|
||||
|
||||
return view('admin.users.index', compact('users', 'vipLevels'));
|
||||
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户资料、等级或密码 (AJAX 或表单)
|
||||
*
|
||||
* @param User $user 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse|RedirectResponse
|
||||
public function update(Request $request, User $user): JsonResponse|RedirectResponse
|
||||
{
|
||||
$targetUser = User::findOrFail($id);
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
|
||||
if ($currentUser->id !== 1) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅超级管理员(id=1)可编辑用户信息。'], 403);
|
||||
}
|
||||
abort(403, '仅超级管理员(id=1)可编辑用户信息。');
|
||||
}
|
||||
|
||||
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
|
||||
}
|
||||
|
||||
// 管理员级别 = 最高等级 + 1,后台编辑最高可设到管理员级别
|
||||
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
|
||||
|
||||
$validated = $request->validate([
|
||||
'sex' => 'sometimes|integer|in:0,1,2',
|
||||
'user_level' => "sometimes|integer|min:0|max:{$adminLevel}",
|
||||
'exp_num' => 'sometimes|integer|min:0',
|
||||
'jjb' => 'sometimes|integer|min:0',
|
||||
'meili' => 'sometimes|integer|min:0',
|
||||
@@ -71,28 +116,56 @@ class UserManagerController extends Controller
|
||||
'hy_time' => 'sometimes|nullable|date',
|
||||
]);
|
||||
|
||||
// 如果传了且没超权,直接赋予
|
||||
if (isset($validated['user_level'])) {
|
||||
if ($currentUser->id !== $targetUser->id) {
|
||||
// 修改别人:只有真正的创始人 (ID=1) 才能修改别人的等级
|
||||
if ($currentUser->id !== 1) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限越界:只有星系创始人(站长)才能调整其他用户的行政等级!'], 403);
|
||||
}
|
||||
}
|
||||
$targetUser->user_level = $validated['user_level'];
|
||||
}
|
||||
|
||||
if (isset($validated['sex'])) {
|
||||
$targetUser->sex = $validated['sex'];
|
||||
}
|
||||
if (isset($validated['exp_num'])) {
|
||||
$targetUser->exp_num = $validated['exp_num'];
|
||||
// 计算差值并通过统一服务记录流水(管理员手动调整)
|
||||
$expDiff = $validated['exp_num'] - ($targetUser->exp_num ?? 0);
|
||||
if ($expDiff !== 0) {
|
||||
$this->currencyService->change(
|
||||
$targetUser, 'exp', $expDiff, CurrencySource::ADMIN_ADJUST,
|
||||
"管理员 {$currentUser->username} 手动调整经验",
|
||||
);
|
||||
$targetUser->refresh();
|
||||
}
|
||||
|
||||
// 调整经验后重新计算等级(有职务用户锁定职务等级,无职务用户按经验重算)
|
||||
$targetUser->load('activePosition.position');
|
||||
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
|
||||
if ($targetUser->activePosition?->position) {
|
||||
// 有在职职务:等级锁定为职务级,不受经验影响
|
||||
$lockedLevel = (int) $targetUser->activePosition->position->level;
|
||||
if ($lockedLevel > 0 && $targetUser->user_level !== $lockedLevel) {
|
||||
$targetUser->user_level = $lockedLevel;
|
||||
}
|
||||
} elseif ($targetUser->user_level < $superLevel) {
|
||||
// 无职务普通用户:按经验重算等级(不超过满级阈值)
|
||||
$newLevel = \App\Models\Sysparam::calculateLevel($targetUser->exp_num ?? 0);
|
||||
$safeLevel = max(1, min($newLevel, $superLevel - 1));
|
||||
$targetUser->user_level = $safeLevel;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($validated['jjb'])) {
|
||||
$targetUser->jjb = $validated['jjb'];
|
||||
$jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0);
|
||||
if ($jjbDiff !== 0) {
|
||||
$this->currencyService->change(
|
||||
$targetUser, 'gold', $jjbDiff, CurrencySource::ADMIN_ADJUST,
|
||||
"管理员 {$currentUser->username} 手动调整金币",
|
||||
);
|
||||
$targetUser->refresh();
|
||||
}
|
||||
}
|
||||
if (isset($validated['meili'])) {
|
||||
$targetUser->meili = $validated['meili'];
|
||||
$meiliDiff = $validated['meili'] - ($targetUser->meili ?? 0);
|
||||
if ($meiliDiff !== 0) {
|
||||
$this->currencyService->change(
|
||||
$targetUser, 'charm', $meiliDiff, CurrencySource::ADMIN_ADJUST,
|
||||
"管理员 {$currentUser->username} 手动调整魅力",
|
||||
);
|
||||
$targetUser->refresh();
|
||||
}
|
||||
}
|
||||
if (array_key_exists('qianming', $validated)) {
|
||||
$targetUser->qianming = $validated['qianming'];
|
||||
@@ -124,12 +197,19 @@ class UserManagerController extends Controller
|
||||
|
||||
/**
|
||||
* 物理删除杀封用户
|
||||
*
|
||||
* @param User $user 路由模型自动注入
|
||||
*/
|
||||
public function destroy(Request $request, int $id): RedirectResponse
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$targetUser = User::findOrFail($id);
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可删除用户
|
||||
if ($currentUser->id !== 1) {
|
||||
abort(403, '仅超级管理员(id=1)可删除用户。');
|
||||
}
|
||||
|
||||
// 越权防护:不允许删除同级或更高等级的账号
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
abort(403, '权限不足:无法删除同级或高级账号!');
|
||||
|
||||
@@ -20,6 +20,32 @@ use Illuminate\View\View;
|
||||
|
||||
class VipController extends Controller
|
||||
{
|
||||
/**
|
||||
* 会员主题支持的特效下拉选项。
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const EFFECT_LABELS = [
|
||||
'none' => '无特效',
|
||||
'fireworks' => '烟花',
|
||||
'rain' => '下雨',
|
||||
'lightning' => '闪电',
|
||||
'snow' => '下雪',
|
||||
];
|
||||
|
||||
/**
|
||||
* 会员主题支持的横幅风格下拉选项。
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const BANNER_STYLE_LABELS = [
|
||||
'aurora' => '鎏光星幕',
|
||||
'storm' => '雷霆风暴',
|
||||
'royal' => '王者金辉',
|
||||
'cosmic' => '星穹幻彩',
|
||||
'farewell' => '告别暮光',
|
||||
];
|
||||
|
||||
/**
|
||||
* 会员等级管理列表页
|
||||
*/
|
||||
@@ -27,7 +53,11 @@ class VipController extends Controller
|
||||
{
|
||||
$levels = VipLevel::orderBy('sort_order')->get();
|
||||
|
||||
return view('admin.vip.index', compact('levels'));
|
||||
return view('admin.vip.index', [
|
||||
'levels' => $levels,
|
||||
'effectOptions' => self::EFFECT_LABELS,
|
||||
'bannerStyleOptions' => self::BANNER_STYLE_LABELS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,22 +65,7 @@ class VipController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 将文本框的多行模板转为 JSON 数组
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data = $this->validatedPayload($request);
|
||||
|
||||
VipLevel::create($data);
|
||||
|
||||
@@ -60,27 +75,13 @@ class VipController extends Controller
|
||||
/**
|
||||
* 更新会员等级
|
||||
*
|
||||
* @param int $id 等级ID
|
||||
* @param VipLevel $vip 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, int $id): RedirectResponse
|
||||
public function update(Request $request, VipLevel $vip): RedirectResponse
|
||||
{
|
||||
$level = VipLevel::findOrFail($id);
|
||||
$level = $vip;
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data = $this->validatedPayload($request);
|
||||
|
||||
$level->update($data);
|
||||
|
||||
@@ -90,12 +91,11 @@ class VipController extends Controller
|
||||
/**
|
||||
* 删除会员等级(关联用户的 vip_level_id 会自动置 null)
|
||||
*
|
||||
* @param int $id 等级ID
|
||||
* @param VipLevel $vip 路由模型自动注入
|
||||
*/
|
||||
public function destroy(int $id): RedirectResponse
|
||||
public function destroy(VipLevel $vip): RedirectResponse
|
||||
{
|
||||
$level = VipLevel::findOrFail($id);
|
||||
$level->delete();
|
||||
$vip->delete();
|
||||
|
||||
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
|
||||
}
|
||||
@@ -120,4 +120,37 @@ class VipController extends Controller
|
||||
|
||||
return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一整理后台提交的会员等级主题配置数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validatedPayload(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'icon' => 'required|string|max:20',
|
||||
'color' => 'required|string|max:10',
|
||||
'exp_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'jjb_multiplier' => 'required|numeric|min:1|max:99',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'price' => 'required|integer|min:0',
|
||||
'duration_days' => 'required|integer|min:0',
|
||||
'join_templates' => 'nullable|string',
|
||||
'leave_templates' => 'nullable|string',
|
||||
'join_effect' => 'required|in:none,fireworks,rain,lightning,snow',
|
||||
'leave_effect' => 'required|in:none,fireworks,rain,lightning,snow',
|
||||
'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
|
||||
'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
|
||||
'allow_custom_messages' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 将多行文本框内容转为 JSON 数组,便于后续随机抽取模板。
|
||||
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
|
||||
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
|
||||
$data['allow_custom_messages'] = $request->boolean('allow_custom_messages');
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 支付配置控制器
|
||||
* 用于管理聊天室对接 NovaLink 支付中心所需的开关、地址、App Key 与 App Secret
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateVipPaymentConfigRequest;
|
||||
use App\Models\SysParam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数注入聊天室状态服务
|
||||
*
|
||||
* @param ChatStateService $chatState 系统参数缓存同步服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示 VIP 支付配置页
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
$aliases = array_keys($this->fieldDescriptions());
|
||||
|
||||
// 仅读取 VIP 支付专属配置,避免与系统参数页重复展示。
|
||||
$params = SysParam::query()
|
||||
->whereIn('alias', $aliases)
|
||||
->pluck('body', 'alias')
|
||||
->toArray();
|
||||
|
||||
return view('admin.vip-payment.config', [
|
||||
'params' => $params,
|
||||
'descriptions' => $this->fieldDescriptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 VIP 支付配置并刷新缓存
|
||||
*
|
||||
* @param UpdateVipPaymentConfigRequest $request 已校验的后台配置请求
|
||||
*/
|
||||
public function update(UpdateVipPaymentConfigRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$descriptions = $this->fieldDescriptions();
|
||||
|
||||
foreach ($descriptions as $alias => $guidetxt) {
|
||||
$body = (string) ($data[$alias] ?? '');
|
||||
|
||||
// 写入数据库并同步描述文案,确保后续后台与缓存读取一致。
|
||||
SysParam::updateOrCreate(
|
||||
['alias' => $alias],
|
||||
['body' => $body, 'guidetxt' => $guidetxt]
|
||||
);
|
||||
|
||||
$this->chatState->setSysParam($alias, $body);
|
||||
SysParam::clearCache($alias);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.vip-payment.edit')->with('success', 'VIP 支付配置已成功保存。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 VIP 支付字段说明文案
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fieldDescriptions(): array
|
||||
{
|
||||
return [
|
||||
'vip_payment_enabled' => 'VIP 在线支付开关(1=开启,0=关闭)',
|
||||
'vip_payment_base_url' => 'NovaLink 支付中心地址(例如 https://novalink.test)',
|
||||
'vip_payment_app_key' => 'NovaLink 支付中心 App Key',
|
||||
'vip_payment_app_secret' => 'NovaLink 支付中心 App Secret',
|
||||
'vip_payment_timeout' => '调用支付中心超时时间(秒)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台会员购买日志控制器
|
||||
* 负责展示聊天室 VIP 在线支付订单列表,并支持按用户、状态、订单号和日期筛选
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VipPaymentOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipPaymentLogController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示会员购买日志列表
|
||||
*
|
||||
* @param Request $request 当前查询请求
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = VipPaymentOrder::query()->with(['user:id,username', 'vipLevel:id,name,color,icon']);
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$username = (string) $request->input('username');
|
||||
|
||||
// 通过用户关联模糊匹配用户名,便于后台快速定位某个会员订单。
|
||||
$query->whereHas('user', function ($builder) use ($username): void {
|
||||
$builder->where('username', 'like', '%'.$username.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', (string) $request->input('status'));
|
||||
}
|
||||
|
||||
if ($request->filled('order_no')) {
|
||||
$keyword = (string) $request->input('order_no');
|
||||
$query->where(function ($builder) use ($keyword): void {
|
||||
$builder->where('order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('merchant_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('payment_order_no', 'like', '%'.$keyword.'%')
|
||||
->orWhere('provider_trade_no', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('created_at', '>=', (string) $request->input('date_start'));
|
||||
}
|
||||
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('created_at', '<=', (string) $request->input('date_end'));
|
||||
}
|
||||
|
||||
$logs = $query->latest('id')->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.vip-payment-logs.index', [
|
||||
'logs' => $logs,
|
||||
'statusOptions' => [
|
||||
'created' => '待创建',
|
||||
'pending' => '待支付',
|
||||
'paid' => '已支付',
|
||||
'closed' => '已关闭',
|
||||
'failed' => '失败',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信机器人配置控制器
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SysParam;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WechatBotController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示微信机器人配置表单
|
||||
*/
|
||||
public function edit(): View
|
||||
{
|
||||
// 从 SysParam 获取配置,若不存在赋予默认空 JSON
|
||||
$param = SysParam::firstOrCreate(
|
||||
['alias' => 'wechat_bot_config'],
|
||||
[
|
||||
'body' => json_encode([
|
||||
'kafka' => [
|
||||
'brokers' => '',
|
||||
'topic' => '',
|
||||
'group_id' => 'chatroom_wechat_bot',
|
||||
'bot_wxid' => '',
|
||||
],
|
||||
'api' => [
|
||||
'base_url' => '',
|
||||
'bot_key' => '',
|
||||
],
|
||||
'global_notify' => [
|
||||
'start_time' => '08:00',
|
||||
'end_time' => '22:00',
|
||||
],
|
||||
'group_notify' => [
|
||||
'target_wxid' => '',
|
||||
'toggle_admin_online' => false,
|
||||
'toggle_baccarat_result' => false,
|
||||
'toggle_lottery_result' => false,
|
||||
],
|
||||
'personal_notify' => [
|
||||
'toggle_friend_online' => false,
|
||||
'toggle_spouse_online' => false,
|
||||
'toggle_level_change' => false,
|
||||
],
|
||||
]),
|
||||
'guidetxt' => '微信机器人全站配置(包含群聊推送和私聊推送开关及Kafka连接)',
|
||||
]
|
||||
);
|
||||
|
||||
$config = json_decode($param->body, true);
|
||||
|
||||
return view('admin.wechat_bot.edit', compact('config'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新微信机器人配置
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'kafka_brokers' => 'nullable|string',
|
||||
'kafka_topic' => 'nullable|string',
|
||||
'kafka_group_id' => 'nullable|string',
|
||||
'kafka_bot_wxid' => 'nullable|string',
|
||||
'api_base_url' => 'nullable|string',
|
||||
'api_bot_key' => 'nullable|string',
|
||||
'qrcode_image' => 'nullable|image|max:2048',
|
||||
'global_start_time' => 'nullable|string',
|
||||
'global_end_time' => 'nullable|string',
|
||||
'group_target_wxid' => 'nullable|string',
|
||||
'toggle_admin_online' => 'nullable|boolean',
|
||||
'toggle_baccarat_result' => 'nullable|boolean',
|
||||
'toggle_lottery_result' => 'nullable|boolean',
|
||||
'toggle_friend_online' => 'nullable|boolean',
|
||||
'toggle_spouse_online' => 'nullable|boolean',
|
||||
'toggle_level_change' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->first();
|
||||
$oldConfig = $param ? (json_decode($param->body, true) ?? []) : [];
|
||||
|
||||
$qrcodePath = $oldConfig['api']['qrcode_image'] ?? '';
|
||||
if ($request->hasFile('qrcode_image')) {
|
||||
// 删除旧图
|
||||
if ($qrcodePath && \Illuminate\Support\Facades\Storage::disk('public')->exists($qrcodePath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($qrcodePath);
|
||||
}
|
||||
$qrcodePath = $request->file('qrcode_image')->store('wechat', 'public');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'kafka' => [
|
||||
'brokers' => $validated['kafka_brokers'] ?? '',
|
||||
'topic' => $validated['kafka_topic'] ?? '',
|
||||
'group_id' => $validated['kafka_group_id'] ?? 'chatroom_wechat_bot',
|
||||
'bot_wxid' => $validated['kafka_bot_wxid'] ?? '',
|
||||
],
|
||||
'api' => [
|
||||
'base_url' => $validated['api_base_url'] ?? '',
|
||||
'bot_key' => $validated['api_bot_key'] ?? '',
|
||||
'qrcode_image' => $qrcodePath,
|
||||
],
|
||||
'global_notify' => [
|
||||
'start_time' => $validated['global_start_time'] ?? '',
|
||||
'end_time' => $validated['global_end_time'] ?? '',
|
||||
],
|
||||
'group_notify' => [
|
||||
'target_wxid' => $validated['group_target_wxid'] ?? '',
|
||||
'toggle_admin_online' => $validated['toggle_admin_online'] ?? false,
|
||||
'toggle_baccarat_result' => $validated['toggle_baccarat_result'] ?? false,
|
||||
'toggle_lottery_result' => $validated['toggle_lottery_result'] ?? false,
|
||||
],
|
||||
'personal_notify' => [
|
||||
'toggle_friend_online' => $validated['toggle_friend_online'] ?? false,
|
||||
'toggle_spouse_online' => $validated['toggle_spouse_online'] ?? false,
|
||||
'toggle_level_change' => $validated['toggle_level_change'] ?? false,
|
||||
],
|
||||
];
|
||||
|
||||
if ($param) {
|
||||
$param->update(['body' => json_encode($config)]);
|
||||
SysParam::clearCache('wechat_bot_config');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送群内公告
|
||||
*/
|
||||
public function sendAnnouncement(Request $request, \App\Services\WechatBot\WechatNotificationService $wechatService): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'announcement_content' => 'required|string|max:1000',
|
||||
], [
|
||||
'announcement_content.required' => '请输入公告内容',
|
||||
'announcement_content.max' => '公告内容太长,不能超过1000字',
|
||||
]);
|
||||
|
||||
try {
|
||||
$wechatService->sendCustomGroupAnnouncement($validated['announcement_content']);
|
||||
|
||||
return back()->with('success', '群公告已通过微信机器人发送成功!(消息已进入队列)');
|
||||
} catch (\Exception $e) {
|
||||
return back()->withInput()->withErrors(['announcement_content' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Message;
|
||||
use App\Models\PositionAuthorityLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -33,6 +36,7 @@ class AdminCommandController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -386,12 +390,12 @@ class AdminCommandController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'type' => 'required|in:fireworks,rain,lightning,snow',
|
||||
'type' => 'required|in:fireworks,rain,lightning,snow',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$type = $request->input('type');
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$type = $request->input('type');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
// 仅 superlevel 等级可触发特效
|
||||
@@ -405,6 +409,272 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 职务奖励金币(凭空发放,无需扣操作者余额)
|
||||
*
|
||||
* 三层限额校验:
|
||||
* 1. amount ≤ position.max_reward (单次上限)
|
||||
* 2. 今日累计发放 + amount ≤ position.daily_reward_limit (操作人单日累计上限)
|
||||
* 3. 今日对同一接收者发放次数 < position.recipient_daily_limit(同一接收者每日次数限)
|
||||
*
|
||||
* 成功后:
|
||||
* - 通过 UserCurrencyService 给接收者增加金币
|
||||
* - 写入 PositionAuthorityLog(action_type=reward,记录到履职记录)
|
||||
* - 向房间发送悄悄话通知接收者
|
||||
*
|
||||
* @param Request $request 需包含 username, room_id, amount
|
||||
*/
|
||||
public function reward(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'room_id' => 'required|integer',
|
||||
'amount' => 'required|integer|min:1|max:999999999',
|
||||
], [
|
||||
'amount.max' => '单次发放金币不能超过 999999999',
|
||||
'amount.min' => '发放金币至少为 1',
|
||||
'amount.integer' => '金币数量必须是整数',
|
||||
'amount.required' => '请输入要发放的金币数量',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$amount = (int) $request->input('amount');
|
||||
$targetUsername = $request->input('username');
|
||||
|
||||
// 不能给自己发放
|
||||
if ($admin->username === $targetUsername) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己发放奖励'], 422);
|
||||
}
|
||||
|
||||
// 目标用户必须存在
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if (! $target) {
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
}
|
||||
|
||||
// id=1 超级管理员:无需职务,无限额限制
|
||||
$isSuperAdmin = $admin->id === 1;
|
||||
$userPosition = null;
|
||||
$position = null;
|
||||
|
||||
if (! $isSuperAdmin) {
|
||||
// ① 必须有在职职务
|
||||
$userPosition = $admin->activePosition;
|
||||
if (! $userPosition) {
|
||||
return response()->json(['status' => 'error', 'message' => '你当前没有在职职务,无权发放奖励'], 403);
|
||||
}
|
||||
|
||||
$position = $userPosition->position;
|
||||
|
||||
// 职务 max_reward = 0 表示禁止,null 表示不限,正整数表示有上限
|
||||
if ($position?->max_reward === 0) {
|
||||
return response()->json(['status' => 'error', 'message' => '你的职务未配置奖励权限'], 403);
|
||||
}
|
||||
|
||||
// ② 单次上限校验(max_reward > 0 时才限制,null = 不限)
|
||||
if ($position->max_reward && $amount > $position->max_reward) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "单次奖励上限为 {$position->max_reward} 金币,请调整金额",
|
||||
], 422);
|
||||
}
|
||||
|
||||
// ③ 操作人单日累计上限校验
|
||||
if ($position->daily_reward_limit) {
|
||||
$todayTotal = PositionAuthorityLog::where('user_id', $admin->id)
|
||||
->where('action_type', 'reward')
|
||||
->whereDate('created_at', today())
|
||||
->sum('amount');
|
||||
|
||||
if ($todayTotal + $amount > $position->daily_reward_limit) {
|
||||
$remaining = max(0, $position->daily_reward_limit - $todayTotal);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "今日剩余可发放额度为 {$remaining} 金币,超出单日上限({$position->daily_reward_limit})",
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 职务级别:接收者每日次数上限
|
||||
if ($position->recipient_daily_limit) {
|
||||
$recipientCount = PositionAuthorityLog::where('target_user_id', $target->id)
|
||||
->where('action_type', 'reward')
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
|
||||
if ($recipientCount >= $position->recipient_daily_limit) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "{$targetUsername} 今日已由全体职务人员累计发放 {$recipientCount} 次奖励,已达每日上限({$position->recipient_daily_limit})",
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 全局系统级别:每位用户单日最多接收奖励次数(所有操作人通用,含超管)
|
||||
$globalMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
|
||||
if ($globalMax > 0) {
|
||||
$globalCount = PositionAuthorityLog::where('target_user_id', $target->id)
|
||||
->where('action_type', 'reward')
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
|
||||
if ($globalCount >= $globalMax) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "{$targetUsername} 今日已累计接收 {$globalCount} 次奖励,已达全局每日上限({$globalMax})",
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// 发放金币(通过 UserCurrencyService 原子性更新 + 写流水)
|
||||
// 组合「部门 · 职务」显示名,超管特殊处理
|
||||
if ($isSuperAdmin) {
|
||||
$positionName = '超级管理员';
|
||||
} elseif ($position) {
|
||||
$deptName = $position->department?->name;
|
||||
$positionName = $deptName ? "{$deptName} · {$position->name}" : $position->name;
|
||||
} else {
|
||||
$positionName = '职务';
|
||||
}
|
||||
|
||||
$this->currencyService->change(
|
||||
$target,
|
||||
'gold',
|
||||
$amount,
|
||||
CurrencySource::POSITION_REWARD,
|
||||
"{$admin->username}({$positionName})职务奖励",
|
||||
$roomId,
|
||||
);
|
||||
|
||||
// 写履职记录(PositionAuthorityLog;超管无职务时 user_position_id 留 null)
|
||||
PositionAuthorityLog::create([
|
||||
'user_id' => $admin->id,
|
||||
'user_position_id' => $userPosition?->id,
|
||||
'action_type' => 'reward',
|
||||
'target_user_id' => $target->id,
|
||||
'amount' => $amount,
|
||||
'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}",
|
||||
]);
|
||||
|
||||
// ① 聊天室公开公告(所有在场用户可见)
|
||||
$publicMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '',
|
||||
'content' => "💰 <b>{$admin->username}</b>({$positionName})向 <b>{$targetUsername}</b> 发放了 <b>{$amount}</b> 枚奖励金币!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $publicMsg);
|
||||
broadcast(new MessageSent($roomId, $publicMsg));
|
||||
SaveMessageJob::dispatch($publicMsg);
|
||||
|
||||
// ② 接收者私信(含 toast_notification 触发右下角小卡片)
|
||||
$freshJjb = $target->fresh()->jjb;
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $targetUsername,
|
||||
'content' => "🎁 <b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币奖励!当前金币:{$freshJjb} 枚。",
|
||||
'is_secret' => true,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
// 前端 toast-notification 组件识别此字段,弹出右下角通知卡片
|
||||
'toast_notification' => [
|
||||
'title' => '💰 奖励金币到账',
|
||||
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
||||
'icon' => '💰',
|
||||
'color' => '#f59e0b',
|
||||
'duration' => 8000,
|
||||
],
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "已向 {$targetUsername} 发放 {$amount} 金币奖励 🎉",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前操作人的奖励额度信息(供发放弹窗展示)
|
||||
*
|
||||
* 返回字段:
|
||||
* - max_once: 单次上限(null = 不限)
|
||||
* - daily_limit: 单日发放总额上限(null = 不限)
|
||||
* - today_sent: 今日已发放总额
|
||||
* - daily_remaining: 今日剩余可发放额度(null = 不限)
|
||||
*/
|
||||
public function rewardQuota(): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$admin = Auth::user();
|
||||
$isSuperAdmin = $admin->id === 1;
|
||||
|
||||
// 最近 10 条本人发放记录(含目标用户名)
|
||||
$recent = PositionAuthorityLog::with('targetUser:id,username')
|
||||
->where('user_id', $admin->id)
|
||||
->where('action_type', 'reward')
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'target' => $log->targetUser?->username ?? '未知',
|
||||
'amount' => $log->amount,
|
||||
'created_at' => $log->created_at?->format('m-d H:i'),
|
||||
]);
|
||||
|
||||
if ($isSuperAdmin) {
|
||||
return response()->json([
|
||||
'max_once' => null,
|
||||
'daily_limit' => null,
|
||||
'today_sent' => (int) PositionAuthorityLog::where('user_id', $admin->id)
|
||||
->where('action_type', 'reward')
|
||||
->whereDate('created_at', today())
|
||||
->sum('amount'),
|
||||
'daily_remaining' => null,
|
||||
'recent_rewards' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
$position = $admin->activePosition?->position;
|
||||
if (! $position) {
|
||||
return response()->json([
|
||||
'max_once' => 0,
|
||||
'daily_limit' => null,
|
||||
'today_sent' => 0,
|
||||
'daily_remaining' => null,
|
||||
'recent_rewards' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
// 今日已发放总额
|
||||
$todaySent = (int) PositionAuthorityLog::where('user_id', $admin->id)
|
||||
->where('action_type', 'reward')
|
||||
->whereDate('created_at', today())
|
||||
->sum('amount');
|
||||
|
||||
$dailyLimit = $position->daily_reward_limit;
|
||||
$remaining = $dailyLimit !== null ? max(0, $dailyLimit - $todaySent) : null;
|
||||
|
||||
return response()->json([
|
||||
'max_once' => $position->max_reward,
|
||||
'daily_limit' => $dailyLimit,
|
||||
'today_sent' => $todaySent,
|
||||
'daily_remaining' => $remaining,
|
||||
'recent_rewards' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查:管理员是否可对目标用户执行指定操作
|
||||
*
|
||||
@@ -429,9 +699,9 @@ class AdminCommandController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标用户等级必须低于操作者
|
||||
// 目标用户等级不能高于操作者(允许平级互相操作)
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if ($target && $target->user_level >= $admin->user_level) {
|
||||
if ($target && $target->user_level > $admin->user_level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class VerificationController extends Controller
|
||||
public function sendEmailCode(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email'
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$email = $request->input('email');
|
||||
@@ -27,23 +27,24 @@ class VerificationController extends Controller
|
||||
if (SysParam::where('alias', 'smtp_enabled')->value('body') !== '1') {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。'
|
||||
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 检查是否有频率限制(同一用户或同一邮箱,60秒只允许发1次)
|
||||
$throttleKey = 'email_throttle_' . $user->id;
|
||||
$throttleKey = 'email_throttle_'.$user->id;
|
||||
if (Cache::has($throttleKey)) {
|
||||
$ttl = Cache::ttl($throttleKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。"
|
||||
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。",
|
||||
], 429);
|
||||
}
|
||||
|
||||
// 3. 生成 6 位随机验证码并缓存,有效期 5 分钟
|
||||
$code = mt_rand(100000, 999999);
|
||||
$codeKey = 'email_verify_code_' . $user->id . '_' . $email;
|
||||
$codeKey = 'email_verify_code_'.$user->id.'_'.$email;
|
||||
Cache::put($codeKey, $code, now()->addMinutes(5));
|
||||
|
||||
// 设置频率锁,过期时间 60 秒
|
||||
@@ -57,14 +58,15 @@ class VerificationController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '验证码已发送,请注意查收邮件。'
|
||||
'message' => '验证码已发送,请注意查收邮件。',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 如果发信失败,主动接触频率限制锁方便用户下一次立重试
|
||||
Cache::forget($throttleKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '邮件系统发送异常,请稍后再试: ' . $e->getMessage()
|
||||
'message' => '邮件系统发送异常,请稍后再试: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Models\UsernameBlacklist;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Sysparam;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
@@ -38,7 +39,15 @@ class AuthController extends Controller
|
||||
|
||||
if ($user) {
|
||||
// 用户存在,验证密码
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$passwordMatches = false;
|
||||
try {
|
||||
$passwordMatches = Hash::check($password, $user->password);
|
||||
} catch (\RuntimeException $e) {
|
||||
// Hash::check() in Laravel 11/12 throws if the hash isn't a valid bcrypt string
|
||||
$passwordMatches = false;
|
||||
}
|
||||
|
||||
if ($passwordMatches) {
|
||||
// Bcrypt 验证通过
|
||||
|
||||
// 检测是否被封禁 (后台管理员级别获得豁免权,防止误把自己关在门外)
|
||||
@@ -99,6 +108,26 @@ class AuthController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '您所在的 IP 地址已被管理员封禁,禁止注册新账号。'], 403);
|
||||
}
|
||||
|
||||
// 检测用户名是否在禁用词列表(永久禁用 或 改名临时保留期内)
|
||||
if ($blockingRecord = UsernameBlacklist::getBlockingRecord($username)) {
|
||||
$reason = '';
|
||||
if ($blockingRecord->type === 'permanent') {
|
||||
$reason = "(包含违禁敏感词:{$blockingRecord->username})";
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => "该用户名已被系统禁止注册{$reason},请更换其他名称。"], 422);
|
||||
}
|
||||
|
||||
// --- 提取邀请人 Cookie ---
|
||||
$inviterIdCookie = $request->cookie('inviter_id');
|
||||
$inviterId = null;
|
||||
if ($inviterIdCookie && is_numeric($inviterIdCookie)) {
|
||||
// 简单校验邀请人是否存在,防止脏数据
|
||||
if (User::where('id', $inviterIdCookie)->exists()) {
|
||||
$inviterId = (int) $inviterIdCookie;
|
||||
}
|
||||
}
|
||||
|
||||
$newUser = User::create([
|
||||
'username' => $username,
|
||||
'password' => Hash::make($password),
|
||||
@@ -107,10 +136,16 @@ class AuthController extends Controller
|
||||
'user_level' => 1, // 默认普通用户等级
|
||||
'sex' => $sex,
|
||||
'usersf' => '1.gif', // 默认头像
|
||||
'inviter_id' => $inviterId, // 记录邀请人
|
||||
]);
|
||||
|
||||
$this->performLogin($newUser, $ip);
|
||||
|
||||
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
|
||||
if ($inviterId) {
|
||||
\Illuminate\Support\Facades\Cookie::queue(\Illuminate\Support\Facades\Cookie::forget('inviter_id'));
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '注册并登录成功!']);
|
||||
}
|
||||
|
||||
@@ -123,9 +158,10 @@ class AuthController extends Controller
|
||||
|
||||
// 递增访问次数
|
||||
$user->increment('visit_num');
|
||||
|
||||
// 更新最后登录IP和时间
|
||||
|
||||
// 更新最后登录IP和时间(同时将旧IP转移到 previous_ip 作上次登录记录)
|
||||
$user->update([
|
||||
'previous_ip' => $user->last_ip,
|
||||
'last_ip' => $ip,
|
||||
'log_time' => now(),
|
||||
'in_time' => now(),
|
||||
@@ -137,6 +173,16 @@ class AuthController extends Controller
|
||||
'sdate' => now(),
|
||||
'uuname' => $user->username,
|
||||
]);
|
||||
|
||||
// 触发微信机器人消息推送 (登录上线类)
|
||||
try {
|
||||
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
|
||||
$wechatService->notifyAdminOnline($user);
|
||||
$wechatService->notifyFriendsOnline($user);
|
||||
$wechatService->notifySpouseOnline($user);
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('WechatBot presence notification failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +197,17 @@ class AuthController extends Controller
|
||||
'out_time' => now(),
|
||||
'out_info' => '正常退出了聊天室',
|
||||
]);
|
||||
|
||||
// [NEW] 同步清除该用户在所有房间的在线状态和心跳,确保其如果马上重登,能触发全新入场欢迎
|
||||
try {
|
||||
$chatState = app(\App\Services\ChatStateService::class);
|
||||
$roomIds = $chatState->getUserRooms($user->username);
|
||||
foreach ($roomIds as $roomId) {
|
||||
$chatState->userLeave($roomId, $user->username);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略清理缓存时发生的异常
|
||||
}
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐前台下注控制器
|
||||
*
|
||||
* 提供用户在聊天室内下注的 API 接口:
|
||||
* - 查询当前局次信息
|
||||
* - 提交下注(扣除金币 + 写入下注记录)
|
||||
* - 查询本人在当前局的下注状态
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BaccaratController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的局次信息(前端轮询或开局事件后调用)。
|
||||
*/
|
||||
public function currentRound(Request $request): JsonResponse
|
||||
{
|
||||
$round = BaccaratRound::currentRound();
|
||||
|
||||
if (! $round) {
|
||||
return response()->json(['round' => null]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$myBet = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'round' => [
|
||||
'id' => $round->id,
|
||||
'status' => $round->status,
|
||||
'bet_closes_at' => $round->bet_closes_at->toIso8601String(),
|
||||
'seconds_left' => max(0, (int) now()->diffInSeconds($round->bet_closes_at, false)),
|
||||
'total_bet_big' => $round->total_bet_big,
|
||||
'total_bet_small' => $round->total_bet_small,
|
||||
'total_bet_triple' => $round->total_bet_triple,
|
||||
'bet_count_big' => $round->bet_count_big,
|
||||
'bet_count_small' => $round->bet_count_small,
|
||||
'bet_count_triple' => $round->bet_count_triple,
|
||||
'my_bet' => $myBet ? [
|
||||
'bet_type' => $myBet->bet_type,
|
||||
'amount' => $myBet->amount,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提交下注。
|
||||
*
|
||||
* 同一局每人限下一注(后台强制幂等)。
|
||||
* 下注成功后立即扣除金币,结算时中奖者才返还本金+赔付。
|
||||
*/
|
||||
public function bet(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('baccarat')) {
|
||||
return response()->json(['ok' => false, 'message' => '百家乐游戏当前未开启。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'round_id' => 'required|integer|exists:baccarat_rounds,id',
|
||||
'bet_type' => 'required|in:big,small,triple',
|
||||
'amount' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
$maxBet = (int) ($config['max_bet'] ?? 50000);
|
||||
|
||||
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
|
||||
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
|
||||
}
|
||||
|
||||
$round = BaccaratRound::find($data['round_id']);
|
||||
|
||||
if (! $round || ! $round->isBettingOpen()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// 检查用户金币余额(金币字段为 jjb)
|
||||
if (($user->jjb ?? 0) < $data['amount']) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
|
||||
}
|
||||
|
||||
$currency = $this->currency;
|
||||
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
|
||||
// 幂等:同一局只能下一注
|
||||
$existing = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
|
||||
}
|
||||
|
||||
// 扣除金币
|
||||
$currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$data['amount'],
|
||||
CurrencySource::BACCARAT_BET,
|
||||
"百家乐 #{$round->id} 押 ".match ($data['bet_type']) {
|
||||
'big' => '大', 'small' => '小', default => '豹子'
|
||||
},
|
||||
);
|
||||
|
||||
// 写入下注记录
|
||||
BaccaratBet::create([
|
||||
'round_id' => $round->id,
|
||||
'user_id' => $user->id,
|
||||
'bet_type' => $data['bet_type'],
|
||||
'amount' => $data['amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 更新局次汇总统计
|
||||
$field = 'total_bet_'.$data['bet_type'];
|
||||
$countField = 'bet_count_'.$data['bet_type'];
|
||||
$round->increment($field, $data['amount']);
|
||||
$round->increment($countField);
|
||||
$round->increment('bet_count');
|
||||
|
||||
// 广播各选项的最新押注人数
|
||||
event(new \App\Events\BaccaratPoolUpdated($round));
|
||||
|
||||
$betLabel = match ($data['bet_type']) {
|
||||
'big' => '大', 'small' => '小', default => '豹子'
|
||||
};
|
||||
|
||||
// 发送系统传音到聊天室,公示该用户的押注信息
|
||||
$chatState = app(\App\Services\ChatStateService::class);
|
||||
$formattedAmount = number_format($data['amount']);
|
||||
$roomId = $round->room_id ?? 1;
|
||||
|
||||
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
|
||||
$content = "🌟 🎲 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$chatState->pushMessage($roomId, $msg);
|
||||
event(new \App\Events\MessageSent($roomId, $msg));
|
||||
\App\Jobs\SaveMessageJob::dispatch($msg);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已押注「{$betLabel}」{$data['amount']} 金币,等待开奖!",
|
||||
'amount' => $data['amount'],
|
||||
'bet_type' => $data['bet_type'],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近5局的历史记录(前端展示趋势)。
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$rounds = BaccaratRound::query()
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->get(['id', 'dice1', 'dice2', 'dice3', 'total_points', 'result', 'settled_at']);
|
||||
|
||||
return response()->json(['history' => $rounds]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:银行控制器
|
||||
*
|
||||
* 提供存款、取款、余额查询三个接口,金币在流通账户(jjb)
|
||||
* 与银行账户(bank_jjb)之间互转,所有操作记录到 bank_logs。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BankController extends Controller
|
||||
{
|
||||
/**
|
||||
* 查询银行余额及最近20条流水记录
|
||||
*/
|
||||
public function info(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$logs = BankLog::where('user_id', $user->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get(['type', 'amount', 'balance_after', 'created_at']);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'jjb' => $user->jjb ?? 0,
|
||||
'bank_jjb' => $user->bank_jjb ?? 0,
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询银行存款排行榜 (分页显示)
|
||||
*/
|
||||
public function ranking(Request $request): JsonResponse
|
||||
{
|
||||
$direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$users = \App\Models\User::where('bank_jjb', '>', 0)
|
||||
->orderBy('bank_jjb', $direction)
|
||||
->paginate(20, ['id', 'username', 'bank_jjb', 'sex', 'usersf', 'user_level']);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'ranking' => $users->map(function ($u) {
|
||||
// 提供必要的前端展示字段
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'bank_jjb' => $u->bank_jjb,
|
||||
'sex' => $u->sex,
|
||||
'usersf' => $u->usersf,
|
||||
'user_level' => $u->user_level,
|
||||
'headfaceUrl' => $u->headfaceUrl,
|
||||
];
|
||||
}),
|
||||
'pagination' => [
|
||||
'current_page' => $users->currentPage(),
|
||||
'last_page' => $users->lastPage(),
|
||||
'total' => $users->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 存款:从流通金币(jjb)转入银行(bank_jjb)
|
||||
*
|
||||
* 请求参数:amount(正整数)
|
||||
*/
|
||||
public function deposit(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'amount' => 'required|integer|min:1|max:9999999',
|
||||
]);
|
||||
|
||||
$amount = $request->integer('amount');
|
||||
$user = Auth::user();
|
||||
|
||||
if (($user->jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '流通金币不足!当前余额 '.($user->jjb ?? 0)." 枚,无法存入 {$amount} 枚。",
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
$user->decrement('jjb', $amount);
|
||||
$user->increment('bank_jjb', $amount);
|
||||
|
||||
BankLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $user->fresh()->bank_jjb,
|
||||
]);
|
||||
});
|
||||
|
||||
$fresh = $user->fresh();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "成功存入 {$amount} 枚金币!",
|
||||
'jjb' => $fresh->jjb,
|
||||
'bank_jjb' => $fresh->bank_jjb,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取款:从银行(bank_jjb)转回流通金币(jjb)
|
||||
*
|
||||
* 请求参数:amount(正整数)
|
||||
*/
|
||||
public function withdraw(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'amount' => 'required|integer|min:1|max:9999999',
|
||||
]);
|
||||
|
||||
$amount = $request->integer('amount');
|
||||
$user = Auth::user();
|
||||
|
||||
if (($user->bank_jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '银行余额不足!当前存款 '.($user->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。",
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $amount): void {
|
||||
$user->decrement('bank_jjb', $amount);
|
||||
$user->increment('jjb', $amount);
|
||||
|
||||
BankLog::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'balance_after' => $user->fresh()->bank_jjb,
|
||||
]);
|
||||
});
|
||||
|
||||
$fresh = $user->fresh();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "成功取出 {$amount} 枚金币!",
|
||||
'jjb' => $fresh->jjb,
|
||||
'bank_jjb' => $fresh->bank_jjb,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:开发日志前台控制器
|
||||
* 对应独立页面 /changelog,展示已发布的版本更新日志
|
||||
* 支持懒加载(IntersectionObserver + 游标分页)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DevChangelog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChangelogController extends Controller
|
||||
{
|
||||
/** 每次加载的条数 */
|
||||
private const PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 更新日志列表页(SSR首屏)
|
||||
* 预加载最新 10 条已发布日志
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$changelogs = DevChangelog::published()
|
||||
->limit(self::PAGE_SIZE)
|
||||
->get();
|
||||
|
||||
return view('changelog.index', compact('changelogs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载更多日志(JSON API)
|
||||
* 游标分页:传入已加载的最后一条 ID,返回更旧的 10 条
|
||||
*
|
||||
* @param Request $request 含 after_id 参数
|
||||
*/
|
||||
public function loadMoreChangelogs(Request $request): JsonResponse
|
||||
{
|
||||
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
|
||||
|
||||
$items = DevChangelog::published()
|
||||
->after($afterId)
|
||||
->limit(self::PAGE_SIZE)
|
||||
->get();
|
||||
|
||||
$data = $items->map(fn (DevChangelog $log) => [
|
||||
'id' => $log->id,
|
||||
'version' => $log->version,
|
||||
'title' => $log->title,
|
||||
'type_label' => $log->type_label,
|
||||
'type_color' => $log->type_color,
|
||||
'content_html' => $log->content_html,
|
||||
'summary' => $log->summary,
|
||||
'published_at' => $log->published_at?->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'items' => $data,
|
||||
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室内快速任命/撤销控制器
|
||||
* 供有职务的管理员在聊天室用户名片弹窗中快速任命或撤销目标用户的职务。
|
||||
* 权限校验委托给 AppointmentService,本控制器只做请求解析和返回 JSON。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ChatAppointmentController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入任命服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AppointmentService $appointmentService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取可用职务列表(供名片弹窗下拉选择)
|
||||
* 返回操作人有权限任命的职务
|
||||
*/
|
||||
public function positions(): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
|
||||
$operatorPosition = $operator->activePosition?->position;
|
||||
|
||||
$query = Position::query()
|
||||
->with('department')
|
||||
->orderByDesc('rank');
|
||||
|
||||
// 仅有具体职务(非 superlevel 直通)的操作人才限制 rank 范围
|
||||
if ($operatorPosition && $operator->user_level < $superLevel) {
|
||||
$query->where('rank', '<', $operatorPosition->rank);
|
||||
}
|
||||
|
||||
$positions = $query->get()->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'icon' => $p->icon,
|
||||
'rank' => $p->rank,
|
||||
'department' => $p->department?->name,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'success', 'positions' => $positions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速任命:将目标用户任命为指定职务
|
||||
* 成功后向操作人所在聊天室广播任命公告
|
||||
*/
|
||||
public function appoint(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string|exists:users,username',
|
||||
'position_id' => 'required|exists:positions,id',
|
||||
'remark' => 'nullable|string|max:100',
|
||||
'room_id' => 'nullable|integer|exists:rooms,id',
|
||||
]);
|
||||
|
||||
$operator = Auth::user();
|
||||
$target = User::where('username', $request->username)->firstOrFail();
|
||||
$position = Position::with('department')->findOrFail($request->position_id);
|
||||
|
||||
$result = $this->appointmentService->appoint($operator, $target, $position, $request->remark);
|
||||
|
||||
// 任命成功后广播礼花通知:优先用前端传来的 room_id,否则从 Redis 查操作人所在房间
|
||||
if ($result['ok']) {
|
||||
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
|
||||
if ($roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $target->username,
|
||||
positionIcon: $position->icon ?? '🎖️',
|
||||
positionName: $position->name,
|
||||
departmentName: $position->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $result['ok'] ? 'success' : 'error',
|
||||
'message' => $result['message'],
|
||||
], $result['ok'] ? 200 : 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速撤销:撤销目标用户当前的职务
|
||||
*/
|
||||
public function revoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string|exists:users,username',
|
||||
'remark' => 'nullable|string|max:100',
|
||||
'room_id' => 'nullable|integer|exists:rooms,id',
|
||||
]);
|
||||
|
||||
$operator = Auth::user();
|
||||
$target = User::where('username', $request->username)->firstOrFail();
|
||||
|
||||
// 撤销前先取目标当前职务信息(撤销后就查不到了)
|
||||
$activeUp = $target->activePosition?->load('position.department');
|
||||
$posIcon = $activeUp?->position?->icon ?? '🎖️';
|
||||
$posName = $activeUp?->position?->name ?? '';
|
||||
$deptName = $activeUp?->position?->department?->name ?? '';
|
||||
|
||||
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
|
||||
|
||||
// 撤销成功后广播通知到聊天室
|
||||
if ($result['ok'] && $posName) {
|
||||
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
|
||||
if ($roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $target->username,
|
||||
positionIcon: $posIcon,
|
||||
positionName: $posName,
|
||||
departmentName: $deptName,
|
||||
operatorName: $operator->username,
|
||||
type: 'revoke',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $result['ok'] ? 'success' : 'error',
|
||||
'message' => $result['message'],
|
||||
], $result['ok'] ? 200 : 422);
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,17 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\AiChatService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ChatBotController extends Controller
|
||||
{
|
||||
@@ -30,6 +33,7 @@ class ChatBotController extends Controller
|
||||
public function __construct(
|
||||
private readonly AiChatService $aiChat,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -58,36 +62,90 @@ class ChatBotController extends Controller
|
||||
], 403);
|
||||
}
|
||||
|
||||
$aiUser = \App\Models\User::where('username', 'AI小班长')->first();
|
||||
if ($aiUser) {
|
||||
$aiUser->increment('exp_num', 1);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$message = $request->input('message');
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
try {
|
||||
// 先广播用户的提问消息
|
||||
$userMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => $user->username,
|
||||
'to_user' => 'AI小班长',
|
||||
'content' => $message,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#000000',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $userMsg);
|
||||
broadcast(new MessageSent($roomId, $userMsg));
|
||||
SaveMessageJob::dispatch($userMsg);
|
||||
|
||||
$result = $this->aiChat->chat($user->id, $message, $roomId);
|
||||
|
||||
$reply = $result['reply'];
|
||||
|
||||
// 检查 AI 是否决定给用户发金币
|
||||
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
|
||||
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
|
||||
$reply = trim($reply);
|
||||
|
||||
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
||||
$dailyCount = (int) Redis::get($redisKey);
|
||||
|
||||
if ($dailyCount < $maxDailyRewards) {
|
||||
$goldAmount = rand(100, $maxGold);
|
||||
|
||||
if ($aiUser && $aiUser->jjb >= $goldAmount) {
|
||||
Redis::incr($redisKey);
|
||||
Redis::expire($redisKey, 86400); // 缓存 24 小时
|
||||
|
||||
// 真实扣除 AI 金币
|
||||
$this->currencyService->change(
|
||||
$aiUser,
|
||||
'gold',
|
||||
-$goldAmount,
|
||||
CurrencySource::GIFT_SENT,
|
||||
"赏赐给 {$user->username} 的金币福利",
|
||||
$roomId
|
||||
);
|
||||
|
||||
// 给用户发放金币
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$goldAmount,
|
||||
CurrencySource::AI_GIFT,
|
||||
'AI小班长发善心赠送的金币福利',
|
||||
$roomId
|
||||
);
|
||||
|
||||
// 发送全场大广播
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => $user->username,
|
||||
'content' => "🤖 听闻小萌新哭穷,本班长看你骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706', // 橙色醒目
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
SaveMessageJob::dispatch($sysMsg);
|
||||
} else {
|
||||
// 如果余额不足
|
||||
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
|
||||
}
|
||||
} else {
|
||||
// 如果已经领过了,修改回复提醒
|
||||
$reply .= "\n\n(系统提示:你今天已经领过金币福利啦,把机会留给其他人吧!)";
|
||||
}
|
||||
}
|
||||
|
||||
// 广播 AI 回复消息
|
||||
$botMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => $user->username,
|
||||
'content' => $result['reply'],
|
||||
'content' => $reply,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
|
||||
@@ -11,31 +11,47 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserJoined;
|
||||
use App\Events\UserLeft;
|
||||
use App\Http\Requests\SendMessageRequest;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Autoact;
|
||||
use App\Models\FriendRequest;
|
||||
use App\Models\Gift;
|
||||
use App\Models\PositionDutyLog;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\MessageFilterService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造聊天室核心控制器所需依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly MessageFilterService $filter,
|
||||
private readonly VipService $vipService,
|
||||
private readonly \App\Services\ShopService $shopService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly AppointmentService $appointmentService,
|
||||
private readonly RoomBroadcastService $broadcast,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,13 +67,38 @@ class ChatController extends Controller
|
||||
|
||||
// 房间人气 +1(每次访问递增,复刻原版人气计数)
|
||||
$room->increment('visit_num');
|
||||
|
||||
|
||||
// 用户进房时间刷新
|
||||
$user->update(['in_time' => now()]);
|
||||
|
||||
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
|
||||
// 0. 判断是否已经是当前房间的在线状态
|
||||
$hasKey = $this->chatState->isUserInRoom($id, $user->username);
|
||||
// 增强校验:判断心跳是否还存在。如果遇到没有启动队列任务的情况,离线任务未能清理脏数据,心跳必定过期。
|
||||
$isHeartbeatAlive = (bool) \Illuminate\Support\Facades\Redis::exists("room:{$id}:alive:{$user->username}");
|
||||
|
||||
// 如果虽然在名单里,但心跳早已丢失(可能直接关浏览器且队列未跑),视为全新进房
|
||||
if ($hasKey && ! $isHeartbeatAlive) {
|
||||
$this->chatState->userLeave($id, $user->username); // 强制洗净状态
|
||||
$hasKey = false;
|
||||
}
|
||||
|
||||
$isAlreadyInRoom = $hasKey;
|
||||
|
||||
// 1. 先将用户从其他所有房间的在线名单中移除(切换房间时旧记录自动清理)
|
||||
// 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题
|
||||
$oldRoomIds = $this->chatState->getUserRooms($user->username);
|
||||
foreach ($oldRoomIds as $oldRoomId) {
|
||||
if ($oldRoomId !== $id) {
|
||||
$this->chatState->userLeave($oldRoomId, $user->username);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
// 获取当前在职职务信息(用于内容显示)
|
||||
$activePosition = $user->activePosition;
|
||||
$userData = [
|
||||
'user_id' => $user->id,
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface,
|
||||
@@ -65,43 +106,224 @@ class ChatController extends Controller
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
'position_icon' => $activePosition?->position?->icon ?? '',
|
||||
'position_name' => $activePosition?->position?->name ?? '',
|
||||
];
|
||||
$this->chatState->userJoin($id, $user->username, $userData);
|
||||
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
|
||||
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
|
||||
|
||||
// 2. 广播 UserJoined 事件,通知房间内的其他人
|
||||
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
||||
// 3. 广播和初始化欢迎(仅限初次进入)
|
||||
$newbieEffect = null;
|
||||
$initialPresenceTheme = null;
|
||||
$initialWelcomeMessage = null;
|
||||
|
||||
// 3. 管理员(superlevel)进入时:触发全房间烟花特效 + 公屏欢迎公告
|
||||
if ($user->user_level >= $superLevel) {
|
||||
// 广播烟花特效给所有在线用户
|
||||
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
|
||||
if (! $isAlreadyInRoom) {
|
||||
// 广播 UserJoined 事件,通知房间内的其他人
|
||||
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
||||
|
||||
// 发送欢迎公告消息(使用系统公告样式)
|
||||
$welcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "🎉 欢迎管理员 <b>{$user->username}</b> 驾临本聊天室!请各位文明聊天!",
|
||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
|
||||
if (! $user->has_received_new_gift) {
|
||||
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
|
||||
);
|
||||
$user->update(['has_received_new_gift' => true]);
|
||||
|
||||
// 发送新人专属欢迎公告
|
||||
$newbieMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'welcome_user' => $user->username,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $newbieMsg);
|
||||
broadcast(new MessageSent($id, $newbieMsg));
|
||||
|
||||
// 广播烟花特效给此时已在房间的其他用户
|
||||
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
|
||||
|
||||
// 传给前端,让新人自己的屏幕上也燃放烟花
|
||||
$newbieEffect = 'fireworks';
|
||||
}
|
||||
|
||||
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
|
||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||
|
||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
||||
|
||||
$generalWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '进出播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'font_color' => $color,
|
||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $welcomeMsg);
|
||||
broadcast(new MessageSent($id, $welcomeMsg));
|
||||
|
||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||
if (! empty($vipPresencePayload)) {
|
||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||
$initialPresenceTheme = $vipPresencePayload;
|
||||
}
|
||||
|
||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||
|
||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||||
|
||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 获取历史消息用于初次渲染
|
||||
// TODO: 可在前端通过请求另外的接口拉取历史记录,或者直接在这里 attach
|
||||
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
|
||||
// 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
|
||||
$allHistory = $this->chatState->getNewMessages($id, 0);
|
||||
$username = $user->username;
|
||||
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
|
||||
$toUser = $msg['to_user'] ?? '';
|
||||
$fromUser = $msg['from_user'] ?? '';
|
||||
$isSecret = ! empty($msg['is_secret']);
|
||||
|
||||
// 公众发言(对大家说):所有人都可以看到
|
||||
if ($toUser === '大家' || $toUser === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 私信 / 悄悄话:只显示发给自己或自己发出的
|
||||
if ($isSecret) {
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
}
|
||||
|
||||
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
}));
|
||||
|
||||
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
|
||||
if (! $isAlreadyInRoom) {
|
||||
$activeUP = $user->activePosition;
|
||||
if ($activeUP) {
|
||||
PositionDutyLog::create([
|
||||
'user_id' => $user->id,
|
||||
'user_position_id' => $activeUP->id,
|
||||
'login_at' => now(),
|
||||
'ip_address' => request()->ip(),
|
||||
'room_id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
// 8. 好友上线通知:向此房间内在线的好友推送慧慧话
|
||||
$this->notifyFriendsOnline($id, $user->username);
|
||||
}
|
||||
|
||||
// 9. 检查是否有未处理的求婚
|
||||
$pendingProposal = \App\Models\Marriage::with(['user', 'ringItem'])
|
||||
->where('partner_id', $user->id)
|
||||
->where('status', 'pending')
|
||||
->first();
|
||||
|
||||
$pendingProposalData = null;
|
||||
if ($pendingProposal) {
|
||||
$pendingProposalData = [
|
||||
'marriage_id' => $pendingProposal->id,
|
||||
'proposer_name' => $pendingProposal->user?->username ?? '',
|
||||
'ring_name' => $pendingProposal->ringItem?->name ?? '',
|
||||
'ring_icon' => $pendingProposal->ringItem?->icon ?? '',
|
||||
'expires_at' => $pendingProposal->expires_at?->diffForHumans() ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// 10. 检查是否有未处理的协议离婚请求(对方发起的)
|
||||
$pendingDivorce = \App\Models\Marriage::with(['user', 'partner'])
|
||||
->where('status', 'married')
|
||||
->where('divorce_type', 'mutual')
|
||||
->whereNotNull('divorcer_id')
|
||||
->where('divorcer_id', '!=', $user->id)
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id)->orWhere('partner_id', $user->id);
|
||||
})
|
||||
->first();
|
||||
|
||||
$pendingDivorceData = null;
|
||||
if ($pendingDivorce) {
|
||||
$initiator = $pendingDivorce->user_id === $pendingDivorce->divorcer_id ? $pendingDivorce->user : $pendingDivorce->partner;
|
||||
$pendingDivorceData = [
|
||||
'marriage_id' => $pendingDivorce->id,
|
||||
'initiator_name' => $initiator?->username ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// 渲染主聊天框架视图
|
||||
return view('chat.frame', [
|
||||
'room' => $room,
|
||||
'user' => $user,
|
||||
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
||||
'newbieEffect' => $newbieEffect,
|
||||
'initialPresenceTheme' => $initialPresenceTheme,
|
||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||
'historyMessages' => $historyMessages,
|
||||
'pendingProposal' => $pendingProposalData,
|
||||
'pendingDivorce' => $pendingDivorceData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
|
||||
*
|
||||
* @param int $roomId 当前房间 ID
|
||||
* @param string $username 上线的用户名
|
||||
*/
|
||||
private function notifyFriendsOnline(int $roomId, string $username): void
|
||||
{
|
||||
// 获取所有把我加为好友的人(他们是将我加为好友的关注者)
|
||||
$friendUsernames = FriendRequest::where('towho', $username)->pluck('who');
|
||||
if ($friendUsernames->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当前房间在线用户列表
|
||||
$onlineUsers = $this->chatState->getRoomUsers($roomId);
|
||||
|
||||
foreach ($friendUsernames as $friendName) {
|
||||
// 好友就在这个房间里,才发通知
|
||||
if (! isset($onlineUsers[$friendName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $friendName,
|
||||
'content' => "🟢 你的好友 <b>{$username}</b> 上线啊!",
|
||||
'is_secret' => true,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息 (等同于原版 NEWSAY.ASP)
|
||||
*
|
||||
@@ -124,6 +346,20 @@ class ChatController extends Controller
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 0.5 检查接收方是否在线(防幽灵消息)
|
||||
$toUser = $data['to_user'] ?? '大家';
|
||||
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '系统播报', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
|
||||
// Redis 保存的在线列表
|
||||
$isOnline = Redis::hexists("room:{$id}:users", $toUser);
|
||||
if (! $isOnline) {
|
||||
// 使用 200 状态码,避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "【{$toUser}】目前已离开聊天室或不在线,消息未发出。",
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 过滤净化消息体
|
||||
$pureContent = $this->filter->filter($data['content'] ?? '');
|
||||
if (empty($pureContent)) {
|
||||
@@ -210,21 +446,16 @@ class ChatController extends Controller
|
||||
// 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级
|
||||
// 管理员(superlevel 及以上)不参与自动升降级,等级由后台手动设置
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$oldLevel = $user->user_level;
|
||||
$leveledUp = false;
|
||||
|
||||
if ($oldLevel < $superLevel) {
|
||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
|
||||
$user->user_level = $newLevel;
|
||||
$leveledUp = ($newLevel > $oldLevel);
|
||||
}
|
||||
}
|
||||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||||
|
||||
$user->save(); // 存点入库
|
||||
|
||||
// 手动心跳存点:同步更新在职用户的勤务时长
|
||||
$this->tickDutyLog($user, $id);
|
||||
|
||||
// 3. 将新的等级反馈给当前用户的在线名单上
|
||||
// 确保刚刚升级后别人查看到的也是最准确等级
|
||||
$activePosition = $user->activePosition;
|
||||
$this->chatState->userJoin($id, $user->username, [
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
@@ -233,6 +464,8 @@ class ChatController extends Controller
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
'position_icon' => $activePosition?->position?->icon ?? '',
|
||||
'position_name' => $activePosition?->position?->name ?? '',
|
||||
]);
|
||||
|
||||
// 4. 如果突破境界,向全房系统喊话广播!
|
||||
@@ -263,22 +496,61 @@ class ChatController extends Controller
|
||||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||||
$autoEvent = Autoact::randomEvent();
|
||||
if ($autoEvent) {
|
||||
// 应用经验/金币变化(不低于 0)
|
||||
if ($autoEvent->exp_change !== 0) {
|
||||
$user->exp_num = max(0, $user->exp_num + $autoEvent->exp_change);
|
||||
// 计算会员倍率加成(仅正向奖励有效)
|
||||
$expMul = $this->vipService->getExpMultiplier($user);
|
||||
$jjbMul = $this->vipService->getJjbMultiplier($user);
|
||||
|
||||
$finalExp = $autoEvent->exp_change > 0 ? (int) round($autoEvent->exp_change * $expMul) : $autoEvent->exp_change;
|
||||
$finalJjb = $autoEvent->jjb_change > 0 ? (int) round($autoEvent->jjb_change * $jjbMul) : $autoEvent->jjb_change;
|
||||
|
||||
$bonusExp = ($autoEvent->exp_change > 0 && $finalExp > $autoEvent->exp_change) ? $finalExp - $autoEvent->exp_change : 0;
|
||||
$bonusJjb = ($autoEvent->jjb_change > 0 && $finalJjb > $autoEvent->jjb_change) ? $finalJjb - $autoEvent->jjb_change : 0;
|
||||
|
||||
// 经验变化:通过 UserCurrencyService 写日志
|
||||
if ($finalExp !== 0) {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'exp',
|
||||
$finalExp,
|
||||
CurrencySource::AUTO_EVENT,
|
||||
"随机事件:{$autoEvent->text_body}",
|
||||
$id,
|
||||
);
|
||||
}
|
||||
if ($autoEvent->jjb_change !== 0) {
|
||||
$user->jjb = max(0, ($user->jjb ?? 0) + $autoEvent->jjb_change);
|
||||
|
||||
// 金币变化:通过 UserCurrencyService 写日志
|
||||
if ($finalJjb !== 0) {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$finalJjb,
|
||||
CurrencySource::AUTO_EVENT,
|
||||
"随机事件:{$autoEvent->text_body}",
|
||||
$id,
|
||||
);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
// 重新从数据库读取最新属性(service 已原子更新,需刷新本地对象)
|
||||
$user->refresh();
|
||||
|
||||
// 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级)
|
||||
if ($user->user_level < $superLevel) {
|
||||
$recalcLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
if ($recalcLevel !== $user->user_level && $recalcLevel < $superLevel) {
|
||||
$user->user_level = $recalcLevel;
|
||||
$user->save();
|
||||
}
|
||||
if ($this->calculateNewLevel($user, $superLevel)) {
|
||||
$leveledUp = true; // 随机事件触发了升级,补充标记以便广播
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 构建会员额外加成文案(参考钓鱼系统,确保不弄错)
|
||||
$bonusParts = [];
|
||||
if ($bonusExp > 0) {
|
||||
$bonusParts[] = "+经验{$bonusExp}";
|
||||
}
|
||||
if ($bonusJjb > 0) {
|
||||
$bonusParts[] = "+金币{$bonusJjb}";
|
||||
}
|
||||
|
||||
$eventContent = $autoEvent->renderText($user->username);
|
||||
if (! empty($bonusParts)) {
|
||||
$eventContent .= '('.$user->vipName().'追加:'.implode(',', $bonusParts).')';
|
||||
}
|
||||
|
||||
// 广播随机事件消息到聊天室
|
||||
@@ -287,12 +559,12 @@ class ChatController extends Controller
|
||||
'room_id' => $id,
|
||||
'from_user' => '星海小博士',
|
||||
'to_user' => '大家',
|
||||
'content' => $autoEvent->renderText($user->username),
|
||||
'content' => $eventContent,
|
||||
'is_secret' => false,
|
||||
'font_color' => match ($autoEvent->event_type) {
|
||||
'good' => '#16a34a', // 绿色(好运)
|
||||
'bad' => '#dc2626', // 红色(坏运)
|
||||
default => '#7c3aed', // 紫色(中性)
|
||||
'bad' => '#dc2626', // 红色(坏运)
|
||||
default => '#7c3aed', // 紫色(中性)
|
||||
},
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
@@ -306,10 +578,10 @@ class ChatController extends Controller
|
||||
|
||||
// 确定用户称号:管理员 > VIP 名称 > 普通会员
|
||||
$title = '普通会员';
|
||||
if ($user->user_level >= $superLevel) {
|
||||
$title = '管理员';
|
||||
} elseif ($user->isVip()) {
|
||||
if ($user->isVip()) {
|
||||
$title = $user->vipName() ?: '会员';
|
||||
} elseif ($user->user_level >= $superLevel) {
|
||||
$title = '管理员';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -328,6 +600,32 @@ class ChatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
|
||||
*
|
||||
* 使用 ChatStateService::getRoomUsers() 保证与名单逻辑完全一致。
|
||||
* 返回 [{ id, name, online, permit_level, door_open }] 数组。
|
||||
*/
|
||||
public function roomsOnlineStatus(): JsonResponse
|
||||
{
|
||||
$rooms = Room::orderBy('id')->get(['id', 'room_name', 'permit_level', 'door_open']);
|
||||
|
||||
$data = $rooms->map(function (Room $room) {
|
||||
// 与名单/心跳使用完全相同的方式读取在线人数
|
||||
$onlineCount = count($this->chatState->getRoomUsers($room->id));
|
||||
|
||||
return [
|
||||
'id' => $room->id,
|
||||
'name' => $room->room_name,
|
||||
'online' => $onlineCount,
|
||||
'permit_level' => $room->permit_level ?? 0,
|
||||
'door_open' => (bool) $room->door_open,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['rooms' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开房间 (等同于原版 LEAVE.ASP)
|
||||
*
|
||||
@@ -340,17 +638,23 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error'], 401);
|
||||
}
|
||||
|
||||
// 1. 从 Redis 删除该用户
|
||||
$this->chatState->userLeave($id, $user->username);
|
||||
$leaveTime = microtime(true);
|
||||
$isExplicit = strval($request->query('explicit')) === '1';
|
||||
|
||||
// 记录退出时间和退出信息
|
||||
$user->update([
|
||||
'out_time' => now(),
|
||||
'out_info' => "正常退出了房间",
|
||||
]);
|
||||
if ($isExplicit) {
|
||||
// 人工显式点击“离开”,不再进行浏览器刷新的防抖,直接同步执行清算和播报。
|
||||
// 这对本地没有开启 Queue Worker 的环境尤为重要,能保证大家立刻看到消息。
|
||||
// 为了防止 ProcessUserLeave 中的时间对比失败,我们直接删掉 join_time 表示彻底离线。
|
||||
\Illuminate\Support\Facades\Redis::del("room:{$id}:join_time:{$user->username}");
|
||||
|
||||
// 2. 广播通知他人
|
||||
broadcast(new UserLeft($id, $user->username))->toOthers();
|
||||
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, $leaveTime);
|
||||
dispatch_sync($job);
|
||||
} else {
|
||||
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
|
||||
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime)
|
||||
// Job 中就不会执行完整的离线播报和注销流程
|
||||
\App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3));
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
@@ -398,9 +702,12 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
|
||||
}
|
||||
|
||||
// 更新用户头像
|
||||
$user->usersf = $headface;
|
||||
$user->save();
|
||||
// 更新前如为自定义头像,将其从磁盘删除,节约空间
|
||||
if ($user->usersf !== $headface) {
|
||||
$user->deleteCustomAvatar();
|
||||
$user->usersf = $headface;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 将新头像同步到 Redis 在线用户列表中(所有房间)
|
||||
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
|
||||
@@ -425,6 +732,76 @@ class ChatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传自定义头像
|
||||
*/
|
||||
public function uploadAvatar(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:6144',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '未登录'], 401);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
|
||||
try {
|
||||
$manager = new ImageManager(new Driver);
|
||||
|
||||
// 生成相对路径
|
||||
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
|
||||
$originalFilename = 'custom_'.$user->id.'_'.time().'_original.'.$file->extension();
|
||||
$path = 'avatars/'.$filename;
|
||||
$originalPath = 'avatars/'.$originalFilename;
|
||||
|
||||
// 1. 处理原图:限制最大宽度为 1280 以免过大,保存原比例高清大图
|
||||
$originalImage = $manager->read($file);
|
||||
$originalImage->scaleDown(width: 1280);
|
||||
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
|
||||
|
||||
// 2. 处理缩略图:裁剪正方形并压缩为 112x112
|
||||
$thumbImage = $manager->read($file);
|
||||
$thumbImage->cover(112, 112);
|
||||
Storage::disk('public')->put($path, (string) $thumbImage->encode());
|
||||
|
||||
$dbValue = 'storage/'.$path;
|
||||
|
||||
// 更新前如为自定义头像,将其从磁盘删除,节约空间
|
||||
if ($user->usersf !== $dbValue) {
|
||||
$user->deleteCustomAvatar();
|
||||
$user->usersf = $dbValue;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 同步 Redis 状态
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$rooms = $this->chatState->getUserRooms($user->username);
|
||||
foreach ($rooms as $roomId) {
|
||||
$this->chatState->userJoin((int) $roomId, $user->username, [
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface, // Use accessor
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '头像上传成功!',
|
||||
'headface' => $user->headface,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
|
||||
* 需要房间主人或等级达到 level_announcement 配置值
|
||||
@@ -446,7 +823,9 @@ class ChatController extends Controller
|
||||
'announcement' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$room->announcement = $request->input('announcement');
|
||||
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
|
||||
$room->announcement = trim($request->input('announcement'))
|
||||
.' ——'.$user->username.' '.now()->format('m-d H:i');
|
||||
$room->save();
|
||||
|
||||
// 广播公告更新到所有在线用户
|
||||
@@ -542,7 +921,7 @@ class ChatController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '送花播报',
|
||||
'to_user' => $toUsername,
|
||||
'content' => "{$gift->emoji} {$user->username} 向 {$toUsername} 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!",
|
||||
'content' => "{$gift->emoji} 【{$user->username}】 向 【{$toUsername}】 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#e91e8f',
|
||||
'action' => '',
|
||||
@@ -578,7 +957,7 @@ class ChatController extends Controller
|
||||
private function grantChatCharm(mixed $sender, string $toUsername): void
|
||||
{
|
||||
// 系统用户不参与魅力计算
|
||||
$systemNames = ['大家', '系统传音', '系统公告', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
|
||||
$systemNames = ['大家', '系统传音', '系统公告', '系统播报', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报'];
|
||||
if (in_array($toUsername, $systemNames)) {
|
||||
return;
|
||||
}
|
||||
@@ -648,4 +1027,217 @@ class ChatController extends Controller
|
||||
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
|
||||
* 在用户退出房间或心跳超时时调用
|
||||
*
|
||||
* @param int $userId 用户 ID
|
||||
*/
|
||||
private function closeDutyLog(int $userId): void
|
||||
{
|
||||
// 将今日开放日志关闭并结算实际时长
|
||||
PositionDutyLog::query()
|
||||
->where('user_id', $userId)
|
||||
->whereNull('logout_at')
|
||||
->whereDate('login_at', today())
|
||||
->update([
|
||||
'logout_at' => now(),
|
||||
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
|
||||
]);
|
||||
|
||||
// 关闭历史遗留的跨天未关闭日志(login_at 非今日)
|
||||
// 保留最后一次心跳刷新的 duration_seconds,确保已积累时长不丢失
|
||||
PositionDutyLog::query()
|
||||
->where('user_id', $userId)
|
||||
->whereNull('logout_at')
|
||||
->whereDate('login_at', '<', today())
|
||||
->update([
|
||||
'logout_at' => DB::raw('login_at + INTERVAL duration_seconds SECOND'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 存点时同步更新或创建在职用户的勤务日志。
|
||||
*
|
||||
* 逻辑:
|
||||
* 1. 用户无在职职务 → 跳过
|
||||
* 2. 今日已有开放日志(无 logout_at)→ 刷新 duration_seconds(实时时长)
|
||||
* 3. 今日无任何日志 → 新建,login_at 取 user->in_time(进房时间),保证时长不丢失
|
||||
*
|
||||
* @param \App\Models\User $user 当前用户(必须已 fresh/refresh)
|
||||
* @param int $roomId 所在房间 ID
|
||||
*/
|
||||
private function tickDutyLog(User $user, int $roomId): void
|
||||
{
|
||||
// 无论有无职务,均记录在线流水
|
||||
$activeUP = $user->activePosition;
|
||||
|
||||
// ① 优先找今日未关闭的开放日志,直接刷新时长
|
||||
$openLog = PositionDutyLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('logout_at')
|
||||
->whereDate('login_at', today())
|
||||
->first();
|
||||
|
||||
if ($openLog) {
|
||||
DB::table('position_duty_logs')
|
||||
->where('id', $openLog->id)
|
||||
->update([
|
||||
'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'),
|
||||
// DB::table raw update 不自动刷 updated_at,必须手动设置,
|
||||
// 否则 CloseStaleDutyLogs 会误判此 session 为掉线而提前关闭。
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 若今日已有「已关闭」的日志段,说明是 CloseStaleDutyLogs 关闭后重建:
|
||||
// 必须用 now() 作为 login_at,防止重用旧的 in_time(如今日 00:00)导致
|
||||
// 每次重建的 duration_seconds 都从午夜算起,累加成等差数列(产生 249h 等异常值)。
|
||||
// 只有今日首次创建(无任何历史日志段)时,才用 in_time 保留真实进房时刻。
|
||||
$hasClosedToday = PositionDutyLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('login_at', today())
|
||||
->whereNotNull('logout_at')
|
||||
->exists();
|
||||
|
||||
$loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday())
|
||||
? $user->in_time
|
||||
: now();
|
||||
|
||||
PositionDutyLog::create([
|
||||
'user_id' => $user->id,
|
||||
'user_position_id' => $activeUP?->id,
|
||||
'login_at' => $loginAt,
|
||||
'ip_address' => request()->ip(),
|
||||
'room_id' => $roomId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据经验值重新计算用户等级,申升减级均会直接修改 $user->user_level。
|
||||
*
|
||||
* PHP 对象引用传递,方法内对 $user 的修改会直接反映到调用方。
|
||||
* 本方法不负责 save(),由调用方决定何时落库。
|
||||
*
|
||||
* @param \App\Models\User $user 当前用户模型
|
||||
* @param int $superLevel 管理员等级阈值(达到后不参与自动升降级)
|
||||
* @return bool 是否发生了升级(true = 等级提升)
|
||||
*/
|
||||
private function calculateNewLevel(\App\Models\User $user, int $superLevel): bool
|
||||
{
|
||||
// 管理员等级由后台手动维护,不参与自动升降级
|
||||
if ($user->user_level >= $superLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||
|
||||
// 等级无变化,或计算结果达到管理员阈值(异常情况),均跳过
|
||||
if ($newLevel === $user->user_level || $newLevel >= $superLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isLeveledUp = $newLevel > $user->user_level;
|
||||
|
||||
// 在职职务成员:等级保护逻辑
|
||||
$activeUP = $user->activePosition;
|
||||
if ($activeUP) {
|
||||
$positionLevel = $activeUP->position->level ?? 0;
|
||||
|
||||
// 职务要求高于当前等级 → 强制补级到职务最低要求
|
||||
if ($positionLevel > $user->user_level) {
|
||||
$user->user_level = $positionLevel;
|
||||
|
||||
return true; // 等级提升,调用方需保存并广播
|
||||
}
|
||||
|
||||
// 降级 且 降后等级低于职务要求 → 阻止
|
||||
if (! $isLeveledUp && $newLevel < $positionLevel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PHP 对象引用传递,这里对 $user->user_level 的修改将直接反映到调用方
|
||||
$user->user_level = $newLevel;
|
||||
|
||||
return $isLeveledUp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户间赠送金币(任何登录用户均可调用)
|
||||
*
|
||||
* 从自己的余额中扣除指定金额,转入对方账户,
|
||||
* 并在房间内通过「系统传音」广播一条赠送提示。
|
||||
*/
|
||||
public function giftGold(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'to_user' => 'required|string',
|
||||
'room_id' => 'required|integer',
|
||||
'amount' => 'required|integer|min:1|max:999999999',
|
||||
], [
|
||||
'amount.max' => '单次赠送金币不能超过 999999999',
|
||||
'amount.min' => '单次赠送金币至少为 1',
|
||||
'amount.integer' => '金币数量必须是整数',
|
||||
'amount.required' => '请输入要赠送的金币数量',
|
||||
]);
|
||||
|
||||
$sender = Auth::user();
|
||||
$toName = $request->input('to_user');
|
||||
$roomId = $request->integer('room_id');
|
||||
$amount = $request->integer('amount');
|
||||
|
||||
// 不能给自己转账
|
||||
if ($toName === $sender->username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
|
||||
}
|
||||
|
||||
// 查目标用户
|
||||
$receiver = User::where('username', $toName)->first();
|
||||
if (! $receiver) {
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在']);
|
||||
}
|
||||
|
||||
// 余额校验
|
||||
if (($sender->jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '金币不足!您当前余额 '.($sender->jjb ?? 0)." 金币,无法赠送 {$amount} 金币。",
|
||||
]);
|
||||
}
|
||||
|
||||
// 执行转账(直接操作字段,与 sendFlower 保持一致风格)
|
||||
$sender->decrement('jjb', $amount);
|
||||
$receiver->increment('jjb', $amount);
|
||||
|
||||
// 广播一条消息:发送者/接收者路由到 say2(下方包厢),其他人路由到 say1(公屏)
|
||||
// 原理:前端 isRelatedToMe = isMe || to_user===me → say2;否则 → say1
|
||||
$giftMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => $sender->username,
|
||||
'to_user' => $toName,
|
||||
'content' => "悄悄赠送给你 {$amount} 金币!💝",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b45309',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $giftMsg);
|
||||
broadcast(new MessageSent($roomId, $giftMsg));
|
||||
SaveMessageJob::dispatch($giftMsg);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "赠送成功!已向 {$toName} 赠送 {$amount} 金币。",
|
||||
'data' => [
|
||||
'my_jjb' => $sender->fresh()->jjb,
|
||||
'target_jjb' => $receiver->fresh()->jjb,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:勤务台页面控制器
|
||||
* 左侧五个子菜单:任职列表、日榜、周榜、月榜、总榜
|
||||
* 路由:GET /duty-hall?tab=roster|day|week|month|all
|
||||
*
|
||||
* 榜单统计三项指标:
|
||||
* 1. 在线时长 — position_duty_logs.duration_seconds 合计
|
||||
* 2. 管理操作次数 — position_authority_logs 非任免类操作次数(warn/kick/mute/banip/other)
|
||||
* 3. 奖励金币次数 — position_authority_logs WHERE action_type='reward' 的次数及累计金额
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.2.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\PositionAuthorityLog;
|
||||
use App\Models\PositionDutyLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DutyHallController extends Controller
|
||||
{
|
||||
/**
|
||||
* 勤务台主页(根据 tab 切换内容)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tab = $request->input('tab', 'roster');
|
||||
|
||||
// ── 任职列表:按部门→职务展示全部(含空缺) ──────────────────
|
||||
$currentStaff = null;
|
||||
if ($tab === 'roster') {
|
||||
$currentStaff = Department::query()
|
||||
->with([
|
||||
'positions' => fn ($q) => $q->orderByDesc('rank'),
|
||||
'positions.activeUserPositions.user',
|
||||
])
|
||||
->orderByDesc('rank')
|
||||
->get();
|
||||
}
|
||||
|
||||
// ── 日/周/月/总榜:三项指标综合排行 ─────────────────────────
|
||||
$leaderboard = null;
|
||||
if (in_array($tab, ['day', 'week', 'month', 'all'])) {
|
||||
|
||||
// ① 在线时长(position_duty_logs)
|
||||
$dutyQuery = PositionDutyLog::query()
|
||||
->selectRaw('user_id, SUM(duration_seconds) as total_seconds, COUNT(*) as checkin_count')
|
||||
// 只统计有离开时间的已完结记录,open session 不计入(防止实时计算偏差)
|
||||
->whereNotNull('logout_at');
|
||||
|
||||
// ② 管理操作(position_authority_logs,排除任命/撤销等人事操作)
|
||||
$authQuery = PositionAuthorityLog::query()
|
||||
->selectRaw('
|
||||
user_id,
|
||||
COUNT(*) as admin_count,
|
||||
SUM(CASE WHEN action_type = \'reward\' THEN 1 ELSE 0 END) as reward_count,
|
||||
SUM(CASE WHEN action_type = \'reward\' THEN COALESCE(amount, 0) ELSE 0 END) as reward_total
|
||||
')
|
||||
->whereNotIn('action_type', ['appoint', 'revoke']);
|
||||
|
||||
// 按时间段同步过滤两张表
|
||||
match ($tab) {
|
||||
'day' => [
|
||||
$dutyQuery->whereDate('login_at', today()),
|
||||
$authQuery->whereDate('created_at', today()),
|
||||
],
|
||||
'week' => [
|
||||
$dutyQuery->whereBetween('login_at', [now()->startOfWeek(), now()->endOfWeek()]),
|
||||
$authQuery->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]),
|
||||
],
|
||||
'month' => [
|
||||
$dutyQuery->whereYear('login_at', now()->year)->whereMonth('login_at', now()->month),
|
||||
$authQuery->whereYear('created_at', now()->year)->whereMonth('created_at', now()->month),
|
||||
],
|
||||
'all' => null, // 不限制时间
|
||||
};
|
||||
|
||||
// 执行查询
|
||||
$dutyRows = $dutyQuery
|
||||
->groupBy('user_id')
|
||||
->orderByDesc('total_seconds')
|
||||
->limit(20)
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
// 管理操作数据(按 user_id 索引,方便后续合并)
|
||||
$authMap = $authQuery
|
||||
->groupBy('user_id')
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
// 合并两表数据:为每条勤务记录附加管理操作指标
|
||||
$leaderboard = $dutyRows->map(function ($row) use ($authMap) {
|
||||
$auth = $authMap->get($row->user_id);
|
||||
$row->admin_count = (int) ($auth?->admin_count ?? 0);
|
||||
$row->reward_count = (int) ($auth?->reward_count ?? 0);
|
||||
$row->reward_total = (int) ($auth?->reward_total ?? 0);
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
// 各榜标签配置
|
||||
$tabs = [
|
||||
'roster' => ['label' => '任职列表', 'icon' => '🏛️'],
|
||||
'day' => ['label' => '日榜', 'icon' => '☀️'],
|
||||
'week' => ['label' => '周榜', 'icon' => '📆'],
|
||||
'month' => ['label' => '月榜', 'icon' => '🗓️'],
|
||||
'all' => ['label' => '总榜', 'icon' => '🏆'],
|
||||
];
|
||||
|
||||
return view('duty-hall.index', compact(
|
||||
'tab',
|
||||
'tabs',
|
||||
'currentStaff',
|
||||
'leaderboard',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 文件功能:用户赚取金币与经验(观看视频广告等任务)控制器
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
class EarnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @var int 每日观看最大次数限制
|
||||
*/
|
||||
private int $maxDailyLimit = 3;
|
||||
|
||||
/**
|
||||
* @var int 每次领奖后至少需要的冷却时间(秒)
|
||||
*/
|
||||
private int $cooldownSeconds = 5;
|
||||
|
||||
/**
|
||||
* 申领看视频的奖励
|
||||
* 成功看完视频后前端发起此请求。
|
||||
* 为防止刷包,必须加上每日总次数及短时频率限制。
|
||||
*/
|
||||
public function claimVideoReward(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$userId = $user->id;
|
||||
$dateKey = now()->format('Y-m-d');
|
||||
|
||||
$dailyCountKey = "earn_video:count:{$userId}:{$dateKey}";
|
||||
$cooldownKey = "earn_video:cooldown:{$userId}";
|
||||
|
||||
// 1. 检查冷却时间
|
||||
if (Redis::exists($cooldownKey)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '操作过快,请稍后再试。',
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 检查每日最大次数
|
||||
$todayCount = (int) Redis::get($dailyCountKey);
|
||||
if ($todayCount >= $this->maxDailyLimit) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "今日视频收益次数已达上限(每天最多{$this->maxDailyLimit}次),请明天再来。",
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. 增加今日次数计数
|
||||
$newCount = Redis::incr($dailyCountKey);
|
||||
if ($newCount === 1) {
|
||||
Redis::expire($dailyCountKey, 86400 * 2);
|
||||
}
|
||||
|
||||
// 4. 配置:单次 5000 金币,500 经验
|
||||
$rewardCoins = 5000;
|
||||
$rewardExp = 500;
|
||||
$roomId = (int) $request->input('room_id', 0);
|
||||
|
||||
// 参照钓鱼逻辑:通过 UserCurrencyService 写日志并变更金币/经验
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $rewardCoins, CurrencySource::VIDEO_REWARD,
|
||||
"看视频赚取金币(第{$newCount}次)", $roomId,
|
||||
);
|
||||
|
||||
$this->currencyService->change(
|
||||
$user, 'exp', $rewardExp, CurrencySource::VIDEO_REWARD,
|
||||
"看视频赚取经验(第{$newCount}次)", $roomId,
|
||||
);
|
||||
|
||||
// 刷新模型以获取 service 原子更新后的最新字段值
|
||||
$user->refresh();
|
||||
|
||||
// 5. 设置冷却时间
|
||||
Redis::setex($cooldownKey, $this->cooldownSeconds, 1);
|
||||
|
||||
// 6. 广播全服系统消息
|
||||
if ($roomId > 0) {
|
||||
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
|
||||
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
|
||||
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
|
||||
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
|
||||
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "👍 【{$user->username}】刚刚看视频赚取了 {$rewardCoins} 金币 + {$rewardExp} 经验!{$promoTag}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||||
broadcast(new MessageSent($roomId, $sysMsg));
|
||||
}
|
||||
|
||||
$remainingToday = $this->maxDailyLimit - $newCount;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "观看完毕!获得 {$rewardCoins} 金币 + {$rewardExp} 经验。今日还可观看 {$remainingToday} 次。",
|
||||
'new_jjb' => $user->jjb,
|
||||
'level_up' => false,
|
||||
'new_level_name' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户反馈前台控制器
|
||||
* 对应独立页面 /feedback,处理用户提交 Bug报告/功能建议、
|
||||
* 赞同(Toggle)、补充评论、删除等操作
|
||||
* 所有写操作均需登录(chat.auth 中间件保护)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\FeedbackItem;
|
||||
use App\Models\FeedbackReply;
|
||||
use App\Models\FeedbackVote;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FeedbackController extends Controller
|
||||
{
|
||||
/** 每次懒加载的条数 */
|
||||
private const PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 用户反馈列表页(SSR首屏)
|
||||
* 预加载按赞同数倒序的 10 条反馈
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$feedbacks = FeedbackItem::with(['replies'])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at')
|
||||
->limit(self::PAGE_SIZE)
|
||||
->get();
|
||||
|
||||
// 当前用户已赞同的反馈 ID 集合(前端切换按钮状态用)
|
||||
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||
->whereIn('feedback_id', $feedbacks->pluck('id'))
|
||||
->pluck('feedback_id')
|
||||
->toArray();
|
||||
|
||||
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载更多反馈(JSON API)
|
||||
* 支持按类型筛选(bug / suggestion)
|
||||
*
|
||||
* @param Request $request 含 after_id / type 筛选参数
|
||||
*/
|
||||
public function loadMore(Request $request): JsonResponse
|
||||
{
|
||||
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
|
||||
$type = $request->input('type'); // bug|suggestion|null(全部)
|
||||
|
||||
$query = FeedbackItem::with(['replies'])
|
||||
->where('id', '<', $afterId)
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||
$query->ofType($type);
|
||||
}
|
||||
|
||||
$items = $query->limit(self::PAGE_SIZE)->get();
|
||||
|
||||
// 当前用户已赞同的 ID(用于切换按钮状态)
|
||||
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||
->whereIn('feedback_id', $items->pluck('id'))
|
||||
->pluck('feedback_id')
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->formatItems($items, $myVotedIds),
|
||||
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交新反馈(Bug报告或功能建议)
|
||||
*
|
||||
* @param Request $request 含 type/title/content 字段
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'type' => 'required|in:bug,suggestion',
|
||||
'title' => 'required|string|max:200',
|
||||
'content' => 'required|string|max:2000',
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$item = FeedbackItem::create([
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'type' => $data['type'],
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '反馈已提交,感谢您的贡献!',
|
||||
'item' => $this->formatItem($item, false),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 赞同/取消赞同反馈(Toggle 操作)
|
||||
* 每人每条只能赞同一次,再次点击则取消
|
||||
* 使用数据库事务保证 votes_count 冗余字段与记录一致
|
||||
*
|
||||
* @param int $id 反馈 ID
|
||||
*/
|
||||
public function vote(int $id): JsonResponse
|
||||
{
|
||||
$feedback = FeedbackItem::findOrFail($id);
|
||||
$userId = Auth::id();
|
||||
|
||||
// 不能赞同自己提交的反馈
|
||||
if ($feedback->user_id === $userId) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '不能赞同自己的反馈',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$voted = false;
|
||||
|
||||
DB::transaction(function () use ($feedback, $userId, &$voted): void {
|
||||
$existing = FeedbackVote::where('feedback_id', $feedback->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// 已赞同 → 取消赞同
|
||||
$existing->delete();
|
||||
$feedback->decrement('votes_count');
|
||||
$voted = false;
|
||||
} else {
|
||||
// 未赞同 → 新增赞同
|
||||
FeedbackVote::create([
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
$feedback->increment('votes_count');
|
||||
$voted = true;
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'voted' => $voted,
|
||||
'votes_count' => $feedback->fresh()->votes_count,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交补充评论
|
||||
* id=1 管理员的回复自动标记 is_admin=true(前台特殊展示)
|
||||
*
|
||||
* @param Request $request 含 content 字段
|
||||
* @param int $id 反馈 ID
|
||||
*/
|
||||
public function reply(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$feedback = FeedbackItem::findOrFail($id);
|
||||
|
||||
$data = $request->validate([
|
||||
'content' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var FeedbackReply $reply */
|
||||
$reply = null;
|
||||
|
||||
DB::transaction(function () use ($feedback, $data, $user, &$reply): void {
|
||||
$reply = FeedbackReply::create([
|
||||
'feedback_id' => $feedback->id,
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'content' => $data['content'],
|
||||
'is_admin' => $user->id === 1,
|
||||
]);
|
||||
|
||||
$feedback->increment('replies_count');
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '评论已提交',
|
||||
'reply' => [
|
||||
'id' => $reply->id,
|
||||
'username' => $reply->username,
|
||||
'content' => $reply->content,
|
||||
'is_admin' => $reply->is_admin,
|
||||
'created_at' => $reply->created_at->diffForHumans(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除反馈
|
||||
* 普通用户:仅24小时内可删除自己的反馈
|
||||
* 管理员(id=1):任意时间可删除任意反馈
|
||||
*
|
||||
* @param int $id 反馈 ID
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$feedback = FeedbackItem::findOrFail($id);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
$isOwner = $feedback->user_id === $user->id;
|
||||
$isAdmin = $user->id === 1;
|
||||
|
||||
if (! $isOwner && ! $isAdmin) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权删除'], 403);
|
||||
}
|
||||
|
||||
if ($isOwner && ! $isAdmin && ! $feedback->is_within_24_hours) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '超过 24 小时的反馈无法删除',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 级联删除关联的赞同记录和评论记录
|
||||
DB::transaction(function () use ($feedback): void {
|
||||
FeedbackVote::where('feedback_id', $feedback->id)->delete();
|
||||
FeedbackReply::where('feedback_id', $feedback->id)->delete();
|
||||
$feedback->delete();
|
||||
});
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '已删除']);
|
||||
}
|
||||
|
||||
// ═══════════════ 私有辅助方法 ═══════════════
|
||||
|
||||
/**
|
||||
* 格式化单条反馈数据(供 JSON 返回给前端)
|
||||
*
|
||||
* @param FeedbackItem $item 反馈实例
|
||||
* @param bool $voted 当前用户是否已赞同
|
||||
*/
|
||||
private function formatItem(FeedbackItem $item, bool $voted): array
|
||||
{
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'type' => $item->type,
|
||||
'type_label' => $item->type_label,
|
||||
'title' => $item->title,
|
||||
'content' => $item->content,
|
||||
'status' => $item->status,
|
||||
'status_label' => $item->status_label,
|
||||
'status_color' => $item->status_config['color'],
|
||||
'admin_remark' => $item->admin_remark,
|
||||
'votes_count' => $item->votes_count,
|
||||
'replies_count' => $item->replies_count,
|
||||
'username' => $item->username,
|
||||
'created_at' => $item->created_at->diffForHumans(),
|
||||
'voted' => $voted,
|
||||
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
||||
'id' => $r->id,
|
||||
'username' => $r->username,
|
||||
'content' => $r->content,
|
||||
'is_admin' => $r->is_admin,
|
||||
'created_at' => $r->created_at->diffForHumans(),
|
||||
])->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量格式化反馈数据集合
|
||||
*
|
||||
* @param \Illuminate\Support\Collection<int, FeedbackItem> $items
|
||||
* @param array<int> $myVotedIds 当前用户已赞同的 ID 列表
|
||||
*/
|
||||
private function formatItems(\Illuminate\Support\Collection $items, array $myVotedIds): array
|
||||
{
|
||||
return $items->map(fn (FeedbackItem $item) => $this->formatItem(
|
||||
$item,
|
||||
in_array($item->id, $myVotedIds)
|
||||
))->values()->toArray();
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,52 @@
|
||||
|
||||
/**
|
||||
* 文件功能:钓鱼小游戏控制器
|
||||
* 复刻原版 ASP 聊天室 diaoyu/ 目录下的钓鱼功能
|
||||
* 简化掉鱼竿道具系统,用 Redis 控制冷却,随机奖惩经验/金币
|
||||
*
|
||||
* 新增随机浮漂点击防挂机机制:
|
||||
* - 抛竿时生成一次性 token 和随机浮漂坐标(百分比),返回给前端
|
||||
* - 前端显示漂浮动画,等待下沉后用户点击,将 token 随收竿请求提交
|
||||
* - 若持有有效「自动钓鱼卡」,前端直接自动点击,无需手动操作
|
||||
* - 服务端验证 token 有效性,防止脚本直接调用收竿接口
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\FishingService;
|
||||
use App\Services\ShopService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FishingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ShopService $shopService,
|
||||
private readonly FishingService $fishingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 抛竿 — 检查冷却和金币,扣除金币,返回随机等待时间
|
||||
* 抛竿 — 检查冷却和金币,扣除金币,生成浮漂 token 和随机坐标。
|
||||
*
|
||||
* 返回:
|
||||
* wait_time — 等待秒数(前端倒数后触发下沉动画)
|
||||
* bobber_x/y — 浮漂随机位置(0-100 百分比)
|
||||
* token — 本次钓鱼唯一令牌(收竿时必须携带)
|
||||
* auto_fishing — 是否持有有效自动钓鱼卡(前端据此自动点击)
|
||||
*
|
||||
* @param int $id 房间ID
|
||||
*/
|
||||
@@ -40,6 +58,11 @@ class FishingController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
// 检查钓鱼全局开关
|
||||
if (! GameConfig::isEnabled('fishing')) {
|
||||
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
|
||||
}
|
||||
|
||||
// 1. 检查冷却时间(Redis TTL)
|
||||
$cooldownKey = "fishing:cd:{$user->id}";
|
||||
if (Redis::exists($cooldownKey)) {
|
||||
@@ -53,7 +76,7 @@ class FishingController extends Controller
|
||||
}
|
||||
|
||||
// 2. 检查金币是否足够
|
||||
$cost = (int) Sysparam::getValue('fishing_cost', '5');
|
||||
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
||||
if (($user->jjb ?? 0) < $cost) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
@@ -62,28 +85,53 @@ class FishingController extends Controller
|
||||
}
|
||||
|
||||
// 3. 扣除金币
|
||||
$user->jjb = max(0, ($user->jjb ?? 0) - $cost);
|
||||
$user->save();
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$cost,
|
||||
CurrencySource::FISHING_COST,
|
||||
"钓鱼抛竿消耗 {$cost} 金币",
|
||||
$id,
|
||||
);
|
||||
$user->refresh();
|
||||
|
||||
// 4. 设置"正在钓鱼"标记(防止重复抛竿,30秒后自动过期)
|
||||
Redis::setex("fishing:active:{$user->id}", 30, time());
|
||||
|
||||
// 5. 计算随机等待时间
|
||||
$waitMin = (int) Sysparam::getValue('fishing_wait_min', '8');
|
||||
$waitMax = (int) Sysparam::getValue('fishing_wait_max', '15');
|
||||
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
|
||||
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
||||
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
||||
$waitTime = rand($waitMin, $waitMax);
|
||||
$token = Str::random(32);
|
||||
$tokenKey = "fishing:token:{$user->id}";
|
||||
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
|
||||
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
|
||||
Redis::setex($tokenKey, $waitTime + 13, json_encode([
|
||||
'token' => $token,
|
||||
'cast_at' => time(),
|
||||
'wait_time' => $waitTime,
|
||||
]));
|
||||
|
||||
// 5. 生成随机浮漂坐标(百分比,避开边缘)
|
||||
$bobberX = rand(15, 85); // 左右 15%~85%
|
||||
$bobberY = rand(20, 65); // 上下 20%~65%
|
||||
|
||||
// 6. 检查是否持有有效自动钓鱼卡
|
||||
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "已花费 {$cost} 金币,鱼竿已抛出!等待鱼儿上钩...",
|
||||
'wait_time' => $waitTime,
|
||||
'bobber_x' => $bobberX,
|
||||
'bobber_y' => $bobberY,
|
||||
'token' => $token,
|
||||
'auto_fishing' => $autoFishingMinutes > 0,
|
||||
'auto_fishing_minutes_left' => $autoFishingMinutes,
|
||||
'cost' => $cost,
|
||||
'jjb' => $user->jjb,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收竿 — 随机计算钓鱼结果,更新经验/金币,广播到聊天室
|
||||
* 收竿 — 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
|
||||
*
|
||||
* 必须携带 token(从抛竿接口获取),否则判定为非法收竿。
|
||||
*
|
||||
* @param int $id 房间ID
|
||||
*/
|
||||
@@ -94,123 +142,52 @@ class FishingController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
// 1. 检查是否有"正在钓鱼"标记
|
||||
$activeKey = "fishing:active:{$user->id}";
|
||||
if (! Redis::exists($activeKey)) {
|
||||
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
|
||||
$tokenKey = "fishing:token:{$user->id}";
|
||||
$storedJson = Redis::get($tokenKey);
|
||||
$clientToken = $request->input('token', '');
|
||||
|
||||
if (! $storedJson) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '您还没有抛竿,或者鱼已经跑了!',
|
||||
'message' => '鱼儿跑了!浮漂已超时,请重新抛竿。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 清除钓鱼标记
|
||||
Redis::del($activeKey);
|
||||
$stored = json_decode($storedJson, true);
|
||||
// 校验 token 一致性
|
||||
if (($stored['token'] ?? '') !== $clientToken) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '令牌无效,请重新抛竿。',
|
||||
], 422);
|
||||
}
|
||||
// 校验服务端时间:距抛竿必须已过 wait_time 秒(允许 ±1s 误差)
|
||||
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
|
||||
$required = (int) ($stored['wait_time'] ?? 0);
|
||||
if ($elapsed < $required - 1) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '鱼还没上钩,别急!',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 清除 token(一次性)
|
||||
Redis::del($tokenKey);
|
||||
|
||||
// 2. 设置冷却时间
|
||||
$cooldown = (int) Sysparam::getValue('fishing_cooldown', '300');
|
||||
$cooldown = (int) (GameConfig::param('fishing', 'fishing_cooldown') ?? Sysparam::getValue('fishing_cooldown', '300'));
|
||||
Redis::setex("fishing:cd:{$user->id}", $cooldown, time());
|
||||
|
||||
// 3. 随机决定钓鱼结果
|
||||
$result = $this->randomFishResult();
|
||||
|
||||
// 4. 更新用户经验和金币(正向奖励按 VIP 倍率加成,负面惩罚不变)
|
||||
$expMul = $this->vipService->getExpMultiplier($user);
|
||||
$jjbMul = $this->vipService->getJjbMultiplier($user);
|
||||
if ($result['exp'] !== 0) {
|
||||
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
|
||||
$user->exp_num = max(0, ($user->exp_num ?? 0) + $finalExp);
|
||||
}
|
||||
if ($result['jjb'] !== 0) {
|
||||
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
|
||||
$user->jjb = max(0, ($user->jjb ?? 0) + $finalJjb);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
// 5. 广播钓鱼结果到聊天室
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => '钓鱼播报',
|
||||
'to_user' => '大家',
|
||||
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($id, $sysMsg);
|
||||
broadcast(new MessageSent($id, $sysMsg));
|
||||
// 3. 随机决定钓鱼结果并广播(直接调用服务)
|
||||
$result = $this->fishingService->processCatch($user, $id, false);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'result' => $result,
|
||||
'exp_num' => $user->exp_num,
|
||||
'jjb' => $user->jjb,
|
||||
'cooldown_seconds' => $cooldown, // 前端自动钓鱼卡循环等待用
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机钓鱼结果(复刻原版概率分布)
|
||||
*
|
||||
* @return array{emoji: string, message: string, exp: int, jjb: int}
|
||||
*/
|
||||
private function randomFishResult(): array
|
||||
{
|
||||
$roll = rand(1, 100);
|
||||
|
||||
// 概率分布(总计 100%)
|
||||
// 1-15: 大鲨鱼 (+100exp, +20金)
|
||||
// 16-30: 娃娃鱼 (+0exp, +30金)
|
||||
// 31-50: 大草鱼 (+50exp)
|
||||
// 51-70: 小鲤鱼 (+50exp, +10金)
|
||||
// 71-85: 落水 (-50exp)
|
||||
// 86-95: 被打 (-20exp, -3金)
|
||||
// 96-100:大丰收 (+150exp, +50金)
|
||||
|
||||
return match (true) {
|
||||
$roll <= 15 => [
|
||||
'emoji' => '🦈',
|
||||
'message' => '钓到一条大鲨鱼!增加经验100、金币20',
|
||||
'exp' => 100,
|
||||
'jjb' => 20,
|
||||
],
|
||||
$roll <= 30 => [
|
||||
'emoji' => '🐟',
|
||||
'message' => '钓到一条娃娃鱼,到集市卖得30个金币',
|
||||
'exp' => 0,
|
||||
'jjb' => 30,
|
||||
],
|
||||
$roll <= 50 => [
|
||||
'emoji' => '🐠',
|
||||
'message' => '钓到一只大草鱼,吃下增加经验50',
|
||||
'exp' => 50,
|
||||
'jjb' => 0,
|
||||
],
|
||||
$roll <= 70 => [
|
||||
'emoji' => '🐡',
|
||||
'message' => '钓到一条小鲤鱼,增加经验50、金币10',
|
||||
'exp' => 50,
|
||||
'jjb' => 10,
|
||||
],
|
||||
$roll <= 85 => [
|
||||
'emoji' => '💧',
|
||||
'message' => '鱼没钓到,摔到河里经验减少50',
|
||||
'exp' => -50,
|
||||
'jjb' => 0,
|
||||
],
|
||||
$roll <= 95 => [
|
||||
'emoji' => '👊',
|
||||
'message' => '偷钓鱼塘被主人发现,一阵殴打!经验减少20、金币减少3',
|
||||
'exp' => -20,
|
||||
'jjb' => -3,
|
||||
],
|
||||
default => [
|
||||
'emoji' => '🎉',
|
||||
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+150,金币+50!',
|
||||
'exp' => 150,
|
||||
'jjb' => 50,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘占卜前台控制器
|
||||
*
|
||||
* 提供用户每日占卜功能:
|
||||
* - 查询今日占卜状态(已占卜/未占卜/剩余次数)
|
||||
* - 执行占卜(免费或付费)
|
||||
* - 查询占卜历史记录
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\FortuneLog;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FortuneTellingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询今日占卜状态(用于面板初始化和刷新)。
|
||||
*/
|
||||
public function todayStatus(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('fortune_telling')) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||
|
||||
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
|
||||
$extraCost = (int) ($config['extra_cost'] ?? 500);
|
||||
|
||||
$todayCount = FortuneLog::todayCount($user->id);
|
||||
$todayLatest = FortuneLog::todayLatest($user->id);
|
||||
$freeUsed = FortuneLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('fortune_date', today())
|
||||
->where('is_free', true)
|
||||
->count();
|
||||
$hasFreeLeft = $freeUsed < $freeCount;
|
||||
|
||||
return response()->json([
|
||||
'enabled' => true,
|
||||
'today_count' => $todayCount,
|
||||
'free_count' => $freeCount,
|
||||
'free_used' => $freeUsed,
|
||||
'has_free_left' => $hasFreeLeft,
|
||||
'extra_cost' => $extraCost,
|
||||
'latest' => $todayLatest ? [
|
||||
'grade' => $todayLatest->grade,
|
||||
'grade_label' => $todayLatest->gradeLabel(),
|
||||
'grade_color' => $todayLatest->gradeColor(),
|
||||
'text' => $todayLatest->text,
|
||||
'buff_desc' => $todayLatest->buff_desc,
|
||||
'created_at' => $todayLatest->created_at->format('H:i'),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次占卜。
|
||||
*
|
||||
* 免费次数用完后每次消耗 extra_cost 金币。
|
||||
*/
|
||||
public function tell(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('fortune_telling')) {
|
||||
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
|
||||
|
||||
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
|
||||
$extraCost = (int) ($config['extra_cost'] ?? 500);
|
||||
|
||||
// 判断今日免费次数是否已用完
|
||||
$freeUsed = FortuneLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('fortune_date', today())
|
||||
->where('is_free', true)
|
||||
->count();
|
||||
|
||||
$isFree = $freeUsed < $freeCount;
|
||||
$cost = $isFree ? 0 : $extraCost;
|
||||
|
||||
// 检查余额
|
||||
if (! $isFree && ($user->jjb ?? 0) < $cost) {
|
||||
return response()->json(['ok' => false, 'message' => "金币不足,额外占卜需要 {$cost} 金币。"]);
|
||||
}
|
||||
|
||||
// 扣费
|
||||
if (! $isFree && $cost > 0) {
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$cost,
|
||||
CurrencySource::FORTUNE_COST,
|
||||
'神秘占卜额外次数消耗',
|
||||
);
|
||||
}
|
||||
|
||||
// 抽签
|
||||
$grade = FortuneLog::rollGrade($config);
|
||||
$fortune = FortuneLog::rollFortune($grade);
|
||||
|
||||
// 记录
|
||||
$log = FortuneLog::create([
|
||||
'user_id' => $user->id,
|
||||
'grade' => $grade,
|
||||
'text' => $fortune['text'],
|
||||
'buff_desc' => $fortune['buff_desc'] ?? null,
|
||||
'is_free' => $isFree,
|
||||
'cost' => $cost,
|
||||
'fortune_date' => today(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'grade' => $log->grade,
|
||||
'grade_label' => $log->gradeLabel(),
|
||||
'grade_color' => $log->gradeColor(),
|
||||
'text' => $log->text,
|
||||
'buff_desc' => $log->buff_desc,
|
||||
'is_free' => $isFree,
|
||||
'cost' => $cost,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询近20条个人占卜历史记录。
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$logs = FortuneLog::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->orderByDesc('id')
|
||||
->limit(20)
|
||||
->get(['grade', 'text', 'buff_desc', 'is_free', 'cost', 'fortune_date', 'created_at'])
|
||||
->map(fn ($log) => [
|
||||
'grade' => $log->grade,
|
||||
'grade_label' => $log->gradeLabel(),
|
||||
'grade_color' => $log->gradeColor(),
|
||||
'text' => $log->text,
|
||||
'buff_desc' => $log->buff_desc,
|
||||
'cost' => $log->cost,
|
||||
'date' => $log->fortune_date->format('m-d'),
|
||||
'time' => $log->created_at->format('H:i'),
|
||||
]);
|
||||
|
||||
return response()->json(['history' => $logs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:好友系统控制器
|
||||
*
|
||||
* 处理聊天室内的好友关系管理:
|
||||
* 1. 添加好友(addFriend)
|
||||
* 2. 删除好友(removeFriend)
|
||||
* 3. 查询与指定用户的好友关系(status)
|
||||
* 4. 查询当前用户的好友列表(index)
|
||||
*
|
||||
* 好友关系模型:单向存储,互相添加才构成双向好友。
|
||||
* 使用原版 friend_requests 表(字段:who / towho / sub_time)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\FriendAdded;
|
||||
use App\Events\FriendRemoved;
|
||||
use App\Models\FriendRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class FriendController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入 Redis 状态服务,用于推送悄悄话通知。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询当前用户与目标用户的好友关系状态。
|
||||
*
|
||||
* 返回:
|
||||
* - is_friend: 当前用户是否已将对方加为好友
|
||||
* - mutual: 是否互相添加(双向好友)
|
||||
*
|
||||
* @param string $username 目标用户名
|
||||
*/
|
||||
public function status(string $username): JsonResponse
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
// 我是否已将对方加为好友
|
||||
$iAdded = FriendRequest::where('who', $me->username)
|
||||
->where('towho', $username)
|
||||
->exists();
|
||||
|
||||
// 对方是否也将我加为好友
|
||||
$theyAdded = FriendRequest::where('who', $username)
|
||||
->where('towho', $me->username)
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
'is_friend' => $iAdded,
|
||||
'mutual' => $iAdded && $theyAdded,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加好友。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 校验目标用户存在、且不是自己
|
||||
* 2. 检查是否已经添加过
|
||||
* 3. 写入 friend_requests 记录
|
||||
* 4. 检查是否互相好友(B 是否已将 A 加为好友)
|
||||
* 5. 广播 FriendAdded 事件通知对方(携带互相状态)
|
||||
* 6. 若对方在线,向对方发送正确的悄悄话
|
||||
*
|
||||
* @param string $username 目标用户名
|
||||
*/
|
||||
public function addFriend(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
// 不能加自己
|
||||
if ($me->username === $username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能将自己加为好友'], 422);
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
$target = User::where('username', $username)->first();
|
||||
if (! $target) {
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
}
|
||||
|
||||
// 是否已添加
|
||||
$exists = FriendRequest::where('who', $me->username)->where('towho', $username)->exists();
|
||||
if ($exists) {
|
||||
return response()->json(['status' => 'error', 'message' => '已是好友,无需重复添加'], 422);
|
||||
}
|
||||
|
||||
// 写入好友关系(A → B)
|
||||
FriendRequest::create([
|
||||
'who' => $me->username,
|
||||
'towho' => $username,
|
||||
'sub_time' => now(),
|
||||
]);
|
||||
|
||||
// 检查 B 是否已将 A 加为好友(互相好友判断)
|
||||
$hasAddedBack = FriendRequest::where('who', $username)
|
||||
->where('towho', $me->username)
|
||||
->exists();
|
||||
|
||||
// 广播给对方(仅对方可见),携带是否已回加的状态;用数字 ID 作为频道名,避免中文名
|
||||
broadcast(new FriendAdded($me->username, $username, $target->id, $hasAddedBack));
|
||||
|
||||
// 若对方在线,推送聊天区悄悄话(文案根据互相状态区分)
|
||||
$this->notifyOnlineUser($username, $me->username, 'added', $request->input('room_id'), $hasAddedBack);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '已成功添加 '.$username.' 为好友 🎉',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除好友。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 删除 friend_requests 中「我 → 对方」的记录
|
||||
* 2. 检查对方是否也将我加为好友(之前是否互相)
|
||||
* 3. 广播 FriendRemoved 事件通知对方
|
||||
* 4. 若对方在线,向对方发送悄悄话
|
||||
*
|
||||
* @param string $username 目标用户名
|
||||
*/
|
||||
public function removeFriend(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
$deleted = FriendRequest::where('who', $me->username)
|
||||
->where('towho', $username)
|
||||
->delete();
|
||||
|
||||
if (! $deleted) {
|
||||
return response()->json(['status' => 'error', 'message' => '好友关系不存在'], 404);
|
||||
}
|
||||
|
||||
// 检查 B 之前是否也将 A 加为好友(删除前的互相状态)
|
||||
$hadAddedBack = FriendRequest::where('who', $username)
|
||||
->where('towho', $me->username)
|
||||
->exists();
|
||||
|
||||
// 查询目标用户 ID(用于私有频道,避免中文名非法)
|
||||
$targetUser = User::where('username', $username)->first();
|
||||
|
||||
// 广播给对方,携带之前的互相好友状态;用数字 ID 避免中文频道名
|
||||
broadcast(new FriendRemoved($me->username, $username, $targetUser?->id ?? 0, $hadAddedBack));
|
||||
|
||||
// 若对方在线,推送聊天区悄悄话(文案根据互相状态区分)
|
||||
$this->notifyOnlineUser($username, $me->username, 'removed', $request->input('room_id'), $hadAddedBack);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '已将 '.$username.' 从好友列表移除',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的完整好友数据,供好友面板使用。
|
||||
*
|
||||
* 返回两个列表:
|
||||
* - friends:我已添加的好友(含互相状态、添加时间)
|
||||
* - pending:对方已加我但我还未加对方的(含对方添加我的时间)
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
// ── 我添加的好友及添加时间 ──
|
||||
$myRows = FriendRequest::where('who', $me->username)
|
||||
->get(['towho', 'sub_time'])
|
||||
->keyBy('towho');
|
||||
|
||||
// ── 把我加了的人(用于互相判断 + pending 列表)──
|
||||
$addedMeRows = FriendRequest::where('towho', $me->username)
|
||||
->get(['who', 'sub_time'])
|
||||
->keyBy('who');
|
||||
|
||||
$myAddedNames = $myRows->keys();
|
||||
$addedMeNames = $addedMeRows->keys();
|
||||
|
||||
// ── 查询全局在线用户(所有房间合并)──
|
||||
$onlineUsernames = collect($this->chatState->getAllOnlineUsernames());
|
||||
|
||||
// 我添加的好友详情
|
||||
$friends = User::whereIn('username', $myAddedNames)
|
||||
->get(['username', 'usersf', 'user_level', 'sex'])
|
||||
->map(function ($u) use ($myRows, $addedMeNames, $onlineUsernames) {
|
||||
$row = $myRows->get($u->username);
|
||||
|
||||
return [
|
||||
'username' => $u->username,
|
||||
'headface' => $u->headface,
|
||||
'user_level' => $u->user_level,
|
||||
'sex' => $u->sex,
|
||||
'mutual' => $addedMeNames->contains($u->username), // 是否互相添加
|
||||
'sub_time' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
|
||||
'is_online' => $onlineUsernames->contains($u->username),
|
||||
];
|
||||
})
|
||||
->sortByDesc('is_online') // 在线好友排在前面
|
||||
->values();
|
||||
|
||||
// 对方加了我但我还未加的(pending)
|
||||
$pendingNames = $addedMeNames->diff($myAddedNames);
|
||||
$pending = User::whereIn('username', $pendingNames)
|
||||
->get(['username', 'usersf', 'user_level', 'sex'])
|
||||
->map(function ($u) use ($addedMeRows, $onlineUsernames) {
|
||||
$row = $addedMeRows->get($u->username);
|
||||
|
||||
return [
|
||||
'username' => $u->username,
|
||||
'headface' => $u->headface,
|
||||
'user_level' => $u->user_level,
|
||||
'sex' => $u->sex,
|
||||
'added_at' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
|
||||
'is_online' => $onlineUsernames->contains($u->username),
|
||||
];
|
||||
})
|
||||
->sortByDesc('is_online')
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'friends' => $friends,
|
||||
'pending' => $pending,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 若目标用户在线,向其发送系统悄悄话通知。
|
||||
*
|
||||
* 根据 $action 和 $mutual 显示不同文案,避免「你们已是好友」的误导提示。
|
||||
*
|
||||
* @param string $targetUsername 接收通知的用户名
|
||||
* @param string $fromUsername 发起操作的用户名
|
||||
* @param string $action 'added' | 'removed' | 'online'
|
||||
* @param int|null $roomId 当前房间 ID
|
||||
* @param bool $mutual 是否互相好友(added: B 是否已回加;removed: 之前是否互相)
|
||||
*/
|
||||
private function notifyOnlineUser(
|
||||
string $targetUsername,
|
||||
string $fromUsername,
|
||||
string $action,
|
||||
?int $roomId = null,
|
||||
bool $mutual = false,
|
||||
): void {
|
||||
if (! $roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查对方是否在该房间在线
|
||||
$onlineUsers = $this->chatState->getRoomUsers($roomId);
|
||||
if (! isset($onlineUsers[$targetUsername])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据操作类型和互相状态生成不同文案(含内联快捷操作链接)
|
||||
$btnStyle = 'font-weight:bold;text-decoration:underline;margin-left:6px;';
|
||||
$btnAdd = "<a href='#' onclick=\"quickFriendAction('add','{$fromUsername}',this);return false;\" style='color:#16a34a;{$btnStyle}'>➕ 回加好友</a>";
|
||||
$btnRemove = "<a href='#' onclick=\"quickFriendAction('remove','{$fromUsername}',this);return false;\" style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
|
||||
|
||||
$content = match ($action) {
|
||||
'added' => $mutual
|
||||
? "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
|
||||
: "💚 <b>{$fromUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
|
||||
'removed' => $mutual
|
||||
? "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
|
||||
: "💔 <b>{$fromUsername}</b> 已将你从他的好友列表移除。",
|
||||
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
|
||||
default => '',
|
||||
};
|
||||
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除相关用灰色,其他用绿色
|
||||
$fontColor = $action === 'removed' ? '#6b7280' : '#16a34a';
|
||||
|
||||
// 构建系统悄悄话消息
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统',
|
||||
'to_user' => $targetUsername,
|
||||
'content' => $content,
|
||||
'is_secret' => true,
|
||||
'font_color' => $fontColor,
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $msg));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:五子棋对战前台控制器
|
||||
*
|
||||
* 提供 PvP(随机对战)和 PvE(人机对战)两种模式的完整 API:
|
||||
* - 创建对局(支持两种模式)
|
||||
* - 加入 PvP 对战
|
||||
* - 落子(自动触发 AI 回应)
|
||||
* - 认输
|
||||
* - 取消邀请
|
||||
* - 获取对局状态
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\GomokuFinishedEvent;
|
||||
use App\Events\GomokuInviteEvent;
|
||||
use App\Events\GomokuMovedEvent;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\GomokuGame;
|
||||
use App\Services\GomokuAiService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GomokuController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GomokuAiService $ai,
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建对局。
|
||||
*
|
||||
* 支持两种模式:
|
||||
* - pvp: 广播邀请通知,等待其他玩家加入
|
||||
* - pve: 立即开局与 AI 对战(需支付入场费)
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('gomoku')) {
|
||||
return response()->json(['ok' => false, 'message' => '五子棋当前未开启。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'mode' => 'required|in:pvp,pve',
|
||||
'room_id' => 'required|integer|exists:rooms,id',
|
||||
'ai_level' => 'required_if:mode,pve|nullable|integer|min:1|max:4',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// PvP:检查是否已在等待/对局中(一次只能参与一场)
|
||||
$activeGame = GomokuGame::query()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('player_black_id', $user->id)
|
||||
->orWhere('player_white_id', $user->id);
|
||||
})
|
||||
->whereIn('status', ['waiting', 'playing'])
|
||||
->first();
|
||||
|
||||
if ($activeGame) {
|
||||
return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局,请先完成或取消。']);
|
||||
}
|
||||
|
||||
// PvE:扣除入场费
|
||||
$entryFee = 0;
|
||||
if ($data['mode'] === 'pve') {
|
||||
$entryFee = $this->getPveEntryFee((int) $data['ai_level']);
|
||||
if ($entryFee > 0 && ($user->jjb ?? 0) < $entryFee) {
|
||||
return response()->json(['ok' => false, 'message' => "金币不足,此难度需 {$entryFee} 金币入场费。"]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse {
|
||||
// PvE 扣除入场费
|
||||
if ($entryFee > 0) {
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$entryFee,
|
||||
CurrencySource::GOMOKU_ENTRY_FEE,
|
||||
"五子棋 AI 对战入场费(难度{$data['ai_level']})",
|
||||
);
|
||||
}
|
||||
|
||||
$timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60);
|
||||
|
||||
$game = GomokuGame::create([
|
||||
'mode' => $data['mode'],
|
||||
'room_id' => $data['room_id'],
|
||||
'player_black_id' => $user->id,
|
||||
'ai_level' => $data['mode'] === 'pve' ? ($data['ai_level'] ?? 1) : null,
|
||||
'status' => $data['mode'] === 'pve' ? 'playing' : 'waiting',
|
||||
'board' => GomokuGame::emptyBoard(),
|
||||
'current_turn' => 1,
|
||||
'entry_fee' => $entryFee,
|
||||
'invite_expires_at' => $data['mode'] === 'pvp' ? now()->addSeconds($timeout) : null,
|
||||
'started_at' => $data['mode'] === 'pve' ? now() : null,
|
||||
]);
|
||||
|
||||
// PvP:广播邀请通知至房间
|
||||
if ($data['mode'] === 'pvp') {
|
||||
broadcast(new GomokuInviteEvent($game, $user->username));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'game_id' => $game->id,
|
||||
'message' => $data['mode'] === 'pvp'
|
||||
? '已发起对战邀请,等待其他玩家加入…'
|
||||
: '对局已开始,您执黑棋先手!',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入 PvP 对战(白棋方)。
|
||||
*/
|
||||
public function join(Request $request, GomokuGame $game): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($game->status !== 'waiting') {
|
||||
return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']);
|
||||
}
|
||||
|
||||
if ($game->player_black_id === $user->id) {
|
||||
return response()->json(['ok' => false, 'message' => '不能加入自己发起的对局。']);
|
||||
}
|
||||
|
||||
if ($game->invite_expires_at && now()->isAfter($game->invite_expires_at)) {
|
||||
$game->update(['status' => 'cancelled']);
|
||||
|
||||
return response()->json(['ok' => false, 'message' => '该邀请已超时,请重新发起。']);
|
||||
}
|
||||
|
||||
// 检查接受方是否已在其他对局中
|
||||
$activeGame = GomokuGame::query()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('player_black_id', $user->id)
|
||||
->orWhere('player_white_id', $user->id);
|
||||
})
|
||||
->whereIn('status', ['waiting', 'playing'])
|
||||
->first();
|
||||
|
||||
if ($activeGame) {
|
||||
return response()->json(['ok' => false, 'message' => '您当前已有进行中的对局。']);
|
||||
}
|
||||
|
||||
$game->update([
|
||||
'player_white_id' => $user->id,
|
||||
'status' => 'playing',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'game_id' => $game->id,
|
||||
'message' => '已成功加入对战!您执白棋。',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 落子。
|
||||
*
|
||||
* PvP 模式:验证轮次后广播落子。
|
||||
* PvE 模式:玩家落子后,自动计算 AI 落点并一并返回。
|
||||
*/
|
||||
public function move(Request $request, GomokuGame $game): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($game->status !== 'playing') {
|
||||
return response()->json(['ok' => false, 'message' => '对局未在进行中。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'row' => 'required|integer|min:0|max:14',
|
||||
'col' => 'required|integer|min:0|max:14',
|
||||
]);
|
||||
|
||||
$row = (int) $data['row'];
|
||||
$col = (int) $data['col'];
|
||||
$board = $game->board;
|
||||
|
||||
// 坐标已被占用
|
||||
if (GomokuGame::isOccupied($board, $row, $col)) {
|
||||
return response()->json(['ok' => false, 'message' => '该位置已有棋子。']);
|
||||
}
|
||||
|
||||
// PvP:验证是否轮到该玩家
|
||||
if ($game->mode === 'pvp') {
|
||||
if (! $game->belongsToUser($user->id)) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
|
||||
}
|
||||
if (! $game->isUserTurn($user->id)) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不是您的回合。']);
|
||||
}
|
||||
} else {
|
||||
// PvE:只允许黑棋玩家操作
|
||||
if ($game->player_black_id !== $user->id) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
|
||||
}
|
||||
if ($game->current_turn !== 1) {
|
||||
return response()->json(['ok' => false, 'message' => 'AI 正在思考,请等待。']);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($game, $row, $col, $board, $user): JsonResponse {
|
||||
// 玩家落子
|
||||
$playerColor = $game->mode === 'pvp' ? $game->colorOf($user->id) : 1;
|
||||
$board = GomokuGame::placeStone($board, $row, $col, $playerColor);
|
||||
|
||||
// 记录落子历史
|
||||
$history = $game->moves_history ?? [];
|
||||
$history[] = ['row' => $row, 'col' => $col, 'color' => $playerColor, 'at' => now()->toIso8601String()];
|
||||
|
||||
// 判断玩家是否胜利
|
||||
if (GomokuGame::checkWin($board, $row, $col, $playerColor)) {
|
||||
return $this->finishGame($game, $board, $history, $playerColor, 'win', $user);
|
||||
}
|
||||
|
||||
// 判断平局
|
||||
if (GomokuGame::isBoardFull($board)) {
|
||||
return $this->finishGame($game, $board, $history, 0, 'draw', $user);
|
||||
}
|
||||
|
||||
// 切换回合
|
||||
$nextTurn = $playerColor === 1 ? 2 : 1;
|
||||
$game->update([
|
||||
'board' => $board,
|
||||
'current_turn' => $nextTurn,
|
||||
'moves_history' => $history,
|
||||
]);
|
||||
|
||||
// PvP:广播落子事件
|
||||
if ($game->mode === 'pvp') {
|
||||
broadcast(new GomokuMovedEvent($game->fresh(), $row, $col, $playerColor));
|
||||
|
||||
return response()->json(['ok' => true, 'moved' => compact('row', 'col')]);
|
||||
}
|
||||
|
||||
// PvE:AI 落子
|
||||
$aiMove = $this->ai->think($board, $game->ai_level ?? 1);
|
||||
$aiRow = $aiMove['row'];
|
||||
$aiCol = $aiMove['col'];
|
||||
$aiColor = 2;
|
||||
$board = GomokuGame::placeStone($board, $aiRow, $aiCol, $aiColor);
|
||||
|
||||
$history[] = ['row' => $aiRow, 'col' => $aiCol, 'color' => $aiColor, 'at' => now()->toIso8601String()];
|
||||
|
||||
// 判断 AI 是否胜利
|
||||
if (GomokuGame::checkWin($board, $aiRow, $aiCol, $aiColor)) {
|
||||
return $this->finishGame($game, $board, $history, $aiColor, 'win', $user);
|
||||
}
|
||||
|
||||
// 再次检查平局(AI 落子后)
|
||||
if (GomokuGame::isBoardFull($board)) {
|
||||
return $this->finishGame($game, $board, $history, 0, 'draw', $user);
|
||||
}
|
||||
|
||||
// AI 落子后切换回玩家回合
|
||||
$game->update([
|
||||
'board' => $board,
|
||||
'current_turn' => 1,
|
||||
'moves_history' => $history,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'moved' => ['row' => $row, 'col' => $col],
|
||||
'ai_moved' => ['row' => $aiRow, 'col' => $aiCol],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 认输(当前玩家主动认输,对手获胜)。
|
||||
*/
|
||||
public function resign(Request $request, GomokuGame $game): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! in_array($game->status, ['playing', 'waiting'])) {
|
||||
return response()->json(['ok' => false, 'message' => '对局已结束。']);
|
||||
}
|
||||
|
||||
if (! $game->belongsToUser($user->id)) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在该对局中。']);
|
||||
}
|
||||
|
||||
// 认输者对应颜色,胜方为另一色
|
||||
$resignColor = $game->colorOf($user->id);
|
||||
$winnerColor = $resignColor === 1 ? 2 : 1;
|
||||
|
||||
return DB::transaction(function () use ($game, $winnerColor, $user): JsonResponse {
|
||||
return $this->finishGame($game, $game->board, $game->moves_history ?? [], $winnerColor, 'resign', $user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 PvP 邀请(发起者主动取消,或超时后被调用)。
|
||||
*/
|
||||
public function cancel(Request $request, GomokuGame $game): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($game->status !== 'waiting') {
|
||||
return response()->json(['ok' => false, 'message' => '该对局已不在等待状态。']);
|
||||
}
|
||||
|
||||
if ($game->player_black_id !== $user->id) {
|
||||
return response()->json(['ok' => false, 'message' => '只有发起者可取消邀请。']);
|
||||
}
|
||||
|
||||
$game->update(['status' => 'cancelled']);
|
||||
|
||||
return response()->json(['ok' => true, 'message' => '邀请已取消。']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对局当前状态(用于前端重连同步)。
|
||||
*/
|
||||
public function state(Request $request, GomokuGame $game): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $game->belongsToUser($user->id) && $game->mode === 'pvp') {
|
||||
return response()->json(['ok' => false, 'message' => '无权访问该对局。']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'game_id' => $game->id,
|
||||
'mode' => $game->mode,
|
||||
'status' => $game->status,
|
||||
'board' => $game->board,
|
||||
'current_turn' => $game->current_turn,
|
||||
'winner' => $game->winner,
|
||||
'your_color' => $game->colorOf($user->id),
|
||||
'ai_level' => $game->ai_level,
|
||||
'reward_gold' => $game->reward_gold,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── 私有工具方法 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 结算对局:更新状态、发放奖励、广播事件。
|
||||
*
|
||||
* @param GomokuGame $game 当前对局
|
||||
* @param array $board 最终棋盘
|
||||
* @param array $history 落子历史
|
||||
* @param int $winnerColor 胜方颜色(0=平局)
|
||||
* @param string $reason 结束原因(win/draw/resign)
|
||||
* @param \App\Models\User $currentUser 当前操作用户(用于加载用户名)
|
||||
*/
|
||||
private function finishGame(
|
||||
GomokuGame $game,
|
||||
array $board,
|
||||
array $history,
|
||||
int $winnerColor,
|
||||
string $reason,
|
||||
mixed $currentUser
|
||||
): JsonResponse {
|
||||
$rewardGold = 0;
|
||||
$winnerName = '';
|
||||
$loserName = '';
|
||||
|
||||
// 加载对局玩家信息
|
||||
$game->load('playerBlack', 'playerWhite');
|
||||
|
||||
if ($winnerColor === 0) {
|
||||
// 平局
|
||||
$winnerName = '';
|
||||
$loserName = '';
|
||||
|
||||
// PvE 平局:返还入场费
|
||||
if ($game->mode === 'pve' && $game->entry_fee > 0) {
|
||||
$this->currency->change(
|
||||
$game->playerBlack,
|
||||
'gold',
|
||||
$game->entry_fee,
|
||||
CurrencySource::GOMOKU_REFUND,
|
||||
'五子棋 AI 平局返还入场费',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 有胜负
|
||||
$rewardGold = $this->calculateReward($game, $winnerColor);
|
||||
|
||||
if ($game->mode === 'pvp') {
|
||||
$winnerUser = $winnerColor === 1 ? $game->playerBlack : $game->playerWhite;
|
||||
$loserUser = $winnerColor === 1 ? $game->playerWhite : $game->playerBlack;
|
||||
$winnerName = $winnerUser?->username ?? '';
|
||||
$loserName = $loserUser?->username ?? '';
|
||||
|
||||
// 将英文 reason 转为友好的中文后缀
|
||||
$reasonText = match ($reason) {
|
||||
'resign' => '(认输)',
|
||||
'timeout' => '(超时)',
|
||||
default => '',
|
||||
};
|
||||
|
||||
// 发放 PvP 胜利奖励给获胜玩家
|
||||
if ($winnerUser && $rewardGold > 0) {
|
||||
$this->currency->change(
|
||||
$winnerUser,
|
||||
'gold',
|
||||
$rewardGold,
|
||||
CurrencySource::GOMOKU_WIN,
|
||||
"五子棋:击败 {$loserName}{$reasonText}",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// PvE 模式:winnerColor=1 代表玩家胜
|
||||
if ($winnerColor === 1) {
|
||||
$winnerName = $game->playerBlack->username ?? '';
|
||||
$loserName = "AI(难度{$game->ai_level})";
|
||||
|
||||
if ($rewardGold > 0) {
|
||||
$this->currency->change(
|
||||
$game->playerBlack,
|
||||
'gold',
|
||||
$rewardGold,
|
||||
CurrencySource::GOMOKU_WIN,
|
||||
"五子棋:击败 AI(难度{$game->ai_level})",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// AI 获胜:入场费已扣,无返还
|
||||
$winnerName = "AI(难度{$game->ai_level})";
|
||||
$loserName = $game->playerBlack->username ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$game->update([
|
||||
'status' => 'finished',
|
||||
'board' => $board,
|
||||
'moves_history' => $history,
|
||||
'winner' => $winnerColor,
|
||||
'reward_gold' => $rewardGold,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
// 广播对局结束事件给参与对局的双方
|
||||
broadcast(new GomokuFinishedEvent($game->fresh(), $winnerName, $loserName, $reason));
|
||||
|
||||
// 有胜负,均向房间广播系统通知
|
||||
if ($winnerColor !== 0) {
|
||||
if ($game->mode === 'pvp') {
|
||||
// PvP:胜方玩家获奖通知
|
||||
$reasonText = match ($reason) {
|
||||
'resign' => '(认输)',
|
||||
default => '',
|
||||
};
|
||||
$text = "♟️ 【五子棋】玩家对战结果!恭喜玩家【{$winnerName}】击败了【{$loserName}】{$reasonText},赢得 {$rewardGold} 金币!";
|
||||
} elseif ($winnerColor === 1) {
|
||||
// PvE:玩家获胜
|
||||
$text = "♟️ 【五子棋】棋神降临!恭喜玩家【{$winnerName}】在人机对战(难度{$game->ai_level})中击败 AI,赢得 {$rewardGold} 金币!";
|
||||
} else {
|
||||
// PvE:AI 获胜(玩家输了)
|
||||
$text = "♟️ 【五子棋】AI 大获全胜!玩家【{$loserName}】在人机对战(难度{$game->ai_level})中不敌 AI,再接再厉!";
|
||||
}
|
||||
$this->broadcastSystemMessage($game->room_id, $text);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'finished' => true,
|
||||
'winner' => $winnerColor,
|
||||
'winner_name' => $winnerName,
|
||||
'reason' => $reason,
|
||||
'reward_gold' => $rewardGold,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送系统房间广播。
|
||||
*
|
||||
* @param int $roomId 房间ID
|
||||
* @param string $content 广播内容
|
||||
*/
|
||||
private function broadcastSystemMessage(int $roomId, string $content): void
|
||||
{
|
||||
$chatState = app(\App\Services\ChatStateService::class);
|
||||
$messageData = [
|
||||
'id' => $chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706', // 琥珀橙色
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$chatState->pushMessage($roomId, $messageData);
|
||||
broadcast(new \App\Events\MessageSent($roomId, $messageData));
|
||||
\App\Jobs\SaveMessageJob::dispatch($messageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据对局模式和获胜方计算奖励金币。
|
||||
*
|
||||
* @param GomokuGame $game 对局
|
||||
* @param int $winnerColor 胜方颜色
|
||||
*/
|
||||
private function calculateReward(GomokuGame $game, int $winnerColor): int
|
||||
{
|
||||
if ($game->mode === 'pvp') {
|
||||
// PvP 胜利奖励从游戏配置读取
|
||||
return (int) GameConfig::param('gomoku', 'pvp_reward', 80);
|
||||
}
|
||||
|
||||
// PvE:AI 胜利无奖励
|
||||
if ($winnerColor !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 按难度从游戏配置读取胜利奖励
|
||||
$key = match ((int) $game->ai_level) {
|
||||
1 => 'pve_easy_reward',
|
||||
2 => 'pve_normal_reward',
|
||||
3 => 'pve_hard_reward',
|
||||
default => 'pve_expert_reward',
|
||||
};
|
||||
|
||||
$defaults = ['pve_easy_reward' => 20, 'pve_normal_reward' => 50, 'pve_hard_reward' => 120, 'pve_expert_reward' => 300];
|
||||
|
||||
return (int) GameConfig::param('gomoku', $key, $defaults[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 AI 难度获取 PvE 入场费。
|
||||
*
|
||||
* @param int $aiLevel AI 难度(1-4)
|
||||
*/
|
||||
private function getPveEntryFee(int $aiLevel): int
|
||||
{
|
||||
// 从游戏配置读取各难度入场费,支持后台实时调整
|
||||
$key = match ($aiLevel) {
|
||||
1 => 'pve_easy_fee',
|
||||
2 => 'pve_normal_fee',
|
||||
3 => 'pve_hard_fee',
|
||||
default => 'pve_expert_fee',
|
||||
};
|
||||
|
||||
$defaults = ['pve_easy_fee' => 0, 'pve_normal_fee' => 10, 'pve_hard_fee' => 30, 'pve_expert_fee' => 80];
|
||||
|
||||
return (int) GameConfig::param('gomoku', $key, $defaults[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户是否有进行中的对局(重进页面时用于恢复)。
|
||||
*
|
||||
* 返回对局基础信息,包含模式、棋盘状态与双方用户名,
|
||||
* 让前端弹出「继续 / 认输」选择。
|
||||
*/
|
||||
public function active(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$game = GomokuGame::query()
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('player_black_id', $user->id)
|
||||
->orWhere('player_white_id', $user->id);
|
||||
})
|
||||
->whereIn('status', ['waiting', 'playing'])
|
||||
->with('playerBlack', 'playerWhite')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $game) {
|
||||
return response()->json(['ok' => true, 'has_active' => false]);
|
||||
}
|
||||
|
||||
// 对阵双方用户名
|
||||
$blackName = $game->playerBlack->username ?? '黑棋';
|
||||
$whiteName = $game->mode === 'pve'
|
||||
? ('AI(难度'.$game->ai_level.')')
|
||||
: ($game->playerWhite?->username ?? '等待中…');
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'has_active' => true,
|
||||
'game_id' => $game->id,
|
||||
'mode' => $game->mode,
|
||||
'status' => $game->status,
|
||||
'ai_level' => $game->ai_level,
|
||||
'your_color' => $game->colorOf($user->id),
|
||||
'board' => $game->board,
|
||||
'current_turn' => $game->current_turn,
|
||||
'black_name' => $blackName,
|
||||
'white_name' => $whiteName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利前台领取控制器
|
||||
*
|
||||
* 用户通过聊天室内弹窗点击"立即领取"调用此接口,
|
||||
* 完成金币入账并返回领取结果。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\HolidayClaim;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HolidayController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户领取节日福利红包。
|
||||
*
|
||||
* 从 holiday_claims 中查找当前用户的待领取记录,
|
||||
* 入账金币并更新活动统计数据。
|
||||
*/
|
||||
public function claim(Request $request, HolidayEvent $event): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 活动是否在领取有效期内
|
||||
if (! $event->isClaimable()) {
|
||||
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
}
|
||||
|
||||
// 查找该用户的领取记录(批量插入时已生成)
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $claim) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
|
||||
// 防止重复领取(claimed_at 为 null 表示未领取)
|
||||
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
|
||||
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
|
||||
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
|
||||
// 金币入账
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
$claim->amount,
|
||||
CurrencySource::HOLIDAY_BONUS,
|
||||
"节日福利:{$event->name}",
|
||||
);
|
||||
|
||||
// 更新活动统计(只在首次领取时)
|
||||
HolidayEvent::query()
|
||||
->where('id', $event->id)
|
||||
->increment('claimed_amount', $claim->amount);
|
||||
|
||||
// 删除领取记录(以此标记"已领取",防止重复调用)
|
||||
$claim->delete();
|
||||
|
||||
// 检查是否已全部领完
|
||||
if ($event->max_claimants > 0) {
|
||||
$remaining = HolidayClaim::where('event_id', $event->id)->count();
|
||||
if ($remaining === 0) {
|
||||
$event->update(['status' => 'completed']);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "🎉 恭喜!已领取 {$claim->amount} 金币!",
|
||||
'amount' => $claim->amount,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户在指定活动中的待领取状态。
|
||||
*/
|
||||
public function status(Request $request, HolidayEvent $event): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'claimable' => $claim !== null && $event->isClaimable(),
|
||||
'amount' => $claim?->amount ?? 0,
|
||||
'expires_at' => $event->expires_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:赛马竞猜前台控制器
|
||||
*
|
||||
* 提供用户在聊天室内参与赛马的 API 接口:
|
||||
* - 查询当前场次信息(马匹、注池、赔率)
|
||||
* - 提交下注(扣除金币 + 写入下注记录)
|
||||
* - 查询本人下注状态
|
||||
* - 查询最近历史记录
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\HorseBet;
|
||||
use App\Models\HorseRace;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class HorseRaceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的场次信息(前端轮询或事件触发后调用)。
|
||||
*/
|
||||
public function currentRace(Request $request): JsonResponse
|
||||
{
|
||||
$race = HorseRace::currentRace();
|
||||
|
||||
if (! $race) {
|
||||
return response()->json(['race' => null]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$myBet = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
// 计算各马匹当前注额
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
||||
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||
|
||||
$horsePools = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
->groupBy('horse_id')
|
||||
->selectRaw('horse_id, SUM(amount) as pool')
|
||||
->pluck('pool', 'horse_id')
|
||||
->toArray();
|
||||
|
||||
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
|
||||
|
||||
// 计算实时赔率
|
||||
$horses = $race->horses ?? [];
|
||||
$horsesWithBets = array_map(function ($horse) use ($horsePools, $oddsMap) {
|
||||
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
|
||||
$odds = $horsePool > 0 ? ($oddsMap[$horse['id']] ?? null) : null;
|
||||
|
||||
return [
|
||||
'id' => $horse['id'],
|
||||
'name' => $horse['name'],
|
||||
'emoji' => $horse['emoji'],
|
||||
'pool' => $horsePool,
|
||||
'odds' => $odds,
|
||||
];
|
||||
}, $horses);
|
||||
|
||||
return response()->json([
|
||||
'race' => [
|
||||
'id' => $race->id,
|
||||
'status' => $race->status,
|
||||
'bet_closes_at' => $race->bet_closes_at?->toIso8601String(),
|
||||
'seconds_left' => $race->status === 'betting'
|
||||
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
|
||||
: 0,
|
||||
'horses' => $horsesWithBets,
|
||||
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
|
||||
'my_bet' => $myBet ? [
|
||||
'horse_id' => $myBet->horse_id,
|
||||
'amount' => $myBet->amount,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提交下注。
|
||||
*
|
||||
* 同一场每人限下一注,下注成功后立即扣除金币。
|
||||
*/
|
||||
public function bet(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('horse_racing')) {
|
||||
return response()->json(['ok' => false, 'message' => '赛马竞猜当前未开启。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'race_id' => 'required|integer|exists:horse_races,id',
|
||||
'horse_id' => 'required|integer|min:1',
|
||||
'amount' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
$maxBet = (int) ($config['max_bet'] ?? 100000);
|
||||
|
||||
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
|
||||
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
|
||||
}
|
||||
|
||||
$race = HorseRace::find($data['race_id']);
|
||||
|
||||
if (! $race || ! $race->isBettingOpen()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||
}
|
||||
|
||||
// 验证马匹 ID 是否有效
|
||||
$horses = $race->horses ?? [];
|
||||
$validIds = array_column($horses, 'id');
|
||||
if (! in_array($data['horse_id'], $validIds, true)) {
|
||||
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$currency = $this->currency;
|
||||
|
||||
// 校验余额
|
||||
if (($user->jjb ?? 0) < $data['amount']) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $race, $data, $currency, $horses): JsonResponse {
|
||||
// 幂等:同一场只能下一注
|
||||
$existing = HorseBet::query()
|
||||
->where('race_id', $race->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['ok' => false, 'message' => '本场您已下注,请等待开奖。']);
|
||||
}
|
||||
|
||||
// 找出马匹名称
|
||||
$horseName = '';
|
||||
foreach ($horses as $horse) {
|
||||
if ((int) $horse['id'] === (int) $data['horse_id']) {
|
||||
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 扣除金币
|
||||
$currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$data['amount'],
|
||||
CurrencySource::HORSE_BET,
|
||||
"赛马 #{$race->id} 押注 {$horseName}",
|
||||
);
|
||||
|
||||
// 写入下注记录
|
||||
HorseBet::create([
|
||||
'race_id' => $race->id,
|
||||
'user_id' => $user->id,
|
||||
'horse_id' => $data['horse_id'],
|
||||
'amount' => $data['amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$chatState = app(ChatStateService::class);
|
||||
$formattedAmount = number_format($data['amount']);
|
||||
$content = "🌟 🐎 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$chatState->pushMessage(1, $msg);
|
||||
event(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已押注「{$horseName}」{$data['amount']} 金币,等待开跑!",
|
||||
'amount' => $data['amount'],
|
||||
'horse_id' => $data['horse_id'],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近10场历史记录(前端展示胜负趋势)。
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$races = HorseRace::query()
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->get(['id', 'horses', 'winner_horse_id', 'total_pool', 'total_bets', 'settled_at']);
|
||||
|
||||
// 转换获胜马匹名称
|
||||
$history = $races->map(function ($race) {
|
||||
$winnerName = '未知';
|
||||
foreach (($race->horses ?? []) as $horse) {
|
||||
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
|
||||
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $race->id,
|
||||
'winner_id' => $race->winner_horse_id,
|
||||
'winner_name' => $winnerName,
|
||||
'total_pool' => $race->total_pool,
|
||||
'total_bets' => $race->total_bets,
|
||||
'settled_at' => $race->settled_at?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['history' => $history]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class InviteController extends Controller
|
||||
{
|
||||
/**
|
||||
* 处理邀请链接跳转
|
||||
*
|
||||
* @param int $inviter_id 邀请人ID
|
||||
*/
|
||||
public function handle(Request $request, int $inviter_id)
|
||||
{
|
||||
// 查找邀请人是否存在
|
||||
$inviter = User::find($inviter_id);
|
||||
|
||||
if ($inviter) {
|
||||
// 将邀请人ID记录到 Cookie 中,有效期7天(7 * 24 * 60 = 10080 分钟)
|
||||
// 确保Cookie仅通过 HTTP 访问且作用于全站
|
||||
Cookie::queue('inviter_id', $inviter->id, 10080);
|
||||
}
|
||||
|
||||
// 重定向回聊天室首页进行注册/登录
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立展示邀请全站排行榜页面
|
||||
*/
|
||||
public function leaderboard()
|
||||
{
|
||||
// 邀请达人榜 (Top 50)
|
||||
$topInviters = User::withCount('invitees')
|
||||
->with(['activePosition.position.department'])
|
||||
->having('invitees_count', '>', 0)
|
||||
->orderByDesc('invitees_count')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return view('invite.leaderboard', compact('topInviters'));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
/**
|
||||
* 文件功能:全局风云排行榜控制器
|
||||
* 各种维度(等级、经验、交友币、魅力)的前20名抓取与缓存展示。
|
||||
* 新增今日榜:显示今天经验成长、今日金币获得、今日魅力增长最多的用户。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -12,26 +13,33 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LeaderboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* 渲染排行榜主视角
|
||||
* 注入积分统计服务(用于今日榜单数据查询)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 渲染排行榜主视角(包含累计榜 + 今日榜)
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// 缓存 15 分钟,防止每秒几百个人看排行榜把数据库扫死
|
||||
// 选用 remember 则在过期时自动执行闭包查询并重置缓存
|
||||
$ttl = 60 * 15;
|
||||
|
||||
// 管理员等级阈值,排行榜中隐藏管理员
|
||||
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
|
||||
|
||||
// 排行榜显示人数(后台可配置)
|
||||
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
|
||||
|
||||
// ── 累计榜(15分钟缓存)──────────────────────────────
|
||||
$ttl = 60 * 15;
|
||||
|
||||
// 1. 境界榜 (以 user_level 为尊)
|
||||
$topLevels = Cache::remember('leaderboard:top_levels', $ttl, function () use ($superLevel, $topN) {
|
||||
return User::select('id', 'username', 'usersf', 'user_level', 'sex')
|
||||
@@ -76,6 +84,57 @@ class LeaderboardController extends Controller
|
||||
->get();
|
||||
});
|
||||
|
||||
return view('leaderboard.index', compact('topLevels', 'topExp', 'topWealth', 'topCharm'));
|
||||
// ── 今日榜(5分钟缓存,数据来自 user_currency_logs 流水表)──
|
||||
$todayTtl = 60 * 5;
|
||||
$today = today()->toDateString();
|
||||
|
||||
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
|
||||
);
|
||||
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
|
||||
);
|
||||
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('charm', $topN, $today)
|
||||
);
|
||||
|
||||
return view('leaderboard.index', compact(
|
||||
'topLevels', 'topExp', 'topWealth', 'topCharm',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日风云榜独立页(经验/金币/魅力今日排行)
|
||||
*/
|
||||
public function todayIndex(): View
|
||||
{
|
||||
$todayTtl = 60 * 5;
|
||||
$today = today()->toDateString();
|
||||
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
|
||||
|
||||
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
|
||||
);
|
||||
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
|
||||
);
|
||||
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
|
||||
fn () => $this->currencyService->todayLeaderboard('charm', $topN, $today)
|
||||
);
|
||||
|
||||
return view('leaderboard.today', compact('todayExp', 'todayGold', 'todayCharm'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户个人流水日志页(查询自己的经验/金币/魅力操作历史)
|
||||
*/
|
||||
public function myLogs(): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
$currency = request('currency');
|
||||
$days = (int) request('days', 7);
|
||||
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
|
||||
|
||||
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:双色球彩票 HTTP 控制器
|
||||
*
|
||||
* 提供前端所需的四个 API 接口:
|
||||
* - current() : 当期状态 + 奖池 + 我的购票列表
|
||||
* - buy() : 购买一注或多注(支持机选)
|
||||
* - history() : 历史期次列表
|
||||
* - my() : 我的全部购票记录
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\LotteryIssue;
|
||||
use App\Models\LotteryTicket;
|
||||
use App\Services\LotteryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LotteryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LotteryService $lottery,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
|
||||
*/
|
||||
public function current(): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('lottery')) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
|
||||
|
||||
if (! $issue) {
|
||||
return response()->json(['enabled' => true, 'issue' => null]);
|
||||
}
|
||||
|
||||
$myTickets = LotteryTicket::query()
|
||||
->where('issue_id', $issue->id)
|
||||
->where('user_id', Auth::id())
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'numbers' => $t->numbersLabel(),
|
||||
'red1' => $t->red1,
|
||||
'red2' => $t->red2,
|
||||
'red3' => $t->red3,
|
||||
'blue' => $t->blue,
|
||||
'is_quick' => $t->is_quick_pick,
|
||||
'prize_level' => $t->prize_level,
|
||||
'payout' => $t->payout,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'enabled' => true,
|
||||
'is_open' => $issue->isOpen(),
|
||||
'issue' => [
|
||||
'id' => $issue->id,
|
||||
'issue_no' => $issue->issue_no,
|
||||
'status' => $issue->status,
|
||||
'pool_amount' => $issue->pool_amount,
|
||||
'is_super_issue' => $issue->is_super_issue,
|
||||
'no_winner_streak' => $issue->no_winner_streak,
|
||||
'seconds_left' => $issue->secondsUntilDraw(),
|
||||
'draw_at' => $issue->draw_at?->toDateTimeString(),
|
||||
'sell_closes_at' => $issue->sell_closes_at?->toDateTimeString(),
|
||||
'red1' => $issue->red1,
|
||||
'red2' => $issue->red2,
|
||||
'red3' => $issue->red3,
|
||||
'blue' => $issue->blue,
|
||||
],
|
||||
'my_tickets' => $myTickets,
|
||||
'my_ticket_count' => $myTickets->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 购票接口:支持自选和机选,支持一次购买多注。
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'numbers' => 'required|array|min:1',
|
||||
'numbers.*.reds' => 'required|array|size:3',
|
||||
'numbers.*.reds.*' => 'required|integer|min:1|max:12',
|
||||
'numbers.*.blue' => 'required|integer|min:1|max:6',
|
||||
'quick_pick' => 'boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$tickets = $this->lottery->buyTickets(
|
||||
user: Auth::user(),
|
||||
numbers: $request->input('numbers'),
|
||||
quickPick: (bool) $request->input('quick_pick', false),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '购票成功!共 '.count($tickets).' 注',
|
||||
'count' => count($tickets),
|
||||
]);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 机选号码接口(仅生成号码,不扣费,供前端展示后确认购买)。
|
||||
*/
|
||||
public function quickPick(Request $request): JsonResponse
|
||||
{
|
||||
$count = min((int) $request->input('count', 1), 10);
|
||||
|
||||
return response()->json([
|
||||
'numbers' => $this->lottery->quickPick($count),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史期次列表。
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$issues = LotteryIssue::query()
|
||||
->where('status', 'settled')
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($i) => [
|
||||
'issue_no' => $i->issue_no,
|
||||
'red1' => $i->red1,
|
||||
'red2' => $i->red2,
|
||||
'red3' => $i->red3,
|
||||
'blue' => $i->blue,
|
||||
'pool_amount' => $i->pool_amount,
|
||||
'payout_amount' => $i->payout_amount,
|
||||
'total_tickets' => $i->total_tickets,
|
||||
'is_super_issue' => $i->is_super_issue,
|
||||
'no_winner_streak' => $i->no_winner_streak,
|
||||
'draw_at' => $i->draw_at?->toDateTimeString(),
|
||||
]);
|
||||
|
||||
return response()->json(['issues' => $issues]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的购票记录(跨期次)。
|
||||
*/
|
||||
public function my(): JsonResponse
|
||||
{
|
||||
$tickets = LotteryTicket::query()
|
||||
->where('user_id', Auth::id())
|
||||
->with('issue:id,issue_no,status,red1,red2,red3,blue,draw_at')
|
||||
->latest()
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn ($t) => [
|
||||
'issue_no' => $t->issue?->issue_no,
|
||||
'status' => $t->issue?->status,
|
||||
'numbers' => $t->numbersLabel(),
|
||||
'prize_level' => $t->prize_level,
|
||||
'payout' => $t->payout,
|
||||
'is_quick' => $t->is_quick_pick,
|
||||
'created_at' => $t->created_at->toDateTimeString(),
|
||||
]);
|
||||
|
||||
return response()->json(['tickets' => $tickets]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台婚姻控制器
|
||||
*
|
||||
* 处理求婚、接受/拒绝、查询婚姻状态、申请离婚等前台操作。
|
||||
* 所有操作通过 MarriageService 执行,Events 负责广播。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\MarriageAccepted;
|
||||
use App\Events\MarriageDivorced;
|
||||
use App\Events\MarriageDivorceRequested;
|
||||
use App\Events\MarriageProposed;
|
||||
use App\Events\MarriageRejected;
|
||||
use App\Models\Marriage;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\MarriageConfigService;
|
||||
use App\Services\MarriageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MarriageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MarriageService $marriage,
|
||||
private readonly MarriageConfigService $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取离婚相关惩罚配置(供前端展示风险提示)。
|
||||
* 返回协议离婚魅力惩罚、强制离婚魅力惩罚及各冷静期天数。
|
||||
*/
|
||||
public function divorceConfig(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'mutual_charm_penalty' => (int) $this->config->get('divorce_mutual_charm', 100),
|
||||
'forced_charm_penalty' => (int) $this->config->get('divorce_forced_charm', 300),
|
||||
'mutual_cooldown_days' => (int) $this->config->get('divorce_mutual_cooldown', 70),
|
||||
'forced_cooldown_days' => (int) $this->config->get('divorce_forced_cooldown', 90),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的婚姻状态(名片/用户列表用)。
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$marriage = Marriage::currentFor($user->id);
|
||||
|
||||
if (! $marriage) {
|
||||
return response()->json(['married' => false]);
|
||||
}
|
||||
|
||||
$marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']);
|
||||
|
||||
return response()->json([
|
||||
'married' => $marriage->status === 'married',
|
||||
'status' => $marriage->status,
|
||||
'marriage' => [
|
||||
'id' => $marriage->id,
|
||||
'user' => $marriage->user,
|
||||
'partner' => $marriage->partner,
|
||||
'ring' => $marriage->ringItem?->only(['name', 'icon']),
|
||||
'intimacy' => $marriage->intimacy,
|
||||
'level' => $marriage->level,
|
||||
'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level),
|
||||
'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level),
|
||||
'married_at' => $marriage->married_at?->toDateString(),
|
||||
'days' => $marriage->married_at?->diffInDays(now()),
|
||||
'proposed_at' => $marriage->proposed_at,
|
||||
'expires_at' => $marriage->expires_at,
|
||||
'divorce_type' => $marriage->divorce_type,
|
||||
'divorcer_id' => $marriage->divorcer_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询目标用户的婚姻信息(用于双击名片展示)。
|
||||
*/
|
||||
public function targetStatus(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['username' => 'required|string']);
|
||||
$target = \App\Models\User::where('username', $request->username)->firstOrFail();
|
||||
|
||||
$marriage = Marriage::query()
|
||||
->where('status', 'married')
|
||||
->where(function ($q) use ($target) {
|
||||
$q->where('user_id', $target->id)->orWhere('partner_id', $target->id);
|
||||
})
|
||||
->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon'])
|
||||
->first();
|
||||
|
||||
if (! $marriage) {
|
||||
return response()->json(['married' => false, 'marriage' => ['status' => 'none']]);
|
||||
}
|
||||
|
||||
$partner = $marriage->user_id === $target->id ? $marriage->partner : $marriage->user;
|
||||
|
||||
return response()->json([
|
||||
'married' => true,
|
||||
'marriage' => [
|
||||
'status' => $marriage->status,
|
||||
'marriage_id' => $marriage->id,
|
||||
'partner_name' => $partner?->username,
|
||||
'is_my_partner' => $partner?->id === $request->user()?->id,
|
||||
'ring' => $marriage->ringItem?->only(['name', 'icon']),
|
||||
'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level),
|
||||
'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level),
|
||||
'days' => $marriage->married_at?->diffInDays(now()),
|
||||
'intimacy' => $marriage->intimacy,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起求婚。
|
||||
*/
|
||||
public function propose(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'target_username' => 'required|string',
|
||||
'ring_purchase_id' => 'required|integer',
|
||||
'wedding_tier_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$proposer = $request->user();
|
||||
$target = \App\Models\User::where('username', $data['target_username'])->first();
|
||||
|
||||
if (! $target) {
|
||||
return response()->json(['ok' => false, 'message' => '用户不存在。'], 404);
|
||||
}
|
||||
|
||||
$result = $this->marriage->propose($proposer, $target, $data['ring_purchase_id'], $data['wedding_tier_id'] ?? null);
|
||||
|
||||
if ($result['ok']) {
|
||||
$marriage = Marriage::find($result['marriage_id']);
|
||||
// 广播给被求婚方(私人频道)
|
||||
broadcast(new MarriageProposed($marriage, $proposer, $target));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户持有的有效戒指列表(求婚前选择用)。
|
||||
*/
|
||||
public function myRings(Request $request): JsonResponse
|
||||
{
|
||||
$rings = UserPurchase::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
|
||||
->with('item:id,name,slug,icon,price,intimacy_bonus,charm_bonus')
|
||||
->get()
|
||||
->map(fn ($p) => [
|
||||
'purchase_id' => $p->id,
|
||||
'name' => $p->item->name,
|
||||
'icon' => $p->item->icon,
|
||||
'slug' => $p->item->slug,
|
||||
'intimacy_bonus' => (int) ($p->item->intimacy_bonus ?? 0),
|
||||
'charm_bonus' => (int) ($p->item->charm_bonus ?? 0),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'success', 'rings' => $rings]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受求婚。
|
||||
*/
|
||||
public function accept(Request $request, Marriage $marriage): JsonResponse
|
||||
{
|
||||
$result = $this->marriage->accept($marriage, $request->user());
|
||||
|
||||
if ($result['ok']) {
|
||||
$marriage->refresh();
|
||||
// 广播全房间结婚公告
|
||||
broadcast(new MarriageAccepted($marriage));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝求婚。
|
||||
*/
|
||||
public function reject(Request $request, Marriage $marriage): JsonResponse
|
||||
{
|
||||
$result = $this->marriage->reject($marriage, $request->user());
|
||||
|
||||
if ($result['ok']) {
|
||||
broadcast(new MarriageRejected($marriage));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请离婚(协议或强制)。
|
||||
*/
|
||||
public function divorce(Request $request, Marriage $marriage): JsonResponse
|
||||
{
|
||||
$type = $request->input('type', 'mutual'); // mutual | forced
|
||||
$result = $this->marriage->divorce($marriage, $request->user(), $type);
|
||||
|
||||
if ($result['ok']) {
|
||||
$marriage->refresh();
|
||||
if ($marriage->status === 'divorced') {
|
||||
broadcast(new MarriageDivorced($marriage, $type));
|
||||
} else {
|
||||
// 协议离婚:通知对方
|
||||
broadcast(new MarriageDivorceRequested($marriage));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认协议离婚。
|
||||
*/
|
||||
public function confirmDivorce(Request $request, Marriage $marriage): JsonResponse
|
||||
{
|
||||
$result = $this->marriage->confirmDivorce($marriage, $request->user());
|
||||
|
||||
if ($result['ok']) {
|
||||
$marriage->refresh();
|
||||
broadcast(new MarriageDivorced($marriage, 'mutual'));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝协议离婚申请(被申请方选择不同意 = 视为强制离婚)。
|
||||
* 申请人赔偿一半金币给对方,婚姻以 forced 类型解除。
|
||||
*/
|
||||
public function rejectDivorce(Request $request, Marriage $marriage): JsonResponse
|
||||
{
|
||||
$result = $this->marriage->rejectDivorce($marriage, $request->user());
|
||||
|
||||
if ($result['ok']) {
|
||||
$marriage->refresh();
|
||||
broadcast(new MarriageDivorced($marriage, 'forced'));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:神秘箱子前台控制器
|
||||
*
|
||||
* 提供神秘箱子相关接口:
|
||||
* - /mystery-box/status 查询当前可领取的箱子(给前端轮询)
|
||||
* - /mystery-box/claim 用户发送暗号领取箱子
|
||||
*
|
||||
* 领取流程:
|
||||
* 1. 用户在聊天框发送暗号(前端拦截后调用此接口)
|
||||
* 2. 验证暗号匹配、箱子未过期、未已领取
|
||||
* 3. 随机奖励金额(trap=扣,其余=加)
|
||||
* 4. 写货币流水日志
|
||||
* 5. 公屏广播结果(中奖/踩雷)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\MysteryBox;
|
||||
use App\Models\MysteryBoxClaim;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MysteryBoxController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
$box = MysteryBox::currentOpenBox();
|
||||
|
||||
if (! $box) {
|
||||
return response()->json(['active' => false]);
|
||||
}
|
||||
|
||||
// 计算剩余时间
|
||||
$secondsLeft = $box->expires_at ? max(0, now()->diffInSeconds($box->expires_at, false)) : null;
|
||||
|
||||
return response()->json([
|
||||
'active' => true,
|
||||
'box_id' => $box->id,
|
||||
'box_type' => $box->box_type,
|
||||
'type_name' => $box->typeName(),
|
||||
'type_emoji' => $box->typeEmoji(),
|
||||
'passcode' => $box->passcode,
|
||||
'seconds_left' => $secondsLeft,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户用暗号领取箱子。
|
||||
*/
|
||||
public function claim(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('mystery_box')) {
|
||||
return response()->json(['ok' => false, 'message' => '神秘箱子功能未开放。']);
|
||||
}
|
||||
|
||||
$passcode = strtoupper(trim((string) $request->input('passcode', '')));
|
||||
|
||||
if ($passcode === '') {
|
||||
return response()->json(['ok' => false, 'message' => '请输入暗号。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $passcode): JsonResponse {
|
||||
// 查找匹配暗号的可领取箱子(加锁防并发)
|
||||
$box = MysteryBox::query()
|
||||
->where('passcode', $passcode)
|
||||
->where('status', 'open')
|
||||
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $box) {
|
||||
return response()->json(['ok' => false, 'message' => '暗号不正确,或箱子已被领走/已过期。']);
|
||||
}
|
||||
|
||||
// ① 随机奖励金额
|
||||
$reward = $box->rollReward();
|
||||
|
||||
// ② 货币变更
|
||||
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
|
||||
$remark = $reward >= 0
|
||||
? "神秘箱子【{$box->typeName()}】奖励"
|
||||
: '神秘箱子【黑化箱】陷阱扣除';
|
||||
|
||||
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
|
||||
|
||||
// ③ 写领取记录 + 更新箱子状态
|
||||
MysteryBoxClaim::create([
|
||||
'mystery_box_id' => $box->id,
|
||||
'user_id' => $user->id,
|
||||
'reward_amount' => $reward,
|
||||
]);
|
||||
|
||||
$box->update(['status' => 'claimed']);
|
||||
|
||||
// ④ 公屏广播结果
|
||||
$user->refresh();
|
||||
$this->broadcastResult($box, $user->username, $reward);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reward' => $reward,
|
||||
'balance' => $user->jjb ?? 0,
|
||||
'message' => $reward >= 0
|
||||
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
|
||||
: '☠️ 中了黑化陷阱!扣除 '.abs($reward).' 金币!',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 公屏广播开箱结果。
|
||||
*
|
||||
* @param MysteryBox $box 箱子实例
|
||||
* @param string $username 领取者用户名
|
||||
* @param int $reward 奖励金额(正/负)
|
||||
*/
|
||||
private function broadcastResult(MysteryBox $box, string $username, int $reward): void
|
||||
{
|
||||
$emoji = $box->typeEmoji();
|
||||
$typeName = $box->typeName();
|
||||
|
||||
if ($reward >= 0) {
|
||||
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}!"
|
||||
.'获得 💰'.number_format($reward).' 金币!';
|
||||
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
||||
} else {
|
||||
$content = "☠️【黑化陷阱】haha!【{$username}】 中了神秘黑化箱的陷阱!"
|
||||
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
|
||||
$color = '#f87171';
|
||||
}
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => $color,
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室礼包(红包)控制器
|
||||
*
|
||||
* 提供两个核心接口:
|
||||
* - send() :superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
|
||||
* - claim() :在线用户抢礼包(先到先得,每人一份)
|
||||
*
|
||||
* 接入 UserCurrencyService 记录所有货币变动流水。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\RedPacketClaimed;
|
||||
use App\Events\RedPacketSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\RedPacketClaim;
|
||||
use App\Models\RedPacketEnvelope;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RedPacketController extends Controller
|
||||
{
|
||||
/** 礼包固定总数量 */
|
||||
private const TOTAL_AMOUNT = 8888;
|
||||
|
||||
/** 礼包固定份数 */
|
||||
private const TOTAL_COUNT = 10;
|
||||
|
||||
/** 礼包有效期(秒) */
|
||||
private const EXPIRE_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* 构造函数:注入依赖服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* superlevel 站长凭空发出礼包。
|
||||
*
|
||||
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
|
||||
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
|
||||
*
|
||||
* @param Request $request 需包含 room_id 和 type(gold / exp)
|
||||
*/
|
||||
public function send(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'type' => 'required|in:gold,exp',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type'); // 'gold' 或 'exp'
|
||||
|
||||
// 权限校验:仅 superlevel 可发礼包
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if ($user->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
|
||||
}
|
||||
|
||||
// 检查该用户在此房间是否有进行中的红包(防止刷包)
|
||||
$activeExists = RedPacketEnvelope::query()
|
||||
->where('sender_id', $user->id)
|
||||
->where('room_id', $roomId)
|
||||
->where('status', 'active')
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($activeExists) {
|
||||
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
|
||||
}
|
||||
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT)
|
||||
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
|
||||
|
||||
// 货币展示文案
|
||||
$typeLabel = $type === 'exp' ? '经验' : '金币';
|
||||
$typeIcon = $type === 'exp' ? '✨' : '💰';
|
||||
$btnBg = $type === 'exp'
|
||||
? 'linear-gradient(135deg,#7c3aed,#4f46e5)'
|
||||
: 'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
// 事务:创建红包记录 + Redis 写入分额
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
|
||||
// 创建红包主记录(凭空发出,不扣发包人货币)
|
||||
$envelope = RedPacketEnvelope::create([
|
||||
'sender_id' => $user->id,
|
||||
'sender_username' => $user->username,
|
||||
'room_id' => $roomId,
|
||||
'type' => $type,
|
||||
'total_amount' => self::TOTAL_AMOUNT,
|
||||
'total_count' => self::TOTAL_COUNT,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'status' => 'active',
|
||||
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
|
||||
]);
|
||||
|
||||
// 将拆分好的数量序列存入 Redis(List,LPOP 抢红包)
|
||||
$key = "red_packet:{$envelope->id}:amounts";
|
||||
foreach ($amounts as $amt) {
|
||||
\Illuminate\Support\Facades\Redis::rpush($key, $amt);
|
||||
}
|
||||
// 多留 60s,确保领完后仍可回查
|
||||
\Illuminate\Support\Facades\Redis::expire($key, self::EXPIRE_SECONDS + 60);
|
||||
|
||||
return $envelope;
|
||||
});
|
||||
|
||||
// 广播系统公告,含可点击「立即抢包」按钮
|
||||
// 注意这里不能死命传 self::EXPIRE_SECONDS,因为这句话会被存入数据库的历史记录。我们需要在取出来的时候能根据发包时间动态变化!
|
||||
// 啊等等!由于这条消息是直接静态写入 `chat_messages` 内容里的,这就意味着如果在这里计算,存进去的还是 300。
|
||||
// 所以我们还是传 `self::EXPIRE_SECONDS` 作为总寿命,在前端逻辑里利用 `Date.now()` 和消息的 `sent_at` 来算出真实剩余倒计时更为严谨!
|
||||
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
|
||||
.$envelope->id
|
||||
.',\''.$user->username.'\','
|
||||
.self::TOTAL_AMOUNT.','
|
||||
.self::TOTAL_COUNT.','
|
||||
.self::EXPIRE_SECONDS
|
||||
.',\''.$type.'\''
|
||||
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
|
||||
.'color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:12px;font-weight:bold;'
|
||||
.'box-shadow:0 2px 6px rgba(0,0,0,0.3);">'.$typeIcon.' 立即抢包</button>';
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 广播红包事件(触发前端弹出红包卡片)
|
||||
broadcast(new RedPacketSent(
|
||||
roomId: $roomId,
|
||||
envelopeId: $envelope->id,
|
||||
senderUsername: $user->username,
|
||||
totalAmount: self::TOTAL_AMOUNT,
|
||||
totalCount: self::TOTAL_COUNT,
|
||||
expireSeconds: self::EXPIRE_SECONDS,
|
||||
type: $type,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询礼包当前状态(弹窗打开时实时刷新用)。
|
||||
*
|
||||
* 返回:剩余份数、是否已过期、当前用户是否已领取。
|
||||
*
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function status(int $envelopeId): JsonResponse
|
||||
{
|
||||
$envelope = RedPacketEnvelope::find($envelopeId);
|
||||
if (! $envelope) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$isExpired = $envelope->expires_at->isPast();
|
||||
$remainingCount = $envelope->remainingCount();
|
||||
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
// 若已过期但 status 尚未同步,顺手更新为 expired
|
||||
if ($isExpired && $envelope->status === 'active') {
|
||||
$envelope->update(['status' => 'expired']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'remaining_count' => $remainingCount,
|
||||
'total_count' => $envelope->total_count,
|
||||
'envelope_status' => $isExpired ? 'expired' : $envelope->status,
|
||||
'is_expired' => $isExpired,
|
||||
'has_claimed' => $hasClaimed,
|
||||
'type' => $envelope->type ?? 'gold',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户抢礼包(先到先得)。
|
||||
*
|
||||
* 使用 Redis LPOP 原子操作获取数量,再写入数据库流水。
|
||||
* 重复领取通过 unique 约束保障幂等性。
|
||||
* 按红包 type 字段决定入账金币还是经验。
|
||||
*
|
||||
* @param Request $request 需包含 room_id
|
||||
* @param int $envelopeId 红包 ID
|
||||
*/
|
||||
public function claim(Request $request, int $envelopeId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
|
||||
// 加载红包记录
|
||||
$envelope = RedPacketEnvelope::find($envelopeId);
|
||||
if (! $envelope) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
|
||||
}
|
||||
|
||||
// 检查红包是否可领
|
||||
if (! $envelope->isClaimable()) {
|
||||
return response()->json(['status' => 'error', 'message' => '红包已抢完或已过期'], 422);
|
||||
}
|
||||
|
||||
// 检查是否已领取过
|
||||
$alreadyClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
if ($alreadyClaimed) {
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 从 Redis 原子 POP 一份数量
|
||||
$redisKey = "red_packet:{$envelopeId}:amounts";
|
||||
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
|
||||
if ($amount === null || $amount === false) {
|
||||
return response()->json(['status' => 'error', 'message' => '礼包已被抢完!'], 422);
|
||||
}
|
||||
$amount = (int) $amount;
|
||||
// 兼容旧记录(type 字段可能为 null)
|
||||
$envelopeType = $envelope->type ?? 'gold';
|
||||
|
||||
// 事务:写领取记录 + 更新统计 + 货币入账
|
||||
try {
|
||||
DB::transaction(function () use ($envelope, $user, $amount, $roomId, $envelopeType): void {
|
||||
// 写领取记录(unique 约束保障不重复)
|
||||
RedPacketClaim::create([
|
||||
'envelope_id' => $envelope->id,
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'amount' => $amount,
|
||||
'claimed_at' => now(),
|
||||
]);
|
||||
|
||||
// 更新红包统计
|
||||
$envelope->increment('claimed_count');
|
||||
$envelope->increment('claimed_amount', $amount);
|
||||
|
||||
// 若已全部领完,关闭红包
|
||||
$envelope->refresh();
|
||||
if ($envelope->claimed_count >= $envelope->total_count) {
|
||||
$envelope->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
// 按类型入账(金币或经验)
|
||||
if ($envelopeType === 'exp') {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'exp',
|
||||
$amount,
|
||||
CurrencySource::RED_PACKET_RECV_EXP,
|
||||
"抢到礼包 {$amount} 经验(红包#{$envelope->id})",
|
||||
$roomId,
|
||||
);
|
||||
} else {
|
||||
$this->currencyService->change(
|
||||
$user,
|
||||
'gold',
|
||||
$amount,
|
||||
CurrencySource::RED_PACKET_RECV,
|
||||
"抢到礼包 {$amount} 金币(红包#{$envelope->id})",
|
||||
$roomId,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// 并发重复领取:将数量放回 Redis(补偿)
|
||||
\Illuminate\Support\Facades\Redis::rpush($redisKey, $amount);
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
|
||||
}
|
||||
|
||||
// 广播领取事件(给自己的私有频道,前端弹 Toast)
|
||||
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
|
||||
|
||||
// 在聊天室发送领取播报(所有人可见)
|
||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
|
||||
$claimedMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $claimedMsg);
|
||||
broadcast(new MessageSent($roomId, $claimedMsg));
|
||||
SaveMessageJob::dispatch($claimedMsg);
|
||||
|
||||
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
||||
$balanceNow = $user->fresh()->$balanceField;
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'amount' => $amount,
|
||||
'type' => $envelopeType,
|
||||
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}:{$balanceNow}。",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机拆分礼包数量。
|
||||
*
|
||||
* 使用「二倍均值法」:每次随机数量不超过剩余均值的 2 倍,
|
||||
* 保证每份至少 1 且总额精确等于 totalAmount。
|
||||
*
|
||||
* @param int $total 总数量
|
||||
* @param int $count 份数
|
||||
* @return int[] 每份数量数组
|
||||
*/
|
||||
private function splitAmount(int $total, int $count): array
|
||||
{
|
||||
$amounts = [];
|
||||
$remaining = $total;
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$leftCount = $count - $i;
|
||||
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
|
||||
$max = max(1, $max);
|
||||
$amount = random_int(1, $max);
|
||||
$amounts[] = $amount;
|
||||
$remaining -= $amount;
|
||||
}
|
||||
|
||||
// 最后一份为剩余全部
|
||||
$amounts[] = $remaining;
|
||||
|
||||
// 打乱顺序,避免后来者必得少
|
||||
shuffle($amounts);
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:商店控制器
|
||||
* 提供商品列表查询、商品购买(含赠送特效广播)、改名卡使用 三个接口
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\ShopItem;
|
||||
use App\Services\ShopService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ShopController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入商店服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ShopService $shopService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取商店商品列表及当前用户状态
|
||||
*
|
||||
* 返回字段:items(商品列表)、user_jjb(当前金币)、
|
||||
* active_week_effect(当前周卡)、has_rename_card(是否持有改名卡)
|
||||
*/
|
||||
public function items(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$items = ShopItem::active()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'slug' => $item->slug,
|
||||
'description' => $item->description,
|
||||
'icon' => $item->icon,
|
||||
'price' => $item->price,
|
||||
'type' => $item->type,
|
||||
'duration_days' => $item->duration_days,
|
||||
'duration_minutes' => $item->duration_minutes,
|
||||
'intimacy_bonus' => $item->intimacy_bonus,
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
|
||||
// 统计背包中各戒指持有数量
|
||||
$ringCounts = \App\Models\UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
|
||||
->selectRaw('shop_item_id, count(*) as qty')
|
||||
->groupBy('shop_item_id')
|
||||
->pluck('qty', 'shop_item_id')
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'items' => $items,
|
||||
'user_jjb' => $user->jjb ?? 0,
|
||||
'active_week_effect' => $this->shopService->getActiveWeekEffect($user),
|
||||
'has_rename_card' => $this->shopService->hasRenameCard($user),
|
||||
'ring_counts' => $ringCounts,
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买商品
|
||||
*
|
||||
* 单次特效卡额外支持:
|
||||
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
|
||||
* - message 公屏赠言(可选)
|
||||
*
|
||||
* @param Request $request 含 item_id, recipient?, message?
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
|
||||
|
||||
$item = ShopItem::find($request->item_id);
|
||||
if (! $item->is_active) {
|
||||
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
|
||||
}
|
||||
|
||||
$result = $this->shopService->buyItem(Auth::user(), $item);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
$response = ['status' => 'success', 'message' => $result['message']];
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
$message = trim($request->input('message', ''));
|
||||
|
||||
// recipient 为空或 "all" 表示全员
|
||||
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
|
||||
|
||||
// 广播特效事件(全员频道)
|
||||
broadcast(new EffectBroadcast(
|
||||
roomId: $roomId,
|
||||
type: $result['play_effect'],
|
||||
operator: $user->username,
|
||||
targetUsername: $targetUsername,
|
||||
giftMessage: $message ?: null,
|
||||
))->toOthers();
|
||||
|
||||
// 同时前端也需要播放(自己也要看到)
|
||||
$response['play_effect'] = $result['play_effect'];
|
||||
$response['target_username'] = $targetUsername;
|
||||
$response['gift_message'] = $message ?: null;
|
||||
|
||||
// 公屏系统消息
|
||||
if ($roomId > 0) {
|
||||
$icons = [
|
||||
'fireworks' => '🎆',
|
||||
'rain' => '🌧',
|
||||
'lightning' => '⚡',
|
||||
'snow' => '❄️',
|
||||
];
|
||||
// 赠礼消息文案(改成"为XX触发了一场特效")
|
||||
$icon = $icons[$result['play_effect']] ?? '✨';
|
||||
$toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友';
|
||||
$remarkPart = $message ? " 「{$message}」" : '';
|
||||
$sysContent = "{$icon} {$user->username} 为 {$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
|
||||
|
||||
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
|
||||
$sysMsgEvent = new MessageSent(
|
||||
roomId: $roomId,
|
||||
message: [
|
||||
'id' => 0,
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音', // 触发金色左边框样式(已有处理分支)
|
||||
'to_user' => '大家',
|
||||
'content' => $sysContent,
|
||||
'font_color' => '#cc6600',
|
||||
'sent_at' => now()->format('H:i:s'),
|
||||
'is_secret' => false,
|
||||
'action' => null,
|
||||
]
|
||||
);
|
||||
broadcast($sysMsgEvent);
|
||||
}
|
||||
} else {
|
||||
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
|
||||
if ($roomId > 0) {
|
||||
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
|
||||
$fishDuration = '';
|
||||
if ($item->type === 'auto_fishing') {
|
||||
$mins = (int) ($item->duration_minutes ?? 0);
|
||||
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
|
||||
}
|
||||
|
||||
// 根据商品类型生成不同通知文案
|
||||
$sysContent = match ($item->type) {
|
||||
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
||||
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
|
||||
};
|
||||
|
||||
broadcast(new MessageSent(
|
||||
roomId: $roomId,
|
||||
message: [
|
||||
'id' => 0,
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $sysContent,
|
||||
'font_color' => '#7c3aed',
|
||||
'sent_at' => now()->format('H:i:s'),
|
||||
'is_secret' => false,
|
||||
'action' => null,
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 返回最新金币余额
|
||||
$response['jjb'] = Auth::user()->fresh()->jjb;
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用改名卡修改昵称
|
||||
*
|
||||
* @param Request $request 含 new_name
|
||||
*/
|
||||
public function rename(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'new_name' => 'required|string|min:1|max:10',
|
||||
]);
|
||||
|
||||
$result = $this->shopService->useRenameCard(Auth::user(), $request->new_name);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => $result['message']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:老虎机游戏前台控制器
|
||||
*
|
||||
* 提供老虎机转动 API:
|
||||
* - 检查游戏开关、每日限制
|
||||
* - 扣除金币、生成三列图案
|
||||
* - 判断结果、赔付金币、写流水
|
||||
* - 三个7时全服公屏广播
|
||||
* - 返回结果供前端播放动画
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\SlotMachineLog;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SlotMachineController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取老虎机配置信息(图案表、赔率、今日剩余次数)。
|
||||
*/
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('slot_machine')) {
|
||||
return response()->json(['enabled' => false]);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||
$user = $request->user();
|
||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
||||
$usedToday = 0;
|
||||
|
||||
if ($dailyLimit > 0) {
|
||||
$usedToday = SlotMachineLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'enabled' => true,
|
||||
'cost_per_spin' => (int) ($config['cost_per_spin'] ?? 100),
|
||||
'daily_limit' => $dailyLimit,
|
||||
'used_today' => $usedToday,
|
||||
'remaining' => $dailyLimit > 0 ? max(0, $dailyLimit - $usedToday) : null,
|
||||
'symbols' => collect(SlotMachineLog::symbols())->map(fn ($s) => $s['emoji']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次转动。
|
||||
*
|
||||
* 流程:检查可玩 → 扣费 → 摇号 → 赔付 → 写日志 → 全服广播(三7)→ 返回结果
|
||||
*/
|
||||
public function spin(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('slot_machine')) {
|
||||
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('slot_machine')?->params ?? [];
|
||||
$cost = (int) ($config['cost_per_spin'] ?? 100);
|
||||
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// 金币余额检查
|
||||
if (($user->jjb ?? 0) < $cost) {
|
||||
return response()->json(['ok' => false, 'message' => "金币不足,每次转动需 {$cost} 金币。"]);
|
||||
}
|
||||
|
||||
// 每日次数限制检查
|
||||
if ($dailyLimit > 0) {
|
||||
$usedToday = SlotMachineLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
|
||||
if ($usedToday >= $dailyLimit) {
|
||||
return response()->json(['ok' => false, 'message' => "今日已转动 {$dailyLimit} 次,明日再来!"]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
|
||||
// ① 扣费
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$cost,
|
||||
CurrencySource::SLOT_SPIN,
|
||||
'老虎机转动消耗',
|
||||
);
|
||||
|
||||
// ② 摇号
|
||||
$r1 = SlotMachineLog::randomSymbol();
|
||||
$r2 = SlotMachineLog::randomSymbol();
|
||||
$r3 = SlotMachineLog::randomSymbol();
|
||||
|
||||
$resultType = SlotMachineLog::judgeResult($r1, $r2, $r3);
|
||||
$symbols = SlotMachineLog::symbols();
|
||||
|
||||
// ③ 计算赔付金额
|
||||
$payout = $this->calcPayout($resultType, $cost, $config);
|
||||
|
||||
// ④ 赔付金币
|
||||
if ($payout > 0) {
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
$payout,
|
||||
CurrencySource::SLOT_WIN,
|
||||
"老虎机 {$resultType} 中奖",
|
||||
);
|
||||
} elseif ($resultType === 'curse' && ($config['curse_enabled'] ?? true)) {
|
||||
// 诅咒:再扣一倍本金
|
||||
$cursePenalty = $cost;
|
||||
if (($user->jjb ?? 0) >= $cursePenalty) {
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$cursePenalty,
|
||||
CurrencySource::SLOT_CURSE,
|
||||
'老虎机三骷髅诅咒额外扣除',
|
||||
);
|
||||
$payout = -$cursePenalty; // 净损失 = 本金+惩罚
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 写游戏日志
|
||||
$resultLabel = SlotMachineLog::resultLabel($resultType);
|
||||
SlotMachineLog::create([
|
||||
'user_id' => $user->id,
|
||||
'reel1' => $r1,
|
||||
'reel2' => $r2,
|
||||
'reel3' => $r3,
|
||||
'result_type' => $resultType,
|
||||
'cost' => $cost,
|
||||
'payout' => $payout,
|
||||
]);
|
||||
|
||||
// ⑥ 广播通知
|
||||
$e1 = $symbols[$r1]['emoji'];
|
||||
$e2 = $symbols[$r2]['emoji'];
|
||||
$e3 = $symbols[$r3]['emoji'];
|
||||
|
||||
if ($resultType === 'jackpot') {
|
||||
// 三个7:全服公屏广播
|
||||
$this->broadcastJackpot($user->username, $payout, $cost);
|
||||
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
|
||||
// 普通中奖:仅向本人发送聊天室系统通知
|
||||
$net = $payout - $cost;
|
||||
$content = "🎰 {$resultLabel}!{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
|
||||
$this->broadcastPersonal($user->username, $content);
|
||||
} elseif ($resultType === 'curse') {
|
||||
// 诅咒:通知本人
|
||||
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
|
||||
$this->broadcastPersonal($user->username, $content);
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reels' => [$r1, $r2, $r3],
|
||||
'emojis' => [
|
||||
$symbols[$r1]['emoji'],
|
||||
$symbols[$r2]['emoji'],
|
||||
$symbols[$r3]['emoji'],
|
||||
],
|
||||
'result_type' => $resultType,
|
||||
'result_label' => SlotMachineLog::resultLabel($resultType),
|
||||
'payout' => $payout, // 净变化(正=赢,负=额外亏)
|
||||
'net_change' => $payout - $cost, // 相对本金的盈亏(debug 用)
|
||||
'balance' => $user->jjb ?? 0,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近10条个人记录。
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$logs = SlotMachineLog::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->get(['id', 'reel1', 'reel2', 'reel3', 'result_type', 'cost', 'payout', 'created_at']);
|
||||
|
||||
$symbols = SlotMachineLog::symbols();
|
||||
|
||||
return response()->json([
|
||||
'history' => $logs->map(fn ($l) => [
|
||||
'emojis' => [$symbols[$l->reel1]['emoji'], $symbols[$l->reel2]['emoji'], $symbols[$l->reel3]['emoji']],
|
||||
'result_label' => SlotMachineLog::resultLabel($l->result_type),
|
||||
'payout' => $l->payout,
|
||||
'created_at' => $l->created_at->format('H:i'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算赔付金额(赢时返还 = 本金 × 赔率)。
|
||||
*
|
||||
* @return int 正数=赢得金额(含本金返还),0=不赔付
|
||||
*/
|
||||
private function calcPayout(string $resultType, int $cost, array $config): int
|
||||
{
|
||||
$multiplier = match ($resultType) {
|
||||
'jackpot' => (int) ($config['jackpot_payout'] ?? 100),
|
||||
'triple_gem' => (int) ($config['triple_payout'] ?? 50),
|
||||
'triple' => (int) ($config['same_payout'] ?? 10),
|
||||
'pair' => (int) ($config['pair_payout'] ?? 2),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
return $multiplier > 0 ? $cost * $multiplier : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三个7全服公屏广播。
|
||||
*/
|
||||
private function broadcastJackpot(string $username, int $payout, int $cost): void
|
||||
{
|
||||
$net = $payout - $cost;
|
||||
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
|
||||
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
|
||||
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向特定用户发送聊天室私人系统通知(仅该用户可见)。
|
||||
*
|
||||
* @param string $toUsername 接收用户名
|
||||
* @param string $content 消息内容
|
||||
*/
|
||||
private function broadcastPersonal(string $toUsername, string $content): void
|
||||
{
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => $toUsername,
|
||||
'content' => $content,
|
||||
'is_secret' => true,
|
||||
'font_color' => '#f59e0b',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user