Compare commits
232
Commits
cfdbf387af
...
v2.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b15e42891d | ||
|
|
214a422504 | ||
|
|
f16f10fe82 | ||
|
|
a3daf3f074 | ||
|
|
d0a38352a5 | ||
|
|
c06e265c0d | ||
|
|
6ae7a4a82b | ||
|
|
792b0765fd | ||
|
|
3e0fb33a9b | ||
|
|
e7049b5f5b | ||
|
|
62371a7c64 | ||
|
|
540d8bf6ff | ||
|
|
bf2d63f125 | ||
|
|
4f22fd552a | ||
|
|
790730e2c2 | ||
|
|
eeb9dfbade | ||
|
|
1c067e452b | ||
|
|
e50502d8f6 | ||
|
|
e8b4dcc968 | ||
|
|
e177ad6d4d | ||
|
|
f17f171f4b | ||
|
|
d10a354370 | ||
|
|
efb03f90b8 | ||
|
|
3ecafd01ea | ||
|
|
d82aa1c434 | ||
|
|
3db8e4ab82 | ||
|
|
10d158b38a | ||
|
|
ea02c36ea6 | ||
|
|
2c8cb21206 | ||
|
|
c0cb7f5ead | ||
|
|
83c312196c | ||
|
|
66206fa521 | ||
|
|
ee62a3add8 | ||
|
|
3c749969b4 | ||
|
|
277cb617da | ||
|
|
dd9ae46c04 | ||
|
|
3d8e270df4 | ||
|
|
8db1a252d7 | ||
|
|
3e85cb67bc | ||
|
|
40a0849151 | ||
|
|
442ca0e1e2 | ||
|
|
3f2eb7d48b | ||
|
|
c9d4d3dbf4 | ||
|
|
f6bc8a83c3 | ||
|
|
a09927f6fd | ||
|
|
16b709d1da | ||
|
|
a16a8fb9f4 | ||
|
|
bcb762df77 | ||
|
|
ffccfa26e9 | ||
|
|
32af6abeb2 | ||
|
|
d4082e0edd | ||
|
|
0402097b59 | ||
|
|
b07f4e971a | ||
|
|
e69bceeb77 | ||
|
|
00270b3904 | ||
|
|
61cfc2091c | ||
|
|
af772350c9 | ||
|
|
f0269c7c17 | ||
|
|
17d1885efc | ||
|
|
c8a9c92b56 | ||
|
|
62bd92c1c6 | ||
|
|
0ac12364bb | ||
|
|
36ac9d090b | ||
|
|
7fc40eba32 | ||
|
|
aecabde44c | ||
|
|
4cdcaa537f | ||
|
|
891e18e83f | ||
|
|
2e8bfb61c2 | ||
|
|
be0052119f | ||
|
|
5b6f687db6 | ||
|
|
e53f2f5d9d | ||
|
|
ee4abdff85 | ||
|
|
925f2498c5 | ||
|
|
6f779edb91 | ||
|
|
8d038c698f | ||
|
|
7966c0f662 | ||
|
|
1f1c329085 | ||
|
|
0953e03b73 | ||
|
|
9ba18315cc | ||
|
|
8e57ad8a45 | ||
|
|
a5c43383e1 | ||
|
|
63f6dc7106 | ||
|
|
48d646d723 | ||
|
|
d5f9cf7371 | ||
|
|
414dc52a3b | ||
|
|
3bbde9b4dd | ||
|
|
b622053bc2 | ||
|
|
66a9e8ad23 | ||
|
|
585a6fbf5f | ||
|
|
9f61dcc619 | ||
|
|
dac8adfc5a | ||
|
|
8a690ac40d | ||
|
|
c7b8ba956b | ||
|
|
e6a686233f | ||
|
|
55d3dd43ba | ||
|
|
fdd20917a4 | ||
|
|
a6bc6c61c5 | ||
|
|
f8fb849714 | ||
|
|
43d613bfb1 | ||
|
|
a766f2a9c5 | ||
|
|
14511b6230 | ||
|
|
136240e5e1 | ||
|
|
ed2d60a24e | ||
|
|
1e9f6673cc | ||
|
|
a5c2022422 | ||
|
|
d4a6a799fe | ||
|
|
e3a3f6a596 | ||
|
|
35ee91530b | ||
|
|
de7c79f0de | ||
|
|
19e50944d9 | ||
|
|
4d337a2e8f | ||
|
|
e876e76ec7 | ||
|
|
35cbfedeb7 | ||
|
|
b7af9330c4 | ||
|
|
cedc787586 | ||
|
|
7900145ba9 | ||
|
|
c3229f870a | ||
|
|
4352919889 | ||
|
|
90d2fcc14a | ||
|
|
a62e7ff41f | ||
|
|
4815d201f6 | ||
|
|
4751a5578e | ||
|
|
85a419fe53 | ||
|
|
d875dd08ec | ||
|
|
2d4593c597 | ||
|
|
91ddfbb408 | ||
|
|
ed7ff81321 | ||
|
|
bb401b8940 | ||
|
|
f54757414d | ||
|
|
d46fe85c70 | ||
|
|
0623120c00 | ||
|
|
ed484c9235 | ||
|
|
6cb63a98e2 | ||
|
|
c44c8198d2 | ||
|
|
992615b7be | ||
|
|
a0aaceecc3 | ||
|
|
c7ad87a767 | ||
|
|
2180fa5ff4 | ||
|
|
8bf913c568 | ||
|
|
22981802ca | ||
|
|
9cc10d56a7 | ||
|
|
8cdf8492fb | ||
|
|
e9a3310649 | ||
|
|
db545aa32e | ||
|
|
086a46dda6 | ||
|
|
5823028276 | ||
|
|
21a727a693 | ||
|
|
ef434f0703 | ||
|
|
5938976360 | ||
|
|
0cfb43183a | ||
|
|
990b939a3a | ||
|
|
4f869eb6ea | ||
|
|
20317f4699 | ||
|
|
83fff919a5 | ||
|
|
95484681c5 | ||
|
|
a847dad00a | ||
|
|
e341f2d1a7 | ||
|
|
318aeba684 | ||
|
|
4b1d47e96c | ||
|
|
8886f5c690 | ||
|
|
c8bc81f961 | ||
|
|
283793bc1c | ||
|
|
84b3624d59 | ||
|
|
74602c7a7b | ||
|
|
31f53ddfbe | ||
|
|
d012b3e73b | ||
|
|
308e5690fe | ||
|
|
2f09d5e2ed | ||
|
|
ac8a3b959d | ||
|
|
518cc86a93 | ||
|
|
3773cf905b | ||
|
|
1a750f2fe7 | ||
|
|
37e4c5d4c5 | ||
|
|
0526419dbc | ||
|
|
89f344bd29 | ||
|
|
247283a282 | ||
|
|
4df557bb9e | ||
|
|
54faf8b501 | ||
|
|
be22710424 | ||
|
|
1db22dc5de | ||
|
|
0310798675 | ||
|
|
04ee32e4d5 | ||
|
|
ef471ec68b | ||
|
|
3e525eaa36 | ||
|
|
ce6f8552c1 | ||
|
|
1429dee8a6 | ||
|
|
cf42071c29 | ||
|
|
10e9835530 | ||
|
|
64a1e5d769 | ||
|
|
ca61dd42f7 | ||
|
|
e9c3fc989c | ||
|
|
c858f6af0c | ||
|
|
2f246f9112 | ||
|
|
f1d8d20180 | ||
|
|
e3cba255f9 | ||
|
|
128b52d0aa | ||
|
|
c410897231 | ||
|
|
855d031b04 | ||
|
|
4d3f4f7a4b | ||
|
|
f18eefe9bc | ||
|
|
8bd1dae9e1 | ||
|
|
5bfcd75442 | ||
|
|
8b15507f22 | ||
|
|
97c021dae2 | ||
|
|
8cf5029711 | ||
|
|
aab609f69b | ||
|
|
a0268b611f | ||
|
|
fc68aaff72 | ||
|
|
32584f11d2 | ||
|
|
a3b5184470 | ||
|
|
b2b91b9238 | ||
|
|
9f8b5e7524 | ||
|
|
5273b4ee4b | ||
|
|
4486a87326 | ||
|
|
be9fc09d9d | ||
|
|
34356a26ae | ||
|
|
0f0bfef2a8 | ||
|
|
d7ec42a025 | ||
|
|
6c631aa495 | ||
|
|
fb96747352 | ||
|
|
7c27ba0c48 | ||
|
|
bef797abd5 | ||
|
|
73c6674fc4 | ||
|
|
b0028c515f | ||
|
|
a066580014 | ||
|
|
5a6446b832 | ||
|
|
a17a67f533 | ||
|
|
fed51dda18 | ||
|
|
c209221bad | ||
|
|
590b7d5b35 | ||
|
|
f0769a841e | ||
|
|
281315d1cf |
@@ -0,0 +1,335 @@
|
||||
# 🛡️ 聊天室项目 — 安全与访问速度优化规划方案
|
||||
|
||||
> **项目路径:** `/Users/pllx/Web/Herd/chatroom`
|
||||
> **技术栈:** Laravel 12 + PHP 8.4 + Redis + MySQL + Reverb (WebSocket) + TailwindCSS 4 + Vite
|
||||
> **检查日期:** 2026-04-27
|
||||
|
||||
---
|
||||
|
||||
## 一、安全优化(🔴高危 / 🟡中危 / 🟢低危)
|
||||
|
||||
### 🔴 1. 关闭 APP_DEBUG(生产环境)
|
||||
|
||||
**当前:** `.env` 中 `APP_DEBUG=true`
|
||||
**风险:** 生产环境开启 DEBUG 会在报错时泄露数据库密码、Redis 密码、Reverb 密钥等敏感信息。
|
||||
|
||||
**方案:**
|
||||
```
|
||||
# .env 生产环境改为
|
||||
APP_DEBUG=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 2. 启用 Session 加密
|
||||
|
||||
**当前:** `SESSION_ENCRYPT=false`
|
||||
**风险:** Session 数据以明文存储在 Redis 中,若 Redis 被入侵或存在 SSRF,用户身份数据全部泄露。
|
||||
|
||||
**方案:**
|
||||
```
|
||||
# .env 添加
|
||||
SESSION_ENCRYPT=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 3. 限制 Reverb WebSocket 允许源(Allowed Origins)
|
||||
|
||||
**当前:** `config/reverb.php` 中 `'allowed_origins' => ['*']`
|
||||
**风险:** 任何第三方网站均可连接你的 WebSocket 服务,可被用于 CSWSH(Cross-Site WebSocket Hijacking)攻击,窃取聊天消息。
|
||||
|
||||
**方案:**
|
||||
```php
|
||||
// config/reverb.php
|
||||
'allowed_origins' => [
|
||||
env('APP_URL', 'http://chatroom.test'),
|
||||
// 如果有多个域名,手动列出
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 4. Reverb WebSocket 启用 TLS(WSS)
|
||||
|
||||
**当前:** `REVERB_SCHEME=http`,WebSocket 走明文 HTTP
|
||||
**风险:** 所有聊天消息、用户在线状态等实时数据明文传输,可被中间人攻击窃听。
|
||||
|
||||
**方案:**
|
||||
```
|
||||
# .env 生产环境
|
||||
REVERB_SCHEME=https
|
||||
REVERB_PORT=443 # 或 8443
|
||||
# 并在 reverb.php 中配置 TLS 证书路径
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 5. 设置 Session Cookie Secure 标志
|
||||
|
||||
**当前:** `SESSION_SECURE_COOKIE` 未设置(null)
|
||||
**风险:** 在 HTTPS 下,未标记 Secure 的 Cookie 仍可能被非 HTTPS 连接泄露。
|
||||
|
||||
**方案:**
|
||||
```
|
||||
# .env 生产环境
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=strict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 6. 加强登录安全策略
|
||||
|
||||
**当前状况:**
|
||||
- 存在验证码(mews/captcha)✅
|
||||
- 登录有 `throttle:chat-login` 限流 ✅
|
||||
- 自动注册(用户名+密码即可注册)⚡ 双刃剑
|
||||
- MD5 老密码兼容 ✅ 会自动升级为 Bcrypt
|
||||
|
||||
**优化方案:**
|
||||
|
||||
| 项目 | 建议 |
|
||||
|------|------|
|
||||
| 登录失败锁定 | 同一 IP 5 次失败后临时锁定 15 分钟,后端实现 |
|
||||
| 密码强度 | 最低 6 位,建议增加 min:6 验证或至少含数字/字母 |
|
||||
| 管理员登录 2FA | id=1 站长登录时增加二次验证(如邮箱验证码) |
|
||||
| 验证码频率 | 同一 IP 每天最多注册 3 个账号,防恶意注册 |
|
||||
|
||||
---
|
||||
|
||||
### 🟡 7. 敏感字段防止 Mass Assignment
|
||||
|
||||
**当前:** User 模型的 `$fillable` 中包含 `user_level`、`jjb`、`meili`、`bank_jjb`、`exp_num` 等金钱/权限字段。
|
||||
|
||||
**风险:** 如果其他地方调用了 `User::create($request->all())` 或 `User::update($request->all())` 且未使用 FormRequest 过滤,可能导致权限提升或刷币。
|
||||
|
||||
**方案:**
|
||||
- 将 `user_level`、`jjb`、`meili`、`bank_jjb`、`exp_num` 等敏感字段移出 `$fillable`
|
||||
- 仅在特定 Service 中使用 `forceFill()` 并加日志审计
|
||||
|
||||
---
|
||||
|
||||
### 🟡 8. XSS 输出转义检查
|
||||
|
||||
**当前:** 消息内容 `content` 限制 500 字符 ✅,但需确认前端渲染时是否正确转义 HTML。
|
||||
|
||||
**需要检查的点:**
|
||||
- [ ] 聊天消息在前端如何渲染?(`innerHTML` 还是 `textContent`?)
|
||||
- [ ] 用户签名(sign)字段是否转义?
|
||||
- [ ] 房间公告是否转义?
|
||||
- [ ] 用户头像路径是否校验?(当前有基本校验)
|
||||
|
||||
**建议加固:**
|
||||
- 前端渲染消息一律使用 `textContent` 或 Vue/React 自动转义
|
||||
- 如果必须支持 HTML 表情/颜色,使用白名单 sanitizer(如 DOMPurify)
|
||||
|
||||
---
|
||||
|
||||
### 🟡 9. 管理员操作审计加强
|
||||
|
||||
**当前:** 已有 `PositionAuthorityLog` 和 `AdminLog` 记录 ✅
|
||||
**建议:**
|
||||
- 所有金币/积分操作必须有完整的前后对比日志
|
||||
- 敏感操作(封号、解封、改权限)推送微信通知给站长
|
||||
|
||||
---
|
||||
|
||||
### 🟡 10. 隐藏管理员入口路径
|
||||
|
||||
**当前:** `/lkddi` 作为管理员登录入口(隐藏路径),但路径硬编码在 `routes/web.php` 中。
|
||||
**风险:** 任何能阅读源码或通过路径扫描的人都能发现。
|
||||
|
||||
**方案(可选):**
|
||||
- 改为通过环境变量配置:`ADMIN_LOGIN_PATH=lkddi`
|
||||
- 增加 IP 白名单限制:仅站长 IP 可访问 `/admin/*`
|
||||
|
||||
---
|
||||
|
||||
### 🟢 11. 其他安全改进
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| CSP Header | 添加 Content-Security-Policy HTTP 头,限制脚本执行来源 |
|
||||
| X-Frame-Options | 添加 DENY/SAMEORIGIN 防止点击劫持 |
|
||||
| Reverb 消息大小上限 | 当前 10KB,建议根据业务适当降低 |
|
||||
| 依赖安全扫描 | 定期运行 `composer audit` 检查 Laravel 及第三方包漏洞 |
|
||||
| 文件上传安全 | 自定义头像上传已限制图片类型 ✅,但建议增加文件内容校验 |
|
||||
|
||||
---
|
||||
|
||||
## 二、访问速度优化(🔥高优 / ⚡中优 / 💡低优)
|
||||
|
||||
### 🔥 1. 启用 Laravel OPcache
|
||||
|
||||
**当前环境:** 通过 Laravel Herd 运行(PHP-FPM),未启用 OPcache。
|
||||
**影响:** 每个 PHP 请求都要重新编译框架文件,浪费大量 CPU。
|
||||
|
||||
**方案(Mac/Linux 生产环境):**
|
||||
```ini
|
||||
; php.ini
|
||||
opcache.enable=1
|
||||
opcache.memory_consumption=128
|
||||
opcache.max_accelerated_files=10000
|
||||
opcache.revalidate_freq=0
|
||||
opcache.validate_timestamps=0 ; 生产环境关闭文件修改检查
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔥 2. 数据库查询优化
|
||||
|
||||
**当前状况:**
|
||||
- 使用 Redis 缓存,但部分页面可能直接查询 MySQL
|
||||
- 存在多个游戏(百家乐、赛马、彩票、五子棋等),每次查询都走数据库
|
||||
|
||||
**优化方案:**
|
||||
|
||||
| 措施 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 排行榜缓存 | `->remember(60)` 缓存排行榜结果 60 秒 | 🔥 |
|
||||
| 在线人数缓存 | 当前使用 Redis 实时维护 ✅,保持现状 | - |
|
||||
| 房间列表缓存 | `Room::all()` 结果缓存到 Redis | 🔥 |
|
||||
| 游戏配置缓存 | `GameConfig::isEnabled()` 结果缓存 10 秒 | ⚡ |
|
||||
| 慢查询日志 | 启用 MySQL slow_query_log,定位慢 SQL | ⚡ |
|
||||
|
||||
---
|
||||
|
||||
### 🔥 3. 静态资源 CDN 加速
|
||||
|
||||
**当前:** 所有静态资源(CSS、JS、图片)直接从源服务器加载。
|
||||
|
||||
**方案:**
|
||||
|
||||
| 资源类型 | 方案 |
|
||||
|----------|------|
|
||||
| Vite 构建产物(CSS/JS) | 上传到 CDN(阿里云 OSS+CDN / CloudFlare R2) |
|
||||
| 头像图片 | 启用单独域名或 CDN,添加长期缓存头 |
|
||||
| 聊天背景图 | 使用 CDN 分发 12 张背景图 |
|
||||
| Reverb WS 连接 | 通过 CDN/反向代理(如 Nginx)代理 WSS 连接 |
|
||||
|
||||
**当前已做:** `.htaccess` 中已对 Vite 构建产物设置 31536000 秒缓存 ✅
|
||||
**改进:** 将 `public/build/` 下的资源部署到 CDN。
|
||||
|
||||
---
|
||||
|
||||
### 🔥 4. Reverb WebSocket 优化
|
||||
|
||||
**当前:** Reverb 单节点运行,HTTP 协议,端口 8080。
|
||||
|
||||
**优化方案:**
|
||||
|
||||
| 措施 | 说明 |
|
||||
|------|------|
|
||||
| 升级为 WSS | 启用 TLS,避免被运营商劫持/限速 |
|
||||
| 水平扩展 | 启用 Reverb Scaling(通过 Redis 发布订阅,见 `reverb.php` 配置) |
|
||||
| Nginx 反向代理 | 用 Nginx 代理 WSS,可同时处理 HTTP 静态资源 |
|
||||
| 心跳优化 | 当前 `ping_interval=60s`,可考虑适当延长 |
|
||||
|
||||
---
|
||||
|
||||
### ⚡ 5. Laravel 应用层优化
|
||||
|
||||
| 措施 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 路由缓存 | `php artisan route:cache` — 减少路由注册开销 | ⚡ |
|
||||
| 配置缓存 | `php artisan config:cache` — 减少 config 加载 | ⚡ |
|
||||
| 事件缓存 | `php artisan event:cache` — L12 原生支持 | ⚡ |
|
||||
| 视图缓存 | `php artisan view:cache` — Blade 编译缓存 | ⚡ |
|
||||
| 模型预加载 | 检查 N+1 查询,使用 `->with()` | ⚡ |
|
||||
|
||||
**⚠️ 注意:** `php artisan optimize` 已在 Laravel 12 中被移除,应单独执行以上四个命令。
|
||||
|
||||
---
|
||||
|
||||
### ⚡ 6. Redis 优化
|
||||
|
||||
**当前:** 单机单实例 Redis,承载 Session、Cache、Queue、Reverb Scaling 全部功能。
|
||||
|
||||
**建议:**
|
||||
- 生产环境建议至少 2 个 Redis 实例:一个用于 Session/Cache(可随时清),一个用于 Queue(需持久化)
|
||||
- Reverb Scaling 发布订阅建议单独连接
|
||||
- 为 Redis 设置 `maxmemory` 和 `maxmemory-policy allkeys-lru` 防止内存溢出
|
||||
|
||||
---
|
||||
|
||||
### ⚡ 7. 前端加载优化
|
||||
|
||||
| 措施 | 说明 |
|
||||
|------|------|
|
||||
| JS 代码分割 | Vite 动态 import 拆分大 JS 文件 |
|
||||
| 懒加载 | 游戏模块(百家乐、赛马等)按需加载 |
|
||||
| 图片懒加载 | 头像、礼物图片使用 `loading="lazy"` |
|
||||
| Alpine.js 轻量化 | 当前已使用 Alpine.js ✅ 但避免过多 watcher |
|
||||
|
||||
---
|
||||
|
||||
### 💡 8. 考虑 Laravel Octane(长期规划)
|
||||
|
||||
**说明:** Laravel Octane(Swoole / RoadRunner)将应用常驻内存,消除框架启动开销,可带来 10-30 倍并发性能提升。
|
||||
|
||||
**条件:** 需要确保代码无静态变量状态污染,适合用户量增长后的升级。
|
||||
|
||||
---
|
||||
|
||||
## 三、实施优先级建议
|
||||
|
||||
### 第一阶段(紧急 · 1-2 天)🔴🔥
|
||||
| # | 任务 | 预估工时 |
|
||||
|---|------|---------|
|
||||
| 1 | 关闭 `APP_DEBUG` | 5 分钟 |
|
||||
| 2 | 启用 `SESSION_ENCRYPT` | 5 分钟 |
|
||||
| 3 | 限制 Reverb `allowed_origins` | 10 分钟 |
|
||||
| 4 | 配置 Route/Config/Event/View 缓存 | 30 分钟 |
|
||||
| 5 | 排行榜、房间列表等 Redis 缓存 | 1 小时 |
|
||||
|
||||
### 第二阶段(重要 · 3-5 天)🟡⚡
|
||||
| # | 任务 | 预估工时 |
|
||||
|---|------|---------|
|
||||
| 6 | 敏感字段移出 `$fillable` | 1 小时 |
|
||||
| 7 | 登录失败锁定 + 注册频率限制 | 2 小时 |
|
||||
| 8 | 数据库慢查询分析与索引优化 | 2 小时 |
|
||||
| 9 | 前端 JS 懒加载与代码分割 | 3 小时 |
|
||||
| 10 | OPcache 配置 | 30 分钟 |
|
||||
|
||||
### 第三阶段(完善 · 1-2 周)🟢💡
|
||||
| # | 任务 | 预估工时 |
|
||||
|---|------|---------|
|
||||
| 11 | Reverb WSS + Nginx 反向代理 | 2 小时 |
|
||||
| 12 | 管理员 2FA 验证 | 4 小时 |
|
||||
| 13 | CDN 部署静态资源 | 1 天 |
|
||||
| 14 | Content-Security-Policy 等安全头 | 1 小时 |
|
||||
| 15 | 生产环境 Redis 分实例部署 | 2 小时 |
|
||||
| 16 | 评估 Laravel Octane 迁移 | 2-3 天 |
|
||||
|
||||
---
|
||||
|
||||
## 四、检查清单工具
|
||||
|
||||
部署到生产环境前可使用以下命令快速检查:
|
||||
|
||||
```bash
|
||||
# Laravel 安全检查
|
||||
php artisan about # 查看环境配置
|
||||
php artisan route:list # 查看所有路由(确认无暴露的管理路径)
|
||||
|
||||
# Composer 安全审计
|
||||
composer audit
|
||||
|
||||
# 缓存优化
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan event:cache
|
||||
php artisan view:cache
|
||||
|
||||
# 依赖更新
|
||||
composer update --no-dev -o
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **总结:** 该项目整体架构设计良好(Redis + Reverb 实时通信 + Alpine.js 轻量前端),
|
||||
> 主要安全短板集中在**生产环境配置**(DEBUG 未关、Session 未加密、WebSocket 无 TLS)和**部分敏感字段保护**。
|
||||
> 速度优化则聚焦于**缓存策略**和**CDN 静态资源分发**。
|
||||
>
|
||||
> 建议从第一阶段紧急问题入手,逐步推进到第二阶段。需要我帮你实施其中任何一部分,随时说!
|
||||
@@ -18,10 +18,12 @@ use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Autoact;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\AiFinanceService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\SignInService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -49,6 +51,7 @@ class AiHeartbeatCommand extends Command
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly AiFinanceService $aiFinance,
|
||||
private readonly SignInService $signInService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -72,6 +75,9 @@ class AiHeartbeatCommand extends Command
|
||||
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
||||
$this->aiFinance->bankExcessGold($user);
|
||||
|
||||
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
||||
$this->performDailySignIn($user);
|
||||
|
||||
// 3. 常规心跳经验与金币发放
|
||||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||||
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
|
||||
@@ -213,6 +219,54 @@ class AiHeartbeatCommand extends Command
|
||||
return (int) $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
||||
*/
|
||||
private function performDailySignIn(User $user): void
|
||||
{
|
||||
// 先检查今日是否已签,避免每分钟都调用事务
|
||||
$alreadySigned = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('sign_in_date', today())
|
||||
->exists();
|
||||
|
||||
if ($alreadySigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取活跃房间作为签到归属(默认房间 1)
|
||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
|
||||
|
||||
$dailySignIn = $this->signInService->claim($user, $roomId);
|
||||
|
||||
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
||||
if (! $dailySignIn->wasRecentlyCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rewardParts = [];
|
||||
if ($dailySignIn->gold_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->gold_reward.' 金币';
|
||||
}
|
||||
if ($dailySignIn->exp_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->exp_reward.' 经验';
|
||||
}
|
||||
if ($dailySignIn->charm_reward > 0) {
|
||||
$rewardParts[] = $dailySignIn->charm_reward.' 魅力';
|
||||
}
|
||||
$rewardText = $rewardParts === [] ? '签到记录' : implode(' + ', $rewardParts);
|
||||
|
||||
$identityText = $dailySignIn->identity_badge_name
|
||||
? ',获得身份 '.e($dailySignIn->identity_badge_name)
|
||||
: '';
|
||||
|
||||
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||||
|
||||
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||||
}
|
||||
|
||||
/**
|
||||
* 往所有活跃房间发送系统广播消息
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Models\PositionDutyLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -46,6 +47,7 @@ class AutoSaveExp extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
private readonly VipService $vipService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {
|
||||
@@ -164,7 +166,7 @@ class AutoSaveExp extends Command
|
||||
);
|
||||
}
|
||||
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
|
||||
$user->load('activePosition.position'); // 确保职务及职位关联已加载
|
||||
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。
|
||||
|
||||
// 3. 自动升降级逻辑
|
||||
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
||||
@@ -229,9 +231,11 @@ class AutoSaveExp extends Command
|
||||
$jjbDisplay = $user->jjb ?? 0;
|
||||
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode(',', $gainParts) : '';
|
||||
|
||||
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
||||
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
|
||||
|
||||
// 格式:⏰ 自动存点 · 部门 X · 职务 Y · 会员 Z · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
|
||||
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
|
||||
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
$content = "⏰ 自动存点 · {$identitySummary['inline']} · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
|
||||
|
||||
$noticeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
|
||||
@@ -41,6 +41,9 @@ enum CurrencySource: string
|
||||
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
|
||||
case POSITION_REWARD = 'position_reward';
|
||||
|
||||
/** 每日签到奖励(连续签到按规则发放) */
|
||||
case SIGN_IN = 'sign_in';
|
||||
|
||||
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
|
||||
case AI_GIFT = 'ai_gift';
|
||||
|
||||
@@ -48,7 +51,6 @@ enum CurrencySource: string
|
||||
case GIFT_SENT = 'gift_sent';
|
||||
|
||||
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
|
||||
// case SIGN_IN = 'sign_in'; // 每日签到
|
||||
// case TASK_REWARD = 'task_reward'; // 任务奖励
|
||||
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
|
||||
|
||||
@@ -138,6 +140,24 @@ enum CurrencySource: string
|
||||
/** 看视频赚金币与经验奖励 */
|
||||
case VIDEO_REWARD = 'video_reward';
|
||||
|
||||
/** 查看别人隐藏信息扣费 */
|
||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||
|
||||
/** 购买消息装扮消耗(气泡,扣除金币) */
|
||||
case MSG_BUBBLE_BUY = 'msg_bubble_buy';
|
||||
|
||||
/** 购买昵称颜色装扮消耗(扣除金币) */
|
||||
case MSG_NAME_COLOR_BUY = 'msg_name_color_buy';
|
||||
|
||||
/** 购买文字颜色装扮消耗(扣除金币) */
|
||||
case MSG_TEXT_COLOR_BUY = 'msg_text_color_buy';
|
||||
|
||||
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币)—— 旧版兼容,新购买不再使用 */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -153,6 +173,7 @@ enum CurrencySource: string
|
||||
self::SHOP_BUY => '商城购买',
|
||||
self::ADMIN_ADJUST => '管理员调整',
|
||||
self::POSITION_REWARD => '职务奖励',
|
||||
self::SIGN_IN => '每日签到',
|
||||
self::AI_GIFT => 'AI赠送',
|
||||
self::GIFT_SENT => '发红包',
|
||||
self::MARRY_CHARM => '结婚魅力加成',
|
||||
@@ -183,6 +204,12 @@ enum CurrencySource: string
|
||||
self::GOMOKU_WIN => '五子棋获胜奖励',
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
self::USER_INFO_REVEAL => '信息查看付费',
|
||||
self::MSG_BUBBLE_BUY => '消息气泡购买',
|
||||
self::MSG_NAME_COLOR_BUY => '昵称颜色购买',
|
||||
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室浏览器刷新请求广播事件
|
||||
*
|
||||
* 仅供站长触发“刷新全员”命令时使用,
|
||||
* 向当前房间所有在线用户广播前端刷新指令。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向房间内全部在线用户广播页面刷新指令。
|
||||
*/
|
||||
class BrowserRefreshRequested implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数:记录房间与操作者信息。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $operator,
|
||||
public readonly string $reason = '',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:当前聊天室 PresenceChannel。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:前端用于展示提示并执行刷新。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'operator' => $this->operator,
|
||||
'reason' => $this->reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 文件功能:聊天室全屏特效广播事件
|
||||
*
|
||||
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
|
||||
* 支持指定接收者(target_username 为 null 则全员播放)。
|
||||
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -59,6 +59,7 @@ class EffectBroadcast implements ShouldBroadcastNow
|
||||
|
||||
/**
|
||||
* 广播数据:特效类型、操作者、目标用户、赠言
|
||||
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -14,22 +14,25 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向房间广播节日福利发放批次开始事件。
|
||||
*/
|
||||
class HolidayEventStarted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param HolidayEvent $event 节日活动实例
|
||||
* @param HolidayEventRun $run 节日福利发放批次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HolidayEvent $event,
|
||||
public readonly HolidayEventRun $run,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -60,15 +63,18 @@ class HolidayEventStarted implements ShouldBroadcastNow
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->event->id,
|
||||
'name' => $this->event->name,
|
||||
'description' => $this->event->description,
|
||||
'total_amount' => $this->event->total_amount,
|
||||
'max_claimants' => $this->event->max_claimants,
|
||||
'distribute_type' => $this->event->distribute_type,
|
||||
'fixed_amount' => $this->event->fixed_amount,
|
||||
'claimed_count' => $this->event->claimed_count,
|
||||
'expires_at' => $this->event->expires_at?->toIso8601String(),
|
||||
'run_id' => $this->run->id,
|
||||
'event_id' => $this->run->holiday_event_id,
|
||||
'name' => $this->run->event_name,
|
||||
'description' => $this->run->event_description,
|
||||
'total_amount' => $this->run->total_amount,
|
||||
'max_claimants' => $this->run->max_claimants,
|
||||
'distribute_type' => $this->run->distribute_type,
|
||||
'fixed_amount' => $this->run->fixed_amount,
|
||||
'claimed_count' => $this->run->claimed_count,
|
||||
'expires_at' => $this->run->expires_at?->toIso8601String(),
|
||||
'scheduled_for' => $this->run->scheduled_for?->toIso8601String(),
|
||||
'repeat_type' => $this->run->repeat_type,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ class MessageSent implements ShouldBroadcastNow
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
* 创建消息广播事件实例。
|
||||
*
|
||||
* @param int $roomId 房间ID
|
||||
* @param int $roomId 房间 ID
|
||||
* @param array $message 发送的消息数据
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -39,8 +39,8 @@ class MessageSent implements ShouldBroadcastNow
|
||||
/**
|
||||
* 获取消息应广播到的频道。
|
||||
*
|
||||
* 公共消息走房间 Presence 频道;
|
||||
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
|
||||
* 公共消息和普通定向发言走房间 Presence 频道;
|
||||
* 悄悄话只发给发送方与接收方的私有用户频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
@@ -78,9 +78,7 @@ class MessageSent implements ShouldBroadcastNow
|
||||
*/
|
||||
private function shouldBroadcastPrivately(): bool
|
||||
{
|
||||
$toUser = trim((string) ($this->message['to_user'] ?? ''));
|
||||
|
||||
return $toUser !== '' && $toUser !== '大家';
|
||||
return (bool) ($this->message['is_secret'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户定向页面刷新广播事件
|
||||
*
|
||||
* 在任命或撤销职务成功后,向目标用户私有频道推送刷新指令,
|
||||
* 确保对方页面上的权限按钮与职务状态及时同步。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 类功能:向指定用户广播页面刷新请求。
|
||||
*/
|
||||
class UserBrowserRefreshRequested implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造函数:记录目标用户与刷新说明。
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $targetUserId,
|
||||
public readonly string $operator,
|
||||
public readonly string $reason = '',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播频道:目标用户私有频道。
|
||||
*/
|
||||
public function broadcastOn(): PrivateChannel
|
||||
{
|
||||
return new PrivateChannel('user.'.$this->targetUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:供前端展示提示并执行刷新。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'operator' => $this->operator,
|
||||
'reason' => $this->reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 文件功能:用户被踢出房间广播事件
|
||||
*
|
||||
* 管理员踢出/冻结用户时触发,前端监听后强制该用户跳转至大厅。
|
||||
* 管理员踢出/封禁用户时触发,前端监听后强制该用户跳转至大厅。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室用户状态变更广播事件
|
||||
* 负责在用户设置或清除当日状态后,实时同步当前房间在线名单展示。
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserStatusUpdated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 构造聊天室用户状态变更广播事件。
|
||||
*
|
||||
* @param int $roomId 房间 ID
|
||||
* @param string $username 状态变更用户昵称
|
||||
* @param array<string, mixed> $user 最新在线名单载荷
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $roomId,
|
||||
public readonly string $username,
|
||||
public readonly array $user,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取广播频道。
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PresenceChannel('room.'.$this->roomId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'username' => $this->username,
|
||||
'user' => $this->user,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\AiProviderConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,6 +34,7 @@ class AiProviderController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -283,19 +285,8 @@ class AiProviderController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => false,
|
||||
'position_icon' => '',
|
||||
'position_name' => '',
|
||||
];
|
||||
// 机器人在线载荷也统一走聊天室展示服务,避免名单字段口径逐步漂移。
|
||||
$userData = $this->chatUserPresenceService->build($user);
|
||||
|
||||
// 广播机器人进出事件(供前端名单增删)
|
||||
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
|
||||
|
||||
@@ -13,9 +13,14 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室顶部快捷入口创建与结束百家乐买单活动。
|
||||
*/
|
||||
class BaccaratLossCoverEventController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -23,6 +28,7 @@ class BaccaratLossCoverEventController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -30,6 +36,13 @@ class BaccaratLossCoverEventController extends Controller
|
||||
*/
|
||||
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
|
||||
{
|
||||
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '当前职务无权创建买单活动。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
|
||||
} catch (\RuntimeException $exception) {
|
||||
@@ -51,6 +64,13 @@ class BaccaratLossCoverEventController extends Controller
|
||||
*/
|
||||
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
|
||||
{
|
||||
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '当前职务无权结束买单活动。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -17,6 +17,9 @@ use App\Models\UserCurrencyLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:提供后台全局金币/积分流水查询与多条件筛选。
|
||||
*/
|
||||
class CurrencyLogController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -26,6 +29,12 @@ class CurrencyLogController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = UserCurrencyLog::query()->with('user');
|
||||
$allSources = CurrencySource::cases();
|
||||
$allowedSources = collect($allSources)->map(fn (CurrencySource $source) => $source->value)->all();
|
||||
$selectedSources = collect($request->array('sources'))
|
||||
->filter(fn (string $source) => in_array($source, $allowedSources, true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// 查询条件过滤
|
||||
if ($request->filled('username')) {
|
||||
@@ -36,8 +45,8 @@ class CurrencyLogController extends Controller
|
||||
$query->where('currency', $request->input('currency'));
|
||||
}
|
||||
|
||||
if ($request->filled('source')) {
|
||||
$query->where('source', $request->input('source'));
|
||||
if ($selectedSources !== []) {
|
||||
$query->whereIn('source', $selectedSources);
|
||||
}
|
||||
|
||||
if ($request->filled('remark')) {
|
||||
@@ -63,8 +72,6 @@ class CurrencyLogController extends Controller
|
||||
// 默认按时间倒序
|
||||
$logs = $query->latest('id')->paginate(50)->withQueryString();
|
||||
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
return view('admin.currency-logs.index', compact('logs', 'allSources'));
|
||||
return view('admin.currency-logs.index', compact('logs', 'allSources', 'selectedSources'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:展示后台积分流水统计与指定日期净流通数据。
|
||||
*/
|
||||
class CurrencyStatsController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -45,20 +47,7 @@ class CurrencyStatsController extends Controller
|
||||
);
|
||||
|
||||
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
|
||||
$netFlow = [];
|
||||
foreach (['exp', 'gold', 'charm'] as $currency) {
|
||||
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '>', 0)
|
||||
->sum('amount');
|
||||
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
|
||||
->where('currency', $currency)->where('amount', '<', 0)
|
||||
->sum('amount');
|
||||
$netFlow[$currency] = [
|
||||
'in' => $totalIn,
|
||||
'out' => abs($totalOut),
|
||||
'net' => $totalIn + $totalOut, // 净增量
|
||||
];
|
||||
}
|
||||
$netFlow = $this->currencyService->netFlowStats($date);
|
||||
|
||||
// 所有已知来源(供视图展示缺失来源的空行)
|
||||
$allSources = CurrencySource::cases();
|
||||
|
||||
@@ -14,15 +14,27 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreHolidayEventRequest;
|
||||
use App\Http\Requests\UpdateHolidayEventRequest;
|
||||
use App\Jobs\TriggerHolidayEventJob;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Services\HolidayEventScheduleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:管理节日福利模板的后台增删改查与手动触发操作。
|
||||
*/
|
||||
class HolidayEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入节日福利调度计算服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly HolidayEventScheduleService $scheduleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 节日福利活动列表页。
|
||||
*/
|
||||
@@ -46,30 +58,9 @@ class HolidayEventController extends Controller
|
||||
/**
|
||||
* 保存新活动。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreHolidayEventRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$data['status'] = 'pending';
|
||||
$data['enabled'] = $request->boolean('enabled', true);
|
||||
|
||||
HolidayEvent::create($data);
|
||||
HolidayEvent::create($this->buildPayload($request->validated(), true));
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
|
||||
}
|
||||
@@ -85,26 +76,9 @@ class HolidayEventController extends Controller
|
||||
/**
|
||||
* 更新活动。
|
||||
*/
|
||||
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||
public function update(UpdateHolidayEventRequest $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'total_amount' => 'required|integer|min:1',
|
||||
'max_claimants' => 'required|integer|min:0',
|
||||
'distribute_type' => 'required|in:random,fixed',
|
||||
'min_amount' => 'nullable|integer|min:1',
|
||||
'max_amount' => 'nullable|integer|min:1',
|
||||
'fixed_amount' => 'nullable|integer|min:1',
|
||||
'send_at' => 'required|date',
|
||||
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||
'cron_expr' => 'nullable|string|max:100',
|
||||
'target_type' => 'required|in:all,vip,level',
|
||||
'target_value' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$holidayEvent->update($data);
|
||||
$holidayEvent->update($this->buildPayload($request->validated()));
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
|
||||
}
|
||||
@@ -128,13 +102,12 @@ class HolidayEventController extends Controller
|
||||
*/
|
||||
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
|
||||
{
|
||||
if ($holidayEvent->status !== 'pending') {
|
||||
return back()->with('error', '只有待触发状态的活动才能手动触发。');
|
||||
if (! $holidayEvent->enabled || $holidayEvent->status === 'cancelled') {
|
||||
return back()->with('error', '当前活动未启用或已取消,不能立即触发。');
|
||||
}
|
||||
|
||||
// 设置触发时间为当前,立即入队
|
||||
$holidayEvent->update(['send_at' => now()]);
|
||||
TriggerHolidayEventJob::dispatch($holidayEvent);
|
||||
// 立即触发只生成临时批次,不覆盖年度锚点或下次计划时间。
|
||||
TriggerHolidayEventJob::dispatch($holidayEvent, true);
|
||||
|
||||
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
|
||||
}
|
||||
@@ -148,4 +121,54 @@ class HolidayEventController extends Controller
|
||||
|
||||
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装节日福利模板的可持久化字段。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(array $data, bool $isCreating = false): array
|
||||
{
|
||||
$payload = $data;
|
||||
|
||||
// 创建与编辑都统一回收无效字段,避免模板状态互相污染。
|
||||
if (($payload['distribute_type'] ?? 'random') === 'random') {
|
||||
$payload['fixed_amount'] = null;
|
||||
} else {
|
||||
$payload['min_amount'] = 1;
|
||||
$payload['max_amount'] = null;
|
||||
}
|
||||
|
||||
if (($payload['target_type'] ?? 'all') !== 'level') {
|
||||
$payload['target_value'] = null;
|
||||
}
|
||||
|
||||
if (($payload['repeat_type'] ?? 'once') !== 'cron') {
|
||||
$payload['cron_expr'] = null;
|
||||
}
|
||||
|
||||
if (($payload['repeat_type'] ?? 'once') === 'yearly') {
|
||||
$payload['send_at'] = $this->scheduleService
|
||||
->resolveNextConfiguredSendAt($payload)
|
||||
->toDateTimeString();
|
||||
} else {
|
||||
$payload['schedule_month'] = null;
|
||||
$payload['schedule_day'] = null;
|
||||
$payload['schedule_time'] = null;
|
||||
$payload['duration_days'] = 1;
|
||||
$payload['daily_occurrences'] = 1;
|
||||
$payload['occurrence_interval_minutes'] = null;
|
||||
}
|
||||
|
||||
// 每次保存模板时,都让系统按新配置重新进入待触发状态。
|
||||
$payload['status'] = 'pending';
|
||||
$payload['enabled'] = (bool) ($payload['enabled'] ?? true);
|
||||
$payload['triggered_at'] = null;
|
||||
$payload['expires_at'] = null;
|
||||
$payload['claimed_count'] = 0;
|
||||
$payload['claimed_amount'] = 0;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台等级经验阈值配置控制器
|
||||
*
|
||||
* 将 sysparam 表中的 levelexp 配置拆分为独立后台页面,
|
||||
* 以列表模式维护每一级所需的累计经验值。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateLevelExpConfigRequest;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责展示和保存等级经验阈值列表。
|
||||
*/
|
||||
class LevelExpConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:注入系统参数缓存同步服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:显示等级经验阈值列表页。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rawThresholds = Sysparam::getLevelExpThresholds();
|
||||
$maxLevel = (int) Sysparam::getValue('maxlevel', '99');
|
||||
|
||||
$thresholds = collect($rawThresholds)
|
||||
->values()
|
||||
->map(fn (int $exp, int $index): array => [
|
||||
'level' => $index + 1,
|
||||
'exp' => $exp,
|
||||
'increment' => $index === 0 ? $exp : $exp - $rawThresholds[$index - 1],
|
||||
]);
|
||||
|
||||
return view('admin.level-exp-configs.index', [
|
||||
'thresholds' => $thresholds,
|
||||
'maxLevel' => $maxLevel,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:保存等级经验阈值配置,并同步刷新缓存。
|
||||
*/
|
||||
public function update(UpdateLevelExpConfigRequest $request): RedirectResponse
|
||||
{
|
||||
$thresholds = $request->validated('thresholds');
|
||||
|
||||
// 将列表页提交的阈值重新拼成兼容旧逻辑的逗号字符串。
|
||||
$body = implode(',', $thresholds);
|
||||
|
||||
Sysparam::updateOrCreate(
|
||||
['alias' => 'levelexp'],
|
||||
[
|
||||
'body' => $body,
|
||||
'guidetxt' => '按列表逐级维护升级所需的累计经验阈值',
|
||||
]
|
||||
);
|
||||
|
||||
// 同步更新 Redis / Cache,确保前台经验等级计算即时生效。
|
||||
$this->chatState->setSysParam('levelexp', $body);
|
||||
Sysparam::clearCache('levelexp');
|
||||
|
||||
return redirect()->route('admin.level-exp-configs.index')->with('success', '等级经验阈值已保存并生效!');
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,15 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\Sysparam;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责后台职务资料、任命白名单与聊天室权限配置的维护。
|
||||
*/
|
||||
class PositionController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -29,16 +34,25 @@ class PositionController extends Controller
|
||||
{
|
||||
// 按部门分组展示
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
|
||||
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->with('appointablePositions')->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
// 全部职务(供任命白名单多选框使用)
|
||||
$allPositions = Position::with('department')->orderByDesc('rank')->get();
|
||||
$allPositions = Position::with('department')->ordered()->get();
|
||||
|
||||
// 全局奖励接收次数上限(0 = 不限)
|
||||
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
|
||||
|
||||
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
|
||||
$positionPermissions = PositionPermissionRegistry::groupedDefinitions();
|
||||
$permissionLabels = PositionPermissionRegistry::labelMap();
|
||||
|
||||
return view('admin.positions.index', compact(
|
||||
'departments',
|
||||
'allPositions',
|
||||
'globalRecipientDailyMax',
|
||||
'positionPermissions',
|
||||
'permissionLabels',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,13 +70,20 @@ class PositionController extends Controller
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
|
||||
'red_packet_count' => 'nullable|integer|min:1|max:100',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
|
||||
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
|
||||
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
|
||||
|
||||
$position = Position::create($data);
|
||||
|
||||
@@ -144,13 +165,20 @@ class PositionController extends Controller
|
||||
'max_reward' => 'nullable|integer|min:0',
|
||||
'daily_reward_limit' => 'nullable|integer|min:0',
|
||||
'recipient_daily_limit' => 'nullable|integer|min:0',
|
||||
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
|
||||
'red_packet_count' => 'nullable|integer|min:1|max:100',
|
||||
'sort_order' => 'required|integer|min:0',
|
||||
'appointable_ids' => 'nullable|array',
|
||||
'appointable_ids.*' => 'exists:positions,id',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
|
||||
]);
|
||||
|
||||
$appointableIds = $data['appointable_ids'] ?? [];
|
||||
unset($data['appointable_ids']);
|
||||
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
|
||||
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
|
||||
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
|
||||
|
||||
$position->update($data);
|
||||
$position->appointablePositions()->sync($appointableIds);
|
||||
|
||||
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
|
||||
'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',
|
||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
|
||||
'duration_days' => 'nullable|integer|min:0',
|
||||
'duration_minutes' => 'nullable|integer|min:0',
|
||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台签到奖励规则管理控制器
|
||||
*
|
||||
* 提供连续签到奖励档位的列表、新增、编辑、启停和删除功能。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SaveSignInRewardRuleRequest;
|
||||
use App\Models\SignInRewardRule;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:管理后台每日签到奖励规则。
|
||||
*/
|
||||
class SignInRewardRuleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 方法功能:展示签到奖励规则列表。
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$rules = SignInRewardRule::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('streak_days')
|
||||
->get();
|
||||
|
||||
return view('admin.sign-in-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:新增签到奖励规则。
|
||||
*/
|
||||
public function store(SaveSignInRewardRuleRequest $request): RedirectResponse
|
||||
{
|
||||
SignInRewardRule::query()->create($this->payload($request));
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:更新签到奖励规则。
|
||||
*/
|
||||
public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse
|
||||
{
|
||||
$signInRewardRule->update($this->payload($request));
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:切换签到奖励规则启用状态。
|
||||
*/
|
||||
public function toggle(SignInRewardRule $signInRewardRule): JsonResponse
|
||||
{
|
||||
$signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'is_enabled' => $signInRewardRule->is_enabled,
|
||||
'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除签到奖励规则。
|
||||
*/
|
||||
public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse
|
||||
{
|
||||
$signInRewardRule->delete();
|
||||
|
||||
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:整理后台表单提交的数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payload(SaveSignInRewardRuleRequest $request): array
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['is_enabled'] = $request->boolean('is_enabled');
|
||||
|
||||
foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) {
|
||||
$data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ class SystemController extends Controller
|
||||
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
|
||||
$data = $request->only($this->editableSystemAliases());
|
||||
|
||||
if (array_key_exists('maxlevel', $data)) {
|
||||
$normalizedMaxLevel = max(1, (int) $data['maxlevel']);
|
||||
|
||||
// 管理员级别始终跟随最高等级 + 1,避免两个配置页出现口径漂移。
|
||||
$data['maxlevel'] = (string) $normalizedMaxLevel;
|
||||
$data['superlevel'] = (string) ($normalizedMaxLevel + 1);
|
||||
}
|
||||
|
||||
foreach ($data as $alias => $body) {
|
||||
$normalizedBody = (string) $body;
|
||||
|
||||
@@ -88,7 +96,7 @@ class SystemController extends Controller
|
||||
return SysParam::query()
|
||||
->orderBy('id')
|
||||
->pluck('alias')
|
||||
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias))
|
||||
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias) && ! $this->isDedicatedAlias($alias))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@@ -104,4 +112,21 @@ class SystemController extends Controller
|
||||
|
||||
return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否已经迁移到独立配置页。
|
||||
*/
|
||||
private function isDedicatedAlias(string $alias): bool
|
||||
{
|
||||
return in_array($alias, [
|
||||
'levelexp',
|
||||
'level_warn',
|
||||
'level_mute',
|
||||
'level_kick',
|
||||
'level_announcement',
|
||||
'level_ban',
|
||||
'level_banip',
|
||||
'level_freeze',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Events\UserBrowserRefreshRequested;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateManagedUserRequest;
|
||||
use App\Models\Department;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPosition;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -23,6 +30,9 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:负责后台用户列表展示、资料编辑与删除操作。
|
||||
*/
|
||||
class UserManagerController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -31,6 +41,7 @@ class UserManagerController extends Controller
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly AppointmentService $appointmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -77,8 +88,12 @@ class UserManagerController extends Controller
|
||||
|
||||
// VIP 等级选项列表(供编辑弹窗使用)
|
||||
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
|
||||
// 职务下拉选项(复用任命系统中的部门与职务数据)
|
||||
$departments = Department::with([
|
||||
'positions' => fn ($positionQuery) => $positionQuery->ordered(),
|
||||
])->ordered()->get();
|
||||
|
||||
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
|
||||
return view('admin.users.index', compact('users', 'vipLevels', 'departments', 'sortBy', 'sortDir', 'onlineUsernames'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,10 +101,11 @@ class UserManagerController extends Controller
|
||||
*
|
||||
* @param User $user 路由模型自动注入
|
||||
*/
|
||||
public function update(Request $request, User $user): JsonResponse|RedirectResponse
|
||||
public function update(UpdateManagedUserRequest $request, User $user): JsonResponse|RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
$responseMessages = [];
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
|
||||
if ($currentUser->id !== 1) {
|
||||
@@ -104,17 +120,7 @@ class UserManagerController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'sex' => 'sometimes|integer|in:0,1,2',
|
||||
'exp_num' => 'sometimes|integer|min:0',
|
||||
'jjb' => 'sometimes|integer|min:0',
|
||||
'meili' => 'sometimes|integer|min:0',
|
||||
'qianming' => 'sometimes|nullable|string|max:255',
|
||||
'headface' => 'sometimes|string|max:50',
|
||||
'password' => 'nullable|string|min:6',
|
||||
'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id',
|
||||
'hy_time' => 'sometimes|nullable|date',
|
||||
]);
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['sex'])) {
|
||||
$targetUser->sex = $validated['sex'];
|
||||
@@ -188,11 +194,31 @@ class UserManagerController extends Controller
|
||||
|
||||
$targetUser->save();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['status' => 'success', 'message' => '用户资料已强行更新完毕!']);
|
||||
if (array_key_exists('position_id', $validated)) {
|
||||
$positionSyncResult = $this->syncUserPosition(
|
||||
operator: $currentUser,
|
||||
targetUser: $targetUser,
|
||||
targetPositionId: $validated['position_id'],
|
||||
);
|
||||
|
||||
if (! $positionSyncResult['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $positionSyncResult['message']], 422);
|
||||
}
|
||||
|
||||
if (! empty($positionSyncResult['message'])) {
|
||||
$responseMessages[] = $positionSyncResult['message'];
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', '用户资料已更新!');
|
||||
if ($request->wantsJson()) {
|
||||
$message = array_merge(['用户资料已强行更新完毕!'], $responseMessages);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => implode(' ', $message)]);
|
||||
}
|
||||
|
||||
$message = array_merge(['用户资料已更新!'], $responseMessages);
|
||||
|
||||
return back()->with('success', implode(' ', $message));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,14 +241,111 @@ class UserManagerController extends Controller
|
||||
abort(403, '权限不足:无法删除同级或高级账号!');
|
||||
}
|
||||
|
||||
// 管理员保护:达到踢人等级(level_kick)的用户视为管理员,不可被强杀
|
||||
$levelKick = (int) \App\Models\Sysparam::getValue('level_kick', '10');
|
||||
if ($targetUser->user_level >= $levelKick) {
|
||||
abort(403, '该用户为管理员,不允许强杀!请先在用户编辑中降低其等级。');
|
||||
// 任命体系保护:仍持有在职职务的账号不可直接强杀,必须先走撤职流程。
|
||||
$targetUser->loadMissing('activePosition.position');
|
||||
if ($targetUser->id === 1 || $targetUser->activePosition?->position) {
|
||||
abort(403, '该用户当前拥有在职职务,不允许强杀!请先撤销职务。');
|
||||
}
|
||||
|
||||
$targetUser->delete();
|
||||
|
||||
return back()->with('success', '目标已被物理删除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:同步后台编辑页选择的目标职务。
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
private function syncUserPosition(User $operator, User $targetUser, ?int $targetPositionId): array
|
||||
{
|
||||
$currentAssignment = $this->appointmentService->getActivePosition($targetUser);
|
||||
$currentPositionId = $currentAssignment?->position_id;
|
||||
|
||||
if ($targetPositionId === $currentPositionId) {
|
||||
return ['ok' => true, 'message' => ''];
|
||||
}
|
||||
|
||||
if ($targetPositionId === null) {
|
||||
if (! $currentAssignment) {
|
||||
return ['ok' => true, 'message' => ''];
|
||||
}
|
||||
|
||||
$result = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
|
||||
if (! $result['ok']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->broadcastRevokedPosition($operator, $targetUser, $currentAssignment);
|
||||
|
||||
return ['ok' => true, 'message' => '用户职务已撤销。'];
|
||||
}
|
||||
|
||||
$targetPosition = Position::with('department')->findOrFail($targetPositionId);
|
||||
|
||||
if ($currentAssignment) {
|
||||
$revokeResult = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
|
||||
if (! $revokeResult['ok']) {
|
||||
return $revokeResult;
|
||||
}
|
||||
}
|
||||
|
||||
$appointResult = $this->appointmentService->appoint($operator, $targetUser, $targetPosition, '后台用户管理编辑');
|
||||
if (! $appointResult['ok']) {
|
||||
return $appointResult;
|
||||
}
|
||||
|
||||
$this->broadcastAppointedPosition($operator, $targetUser, $targetPosition);
|
||||
|
||||
return ['ok' => true, 'message' => "用户职务已更新为【{$targetPosition->name}】。"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播后台任命成功后的公告与目标用户刷新事件。
|
||||
*/
|
||||
private function broadcastAppointedPosition(User $operator, User $targetUser, Position $targetPosition): void
|
||||
{
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUser->username,
|
||||
positionIcon: $targetPosition->icon ?? '🎖️',
|
||||
positionName: $targetPosition->name,
|
||||
departmentName: $targetPosition->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
));
|
||||
}
|
||||
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: $targetUser->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已发生变更,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:广播后台撤销职务后的公告与目标用户刷新事件。
|
||||
*/
|
||||
private function broadcastRevokedPosition(User $operator, User $targetUser, UserPosition $currentAssignment): void
|
||||
{
|
||||
$currentAssignment->loadMissing('position.department');
|
||||
|
||||
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
|
||||
broadcast(new AppointmentAnnounced(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUser->username,
|
||||
positionIcon: $currentAssignment->position?->icon ?? '🎖️',
|
||||
positionName: $currentAssignment->position?->name ?? '',
|
||||
departmentName: $currentAssignment->position?->department?->name ?? '',
|
||||
operatorName: $operator->username,
|
||||
type: 'revoke',
|
||||
));
|
||||
}
|
||||
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: $targetUser->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已被撤销,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 文件功能:管理员聊天室实时命令控制器
|
||||
*
|
||||
* 提供管理员在聊天室内对用户执行的管理操作:
|
||||
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)、站长公屏讲话。
|
||||
* 警告(=J)、踢出(=T)、禁言(=B)、封号、封IP、查看私信(=S)、职务公屏讲话。
|
||||
*
|
||||
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
|
||||
*
|
||||
@@ -16,21 +16,29 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\BrowserRefreshRequested;
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Message;
|
||||
use App\Models\PositionAuthorityLog;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:处理聊天室内的实时管理命令与部分职务奖励操作。
|
||||
*/
|
||||
class AdminCommandController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -39,6 +47,7 @@ class AdminCommandController extends Controller
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,12 +68,25 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '请注意言行');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 权限检查(等级由 level_warn 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_warn', '5')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_WARN, '警告');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播警告消息
|
||||
@@ -73,7 +95,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "⚠️ 管理员 <b>{$admin->username}</b> 警告 <b>{$targetUsername}</b>:{$reason}",
|
||||
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -87,9 +109,9 @@ class AdminCommandController extends Controller
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "⚠️ 管理员 <b>{$admin->username}</b> 警告了你:{$reason}",
|
||||
content: "⚠️ {$operatorDisplay} 警告了你:{$reason}",
|
||||
title: '⚠️ 收到警告',
|
||||
toastMessage: "<b>{$admin->username}</b> 警告了你:{$reason}",
|
||||
toastMessage: "{$operatorDisplay} 警告了你:{$reason}",
|
||||
color: '#f59e0b',
|
||||
icon: '⚠️',
|
||||
);
|
||||
@@ -115,21 +137,34 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 权限检查(等级由 level_kick 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_KICK, '踢出');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🚫 管理员 <b>{$admin->username}</b> 已将你踢出聊天室。原因:{$reason}",
|
||||
content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}",
|
||||
title: '🚫 已被踢出',
|
||||
toastMessage: "<b>{$admin->username}</b> 已将你踢出聊天室。<br>原因:{$reason}",
|
||||
toastMessage: "{$operatorDisplay} 已将你踢出聊天室。<br>原因:{$reason}",
|
||||
color: '#ef4444',
|
||||
icon: '🚫',
|
||||
);
|
||||
@@ -143,7 +178,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🚫 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -177,12 +212,26 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$duration = $request->input('duration');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 权限检查(等级由 level_mute 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_mute', '8')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$duration = $request->input('duration');
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_MUTE, '禁言');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 设置 Redis 禁言标记,TTL 自动过期
|
||||
@@ -195,7 +244,7 @@ class AdminCommandController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🔇 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'action' => '',
|
||||
@@ -209,9 +258,9 @@ class AdminCommandController extends Controller
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🔇 管理员 <b>{$admin->username}</b> 已将你禁言 {$duration} 分钟。",
|
||||
content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。",
|
||||
title: '🔇 已被禁言',
|
||||
toastMessage: "<b>{$admin->username}</b> 已将你禁言 <b>{$duration}</b> 分钟。",
|
||||
toastMessage: "{$operatorDisplay} 已将你禁言 <b>{$duration}</b> 分钟。",
|
||||
color: '#6366f1',
|
||||
icon: '🔇',
|
||||
);
|
||||
@@ -221,21 +270,22 @@ class AdminCommandController extends Controller
|
||||
roomId: $roomId,
|
||||
username: $targetUsername,
|
||||
muteTime: $duration,
|
||||
operator: $admin->username,
|
||||
message: "{$operatorLabel} 已将 [{$targetUsername}] 禁言 {$duration} 分钟。",
|
||||
operator: $operatorLabel,
|
||||
));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结用户账号(=Y 理由)
|
||||
* 封禁用户账号。
|
||||
*
|
||||
* 将用户账号状态设为冻结,禁止登录。
|
||||
* 将目标账号设为封禁状态,并将其从当前在线房间中移出。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 username, reason
|
||||
* @param Request $request 请求对象,需包含 username, room_id, reason
|
||||
* @return JsonResponse 操作结果
|
||||
*/
|
||||
public function freeze(Request $request): JsonResponse
|
||||
public function ban(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
@@ -245,48 +295,51 @@ class AdminCommandController extends Controller
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = $request->input('room_id');
|
||||
$reason = $request->input('reason', '违反聊天室规则');
|
||||
|
||||
// 权限检查(等级由 level_freeze 配置)
|
||||
if (! $this->canExecute($admin, $targetUsername, 'level_freeze', '14')) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 冻结用户账号(将等级设为 -1 表示冻结)
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if (! $target) {
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BAN, '封禁');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$target = $authorization['target'];
|
||||
$target->user_level = -1;
|
||||
$target->save();
|
||||
|
||||
// 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: (int) $roomId,
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🧊 管理员 <b>{$admin->username}</b> 已冻结你的账号。原因:{$reason}",
|
||||
title: '🧊 账号已冻结',
|
||||
toastMessage: "<b>{$admin->username}</b> 已冻结你的账号。<br>原因:{$reason}",
|
||||
color: '#3b82f6',
|
||||
icon: '🧊',
|
||||
content: "⛔ {$operatorDisplay} 已封禁你的账号。原因:{$reason}",
|
||||
title: '⛔ 账号已封禁',
|
||||
toastMessage: "{$operatorDisplay} 已封禁你的账号。<br>原因:{$reason}",
|
||||
color: '#991b1b',
|
||||
icon: '⛔',
|
||||
);
|
||||
|
||||
// 从所有房间移除
|
||||
$rooms = $this->chatState->getUserRooms($targetUsername);
|
||||
foreach ($rooms as $rid) {
|
||||
$this->chatState->userLeave($rid, $targetUsername);
|
||||
}
|
||||
$this->removeUserFromAllRooms($targetUsername);
|
||||
|
||||
// 广播冻结消息
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🧊 管理员 <b>{$admin->username}</b> 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
|
||||
'content' => "⛔ {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#dc2626',
|
||||
'font_color' => '#991b1b',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
@@ -294,10 +347,93 @@ class AdminCommandController extends Controller
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 广播踢出事件
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}"));
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被封禁:{$reason}"));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已冻结 {$targetUsername} 的账号"]);
|
||||
return response()->json(['status' => 'success', 'message' => "已封禁 {$targetUsername} 的账号"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁用户 IP。
|
||||
*
|
||||
* 将目标账号设为封禁状态并把最近登录 IP 写入黑名单。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 username, room_id, reason
|
||||
* @return JsonResponse 操作结果
|
||||
*/
|
||||
public function banIp(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'room_id' => 'required|integer',
|
||||
'reason' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$targetUsername = $request->input('username');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', '严重违规'));
|
||||
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
|
||||
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
|
||||
|
||||
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
|
||||
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_BANIP, '封禁 IP ');
|
||||
if (! $authorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$target = $authorization['target'];
|
||||
$targetIp = (string) ($target->last_ip ?? '');
|
||||
if ($targetIp !== '') {
|
||||
Redis::sadd('banned_ips', $targetIp);
|
||||
}
|
||||
|
||||
$target->user_level = -1;
|
||||
$target->save();
|
||||
|
||||
$this->pushTargetToastMessage(
|
||||
roomId: $roomId,
|
||||
targetUsername: $targetUsername,
|
||||
content: "🌐 {$operatorDisplay} 已封禁你的 IP 并冻结账号。原因:{$reason}",
|
||||
title: '🌐 IP 已封禁',
|
||||
toastMessage: "{$operatorDisplay} 已封禁你的 IP 并冻结账号。<br>原因:{$reason}",
|
||||
color: '#7c2d12',
|
||||
icon: '🌐',
|
||||
);
|
||||
|
||||
$this->removeUserFromAllRooms($targetUsername);
|
||||
|
||||
$ipSuffix = $targetIp !== '' ? "(IP:{$targetIp})" : '(未记录有效 IP)';
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => "🌐 {$operatorDisplay} 已封禁 <b>{$safeTargetUsername}</b> 的 IP 并冻结账号。原因:{$reason} {$ipSuffix}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#9a3412',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($roomId, $msg);
|
||||
broadcast(new MessageSent($roomId, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "IP 已被封禁:{$reason}"));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $targetIp !== '' ? "已封禁 {$targetUsername} 的 IP 与账号" : "已封禁 {$targetUsername} 的账号,但未记录可封禁 IP",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,9 +483,10 @@ class AdminCommandController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长公屏讲话
|
||||
* 聊天室公屏讲话
|
||||
*
|
||||
* 站长发送全聊天室公告,以特殊样式显示。
|
||||
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
|
||||
* id=1 站长仍然拥有完整兜底权限。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 content, room_id
|
||||
* @return JsonResponse 操作结果
|
||||
@@ -362,22 +499,27 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
|
||||
}
|
||||
|
||||
$roomId = $request->input('room_id');
|
||||
$content = $request->input('content');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播站长公告
|
||||
$content = ChatContentSanitizer::htmlText($request->input('content'));
|
||||
|
||||
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告”
|
||||
$publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin));
|
||||
$publisherUsername = ChatContentSanitizer::htmlText($admin->username);
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 站长 <b>{$admin->username}</b> 讲话:{$content}",
|
||||
'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告:{$content}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
@@ -390,6 +532,184 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'success', 'message' => '公告已发送']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公屏公告发布者身份标签。
|
||||
*
|
||||
* 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。
|
||||
*/
|
||||
private function buildAnnouncementPublisherLabel(User $user): string
|
||||
{
|
||||
return $this->buildOperatorIdentityLabel($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成操作者的身份标签。
|
||||
*
|
||||
* 有在职职务时统一显示为“部门·职务”,无在职职务的 id=1 兜底显示“站长”。
|
||||
*/
|
||||
private function buildOperatorIdentityLabel(User $user): string
|
||||
{
|
||||
$position = $user->activePosition?->position;
|
||||
|
||||
if ($position) {
|
||||
$departmentName = (string) ($position->department?->name ?? '');
|
||||
|
||||
return $departmentName ? "{$departmentName}·{$position->name}" : $position->name;
|
||||
}
|
||||
|
||||
if ($user->id === 1) {
|
||||
return '站长';
|
||||
}
|
||||
|
||||
return '管理员';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成操作者在聊天室文案中的完整展示文本。
|
||||
*/
|
||||
private function buildOperatorDisplayHtml(User $user): string
|
||||
{
|
||||
$identityLabel = e($this->buildOperatorIdentityLabel($user));
|
||||
$username = e($user->username);
|
||||
|
||||
return "<b>{$identityLabel}</b> <b>{$username}</b>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验操作者是否可在指定房间执行聊天室管理命令。
|
||||
*
|
||||
* @return array{ok: bool, message: string, room?: Room}
|
||||
*/
|
||||
private function authorizeManagedRoom(int $roomId, User $operator): array
|
||||
{
|
||||
$room = Room::query()->find($roomId);
|
||||
if (! $room) {
|
||||
return ['ok' => false, 'message' => '房间不存在'];
|
||||
}
|
||||
|
||||
if (! $room->canUserEnter($operator)) {
|
||||
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
|
||||
}
|
||||
|
||||
// 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。
|
||||
if (! $this->chatState->isUserInRoom($roomId, $operator->username)) {
|
||||
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过', 'room' => $room];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标用户是否仍在线于当前房间。
|
||||
*
|
||||
* @return array{ok: bool, message: string}
|
||||
*/
|
||||
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
|
||||
{
|
||||
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
|
||||
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验聊天室用户管理动作是否可执行。
|
||||
*
|
||||
* 规则:
|
||||
* - id=1 站长始终放行
|
||||
* - 其他人必须拥有对应职务权限
|
||||
* - 不能操作自己
|
||||
* - 不能处理 user_level 高于自己的用户
|
||||
* - 不能处理部门位阶或职务位阶高于自己的用户
|
||||
*
|
||||
* @return array{ok: bool, message: string, target?: User}
|
||||
*/
|
||||
private function authorizeModerationAction(
|
||||
User $admin,
|
||||
string $targetUsername,
|
||||
string $permissionCode,
|
||||
string $actionLabel,
|
||||
): array {
|
||||
if ($admin->id !== 1 && ! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
|
||||
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
|
||||
}
|
||||
|
||||
if ($admin->username === $targetUsername) {
|
||||
return ['ok' => false, 'message' => '不能对自己执行该操作'];
|
||||
}
|
||||
|
||||
$target = User::query()
|
||||
->where('username', $targetUsername)
|
||||
->with('activePosition.position.department')
|
||||
->first();
|
||||
|
||||
if (! $target) {
|
||||
return ['ok' => false, 'message' => '用户不存在'];
|
||||
}
|
||||
|
||||
if (! $this->canModerateTargetByDutyRank($admin, $target)) {
|
||||
return ['ok' => false, 'message' => '不能处理职务高于自己的用户'];
|
||||
}
|
||||
|
||||
if ($admin->id !== 1 && $target->user_level > $admin->user_level) {
|
||||
return ['ok' => false, 'message' => '不能处理等级高于自己的用户'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '校验通过', 'target' => $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将目标用户从当前在线的全部房间中移除。
|
||||
*/
|
||||
private function removeUserFromAllRooms(string $targetUsername): void
|
||||
{
|
||||
$rooms = $this->chatState->getUserRooms($targetUsername);
|
||||
|
||||
foreach ($rooms as $rid) {
|
||||
$this->chatState->userLeave((int) $rid, $targetUsername);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断操作者是否可以按职务位阶处理目标用户。
|
||||
*
|
||||
* 规则:
|
||||
* - 先比较部门 rank
|
||||
* - 部门相同再比较职务 rank
|
||||
* - 对方没有在职职务时视为可处理
|
||||
* - 同职务平级允许操作,仅禁止处理更高职务
|
||||
*/
|
||||
private function canModerateTargetByDutyRank(User $admin, User $target): bool
|
||||
{
|
||||
if ($admin->id === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$adminPosition = $admin->activePosition?->load('position.department')->position;
|
||||
if (! $adminPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetPosition = $target->activePosition?->position;
|
||||
if (! $targetPosition) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$adminDepartmentRank = (int) ($adminPosition->department?->rank ?? 0);
|
||||
$targetDepartmentRank = (int) ($targetPosition->department?->rank ?? 0);
|
||||
|
||||
if ($adminDepartmentRank > $targetDepartmentRank) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($adminDepartmentRank < $targetDepartmentRank) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $adminPosition->rank >= (int) $targetPosition->rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员全员清屏
|
||||
*
|
||||
@@ -406,12 +726,15 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
// 改为按职务权限控制聊天室顶部“清屏”按钮。
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
|
||||
}
|
||||
|
||||
// 需要站长权限才能全员清屏
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 清除 Redis 中该房间的消息缓存
|
||||
@@ -423,11 +746,50 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'success', 'message' => '已执行全员清屏']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站长触发当前房间全员刷新页面。
|
||||
*
|
||||
* 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件,
|
||||
* 适用于功能更新后强制让前端重新拉取最新页面状态。
|
||||
*/
|
||||
public function refreshAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'room_id' => 'required|integer',
|
||||
'reason' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
if ((int) $admin->id !== 1) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403);
|
||||
}
|
||||
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
$reason = ChatContentSanitizer::htmlText($request->input('reason', ''));
|
||||
|
||||
// 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。
|
||||
broadcast(new BrowserRefreshRequested(
|
||||
roomId: $roomId,
|
||||
operator: $admin->username,
|
||||
reason: $reason,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '已通知当前房间所有在线用户刷新页面',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员触发全屏特效。
|
||||
*
|
||||
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
|
||||
* 仅 superlevel 等级管理员可触发。
|
||||
* 仅拥有 room.fullscreen_effect 权限的职务可触发。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 room_id, type
|
||||
* @return JsonResponse 操作结果
|
||||
@@ -440,13 +802,15 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
|
||||
$admin = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type');
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
|
||||
}
|
||||
|
||||
// 仅 superlevel 等级可触发特效
|
||||
if ($admin->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 广播特效事件给房间内所有在线用户
|
||||
@@ -487,6 +851,10 @@ class AdminCommandController extends Controller
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$amount = (int) $request->input('amount');
|
||||
$targetUsername = $request->input('username');
|
||||
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
|
||||
if (! $roomAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// 不能给自己发放
|
||||
if ($admin->username === $targetUsername) {
|
||||
@@ -499,12 +867,21 @@ class AdminCommandController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
|
||||
}
|
||||
|
||||
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
|
||||
if (! $targetAuthorization['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
|
||||
}
|
||||
|
||||
// id=1 超级管理员:无需职务,无限额限制
|
||||
$isSuperAdmin = $admin->id === 1;
|
||||
$userPosition = null;
|
||||
$position = null;
|
||||
|
||||
if (! $isSuperAdmin) {
|
||||
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
|
||||
}
|
||||
|
||||
// ① 必须有在职职务
|
||||
$userPosition = $admin->activePosition;
|
||||
if (! $userPosition) {
|
||||
@@ -721,39 +1098,6 @@ class AdminCommandController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查:管理员是否可对目标用户执行指定操作
|
||||
*
|
||||
* 根据 sysparam 中配置的等级门槛判断权限。
|
||||
*
|
||||
* @param User $admin 管理员用户
|
||||
* @param string $targetUsername 目标用户名
|
||||
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn)
|
||||
* @param string $defaultLevel 默认等级值
|
||||
* @return bool 是否有权限
|
||||
*/
|
||||
private function canExecute(User $admin, string $targetUsername, string $levelKey, string $defaultLevel = '5'): bool
|
||||
{
|
||||
// 必须达到该操作所需的最低等级
|
||||
$requiredLevel = (int) Sysparam::getValue($levelKey, $defaultLevel);
|
||||
if ($admin->user_level < $requiredLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不能操作自己
|
||||
if ($admin->username === $targetUsername) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标用户等级不能高于操作者(允许平级互相操作)
|
||||
$target = User::where('username', $targetUsername)->first();
|
||||
if ($target && $target->user_level > $admin->user_level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
|
||||
*/
|
||||
|
||||
@@ -14,11 +14,16 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理银行余额、存取款和存款排行展示。
|
||||
*/
|
||||
class BankController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -45,20 +50,27 @@ class BankController extends Controller
|
||||
*/
|
||||
public function ranking(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $operator */
|
||||
$operator = Auth::user();
|
||||
$direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$users = \App\Models\User::where('bank_jjb', '>', 0)
|
||||
$users = User::where('bank_jjb', '>', 0)
|
||||
->orderBy('bank_jjb', $direction)
|
||||
->paginate(20, ['id', 'username', 'bank_jjb', 'sex', 'usersf', 'user_level']);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'ranking' => $users->map(function ($u) {
|
||||
// 提供必要的前端展示字段
|
||||
'ranking' => $users->map(function (User $u) use ($operator) {
|
||||
$canViewBalance = $this->canViewBankBalance($operator, $u);
|
||||
|
||||
// 提供必要的前端展示字段,普通用户查看别人存款时只返回星号,防止前端绕过遮罩。
|
||||
return [
|
||||
'id' => $u->id,
|
||||
'username' => $u->username,
|
||||
'bank_jjb' => $u->bank_jjb,
|
||||
'bank_jjb' => $canViewBalance ? ($u->bank_jjb ?? 0) : '******',
|
||||
'bank_jjb_masked' => ! $canViewBalance,
|
||||
'can_reveal' => ! $canViewBalance,
|
||||
'reveal_cost' => UserController::INFO_REVEAL_COST,
|
||||
'sex' => $u->sex,
|
||||
'usersf' => $u->usersf,
|
||||
'user_level' => $u->user_level,
|
||||
@@ -158,4 +170,18 @@ class BankController extends Controller
|
||||
'bank_jjb' => $fresh->bank_jjb,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断操作者是否可以免费查看目标用户银行存款。
|
||||
*/
|
||||
private function canViewBankBalance(User $operator, User $targetUser): bool
|
||||
{
|
||||
if ($operator->id === $targetUser->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
return (int) $operator->user_level >= $superLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\AppointmentAnnounced;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserBrowserRefreshRequested;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\Position;
|
||||
use App\Models\User;
|
||||
@@ -106,6 +107,13 @@ class ChatAppointmentController extends Controller
|
||||
icon: '✨',
|
||||
);
|
||||
}
|
||||
|
||||
// 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: (int) $target->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已发生变更,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -161,6 +169,13 @@ class ChatAppointmentController extends Controller
|
||||
icon: '📋',
|
||||
);
|
||||
}
|
||||
|
||||
// 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。
|
||||
broadcast(new UserBrowserRefreshRequested(
|
||||
targetUserId: (int) $target->id,
|
||||
operator: $operator->username,
|
||||
reason: '你的职务已被撤销,页面权限正在同步更新。',
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -56,8 +56,12 @@ class ChatBotController extends Controller
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:2000',
|
||||
'room_id' => 'required|integer',
|
||||
'is_secret' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 私聊模式:AI 回复也走悄悄话,仅发言人和 AI 可见
|
||||
$isSecret = (bool) $request->input('is_secret', false);
|
||||
|
||||
// 检查全局开关
|
||||
$enabled = Sysparam::getValue('chatbot_enabled', '0');
|
||||
if ($enabled !== '1') {
|
||||
@@ -81,19 +85,22 @@ class ChatBotController extends Controller
|
||||
|
||||
$reply = $result['reply'];
|
||||
|
||||
// 检查 AI 是否决定给用户发金币
|
||||
if (str_contains($reply, '[ACTION:GIVE_GOLD]')) {
|
||||
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
|
||||
// 检查 AI 是否决定给用户发金币(新格式:[ACTION:GIVE_GOLD:金额])
|
||||
if (preg_match('/\[ACTION:GIVE_GOLD:(\d+)\]/', $reply, $matches)) {
|
||||
$aiGoldAmount = (int) $matches[1];
|
||||
$reply = preg_replace('/\[ACTION:GIVE_GOLD:\d+\]/', '', $reply);
|
||||
$reply = trim($reply);
|
||||
|
||||
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
|
||||
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
// 校验 AI 给出的金额在合理范围内
|
||||
$goldAmount = max(100, min($aiGoldAmount, $maxGold));
|
||||
|
||||
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
|
||||
$dailyCount = (int) Redis::get($redisKey);
|
||||
|
||||
if ($dailyCount < $maxDailyRewards) {
|
||||
$goldAmount = rand(100, $maxGold);
|
||||
|
||||
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
|
||||
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
|
||||
@@ -148,14 +155,14 @@ class ChatBotController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 广播 AI 回复消息
|
||||
// 广播 AI 回复消息(私聊模式下仅发言人与 AI 可见)
|
||||
$botMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => $user->username,
|
||||
'content' => $reply,
|
||||
'is_secret' => false,
|
||||
'is_secret' => $isSecret,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
|
||||
@@ -25,10 +25,15 @@ use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\AppointmentService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\MessageFilterService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\VipService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use App\Support\ChatDailyStatusCatalog;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -52,12 +57,14 @@ class ChatController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
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,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -102,21 +109,7 @@ class ChatController extends Controller
|
||||
}
|
||||
|
||||
// 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,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'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 ?? '',
|
||||
];
|
||||
$userData = $this->chatUserPresenceService->build($user);
|
||||
$this->chatState->userJoin($id, $user->username, $userData);
|
||||
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
|
||||
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
|
||||
@@ -125,13 +118,20 @@ class ChatController extends Controller
|
||||
$newbieEffect = null;
|
||||
$initialPresenceTheme = null;
|
||||
$initialWelcomeMessage = null;
|
||||
$initialWelcomeMessages = [];
|
||||
|
||||
if (! $isAlreadyInRoom) {
|
||||
// 广播 UserJoined 事件,通知房间内的其他人
|
||||
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
|
||||
|
||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
|
||||
if (! $user->has_received_new_gift) {
|
||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||
// 必须在推送新消息之前执行,否则可能误删刚刚创建的欢迎播报
|
||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||
|
||||
// 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告。
|
||||
$user->refresh();
|
||||
$isNewbie = ! $user->has_received_new_gift;
|
||||
if ($isNewbie) {
|
||||
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
|
||||
@@ -149,21 +149,47 @@ class ChatController extends Controller
|
||||
'font_color' => '#b91c1c',
|
||||
'action' => '',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'newbie_bonus',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $newbieMsg);
|
||||
broadcast(new MessageSent($id, $newbieMsg));
|
||||
SaveMessageJob::dispatch($newbieMsg);
|
||||
$initialWelcomeMessages[] = $newbieMsg;
|
||||
|
||||
// 广播烟花特效给此时已在房间的其他用户
|
||||
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
|
||||
|
||||
// 传给前端,让新人自己的屏幕上也燃放烟花
|
||||
$newbieEffect = 'fireworks';
|
||||
}
|
||||
|
||||
// superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
|
||||
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
|
||||
$this->chatState->removeOldWelcomeMessages($id, $user->username);
|
||||
// AI小班长发送欢迎消息,附带聊天室简单介绍
|
||||
$aiWelcomeTemplates = [
|
||||
"🫡 呀!新战友【{$user->username}】来了!我是本聊天室的 AI小班长——女兵班长在此!咱们这儿能聊天交友、玩百家乐/赛马/钓鱼/五子棋,还能每日签到领金币、逛商店买道具。有啥不懂的随时问我,班长罩着你!",
|
||||
"✨ 欢迎新兵【{$user->username}】入伍!我是 AI小班长,你的贴心兵姐姐~ 本聊天室可以群聊私聊、玩各种小游戏(百家乐/赛马/钓鱼/五子棋)、每日签到赚金币。新人有 6666 金币见面礼哦!有问题喊班长!",
|
||||
"🐻 立正!欢迎【{$user->username}】同志加入!我是这里的 AI小班长,负责带新兵熟悉环境。本聊天室可聊天交友、玩游戏(百家乐/赛马/钓鱼/五子棋/老虎机)、签到领福利、商店买道具。别客气,把这儿当家!",
|
||||
"💪 热烈欢迎【{$user->username}】!我是 AI小班长,你的战友兼客服兵姐姐。这儿可以聊天、玩游戏(百家乐/赛马/钓鱼)、签到打卡、逛商店,功能多多!有不懂的随时 @我,班长在线答疑!",
|
||||
];
|
||||
$aiWelcomeContent = $aiWelcomeTemplates[array_rand($aiWelcomeTemplates)];
|
||||
|
||||
$aiWelcomeMsg = [
|
||||
'id' => $this->chatState->nextMessageId($id),
|
||||
'room_id' => $id,
|
||||
'from_user' => 'AI小班长',
|
||||
'to_user' => '大家',
|
||||
'content' => $aiWelcomeContent,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'ai_newbie_welcome',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$this->chatState->pushMessage($id, $aiWelcomeMsg);
|
||||
broadcast(new MessageSent($id, $aiWelcomeMsg));
|
||||
SaveMessageJob::dispatch($aiWelcomeMsg);
|
||||
$initialWelcomeMessages[] = $aiWelcomeMsg;
|
||||
}
|
||||
|
||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||
@@ -179,6 +205,7 @@ class ChatController extends Controller
|
||||
'font_color' => $color,
|
||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||
'welcome_user' => $user->username,
|
||||
'welcome_kind' => 'entry_broadcast',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -190,6 +217,7 @@ class ChatController extends Controller
|
||||
|
||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
||||
|
||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||
@@ -220,8 +248,8 @@ class ChatController extends Controller
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
}
|
||||
|
||||
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
|
||||
return $fromUser === $username || $toUser === $username;
|
||||
// 非悄悄话的定向发言仍属于公屏消息,历史回放也要让房间内其他人可见。
|
||||
return true;
|
||||
}));
|
||||
|
||||
// 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次)
|
||||
@@ -278,7 +306,9 @@ class ChatController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// 渲染主聊天框架视图
|
||||
// 渲染主聊天框架视图前,先计算当前用户的聊天室顶部管理权限。
|
||||
$roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);
|
||||
|
||||
return view('chat.frame', [
|
||||
'room' => $room,
|
||||
'user' => $user,
|
||||
@@ -286,9 +316,14 @@ class ChatController extends Controller
|
||||
'newbieEffect' => $newbieEffect,
|
||||
'initialPresenceTheme' => $initialPresenceTheme,
|
||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||
'historyMessages' => $historyMessages,
|
||||
'pendingProposal' => $pendingProposalData,
|
||||
'pendingDivorce' => $pendingDivorceData,
|
||||
'roomPermissionMap' => $roomPermissionMap,
|
||||
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
|
||||
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
|
||||
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -355,6 +390,10 @@ class ChatController extends Controller
|
||||
$user = Auth::user();
|
||||
$imagePayload = null;
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
|
||||
$muteKey = "mute:{$id}:{$user->username}";
|
||||
if (Redis::exists($muteKey)) {
|
||||
@@ -410,6 +449,10 @@ class ChatController extends Controller
|
||||
$messageData = array_merge($messageData, $imagePayload);
|
||||
}
|
||||
|
||||
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
|
||||
$messageData = array_merge($messageData, $decorations);
|
||||
|
||||
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
||||
$this->chatState->pushMessage($id, $messageData);
|
||||
|
||||
@@ -519,18 +562,7 @@ class ChatController extends Controller
|
||||
|
||||
// 3. 将新的等级反馈给当前用户的在线名单上
|
||||
// 确保刚刚升级后别人查看到的也是最准确等级
|
||||
$activePosition = $user->activePosition;
|
||||
$this->chatState->userJoin($id, $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' => $user->user_level >= $superLevel,
|
||||
'position_icon' => $activePosition?->position?->icon ?? '',
|
||||
'position_name' => $activePosition?->position?->name ?? '',
|
||||
]);
|
||||
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
|
||||
|
||||
// 4. 如果突破境界,向全房系统喊话广播!
|
||||
if ($leveledUp) {
|
||||
@@ -647,6 +679,7 @@ class ChatController extends Controller
|
||||
} elseif ($user->user_level >= $superLevel) {
|
||||
$title = '管理员';
|
||||
}
|
||||
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
@@ -657,6 +690,11 @@ class ChatController extends Controller
|
||||
'jjb_gain' => $actualJjbGain,
|
||||
'user_level' => $user->user_level,
|
||||
'title' => $title,
|
||||
'identity_summary' => $identitySummary['inline'],
|
||||
'department_name' => $identitySummary['department_name'],
|
||||
'position_name' => $identitySummary['position_name'],
|
||||
'vip_name' => $identitySummary['vip_name'],
|
||||
'vip_icon' => $identitySummary['vip_icon'],
|
||||
'leveled_up' => $leveledUp,
|
||||
'is_max_level' => $user->user_level >= $superLevel,
|
||||
'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null,
|
||||
@@ -799,18 +837,10 @@ class ChatController extends Controller
|
||||
|
||||
// 将新头像同步到 Redis 在线用户列表中(所有房间)
|
||||
// 通过更新 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' => $headface,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
]);
|
||||
// 头像更新后,统一通过在线载荷服务刷新所有扩展字段,避免状态或职务字段丢失。
|
||||
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -865,18 +895,10 @@ class ChatController extends Controller
|
||||
}
|
||||
|
||||
// 同步 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,
|
||||
]);
|
||||
// 自定义头像上传成功后,同步覆盖在线名单中的全部展示字段。
|
||||
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -892,7 +914,8 @@ class ChatController extends Controller
|
||||
|
||||
/**
|
||||
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
|
||||
* 需要房间主人或等级达到 level_announcement 配置值
|
||||
* 需要当前在职职务拥有 room.announcement 权限,
|
||||
* id=1 站长始终允许操作。
|
||||
*
|
||||
* @param int $id 房间ID
|
||||
*/
|
||||
@@ -901,19 +924,23 @@ class ChatController extends Controller
|
||||
$user = Auth::user();
|
||||
$room = Room::findOrFail($id);
|
||||
|
||||
// 权限检查:房间主人 或 等级 >= level_announcement
|
||||
$requiredLevel = (int) Sysparam::getValue('level_announcement', '10');
|
||||
if ($user->username !== $room->master && $user->user_level < $requiredLevel) {
|
||||
// 改为统一走职务权限判断,不再给房主单独保留公告特权。
|
||||
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_ANNOUNCEMENT)) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
|
||||
}
|
||||
|
||||
if (! $this->chatState->isUserInRoom($id, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'announcement' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
|
||||
$room->announcement = trim($request->input('announcement'))
|
||||
.' ——'.$user->username.' '.now()->format('m-d H:i');
|
||||
$announcementText = trim((string) $request->input('announcement'));
|
||||
|
||||
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。
|
||||
$room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
|
||||
$room->save();
|
||||
|
||||
// 广播公告更新到所有在线用户
|
||||
@@ -922,7 +949,7 @@ class ChatController extends Controller
|
||||
'room_id' => $id,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '大家',
|
||||
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}",
|
||||
'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#cc0000',
|
||||
'action' => '',
|
||||
@@ -964,6 +991,10 @@ class ChatController extends Controller
|
||||
$giftId = $request->integer('gift_id');
|
||||
$count = $request->integer('count', 1);
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 不能给自己送花
|
||||
if ($toUsername === $user->username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']);
|
||||
@@ -981,6 +1012,10 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在']);
|
||||
}
|
||||
|
||||
if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$totalCost = $gift->cost * $count;
|
||||
$totalCharm = $gift->charm * $count;
|
||||
|
||||
@@ -1004,12 +1039,15 @@ class ChatController extends Controller
|
||||
|
||||
// 广播送花消息(含图片标记,前端识别后渲染图片)
|
||||
$countText = $count > 1 ? " {$count} 份" : '';
|
||||
$safeSender = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeReceiver = ChatContentSanitizer::htmlText($toUsername);
|
||||
$safeGiftName = ChatContentSanitizer::htmlText($gift->name);
|
||||
$sysMsg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '送花播报',
|
||||
'to_user' => $toUsername,
|
||||
'content' => "{$gift->emoji} 【{$user->username}】 向 【{$toUsername}】 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!",
|
||||
'content' => "{$gift->emoji} 【{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}【{$safeGiftName}】!魅力 +{$totalCharm}!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#e91e8f',
|
||||
'action' => '',
|
||||
@@ -1278,6 +1316,10 @@ class ChatController extends Controller
|
||||
$roomId = $request->integer('room_id');
|
||||
$amount = $request->integer('amount');
|
||||
|
||||
if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 不能给自己转账
|
||||
if ($toName === $sender->username) {
|
||||
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
|
||||
@@ -1289,6 +1331,10 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '用户不存在']);
|
||||
}
|
||||
|
||||
if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 余额校验
|
||||
if (($sender->jjb ?? 0) < $amount) {
|
||||
return response()->json([
|
||||
@@ -1315,7 +1361,7 @@ class ChatController extends Controller
|
||||
// 接收方收到消息时,在右下角弹到账提示卡片。
|
||||
'toast_notification' => [
|
||||
'title' => '💰 赠金币到账',
|
||||
'message' => "<b>{$sender->username}</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||
'icon' => '💰',
|
||||
'color' => '#f59e0b',
|
||||
'duration' => 8000,
|
||||
@@ -1336,4 +1382,37 @@ class ChatController extends Controller
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户是否能在指定房间执行聊天动作。
|
||||
*/
|
||||
private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse
|
||||
{
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
|
||||
}
|
||||
|
||||
$room = Room::query()->find($roomId);
|
||||
if (! $room) {
|
||||
return response()->json(['status' => 'error', 'message' => '房间不存在'], 404);
|
||||
}
|
||||
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => $message], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
|
||||
*/
|
||||
private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse
|
||||
{
|
||||
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
|
||||
return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间,无法执行该操作'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台每日签到控制器
|
||||
*
|
||||
* 提供签到状态查询、领取奖励、刷新在线名单载荷和聊天室签到通知。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Http\Requests\ClaimDailySignInRequest;
|
||||
use App\Http\Requests\DailySignInCalendarRequest;
|
||||
use App\Http\Requests\MakeupDailySignInRequest;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\User;
|
||||
use App\Models\UserIdentityBadge;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\SignInService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 类功能:处理前台用户每日签到状态与领取奖励流程。
|
||||
*/
|
||||
class DailySignInController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造每日签到控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SignInService $signInService,
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $presenceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:查询当前用户今日签到状态和奖励预览。
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$status = $this->signInService->status($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $this->formatStatusPayload($user, $status),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:查询指定月份的签到日历与补签卡状态。
|
||||
*/
|
||||
public function calendar(DailySignInCalendarRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $this->signInService->calendar($user, $request->validated('month')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:领取今日签到奖励并同步聊天室在线名单。
|
||||
*/
|
||||
public function claim(ClaimDailySignInRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$roomId = $request->validated('room_id');
|
||||
$roomId = $roomId === null ? null : (int) $roomId;
|
||||
|
||||
$dailySignIn = $this->signInService->claim($user, $roomId);
|
||||
if (! $dailySignIn->wasRecentlyCreated) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '今日已签到,请明天再来。',
|
||||
'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
|
||||
$presencePayload = $this->presenceService->build($freshUser);
|
||||
$this->refreshOnlinePresence($freshUser, $presencePayload);
|
||||
|
||||
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
|
||||
$this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $this->buildSuccessMessage($dailySignIn),
|
||||
'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:使用补签卡补签历史漏签日期。
|
||||
*/
|
||||
public function makeup(MakeupDailySignInRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$roomId = $request->validated('room_id');
|
||||
$roomId = $roomId === null ? null : (int) $roomId;
|
||||
|
||||
$dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId);
|
||||
$refreshedSignIn = $dailySignIn->fresh();
|
||||
$latestSignIn = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('sign_in_date')
|
||||
->first();
|
||||
$currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0);
|
||||
|
||||
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
|
||||
$presencePayload = $this->presenceService->build($freshUser);
|
||||
$this->refreshOnlinePresence($freshUser, $presencePayload);
|
||||
|
||||
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
|
||||
$this->broadcastSignInNotice($freshUser, $refreshedSignIn, $roomId, $currentStreakDays);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。',
|
||||
'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。
|
||||
*
|
||||
* @param array<string, mixed> $presencePayload
|
||||
*/
|
||||
private function refreshOnlinePresence(User $user, array $presencePayload): void
|
||||
{
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) {
|
||||
// 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。
|
||||
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
|
||||
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:向当前聊天室广播签到成功通知。
|
||||
*/
|
||||
private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId, ?int $currentStreakDays = null): void
|
||||
{
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $this->buildNoticeContent($user, $dailySignIn, $currentStreakDays),
|
||||
'is_secret' => false,
|
||||
'font_color' => '#0f766e',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage($roomId, $message);
|
||||
broadcast(new MessageSent($roomId, $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成聊天室内的签到播报内容。
|
||||
*/
|
||||
private function buildNoticeContent(User $user, DailySignIn $dailySignIn, ?int $currentStreakDays = null): string
|
||||
{
|
||||
$rewardText = $this->buildRewardText($dailySignIn);
|
||||
$identityText = $dailySignIn->identity_badge_name
|
||||
? ',获得身份 '.e($dailySignIn->identity_badge_name)
|
||||
: '';
|
||||
|
||||
if ($dailySignIn->is_makeup) {
|
||||
$signInDate = $dailySignIn->sign_in_date?->format('Y-m-d') ?? '漏签日期';
|
||||
$streakDays = $currentStreakDays ?? (int) $dailySignIn->streak_days;
|
||||
|
||||
return '【'.e($user->username).'】使用补签卡补签 '.$signInDate
|
||||
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
|
||||
}
|
||||
|
||||
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
|
||||
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
|
||||
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
|
||||
.'✅ 快速签到</button>';
|
||||
|
||||
return '【'.e($user->username).'】完成今日签到,连续签到 '
|
||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:生成本机签到成功提示文案。
|
||||
*/
|
||||
private function buildSuccessMessage(DailySignIn $dailySignIn): string
|
||||
{
|
||||
return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:按实际签到奖励快照生成奖励描述。
|
||||
*/
|
||||
private function buildRewardText(DailySignIn $dailySignIn): string
|
||||
{
|
||||
$items = [];
|
||||
if ($dailySignIn->gold_reward > 0) {
|
||||
$items[] = $dailySignIn->gold_reward.' 金币';
|
||||
}
|
||||
if ($dailySignIn->exp_reward > 0) {
|
||||
$items[] = $dailySignIn->exp_reward.' 经验';
|
||||
}
|
||||
if ($dailySignIn->charm_reward > 0) {
|
||||
$items[] = $dailySignIn->charm_reward.' 魅力';
|
||||
}
|
||||
|
||||
return $items === [] ? '签到记录' : implode(' + ', $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化状态查询响应载荷。
|
||||
*
|
||||
* @param array<string, mixed> $status
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatStatusPayload(User $user, array $status): array
|
||||
{
|
||||
return [
|
||||
'signed_today' => $status['signed_today'],
|
||||
'can_claim' => $status['can_claim'],
|
||||
'current_streak_days' => $status['current_streak_days'],
|
||||
'claimable_streak_days' => $status['claimable_streak_days'],
|
||||
'preview_rule' => $status['matched_rule']?->only([
|
||||
'streak_days',
|
||||
'gold_reward',
|
||||
'exp_reward',
|
||||
'charm_reward',
|
||||
'identity_badge_name',
|
||||
'identity_badge_icon',
|
||||
'identity_badge_color',
|
||||
'identity_duration_days',
|
||||
]),
|
||||
'identity' => $this->formatIdentityPayload($status['current_identity']),
|
||||
'user' => [
|
||||
'jjb' => (int) $user->jjb,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化签到领取响应载荷。
|
||||
*
|
||||
* @param array<string, mixed>|null $presencePayload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array
|
||||
{
|
||||
$identity = $user->currentSignInIdentity();
|
||||
|
||||
return [
|
||||
'sign_in' => [
|
||||
'id' => $dailySignIn->id,
|
||||
'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(),
|
||||
'is_makeup' => (bool) $dailySignIn->is_makeup,
|
||||
'streak_days' => (int) $dailySignIn->streak_days,
|
||||
'gold_reward' => (int) $dailySignIn->gold_reward,
|
||||
'exp_reward' => (int) $dailySignIn->exp_reward,
|
||||
'charm_reward' => (int) $dailySignIn->charm_reward,
|
||||
],
|
||||
'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days,
|
||||
'identity' => $this->formatIdentityPayload($identity),
|
||||
'presence' => $presencePayload ?? $this->presenceService->build($user),
|
||||
'user' => [
|
||||
'jjb' => (int) $user->jjb,
|
||||
'gold' => (int) $user->jjb,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:格式化签到身份数据供前端展示。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function formatIdentityPayload(?UserIdentityBadge $identity): ?array
|
||||
{
|
||||
if ($identity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $identity->badge_code,
|
||||
'label' => $identity->badge_name,
|
||||
'name' => $identity->badge_name,
|
||||
'icon' => $identity->badge_icon ?? '✅',
|
||||
'color' => $identity->badge_color ?? '#0f766e',
|
||||
'expires_at' => $identity->expires_at?->toIso8601String(),
|
||||
'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,38 @@ class FeedbackController extends Controller
|
||||
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈第一页数据(JSON API)
|
||||
* 供聊天室模态弹窗使用,格式与 loadMore 一致
|
||||
*
|
||||
* @param Request $request 含 type 筛选参数
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->input('type'); // bug|suggestion|null(全部)
|
||||
|
||||
$query = FeedbackItem::with(['replies'])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($type && in_array($type, ['bug', 'suggestion'])) {
|
||||
$query->ofType($type);
|
||||
}
|
||||
|
||||
$items = $query->limit(self::PAGE_SIZE)->get();
|
||||
|
||||
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
|
||||
->whereIn('feedback_id', $items->pluck('id'))
|
||||
->pluck('feedback_id')
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->formatItems($items, $myVotedIds),
|
||||
'last_id' => $items->last()?->id ?? 0,
|
||||
'has_more' => $items->count() === self::PAGE_SIZE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载更多反馈(JSON API)
|
||||
* 支持按类型筛选(bug / suggestion)
|
||||
@@ -257,6 +289,10 @@ class FeedbackController extends Controller
|
||||
*/
|
||||
private function formatItem(FeedbackItem $item, bool $voted): array
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
$isOwner = $item->user_id === $user->id;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'type' => $item->type,
|
||||
@@ -272,6 +308,8 @@ class FeedbackController extends Controller
|
||||
'username' => $item->username,
|
||||
'created_at' => $item->created_at->diffForHumans(),
|
||||
'voted' => $voted,
|
||||
'is_owner' => $isOwner,
|
||||
'can_delete' => ($isOwner && $item->is_within_24_hours) || $user->id === 1,
|
||||
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
|
||||
'id' => $r->id,
|
||||
'username' => $r->username,
|
||||
|
||||
@@ -267,19 +267,20 @@ class FriendController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据操作类型和互相状态生成不同文案(含内联快捷操作链接)
|
||||
// 根据操作类型和互相状态生成不同文案(含前端代理快捷操作链接)
|
||||
$btnStyle = 'font-weight:bold;text-decoration:underline;margin-left:6px;';
|
||||
$btnAdd = "<a href='#' onclick=\"quickFriendAction('add','{$fromUsername}',this);return false;\" style='color:#16a34a;{$btnStyle}'>➕ 回加好友</a>";
|
||||
$btnRemove = "<a href='#' onclick=\"quickFriendAction('remove','{$fromUsername}',this);return false;\" style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
|
||||
$safeUsername = e($fromUsername);
|
||||
$btnAdd = "<a href='#' data-quick-friend-action='add' data-quick-friend-username='{$safeUsername}' style='color:#16a34a;{$btnStyle}'>➕ 回加好友</a>";
|
||||
$btnRemove = "<a href='#' data-quick-friend-action='remove' data-quick-friend-username='{$safeUsername}' style='color:#6b7280;{$btnStyle}'>🗑️ 同步移除</a>";
|
||||
|
||||
$content = match ($action) {
|
||||
'added' => $mutual
|
||||
? "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
|
||||
: "💚 <b>{$fromUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
|
||||
? "💚 <b>{$safeUsername}</b> 将你加为好友了!你们现在互为好友 🎉"
|
||||
: "💚 <b>{$safeUsername}</b> 将你加为好友了!但你还没有添加对方为好友。{$btnAdd}",
|
||||
'removed' => $mutual
|
||||
? "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
|
||||
: "💔 <b>{$fromUsername}</b> 已将你从他的好友列表移除。",
|
||||
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
|
||||
? "💔 <b>{$safeUsername}</b> 已将你从好友列表移除。你的好友列表中仍保留对方。{$btnRemove}"
|
||||
: "💔 <b>{$safeUsername}</b> 已将你从他的好友列表移除。",
|
||||
'online' => "🟢 你的好友 <b>{$safeUsername}</b> 上线啦!",
|
||||
default => '',
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Http\Requests\StoreGuestbookRequest;
|
||||
use App\Models\Guestbook;
|
||||
use App\Models\User;
|
||||
use App\Services\MessageFilterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -128,4 +129,69 @@ class GuestbookController extends Controller
|
||||
|
||||
return back()->with('success', '该行留言已被抹除。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回留言列表 JSON(供聊天室模态弹窗 AJAX 使用)
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$tab = $request->input('tab', 'public');
|
||||
$page = (int) $request->input('page', 1);
|
||||
$user = Auth::user();
|
||||
|
||||
$query = Guestbook::query()->orderByDesc('id');
|
||||
|
||||
if ($tab === 'inbox') {
|
||||
$query->where('towho', $user->username);
|
||||
} elseif ($tab === 'outbox') {
|
||||
$query->where('who', $user->username);
|
||||
} else {
|
||||
$query->where(function ($q) use ($user) {
|
||||
$q->where('secret', 0)
|
||||
->orWhere('who', $user->username)
|
||||
->orWhere('towho', $user->username);
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = 15;
|
||||
$total = $query->count();
|
||||
$messages = $query->skip(($page - 1) * $perPage)->take($perPage)->get();
|
||||
|
||||
$items = $messages->map(function ($msg) use ($user) {
|
||||
$isSecret = (bool) $msg->secret;
|
||||
$isToMe = $msg->towho === $user->username;
|
||||
$isFromMe = $msg->who === $user->username;
|
||||
$canDelete = $isFromMe || $isToMe || $user->user_level >= 15;
|
||||
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
'who' => $msg->who,
|
||||
'towho' => $msg->towho ?: '',
|
||||
'secret' => $isSecret,
|
||||
'text_body' => $msg->text_body,
|
||||
'post_time' => $msg->post_time?->diffForHumans() ?? '',
|
||||
'timestamp' => $msg->post_time?->toIso8601String() ?? '',
|
||||
'is_to_me' => $isToMe,
|
||||
'is_from_me' => $isFromMe,
|
||||
'can_delete' => $canDelete,
|
||||
'who_avatar' => mb_substr($msg->who, 0, 1),
|
||||
];
|
||||
});
|
||||
|
||||
// 获取所有用户名列表(供发信选择器使用)
|
||||
$users = User::where('username', '!=', $user->username)
|
||||
->orderBy('username')
|
||||
->pluck('username');
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'has_more' => ($page * $perPage) < $total,
|
||||
'users' => $users,
|
||||
'tab' => $tab,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,20 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\HolidayClaim;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:处理节日福利批次的前台领取与状态查询。
|
||||
*/
|
||||
class HolidayController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入用户金币服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
@@ -30,56 +36,72 @@ class HolidayController extends Controller
|
||||
/**
|
||||
* 用户领取节日福利红包。
|
||||
*
|
||||
* 从 holiday_claims 中查找当前用户的待领取记录,
|
||||
* 入账金币并更新活动统计数据。
|
||||
* 从 holiday_claims 中查找当前用户在指定批次下的待领取记录,
|
||||
* 入账金币并更新批次统计数据。
|
||||
*/
|
||||
public function claim(Request $request, HolidayEvent $event): JsonResponse
|
||||
public function claim(Request $request, HolidayEventRun $run): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// 活动是否在领取有效期内
|
||||
if (! $event->isClaimable()) {
|
||||
// 批次是否在领取有效期内。
|
||||
if (! $run->isClaimable()) {
|
||||
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
}
|
||||
|
||||
// 查找该用户的领取记录(批量插入时已生成)
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
return DB::transaction(function () use ($run, $user): JsonResponse {
|
||||
/** @var HolidayEventRun|null $lockedRun */
|
||||
$lockedRun = HolidayEventRun::query()
|
||||
->whereKey($run->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $claim) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
if (! $lockedRun || ! $lockedRun->isClaimable()) {
|
||||
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
}
|
||||
|
||||
// 防止重复领取(claimed_at 为 null 表示未领取)
|
||||
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
|
||||
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
|
||||
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
|
||||
// 金币入账
|
||||
/** @var HolidayClaim|null $claim */
|
||||
$claim = HolidayClaim::query()
|
||||
->where('run_id', $lockedRun->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $claim) {
|
||||
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
|
||||
// claimed_at 不为空代表本轮已领过,直接返回幂等提示。
|
||||
if ($claim->claimed_at !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '您已领取过本轮福利。',
|
||||
'amount' => $claim->amount,
|
||||
]);
|
||||
}
|
||||
|
||||
// 金币入账。
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
$claim->amount,
|
||||
CurrencySource::HOLIDAY_BONUS,
|
||||
"节日福利:{$event->name}",
|
||||
"节日福利:{$lockedRun->event_name}",
|
||||
);
|
||||
|
||||
// 更新活动统计(只在首次领取时)
|
||||
HolidayEvent::query()
|
||||
->where('id', $event->id)
|
||||
->increment('claimed_amount', $claim->amount);
|
||||
// 领取成功后只更新 claimed_at,不再删除记录,便于幂等和历史追踪。
|
||||
$claim->update(['claimed_at' => now()]);
|
||||
|
||||
// 删除领取记录(以此标记"已领取",防止重复调用)
|
||||
$claim->delete();
|
||||
// 批次领取统计按成功领取次数累计。
|
||||
$lockedRun->increment('claimed_count');
|
||||
$lockedRun->increment('claimed_amount', $claim->amount);
|
||||
|
||||
// 检查是否已全部领完
|
||||
if ($event->max_claimants > 0) {
|
||||
$remaining = HolidayClaim::where('event_id', $event->id)->count();
|
||||
if ($remaining === 0) {
|
||||
$event->update(['status' => 'completed']);
|
||||
}
|
||||
$remainingPendingClaims = HolidayClaim::query()
|
||||
->where('run_id', $lockedRun->id)
|
||||
->whereNull('claimed_at')
|
||||
->count();
|
||||
|
||||
if ($remainingPendingClaims === 0) {
|
||||
$lockedRun->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -91,21 +113,23 @@ class HolidayController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户在指定活动中的待领取状态。
|
||||
* 查询当前用户在指定批次中的待领取状态。
|
||||
*/
|
||||
public function status(Request $request, HolidayEvent $event): JsonResponse
|
||||
public function status(Request $request, HolidayEventRun $run): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$claim = HolidayClaim::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('run_id', $run->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'claimable' => $claim !== null && $event->isClaimable(),
|
||||
'claimable' => $claim !== null && $claim->claimed_at === null && $run->isClaimable(),
|
||||
'claimed' => $claim?->claimed_at !== null,
|
||||
'amount' => $claim?->amount ?? 0,
|
||||
'expires_at' => $event->expires_at?->toIso8601String(),
|
||||
'status' => $run->status,
|
||||
'expires_at' => $run->expires_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ class HorseRaceController extends Controller
|
||||
public function currentRace(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
|
||||
}
|
||||
|
||||
$race = HorseRace::currentRace();
|
||||
|
||||
if (! $race) {
|
||||
@@ -70,15 +74,16 @@ class HorseRaceController extends Controller
|
||||
$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;
|
||||
$horses = $this->normalizeRaceHorses($race->horses);
|
||||
$horsesWithBets = array_map(function (array $horse) use ($horsePools, $oddsMap) {
|
||||
$horseId = (int) $horse['id'];
|
||||
$horsePool = (int) ($horsePools[$horseId] ?? 0);
|
||||
$odds = $horsePool > 0 ? ($oddsMap[$horseId] ?? null) : null;
|
||||
|
||||
return [
|
||||
'id' => $horse['id'],
|
||||
'name' => $horse['name'],
|
||||
'emoji' => $horse['emoji'],
|
||||
'id' => $horseId,
|
||||
'name' => (string) $horse['name'],
|
||||
'emoji' => (string) $horse['emoji'],
|
||||
'pool' => $horsePool,
|
||||
'odds' => $odds,
|
||||
];
|
||||
@@ -150,7 +155,7 @@ class HorseRaceController extends Controller
|
||||
}
|
||||
|
||||
// 验证马匹 ID 是否有效
|
||||
$horses = $race->horses ?? [];
|
||||
$horses = $this->normalizeRaceHorses($race->horses);
|
||||
$validIds = array_column($horses, 'id');
|
||||
if (! in_array($data['horse_id'], $validIds, true)) {
|
||||
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
|
||||
@@ -178,12 +183,7 @@ class HorseRaceController extends Controller
|
||||
|
||||
// 找出马匹名称
|
||||
$horseName = '';
|
||||
foreach ($horses as $horse) {
|
||||
if ((int) $horse['id'] === (int) $data['horse_id']) {
|
||||
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$horseName = $this->resolveHorseDisplayName($horses, (int) $data['horse_id']);
|
||||
|
||||
// 扣除金币
|
||||
$currency->change(
|
||||
@@ -244,9 +244,9 @@ class HorseRaceController extends Controller
|
||||
// 转换获胜马匹名称
|
||||
$history = $races->map(function ($race) {
|
||||
$winnerName = '未知';
|
||||
foreach (($race->horses ?? []) as $horse) {
|
||||
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
|
||||
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
|
||||
foreach ($this->normalizeRaceHorses($race->horses) as $horse) {
|
||||
if ((int) $horse['id'] === (int) $race->winner_horse_id) {
|
||||
$winnerName = (string) $horse['emoji'].(string) $horse['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -263,4 +263,56 @@ class HorseRaceController extends Controller
|
||||
|
||||
return response()->json(['history' => $history]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
|
||||
*
|
||||
* @return array<int, array{id:int,name:string,emoji:string}>
|
||||
*/
|
||||
private function normalizeRaceHorses(mixed $horses): array
|
||||
{
|
||||
if (! is_array($horses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalizedHorses = [];
|
||||
|
||||
foreach ($horses as $index => $horse) {
|
||||
if (! is_array($horse)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$horseId = isset($horse['id']) && is_numeric($horse['id'])
|
||||
? (int) $horse['id']
|
||||
: $index + 1;
|
||||
$horseName = trim((string) ($horse['name'] ?? ''));
|
||||
if ($horseName === '') {
|
||||
$horseName = '未知马匹';
|
||||
}
|
||||
|
||||
$normalizedHorses[] = [
|
||||
'id' => $horseId,
|
||||
'name' => $horseName,
|
||||
'emoji' => (string) ($horse['emoji'] ?? '🐎'),
|
||||
];
|
||||
}
|
||||
|
||||
return $normalizedHorses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据马匹编号返回展示名称,供系统播报与下注回执共用。
|
||||
*
|
||||
* @param array<int, array{id:int,name:string,emoji:string}> $horses
|
||||
*/
|
||||
private function resolveHorseDisplayName(array $horses, int $horseId): string
|
||||
{
|
||||
foreach ($horses as $horse) {
|
||||
if ((int) ($horse['id'] ?? 0) === $horseId) {
|
||||
return (string) ($horse['emoji'] ?? '🐎').(string) ($horse['name'] ?? '未知马匹');
|
||||
}
|
||||
}
|
||||
|
||||
return '🐎未知马匹';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\User;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:展示全站排行榜、今日排行榜与用户个人积分流水记录。
|
||||
*/
|
||||
class LeaderboardController extends Controller
|
||||
{
|
||||
/**
|
||||
@@ -133,8 +137,15 @@ class LeaderboardController extends Controller
|
||||
$user = auth()->user();
|
||||
$currency = request('currency');
|
||||
$days = (int) request('days', 7);
|
||||
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
|
||||
$direction = in_array(request('direction'), ['income', 'expense'], true) ? request('direction') : null;
|
||||
$sourceOptions = CurrencySource::cases();
|
||||
$allowedSources = collect($sourceOptions)->map(fn (CurrencySource $source) => $source->value)->all();
|
||||
$selectedSources = collect(request()->array('sources'))
|
||||
->filter(fn (string $source) => in_array($source, $allowedSources, true))
|
||||
->values()
|
||||
->all();
|
||||
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days, $direction, $selectedSources);
|
||||
|
||||
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
|
||||
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days', 'direction', 'sourceOptions', 'selectedSources'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 文件功能:聊天室礼包(红包)控制器
|
||||
*
|
||||
* 提供两个核心接口:
|
||||
* - send() :superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
|
||||
* - config():读取当前职务的默认礼包数量与份数
|
||||
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
|
||||
* - claim() :在线用户抢礼包(先到先得,每人一份)
|
||||
*
|
||||
* 接入 UserCurrencyService 记录所有货币变动流水。
|
||||
@@ -23,9 +24,11 @@ use App\Events\RedPacketSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\RedPacketClaim;
|
||||
use App\Models\RedPacketEnvelope;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -39,11 +42,11 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class RedPacketController extends Controller
|
||||
{
|
||||
/** 礼包固定总数量 */
|
||||
private const TOTAL_AMOUNT = 8888;
|
||||
/** 礼包默认总数量 */
|
||||
private const DEFAULT_TOTAL_AMOUNT = 8888;
|
||||
|
||||
/** 礼包固定份数 */
|
||||
private const TOTAL_COUNT = 10;
|
||||
/** 礼包默认份数 */
|
||||
private const DEFAULT_TOTAL_COUNT = 10;
|
||||
|
||||
/** 礼包有效期(秒) */
|
||||
private const EXPIRE_SECONDS = 300;
|
||||
@@ -54,12 +57,37 @@ class RedPacketController extends Controller
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* superlevel 站长凭空发出礼包。
|
||||
* 获取当前用户可发出的礼包默认配置。
|
||||
*
|
||||
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
|
||||
* 聊天室发包弹窗打开时调用,确保页面展示与最终发包数量同源。
|
||||
*/
|
||||
public function config(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 仅拥有礼包红包权限的在职职务可以读取发包配置。
|
||||
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
|
||||
}
|
||||
|
||||
$redPacketConfig = $this->redPacketConfigForUser($user);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'amount' => $redPacketConfig['amount'],
|
||||
'count' => $redPacketConfig['count'],
|
||||
'expire_seconds' => self::EXPIRE_SECONDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拥有权限的职务用户凭空发出礼包。
|
||||
*
|
||||
* 不扣发包人自身货币,礼包总量和份数读取当前在职职务配置。
|
||||
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
|
||||
*
|
||||
* @param Request $request 需包含 room_id 和 type(gold / exp)
|
||||
@@ -75,12 +103,15 @@ class RedPacketController extends Controller
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$type = $request->input('type'); // 'gold' 或 'exp'
|
||||
|
||||
// 权限校验:仅 superlevel 可发礼包
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
if ($user->user_level < $superLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
|
||||
// 改为按职务权限码控制礼包发放。
|
||||
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
|
||||
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
|
||||
}
|
||||
|
||||
$redPacketConfig = $this->redPacketConfigForUser($user);
|
||||
$totalAmount = $redPacketConfig['amount'];
|
||||
$totalCount = $redPacketConfig['count'];
|
||||
|
||||
// 检查该用户在此房间是否有进行中的红包(防止刷包)
|
||||
$activeExists = RedPacketEnvelope::query()
|
||||
->where('sender_id', $user->id)
|
||||
@@ -93,8 +124,8 @@ class RedPacketController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
|
||||
}
|
||||
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT)
|
||||
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
|
||||
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于职务配置总量)
|
||||
$amounts = $this->splitAmount($totalAmount, $totalCount);
|
||||
|
||||
// 货币展示文案
|
||||
$typeLabel = $type === 'exp' ? '经验' : '金币';
|
||||
@@ -104,15 +135,15 @@ class RedPacketController extends Controller
|
||||
: 'linear-gradient(135deg,#dc2626,#ea580c)';
|
||||
|
||||
// 事务:创建红包记录 + Redis 写入分额
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
|
||||
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts, $totalAmount, $totalCount): RedPacketEnvelope {
|
||||
// 创建红包主记录(凭空发出,不扣发包人货币)
|
||||
$envelope = RedPacketEnvelope::create([
|
||||
'sender_id' => $user->id,
|
||||
'sender_username' => $user->username,
|
||||
'room_id' => $roomId,
|
||||
'type' => $type,
|
||||
'total_amount' => self::TOTAL_AMOUNT,
|
||||
'total_count' => self::TOTAL_COUNT,
|
||||
'total_amount' => $totalAmount,
|
||||
'total_count' => $totalCount,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'status' => 'active',
|
||||
@@ -137,8 +168,8 @@ class RedPacketController extends Controller
|
||||
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
|
||||
.$envelope->id
|
||||
.',\''.$user->username.'\','
|
||||
.self::TOTAL_AMOUNT.','
|
||||
.self::TOTAL_COUNT.','
|
||||
.$totalAmount.','
|
||||
.$totalCount.','
|
||||
.self::EXPIRE_SECONDS
|
||||
.',\''.$type.'\''
|
||||
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
|
||||
@@ -150,7 +181,7 @@ class RedPacketController extends Controller
|
||||
'room_id' => $roomId,
|
||||
'from_user' => '系统公告',
|
||||
'to_user' => '',
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
|
||||
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>{$totalAmount}</b> {$typeLabel}的礼包!共 {$totalCount} 份,先到先得,快去抢!{$btnHtml}",
|
||||
'is_secret' => false,
|
||||
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
|
||||
'action' => '',
|
||||
@@ -165,15 +196,15 @@ class RedPacketController extends Controller
|
||||
roomId: $roomId,
|
||||
envelopeId: $envelope->id,
|
||||
senderUsername: $user->username,
|
||||
totalAmount: self::TOTAL_AMOUNT,
|
||||
totalCount: self::TOTAL_COUNT,
|
||||
totalAmount: $totalAmount,
|
||||
totalCount: $totalCount,
|
||||
expireSeconds: self::EXPIRE_SECONDS,
|
||||
type: $type,
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
|
||||
'message' => "🧧 {$typeLabel}礼包已发出!{$totalAmount} {$typeLabel} · {$totalCount} 份",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -388,4 +419,34 @@ class RedPacketController extends Controller
|
||||
|
||||
return $amounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前在职职务解析礼包红包配置。
|
||||
*
|
||||
* @return array{amount: int, count: int}
|
||||
*/
|
||||
private function redPacketConfigForUser(User $user): array
|
||||
{
|
||||
$position = $user->activePosition?->position;
|
||||
$amount = (int) ($position?->red_packet_amount ?? self::DEFAULT_TOTAL_AMOUNT);
|
||||
$count = (int) ($position?->red_packet_count ?? self::DEFAULT_TOTAL_COUNT);
|
||||
|
||||
if ($amount < 1) {
|
||||
$amount = self::DEFAULT_TOTAL_AMOUNT;
|
||||
}
|
||||
|
||||
if ($count < 1 || $count > 100) {
|
||||
$count = self::DEFAULT_TOTAL_COUNT;
|
||||
}
|
||||
|
||||
if ($amount < $count) {
|
||||
$amount = self::DEFAULT_TOTAL_AMOUNT;
|
||||
$count = self::DEFAULT_TOTAL_COUNT;
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\DecorationService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -22,6 +28,7 @@ class ShopController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ShopService $shopService,
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,8 +54,10 @@ class ShopController extends Controller
|
||||
'charm_bonus' => $item->charm_bonus,
|
||||
]);
|
||||
|
||||
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||
|
||||
// 统计背包中各戒指持有数量
|
||||
$ringCounts = \App\Models\UserPurchase::query()
|
||||
$ringCounts = UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
|
||||
@@ -64,6 +73,10 @@ class ShopController extends Controller
|
||||
'has_rename_card' => $this->shopService->hasRenameCard($user),
|
||||
'ring_counts' => $ringCounts,
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $signRepairCard,
|
||||
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -74,36 +87,75 @@ class ShopController extends Controller
|
||||
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
|
||||
* - message 公屏赠言(可选)
|
||||
*
|
||||
* @param Request $request 含 item_id, recipient?, message?
|
||||
* @param Request $request 含 item_id, recipient?, message?, quantity?
|
||||
*/
|
||||
public function buy(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
|
||||
$request->validate([
|
||||
'item_id' => 'required|integer|exists:shop_items,id',
|
||||
'room_id' => 'required|integer|exists:rooms,id',
|
||||
'recipient' => 'nullable|string|max:50',
|
||||
'message' => 'nullable|string|max:120',
|
||||
'quantity' => 'nullable|integer|min:1|max:99',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->input('room_id');
|
||||
$room = Room::query()->findOrFail($roomId);
|
||||
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买聊天室道具。'], 403);
|
||||
}
|
||||
|
||||
$item = ShopItem::find($request->item_id);
|
||||
if (! $item->is_active) {
|
||||
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
|
||||
}
|
||||
|
||||
$result = $this->shopService->buyItem(Auth::user(), $item);
|
||||
$quantity = (int) $request->input('quantity', 1);
|
||||
$result = $this->shopService->buyItem($user, $item, $quantity);
|
||||
|
||||
if (! $result['ok']) {
|
||||
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||
}
|
||||
|
||||
$response = ['status' => 'success', 'message' => $result['message']];
|
||||
$response = [
|
||||
'status' => 'success',
|
||||
'message' => $result['message'],
|
||||
'quantity' => $result['quantity'] ?? 1,
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// 购买自动钓鱼卡后返回剩余分钟数,前端用于自动开启钓鱼
|
||||
if ($item->type === 'auto_fishing') {
|
||||
$response['auto_fishing_minutes_left'] = $this->shopService->getActiveAutoFishingMinutesLeft($user);
|
||||
}
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
$response['style'] = $result['style'];
|
||||
$response['expires_at'] = $result['expires_at'];
|
||||
|
||||
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||
$freshUser = $user->fresh();
|
||||
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
$message = trim($request->input('message', ''));
|
||||
$message = ChatContentSanitizer::htmlText($request->input('message', ''));
|
||||
|
||||
// recipient 为空或 "all" 表示全员
|
||||
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
|
||||
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
|
||||
|
||||
// 广播特效事件(全员频道)
|
||||
// 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
|
||||
broadcast(new EffectBroadcast(
|
||||
roomId: $roomId,
|
||||
type: $result['play_effect'],
|
||||
@@ -133,9 +185,11 @@ class ShopController extends Controller
|
||||
];
|
||||
// 赠礼消息文案(改成"为XX触发了一场特效")
|
||||
$icon = $icons[$result['play_effect']] ?? '✨';
|
||||
$toStr = $targetUsername ? "【{$targetUsername}】" : '全体聊友';
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$toStr = $safeTargetUsername ? "【{$safeTargetUsername}】" : '全体聊友';
|
||||
$remarkPart = $message ? " 「{$message}」" : '';
|
||||
$sysContent = "{$icon} {$user->username} 为 {$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
|
||||
$sysContent = "{$icon} {$safeBuyer} 为 {$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
|
||||
|
||||
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
|
||||
$sysMsgEvent = new MessageSent(
|
||||
@@ -156,9 +210,6 @@ class ShopController extends Controller
|
||||
}
|
||||
} else {
|
||||
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
|
||||
$user = Auth::user();
|
||||
$roomId = (int) $request->room_id;
|
||||
|
||||
if ($roomId > 0) {
|
||||
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
|
||||
$fishDuration = '';
|
||||
@@ -171,12 +222,16 @@ class ShopController extends Controller
|
||||
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
|
||||
|
||||
// 根据商品类型生成不同通知文案
|
||||
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
|
||||
$safeItemName = ChatContentSanitizer::htmlText($item->name);
|
||||
$sysContent = match ($item->type) {
|
||||
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
|
||||
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
default => "🛒 【{$user->username}】购买了「{$item->name}」。",
|
||||
'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
|
||||
'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
'msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
broadcast(new MessageSent(
|
||||
@@ -197,7 +252,7 @@ class ShopController extends Controller
|
||||
}
|
||||
|
||||
// 返回最新金币余额
|
||||
$response['jjb'] = Auth::user()->fresh()->jjb;
|
||||
$response['jjb'] = $user->fresh()->jjb;
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
/**
|
||||
* 文件功能:用户中心与管理控制器
|
||||
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP
|
||||
*
|
||||
* 权限等级通过 sysparam 表动态配置:
|
||||
* level_kick - 踢人所需等级
|
||||
* level_mute - 禁言所需等级
|
||||
* level_ban - 封号所需等级
|
||||
* level_banip - 封IP所需等级
|
||||
* 接管原版 USERinfo.ASP, USERSET.ASP 与 chpasswd.asp。
|
||||
* 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -17,22 +12,55 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\UserKicked;
|
||||
use App\Events\UserMuted;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Http\Requests\ChangePasswordRequest;
|
||||
use App\Http\Requests\RevealProfileInfoRequest;
|
||||
use App\Http\Requests\UpdateChatPreferencesRequest;
|
||||
use App\Http\Requests\UpdateDailyStatusRequest;
|
||||
use App\Http\Requests\UpdateProfileRequest;
|
||||
use App\Models\Room;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\ChatUserPresenceService;
|
||||
use App\Services\PositionPermissionService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* 查看别人隐藏信息的单次扣费金额。
|
||||
*/
|
||||
public const INFO_REVEAL_COST = 1000;
|
||||
|
||||
/**
|
||||
* 可付费查看的信息字段和中文名称。
|
||||
*/
|
||||
private const REVEALABLE_INFO_LABELS = [
|
||||
'exp_num' => '经验',
|
||||
'jjb' => '金币',
|
||||
'bank_jjb' => '存款',
|
||||
'meili' => '魅力',
|
||||
];
|
||||
|
||||
/**
|
||||
* 构造用户控制器依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
private readonly PositionPermissionService $positionPermissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查看其他用户资料片 (对应 USERinfo.ASP)
|
||||
*/
|
||||
@@ -69,23 +97,33 @@ class UserController extends Controller
|
||||
'position_name' => $activePosition?->name ?? '',
|
||||
'position_icon' => $activePosition?->icon ?? '',
|
||||
'department_name' => $activePosition?->department?->name ?? '',
|
||||
'department_rank' => (int) ($activePosition?->department?->rank ?? 0),
|
||||
'position_rank' => (int) ($activePosition?->rank ?? 0),
|
||||
];
|
||||
|
||||
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
|
||||
if ($operator && ($operator->user_level >= $targetUser->user_level || $operator->id === $targetUser->id)) {
|
||||
$data['exp_num'] = $targetUser->exp_num ?? 0;
|
||||
$data['jjb'] = $targetUser->jjb ?? 0;
|
||||
$data['meili'] = $targetUser->meili ?? 0;
|
||||
}
|
||||
// 经验、金币、魅力卡片始终展示;等级不足时只显示星号,不隐藏字段本身。
|
||||
$canViewAssetNumbers = $operator && $this->canViewAssetNumbers($operator, $targetUser);
|
||||
$data['exp_num'] = $canViewAssetNumbers ? ($targetUser->exp_num ?? 0) : '******';
|
||||
$data['jjb'] = $canViewAssetNumbers ? ($targetUser->jjb ?? 0) : '******';
|
||||
$data['meili'] = $canViewAssetNumbers ? ($targetUser->meili ?? 0) : '******';
|
||||
$data['asset_numbers_masked'] = ! $canViewAssetNumbers;
|
||||
$data['asset_numbers_can_reveal'] = ! $canViewAssetNumbers;
|
||||
$data['asset_numbers_reveal_cost'] = self::INFO_REVEAL_COST;
|
||||
$data['asset_reveal_user_id'] = $targetUser->id;
|
||||
|
||||
// 银行存款:只有超级管理员(user_level >= superlevel)或本人才能查看具体金额,其余一律显示星号
|
||||
if ($operator) {
|
||||
$isSelf = $operator->id === $targetUser->id;
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$isSuperAdmin = $operator->user_level >= $superLevel;
|
||||
$data['bank_jjb'] = ($isSelf || $isSuperAdmin)
|
||||
? ($targetUser->bank_jjb ?? 0)
|
||||
: '******';
|
||||
$canViewBankBalance = $isSelf || $isSuperAdmin;
|
||||
|
||||
// 名片里的别人存款默认只返回星号,真实金额必须通过付费查看接口当次获取。
|
||||
$data['bank_jjb'] = $canViewBankBalance ? ($targetUser->bank_jjb ?? 0) : '******';
|
||||
$data['bank_jjb_masked'] = ! $canViewBankBalance;
|
||||
$data['bank_jjb_can_reveal'] = ! $canViewBankBalance;
|
||||
$data['bank_jjb_reveal_cost'] = self::INFO_REVEAL_COST;
|
||||
$data['bank_reveal_user_id'] = $targetUser->id;
|
||||
}
|
||||
|
||||
// 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接
|
||||
@@ -109,10 +147,23 @@ class UserController extends Controller
|
||||
->all();
|
||||
$data['vip']['Name'] = $targetUser->vipName();
|
||||
$data['vip']['Icon'] = $targetUser->vipIcon();
|
||||
$signIdentity = $targetUser->currentSignInIdentity();
|
||||
$latestSignIn = $targetUser->dailySignIns()->first();
|
||||
$data['sign_in'] = [
|
||||
'streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
|
||||
'identity' => $signIdentity ? [
|
||||
'key' => $signIdentity->badge_code,
|
||||
'label' => $signIdentity->badge_name,
|
||||
'icon' => $signIdentity->badge_icon ?? '✅',
|
||||
'color' => $signIdentity->badge_color ?? '#0f766e',
|
||||
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
||||
] : null,
|
||||
];
|
||||
|
||||
// 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地
|
||||
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
|
||||
if ($operator && $operator->user_level >= $levelBanIp) {
|
||||
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
||||
$canViewNetworkInfo = $operator
|
||||
&& ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP));
|
||||
if ($canViewNetworkInfo) {
|
||||
$data['first_ip'] = $targetUser->first_ip;
|
||||
// last_ip 目前定义为『上次登录IP』(取数据库 previous_ip)
|
||||
$data['last_ip'] = $targetUser->previous_ip;
|
||||
@@ -162,6 +213,54 @@ class UserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费查看用户资料中被星号隐藏的经验、金币、存款或魅力。
|
||||
*/
|
||||
public function revealInfo(RevealProfileInfoRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $operator */
|
||||
$operator = Auth::user();
|
||||
$targetUser = User::query()
|
||||
->select(['id', 'username', 'user_level', 'exp_num', 'jjb', 'bank_jjb', 'meili'])
|
||||
->findOrFail($request->integer('user_id'));
|
||||
$asset = (string) $request->string('asset');
|
||||
$assetLabel = self::REVEALABLE_INFO_LABELS[$asset];
|
||||
$charged = false;
|
||||
$operatorGold = (int) $operator->jjb;
|
||||
|
||||
if (! $this->canViewProfileInfo($operator, $targetUser, $asset)) {
|
||||
$operatorGold = $this->currencyService->deductGoldIfEnough(
|
||||
user: $operator,
|
||||
amount: self::INFO_REVEAL_COST,
|
||||
source: CurrencySource::USER_INFO_REVEAL,
|
||||
remark: "查看 {$targetUser->username} 的{$assetLabel}",
|
||||
);
|
||||
|
||||
if ($operatorGold === null) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '金币不足,查看'.$assetLabel.'需要 '.self::INFO_REVEAL_COST.' 金币。',
|
||||
]);
|
||||
}
|
||||
|
||||
$charged = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $charged
|
||||
? '已扣除 '.self::INFO_REVEAL_COST.' 金币,'.$assetLabel.'已显示。'
|
||||
: $assetLabel.'已显示。',
|
||||
'user_id' => $targetUser->id,
|
||||
'username' => $targetUser->username,
|
||||
'asset' => $asset,
|
||||
'value' => $targetUser->{$asset} ?? 0,
|
||||
'charged' => $charged,
|
||||
'reveal_cost' => self::INFO_REVEAL_COST,
|
||||
'jjb' => $operatorGold,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改个人资料 (对应 USERSET.ASP)
|
||||
*/
|
||||
@@ -228,6 +327,55 @@ class UserController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存聊天室当日状态,并同步当前在线名单显示。
|
||||
*/
|
||||
public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$data = $request->validated();
|
||||
$roomId = (int) $data['room_id'];
|
||||
|
||||
// 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。
|
||||
if (! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '请先进入聊天室后再设置状态。',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($data['action'] === 'clear') {
|
||||
$user->update([
|
||||
'daily_status_key' => null,
|
||||
'daily_status_expires_at' => null,
|
||||
]);
|
||||
} else {
|
||||
// 状态有效期固定维持到当天结束,次日自动失效。
|
||||
$user->update([
|
||||
'daily_status_key' => $data['status_key'],
|
||||
'daily_status_expires_at' => now()->endOfDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
$presencePayload = $this->chatUserPresenceService->build($user);
|
||||
$roomIds = $this->chatState->getUserRooms($user->username);
|
||||
|
||||
foreach ($roomIds as $activeRoomId) {
|
||||
// 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。
|
||||
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
|
||||
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。',
|
||||
'data' => [
|
||||
'status' => $this->chatUserPresenceService->currentDailyStatus($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码 (对应 chpasswd.asp)
|
||||
*/
|
||||
@@ -262,7 +410,7 @@ class UserController extends Controller
|
||||
/**
|
||||
* 生成微信绑定代码
|
||||
*/
|
||||
public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse
|
||||
public function generateWechatCode(Request $request): JsonResponse
|
||||
{
|
||||
$user = \Illuminate\Support\Facades\Auth::user();
|
||||
if (! $user) {
|
||||
@@ -282,7 +430,7 @@ class UserController extends Controller
|
||||
/**
|
||||
* 取消绑定微信
|
||||
*/
|
||||
public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse
|
||||
public function unbindWechat(Request $request): JsonResponse
|
||||
{
|
||||
$user = \Illuminate\Support\Facades\Auth::user();
|
||||
if (! $user) {
|
||||
@@ -299,158 +447,29 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用权限校验:检查操作者是否有权操作目标用户
|
||||
*
|
||||
* @param object $operator 操作者
|
||||
* @param string $targetUsername 目标用户名
|
||||
* @param int $roomId 房间ID
|
||||
* @param string $levelKey sysparam中的等级键名(如 level_kick)
|
||||
* @param string $actionName 操作名称(用于错误提示)
|
||||
* @return array{room: Room, target: User}|JsonResponse
|
||||
* 判断操作者是否可以免费查看目标用户经验、金币与魅力。
|
||||
*/
|
||||
private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse
|
||||
private function canViewAssetNumbers(User $operator, User $targetUser): bool
|
||||
{
|
||||
$room = Room::findOrFail($roomId);
|
||||
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
|
||||
|
||||
// 鉴权:操作者要是房间房主或达到所需等级
|
||||
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
|
||||
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
|
||||
}
|
||||
|
||||
$targetUser = User::where('username', $targetUsername)->first();
|
||||
if (! $targetUser) {
|
||||
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
|
||||
}
|
||||
|
||||
// 防误伤:不能操作等级 >= 自己的人
|
||||
if ($targetUser->user_level >= $operator->user_level) {
|
||||
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}。"], 403);
|
||||
}
|
||||
|
||||
return ['room' => $room, 'target' => $targetUser];
|
||||
return $operator->id === $targetUser->id
|
||||
|| (int) $operator->user_level >= (int) $targetUser->user_level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出房间 (对应 KILLUSER.ASP)
|
||||
* 所需等级由 sysparam level_kick 配置
|
||||
* 判断操作者是否可以免费查看指定资料信息。
|
||||
*/
|
||||
public function kick(Request $request, string $username): JsonResponse
|
||||
private function canViewProfileInfo(User $operator, User $targetUser, string $asset): bool
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
if ($asset !== 'bank_jjb') {
|
||||
return $this->canViewAssetNumbers($operator, $targetUser);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
if ($operator->id === $targetUser->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 广播踢出事件
|
||||
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁言 (对应原版限制功能)
|
||||
* 所需等级由 sysparam level_mute 配置
|
||||
* 禁言信息存入 Redis,TTL 到期自动解除
|
||||
*/
|
||||
public function mute(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
$duration = (int) $request->input('duration', 5);
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 写入 Redis 禁言标记,TTL = 禁言分钟数 * 60
|
||||
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
|
||||
'operator' => $operator->username,
|
||||
'reason' => '管理员禁言',
|
||||
'until' => now()->addMinutes($duration)->toDateTimeString(),
|
||||
]));
|
||||
|
||||
// 广播禁言事件
|
||||
broadcast(new UserMuted($roomId, $username, $duration));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封号(禁止登录)
|
||||
* 所需等级由 sysparam level_ban 配置
|
||||
* 将用户等级设为 -1 表示封禁
|
||||
*/
|
||||
public function ban(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 封号:设置等级为 -1
|
||||
$result['target']->user_level = -1;
|
||||
$result['target']->save();
|
||||
|
||||
// 踢出聊天室
|
||||
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封IP(记录IP到黑名单并踢出)
|
||||
* 所需等级由 sysparam level_banip 配置
|
||||
*/
|
||||
public function banIp(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$operator = Auth::user();
|
||||
$roomId = $request->input('room_id');
|
||||
|
||||
if (! $roomId) {
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
|
||||
}
|
||||
|
||||
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$targetIp = $result['target']->last_ip;
|
||||
|
||||
if ($targetIp) {
|
||||
// 将IP加入 Redis 黑名单(永久)
|
||||
Redis::sadd('banned_ips', $targetIp);
|
||||
}
|
||||
|
||||
// 同时封号
|
||||
$result['target']->user_level = -1;
|
||||
$result['target']->save();
|
||||
|
||||
// 踢出聊天室
|
||||
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
|
||||
|
||||
$ipInfo = $targetIp ? "(IP: {$targetIp})" : '(未记录IP)';
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}。"]);
|
||||
return (int) $operator->user_level >= $superLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台签到奖励规则保存请求校验
|
||||
*
|
||||
* 集中校验连续签到天数、奖励数值与身份徽章配置。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:校验后台新增和更新签到奖励规则的表单数据。
|
||||
*/
|
||||
class SaveSignInRewardRuleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已通过后台权限中间件的管理员继续校验。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到奖励规则表单的校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$ruleId = $this->route('signInRewardRule')?->id;
|
||||
|
||||
return [
|
||||
'streak_days' => [
|
||||
'required',
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:3650',
|
||||
Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId),
|
||||
],
|
||||
'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
|
||||
'identity_badge_code' => ['nullable', 'string', 'max:50'],
|
||||
'identity_badge_name' => ['nullable', 'string', 'max:50'],
|
||||
'identity_badge_icon' => ['nullable', 'string', 'max:120'],
|
||||
'identity_badge_color' => ['nullable', 'string', 'max:20'],
|
||||
'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'],
|
||||
'sort_order' => ['required', 'integer', 'min:0', 'max:999999'],
|
||||
'is_enabled' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到奖励规则表单的中文字段名。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'streak_days' => '连续签到天数',
|
||||
'gold_reward' => '金币奖励',
|
||||
'exp_reward' => '经验奖励',
|
||||
'charm_reward' => '魅力奖励',
|
||||
'identity_badge_name' => '身份名称',
|
||||
'identity_duration_days' => '身份有效天数',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台用户资料更新请求校验
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:集中校验后台用户编辑弹窗提交的资料字段。
|
||||
*/
|
||||
class UpdateManagedUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已通过路由中间件的后台用户继续执行校验。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回后台用户编辑表单的校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'sex' => ['sometimes', 'integer', 'in:0,1,2'],
|
||||
'exp_num' => ['sometimes', 'integer', 'min:0'],
|
||||
'jjb' => ['sometimes', 'integer', 'min:0'],
|
||||
'meili' => ['sometimes', 'integer', 'min:0'],
|
||||
'qianming' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'position_id' => ['sometimes', 'nullable', 'integer', 'exists:positions,id'],
|
||||
'headface' => ['sometimes', 'string', 'max:50'],
|
||||
'password' => ['nullable', 'string', 'min:6'],
|
||||
'vip_level_id' => ['sometimes', 'nullable', 'integer', 'exists:vip_levels,id'],
|
||||
'hy_time' => ['sometimes', 'nullable', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回后台用户编辑表单的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'position_id.exists' => '所选职务不存在,请重新选择。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台每日签到请求校验
|
||||
*
|
||||
* 校验用户发起签到时携带的房间参数,避免脏 room_id 写入签到流水和聊天室通知。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:校验每日签到领取接口的请求参数。
|
||||
*/
|
||||
class ClaimDailySignInRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已登录聊天室用户发起签到请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回每日签到领取参数校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回每日签到领取的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'room_id.exists' => '当前聊天室不存在,请刷新页面后再签到。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台每日签到日历查询请求校验。
|
||||
*
|
||||
* 校验月份参数,供签到日历按月展示签到状态。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:校验用户查询签到日历时传入的月份参数。
|
||||
*/
|
||||
class DailySignInCalendarRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已登录用户查询自己的签到日历。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到日历查询规则。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'month' => ['nullable', 'date_format:Y-m'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回签到日历查询的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'month.date_format' => '月份格式不正确。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:前台每日签到补签请求校验。
|
||||
*
|
||||
* 校验补签日期和房间参数,确保用户只能补签历史漏签日期。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* 类功能:校验用户在签到日历中提交的补签请求。
|
||||
*/
|
||||
class MakeupDailySignInRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已登录用户提交补签请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回补签请求的校验规则。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'target_date' => ['required', 'date', 'before:today', 'after_or_equal:'.Carbon::today()->startOfMonth()->toDateString()],
|
||||
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回补签请求的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'target_date.required' => '请选择要补签的日期。',
|
||||
'target_date.date' => '补签日期格式不正确。',
|
||||
'target_date.before' => '只能补签今天之前的漏签日期。',
|
||||
'target_date.after_or_equal' => '补签卡只能补签本月的未签到日期。',
|
||||
'room_id.exists' => '当前聊天室不存在,请刷新页面后再补签。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户信息付费查看请求验证器
|
||||
* 校验用户点击查看别人经验、金币、存款、魅力时提交的目标与信息类型。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:验证付费查看用户隐藏信息的请求参数。
|
||||
*/
|
||||
class RevealProfileInfoRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 授权所有已登录用户提交查看请求,具体可见性和扣费由控制器统一处理。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取付费查看信息请求的验证规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'asset' => ['required', 'string', Rule::in(['exp_num', 'jjb', 'bank_jjb', 'meili'])],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取付费查看信息请求的中文验证提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => '缺少要查看的用户。',
|
||||
'user_id.integer' => '要查看的用户无效。',
|
||||
'user_id.exists' => '要查看的用户不存在。',
|
||||
'asset.required' => '缺少要查看的信息类型。',
|
||||
'asset.in' => '要查看的信息类型无效。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class SendMessageRequest extends FormRequest
|
||||
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
|
||||
'to_user' => ['nullable', 'string', 'max:50'],
|
||||
'is_secret' => ['nullable', 'boolean'],
|
||||
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
|
||||
'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex
|
||||
'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
|
||||
];
|
||||
}
|
||||
@@ -91,6 +91,7 @@ class SendMessageRequest extends FormRequest
|
||||
'image.image' => '上传的文件必须是图片。',
|
||||
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
|
||||
'image.max' => '图片大小不能超过 6MB。',
|
||||
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
|
||||
'action.in' => '发言动作不合法,请重新选择。',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利活动创建请求
|
||||
*
|
||||
* 负责校验后台创建节日福利模板时提交的奖励、调度与目标用户字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HolidayEventScheduleRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:校验创建节日福利模板的表单数据。
|
||||
*/
|
||||
class StoreHolidayEventRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许提交创建请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理布尔字段,避免浏览器复选框值造成类型偏差。
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('enabled')) {
|
||||
$this->merge([
|
||||
'enabled' => $this->boolean('enabled'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节日福利模板的字段校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'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', Rule::in(['random', 'fixed'])],
|
||||
'min_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,random'],
|
||||
'max_amount' => ['nullable', 'integer', 'min:1', 'gte:min_amount'],
|
||||
'fixed_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,fixed'],
|
||||
'send_at' => ['nullable', 'date', Rule::requiredIf(fn (): bool => $this->input('repeat_type') !== 'yearly')],
|
||||
'expire_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||
'repeat_type' => [
|
||||
'required',
|
||||
Rule::in(['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly']),
|
||||
new HolidayEventScheduleRule,
|
||||
],
|
||||
'cron_expr' => ['nullable', 'string', 'max:100', 'required_if:repeat_type,cron'],
|
||||
'schedule_month' => ['nullable', 'integer', 'between:1,12'],
|
||||
'schedule_day' => ['nullable', 'integer', 'between:1,31'],
|
||||
'schedule_time' => ['nullable', 'date_format:H:i'],
|
||||
'duration_days' => ['nullable', 'integer', 'min:1', 'max:31'],
|
||||
'daily_occurrences' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
'occurrence_interval_minutes' => ['nullable', 'integer', 'min:1', 'max:1439'],
|
||||
'target_type' => ['required', Rule::in(['all', 'vip', 'level'])],
|
||||
'target_value' => ['nullable', 'string', 'max:50', 'required_if:target_type,level'],
|
||||
'enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => '请输入活动名称',
|
||||
'total_amount.required' => '请填写总金币奖池',
|
||||
'max_claimants.required' => '请填写可领取人数上限',
|
||||
'distribute_type.required' => '请选择分配方式',
|
||||
'min_amount.required_if' => '随机分配模式下必须填写最低保底金额',
|
||||
'fixed_amount.required_if' => '定额发放模式下必须填写每人固定金额',
|
||||
'send_at.required' => '请选择触发时间',
|
||||
'expire_minutes.required' => '请填写领取有效期',
|
||||
'repeat_type.required' => '请选择重复方式',
|
||||
'cron_expr.required_if' => 'CRON 模式下必须填写表达式',
|
||||
'schedule_time.date_format' => '首轮开始时间格式必须为 HH:ii',
|
||||
'target_type.required' => '请选择目标用户范围',
|
||||
'target_value.required_if' => '指定等级以上模式下必须填写最低等级',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室用户状态设置验证器
|
||||
* 负责校验用户在聊天室内提交的当日状态设置与清除请求。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\ChatDailyStatusCatalog;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateDailyStatusRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 允许已登录用户保存自己的当日状态。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天室当日状态设置的验证规则。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'room_id' => ['required', 'integer', 'exists:rooms,id'],
|
||||
'action' => ['required', 'string', Rule::in(['set', 'clear'])],
|
||||
'status_key' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('action') === 'set'),
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(ChatDailyStatusCatalog::keys()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天室当日状态设置的中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'room_id.required' => '缺少当前房间信息。',
|
||||
'room_id.integer' => '房间编号格式无效。',
|
||||
'room_id.exists' => '当前房间不存在。',
|
||||
'action.required' => '缺少状态操作类型。',
|
||||
'action.in' => '不支持的状态操作类型。',
|
||||
'status_key.required' => '请选择要设置的状态。',
|
||||
'status_key.in' => '请选择系统支持的状态。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利活动更新请求
|
||||
*
|
||||
* 复用创建请求的字段校验规则,用于后台编辑节日福利模板。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
/**
|
||||
* 类功能:校验更新节日福利模板的表单数据。
|
||||
*/
|
||||
class UpdateHolidayEventRequest extends StoreHolidayEventRequest {}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:校验后台等级经验阈值配置请求
|
||||
*
|
||||
* 约束管理员以列表模式提交的每级经验值,
|
||||
* 确保阈值为正整数且严格递增。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:验证等级经验阈值列表的结构与数值合法性。
|
||||
*/
|
||||
class UpdateLevelExpConfigRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 方法功能:允许已通过后台鉴权的用户提交该请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:预处理输入,过滤空行并统一转成整数序列。
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$thresholds = collect($this->input('thresholds', []))
|
||||
->map(fn ($value): string => trim((string) $value))
|
||||
->filter(fn (string $value): bool => $value !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->merge([
|
||||
'thresholds' => $thresholds,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回等级经验阈值表单的校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'thresholds' => ['required', 'array', 'min:1', $this->strictlyIncreasingRule(), $this->maxLevelLimitRule()],
|
||||
'thresholds.*' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:返回中文校验错误消息。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'thresholds.required' => '请至少配置一个等级经验阈值。',
|
||||
'thresholds.array' => '等级经验阈值提交格式不正确。',
|
||||
'thresholds.min' => '请至少保留一个等级经验阈值。',
|
||||
'thresholds.*.required' => '等级经验阈值不能为空。',
|
||||
'thresholds.*.integer' => '等级经验阈值必须是整数。',
|
||||
'thresholds.*.min' => '等级经验阈值必须大于 0。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:自定义校验阈值必须严格递增。
|
||||
*/
|
||||
private function strictlyIncreasingRule(): ValidationRule
|
||||
{
|
||||
return new class implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 方法功能:执行严格递增校验。
|
||||
*
|
||||
* @param Closure(string): void $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = null;
|
||||
|
||||
foreach ($value as $index => $threshold) {
|
||||
if (! is_numeric($threshold)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = (int) $threshold;
|
||||
|
||||
// 每一级累计经验必须大于前一级,避免等级计算出现倒挂。
|
||||
if ($previous !== null && $current <= $previous) {
|
||||
$fail('等级经验阈值必须按等级从小到大严格递增,第 '.($index + 1).' 级配置不正确。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = $current;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:校验等级阈值数量不能超过用户最高可达等级。
|
||||
*/
|
||||
private function maxLevelLimitRule(): ValidationRule
|
||||
{
|
||||
return new class((int) Sysparam::getValue('maxlevel', '99')) implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 方法功能:构造数量上限校验器。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $maxLevel
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 方法功能:执行阈值数量与最高等级的上限校验。
|
||||
*
|
||||
* @param Closure(string): void $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_array($value) || $this->maxLevel < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 阈值行数对应可升级的等级数,不能超过用户最高可达等级。
|
||||
if (count($value) > $this->maxLevel) {
|
||||
$fail('等级经验阈值数量不能超过用户最高可达等级,请先提高最高等级或删除多余等级。');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
*/
|
||||
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
|
||||
{
|
||||
$diceStr = "《{$round->dice1}》《{$round->dice2}》《{$round->dice3}》";
|
||||
$diceStr = "[{$round->dice1}][{$round->dice2}][{$round->dice3}]";
|
||||
|
||||
$resultText = match ($round->result) {
|
||||
'big' => "🔵 大({$round->total_points} 点)",
|
||||
|
||||
@@ -213,7 +213,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
||||
'to_user' => $username,
|
||||
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
|
||||
'is_secret' => true,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
'toast_notification' => [
|
||||
@@ -252,7 +252,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -88,7 +88,7 @@ class OpenHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#f59e0b',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => $now->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -78,7 +78,7 @@ class RunHorseRaceJob implements ShouldQueue
|
||||
'to_user' => '大家',
|
||||
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#336699',
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
@@ -18,14 +18,18 @@ use App\Events\HolidayEventStarted;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\HolidayClaim;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use App\Services\HolidayEventScheduleService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* 类功能:按模板配置生成节日福利发放批次并广播给前端。
|
||||
*/
|
||||
class TriggerHolidayEventJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
@@ -36,10 +40,12 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @param HolidayEvent $event 节日活动记录
|
||||
* @param HolidayEvent $event 节日活动模板
|
||||
* @param bool $manual 是否为管理员手动立即触发
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HolidayEvent $event,
|
||||
public readonly bool $manual = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,119 +53,171 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
*/
|
||||
public function handle(
|
||||
ChatStateService $chatState,
|
||||
UserCurrencyService $currency,
|
||||
HolidayEventScheduleService $scheduleService,
|
||||
): void {
|
||||
$event = $this->event->fresh();
|
||||
$run = $this->prepareRun($scheduleService);
|
||||
|
||||
// 防止重复触发
|
||||
if (! $event || $event->status !== 'pending') {
|
||||
if (! $run) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$expiresAt = $now->copy()->addMinutes($event->expire_minutes);
|
||||
|
||||
// 先标记为 active,防止并发重复触发
|
||||
$updated = HolidayEvent::query()
|
||||
->where('id', $event->id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'active',
|
||||
'triggered_at' => $now,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
if (! $updated) {
|
||||
return; // 已被其他进程触发
|
||||
}
|
||||
|
||||
$event->refresh();
|
||||
|
||||
// 获取在线用户(满足 target_type 条件)
|
||||
$onlineIds = $this->getEligibleOnlineUsers($event, $chatState);
|
||||
// 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。
|
||||
$onlineIds = $this->getEligibleOnlineUsers($run);
|
||||
|
||||
if (empty($onlineIds)) {
|
||||
// 无合格在线用户,直接标记完成
|
||||
$event->update(['status' => 'completed']);
|
||||
$run->update(['status' => 'completed', 'audience_count' => 0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 max_claimants 限制人数
|
||||
if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) {
|
||||
// 按本批次快照中的 max_claimants 限制人数。
|
||||
if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) {
|
||||
shuffle($onlineIds);
|
||||
$onlineIds = array_slice($onlineIds, 0, $event->max_claimants);
|
||||
$onlineIds = array_slice($onlineIds, 0, $run->max_claimants);
|
||||
}
|
||||
|
||||
// 计算每人金额
|
||||
$amounts = $this->distributeAmounts($event, count($onlineIds));
|
||||
// 计算每人的待领取金额。
|
||||
$amounts = $this->distributeAmounts($run, count($onlineIds));
|
||||
|
||||
DB::transaction(function () use ($event, $onlineIds, $amounts, $now) {
|
||||
DB::transaction(function () use ($run, $onlineIds, $amounts): void {
|
||||
$claims = [];
|
||||
|
||||
foreach ($onlineIds as $i => $userId) {
|
||||
$claims[] = [
|
||||
'event_id' => $event->id,
|
||||
'event_id' => $run->holiday_event_id,
|
||||
'run_id' => $run->id,
|
||||
'user_id' => $userId,
|
||||
'amount' => $amounts[$i] ?? 0,
|
||||
'claimed_at' => $now,
|
||||
'claimed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// 批量插入领取记录
|
||||
// 一次性生成本轮全部待领取记录,claimed_at 默认为 null。
|
||||
HolidayClaim::insert($claims);
|
||||
|
||||
$run->update(['audience_count' => count($claims)]);
|
||||
});
|
||||
|
||||
// 广播全房间 WebSocket 事件
|
||||
broadcast(new HolidayEventStarted($event->refresh()));
|
||||
// 广播本轮发放批次,前端将基于 run_id 领取。
|
||||
broadcast(new HolidayEventStarted($run->fresh()));
|
||||
|
||||
// 向聊天室追加系统消息(写入 Redis + 落库)
|
||||
$this->pushSystemMessage($event, count($onlineIds), $chatState);
|
||||
|
||||
// 处理重复活动(计算下次触发时间)
|
||||
$this->scheduleNextRepeat($event);
|
||||
// 向聊天室追加系统公告,提醒用户点击弹窗领取。
|
||||
$this->pushSystemMessage($run, count($onlineIds), $chatState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取满足条件的在线用户 ID 列表。
|
||||
* 生成批次并推进模板到下一次触发时间。
|
||||
*/
|
||||
private function prepareRun(HolidayEventScheduleService $scheduleService): ?HolidayEventRun
|
||||
{
|
||||
/** @var HolidayEventRun|null $run */
|
||||
$run = DB::transaction(function () use ($scheduleService): ?HolidayEventRun {
|
||||
/** @var HolidayEvent|null $event */
|
||||
$event = HolidayEvent::query()
|
||||
->whereKey($this->event->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $event || ! $event->enabled || $event->status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
|
||||
|
||||
if (! $this->manual) {
|
||||
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
|
||||
if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
|
||||
$event->update([
|
||||
'send_at' => $nextSendAt,
|
||||
'status' => $nextSendAt ? 'pending' : 'completed',
|
||||
'triggered_at' => $now,
|
||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
]);
|
||||
} else {
|
||||
// 手动立即触发只更新最后触发信息,不改动既有 send_at 锚点。
|
||||
$event->update([
|
||||
'triggered_at' => $now,
|
||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
||||
]);
|
||||
}
|
||||
|
||||
return HolidayEventRun::query()->create([
|
||||
'holiday_event_id' => $event->id,
|
||||
'event_name' => $event->name,
|
||||
'event_description' => $event->description,
|
||||
'total_amount' => $event->total_amount,
|
||||
'max_claimants' => $event->max_claimants,
|
||||
'distribute_type' => $event->distribute_type,
|
||||
'min_amount' => $event->min_amount,
|
||||
'max_amount' => $event->max_amount,
|
||||
'fixed_amount' => $event->fixed_amount,
|
||||
'target_type' => $event->target_type,
|
||||
'target_value' => $event->target_value,
|
||||
'repeat_type' => $event->repeat_type,
|
||||
'scheduled_for' => $scheduledFor,
|
||||
'triggered_at' => $now,
|
||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
||||
'status' => 'active',
|
||||
'audience_count' => 0,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取满足当前批次条件的在线用户 ID 列表。
|
||||
*
|
||||
* @return array<int>
|
||||
*/
|
||||
private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array
|
||||
private function getEligibleOnlineUsers(HolidayEventRun $run): array
|
||||
{
|
||||
try {
|
||||
$key = 'room:1:users';
|
||||
$users = Redis::hgetall($key);
|
||||
$users = Redis::hgetall('room:1:users');
|
||||
if (empty($users)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$usernames = array_keys($users);
|
||||
|
||||
// 根据 user_id 从 Redis value 或数据库查出 ID
|
||||
$ids = [];
|
||||
$fallbacks = [];
|
||||
$fallbackUsernames = [];
|
||||
|
||||
foreach ($users as $username => $jsonInfo) {
|
||||
$info = json_decode($jsonInfo, true);
|
||||
|
||||
if (isset($info['user_id'])) {
|
||||
$ids[] = (int) $info['user_id'];
|
||||
} else {
|
||||
$fallbacks[] = $username;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fallbackUsernames[] = $username;
|
||||
}
|
||||
|
||||
if (! empty($fallbacks)) {
|
||||
$dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
$ids = array_merge($ids, $dbIds);
|
||||
if (! empty($fallbackUsernames)) {
|
||||
$fallbackIds = User::query()
|
||||
->whereIn('username', $fallbackUsernames)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$ids = array_merge($ids, $fallbackIds);
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique($ids));
|
||||
|
||||
// 根据 target_type 过滤
|
||||
return match ($event->target_type) {
|
||||
// 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。
|
||||
return match ($run->target_type) {
|
||||
'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($event->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||
default => $ids,
|
||||
};
|
||||
} catch (\Throwable) {
|
||||
@@ -172,23 +230,23 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
*
|
||||
* @return array<int>
|
||||
*/
|
||||
private function distributeAmounts(HolidayEvent $event, int $count): array
|
||||
private function distributeAmounts(HolidayEventRun $run, int $count): array
|
||||
{
|
||||
if ($count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($event->distribute_type === 'fixed') {
|
||||
// 定额模式:每人相同金额
|
||||
$amount = $event->fixed_amount ?? (int) floor($event->total_amount / $count);
|
||||
if ($run->distribute_type === 'fixed') {
|
||||
// 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。
|
||||
$amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count);
|
||||
|
||||
return array_fill(0, $count, $amount);
|
||||
}
|
||||
|
||||
// 随机模式:二倍均值算法
|
||||
$total = $event->total_amount;
|
||||
$min = max(1, $event->min_amount ?? 1);
|
||||
$max = $event->max_amount ?? (int) ceil($total * 2 / $count);
|
||||
// 随机模式沿用二倍均值算法,保证总金额恰好发完。
|
||||
$total = $run->total_amount;
|
||||
$min = max(1, $run->min_amount ?? 1);
|
||||
$max = $run->max_amount ?? (int) ceil($total * 2 / $count);
|
||||
|
||||
$amounts = [];
|
||||
$remaining = $total;
|
||||
@@ -210,10 +268,10 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
/**
|
||||
* 向聊天室推送系统公告消息并写入 Redis + 落库。
|
||||
*/
|
||||
private function pushSystemMessage(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void
|
||||
private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void
|
||||
{
|
||||
$typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配';
|
||||
$content = "🎊 【{$event->name}】节日福利开始啦!总奖池 💰".number_format($event->total_amount)
|
||||
$typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配';
|
||||
$content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount)
|
||||
." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!";
|
||||
|
||||
$msg = [
|
||||
@@ -232,30 +290,4 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重复活动:计算下次触发时间并重置状态。
|
||||
*/
|
||||
private function scheduleNextRepeat(HolidayEvent $event): void
|
||||
{
|
||||
$nextSendAt = match ($event->repeat_type) {
|
||||
'daily' => $event->send_at->copy()->addDay(),
|
||||
'weekly' => $event->send_at->copy()->addWeek(),
|
||||
'monthly' => $event->send_at->copy()->addMonth(),
|
||||
default => null, // 'once' 或 'cron' 不自动重复
|
||||
};
|
||||
|
||||
if ($nextSendAt) {
|
||||
$event->update([
|
||||
'status' => 'pending',
|
||||
'send_at' => $nextSendAt,
|
||||
'triggered_at' => null,
|
||||
'expires_at' => null,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
]);
|
||||
} else {
|
||||
$event->update(['status' => 'completed']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到记录模型。
|
||||
*
|
||||
* 保存用户每天签到、连续天数、命中奖励规则与实际奖励快照。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 类功能:封装每日签到流水的字段类型与用户、规则关联。
|
||||
*/
|
||||
class DailySignIn extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DailySignInFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'room_id',
|
||||
'is_makeup',
|
||||
'makeup_purchase_id',
|
||||
'makeup_at',
|
||||
'sign_in_date',
|
||||
'streak_days',
|
||||
'reward_rule_id',
|
||||
'gold_reward',
|
||||
'exp_reward',
|
||||
'charm_reward',
|
||||
'identity_badge_code',
|
||||
'identity_badge_name',
|
||||
'identity_badge_icon',
|
||||
'identity_badge_color',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'room_id' => 'integer',
|
||||
'is_makeup' => 'boolean',
|
||||
'makeup_purchase_id' => 'integer',
|
||||
'makeup_at' => 'datetime',
|
||||
'sign_in_date' => 'date',
|
||||
'streak_days' => 'integer',
|
||||
'reward_rule_id' => 'integer',
|
||||
'gold_reward' => 'integer',
|
||||
'exp_reward' => 'integer',
|
||||
'charm_reward' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:签到记录所属用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:本次签到命中的奖励规则。
|
||||
*/
|
||||
public function rewardRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SignInRewardRule::class, 'reward_rule_id');
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,16 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 类功能:记录用户在某个节日福利发放批次中的领取状态。
|
||||
*/
|
||||
class HolidayClaim extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'run_id',
|
||||
'user_id',
|
||||
'amount',
|
||||
'claimed_at',
|
||||
@@ -45,6 +49,14 @@ class HolidayClaim extends Model
|
||||
return $this->belongsTo(HolidayEvent::class, 'event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联所属发放批次。
|
||||
*/
|
||||
public function run(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HolidayEventRun::class, 'run_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联领取用户。
|
||||
*/
|
||||
|
||||
+24
-12
@@ -13,11 +13,17 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 类功能:定义节日福利模板及其调度配置。
|
||||
*/
|
||||
class HolidayEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
@@ -31,6 +37,12 @@ class HolidayEvent extends Model
|
||||
'expire_minutes',
|
||||
'repeat_type',
|
||||
'cron_expr',
|
||||
'schedule_month',
|
||||
'schedule_day',
|
||||
'schedule_time',
|
||||
'duration_days',
|
||||
'daily_occurrences',
|
||||
'occurrence_interval_minutes',
|
||||
'target_type',
|
||||
'target_value',
|
||||
'status',
|
||||
@@ -57,6 +69,11 @@ class HolidayEvent extends Model
|
||||
'max_amount' => 'integer',
|
||||
'fixed_amount' => 'integer',
|
||||
'expire_minutes' => 'integer',
|
||||
'schedule_month' => 'integer',
|
||||
'schedule_day' => 'integer',
|
||||
'duration_days' => 'integer',
|
||||
'daily_occurrences' => 'integer',
|
||||
'occurrence_interval_minutes' => 'integer',
|
||||
'claimed_count' => 'integer',
|
||||
'claimed_amount' => 'integer',
|
||||
];
|
||||
@@ -71,25 +88,19 @@ class HolidayEvent extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断活动是否在领取有效期内。
|
||||
* 本模板对应的所有发放批次。
|
||||
*/
|
||||
public function isClaimable(): bool
|
||||
public function runs(): HasMany
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& $this->expires_at
|
||||
&& $this->expires_at->isFuture();
|
||||
return $this->hasMany(HolidayEventRun::class, 'holiday_event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否还有剩余领取名额。
|
||||
* 判断模板是否使用年度节日调度。
|
||||
*/
|
||||
public function hasQuota(): bool
|
||||
public function usesYearlySchedule(): bool
|
||||
{
|
||||
if ($this->max_claimants === 0) {
|
||||
return true; // 不限人数
|
||||
}
|
||||
|
||||
return $this->claimed_count < $this->max_claimants;
|
||||
return $this->repeat_type === 'yearly';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +111,7 @@ class HolidayEvent extends Model
|
||||
return static::query()
|
||||
->where('status', 'pending')
|
||||
->where('enabled', true)
|
||||
->whereNotNull('send_at')
|
||||
->where('send_at', '<=', now())
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次模型
|
||||
*
|
||||
* 记录节日福利模板的每一次实际发放批次,
|
||||
* 承担领取有效期、批次统计与历史追踪职责。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 类功能:封装节日福利单次发放批次的时间、状态和领取统计。
|
||||
*/
|
||||
class HolidayEventRun extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\HolidayEventRunFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'holiday_event_id',
|
||||
'event_name',
|
||||
'event_description',
|
||||
'total_amount',
|
||||
'max_claimants',
|
||||
'distribute_type',
|
||||
'min_amount',
|
||||
'max_amount',
|
||||
'fixed_amount',
|
||||
'target_type',
|
||||
'target_value',
|
||||
'repeat_type',
|
||||
'scheduled_for',
|
||||
'triggered_at',
|
||||
'expires_at',
|
||||
'status',
|
||||
'audience_count',
|
||||
'claimed_count',
|
||||
'claimed_amount',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scheduled_for' => 'datetime',
|
||||
'triggered_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'total_amount' => 'integer',
|
||||
'max_claimants' => 'integer',
|
||||
'min_amount' => 'integer',
|
||||
'max_amount' => 'integer',
|
||||
'fixed_amount' => 'integer',
|
||||
'audience_count' => 'integer',
|
||||
'claimed_count' => 'integer',
|
||||
'claimed_amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联所属节日福利模板。
|
||||
*/
|
||||
public function holidayEvent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HolidayEvent::class, 'holiday_event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联本批次的领取记录。
|
||||
*/
|
||||
public function claims(): HasMany
|
||||
{
|
||||
return $this->hasMany(HolidayClaim::class, 'run_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前批次是否仍处于可领取状态。
|
||||
*/
|
||||
public function isClaimable(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& $this->expires_at !== null
|
||||
&& $this->expires_at->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询需要被自动收尾的批次。
|
||||
*/
|
||||
public static function pendingToExpire(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('status', 'active')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->get();
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -3,7 +3,8 @@
|
||||
/**
|
||||
* 文件功能:职务模型
|
||||
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
|
||||
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
|
||||
* 任命权限通过 position_appoint_limits 中间表多对多关联定义,
|
||||
* 聊天室顶部管理权限通过 permissions JSON 字段配置
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -35,6 +36,9 @@ class Position extends Model
|
||||
'daily_reward_limit',
|
||||
'recipient_daily_limit',
|
||||
'sort_order',
|
||||
'permissions',
|
||||
'red_packet_amount',
|
||||
'red_packet_count',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,9 @@ class Position extends Model
|
||||
'daily_reward_limit' => 'integer',
|
||||
'recipient_daily_limit' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'permissions' => 'array',
|
||||
'red_packet_amount' => 'integer',
|
||||
'red_packet_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,6 +130,24 @@ class Position extends Model
|
||||
return $this->currentCount() >= $this->max_persons;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前职务是否拥有指定权限码。
|
||||
*/
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
return in_array($permission, $this->permissions ?? [], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前职务的权限码列表。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function permissionCodes(): array
|
||||
{
|
||||
return array_values($this->permissions ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询范围:按位阶降序
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,7 @@ class PositionAuthorityLog extends Model
|
||||
'warn' => '警告',
|
||||
'kick' => '踢出',
|
||||
'mute' => '禁言',
|
||||
'ban' => '封号',
|
||||
'banip' => '封锁IP',
|
||||
'other' => '其他',
|
||||
];
|
||||
|
||||
@@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ShopItem extends Model
|
||||
{
|
||||
public const TYPE_SIGN_REPAIR = 'sign_repair';
|
||||
|
||||
protected $table = 'shop_items';
|
||||
|
||||
protected $fillable = [
|
||||
@@ -41,6 +43,14 @@ class ShopItem extends Model
|
||||
return $this->type === 'auto_fishing';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为签到补签卡。
|
||||
*/
|
||||
public function isSignRepairCard(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_SIGN_REPAIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为特效类商品(instant 或 duration,slug 以 once_ 或 week_ 开头)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到奖励规则模型。
|
||||
*
|
||||
* 通过连续签到天数门槛配置金币、经验、魅力与身份徽章奖励。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 类功能:封装签到奖励规则字段、类型转换与命中签到记录关联。
|
||||
*/
|
||||
class SignInRewardRule extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\SignInRewardRuleFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'streak_days',
|
||||
'gold_reward',
|
||||
'exp_reward',
|
||||
'charm_reward',
|
||||
'identity_badge_code',
|
||||
'identity_badge_name',
|
||||
'identity_badge_icon',
|
||||
'identity_badge_color',
|
||||
'identity_duration_days',
|
||||
'is_enabled',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'streak_days' => 'integer',
|
||||
'gold_reward' => 'integer',
|
||||
'exp_reward' => 'integer',
|
||||
'charm_reward' => 'integer',
|
||||
'identity_duration_days' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:命中过该规则的签到记录。
|
||||
*/
|
||||
public function dailySignIns(): HasMany
|
||||
{
|
||||
return $this->hasMany(DailySignIn::class, 'reward_rule_id');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 类功能:读取和计算系统参数,提供等级经验阈值等全局配置能力。
|
||||
*/
|
||||
class Sysparam extends Model
|
||||
{
|
||||
/** @var string 表名 */
|
||||
@@ -74,7 +77,8 @@ class Sysparam extends Model
|
||||
// 不超过最大等级
|
||||
$maxLevel = (int) static::getValue('maxlevel', '99');
|
||||
|
||||
return min($level, $maxLevel);
|
||||
// 聊天室普通用户最低等级为 1,避免低经验新人被自动存点降成 0 后无法进入默认房间。
|
||||
return max(1, min($level, $maxLevel));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
/**
|
||||
* 类功能:封装聊天室用户资料、会员状态与职务关联等核心数据行为。
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
@@ -51,6 +54,8 @@ class User extends Authenticatable
|
||||
'custom_join_effect',
|
||||
'custom_leave_effect',
|
||||
'chat_preferences',
|
||||
'daily_status_key',
|
||||
'daily_status_expires_at',
|
||||
'user_level',
|
||||
'inviter_id',
|
||||
'room_id',
|
||||
@@ -104,6 +109,8 @@ class User extends Authenticatable
|
||||
'q3_time' => 'datetime',
|
||||
'has_received_new_gift' => 'boolean',
|
||||
'chat_preferences' => 'array',
|
||||
'active_decorations' => 'array',
|
||||
'daily_status_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -246,6 +253,49 @@ class User extends Authenticatable
|
||||
return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户每日签到记录。
|
||||
*/
|
||||
public function dailySignIns(): HasMany
|
||||
{
|
||||
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户全部身份徽章。
|
||||
*/
|
||||
public function identityBadges(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserIdentityBadge::class, 'user_id')->latest('acquired_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户当前启用的签到身份徽章。
|
||||
*/
|
||||
public function currentSignInIdentityBadge(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserIdentityBadge::class, 'user_id')
|
||||
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->latestOfMany('acquired_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前签到身份徽章辅助方法。
|
||||
*/
|
||||
public function currentSignInIdentity(): ?UserIdentityBadge
|
||||
{
|
||||
if ($this->relationLoaded('currentSignInIdentityBadge')) {
|
||||
return $this->getRelation('currentSignInIdentityBadge');
|
||||
}
|
||||
|
||||
return $this->currentSignInIdentityBadge()->first();
|
||||
}
|
||||
|
||||
// ── 职务相关关联 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户身份徽章模型。
|
||||
*
|
||||
* 管理用户从签到等来源获得的当前身份展示标识。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 类功能:封装用户身份徽章字段、类型转换与用户关联。
|
||||
*/
|
||||
class UserIdentityBadge extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserIdentityBadgeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const SOURCE_SIGN_IN = 'sign_in';
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'source',
|
||||
'badge_code',
|
||||
'badge_name',
|
||||
'badge_icon',
|
||||
'badge_color',
|
||||
'acquired_at',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'acquired_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:身份徽章所属用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
// 注册登录入口限流器,阻断爆破和批量注册滥用。
|
||||
$this->registerAuthRateLimiters();
|
||||
|
||||
// 注册聊天室高频动作限流器,避免消息、购买与特效广播被脚本刷爆。
|
||||
$this->registerChatActionRateLimiters();
|
||||
|
||||
// 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史)
|
||||
Event::subscribe(SaveMarriageSystemMessage::class);
|
||||
|
||||
@@ -133,4 +136,36 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
return implode('|', [$scene, $username, $request->ip()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册聊天室内高频动作限流器。
|
||||
*/
|
||||
private function registerChatActionRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('chat-send', function (Request $request): Limit {
|
||||
return Limit::perMinute(40)
|
||||
->by($this->buildChatActionRateLimitKey($request, 'chat-send'));
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-shop-buy', function (Request $request): Limit {
|
||||
return Limit::perMinute(20)
|
||||
->by($this->buildChatActionRateLimitKey($request, 'chat-shop-buy'));
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-effect', function (Request $request): Limit {
|
||||
return Limit::perMinute(6)
|
||||
->by($this->buildChatActionRateLimitKey($request, 'chat-effect'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造聊天室动作限流键,按场景、用户与房间隔离计数。
|
||||
*/
|
||||
private function buildChatActionRateLimitKey(Request $request, string $scene): string
|
||||
{
|
||||
$userId = (string) ($request->user()?->id ?? 'guest');
|
||||
$roomId = (string) ($request->route('id') ?? $request->input('room_id', 'global'));
|
||||
|
||||
return implode('|', [$scene, $userId, $roomId, $request->ip()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利年度调度校验规则
|
||||
*
|
||||
* 负责校验 yearly 模式下的月/日/时间、多天与多轮次组合是否合法。
|
||||
*/
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\DataAwareRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* 类功能:校验节日福利年度调度配置的组合合法性。
|
||||
*/
|
||||
class HolidayEventScheduleRule implements DataAwareRule, ValidationRule
|
||||
{
|
||||
/**
|
||||
* 当前请求的全部字段。
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* 注入待校验的完整数据集。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function setData(array $data): static
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行年度调度规则校验。
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$repeatType = (string) ($this->data['repeat_type'] ?? $value ?? '');
|
||||
|
||||
if ($repeatType !== 'yearly') {
|
||||
return;
|
||||
}
|
||||
|
||||
$month = (int) ($this->data['schedule_month'] ?? 0);
|
||||
$day = (int) ($this->data['schedule_day'] ?? 0);
|
||||
$time = (string) ($this->data['schedule_time'] ?? '');
|
||||
$durationDays = (int) ($this->data['duration_days'] ?? 0);
|
||||
$dailyOccurrences = (int) ($this->data['daily_occurrences'] ?? 0);
|
||||
$intervalMinutes = $this->data['occurrence_interval_minutes'];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
$fail('年度节日模式必须设置有效的触发月份。');
|
||||
}
|
||||
|
||||
if ($day < 1 || $day > 31) {
|
||||
$fail('年度节日模式必须设置有效的触发日期。');
|
||||
}
|
||||
|
||||
if ($month > 0 && $day > 0 && ! checkdate($month, $day, 2024)) {
|
||||
$fail('所选月份和日期不是有效的公历节日日期。');
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d{2}:\d{2}$/', $time)) {
|
||||
$fail('年度节日模式必须设置首轮开始时间。');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($durationDays < 1 || $durationDays > 31) {
|
||||
$fail('连续发放天数必须在 1 到 31 天之间。');
|
||||
}
|
||||
|
||||
if ($dailyOccurrences < 1 || $dailyOccurrences > 24) {
|
||||
$fail('每天发送次数必须在 1 到 24 次之间。');
|
||||
}
|
||||
|
||||
if ($dailyOccurrences > 1 && ((int) $intervalMinutes) < 1) {
|
||||
$fail('同一天多轮发送时,必须设置大于 0 的间隔分钟数。');
|
||||
}
|
||||
|
||||
[$hour, $minute] = array_map('intval', explode(':', $time));
|
||||
$startMinutes = $hour * 60 + $minute;
|
||||
$lastOffsetMinutes = max(0, ($dailyOccurrences - 1) * (int) ($intervalMinutes ?? 0));
|
||||
|
||||
if ($startMinutes + $lastOffsetMinutes >= 1440) {
|
||||
$fail('同一天多轮发送的最后一轮不能跨到次日,请缩短间隔或减少次数。');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,22 +88,30 @@ class AiChatService
|
||||
// 动态获取由 guide 页面提取出的最新纯文本规则
|
||||
$guideRulesText = $this->getDynamicGuideRules();
|
||||
|
||||
// 动态获取金币福利上限,告知 AI 自行决定金额
|
||||
$maxGold = (int) \App\Models\Sysparam::getValue('chatbot_max_gold', '5000');
|
||||
|
||||
return <<<PROMPT
|
||||
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"。
|
||||
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"。
|
||||
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
|
||||
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
|
||||
|
||||
【背景与基础】
|
||||
- 本聊天室脱胎于原军中的经典“和平聊吧”,创始人是“流星”。
|
||||
- 当前版本基于前沿的 PHP Laravel 12 重构。
|
||||
【聊天室历史与创始人故事(你必须熟记,被问到时能娓娓道来)】
|
||||
本聊天室的创始人是"流星"(网名),以下是他亲手写下的回忆:
|
||||
|
||||
2000年流星接触网络,就喜欢上了这个能结识朋友、学习知识的世界。懵懵懂懂,花开花落,走过了近六个春秋,网络终于接受了他这个朋友。六年里,从申请成功第一个 EMAIL 就感觉好极了,到后来有了自己的网站,却越来越发现,在网络这博大的知识海洋里,自己原来是那么的渺小。
|
||||
流星的第一个个人网站在【上海人人网】申请,当时是个网盲,按照向导做了个简单页面。也许正是这个不成熟的"网站",给了他无尽的兴趣与力量,让他直至走进军营,仍一如既往地追求。后来在【中国人】申请了第二个个人网站,又在网上认识了一些喜欢做网页的大虾,在他们的支持下申请了第一个空间。曾尝试自己修改"江湖"程序,虽一再坚持,最终没有成功,唯一的成绩是修改了一个留言版。
|
||||
由于溺爱网络,导致高考落榜。走进军营让自己释然,而上天总眷恋执着追求的人——入伍后一次偶然机会,他又重新接触了网络,开始了新的网络生活。
|
||||
2004年5月,流星架设了第一个军网聊天室【梦幻之城】,版本很古老,却让他很开心。经过无数次测试与修改,第二个军网聊天室【风之恋】向大家敞开了大门。笑傲江湖就是在这时与他相识。由于硬件条件有限,笑傲江湖的【和平聊吧】在第一次更新版本时,就用了流星修改的【风之恋】。
|
||||
2006年9月综合信息网普及十六军基层时,经笑傲江湖推荐、由梦蝶提供服务器,流星的聊天室在军旅之巅"北京军区"再次与大家相见。他共3次改版聊天室,一次次失败,又一次次战胜失败。第三次是当时感觉最成功的版本!现在使用的是流星重新用 PHP 语言 Laravel 12 框架重写的版本,于 2026年2月26日正式上线。
|
||||
|
||||
【聊天室最新版官方知识库手册(你必须掌握这些作为客服知识库)】
|
||||
$guideRulesText
|
||||
|
||||
【发金币福利特权】
|
||||
每天每个用户只能向你讨要一次金币福利(100-5000枚随机)。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
||||
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]。
|
||||
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
||||
每天每个用户只能向你讨要一次金币福利。如果用户向你讨要金币(或者哭穷),你可以发善心给他们发金币。
|
||||
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD:金额](例如:[ACTION:GIVE_GOLD:888])。金额由你根据聊天内容、对方态度和你的心情自行决定,最低 100 枚,最高 {$maxGold} 枚。讨得真诚、聊得投缘的可以多给些,敷衍了事的少给些。
|
||||
系统程序看到这个符号后会自动为用户发放你指定的金币数并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
|
||||
|
||||
【交流要求】
|
||||
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
|
||||
|
||||
@@ -220,8 +220,9 @@ class ChatStateService
|
||||
|
||||
foreach ($messages as $msgJson) {
|
||||
$msg = json_decode($msgJson, true);
|
||||
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
|
||||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
|
||||
// 只清理普通进出播报,避免误删新人礼包公告和 AI 小班长新人欢迎。
|
||||
$welcomeKind = $msg['welcome_kind'] ?? 'entry_broadcast';
|
||||
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username && $welcomeKind === 'entry_broadcast') {
|
||||
continue;
|
||||
}
|
||||
$filtered[] = $msgJson;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室在线用户展示数据服务
|
||||
* 负责统一拼装聊天室在线名单、Presence 频道与 Redis 在线状态使用的用户载荷。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Support\ChatDailyStatusCatalog;
|
||||
|
||||
/**
|
||||
* 类功能:统一生成聊天室在线用户与身份展示相关载荷。
|
||||
*/
|
||||
class ChatUserPresenceService
|
||||
{
|
||||
/**
|
||||
* 构建聊天室在线用户载荷。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(User $user): array
|
||||
{
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$user->loadMissing(['activePosition.position.department', 'vipLevel']);
|
||||
$position = $user->activePosition?->position;
|
||||
$payload = [
|
||||
'id' => $user->id,
|
||||
'user_id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'level' => $user->user_level,
|
||||
'user_level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface,
|
||||
'headface_url' => $user->headfaceUrl,
|
||||
'headfaceUrl' => $user->headfaceUrl,
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
'position_icon' => $position?->icon ?? '',
|
||||
'position_name' => $position?->name ?? '',
|
||||
'department_name' => $position?->department?->name ?? '',
|
||||
];
|
||||
|
||||
$activeStatus = $this->currentDailyStatus($user);
|
||||
if ($activeStatus !== null) {
|
||||
$payload['daily_status_key'] = $activeStatus['key'];
|
||||
$payload['daily_status_label'] = $activeStatus['label'];
|
||||
$payload['daily_status_icon'] = $activeStatus['icon'];
|
||||
$payload['daily_status_group'] = $activeStatus['group'];
|
||||
$payload['daily_status_expires_at'] = $activeStatus['expires_at'];
|
||||
}
|
||||
|
||||
$signIdentity = $user->currentSignInIdentity();
|
||||
if ($signIdentity !== null) {
|
||||
$payload['sign_identity_key'] = $signIdentity->badge_code;
|
||||
$payload['sign_identity_label'] = $signIdentity->badge_name;
|
||||
$payload['sign_identity_icon'] = $signIdentity->badge_icon ?? '✅';
|
||||
$payload['sign_identity_color'] = $signIdentity->badge_color ?? '#0f766e';
|
||||
$payload['sign_identity_expires_at'] = $signIdentity->expires_at?->toIso8601String();
|
||||
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
||||
}
|
||||
|
||||
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
|
||||
if (! empty($decorations['avatar_frame'])) {
|
||||
$payload['avatar_frame'] = $decorations['avatar_frame'];
|
||||
}
|
||||
if (! empty($decorations['name_color'])) {
|
||||
$payload['name_color'] = $decorations['name_color'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户部门、职务与会员展示信息。
|
||||
*
|
||||
* @return array{
|
||||
* department_name: string,
|
||||
* position_icon: string,
|
||||
* position_name: string,
|
||||
* vip_icon: string,
|
||||
* vip_name: string,
|
||||
* vip_label: string,
|
||||
* inline: string
|
||||
* }
|
||||
*/
|
||||
public function buildIdentitySummary(User $user): array
|
||||
{
|
||||
$user->loadMissing(['activePosition.position.department', 'vipLevel']);
|
||||
|
||||
$position = $user->activePosition?->position;
|
||||
$departmentName = $position?->department?->name ?? '无部门';
|
||||
$positionIcon = $position?->icon ?? '';
|
||||
$positionName = $position?->name ?? '无职务';
|
||||
$vipIcon = $user->vipIcon();
|
||||
$vipName = $user->vipName() ?: '普通会员';
|
||||
$vipLabel = trim($vipIcon.' '.$vipName);
|
||||
$positionLabel = trim($positionIcon.' '.$positionName);
|
||||
|
||||
return [
|
||||
'department_name' => $departmentName,
|
||||
'position_icon' => $positionIcon,
|
||||
'position_name' => $positionName,
|
||||
'vip_icon' => $vipIcon,
|
||||
'vip_name' => $vipName,
|
||||
'vip_label' => $vipLabel,
|
||||
'inline' => "部门 {$departmentName} · 职务 {$positionLabel} · 会员 {$vipLabel}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取用户当前仍然有效的当日状态。
|
||||
*
|
||||
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
|
||||
*/
|
||||
public function currentDailyStatus(User $user): ?array
|
||||
{
|
||||
return ChatDailyStatusCatalog::resolveActiveStatus(
|
||||
$user->daily_status_key,
|
||||
$user->daily_status_expires_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户个人装扮统一管理服务
|
||||
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||||
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||||
*
|
||||
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||||
* - bubble : 消息气泡边框样式
|
||||
* - name_color : 昵称颜色效果
|
||||
* - avatar_frame: 头像装饰边框
|
||||
* - text_color : 消息文字颜色特效
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPurchase;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
|
||||
*/
|
||||
class DecorationService
|
||||
{
|
||||
/**
|
||||
* 商店商品 type → 装扮槽位映射。
|
||||
* 以后新增装扮类型,在此加一行即可。
|
||||
*/
|
||||
private const TYPE_TO_SLOT = [
|
||||
'msg_bubble' => 'bubble',
|
||||
'msg_name_color' => 'name_color',
|
||||
'avatar_frame' => 'avatar_frame',
|
||||
'msg_text_color' => 'text_color',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
if (! $slot) {
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$expiresAt = Carbon::now()->addDays($days);
|
||||
|
||||
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
|
||||
$source = match ($item->type) {
|
||||
'msg_bubble' => CurrencySource::MSG_BUBBLE_BUY,
|
||||
'msg_name_color' => CurrencySource::MSG_NAME_COLOR_BUY,
|
||||
'msg_text_color' => CurrencySource::MSG_TEXT_COLOR_BUY,
|
||||
'avatar_frame' => CurrencySource::AVATAR_FRAME_BUY,
|
||||
default => CurrencySource::MSG_DECORATION_BUY,
|
||||
};
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$item->price, $source,
|
||||
"购买装扮:{$item->name}({$days}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$decorations[$slot] = [
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
});
|
||||
|
||||
// 重新读取最新余额,避免缓存脏数据
|
||||
$balanceAfter = (int) $user->fresh()->jjb;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||||
*
|
||||
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
|
||||
* 如果清理了过期数据会自动写回 users.active_decorations。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
|
||||
*/
|
||||
public function getActiveDecorations(User $user): array
|
||||
{
|
||||
$decorations = $user->active_decorations ?? [];
|
||||
|
||||
// 非数组(null 或格式异常)直接返回空
|
||||
if (! is_array($decorations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$changed = false;
|
||||
|
||||
foreach ($decorations as $slot => $data) {
|
||||
// 格式校验:必须包含 expires_at 字段
|
||||
if (! is_array($data) || empty($data['expires_at'])) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析过期时间并与当前时间比较
|
||||
try {
|
||||
$expiresAt = Carbon::parse($data['expires_at']);
|
||||
if ($expiresAt->isPast()) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 时间格式异常也视为无效,清理之
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 有过期项被清理时写回数据库
|
||||
if ($changed) {
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return $decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息广播 payload 需要携带的装扮字段。
|
||||
*
|
||||
* 消息广播需要气泡样式(msg_bubble)、昵称颜色(msg_name_color)、文字颜色特效(msg_text_color)
|
||||
* 以及头像框(avatar_frame),前端据此渲染发送者头像的装饰边框。
|
||||
*
|
||||
* @param User $user 消息发送者
|
||||
* @return array{msg_bubble?:string, msg_name_color?:string, msg_text_color?:string, avatar_frame?:string}
|
||||
*/
|
||||
public function getDecorationsForMessage(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['bubble']['style'])) {
|
||||
$result['msg_bubble'] = $decorations['bubble']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['msg_name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
if (! empty($decorations['text_color']['style'])) {
|
||||
$result['msg_text_color'] = $decorations['text_color']['style'];
|
||||
}
|
||||
if (! empty($decorations['avatar_frame']['style'])) {
|
||||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户 Presence 载荷需要携带的装扮字段。
|
||||
*
|
||||
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
|
||||
* 气泡样式仅作用于消息,不需要在用户列表中展示。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array{avatar_frame?:string, name_color?:string}
|
||||
*/
|
||||
public function getDecorationsForPresence(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['avatar_frame']['style'])) {
|
||||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利调度计算服务
|
||||
*
|
||||
* 统一负责节日福利模板的首次触发时间与下一次触发时间推导,
|
||||
* 支持普通重复模式与年度节日高级调度。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
/**
|
||||
* 类功能:计算节日福利模板的下一次触发时间。
|
||||
*/
|
||||
class HolidayEventScheduleService
|
||||
{
|
||||
/**
|
||||
* 根据表单配置计算模板的下一次触发时间。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function resolveNextConfiguredSendAt(array $data, ?CarbonInterface $reference = null): CarbonImmutable
|
||||
{
|
||||
$referenceTime = CarbonImmutable::instance($reference ?? now());
|
||||
|
||||
if (($data['repeat_type'] ?? 'once') !== 'yearly') {
|
||||
return CarbonImmutable::parse((string) $data['send_at']);
|
||||
}
|
||||
|
||||
return $this->findNextYearlyOccurrence($data, $referenceTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算模板在一次自动触发后的下一次 send_at。
|
||||
*/
|
||||
public function advanceAfterTrigger(HolidayEvent $event): ?CarbonImmutable
|
||||
{
|
||||
if ($event->send_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentSendAt = CarbonImmutable::instance($event->send_at);
|
||||
|
||||
return match ($event->repeat_type) {
|
||||
'daily' => $currentSendAt->addDay(),
|
||||
'weekly' => $currentSendAt->addWeek(),
|
||||
'monthly' => $currentSendAt->addMonth(),
|
||||
'yearly' => $this->findNextYearlyOccurrence($this->extractYearlyConfig($event), $currentSendAt->addSecond()),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找年度节日配置在参考时间之后的下一次触发点。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function findNextYearlyOccurrence(array $data, CarbonInterface $reference): CarbonImmutable
|
||||
{
|
||||
$referenceTime = CarbonImmutable::instance($reference);
|
||||
|
||||
foreach ([$referenceTime->year, $referenceTime->year + 1, $referenceTime->year + 2] as $year) {
|
||||
foreach ($this->buildYearlyOccurrencesForYear($data, $year) as $occurrence) {
|
||||
if ($occurrence->greaterThanOrEqualTo($referenceTime)) {
|
||||
return $occurrence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->buildYearlyOccurrencesForYear($data, $referenceTime->year + 3)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定年份内的全部年度节日触发点。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<int, CarbonImmutable>
|
||||
*/
|
||||
public function buildYearlyOccurrencesForYear(array $data, int $year): array
|
||||
{
|
||||
[$hour, $minute] = array_map('intval', explode(':', (string) $data['schedule_time']));
|
||||
|
||||
$baseDate = CarbonImmutable::create(
|
||||
$year,
|
||||
(int) $data['schedule_month'],
|
||||
(int) $data['schedule_day'],
|
||||
$hour,
|
||||
$minute,
|
||||
0,
|
||||
config('app.timezone')
|
||||
);
|
||||
|
||||
$occurrences = [];
|
||||
$durationDays = max(1, (int) ($data['duration_days'] ?? 1));
|
||||
$dailyOccurrences = max(1, (int) ($data['daily_occurrences'] ?? 1));
|
||||
$intervalMinutes = (int) ($data['occurrence_interval_minutes'] ?? 0);
|
||||
|
||||
for ($dayIndex = 0; $dayIndex < $durationDays; $dayIndex++) {
|
||||
$dayStart = $baseDate->addDays($dayIndex);
|
||||
|
||||
for ($occurrenceIndex = 0; $occurrenceIndex < $dailyOccurrences; $occurrenceIndex++) {
|
||||
$occurrences[] = $dayStart->addMinutes($intervalMinutes * $occurrenceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return $occurrences;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板模型中提取年度节日调度字段。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractYearlyConfig(HolidayEvent $event): array
|
||||
{
|
||||
return [
|
||||
'schedule_month' => $event->schedule_month,
|
||||
'schedule_day' => $event->schedule_day,
|
||||
'schedule_time' => $event->schedule_time,
|
||||
'duration_days' => $event->duration_days,
|
||||
'daily_occurrences' => $event->daily_occurrences,
|
||||
'occurrence_interval_minutes' => $event->occurrence_interval_minutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:职务权限判断服务
|
||||
* 负责把当前用户的在职职务权限转换为统一布尔判断,
|
||||
* 供控制器和 Blade 视图复用。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\PositionPermissionRegistry;
|
||||
|
||||
/**
|
||||
* 类功能:统一提供用户职务权限读取与校验方法。
|
||||
*/
|
||||
class PositionPermissionService
|
||||
{
|
||||
/**
|
||||
* 返回当前用户拥有的全部聊天室权限码。
|
||||
*
|
||||
* 规则:所有用户都仅按当前在职职务的 permissions 生效。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function permissionsForUser(?User $user): array
|
||||
{
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$position = $user->activePosition?->position;
|
||||
if (! $position) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_intersect(
|
||||
PositionPermissionRegistry::codes(),
|
||||
$position->permissions ?? [],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前用户全部权限的布尔映射表。
|
||||
*
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function permissionMapForUser(?User $user): array
|
||||
{
|
||||
$permissionMap = array_fill_keys(PositionPermissionRegistry::codes(), false);
|
||||
|
||||
foreach ($this->permissionsForUser($user) as $permission) {
|
||||
$permissionMap[$permission] = true;
|
||||
}
|
||||
|
||||
return $permissionMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否拥有指定权限码。
|
||||
*/
|
||||
public function hasPermission(?User $user, string $permission): bool
|
||||
{
|
||||
if (! in_array($permission, PositionPermissionRegistry::codes(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($permission, $this->permissionsForUser($user), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否至少拥有一项指定权限。
|
||||
*
|
||||
* @param list<string> $permissions
|
||||
*/
|
||||
public function hasAnyPermission(?User $user, array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->hasPermission($user, $permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,29 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShopService
|
||||
{
|
||||
/**
|
||||
* @param DecorationService $decorationService 装扮服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DecorationService $decorationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买商品入口:扣金币、按类型分发处理
|
||||
*
|
||||
* @return array{ok:bool, message:string, play_effect?:string}
|
||||
* @return array{ok:bool, message:string, play_effect?:string, quantity?:int, total_price?:int}
|
||||
*/
|
||||
public function buyItem(User $user, ShopItem $item): array
|
||||
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
|
||||
{
|
||||
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) {
|
||||
return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
|
||||
}
|
||||
|
||||
$totalPrice = $item->price * $quantity;
|
||||
|
||||
// 校验金币是否足够
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
if ($user->jjb < $totalPrice) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
return match ($item->type) {
|
||||
@@ -34,6 +47,12 @@ class ShopService
|
||||
'one_time' => $this->buyRenameCard($user, $item),
|
||||
'ring' => $this->buyRing($user, $item),
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
@@ -243,6 +262,39 @@ class ShopService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买签到补签卡:扣金币并写入可消耗背包记录。
|
||||
*
|
||||
* @return array{ok:bool, message:string, quantity:int, total_price:int}
|
||||
*/
|
||||
public function buySignRepairCard(User $user, ShopItem $item, int $quantity = 1): array
|
||||
{
|
||||
$quantity = max(1, min(99, $quantity));
|
||||
$totalPrice = $item->price * $quantity;
|
||||
|
||||
DB::transaction(function () use ($user, $item, $quantity, $totalPrice): void {
|
||||
// 补签卡支持一次购买多张,扣款必须按总价执行。
|
||||
$user->decrement('jjb', $totalPrice);
|
||||
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
// 每张补签卡保留独立购买记录,补签时逐张消耗。
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "🗓️ {$item->name} × {$quantity} 购买成功!可在签到日历中补签漏掉的日期。",
|
||||
'quantity' => $quantity,
|
||||
'total_price' => $totalPrice,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前激活的改名卡(是否持有未用改名卡)
|
||||
*/
|
||||
@@ -254,6 +306,18 @@ class ShopService
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可用补签卡数量。
|
||||
*/
|
||||
public function getSignRepairCardCount(User $user): int
|
||||
{
|
||||
return UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('shopItem', fn ($q) => $q->where('type', ShopItem::TYPE_SIGN_REPAIR))
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买自动钓鱼卡:手刺金币,写入 active 记录,到期时间 = 现在 + duration_minutes。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到服务。
|
||||
*
|
||||
* 负责查询签到状态、领取每日签到奖励、防止重复领取、刷新签到身份徽章。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\SignInRewardRule;
|
||||
use App\Models\User;
|
||||
use App\Models\UserIdentityBadge;
|
||||
use App\Models\UserPurchase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 类功能:封装每日签到状态查询与领取奖励的核心业务流程。
|
||||
*/
|
||||
class SignInService
|
||||
{
|
||||
/**
|
||||
* 构造每日签到服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询用户当前签到状态与今日可领取预览。
|
||||
*
|
||||
* @return array{
|
||||
* signed_today: bool,
|
||||
* can_claim: bool,
|
||||
* current_streak_days: int,
|
||||
* claimable_streak_days: int,
|
||||
* today_sign_in: DailySignIn|null,
|
||||
* latest_sign_in: DailySignIn|null,
|
||||
* matched_rule: SignInRewardRule|null,
|
||||
* current_identity: UserIdentityBadge|null
|
||||
* }
|
||||
*/
|
||||
public function status(User $user): array
|
||||
{
|
||||
$today = today();
|
||||
|
||||
$todaySignIn = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('sign_in_date', $today)
|
||||
->first();
|
||||
|
||||
$latestSignIn = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('sign_in_date')
|
||||
->first();
|
||||
|
||||
$signedToday = $todaySignIn !== null;
|
||||
$claimableStreakDays = $signedToday
|
||||
? (int) $todaySignIn->streak_days
|
||||
: $this->calculateNextStreakDays($latestSignIn, $today);
|
||||
|
||||
return [
|
||||
'signed_today' => $signedToday,
|
||||
'can_claim' => ! $signedToday,
|
||||
'current_streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
|
||||
'claimable_streak_days' => $claimableStreakDays,
|
||||
'today_sign_in' => $todaySignIn,
|
||||
'latest_sign_in' => $latestSignIn,
|
||||
'matched_rule' => $this->matchRewardRule($claimableStreakDays),
|
||||
'current_identity' => $user->currentSignInIdentity(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定月份的签到日历状态。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function calendar(User $user, ?string $month = null): array
|
||||
{
|
||||
$monthStart = $month
|
||||
? Carbon::createFromFormat('Y-m', $month)->startOfMonth()
|
||||
: today()->startOfMonth();
|
||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||
$today = today();
|
||||
// 补签卡只允许修补当前自然月,查看历史月份时不再开放补签入口。
|
||||
$isCurrentMonth = $monthStart->isSameMonth($today);
|
||||
|
||||
$signIns = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereBetween('sign_in_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
|
||||
->get()
|
||||
->keyBy(fn (DailySignIn $signIn): string => $signIn->sign_in_date->toDateString());
|
||||
|
||||
$days = [];
|
||||
for ($date = $monthStart->copy(); $date->lte($monthEnd); $date->addDay()) {
|
||||
$dateKey = $date->toDateString();
|
||||
$signIn = $signIns->get($dateKey);
|
||||
$isFuture = $date->gt($today);
|
||||
|
||||
$days[] = [
|
||||
'date' => $dateKey,
|
||||
'day' => (int) $date->day,
|
||||
'weekday' => (int) $date->dayOfWeek,
|
||||
'signed' => $signIn !== null,
|
||||
'is_today' => $date->isSameDay($today),
|
||||
'is_future' => $isFuture,
|
||||
'can_makeup' => $isCurrentMonth && $signIn === null && $date->lt($today),
|
||||
'is_makeup' => (bool) ($signIn?->is_makeup ?? false),
|
||||
'streak_days' => (int) ($signIn?->streak_days ?? 0),
|
||||
'reward_text' => $signIn ? $this->buildRewardText($signIn) : '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'month' => $monthStart->format('Y-m'),
|
||||
'month_label' => $monthStart->format('Y年n月'),
|
||||
'prev_month' => $monthStart->copy()->subMonth()->format('Y-m'),
|
||||
'next_month' => $monthStart->copy()->addMonth()->format('Y-m'),
|
||||
'days' => $days,
|
||||
'reward_rules' => $this->rewardRulePreview(),
|
||||
'makeup_card_count' => $this->availableSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $this->activeSignRepairCard()?->only(['id', 'name', 'slug', 'icon', 'price', 'description']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取每日签到奖励。
|
||||
*/
|
||||
public function claim(User $user, ?int $roomId = null): DailySignIn
|
||||
{
|
||||
return DB::transaction(function () use ($user, $roomId): DailySignIn {
|
||||
$today = today();
|
||||
|
||||
// 锁定用户行,将同一用户的并发签到串行化,避免“查无今日记录”时并发重复发奖。
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$existingSignIn = DailySignIn::query()
|
||||
->where('user_id', $lockedUser->id)
|
||||
->whereDate('sign_in_date', $today)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existingSignIn !== null) {
|
||||
return $existingSignIn->load('rewardRule');
|
||||
}
|
||||
|
||||
$latestSignIn = DailySignIn::query()
|
||||
->where('user_id', $lockedUser->id)
|
||||
->whereDate('sign_in_date', '<', $today)
|
||||
->latest('sign_in_date')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$streakDays = $this->calculateNextStreakDays($latestSignIn, $today);
|
||||
$rewardRule = $this->matchRewardRule($streakDays);
|
||||
|
||||
$dailySignIn = DailySignIn::query()->create([
|
||||
'user_id' => $lockedUser->id,
|
||||
'room_id' => $roomId,
|
||||
'sign_in_date' => $today->toDateString(),
|
||||
'streak_days' => $streakDays,
|
||||
'reward_rule_id' => $rewardRule?->id,
|
||||
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
|
||||
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
|
||||
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
|
||||
'identity_badge_code' => $rewardRule?->identity_badge_code,
|
||||
'identity_badge_name' => $rewardRule?->identity_badge_name,
|
||||
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
|
||||
'identity_badge_color' => $rewardRule?->identity_badge_color,
|
||||
]);
|
||||
|
||||
$this->grantRewards($lockedUser, $dailySignIn, $roomId);
|
||||
$this->refreshSignInIdentityBadge($lockedUser, $rewardRule, $streakDays);
|
||||
|
||||
return $dailySignIn->load('rewardRule');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用补签卡补签指定历史日期。
|
||||
*/
|
||||
public function makeup(User $user, string $targetDate, ?int $roomId = null): DailySignIn
|
||||
{
|
||||
return DB::transaction(function () use ($user, $targetDate, $roomId): DailySignIn {
|
||||
$date = Carbon::parse($targetDate)->startOfDay();
|
||||
|
||||
if ($date->greaterThanOrEqualTo(today())) {
|
||||
throw ValidationException::withMessages(['target_date' => '只能补签今天之前的漏签日期。']);
|
||||
}
|
||||
|
||||
// 后端再次限制补签月份,避免绕过前端日历直接提交历史月份。
|
||||
if (! $date->isSameMonth(today())) {
|
||||
throw ValidationException::withMessages(['target_date' => '补签卡只能补签本月的未签到日期。']);
|
||||
}
|
||||
|
||||
// 锁定用户行,保证补签卡消耗和签到记录创建不会并发重复执行。
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$existingSignIn = DailySignIn::query()
|
||||
->where('user_id', $lockedUser->id)
|
||||
->whereDate('sign_in_date', $date)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existingSignIn !== null) {
|
||||
throw ValidationException::withMessages(['target_date' => '这一天已经签到过,不能重复补签。']);
|
||||
}
|
||||
|
||||
$repairCard = $this->nextAvailableSignRepairCard($lockedUser);
|
||||
if ($repairCard === null) {
|
||||
throw ValidationException::withMessages(['target_date' => '没有可用补签卡,请先购买补签卡。']);
|
||||
}
|
||||
|
||||
$latestBeforeTarget = DailySignIn::query()
|
||||
->where('user_id', $lockedUser->id)
|
||||
->whereDate('sign_in_date', '<', $date)
|
||||
->latest('sign_in_date')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$streakDays = $this->calculateNextStreakDays($latestBeforeTarget, $date);
|
||||
|
||||
$dailySignIn = DailySignIn::query()->create([
|
||||
'user_id' => $lockedUser->id,
|
||||
'room_id' => $roomId,
|
||||
'is_makeup' => true,
|
||||
'makeup_purchase_id' => $repairCard->id,
|
||||
'makeup_at' => now(),
|
||||
'sign_in_date' => $date->toDateString(),
|
||||
'streak_days' => $streakDays,
|
||||
'reward_rule_id' => null,
|
||||
'gold_reward' => 0,
|
||||
'exp_reward' => 0,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => null,
|
||||
'identity_badge_name' => null,
|
||||
'identity_badge_icon' => null,
|
||||
'identity_badge_color' => null,
|
||||
]);
|
||||
|
||||
// 补签卡与补签记录必须在同一事务里完成状态流转。
|
||||
$repairCard->update(['status' => 'used', 'used_at' => now()]);
|
||||
$this->recalculateFutureStreaks($lockedUser, $date);
|
||||
$currentStreakDays = $this->latestStreakDays($lockedUser);
|
||||
$rewardRule = $this->matchRewardRule($currentStreakDays);
|
||||
$dailySignIn->update([
|
||||
'reward_rule_id' => $rewardRule?->id,
|
||||
'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0),
|
||||
'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0),
|
||||
'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0),
|
||||
'identity_badge_code' => $rewardRule?->identity_badge_code,
|
||||
'identity_badge_name' => $rewardRule?->identity_badge_name,
|
||||
'identity_badge_icon' => $rewardRule?->identity_badge_icon,
|
||||
'identity_badge_color' => $rewardRule?->identity_badge_color,
|
||||
]);
|
||||
$this->grantRewards(
|
||||
$lockedUser,
|
||||
$dailySignIn->fresh(),
|
||||
$roomId,
|
||||
"补签 {$date->toDateString()}:当前连续签到 {$currentStreakDays} 天"
|
||||
);
|
||||
$this->refreshIdentityFromLatestSignIn($lockedUser);
|
||||
|
||||
return $dailySignIn->fresh('rewardRule');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算下一次签到应获得的连续天数。
|
||||
*/
|
||||
private function calculateNextStreakDays(?DailySignIn $latestSignIn, ?Carbon $targetDate = null): int
|
||||
{
|
||||
$targetDate ??= today();
|
||||
|
||||
if ($latestSignIn === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$yesterday = $targetDate->copy()->subDay()->toDateString();
|
||||
|
||||
if ($latestSignIn->sign_in_date?->toDateString() === $yesterday) {
|
||||
// 签到满一年后开启新周期,下一天重新从第 1 天计算。
|
||||
if ((int) $latestSignIn->streak_days >= $this->daysInSignInYear($latestSignIn)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) $latestSignIn->streak_days + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按签到记录所在年份计算当年天数,闰年为 366 天。
|
||||
*/
|
||||
private function daysInSignInYear(DailySignIn $dailySignIn): int
|
||||
{
|
||||
return $dailySignIn->sign_in_date?->isLeapYear() ? 366 : 365;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配当前连续天数可用的最高奖励规则。
|
||||
*/
|
||||
private function matchRewardRule(int $streakDays): ?SignInRewardRule
|
||||
{
|
||||
return SignInRewardRule::query()
|
||||
->where('is_enabled', true)
|
||||
->where('streak_days', '<=', $streakDays)
|
||||
->orderByDesc('streak_days')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按签到记录快照发放金币、经验和魅力。
|
||||
*/
|
||||
private function grantRewards(User $user, DailySignIn $dailySignIn, ?int $roomId, ?string $remark = null): void
|
||||
{
|
||||
$remark ??= "每日签到:连续 {$dailySignIn->streak_days} 天";
|
||||
|
||||
$this->currencyService->change($user, 'gold', $dailySignIn->gold_reward, CurrencySource::SIGN_IN, $remark, $roomId);
|
||||
$this->currencyService->change($user, 'exp', $dailySignIn->exp_reward, CurrencySource::SIGN_IN, $remark, $roomId);
|
||||
$this->currencyService->change($user, 'charm', $dailySignIn->charm_reward, CurrencySource::SIGN_IN, $remark, $roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新用户当前签到身份徽章。
|
||||
*/
|
||||
private function refreshSignInIdentityBadge(User $user, ?SignInRewardRule $rewardRule, int $streakDays): void
|
||||
{
|
||||
UserIdentityBadge::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
if ($rewardRule?->identity_badge_code === null || $rewardRule->identity_badge_name === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 同一来源同一徽章唯一,重复命中时更新状态和连续天数快照即可。
|
||||
UserIdentityBadge::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
|
||||
'badge_code' => $rewardRule->identity_badge_code,
|
||||
],
|
||||
[
|
||||
'badge_name' => $rewardRule->identity_badge_name,
|
||||
'badge_icon' => $rewardRule->identity_badge_icon,
|
||||
'badge_color' => $rewardRule->identity_badge_color,
|
||||
'acquired_at' => now(),
|
||||
// 身份有效期由后台规则控制,0 表示长期有效。
|
||||
'expires_at' => $rewardRule->identity_duration_days > 0
|
||||
? now()->addDays($rewardRule->identity_duration_days)
|
||||
: null,
|
||||
'is_active' => true,
|
||||
'metadata' => [
|
||||
'streak_days' => $streakDays,
|
||||
'reward_rule_id' => $rewardRule->id,
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期向后重算连续天数,补齐断点后让后续日历展示同步变化。
|
||||
*/
|
||||
private function recalculateFutureStreaks(User $user, Carbon $fromDate): void
|
||||
{
|
||||
$records = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('sign_in_date', '>=', $fromDate)
|
||||
->orderBy('sign_in_date')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
$previous = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereDate('sign_in_date', '<', $fromDate)
|
||||
->latest('sign_in_date')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$previousDate = $previous?->sign_in_date;
|
||||
$previousStreak = (int) ($previous?->streak_days ?? 0);
|
||||
|
||||
$records->each(function (DailySignIn $record) use (&$previousDate, &$previousStreak): void {
|
||||
$streakDays = $previousDate?->copy()->addDay()->toDateString() === $record->sign_in_date?->toDateString()
|
||||
? $previousStreak + 1
|
||||
: 1;
|
||||
|
||||
if ((int) $record->streak_days !== $streakDays) {
|
||||
// 只重算连续天数快照,不追溯变更已发放奖励流水。
|
||||
$record->update(['streak_days' => $streakDays]);
|
||||
}
|
||||
|
||||
$previousDate = $record->sign_in_date;
|
||||
$previousStreak = $streakDays;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用最新签到记录刷新当前签到身份。
|
||||
*/
|
||||
private function refreshIdentityFromLatestSignIn(User $user): void
|
||||
{
|
||||
$latestSignIn = DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('sign_in_date')
|
||||
->first();
|
||||
|
||||
if ($latestSignIn === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshSignInIdentityBadge($user, $this->matchRewardRule((int) $latestSignIn->streak_days), (int) $latestSignIn->streak_days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户最新签到记录的连续天数。
|
||||
*/
|
||||
private function latestStreakDays(User $user): int
|
||||
{
|
||||
return (int) DailySignIn::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('sign_in_date')
|
||||
->value('streak_days');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户下一张可消耗的补签卡。
|
||||
*/
|
||||
private function nextAvailableSignRepairCard(User $user): ?UserPurchase
|
||||
{
|
||||
return UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
|
||||
->oldest()
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户可用补签卡数量。
|
||||
*/
|
||||
private function availableSignRepairCardCount(User $user): int
|
||||
{
|
||||
return UserPurchase::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR))
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前上架的补签卡商品。
|
||||
*/
|
||||
private function activeSignRepairCard(): ?ShopItem
|
||||
{
|
||||
return ShopItem::query()
|
||||
->where('type', ShopItem::TYPE_SIGN_REPAIR)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询启用中的签到奖励档位,供前台展示目标奖励。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function rewardRulePreview(): array
|
||||
{
|
||||
return SignInRewardRule::query()
|
||||
->where('is_enabled', true)
|
||||
->orderBy('streak_days')
|
||||
->get()
|
||||
->map(fn (SignInRewardRule $rule): array => [
|
||||
'streak_days' => (int) $rule->streak_days,
|
||||
'gold_reward' => (int) $rule->gold_reward,
|
||||
'exp_reward' => (int) $rule->exp_reward,
|
||||
'charm_reward' => (int) $rule->charm_reward,
|
||||
'identity_badge_name' => $rule->identity_badge_name,
|
||||
'identity_badge_icon' => $rule->identity_badge_icon,
|
||||
'identity_badge_color' => $rule->identity_badge_color,
|
||||
'identity_duration_days' => (int) $rule->identity_duration_days,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日历内展示的签到奖励文本。
|
||||
*/
|
||||
private function buildRewardText(DailySignIn $dailySignIn): string
|
||||
{
|
||||
$items = [];
|
||||
if ($dailySignIn->gold_reward > 0) {
|
||||
$items[] = $dailySignIn->gold_reward.'金';
|
||||
}
|
||||
if ($dailySignIn->exp_reward > 0) {
|
||||
$items[] = $dailySignIn->exp_reward.'经验';
|
||||
}
|
||||
if ($dailySignIn->charm_reward > 0) {
|
||||
$items[] = $dailySignIn->charm_reward.'魅力';
|
||||
}
|
||||
|
||||
return $items === [] ? '已记录' : implode(' + ', $items);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,14 @@ namespace App\Services;
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\User;
|
||||
use App\Models\UserCurrencyLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:统一处理用户经验、金币与魅力变更,并记录对应流水。
|
||||
*/
|
||||
class UserCurrencyService
|
||||
{
|
||||
/**
|
||||
@@ -85,6 +90,58 @@ class UserCurrencyService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在余额充足时扣除用户流通金币,并返回扣费后的金币余额。
|
||||
*
|
||||
* @param User $user 被扣费用户
|
||||
* @param int $amount 扣费金币数量
|
||||
* @param CurrencySource $source 扣费来源
|
||||
* @param string $remark 扣费备注
|
||||
* @param int|null $roomId 所在房间 ID(可选)
|
||||
*/
|
||||
public function deductGoldIfEnough(
|
||||
User $user,
|
||||
int $amount,
|
||||
CurrencySource $source,
|
||||
string $remark = '',
|
||||
?int $roomId = null,
|
||||
): ?int {
|
||||
if ($amount <= 0) {
|
||||
return (int) $user->jjb;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $amount, $source, $remark, $roomId): ?int {
|
||||
// 付费查看属于真实消费,先锁定用户行再判断余额,避免并发点击透支金币。
|
||||
$lockedUser = User::query()
|
||||
->whereKey($user->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ((int) $lockedUser->jjb < $amount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 扣除流通金币后写入统一流水,方便后台统计与用户追溯消费来源。
|
||||
$lockedUser->decrement('jjb', $amount);
|
||||
$balanceAfter = (int) $lockedUser->fresh()->jjb;
|
||||
|
||||
UserCurrencyLog::create([
|
||||
'user_id' => $lockedUser->id,
|
||||
'username' => $lockedUser->username,
|
||||
'currency' => 'gold',
|
||||
'amount' => -$amount,
|
||||
'balance_after' => $balanceAfter,
|
||||
'source' => $source->value,
|
||||
'remark' => $remark,
|
||||
'room_id' => $roomId,
|
||||
]);
|
||||
|
||||
$user->setAttribute('jjb', $balanceAfter);
|
||||
|
||||
return $balanceAfter;
|
||||
}, attempts: 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
|
||||
* 每位用户仍独立走事务,单人失败不影响其他人。
|
||||
@@ -110,15 +167,75 @@ class UserCurrencyService
|
||||
*/
|
||||
public function activityStats(?string $date = null): Collection
|
||||
{
|
||||
$date = $date ?? today()->toDateString();
|
||||
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
|
||||
|
||||
return UserCurrencyLog::query()
|
||||
->whereDate('created_at', $date)
|
||||
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
|
||||
->groupBy('source', 'currency')
|
||||
->orderBy('currency')
|
||||
->orderByRaw('ABS(SUM(amount)) DESC')
|
||||
->get();
|
||||
return Cache::remember("currency_stats:activity:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
|
||||
return UserCurrencyLog::query()
|
||||
->where('created_at', '>=', $rangeStart)
|
||||
->where('created_at', '<', $rangeEnd)
|
||||
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
|
||||
->groupBy('source', 'currency')
|
||||
->orderBy('currency')
|
||||
->orderByRaw('ABS(SUM(amount)) DESC')
|
||||
->get();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某日三种货币的净流通量(流入、流出、净增)。
|
||||
*
|
||||
* @param string|null $date 日期字符串如 '2026-02-28',默认今日
|
||||
* @return array<string, array{in:int, out:int, net:int}>
|
||||
*/
|
||||
public function netFlowStats(?string $date = null): array
|
||||
{
|
||||
[$date, $rangeStart, $rangeEnd] = $this->statsDateBounds($date);
|
||||
|
||||
$rows = Cache::remember("currency_stats:net_flow:{$date}", 300, function () use ($rangeStart, $rangeEnd) {
|
||||
return UserCurrencyLog::query()
|
||||
->where('created_at', '>=', $rangeStart)
|
||||
->where('created_at', '<', $rangeEnd)
|
||||
->selectRaw('
|
||||
currency,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as total_in,
|
||||
ABS(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)) as total_out,
|
||||
SUM(amount) as net_total
|
||||
')
|
||||
->groupBy('currency')
|
||||
->get()
|
||||
->keyBy('currency');
|
||||
});
|
||||
|
||||
$netFlow = [];
|
||||
|
||||
foreach (['exp', 'gold', 'charm'] as $currency) {
|
||||
$row = $rows->get($currency);
|
||||
|
||||
$netFlow[$currency] = [
|
||||
'in' => (int) ($row->total_in ?? 0),
|
||||
'out' => (int) ($row->total_out ?? 0),
|
||||
'net' => (int) ($row->net_total ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $netFlow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析统计查询的日期边界,统一复用缓存 key 与时间范围。
|
||||
*
|
||||
* @param string|null $date 日期字符串如 '2026-02-28'
|
||||
* @return array{0:string, 1:CarbonImmutable, 2:CarbonImmutable}
|
||||
*/
|
||||
private function statsDateBounds(?string $date = null): array
|
||||
{
|
||||
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
|
||||
|
||||
return [
|
||||
$statsDate->toDateString(),
|
||||
$statsDate,
|
||||
$statsDate->addDay(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,12 +282,17 @@ class UserCurrencyService
|
||||
* @param int $userId 用户 ID
|
||||
* @param string|null $currency 为 null 时返回所有货币类型
|
||||
* @param int $days 查询最近多少天
|
||||
* @param string|null $direction income=收入 / expense=支出 / null=全部
|
||||
* @param array<int, string> $sources 来源 source 值列表,为空时不过滤
|
||||
*/
|
||||
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
|
||||
public function userLogs(int $userId, ?string $currency = null, int $days = 7, ?string $direction = null, array $sources = []): Collection
|
||||
{
|
||||
return UserCurrencyLog::query()
|
||||
->where('user_id', $userId)
|
||||
->when($currency, fn ($q) => $q->where('currency', $currency))
|
||||
->when($direction === 'income', fn ($q) => $q->where('amount', '>', 0))
|
||||
->when($direction === 'expense', fn ($q) => $q->where('amount', '<', 0))
|
||||
->when($sources !== [], fn ($q) => $q->whereIn('source', $sources))
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->orderByDesc('created_at')
|
||||
->limit(200)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室动态内容净化工具
|
||||
*
|
||||
* 统一处理用户输入在系统消息、公告与商城赠言中的 HTML 转义,避免动态文本注入到前端 innerHTML。
|
||||
*/
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 类功能:提供聊天室系统文案中用户可控字段的安全转义能力。
|
||||
*/
|
||||
class ChatContentSanitizer
|
||||
{
|
||||
/**
|
||||
* 将用户可控文本规整为可安全拼入受控 HTML 模板的字符串。
|
||||
*/
|
||||
public static function htmlText(?string $value): string
|
||||
{
|
||||
return e(trim((string) $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天室当日状态目录
|
||||
* 负责集中维护聊天室状态分组、文案、图标与激活状态解析逻辑。
|
||||
*/
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
class ChatDailyStatusCatalog
|
||||
{
|
||||
/**
|
||||
* 固定状态目录。
|
||||
*
|
||||
* @return array<int, array{group: string, items: array<int, array{key: string, label: string, icon: string}>}>
|
||||
*/
|
||||
public static function groupedOptions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'group' => '心情想法',
|
||||
'items' => [
|
||||
['key' => 'feeling_great', 'label' => '美滋滋', 'icon' => '🙂'],
|
||||
['key' => 'heartbroken', 'label' => '裂开', 'icon' => '💔'],
|
||||
['key' => 'lucky_fish', 'label' => '求锦鲤', 'icon' => '🐟'],
|
||||
['key' => 'waiting_sun', 'label' => '等天晴', 'icon' => '☀️'],
|
||||
['key' => 'tired', 'label' => '疲惫', 'icon' => '😮💨'],
|
||||
['key' => 'dazed', 'label' => '发呆', 'icon' => '💭'],
|
||||
['key' => 'charging', 'label' => '冲', 'icon' => '🚀'],
|
||||
['key' => 'emo', 'label' => 'emo', 'icon' => '🥀'],
|
||||
['key' => 'overthinking', 'label' => '胡思乱想', 'icon' => '☁️'],
|
||||
['key' => 'energetic', 'label' => '元气满满', 'icon' => '✨'],
|
||||
['key' => 'bot', 'label' => 'bot', 'icon' => '🤖'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => '工作学习',
|
||||
'items' => [
|
||||
['key' => 'working_hard', 'label' => '搬砖', 'icon' => '🧱'],
|
||||
['key' => 'studying', 'label' => '沉迷学习', 'icon' => '📚'],
|
||||
['key' => 'busy', 'label' => '忙', 'icon' => '🙆'],
|
||||
['key' => 'slacking', 'label' => '摸鱼', 'icon' => '🐟'],
|
||||
['key' => 'business_trip', 'label' => '出差', 'icon' => '✈️'],
|
||||
['key' => 'running_home', 'label' => '飞奔回家', 'icon' => '🏃'],
|
||||
['key' => 'do_not_disturb', 'label' => '勿扰模式', 'icon' => '🌙'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => '活动',
|
||||
'items' => [
|
||||
['key' => 'wandering', 'label' => '浪', 'icon' => '🌊'],
|
||||
['key' => 'clocking_in', 'label' => '打卡', 'icon' => '✌️'],
|
||||
['key' => 'exercising', 'label' => '运动', 'icon' => '🏃♂️'],
|
||||
['key' => 'coffee', 'label' => '喝咖啡', 'icon' => '☕'],
|
||||
['key' => 'milk_tea', 'label' => '喝奶茶', 'icon' => '🧋'],
|
||||
['key' => 'eating', 'label' => '干饭', 'icon' => '🍚'],
|
||||
['key' => 'parenting', 'label' => '带娃', 'icon' => '🧒'],
|
||||
['key' => 'save_world', 'label' => '拯救世界', 'icon' => '🦸'],
|
||||
['key' => 'selfie', 'label' => '自拍', 'icon' => '🤳'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'group' => '休息',
|
||||
'items' => [
|
||||
['key' => 'retreat', 'label' => '闭关', 'icon' => '🧘'],
|
||||
['key' => 'staying_home', 'label' => '宅', 'icon' => '🛋️'],
|
||||
['key' => 'sleeping', 'label' => '睡觉', 'icon' => '💤'],
|
||||
['key' => 'cat_time', 'label' => '吸猫', 'icon' => '🐈'],
|
||||
['key' => 'walk_dog', 'label' => '遛狗', 'icon' => '🐕'],
|
||||
['key' => 'gaming', 'label' => '玩游戏', 'icon' => '🎮'],
|
||||
['key' => 'listening_music', 'label' => '听歌', 'icon' => '🎧'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回全部合法状态键。
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (array $item): string => $item['key'],
|
||||
self::flatOptions()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据状态键查找对应配置。
|
||||
*
|
||||
* @return array{key: string, label: string, icon: string, group: string}|null
|
||||
*/
|
||||
public static function find(?string $key): ?array
|
||||
{
|
||||
if ($key === null || $key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::flatOptions() as $item) {
|
||||
if ($item['key'] === $key) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前仍处于有效期内的状态数据。
|
||||
*
|
||||
* @param CarbonInterface|string|null $expiresAt 到期时间
|
||||
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
|
||||
*/
|
||||
public static function resolveActiveStatus(?string $key, CarbonInterface|string|null $expiresAt): ?array
|
||||
{
|
||||
$option = self::find($key);
|
||||
if ($option === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiresAtCarbon = self::normalizeExpiresAt($expiresAt);
|
||||
if ($expiresAtCarbon === null || $expiresAtCarbon->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $option['key'],
|
||||
'label' => $option['label'],
|
||||
'icon' => $option['icon'],
|
||||
'group' => $option['group'],
|
||||
'expires_at' => $expiresAtCarbon->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分组目录整理为扁平列表,便于校验与查找。
|
||||
*
|
||||
* @return array<int, array{key: string, label: string, icon: string, group: string}>
|
||||
*/
|
||||
private static function flatOptions(): array
|
||||
{
|
||||
$flat = [];
|
||||
|
||||
foreach (self::groupedOptions() as $group) {
|
||||
foreach ($group['items'] as $item) {
|
||||
$flat[] = [
|
||||
'key' => $item['key'],
|
||||
'label' => $item['label'],
|
||||
'icon' => $item['icon'],
|
||||
'group' => $group['group'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整状态到期时间为 Carbon 对象。
|
||||
*
|
||||
* @param CarbonInterface|string|null $expiresAt 原始到期时间
|
||||
*/
|
||||
private static function normalizeExpiresAt(CarbonInterface|string|null $expiresAt): ?CarbonInterface
|
||||
{
|
||||
if ($expiresAt instanceof CarbonInterface) {
|
||||
return $expiresAt;
|
||||
}
|
||||
|
||||
if ($expiresAt === null || $expiresAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse($expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:职务权限码注册表
|
||||
* 统一维护聊天室顶部管理菜单可配置的全部权限码、
|
||||
* 中文标题、说明文案与默认分配规则,便于后续继续扩展。
|
||||
*/
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 类功能:集中定义职务权限元数据与默认权限策略。
|
||||
*/
|
||||
class PositionPermissionRegistry
|
||||
{
|
||||
/**
|
||||
* 房间公告权限。
|
||||
*/
|
||||
public const ROOM_ANNOUNCEMENT = 'room.announcement';
|
||||
|
||||
/**
|
||||
* 房间公屏讲话权限。
|
||||
*/
|
||||
public const ROOM_PUBLIC_BROADCAST = 'room.public_broadcast';
|
||||
|
||||
/**
|
||||
* 房间全员清屏权限。
|
||||
*/
|
||||
public const ROOM_CLEAR_SCREEN = 'room.clear_screen';
|
||||
|
||||
/**
|
||||
* 礼包红包权限。
|
||||
*/
|
||||
public const ROOM_RED_PACKET = 'room.red_packet';
|
||||
|
||||
/**
|
||||
* 百家乐买单活动权限。
|
||||
*/
|
||||
public const ROOM_BACCARAT_LOSS_COVER = 'room.baccarat_loss_cover';
|
||||
|
||||
/**
|
||||
* 全屏特效权限。
|
||||
*/
|
||||
public const ROOM_FULLSCREEN_EFFECT = 'room.fullscreen_effect';
|
||||
|
||||
/**
|
||||
* 职务奖励金币权限。
|
||||
*/
|
||||
public const ROOM_REWARD = 'room.reward';
|
||||
|
||||
/**
|
||||
* 用户警告权限。
|
||||
*/
|
||||
public const USER_WARN = 'room.user_warn';
|
||||
|
||||
/**
|
||||
* 用户踢出权限。
|
||||
*/
|
||||
public const USER_KICK = 'room.user_kick';
|
||||
|
||||
/**
|
||||
* 用户禁言权限。
|
||||
*/
|
||||
public const USER_MUTE = 'room.user_mute';
|
||||
|
||||
/**
|
||||
* 用户封号权限。
|
||||
*/
|
||||
public const USER_BAN = 'room.user_ban';
|
||||
|
||||
/**
|
||||
* 用户封IP权限。
|
||||
*/
|
||||
public const USER_BANIP = 'room.user_banip';
|
||||
|
||||
/**
|
||||
* 返回全部权限定义。
|
||||
*
|
||||
* @return array<string, array{group: string, label: string, description: string}>
|
||||
*/
|
||||
public static function definitions(): array
|
||||
{
|
||||
return [
|
||||
self::ROOM_ANNOUNCEMENT => [
|
||||
'group' => '聊天室管理',
|
||||
'label' => '设置公告',
|
||||
'description' => '允许修改聊天室顶部滚动公告。',
|
||||
],
|
||||
self::ROOM_PUBLIC_BROADCAST => [
|
||||
'group' => '聊天室管理',
|
||||
'label' => '公屏讲话',
|
||||
'description' => '允许在聊天室内发送管理员公屏讲话。',
|
||||
],
|
||||
self::ROOM_CLEAR_SCREEN => [
|
||||
'group' => '聊天室管理',
|
||||
'label' => '全员清屏',
|
||||
'description' => '允许清除当前房间所有人的普通聊天记录。',
|
||||
],
|
||||
self::ROOM_RED_PACKET => [
|
||||
'group' => '活动管理',
|
||||
'label' => '礼包红包',
|
||||
'description' => '允许在聊天室内发出金币或经验礼包。',
|
||||
],
|
||||
self::ROOM_BACCARAT_LOSS_COVER => [
|
||||
'group' => '活动管理',
|
||||
'label' => '买单活动',
|
||||
'description' => '允许创建和结束百家乐买单活动。',
|
||||
],
|
||||
self::ROOM_FULLSCREEN_EFFECT => [
|
||||
'group' => '全屏特效',
|
||||
'label' => '全屏特效',
|
||||
'description' => '允许触发聊天室内全部全屏动画特效。',
|
||||
],
|
||||
self::ROOM_REWARD => [
|
||||
'group' => '用户管理',
|
||||
'label' => '奖励金币',
|
||||
'description' => '允许在额度限制内向低位阶用户发放职务金币奖励。',
|
||||
],
|
||||
self::USER_WARN => [
|
||||
'group' => '用户管理',
|
||||
'label' => '警告用户',
|
||||
'description' => '允许在用户名片内对低于自身职务的用户发送警告。',
|
||||
],
|
||||
self::USER_KICK => [
|
||||
'group' => '用户管理',
|
||||
'label' => '踢出用户',
|
||||
'description' => '允许在用户名片内将低于自身职务的用户踢出当前聊天室。',
|
||||
],
|
||||
self::USER_MUTE => [
|
||||
'group' => '用户管理',
|
||||
'label' => '禁言用户',
|
||||
'description' => '允许在用户名片内对低于自身职务的用户执行禁言。',
|
||||
],
|
||||
self::USER_BAN => [
|
||||
'group' => '用户管理',
|
||||
'label' => '封号用户',
|
||||
'description' => '允许在用户名片内封禁低于自身职务的用户账号并强制下线。',
|
||||
],
|
||||
self::USER_BANIP => [
|
||||
'group' => '用户管理',
|
||||
'label' => '封IP',
|
||||
'description' => '允许在用户名片内封禁低于自身职务的用户 IP,并查看管理员网络信息。',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回全部权限码列表。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function codes(): array
|
||||
{
|
||||
return array_keys(self::definitions());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回权限码到中文标题的映射。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function labelMap(): array
|
||||
{
|
||||
$labels = [];
|
||||
|
||||
foreach (self::definitions() as $code => $definition) {
|
||||
$labels[$code] = $definition['label'];
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分组返回权限定义,供后台表单渲染。
|
||||
*
|
||||
* @return array<string, array<string, array{label: string, description: string}>>
|
||||
*/
|
||||
public static function groupedDefinitions(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach (self::definitions() as $code => $definition) {
|
||||
$grouped[$definition['group']][$code] = [
|
||||
'label' => $definition['label'],
|
||||
'description' => $definition['description'],
|
||||
];
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限码数组转换为中文标题列表。
|
||||
*
|
||||
* @param list<string> $codes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function summaryLabels(array $codes): array
|
||||
{
|
||||
$labels = self::labelMap();
|
||||
|
||||
return array_values(array_map(
|
||||
fn (string $code): string => $labels[$code] ?? $code,
|
||||
array_values(array_intersect(self::codes(), $codes))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据职务等级返回默认权限。
|
||||
*
|
||||
* 默认策略:
|
||||
* - Lv.60 及以上默认拥有「设置公告」
|
||||
* - Lv.97 及以上默认拥有顶部管理菜单全部权限
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function defaultPermissionsForLevel(int $level): array
|
||||
{
|
||||
if ($level >= 97) {
|
||||
return self::codes();
|
||||
}
|
||||
|
||||
if ($level >= 60) {
|
||||
return [self::ROOM_ANNOUNCEMENT];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到记录测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造用户签到流水和连续签到奖励快照。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DailySignIn;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<DailySignIn>
|
||||
*/
|
||||
class DailySignInFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认每日签到测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'room_id' => null,
|
||||
'sign_in_date' => today(),
|
||||
'streak_days' => 1,
|
||||
'reward_rule_id' => null,
|
||||
'gold_reward' => 0,
|
||||
'exp_reward' => 0,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => null,
|
||||
'identity_badge_name' => null,
|
||||
'identity_badge_icon' => null,
|
||||
'identity_badge_color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次测试工厂
|
||||
*
|
||||
* 用于测试中快速创建已触发的节日福利批次与历史快照。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<HolidayEventRun>
|
||||
*/
|
||||
class HolidayEventRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认批次测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$event = HolidayEvent::query()->create([
|
||||
'name' => '节日福利测试模板',
|
||||
'description' => '用于测试的节日福利模板',
|
||||
'total_amount' => 6000,
|
||||
'max_claimants' => 10,
|
||||
'distribute_type' => 'fixed',
|
||||
'min_amount' => 100,
|
||||
'max_amount' => 1000,
|
||||
'fixed_amount' => 600,
|
||||
'send_at' => now(),
|
||||
'expire_minutes' => 30,
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
'status' => 'pending',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'holiday_event_id' => $event->id,
|
||||
'event_name' => $event->name,
|
||||
'event_description' => $event->description,
|
||||
'total_amount' => $event->total_amount,
|
||||
'max_claimants' => $event->max_claimants,
|
||||
'distribute_type' => $event->distribute_type,
|
||||
'min_amount' => $event->min_amount,
|
||||
'max_amount' => $event->max_amount,
|
||||
'fixed_amount' => $event->fixed_amount,
|
||||
'target_type' => $event->target_type,
|
||||
'target_value' => $event->target_value,
|
||||
'repeat_type' => $event->repeat_type,
|
||||
'scheduled_for' => now(),
|
||||
'triggered_at' => now(),
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
'status' => 'active',
|
||||
'audience_count' => 0,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:每日签到奖励规则测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造不同连续天数门槛的签到奖励规则。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SignInRewardRule;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SignInRewardRule>
|
||||
*/
|
||||
class SignInRewardRuleFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认签到奖励规则测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'streak_days' => fake()->unique()->numberBetween(1, 30),
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 10,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => null,
|
||||
'identity_badge_name' => null,
|
||||
'identity_badge_icon' => null,
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 0,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户身份徽章测试工厂。
|
||||
*
|
||||
* 用于测试中快速构造签到来源或其他来源的身份徽章。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserIdentityBadge;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserIdentityBadge>
|
||||
*/
|
||||
class UserIdentityBadgeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认身份徽章测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'source' => UserIdentityBadge::SOURCE_SIGN_IN,
|
||||
'badge_code' => 'sign_in_beginner',
|
||||
'badge_name' => '签到新星',
|
||||
'badge_icon' => null,
|
||||
'badge_color' => '#0f766e',
|
||||
'acquired_at' => now(),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,6 @@ return new class extends Migration
|
||||
['alias' => 'jjb_per_heartbeat', 'body' => '1-3', 'guidetxt' => '💰 每次心跳金币奖励(支持固定值如"1"或范围如"1-5";0关闭)', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
// ── 管理操作权限等级 ──────────────────────────────────────
|
||||
['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
// ── 随机事件 ──────────────────────────────────────────────
|
||||
['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为职务表补充聊天室权限字段
|
||||
* 使用 JSON 数组保存可扩展的职务权限码,
|
||||
* 并为现有职务按等级回填一份默认权限。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 执行迁移:新增 permissions 字段并回填默认权限。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->json('permissions')->nullable()->comment('聊天室权限码 JSON 数组');
|
||||
});
|
||||
|
||||
$positions = DB::table('positions')->select(['id', 'level'])->get();
|
||||
|
||||
foreach ($positions as $position) {
|
||||
DB::table('positions')
|
||||
->where('id', $position->id)
|
||||
->update([
|
||||
'permissions' => json_encode(
|
||||
$this->defaultPermissionsForLevel((int) $position->level),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移:删除 permissions 字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->dropColumn('permissions');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前等级回填默认权限。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function defaultPermissionsForLevel(int $level): array
|
||||
{
|
||||
if ($level >= 97) {
|
||||
return [
|
||||
'room.announcement',
|
||||
'room.public_broadcast',
|
||||
'room.clear_screen',
|
||||
'room.red_packet',
|
||||
'room.baccarat_loss_cover',
|
||||
'room.fullscreen_effect',
|
||||
];
|
||||
}
|
||||
|
||||
if ($level >= 60) {
|
||||
return ['room.announcement'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次表迁移
|
||||
*
|
||||
* 将节日福利模板的每一次实际发放拆成独立批次,
|
||||
* 用于支持按年复用、连续多天、多轮次领取与历史追踪。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 holiday_event_runs 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('holiday_event_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('holiday_event_id')->comment('所属节日福利模板 ID');
|
||||
|
||||
// 批次快照:避免模板后续被修改后影响历史记录展示。
|
||||
$table->string('event_name', 100)->comment('活动名称快照');
|
||||
$table->text('event_description')->nullable()->comment('活动描述快照');
|
||||
$table->unsignedInteger('total_amount')->comment('总金币奖池快照');
|
||||
$table->unsignedInteger('max_claimants')->default(0)->comment('本批次最大领取人数(0=不限)');
|
||||
$table->enum('distribute_type', ['random', 'fixed'])->default('random')->comment('分配方式快照');
|
||||
$table->unsignedInteger('min_amount')->default(1)->comment('随机模式最低金额快照');
|
||||
$table->unsignedInteger('max_amount')->nullable()->comment('随机模式单人上限快照');
|
||||
$table->unsignedInteger('fixed_amount')->nullable()->comment('定额模式单人金额快照');
|
||||
$table->enum('target_type', ['all', 'vip', 'level'])->default('all')->comment('目标用户类型快照');
|
||||
$table->string('target_value', 50)->nullable()->comment('目标用户条件快照');
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])->default('once')->comment('模板重复方式快照');
|
||||
|
||||
// 批次状态与时间。
|
||||
$table->dateTime('scheduled_for')->comment('本批次原定触发时间');
|
||||
$table->dateTime('triggered_at')->comment('本批次实际触发时间');
|
||||
$table->dateTime('expires_at')->nullable()->comment('本批次领取截止时间');
|
||||
$table->enum('status', ['active', 'completed', 'expired', 'cancelled'])->default('active')->comment('批次状态');
|
||||
|
||||
// 统计信息。
|
||||
$table->unsignedInteger('audience_count')->default(0)->comment('本批次待领取总人数');
|
||||
$table->unsignedInteger('claimed_count')->default(0)->comment('本批次已领取人数');
|
||||
$table->unsignedInteger('claimed_amount')->default(0)->comment('本批次已领取金币总额');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('holiday_event_id')->references('id')->on('holiday_events')->cascadeOnDelete();
|
||||
$table->index(['status', 'expires_at']);
|
||||
$table->index(['holiday_event_id', 'scheduled_for']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_event_runs 表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('holiday_event_runs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利模板年度调度字段迁移
|
||||
*
|
||||
* 为节日福利模板补充按年复用、连续多天与单日多轮发送所需的调度字段。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 升级 holiday_events 表结构。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
$table->tinyInteger('schedule_month')->unsigned()->nullable()->after('repeat_type')->comment('年度节日触发月份');
|
||||
$table->tinyInteger('schedule_day')->unsigned()->nullable()->after('schedule_month')->comment('年度节日触发日');
|
||||
$table->string('schedule_time', 5)->nullable()->after('schedule_day')->comment('年度节日首轮开始时间(HH:ii)');
|
||||
$table->unsignedSmallInteger('duration_days')->default(1)->after('schedule_time')->comment('连续发放天数');
|
||||
$table->unsignedSmallInteger('daily_occurrences')->default(1)->after('duration_days')->comment('每天触发次数');
|
||||
$table->unsignedSmallInteger('occurrence_interval_minutes')->nullable()->after('daily_occurrences')->comment('同一天多轮发送间隔(分钟)');
|
||||
});
|
||||
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
// Laravel 12 / MySQL 变更列时需要显式保留原有默认值。
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])
|
||||
->default('once')
|
||||
->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_events 的年度调度字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron'])
|
||||
->default('once')
|
||||
->change();
|
||||
|
||||
$table->dropColumn([
|
||||
'schedule_month',
|
||||
'schedule_day',
|
||||
'schedule_time',
|
||||
'duration_days',
|
||||
'daily_occurrences',
|
||||
'occurrence_interval_minutes',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利领取记录批次化迁移
|
||||
*
|
||||
* 将领取记录与具体发放批次绑定,并为历史待领取数据补建 run 记录。
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 升级 holiday_claims 表并回填历史批次。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('run_id')->nullable()->after('event_id')->comment('关联发放批次 ID');
|
||||
$table->index('event_id');
|
||||
});
|
||||
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->dropUnique('uq_holiday_event_user');
|
||||
$table->dateTime('claimed_at')->nullable()->change();
|
||||
$table->foreign('run_id')->references('id')->on('holiday_event_runs')->cascadeOnDelete();
|
||||
$table->unique(['run_id', 'user_id'], 'uq_holiday_run_user');
|
||||
});
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$events = DB::table('holiday_events')
|
||||
->whereExists(function ($query) {
|
||||
$query->selectRaw('1')
|
||||
->from('holiday_claims')
|
||||
->whereColumn('holiday_claims.event_id', 'holiday_events.id');
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$audienceCount = (int) DB::table('holiday_claims')->where('event_id', $event->id)->count();
|
||||
$scheduledFor = $event->triggered_at ?? $event->send_at ?? $now->toDateTimeString();
|
||||
$expiresAt = $event->expires_at;
|
||||
|
||||
$runStatus = $expiresAt !== null && CarbonImmutable::parse($expiresAt)->isPast()
|
||||
? 'expired'
|
||||
: 'active';
|
||||
|
||||
$runId = DB::table('holiday_event_runs')->insertGetId([
|
||||
'holiday_event_id' => $event->id,
|
||||
'event_name' => $event->name,
|
||||
'event_description' => $event->description,
|
||||
'total_amount' => $event->total_amount,
|
||||
'max_claimants' => $event->max_claimants,
|
||||
'distribute_type' => $event->distribute_type,
|
||||
'min_amount' => $event->min_amount,
|
||||
'max_amount' => $event->max_amount,
|
||||
'fixed_amount' => $event->fixed_amount,
|
||||
'target_type' => $event->target_type,
|
||||
'target_value' => $event->target_value,
|
||||
'repeat_type' => $event->repeat_type,
|
||||
'scheduled_for' => $scheduledFor,
|
||||
'triggered_at' => $event->triggered_at ?? $scheduledFor,
|
||||
'expires_at' => $expiresAt,
|
||||
'status' => $runStatus,
|
||||
'audience_count' => $audienceCount,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 旧结构下表里剩余记录全部表示“尚未领取”,统一迁移为未领取状态。
|
||||
DB::table('holiday_claims')
|
||||
->where('event_id', $event->id)
|
||||
->update([
|
||||
'run_id' => $runId,
|
||||
'claimed_at' => null,
|
||||
]);
|
||||
|
||||
if (in_array($event->repeat_type, ['daily', 'weekly', 'monthly'], true) && $event->send_at !== null) {
|
||||
$currentSendAt = CarbonImmutable::parse($event->send_at);
|
||||
$nextSendAt = match ($event->repeat_type) {
|
||||
'daily' => $currentSendAt->addDay(),
|
||||
'weekly' => $currentSendAt->addWeek(),
|
||||
'monthly' => $currentSendAt->addMonth(),
|
||||
default => $currentSendAt,
|
||||
};
|
||||
|
||||
DB::table('holiday_events')
|
||||
->where('id', $event->id)
|
||||
->update([
|
||||
'send_at' => $nextSendAt->toDateTimeString(),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_claims 的批次化关联。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->dropUnique('uq_holiday_run_user');
|
||||
$table->dropForeign(['run_id']);
|
||||
$table->dateTime('claimed_at')->nullable(false)->change();
|
||||
$table->dropColumn('run_id');
|
||||
$table->dropIndex(['event_id']);
|
||||
$table->unique(['event_id', 'user_id'], 'uq_holiday_event_user');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为 users 表新增聊天室当日状态字段
|
||||
* 支持记录用户当天选择的状态键与到期时间。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 为 users 表补充聊天室当日状态字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('daily_status_key', 50)
|
||||
->nullable()
|
||||
->after('chat_preferences')
|
||||
->comment('聊天室当日状态键');
|
||||
$table->dateTime('daily_status_expires_at')
|
||||
->nullable()
|
||||
->after('daily_status_key')
|
||||
->comment('聊天室当日状态到期时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 users 表中的聊天室当日状态字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'daily_status_key',
|
||||
'daily_status_expires_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建每日签到奖励规则表。
|
||||
*
|
||||
* 规则按连续签到天数配置奖励,签到服务会匹配小于等于当前连续天数的最高启用规则。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 sign_in_reward_rules 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建每日签到奖励规则表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sign_in_reward_rules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('streak_days')->unique()->comment('连续签到天数门槛');
|
||||
$table->unsignedInteger('gold_reward')->default(0)->comment('金币奖励');
|
||||
$table->unsignedInteger('exp_reward')->default(0)->comment('经验奖励');
|
||||
$table->unsignedInteger('charm_reward')->default(0)->comment('魅力奖励');
|
||||
$table->string('identity_badge_code', 50)->nullable()->comment('身份徽章编码');
|
||||
$table->string('identity_badge_name', 50)->nullable()->comment('身份徽章名称');
|
||||
$table->string('identity_badge_icon', 120)->nullable()->comment('身份徽章图标');
|
||||
$table->string('identity_badge_color', 20)->nullable()->comment('身份徽章文字颜色');
|
||||
$table->unsignedSmallInteger('identity_duration_days')->default(0)->comment('身份有效天数,0表示永久');
|
||||
$table->boolean('is_enabled')->default(true)->index()->comment('是否启用');
|
||||
$table->unsignedInteger('sort_order')->default(0)->index()->comment('后台排序值');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_enabled', 'streak_days'], 'idx_sign_in_rules_enabled_streak');
|
||||
});
|
||||
|
||||
DB::table('sign_in_reward_rules')->insert($this->defaultRules());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除每日签到奖励规则表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sign_in_reward_rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认连续签到奖励规则。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function defaultRules(): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
return [
|
||||
[
|
||||
'streak_days' => 1,
|
||||
'gold_reward' => 100,
|
||||
'exp_reward' => 10,
|
||||
'charm_reward' => 0,
|
||||
'identity_badge_code' => 'sign_in_new_star',
|
||||
'identity_badge_name' => '签到新星',
|
||||
'identity_badge_icon' => '✅',
|
||||
'identity_badge_color' => '#0f766e',
|
||||
'identity_duration_days' => 7,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 3,
|
||||
'gold_reward' => 180,
|
||||
'exp_reward' => 18,
|
||||
'charm_reward' => 1,
|
||||
'identity_badge_code' => 'sign_in_spark',
|
||||
'identity_badge_name' => '三日星火',
|
||||
'identity_badge_icon' => '✨',
|
||||
'identity_badge_color' => '#2563eb',
|
||||
'identity_duration_days' => 10,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 3,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 7,
|
||||
'gold_reward' => 350,
|
||||
'exp_reward' => 35,
|
||||
'charm_reward' => 3,
|
||||
'identity_badge_code' => 'sign_in_flame',
|
||||
'identity_badge_name' => '七日恒星',
|
||||
'identity_badge_icon' => '🔥',
|
||||
'identity_badge_color' => '#dc2626',
|
||||
'identity_duration_days' => 30,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 7,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 14,
|
||||
'gold_reward' => 600,
|
||||
'exp_reward' => 60,
|
||||
'charm_reward' => 5,
|
||||
'identity_badge_code' => 'sign_in_moon_guard',
|
||||
'identity_badge_name' => '半月守望',
|
||||
'identity_badge_icon' => '🌙',
|
||||
'identity_badge_color' => '#7c3aed',
|
||||
'identity_duration_days' => 45,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 14,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 30,
|
||||
'gold_reward' => 1200,
|
||||
'exp_reward' => 120,
|
||||
'charm_reward' => 8,
|
||||
'identity_badge_code' => 'sign_in_month_master',
|
||||
'identity_badge_name' => '月签达人',
|
||||
'identity_badge_icon' => '🏆',
|
||||
'identity_badge_color' => '#ca8a04',
|
||||
'identity_duration_days' => 60,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 30,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 60,
|
||||
'gold_reward' => 2200,
|
||||
'exp_reward' => 220,
|
||||
'charm_reward' => 14,
|
||||
'identity_badge_code' => 'sign_in_diamond',
|
||||
'identity_badge_name' => '双月钻冕',
|
||||
'identity_badge_icon' => '💎',
|
||||
'identity_badge_color' => '#0891b2',
|
||||
'identity_duration_days' => 90,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 60,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 100,
|
||||
'gold_reward' => 4000,
|
||||
'exp_reward' => 400,
|
||||
'charm_reward' => 25,
|
||||
'identity_badge_code' => 'sign_in_legend',
|
||||
'identity_badge_name' => '百日传奇',
|
||||
'identity_badge_icon' => '👑',
|
||||
'identity_badge_color' => '#9333ea',
|
||||
'identity_duration_days' => 180,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 100,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'streak_days' => 365,
|
||||
'gold_reward' => 12000,
|
||||
'exp_reward' => 1200,
|
||||
'charm_reward' => 100,
|
||||
'identity_badge_code' => 'sign_in_year_guardian',
|
||||
'identity_badge_name' => '周年守护者',
|
||||
'identity_badge_icon' => '🌟',
|
||||
'identity_badge_color' => '#e11d48',
|
||||
'identity_duration_days' => 0,
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 365,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建用户身份徽章表。
|
||||
*
|
||||
* 用于记录用户通过签到等来源获得的身份展示信息,当前阶段只由签到服务刷新 sign_in 来源。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 user_identity_badges 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建用户身份徽章表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_identity_badges', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||
$table->string('source', 30)->default('sign_in')->comment('徽章来源');
|
||||
$table->string('badge_code', 50)->comment('徽章编码');
|
||||
$table->string('badge_name', 50)->comment('徽章名称');
|
||||
$table->string('badge_icon', 120)->nullable()->comment('徽章图标');
|
||||
$table->string('badge_color', 20)->nullable()->comment('徽章文字颜色');
|
||||
$table->timestamp('acquired_at')->nullable()->comment('获得时间');
|
||||
$table->timestamp('expires_at')->nullable()->index()->comment('过期时间,空表示长期有效');
|
||||
$table->boolean('is_active')->default(true)->index()->comment('是否当前启用');
|
||||
$table->json('metadata')->nullable()->comment('扩展信息');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'source', 'badge_code'], 'uniq_user_identity_badge');
|
||||
$table->index(['user_id', 'source', 'is_active'], 'idx_user_identity_badges_current');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除用户身份徽章表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_identity_badges');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建每日签到记录表。
|
||||
*
|
||||
* 每个用户每天只能签到一次,并保存当次连续天数与实际发放奖励快照。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 daily_sign_ins 表结构与回滚逻辑。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建每日签到记录表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('daily_sign_ins', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||
$table->unsignedBigInteger('room_id')->nullable()->index()->comment('签到所在房间ID');
|
||||
$table->date('sign_in_date')->comment('签到日期');
|
||||
$table->unsignedInteger('streak_days')->comment('连续签到天数');
|
||||
$table->foreignId('reward_rule_id')->nullable()->constrained('sign_in_reward_rules')->nullOnDelete()->comment('命中奖励规则ID');
|
||||
$table->unsignedInteger('gold_reward')->default(0)->comment('实际发放金币');
|
||||
$table->unsignedInteger('exp_reward')->default(0)->comment('实际发放经验');
|
||||
$table->unsignedInteger('charm_reward')->default(0)->comment('实际发放魅力');
|
||||
$table->string('identity_badge_code', 50)->nullable()->comment('本次刷新身份徽章编码');
|
||||
$table->string('identity_badge_name', 50)->nullable()->comment('本次刷新身份徽章名称');
|
||||
$table->string('identity_badge_icon', 120)->nullable()->comment('本次刷新身份徽章图标');
|
||||
$table->string('identity_badge_color', 20)->nullable()->comment('本次刷新身份徽章颜色');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'sign_in_date'], 'uniq_user_daily_sign_in');
|
||||
$table->index(['sign_in_date', 'streak_days'], 'idx_daily_sign_ins_date_streak');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除每日签到记录表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('daily_sign_ins');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为每日签到记录补充补签来源字段。
|
||||
*
|
||||
* 记录补签卡消耗的购买记录与补签时间,便于日历区分正常签到和补签。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:新增补签标记、补签卡购买记录和补签完成时间。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('daily_sign_ins', function (Blueprint $table): void {
|
||||
$table->boolean('is_makeup')->default(false)->after('room_id')->comment('是否通过补签卡补签');
|
||||
$table->foreignId('makeup_purchase_id')->nullable()->after('is_makeup')->constrained('user_purchases')->nullOnDelete()->comment('消耗的补签卡购买记录ID');
|
||||
$table->timestamp('makeup_at')->nullable()->after('makeup_purchase_id')->comment('补签完成时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚补签字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('daily_sign_ins', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('makeup_purchase_id');
|
||||
$table->dropColumn(['is_makeup', 'makeup_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为商店商品类型加入签到补签卡。
|
||||
*
|
||||
* 允许后台商店配置补签卡,并提供一条默认上架商品。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:扩展商品类型枚举并写入默认补签卡。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
|
||||
DB::table('shop_items')->updateOrInsert(
|
||||
['slug' => 'sign_repair_card'],
|
||||
[
|
||||
'name' => '补签卡',
|
||||
'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。',
|
||||
'icon' => '🗓️',
|
||||
'price' => 10000,
|
||||
'type' => 'sign_repair',
|
||||
'duration_days' => 0,
|
||||
'duration_minutes' => 0,
|
||||
'intimacy_bonus' => 0,
|
||||
'charm_bonus' => 0,
|
||||
'sort_order' => 35,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚补签卡商品类型。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('shop_items')->where('slug', 'sign_repair_card')->delete();
|
||||
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'sign_repair'");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为职务表增加礼包红包默认配置。
|
||||
*
|
||||
* 每个职务可独立设置礼包红包总量和份数,发金币礼包与经验礼包时共用这组配置。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 类功能:维护 positions 表的礼包红包默认总量与份数字段。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:新增职务礼包红包总量和份数字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->unsignedInteger('red_packet_amount')
|
||||
->default(8888)
|
||||
->after('permissions')
|
||||
->comment('礼包红包默认总量');
|
||||
$table->unsignedSmallInteger('red_packet_count')
|
||||
->default(10)
|
||||
->after('red_packet_amount')
|
||||
->comment('礼包红包默认份数');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:删除职务礼包红包配置字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('positions', function (Blueprint $table) {
|
||||
$table->dropColumn(['red_packet_amount', 'red_packet_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:清理已废弃的等级阈值聊天室管理权限参数
|
||||
* 统一删除 level_* 权限残留,避免继续与职务权限体系并存。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:删除旧等级权限参数。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('sysparam')
|
||||
->whereIn('alias', [
|
||||
'level_warn',
|
||||
'level_mute',
|
||||
'level_kick',
|
||||
'level_announcement',
|
||||
'level_ban',
|
||||
'level_banip',
|
||||
'level_freeze',
|
||||
])
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚时恢复旧等级权限参数默认值。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
foreach ([
|
||||
'level_warn' => ['5', '警告所需等级'],
|
||||
'level_mute' => ['50', '禁言所需等级'],
|
||||
'level_kick' => ['60', '踢人所需等级'],
|
||||
'level_announcement' => ['60', '设置公告所需等级'],
|
||||
'level_ban' => ['80', '封号所需等级'],
|
||||
'level_banip' => ['90', '封IP所需等级'],
|
||||
'level_freeze' => ['14', '冻结账号所需等级'],
|
||||
] as $alias => [$body, $guidetxt]) {
|
||||
DB::table('sysparam')->updateOrInsert(
|
||||
['alias' => $alias],
|
||||
[
|
||||
'body' => $body,
|
||||
'guidetxt' => $guidetxt,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:将旧的冻结权限合并到封号权限
|
||||
* 确保已配置 room.user_freeze 的职务在合并后自动继承 room.user_ban。
|
||||
*/
|
||||
|
||||
use App\Models\Position;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 方法功能:把冻结权限迁移为封号权限。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Position::query()->each(function (Position $position): void {
|
||||
$permissions = array_values($position->permissions ?? []);
|
||||
|
||||
if (! in_array('room.user_freeze', $permissions, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedPermissions = array_values(array_unique(array_map(
|
||||
fn (string $permission): string => $permission === 'room.user_freeze' ? 'room.user_ban' : $permission,
|
||||
$permissions,
|
||||
)));
|
||||
|
||||
$position->forceFill([
|
||||
'permissions' => $normalizedPermissions,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:回滚时把合并后的封号权限恢复为冻结权限。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Position::query()->each(function (Position $position): void {
|
||||
$permissions = array_values($position->permissions ?? []);
|
||||
|
||||
if (! in_array('room.user_ban', $permissions, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedPermissions = array_values(array_unique(array_map(
|
||||
fn (string $permission): string => $permission === 'room.user_ban' ? 'room.user_freeze' : $permission,
|
||||
$permissions,
|
||||
)));
|
||||
|
||||
$position->forceFill([
|
||||
'permissions' => $normalizedPermissions,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户表增加 active_decorations JSON 列
|
||||
* 用于存储用户当前激活的个人装扮信息(气泡/昵称颜色/头像框)及过期时间。
|
||||
*
|
||||
* JSON 结构示例:
|
||||
* {
|
||||
* "bubble": {"style": "msg_bubble_golden", "expires_at": "2026-05-04T12:00:00+08:00"},
|
||||
* "name_color": {"style": "msg_name_rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
|
||||
* "avatar_frame":{"style": "avatar_frame_dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
|
||||
* }
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 新增 active_decorations 列(可空 JSON)。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('active_decorations')->nullable()
|
||||
->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除 active_decorations 列。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('active_decorations');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为店铺商品类型加入个人装扮(消息气泡、昵称颜色、头像框)。
|
||||
*
|
||||
* 支持用户在商店购买有期限的个人装扮道具,购买后自动激活并在聊天和用户列表中展示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 扩展 shop_items.type ENUM,加入三种装扮类型。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:移除装扮类型值,将已有装扮商品标记为 one_time。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
// 将已有的装扮商品类型回退为 one_time,避免 ENUM 收缩报错
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` IN ('msg_bubble','msg_name_color','avatar_frame')");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为店铺商品类型加入消息文字颜色特效装扮(msg_text_color)。
|
||||
*
|
||||
* 用户在商店购买文字颜色特效后,消息正文呈现动态色彩效果(七彩、流光、霓虹等)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 扩展 shop_items.type ENUM,加入 msg_text_color 类型。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:移除 msg_text_color,将已有该类型商品标记为 one_time。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'msg_text_color'");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,26 +2,21 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 类功能:统一调度项目基础数据填充器。
|
||||
*/
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
* 方法功能:执行系统参数和默认房间等基础数据填充。
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
// User::factory()->create([
|
||||
// 'name' => 'Test User',
|
||||
// 'email' => 'test@example.com',
|
||||
// ]);
|
||||
|
||||
$this->call([
|
||||
SysParamSeeder::class,
|
||||
RoomSeeder::class,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user