55 Commits

Author SHA1 Message Date
pllx 01a8e116a3 针对日志并发问题,在自动存点中增加Redis分布式冷却锁,杜绝进程重复触发导致经验暴涨,完善单元测试 2026-07-06 22:43:32 +08:00
pllx a20bcf13be 修复自动存点挂机经验重复累加和离线僵尸吃经验的漏洞,完善对应单元测试 2026-07-06 22:39:20 +08:00
pllx 7b46a08a46 fix: 修复安全弹窗桥接后设置面板无法关闭的Bug 2026-07-01 11:27:40 +08:00
pllx 0006b7bcf6 feat: 登录大厅未设邮箱且未绑微信强引导弹窗并适配会话级防骚扰 2026-07-01 11:24:30 +08:00
pllx 563ac99348 feat: 忘记密码增设局部焦点提示及发信成功30秒防刷置灰倒计时锁 2026-07-01 11:16:09 +08:00
pllx 94236e25eb feat: 忘记密码增设脱敏邮箱二次手动输入一致性核对安全锁 2026-07-01 11:13:46 +08:00
pllx b098639db5 feat: 忘记密码升级为智能分流向导并部署IP/账号双层防扫防轰炸限流保护 2026-07-01 11:11:20 +08:00
pllx 879dcb0b59 feat: 微信端私聊机器人支持绑定微信号的密码自助重置功能,并提供反射自动化测试 2026-07-01 10:53:24 +08:00
pllx fcc88ecb0c fix: 豁免超级管理员(站长)在执行管理命令时必须在房间内的限制 2026-07-01 09:53:49 +08:00
pllx e8d9de51f8 fix: 对 AI小班长 等系统虚拟角色豁免警告时的在线状态校验 2026-07-01 09:53:14 +08:00
pllx b19ebdc7d8 style: 优化前台警告消息样式,标红、加大并加粗以提升视觉张力 2026-07-01 09:52:04 +08:00
pllx d8cb75d282 docs: 完善部署说明文档,补充 Octane 代理说明及 WebSocket 协议升级避坑指南 2026-06-30 14:20:55 +08:00
pllx 9fd7b14ec7 fix: 完全还原大厅页面,只保留导航栏作为百家乐的直达入口 2026-06-30 14:14:21 +08:00
pllx fc28fe37c6 fix: 移除大厅页面上多余的非百家乐操作按钮 2026-06-30 14:13:37 +08:00
pllx 524c8ed6f3 feat: 新建前台百家乐历史走势页面与珠盘路,打通大厅与导航栏快捷入口 2026-06-30 14:12:45 +08:00
pllx 394e216b92 dev: 添加 chokidar 依赖以支持开发环境下的 Octane 代码热重载(watch) 2026-06-30 13:38:46 +08:00
pllx b8a6c9d0c2 perf: 编写并执行数据库迁移,为 messages 表添加 (room_id, sent_at) 联合索引以消除慢查询 2026-06-30 11:38:38 +08:00
pllx be1406fc58 perf: 引入 laravel/octane 并安装常驻服务 Roadrunner 引擎,消除每次请求框架初始化开销 2026-06-30 11:38:35 +08:00
pllx 83192ffcce perf: 重构敏感词过滤为DFA算法,并引入WebSocket房间频道鉴权Redis缓存 2026-06-30 11:32:55 +08:00
pllx b19073cf85 perf: 优化挂机心跳接口,无奖励变动心跳拒绝写库save,仅在Redis保活以释放I/O压力 2026-06-30 11:32:53 +08:00
pllx 3563b45038 feat: 引入事务悲观锁(lockForUpdate)与余额二次校验,防范高并发资产越权与透支漏洞 2026-06-30 11:32:52 +08:00
pllx d1409d16bb 修复:自动存点导致有职务用户等级反复升降的循环问题
修复 AutoSaveExp 自动存点指令中,有职务用户的等级处理逻辑:
- 职务等级改为最低要求(不足时补级),不再强制锁定
- 允许职务成员通过经验值超越职务等级
- 与 ChatController::calculateNewLevel 保持一致

之前 AutoSaveExp 每5分钟把蔚蓝的心情(LV.91/金牌聊务员)
强制降到90级,心跳60秒后又升回91并触发升级广播,
导致用户反复看到"升级到91级"的提示。
2026-06-27 17:54:38 +08:00
pllx 2ea84ed93e 社交操作补充 user_currency_logs 流水记录
- sendFlower 送鲜花:发送方 gold 用 SEND_GIFT 记录,接收方 charm 用 RECV_GIFT 记录
- giftGold 赠送金币:发送方用 GIFT_SENT 记录,接收方用 GOLD_GIFT_RECV 记录
- grantChatCharm 聊天魅力奖励:用 CHAT_CHARM 记录发送方魅力值变动
- CurrencySource 新增 GOLD_GIFT_RECV、CHAT_CHARM 两个来源枚举
2026-05-28 13:18:52 +08:00
pllx dce0a52750 银行存取补充 user_currency_logs 流水记录
- CurrencySource 新增 BANK_DEPOSIT / BANK_WITHDRAW 两个来源枚举
- BankController deposit/withdraw 改用 UserCurrencyService::change() 记录 jjb 变动
- AiFinanceService bankExcessGold/raiseWalletTo 补充 UserCurrencyService::change() 调用
- 银行存取双轨记录:bank_logs(银行流水)+ user_currency_logs(金币流水)
2026-05-28 13:13:34 +08:00
pllx ce6d71a4f2 优化 emoji 浏览器兼容 2026-05-20 12:01:06 +08:00
pllx a0a189867f 优化 emoji 浏览器兼容 2026-05-20 11:52:49 +08:00
pllx 3c7cefe447 新增曾用名 2026-05-19 18:01:47 +08:00
pllx 74e4803bc2 修复手机端欢迎语弹窗位置 2026-05-12 19:09:23 +08:00
pllx b8feab34a6 优化聊天室手机管理菜单和欢迎语 2026-05-12 19:05:45 +08:00
pllx 0c9e7baca2 精简私信记录时间显示 2026-05-09 11:28:19 +08:00
pllx da0846c7ab 恢复用户卡片私信查看 2026-05-09 11:25:28 +08:00
pllx 8c1b0b0840 修复节日福利过期补发 2026-05-09 11:14:55 +08:00
pllx 1b062f67ea 增加每日游戏三杰榜 2026-05-09 10:23:38 +08:00
pllx 41522393de 缩短弹窗通知显示时间 2026-05-05 22:03:18 +08:00
pllx 645fe2a830 调整手机端弹窗通知规则 2026-05-05 21:57:24 +08:00
pllx 64945a973e 统一会话失效接口响应 2026-05-05 21:55:48 +08:00
pllx 725a38eac3 修复礼包领取弹窗重复显示 2026-05-05 21:48:51 +08:00
pllx 11a882bd8e 优化右下角弹窗交互 2026-05-05 21:48:36 +08:00
pllx a65827c5d9 优化部署脚本无更新时跳过执行 2026-05-04 18:25:31 +08:00
pllx 9b993e487c 优化部署脚本跳过无关构建迁移 2026-05-04 18:23:54 +08:00
pllx 6225a0fb45 优化定时任务调度耗时 2026-05-04 18:18:35 +08:00
pllx b3eebd286e 成就定时扫描增加解锁通知 2026-05-04 17:59:48 +08:00
pllx fdd4f8a179 修复猜谜双题答题状态误同步 2026-04-30 16:49:25 +08:00
pllx 82dbc19319 优化用户资料卡成就摘要查询 2026-04-30 16:45:46 +08:00
pllx ffd8789e67 修复礼包红包弹窗状态提示异常 2026-04-30 16:27:37 +08:00
pllx ee525f049e 修复礼包红包弹窗领取展示残留 2026-04-30 16:23:37 +08:00
pllx f354516869 新增聊天室成就系统与消息保留策略 2026-04-30 16:19:49 +08:00
pllx 92e3dd0cdf 红包领取增加全员通知 2026-04-30 15:52:16 +08:00
pllx 9764961519 优化游戏通知 2026-04-30 15:41:50 +08:00
pllx 4af4468fc4 修复滚屏状态导入缺失 2026-04-30 15:25:54 +08:00
pllx a6e50c36d7 优化红包 2026-04-30 15:21:58 +08:00
pllx b21f583fe5 修复聊天室滚屏开关失效 2026-04-30 15:19:38 +08:00
pllx 8c7b1086ff 修复跑马通知屏蔽识别 2026-04-30 15:07:09 +08:00
pllx 59a417bd10 补充座驾购买流水筛选 2026-04-30 13:27:21 +08:00
pllx 0fe003a773 新增座驾购买公屏通知 2026-04-30 11:27:53 +08:00
103 changed files with 7629 additions and 658 deletions
+4
View File
@@ -17,6 +17,8 @@
/.agents /.agents
/.codex /.codex
/.hermes /.hermes
/.reasonix
/.understand-anything
/auth.json /auth.json
/node_modules /node_modules
/public/build /public/build
@@ -36,3 +38,5 @@ dump.rdb
# AI 生成文件 # AI 生成文件
AGENTS.md AGENTS.md
GEMINI.md GEMINI.md
rr
.rr.yaml
+70 -44
View File
@@ -186,52 +186,66 @@ php artisan view:cache
### 6. Nginx 配置 ### 6. Nginx 配置
在宝塔面板的 Nginx 配置中,需要添加 WebSocket 反向代理。 为了让外界能够通过 HTTPS 正常访问常驻内存服务器(Roadrunner 8000端口)以及实时 WebSocket 广播(Reverb 8080端口),需要在宝塔面板的 Nginx 配置中进行反向代理。
**第一步:** `server {}`**外面上方**添加 `server {}` 块**内部**`#REWRITE-END` 之后,禁止访问敏感文件之前)添加以下配置
```nginx ```nginx
map $http_upgrade $connection_upgrade { # ── 1. 静态资源优先由 Nginx 直接响应,动态请求分流给 Octane ──
default upgrade; location / {
'' close; try_files $uri $uri/ @octane;
} }
# ── 2. API、心跳等动态请求反向代理到本地 8000 端口 (Octane / Roadrunner) ──
location @octane {
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8000;
}
# ── 3. ⚡ WebSocket 实时广播反向代理 (Laravel Reverb 8080端口) ──
# 浏览器通过 /app 和 /apps 路径发起 WebSocket 连接
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
# 避坑提示:某些宝塔环境全局未配置 $connection_upgrade 变量,
# 直接写死 "Upgrade" 能 100% 避免 500 代理握手失败。
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 长连接保活:120秒无数据才断开
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
location /apps {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
``` ```
**第二步:** 在 `server {}` 块**内部**`#REWRITE-END` 之后)添加: **测试并重载 Nginx**
```nginx
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
location /apps {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
```
**第三步:** 在宝塔面板 → 网站 → 伪静态中选择 `laravel5`
**第四步:** 测试并重载 Nginx:
```bash ```bash
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
@@ -241,7 +255,7 @@ nginx -t && systemctl reload nginx
### 7. Supervisor 守护进程(关键!) ### 7. Supervisor 守护进程(关键!)
在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加个守护进程: 在宝塔面板 → 软件商店 → 安装「Supervisor管理器」,然后添加个守护进程,确保长连接和心跳在后台持续运行
#### 进程一:Laravel ReverbWebSocket 服务器) #### 进程一:Laravel ReverbWebSocket 服务器)
@@ -250,6 +264,7 @@ nginx -t && systemctl reload nginx
| 名称 | `chatroom-reverb` | | 名称 | `chatroom-reverb` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` | | 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan reverb:start` | | 启动命令 | `php artisan reverb:start` |
| 运行用户 | `www` |
| 进程数量 | `1` | | 进程数量 | `1` |
#### 进程二:Laravel Horizon(队列处理器) #### 进程二:Laravel Horizon(队列处理器)
@@ -259,12 +274,23 @@ nginx -t && systemctl reload nginx
| 名称 | `chatroom-horizon` | | 名称 | `chatroom-horizon` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` | | 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan horizon` | | 启动命令 | `php artisan horizon` |
| 运行用户 | `www` |
| 进程数量 | `1` | | 进程数量 | `1` |
#### 进程三:Laravel OctaneRoadrunner 常驻内存服务)
| 配置项 | 值 |
| -------- | ---------------------------------------------------------- |
| 名称 | `chatroom-octane` |
| 运行目录 | `/www/wwwroot/chat.ay.lc` |
| 启动命令 | `php artisan octane:start --server=roadrunner --port=8000` |
| 运行用户 | `www` |
| 进程数量 | `1` |
> ⚠️ **如果不配置 Supervisor** > ⚠️ **如果不配置 Supervisor**
> >
> - 关闭 SSH 终端后,ReverbHorizon 会立刻停止 > - 关闭 SSH 终端后,ReverbHorizon 和 Octane 会立刻停止运行,导致聊天室无法打开或无法收发消息!
> - 聊天室将无法发送/接收消息,在线列表为空 > - **更新代码后**:当您在服务器上完成 `git pull` 后,需要运行 `php artisan octane:reload` 重新载入内存,代码才会生效。
--- ---
+80 -33
View File
@@ -16,9 +16,11 @@ namespace App\Console\Commands;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Jobs\AiFishingJob;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\Autoact; use App\Models\Autoact;
use App\Models\DailySignIn; use App\Models\DailySignIn;
use App\Models\GameConfig;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\AiFinanceService; use App\Services\AiFinanceService;
@@ -61,11 +63,15 @@ class AiHeartbeatCommand extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$startedAt = microtime(true);
// 1. 检查总开关 // 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') { if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS; return Command::SUCCESS;
} }
$config = $this->heartbeatConfig();
// 2. 获取 AI 实体 // 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first(); $user = User::where('username', 'AI小班长')->first();
if (! $user) { if (! $user) {
@@ -73,21 +79,26 @@ class AiHeartbeatCommand extends Command
} }
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。 // 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
$this->aiFinance->bankExcessGold($user); if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$this->aiFinance->bankExcessGold($user);
}
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖) // 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
$this->performDailySignIn($user); if ($this->performDailySignIn($user)) {
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
$user->refresh();
}
// 3. 常规心跳经验与金币发放 // 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励) // (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1')); $expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
if ($expGain > 0) { if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user); $expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier); $actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain; $user->exp_num += $actualExpGain;
} }
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0')); $jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
if ($jjbGain > 0) { if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user); $jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier); $actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
@@ -95,30 +106,35 @@ class AiHeartbeatCommand extends Command
} }
$user->save(); $user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级) // 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100'); $superLevel = (int) $config['superlevel'];
$leveledUp = $this->calculateNewLevel($user, $superLevel); $leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发 // 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10'); $eventChance = (int) $config['auto_event_chance'];
if ($eventChance > 0 && rand(1, 100) <= $eventChance) { if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent(); $autoEvent = Autoact::randomEvent();
if ($autoEvent) { if ($autoEvent) {
$hasCurrencyChange = false;
// 执行随机事件的金钱经验惩奖 // 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) { if ($autoEvent->exp_change !== 0) {
$this->currencyService->change( $this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 $user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
); );
$hasCurrencyChange = true;
} }
if ($autoEvent->jjb_change !== 0) { if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change( $this->currencyService->change(
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1 $user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
); );
$hasCurrencyChange = true;
} }
$user->refresh(); if ($hasCurrencyChange) {
$user->refresh();
}
// 重新计算等级 // 重新计算等级
if ($this->calculateNewLevel($user, $superLevel)) { if ($this->calculateNewLevel($user, $superLevel)) {
@@ -149,39 +165,68 @@ class AiHeartbeatCommand extends Command
} }
// 7. 钓鱼小游戏随机参与逻辑 // 7. 钓鱼小游戏随机参与逻辑
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1'; $fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率 $fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) { if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); $fishingConfig = GameConfig::forGame('fishing');
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
// 模拟玩家等待时间 if ($fishingConfig?->enabled) {
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); $cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); // 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
$waitTime = rand($waitMin, $waitMax); if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
1,
);
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids // 模拟玩家等待时间
$activeRoomIds = $this->chatState->getAllActiveRoomIds(); $waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; $waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
$waitTime = rand($waitMin, $waitMax);
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
}
} }
} }
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。 // 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
$this->aiFinance->bankExcessGold($user); if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
$this->aiFinance->bankExcessGold($user);
}
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
$this->info("AI心跳完成,耗时 {$elapsedMs}ms。");
return Command::SUCCESS; return Command::SUCCESS;
} }
/**
* 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。
*
* @return array<string, string>
*/
private function heartbeatConfig(): array
{
return [
'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'),
'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'),
'superlevel' => Sysparam::getValue('superlevel', '100'),
'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'),
'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'),
'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'),
'fishing_cost' => Sysparam::getValue('fishing_cost', '5'),
'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'),
'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'),
];
}
/** /**
* 计算并更新用户等级 * 计算并更新用户等级
*/ */
@@ -222,7 +267,7 @@ class AiHeartbeatCommand extends Command
/** /**
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。 * 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
*/ */
private function performDailySignIn(User $user): void private function performDailySignIn(User $user): bool
{ {
// 先检查今日是否已签,避免每分钟都调用事务 // 先检查今日是否已签,避免每分钟都调用事务
$alreadySigned = DailySignIn::query() $alreadySigned = DailySignIn::query()
@@ -231,7 +276,7 @@ class AiHeartbeatCommand extends Command
->exists(); ->exists();
if ($alreadySigned) { if ($alreadySigned) {
return; return false;
} }
// 获取活跃房间作为签到归属(默认房间 1) // 获取活跃房间作为签到归属(默认房间 1)
@@ -242,7 +287,7 @@ class AiHeartbeatCommand extends Command
// 仅当本次心跳实际完成签到时才广播(幂等保护) // 仅当本次心跳实际完成签到时才广播(幂等保护)
if (! $dailySignIn->wasRecentlyCreated) { if (! $dailySignIn->wasRecentlyCreated) {
return; return false;
} }
$rewardParts = []; $rewardParts = [];
@@ -265,6 +310,8 @@ class AiHeartbeatCommand extends Command
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'; .$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('系统传音', $content, '#0f766e'); $this->broadcastSystemMessage('系统传音', $content, '#0f766e');
return true;
} }
/** /**
+85 -16
View File
@@ -20,6 +20,7 @@ use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\PositionDutyLog; use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
@@ -27,6 +28,7 @@ use App\Services\ChatUserPresenceService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Services\VipService; use App\Services\VipService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -65,6 +67,8 @@ class AutoSaveExp extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$startedAt = microtime(true);
// 读取奖励配置 // 读取奖励配置
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1'); $expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0'); $jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
@@ -81,15 +85,40 @@ class AutoSaveExp extends Command
// 统计本次处理总人次(一个用户在多个房间会被计算多次) // 统计本次处理总人次(一个用户在多个房间会被计算多次)
$totalProcessed = 0; $totalProcessed = 0;
$usersByUsername = $this->preloadOnlineUsers($roomMap);
$processedUsernames = [];
foreach ($roomMap as $roomId => $usernames) { foreach ($roomMap as $roomId => $usernames) {
foreach ($usernames as $username) { foreach ($usernames as $username) {
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel); // 1. 如果本轮已经处理过该用户,跳过发放经验(防止多房间/多开重复吃经验)
if (isset($processedUsernames[$username])) {
continue;
}
// 2. 利用 Redis 缓存锁,防止在短时间内(200秒内)重复进行自动存点奖励(防御多进程并发/重复调度)
$cooldownKey = "user:auto_save_cooldown:{$username}";
if (Redis::exists($cooldownKey)) {
continue;
}
$user = $usersByUsername->get($username);
if (! $user) {
continue;
}
$this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
// 3. 设置冷却标记,200秒后过期(本任务5分钟运行一次,200秒冷却期既防重复,又不影响下一次存点)
Redis::setex($cooldownKey, 200, 1);
$processedUsernames[$username] = true;
$totalProcessed++; $totalProcessed++;
} }
} }
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。"); $elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
return Command::SUCCESS; return Command::SUCCESS;
} }
@@ -108,40 +137,71 @@ class AutoSaveExp extends Command
$roomMap = []; $roomMap = [];
// 从数据库取出所有房间 ID // 从数据库取出所有房间 ID
$roomIds = \App\Models\Room::pluck('id'); $roomIds = Room::pluck('id');
foreach ($roomIds as $roomId) { foreach ($roomIds as $roomId) {
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致 // Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
$usernames = Redis::hkeys("room:{$roomId}:users"); $usernames = Redis::hkeys("room:{$roomId}:users");
if (! empty($usernames)) { if (! empty($usernames)) {
$roomMap[(int) $roomId] = $usernames; $activeUsernames = [];
foreach ($usernames as $username) {
// 检查活跃标记是否存在(90秒内)
if (Redis::exists("room:{$roomId}:alive:{$username}")) {
$activeUsernames[] = $username;
} else {
// 懒清理僵尸数据
Redis::hdel("room:{$roomId}:users", $username);
}
}
if (! empty($activeUsernames)) {
$roomMap[(int) $roomId] = $activeUsernames;
}
} }
} }
return $roomMap; return $roomMap;
} }
/**
* 预加载所有在线用户名对应的用户资料与身份关系,避免循环内逐个查询用户和身份信息。
*
* @param array<int, array<string>> $roomMap 在线房间与用户名映射
* @return Collection<string, User> 以用户名为键的用户集合
*/
private function preloadOnlineUsers(array $roomMap): Collection
{
$usernames = collect($roomMap)
->flatten()
->unique()
->values();
if ($usernames->isEmpty()) {
return collect();
}
return User::query()
->with(['activePosition.position.department', 'vipLevel'])
->whereIn('username', $usernames)
->get()
->keyBy('username');
}
/** /**
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。 * 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
* *
* @param string $username 用户 * @param User $user 已预加载身份关系的在线用户
* @param int $roomId 所在房间ID * @param int $roomId 所在房间ID
* @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围) * @param string $expGainRaw 经验奖励原始配置(支持 "1" "1-10" 范围)
* @param string $jjbGainRaw 金币奖励原始配置 * @param string $jjbGainRaw 金币奖励原始配置
* @param int $superLevel 管理员等级阈值 * @param int $superLevel 管理员等级阈值
*/ */
private function processUser( private function processUser(
string $username, User $user,
int $roomId, int $roomId,
string $expGainRaw, string $expGainRaw,
string $jjbGainRaw, string $jjbGainRaw,
int $superLevel int $superLevel
): void { ): void {
$user = User::where('username', $username)->first();
if (! $user) {
return;
}
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率) // 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw); $expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user); $expMultiplier = $this->vipService->getExpMultiplier($user);
@@ -165,8 +225,11 @@ class AutoSaveExp extends Command
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId, $user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
); );
} }
$user->refresh(); // 刷新获取最新属性(service 已原子更新) if ($actualExpGain > 0 || $actualJjbGain > 0) {
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份 // 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系
$user->refresh();
$user->load(['activePosition.position.department', 'vipLevel']);
}
// 3. 自动升降级逻辑 // 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化 // - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -178,11 +241,17 @@ class AutoSaveExp extends Command
$activeUP = $user->activePosition; // 已在 refresh 后加载 $activeUP = $user->activePosition; // 已在 refresh 后加载
if ($activeUP?->position) { if ($activeUP?->position) {
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖 // 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
$requiredLevel = (int) $activeUP->position->level; $requiredLevel = (int) $activeUP->position->level;
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) { if ($requiredLevel > 0 && $user->user_level < $requiredLevel) {
$user->user_level = $requiredLevel; $user->user_level = $requiredLevel;
} }
// 职务成员也可以按经验超越职务等级
$expLevel = Sysparam::calculateLevel($user->exp_num);
if ($expLevel > $user->user_level && $expLevel < $superLevel) {
$user->user_level = $expLevel;
$leveledUp = ($expLevel > $oldLevel);
}
} elseif ($oldLevel < $superLevel) { } elseif ($oldLevel < $superLevel) {
// 普通用户:按经验计算并更新等级 // 普通用户:按经验计算并更新等级
$newLevel = Sysparam::calculateLevel($user->exp_num); $newLevel = Sysparam::calculateLevel($user->exp_num);
@@ -241,7 +310,7 @@ class AutoSaveExp extends Command
'id' => $this->chatState->nextMessageId($roomId), 'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId, 'room_id' => $roomId,
'from_user' => '系统', 'from_user' => '系统',
'to_user' => $username, // 定向推送给本人 'to_user' => $user->username, // 定向推送给本人
'content' => $content, 'content' => $content,
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到 'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
'font_color' => '#16a34a', // 草绿色 'font_color' => '#16a34a', // 草绿色
@@ -107,6 +107,44 @@ class ConsumeWechatMessages extends Command
$this->info("收到潜在绑定请求: {$content} from {$fromUser}"); $this->info("收到潜在绑定请求: {$content} from {$fromUser}");
$this->handleBindRequest(strtoupper($content), $fromUser, $apiService); $this->handleBindRequest(strtoupper($content), $fromUser, $apiService);
} }
// 微信密码重置逻辑:必须是私聊
if (! $isChatroom && str_contains($content, '重置密码')) {
$this->info("收到微信密码重置请求 from {$fromUser}");
$this->handlePasswordResetRequest($fromUser, $apiService);
}
}
/**
* 处理微信端的自助密码重置请求
*/
protected function handlePasswordResetRequest(string $wxid, WechatBotApiService $apiService): void
{
// 检索绑定了该微信号的聊天室用户
$user = User::where('wxid', $wxid)->first();
if (! $user) {
$apiService->sendTextMessage(
$wxid,
"❌ 密码重置失败:您当前的微信号尚未绑定任何聊天室账号。\n\n"
.'请先登录聊天室大厅,在个人设置中获取 6 位绑定码,然后再在微信中向我发送“BD-绑定码”(例如: BD-123456)进行绑定。'
);
return;
}
// 随机生成 8 位新随机密码并更新
$newPassword = \Illuminate\Support\Str::random(8);
$user->password = \Illuminate\Support\Facades\Hash::make($newPassword);
$user->save();
$successMsg = "🎉 密码重置成功!\n"
."本小助手已为您绑定的聊天室账号 [{$user->username}] 重新生成了密码:\n\n"
."{$newPassword}\n\n"
.'温馨提示:请使用此随机密码登录聊天室,并尽快在个人偏好设置中将其修改为您的常用密码。';
$apiService->sendTextMessage($wxid, $successMsg);
$this->info("微信用户 [{$user->username}] 已通过微信机器人成功重置了密码");
} }
/** /**
+33 -13
View File
@@ -3,8 +3,8 @@
/** /**
* 文件功能:定期清理聊天记录 Artisan 命令 * 文件功能:定期清理聊天记录 Artisan 命令
* *
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控 * 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。 * 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
* *
* @author ChatRoom Laravel * @author ChatRoom Laravel
* *
@@ -31,7 +31,7 @@ class PurgeOldMessages extends Command
* @var string * @var string
*/ */
protected $signature = 'messages:purge protected $signature = 'messages:purge
{--days= : 覆盖默认保留天数} {--days= : 覆盖通知消息默认保留天数}
{--image-days=3 : 聊天图片单独保留天数} {--image-days=3 : 聊天图片单独保留天数}
{--dry-run : 仅预览不实际删除}'; {--dry-run : 仅预览不实际删除}';
@@ -40,7 +40,7 @@ class PurgeOldMessages extends Command
* *
* @var string * @var string
*/ */
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件'; protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
/** /**
* 执行命令 * 执行命令
@@ -49,9 +49,9 @@ class PurgeOldMessages extends Command
*/ */
public function handle(): int public function handle(): int
{ {
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天 // 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
$days = (int) ($this->option('days') $days = (int) ($this->option('days')
?: Sysparam::getValue('message_retention_days', '30')); ?: Sysparam::getValue('game_message_retention_days', '30'));
$imageDays = max(0, (int) $this->option('image-days')); $imageDays = max(0, (int) $this->option('image-days'));
$cutoff = Carbon::now()->subDays($days); $cutoff = Carbon::now()->subDays($days);
@@ -59,22 +59,22 @@ class PurgeOldMessages extends Command
$this->cleanupExpiredImages($imageDays, $isDryRun); $this->cleanupExpiredImages($imageDays, $isDryRun);
// 统计待清理数量 $expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
$totalCount = Message::where('sent_at', '<', $cutoff)->count(); $totalCount = (clone $expiredNoticeQuery)->count();
if ($totalCount === 0) { if ($totalCount === 0) {
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理"); $this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留");
return self::SUCCESS; return self::SUCCESS;
} }
if ($isDryRun) { if ($isDryRun) {
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}"); $this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}");
return self::SUCCESS; return self::SUCCESS;
} }
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()}..."); $this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()}...");
$this->info(" 待清理数量:{$totalCount}"); $this->info(" 待清理数量:{$totalCount}");
// 分批删除,每批 1000 条,避免长时间锁表 // 分批删除,每批 1000 条,避免长时间锁表
@@ -82,7 +82,7 @@ class PurgeOldMessages extends Command
$batchSize = 1000; $batchSize = 1000;
do { do {
$batch = Message::where('sent_at', '<', $cutoff) $batch = $this->expiredNoticeQuery($cutoff)
->limit($batchSize) ->limit($batchSize)
->delete(); ->delete();
@@ -93,11 +93,31 @@ class PurgeOldMessages extends Command
} }
} while ($batch === $batchSize); } while ($batch === $batchSize);
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。"); $this->info("✅ 清理完成!共删除 {$deleted}游戏/临时通知,用户聊天记录未删除");
return self::SUCCESS; return self::SUCCESS;
} }
/**
* 构造过期通知清理查询,兼容新增字段前已经落库的旧通知。
*/
private function expiredNoticeQuery(Carbon $cutoff): \Illuminate\Database\Eloquent\Builder
{
return Message::query()
->where('sent_at', '<', $cutoff)
->where(function ($query) {
$query->whereIn('retention_type', Message::purgableRetentionTypes())
->orWhere(function ($legacyQuery) {
// 兼容迁移前默认归为 user_chat 的旧通知,避免历史游戏播报继续堆积。
$legacyQuery->where('retention_type', Message::RETENTION_USER_CHAT)
->where(function ($noticeQuery) {
$noticeQuery->whereIn('from_user', ['钓鱼播报', '星海小博士', '进出播报', '座驾播报'])
->orWhereIn('action', ['fishing_result', 'idiom_result', 'riddle_result', 'system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp']);
});
});
});
}
/** /**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。 * 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/ */
@@ -0,0 +1,98 @@
<?php
/**
* 文件功能:扫描并补算用户成就的 Artisan 命令。
*
* 支持单用户、全量与最近活跃用户三种扫描方式,便于定时任务和后台补算复用。
*/
namespace App\Console\Commands;
use App\Models\User;
use App\Services\AchievementService;
use Illuminate\Console\Command;
/**
* 类功能:通过命令行批量检查用户成就进度并写入解锁记录。
*/
class ScanAchievementsCommand extends Command
{
/**
* 命令签名。
*
* @var string
*/
protected $signature = 'achievements:scan
{--user= : 指定用户 ID 或用户名}
{--all : 扫描全部用户}
{--notify : 解锁时向用户推送本人可见通知}
{--dry-run : 仅预览,不写入成就记录}';
/**
* 命令描述。
*
* @var string
*/
protected $description = '扫描聊天室用户成就进度并补齐解锁记录';
/**
* 创建命令依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {
parent::__construct();
}
/**
* 执行成就扫描命令。
*/
public function handle(): int
{
$notify = (bool) $this->option('notify');
$dryRun = (bool) $this->option('dry-run');
if ($this->option('user')) {
$user = $this->resolveUser((string) $this->option('user'));
if (! $user) {
$this->error('未找到指定用户。');
return self::FAILURE;
}
$result = $this->achievementService->scanUser($user, $notify, $dryRun);
$this->info("已扫描用户 {$user->username}:检查 {$result['checked']} 项,解锁 {$result['unlocked']} 项,更新 {$result['updated']} 项。");
return self::SUCCESS;
}
$query = User::query()->orderBy('id');
if (! $this->option('all')) {
// 默认只扫最近活跃用户,避免定时任务每次全表扫描。
$query->where('updated_at', '>=', now()->subDay())->limit(200);
}
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
$query->chunkById(100, function ($users) use (&$summary, $notify, $dryRun): void {
$chunkSummary = $this->achievementService->scanUsers($users, $notify, $dryRun);
$summary['users'] += $chunkSummary['users'];
$summary['checked'] += $chunkSummary['checked'];
$summary['unlocked'] += $chunkSummary['unlocked'];
$summary['updated'] += $chunkSummary['updated'];
});
$this->info("成就扫描完成:用户 {$summary['users']} 人,检查 {$summary['checked']} 项,解锁 {$summary['unlocked']} 项,更新 {$summary['updated']} 项。");
return self::SUCCESS;
}
/**
* 根据 ID 或用户名解析用户。
*/
private function resolveUser(string $value): ?User
{
return User::query()
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('username', $value))
->first();
}
}
+17 -1
View File
@@ -164,6 +164,18 @@ enum CurrencySource: string
/** 猜谜活动奖励 */ /** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward'; case GAME_REWARD = 'game_reward';
/** 收到用户赠送的金币 */
case GOLD_GIFT_RECV = 'gold_gift_recv';
/** 聊天互动魅力奖励 */
case CHAT_CHARM = 'chat_charm';
/** 存入银行(jjb → bank_jjb */
case BANK_DEPOSIT = 'bank_deposit';
/** 从银行取款(bank_jjb → jjb */
case BANK_WITHDRAW = 'bank_withdraw';
/** /**
* 返回该来源的中文名称,用于后台统计展示。 * 返回该来源的中文名称,用于后台统计展示。
*/ */
@@ -177,7 +189,7 @@ enum CurrencySource: string
self::RECV_GIFT => '收到礼物', self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包', self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买', self::SHOP_BUY => '商城购买',
self::RIDE_BUY => '座驾购买', self::RIDE_BUY => '座驾购买(金币)',
self::ADMIN_ADJUST => '管理员调整', self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励', self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到', self::SIGN_IN => '每日签到',
@@ -218,6 +230,10 @@ enum CurrencySource: string
self::MSG_DECORATION_BUY => '消息装扮购买(旧)', self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买', self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜谜活动奖励', self::GAME_REWARD => '猜谜活动奖励',
self::GOLD_GIFT_RECV => '收到赠送金币',
self::CHAT_CHARM => '聊天魅力奖励',
self::BANK_DEPOSIT => '存入银行',
self::BANK_WITHDRAW => '银行取款',
}; };
} }
} }
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:前台用户成就展示控制器。
*
* 展示当前登录用户的成就分类、解锁状态和进度。
*/
namespace App\Http\Controllers;
use App\Services\AchievementService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* 类功能:提供“我的成就”页面数据。
*/
class AchievementController extends Controller
{
/**
* 创建成就控制器依赖。
*/
public function __construct(
private readonly AchievementService $achievementService,
) {}
/**
* 展示当前登录用户的成就总览。
*/
public function index(Request $request): View
{
$user = Auth::user();
$this->achievementService->scanUser($user);
$achievementData = $this->achievementService->displayForUser($user);
$activeTab = in_array($request->query('status'), ['unlocked', 'locked'], true)
? $request->query('status')
: 'all';
$allAchievements = $achievementData['achievements'];
// 页面 tab 只影响展示列表,不影响顶部总进度统计。
$achievementTabs = [
'all' => [
'label' => '全部',
'count' => $allAchievements->count(),
'url' => route('achievements.index'),
],
'unlocked' => [
'label' => '已完成',
'count' => $allAchievements->where('unlocked', true)->count(),
'url' => route('achievements.index', ['status' => 'unlocked']),
],
'locked' => [
'label' => '未达成',
'count' => $allAchievements->where('unlocked', false)->count(),
'url' => route('achievements.index', ['status' => 'locked']),
],
];
$achievementData['achievements'] = match ($activeTab) {
'unlocked' => $allAchievements->where('unlocked', true)->values(),
'locked' => $allAchievements->where('unlocked', false)->values(),
default => $allAchievements,
};
return view('achievements.index', [
'user' => $user,
'active_tab' => $activeTab,
'achievement_tabs' => $achievementTabs,
...$achievementData,
]);
}
}
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:后台成就记录查询控制器。
*
* 提供固定成就目录的解锁统计与用户成就记录只读查询。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserAchievement;
use App\Support\AchievementCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* 类功能:展示后台成就总览、解锁记录与按成就分组统计。
*/
class AchievementController extends Controller
{
/**
* 展示成就记录总览。
*/
public function index(Request $request): View
{
$definitions = AchievementCatalog::definitions();
$query = UserAchievement::query()
->with('user:id,username')
->whereNotNull('achieved_at')
->latest('achieved_at');
if ($request->filled('username')) {
$query->whereHas('user', function ($userQuery) use ($request): void {
$userQuery->where('username', 'like', '%'.$request->string('username')->toString().'%');
});
}
if ($request->filled('achievement_key')) {
$query->where('achievement_key', $request->string('achievement_key')->toString());
}
$records = $query->paginate(30)->withQueryString();
$summary = [
'total_definitions' => count($definitions),
'unlocked_records' => UserAchievement::query()->whereNotNull('achieved_at')->count(),
'unlocked_users' => UserAchievement::query()->whereNotNull('achieved_at')->distinct('user_id')->count('user_id'),
];
$topAchievements = UserAchievement::query()
->whereNotNull('achieved_at')
->select('achievement_key', DB::raw('count(*) as unlocked_count'))
->groupBy('achievement_key')
->orderByDesc('unlocked_count')
->limit(10)
->get();
return view('admin.achievements.index', compact('definitions', 'records', 'summary', 'topAchievements'));
}
}
@@ -457,8 +457,9 @@ class AdminCommandController extends Controller
abort(403, '仅站长可查看私信'); abort(403, '仅站长可查看私信');
} }
// 查询最近 50 条悄悄话(发送或接收) // 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
$messages = Message::where('is_secret', true) $messages = Message::where('is_secret', true)
->where('from_user', 'not like', '系统%')
->where(function ($q) use ($username) { ->where(function ($q) use ($username) {
$q->where('from_user', $username) $q->where('from_user', $username)
->orWhere('to_user', $username); ->orWhere('to_user', $username);
@@ -598,8 +599,8 @@ class AdminCommandController extends Controller
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令']; return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
} }
// 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。 // 超级管理员(站长 id = 1)豁免必须进房的限制,允许全局管理;普通管理员必须在房间内方可管理
if (! $this->chatState->isUserInRoom($roomId, $operator->username)) { if ($operator->id !== 1 && ! $this->chatState->isUserInRoom($roomId, $operator->username)) {
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令']; return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
} }
@@ -613,6 +614,11 @@ class AdminCommandController extends Controller
*/ */
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
{ {
// 系统虚拟机器人账号豁免在线状态校验,允许测试与交互警告
if (in_array($targetUsername, ['AI小班长', '星海小博士'])) {
return ['ok' => true, 'message' => '校验通过'];
}
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) { if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作']; return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
} }
@@ -1023,7 +1029,7 @@ class AdminCommandController extends Controller
'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!", 'message' => "<b>{$admin->username}</b>{$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
'icon' => '💰', 'icon' => '💰',
'color' => '#f59e0b', 'color' => '#f59e0b',
'duration' => 8000, 'duration' => 3000,
], ],
]; ];
$this->chatState->pushMessage($roomId, $msg); $this->chatState->pushMessage($roomId, $msg);
+49 -28
View File
@@ -19,7 +19,9 @@ use App\Enums\CurrencySource;
use App\Models\BaccaratBet; use App\Models\BaccaratBet;
use App\Models\BaccaratRound; use App\Models\BaccaratRound;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\User;
use App\Services\BaccaratLossCoverService; use App\Services\BaccaratLossCoverService;
use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService; use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -35,6 +37,7 @@ class BaccaratController extends Controller
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService, private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService, private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -131,7 +134,7 @@ class BaccaratController extends Controller
$user = $request->user(); $user = $request->user();
// 检查用户金币余额(金币字段为 jjb // 快速过滤(非锁
if (($user->jjb ?? 0) < $data['amount']) { if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']); return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
} }
@@ -140,10 +143,21 @@ class BaccaratController extends Controller
$lossCoverService = $this->lossCoverService; $lossCoverService = $this->lossCoverService;
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse { return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
// 幂等:同一局只能下一注 // 1. 悲观锁锁定用户行
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
// 2. 锁保护下二次校验余额
if ((int) $lockedUser->jjb < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
// 3. 幂等:同一局只能下一注
$existing = BaccaratBet::query() $existing = BaccaratBet::query()
->where('round_id', $round->id) ->where('round_id', $round->id)
->where('user_id', $user->id) ->where('user_id', $lockedUser->id)
->lockForUpdate() ->lockForUpdate()
->exists(); ->exists();
@@ -151,9 +165,9 @@ class BaccaratController extends Controller
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']); return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
} }
// 扣除金币 // 4. 扣除金币,传入锁定的用户实例
$currency->change( $currency->change(
$user, $lockedUser,
'gold', 'gold',
-$data['amount'], -$data['amount'],
CurrencySource::BACCARAT_BET, CurrencySource::BACCARAT_BET,
@@ -165,16 +179,19 @@ class BaccaratController extends Controller
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。 // 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
$lossCoverEvent = $lossCoverService->findEventForBetTime(now()); $lossCoverEvent = $lossCoverService->findEventForBetTime(now());
// 写入下注记录 // 5. 写入下注记录
$bet = BaccaratBet::create([ $bet = BaccaratBet::create([
'round_id' => $round->id, 'round_id' => $round->id,
'user_id' => $user->id, 'user_id' => $lockedUser->id,
'loss_cover_event_id' => $lossCoverEvent?->id, 'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $data['bet_type'], 'bet_type' => $data['bet_type'],
'amount' => $data['amount'], 'amount' => $data['amount'],
'status' => 'pending', 'status' => 'pending',
]); ]);
// 同步修改 Auth 内存实例的金币
$user->setAttribute('jjb', $lockedUser->jjb);
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。 // 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
$lossCoverService->registerBet($bet); $lossCoverService->registerBet($bet);
@@ -192,27 +209,7 @@ class BaccaratController extends Controller
'big' => '大', 'small' => '小', default => '豹子' 'big' => '大', 'small' => '小', default => '豹子'
}; };
// 发送系统传音到聊天室,公示该用户的押注信息 $this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
$chatState = app(\App\Services\ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$roomId = $round->room_id ?? 1;
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
$content = "🎲 <b> 【百家乐】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
event(new \App\Events\MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -238,4 +235,28 @@ class BaccaratController extends Controller
return response()->json(['history' => $rounds]); return response()->json(['history' => $rounds]);
} }
/**
* 前台百家乐历史走势与开奖页面。
*/
public function historyPage(Request $request): \Illuminate\View\View
{
// 1. 各选项的历史分布统计
$summary = [
'total_rounds' => BaccaratRound::query()->where('status', 'settled')->count(),
'result_dist' => BaccaratRound::query()
->where('status', 'settled')
->select('result', \Illuminate\Support\Facades\DB::raw('count(*) as cnt'))
->groupBy('result')
->pluck('cnt', 'result'),
];
// 2. 分页拉取所有已结算的局次
$rounds = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
->paginate(30);
return view('rooms.baccarat-history', compact('rounds', 'summary'));
}
} }
+82 -26
View File
@@ -13,9 +13,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\BankLog; use App\Models\BankLog;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -26,6 +28,10 @@ use Illuminate\Support\Facades\DB;
*/ */
class BankController extends Controller class BankController extends Controller
{ {
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/** /**
* 查询银行余额及最近20条流水记录 * 查询银行余额及最近20条流水记录
*/ */
@@ -99,6 +105,7 @@ class BankController extends Controller
$amount = $request->integer('amount'); $amount = $request->integer('amount');
$user = Auth::user(); $user = Auth::user();
// 快速过滤(非锁),降低非法请求穿透到数据库的概率
if (($user->jjb ?? 0) < $amount) { if (($user->jjb ?? 0) < $amount) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
@@ -106,25 +113,49 @@ class BankController extends Controller
]); ]);
} }
DB::transaction(function () use ($user, $amount): void { try {
$user->decrement('jjb', $amount); DB::transaction(function () use ($user, $amount): void {
$user->increment('bank_jjb', $amount); // 1. 强制在数据库层面对用户行数据加写锁(X锁)
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
BankLog::create([ // 2. 在锁保护下安全校验最新余额
'user_id' => $user->id, if ((int) $lockedUser->jjb < $amount) {
'type' => 'deposit', throw new \Exception('流通金币不足!当前余额 '.(int) $lockedUser->jjb." 枚,无法存入 {$amount} 枚。");
'amount' => $amount, }
'balance_after' => $user->fresh()->bank_jjb,
// 3. 执行资产扣除,将已加锁的 lockedUser 传给 change 方法
$this->currencyService->change($lockedUser, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
// 4. 增加银行余额
$lockedUser->increment('bank_jjb', $amount);
// 5. 写入银行流水记录
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'deposit',
'amount' => $amount,
'balance_after' => $lockedUser->fresh()->bank_jjb,
]);
// 6. 同步 Auth 内存状态,保障同生命周期内其他地方拿到的是正确数据
$user->setAttribute('jjb', $lockedUser->jjb);
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
});
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
]); ]);
}); }
$fresh = $user->fresh();
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => "成功存入 {$amount} 枚金币!", 'message' => "成功存入 {$amount} 枚金币!",
'jjb' => $fresh->jjb, 'jjb' => $user->jjb,
'bank_jjb' => $fresh->bank_jjb, 'bank_jjb' => $user->bank_jjb,
]); ]);
} }
@@ -142,6 +173,7 @@ class BankController extends Controller
$amount = $request->integer('amount'); $amount = $request->integer('amount');
$user = Auth::user(); $user = Auth::user();
// 快速过滤(非锁)
if (($user->bank_jjb ?? 0) < $amount) { if (($user->bank_jjb ?? 0) < $amount) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
@@ -149,25 +181,49 @@ class BankController extends Controller
]); ]);
} }
DB::transaction(function () use ($user, $amount): void { try {
$user->decrement('bank_jjb', $amount); DB::transaction(function () use ($user, $amount): void {
$user->increment('jjb', $amount); // 1. 强制在数据库层面对用户行数据加写锁(X锁)
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
BankLog::create([ // 2. 校验银行存款是否足够
'user_id' => $user->id, if ((int) $lockedUser->bank_jjb < $amount) {
'type' => 'withdraw', throw new \Exception('银行余额不足!当前存款 '.($lockedUser->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。");
'amount' => $amount, }
'balance_after' => $user->fresh()->bank_jjb,
// 3. 扣除银行存款
$lockedUser->decrement('bank_jjb', $amount);
// 4. 增加流通金币并记录划转日志
$this->currencyService->change($lockedUser, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
// 5. 记录银行账户流水
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'withdraw',
'amount' => $amount,
'balance_after' => $lockedUser->fresh()->bank_jjb,
]);
// 6. 同步 Auth 内存状态
$user->setAttribute('jjb', $lockedUser->jjb);
$user->setAttribute('bank_jjb', $lockedUser->bank_jjb);
});
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
]); ]);
}); }
$fresh = $user->fresh();
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => "成功取出 {$amount} 枚金币!", 'message' => "成功取出 {$amount} 枚金币!",
'jjb' => $fresh->jjb, 'jjb' => $user->jjb,
'bank_jjb' => $fresh->bank_jjb, 'bank_jjb' => $user->bank_jjb,
]); ]);
} }
+25 -21
View File
@@ -26,6 +26,7 @@ use App\Models\User;
use App\Services\AppointmentService; use App\Services\AppointmentService;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService; use App\Services\ChatUserPresenceService;
use App\Services\DailyGameProfitLeaderboardService;
use App\Services\MessageFilterService; use App\Services\MessageFilterService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
use App\Services\RideService; use App\Services\RideService;
@@ -63,6 +64,7 @@ class ChatController extends Controller
private readonly VipService $vipService, private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService, private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly DailyGameProfitLeaderboardService $dailyGameProfitLeaderboardService,
private readonly AppointmentService $appointmentService, private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast, private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
@@ -372,6 +374,7 @@ class ChatController extends Controller
'pendingDivorce' => $pendingDivorceData, 'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap, 'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true), 'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
'dailyGameProfitLeaders' => $this->dailyGameProfitLeaderboardService->topThree(),
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(), 'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user), 'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
]); ]);
@@ -506,7 +509,7 @@ class ChatController extends Controller
'message' => strip_tags($pureContent), 'message' => strip_tags($pureContent),
'icon' => '👋', 'icon' => '👋',
'color' => '#e11d48', 'color' => '#e11d48',
'duration' => 8000, 'duration' => 3000,
]; ];
} }
@@ -616,15 +619,20 @@ class ChatController extends Controller
$superLevel = (int) Sysparam::getValue('superlevel', '100'); $superLevel = (int) Sysparam::getValue('superlevel', '100');
$leveledUp = $this->calculateNewLevel($user, $superLevel); $leveledUp = $this->calculateNewLevel($user, $superLevel);
$user->save(); // 存点入库 $hasChanges = $leveledUp || ($canGainReward && ($actualExpGain > 0 || $actualJjbGain > 0));
if ($hasChanges) {
$user->save(); // 存点入库
// 将新的等级与资产状态反馈给当前用户的在线名单上,确保别人查看到的也是最准确等级
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
} else {
// 无变动时,仅在 Redis 层面保活该用户的在线状态,拒绝写库并减少 Redis HSET 负荷
$this->chatState->refreshAlive($id, $user->username);
}
// 手动心跳存点:同步更新在职用户的勤务时长 // 手动心跳存点:同步更新在职用户的勤务时长
$this->tickDutyLog($user, $id); $this->tickDutyLog($user, $id);
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
// 4. 如果突破境界,向全房系统喊话广播! // 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) { if ($leveledUp) {
// 生成炫酷广播消息发向该频道 // 生成炫酷广播消息发向该频道
@@ -1088,12 +1096,9 @@ class ChatController extends Controller
]); ]);
} }
// 扣除金币、增加对方魅力 // 扣除金币、增加对方魅力(含流水日志)
$user->jjb = ($user->jjb ?? 0) - $totalCost; $this->currencyService->change($user, 'gold', -$totalCost, CurrencySource::SEND_GIFT, "送花:送给 {$toUsername} {$count} 份【{$gift->name}】,魅力 +{$totalCharm}", $roomId);
$user->save(); $this->currencyService->change($toUser, 'charm', $totalCharm, CurrencySource::RECV_GIFT, "收到 {$user->username} 赠送的 {$count} 份【{$gift->name}", $roomId);
$toUser->meili = ($toUser->meili ?? 0) + $totalCharm;
$toUser->save();
// 构建礼物图片 URL // 构建礼物图片 URL
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : ''; $giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
@@ -1125,8 +1130,8 @@ class ChatController extends Controller
'status' => 'success', 'status' => 'success',
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}", 'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
'data' => [ 'data' => [
'my_jjb' => $user->jjb, 'my_jjb' => $user->fresh()->jjb,
'target_charm' => $toUser->meili, 'target_charm' => $toUser->fresh()->meili,
], ],
]); ]);
} }
@@ -1179,9 +1184,8 @@ class ChatController extends Controller
return; return;
} }
// 发送者获得魅力 // 发送者获得魅力(含流水日志)
$sender->meili = ($sender->meili ?? 0) + $charmGain; $this->currencyService->change($sender, 'charm', $charmGain, CurrencySource::CHAT_CHARM, "{$toUsername} 聊天获得魅力奖励");
$sender->save();
// 更新 Redis 计数器(1 小时过期) // 更新 Redis 计数器(1 小时过期)
Redis::incrby($capKey, $charmGain); Redis::incrby($capKey, $charmGain);
@@ -1404,9 +1408,9 @@ class ChatController extends Controller
]); ]);
} }
// 执行转账(直接操作字段,与 sendFlower 保持一致风格 // 执行转账(含流水日志
$sender->decrement('jjb', $amount); $this->currencyService->change($sender, 'gold', -$amount, CurrencySource::GIFT_SENT, "赠送金币给 {$toName}", $roomId);
$receiver->increment('jjb', $amount); $this->currencyService->change($receiver, 'gold', $amount, CurrencySource::GOLD_GIFT_RECV, "收到 {$sender->username} 赠送的金币", $roomId);
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。 // 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
$giftMsg = [ $giftMsg = [
@@ -1425,7 +1429,7 @@ class ChatController extends Controller
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!", 'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰', 'icon' => '💰',
'color' => '#f59e0b', 'color' => '#f59e0b',
'duration' => 8000, 'duration' => 3000,
], ],
]; ];
+16 -1
View File
@@ -24,6 +24,7 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent; use App\Events\GomokuMovedEvent;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\GomokuGame; use App\Models\GomokuGame;
use App\Models\User;
use App\Services\GameRoomScopeService; use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService; use App\Services\GomokuAiService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
@@ -92,13 +93,27 @@ class GomokuController extends Controller
return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse { return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse {
// PvE 扣除入场费 // PvE 扣除入场费
if ($entryFee > 0) { if ($entryFee > 0) {
// 1. 悲观锁锁定用户行
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
// 2. 二次确认金币是否足够
if ((int) $lockedUser->jjb < $entryFee) {
return response()->json(['ok' => false, 'message' => '金币不足,无法加入游戏对局。']);
}
$this->currency->change( $this->currency->change(
$user, $lockedUser,
'gold', 'gold',
-$entryFee, -$entryFee,
CurrencySource::GOMOKU_ENTRY_FEE, CurrencySource::GOMOKU_ENTRY_FEE,
"五子棋 AI 对战入场费(难度{$data['ai_level']}", "五子棋 AI 对战入场费(难度{$data['ai_level']}",
); );
// 同步修改 Auth 内存实例的金币
$user->setAttribute('jjb', $lockedUser->jjb);
} }
$timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60); $timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60);
+3 -20
View File
@@ -17,12 +17,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\CurrencySource; use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig; use App\Models\GameConfig;
use App\Models\HorseBet; use App\Models\HorseBet;
use App\Models\HorseRace; use App\Models\HorseRace;
use App\Services\ChatStateService; use App\Services\GameBetBroadcastService;
use App\Services\GameRoomScopeService; use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -40,6 +38,7 @@ class HorseRaceController extends Controller
public function __construct( public function __construct(
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService, private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -221,23 +220,7 @@ class HorseRaceController extends Controller
'status' => 'pending', 'status' => 'pending',
]); ]);
$chatState = app(ChatStateService::class); $this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage((int) $race->room_id, $msg);
event(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -19,6 +19,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
@@ -37,6 +38,64 @@ class PasswordResetController extends Controller
]); ]);
} }
/**
* 账号检测接口:根据昵称检测是否绑定微信或邮箱,并提供分流依据(支持 IP 防扫限流)。
*/
public function checkAccount(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|max:100',
]);
$username = trim((string) $request->input('username'));
$ip = $request->ip();
// IP 防扫描节流限制:每个 IP 每 1 分钟最多请求 5 次
$ipKey = 'pw-check:ip:'.$ip;
if (RateLimiter::tooManyAttempts($ipKey, 5)) {
$seconds = RateLimiter::availableIn($ipKey);
return response()->json([
'status' => 'error',
'message' => "账号检测请求过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
RateLimiter::hit($ipKey, 60);
$user = User::query()->where('username', $username)->first();
if (! $user) {
return response()->json([
'status' => 'not_found',
'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。',
]);
}
$hasEmail = ! empty($user->email);
$hasWechat = ! empty($user->wxid);
// 对邮箱地址进行安全脱敏(如 pllx@ay.lc -> p***x@ay.lc
$maskedEmail = '';
if ($hasEmail) {
$parts = explode('@', $user->email);
$name = $parts[0] ?? '';
$domain = $parts[1] ?? '';
$len = strlen($name);
if ($len <= 2) {
$maskedEmail = substr($name, 0, 1).'*'.'@'.$domain;
} else {
$maskedEmail = substr($name, 0, 1).str_repeat('*', $len - 2).substr($name, -1).'@'.$domain;
}
}
return response()->json([
'status' => 'success',
'username' => $user->username,
'has_email' => $hasEmail,
'has_wechat' => $hasWechat,
'masked_email' => $maskedEmail,
]);
}
/** /**
* 发送邮箱找回密码链接。 * 发送邮箱找回密码链接。
*/ */
@@ -49,7 +108,49 @@ class PasswordResetController extends Controller
], 403); ], 403);
} }
$email = trim((string) $request->string('email')); $inputEmail = trim((string) $request->input('email', ''));
$username = trim((string) $request->input('username', ''));
$ip = $request->ip();
$user = User::query()->where('username', $username)->first();
if (! $user || empty($user->email)) {
return response()->json([
'status' => 'error',
'message' => '找不到绑定了邮箱的账号。',
], 422);
}
// 强行双向比对核对(忽略大小写和前后空白)
if (strcasecmp($user->email, $inputEmail) !== 0) {
return response()->json([
'status' => 'error',
'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。',
], 422);
}
$email = $user->email;
// 1. IP 级别发信限流:限制单个 IP 每分钟最多请求 2 次
$ipKey = 'pw-email:ip:'.$ip;
if (RateLimiter::tooManyAttempts($ipKey, 2)) {
$seconds = RateLimiter::availableIn($ipKey);
return response()->json([
'status' => 'error',
'message' => "发送验证链接过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
// 2. 账号邮箱级别冷却锁:同一个邮箱每 3 分钟限发 1 次,防止狂刷邮件轰炸他人
$targetKey = 'pw-email:target:'.md5($email);
if (RateLimiter::tooManyAttempts($targetKey, 1)) {
$seconds = RateLimiter::availableIn($targetKey);
return response()->json([
'status' => 'error',
'message' => "该账号申请重置链接过于频繁,请在 {$seconds} 秒后重试。",
], 429);
}
// 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。 // 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。
if (User::query()->where('email', $email)->count() > 1) { if (User::query()->where('email', $email)->count() > 1) {
@@ -59,6 +160,10 @@ class PasswordResetController extends Controller
], 422); ], 422);
} }
// 记录发信请求频率
RateLimiter::hit($ipKey, 60);
RateLimiter::hit($targetKey, 180);
$status = Password::sendResetLink(['email' => $email]); $status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) { if ($status === Password::RESET_LINK_SENT) {
+4 -16
View File
@@ -26,6 +26,7 @@ use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope; use App\Models\RedPacketEnvelope;
use App\Models\User; use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\GameBetBroadcastService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService; use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry; use App\Support\PositionPermissionRegistry;
@@ -58,6 +59,7 @@ class RedPacketController extends Controller
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
/** /**
@@ -357,23 +359,9 @@ class RedPacketController extends Controller
type: $envelopeType, type: $envelopeType,
)); ));
// 在聊天室发送领取播报(所有人可见) // 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币'; $typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰'; $this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
$claimedMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $claimedMsg);
broadcast(new MessageSent($roomId, $claimedMsg));
SaveMessageJob::dispatch($claimedMsg);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb'; $balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField; $balanceNow = $user->fresh()->$balanceField;
@@ -218,6 +218,8 @@ class RiddleQuizController extends Controller
'data' => [ 'data' => [
'quiz_type' => $round->quiz_type, 'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel, 'quiz_type_label' => $quizTypeLabel,
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'answer' => (string) $round->idiom?->answer, 'answer' => (string) $round->idiom?->answer,
'quiz_answer' => (string) $round->idiom?->answer, 'quiz_answer' => (string) $round->idiom?->answer,
'reward_gold' => $round->reward_gold, 'reward_gold' => $round->reward_gold,
+37
View File
@@ -8,9 +8,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Http\Requests\BuyRideRequest; use App\Http\Requests\BuyRideRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Ride; use App\Models\Ride;
use App\Models\Room; use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\RideService; use App\Services\RideService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -67,6 +70,8 @@ class RideController extends Controller
return response()->json(['status' => 'error', 'message' => $result['message']], 400); return response()->json(['status' => 'error', 'message' => $result['message']], 400);
} }
$this->pushRidePurchaseNotice($user, $item, $roomId);
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => $result['message'], 'message' => $result['message'],
@@ -75,4 +80,36 @@ class RideController extends Controller
'jjb' => $user->fresh()->jjb, 'jjb' => $user->fresh()->jjb,
]); ]);
} }
/**
* 向当前房间广播座驾购买成功通知,方便其他用户快速打开座驾页面。
*/
private function pushRidePurchaseNotice(User $user, Ride $item, int $roomId): void
{
$button = '<button onclick="openRideModal()">购买座驾</button>';
$content = sprintf(
'🚀 【座驾】 <b>%s</b> 购买了 <b>%s</b>,有效期 <b>%d 天</b>,排面已安排!%s',
e($user->username),
e($item->name),
(int) $item->duration_days,
$button,
);
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#0f766e',
'action' => 'ride_purchase',
'sent_at' => now()->toDateTimeString(),
];
// 购买通知需要写入房间消息缓存、实时广播并落库,刷新后仍可追溯。
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
}
} }
+5
View File
@@ -21,6 +21,7 @@ use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateProfileRequest;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Services\AchievementService;
use App\Services\ChatStateService; use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService; use App\Services\ChatUserPresenceService;
use App\Services\PositionPermissionService; use App\Services\PositionPermissionService;
@@ -58,6 +59,7 @@ class UserController extends Controller
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService, private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly UserCurrencyService $currencyService, private readonly UserCurrencyService $currencyService,
private readonly AchievementService $achievementService,
private readonly PositionPermissionService $positionPermissionService, private readonly PositionPermissionService $positionPermissionService,
) {} ) {}
@@ -83,6 +85,7 @@ class UserController extends Controller
$activePosition = $targetUser->activePosition?->load('position.department')->position; $activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [ $data = [
'username' => $targetUser->username, 'username' => $targetUser->username,
'previous_name' => $targetUser->previous_name,
'sex' => match ((int) $targetUser->sex) { 'sex' => match ((int) $targetUser->sex) {
1 => '男', 2 => '女', default => '' 1 => '男', 2 => '女', default => ''
}, },
@@ -159,6 +162,8 @@ class UserController extends Controller
'expires_at' => $signIdentity->expires_at?->toIso8601String(), 'expires_at' => $signIdentity->expires_at?->toIso8601String(),
] : null, ] : null,
]; ];
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。 // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
$canViewNetworkInfo = $operator $canViewNetworkInfo = $operator
@@ -32,6 +32,7 @@ class SendPasswordResetLinkRequest extends FormRequest
{ {
return [ return [
'email' => ['required', 'email', 'max:255'], 'email' => ['required', 'email', 'max:255'],
'username' => ['required', 'string', 'max:100'],
]; ];
} }
@@ -43,9 +44,10 @@ class SendPasswordResetLinkRequest extends FormRequest
public function messages(): array public function messages(): array
{ {
return [ return [
'email.required' => '请输入绑定账号的邮箱地址。', 'email.required' => '请输入绑定邮箱地址以进行二次确认。',
'email.email' => '邮箱格式不正确,请重新输入。', 'email.email' => '邮箱格式不正确,请重新输入。',
'email.max' => '邮箱长度不能超过 255 个字符。', 'email.max' => '邮箱长度不能超过 255 个字符。',
'username.required' => '用户昵称参数缺失。',
]; ];
} }
} }
@@ -31,6 +31,7 @@ class UpdateProfileRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'previous_name' => ['nullable', 'string', 'max:50'],
'sex' => ['required', 'in:0,1,2'], 'sex' => ['required', 'in:0,1,2'],
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif 'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
'sign' => ['nullable', 'string', 'max:255'], 'sign' => ['nullable', 'string', 'max:255'],
+1
View File
@@ -50,6 +50,7 @@ class SaveMessageJob implements ShouldQueue
'image_path' => $this->messageData['image_path'] ?? null, 'image_path' => $this->messageData['image_path'] ?? null,
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null, 'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
'image_original_name' => $this->messageData['image_original_name'] ?? null, 'image_original_name' => $this->messageData['image_original_name'] ?? null,
'retention_type' => Message::resolveRetentionType($this->messageData),
// 恢复 Carbon 时间对象 // 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']), 'sent_at' => Carbon::parse($this->messageData['sent_at']),
]); ]);
+16 -2
View File
@@ -124,6 +124,9 @@ class TriggerHolidayEventJob implements ShouldQueue
$now = now(); $now = now();
$scheduledFor = $this->manual ? $now->copy() : $event->send_at; $scheduledFor = $this->manual ? $now->copy() : $event->send_at;
$expiresAt = $this->manual
? $now->copy()->addMinutes($event->expire_minutes)
: $scheduledFor?->copy()->addMinutes($event->expire_minutes);
if (! $this->manual) { if (! $this->manual) {
// 定时触发只允许处理真正到期且仍处于 pending 的模板。 // 定时触发只允许处理真正到期且仍处于 pending 的模板。
@@ -131,12 +134,23 @@ class TriggerHolidayEventJob implements ShouldQueue
return null; return null;
} }
$validScheduledFor = $scheduleService->skipExpiredOccurrences($event, $now);
if ($validScheduledFor === null || ! $validScheduledFor->equalTo($scheduledFor)) {
// 漏跑且已过期的批次只推进模板,不生成领取批次和聊天室公告。
$event->update([
'send_at' => $validScheduledFor,
'status' => $validScheduledFor ? 'pending' : 'completed',
]);
return null;
}
$nextSendAt = $scheduleService->advanceAfterTrigger($event); $nextSendAt = $scheduleService->advanceAfterTrigger($event);
$event->update([ $event->update([
'send_at' => $nextSendAt, 'send_at' => $nextSendAt,
'status' => $nextSendAt ? 'pending' : 'completed', 'status' => $nextSendAt ? 'pending' : 'completed',
'triggered_at' => $now, 'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'expires_at' => $expiresAt,
'claimed_count' => 0, 'claimed_count' => 0,
'claimed_amount' => 0, 'claimed_amount' => 0,
]); ]);
@@ -163,7 +177,7 @@ class TriggerHolidayEventJob implements ShouldQueue
'repeat_type' => $event->repeat_type, 'repeat_type' => $event->repeat_type,
'scheduled_for' => $scheduledFor, 'scheduled_for' => $scheduledFor,
'triggered_at' => $now, 'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes), 'expires_at' => $expiresAt,
'status' => 'active', 'status' => 'active',
'audience_count' => 0, 'audience_count' => 0,
'claimed_count' => 0, 'claimed_count' => 0,
+115
View File
@@ -20,6 +20,120 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class Message extends Model class Message extends Model
{ {
public const RETENTION_USER_CHAT = 'user_chat';
public const RETENTION_SYSTEM_NOTICE = 'system_notice';
public const RETENTION_GAME_NOTICE = 'game_notice';
public const RETENTION_EPHEMERAL_NOTICE = 'ephemeral_notice';
/**
* 可按过期策略清理的消息保留类型。
*
* @return array<int, string>
*/
public static function purgableRetentionTypes(): array
{
return [
self::RETENTION_GAME_NOTICE,
self::RETENTION_EPHEMERAL_NOTICE,
];
}
/**
* 根据广播消息载荷推断数据库保留类型。
*
* @param array<string, mixed> $messageData 聊天室消息载荷
*/
public static function resolveRetentionType(array $messageData): string
{
$explicitType = (string) ($messageData['retention_type'] ?? '');
if (in_array($explicitType, [
self::RETENTION_USER_CHAT,
self::RETENTION_SYSTEM_NOTICE,
self::RETENTION_GAME_NOTICE,
self::RETENTION_EPHEMERAL_NOTICE,
], true)) {
return $explicitType;
}
$fromUser = (string) ($messageData['from_user'] ?? '');
$action = (string) ($messageData['action'] ?? '');
$messageType = (string) ($messageData['message_type'] ?? 'text');
if (self::isEphemeralNotice($fromUser, $action)) {
return self::RETENTION_EPHEMERAL_NOTICE;
}
if (self::isGameNotice($fromUser, $action, $messageType, $messageData)) {
return self::RETENTION_GAME_NOTICE;
}
if (self::isSystemNotice($fromUser)) {
return self::RETENTION_SYSTEM_NOTICE;
}
return self::RETENTION_USER_CHAT;
}
/**
* 判断消息是否属于可短期保留的进出场类通知。
*/
public static function isEphemeralNotice(string $fromUser, string $action = ''): bool
{
return in_array($fromUser, ['进出播报', '座驾播报'], true)
|| in_array($action, ['system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp'], true);
}
/**
* 判断消息是否属于游戏或玩法通知。
*
* @param array<string, mixed> $messageData 聊天室消息载荷
*/
public static function isGameNotice(string $fromUser, string $action, string $messageType = 'text', array $messageData = []): bool
{
$gameSenders = ['钓鱼播报', '星海小博士'];
$gameActions = [
'fishing_result',
'idiom_result',
'riddle_result',
'ride_purchase',
];
if (in_array($fromUser, $gameSenders, true) || in_array($action, $gameActions, true)) {
return true;
}
if (isset($messageData['toast_notification'])) {
$title = (string) data_get($messageData, 'toast_notification.title', '');
return str_contains($title, '下注')
|| str_contains($title, '赛马')
|| str_contains($title, '百家乐')
|| str_contains($title, '双色球')
|| str_contains($title, '红包')
|| str_contains($title, '结算');
}
return in_array($messageType, ['game_notice'], true);
}
/**
* 判断消息是否来自系统发送者。
*/
public static function isSystemNotice(string $fromUser): bool
{
return in_array($fromUser, [
'系统',
'系统公告',
'系统传音',
'系统播报',
'送花播报',
'AI小班长',
], true);
}
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
@@ -37,6 +151,7 @@ class Message extends Model
'image_path', 'image_path',
'image_thumb_path', 'image_thumb_path',
'image_original_name', 'image_original_name',
'retention_type',
'sent_at', 'sent_at',
]; ];
+16
View File
@@ -139,4 +139,20 @@ class Room extends Model
|| $user->username === $this->master || $user->username === $this->master
|| $user->user_level >= $superLevel; || $user->user_level >= $superLevel;
} }
/**
* 模型的 booted 方法。
*
* 在房间被更新或删除时,同步清理 Redis 上的 Presence Channel 鉴权缓存。
*/
protected static function booted(): void
{
static::saved(function (Room $room) {
\Illuminate\Support\Facades\Cache::forget("room:meta:{$room->id}");
});
static::deleted(function (Room $room) {
\Illuminate\Support\Facades\Cache::forget("room:meta:{$room->id}");
});
}
} }
+17
View File
@@ -45,6 +45,7 @@ class User extends Authenticatable
*/ */
protected $fillable = [ protected $fillable = [
'username', 'username',
'previous_name',
'password', 'password',
'email', 'email',
'sex', 'sex',
@@ -261,6 +262,22 @@ class User extends Authenticatable
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date'); return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
} }
/**
* 关联:用户已解锁和进行中的成就记录。
*/
public function achievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'user_id')->latest('achieved_at');
}
/**
* 关联:用户各成就的最新进度快照。
*/
public function achievementProgress(): HasMany
{
return $this->hasMany(UserAchievementProgress::class, 'user_id')->latest('last_scanned_at');
}
/** /**
* 关联:用户全部身份徽章。 * 关联:用户全部身份徽章。
*/ */
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:用户成就解锁记录模型。
*
* 保存每个用户在固定成就目录中的进度快照、达成时间与通知状态。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就记录字段、类型转换与用户关联。
*/
class UserAchievement extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'achieved_at',
'notified_at',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'progress_value' => 'integer',
'achieved_at' => 'datetime',
'notified_at' => 'datetime',
'metadata' => 'array',
];
}
/**
* 关联:成就记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:用户成就进度模型。
*
* 保存用户在每个固定成就上的最新进度快照,解锁状态由 user_achievements 单独记录。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户成就进度字段、类型转换与用户关联。
*/
class UserAchievementProgress extends Model
{
/** @use HasFactory<\Database\Factories\UserAchievementProgressFactory> */
use HasFactory;
/**
* 对应的数据表名。
*
* @var string
*/
protected $table = 'user_achievement_progress';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'achievement_key',
'progress_value',
'threshold_value',
'last_scanned_at',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'progress_value' => 'integer',
'threshold_value' => 'integer',
'last_scanned_at' => 'datetime',
];
}
/**
* 关联:进度记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+442
View File
@@ -0,0 +1,442 @@
<?php
/**
* 文件功能:用户成就扫描与授予服务。
*
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\BaccaratBet;
use App\Models\DailySignIn;
use App\Models\GomokuGame;
use App\Models\HorseBet;
use App\Models\LotteryTicket;
use App\Models\Marriage;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\PositionDutyLog;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\SlotMachineLog;
use App\Models\User;
use App\Models\UserAchievement;
use App\Models\UserAchievementProgress;
use App\Models\UserCurrencyLog;
use App\Models\UserPosition;
use App\Support\AchievementCatalog;
use Illuminate\Support\Collection;
/**
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
*/
class AchievementService
{
/**
* 创建成就服务依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 扫描单个用户的所有固定成就。
*
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
*/
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
{
$progress = $this->progressForUser($user);
$checked = 0;
$unlocked = 0;
$updated = 0;
foreach (AchievementCatalog::definitions() as $definition) {
$checked++;
$value = (int) ($progress[$definition['metric']] ?? 0);
$achievement = UserAchievement::query()
->where('user_id', $user->id)
->where('achievement_key', $definition['key'])
->first();
if ($dryRun) {
if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) {
$unlocked++;
}
continue;
}
$this->storeProgress($user, $definition, $value);
if (! $achievement) {
$achievement = UserAchievement::query()->create([
'user_id' => $user->id,
'achievement_key' => $definition['key'],
'progress_value' => $value,
'metadata' => ['threshold' => $definition['threshold']],
]);
$updated++;
} elseif ($achievement->progress_value !== $value) {
$achievement->forceFill(['progress_value' => $value])->save();
$updated++;
}
if ($value < $definition['threshold'] || $achievement->achieved_at) {
continue;
}
$achievement->forceFill([
'progress_value' => $value,
'achieved_at' => now(),
'metadata' => ['threshold' => $definition['threshold']],
])->save();
$unlocked++;
if ($notify) {
$this->notifyUnlocked($user, $achievement, $definition);
}
}
return [
'checked' => $checked,
'unlocked' => $unlocked,
'updated' => $updated,
'dry_run' => $dryRun,
];
}
/**
* 批量扫描用户成就。
*
* @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool}
*/
public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array
{
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
foreach ($users as $user) {
$result = $this->scanUser($user, $notify, $dryRun);
$summary['users']++;
$summary['checked'] += $result['checked'];
$summary['unlocked'] += $result['unlocked'];
$summary['updated'] += $result['updated'];
}
return $summary;
}
/**
* 组装用户成就展示数据。
*
* @return array{categories: array<string, string>, achievements: Collection<int, array<string, mixed>>, unlocked_count: int, total_count: int}
*/
public function displayForUser(User $user): array
{
$progress = $this->progressForUser($user);
$records = UserAchievement::query()
->where('user_id', $user->id)
->get()
->keyBy('achievement_key');
$achievements = collect(AchievementCatalog::definitions())
->sortBy('sort')
->map(function (array $definition) use ($progress, $records): array {
$record = $records->get($definition['key']);
$value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0));
$threshold = (int) $definition['threshold'];
return [
...$definition,
'progress_value' => $value,
'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100,
'achieved_at' => $record?->achieved_at,
'unlocked' => (bool) $record?->achieved_at,
];
})
->values();
return [
'categories' => AchievementCatalog::categories(),
'achievements' => $achievements,
'unlocked_count' => $achievements->where('unlocked', true)->count(),
'total_count' => $achievements->count(),
];
}
/**
* 读取用户最近解锁成就。
*
* @return Collection<int, array<string, mixed>>
*/
public function recentUnlockedForUser(User $user, int $limit = 5): Collection
{
return UserAchievement::query()
->where('user_id', $user->id)
->whereNotNull('achieved_at')
->latest('achieved_at')
->limit($limit)
->get()
->map(function (UserAchievement $achievement): array {
$definition = AchievementCatalog::find($achievement->achievement_key);
return [
'key' => $achievement->achievement_key,
'name' => $definition['name'] ?? $achievement->achievement_key,
'icon' => $definition['icon'] ?? '🏅',
'description' => $definition['description'] ?? '',
'achieved_at' => $achievement->achieved_at?->toDateTimeString(),
];
});
}
/**
* 读取用户资料卡使用的成就摘要。
*
* @return array{unlocked_count: int, total_count: int, recent: array<int, array<string, mixed>>}
*/
public function profileSummaryForUser(User $user): array
{
return [
'unlocked_count' => (int) UserAchievement::query()
->where('user_id', $user->id)
->whereNotNull('achieved_at')
->count(),
'total_count' => count(AchievementCatalog::definitions()),
'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(),
];
}
/**
* 聚合单个用户所有成就进度。
*
* @return array<string, int>
*/
public function progressForUser(User $user): array
{
$username = (string) $user->username;
return [
'chat_messages' => $this->chatMessageCount($username),
'welcome_messages' => $this->welcomeMessageCount($username),
'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(),
'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'),
'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(),
'exp_gain' => $this->currencyGain($user->id, 'exp'),
'gold_gain' => $this->currencyGain($user->id, 'gold'),
'charm_gain' => $this->currencyGain($user->id, 'charm'),
'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb),
'bank_balance' => max(0, (int) $user->bank_jjb),
'game_gold_won' => $this->gameGoldWon($user->id),
'game_gold_lost' => $this->gameGoldLost($user->id),
'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(),
'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(),
'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(),
'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(),
'gomoku_wins' => $this->gomokuWinCount($user->id),
'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value),
'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value),
'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(),
'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(),
'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(),
'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'),
'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value),
'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value),
'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(),
'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60),
'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(),
];
}
/**
* 统计普通用户聊天消息数量。
*/
private function chatMessageCount(string $username): int
{
return (int) Message::query()
->where('from_user', $username)
->whereIn('message_type', ['text', 'image', 'expired_image'])
->where(function ($query) {
$query->where('retention_type', Message::RETENTION_USER_CHAT)
->orWhereNull('retention_type');
})
->count();
}
/**
* 统计用户发出的欢迎动作次数。
*/
private function welcomeMessageCount(string $username): int
{
return (int) Message::query()
->where('from_user', $username)
->where('action', '欢迎')
->count();
}
/**
* 统计指定货币的累计正向获得量。
*/
private function currencyGain(int $userId, string $currency): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', $currency)
->where('amount', '>', 0)
->sum('amount');
}
/**
* 统计指定流水来源次数。
*/
private function currencySourceCount(int $userId, string $source): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('source', $source)
->count();
}
/**
* 统计用户通过游戏相关流水累计赢取的金币。
*/
private function gameGoldWon(int $userId): int
{
return (int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', 'gold')
->where('amount', '>', 0)
->whereIn('source', $this->gameWinSources())
->sum('amount');
}
/**
* 统计用户在游戏相关流水中累计输掉或消耗的金币。
*/
private function gameGoldLost(int $userId): int
{
return abs((int) UserCurrencyLog::query()
->where('user_id', $userId)
->where('currency', 'gold')
->where('amount', '<', 0)
->whereIn('source', $this->gameLossSources())
->sum('amount'));
}
/**
* 返回游戏赢钱来源,用于游戏赢取类成就聚合。
*
* @return array<int, string>
*/
private function gameWinSources(): array
{
return [
CurrencySource::BACCARAT_WIN->value,
CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
CurrencySource::HORSE_WIN->value,
CurrencySource::LOTTERY_WIN->value,
CurrencySource::SLOT_WIN->value,
CurrencySource::FISHING_GAIN->value,
CurrencySource::MYSTERY_BOX->value,
CurrencySource::GOMOKU_WIN->value,
CurrencySource::GAME_REWARD->value,
];
}
/**
* 返回游戏输钱来源,用于游戏输钱类成就聚合。
*
* @return array<int, string>
*/
private function gameLossSources(): array
{
return [
CurrencySource::BACCARAT_BET->value,
CurrencySource::HORSE_BET->value,
CurrencySource::LOTTERY_BUY->value,
CurrencySource::SLOT_SPIN->value,
CurrencySource::SLOT_CURSE->value,
CurrencySource::FISHING_COST->value,
CurrencySource::FORTUNE_COST->value,
CurrencySource::GOMOKU_ENTRY_FEE->value,
CurrencySource::MYSTERY_BOX_TRAP->value,
];
}
/**
* 统计五子棋胜利次数。
*/
private function gomokuWinCount(int $userId): int
{
return (int) GomokuGame::query()
->where('status', 'finished')
->where(function ($query) use ($userId) {
$query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1))
->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2));
})
->count();
}
/**
* 写入用户成就进度快照。
*
* @param array<string, mixed> $definition 成就定义
*/
private function storeProgress(User $user, array $definition, int $value): void
{
UserAchievementProgress::query()->updateOrCreate(
[
'user_id' => $user->id,
'achievement_key' => $definition['key'],
],
[
'progress_value' => $value,
'threshold_value' => (int) $definition['threshold'],
'last_scanned_at' => now(),
],
);
}
/**
* 给用户推送成就解锁通知。
*
* @param array<string, mixed> $definition 成就定义
*/
private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void
{
if ($achievement->notified_at) {
return;
}
$roomId = (int) ($user->room_id ?: 1);
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => $user->username,
'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
'is_secret' => true,
'font_color' => '#ca8a04',
'action' => 'achievement_unlocked',
'retention_type' => Message::RETENTION_SYSTEM_NOTICE,
'toast_notification' => [
'title' => '🏅 成就解锁',
'message' => "{$definition['icon']} {$definition['name']}",
'icon' => '🏅',
'color' => '#ca8a04',
'duration' => 3000,
],
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
$achievement->forceFill(['notified_at' => now()])->save();
}
}
+12 -8
View File
@@ -10,10 +10,13 @@
namespace App\Services; namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\BankLog; use App\Models\BankLog;
use App\Models\User; use App\Models\User;
use App\Models\UserCurrencyLog;
use App\Services\UserCurrencyService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@@ -41,6 +44,7 @@ class AiFinanceService
*/ */
public function __construct( public function __construct(
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {} ) {}
/** /**
@@ -107,15 +111,15 @@ class AiFinanceService
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE; $depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。 // 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
$lockedUser->jjb = self::AVAILABLE_GOLD_RESERVE; $this->currencyService->change($lockedUser, 'gold', -$depositAmount, CurrencySource::BANK_DEPOSIT, "自动存入银行 {$depositAmount} 金币");
$lockedUser->bank_jjb = $bankBefore + $depositAmount;
$lockedUser->save(); $lockedUser->increment('bank_jjb', $depositAmount);
BankLog::create([ BankLog::create([
'user_id' => $lockedUser->id, 'user_id' => $lockedUser->id,
'type' => 'deposit', 'type' => 'deposit',
'amount' => $depositAmount, 'amount' => $depositAmount,
'balance_after' => (int) $lockedUser->bank_jjb, 'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
]); ]);
return array_values(array_filter( return array_values(array_filter(
@@ -162,15 +166,15 @@ class AiFinanceService
} }
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。 // 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
$lockedUser->jjb = $walletGold + $withdrawAmount; $this->currencyService->change($lockedUser, 'gold', $withdrawAmount, CurrencySource::BANK_WITHDRAW, "从银行自动取款 {$withdrawAmount} 金币");
$lockedUser->bank_jjb = $bankGold - $withdrawAmount;
$lockedUser->save(); $lockedUser->decrement('bank_jjb', $withdrawAmount);
BankLog::create([ BankLog::create([
'user_id' => $lockedUser->id, 'user_id' => $lockedUser->id,
'type' => 'withdraw', 'type' => 'withdraw',
'amount' => $withdrawAmount, 'amount' => $withdrawAmount,
'balance_after' => (int) $lockedUser->bank_jjb, 'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
]); ]);
return (int) $lockedUser->jjb >= $targetWalletGold; return (int) $lockedUser->jjb >= $targetWalletGold;
@@ -0,0 +1,103 @@
<?php
/**
* 文件功能:每日游戏净盈利前三榜读服务
*
* 聚合百家乐与赛马当天金币流水,给聊天室顶部悬浮榜提供轻量数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\UserCurrencyLog;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* 类功能:查询百家乐与赛马每日净盈利前三用户。
*/
class DailyGameProfitLeaderboardService
{
/**
* 每日榜单固定称号。
*/
private const TITLES = [
1 => '金库爆破王',
2 => '马桌双修财神',
3 => '金币收割机',
];
/**
* 参与净盈利统计的游戏流水来源。
*/
private const GAME_PROFIT_SOURCES = [
CurrencySource::BACCARAT_BET,
CurrencySource::BACCARAT_WIN,
CurrencySource::HORSE_BET,
CurrencySource::HORSE_WIN,
];
/**
* 获取指定日期的游戏净盈利前三榜。
*
* @return Collection<int, object{rank:int,title:string,user_id:int,username:string,headface_url:string,net_profit:int}>
*/
public function topThree(?string $date = null): Collection
{
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
$cacheKey = 'daily_game_profit_leaderboard:v2:'.$statsDate->toDateString();
return Cache::remember($cacheKey, 300, function () use ($statsDate) {
$rangeStart = $statsDate;
$rangeEnd = $statsDate->addDay();
return UserCurrencyLog::query()
->join('users', 'users.id', '=', 'user_currency_logs.user_id')
->where('user_currency_logs.currency', 'gold')
->whereIn('user_currency_logs.source', array_map(
fn (CurrencySource $source): string => $source->value,
self::GAME_PROFIT_SOURCES
))
->where('user_currency_logs.created_at', '>=', $rangeStart)
->where('user_currency_logs.created_at', '<', $rangeEnd)
->where('users.username', '!=', 'AI小班长')
->groupBy('user_currency_logs.user_id', 'users.username', 'users.usersf')
->havingRaw('SUM(user_currency_logs.amount) > 0')
->orderByRaw('SUM(user_currency_logs.amount) DESC')
->orderBy('user_currency_logs.user_id')
->limit(3)
->selectRaw('user_currency_logs.user_id, users.username, users.usersf, SUM(user_currency_logs.amount) as net_profit')
->get()
->values()
->map(function (object $row, int $index): object {
$rank = $index + 1;
return (object) [
'rank' => $rank,
'title' => self::TITLES[$rank],
'user_id' => (int) $row->user_id,
'username' => (string) $row->username,
'headface_url' => $this->resolveHeadfaceUrl((string) ($row->usersf ?: '1.gif')),
'net_profit' => (int) $row->net_profit,
];
});
});
}
/**
* 解析榜单头像地址。
*/
private function resolveHeadfaceUrl(string $headface): string
{
if (str_starts_with($headface, 'storage/')) {
return '/'.$headface;
}
return '/images/headface/'.strtolower($headface);
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* 文件功能:游戏下注与奖励公屏右下角通知广播服务
*
* 统一处理百家乐、赛马、双色球等游戏下注或奖励领取成功后的公屏消息、
* 右下角 Toast 通知载荷和异步落库,避免各玩法重复拼装广播结构。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
/**
* 类功能:为游戏下注和奖励领取成功事件生成并广播全员可见通知。
*/
class GameBetBroadcastService
{
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 广播百家乐下注成功通知。
*/
public function baccarat(int $roomId, string $username, int $amount, string $betLabel): void
{
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🎲 <b>【百家乐】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨",
fontColor: '#d97706',
toastTitle: '🎲 有人下注百家乐',
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$betLabel}",
toastIcon: '🎲',
toastColor: '#d97706',
toastActorUsername: $username,
);
}
/**
* 广播赛马下注成功通知。
*/
public function horseRace(int $roomId, string $username, int $amount, string $horseName): void
{
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🐎 <b>【赛马】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨",
fontColor: '#d97706',
toastTitle: '🐎 有人下注赛马',
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$horseName}",
toastIcon: '🐎',
toastColor: '#d97706',
toastActorUsername: $username,
);
}
/**
* 广播双色球购票成功通知。
*/
public function lottery(int $roomId, string $username, string $issueNo, string $numbersLabel, int $ticketCount): void
{
$moreText = $ticketCount > 1 ? "{$ticketCount} 注号码" : '';
$this->pushBetMessage(
roomId: $roomId,
content: "🎟️ 【{$username}】购买 {$issueNo}{$numbersLabel} {$moreText}",
fontColor: '#dc2626',
toastTitle: '🎟️ 有人购买双色球',
toastMessage: "<b>{$username}</b> 购买 {$issueNo}{$numbersLabel} {$moreText}",
toastIcon: '🎟️',
toastColor: '#dc2626',
action: '大声宣告',
toastActorUsername: $username,
);
}
/**
* 广播红包领取成功通知。
*/
public function redPacketClaimed(int $roomId, string $username, int $amount, string $type): void
{
$typeLabel = $type === 'exp' ? '经验' : '金币';
$typeIcon = $type === 'exp' ? '✨' : '💰';
$toastColor = $type === 'exp' ? '#6d28d9' : '#d97706';
$formattedAmount = number_format($amount);
$this->pushBetMessage(
roomId: $roomId,
content: "🧧 <b>{$username}</b> 抢到了 <b>{$formattedAmount}</b> {$typeLabel}礼包!{$typeIcon}",
fontColor: $toastColor,
toastTitle: '🧧 有人领取红包',
toastMessage: "<b>{$username}</b> 抢到 <b>{$formattedAmount}</b> {$typeLabel}礼包",
toastIcon: '🧧',
toastColor: $toastColor,
toastActorUsername: $username,
skipToastForActor: true,
);
}
/**
* 推送带右下角通知载荷的公屏游戏消息。
*/
private function pushBetMessage(
int $roomId,
string $content,
string $fontColor,
string $toastTitle,
string $toastMessage,
string $toastIcon,
string $toastColor,
string $action = '',
?string $toastActorUsername = null,
bool $skipToastForActor = false,
): void {
$toastNotification = [
'title' => $toastTitle,
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 3000,
];
if ($toastActorUsername !== null) {
// 记录触发人用于前端去重,避免本人同时看到本地到账提示和公屏领取提示。
$toastNotification['actor_username'] = $toastActorUsername;
$toastNotification['skip_for_actor'] = $skipToastForActor;
}
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $fontColor,
'action' => $action,
'sent_at' => now()->toDateTimeString(),
'toast_notification' => $toastNotification,
];
// 下注通知必须进房间 Presence 频道,确保当前房间所有在线人员都能看到右下角提示。
$this->chatState->pushMessage($roomId, $message);
event(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
}
}
@@ -45,6 +45,39 @@ class HolidayEventScheduleService
$currentSendAt = CarbonImmutable::instance($event->send_at); $currentSendAt = CarbonImmutable::instance($event->send_at);
return $this->nextOccurrenceAfter($event, $currentSendAt);
}
/**
* 跳过已经超过领取窗口的历史计划点。
*/
public function skipExpiredOccurrences(HolidayEvent $event, CarbonInterface $reference): ?CarbonImmutable
{
if ($event->send_at === null) {
return null;
}
$candidate = CarbonImmutable::instance($event->send_at);
$referenceTime = CarbonImmutable::instance($reference);
$expireMinutes = max(0, (int) $event->expire_minutes);
while ($candidate->addMinutes($expireMinutes)->lessThanOrEqualTo($referenceTime)) {
// 历史批次的领取窗口已经结束,只推进调度指针,不能补发金币。
$candidate = $this->nextOccurrenceAfter($event, $candidate);
if ($candidate === null) {
return null;
}
}
return $candidate;
}
/**
* 计算指定计划点之后的下一次触发时间。
*/
private function nextOccurrenceAfter(HolidayEvent $event, CarbonImmutable $currentSendAt): ?CarbonImmutable
{
return match ($event->repeat_type) { return match ($event->repeat_type) {
'daily' => $currentSendAt->addDay(), 'daily' => $currentSendAt->addDay(),
'weekly' => $currentSendAt->addWeek(), 'weekly' => $currentSendAt->addWeek(),
+2 -2
View File
@@ -31,6 +31,7 @@ class LotteryService
private readonly UserCurrencyService $currency, private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState, private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService, private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {} ) {}
// ─── 购票 ───────────────────────────────────────────────────────── // ─── 购票 ─────────────────────────────────────────────────────────
@@ -139,8 +140,7 @@ class LotteryService
// 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票) // 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票)
$firstTicket = $tickets[0]; $firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel(); $numsStr = $firstTicket->numbersLabel();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : ''; $this->betBroadcastService->lottery((int) $issue->room_id, $user->username, $issue->issue_no, $numsStr, $buyCount);
$this->pushSystemMessage("🎟️ 【{$user->username}】购买 {$issue->issue_no}{$numsStr} {$moreStr}", (int) $issue->room_id);
return $tickets; return $tickets;
} }
+66 -7
View File
@@ -20,6 +20,35 @@ class MessageFilterService
'外挂', '刷单', '脚本', // 示例黑名单 '外挂', '刷单', '脚本', // 示例黑名单
]; ];
/**
* Trie 字典树实例,用于 DFA 过滤
*/
private ?array $trie = null;
/**
* 构建 Trie 字典树
*/
private function buildTrie(): void
{
if ($this->trie !== null) {
return;
}
$this->trie = [];
foreach ($this->badWords as $word) {
$temp = &$this->trie;
$len = mb_strlen($word);
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($word, $i, 1);
if (! isset($temp[$char])) {
$temp[$char] = [];
}
$temp = &$temp[$char];
}
$temp['is_end'] = true;
}
}
/** /**
* 执行过滤净化,保障入库和显示安全。 * 执行过滤净化,保障入库和显示安全。
* *
@@ -35,16 +64,46 @@ class MessageFilterService
// 1. HTML 标签全量脱除,阻绝任意 XSS/HTML 注入 // 1. HTML 标签全量脱除,阻绝任意 XSS/HTML 注入
$content = strip_tags($content); $content = strip_tags($content);
// 2. 敏感词替换 // 2. 惰性初始化并构建 DFA 字典树
foreach ($this->badWords as $word) { $this->buildTrie();
if (mb_strpos($content, $word) !== false) {
// 将脏字替换为相同长度的 星号 或 提示 // 3. 将字符串转为多字节字符数组,进行 DFA 扫描与替换
$replacement = str_repeat('*', mb_strlen($word)); $len = mb_strlen($content);
$content = str_replace($word, $replacement, $content); $chars = [];
for ($i = 0; $i < $len; $i++) {
$chars[] = mb_substr($content, $i, 1);
}
$result = [];
$i = 0;
while ($i < $len) {
$temp = &$this->trie;
$matchLength = 0;
$j = $i;
while ($j < $len && isset($temp[$chars[$j]])) {
$temp = &$temp[$chars[$j]];
if (isset($temp['is_end']) && $temp['is_end'] === true) {
$matchLength = $j - $i + 1; // 匹配到最长敏感词
}
$j++;
}
if ($matchLength > 0) {
// 替换为相同长度的 *
for ($k = 0; $k < $matchLength; $k++) {
$result[] = '*';
}
$i += $matchLength; // 跳过敏感词
} else {
$result[] = $chars[$i];
$i++;
} }
} }
// 3. 将连续的空格去重,只保留一个真正的空格 $content = implode('', $result);
// 4. 将连续的空格去重,只保留一个真正的空格
$content = preg_replace('/\s+/', ' ', $content); $content = preg_replace('/\s+/', ' ', $content);
return trim($content); return trim($content);
+19 -7
View File
@@ -65,21 +65,30 @@ class UserCurrencyService
} }
DB::transaction(function () use ($user, $currency, $amount, $source, $remark, $roomId, $field) { DB::transaction(function () use ($user, $currency, $amount, $source, $remark, $roomId, $field) {
// 原子性更新用户属性(用 increment/decrement 防并发竞态 // 原子性更新用户属性(用 lockForUpdate 悲观锁锁定对应的数据库行
$lockedUser = User::query()
->whereKey($user->id)
->lockForUpdate()
->firstOrFail();
if ($amount > 0) { if ($amount > 0) {
$user->increment($field, $amount); $lockedUser->increment($field, $amount);
} else { } else {
// 扣除时不让余额低于 0 // 扣除时不让余额低于 0,基于锁定的最新数据计算
$user->decrement($field, min(abs($amount), $user->$field ?? 0)); $currentBalance = (int) $lockedUser->$field;
$deductAmount = min(abs($amount), $currentBalance);
if ($deductAmount > 0) {
$lockedUser->decrement($field, $deductAmount);
}
} }
// 重新读取最新余额(避免缓存脏数据) // 重新读取最新余额(避免缓存脏数据)
$balanceAfter = (int) $user->fresh()->$field; $balanceAfter = (int) $lockedUser->fresh()->$field;
// 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名) // 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名)
UserCurrencyLog::create([ UserCurrencyLog::create([
'user_id' => $user->id, 'user_id' => $lockedUser->id,
'username' => $user->username, 'username' => $lockedUser->username,
'currency' => $currency, 'currency' => $currency,
'amount' => $amount, 'amount' => $amount,
'balance_after' => $balanceAfter, 'balance_after' => $balanceAfter,
@@ -87,6 +96,9 @@ class UserCurrencyService
'remark' => $remark, 'remark' => $remark,
'room_id' => $roomId, 'room_id' => $roomId,
]); ]);
// 同步修改内存中 $user 的实例属性,避免后续逻辑拿到过期的数据
$user->setAttribute($field, $balanceAfter);
}); });
} }
+229
View File
@@ -0,0 +1,229 @@
<?php
/**
* 文件功能:聊天室固定成就目录。
*
* 第一版成就规则全部写在代码里,避免过早引入后台规则引擎。
*/
namespace App\Support;
/**
* 类功能:集中提供成就定义、分类与展示文案。
*/
class AchievementCatalog
{
/**
* 返回全部成就定义。
*
* @return array<string, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}>
*/
public static function definitions(): array
{
$definitions = [
['key' => 'chat_first_message', 'category' => 'chat', 'name' => '初来乍到', 'icon' => '💬', 'description' => '发送第一条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1, 'sort' => 10],
['key' => 'chat_100_messages', 'category' => 'chat', 'name' => '百句达人', 'icon' => '🗣️', 'description' => '累计发送 100 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100, 'sort' => 20],
['key' => 'chat_500_messages', 'category' => 'chat', 'name' => '话题熟客', 'icon' => '📢', 'description' => '累计发送 500 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 500, 'sort' => 30],
['key' => 'chat_1000_messages', 'category' => 'chat', 'name' => '千句常驻', 'icon' => '📣', 'description' => '累计发送 1000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1000, 'sort' => 40],
['key' => 'chat_5000_messages', 'category' => 'chat', 'name' => '五千热聊', 'icon' => '🔥', 'description' => '累计发送 5000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 5000, 'sort' => 50],
['key' => 'chat_10000_messages', 'category' => 'chat', 'name' => '万句元老', 'icon' => '🏛️', 'description' => '累计发送 10000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 10000, 'sort' => 60],
['key' => 'chat_50000_messages', 'category' => 'chat', 'name' => '五万传声', 'icon' => '📡', 'description' => '累计发送 50000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 50000, 'sort' => 70],
['key' => 'chat_100000_messages', 'category' => 'chat', 'name' => '十万回响', 'icon' => '🌌', 'description' => '累计发送 100000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100000, 'sort' => 80],
['key' => 'chat_welcome_10', 'category' => 'chat', 'name' => '迎新助手', 'icon' => '🙋', 'description' => '累计欢迎他人 10 次', 'metric' => 'welcome_messages', 'threshold' => 10, 'sort' => 90],
['key' => 'chat_welcome_50', 'category' => 'chat', 'name' => '欢迎达人', 'icon' => '👋', 'description' => '累计欢迎他人 50 次', 'metric' => 'welcome_messages', 'threshold' => 50, 'sort' => 100],
['key' => 'chat_welcome_100', 'category' => 'chat', 'name' => '迎宾队长', 'icon' => '🎉', 'description' => '累计欢迎他人 100 次', 'metric' => 'welcome_messages', 'threshold' => 100, 'sort' => 110],
['key' => 'chat_welcome_500', 'category' => 'chat', 'name' => '满堂迎客', 'icon' => '🏮', 'description' => '累计欢迎他人 500 次', 'metric' => 'welcome_messages', 'threshold' => 500, 'sort' => 120],
['key' => 'signin_total_1', 'category' => 'sign_in', 'name' => '首次打卡', 'icon' => '☀️', 'description' => '累计签到 1 天', 'metric' => 'total_sign_ins', 'threshold' => 1, 'sort' => 130],
['key' => 'signin_total_7', 'category' => 'sign_in', 'name' => '一周到场', 'icon' => '🗓️', 'description' => '累计签到 7 天', 'metric' => 'total_sign_ins', 'threshold' => 7, 'sort' => 140],
['key' => 'signin_total_30', 'category' => 'sign_in', 'name' => '月度出勤', 'icon' => '📆', 'description' => '累计签到 30 天', 'metric' => 'total_sign_ins', 'threshold' => 30, 'sort' => 150],
['key' => 'signin_total_100', 'category' => 'sign_in', 'name' => '百日足迹', 'icon' => '👣', 'description' => '累计签到 100 天', 'metric' => 'total_sign_ins', 'threshold' => 100, 'sort' => 160],
['key' => 'signin_total_365', 'category' => 'sign_in', 'name' => '年度常客', 'icon' => '🏅', 'description' => '累计签到 365 天', 'metric' => 'total_sign_ins', 'threshold' => 365, 'sort' => 170],
['key' => 'signin_3_streak', 'category' => 'sign_in', 'name' => '三日连到', 'icon' => '✅', 'description' => '连续签到 3 天', 'metric' => 'sign_in_streak', 'threshold' => 3, 'sort' => 180],
['key' => 'signin_7_streak', 'category' => 'sign_in', 'name' => '七日不断', 'icon' => '☑️', 'description' => '连续签到 7 天', 'metric' => 'sign_in_streak', 'threshold' => 7, 'sort' => 190],
['key' => 'signin_15_streak', 'category' => 'sign_in', 'name' => '半月不断', 'icon' => '🌙', 'description' => '连续签到 15 天', 'metric' => 'sign_in_streak', 'threshold' => 15, 'sort' => 200],
['key' => 'signin_30_streak', 'category' => 'sign_in', 'name' => '月度全勤', 'icon' => '📅', 'description' => '连续签到 30 天', 'metric' => 'sign_in_streak', 'threshold' => 30, 'sort' => 210],
['key' => 'signin_60_streak', 'category' => 'sign_in', 'name' => '双月坚守', 'icon' => '🔥', 'description' => '连续签到 60 天', 'metric' => 'sign_in_streak', 'threshold' => 60, 'sort' => 220],
['key' => 'signin_100_streak', 'category' => 'sign_in', 'name' => '百日坚持', 'icon' => '💯', 'description' => '连续签到 100 天', 'metric' => 'sign_in_streak', 'threshold' => 100, 'sort' => 230],
['key' => 'signin_365_streak', 'category' => 'sign_in', 'name' => '全年不断', 'icon' => '🏆', 'description' => '连续签到 365 天', 'metric' => 'sign_in_streak', 'threshold' => 365, 'sort' => 240],
['key' => 'signin_makeup_used', 'category' => 'sign_in', 'name' => '补签救场', 'icon' => '🧩', 'description' => '使用过 1 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 1, 'sort' => 250],
['key' => 'signin_makeup_5', 'category' => 'sign_in', 'name' => '补签老手', 'icon' => '🪄', 'description' => '累计使用 5 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 5, 'sort' => 260],
['key' => 'signin_makeup_20', 'category' => 'sign_in', 'name' => '断线重连', 'icon' => '🔁', 'description' => '累计使用 20 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 20, 'sort' => 270],
['key' => 'growth_exp_10000', 'category' => 'growth', 'name' => '小有所成', 'icon' => '✨', 'description' => '累计获得 10000 经验', 'metric' => 'exp_gain', 'threshold' => 10000, 'sort' => 210],
['key' => 'growth_gold_100000', 'category' => 'growth', 'name' => '金币新贵', 'icon' => '💰', 'description' => '累计获得 100000 金币', 'metric' => 'gold_gain', 'threshold' => 100000, 'sort' => 220],
['key' => 'growth_charm_1000', 'category' => 'growth', 'name' => '魅力初显', 'icon' => '🌸', 'description' => '累计获得 1000 魅力', 'metric' => 'charm_gain', 'threshold' => 1000, 'sort' => 230],
['key' => 'growth_assets_1000000', 'category' => 'growth', 'name' => '百万身家', 'icon' => '💎', 'description' => '金币资产达到 1000000', 'metric' => 'gold_assets', 'threshold' => 1000000, 'sort' => 240],
['key' => 'growth_assets_10000000', 'category' => 'growth', 'name' => '千万富豪', 'icon' => '👑', 'description' => '金币资产达到 10000000', 'metric' => 'gold_assets', 'threshold' => 10000000, 'sort' => 250],
['key' => 'growth_assets_100000000', 'category' => 'growth', 'name' => '亿级资产', 'icon' => '🏆', 'description' => '金币资产达到 100000000', 'metric' => 'gold_assets', 'threshold' => 100000000, 'sort' => 260],
['key' => 'growth_bank_500000', 'category' => 'growth', 'name' => '存款达人', 'icon' => '🏦', 'description' => '银行存款达到 500000 金币', 'metric' => 'bank_balance', 'threshold' => 500000, 'sort' => 270],
['key' => 'growth_bank_1000000', 'category' => 'growth', 'name' => '百万存款', 'icon' => '🏧', 'description' => '银行存款达到 1000000 金币', 'metric' => 'bank_balance', 'threshold' => 1000000, 'sort' => 280],
['key' => 'growth_bank_10000000', 'category' => 'growth', 'name' => '金库存户', 'icon' => '🔐', 'description' => '银行存款达到 10000000 金币', 'metric' => 'bank_balance', 'threshold' => 10000000, 'sort' => 290],
['key' => 'game_baccarat_20', 'category' => 'game', 'name' => '百家乐入门', 'icon' => '🎲', 'description' => '累计参与百家乐下注 20 次', 'metric' => 'baccarat_bets', 'threshold' => 20, 'sort' => 310],
['key' => 'game_horse_20', 'category' => 'game', 'name' => '赛马看客', 'icon' => '🐎', 'description' => '累计参与赛马下注 20 次', 'metric' => 'horse_bets', 'threshold' => 20, 'sort' => 320],
['key' => 'game_lottery_20', 'category' => 'game', 'name' => '双色球常客', 'icon' => '🎟️', 'description' => '累计购买双色球 20 注', 'metric' => 'lottery_tickets', 'threshold' => 20, 'sort' => 330],
['key' => 'game_slot_20', 'category' => 'game', 'name' => '老虎机试手', 'icon' => '🎰', 'description' => '累计转动老虎机 20 次', 'metric' => 'slot_spins', 'threshold' => 20, 'sort' => 340],
['key' => 'game_gomoku_win', 'category' => 'game', 'name' => '五子棋首胜', 'icon' => '♟️', 'description' => '获得 1 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 1, 'sort' => 350],
['key' => 'game_fishing_20', 'category' => 'game', 'name' => '垂钓小能手', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 20 次', 'metric' => 'fishing_times', 'threshold' => 20, 'sort' => 360],
['key' => 'game_riddle_win', 'category' => 'game', 'name' => '猜谜破题', 'icon' => '🧠', 'description' => '成功答对 1 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 1, 'sort' => 370],
['key' => 'game_win_1000', 'category' => 'game', 'name' => '小赚一笔', 'icon' => '🪙', 'description' => '游戏累计赢取 1000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000, 'sort' => 380],
['key' => 'game_win_10000', 'category' => 'game', 'name' => '手气渐热', 'icon' => '💵', 'description' => '游戏累计赢取 10000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000, 'sort' => 390],
['key' => 'game_win_100000', 'category' => 'game', 'name' => '十万进账', 'icon' => '💰', 'description' => '游戏累计赢取 100000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000, 'sort' => 400],
['key' => 'game_win_1000000', 'category' => 'game', 'name' => '百万赢家', 'icon' => '🏆', 'description' => '游戏累计赢取 1000000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000000, 'sort' => 410],
['key' => 'game_win_10000000', 'category' => 'game', 'name' => '千万胜手', 'icon' => '👑', 'description' => '游戏累计赢取 10000000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000000, 'sort' => 420],
['key' => 'game_loss_1000', 'category' => 'game', 'name' => '小输当练', 'icon' => '🧾', 'description' => '游戏累计输掉 1000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000, 'sort' => 430],
['key' => 'game_loss_10000', 'category' => 'game', 'name' => '万金试炼', 'icon' => '📉', 'description' => '游戏累计输掉 10000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000, 'sort' => 440],
['key' => 'game_loss_100000', 'category' => 'game', 'name' => '十万学费', 'icon' => '🎒', 'description' => '游戏累计输掉 100000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000, 'sort' => 450],
['key' => 'game_loss_1000000', 'category' => 'game', 'name' => '百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 1000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000000, 'sort' => 460],
['key' => 'game_loss_10000000', 'category' => 'game', 'name' => '千万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 10000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000000, 'sort' => 470],
['key' => 'social_red_packet_sent', 'category' => 'social', 'name' => '慷慨发包', 'icon' => '🧧', 'description' => '发送过 1 次红包', 'metric' => 'red_packets_sent', 'threshold' => 1, 'sort' => 410],
['key' => 'social_red_packet_claimed', 'category' => 'social', 'name' => '手气不错', 'icon' => '🙌', 'description' => '领取过 1 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 1, 'sort' => 420],
['key' => 'social_married', 'category' => 'social', 'name' => '情定聊天室', 'icon' => '💍', 'description' => '完成一次结婚', 'metric' => 'marriages', 'threshold' => 1, 'sort' => 430],
['key' => 'social_intimacy_1000', 'category' => 'social', 'name' => '亲密搭档', 'icon' => '💞', 'description' => '婚姻亲密度达到 1000', 'metric' => 'marriage_intimacy', 'threshold' => 1000, 'sort' => 440],
['key' => 'social_gift_sent', 'category' => 'social', 'name' => '赠礼之友', 'icon' => '🎁', 'description' => '送出过 1 次礼物', 'metric' => 'gifts_sent', 'threshold' => 1, 'sort' => 450],
['key' => 'social_gift_received', 'category' => 'social', 'name' => '人气收礼', 'icon' => '💐', 'description' => '收到过 1 次礼物', 'metric' => 'gifts_received', 'threshold' => 1, 'sort' => 460],
['key' => 'duty_first_position', 'category' => 'duty', 'name' => '首次任命', 'icon' => '🎖️', 'description' => '获得过 1 次职务任命', 'metric' => 'positions', 'threshold' => 1, 'sort' => 510],
['key' => 'duty_60_minutes', 'category' => 'duty', 'name' => '勤务一小时', 'icon' => '⏱️', 'description' => '累计值班 60 分钟', 'metric' => 'duty_minutes', 'threshold' => 60, 'sort' => 520],
['key' => 'duty_admin_action', 'category' => 'duty', 'name' => '管理出手', 'icon' => '🛡️', 'description' => '执行过 1 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1, 'sort' => 530],
];
$definitions = array_merge($definitions, self::extendedTierDefinitions());
return collect($definitions)->keyBy('key')->all();
}
/**
* 返回长期运营需要的扩展阶梯成就。
*
* @return array<int, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int}>
*/
private static function extendedTierDefinitions(): array
{
return [
['key' => 'chat_2000_messages', 'category' => 'chat', 'name' => '两千连珠', 'icon' => '🧵', 'description' => '累计发送 2000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 2000, 'sort' => 45],
['key' => 'chat_20000_messages', 'category' => 'chat', 'name' => '两万谈资', 'icon' => '🛰️', 'description' => '累计发送 20000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 20000, 'sort' => 65],
['key' => 'chat_200000_messages', 'category' => 'chat', 'name' => '二十万长谈', 'icon' => '🌠', 'description' => '累计发送 200000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 200000, 'sort' => 85],
['key' => 'chat_300000_messages', 'category' => 'chat', 'name' => '三十万星河', 'icon' => '🌌', 'description' => '累计发送 300000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 300000, 'sort' => 86],
['key' => 'chat_welcome_1000', 'category' => 'chat', 'name' => '千次迎客', 'icon' => '🎊', 'description' => '累计欢迎他人 1000 次', 'metric' => 'welcome_messages', 'threshold' => 1000, 'sort' => 121],
['key' => 'chat_welcome_3000', 'category' => 'chat', 'name' => '迎宾长明灯', 'icon' => '🏵️', 'description' => '累计欢迎他人 3000 次', 'metric' => 'welcome_messages', 'threshold' => 3000, 'sort' => 122],
['key' => 'signin_total_60', 'category' => 'sign_in', 'name' => '两月足迹', 'icon' => '📍', 'description' => '累计签到 60 天', 'metric' => 'total_sign_ins', 'threshold' => 60, 'sort' => 155],
['key' => 'signin_total_180', 'category' => 'sign_in', 'name' => '半年到场', 'icon' => '🧭', 'description' => '累计签到 180 天', 'metric' => 'total_sign_ins', 'threshold' => 180, 'sort' => 165],
['key' => 'signin_total_730', 'category' => 'sign_in', 'name' => '两年常驻', 'icon' => '🏕️', 'description' => '累计签到 730 天', 'metric' => 'total_sign_ins', 'threshold' => 730, 'sort' => 171],
['key' => 'signin_total_1000', 'category' => 'sign_in', 'name' => '千日留名', 'icon' => '📜', 'description' => '累计签到 1000 天', 'metric' => 'total_sign_ins', 'threshold' => 1000, 'sort' => 172],
['key' => 'signin_180_streak', 'category' => 'sign_in', 'name' => '半年不断', 'icon' => '🧱', 'description' => '连续签到 180 天', 'metric' => 'sign_in_streak', 'threshold' => 180, 'sort' => 241],
['key' => 'signin_730_streak', 'category' => 'sign_in', 'name' => '两年不断', 'icon' => '🗻', 'description' => '连续签到 730 天', 'metric' => 'sign_in_streak', 'threshold' => 730, 'sort' => 242],
['key' => 'signin_makeup_50', 'category' => 'sign_in', 'name' => '时光修补匠', 'icon' => '🧵', 'description' => '累计使用 50 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 50, 'sort' => 271],
['key' => 'growth_exp_50000', 'category' => 'growth', 'name' => '经验老练', 'icon' => '🌟', 'description' => '累计获得 50000 经验', 'metric' => 'exp_gain', 'threshold' => 50000, 'sort' => 211],
['key' => 'growth_exp_100000', 'category' => 'growth', 'name' => '十万经验', 'icon' => '🎓', 'description' => '累计获得 100000 经验', 'metric' => 'exp_gain', 'threshold' => 100000, 'sort' => 212],
['key' => 'growth_exp_500000', 'category' => 'growth', 'name' => '经验厚积', 'icon' => '📚', 'description' => '累计获得 500000 经验', 'metric' => 'exp_gain', 'threshold' => 500000, 'sort' => 213],
['key' => 'growth_exp_1000000', 'category' => 'growth', 'name' => '百万经验', 'icon' => '🏫', 'description' => '累计获得 1000000 经验', 'metric' => 'exp_gain', 'threshold' => 1000000, 'sort' => 214],
['key' => 'growth_gold_500000', 'category' => 'growth', 'name' => '半百万进账', 'icon' => '💴', 'description' => '累计获得 500000 金币', 'metric' => 'gold_gain', 'threshold' => 500000, 'sort' => 221],
['key' => 'growth_gold_1000000', 'category' => 'growth', 'name' => '百万进账', 'icon' => '💵', 'description' => '累计获得 1000000 金币', 'metric' => 'gold_gain', 'threshold' => 1000000, 'sort' => 222],
['key' => 'growth_gold_5000000', 'category' => 'growth', 'name' => '五百万进账', 'icon' => '💶', 'description' => '累计获得 5000000 金币', 'metric' => 'gold_gain', 'threshold' => 5000000, 'sort' => 223],
['key' => 'growth_gold_10000000', 'category' => 'growth', 'name' => '千万进账', 'icon' => '💷', 'description' => '累计获得 10000000 金币', 'metric' => 'gold_gain', 'threshold' => 10000000, 'sort' => 224],
['key' => 'growth_gold_100000000', 'category' => 'growth', 'name' => '亿级进账', 'icon' => '🪙', 'description' => '累计获得 100000000 金币', 'metric' => 'gold_gain', 'threshold' => 100000000, 'sort' => 225],
['key' => 'growth_charm_5000', 'category' => 'growth', 'name' => '魅力上扬', 'icon' => '🌺', 'description' => '累计获得 5000 魅力', 'metric' => 'charm_gain', 'threshold' => 5000, 'sort' => 231],
['key' => 'growth_charm_10000', 'category' => 'growth', 'name' => '万点魅力', 'icon' => '💐', 'description' => '累计获得 10000 魅力', 'metric' => 'charm_gain', 'threshold' => 10000, 'sort' => 232],
['key' => 'growth_charm_50000', 'category' => 'growth', 'name' => '魅力满堂', 'icon' => '🪷', 'description' => '累计获得 50000 魅力', 'metric' => 'charm_gain', 'threshold' => 50000, 'sort' => 233],
['key' => 'growth_charm_100000', 'category' => 'growth', 'name' => '十万魅力', 'icon' => '👒', 'description' => '累计获得 100000 魅力', 'metric' => 'charm_gain', 'threshold' => 100000, 'sort' => 234],
['key' => 'growth_assets_5000000', 'category' => 'growth', 'name' => '五百万身家', 'icon' => '💍', 'description' => '金币资产达到 5000000', 'metric' => 'gold_assets', 'threshold' => 5000000, 'sort' => 245],
['key' => 'growth_assets_50000000', 'category' => 'growth', 'name' => '五千万资产', 'icon' => '🏦', 'description' => '金币资产达到 50000000', 'metric' => 'gold_assets', 'threshold' => 50000000, 'sort' => 255],
['key' => 'growth_assets_500000000', 'category' => 'growth', 'name' => '五亿资产', 'icon' => '🏛️', 'description' => '金币资产达到 500000000', 'metric' => 'gold_assets', 'threshold' => 500000000, 'sort' => 261],
['key' => 'growth_assets_1000000000', 'category' => 'growth', 'name' => '十亿传说', 'icon' => '🚀', 'description' => '金币资产达到 1000000000', 'metric' => 'gold_assets', 'threshold' => 1000000000, 'sort' => 262],
['key' => 'growth_bank_5000000', 'category' => 'growth', 'name' => '五百万存款', 'icon' => '🧱', 'description' => '银行存款达到 5000000 金币', 'metric' => 'bank_balance', 'threshold' => 5000000, 'sort' => 285],
['key' => 'growth_bank_50000000', 'category' => 'growth', 'name' => '五千万金库', 'icon' => '🏦', 'description' => '银行存款达到 50000000 金币', 'metric' => 'bank_balance', 'threshold' => 50000000, 'sort' => 291],
['key' => 'growth_bank_100000000', 'category' => 'growth', 'name' => '亿级金库', 'icon' => '🔒', 'description' => '银行存款达到 100000000 金币', 'metric' => 'bank_balance', 'threshold' => 100000000, 'sort' => 292],
['key' => 'game_baccarat_100', 'category' => 'game', 'name' => '百局百家乐', 'icon' => '🎲', 'description' => '累计参与百家乐下注 100 次', 'metric' => 'baccarat_bets', 'threshold' => 100, 'sort' => 311],
['key' => 'game_baccarat_500', 'category' => 'game', 'name' => '百家乐熟手', 'icon' => '🃏', 'description' => '累计参与百家乐下注 500 次', 'metric' => 'baccarat_bets', 'threshold' => 500, 'sort' => 312],
['key' => 'game_baccarat_1000', 'category' => 'game', 'name' => '千局庄闲', 'icon' => '🎴', 'description' => '累计参与百家乐下注 1000 次', 'metric' => 'baccarat_bets', 'threshold' => 1000, 'sort' => 313],
['key' => 'game_horse_100', 'category' => 'game', 'name' => '百场赛马', 'icon' => '🏇', 'description' => '累计参与赛马下注 100 次', 'metric' => 'horse_bets', 'threshold' => 100, 'sort' => 321],
['key' => 'game_horse_500', 'category' => 'game', 'name' => '马场熟客', 'icon' => '🎠', 'description' => '累计参与赛马下注 500 次', 'metric' => 'horse_bets', 'threshold' => 500, 'sort' => 322],
['key' => 'game_horse_1000', 'category' => 'game', 'name' => '千场观赛', 'icon' => '🏁', 'description' => '累计参与赛马下注 1000 次', 'metric' => 'horse_bets', 'threshold' => 1000, 'sort' => 323],
['key' => 'game_lottery_100', 'category' => 'game', 'name' => '百注双色球', 'icon' => '🎟️', 'description' => '累计购买双色球 100 注', 'metric' => 'lottery_tickets', 'threshold' => 100, 'sort' => 331],
['key' => 'game_lottery_500', 'category' => 'game', 'name' => '彩池常客', 'icon' => '🔵', 'description' => '累计购买双色球 500 注', 'metric' => 'lottery_tickets', 'threshold' => 500, 'sort' => 332],
['key' => 'game_lottery_1000', 'category' => 'game', 'name' => '千注追梦', 'icon' => '🔴', 'description' => '累计购买双色球 1000 注', 'metric' => 'lottery_tickets', 'threshold' => 1000, 'sort' => 333],
['key' => 'game_slot_100', 'category' => 'game', 'name' => '百转老虎机', 'icon' => '🎰', 'description' => '累计转动老虎机 100 次', 'metric' => 'slot_spins', 'threshold' => 100, 'sort' => 341],
['key' => 'game_slot_500', 'category' => 'game', 'name' => '转轮熟手', 'icon' => '⚙️', 'description' => '累计转动老虎机 500 次', 'metric' => 'slot_spins', 'threshold' => 500, 'sort' => 342],
['key' => 'game_slot_1000', 'category' => 'game', 'name' => '千转不歇', 'icon' => '🔔', 'description' => '累计转动老虎机 1000 次', 'metric' => 'slot_spins', 'threshold' => 1000, 'sort' => 343],
['key' => 'game_gomoku_5_wins', 'category' => 'game', 'name' => '五子五胜', 'icon' => '⚫', 'description' => '获得 5 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 5, 'sort' => 351],
['key' => 'game_gomoku_20_wins', 'category' => 'game', 'name' => '棋盘强手', 'icon' => '⚪', 'description' => '获得 20 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 20, 'sort' => 352],
['key' => 'game_gomoku_100_wins', 'category' => 'game', 'name' => '百胜棋手', 'icon' => '♟️', 'description' => '获得 100 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 100, 'sort' => 353],
['key' => 'game_fishing_100', 'category' => 'game', 'name' => '百竿垂钓', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 100 次', 'metric' => 'fishing_times', 'threshold' => 100, 'sort' => 361],
['key' => 'game_fishing_500', 'category' => 'game', 'name' => '鱼塘熟手', 'icon' => '🐟', 'description' => '累计抛竿钓鱼 500 次', 'metric' => 'fishing_times', 'threshold' => 500, 'sort' => 362],
['key' => 'game_fishing_1000', 'category' => 'game', 'name' => '千竿钓客', 'icon' => '🐠', 'description' => '累计抛竿钓鱼 1000 次', 'metric' => 'fishing_times', 'threshold' => 1000, 'sort' => 363],
['key' => 'game_riddle_10_wins', 'category' => 'game', 'name' => '十题小成', 'icon' => '🧠', 'description' => '成功答对 10 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 10, 'sort' => 371],
['key' => 'game_riddle_50_wins', 'category' => 'game', 'name' => '破题熟手', 'icon' => '💡', 'description' => '成功答对 50 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 50, 'sort' => 372],
['key' => 'game_riddle_200_wins', 'category' => 'game', 'name' => '谜面克星', 'icon' => '📘', 'description' => '成功答对 200 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 200, 'sort' => 373],
['key' => 'game_win_5000', 'category' => 'game', 'name' => '五千到手', 'icon' => '🪙', 'description' => '游戏累计赢取 5000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000, 'sort' => 385],
['key' => 'game_win_50000', 'category' => 'game', 'name' => '五万好运', 'icon' => '💵', 'description' => '游戏累计赢取 50000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000, 'sort' => 395],
['key' => 'game_win_500000', 'category' => 'game', 'name' => '半百万赢家', 'icon' => '💰', 'description' => '游戏累计赢取 500000 金币', 'metric' => 'game_gold_won', 'threshold' => 500000, 'sort' => 405],
['key' => 'game_win_5000000', 'category' => 'game', 'name' => '五百万胜手', 'icon' => '🏆', 'description' => '游戏累计赢取 5000000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000000, 'sort' => 415],
['key' => 'game_win_50000000', 'category' => 'game', 'name' => '五千万战绩', 'icon' => '👑', 'description' => '游戏累计赢取 50000000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000000, 'sort' => 421],
['key' => 'game_win_100000000', 'category' => 'game', 'name' => '亿级赢家', 'icon' => '🌟', 'description' => '游戏累计赢取 100000000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000000, 'sort' => 422],
['key' => 'game_loss_5000', 'category' => 'game', 'name' => '五千试水', 'icon' => '🧾', 'description' => '游戏累计输掉 5000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000, 'sort' => 435],
['key' => 'game_loss_50000', 'category' => 'game', 'name' => '五万起伏', 'icon' => '📉', 'description' => '游戏累计输掉 50000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000, 'sort' => 445],
['key' => 'game_loss_500000', 'category' => 'game', 'name' => '半百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 500000 金币', 'metric' => 'game_gold_lost', 'threshold' => 500000, 'sort' => 455],
['key' => 'game_loss_5000000', 'category' => 'game', 'name' => '五百万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 5000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000000, 'sort' => 465],
['key' => 'game_loss_50000000', 'category' => 'game', 'name' => '五千万风浪', 'icon' => '🌪️', 'description' => '游戏累计输掉 50000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000000, 'sort' => 471],
['key' => 'game_loss_100000000', 'category' => 'game', 'name' => '亿级沉浮', 'icon' => '🕳️', 'description' => '游戏累计输掉 100000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000000, 'sort' => 472],
['key' => 'social_red_packet_sent_10', 'category' => 'social', 'name' => '十次发包', 'icon' => '🧧', 'description' => '累计发送 10 次红包', 'metric' => 'red_packets_sent', 'threshold' => 10, 'sort' => 411],
['key' => 'social_red_packet_sent_50', 'category' => 'social', 'name' => '红包常客', 'icon' => '🎁', 'description' => '累计发送 50 次红包', 'metric' => 'red_packets_sent', 'threshold' => 50, 'sort' => 412],
['key' => 'social_red_packet_sent_100', 'category' => 'social', 'name' => '百包散财', 'icon' => '🏮', 'description' => '累计发送 100 次红包', 'metric' => 'red_packets_sent', 'threshold' => 100, 'sort' => 413],
['key' => 'social_red_packet_claimed_10', 'category' => 'social', 'name' => '十次手气', 'icon' => '🙌', 'description' => '累计领取 10 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 10, 'sort' => 421],
['key' => 'social_red_packet_claimed_50', 'category' => 'social', 'name' => '抢包熟手', 'icon' => '🫴', 'description' => '累计领取 50 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 50, 'sort' => 422],
['key' => 'social_red_packet_claimed_100', 'category' => 'social', 'name' => '百包入手', 'icon' => '🧧', 'description' => '累计领取 100 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 100, 'sort' => 423],
['key' => 'social_intimacy_5000', 'category' => 'social', 'name' => '亲密升温', 'icon' => '💞', 'description' => '婚姻亲密度达到 5000', 'metric' => 'marriage_intimacy', 'threshold' => 5000, 'sort' => 441],
['key' => 'social_intimacy_10000', 'category' => 'social', 'name' => '万点亲密', 'icon' => '💕', 'description' => '婚姻亲密度达到 10000', 'metric' => 'marriage_intimacy', 'threshold' => 10000, 'sort' => 442],
['key' => 'social_intimacy_50000', 'category' => 'social', 'name' => '情深五万', 'icon' => '💖', 'description' => '婚姻亲密度达到 50000', 'metric' => 'marriage_intimacy', 'threshold' => 50000, 'sort' => 443],
['key' => 'social_gift_sent_10', 'category' => 'social', 'name' => '十礼相赠', 'icon' => '🎁', 'description' => '累计送出 10 次礼物', 'metric' => 'gifts_sent', 'threshold' => 10, 'sort' => 451],
['key' => 'social_gift_sent_50', 'category' => 'social', 'name' => '赠礼熟手', 'icon' => '🎀', 'description' => '累计送出 50 次礼物', 'metric' => 'gifts_sent', 'threshold' => 50, 'sort' => 452],
['key' => 'social_gift_sent_100', 'category' => 'social', 'name' => '百礼往来', 'icon' => '💝', 'description' => '累计送出 100 次礼物', 'metric' => 'gifts_sent', 'threshold' => 100, 'sort' => 453],
['key' => 'social_gift_received_10', 'category' => 'social', 'name' => '十礼入怀', 'icon' => '💐', 'description' => '累计收到 10 次礼物', 'metric' => 'gifts_received', 'threshold' => 10, 'sort' => 461],
['key' => 'social_gift_received_50', 'category' => 'social', 'name' => '人气渐盛', 'icon' => '🌹', 'description' => '累计收到 50 次礼物', 'metric' => 'gifts_received', 'threshold' => 50, 'sort' => 462],
['key' => 'social_gift_received_100', 'category' => 'social', 'name' => '百礼人气', 'icon' => '🌷', 'description' => '累计收到 100 次礼物', 'metric' => 'gifts_received', 'threshold' => 100, 'sort' => 463],
['key' => 'duty_3_positions', 'category' => 'duty', 'name' => '多职历练', 'icon' => '🎖️', 'description' => '累计获得 3 次职务任命', 'metric' => 'positions', 'threshold' => 3, 'sort' => 511],
['key' => 'duty_10_positions', 'category' => 'duty', 'name' => '十任履历', 'icon' => '📌', 'description' => '累计获得 10 次职务任命', 'metric' => 'positions', 'threshold' => 10, 'sort' => 512],
['key' => 'duty_300_minutes', 'category' => 'duty', 'name' => '勤务五小时', 'icon' => '⏱️', 'description' => '累计值班 300 分钟', 'metric' => 'duty_minutes', 'threshold' => 300, 'sort' => 521],
['key' => 'duty_600_minutes', 'category' => 'duty', 'name' => '勤务十小时', 'icon' => '🕰️', 'description' => '累计值班 600 分钟', 'metric' => 'duty_minutes', 'threshold' => 600, 'sort' => 522],
['key' => 'duty_3000_minutes', 'category' => 'duty', 'name' => '值班老手', 'icon' => '📋', 'description' => '累计值班 3000 分钟', 'metric' => 'duty_minutes', 'threshold' => 3000, 'sort' => 523],
['key' => 'duty_10000_minutes', 'category' => 'duty', 'name' => '万分钟勤务', 'icon' => '🏢', 'description' => '累计值班 10000 分钟', 'metric' => 'duty_minutes', 'threshold' => 10000, 'sort' => 524],
['key' => 'duty_10_admin_actions', 'category' => 'duty', 'name' => '十次管理', 'icon' => '🛡️', 'description' => '累计执行 10 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 10, 'sort' => 531],
['key' => 'duty_100_admin_actions', 'category' => 'duty', 'name' => '百次管理', 'icon' => '⚖️', 'description' => '累计执行 100 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 100, 'sort' => 532],
['key' => 'duty_1000_admin_actions', 'category' => 'duty', 'name' => '千次执勤', 'icon' => '🏛️', 'description' => '累计执行 1000 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1000, 'sort' => 533],
];
}
/**
* 返回成就分类标题。
*
* @return array<string, string>
*/
public static function categories(): array
{
return [
'chat' => '聊天',
'sign_in' => '签到',
'growth' => '成长',
'game' => '游戏',
'social' => '社交',
'duty' => '职务',
];
}
/**
* 根据 key 获取单个成就定义。
*
* @return array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}|null
*/
public static function find(string $key): ?array
{
return self::definitions()[$key] ?? null;
}
}
+29 -17
View File
@@ -1,5 +1,10 @@
<?php <?php
/**
* 文件功能:Laravel 应用启动配置。
* 负责注册路由、中间件别名、代理信任规则与全局异常响应格式。
*/
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -38,8 +43,12 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->redirectGuestsTo('/'); $middleware->redirectGuestsTo('/');
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$isChatAjaxRequest = static function (Request $request): bool { $isJsonSessionRequest = static function (Request $request): bool {
return $request->expectsJson() && $request->is( if ($request->expectsJson() || $request->ajax()) {
return true;
}
return $request->is(
'room/*/send', 'room/*/send',
'room/*/heartbeat', 'room/*/heartbeat',
'room/*/leave', 'room/*/leave',
@@ -51,25 +60,28 @@ return Application::configure(basePath: dirname(__DIR__))
); );
}; };
// 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向 $expiredSessionResponse = static function () {
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误 return response()->json([
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) { 'status' => 'error',
if ($isChatAjaxRequest($request)) { 'code' => 'SESSION_EXPIRED',
return response()->json([ 'message' => '登录状态已失效,请刷新页面后重新登录。',
'status' => 'error', 'reload' => true,
'message' => '页面已过期,请刷新后重试。', 'login_url' => route('home'),
], 419); ], 419);
};
// CSRF token 失效通常意味着页面还停留在旧会话里;JSON 请求统一返回业务提示,避免泄露框架异常堆栈。
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
if ($isJsonSessionRequest($request)) {
return $expiredSessionResponse();
} }
}); });
// Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException // Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException
// 这里补一层兜底,确保聊天接口始终返回稳定 JSON,而不是默认 HTML 错误页 // 这里补一层兜底,确保接口始终返回稳定 JSON,而不是默认异常结构
$exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isChatAjaxRequest) { $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
if ($e->getStatusCode() === 419 && $isChatAjaxRequest($request)) { if ($e->getStatusCode() === 419 && $isJsonSessionRequest($request)) {
return response()->json([ return $expiredSessionResponse();
'status' => 'error',
'message' => '页面已过期,请刷新后重试。',
], 419);
} }
}); });
})->create(); })->create();
+3
View File
@@ -14,11 +14,14 @@
"intervention/image": "^3.11", "intervention/image": "^3.11",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/horizon": "^5.45", "laravel/horizon": "^5.45",
"laravel/octane": "^2.17",
"laravel/reverb": "^1.8", "laravel/reverb": "^1.8",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"longlang/phpkafka": "^1.2", "longlang/phpkafka": "^1.2",
"mews/captcha": "^3.4", "mews/captcha": "^3.4",
"predis/predis": "^3.4", "predis/predis": "^3.4",
"spiral/roadrunner-cli": "^2.6.0",
"spiral/roadrunner-http": "^3.3.0",
"stevebauman/location": "^7.6", "stevebauman/location": "^7.6",
"zoujingli/ip2region": "^3.0" "zoujingli/ip2region": "^3.0"
}, },
Generated
+1626 -157
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
<?php
use Laravel\Octane\Contracts\OperationTerminated;
use Laravel\Octane\Events\RequestHandled;
use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;
use Laravel\Octane\Events\TaskReceived;
use Laravel\Octane\Events\TaskTerminated;
use Laravel\Octane\Events\TickReceived;
use Laravel\Octane\Events\TickTerminated;
use Laravel\Octane\Events\WorkerErrorOccurred;
use Laravel\Octane\Events\WorkerStarting;
use Laravel\Octane\Events\WorkerStopping;
use Laravel\Octane\Listeners\CloseMonologHandlers;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\DisconnectFromDatabases;
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
return [
/*
|--------------------------------------------------------------------------
| Octane Server
|--------------------------------------------------------------------------
|
| This value determines the default "server" that will be used by Octane
| when starting, restarting, or stopping your server via the CLI. You
| are free to change this to the supported server of your choosing.
|
| Supported: "roadrunner", "swoole", "frankenphp"
|
*/
'server' => env('OCTANE_SERVER', 'roadrunner'),
/*
|--------------------------------------------------------------------------
| Force HTTPS
|--------------------------------------------------------------------------
|
| When this configuration value is set to "true", Octane will inform the
| framework that all absolute links must be generated using the HTTPS
| protocol. Otherwise your links may be generated using plain HTTP.
|
*/
'https' => env('OCTANE_HTTPS', false),
/*
|--------------------------------------------------------------------------
| Octane Listeners
|--------------------------------------------------------------------------
|
| All of the event listeners for Octane's events are defined below. These
| listeners are responsible for resetting your application's state for
| the next request. You may even add your own listeners to the list.
|
*/
'listeners' => [
WorkerStarting::class => [
EnsureUploadedFilesAreValid::class,
EnsureUploadedFilesCanBeMoved::class,
],
RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
//
],
RequestHandled::class => [
//
],
RequestTerminated::class => [
// FlushUploadedFiles::class,
],
TaskReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TaskTerminated::class => [
//
],
TickReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TickTerminated::class => [
//
],
OperationTerminated::class => [
FlushOnce::class,
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
// CollectGarbage::class,
],
WorkerErrorOccurred::class => [
ReportException::class,
StopWorkerIfNecessary::class,
],
WorkerStopping::class => [
CloseMonologHandlers::class,
],
],
/*
|--------------------------------------------------------------------------
| Warm / Flush Bindings
|--------------------------------------------------------------------------
|
| The bindings listed below will either be pre-warmed when a worker boots
| or they will be flushed before every new request. Flushing a binding
| will force the container to resolve that binding again when asked.
|
*/
'warm' => [
...Octane::defaultServicesToWarm(),
],
'flush' => [
//
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Tables
|--------------------------------------------------------------------------
|
| While using Swoole, you may define additional tables as required by the
| application. These tables can be used to store data that needs to be
| quickly accessed by other workers on the particular Swoole server.
|
*/
'tables' => [
'example:1000' => [
'name' => 'string:1000',
'votes' => 'int',
],
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Cache Table
|--------------------------------------------------------------------------
|
| While using Swoole, you may leverage the Octane cache, which is powered
| by a Swoole table. You may set the maximum number of rows as well as
| the number of bytes per row using the configuration options below.
|
*/
'cache' => [
'rows' => 1000,
'bytes' => 10000,
],
/*
|--------------------------------------------------------------------------
| File Watching
|--------------------------------------------------------------------------
|
| The following list of files and directories will be watched when using
| the --watch option offered by Octane. If any of the directories and
| files are changed, Octane will automatically reload your workers.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'.env',
],
/*
|--------------------------------------------------------------------------
| Garbage Collection Threshold
|--------------------------------------------------------------------------
|
| When executing long-lived PHP scripts such as Octane, memory can build
| up before being cleared by PHP. You can force Octane to run garbage
| collection if your application consumes this amount of megabytes.
|
*/
'garbage' => 50,
/*
|--------------------------------------------------------------------------
| Maximum Execution Time
|--------------------------------------------------------------------------
|
| The following setting configures the maximum execution time for requests
| being handled by Octane. You may set this value to 0 to indicate that
| there isn't a specific time limit on Octane request execution time.
|
*/
'max_execution_time' => 30,
];
@@ -0,0 +1,36 @@
<?php
/**
* 文件功能:用户成就测试工厂。
*
* 为成就相关 Feature Test 快速生成解锁或进度记录。
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\UserAchievement;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 类功能:生成用户成就模型的默认测试数据。
*
* @extends Factory<UserAchievement>
*/
class UserAchievementFactory extends Factory
{
/**
* 定义模型的默认测试状态。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'achievement_key' => 'chat_first_message',
'progress_value' => 1,
'metadata' => ['threshold' => 1],
];
}
}
@@ -0,0 +1,37 @@
<?php
/**
* 文件功能:用户成就进度测试工厂。
*
* 为成就进度相关测试生成默认记录。
*/
namespace Database\Factories;
use App\Models\User;
use App\Models\UserAchievementProgress;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 类功能:生成用户成就进度模型的默认测试数据。
*
* @extends Factory<UserAchievementProgress>
*/
class UserAchievementProgressFactory extends Factory
{
/**
* 定义模型的默认测试状态。
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'achievement_key' => 'chat_first_message',
'progress_value' => 0,
'threshold_value' => 1,
'last_scanned_at' => now(),
];
}
}
@@ -0,0 +1,31 @@
<?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('messages', function (Blueprint $table) {
$table->string('retention_type', 30)
->default('user_chat')
->index()
->comment('消息保留类型:user_chat/system_notice/game_notice/ephemeral_notice');
});
}
/**
* 回滚迁移:移除聊天消息保留类型字段。
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->dropColumn('retention_type');
});
}
};
@@ -0,0 +1,36 @@
<?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::create('user_achievements', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->string('achievement_key', 80)->comment('成就唯一标识');
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度快照');
$table->timestamp('achieved_at')->nullable()->index()->comment('达成时间');
$table->timestamp('notified_at')->nullable()->comment('通知时间');
$table->json('metadata')->nullable()->comment('成就解锁附加信息');
$table->timestamps();
$table->unique(['user_id', 'achievement_key']);
$table->index(['achievement_key', 'achieved_at']);
});
}
/**
* 回滚迁移:删除用户成就解锁记录表。
*/
public function down(): void
{
Schema::dropIfExists('user_achievements');
}
};
@@ -0,0 +1,34 @@
<?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::create('user_achievement_progress', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
$table->string('achievement_key', 80)->comment('成就唯一标识');
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度值');
$table->unsignedBigInteger('threshold_value')->default(0)->comment('达成门槛快照');
$table->timestamp('last_scanned_at')->nullable()->index()->comment('最近扫描时间');
$table->timestamps();
$table->unique(['user_id', 'achievement_key']);
});
}
/**
* 回滚迁移:删除用户成就进度快照表。
*/
public function down(): void
{
Schema::dropIfExists('user_achievement_progress');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('previous_name', 50)->nullable()->after('username')->comment('曾用名');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('previous_name');
});
}
};
@@ -0,0 +1,37 @@
<?php
/**
* 文件功能:为消息表(messages)添加 (room_id, sent_at) 联合复合索引
* 解决拉取房间聊天历史消息时的 Slow Query 瓶颈
*
* @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
{
/**
* 执行迁移,添加联合索引
*/
public function up(): void
{
Schema::table('messages', function (Blueprint $table) {
// 添加复合索引 idx_room_sent_at
$table->index(['room_id', 'sent_at'], 'idx_room_sent_at');
});
}
/**
* 回滚迁移,删除联合索引
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->dropIndex('idx_room_sent_at');
});
}
};
+74 -6
View File
@@ -40,11 +40,62 @@ fi
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null) PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
echo -e "${BLUE}使用 PHP$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}" echo -e "${BLUE}使用 PHP$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
frontend_build_required() {
local file="$1"
case "$file" in
resources/js/*|resources/css/*|resources/views/*.blade.php|resources/views/*/*.blade.php|resources/views/*/*/*.blade.php|resources/views/*/*/*/*.blade.php)
return 0
;;
package.json|package-lock.json|vite.config.*|tailwind.config.*|postcss.config.*)
return 0
;;
esac
return 1
}
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突) # 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
echo -e "${YELLOW}[1/8] 拉取代码...${NC}" echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
BEFORE_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
git checkout -- composer.lock package-lock.json 2>/dev/null || true git checkout -- composer.lock package-lock.json 2>/dev/null || true
git fetch origin && git pull origin master git fetch origin && git pull origin master
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
AFTER_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
if [ -n "$BEFORE_REV" ] && [ -n "$AFTER_REV" ] && [ "$BEFORE_REV" = "$AFTER_REV" ]; then
echo -e "${GREEN}✅ 当前代码已是最新版本,本次没有可升级内容,跳过后续部署步骤。${NC}"
exit 0
fi
FRONTEND_BUILD_NEEDED=0
FRONTEND_CHANGED_FILES=""
MIGRATION_NEEDED=0
NEW_MIGRATION_FILES=""
if [ -z "$BEFORE_REV" ] || [ -z "$AFTER_REV" ]; then
FRONTEND_BUILD_NEEDED=1
FRONTEND_CHANGED_FILES="无法识别更新前后版本,保险起见执行构建"
MIGRATION_NEEDED=1
NEW_MIGRATION_FILES="无法识别更新前后版本,保险起见执行迁移检查"
elif [ "$BEFORE_REV" != "$AFTER_REV" ]; then
while IFS= read -r changed_file; do
if frontend_build_required "$changed_file"; then
FRONTEND_BUILD_NEEDED=1
FRONTEND_CHANGED_FILES="${FRONTEND_CHANGED_FILES}${changed_file}"$'\n'
fi
done < <(git diff --name-only "$BEFORE_REV" "$AFTER_REV")
while IFS=$'\t' read -r change_status changed_file extra_path; do
if [ "$change_status" = "A" ]; then
case "$changed_file" in
database/migrations/*.php)
MIGRATION_NEEDED=1
NEW_MIGRATION_FILES="${NEW_MIGRATION_FILES}${changed_file}"$'\n'
;;
esac
fi
done < <(git diff --name-status "$BEFORE_REV" "$AFTER_REV")
fi
# 2. Composer Install (关键检查点) # 2. Composer Install (关键检查点)
echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}" echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
@@ -92,15 +143,32 @@ if [ $? -ne 0 ]; then
fi fi
# 3. 前端构建 # 3. 前端构建
echo -e "${YELLOW}[3/8] 前端构建 (npm run build)...${NC}" if [ "$FRONTEND_BUILD_NEEDED" -eq 1 ]; then
npm run build echo -e "${YELLOW}[3/8] 检测到前端构建输入变更,开始前端构建 (npm run build)...${NC}"
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi if [ -n "$FRONTEND_CHANGED_FILES" ]; then
echo -e "${GREEN}✅ 前端资源构建完成。${NC}" echo -e "${BLUE}触发构建的文件:${NC}"
printf '%s\n' "$FRONTEND_CHANGED_FILES"
fi
npm run build
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
else
echo -e "${GREEN}[3/8] 未检测到前端构建输入变更,跳过 npm run build。${NC}"
fi
# 4. 数据库迁移 # 4. 数据库迁移
echo -e "${YELLOW}[4/8] 数据库迁移...${NC}" if [ "$MIGRATION_NEEDED" -eq 1 ]; then
"$PHP_BIN" artisan migrate --force echo -e "${YELLOW}[4/8] 检测到新增迁移文件,执行数据库迁移...${NC}"
if [ -n "$NEW_MIGRATION_FILES" ]; then
echo -e "${BLUE}新增迁移文件:${NC}"
printf '%s\n' "$NEW_MIGRATION_FILES"
fi
"$PHP_BIN" artisan migrate --force
if [ $? -ne 0 ]; then echo -e "${RED}❌ 数据库迁移失败${NC}"; exit 1; fi
else
echo -e "${GREEN}[4/8] 未检测到新增迁移文件,跳过数据库迁移。${NC}"
fi
# 5. 优化 # 5. 优化
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}" echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
+23 -12
View File
@@ -26,16 +26,23 @@ map $http_upgrade $connection_upgrade {
# 建议放在 #REWRITE-END 之后,禁止访问敏感文件之前 # 建议放在 #REWRITE-END 之后,禁止访问敏感文件之前
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# ── Laravel 伪静态规则 ────────────────────────────────── # ── 1. 静态资源优先由 Nginx 直接响应,动态请求分流给 Octane ──
# 如果宝塔的伪静态配置文件 rewrite/chat.ay.lc.conf 为空, location / {
# 请在宝塔面板 → 网站 → 伪静态 中选择 "laravel5" try_files $uri $uri/ @octane;
# 或者直接在 rewrite/chat.ay.lc.conf 中写入以下内容: }
#
# location / {
# try_files $uri $uri/ /index.php?$query_string;
# }
# ── Vite 静态资源缓存(文件名带 hash,可安全长期缓存)──────────── # ── 2. API、心跳等动态请求反向代理到本地 8000 端口 (Octane / Roadrunner) ──
location @octane {
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8000;
}
# ── 3. Vite 静态资源缓存(文件名带 hash,可安全长期缓存)────────────
location ^~ /build/assets/ { location ^~ /build/assets/ {
access_log off; access_log off;
expires 1y; expires 1y;
@@ -43,7 +50,7 @@ map $http_upgrade $connection_upgrade {
try_files $uri =404; try_files $uri =404;
} }
# ── 常规静态资源缓存(不带 hash,保守缓存 7 天)─────────────── # ── 4. 常规静态资源缓存(不带 hash,保守缓存 7 天)───────────────
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot)$ { location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot)$ {
access_log off; access_log off;
expires 7d; expires 7d;
@@ -77,7 +84,11 @@ map $http_upgrade $connection_upgrade {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 避坑提示:某些宝塔环境全局未配置 $connection_upgrade 变量,
# 直接写死 "Upgrade" 能 100% 避免 500 代理握手失败。
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -95,7 +106,7 @@ map $http_upgrade $connection_upgrade {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection "Upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+31
View File
@@ -7,6 +7,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chokidar": "^5.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-echo": "^2.3.0", "laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
@@ -1233,6 +1234,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
@@ -2161,6 +2178,20 @@
"tweetnacl": "^1.0.3" "tweetnacl": "^1.0.3"
} }
}, },
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"chokidar": "^5.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-echo": "^2.3.0", "laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
@@ -61,6 +61,11 @@ description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用
- 第一行用户身份信息显示 `用户 <用户名> · 部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>` - 第一行用户身份信息显示 `用户 <用户名> · 部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`
- 第二行标题只显示 `乘坐【<座驾名称>】闪亮登场`,不要重复用户名。 - 第二行标题只显示 `乘坐【<座驾名称>】闪亮登场`,不要重复用户名。
- `effect_user_info` 供动画 HUD 第一行使用,`effect_title` 供动画 HUD 第二行使用,`identity_text` 只供文字播报使用。 - `effect_user_info` 供动画 HUD 第一行使用,`effect_title` 供动画 HUD 第二行使用,`identity_text` 只供文字播报使用。
- 座驾购买成功必须向当前房间广播文字通知:
- 通知使用 `系统传音``action = ride_purchase`
- 文案包含 `【座驾】`,前端会按百家乐同款紧凑卡片样式渲染。
- 通知末尾必须带 `购买座驾` 按钮,点击调用 `openRideModal()`,方便其他人快速购买。
- 购买通知必须写入 `ChatStateService`、广播 `MessageSent`,并通过 `SaveMessageJob` 落库。
- 前端改动后如果浏览器仍显示旧动画标题,必须运行 `npm run build` 或确认 Vite dev server 已刷新,避免加载旧的 `public/build` 资源。 - 前端改动后如果浏览器仍显示旧动画标题,必须运行 `npm run build` 或确认 Vite dev server 已刷新,避免加载旧的 `public/build` 资源。
## 新增座驾步骤 ## 新增座驾步骤
+234 -1
View File
@@ -53,6 +53,13 @@
--scrollbar-track: #EAF2FF; --scrollbar-track: #EAF2FF;
} }
/* Emoji 字体回退:仅对 Emoji 字符生效,不影响中文文本 */
@font-face {
font-family: 'EmojiFallback';
src: local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Segoe UI Symbol'), local('Noto Color Emoji');
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+1F600-1F64F, U+1F680-1F6FF, U+200D, U+20E3, U+231A-231B, U+23E9-23F3, U+23F8-23FA, U+25AA-25AB, U+25B6, U+25C0, U+25FB-25FE, U+FE00-FE0F, U+1F3FB-1F3FF, U+1F9B0-1F9B3, U+1F1E0-1F1FF;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -60,7 +67,7 @@
} }
body { body {
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif; font-family: "Microsoft YaHei", "SimSun", "宋体", "EmojiFallback", sans-serif;
font-size: 10pt; font-size: 10pt;
background: var(--bg-main); background: var(--bg-main);
color: var(--text-navy); color: var(--text-navy);
@@ -170,6 +177,7 @@ a:hover {
.room-title-bar { .room-title-bar {
background: var(--bg-header); background: var(--bg-header);
color: var(--text-white); color: var(--text-white);
position: relative;
text-align: center; text-align: center;
padding: 6px 10px; padding: 6px 10px;
font-size: 12px; font-size: 12px;
@@ -178,6 +186,10 @@ a:hover {
border-bottom: 1px solid var(--border-blue); border-bottom: 1px solid var(--border-blue);
} }
.room-title-main {
text-align: center;
}
.room-title-bar .room-name { .room-title-bar .room-name {
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
@@ -188,6 +200,145 @@ a:hover {
font-size: 11px; font-size: 11px;
} }
.daily-game-profit-board {
position: absolute;
top: 8px;
right: 34px;
width: 132px;
color: #fff7ed;
text-align: center;
font-size: 13px;
line-height: 1.28;
z-index: 2;
}
.daily-game-profit-board-title {
color: #fde68a;
font-weight: bold;
margin-bottom: 5px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.daily-game-profit-avatars {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.daily-game-profit-avatar-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.24);
}
.daily-game-profit-avatar {
width: 30px;
height: 30px;
border-radius: 999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.18);
}
.daily-game-profit-avatar-rank-1 {
background: linear-gradient(135deg, #fde047, #f59e0b 48%, #fff7ad);
}
.daily-game-profit-avatar-rank-1::before {
content: "1";
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: #f59e0b;
color: #fff7ed;
font-size: 9px;
font-weight: bold;
line-height: 14px;
text-align: center;
box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9);
}
.daily-game-profit-avatar-rank-2 {
background: linear-gradient(135deg, #f8fafc, #94a3b8 48%, #ffffff);
}
.daily-game-profit-avatar-rank-2::before {
content: "2";
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: #64748b;
color: #ffffff;
font-size: 9px;
font-weight: bold;
line-height: 14px;
text-align: center;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9);
}
.daily-game-profit-avatar-rank-3 {
background: linear-gradient(135deg, #fed7aa, #c2410c 48%, #ffedd5);
}
.daily-game-profit-avatar-rank-3::before {
content: "3";
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: #c2410c;
color: #fff7ed;
font-size: 9px;
font-weight: bold;
line-height: 14px;
text-align: center;
box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9);
}
.daily-game-profit-avatar-wrap::after {
content: attr(data-username);
position: absolute;
left: 50%;
bottom: -24px;
transform: translateX(-50%);
max-width: 110px;
padding: 3px 7px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.88);
color: #ffffff;
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
overflow: hidden;
text-overflow: ellipsis;
}
.daily-game-profit-avatar-wrap:hover::after {
opacity: 1;
}
.daily-game-profit-empty {
color: #dbeafe;
font-size: 11px;
opacity: 0.82;
}
/* 公告/祝福语滚动条(复刻原版 marquee) */ /* 公告/祝福语滚动条(复刻原版 marquee) */
.room-announcement-bar { .room-announcement-bar {
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8); background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
@@ -930,6 +1081,46 @@ a:hover {
flex-shrink: 0; flex-shrink: 0;
} }
/* 输入栏管理菜单在手机抽屉中会被原本的按钮相对定位带偏,手机端改为视口居中弹层。 */
#admin-menu {
position: fixed !important;
top: 50% !important;
right: auto !important;
bottom: auto !important;
left: 50% !important;
width: min(92vw, 320px) !important;
max-width: calc(100vw - 24px) !important;
max-height: min(78vh, 560px) !important;
box-sizing: border-box;
transform: translate(-50%, -50%);
z-index: 10040 !important;
}
/* 欢迎语模板较长,手机端同样需要脱离按钮定位,避免右侧被屏幕裁掉。 */
#welcome-menu {
position: fixed !important;
top: 50% !important;
right: auto !important;
bottom: auto !important;
left: 50% !important;
width: min(92vw, 340px) !important;
min-width: 0 !important;
max-width: calc(100vw - 24px) !important;
max-height: min(72vh, 520px) !important;
box-sizing: border-box;
overflow-y: auto;
transform: translate(-50%, -50%);
z-index: 10040 !important;
}
#welcome-menu .welcome-menu-item {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.45;
word-break: break-word;
}
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */ /* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
#mob-online-users-list .user-item { #mob-online-users-list .user-item {
padding: 7px 8px; padding: 7px 8px;
@@ -956,6 +1147,48 @@ a:hover {
overflow-y: auto !important; overflow-y: auto !important;
} }
.daily-game-profit-board {
top: 5px;
right: 6px;
width: 102px;
font-size: 10px;
}
.daily-game-profit-empty {
display: none;
}
.daily-game-profit-board-title {
margin-bottom: 2px;
}
.daily-game-profit-avatars {
gap: 4px;
}
.daily-game-profit-avatar-wrap {
width: 25px;
height: 25px;
}
.daily-game-profit-avatar {
width: 22px;
height: 22px;
}
.daily-game-profit-avatar-rank-1::before,
.daily-game-profit-avatar-rank-2::before,
.daily-game-profit-avatar-rank-3::before {
width: 11px;
height: 11px;
font-size: 8px;
line-height: 11px;
}
.daily-game-profit-avatar-wrap::after {
display: none;
}
/* ── 手机端隐藏房间介绍 ── */ /* ── 手机端隐藏房间介绍 ── */
.room-desc { .room-desc {
display: none !important; display: none !important;
+8
View File
@@ -64,8 +64,10 @@ export { bindChatToast } from "./chat-room/toast.js";
export { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js"; export { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
export { export {
isExpiredChatImageMessage, isExpiredChatImageMessage,
isAutoScrollEnabled,
localClearScreen, localClearScreen,
scrollChatToBottom, scrollChatToBottom,
setAutoScrollEnabled,
syncAutoScrollControls, syncAutoScrollControls,
toggleAutoScroll, toggleAutoScroll,
} from "./chat-room/message-utils.js"; } from "./chat-room/message-utils.js";
@@ -238,8 +240,10 @@ import { bindChatToast } from "./chat-room/toast.js";
import { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js"; import { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
import { import {
isExpiredChatImageMessage, isExpiredChatImageMessage,
isAutoScrollEnabled,
localClearScreen, localClearScreen,
scrollChatToBottom, scrollChatToBottom,
setAutoScrollEnabled,
syncAutoScrollControls, syncAutoScrollControls,
toggleAutoScroll, toggleAutoScroll,
} from "./chat-room/message-utils.js"; } from "./chat-room/message-utils.js";
@@ -319,8 +323,10 @@ if (typeof window !== "undefined") {
bindChatComposerControls, bindChatComposerControls,
setChatComposerAction, setChatComposerAction,
isExpiredChatImageMessage, isExpiredChatImageMessage,
isAutoScrollEnabled,
localClearScreen, localClearScreen,
scrollChatToBottom, scrollChatToBottom,
setAutoScrollEnabled,
syncAutoScrollControls, syncAutoScrollControls,
toggleAutoScroll, toggleAutoScroll,
bindInstantHoverTooltip, bindInstantHoverTooltip,
@@ -592,8 +598,10 @@ if (typeof window !== "undefined") {
// ── 静态核心模块 window 挂载 ── // ── 静态核心模块 window 挂载 ──
window.escapeHtml = escapeHtml; window.escapeHtml = escapeHtml;
window.isExpiredChatImageMessage = isExpiredChatImageMessage; window.isExpiredChatImageMessage = isExpiredChatImageMessage;
window.isChatAutoScrollEnabled = isAutoScrollEnabled;
window.localClearScreen = localClearScreen; window.localClearScreen = localClearScreen;
window.normalizeSafeChatUrl = normalizeSafeChatUrl; window.normalizeSafeChatUrl = normalizeSafeChatUrl;
window.setChatAutoScrollEnabled = setAutoScrollEnabled;
window.setAction = setChatComposerAction; window.setAction = setChatComposerAction;
window.syncAutoScrollControls = syncAutoScrollControls; window.syncAutoScrollControls = syncAutoScrollControls;
@@ -1,6 +1,7 @@
// 聊天室任命/撤销公告监听,负责渲染大卡片和频道内系统提示。 // 聊天室任命/撤销公告监听,负责渲染大卡片和频道内系统提示。
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
const APPOINTMENT_PHRASES = [ const APPOINTMENT_PHRASES = [
"望再接再厉,大展宏图,为大家服务!", "望再接再厉,大展宏图,为大家服务!",
@@ -132,7 +133,7 @@ function appendAppointmentMessage(container, message) {
} }
container.appendChild(message); container.appendChild(message);
container.scrollTop = container.scrollHeight; scrollChatToBottom(container, isAutoScrollEnabled);
} }
/** /**
+68 -7
View File
@@ -4,6 +4,7 @@
import { escapeHtml, normalizeSafeChatUrl } from "./html.js"; import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js"; import { normalizeDailyStatus } from "./preferences-status.js";
import { enqueueChatMessage } from "./message-renderer.js"; import { enqueueChatMessage } from "./message-renderer.js";
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
// ── 事件注册标记 ── // ── 事件注册标记 ──
let chatEventsBound = false; let chatEventsBound = false;
@@ -19,6 +20,16 @@ function getState() {
return window.chatState; return window.chatState;
} }
/**
* 在开启自动滚屏时把指定聊天窗格滚动到底部。
*
* @param {HTMLElement|null|undefined} container 聊天消息容器
* @returns {void}
*/
function scrollWhenEnabled(container) {
scrollChatToBottom(container, isAutoScrollEnabled);
}
/** /**
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。 * 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/ */
@@ -59,6 +70,48 @@ function runWhenDomReady(callback) {
callback(); callback();
} }
/**
* 判断 Toast 通知是否需要对当前用户隐藏。
*
* @param {Record<string, any>} toastNotification 右下角通知载荷
* @returns {boolean}
*/
function shouldSkipToastForCurrentUser(toastNotification) {
if (!toastNotification?.skip_for_actor) {
return false;
}
return String(toastNotification.actor_username || "") === String(window.chatContext?.username || "");
}
/**
* 判断当前是否为手机浏览器视口。
*
* @returns {boolean}
*/
function isMobileToastViewport() {
return window.matchMedia?.("(max-width: 640px)")?.matches === true;
}
/**
* 手机端只隐藏无本人关联的公屏全局 Toast。
*
* @param {Record<string, any>} message 聊天消息载荷
* @param {Record<string, any>} toastNotification 右下角通知载荷
* @returns {boolean}
*/
function shouldHideGlobalToastOnMobile(message, toastNotification) {
if (!isMobileToastViewport() || message?.to_user !== '大家') {
return false;
}
const currentUsername = String(window.chatContext?.username || "");
const actorUsername = String(toastNotification?.actor_username || "");
const targetUsername = String(toastNotification?.target_username || "");
return actorUsername !== currentUsername && targetUsername !== currentUsername;
}
// ── 禁言逻辑 ── // ── 禁言逻辑 ──
function handleMutedEvent(e) { function handleMutedEvent(e) {
const state = getState(); const state = getState();
@@ -79,7 +132,7 @@ function handleMutedEvent(e) {
: (state?.container); : (state?.container);
if (targetContainer) { if (targetContainer) {
targetContainer.appendChild(div); targetContainer.appendChild(div);
targetContainer.scrollTop = targetContainer.scrollHeight; scrollWhenEnabled(targetContainer);
} }
if (isMe && d.mute_time > 0) { if (isMe && d.mute_time > 0) {
@@ -99,7 +152,7 @@ function handleMutedEvent(e) {
const say2 = document.getElementById("say2"); const say2 = document.getElementById("say2");
if (say2) { if (say2) {
say2.appendChild(unmuteDiv); say2.appendChild(unmuteDiv);
say2.scrollTop = say2.scrollHeight; scrollWhenEnabled(say2);
} }
}, d.mute_time * 60 * 1000); }, d.mute_time * 60 * 1000);
} }
@@ -148,7 +201,7 @@ function setupScreenClearedListener() {
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`; sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) { if (say1) {
say1.appendChild(sysDiv); say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight; scrollWhenEnabled(say1);
} }
}); });
} }
@@ -205,7 +258,7 @@ function setupChangelogPublishedListener() {
const say1 = document.getElementById("chat-messages-container"); const say1 = document.getElementById("chat-messages-container");
if (say1) { if (say1) {
say1.appendChild(sysDiv); say1.appendChild(sysDiv);
say1.scrollTop = say1.scrollHeight; scrollWhenEnabled(say1);
} }
}); });
} }
@@ -255,7 +308,7 @@ function setupGomokuInviteListener() {
const say1 = document.getElementById("chat-messages-container"); const say1 = document.getElementById("chat-messages-container");
if (say1) { if (say1) {
say1.appendChild(div); say1.appendChild(div);
say1.scrollTop = say1.scrollHeight; scrollWhenEnabled(say1);
} }
if (!isSelf) { if (!isSelf) {
@@ -294,7 +347,7 @@ function setupGomokuInviteListener() {
const say1 = document.getElementById("chat-messages-container"); const say1 = document.getElementById("chat-messages-container");
if (say1) { if (say1) {
say1.appendChild(div); say1.appendChild(div);
say1.scrollTop = say1.scrollHeight; scrollWhenEnabled(say1);
} }
}); });
} }
@@ -406,12 +459,20 @@ export function bindChatEvents() {
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片 // 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) { if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
const t = msg.toast_notification; const t = msg.toast_notification;
if (shouldSkipToastForCurrentUser(t)) {
return;
}
if (shouldHideGlobalToastOnMobile(msg, t)) {
return;
}
window.chatToast?.show({ window.chatToast?.show({
title: t.title || "通知", title: t.title || "通知",
message: t.message || "", message: t.message || "",
icon: t.icon || "💬", icon: t.icon || "💬",
color: t.color || "#336699", color: t.color || "#336699",
duration: t.duration ?? 8000, duration: t.duration ?? 3000,
}); });
} }
}); });
+3 -1
View File
@@ -1,6 +1,8 @@
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截。 // 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截。
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。 // 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
let chatComposerEventsBound = false; let chatComposerEventsBound = false;
function csrf() { function csrf() {
@@ -165,7 +167,7 @@ async function sendMessage(e) {
const say2 = document.getElementById("say2"); const say2 = document.getElementById("say2");
if (say2) { if (say2) {
say2.appendChild(muteDiv); say2.appendChild(muteDiv);
say2.scrollTop = say2.scrollHeight; scrollChatToBottom(say2, isAutoScrollEnabled);
} }
if (state) { if (state) {
state.isSending = false; state.isSending = false;
+3 -1
View File
@@ -1,5 +1,7 @@
// 婚姻弹窗辅助入口,承接从 marriage-modals.blade.php 迁移出的全局函数。 // 婚姻弹窗辅助入口,承接从 marriage-modals.blade.php 迁移出的全局函数。
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
/** /**
* 向聊天主窗口追加一条婚姻系统公告,允许传入受控 HTML 按钮。 * 向聊天主窗口追加一条婚姻系统公告,允许传入受控 HTML 按钮。
* *
@@ -17,7 +19,7 @@ export function appendSystemMessage(html) {
div.style.cssText = "background:linear-gradient(135deg,#fdf4ff,#fce7f3); border-left:3px solid #ec4899; border-radius:6px; padding:5px 12px; margin:3px 0; font-size:13px; line-height:1.6;"; div.style.cssText = "background:linear-gradient(135deg,#fdf4ff,#fce7f3); border-left:3px solid #ec4899; border-radius:6px; padding:5px 12px; margin:3px 0; font-size:13px; line-height:1.6;";
div.innerHTML = `<span style="color:#9d174d;">${html}</span>`; div.innerHTML = `<span style="color:#9d174d;">${html}</span>`;
container.appendChild(div); container.appendChild(div);
container.scrollTop = container.scrollHeight; scrollChatToBottom(container, isAutoScrollEnabled);
} }
/** /**
+33 -5
View File
@@ -201,13 +201,25 @@ function resolveGameNotificationCardMeta(msg) {
}; };
} }
if (normalizedContent.includes("【座驾】")) {
return {
label: "座驾",
icon: "🚀",
accent: "#0f766e",
background: "linear-gradient(135deg,#ecfeff,#f0fdfa)",
border: "rgba(15,118,110,.16)",
text: "#115e59",
chipBg: "#ccfbf1",
};
}
if ( if (
normalizedContent.includes("【赛马】") normalizedContent.includes("【赛马】")
|| normalizedContent.startsWith("🐎 开赛:") || normalizedContent.startsWith("🐎 开赛:")
|| normalizedContent.startsWith("🏇 比赛开始:") || normalizedContent.startsWith("🏇 比赛开始:")
|| normalizedContent.startsWith("🏆 冠军:") || normalizedContent.startsWith("🏆 冠军:")
|| /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent) || /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent)
|| /^🏇\s*第\s*#?\d+\s*场比赛开始/u.test(normalizedContent) || /^🏇\s*(?:赛马)?第\s*#?\d+\s*场比赛开始/u.test(normalizedContent)
|| /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent) || /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent)
) { ) {
return { return {
@@ -336,6 +348,14 @@ function extractSystemGameCardSummary(content, meta) {
.trim(); .trim();
} }
if (meta.label === "座驾") {
return normalizedContent
.replace(/^[🚀]+\s*/u, "")
.replace(/^【座驾】/u, "")
.replace(/\s+/gu, " ")
.trim();
}
if (meta.label === "赛马") { if (meta.label === "赛马") {
return normalizedContent return normalizedContent
.replace(/^[🐎🏇🏆]+\s*/u, "") .replace(/^[🐎🏇🏆]+\s*/u, "")
@@ -777,10 +797,18 @@ export function appendMessage(msg, renderBatch = null) {
let parsedContent = parseBracketUsers(msg.content); let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`; html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
} else { } else {
div.style.cssText = const isWarning = (msg.content || "").includes("警告");
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;"; if (isWarning) {
let sysTranContent = parseBracketUsers(msg.content); div.style.cssText =
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`; "background: #fef2f2; border-left: 4px solid #ef4444; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
let sysTranContent = parseBracketUsers(msg.content, "#dc2626");
html = `<span style="color: #dc2626; font-weight: 800; font-size: 1.15em;">🌟 ${sysTranContent}</span>`;
} else {
div.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 4px 10px; margin: 2px 0;";
let sysTranContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
}
} }
} else if (resolveGameNotificationCardMeta(msg)) { } else if (resolveGameNotificationCardMeta(msg)) {
html = buildSystemGameNotificationHtml(msg, timeStr); html = buildSystemGameNotificationHtml(msg, timeStr);
+39 -1
View File
@@ -75,10 +75,46 @@ export function localClearScreen(roomId = window.chatContext?.roomId, maxMessage
if (publicPane) { if (publicPane) {
publicPane.appendChild(notice); publicPane.appendChild(notice);
publicPane.scrollTop = publicPane.scrollHeight; scrollChatToBottom(publicPane, isAutoScrollEnabled);
} }
} }
/**
* 读取当前是否允许聊天窗口自动滚屏。
*
* @returns {boolean}
*/
export function isAutoScrollEnabled() {
const state = window.chatState;
if (state && typeof state.autoScroll !== "undefined") {
return Boolean(state.autoScroll);
}
const checkbox = document.getElementById("auto_scroll");
return checkbox ? Boolean(checkbox.checked) : true;
}
/**
* 写入自动滚屏状态,并同步页面复选框和状态文字。
*
* @param {boolean} enabled 是否开启自动滚屏
* @returns {boolean}
*/
export function setAutoScrollEnabled(enabled) {
const nextEnabled = Boolean(enabled);
const state = window.chatState;
if (state) {
state.autoScroll = nextEnabled;
}
syncAutoScrollControls(nextEnabled);
return nextEnabled;
}
/** /**
* 同步自动滚屏复选框与状态文字。 * 同步自动滚屏复选框与状态文字。
* *
@@ -111,6 +147,8 @@ export function toggleAutoScroll(getCurrent, setCurrent) {
if (typeof setCurrent === "function") { if (typeof setCurrent === "function") {
setCurrent(nextEnabled); setCurrent(nextEnabled);
} else {
setAutoScrollEnabled(nextEnabled);
} }
syncAutoScrollControls(nextEnabled); syncAutoScrollControls(nextEnabled);
+6 -2
View File
@@ -545,14 +545,18 @@ export function resolveBlockedSystemSenderKey(msg) {
return "百家乐"; return "百家乐";
} }
// 赛马通知已缩短为“开赛:...”“比赛开始:...”“冠军:...”等格式 // 赛马通知可能来自旧版“跑马”发送者,也可能以系统传音发送紧凑开赛/结束文案
if ( if (
fromUser === "跑马" ||
isSystemBroadcast && ( isSystemBroadcast && (
content.includes("赛马") || content.includes("赛马") ||
content.includes("跑马") || content.includes("跑马") ||
content.startsWith("🐎 开赛:") || content.startsWith("🐎 开赛:") ||
content.startsWith("🏇 比赛开始:") || content.startsWith("🏇 比赛开始:") ||
content.startsWith("🏆 冠军:") content.startsWith("🏆 冠军:") ||
/^🐎\s*第\s*#?\d+\s*场开赛/u.test(content) ||
/^🏇\s*(?:赛马)?第\s*#?\d+\s*场比赛开始/u.test(content) ||
/^🏆\s*第\s*#?\d+\s*场结束/u.test(content)
) )
) { ) {
return "跑马"; return "跑马";
@@ -345,6 +345,7 @@ export async function savePassword() {
*/ */
export async function saveSettings() { export async function saveSettings() {
const profileData = { const profileData = {
previous_name: element("set-previous-name").value || "",
sex: element("set-sex").value, sex: element("set-sex").value,
email: element("set-email").value, email: element("set-email").value,
email_code: element("set-email-code")?.value || "", email_code: element("set-email-code")?.value || "",
+26 -36
View File
@@ -27,6 +27,24 @@ function byId(id) {
return document.getElementById(id); return document.getElementById(id);
} }
/**
* 更新红包弹窗状态提示,兼容状态提示区域被移除的简化弹窗。
*
* @param {string} message 状态文案
* @param {string} color 状态颜色
* @returns {void}
*/
function setRedPacketStatus(message, color) {
const status = byId("rp-status-msg");
if (!status) {
return;
}
status.textContent = message;
status.style.color = color;
}
/** /**
* 重置礼包按钮状态。 * 重置礼包按钮状态。
* *
@@ -317,9 +335,7 @@ function renderRedPacketModal(payload) {
byId("rp-remaining").textContent = payload.remainingCount; byId("rp-remaining").textContent = payload.remainingCount;
byId("rp-countdown").textContent = payload.expireSeconds; byId("rp-countdown").textContent = payload.expireSeconds;
byId("rp-timer-bar").style.width = "100%"; byId("rp-timer-bar").style.width = "100%";
byId("rp-status-msg").textContent = ""; setRedPacketStatus("", "#16a34a");
byId("rp-claims-list").style.display = "none";
byId("rp-claims-items").textContent = "";
const emoji = modal.querySelector(".rp-emoji"); const emoji = modal.querySelector(".rp-emoji");
if (emoji) { if (emoji) {
@@ -361,9 +377,7 @@ function startRedPacketTimer() {
claimButton.disabled = true; claimButton.disabled = true;
claimButton.textContent = "礼包已过期"; claimButton.textContent = "礼包已过期";
const status = byId("rp-status-msg"); setRedPacketStatus("红包已过期,即将关闭…", "#9ca3af");
status.style.color = "#9ca3af";
status.textContent = "红包已过期,即将关闭…";
setTimeout(() => closeRedPacketModal(), 3000); setTimeout(() => closeRedPacketModal(), 3000);
} }
@@ -479,9 +493,7 @@ export async function claimRedPacket() {
const data = await response.json(); const data = await response.json();
handleClaimResponse(response, data, button); handleClaimResponse(response, data, button);
} catch (error) { } catch (error) {
const status = byId("rp-status-msg"); setRedPacketStatus("网络异常,请重试", "#dc2626");
status.textContent = "网络异常,请重试";
status.style.color = "#dc2626";
button.disabled = false; button.disabled = false;
button.textContent = "🧧 立即抢红包"; button.textContent = "🧧 立即抢红包";
} }
@@ -496,14 +508,12 @@ export async function claimRedPacket() {
* @returns {void} * @returns {void}
*/ */
function handleClaimResponse(response, data, button) { function handleClaimResponse(response, data, button) {
const status = byId("rp-status-msg");
const typeLabel = redPacketType === "exp" ? "经验" : "金币"; const typeLabel = redPacketType === "exp" ? "经验" : "金币";
if (response.ok && data.status === "success") { if (response.ok && data.status === "success") {
redPacketClaimed = true; redPacketClaimed = true;
button.textContent = "🎉 已抢到!"; button.textContent = "🎉 已抢到!";
status.style.color = "#16a34a"; setRedPacketStatus(`恭喜!您抢到了 ${data.amount} ${typeLabel}`, "#16a34a");
status.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}`;
const remaining = byId("rp-remaining"); const remaining = byId("rp-remaining");
if (remaining && typeof data.remaining_count === "number") { if (remaining && typeof data.remaining_count === "number") {
@@ -515,15 +525,14 @@ function handleClaimResponse(response, data, button) {
message: `恭喜您抢到了礼包 ${data.amount} ${typeLabel}`, message: `恭喜您抢到了礼包 ${data.amount} ${typeLabel}`,
icon: "🧧", icon: "🧧",
color: redPacketType === "exp" ? "#7c3aed" : "#dc2626", color: redPacketType === "exp" ? "#7c3aed" : "#dc2626",
duration: 8000, duration: 3000,
}); });
setTimeout(() => closeRedPacketModal(), 3000); closeRedPacketModal();
return; return;
} }
status.style.color = "#dc2626"; setRedPacketStatus(data.message || "抢包失败", "#dc2626");
status.textContent = data.message || "抢包失败";
updateClaimButtonAfterFailure(button, data.message || ""); updateClaimButtonAfterFailure(button, data.message || "");
} }
@@ -556,7 +565,7 @@ function updateClaimButtonAfterFailure(button, message) {
} }
/** /**
* 收到领取广播后,同步弹窗内领取名单与剩余数 * 收到领取广播后,同步弹窗内剩余份数与抢完状态
* *
* @param {string} username 领取者用户名 * @param {string} username 领取者用户名
* @param {number|string} amount 领取数量 * @param {number|string} amount 领取数量
@@ -570,25 +579,6 @@ export function updateRedPacketClaimsUI(username, amount, remaining, type = redP
remainingElement.textContent = remaining; remainingElement.textContent = remaining;
} }
const list = byId("rp-claims-list");
const items = byId("rp-claims-items");
if (!list || !items) {
return;
}
list.style.display = "block";
const item = document.createElement("div");
const name = document.createElement("span");
const value = document.createElement("span");
const typeLabel = type === "exp" ? "经验" : "金币";
item.className = "rp-claim-item";
name.textContent = username;
value.textContent = `+${amount} ${typeLabel}`;
item.append(name, value);
items.prepend(item);
if (remaining <= 0) { if (remaining <= 0) {
const button = byId("rp-claim-btn"); const button = byId("rp-claim-btn");
if (button && !redPacketClaimed) { if (button && !redPacketClaimed) {
+44 -18
View File
@@ -17,22 +17,22 @@ const QUIZ_INLINE_META_FONT_SIZE = "0.78em";
*/ */
export function normalizeQuizRoundPayload(payload) { export function normalizeQuizRoundPayload(payload) {
const source = payload && typeof payload === "object" ? payload : {}; const source = payload && typeof payload === "object" ? payload : {};
const quizType = String(source.quiz_type || source.idiom_type || "idiom"); const quizType = String(source.quiz_type || source.quizType || source.idiom_type || "idiom");
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题")); const quizTypeLabel = String(source.quiz_type_label || source.quizTypeLabel || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
const roundId = Number.parseInt( const roundId = Number.parseInt(
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"), String(source.quiz_round_id || source.quizRoundId || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.roundId || source.quiz_round_ended_id || source.quizRoundEndedId || source.idiom_game_round_ended_id || "0"),
10, 10,
); );
const endedRoundId = Number.parseInt( const endedRoundId = Number.parseInt(
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"), String(source.quiz_round_ended_id || source.quizRoundEndedId || source.idiom_game_round_ended_id || "0"),
10, 10,
); );
const rewardGold = Number.parseInt( const rewardGold = Number.parseInt(
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0), String(source.quiz_reward_gold ?? source.quizRewardGold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? source.rewardGold ?? 0),
10, 10,
); );
const rewardExp = Number.parseInt( const rewardExp = Number.parseInt(
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0), String(source.quiz_reward_exp ?? source.quizRewardExp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? source.rewardExp ?? 0),
10, 10,
); );
const hint = String(source.quiz_hint || source.hint || source.content || ""); const hint = String(source.quiz_hint || source.hint || source.content || "");
@@ -208,6 +208,10 @@ function syncQuizWinnerLabel(button, winnerUsername = "") {
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。 * 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
*/ */
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") { export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
if (roundId <= 0) {
return;
}
queryQuizAnswerButtons(roundId).forEach((button) => { queryQuizAnswerButtons(roundId).forEach((button) => {
button.disabled = true; button.disabled = true;
button.dataset.quizEnded = "1"; button.dataset.quizEnded = "1";
@@ -412,14 +416,17 @@ function handleRiddleGameAnswered(e) {
const roundId = quizMeta.endedRoundId || quizMeta.roundId; const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (!answer) return; if (!answer) return;
currentRoundId = 0;
currentQuizType = "idiom";
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername); disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
// 关闭当前用户的答题弹窗(如果开着的话) // 关闭当前结算回合自己的答题弹窗,避免另一道同时进行的题被误关。
const answerModal = document.getElementById("idiom-answer-modal"); const answerModal = document.getElementById("idiom-answer-modal");
if (answerModal && answerModal.style.display !== "none") { const modalRoundId = Number.parseInt(String(answerModal?.dataset?.currentRoundId || "0"), 10);
if (answerModal && modalRoundId === roundId && answerModal.style.display !== "none") {
answerModal.style.display = "none"; answerModal.style.display = "none";
answerModal.dataset.currentRoundId = "0";
answerModal.dataset.currentQuizType = "";
currentRoundId = 0;
currentQuizType = "idiom";
} }
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。 // 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
@@ -454,7 +461,7 @@ function handleRiddleGameAnswered(e) {
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`, message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
icon: "🎉", icon: "🎉",
color: "#16a34a", color: "#16a34a",
duration: 6000, duration: 3000,
}); });
} }
@@ -469,6 +476,10 @@ function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel =
const modal = document.getElementById("idiom-answer-modal"); const modal = document.getElementById("idiom-answer-modal");
if (!modal) return; if (!modal) return;
modal.dataset.currentRoundId = String(roundId);
modal.dataset.currentRoomId = String(currentRoomId);
modal.dataset.currentQuizType = currentQuizType;
const hintEl = document.getElementById("idiom-answer-hint"); const hintEl = document.getElementById("idiom-answer-hint");
const rewardEl = document.getElementById("idiom-answer-reward"); const rewardEl = document.getElementById("idiom-answer-reward");
const typeEl = document.getElementById("idiom-answer-type"); const typeEl = document.getElementById("idiom-answer-type");
@@ -500,7 +511,12 @@ function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel =
*/ */
function closeIdiomAnswerModal() { function closeIdiomAnswerModal() {
const modal = document.getElementById("idiom-answer-modal"); const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.style.display = "none"; if (modal) {
modal.style.display = "none";
modal.dataset.currentRoundId = "0";
modal.dataset.currentRoomId = "0";
modal.dataset.currentQuizType = "";
}
} }
/** /**
@@ -523,6 +539,11 @@ async function submitIdiomAnswer() {
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = "提交中..."; submitBtn.textContent = "提交中...";
const modal = document.getElementById("idiom-answer-modal");
const submittingRoundId = Number.parseInt(String(modal?.dataset?.currentRoundId || currentRoundId || "0"), 10);
const submittingRoomId = Number.parseInt(String(modal?.dataset?.currentRoomId || currentRoomId || "0"), 10);
const submittingQuizType = String(modal?.dataset?.currentQuizType || currentQuizType || "idiom");
try { try {
const response = await fetch("/riddle-quiz/answer", { const response = await fetch("/riddle-quiz/answer", {
method: "POST", method: "POST",
@@ -532,34 +553,39 @@ async function submitIdiomAnswer() {
"Accept": "application/json", "Accept": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
round_id: currentRoundId, round_id: submittingRoundId,
answer: answer, answer: answer,
room_id: currentRoomId, room_id: submittingRoomId,
quiz_type: currentQuizType, quiz_type: submittingQuizType,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (data.status === "success") { if (data.status === "success") {
const answeredRoundId = Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || submittingRoundId || "0"), 10);
feedbackEl.textContent = data.message || "🎉 回答正确!"; feedbackEl.textContent = data.message || "🎉 回答正确!";
feedbackEl.style.color = "#16a34a"; feedbackEl.style.color = "#16a34a";
input.disabled = true; input.disabled = true;
disableIdiomAnswerButtons( disableIdiomAnswerButtons(
currentRoundId, answeredRoundId,
"本回合已结束", "本回合已结束",
String(window.chatContext?.username || ""), String(window.chatContext?.username || ""),
); );
currentRoundId = currentRoundId === answeredRoundId ? 0 : currentRoundId;
// 延迟关闭弹窗 // 延迟关闭弹窗
setTimeout(() => { setTimeout(() => {
closeIdiomAnswerModal(); const latestModalRoundId = Number.parseInt(String(modal?.dataset?.currentRoundId || "0"), 10);
if (latestModalRoundId === answeredRoundId) {
closeIdiomAnswerModal();
}
}, 2000); }, 2000);
} else { } else {
feedbackEl.textContent = data.message || "答案不正确"; feedbackEl.textContent = data.message || "答案不正确";
feedbackEl.style.color = "#ef4444"; feedbackEl.style.color = "#ef4444";
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) { if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束"); disableIdiomAnswerButtons(submittingRoundId, data.message || "本回合已结束");
} }
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = "提交答案"; submitBtn.textContent = "提交答案";
+47 -1
View File
@@ -7,10 +7,54 @@
* @returns {void} * @returns {void}
*/ */
function dismissToast(card) { function dismissToast(card) {
if (card.dataset.toastClosing === "1") {
return;
}
card.dataset.toastClosing = "1";
card.style.opacity = "0"; card.style.opacity = "0";
window.setTimeout(() => card.remove(), 400); window.setTimeout(() => card.remove(), 400);
} }
/**
* 关闭容器内所有 Toast 卡片
*
* @param {HTMLElement} container
* @returns {void}
*/
function dismissAllToasts(container) {
container.querySelectorAll(".chat-toast-card").forEach((card) => {
if (card instanceof HTMLElement) {
dismissToast(card);
}
});
}
/**
* 绑定点击 Toast 外部区域时自动关闭
*
* @param {HTMLElement} container
* @returns {void}
*/
function bindOutsideDismiss(container) {
document.addEventListener("pointerdown", (event) => {
if (container.childElementCount === 0) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (target.closest(".chat-toast-card")) {
return;
}
dismissAllToasts(container);
}, true);
}
/** /**
* 创建右下角 Toast 通知 API * 创建右下角 Toast 通知 API
* *
@@ -36,10 +80,11 @@ function createChatToast(container) {
message, message,
icon = "💬", icon = "💬",
color = "#336699", color = "#336699",
duration = 6000, duration = 3000,
action = null, action = null,
}) { }) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "chat-toast-card";
card.style.cssText = ` card.style.cssText = `
background:#fff; border-radius:10px; overflow:hidden; background:#fff; border-radius:10px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.18); box-shadow:0 8px 32px rgba(0,0,0,.18);
@@ -112,5 +157,6 @@ export function bindChatToast() {
return; return;
} }
bindOutsideDismiss(container);
window.chatToast = createChatToast(container); window.chatToast = createChatToast(container);
} }
+11
View File
@@ -121,6 +121,17 @@ export function userCardComponent() {
return operatorPositionRank >= targetPositionRank; return operatorPositionRank >= targetPositionRank;
}, },
/** 格式化私信记录时间,只保留到秒。 */
formatWhisperTime(value) {
if (!value) {
return "";
}
const normalizedValue = String(value).replace("T", " ").replace(/\.\d+Z?$/, "").replace(/Z$/, "");
return normalizedValue.slice(0, 19);
},
/** 返回名片资产字段的中文名称。 */ /** 返回名片资产字段的中文名称。 */
assetValueLabel(asset) { assetValueLabel(asset) {
return { return {
+247 -26
View File
@@ -1,6 +1,7 @@
// 邮箱找回密码页交互入口,负责 AJAX 发送重置链接和页面提示。 // 忘记密码页交互逻辑:智能账号检测与分流引导
let passwordForgotControlsBound = false; let passwordForgotControlsBound = false;
let currentUsername = "";
/** /**
* 读取 CSRF 令牌供找回密码请求使用 * 读取 CSRF 令牌供找回密码请求使用
@@ -11,6 +12,19 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? "";
} }
/**
* 获取当前应当使用的局部消息框元素
*
* @returns {HTMLDivElement|null}
*/
function getActiveAlertBox() {
const stepDetect = document.getElementById("step-detect");
if (stepDetect && stepDetect.style.display !== "none") {
return document.getElementById("alert-detect");
}
return document.getElementById("alert-email");
}
/** /**
* 展示找回密码页面提示 * 展示找回密码页面提示
* *
@@ -19,7 +33,7 @@ function getCsrfToken() {
* @returns {void} * @returns {void}
*/ */
function showAlert(message, type) { function showAlert(message, type) {
const alertBox = document.getElementById("alert-box"); const alertBox = getActiveAlertBox();
if (!alertBox) { if (!alertBox) {
return; return;
} }
@@ -27,36 +41,83 @@ function showAlert(message, type) {
alertBox.textContent = message; alertBox.textContent = message;
alertBox.className = type === "success" ? "alert alert-success" : "alert alert-error"; alertBox.className = type === "success" ? "alert alert-success" : "alert alert-error";
alertBox.style.display = "block"; alertBox.style.display = "block";
// 平滑滚动提示框到可视区域
alertBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
} }
/** /**
* 提交邮箱找回密码请求 * 隐藏找回密码页面提示
*
* @returns {void}
*/
function hideAlert() {
const alertDetect = document.getElementById("alert-detect");
const alertEmail = document.getElementById("alert-email");
if (alertDetect) {
alertDetect.style.display = "none";
}
if (alertEmail) {
alertEmail.style.display = "none";
}
}
/**
* 绑定双绑定状态下的选项卡切换事件
*
* @returns {void}
*/
function bindTabEvents() {
const tabsContainer = document.getElementById("channel-tabs");
if (!tabsContainer) {
return;
}
tabsContainer.addEventListener("click", (event) => {
const btn = event.target.closest(".tab-btn");
if (!btn) {
return;
}
// 清除所有高亮
tabsContainer.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// 切换显示面板
const targetId = btn.getAttribute("data-target");
document.querySelectorAll(".channel-pane").forEach((pane) => {
if (pane.id === targetId) {
pane.style.display = "block";
} else {
pane.style.display = "none";
}
});
});
}
/**
* 第一步提交昵称检测账号绑定状态
* *
* @param {SubmitEvent} event * @param {SubmitEvent} event
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function submitPasswordRecovery(event) { async function submitAccountDetect(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
const submitButton = document.getElementById("submit-btn"); const detectBtn = document.getElementById("detect-btn");
const alertBox = document.getElementById("alert-box");
if (!(form instanceof HTMLFormElement)) { if (!(form instanceof HTMLFormElement)) {
return; return;
} }
if (submitButton instanceof HTMLButtonElement) { if (detectBtn instanceof HTMLButtonElement) {
submitButton.disabled = true; detectBtn.disabled = true;
submitButton.innerText = "发送中..."; detectBtn.innerText = "账号检索中...";
}
if (alertBox) {
alertBox.style.display = "none";
} }
hideAlert();
try { try {
const response = await fetch(form.getAttribute("data-password-email-url") ?? form.action, { const response = await fetch(form.action, {
method: "POST", method: "POST",
credentials: "same-origin",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-CSRF-TOKEN": getCsrfToken(), "X-CSRF-TOKEN": getCsrfToken(),
@@ -66,26 +127,169 @@ async function submitPasswordRecovery(event) {
}); });
const body = await response.json(); const body = await response.json();
if (response.status === 200 && body.status === "success") { if (response.status === 429) {
showAlert(body.message, "success"); showAlert(body.message || "请求过于频繁,请稍后再试。", "error");
form.reset();
return; return;
} }
const errorMessage = body.message || (body.errors ? Object.values(body.errors)[0][0] : "邮件发送失败,请稍后重试。"); if (body.status === "not_found") {
showAlert(errorMessage, "error"); showAlert(body.message, "error");
return;
}
if (body.status === "success") {
currentUsername = body.username;
document.getElementById("step-detect").style.display = "none";
document.getElementById("step-result").style.display = "block";
document.getElementById("recovery-title").innerText = "密码找回建议";
document.getElementById("forgot-footer").style.display = "none";
const summary = document.getElementById("detect-summary");
const tabs = document.getElementById("channel-tabs");
const panelWechat = document.getElementById("panel-wechat");
const panelEmail = document.getElementById("panel-email");
const panelNone = document.getElementById("panel-none");
// 默认隐藏所有特定面板
tabs.style.display = "none";
panelWechat.style.display = "none";
panelEmail.style.display = "none";
panelNone.style.display = "none";
const hasEmail = body.has_email;
const hasWechat = body.has_wechat;
const hintLabel = document.getElementById("masked-email-hint");
const emailInput = document.getElementById("email");
if (hasEmail && hasWechat) {
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 已同时绑定了微信和邮箱。您可以选择以下任意一种方式找回密码:`;
tabs.style.display = "grid";
// 默认选中微信面板
tabs.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
const wechatTabBtn = tabs.querySelector('[data-target="panel-wechat"]');
if (wechatTabBtn) {
wechatTabBtn.classList.add("active");
}
panelWechat.style.display = "block";
if (hintLabel) {
hintLabel.innerHTML = `📬 绑定的邮箱提示:<span style="color:#fff;">${body.masked_email}</span>`;
}
if (emailInput instanceof HTMLInputElement) {
emailInput.value = "";
}
} else if (hasWechat) {
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 仅绑定了微信。系统不支持邮箱找回,建议您使用微信助手进行重置:`;
panelWechat.style.display = "block";
} else if (hasEmail) {
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 仅绑定了邮箱。建议您使用邮箱发送重置链接找回:`;
panelEmail.style.display = "block";
if (hintLabel) {
hintLabel.innerHTML = `📬 绑定的邮箱提示:<span style="color:#fff;">${body.masked_email}</span>`;
}
if (emailInput instanceof HTMLInputElement) {
emailInput.value = "";
}
} else {
summary.innerHTML = `检测到账号 <strong style="color:var(--gold);">${currentUsername}</strong> 尚未绑定微信或邮箱找回途径:`;
panelNone.style.display = "block";
}
}
} catch { } catch {
showAlert("网络或服务器异常,请稍后再试。", "error"); showAlert("网络或服务器连接异常,请稍后再试。", "error");
} finally { } finally {
if (submitButton instanceof HTMLButtonElement) { if (detectBtn instanceof HTMLButtonElement) {
submitButton.disabled = false; detectBtn.disabled = false;
submitButton.innerText = "发送重置邮件"; detectBtn.innerText = "下一步 (检测账号)";
} }
} }
} }
/** /**
* 绑定邮箱找回密码页提交事件 * 第二步提交发送重置链接邮件请求
*
* @param {SubmitEvent} event
* @returns {Promise<void>}
*/
async function submitPasswordRecovery(event) {
event.preventDefault();
const form = event.target;
const submitButton = document.getElementById("submit-btn");
if (!(form instanceof HTMLFormElement)) {
return;
}
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = true;
submitButton.innerText = "发送重置链接中...";
}
hideAlert();
let isSentSuccess = false;
try {
const emailInput = document.getElementById("email");
const emailVal = emailInput instanceof HTMLInputElement ? emailInput.value.trim() : "";
const response = await fetch(form.getAttribute("data-password-email-url") ?? "", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": getCsrfToken(),
Accept: "application/json",
},
body: JSON.stringify({
username: currentUsername,
email: emailVal,
}),
});
const body = await response.json();
if (response.status === 429) {
showAlert(body.message || "请求发送过于频繁,请稍后再试。", "error");
return;
}
if (body.status === "success") {
showAlert(body.message, "success");
isSentSuccess = true;
return;
}
const errorMessage = body.message || (body.errors ? Object.values(body.errors)[0][0] : "重置邮件发送失败,请稍后重试。");
showAlert(errorMessage, "error");
} catch {
showAlert("网络发送异常,请稍后再试。", "error");
} finally {
if (submitButton instanceof HTMLButtonElement) {
if (isSentSuccess) {
// 成功时启动 30 秒置灰倒计时锁定
let seconds = 30;
submitButton.disabled = true;
submitButton.innerText = `${seconds}秒内不能重复发送`;
const timer = setInterval(() => {
seconds--;
if (seconds <= 0) {
clearInterval(timer);
submitButton.disabled = false;
submitButton.innerText = "二次验证并发送重置邮件";
} else {
submitButton.innerText = `${seconds}秒内不能重复发送`;
}
}, 1000);
} else {
// 校验失败,立刻解除禁用供重新编辑
submitButton.disabled = false;
submitButton.innerText = "二次验证并发送重置邮件";
}
}
}
}
/**
* 绑定智能找回向导控制事件
* *
* @returns {void} * @returns {void}
*/ */
@@ -93,11 +297,28 @@ function bindPasswordForgotControls() {
if (passwordForgotControlsBound || typeof document === "undefined") { if (passwordForgotControlsBound || typeof document === "undefined") {
return; return;
} }
passwordForgotControlsBound = true; passwordForgotControlsBound = true;
// 第一步表单提交
document.getElementById("account-detect-form")?.addEventListener("submit", (event) => {
void submitAccountDetect(event);
});
// 第二步发信表单提交
document.getElementById("password-recovery-form")?.addEventListener("submit", (event) => { document.getElementById("password-recovery-form")?.addEventListener("submit", (event) => {
void submitPasswordRecovery(event); void submitPasswordRecovery(event);
}); });
// 返回按钮事件
document.getElementById("back-detect-btn")?.addEventListener("click", () => {
hideAlert();
document.getElementById("step-detect").style.display = "block";
document.getElementById("step-result").style.display = "none";
document.getElementById("recovery-title").innerText = "忘记密码";
document.getElementById("forgot-footer").style.display = "block";
});
bindTabEvents();
} }
bindPasswordForgotControls(); bindPasswordForgotControls();
@@ -0,0 +1,108 @@
{{--
文件功能:我的成就页面
按分类展示当前用户的固定成就解锁状态与进度。
--}}
@extends('layouts.app')
@section('title', '我的成就 - 飘落流星')
@section('nav-icon', '🏅')
@section('nav-title', '我的成就')
@section('content')
<main class="p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto flex flex-col gap-6">
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="text-xl font-bold text-gray-900">{{ $user->username }} 的成就档案</h2>
<p class="text-sm text-gray-500 mt-1">已解锁 {{ $unlocked_count }} / {{ $total_count }} </p>
</div>
<div class="w-full sm:w-64 bg-gray-100 rounded-full h-3 overflow-hidden">
<div class="h-3 bg-amber-500"
style="width: {{ $total_count > 0 ? min(100, floor($unlocked_count / $total_count * 100)) : 0 }}%">
</div>
</div>
</div>
</section>
<nav class="bg-white border border-gray-200 rounded-lg shadow-sm p-1 flex flex-col sm:flex-row gap-1"
aria-label="成就筛选">
@foreach ($achievement_tabs as $tabKey => $tab)
<a href="{{ $tab['url'] }}"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-semibold {{ $active_tab === $tabKey ? 'bg-gray-900 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' }}"
aria-current="{{ $active_tab === $tabKey ? 'page' : 'false' }}">
<span>{{ $tab['label'] }}</span>
<span
class="text-xs px-2 py-0.5 rounded-full {{ $active_tab === $tabKey ? 'bg-white/15 text-white' : 'bg-gray-100 text-gray-500' }}">
{{ $tab['count'] }}
</span>
</a>
@endforeach
</nav>
@foreach ($categories as $categoryKey => $categoryLabel)
@php
$items = $achievements->where('category', $categoryKey)->values();
@endphp
@if ($items->isEmpty())
@continue
@endif
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-800">{{ $categoryLabel }}成就</h3>
<span class="text-xs text-gray-500">
{{ $items->where('unlocked', true)->count() }} / {{ $items->count() }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($items as $achievement)
<article
class="bg-white border {{ $achievement['unlocked'] ? 'border-amber-200' : 'border-gray-200' }} rounded-lg p-4 shadow-sm flex gap-3">
<div
class="w-11 h-11 rounded-lg flex items-center justify-center text-2xl shrink-0 {{ $achievement['unlocked'] ? 'bg-amber-100' : 'bg-gray-100 grayscale' }}">
{{ $achievement['icon'] }}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<h4 class="font-bold text-gray-900 truncate">{{ $achievement['name'] }}</h4>
<span
class="text-xs px-2 py-0.5 rounded {{ $achievement['unlocked'] ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' }}">
{{ $achievement['unlocked'] ? '已解锁' : '进行中' }}
</span>
</div>
<p class="text-sm text-gray-500 mt-1">{{ $achievement['description'] }}</p>
<div class="mt-3 flex items-center gap-3">
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="h-2 {{ $achievement['unlocked'] ? 'bg-emerald-500' : 'bg-indigo-500' }}"
style="width: {{ $achievement['progress_percent'] }}%"></div>
</div>
<span class="text-xs text-gray-500 whitespace-nowrap">
{{ number_format($achievement['progress_value']) }} /
{{ number_format($achievement['threshold']) }}
</span>
</div>
@if ($achievement['achieved_at'])
<p class="text-xs text-amber-700 mt-2">
解锁于 {{ $achievement['achieved_at']->format('Y-m-d H:i') }}
</p>
@endif
</div>
</article>
@endforeach
</div>
</section>
@endforeach
@if ($achievements->isEmpty())
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-8 text-center">
<h3 class="text-base font-bold text-gray-800">暂无对应成就</h3>
<p class="text-sm text-gray-500 mt-2">切换其他筛选查看成就列表。</p>
</section>
@endif
</div>
</main>
@endsection
@@ -0,0 +1,118 @@
{{--
文件功能:后台成就记录页面
提供固定成就目录、解锁统计与用户成就记录只读查询。
--}}
@extends('admin.layouts.app')
@section('title', '成就记录')
@section('content')
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">固定成就</p>
<p class="mt-2 text-2xl font-bold text-slate-900">{{ number_format($summary['total_definitions']) }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">解锁记录</p>
<p class="mt-2 text-2xl font-bold text-amber-600">{{ number_format($summary['unlocked_records']) }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-sm text-slate-500">解锁用户</p>
<p class="mt-2 text-2xl font-bold text-emerald-600">{{ number_format($summary['unlocked_users']) }}</p>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<section class="xl:col-span-2 rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="border-b border-slate-200 p-4">
<form method="GET" class="flex flex-col md:flex-row gap-3">
<input type="text" name="username" value="{{ request('username') }}" placeholder="用户名"
class="w-full md:w-56 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
<select name="achievement_key"
class="w-full md:w-64 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
<option value="">全部成就</option>
@foreach ($definitions as $key => $definition)
<option value="{{ $key }}" @selected(request('achievement_key') === $key)>
{{ $definition['icon'] }} {{ $definition['name'] }}
</option>
@endforeach
</select>
<button type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">筛选</button>
<a href="{{ route('admin.achievements.index') }}"
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-50">重置</a>
</form>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">用户</th>
<th class="px-4 py-3">成就</th>
<th class="px-4 py-3">进度</th>
<th class="px-4 py-3">解锁时间</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($records as $record)
@php
$definition = $definitions[$record->achievement_key] ?? null;
$threshold = (int) data_get($record->metadata, 'threshold', $definition['threshold'] ?? 0);
@endphp
<tr>
<td class="px-4 py-3 font-semibold text-slate-900">
{{ $record->user?->username ?? '未知用户' }}
</td>
<td class="px-4 py-3">
<div class="font-bold text-slate-800">
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $record->achievement_key }}
</div>
<div class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</div>
</td>
<td class="px-4 py-3 text-slate-600">
{{ number_format($record->progress_value) }} / {{ number_format($threshold) }}
</td>
<td class="px-4 py-3 text-slate-500">
{{ $record->achieved_at?->format('Y-m-d H:i') }}
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-10 text-center text-slate-500">暂无解锁记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="border-t border-slate-200 p-4">
{{ $records->links() }}
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-base font-bold text-slate-900">热门成就</h2>
<div class="mt-4 space-y-3">
@forelse ($topAchievements as $row)
@php $definition = $definitions[$row->achievement_key] ?? null; @endphp
<div class="flex items-center justify-between gap-3 rounded-md bg-slate-50 px-3 py-2">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $row->achievement_key }}
</p>
<p class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</p>
</div>
<span class="shrink-0 rounded bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
{{ number_format($row->unlocked_count) }}
</span>
</div>
@empty
<p class="text-sm text-slate-500">暂无热门成就。</p>
@endforelse
</div>
</section>
</div>
</div>
@endsection
@@ -68,6 +68,10 @@
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '💴 用户流水' !!} {!! '💴 用户流水' !!}
</a> </a>
<a href="{{ route('admin.achievements.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.achievements.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🏅 成就记录
</a>
<a href="{{ route('admin.rooms.index') }}" <a href="{{ route('admin.rooms.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🏠 房间管理' !!} {!! '🏠 房间管理' !!}
+55
View File
@@ -285,6 +285,61 @@
</div> </div>
</div> </div>
{{-- ═══════════ 账号安全绑定强引导(邮箱/微信二选一) ═══════════ --}}
@if (empty(Auth::user()->email) && empty(Auth::user()->wxid))
<div id="security-bind-modal" style="display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(5px); z-index: 20000; align-items: center; justify-content: center; padding: 20px;">
<div style="background: #1e293b; border: 2px solid #c6a35b; border-radius: 8px; max-width: 440px; width: 100%; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.6); position: relative; color: #f8fafc; text-align: left;">
<h3 style="font-family: 'Noto Serif SC', serif; font-size: 18px; color: #c6a35b; margin-top: 0; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;">
🛡️ 账号安全绑定提醒
</h3>
<p style="font-size: 13.5px; line-height: 1.6; color: #cbd5e1; margin-bottom: 22px;">
检测到您的账号<strong>尚未设置邮箱</strong>,也<strong>未绑定微信</strong><br>
为了在您遗忘密码时能够自主找回,建议您至少绑定其中一项。您可前往设置录入邮箱,或者<strong>扫码添加并私聊我们的微信助手【小小】完成绑定</strong>
</p>
<div style="display: flex; flex-direction: column; gap: 10px;">
<button onclick="openSecuritySettings()" style="height: 42px; background: linear-gradient(90deg, #8d342d, #b91c1c); color: #fff; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 13.5px; transition: opacity 0.2s;">
立即前往设置(查看微信小小二维码)
</button>
<button onclick="closeSecurityBindModal()" style="height: 38px; background: transparent; color: #94a3b8; border: 1px solid #475569; border-radius: 4px; cursor: pointer; font-size: 12.5px; transition: background 0.2s;">
暂不设置,以后再说
</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// 本次浏览器标签页会话期间仅强提醒一次,防止频繁刷新/切房骚扰
if (!sessionStorage.getItem('security_bind_notified')) {
const modal = document.getElementById('security-bind-modal');
if (modal) {
modal.style.display = 'flex';
sessionStorage.setItem('security_bind_notified', '1');
}
}
});
function openSecuritySettings() {
closeSecurityBindModal();
// 优先通过系统全局包装函数打开,触发延迟分包加载与事件绑定交互
if (typeof window.openSettingsModal === 'function') {
window.openSettingsModal();
} else {
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.style.display = 'flex';
}
}
}
function closeSecurityBindModal() {
const modal = document.getElementById('security-bind-modal');
if (modal) {
modal.style.display = 'none';
}
}
</script>
@endif
</body> </body>
</html> </html>
@@ -11,7 +11,7 @@
window.showRedPacketModal(...) 展示红包弹窗(收到 WebSocket 事件触发) window.showRedPacketModal(...) 展示红包弹窗(收到 WebSocket 事件触发)
window.closeRedPacketModal() 关闭红包弹窗 window.closeRedPacketModal() 关闭红包弹窗
window.claimRedPacket() 用户抢红包 window.claimRedPacket() 用户抢红包
window.updateRedPacketClaimsUI() 更新领取名单WebSocket 广播后调用) window.updateRedPacketClaimsUI() 更新剩余份数WebSocket 广播后调用)
注:依赖 window.chatBannerchat-banner.blade.php)、window.chatDialog、window.chatToast。 注:依赖 window.chatBannerchat-banner.blade.php)、window.chatDialog、window.chatToast。
@@ -190,28 +190,6 @@
transform: none; transform: none;
} }
/* 已领取名单 */
#rp-claims-list {
margin-top: 12px;
max-height: 100px;
overflow-y: auto;
border-top: 1px dashed #fca5a5;
padding-top: 8px;
}
.rp-claim-item {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #555;
padding: 2px 0;
}
.rp-claim-item span:last-child {
color: #dc2626;
font-weight: bold;
}
/* 关闭按钮 */ /* 关闭按钮 */
#rp-close-btn { #rp-close-btn {
position: absolute; position: absolute;
@@ -260,12 +238,6 @@
<div id="rp-timer-bar" style="width:100%;"></div> <div id="rp-timer-bar" style="width:100%;"></div>
</div> </div>
<button id="rp-claim-btn" data-red-packet-claim>🧧 立即抢红包</button> <button id="rp-claim-btn" data-red-packet-claim>🧧 立即抢红包</button>
<div id="rp-status-msg"></div>
{{-- 领取名单 --}}
<div id="rp-claims-list" style="display:none;">
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
<div id="rp-claims-items"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -7,9 +7,28 @@
{{-- 顶部标题栏(第一行:房间名称 + 介绍) --}} {{-- 顶部标题栏(第一行:房间名称 + 介绍) --}}
<div class="room-title-bar"> <div class="room-title-bar">
<div class="room-name">{{ $room->name }}<span style="font-size:12px; font-weight:normal;">在线<span <div class="room-title-main">
id="online-count">0</span></span></div> <div class="room-name">{{ $room->name }}<span style="font-size:12px; font-weight:normal;">在线<span
<div class="room-desc" id="room-title-display">{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }} id="online-count">0</span></span></div>
<div class="room-desc" id="room-title-display">{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }}
</div>
</div>
<div class="daily-game-profit-board" aria-label="今日游戏三杰">
<div class="daily-game-profit-board-title">今日游戏三杰</div>
@if (($dailyGameProfitLeaders ?? collect())->isNotEmpty())
<div class="daily-game-profit-avatars">
@foreach ($dailyGameProfitLeaders as $leader)
<span class="daily-game-profit-avatar-wrap daily-game-profit-avatar-rank-{{ $leader->rank }}"
data-username="{{ $leader->username }}">
<img class="daily-game-profit-avatar" src="{{ $leader->headface_url }}" alt="{{ $leader->username }}"
onerror="this.src='/images/headface/1.gif'">
</span>
@endforeach
</div>
@else
<div class="daily-game-profit-empty">今日财神位待开张</div>
@endif
</div> </div>
</div> </div>
@@ -87,16 +87,16 @@
<div id="welcome-menu" class="welcome-menu" data-chat-welcome-menu style="display:none;"> <div id="welcome-menu" class="welcome-menu" data-chat-welcome-menu style="display:none;">
@php @php
$welcomeMessages = [ $welcomeMessages = [
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!', '热烈欢迎【{name}】驾到,卸下疲惫随心闲聊自在畅聊不负相逢',
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!', '四海好友齐聚暖心聊天室,欢迎【{name}】来唠嗑谈心交友放松好心情',
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光', '欢迎【{name}】小可爱光临聊天室,随心畅聊无拘无束共度惬意时光',
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~', '欢迎【{name}】前来报到 在这里随心聊天放松心情温暖同行',
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!', '欢迎【{name}】新朋友光临本聊天室,愿你在这里卸下烦恼,随心闲聊自在舒心',
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!', '欢迎大佬【{name}】空降聊天室!唠嗑摸鱼随便来,主打开心放松不拘谨',
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!', '恭喜【{name}】解锁暖心聊天室!欢迎加入,闲聊八卦吐槽日常都安排上',
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!', '小小聊天室盛满温柔,欢迎【{name}】驻足停留,畅谈日常治愈所有不开心',
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!', '暖心聊天室恭候【{name}】到来,一起聊天互动解忧寻知己乐相伴',
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!', '岁月温柔相逢可贵,恭候【{name}】到来,一起闲话家常、治愈所有不开心',
]; ];
@endphp @endphp
@foreach ($welcomeMessages as $msg) @foreach ($welcomeMessages as $msg)
@@ -188,7 +188,7 @@ $welcomeMessages = [
<div style="font-size:10px;color:#4338ca;padding:0 2px 8px;">常用操作</div> <div style="font-size:10px;color:#4338ca;padding:0 2px 8px;">常用操作</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;"> <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
<button type="button" data-chat-feature-local-clear <button type="button" data-chat-feature-local-clear
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 清屏</button> style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 本地清屏</button>
<button type="button" data-chat-daily-status-open <button type="button" data-chat-daily-status-open
style="font-size:11px;padding:6px 8px;background:#fff;color:#4f46e5;border:1px solid #a5b4fc;border-radius:6px;cursor:pointer;"> style="font-size:11px;padding:6px 8px;background:#fff;color:#4f46e5;border:1px solid #a5b4fc;border-radius:6px;cursor:pointer;">
<span id="daily-status-shortcut-icon">{{ $activeDailyStatus['icon'] ?? '🙂' }}</span> <span id="daily-status-shortcut-icon">{{ $activeDailyStatus['icon'] ?? '🙂' }}</span>
@@ -128,6 +128,12 @@
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;"> <div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">👤 个人资料</div> <div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">👤 个人资料</div>
<div style="display:flex; flex-direction:column; gap:6px;"> <div style="display:flex; flex-direction:column; gap:6px;">
<div style="display:flex; align-items:center; gap:8px;">
<label style="font-size:12px; width:50px; text-align:right;">曾用名:</label>
<input id="set-previous-name" type="text" value="{{ Auth::user()->previous_name ?? '' }}"
placeholder="设置曾用名(选填)"
style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
</div>
<div style="display:flex; align-items:center; gap:8px;"> <div style="display:flex; align-items:center; gap:8px;">
<label style="font-size:12px; width:50px; text-align:right;">性别:</label> <label style="font-size:12px; width:50px; text-align:right;">性别:</label>
<select id="set-sex" <select id="set-sex"
@@ -7,7 +7,7 @@
- message: 卡片内容(支持 HTML - message: 卡片内容(支持 HTML
- icon: 左侧 Emoji 图标(可选,默认 💬) - icon: 左侧 Emoji 图标(可选,默认 💬)
- color: 强调色 HEX(可选,默认 #336699 - color: 强调色 HEX(可选,默认 #336699
- duration: 自动消失毫秒数(可选,默认 60000 = 不自动消失) - duration: 自动消失毫秒数(可选,默认 30000 = 不自动消失)
- action: { label, onClick } 可选操作按钮 - action: { label, onClick } 可选操作按钮
使用示例: 使用示例:
@@ -37,4 +37,20 @@
transform: translateX(0) scale(1); transform: translateX(0) scale(1);
} }
} }
@media (max-width: 640px) {
#chat-toast-container {
right: 12px !important;
bottom: calc(88px + env(safe-area-inset-bottom, 0px)) !important;
left: 12px !important;
align-items: flex-end;
}
#chat-toast-container .chat-toast-card {
width: fit-content !important;
min-width: 0 !important;
max-width: min(320px, calc(100vw - 24px)) !important;
overflow-wrap: anywhere;
}
}
</style> </style>
@@ -45,6 +45,11 @@
<div class="profile-info"> <div class="profile-info">
<h4> <h4>
<span x-text="userInfo.username"></span> <span x-text="userInfo.username"></span>
<template x-if="userInfo.previous_name">
<span style="font-size:11px; color:#999; font-weight:normal; margin-left:6px;">
(曾用名: <span x-text="userInfo.previous_name"></span>)
</span>
</template>
<span class="level-badge" x-text="'LV.' + userInfo.user_level"></span> <span class="level-badge" x-text="'LV.' + userInfo.user_level"></span>
<span class="sex-badge" <span class="sex-badge"
x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')" x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')"
@@ -421,7 +426,8 @@
$canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true); $canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true);
$canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true); $canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true);
$canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true); $canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true);
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser; $canViewWhispers = $myLevel >= $superLevel;
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser || $canViewWhispers;
$hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel; $hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel;
@endphp @endphp
@if ($hasUserModerationPermission || $hasPositionActions) @if ($hasUserModerationPermission || $hasPositionActions)
@@ -445,43 +451,49 @@
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;"> <div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
管理员操作 管理员操作
</div> </div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;"> <div style="display: flex; gap: 4px; flex-wrap: nowrap; margin-bottom: 8px;">
@if ($canWarnUser) @if ($canWarnUser)
<button <button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="warnUser()">⚠️ 警告 x-on:click="warnUser()">警告
</button> </button>
@endif @endif
@if ($canKickUser) @if ($canKickUser)
<button <button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
x-on:click="kickUser()">🚫 踢出 x-on:click="kickUser()">踢出
</button> </button>
@endif @endif
@if ($canMuteUser) @if ($canMuteUser)
<button <button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
x-on:click="isMuting = !isMuting">🔇 禁言 x-on:click="isMuting = !isMuting">禁言
</button> </button>
@endif @endif
@if ($canBanUser) @if ($canBanUser)
<button <button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #b91c1c; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fee2e2; border: 1px solid #b91c1c; cursor: pointer;"
x-on:click="banUser()"> 封号 x-on:click="banUser()">封号
</button> </button>
@endif @endif
@if ($canBanIpUser) @if ($canBanIpUser)
<button <button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #ffedd5; border: 1px solid #c2410c; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #ffedd5; border: 1px solid #c2410c; cursor: pointer;"
x-on:click="banIpUser()">🌐 封IP x-on:click="banIpUser()">封IP
</button>
@endif
@if ($canViewWhispers)
<button
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #ecfeff; border: 1px solid #0891b2; cursor: pointer;"
x-on:click="loadWhispers()">私信
</button> </button>
@endif @endif
{{-- 职务奖励金币(凭空产生),仅有明确奖励权限且 max_reward != 0 的人可见 --}} {{-- 职务奖励金币(凭空产生),仅有明确奖励权限且 max_reward != 0 的人可见 --}}
@if ($canRewardUser) @if ($canRewardUser)
<button x-show="window.chatContext?.myMaxReward !== 0" <button x-show="window.chatContext?.myMaxReward !== 0"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;" style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="openRewardModal(userInfo.username)">💰 奖励金币 x-on:click="openRewardModal(userInfo.username)">奖励
</button> </button>
@endif @endif
</div> </div>
@@ -565,7 +577,7 @@
<span style="color: #6366f1;" x-text="w.from_user"></span> <span style="color: #6366f1;" x-text="w.from_user"></span>
<span style="color: #059669;" x-text="w.to_user"></span> <span style="color: #059669;" x-text="w.to_user"></span>
<span x-text="w.content"></span> <span x-text="w.content"></span>
<span style="color: #aaa; font-size: 10px;" x-text="'(' + w.sent_at + ')'"></span> <span style="color: #aaa; font-size: 10px;" x-text="'(' + formatWhisperTime(w.sent_at) + ')'"></span>
</div> </div>
</template> </template>
<div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div> <div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div>
+16
View File
@@ -91,6 +91,10 @@
class="text-indigo-200 hover:text-white font-bold flex items-center transition hidden sm:flex"> class="text-indigo-200 hover:text-white font-bold flex items-center transition hidden sm:flex">
大厅 大厅
</a> </a>
<a href="{{ route('baccarat.history-page') }}"
class="text-red-400 hover:text-red-300 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('baccarat.*') ? 'text-red-200 underline underline-offset-4' : '' }}">
🎲 百家乐
</a>
<a href="{{ route('invite.leaderboard') }}" <a href="{{ route('invite.leaderboard') }}"
class="text-rose-400 hover:text-rose-300 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('invite.leaderboard') ? 'text-rose-200 underline underline-offset-4' : '' }}"> class="text-rose-400 hover:text-rose-300 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('invite.leaderboard') ? 'text-rose-200 underline underline-offset-4' : '' }}">
邀请排行 邀请排行
@@ -103,6 +107,10 @@
class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex"> class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex">
今日榜 今日榜
</a> </a>
<a href="{{ route('achievements.index') }}"
class="text-amber-300 hover:text-amber-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('achievements.*') ? 'text-amber-100 underline underline-offset-4' : '' }}">
成就
</a>
<a href="{{ route('duty-hall.index') }}" <a href="{{ route('duty-hall.index') }}"
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}"> class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
勤务台 勤务台
@@ -172,6 +180,10 @@
class="px-4 py-2.5 text-indigo-100 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('rooms.index') ? 'border-indigo-400 bg-indigo-700/50' : 'border-transparent' }}"> class="px-4 py-2.5 text-indigo-100 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('rooms.index') ? 'border-indigo-400 bg-indigo-700/50' : 'border-transparent' }}">
大厅 大厅
</a> </a>
<a href="{{ route('baccarat.history-page') }}"
class="px-4 py-2.5 text-red-300 hover:bg-indigo-700 hover:text-red-200 font-medium border-l-4 {{ request()->routeIs('baccarat.*') ? 'border-red-400 bg-indigo-700/50' : 'border-transparent' }}">
🎲 百家乐
</a>
<a href="{{ route('invite.leaderboard') }}" <a href="{{ route('invite.leaderboard') }}"
class="px-4 py-2.5 text-rose-300 hover:bg-indigo-700 hover:text-rose-200 font-medium border-l-4 {{ request()->routeIs('invite.leaderboard') ? 'border-rose-400 bg-indigo-700/50' : 'border-transparent' }}"> class="px-4 py-2.5 text-rose-300 hover:bg-indigo-700 hover:text-rose-200 font-medium border-l-4 {{ request()->routeIs('invite.leaderboard') ? 'border-rose-400 bg-indigo-700/50' : 'border-transparent' }}">
邀请排行 邀请排行
@@ -184,6 +196,10 @@
class="px-4 py-2.5 text-green-300 hover:bg-indigo-700 hover:text-green-200 font-medium border-l-4 {{ request()->routeIs('leaderboard.today') ? 'border-green-400 bg-indigo-700/50' : 'border-transparent' }}"> class="px-4 py-2.5 text-green-300 hover:bg-indigo-700 hover:text-green-200 font-medium border-l-4 {{ request()->routeIs('leaderboard.today') ? 'border-green-400 bg-indigo-700/50' : 'border-transparent' }}">
今日榜 今日榜
</a> </a>
<a href="{{ route('achievements.index') }}"
class="px-4 py-2.5 text-amber-200 hover:bg-indigo-700 hover:text-amber-100 font-medium border-l-4 {{ request()->routeIs('achievements.*') ? 'border-amber-400 bg-indigo-700/50' : 'border-transparent' }}">
成就
</a>
<a href="{{ route('duty-hall.index') }}" <a href="{{ route('duty-hall.index') }}"
class="px-4 py-2.5 text-purple-200 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('duty-hall.*') ? 'border-purple-400 bg-indigo-700/50' : 'border-transparent' }}"> class="px-4 py-2.5 text-purple-200 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('duty-hall.*') ? 'border-purple-400 bg-indigo-700/50' : 'border-transparent' }}">
勤务台 勤务台
+184 -26
View File
@@ -182,6 +182,101 @@
line-height: 1.7; line-height: 1.7;
} }
/* ── 智能引导 Tab 及微信向导样式 ── */
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
margin-bottom: 20px;
border: 1px solid var(--border);
background: rgba(255, 248, 236, 0.02);
padding: 2px;
}
.tab-btn {
height: 46px;
background: transparent;
border: none;
color: var(--text-soft);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.tab-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.02);
}
.tab-btn.active {
color: var(--gold);
background: rgba(198, 163, 91, 0.08);
font-weight: 700;
}
.wechat-guide {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 18px;
}
.guide-step {
display: flex;
align-items: flex-start;
gap: 12px;
background: rgba(255, 248, 236, 0.02);
border: 1px solid rgba(198, 163, 91, 0.08);
padding: 12px 14px;
}
.step-num {
width: 22px;
height: 22px;
background: var(--gold);
color: var(--bg);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
font-size: 12px;
flex-shrink: 0;
box-shadow: 0 0 10px rgba(198, 163, 91, 0.2);
}
.step-desc {
font-size: 13.5px;
line-height: 1.6;
color: var(--text-soft);
}
.step-desc strong {
color: var(--text);
}
.cmd-badge {
display: inline-block;
padding: 2px 8px;
background: var(--red);
color: #fff;
font-weight: 700;
font-size: 12px;
border-radius: 3px;
margin: 0 4px;
}
.wechat-tip {
border-left: 3px solid var(--gold);
background: rgba(198, 163, 91, 0.03);
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--text-soft);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.content { .content {
padding: 22px 18px 18px; padding: 22px 18px 18px;
@@ -198,41 +293,104 @@
<main class="card"> <main class="card">
<section class="content"> <section class="content">
<div class="eyebrow">PASSWORD RECOVERY</div> <div class="eyebrow">PASSWORD RECOVERY</div>
<h1>邮箱找回密码</h1> <h1 id="recovery-title">忘记密码</h1>
<p class="lead">请输入账号已绑定的邮箱地址。系统会把重置密码链接发送到邮箱,您再进入独立页面设置新密码。</p>
<div id="alert-box" class="alert" aria-live="polite"></div> <!-- ================== 步骤一:检测账号昵称 ================== -->
<div id="step-detect" class="step-panel">
<p class="lead">请输入您的聊天室用户昵称,小助手将智能查询并为您提供最安全的重置方案。</p>
<form id="account-detect-form" action="{{ route('password.check_account') }}">
<div id="alert-detect" class="alert" aria-live="polite"></div>
<label for="username">账号昵称</label>
<input
id="username"
name="username"
type="text"
maxlength="100"
placeholder="请输入您的聊天室账号昵称"
required
>
<div class="actions">
<button id="detect-btn" type="submit">下一步 (检测账号)</button>
<a class="ghost-link" href="{{ route('home') }}">返回首页</a>
</div>
</form>
</div>
<form id="password-recovery-form" data-password-email-url="{{ route('password.email') }}"> <!-- ================== 步骤二:智能找回分流面板 ================== -->
<label for="email">绑定邮箱</label> <div id="step-result" class="step-panel" style="display: none;">
<input <!-- 渠道切换 Tab(仅在双绑定时可见) -->
id="email" <div id="channel-tabs" class="tabs" style="display: none;">
name="email" <button type="button" class="tab-btn active" data-target="panel-wechat">💬 微信找回 (推荐)</button>
type="email" <button type="button" class="tab-btn" data-target="panel-email">📧 邮箱找回</button>
maxlength="255" </div>
placeholder="请输入账号绑定的邮箱地址"
autocomplete="email"
{{ $smtpEnabled ? '' : 'disabled' }}
required
>
<div class="tip">仅支持已绑定唯一邮箱的账号自助找回。重置链接默认 60 分钟内有效。</div>
<div class="actions"> <!-- 结果提示横幅 -->
<button id="submit-btn" type="submit" {{ $smtpEnabled ? '' : 'disabled' }}>发送重置邮件</button> <p id="detect-summary" class="lead" style="margin-bottom: 15px; font-weight: 500;"></p>
<!-- 模块一:微信私聊重置指引 -->
<div id="panel-wechat" class="channel-pane" style="display: none;">
<div class="wechat-guide">
<div class="guide-step">
<div class="step-num">1</div>
<div class="step-desc">打开微信,进入与微信助手 <strong>【小小】</strong> 的私发消息窗口。</div>
</div>
<div class="guide-step">
<div class="step-num">2</div>
<div class="step-desc">在对话框中直接输入并发送重置口令:<span class="cmd-badge">重置密码</span></div>
</div>
<div class="guide-step">
<div class="step-num">3</div>
<div class="step-desc">微信小助手将即时为您自动生成 <strong>8位随机新密码</strong> 并直接通过微信回复您!</div>
</div>
</div>
<div class="wechat-tip">
⚠️ <strong>安全提示:</strong> 该操作直接经由微信私聊分发,密码不泄露在大厅公屏,请放心使用。
</div>
</div>
<!-- 模块二:邮箱重置发送表单 -->
<div id="panel-email" class="channel-pane" style="display: none;">
<!-- 脱敏提示标签 -->
<div id="masked-email-hint" style="margin-bottom: 14px; font-size: 13.5px; color: var(--gold); font-weight: bold; line-height: 1.6; padding: 10px; border: 1px dashed rgba(198,163,91,0.3); background: rgba(198,163,91,0.02);"></div>
<form id="password-recovery-form" data-password-email-url="{{ route('password.email') }}">
<div id="alert-email" class="alert" aria-live="polite"></div>
<label for="email">二次安全确认:请输入绑定的完整邮箱</label>
<input
id="email"
name="email"
type="email"
maxlength="255"
placeholder="请输入绑定的完整邮箱地址以进行二次确认"
autocomplete="off"
required
>
<div class="tip" style="margin-top: 10px; margin-bottom: 15px;">为保障安全,邮箱已作脱敏隐藏。请手动输入完全一致的完整绑定邮箱,否则无法发送重置链接。</div>
<button id="submit-btn" type="submit" style="width: 100%;" {{ $smtpEnabled ? '' : 'disabled' }}>二次验证并发送重置邮件</button>
</form>
</div>
<!-- 模块三:均未绑定提示(无法自助找回) -->
<div id="panel-none" class="channel-pane" style="display: none;">
<div class="wechat-tip" style="border-color: var(--danger); background: rgba(143, 46, 39, 0.08); color: #f5cbc7;">
<strong>无法自助找回:</strong><br>
由于您的账号既未绑定邮箱,也未绑定微信,系统无法验证您的持有权。请联系站长或管理员进行人工核验申诉重置。
</div>
</div>
<!-- 底部返回与重试操作 -->
<div class="actions" style="margin-top: 20px;">
<button id="back-detect-btn" type="button" class="ghost-link" style="color: var(--gold); border-color: rgba(198,163,91,0.4);">返回重新检测</button>
<a class="ghost-link" href="{{ route('home') }}">返回首页</a> <a class="ghost-link" href="{{ route('home') }}">返回首页</a>
</div> </div>
</form> </div>
<div class="footer"> <div class="footer" id="forgot-footer">
@if ($smtpEnabled) 找回密码成功后,请使用账号昵称和新重置的密码登录聊天室。
找回成功后,请回到首页使用原昵称和新密码登录聊天室。
@else
当前系统尚未开启邮箱发信服务,暂时无法通过邮箱找回密码。
@endif
</div> </div>
</section> </section>
</main> </main>
</body> </body>
</html> </html>
@@ -0,0 +1,239 @@
@extends('layouts.app')
@section('title', '百家乐开奖走势 - 飘落流星')
@section('head')
<style>
/* 珠盘路横向滚动条美化 */
.custom-scrollbar::-webkit-scrollbar {
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #4b5563;
border-radius: 9999px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: #1f2937;
border-radius: 9999px;
}
</style>
@endsection
@section('content')
<div class="bg-gray-950 min-h-screen text-gray-100 py-10 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
{{-- 顶部标题与返回 --}}
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between border-b border-gray-800 pb-6 gap-4">
<div>
<h2 class="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
<span class="text-4xl">🎲</span> 百家乐开奖走势
</h2>
<p class="mt-2 text-sm text-gray-400">公开频段历史百家乐开奖记录、点数分布以及最新珠盘路大趋势。</p>
</div>
<a href="{{ route('rooms.index') }}"
class="inline-flex items-center gap-2 px-4 py-2 border border-gray-700 bg-gray-900 text-gray-300 rounded-xl text-sm font-semibold hover:bg-gray-800 hover:text-white transition shadow-sm self-start">
&larr; 返回大厅
</a>
</div>
{{-- 统计指标卡片组 --}}
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div class="bg-gray-900/60 backdrop-blur-md rounded-2xl border border-gray-800 p-5 shadow-lg">
<div class="text-3xl font-extrabold text-indigo-400 font-mono">{{ number_format($summary['total_rounds']) }}</div>
<div class="mt-2 text-xs text-gray-400 font-bold uppercase tracking-wider">历史总局数</div>
</div>
@php
$dist = $summary['result_dist'];
$total = max(1, $summary['total_rounds']);
$bigPercent = round((($dist['big'] ?? 0) / $total) * 100, 1);
$smallPercent = round((($dist['small'] ?? 0) / $total) * 100, 1);
$triplePercent = round((($dist['triple'] ?? 0) / $total) * 100, 1);
$killPercent = round((($dist['kill'] ?? 0) / $total) * 100, 1);
@endphp
<div class="bg-gray-900/60 backdrop-blur-md rounded-2xl border border-red-900/30 p-5 shadow-lg">
<div class="text-3xl font-extrabold text-red-500 font-mono">{{ $bigPercent }}%</div>
<div class="mt-2 text-xs text-gray-400 font-bold flex items-center justify-between">
<span>开大比例</span>
<span class="text-red-400 font-mono">{{ $dist['big'] ?? 0 }} </span>
</div>
</div>
<div class="bg-gray-900/60 backdrop-blur-md rounded-2xl border border-blue-900/30 p-5 shadow-lg">
<div class="text-3xl font-extrabold text-blue-500 font-mono">{{ $smallPercent }}%</div>
<div class="mt-2 text-xs text-gray-400 font-bold flex items-center justify-between">
<span>开小比例</span>
<span class="text-blue-400 font-mono">{{ $dist['small'] ?? 0 }} </span>
</div>
</div>
<div class="bg-gray-900/60 backdrop-blur-md rounded-2xl border border-purple-900/30 p-5 shadow-lg">
<div class="text-3xl font-extrabold text-purple-500 font-mono">{{ $triplePercent }}%</div>
<div class="mt-2 text-xs text-gray-400 font-bold flex items-center justify-between">
<span>开豹比例</span>
<span class="text-purple-400 font-mono">{{ $dist['triple'] ?? 0 }} </span>
</div>
</div>
<div class="bg-gray-900/60 backdrop-blur-md rounded-2xl border border-gray-800 p-5 shadow-lg col-span-2 md:col-span-1">
<div class="text-3xl font-extrabold text-gray-400 font-mono">{{ $killPercent }}%</div>
<div class="mt-2 text-xs text-gray-400 font-bold flex items-center justify-between">
<span>开收割比例</span>
<span class="text-gray-300 font-mono">{{ $dist['kill'] ?? 0 }} </span>
</div>
</div>
</div>
{{-- 珠盘路大趋势走势图 --}}
<div class="bg-gray-900 rounded-2xl p-6 border border-gray-800 shadow-xl overflow-hidden mb-8">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-bold text-gray-400 flex items-center gap-2">
<span>📊 珠盘路走势板(最近 30 局)</span>
</h3>
<span class="text-[10px] bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full font-mono">从左往右为历史开奖顺序</span>
</div>
<div class="flex space-x-2 overflow-x-auto pb-3 custom-scrollbar">
@forelse($rounds->take(30)->reverse() as $r)
<div class="flex flex-col items-center justify-center shrink-0 w-14 h-20 rounded-xl bg-gray-950/80 border border-gray-800/80 p-1.5 shadow-inner">
<span class="text-[9px] text-gray-600 font-mono font-medium">#{{ $r->id }}</span>
@php
$colorClass = match ($r->result) {
'big' => 'bg-gradient-to-br from-red-500 to-red-600 text-white shadow-red-500/20 border-red-400',
'small' => 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-blue-500/20 border-blue-400',
'triple' => 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-purple-500/20 border-purple-400',
default => 'bg-gradient-to-br from-gray-600 to-gray-700 text-gray-200 border-gray-500',
};
$label = match ($r->result) {
'big' => '大',
'small' => '小',
'triple' => '豹',
default => '割',
};
@endphp
<div class="my-1.5 w-7 h-7 rounded-full border-2 flex items-center justify-center text-xs font-black shadow-md {{ $colorClass }}">
{{ $label }}
</div>
<span class="text-[9px] text-gray-400 font-bold font-mono">{{ $r->total_points }} </span>
</div>
@empty
<div class="w-full py-6 text-center text-gray-600 text-xs">暂无走势数据</div>
@endforelse
</div>
</div>
{{-- 历史记录数据表 --}}
<div class="bg-gray-900 rounded-2xl border border-gray-800 shadow-xl overflow-hidden">
<div class="px-6 py-5 border-b border-gray-800 flex justify-between items-center bg-gray-900/50">
<h3 class="font-bold text-base text-white">百家乐局次历史记录</h3>
<span class="text-xs text-gray-500">仅展示已结算局次数据</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-gray-800 text-xs text-gray-400 uppercase tracking-wider bg-gray-950/40">
<th class="px-6 py-4 font-bold">局号</th>
<th class="px-6 py-4 font-bold">结算时间</th>
<th class="px-6 py-4 font-bold text-center">骰子开出</th>
<th class="px-6 py-4 font-bold text-center">总点数</th>
<th class="px-6 py-4 font-bold text-center">开奖结果</th>
<th class="px-6 py-4 font-bold text-right">全场总押注</th>
<th class="px-6 py-4 font-bold text-right">全场派奖金币</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 text-sm">
@forelse ($rounds as $round)
<tr class="hover:bg-gray-800/20 transition-colors">
<td class="px-6 py-4 font-mono text-gray-400 font-medium">#{{ $round->id }}</td>
<td class="px-6 py-4 text-gray-400 text-xs">
{{ $round->settled_at ? $round->settled_at->format('m-d H:i') : '—' }}
</td>
<td class="px-6 py-4 text-center">
<div class="inline-flex gap-1.5 justify-center">
@for ($i = 1; $i <= 3; $i++)
@php $diceVal = $round->{'dice'.$i}; @endphp
@if ($diceVal)
<span class="w-7 h-7 inline-flex items-center justify-center bg-gray-800 border border-gray-700 text-white font-black rounded-lg shadow-inner text-xs font-mono">
{{ $diceVal }}
</span>
@else
<span class="text-gray-600"></span>
@endif
@endfor
</div>
</td>
<td class="px-6 py-4 text-center font-extrabold text-white font-mono text-base">
{{ $round->total_points ?? '—' }}
</td>
<td class="px-6 py-4 text-center">
@php
$resultStyles = [
'big' => 'bg-red-950/50 text-red-400 border-red-800/60',
'small' => 'bg-blue-950/50 text-blue-400 border-blue-800/60',
'triple' => 'bg-purple-950/50 text-purple-400 border-purple-800/60',
'kill' => 'bg-gray-900 text-gray-500 border-gray-800',
];
$labelText = match ($round->result) {
'big' => '大',
'small' => '小',
'triple' => '豹子',
'kill' => '收割',
default => $round->result ?? '—',
};
$borderStyle = $resultStyles[$round->result] ?? 'bg-gray-900 text-gray-500 border-gray-800';
@endphp
@if ($round->result)
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-black border {{ $borderStyle }}">
{{ $labelText }}
</span>
@else
<span class="text-gray-600">未结算</span>
@endif
</td>
<td class="px-6 py-4 text-right text-xs font-mono text-gray-400">
<div class="flex flex-col items-end">
<span class="font-bold text-gray-300">
{{ number_format(($round->total_bet_big ?? 0) + ($round->total_bet_small ?? 0) + ($round->total_bet_triple ?? 0)) }}
</span>
<span class="text-[9px] text-gray-500 mt-0.5">
:<span class="text-red-400">{{ number_format($round->total_bet_big ?? 0) }}</span> |
:<span class="text-blue-400">{{ number_format($round->total_bet_small ?? 0) }}</span> |
:<span class="text-purple-400">{{ number_format($round->total_bet_triple ?? 0) }}</span>
</span>
</div>
</td>
<td class="px-6 py-4 text-right font-mono font-extrabold text-amber-500 text-base">
{{ number_format($round->total_payout ?? 0) }}
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
<p class="font-bold">暂无开奖记录</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- 分页器美化 --}}
@if ($rounds->hasPages())
<div class="px-6 py-5 border-t border-gray-800 bg-gray-950/20">
{{-- Laravel 默认分页器兼容 --}}
<div class="baccarat-pagination-dark">
{{ $rounds->links() }}
</div>
</div>
@endif
</div>
</div>
</div>
@endsection
+3 -1
View File
@@ -9,7 +9,9 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
// 聊天室房间 Presence Channel 鉴权与成员信息抓取 // 聊天室房间 Presence Channel 鉴权与成员信息抓取
Broadcast::channel('room.{roomId}', function ($user, $roomId) { Broadcast::channel('room.{roomId}', function ($user, $roomId) {
$room = \App\Models\Room::find($roomId); $room = \Illuminate\Support\Facades\Cache::remember("room:meta:{$roomId}", 300, function () use ($roomId) {
return \App\Models\Room::find($roomId);
});
if (! $room || ! $room->canUserEnter($user)) { if (! $room || ! $room->canUserEnter($user)) {
return false; return false;
} }
+6 -2
View File
@@ -12,10 +12,14 @@ Artisan::command('inspire', function () {
Schedule::command('messages:purge')->dailyAt('03:00'); Schedule::command('messages:purge')->dailyAt('03:00');
// 每 5 分钟为所有在线用户自动存点(经验/金币/等级) // 每 5 分钟为所有在线用户自动存点(经验/金币/等级)
Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes(); Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes()->withoutOverlapping()->runInBackground();
// 每 10 分钟扫描最近活跃用户的成就进度,夜间再做一次全量补算
Schedule::command('achievements:scan --notify')->everyTenMinutes()->withoutOverlapping();
Schedule::command('achievements:scan --all')->dailyAt('03:30')->withoutOverlapping();
// 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件 // 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件
Schedule::command('chatroom:ai-heartbeat')->everyMinute(); Schedule::command('chatroom:ai-heartbeat')->everyMinute()->withoutOverlapping()->runInBackground();
// 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at // 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at
Schedule::command('duty:close-stale-logs')->everyFifteenMinutes(); Schedule::command('duty:close-stale-logs')->everyFifteenMinutes();
+5
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\AchievementController;
use App\Http\Controllers\Admin\AdminAuthController; use App\Http\Controllers\Admin\AdminAuthController;
use App\Http\Controllers\AdminCommandController; use App\Http\Controllers\AdminCommandController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
@@ -46,6 +47,7 @@ Route::post('/login', [AuthController::class, 'login'])
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/forgot-password', [PasswordResetController::class, 'create'])->name('password.request'); Route::get('/forgot-password', [PasswordResetController::class, 'create'])->name('password.request');
Route::post('/forgot-password', [PasswordResetController::class, 'storeLink'])->name('password.email'); Route::post('/forgot-password', [PasswordResetController::class, 'storeLink'])->name('password.email');
Route::post('/forgot-password/check-account', [PasswordResetController::class, 'checkAccount'])->name('password.check_account');
Route::get('/reset-password/{token}', [PasswordResetController::class, 'edit'])->name('password.reset'); Route::get('/reset-password/{token}', [PasswordResetController::class, 'edit'])->name('password.reset');
Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update'); Route::post('/reset-password', [PasswordResetController::class, 'update'])->name('password.update');
}); });
@@ -78,6 +80,7 @@ Route::middleware(['chat.auth'])->group(function () {
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index'); Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today'); Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today');
Route::get('/achievements', [AchievementController::class, 'index'])->name('achievements.index');
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// 邀请排行达人榜 // 邀请排行达人榜
@@ -168,6 +171,7 @@ Route::middleware(['chat.auth'])->group(function () {
Route::get('/current', [\App\Http\Controllers\BaccaratController::class, 'currentRound'])->name('current'); Route::get('/current', [\App\Http\Controllers\BaccaratController::class, 'currentRound'])->name('current');
Route::post('/bet', [\App\Http\Controllers\BaccaratController::class, 'bet'])->name('bet'); Route::post('/bet', [\App\Http\Controllers\BaccaratController::class, 'bet'])->name('bet');
Route::get('/history', [\App\Http\Controllers\BaccaratController::class, 'history'])->name('history'); Route::get('/history', [\App\Http\Controllers\BaccaratController::class, 'history'])->name('history');
Route::get('/history-page', [\App\Http\Controllers\BaccaratController::class, 'historyPage'])->name('history-page');
}); });
// ── 百家乐买单活动(前台)─────────────────────────────────────── // ── 百家乐买单活动(前台)───────────────────────────────────────
@@ -512,6 +516,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
// 全局用户金币/积分流水 // 全局用户金币/积分流水
Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index'); Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index');
Route::get('/achievements', [\App\Http\Controllers\Admin\AchievementController::class, 'index'])->name('achievements.index');
// 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1 // 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1
Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index'); Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index');
+285
View File
@@ -0,0 +1,285 @@
<?php
/**
* 文件功能:用户成就系统功能测试。
*
* 覆盖固定成就扫描、重复扫描幂等性与本人可见通知。
*/
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Jobs\SaveMessageJob;
use App\Models\DailySignIn;
use App\Models\Message;
use App\Models\Room;
use App\Models\User;
use App\Models\UserAchievement;
use App\Models\UserCurrencyLog;
use App\Services\AchievementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* 类功能:验证成就扫描服务和 Artisan 命令的核心行为。
*/
class AchievementServiceTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis 房间状态。
*/
protected function setUp(): void
{
parent::setUp();
$this->flushChatRoomRedisState();
}
/**
* 测试命令可以按现有日志解锁聊天、签到与游戏成就。
*/
public function test_scan_command_unlocks_achievements_from_existing_logs(): void
{
$room = Room::create(['room_name' => 'ach']);
$user = User::factory()->create([
'username' => 'achiever',
'room_id' => $room->id,
'jjb' => 700000,
'bank_jjb' => 300000,
]);
for ($i = 0; $i < 100; $i++) {
Message::query()->create([
'room_id' => $room->id,
'from_user' => $user->username,
'to_user' => '大家',
'content' => '成就聊天',
'is_secret' => false,
'font_color' => '',
'action' => '',
'message_type' => 'text',
'retention_type' => Message::RETENTION_USER_CHAT,
'sent_at' => now()->subMinutes($i + 1),
]);
}
DailySignIn::query()->create([
'user_id' => $user->id,
'room_id' => $room->id,
'sign_in_date' => today(),
'streak_days' => 7,
'gold_reward' => 10,
'exp_reward' => 20,
'charm_reward' => 0,
]);
UserCurrencyLog::query()->create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => 1000,
'balance_after' => 1000,
'source' => CurrencySource::GAME_REWARD->value,
'remark' => '猜谜奖励',
'room_id' => $room->id,
]);
UserCurrencyLog::query()->create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => -1000,
'balance_after' => 0,
'source' => CurrencySource::BACCARAT_BET->value,
'remark' => '百家乐下注',
'room_id' => $room->id,
]);
$this->artisan('achievements:scan', ['--user' => $user->username])
->assertSuccessful();
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'chat_first_message',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'chat_100_messages',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'signin_7_streak',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'game_riddle_win',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'game_win_1000',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'game_loss_1000',
]);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'growth_assets_1000000',
]);
$this->assertDatabaseHas('user_achievement_progress', [
'user_id' => $user->id,
'achievement_key' => 'chat_100_messages',
'progress_value' => 100,
'threshold_value' => 100,
]);
$this->assertNotNull(UserAchievement::query()
->where('user_id', $user->id)
->where('achievement_key', 'chat_100_messages')
->value('achieved_at'));
}
/**
* 测试重复扫描不会重复创建同一个用户成就。
*/
public function test_scan_command_is_idempotent_for_same_achievement(): void
{
$room = Room::create(['room_name' => 'idem']);
$user = User::factory()->create(['username' => 'idem_user', 'room_id' => $room->id]);
Message::query()->create([
'room_id' => $room->id,
'from_user' => $user->username,
'to_user' => '大家',
'content' => '第一条',
'is_secret' => false,
'font_color' => '',
'action' => '',
'message_type' => 'text',
'retention_type' => Message::RETENTION_USER_CHAT,
'sent_at' => now(),
]);
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
$this->assertSame(1, UserAchievement::query()
->where('user_id', $user->id)
->where('achievement_key', 'chat_first_message')
->count());
}
/**
* 测试成就解锁通知只发给本人并带悄悄话标记。
*/
public function test_unlock_notification_is_private_to_the_user(): void
{
Queue::fake([SaveMessageJob::class]);
$room = Room::create(['room_name' => 'notice']);
$user = User::factory()->create(['username' => 'notice_user', 'room_id' => $room->id]);
Message::query()->create([
'room_id' => $room->id,
'from_user' => $user->username,
'to_user' => '大家',
'content' => '第一条',
'is_secret' => false,
'font_color' => '',
'action' => '',
'message_type' => 'text',
'retention_type' => Message::RETENTION_USER_CHAT,
'sent_at' => now(),
]);
app(AchievementService::class)->scanUser($user, notify: true);
$messages = collect(Redis::lrange("room:{$room->id}:messages", 0, -1))
->map(fn (string $item): array => json_decode($item, true));
$notice = $messages->first(fn (array $item): bool => ($item['action'] ?? '') === 'achievement_unlocked');
$this->assertNotNull($notice);
$this->assertSame($user->username, $notice['to_user'] ?? null);
$this->assertTrue((bool) ($notice['is_secret'] ?? false));
$this->assertSame(Message::RETENTION_SYSTEM_NOTICE, $notice['retention_type'] ?? null);
Queue::assertPushed(SaveMessageJob::class);
}
/**
* 测试打开我的成就页面时会静默补算已达标成就。
*/
public function test_achievement_page_silently_unlocks_reached_achievements(): void
{
$room = Room::create(['room_name' => 'page']);
$user = User::factory()->create(['username' => 'page_user', 'room_id' => $room->id]);
Message::query()->create([
'room_id' => $room->id,
'from_user' => $user->username,
'to_user' => '大家',
'content' => '页面触发补算',
'is_secret' => false,
'font_color' => '',
'action' => '',
'message_type' => 'text',
'retention_type' => Message::RETENTION_USER_CHAT,
'sent_at' => now(),
]);
$this->assertDatabaseMissing('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'chat_first_message',
]);
$this->actingAs($user)
->get(route('achievements.index'))
->assertOk()
->assertSee('已解锁 1 /', false);
$this->assertDatabaseHas('user_achievements', [
'user_id' => $user->id,
'achievement_key' => 'chat_first_message',
]);
$this->assertNotNull(UserAchievement::query()
->where('user_id', $user->id)
->where('achievement_key', 'chat_first_message')
->value('achieved_at'));
}
/**
* 测试我的成就页面可以按已完成和未达成筛选。
*/
public function test_achievement_page_can_filter_by_unlocked_and_locked_tabs(): void
{
$room = Room::create(['room_name' => 'tabs']);
$user = User::factory()->create(['username' => 'tab_user', 'room_id' => $room->id]);
Message::query()->create([
'room_id' => $room->id,
'from_user' => $user->username,
'to_user' => '大家',
'content' => '筛选测试',
'is_secret' => false,
'font_color' => '',
'action' => '',
'message_type' => 'text',
'retention_type' => Message::RETENTION_USER_CHAT,
'sent_at' => now(),
]);
$this->actingAs($user)
->get(route('achievements.index', ['status' => 'unlocked']))
->assertOk()
->assertSee('已完成')
->assertSee('初来乍到')
->assertDontSee('百句达人');
$this->actingAs($user)
->get(route('achievements.index', ['status' => 'locked']))
->assertOk()
->assertSee('未达成')
->assertSee('百句达人')
->assertDontSee('初来乍到');
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
/**
* 文件功能:AI小班长心跳命令测试
*
* 覆盖定时心跳的快速退出、基础奖励、签到幂等和钓鱼任务派发,
* 确保优化调度耗时后不改变原有业务行为。
*/
namespace Tests\Feature;
use App\Events\MessageSent;
use App\Jobs\AiFishingJob;
use App\Models\DailySignIn;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\SignInService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Mockery\MockInterface;
use Tests\TestCase;
/**
* 类功能:验证 AI小班长定时心跳命令的性能优化不破坏核心效果。
*/
class AiHeartbeatCommandTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空配置缓存,避免系统参数读取互相串扰。
*/
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
/**
* AI 开关关闭时,命令应直接成功退出且不修改 AI 资产。
*/
public function test_ai_heartbeat_exits_when_chatbot_disabled(): void
{
$aiUser = User::factory()->create([
'username' => 'AI小班长',
'exp_num' => 10,
'jjb' => 20,
]);
Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '0']);
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
$aiUser->refresh();
$this->assertSame(10, (int) $aiUser->exp_num);
$this->assertSame(20, (int) $aiUser->jjb);
}
/**
* AI 开启时,基础心跳仍会发放配置中的经验和金币。
*/
public function test_ai_heartbeat_grants_base_exp_and_gold(): void
{
Event::fake([MessageSent::class]);
$aiUser = User::factory()->create([
'username' => 'AI小班长',
'user_level' => 1,
'exp_num' => 10,
'jjb' => 20,
]);
$this->markAlreadySigned($aiUser);
$this->enableHeartbeat([
'exp_per_heartbeat' => '5',
'jjb_per_heartbeat' => '7',
'auto_event_chance' => '0',
]);
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
$aiUser->refresh();
$this->assertSame(15, (int) $aiUser->exp_num);
$this->assertSame(27, (int) $aiUser->jjb);
}
/**
* 今日已签到时,命令不应再进入签到事务,避免每分钟重复做重活。
*/
public function test_ai_heartbeat_skips_sign_in_claim_when_already_signed_today(): void
{
$aiUser = User::factory()->create([
'username' => 'AI小班长',
'exp_num' => 0,
'jjb' => 0,
]);
$this->markAlreadySigned($aiUser);
$this->enableHeartbeat([
'exp_per_heartbeat' => '0',
'jjb_per_heartbeat' => '0',
'auto_event_chance' => '0',
]);
$this->mock(SignInService::class, function (MockInterface $mock): void {
$mock->shouldReceive('claim')->never();
});
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
}
/**
* 钓鱼开启且命中概率时,命令只派发延迟收竿任务,不在当前心跳里等待结果。
*/
public function test_ai_heartbeat_dispatches_delayed_fishing_job(): void
{
Bus::fake();
Event::fake([MessageSent::class]);
$aiUser = User::factory()->create([
'username' => 'AI小班长',
'exp_num' => 0,
'jjb' => 100,
]);
$this->markAlreadySigned($aiUser);
$this->enableHeartbeat([
'exp_per_heartbeat' => '0',
'jjb_per_heartbeat' => '0',
'auto_event_chance' => '0',
'chatbot_fishing_enabled' => '1',
'chatbot_fishing_chance' => '100',
]);
GameConfig::query()->create([
'game_key' => 'fishing',
'name' => '钓鱼',
'icon' => '🎣',
'enabled' => true,
'params' => [
'fishing_cost' => 5,
'fishing_wait_min' => 8,
'fishing_wait_max' => 8,
],
]);
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
Bus::assertDispatched(AiFishingJob::class);
$aiUser->refresh();
$this->assertSame(95, (int) $aiUser->jjb);
}
/**
* 批量写入本轮心跳需要的系统参数。
*
* @param array<string, string> $overrides 覆盖默认值的配置
*/
private function enableHeartbeat(array $overrides = []): void
{
$defaults = [
'chatbot_enabled' => '1',
'exp_per_heartbeat' => '0',
'jjb_per_heartbeat' => '0',
'superlevel' => '100',
'auto_event_chance' => '0',
'chatbot_fishing_enabled' => '0',
'chatbot_fishing_chance' => '0',
'fishing_cost' => '5',
'fishing_wait_min' => '8',
'fishing_wait_max' => '15',
];
foreach (array_merge($defaults, $overrides) as $alias => $body) {
Sysparam::query()->updateOrCreate(['alias' => $alias], ['body' => $body]);
}
Cache::flush();
}
/**
* 创建今日已签到记录,让测试聚焦心跳命令本身。
*/
private function markAlreadySigned(User $user): void
{
DailySignIn::query()->create([
'user_id' => $user->id,
'room_id' => 1,
'sign_in_date' => today()->toDateString(),
'streak_days' => 1,
'gold_reward' => 0,
'exp_reward' => 0,
'charm_reward' => 0,
]);
}
}
+89
View File
@@ -87,6 +87,7 @@ class AutoSaveExpCommandTest extends TestCase
]); ]);
Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE)); Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
Redis::setex("room:{$room->id}:alive:{$user->username}", 90, 1);
$this->artisan('chatroom:auto-save-exp')->assertSuccessful(); $this->artisan('chatroom:auto-save-exp')->assertSuccessful();
@@ -98,4 +99,92 @@ class AutoSaveExpCommandTest extends TestCase
$this->assertStringContainsString('部门 办公厅 · 职务 🏛️ 厅长 · 会员 👑 至尊会员', $notice['content']); $this->assertStringContainsString('部门 办公厅 · 职务 🏛️ 厅长 · 会员 👑 至尊会员', $notice['content']);
$this->assertStringContainsString('LV.100 · 经验 168762 · 金币 1100017 · 已满级 ✓', $notice['content']); $this->assertStringContainsString('LV.100 · 经验 168762 · 金币 1100017 · 已满级 ✓', $notice['content']);
} }
/**
* 测试自动存点命令会清理掉线不活跃的用户(懒清理)并不发放奖励。
*/
public function test_auto_save_cleans_up_inactive_zombie_users(): void
{
Sysparam::updateOrCreate(['alias' => 'exp_per_heartbeat'], ['body' => '5']);
Cache::flush();
$room = Room::create(['room_name' => 'zmb']);
$user = User::factory()->create([
'username' => 'zombie-user',
'exp_num' => 10,
]);
// 只把用户加入哈希表,但不设置 alive 活跃键
Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
$this->artisan('chatroom:auto-save-exp')->assertSuccessful();
// 验证用户已经被从房间在线哈希表中懒清理剔除
$this->assertFalse((bool) Redis::hexists("room:{$room->id}:users", $user->username));
// 验证用户的经验没有增加(仍然是 10)
$user->refresh();
$this->assertEquals(10, $user->exp_num);
}
/**
* 测试自动存点命令会全局去重,防止玩家在多房间同时挂机时重复吃挂机奖励。
*/
public function test_auto_save_deduplicates_multiple_rooms_rewards(): void
{
Sysparam::updateOrCreate(['alias' => 'exp_per_heartbeat'], ['body' => '5']);
Cache::flush();
$room1 = Room::create(['room_name' => 'r1']);
$room2 = Room::create(['room_name' => 'r2']);
$user = User::factory()->create([
'username' => 'multi-room-user',
'exp_num' => 10,
]);
// 玩家同时在两个房间在线,且两边都有心跳活跃标记
Redis::hset("room:{$room1->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
Redis::setex("room:{$room1->id}:alive:{$user->username}", 90, 1);
Redis::hset("room:{$room2->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
Redis::setex("room:{$room2->id}:alive:{$user->username}", 90, 1);
$this->artisan('chatroom:auto-save-exp')->assertSuccessful();
// 验证用户只被加了一次经验(10 + 5 = 15,而不是 10 + 5 + 5 = 20
$user->refresh();
$this->assertEquals(15, $user->exp_num);
}
/**
* 测试自动存点命令会通过 Redis 冷却锁,防止在短时间内并发重复调度执行。
*/
public function test_auto_save_cooldown_prevents_overlapping_process(): void
{
Sysparam::updateOrCreate(['alias' => 'exp_per_heartbeat'], ['body' => '5']);
Cache::flush();
$room = Room::create(['room_name' => 'cd']);
$user = User::factory()->create([
'username' => 'cooldown-user',
'exp_num' => 10,
]);
Redis::hset("room:{$room->id}:users", $user->username, json_encode(['username' => $user->username], JSON_UNESCAPED_UNICODE));
Redis::setex("room:{$room->id}:alive:{$user->username}", 90, 1);
// 第一次执行自动存点
$this->artisan('chatroom:auto-save-exp')->assertSuccessful();
// 验证第一次加了经验 (10 + 5 = 15)
$user->refresh();
$this->assertEquals(15, $user->exp_num);
// 紧接着在冷却时间内(200秒内)模拟第二次并发执行
$this->artisan('chatroom:auto-save-exp')->assertSuccessful();
// 验证因为有 Redis 冷却锁,第二次执行被拦截,经验仍为 15,并未再次增加
$user->refresh();
$this->assertEquals(15, $user->exp_num);
}
} }
+35 -5
View File
@@ -27,6 +27,9 @@ class BaccaratControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
/**
* 方法功能:初始化百家乐默认配置。
*/
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@@ -46,7 +49,10 @@ class BaccaratControllerTest extends TestCase
); );
} }
public function test_can_get_current_round() /**
* 方法功能:验证可以获取当前百家乐局次。
*/
public function test_can_get_current_round(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 3456]); $user = User::factory()->create(['jjb' => 3456]);
@@ -73,7 +79,10 @@ class BaccaratControllerTest extends TestCase
$this->assertSame(3456, $response->json('jjb')); $this->assertSame(3456, $response->json('jjb'));
} }
public function test_can_bet() /**
* 方法功能:验证可以下注并给房间在线用户广播右下角通知。
*/
public function test_can_bet(): void
{ {
Event::fake(); Event::fake();
@@ -112,9 +121,24 @@ class BaccaratControllerTest extends TestCase
]); ]);
Event::assertDispatched(\App\Events\BaccaratPoolUpdated::class); Event::assertDispatched(\App\Events\BaccaratPoolUpdated::class);
$messages = Redis::lrange('room:1:messages', 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['to_user'] ?? null) === '大家'
&& ($item['toast_notification']['title'] ?? null) === '🎲 有人下注百家乐'
&& str_contains((string) ($item['toast_notification']['message'] ?? ''), $user->username));
$this->assertNotNull($publicMessage);
$this->assertFalse((bool) ($publicMessage['is_secret'] ?? true));
$this->assertStringContainsString($user->username, (string) ($publicMessage['toast_notification']['message'] ?? ''));
$this->assertSame('🎲', $publicMessage['toast_notification']['icon'] ?? null);
} }
public function test_cannot_bet_out_of_range() /**
* 方法功能:验证超出配置范围的下注会被拦截。
*/
public function test_cannot_bet_out_of_range(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 200]); $user = User::factory()->create(['jjb' => 200]);
@@ -143,7 +167,10 @@ class BaccaratControllerTest extends TestCase
$response->assertJson(['ok' => false]); $response->assertJson(['ok' => false]);
} }
public function test_cannot_bet_twice_in_same_round() /**
* 方法功能:验证同一用户同一局不能重复下注。
*/
public function test_cannot_bet_twice_in_same_round(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 200]); $user = User::factory()->create(['jjb' => 200]);
@@ -180,7 +207,10 @@ class BaccaratControllerTest extends TestCase
$response->assertJson(['ok' => false]); $response->assertJson(['ok' => false]);
} }
public function test_can_get_history() /**
* 方法功能:验证可以获取最近已结算百家乐历史。
*/
public function test_can_get_history(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
+181 -7
View File
@@ -8,6 +8,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Events\MessageSent; use App\Events\MessageSent;
use App\Models\Department; use App\Models\Department;
use App\Models\Gift; use App\Models\Gift;
@@ -16,6 +17,7 @@ use App\Models\Ride;
use App\Models\Room; use App\Models\Room;
use App\Models\Sysparam; use App\Models\Sysparam;
use App\Models\User; use App\Models\User;
use App\Models\UserCurrencyLog;
use App\Models\UserPosition; use App\Models\UserPosition;
use App\Models\UserRidePurchase; use App\Models\UserRidePurchase;
use App\Models\VipLevel; use App\Models\VipLevel;
@@ -25,6 +27,7 @@ use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
@@ -66,6 +69,57 @@ class ChatControllerTest extends TestCase
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username)); $this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
} }
/**
* 测试聊天室顶部会展示百家乐与赛马今日净盈利前三。
*/
public function test_room_header_displays_daily_game_profit_top_three(): void
{
Cache::flush();
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
$room = Room::create(['room_name' => 'profitroom']);
$viewer = User::factory()->create(['username' => '围观群众', 'user_level' => 1]);
$first = User::factory()->create(['username' => '第一赢家', 'user_level' => 1]);
$second = User::factory()->create(['username' => '第二赢家', 'user_level' => 1]);
$third = User::factory()->create(['username' => '第三赢家', 'user_level' => 1]);
$yesterdayWinner = User::factory()->create(['username' => '昨日财神', 'user_level' => 1]);
$breakEvenUser = User::factory()->create(['username' => '白忙选手', 'user_level' => 1]);
$admin = User::factory()->create(['username' => '超级管理员', 'user_level' => 100]);
$bot = User::factory()->create(['username' => 'AI小班长', 'user_level' => 1]);
$this->recordGameGoldLog($first, CurrencySource::BACCARAT_WIN, 10000);
$this->recordGameGoldLog($first, CurrencySource::BACCARAT_BET, -2500);
$this->recordGameGoldLog($second, CurrencySource::HORSE_WIN, 6000);
$this->recordGameGoldLog($second, CurrencySource::HORSE_BET, -1000);
$this->recordGameGoldLog($third, CurrencySource::BACCARAT_WIN, 3000);
$this->recordGameGoldLog($yesterdayWinner, CurrencySource::HORSE_WIN, 50000, now()->subDay());
$this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_WIN, 1000);
$this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_BET, -1000);
$this->recordGameGoldLog($admin, CurrencySource::BACCARAT_WIN, 999999);
$this->recordGameGoldLog($bot, CurrencySource::HORSE_WIN, 888888);
$response = $this->actingAs($viewer)->get(route('chat.room', $room->id));
$leaders = $response->viewData('dailyGameProfitLeaders');
$this->assertSame(['超级管理员', '第一赢家', '第二赢家'], $leaders->pluck('username')->all());
$this->assertSame([999999, 7500, 5000], $leaders->pluck('net_profit')->all());
$this->assertStringStartsWith('/images/headface/', $leaders->first()->headface_url);
$response->assertOk();
$response->assertSee('今日游戏三杰');
$response->assertSee('class="daily-game-profit-avatar"', false);
$response->assertSee('data-username="超级管理员"', false);
$response->assertSee('data-username="第一赢家"', false);
$response->assertSee('data-username="第二赢家"', false);
$response->assertDontSee('金库爆破王');
$response->assertDontSee('马桌双修财神');
$response->assertDontSee('金币收割机');
$response->assertDontSee('+999,999');
$response->assertDontSee('+7,500');
$response->assertDontSee('+5,000');
}
/** /**
* 测试关闭房间会拒绝普通用户直接进入。 * 测试关闭房间会拒绝普通用户直接进入。
*/ */
@@ -378,12 +432,11 @@ class ChatControllerTest extends TestCase
$response = $this->actingAs($user)->get(route('chat.room', $room->id)); $response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk(); $response->assertOk();
$response->assertSee('⚠️ 警告', false); $response->assertSee('警告', false);
$response->assertSee('🔇 禁言', false); $response->assertSee('禁言', false);
$response->assertSee('封号', false); $response->assertSee('封号', false);
$response->assertDontSee('🚫 踢出', false); $response->assertDontSee('踢出', false);
$response->assertDontSee('🌐 封IP', false); $response->assertDontSee('封IP', false);
$response->assertDontSee('🧊 冻结', false);
} }
/** /**
@@ -779,10 +832,36 @@ class ChatControllerTest extends TestCase
\Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([ \Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([
'status' => 'error', 'status' => 'error',
'message' => '页面已过期,请刷新后重试。', 'code' => 'SESSION_EXPIRED',
'message' => '登录状态已失效,请刷新页面后重新登录。',
'reload' => true,
]); ]);
} }
/**
* 测试掉线后的普通 JSON 接口遇到 CSRF 失效时,不会泄露框架异常结构。
*/
public function test_json_token_mismatch_exception_renders_session_expired_response(): void
{
$request = Request::create('/user/profile', 'POST', server: [
'HTTP_ACCEPT' => 'application/json',
]);
$response = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class)
->render($request, new TokenMismatchException('CSRF token mismatch.'));
\Illuminate\Testing\TestResponse::fromBaseResponse($response)
->assertStatus(419)
->assertJson([
'status' => 'error',
'code' => 'SESSION_EXPIRED',
'message' => '登录状态已失效,请刷新页面后重新登录。',
'reload' => true,
])
->assertJsonMissingPath('exception')
->assertJsonMissingPath('trace');
}
/** /**
* 测试房间公告更新广播中的动态内容会被转义。 * 测试房间公告更新广播中的动态内容会被转义。
*/ */
@@ -854,6 +933,84 @@ class ChatControllerTest extends TestCase
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png'); Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
} }
/**
* 测试消息清理命令会永久保留普通用户聊天,只删除过期游戏/临时通知。
*/
public function test_purge_command_keeps_user_chat_but_deletes_expired_notices(): void
{
$userMessage = \App\Models\Message::create([
'room_id' => 1,
'from_user' => 'normal_user',
'to_user' => '大家',
'content' => '这条用户聊天需要永久保留',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'message_type' => 'text',
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
'sent_at' => now()->subDays(60),
]);
$gameNotice = \App\Models\Message::create([
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => '过期游戏通知',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'message_type' => 'text',
'retention_type' => \App\Models\Message::RETENTION_GAME_NOTICE,
'sent_at' => now()->subDays(60),
]);
$temporaryNotice = \App\Models\Message::create([
'room_id' => 1,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => '过期进出播报',
'is_secret' => false,
'font_color' => '#000000',
'action' => 'system_welcome',
'message_type' => 'text',
'retention_type' => \App\Models\Message::RETENTION_EPHEMERAL_NOTICE,
'sent_at' => now()->subDays(60),
]);
$this->artisan('messages:purge', [
'--days' => 30,
'--image-days' => 3,
])->assertExitCode(0);
$this->assertDatabaseHas('messages', ['id' => $userMessage->id]);
$this->assertDatabaseMissing('messages', ['id' => $gameNotice->id]);
$this->assertDatabaseMissing('messages', ['id' => $temporaryNotice->id]);
}
/**
* 测试迁移前默认归为 user_chat 的旧游戏通知也会被清理。
*/
public function test_purge_command_deletes_legacy_game_notice_patterns(): void
{
$legacyNotice = \App\Models\Message::create([
'room_id' => 1,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => '旧钓鱼通知',
'is_secret' => false,
'font_color' => '#000000',
'action' => 'fishing_result',
'message_type' => 'text',
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
'sent_at' => now()->subDays(60),
]);
$this->artisan('messages:purge', [
'--days' => 30,
'--image-days' => 3,
])->assertExitCode(0);
$this->assertDatabaseMissing('messages', ['id' => $legacyNotice->id]);
}
/** /**
* 测试心跳接口可以正常返回成功响应。 * 测试心跳接口可以正常返回成功响应。
*/ */
@@ -1394,6 +1551,23 @@ class ChatControllerTest extends TestCase
$response->assertStatus(403); $response->assertStatus(403);
} }
/**
* 记录一条游戏金币流水,用于构造每日游戏净盈利榜测试数据。
*/
private function recordGameGoldLog(User $user, CurrencySource $source, int $amount, \DateTimeInterface|string|null $createdAt = null): void
{
UserCurrencyLog::forceCreate([
'user_id' => $user->id,
'username' => $user->username,
'currency' => 'gold',
'amount' => $amount,
'balance_after' => max(0, 100000 + $amount),
'source' => $source->value,
'remark' => '每日游戏净盈利榜测试',
'created_at' => $createdAt ?? now(),
]);
}
/** /**
* 创建带指定聊天室权限的在职职务用户。 * 创建带指定聊天室权限的在职职务用户。
* *
+115
View File
@@ -0,0 +1,115 @@
<?php
/**
* 文件功能:微信机器人 Kafka 消息消费及密码重置测试
*
* 覆盖在微信上对助手机器人发送重置密码的拦截逻辑,
* 验证对未绑定账号及已绑定账号的回复行为和密码更新结果。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Tests\Feature;
use App\Models\User;
use App\Services\WechatBot\KafkaConsumerService;
use App\Services\WechatBot\WechatBotApiService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Mockery\MockInterface;
use Tests\TestCase;
/**
* 验证 ConsumeWechatMessages 控制台消费命令的解析与动作路由行为。
*/
class ConsumeWechatMessagesTest extends TestCase
{
use RefreshDatabase;
/**
* 当微信用户未绑定任何聊天室账户时发送“重置密码”,回复未绑定指引微信。
*/
public function test_wechat_password_reset_fails_when_not_bound(): void
{
$apiServiceMock = $this->mock(WechatBotApiService::class, function (MockInterface $mock) {
$mock->shouldReceive('sendTextMessage')
->once()
->with('wxid_test_user_123', \Mockery::on(function ($arg) {
return str_contains($arg, '密码重置失败') && str_contains($arg, '尚未绑定');
}))
->andReturn([]);
});
$msg = [
'msg_type' => 1,
'content' => '重置密码',
'from_user' => 'wxid_test_user_123',
'is_chatroom' => false,
];
$command = new \App\Console\Commands\ConsumeWechatMessages(
$this->createMock(KafkaConsumerService::class)
);
$command->setLaravel($this->app);
// 绑定 NullOutput 避免 $this->info 报 null 指针错误
$output = new \Symfony\Component\Console\Output\NullOutput;
$reflectorOutput = new \ReflectionProperty($command, 'output');
$reflectorOutput->setValue($command, new \Illuminate\Console\OutputStyle(
new \Symfony\Component\Console\Input\ArrayInput([]),
$output
));
$reflector = new \ReflectionMethod($command, 'processMessage');
$reflector->invoke($command, $msg, $apiServiceMock);
}
/**
* 当已绑定微信的用户在微信私聊中发送“重置密码”时,自动随机生成 8 位密码并完成更新,通过微信私信回发。
*/
public function test_wechat_password_reset_succeeds_when_bound(): void
{
$user = User::factory()->create([
'username' => 'test_bind_user',
'wxid' => 'wxid_test_user_123',
'password' => Hash::make('old_password_123'),
]);
$apiServiceMock = $this->mock(WechatBotApiService::class, function (MockInterface $mock) use ($user) {
$mock->shouldReceive('sendTextMessage')
->once()
->with('wxid_test_user_123', \Mockery::on(function ($arg) use ($user) {
return str_contains($arg, '密码重置成功') && str_contains($arg, $user->username);
}))
->andReturn([]);
});
$msg = [
'msg_type' => 1,
'content' => ' 想要重置密码呢 ',
'from_user' => 'wxid_test_user_123',
'is_chatroom' => false,
];
$command = new \App\Console\Commands\ConsumeWechatMessages(
$this->createMock(KafkaConsumerService::class)
);
$command->setLaravel($this->app);
// 绑定 NullOutput
$output = new \Symfony\Component\Console\Output\NullOutput;
$reflectorOutput = new \ReflectionProperty($command, 'output');
$reflectorOutput->setValue($command, new \Illuminate\Console\OutputStyle(
new \Symfony\Component\Console\Input\ArrayInput([]),
$output
));
$reflector = new \ReflectionMethod($command, 'processMessage');
$reflector->invoke($command, $msg, $apiServiceMock);
$user->refresh();
$this->assertFalse(Hash::check('old_password_123', $user->password));
}
}
@@ -10,6 +10,7 @@ namespace Tests\Feature\Feature;
use App\Events\BrowserRefreshRequested; use App\Events\BrowserRefreshRequested;
use App\Jobs\SaveMessageJob; use App\Jobs\SaveMessageJob;
use App\Models\Department; use App\Models\Department;
use App\Models\Message;
use App\Models\Position; use App\Models\Position;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
@@ -232,6 +233,55 @@ class AdminCommandControllerTest extends TestCase
Event::assertNotDispatched(BrowserRefreshRequested::class); Event::assertNotDispatched(BrowserRefreshRequested::class);
} }
/**
* 测试站长查看用户私信时不会混入系统发给用户的私信通知。
*/
public function test_view_whispers_excludes_system_private_messages(): void
{
$admin = User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
$target = User::factory()->create([
'username' => '目标用户',
]);
$friend = User::factory()->create([
'username' => '好友用户',
]);
Message::create([
'room_id' => 1,
'from_user' => $target->username,
'to_user' => $friend->username,
'content' => '这条用户私聊需要显示',
'is_secret' => true,
'sent_at' => now(),
]);
Message::create([
'room_id' => 1,
'from_user' => '系统',
'to_user' => $target->username,
'content' => '这条系统私信不应显示',
'is_secret' => true,
'sent_at' => now(),
]);
Message::create([
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => $target->username,
'content' => '这条系统传音私信也不应显示',
'is_secret' => true,
'sent_at' => now(),
]);
$response = $this->actingAs($admin)->getJson(route('command.whispers', $target->username));
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonCount(1, 'messages')
->assertJsonPath('messages.0.content', '这条用户私聊需要显示');
}
/** /**
* 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。 * 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。
*/ */
@@ -61,4 +61,46 @@ class AdminCurrencyLogControllerTest extends TestCase
$response->viewData('logs')->getCollection()->pluck('source')->sort()->values()->all() $response->viewData('logs')->getCollection()->pluck('source')->sort()->values()->all()
); );
} }
/**
* 验证后台流水来源筛选包含座驾购买金币选项并可正常筛选。
*/
public function test_admin_can_filter_currency_logs_by_ride_buy_source(): void
{
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
$admin = User::factory()->create(['user_level' => 100]);
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'gold',
'amount' => -18888,
'balance_after' => 11112,
'source' => CurrencySource::RIDE_BUY->value,
'remark' => CurrencySource::RIDE_BUY->label(),
'created_at' => now(),
]);
UserCurrencyLog::query()->create([
'user_id' => $admin->id,
'username' => $admin->username,
'currency' => 'gold',
'amount' => -1000,
'balance_after' => 10112,
'source' => CurrencySource::SHOP_BUY->value,
'remark' => CurrencySource::SHOP_BUY->label(),
'created_at' => now()->subMinute(),
]);
$response = $this->actingAs($admin)->get(route('admin.currency-logs.index', [
'sources' => [CurrencySource::RIDE_BUY->value],
]));
$response->assertOk();
$response->assertSee('座驾购买(金币)');
$response->assertViewHas('selectedSources', [CurrencySource::RIDE_BUY->value]);
$this->assertSame(
[CurrencySource::RIDE_BUY->value],
$response->viewData('logs')->getCollection()->pluck('source')->values()->all()
);
}
} }
@@ -145,6 +145,43 @@ class HolidayEventSchedulingTest extends TestCase
$this->assertSame('2026-08-01 09:00:00', $event->fresh()?->send_at?->format('Y-m-d H:i:s')); $this->assertSame('2026-08-01 09:00:00', $event->fresh()?->send_at?->format('Y-m-d H:i:s'));
} }
/**
* 方法功能:验证本地停机导致年度福利过期后,自动任务只推进到下一次计划,不补发旧批次。
*/
public function test_automatic_trigger_skips_expired_yearly_occurrences_after_downtime(): void
{
Event::fake([HolidayEventStarted::class, MessageSent::class]);
Queue::fake([SaveMessageJob::class]);
$this->travelTo(CarbonImmutable::parse('2026-05-09 11:00:00'));
$chatState = $this->createMock(ChatStateService::class);
$chatState->expects($this->never())->method('nextMessageId');
$chatState->expects($this->never())->method('pushMessage');
$event = $this->createYearlyEvent([
'name' => '五一劳动节',
'schedule_month' => 5,
'schedule_day' => 1,
'schedule_time' => '10:00',
'duration_days' => 3,
'daily_occurrences' => 4,
'occurrence_interval_minutes' => 180,
'send_at' => '2026-05-03 13:00:00',
'expire_minutes' => 120,
]);
(new TriggerHolidayEventJob($event))->handle($chatState, app(HolidayEventScheduleService::class));
$freshEvent = $event->fresh();
$this->assertSame('2027-05-01 10:00:00', $freshEvent?->send_at?->format('Y-m-d H:i:s'));
$this->assertSame('pending', $freshEvent?->status);
$this->assertDatabaseCount('holiday_event_runs', 0);
Event::assertNotDispatched(HolidayEventStarted::class);
Event::assertNotDispatched(MessageSent::class);
}
/** /**
* 方法功能:验证旧的 daily 重复模式仍按原逻辑继续推进。 * 方法功能:验证旧的 daily 重复模式仍按原逻辑继续推进。
*/ */
+17 -5
View File
@@ -60,7 +60,7 @@ class HorseRaceControllerTest extends TestCase
/** /**
* 方法功能:验证可获取当前进行中的场次。 * 方法功能:验证可获取当前进行中的场次。
*/ */
public function test_can_get_current_race() public function test_can_get_current_race(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 4567]); $user = User::factory()->create(['jjb' => 4567]);
@@ -214,7 +214,7 @@ class HorseRaceControllerTest extends TestCase
/** /**
* 方法功能:验证用户可以成功下注。 * 方法功能:验证用户可以成功下注。
*/ */
public function test_can_bet() public function test_can_bet(): void
{ {
Event::fake(); Event::fake();
@@ -249,12 +249,24 @@ class HorseRaceControllerTest extends TestCase
'horse_id' => 1, 'horse_id' => 1,
'amount' => 100, 'amount' => 100,
]); ]);
$messages = Redis::lrange('room:1:messages', 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['to_user'] ?? null) === '大家'
&& ($item['toast_notification']['title'] ?? null) === '🐎 有人下注赛马'
&& str_contains((string) ($item['toast_notification']['message'] ?? ''), $user->username));
$this->assertNotNull($publicMessage);
$this->assertFalse((bool) ($publicMessage['is_secret'] ?? true));
$this->assertStringContainsString($user->username, (string) ($publicMessage['toast_notification']['message'] ?? ''));
$this->assertSame('🐎', $publicMessage['toast_notification']['icon'] ?? null);
} }
/** /**
* 方法功能:验证超出配置范围的下注会被拦截。 * 方法功能:验证超出配置范围的下注会被拦截。
*/ */
public function test_cannot_bet_out_of_range() public function test_cannot_bet_out_of_range(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 500]); $user = User::factory()->create(['jjb' => 500]);
@@ -284,7 +296,7 @@ class HorseRaceControllerTest extends TestCase
/** /**
* 方法功能:验证同一用户同一场只能下注一次。 * 方法功能:验证同一用户同一场只能下注一次。
*/ */
public function test_cannot_bet_twice_in_same_race() public function test_cannot_bet_twice_in_same_race(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 500]); $user = User::factory()->create(['jjb' => 500]);
@@ -322,7 +334,7 @@ class HorseRaceControllerTest extends TestCase
/** /**
* 方法功能:验证可读取最近的赛马历史记录。 * 方法功能:验证可读取最近的赛马历史记录。
*/ */
public function test_can_get_history() public function test_can_get_history(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
+49 -6
View File
@@ -1,5 +1,11 @@
<?php <?php
/**
* 文件功能:双色球彩票前台接口测试
*
* 覆盖当前期查询、机选、购票、历史和我的购票记录。
*/
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\GameConfig; use App\Models\GameConfig;
@@ -7,12 +13,19 @@ use App\Models\LotteryIssue;
use App\Models\LotteryTicket; use App\Models\LotteryTicket;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase; use Tests\TestCase;
/**
* 类功能:验证双色球彩票控制器的核心前台行为。
*/
class LotteryControllerTest extends TestCase class LotteryControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
/**
* 方法功能:初始化双色球默认配置。
*/
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@@ -31,7 +44,10 @@ class LotteryControllerTest extends TestCase
); );
} }
public function test_can_get_current() /**
* 方法功能:验证可以获取当前彩票期次。
*/
public function test_can_get_current(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
@@ -60,7 +76,10 @@ class LotteryControllerTest extends TestCase
$this->assertEquals($issue->id, $response->json('issue.id')); $this->assertEquals($issue->id, $response->json('issue.id'));
} }
public function test_can_quick_pick() /**
* 方法功能:验证可以生成机选号码。
*/
public function test_can_quick_pick(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
@@ -72,7 +91,10 @@ class LotteryControllerTest extends TestCase
$this->assertCount(2, $response->json('numbers')); $this->assertCount(2, $response->json('numbers'));
} }
public function test_cannot_buy_without_enough_gold() /**
* 方法功能:验证金币不足时无法购票。
*/
public function test_cannot_buy_without_enough_gold(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 50]); $user = User::factory()->create(['jjb' => 50]);
@@ -107,7 +129,10 @@ class LotteryControllerTest extends TestCase
$response->assertStatus(422); // Exception thrown with 422 $response->assertStatus(422); // Exception thrown with 422
} }
public function test_can_buy_with_enough_gold() /**
* 方法功能:验证金币充足时可以购票并广播右下角通知。
*/
public function test_can_buy_with_enough_gold(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 200]); $user = User::factory()->create(['jjb' => 200]);
@@ -143,9 +168,24 @@ class LotteryControllerTest extends TestCase
$response->assertJson(['status' => 'success']); $response->assertJson(['status' => 'success']);
$this->assertEquals(100, $user->fresh()->jjb); // cost is 100 per ticket $this->assertEquals(100, $user->fresh()->jjb); // cost is 100 per ticket
$messages = Redis::lrange('room:1:messages', 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['to_user'] ?? null) === '大家'
&& ($item['toast_notification']['title'] ?? null) === '🎟️ 有人购买双色球'
&& str_contains((string) ($item['toast_notification']['message'] ?? ''), $user->username));
$this->assertNotNull($publicMessage);
$this->assertFalse((bool) ($publicMessage['is_secret'] ?? true));
$this->assertStringContainsString($user->username, (string) ($publicMessage['toast_notification']['message'] ?? ''));
$this->assertSame('🎟️', $publicMessage['toast_notification']['icon'] ?? null);
} }
public function test_can_get_history() /**
* 方法功能:验证可以查询历史期次。
*/
public function test_can_get_history(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
@@ -173,7 +213,10 @@ class LotteryControllerTest extends TestCase
$this->assertCount(1, $response->json('issues')); $this->assertCount(1, $response->json('issues'));
} }
public function test_can_get_my_tickets() /**
* 方法功能:验证可以查询我的购票记录。
*/
public function test_can_get_my_tickets(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $user = User::factory()->create();
@@ -0,0 +1,191 @@
<?php
/**
* 文件功能:找回密码账号检测与防扫描轰炸限流 Feature 测试
*
* 覆盖密码重置流程中的多维限流阀门,确保 IP 防扫与用户邮箱防轰炸策略稳健起效。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Tests\Feature;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
class PasswordResetRateLimitTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
RateLimiter::clear('pw-check:ip:127.0.0.1');
RateLimiter::clear('pw-email:ip:127.0.0.1');
}
/**
* 测试账号检测:不存在账号返回 404/not_found
*/
public function test_check_account_returns_not_found_when_user_does_not_exist(): void
{
$response = $this->postJson(route('password.check_account'), [
'username' => 'non_existing_user_abc',
]);
$response->assertStatus(200);
$response->assertJson([
'status' => 'not_found',
'message' => '抱歉,没有找到该昵称对应的账号。请确认后再试。',
]);
}
/**
* 测试账号检测分流状态与脱敏邮箱输出
*/
public function test_check_account_shows_correct_channels(): void
{
// 1. 双绑定用户
$bothBoundUser = User::factory()->create([
'username' => 'both_user',
'email' => 'both.test@example.com',
'wxid' => 'wxid_both',
]);
$response1 = $this->postJson(route('password.check_account'), [
'username' => 'both_user',
]);
$response1->assertJson([
'status' => 'success',
'has_email' => true,
'has_wechat' => true,
'masked_email' => 'b*******t@example.com',
]);
// 2. 仅绑定微信用户
$wechatUser = User::factory()->create([
'username' => 'wx_user',
'email' => null,
'wxid' => 'wxid_single',
]);
$response2 = $this->postJson(route('password.check_account'), [
'username' => 'wx_user',
]);
$response2->assertJson([
'status' => 'success',
'has_email' => false,
'has_wechat' => true,
]);
// 3. 均未绑定用户
$noneUser = User::factory()->create([
'username' => 'none_user',
'email' => null,
'wxid' => null,
]);
$response3 = $this->postJson(route('password.check_account'), [
'username' => 'none_user',
]);
$response3->assertJson([
'status' => 'success',
'has_email' => false,
'has_wechat' => false,
]);
}
/**
* 测试 IP 防扫描限流限制 (每个 IP 1分钟限制 5 次检测)
*/
public function test_check_account_rate_limiting(): void
{
$ip = '127.0.0.1';
// 连续请求 5 次都应该正常响应
for ($i = 0; $i < 5; $i++) {
$response = $this->postJson(route('password.check_account'), [
'username' => 'non_existing_user',
]);
$response->assertStatus(200);
}
// 第 6 次应该被 RateLimiter 节流拦截返回 429
$response = $this->postJson(route('password.check_account'), [
'username' => 'non_existing_user',
]);
$response->assertStatus(429);
$response->assertJson([
'status' => 'error',
]);
$this->assertStringContainsString('请求过于频繁', $response->json('message'));
}
/**
* 测试当用户输入的邮箱地址与绑定的真实邮箱不匹配时,二次核验拦截报错
*/
public function test_store_link_fails_when_masked_email_mismatch(): void
{
Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']);
User::factory()->create([
'username' => 'mismatch_user',
'email' => 'real.mail@example.com',
]);
$response = $this->postJson(route('password.email'), [
'username' => 'mismatch_user',
'email' => 'wrong.mail@example.com', // 错误的手输邮箱
]);
$response->assertStatus(422);
$response->assertJson([
'status' => 'error',
'message' => '输入的完整邮箱地址与该账号绑定的邮箱不一致,二次确认失败。',
]);
}
/**
* 测试发送链接的双层安全控频防御 (防止发信轰炸他人)
*/
public function test_store_link_prevents_email_bombing(): void
{
Sysparam::updateOrCreate(['alias' => 'smtp_enabled'], ['body' => '1']);
$user = User::factory()->create([
'username' => 'bomb_user',
'email' => 'bomb.target@example.com',
]);
$email = $user->email;
$targetKey = 'pw-email:target:'.md5($email);
RateLimiter::clear($targetKey);
// 模拟第一次发送
$response1 = $this->postJson(route('password.email'), [
'username' => 'bomb_user',
'email' => 'bomb.target@example.com',
]);
// 第二次在 3 分钟内再次连续发送,触发 429 节流
$response2 = $this->postJson(route('password.email'), [
'username' => 'bomb_user',
'email' => 'bomb.target@example.com',
]);
$response2->assertStatus(429);
$response2->assertJson([
'status' => 'error',
]);
$this->assertStringContainsString('重置链接过于频繁', $response2->json('message'));
}
}
+20
View File
@@ -1,5 +1,11 @@
<?php <?php
/**
* 文件功能:礼包红包控制器功能测试
*
* 覆盖礼包发放权限、领取入账、领取广播、状态查询和重复领取约束。
*/
namespace Tests\Feature; namespace Tests\Feature;
use App\Events\RedPacketClaimed; use App\Events\RedPacketClaimed;
@@ -262,6 +268,20 @@ class RedPacketControllerTest extends TestCase
&& $event->remainingCount === 9 && $event->remainingCount === 9
&& $event->type === 'gold'; && $event->type === 'gold';
}); });
$messages = Redis::lrange('room:1:messages', 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['to_user'] ?? null) === '大家'
&& ($item['toast_notification']['title'] ?? null) === '🧧 有人领取红包'
&& str_contains((string) ($item['toast_notification']['message'] ?? ''), $user->username));
$this->assertNotNull($publicMessage);
$this->assertFalse((bool) ($publicMessage['is_secret'] ?? true));
$this->assertStringContainsString('金币礼包', (string) ($publicMessage['toast_notification']['message'] ?? ''));
$this->assertSame('🧧', $publicMessage['toast_notification']['icon'] ?? null);
$this->assertSame($user->username, $publicMessage['toast_notification']['actor_username'] ?? null);
$this->assertTrue((bool) ($publicMessage['toast_notification']['skip_for_actor'] ?? false));
} }
/** /**
@@ -368,6 +368,83 @@ class RiddleQuizControllerTest extends TestCase
$this->assertSame(10, $player->exp_num); $this->assertSame(10, $player->exp_num);
} }
/**
* 方法功能:验证同一房间两种题型同时存在时,答对一题不会结算另一题。
*/
public function test_answer_only_ends_matching_round_when_two_quiz_types_are_active(): void
{
$this->mockChatStateService();
$player = User::factory()->create([
'username' => '破题用户',
'exp_num' => 10,
]);
$room = Room::create(['room_name' => '双题房间']);
$idiom = $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 20,
'reward_exp' => 15,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$idiomRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now(),
]);
$brainTeaserRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $brainTeaser->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now(),
]);
$response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [
'round_id' => $idiomRound->id,
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'answer' => '画蛇添足',
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_round_id', $idiomRound->id);
$idiomRound->refresh();
$brainTeaserRound->refresh();
$this->assertSame('answered', $idiomRound->status);
$this->assertSame('破题用户', $idiomRound->winner_username);
$this->assertSame('active', $brainTeaserRound->status);
$this->assertNull($brainTeaserRound->winner_username);
}
/** /**
* 方法功能:验证自动出题会按房间和题型两个维度独立判断。 * 方法功能:验证自动出题会按房间和题型两个维度独立判断。
*/ */
@@ -491,6 +568,7 @@ class RiddleQuizControllerTest extends TestCase
Schema::disableForeignKeyConstraints(); Schema::disableForeignKeyConstraints();
Schema::dropIfExists('idiom_game_rounds'); Schema::dropIfExists('idiom_game_rounds');
Schema::dropIfExists('idioms'); Schema::dropIfExists('idioms');
Schema::dropIfExists('user_currency_logs');
Schema::dropIfExists('game_configs'); Schema::dropIfExists('game_configs');
Schema::dropIfExists('rooms'); Schema::dropIfExists('rooms');
Schema::dropIfExists('users'); Schema::dropIfExists('users');
@@ -507,6 +585,8 @@ class RiddleQuizControllerTest extends TestCase
$table->text('custom_leave_message')->nullable(); $table->text('custom_leave_message')->nullable();
$table->integer('user_level')->default(1); $table->integer('user_level')->default(1);
$table->integer('exp_num')->default(0); $table->integer('exp_num')->default(0);
$table->integer('jjb')->default(0);
$table->integer('meili')->default(0);
$table->timestamps(); $table->timestamps();
}); });
@@ -552,6 +632,19 @@ class RiddleQuizControllerTest extends TestCase
$table->timestamp('ended_at')->nullable(); $table->timestamp('ended_at')->nullable();
$table->timestamps(); $table->timestamps();
}); });
Schema::create('user_currency_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->index();
$table->string('username', 50);
$table->string('currency', 10);
$table->integer('amount');
$table->integer('balance_after');
$table->string('source', 30)->index();
$table->string('remark', 200)->nullable();
$table->unsignedBigInteger('room_id')->nullable();
$table->timestamp('created_at')->nullable()->index();
});
} }
/** /**

Some files were not shown because too many files have changed in this diff Show More