Compare commits

130 Commits

Author SHA1 Message Date
lkddi 247283a282 迁移买单活动管理弹层脚本 2026-04-25 04:05:32 +08:00
lkddi 4df557bb9e 迁移管理菜单和钓鱼按钮事件绑定 2026-04-25 04:03:13 +08:00
lkddi 54faf8b501 补充聊天室前端入口说明注释 2026-04-25 04:00:38 +08:00
lkddi be22710424 迁移竖向工具条按钮事件绑定 2026-04-25 03:59:53 +08:00
lkddi 1db22dc5de 迁移好友面板脚本到Vite模块 2026-04-25 03:58:26 +08:00
lkddi 0310798675 迁移手机抽屉工具按钮事件绑定 2026-04-25 03:55:57 +08:00
lkddi 04ee32e4d5 迁移全局弹窗按钮事件绑定 2026-04-25 03:54:23 +08:00
lkddi ef471ec68b 迁移功能菜单和每日状态事件绑定 2026-04-25 03:53:29 +08:00
lkddi 3e525eaa36 迁移屏蔽复选框事件绑定 2026-04-25 03:50:10 +08:00
lkddi ce6f8552c1 迁移欢迎语菜单事件绑定 2026-04-25 03:49:13 +08:00
lkddi 1429dee8a6 迁移屏蔠菜单事件绑定 2026-04-25 03:45:30 +08:00
lkddi cf42071c29 迁移手机抽屉基础事件绑定 2026-04-25 03:44:04 +08:00
lkddi 10e9835530 迁移右侧面板事件绑定 2026-04-25 03:42:54 +08:00
lkddi 64a1e5d769 迁移聊天图片上传事件绑定 2026-04-25 03:41:45 +08:00
lkddi ca61dd42f7 迁移字号选择器事件绑定 2026-04-25 03:40:30 +08:00
lkddi e9c3fc989c 迁移静音开关事件绑定 2026-04-25 03:39:31 +08:00
lkddi c858f6af0c 迁移聊天室静音偏好工具 2026-04-25 03:38:27 +08:00
lkddi 2f246f9112 迁移聊天图片预览事件到Vite模块 2026-04-25 03:36:30 +08:00
lkddi f1d8d20180 迁移聊天室前端工具并优化消息渲染 2026-04-25 03:34:31 +08:00
lkddi e3cba255f9 优化聊天室特效加载与移动端性能 2026-04-25 03:34:19 +08:00
lkddi 128b52d0aa 优化聊天室首屏和在线名单性能 2026-04-25 03:14:07 +08:00
lkddi c410897231 将聊天室特效脚本纳入 Vite 打包 2026-04-25 03:02:56 +08:00
lkddi 855d031b04 收口聊天室安全边界并优化特效生命周期 2026-04-25 02:52:30 +08:00
lkddi 4d3f4f7a4b 修复手动存点通知重复显示 2026-04-25 02:50:24 +08:00
lkddi f18eefe9bc 优化存点显示 2026-04-25 02:28:18 +08:00
lkddi 8bd1dae9e1 优化存点 2026-04-25 02:24:24 +08:00
lkddi 5bfcd75442 修复游戏弹窗点击外部关闭 2026-04-25 02:15:09 +08:00
lkddi 8b15507f22 暂不显示管理员名单 2026-04-25 02:09:00 +08:00
lkddi 97c021dae2 优化ai提示词 2026-04-25 00:57:15 +08:00
lkddi 8cf5029711 优化ai小班长聊天 2026-04-25 00:42:46 +08:00
lkddi aab609f69b ai小班长增加签到 2026-04-25 00:27:08 +08:00
lkddi a0268b611f 禁用赛马接口缓存 2026-04-24 23:53:17 +08:00
lkddi fc68aaff72 增强部署脚本依赖检查 2026-04-24 23:46:53 +08:00
lkddi 32584f11d2 修复bug 2026-04-24 23:42:52 +08:00
lkddi a3b5184470 修复聊天室样式缓存问题 2026-04-24 23:40:55 +08:00
lkddi b2b91b9238 压缩状态设置弹窗布局 2026-04-24 23:29:48 +08:00
lkddi 9f8b5e7524 支持点击结束全屏特效 2026-04-24 23:15:42 +08:00
lkddi 5273b4ee4b 完善职务礼包红包默认配置 2026-04-24 23:09:32 +08:00
lkddi 4486a87326 移除补签卡默认值更新迁移 2026-04-24 22:48:14 +08:00
lkddi be9fc09d9d 新增每日签到与补签卡功能 2026-04-24 22:47:27 +08:00
lkddi 34356a26ae 完善跑马面板与控制器逻辑 2026-04-24 21:18:09 +08:00
lkddi 0f0bfef2a8 新增聊天室状态与功能快捷菜单 2026-04-24 21:17:44 +08:00
lkddi d7ec42a025 移除失效的 FluidPlayer 样式引用 2026-04-22 12:11:21 +08:00
lkddi 6c631aa495 优化 职务图标显示 2026-04-22 10:37:17 +08:00
lkddi fb96747352 优化 职务图标文字提示 2026-04-22 10:33:26 +08:00
lkddi 7c27ba0c48 优化 职务图标 文字提示 2026-04-22 10:18:49 +08:00
lkddi bef797abd5 修复在线名单职务图标显示 2026-04-22 10:10:40 +08:00
lkddi 73c6674fc4 优化节日福利列表与领取提示展示 2026-04-22 09:52:35 +08:00
lkddi b0028c515f 将用户管理操作接入职务权限体系 2026-04-21 18:00:02 +08:00
lkddi a066580014 升级节日福利年度调度与批次领取 2026-04-21 17:53:11 +08:00
lkddi 5a6446b832 后台用户编辑页接入职务任命流程 2026-04-21 17:26:52 +08:00
lkddi a17a67f533 去除任命成功的重复弹窗提示 2026-04-21 17:16:18 +08:00
lkddi fed51dda18 新增聊天室刷新同步与全员刷新功能 2026-04-21 17:14:12 +08:00
lkddi c209221bad 优化聊天室烟花特效表现与卡顿问题 2026-04-21 17:13:14 +08:00
lkddi 590b7d5b35 修复职务任命撤销弹窗显示HTML代码 2026-04-21 16:45:54 +08:00
lkddi f0769a841e 优化后台提示展示与聊天室公告样式 2026-04-21 16:43:39 +08:00
lkddi 281315d1cf 新增职务权限管理与聊天室管理权限控制 2026-04-21 16:43:17 +08:00
lkddi cfdbf387af 修复管理员登录页验证码显示不全 2026-04-21 15:48:01 +08:00
lkddi cf4006eb8b 修复后台弹窗被顶部栏遮挡问题 2026-04-21 15:47:55 +08:00
lkddi 96a449d94b 优化 红包 页面 2026-04-21 15:10:41 +08:00
lkddi 916f4c5aa6 升级 laravel12 补丁 2026-04-19 16:43:51 +08:00
lkddi d4a9389fbc 完善首页邮箱找回密码流程 2026-04-19 16:10:41 +08:00
lkddi 900c93c6c7 修复 HTTPS 资源链接生成 2026-04-19 15:15:58 +08:00
lkddi 438241e878 收紧输入渲染与后台配置权限 2026-04-19 14:43:02 +08:00
lkddi ba6406ed68 加固房间准入与消息广播边界 2026-04-19 14:42:52 +08:00
lkddi 5ce83a769d 修复认证与基础安全链路 2026-04-19 14:42:42 +08:00
lkddi bd97ed0b73 优化 ai小班长百家乐押注 2026-04-19 12:36:23 +08:00
lkddi b98ae7f94e 优化手机输入及钓鱼 2026-04-19 12:14:10 +08:00
lkddi c710d585da 优化钓鱼卡提示 2026-04-17 16:20:50 +08:00
lkddi 3afe5a4480 优化 百家乐 2026-04-17 15:33:36 +08:00
lkddi c7cb826013 优化 押注 下单提示 2026-04-17 15:30:25 +08:00
lkddi 0e8a1669b9 增加 神秘箱子的屏蔽 2026-04-17 15:27:40 +08:00
lkddi 0f4de941db 优化神秘箱子提醒 2026-04-17 15:19:58 +08:00
lkddi 4866d25df9 优化神秘箱子提醒 2026-04-17 15:18:08 +08:00
lkddi dd938ec6e7 优化百家乐、跑马 押注消息 2026-04-17 14:57:58 +08:00
lkddi 1a39ddd725 优化屏蔽,可以保存状态 2026-04-14 22:48:29 +08:00
lkddi 7255d50966 优化按钮 2026-04-14 22:43:34 +08:00
lkddi 6927a88dd3 优化提醒 2026-04-14 22:41:33 +08:00
lkddi fc9a66469a 屏蔽新增 百家乐 跑马 2026-04-14 22:31:11 +08:00
lkddi 0183de66dd 新增 屏蔽消息功能 2026-04-14 22:25:16 +08:00
lkddi b76b6559ea 赛马优化通知 2026-04-14 22:14:10 +08:00
lkddi a2e51f5668 优化百家乐提醒 2026-04-14 22:09:03 +08:00
lkddi 392f46769c 赠金币 增加右下角弹窗 2026-04-14 21:59:49 +08:00
lkddi df29da7440 赠送金币改为私聊通知 2026-04-14 21:53:36 +08:00
lkddi 762caac938 优化管理首页 2026-04-14 21:09:37 +08:00
lkddi 426d01d99b 新增管理登录页面 2026-04-14 13:43:16 +08:00
lkddi 596c7f357f 优化 你玩游戏我买单 页面 2026-04-13 17:55:00 +08:00
lkddi 2eb732642b 优化会员购买记录 2026-04-13 17:44:37 +08:00
lkddi d060e1b797 新增微信支付 2026-04-13 17:25:33 +08:00
lkddi dca43a2d0d 优化vip 2026-04-12 23:25:38 +08:00
lkddi 353aaaf6ce 优化ai小班长 2026-04-12 22:42:32 +08:00
lkddi d739fc7028 优化发送金币后自动关闭 2026-04-12 22:39:22 +08:00
lkddi c297b61493 优化 游戏金币余额显示 2026-04-12 22:31:35 +08:00
lkddi ef407a8c6e 优化ai小班长 2026-04-12 22:25:18 +08:00
lkddi f8d5a3b250 优化ai小班长 2026-04-12 21:42:55 +08:00
lkddi a4cc85b558 优化登录页面 2026-04-12 19:24:35 +08:00
lkddi bf856e18e3 优化 2026-04-12 19:06:48 +08:00
lkddi d60065ff3e 优化 2026-04-12 19:03:37 +08:00
lkddi e837d1fcd0 优化 2026-04-12 19:02:56 +08:00
lkddi 61541bfe4c 优化ai小班长 2026-04-12 18:50:41 +08:00
lkddi d6f14868fd 增加 百家乐通知后面增加 快速参与按钮 2026-04-12 18:15:47 +08:00
lkddi 4ac3311328 优化快速下注 保证至少有5个按钮 2026-04-12 18:12:53 +08:00
lkddi e5f0f28978 优化显示快速下单金额 2026-04-12 18:04:28 +08:00
lkddi 5755bea748 优化跑马显示 2026-04-12 18:01:35 +08:00
lkddi 1559e49d3d 优化下注提示 2026-04-12 17:58:03 +08:00
lkddi d52db10863 优化下注金额 2026-04-12 17:56:16 +08:00
lkddi adc89240fd 优化跑马下单按钮 2026-04-12 17:49:49 +08:00
lkddi 28cbf2b564 优化游戏 金币显示 2026-04-12 17:46:24 +08:00
lkddi e7aea014fb 优化商店 2026-04-12 17:41:27 +08:00
lkddi 87c7a8d786 修复跑马bug 2026-04-12 17:39:46 +08:00
lkddi 5b637d2c64 优化自动关闭 2026-04-12 17:34:07 +08:00
lkddi 2090250967 增加婚姻 查看已婚列表 2026-04-12 17:28:42 +08:00
lkddi 705af810a9 优化按钮 2026-04-12 17:11:12 +08:00
lkddi 77c17f87f9 优化 会员页面; 2026-04-12 17:06:38 +08:00
lkddi 1e64d2d5e2 优化管理操作按钮 2026-04-12 16:54:25 +08:00
lkddi 70cb170f2c Add new chat effects and shop items 2026-04-12 16:48:58 +08:00
lkddi 33a3e5d118 修改特效按钮 2026-04-12 16:24:48 +08:00
lkddi bc825157c9 增加会员查看 2026-04-12 16:16:23 +08:00
lkddi 9b1f2a2146 修改会员登录默认特性 2026-04-12 16:05:35 +08:00
lkddi 0899ff184c 优化会员登录提示 2026-04-12 14:32:44 +08:00
lkddi 82e29753b8 vip会员支持补差升级 2026-04-12 14:17:01 +08:00
lkddi 00b9396dea 新增聊天室发送图片功能 2026-04-12 14:04:18 +08:00
lkddi d2f08eb2dd 删除无用图片 2026-04-12 13:37:55 +08:00
lkddi 8471516fd7 更换登录页面样式 2026-04-12 13:37:22 +08:00
lkddi dee91bccca 修复跑马 不能正常显示赢后的奖励金额 2026-04-12 11:09:15 +08:00
lkddi 9b4b0ab5f3 优化跑马提示 2026-04-11 23:46:05 +08:00
lkddi f4a632a9c1 完善百家乐买单补偿自动领取与聊天室播报 2026-04-11 23:43:07 +08:00
lkddi e43dceab2c Add baccarat loss cover activity 2026-04-11 23:27:29 +08:00
lkddi dd9a8c5db8 修复悄悄话文字颜色及不能发数字0的问题 2026-04-11 22:48:15 +08:00
lkddi ff402be02f 优化 刷新页面不在重复播报 离开和登录提示 2026-04-11 22:40:42 +08:00
277 changed files with 30816 additions and 3908 deletions
+12
View File
@@ -0,0 +1,12 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[cleanup]
script = '''
php artisan reverb:start
php artisan horizon
'''
+14
View File
@@ -0,0 +1,14 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "chatroom"
[setup]
script = ""
[[actions]]
name = "启动ws"
icon = "tool"
command = '''
php artisan reverb:start
php artisan horizon
'''
+2
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_FORCE_HTTPS=false
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -32,6 +33,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
TRUSTED_PROXIES=127.0.0.1,::1
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
+4
View File
@@ -11,6 +11,10 @@
/.phpunit.cache
/.vscode
/.zed
/.junie
/.github
/.gemini
/.agents
/auth.json
/node_modules
/public/build
+73 -1
View File
@@ -18,13 +18,19 @@ use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\DailySignIn;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AiFinanceService;
use App\Services\ChatStateService;
use App\Services\SignInService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
/**
* 定时模拟 AI小班长心跳,并同步维护其常规存款理财行为。
*/
class AiHeartbeatCommand extends Command
{
/**
@@ -37,14 +43,22 @@ class AiHeartbeatCommand extends Command
*/
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
/**
* 注入聊天室状态、VIP、积分与 AI 资金调度服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly AiFinanceService $aiFinance,
private readonly SignInService $signInService,
) {
parent::__construct();
}
/**
* 执行 AI小班长单次心跳,并处理奖励、随机事件与资金调度。
*/
public function handle(): int
{
// 1. 检查总开关
@@ -58,6 +72,12 @@ class AiHeartbeatCommand extends Command
return Command::SUCCESS;
}
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
$this->aiFinance->bankExcessGold($user);
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
$this->performDailySignIn($user);
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
@@ -133,7 +153,8 @@ class AiHeartbeatCommand extends Command
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if ($user->jjb >= $cost) {
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
if ($this->aiFinance->prepareSpend($user, $cost)) {
// 先扣除费用
$this->currencyService->change(
$user, 'gold', -$cost,
@@ -155,6 +176,9 @@ class AiHeartbeatCommand extends Command
}
}
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
$this->aiFinance->bankExcessGold($user);
return Command::SUCCESS;
}
@@ -195,6 +219,54 @@ class AiHeartbeatCommand extends Command
return (int) $raw;
}
/**
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
*/
private function performDailySignIn(User $user): void
{
// 先检查今日是否已签,避免每分钟都调用事务
$alreadySigned = DailySignIn::query()
->where('user_id', $user->id)
->whereDate('sign_in_date', today())
->exists();
if ($alreadySigned) {
return;
}
// 获取活跃房间作为签到归属(默认房间 1)
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
$dailySignIn = $this->signInService->claim($user, $roomId);
// 仅当本次心跳实际完成签到时才广播(幂等保护)
if (! $dailySignIn->wasRecentlyCreated) {
return;
}
$rewardParts = [];
if ($dailySignIn->gold_reward > 0) {
$rewardParts[] = $dailySignIn->gold_reward.' 金币';
}
if ($dailySignIn->exp_reward > 0) {
$rewardParts[] = $dailySignIn->exp_reward.' 经验';
}
if ($dailySignIn->charm_reward > 0) {
$rewardParts[] = $dailySignIn->charm_reward.' 魅力';
}
$rewardText = $rewardParts === [] ? '签到记录' : implode(' + ', $rewardParts);
$identityText = $dailySignIn->identity_badge_name
? ',获得身份 '.e($dailySignIn->identity_badge_name)
: '';
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
$this->broadcastSystemMessage('签到播报', $content, '#0f766e');
}
/**
* 往所有活跃房间发送系统广播消息
*/
+7 -3
View File
@@ -23,6 +23,7 @@ use App\Models\PositionDutyLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
@@ -46,6 +47,7 @@ class AutoSaveExp extends Command
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
@@ -164,7 +166,7 @@ class AutoSaveExp extends Command
);
}
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
$user->load('activePosition.position'); // 确保职务及职位关联已加载
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。
// 3. 自动升降级逻辑
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
@@ -229,9 +231,11 @@ class AutoSaveExp extends Command
$jjbDisplay = $user->jjb ?? 0;
$gainStr = ! empty($gainParts) ? ' 本次获得:'.implode('', $gainParts) : '';
// 格式:⏰ 自动存点 · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
// 格式:⏰ 自动存点 · 部门 X · 职务 Y · 会员 Z · LV.100 · 经验 10,468 · 金币 8,345 · 已满级 · 本次获得:经验+1,金币+3
$statusTag = $user->user_level >= $superLevel ? ' · 已满级 ✓' : '';
$content = "⏰ 自动存点 · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$content = "⏰ 自动存点 · {$identitySummary['inline']} · LV.{$user->user_level} · 经验 {$user->exp_num} · 金币 {$jjbDisplay}{$statusTag}{$gainStr}";
$noticeMsg = [
'id' => $this->chatState->nextMessageId($roomId),
+69 -1
View File
@@ -17,7 +17,12 @@ use App\Models\Message;
use App\Models\Sysparam;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* 定期清理聊天记录命令
* 负责删除过期文本消息,并额外回收聊天图片文件。
*/
class PurgeOldMessages extends Command
{
/**
@@ -27,6 +32,7 @@ class PurgeOldMessages extends Command
*/
protected $signature = 'messages:purge
{--days= : 覆盖默认保留天数}
{--image-days=3 : 聊天图片单独保留天数}
{--dry-run : 仅预览不实际删除}';
/**
@@ -34,7 +40,7 @@ class PurgeOldMessages extends Command
*
* @var string
*/
protected $description = '清理超过指定天数的聊天记录(保留天数由 sysparam message_retention_days 配置,默认 30';
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件';
/**
* 执行命令
@@ -46,10 +52,13 @@ class PurgeOldMessages extends Command
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
$days = (int) ($this->option('days')
?: Sysparam::getValue('message_retention_days', '30'));
$imageDays = max(0, (int) $this->option('image-days'));
$cutoff = Carbon::now()->subDays($days);
$isDryRun = $this->option('dry-run');
$this->cleanupExpiredImages($imageDays, $isDryRun);
// 统计待清理数量
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
@@ -88,4 +97,63 @@ class PurgeOldMessages extends Command
return self::SUCCESS;
}
/**
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
*/
private function cleanupExpiredImages(int $imageDays, bool $isDryRun): void
{
$imageCutoff = Carbon::now()->subDays($imageDays);
$query = Message::query()
->where('message_type', 'image')
->where('sent_at', '<', $imageCutoff)
->where(function ($builder) {
$builder->whereNotNull('image_path')->orWhereNotNull('image_thumb_path');
});
$totalCount = (clone $query)->count();
if ($totalCount === 0) {
$this->line("🖼️ 没有超过 {$imageDays} 天的聊天图片需要清理。");
return;
}
if ($isDryRun) {
$this->warn("🔍 [预览模式] 将清理 {$totalCount} 条超过 {$imageDays} 天的聊天图片(截止 {$imageCutoff->toDateTimeString()}");
return;
}
$processed = 0;
$query->orderBy('id')->chunkById(200, function ($messages) use (&$processed) {
foreach ($messages as $message) {
$paths = array_values(array_filter([
$message->image_path,
$message->image_thumb_path,
]));
// 先删物理文件,再把数据库消息降级成“图片已过期”占位,避免出现坏图。
if ($paths !== []) {
Storage::disk('public')->delete($paths);
}
$placeholder = trim((string) $message->content);
$placeholder = $placeholder !== '' ? $placeholder.' [图片已过期]' : '[图片已过期]';
$message->forceFill([
'content' => $placeholder,
'message_type' => 'expired_image',
'image_path' => null,
'image_thumb_path' => null,
'image_original_name' => null,
])->save();
$processed++;
}
});
$this->info("🖼️ 已清理 {$processed} 条超过 {$imageDays} 天的聊天图片。");
}
}
+8 -1
View File
@@ -41,6 +41,9 @@ enum CurrencySource: string
/** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */
case POSITION_REWARD = 'position_reward';
/** 每日签到奖励(连续签到按规则发放) */
case SIGN_IN = 'sign_in';
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
@@ -48,7 +51,6 @@ enum CurrencySource: string
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
@@ -84,6 +86,9 @@ enum CurrencySource: string
/** 百家乐中奖赔付(收入金币,含本金返还) */
case BACCARAT_WIN = 'baccarat_win';
/** 百家乐买单活动补偿领取(收入金币) */
case BACCARAT_LOSS_COVER_CLAIM = 'baccarat_loss_cover_claim';
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
case AUTO_EVENT = 'auto_event';
@@ -150,6 +155,7 @@ enum CurrencySource: string
self::SHOP_BUY => '商城购买',
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::SIGN_IN => '每日签到',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
@@ -162,6 +168,7 @@ enum CurrencySource: string
self::HOLIDAY_BONUS => '节日福利',
self::BACCARAT_BET => '百家乐下注',
self::BACCARAT_WIN => '百家乐赢钱',
self::BACCARAT_LOSS_COVER_CLAIM => '百家乐买单活动补偿',
self::AUTO_EVENT => '随机事件(星海小博士)',
self::SLOT_SPIN => '老虎机转动',
self::SLOT_WIN => '老虎机中奖',
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:聊天室浏览器刷新请求广播事件
*
* 仅供站长触发“刷新全员”命令时使用,
* 向当前房间所有在线用户广播前端刷新指令。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向房间内全部在线用户广播页面刷新指令。
*/
class BrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录房间与操作者信息。
*/
public function __construct(
public readonly int $roomId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:当前聊天室 PresenceChannel。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据:前端用于展示提示并执行刷新。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
+17 -1
View File
@@ -20,6 +20,10 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 开发日志发布广播事件
* 负责把更新日志的安全展示字段广播给大厅聊天室。
*/
class ChangelogPublished implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -67,8 +71,20 @@ class ChangelogPublished implements ShouldBroadcastNow
'title' => $this->changelog->title,
'type' => $this->changelog->type,
'type_label' => $this->changelog->type_label,
// 同步提供已转义字段,便于前端在 innerHTML 场景下直接复用安全文本。
'safe_version' => e((string) $this->changelog->version),
'safe_title' => e((string) $this->changelog->title),
'safe_type_label' => e((string) $this->changelog->type_label),
// 前端点击后跳转的目标 URL,自动锚定至对应版本
'url' => url('/changelog').'#v'.$this->changelog->version,
'url' => $this->buildDetailUrl(),
];
}
/**
* 生成广播使用的更新日志详情地址,并编码版本锚点避免 href 注入。
*/
private function buildDetailUrl(): string
{
return route('changelog.index').'#v'.rawurlencode((string) $this->changelog->version);
}
}
+4 -3
View File
@@ -4,7 +4,7 @@
* 文件功能:聊天室全屏特效广播事件
*
* 管理员或用户购买单次卡后触发,通过 WebSocket 广播给房间内用户播放 Canvas 动画。
* 支持指定接收者target_username null 则全员播放)
* 支持指定接收者;当存在 target_username 时,触发者本人和指定接收者都应可见
*
* @author ChatRoom Laravel
*
@@ -26,13 +26,13 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 支持的特效类型列表(用于校验)
*/
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow'];
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $type 特效类型:fireworks / rain / lightning / snow
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
* @param string $operator 触发特效的用户名(购买者)
* @param string|null $targetUsername 接收者用户名(null = 全员)
* @param string|null $giftMessage 附带赠言
@@ -59,6 +59,7 @@ class EffectBroadcast implements ShouldBroadcastNow
/**
* 广播数据:特效类型、操作者、目标用户、赠言
* 前端据此判断“全员可见”或“仅操作者 + 指定接收者可见”。
*
* @return array<string, mixed>
*/
+18 -12
View File
@@ -14,22 +14,25 @@
namespace App\Events;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向房间广播节日福利发放批次开始事件。
*/
class HolidayEventStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HolidayEvent $event 节日活动实例
* @param HolidayEventRun $run 节日福利发放批次
*/
public function __construct(
public readonly HolidayEvent $event,
public readonly HolidayEventRun $run,
) {}
/**
@@ -60,15 +63,18 @@ class HolidayEventStarted implements ShouldBroadcastNow
public function broadcastWith(): array
{
return [
'event_id' => $this->event->id,
'name' => $this->event->name,
'description' => $this->event->description,
'total_amount' => $this->event->total_amount,
'max_claimants' => $this->event->max_claimants,
'distribute_type' => $this->event->distribute_type,
'fixed_amount' => $this->event->fixed_amount,
'claimed_count' => $this->event->claimed_count,
'expires_at' => $this->event->expires_at?->toIso8601String(),
'run_id' => $this->run->id,
'event_id' => $this->run->holiday_event_id,
'name' => $this->run->event_name,
'description' => $this->run->event_description,
'total_amount' => $this->run->total_amount,
'max_claimants' => $this->run->max_claimants,
'distribute_type' => $this->run->distribute_type,
'fixed_amount' => $this->run->fixed_amount,
'claimed_count' => $this->run->claimed_count,
'expires_at' => $this->run->expires_at?->toIso8601String(),
'scheduled_for' => $this->run->scheduled_for?->toIso8601String(),
'repeat_type' => $this->run->repeat_type,
];
}
}
+28
View File
@@ -13,6 +13,8 @@
namespace App\Events;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
@@ -20,6 +22,12 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:赛马结算广播事件
*
* 向房间公共频道广播最终赛果,并附带前端展示个人奖金所需的
* 奖池分配参数,避免结算弹窗只能显示固定的 0 金币。
*/
class HorseRaceSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -56,6 +64,24 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastWith(): array
{
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
// 统计各马匹总下注,为前端还原个人分奖金额提供基础参数。
$horsePools = HorseBet::query()
->where('race_id', $this->race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->map(fn ($pool) => (int) $pool)
->toArray();
$winnerPool = (int) ($horsePools[$this->race->winner_horse_id] ?? 0);
$distributablePool = (int) round(
HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool)
);
// 找出获胜马匹的名称
$horses = $this->race->horses ?? [];
$winnerName = '未知';
@@ -71,6 +97,8 @@ class HorseRaceSettled implements ShouldBroadcastNow
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool,
'winner_pool' => $winnerPool,
'distributable_pool' => $distributablePool,
'settled_at' => $this->race->settled_at?->toIso8601String(),
];
}
+56 -2
View File
@@ -10,12 +10,17 @@
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:根据消息可见范围选择广播频道。
*/
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -32,14 +37,25 @@ class MessageSent implements ShouldBroadcastNow
) {}
/**
* Get the channels the event should broadcast on.
* 获取消息应广播到的频道。
*
* 聊天消息广播至包含在线状态管理的 PresenceChannel。
* 公共消息走房间 Presence 频道;
* 定向消息 / 悄悄话只发给发送方与接收方的私有用户频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
if ($this->shouldBroadcastPrivately()) {
$privateChannels = [];
foreach ($this->resolveVisibleUserIds() as $userId) {
$privateChannels[] = new PrivateChannel('user.'.$userId);
}
return $privateChannels;
}
return [
new PresenceChannel('room.'.$this->roomId),
];
@@ -56,4 +72,42 @@ class MessageSent implements ShouldBroadcastNow
'message' => $this->message,
];
}
/**
* 判断当前消息是否应仅广播给特定用户。
*/
private function shouldBroadcastPrivately(): bool
{
$toUser = trim((string) ($this->message['to_user'] ?? ''));
return $toUser !== '' && $toUser !== '大家';
}
/**
* 解析本条消息真正可见的用户 ID 列表。
*
* @return array<int, int>
*/
private function resolveVisibleUserIds(): array
{
$userIds = [];
$fromUser = trim((string) ($this->message['from_user'] ?? ''));
if ($fromUser !== '') {
$senderId = User::query()->where('username', $fromUser)->value('id');
if ($senderId !== null) {
$userIds[] = (int) $senderId;
}
}
$toUser = trim((string) ($this->message['to_user'] ?? ''));
if ($toUser !== '' && $toUser !== '大家') {
$receiverId = User::query()->where('username', $toUser)->value('id');
if ($receiverId !== null) {
$userIds[] = (int) $receiverId;
}
}
return array_values(array_unique($userIds));
}
}
+29 -5
View File
@@ -1,10 +1,10 @@
<?php
/**
* 文件功能:红包领取成功广播事件(广播至领取者私有频道)
* 文件功能:红包领取成功广播事件(广播至房间与领取者私有频道)
*
* 触发时机:RedPacketController::claim() 成功后广播,
* 前端收到后弹出 Toast 通知展示到账金额
* 房间内在线用户收到后实时刷新剩余份数,领取者本人可同步收到到账提示
*
* @author ChatRoom Laravel
*
@@ -15,11 +15,18 @@ namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:广播礼包被领取后的实时状态
*
* 统一向房间频道推送剩余份数变化,同时向领取者私有频道推送到账结果,
* 让红包弹窗与用户提示保持一致。
*/
class RedPacketClaimed implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -28,32 +35,49 @@ class RedPacketClaimed implements ShouldBroadcastNow
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $envelopeId 红包 ID
* @param int $roomId 房间 ID
* @param int $remainingCount 剩余份数
* @param string $type 红包类型
*/
public function __construct(
public readonly User $claimer,
public readonly int $amount,
public readonly int $envelopeId,
public readonly int $roomId,
public readonly int $remainingCount,
public readonly string $type = 'gold',
) {}
/**
* 广播至领取者私有频道。
* 广播至房间频道与领取者私有频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('user.'.$this->claimer->id)];
return [
new PresenceChannel('room.'.$this->roomId),
new PrivateChannel('user.'.$this->claimer->id),
];
}
/**
* 广播领取结果与剩余份数。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$typeLabel = $this->type === 'exp' ? '经验' : '金币';
return [
'envelope_id' => $this->envelopeId,
'claimer_id' => $this->claimer->id,
'claimer_username' => $this->claimer->username,
'amount' => $this->amount,
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
'remaining_count' => $this->remainingCount,
'type' => $this->type,
'message' => "🧧 成功抢到 {$this->amount} {$typeLabel}礼包!",
];
}
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:用户定向页面刷新广播事件
*
* 在任命或撤销职务成功后,向目标用户私有频道推送刷新指令,
* 确保对方页面上的权限按钮与职务状态及时同步。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定用户广播页面刷新请求。
*/
class UserBrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录目标用户与刷新说明。
*/
public function __construct(
public readonly int $targetUserId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:目标用户私有频道。
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('user.'.$this->targetUserId);
}
/**
* 广播数据:供前端展示提示并执行刷新。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:聊天室用户状态变更广播事件
* 负责在用户设置或清除当日状态后,实时同步当前房间在线名单展示。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserStatusUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造聊天室用户状态变更广播事件。
*
* @param int $roomId 房间 ID
* @param string $username 状态变更用户昵称
* @param array<string, mixed> $user 最新在线名单载荷
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
public readonly array $user,
) {}
/**
* 获取广播频道。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 获取广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'username' => $this->username,
'user' => $this->user,
];
}
}
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:后台隐藏登录控制器
*
* 仅提供站长独立登录入口,登录成功后直接进入后台控制台,
* 不经过聊天室首页与“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminLoginRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* 类功能:处理站长隐藏登录页展示与登录提交。
*/
class AdminAuthController extends Controller
{
/**
* 隐藏登录入口后缀。
*/
private const LOGIN_SUFFIX = 'lkddi';
/**
* 站长账号固定主键。
*/
private const SITE_OWNER_ID = 1;
/**
* 显示站长隐藏登录页面。
*/
public function create(Request $request): View|RedirectResponse
{
// 已通过隐藏入口登录的站长再次访问时,直接回后台首页
if (Auth::id() === self::SITE_OWNER_ID && $request->session()->get('admin_login_via_hidden')) {
return redirect()->route('admin.dashboard');
}
return view('admin.auth.login', [
'loginSuffix' => self::LOGIN_SUFFIX,
]);
}
/**
* 处理站长隐藏登录请求。
*/
public function store(AdminLoginRequest $request): RedirectResponse
{
$validated = $request->validated();
$siteOwner = User::query()->find(self::SITE_OWNER_ID);
// 只有 id=1 的站长账号允许通过该入口进入后台
if (! $siteOwner || $siteOwner->username !== $validated['username']) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['username' => '该入口仅限站长账号使用。']);
}
if (! $this->passwordMatches($siteOwner, $validated['password'])) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['password' => '账号或密码错误。']);
}
// 若当前已有其他账号占用会话,先退出后再切换为站长会话
if (Auth::check() && Auth::id() !== $siteOwner->id) {
Auth::logout();
}
Auth::login($siteOwner);
$request->session()->regenerate();
$request->session()->put('admin_login_via_hidden', true);
// 复用主登录的会话登记逻辑,保证后台入口也会更新登录痕迹
$this->recordAdminLogin($siteOwner, (string) $request->ip());
return redirect()->route('admin.dashboard')->with('success', '站长后台登录成功。');
}
/**
* 校验站长密码,兼容旧库 MD5 并自动升级为 bcrypt。
*/
private function passwordMatches(User $siteOwner, string $plainPassword): bool
{
try {
if (Hash::check($plainPassword, $siteOwner->password)) {
return true;
}
} catch (\RuntimeException $exception) {
// 旧库非 bcrypt 密码会在这里抛异常,后续继续走 MD5 兼容逻辑
}
if (md5($plainPassword) !== $siteOwner->password) {
return false;
}
// 兼容老密码登录成功后,立即升级为 Laravel 默认哈希
$siteOwner->forceFill([
'password' => Hash::make($plainPassword),
])->save();
return true;
}
/**
* 记录站长通过隐藏入口登录后的访问痕迹。
*/
private function recordAdminLogin(User $siteOwner, string $ip): void
{
// 登录成功后补齐访问次数、IP 与时间,保持与前台登录统计一致
$siteOwner->increment('visit_num');
$siteOwner->update([
'previous_ip' => $siteOwner->last_ip,
'last_ip' => $ip,
'log_time' => now(),
'in_time' => now(),
]);
\App\Models\IpLog::create([
'ip' => $ip,
'sdate' => now(),
'uuname' => $siteOwner->username,
]);
try {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyAdminOnline($siteOwner);
$wechatService->notifyFriendsOnline($siteOwner);
$wechatService->notifySpouseOnline($siteOwner);
} catch (\Exception $exception) {
// 机器人通知异常不影响站长进入后台,但需要落日志便于排查
Log::error('Hidden admin login notification failed', ['error' => $exception->getMessage()]);
}
}
}
@@ -20,6 +20,7 @@ use App\Http\Controllers\Controller;
use App\Models\AiProviderConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,6 +34,7 @@ class AiProviderController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
@@ -283,19 +285,8 @@ class AiProviderController extends Controller
]);
}
$userData = [
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
// 机器人在线载荷也统一走聊天室展示服务,避免名单字段口径逐步漂移。
$userData = $this->chatUserPresenceService->build($user);
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:百家乐买单活动后台控制器
*
* 提供聊天室管理员在输入框上方快捷创建活动、
* 查看当前活动并手动结束活动的 JSON 接口。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
use App\Models\BaccaratLossCoverEvent;
use App\Services\BaccaratLossCoverService;
use App\Services\PositionPermissionService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:处理聊天室顶部快捷入口创建与结束百家乐买单活动。
*/
class BaccaratLossCoverEventController extends Controller
{
/**
* 注入百家乐买单活动服务。
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* 创建新的百家乐买单活动。
*/
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权创建买单活动。',
], 403);
}
try {
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
} catch (\RuntimeException $exception) {
return response()->json([
'ok' => false,
'message' => $exception->getMessage(),
], 422);
}
return response()->json([
'ok' => true,
'message' => "活动「{$event->title}」已创建成功。",
'event_id' => $event->id,
]);
}
/**
* 手动结束或取消一场百家乐买单活动。
*/
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
{
if (! $this->positionPermissionService->hasPermission($request->user(), PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER)) {
return response()->json([
'ok' => false,
'message' => '当前职务无权结束买单活动。',
], 403);
}
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
return response()->json([
'ok' => true,
'message' => '活动状态已更新。',
'status' => $event->status,
]);
}
}
@@ -9,7 +9,7 @@
* 安全保证:
* - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护
* - 普通用户无权访问此接口,无法伪造对他人的广播
* - options 中的用户输入字段在后端经过 strip_tags 清洗
* - options 中的用户输入字段在后端统一降级为纯文本 / 白名单样式值
*
* @author ChatRoom Laravel
*
@@ -23,6 +23,9 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:安全地下发大卡片广播消息。
*/
class BannerBroadcastController extends Controller
{
/**
@@ -46,23 +49,39 @@ class BannerBroadcastController extends Controller
'options.body' => ['nullable', 'string', 'max:500'],
'options.sub' => ['nullable', 'string', 'max:200'],
'options.gradient' => ['nullable', 'array', 'max:5'],
'options.gradient.*' => ['nullable', 'string', 'max:30'],
'options.titleColor' => ['nullable', 'string', 'max:30'],
'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'],
'options.buttons' => ['nullable', 'array', 'max:4'],
'options.buttons.*.label' => ['nullable', 'string', 'max:30'],
'options.buttons.*.color' => ['nullable', 'string', 'max:30'],
'options.buttons.*.action' => ['nullable', 'string', 'max:20'],
]);
// 对可能包含用户输入的字段进行 HTML 净化(防 XSS)
// 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。
$opts = $validated['options'];
foreach (['title', 'name', 'body', 'sub'] as $field) {
if (isset($opts[$field])) {
$opts[$field] = strip_tags($opts[$field], '<b><strong><em><span><br>');
$opts[$field] = $this->sanitizeBannerText($opts[$field]);
}
}
// 按钮 label 不允许 HTML
if (isset($opts['titleColor'])) {
$opts['titleColor'] = $this->sanitizeCssValue($opts['titleColor'], '#fde68a');
}
if (! empty($opts['gradient'])) {
$opts['gradient'] = array_values(array_map(
fn ($color) => $this->sanitizeCssValue($color, '#4f46e5'),
$opts['gradient']
));
}
// 按钮 label 与颜色都只允许安全文本 / 颜色值。
if (! empty($opts['buttons'])) {
$opts['buttons'] = array_map(function ($btn) {
$btn['label'] = strip_tags($btn['label'] ?? '');
$btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981');
$btn['label'] = $this->sanitizeBannerText($btn['label'] ?? '');
$btn['color'] = $this->sanitizeCssValue($btn['color'] ?? '#10b981', '#10b981');
// action 只允许预定义值,防止注入任意 JS
$btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link'])
? $btn['action'] : 'close';
@@ -79,4 +98,38 @@ class BannerBroadcastController extends Controller
return response()->json(['status' => 'success', 'message' => '广播已发送']);
}
/**
* Banner 文案净化为安全纯文本。
*/
private function sanitizeBannerText(?string $text): string
{
return trim(strip_tags((string) $text));
}
/**
* 清洗颜色 / 渐变等 CSS 值,阻断样式属性注入。
*/
private function sanitizeCssValue(?string $value, string $default): string
{
$sanitized = strtolower(trim((string) $value));
if ($sanitized === '' || preg_match('/(?:javascript|expression|url\s*\(|data:|var\s*\()/i', $sanitized)) {
return $default;
}
$allowedPatterns = [
'/^#[0-9a-f]{3,8}$/i',
'/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
'/^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i',
'/^(?:white|black|red|blue|green|gray|grey|yellow|orange|pink|purple|teal|cyan|indigo|amber|emerald|transparent|currentcolor)$/i',
];
foreach ($allowedPatterns as $allowedPattern) {
if (preg_match($allowedPattern, $sanitized)) {
return $sanitized;
}
}
return $default;
}
}
@@ -174,18 +174,29 @@ class ChangelogController extends Controller
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
// 广播文案允许保留安全链接,但标题与版本号必须先做 HTML 转义,避免系统消息被拼成恶意标签。
$safeTypeLabel = e(DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新');
$safeVersion = e((string) $log->version);
$safeTitle = e((string) $log->title);
$detailUrl = e($this->buildChangelogDetailUrl($log));
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'content' => "📢 【版本更新 {$safeTypeLabel}】v{$safeVersion}{$safeTitle}》— <a href=\"{$detailUrl}\" target=\"_blank\" rel=\"noopener\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
/**
* 生成开发日志详情链接,并对版本片段做 URL 编码,避免广播 href 被注入额外属性。
*/
private function buildChangelogDetailUrl(DevChangelog $log): string
{
return route('changelog.index').'#v'.rawurlencode((string) $log->version);
}
}
@@ -13,18 +13,37 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService;
use Illuminate\View\View;
/**
* 类功能:负责后台首页仪表盘的汇总统计展示。
*/
class DashboardController extends Controller
{
/**
* 注入聊天室状态服务,供仪表盘读取实时在线数据。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示后台首页与全局统计
*/
public function index(): View
{
$onlineUsernames = collect();
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
// 使用在线名单服务的懒清理结果,保证统计口径与聊天室在线列表一致。
$onlineUsernames = $onlineUsernames->merge(array_keys($this->chatState->getRoomUsers($roomId)));
}
$stats = [
'total_users' => User::count(),
'total_rooms' => Room::count(),
'online_users' => $onlineUsernames->unique()->count(),
// 更多统计指标以后再发掘
];
@@ -14,15 +14,27 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreHolidayEventRequest;
use App\Http\Requests\UpdateHolidayEventRequest;
use App\Jobs\TriggerHolidayEventJob;
use App\Models\HolidayEvent;
use App\Services\HolidayEventScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:管理节日福利模板的后台增删改查与手动触发操作。
*/
class HolidayEventController extends Controller
{
/**
* 注入节日福利调度计算服务。
*/
public function __construct(
private readonly HolidayEventScheduleService $scheduleService,
) {}
/**
* 节日福利活动列表页。
*/
@@ -46,30 +58,9 @@ class HolidayEventController extends Controller
/**
* 保存新活动。
*/
public function store(Request $request): RedirectResponse
public function store(StoreHolidayEventRequest $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'total_amount' => 'required|integer|min:1',
'max_claimants' => 'required|integer|min:0',
'distribute_type' => 'required|in:random,fixed',
'min_amount' => 'nullable|integer|min:1',
'max_amount' => 'nullable|integer|min:1',
'fixed_amount' => 'nullable|integer|min:1',
'send_at' => 'required|date',
'expire_minutes' => 'required|integer|min:1|max:1440',
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
'cron_expr' => 'nullable|string|max:100',
'target_type' => 'required|in:all,vip,level',
'target_value' => 'nullable|string|max:50',
'enabled' => 'boolean',
]);
$data['status'] = 'pending';
$data['enabled'] = $request->boolean('enabled', true);
HolidayEvent::create($data);
HolidayEvent::create($this->buildPayload($request->validated(), true));
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
}
@@ -85,26 +76,9 @@ class HolidayEventController extends Controller
/**
* 更新活动。
*/
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
public function update(UpdateHolidayEventRequest $request, HolidayEvent $holidayEvent): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'total_amount' => 'required|integer|min:1',
'max_claimants' => 'required|integer|min:0',
'distribute_type' => 'required|in:random,fixed',
'min_amount' => 'nullable|integer|min:1',
'max_amount' => 'nullable|integer|min:1',
'fixed_amount' => 'nullable|integer|min:1',
'send_at' => 'required|date',
'expire_minutes' => 'required|integer|min:1|max:1440',
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
'cron_expr' => 'nullable|string|max:100',
'target_type' => 'required|in:all,vip,level',
'target_value' => 'nullable|string|max:50',
]);
$holidayEvent->update($data);
$holidayEvent->update($this->buildPayload($request->validated()));
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
}
@@ -128,13 +102,12 @@ class HolidayEventController extends Controller
*/
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
{
if ($holidayEvent->status !== 'pending') {
return back()->with('error', '只有待触发状态的活动才能手动触发。');
if (! $holidayEvent->enabled || $holidayEvent->status === 'cancelled') {
return back()->with('error', '当前活动未启用或已取消,不能立即触发。');
}
// 设置触发时间为当前,立即入队
$holidayEvent->update(['send_at' => now()]);
TriggerHolidayEventJob::dispatch($holidayEvent);
// 立即触发只生成临时批次,不覆盖年度锚点或下次计划时间。
TriggerHolidayEventJob::dispatch($holidayEvent, true);
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
}
@@ -148,4 +121,54 @@ class HolidayEventController extends Controller
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
}
/**
* 组装节日福利模板的可持久化字段。
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function buildPayload(array $data, bool $isCreating = false): array
{
$payload = $data;
// 创建与编辑都统一回收无效字段,避免模板状态互相污染。
if (($payload['distribute_type'] ?? 'random') === 'random') {
$payload['fixed_amount'] = null;
} else {
$payload['min_amount'] = 1;
$payload['max_amount'] = null;
}
if (($payload['target_type'] ?? 'all') !== 'level') {
$payload['target_value'] = null;
}
if (($payload['repeat_type'] ?? 'once') !== 'cron') {
$payload['cron_expr'] = null;
}
if (($payload['repeat_type'] ?? 'once') === 'yearly') {
$payload['send_at'] = $this->scheduleService
->resolveNextConfiguredSendAt($payload)
->toDateTimeString();
} else {
$payload['schedule_month'] = null;
$payload['schedule_day'] = null;
$payload['schedule_time'] = null;
$payload['duration_days'] = 1;
$payload['daily_occurrences'] = 1;
$payload['occurrence_interval_minutes'] = null;
}
// 每次保存模板时,都让系统按新配置重新进入待触发状态。
$payload['status'] = 'pending';
$payload['enabled'] = (bool) ($payload['enabled'] ?? true);
$payload['triggered_at'] = null;
$payload['expires_at'] = null;
$payload['claimed_count'] = 0;
$payload['claimed_amount'] = 0;
return $payload;
}
}
@@ -16,10 +16,15 @@ use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\Sysparam;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:负责后台职务资料、任命白名单与聊天室权限配置的维护。
*/
class PositionController extends Controller
{
/**
@@ -29,16 +34,25 @@ class PositionController extends Controller
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->with('appointablePositions')->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
$allPositions = Position::with('department')->ordered()->get();
// 全局奖励接收次数上限(0 = 不限)
$globalRecipientDailyMax = (int) Sysparam::getValue('reward_recipient_daily_max', '0');
return view('admin.positions.index', compact('departments', 'allPositions', 'globalRecipientDailyMax'));
$positionPermissions = PositionPermissionRegistry::groupedDefinitions();
$permissionLabels = PositionPermissionRegistry::labelMap();
return view('admin.positions.index', compact(
'departments',
'allPositions',
'globalRecipientDailyMax',
'positionPermissions',
'permissionLabels',
));
}
/**
@@ -56,13 +70,20 @@ class PositionController extends Controller
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
'red_packet_count' => 'nullable|integer|min:1|max:100',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
$position = Position::create($data);
@@ -144,13 +165,20 @@ class PositionController extends Controller
'max_reward' => 'nullable|integer|min:0',
'daily_reward_limit' => 'nullable|integer|min:0',
'recipient_daily_limit' => 'nullable|integer|min:0',
'red_packet_amount' => 'nullable|integer|min:1|max:999999999|gte:red_packet_count',
'red_packet_count' => 'nullable|integer|min:1|max:100',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
'permissions' => 'nullable|array',
'permissions.*' => ['string', Rule::in(PositionPermissionRegistry::codes())],
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$data['permissions'] = array_values(array_unique($data['permissions'] ?? []));
$data['red_packet_amount'] = (int) ($data['red_packet_amount'] ?? 8888);
$data['red_packet_count'] = (int) ($data['red_packet_count'] ?? 10);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
@@ -101,7 +101,7 @@ class ShopItemController extends Controller
'icon' => 'required|string|max:20',
'description' => 'nullable|string|max:500',
'price' => 'required|integer|min:0',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing',
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,'.ShopItem::TYPE_SIGN_REPAIR,
'duration_days' => 'nullable|integer|min:0',
'duration_minutes' => 'nullable|integer|min:0',
'intimacy_bonus' => 'nullable|integer|min:0',
@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:后台签到奖励规则管理控制器
*
* 提供连续签到奖励档位的列表、新增、编辑、启停和删除功能。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\SaveSignInRewardRuleRequest;
use App\Models\SignInRewardRule;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
/**
* 类功能:管理后台每日签到奖励规则。
*/
class SignInRewardRuleController extends Controller
{
/**
* 方法功能:展示签到奖励规则列表。
*/
public function index(): View
{
$rules = SignInRewardRule::query()
->orderBy('sort_order')
->orderBy('streak_days')
->get();
return view('admin.sign-in-rules.index', compact('rules'));
}
/**
* 方法功能:新增签到奖励规则。
*/
public function store(SaveSignInRewardRuleRequest $request): RedirectResponse
{
SignInRewardRule::query()->create($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。');
}
/**
* 方法功能:更新签到奖励规则。
*/
public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->update($this->payload($request));
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。');
}
/**
* 方法功能:切换签到奖励规则启用状态。
*/
public function toggle(SignInRewardRule $signInRewardRule): JsonResponse
{
$signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]);
return response()->json([
'ok' => true,
'is_enabled' => $signInRewardRule->is_enabled,
'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。',
]);
}
/**
* 方法功能:删除签到奖励规则。
*/
public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse
{
$signInRewardRule->delete();
return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。');
}
/**
* 方法功能:整理后台表单提交的数据。
*
* @return array<string, mixed>
*/
private function payload(SaveSignInRewardRuleRequest $request): array
{
$data = $request->validated();
$data['is_enabled'] = $request->boolean('is_enabled');
foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) {
$data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
}
return $data;
}
}
+52 -13
View File
@@ -17,28 +17,37 @@ use App\Models\SysParam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* 类功能:后台通用系统参数配置控制器
* 仅允许维护低敏公共参数,站长专属敏感配置需走各自独立页面。
*/
class SystemController extends Controller
{
/**
* 构造函数注入聊天室状态服务
*/
public function __construct(
private readonly ChatStateService $chatState
) {}
/**
* 显示全局参数配置表单
* 显示通用系统参数配置表单
*/
public function edit(): View
{
// 读取数据库中最新的参数 (剔除专属模块已接管的配置,避免重复显示)
$params = SysParam::whereNotIn('alias', ['chatbot_enabled'])
->where('alias', 'not like', 'smtp_%')
->get()->pluck('body', 'alias')->toArray();
$editableAliases = $this->editableSystemAliases();
// 为后台界面准备的文案对照 (可动态化或硬编码)
$descriptions = SysParam::whereNotIn('alias', ['chatbot_enabled'])
->where('alias', 'not like', 'smtp_%')
->get()->pluck('guidetxt', 'alias')->toArray();
// 通用系统页仅加载白名单字段,避免站长专属配置被普通高管查看。
$systemParams = SysParam::query()
->whereIn('alias', $editableAliases)
->orderBy('id')
->get(['alias', 'body', 'guidetxt']);
$params = $systemParams->pluck('body', 'alias')->all();
$descriptions = $systemParams->pluck('guidetxt', 'alias')->all();
return view('admin.system.edit', compact('params', 'descriptions'));
}
@@ -48,16 +57,19 @@ class SystemController extends Controller
*/
public function update(Request $request): RedirectResponse
{
$data = $request->except(['_token', '_method']);
// 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。
$data = $request->only($this->editableSystemAliases());
foreach ($data as $alias => $body) {
$normalizedBody = (string) $body;
SysParam::updateOrCreate(
['alias' => $alias],
['body' => $body]
['body' => $normalizedBody]
);
// 写入 Cache 保证极速读取
$this->chatState->setSysParam($alias, $body);
// 仅对白名单字段同步缓存,杜绝越权请求覆盖站长专属配置。
$this->chatState->setSysParam($alias, $normalizedBody);
// 同时清除 Sysparam 模型的内部缓存
SysParam::clearCache($alias);
@@ -65,4 +77,31 @@ class SystemController extends Controller
return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!');
}
/**
* 获取通用系统页允许维护的参数别名白名单
*
* @return array<int, string>
*/
private function editableSystemAliases(): array
{
return SysParam::query()
->orderBy('id')
->pluck('alias')
->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias))
->values()
->all();
}
/**
* 判断参数是否属于站长专属敏感配置
*/
private function isSensitiveAlias(string $alias): bool
{
if (Str::startsWith($alias, ['smtp_', 'vip_payment_', 'wechat_bot_', 'chatbot_'])) {
return true;
}
return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']);
}
}
@@ -12,8 +12,15 @@
namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Events\AppointmentAnnounced;
use App\Events\UserBrowserRefreshRequested;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateManagedUserRequest;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -23,6 +30,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
/**
* 类功能:负责后台用户列表展示、资料编辑与删除操作。
*/
class UserManagerController extends Controller
{
/**
@@ -31,6 +41,7 @@ class UserManagerController extends Controller
public function __construct(
private readonly UserCurrencyService $currencyService,
private readonly ChatStateService $chatState,
private readonly AppointmentService $appointmentService,
) {}
/**
@@ -77,8 +88,12 @@ class UserManagerController extends Controller
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
// 职务下拉选项(复用任命系统中的部门与职务数据)
$departments = Department::with([
'positions' => fn ($positionQuery) => $positionQuery->ordered(),
])->ordered()->get();
return view('admin.users.index', compact('users', 'vipLevels', 'sortBy', 'sortDir', 'onlineUsernames'));
return view('admin.users.index', compact('users', 'vipLevels', 'departments', 'sortBy', 'sortDir', 'onlineUsernames'));
}
/**
@@ -86,10 +101,11 @@ class UserManagerController extends Controller
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, User $user): JsonResponse|RedirectResponse
public function update(UpdateManagedUserRequest $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = $user;
$currentUser = Auth::user();
$responseMessages = [];
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
if ($currentUser->id !== 1) {
@@ -104,17 +120,7 @@ class UserManagerController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
$validated = $request->validate([
'sex' => 'sometimes|integer|in:0,1,2',
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
'qianming' => 'sometimes|nullable|string|max:255',
'headface' => 'sometimes|string|max:50',
'password' => 'nullable|string|min:6',
'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id',
'hy_time' => 'sometimes|nullable|date',
]);
$validated = $request->validated();
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
@@ -188,11 +194,31 @@ class UserManagerController extends Controller
$targetUser->save();
if ($request->wantsJson()) {
return response()->json(['status' => 'success', 'message' => '用户资料已强行更新完毕!']);
if (array_key_exists('position_id', $validated)) {
$positionSyncResult = $this->syncUserPosition(
operator: $currentUser,
targetUser: $targetUser,
targetPositionId: $validated['position_id'],
);
if (! $positionSyncResult['ok']) {
return response()->json(['status' => 'error', 'message' => $positionSyncResult['message']], 422);
}
if (! empty($positionSyncResult['message'])) {
$responseMessages[] = $positionSyncResult['message'];
}
}
return back()->with('success', '用户资料已更新!');
if ($request->wantsJson()) {
$message = array_merge(['用户资料已强行更新完毕!'], $responseMessages);
return response()->json(['status' => 'success', 'message' => implode(' ', $message)]);
}
$message = array_merge(['用户资料已更新!'], $responseMessages);
return back()->with('success', implode(' ', $message));
}
/**
@@ -225,4 +251,101 @@ class UserManagerController extends Controller
return back()->with('success', '目标已被物理删除。');
}
/**
* 方法功能:同步后台编辑页选择的目标职务。
*
* @return array{ok: bool, message: string}
*/
private function syncUserPosition(User $operator, User $targetUser, ?int $targetPositionId): array
{
$currentAssignment = $this->appointmentService->getActivePosition($targetUser);
$currentPositionId = $currentAssignment?->position_id;
if ($targetPositionId === $currentPositionId) {
return ['ok' => true, 'message' => ''];
}
if ($targetPositionId === null) {
if (! $currentAssignment) {
return ['ok' => true, 'message' => ''];
}
$result = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
if (! $result['ok']) {
return $result;
}
$this->broadcastRevokedPosition($operator, $targetUser, $currentAssignment);
return ['ok' => true, 'message' => '用户职务已撤销。'];
}
$targetPosition = Position::with('department')->findOrFail($targetPositionId);
if ($currentAssignment) {
$revokeResult = $this->appointmentService->revoke($operator, $targetUser, '后台用户管理编辑');
if (! $revokeResult['ok']) {
return $revokeResult;
}
}
$appointResult = $this->appointmentService->appoint($operator, $targetUser, $targetPosition, '后台用户管理编辑');
if (! $appointResult['ok']) {
return $appointResult;
}
$this->broadcastAppointedPosition($operator, $targetUser, $targetPosition);
return ['ok' => true, 'message' => "用户职务已更新为【{$targetPosition->name}】。"];
}
/**
* 方法功能:广播后台任命成功后的公告与目标用户刷新事件。
*/
private function broadcastAppointedPosition(User $operator, User $targetUser, Position $targetPosition): void
{
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $targetUser->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
broadcast(new UserBrowserRefreshRequested(
targetUserId: $targetUser->id,
operator: $operator->username,
reason: '你的职务已发生变更,页面权限正在同步更新。',
));
}
/**
* 方法功能:广播后台撤销职务后的公告与目标用户刷新事件。
*/
private function broadcastRevokedPosition(User $operator, User $targetUser, UserPosition $currentAssignment): void
{
$currentAssignment->loadMissing('position.department');
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $targetUser->username,
positionIcon: $currentAssignment->position?->icon ?? '🎖️',
positionName: $currentAssignment->position?->name ?? '',
departmentName: $currentAssignment->position?->department?->name ?? '',
operatorName: $operator->username,
type: 'revoke',
));
}
broadcast(new UserBrowserRefreshRequested(
targetUserId: $targetUser->id,
operator: $operator->username,
reason: '你的职务已被撤销,页面权限正在同步更新。',
));
}
}
+76 -3
View File
@@ -13,11 +13,17 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\VipLevel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 后台 VIP 会员等级管理控制器
* 负责会员等级维护,以及查看各等级下的会员名单。
*/
class VipController extends Controller
{
/**
@@ -31,6 +37,12 @@ class VipController extends Controller
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
'sakura' => '樱花飘落',
'meteors' => '流星',
'gold-rain' => '金币雨',
'hearts' => '爱心飘落',
'confetti' => '彩带庆典',
'fireflies' => '萤火虫',
];
/**
@@ -51,7 +63,10 @@ class VipController extends Controller
*/
public function index(): View
{
$levels = VipLevel::orderBy('sort_order')->get();
$levels = VipLevel::query()
->withCount('users')
->orderBy('sort_order')
->get();
return view('admin.vip.index', [
'levels' => $levels,
@@ -60,6 +75,64 @@ class VipController extends Controller
]);
}
/**
* 查看某个会员等级下的会员名单。
*
* @param Request $request 当前筛选请求
* @param VipLevel $vip 当前会员等级
*/
public function members(Request $request, VipLevel $vip): View
{
$query = User::query()->where('vip_level_id', $vip->id);
$now = now();
if ($request->filled('keyword')) {
$keyword = trim((string) $request->input('keyword'));
// 支持后台按用户名快速筛选某个等级下的会员。
$query->where('username', 'like', '%'.$keyword.'%');
}
if ($request->input('status') === 'active') {
// 当前有效会员:永久会员或到期时间仍在未来。
$query->where(function ($builder) use ($now): void {
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
});
}
if ($request->input('status') === 'expired') {
// 已过期会员:到期时间存在且已经早于当前时间。
$query->whereNotNull('hy_time')->where('hy_time', '<=', $now);
}
$members = $query
->select(['id', 'username', 'sex', 'vip_level_id', 'hy_time', 'created_at'])
->orderByRaw('CASE WHEN hy_time IS NULL THEN 0 WHEN hy_time > ? THEN 1 ELSE 2 END', [$now])
->orderByRaw('hy_time IS NULL DESC')
->orderByDesc('hy_time')
->orderBy('username')
->paginate(20)
->withQueryString();
$totalAssignedCount = User::query()
->where('vip_level_id', $vip->id)
->count();
$activeCount = User::query()
->where('vip_level_id', $vip->id)
->where(function ($builder) use ($now): void {
$builder->whereNull('hy_time')->orWhere('hy_time', '>', $now);
})
->count();
return view('admin.vip.members', [
'vip' => $vip,
'members' => $members,
'totalAssignedCount' => $totalAssignedCount,
'activeCount' => $activeCount,
]);
}
/**
* 新增会员等级
*/
@@ -139,8 +212,8 @@ class VipController extends Controller
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
'join_effect' => 'required|in:none,fireworks,rain,lightning,snow',
'leave_effect' => 'required|in:none,fireworks,rain,lightning,snow',
'join_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
'leave_effect' => ['required', 'string', Rule::in(VipLevel::EFFECT_OPTIONS)],
'join_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'leave_banner_style' => 'required|in:aurora,storm,royal,cosmic,farewell',
'allow_custom_messages' => 'nullable|boolean',
+422 -83
View File
@@ -4,7 +4,7 @@
* 文件功能:管理员聊天室实时命令控制器
*
* 提供管理员在聊天室内对用户执行的管理操作:
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)站长公屏讲话。
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)职务公屏讲话。
*
* 对应原 ASP 文件:DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
*
@@ -16,19 +16,29 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\BrowserRefreshRequested;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\ChatContentSanitizer;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Validation\Rule;
/**
* 类功能:处理聊天室内的实时管理命令与部分职务奖励操作。
*/
class AdminCommandController extends Controller
{
/**
@@ -37,6 +47,7 @@ class AdminCommandController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -57,12 +68,25 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '请注意言行');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 权限检查(等级由 level_warn 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_warn', '5')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$reason = ChatContentSanitizer::htmlText($request->input('reason', '请注意言行'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_WARN, '警告');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// 广播警告消息
@@ -71,7 +95,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⚠️ 管理员 <b>{$admin->username}</b> 警告 <b>{$targetUsername}</b>{$reason}",
'content' => "⚠️ {$operatorDisplay} 警告 <b>{$safeTargetUsername}</b>{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -81,6 +105,17 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 给被警告用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "⚠️ {$operatorDisplay} 警告了你:{$reason}",
title: '⚠️ 收到警告',
toastMessage: "{$operatorDisplay} 警告了你:{$reason}",
color: '#f59e0b',
icon: '⚠️',
);
return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]);
}
@@ -102,14 +137,38 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
// 权限检查(等级由 level_kick 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_KICK, '踢出');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🚫 {$operatorDisplay} 已将你踢出聊天室。原因:{$reason}",
title: '🚫 已被踢出',
toastMessage: "{$operatorDisplay} 已将你踢出聊天室。<br>原因:{$reason}",
color: '#ef4444',
icon: '🚫',
);
// 从 Redis 在线列表移除
$this->chatState->userLeave($roomId, $targetUsername);
@@ -119,7 +178,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🚫 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
'content' => "🚫 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 踢出聊天室。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -153,12 +212,26 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$duration = $request->input('duration');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 权限检查(等级由 level_mute 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_mute', '8')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$duration = $request->input('duration');
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
$operatorLabel = $this->buildOperatorIdentityLabel($admin).' '.$admin->username;
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_MUTE, '禁言');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// 设置 Redis 禁言标记,TTL 自动过期
@@ -171,7 +244,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🔇 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
'content' => "🔇 {$operatorDisplay} 已将 <b>{$safeTargetUsername}</b> 禁言 {$duration} 分钟。",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -181,12 +254,24 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🔇 {$operatorDisplay} 已将你禁言 {$duration} 分钟。",
title: '🔇 已被禁言',
toastMessage: "{$operatorDisplay} 已将你禁言 <b>{$duration}</b> 分钟。",
color: '#6366f1',
icon: '🔇',
);
// 广播禁言事件(前端禁用输入框)
broadcast(new \App\Events\UserMuted(
roomId: $roomId,
username: $targetUsername,
muteTime: $duration,
operator: $admin->username,
message: "{$operatorLabel} 已将 [{$targetUsername}] 禁言 {$duration} 分钟。",
operator: $operatorLabel,
));
return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]);
@@ -210,22 +295,43 @@ class AdminCommandController extends Controller
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 权限检查(等级由 level_freeze 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_freeze', '14')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
$reason = ChatContentSanitizer::htmlText($request->input('reason', '违反聊天室规则'));
$safeTargetUsername = ChatContentSanitizer::htmlText($targetUsername);
$operatorDisplay = $this->buildOperatorDisplayHtml($admin);
// 权限检查:必须拥有职务权限,且不能处理职务高于自己的用户。
$authorization = $this->authorizeModerationAction($admin, $targetUsername, PositionPermissionRegistry::USER_FREEZE, '冻结');
if (! $authorization['ok']) {
return response()->json(['status' => 'error', 'message' => $authorization['message']], 403);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// 冻结用户账号(将等级设为 -1 表示冻结)
$target = User::where('username', $targetUsername)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
$target = $authorization['target'];
$target->user_level = -1;
$target->save();
// 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $targetUsername,
content: "🧊 {$operatorDisplay} 已冻结你的账号。原因:{$reason}",
title: '🧊 账号已冻结',
toastMessage: "{$operatorDisplay} 已冻结你的账号。<br>原因:{$reason}",
color: '#3b82f6',
icon: '🧊',
);
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
@@ -238,7 +344,7 @@ class AdminCommandController extends Controller
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🧊 管理员 <b>{$admin->username}</b> 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
'content' => "🧊 {$operatorDisplay} 已冻结 <b>{$safeTargetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
@@ -301,9 +407,10 @@ class AdminCommandController extends Controller
}
/**
* 站长公屏讲话
* 聊天室公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
* 拥有 room.public_broadcast 权限的职务可以发送全聊天室公告,
* id=1 站长仍然拥有完整兜底权限。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
@@ -316,22 +423,27 @@ class AdminCommandController extends Controller
]);
$admin = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发布公屏讲话'], 403);
}
$roomId = $request->input('room_id');
$content = $request->input('content');
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 广播站长公告
$content = ChatContentSanitizer::htmlText($request->input('content'));
// 按当前在职职务拼装发布者身份,避免继续显示为固定“站长公告”
$publisherLabel = ChatContentSanitizer::htmlText($this->buildAnnouncementPublisherLabel($admin));
$publisherUsername = ChatContentSanitizer::htmlText($admin->username);
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 站长 <b>{$admin->username}</b> 讲话{$content}",
'content' => "📢 <b>{$publisherLabel}</b> <b>{$publisherUsername}</b> 发布公告{$content}",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
@@ -344,6 +456,172 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'success', 'message' => '公告已发送']);
}
/**
* 生成公屏公告发布者身份标签。
*
* 普通在职用户按“部门+职务”显示;站长无在职职务时保持“站长”标识兜底。
*/
private function buildAnnouncementPublisherLabel(User $user): string
{
return $this->buildOperatorIdentityLabel($user);
}
/**
* 生成操作者的身份标签。
*
* 有在职职务时统一显示为“部门·职务”,无在职职务的 id=1 兜底显示“站长”。
*/
private function buildOperatorIdentityLabel(User $user): string
{
$position = $user->activePosition?->position;
if ($position) {
$departmentName = (string) ($position->department?->name ?? '');
return $departmentName ? "{$departmentName}·{$position->name}" : $position->name;
}
if ($user->id === 1) {
return '站长';
}
return '管理员';
}
/**
* 生成操作者在聊天室文案中的完整展示文本。
*/
private function buildOperatorDisplayHtml(User $user): string
{
$identityLabel = e($this->buildOperatorIdentityLabel($user));
$username = e($user->username);
return "<b>{$identityLabel}</b> <b>{$username}</b>";
}
/**
* 校验操作者是否可在指定房间执行聊天室管理命令。
*
* @return array{ok: bool, message: string, room?: Room}
*/
private function authorizeManagedRoom(int $roomId, User $operator): array
{
$room = Room::query()->find($roomId);
if (! $room) {
return ['ok' => false, 'message' => '房间不存在'];
}
if (! $room->canUserEnter($operator)) {
return ['ok' => false, 'message' => '无权进入该房间,不能执行管理命令'];
}
// 管理命令只能作用于操作者当前所在房间,防止手工 POST 跨房间操作。
if (! $this->chatState->isUserInRoom($roomId, $operator->username)) {
return ['ok' => false, 'message' => '请先进入该房间后再执行管理命令'];
}
return ['ok' => true, 'message' => '校验通过', 'room' => $room];
}
/**
* 校验目标用户是否仍在线于当前房间。
*
* @return array{ok: bool, message: string}
*/
private function authorizeTargetOnlineInRoom(int $roomId, string $targetUsername): array
{
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return ['ok' => false, 'message' => '目标用户不在当前房间,无法执行该操作'];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 校验聊天室用户管理动作是否可执行。
*
* 规则:
* - id=1 站长始终放行
* - 其他人必须拥有对应职务权限
* - 不能操作自己
* - 不能处理 user_level 高于自己的用户
* - 不能处理部门位阶或职务位阶高于自己的用户
*
* @return array{ok: bool, message: string, target?: User}
*/
private function authorizeModerationAction(
User $admin,
string $targetUsername,
string $permissionCode,
string $actionLabel,
): array {
if (! $this->positionPermissionService->hasPermission($admin, $permissionCode)) {
return ['ok' => false, 'message' => "当前职务无权{$actionLabel}用户"];
}
if ($admin->username === $targetUsername) {
return ['ok' => false, 'message' => '不能对自己执行该操作'];
}
$target = User::query()
->where('username', $targetUsername)
->with('activePosition.position.department')
->first();
if (! $target) {
return ['ok' => false, 'message' => '用户不存在'];
}
if (! $this->canModerateTargetByDutyRank($admin, $target)) {
return ['ok' => false, 'message' => '不能处理职务高于自己的用户'];
}
if ($admin->id !== 1 && $target->user_level > $admin->user_level) {
return ['ok' => false, 'message' => '不能处理等级高于自己的用户'];
}
return ['ok' => true, 'message' => '校验通过', 'target' => $target];
}
/**
* 判断操作者是否可以按职务位阶处理目标用户。
*
* 规则:
* - 先比较部门 rank
* - 部门相同再比较职务 rank
* - 对方没有在职职务时视为可处理
* - 同职务平级允许操作,仅禁止处理更高职务
*/
private function canModerateTargetByDutyRank(User $admin, User $target): bool
{
if ($admin->id === 1) {
return true;
}
$adminPosition = $admin->activePosition?->load('position.department')->position;
if (! $adminPosition) {
return false;
}
$targetPosition = $target->activePosition?->position;
if (! $targetPosition) {
return true;
}
$adminDepartmentRank = (int) ($adminPosition->department?->rank ?? 0);
$targetDepartmentRank = (int) ($targetPosition->department?->rank ?? 0);
if ($adminDepartmentRank > $targetDepartmentRank) {
return true;
}
if ($adminDepartmentRank < $targetDepartmentRank) {
return false;
}
return (int) $adminPosition->rank >= (int) $targetPosition->rank;
}
/**
* 管理员全员清屏
*
@@ -360,12 +638,15 @@ class AdminCommandController extends Controller
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$roomId = (int) $request->input('room_id');
// 改为按职务权限控制聊天室顶部“清屏”按钮。
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_CLEAR_SCREEN)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权执行全员清屏'], 403);
}
// 需要站长权限才能全员清屏
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 清除 Redis 中该房间的消息缓存
@@ -378,10 +659,49 @@ class AdminCommandController extends Controller
}
/**
* 管理员触发全屏特效(烟花/下雨/雷电)
* 站长触发当前房间全员刷新页面。
*
* 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件,
* 适用于功能更新后强制让前端重新拉取最新页面状态。
*/
public function refreshAll(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:100',
]);
$admin = Auth::user();
if ((int) $admin->id !== 1) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403);
}
$roomId = (int) $request->input('room_id');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
$reason = ChatContentSanitizer::htmlText($request->input('reason', ''));
// 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。
broadcast(new BrowserRefreshRequested(
roomId: $roomId,
operator: $admin->username,
reason: $reason,
));
return response()->json([
'status' => 'success',
'message' => '已通知当前房间所有在线用户刷新页面',
]);
}
/**
* 管理员触发全屏特效。
*
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
* superlevel 等级管理员可触发。
* 拥有 room.fullscreen_effect 权限的职务可触发。
*
* @param Request $request 请求对象,需包含 room_id, type
* @return JsonResponse 操作结果
@@ -390,21 +710,23 @@ class AdminCommandController extends Controller
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:fireworks,rain,lightning,snow',
'type' => ['required', 'string', Rule::in(EffectBroadcast::TYPES)],
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$roomId = (int) $request->input('room_id');
$type = $request->input('type');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权触发特效'], 403);
}
// 仅 superlevel 等级可触发特效
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 广播特效事件给房间内所有在线用户
broadcast(new \App\Events\EffectBroadcast($roomId, $type, $admin->username));
broadcast(new EffectBroadcast($roomId, $type, $admin->username));
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
}
@@ -441,6 +763,10 @@ class AdminCommandController extends Controller
$roomId = (int) $request->input('room_id');
$amount = (int) $request->input('amount');
$targetUsername = $request->input('username');
$roomAuthorization = $this->authorizeManagedRoom($roomId, $admin);
if (! $roomAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $roomAuthorization['message']], 403);
}
// 不能给自己发放
if ($admin->username === $targetUsername) {
@@ -453,12 +779,21 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
$targetAuthorization = $this->authorizeTargetOnlineInRoom($roomId, $targetUsername);
if (! $targetAuthorization['ok']) {
return response()->json(['status' => 'error', 'message' => $targetAuthorization['message']], 403);
}
// id=1 超级管理员:无需职务,无限额限制
$isSuperAdmin = $admin->id === 1;
$userPosition = null;
$position = null;
if (! $isSuperAdmin) {
if (! $this->positionPermissionService->hasPermission($admin, PositionPermissionRegistry::ROOM_REWARD)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发放奖励'], 403);
}
// ① 必须有在职职务
$userPosition = $admin->activePosition;
if (! $userPosition) {
@@ -676,35 +1011,39 @@ class AdminCommandController extends Controller
}
/**
* 权限检查:管理员是否可对目标用户执行指定操作
*
* 根据 sysparam 中配置的等级门槛判断权限。
*
* @param User $admin 管理员用户
* @param string $targetUsername 目标用户名
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn
* @param string $defaultLevel 默认等级值
* @return bool 是否有权限
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
*/
private function canExecute(User $admin, string $targetUsername, string $levelKey, string $defaultLevel = '5'): bool
{
// 必须达到该操作所需的最低等级
$requiredLevel = (int) Sysparam::getValue($levelKey, $defaultLevel);
if ($admin->user_level < $requiredLevel) {
return false;
}
private function pushTargetToastMessage(
int $roomId,
string $targetUsername,
string $content,
string $title,
string $toastMessage,
string $color,
string $icon,
): void {
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => $color,
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 复用现有聊天 toast 机制,在右下角弹出操作结果提示。
'toast_notification' => [
'title' => $title,
'message' => $toastMessage,
'icon' => $icon,
'color' => $color,
'duration' => 10000,
],
];
// 不能操作自己
if ($admin->username === $targetUsername) {
return false;
}
// 目标用户等级不能高于操作者(允许平级互相操作)
$target = User::where('username', $targetUsername)->first();
if ($target && $target->user_level > $admin->user_level) {
return false;
}
return true;
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+9 -4
View File
@@ -20,6 +20,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理聊天室前台登录、自动注册与退出登录。
*/
class AuthController extends Controller
{
/**
@@ -61,7 +64,7 @@ class AuthController extends Controller
}
}
$this->performLogin($user, $ip);
$this->performLogin($user, $ip, $request);
return response()->json(['status' => 'success', 'message' => '登录成功']);
}
@@ -83,7 +86,7 @@ class AuthController extends Controller
}
}
$this->performLogin($user, $ip);
$this->performLogin($user, $ip, $request);
return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']);
}
@@ -139,7 +142,7 @@ class AuthController extends Controller
'inviter_id' => $inviterId, // 记录邀请人
]);
$this->performLogin($newUser, $ip);
$this->performLogin($newUser, $ip, $request);
// 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册
if ($inviterId) {
@@ -152,9 +155,11 @@ class AuthController extends Controller
/**
* 执行实际的登录操作并记录时间、IP 等。
*/
private function performLogin(User $user, string $ip): void
private function performLogin(User $user, string $ip, Request $request): void
{
Auth::login($user);
// 登录成功后立即轮换 session id,阻断会话固定攻击。
$request->session()->regenerate();
// 递增访问次数
$user->increment('visit_num');
+27 -5
View File
@@ -19,6 +19,7 @@ use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -28,6 +29,7 @@ class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
) {}
/**
@@ -35,18 +37,26 @@ class BaccaratController extends Controller
*/
public function currentRound(Request $request): JsonResponse
{
$user = $request->user();
$round = BaccaratRound::currentRound();
if (! $round) {
return response()->json(['round' => null]);
return response()->json([
'round' => null,
// 即使当前无局次,也返回最新金币余额,供前端每次打开弹窗时刷新右上角显示。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
$user = $request->user();
$myBet = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->first();
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
return response()->json([
'round' => [
'id' => $round->id,
@@ -59,11 +69,15 @@ class BaccaratController extends Controller
'bet_count_big' => $round->bet_count_big,
'bet_count_small' => $round->bet_count_small,
'bet_count_triple' => $round->bet_count_triple,
'min_bet' => $minBet,
'max_bet' => $maxBet,
'my_bet' => $myBet ? [
'bet_type' => $myBet->bet_type,
'amount' => $myBet->amount,
] : null,
],
// 返回当前用户最新金币,前端每次打开弹窗都可同步右上角余额。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
@@ -107,8 +121,9 @@ class BaccaratController extends Controller
}
$currency = $this->currency;
$lossCoverService = $this->lossCoverService;
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
@@ -131,15 +146,22 @@ class BaccaratController extends Controller
},
);
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
// 写入下注记录
BaccaratBet::create([
$bet = BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $data['bet_type'],
'amount' => $data['amount'],
'status' => 'pending',
]);
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
$lossCoverService->registerBet($bet);
// 更新局次汇总统计
$field = 'total_bet_'.$data['bet_type'];
$countField = 'bet_count_'.$data['bet_type'];
@@ -160,7 +182,7 @@ class BaccaratController extends Controller
$roomId = $round->room_id ?? 1;
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
$content = "🌟 🎲 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$content = "🎲 <b> 【百家乐】【{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
@@ -0,0 +1,141 @@
<?php
/**
* 文件功能:百家乐买单活动前台控制器
*
* 提供活动摘要、历史记录以及用户领取补偿的接口,
* 供娱乐大厅弹窗与聊天室系统消息按钮调用。
*/
namespace App\Http\Controllers;
use App\Models\BaccaratLossCoverEvent;
use App\Services\BaccaratLossCoverService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BaccaratLossCoverController extends Controller
{
/**
* 注入百家乐买单活动服务。
*/
public function __construct(
private readonly BaccaratLossCoverService $lossCoverService,
) {}
/**
* 返回当前最值得关注的一次活动摘要。
*/
public function summary(Request $request): JsonResponse
{
$event = BaccaratLossCoverEvent::query()
->with(['creator:id,username'])
->whereIn('status', $this->summaryStatuses($request))
->orderByRaw($this->summaryStatusOrder($request))
->orderByDesc('starts_at')
->first();
$record = null;
if ($event) {
$record = $event->records()->where('user_id', $request->user()->id)->first();
}
return response()->json([
'event' => $event ? $this->transformEvent($event, $record) : null,
]);
}
/**
* 返回最近的活动列表以及当前用户的领取记录。
*/
public function history(Request $request): JsonResponse
{
$events = BaccaratLossCoverEvent::query()
->with(['creator:id,username', 'records' => function ($query) use ($request) {
$query->where('user_id', $request->user()->id);
}])
->latest('starts_at')
->limit(20)
->get()
->map(function (BaccaratLossCoverEvent $event) {
$record = $event->records->first();
return $this->transformEvent($event, $record);
});
return response()->json([
'events' => $events,
]);
}
/**
* 领取指定活动的补偿金币。
*/
public function claim(Request $request, BaccaratLossCoverEvent $event): JsonResponse
{
$result = $this->lossCoverService->claim($event, $request->user());
return response()->json($result);
}
/**
* 将活动与个人记录整理为前端更容易消费的结构。
*/
private function transformEvent(BaccaratLossCoverEvent $event, mixed $record): array
{
return [
'id' => $event->id,
'title' => $event->title,
'description' => $event->description,
'status' => $event->status,
'status_label' => $event->statusLabel(),
'starts_at' => $event->starts_at?->toIso8601String(),
'ends_at' => $event->ends_at?->toIso8601String(),
'claim_deadline_at' => $event->claim_deadline_at?->toIso8601String(),
'participant_count' => $event->participant_count,
'compensable_user_count' => $event->compensable_user_count,
'total_loss_amount' => $event->total_loss_amount,
'total_claimed_amount' => $event->total_claimed_amount,
'creator_username' => $event->creator?->username ?? '管理员',
'my_record' => $record ? [
'total_bet_amount' => $record->total_bet_amount,
'total_win_payout' => $record->total_win_payout,
'total_loss_amount' => $record->total_loss_amount,
'compensation_amount' => $record->compensation_amount,
'claim_status' => $record->claim_status,
'claim_status_label' => $record->claimStatusLabel(),
'claimed_amount' => $record->claimed_amount,
'claimed_at' => $record->claimed_at?->toIso8601String(),
] : null,
];
}
/**
* 按调用场景返回活动摘要允许出现的状态集合。
*
* “当前活动”页签只展示未开始、进行中或结算中的活动,
* 避免把已结束但仍可领取的历史活动误显示在当前页签里。
*
* @return list<string>
*/
private function summaryStatuses(Request $request): array
{
if ($request->string('scene')->toString() === 'overview') {
return ['active', 'settlement_pending', 'scheduled'];
}
return ['active', 'settlement_pending', 'claimable', 'scheduled'];
}
/**
* 按调用场景生成活动状态排序规则。
*/
private function summaryStatusOrder(Request $request): string
{
if ($request->string('scene')->toString() === 'overview') {
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'scheduled' THEN 2 ELSE 3 END";
}
return "CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'claimable' THEN 2 WHEN 'scheduled' THEN 3 ELSE 4 END";
}
}
@@ -13,6 +13,9 @@
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Events\MessageSent;
use App\Events\UserBrowserRefreshRequested;
use App\Jobs\SaveMessageJob;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
@@ -92,7 +95,25 @@ class ChatAppointmentController extends Controller
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
// 给被任命用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $target->username,
content: "✨ <b>{$operator->username}</b> 已任命你为 {$position->icon} {$position->name}",
title: '✨ 职务任命通知',
toastMessage: "<b>{$operator->username}</b> 已任命你为 <b>{$position->icon} {$position->name}</b>。",
color: '#a855f7',
icon: '✨',
);
}
// 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已发生变更,页面权限正在同步更新。',
));
}
return response()->json([
@@ -136,7 +157,25 @@ class ChatAppointmentController extends Controller
operatorName: $operator->username,
type: 'revoke',
));
// 给被撤职用户补一条私聊提示,并复用右下角 toast 通知。
$this->pushTargetToastMessage(
roomId: (int) $roomId,
targetUsername: $target->username,
content: "📋 <b>{$operator->username}</b> 已撤销你的 {$posIcon} {$posName} 职务。",
title: '📋 职务变动通知',
toastMessage: "<b>{$operator->username}</b> 已撤销你的 <b>{$posIcon} {$posName}</b> 职务。",
color: '#6b7280',
icon: '📋',
);
}
// 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。
broadcast(new UserBrowserRefreshRequested(
targetUserId: (int) $target->id,
operator: $operator->username,
reason: '你的职务已被撤销,页面权限正在同步更新。',
));
}
return response()->json([
@@ -144,4 +183,41 @@ class ChatAppointmentController extends Controller
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
*/
private function pushTargetToastMessage(
int $roomId,
string $targetUsername,
string $content,
string $title,
string $toastMessage,
string $color,
string $icon,
): void {
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => $color,
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 复用现有聊天 toast 机制,在右下角弹出职务变动提示。
'toast_notification' => [
'title' => $title,
'message' => $toastMessage,
'icon' => $icon,
'color' => $color,
'duration' => 10000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+16 -3
View File
@@ -18,6 +18,7 @@ use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Sysparam;
use App\Services\AiChatService;
use App\Services\AiFinanceService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -25,6 +26,9 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 处理用户与 AI小班长的对话、金币福利与上下文清理。
*/
class ChatBotController extends Controller
{
/**
@@ -34,6 +38,7 @@ class ChatBotController extends Controller
private readonly AiChatService $aiChat,
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly AiFinanceService $aiFinance,
) {}
/**
@@ -51,8 +56,12 @@ class ChatBotController extends Controller
$request->validate([
'message' => 'required|string|max:2000',
'room_id' => 'required|integer',
'is_secret' => 'nullable|boolean',
]);
// 私聊模式:AI 回复也走悄悄话,仅发言人和 AI 可见
$isSecret = (bool) $request->input('is_secret', false);
// 检查全局开关
$enabled = Sysparam::getValue('chatbot_enabled', '0');
if ($enabled !== '1') {
@@ -90,7 +99,8 @@ class ChatBotController extends Controller
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
if ($aiUser && $aiUser->jjb >= $goldAmount) {
// 常规发福利只检查 AI 当前手上金币,不再为了维持 100 万而自动从银行提钱。
if ($aiUser && $this->aiFinance->prepareSpend($aiUser, $goldAmount)) {
Redis::incr($redisKey);
Redis::expire($redisKey, 86400); // 缓存 24 小时
@@ -129,6 +139,9 @@ class ChatBotController extends Controller
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
// 福利发放完成后,若手上金币仍高于 100 万,则把超出的部分回存银行。
$this->aiFinance->bankExcessGold($aiUser);
} else {
// 如果余额不足
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
@@ -139,14 +152,14 @@ class ChatBotController extends Controller
}
}
// 广播 AI 回复消息
// 广播 AI 回复消息(私聊模式下仅发言人与 AI 可见)
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => $reply,
'is_secret' => false,
'is_secret' => $isSecret,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
+217 -82
View File
@@ -25,20 +25,31 @@ use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use App\Support\ChatContentSanitizer;
use App\Support\ChatDailyStatusCatalog;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
/**
* 聊天室核心控制器
* 负责进房、发言、退房、公告与聊天室内各种实时交互。
*/
class ChatController extends Controller
{
/**
@@ -46,12 +57,14 @@ class ChatController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
@@ -65,6 +78,8 @@ class ChatController extends Controller
$room = Room::findOrFail($id);
$user = Auth::user();
$this->ensureUserCanEnterRoom($room, $user);
// 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num');
@@ -94,21 +109,7 @@ class ChatController extends Controller
}
// 2. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'user_id' => $user->id,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
];
$userData = $this->chatUserPresenceService->build($user);
$this->chatState->userJoin($id, $user->username, $userData);
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
@@ -175,17 +176,17 @@ class ChatController extends Controller
];
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
}
if (! empty($vipPresencePayload)) {
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
$initialPresenceTheme = $vipPresencePayload;
}
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
$initialWelcomeMessage = $generalWelcomeMsg;
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
$this->chatState->pushMessage($id, $generalWelcomeMsg);
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
broadcast(new MessageSent($id, $generalWelcomeMsg));
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
if (! empty($vipPresencePayload['presence_effect'])) {
@@ -270,7 +271,9 @@ class ChatController extends Controller
];
}
// 渲染主聊天框架视图
// 渲染主聊天框架视图前,先计算当前用户的聊天室顶部管理权限。
$roomPermissionMap = $this->positionPermissionService->permissionMapForUser($user);
return view('chat.frame', [
'room' => $room,
'user' => $user,
@@ -281,9 +284,25 @@ class ChatController extends Controller
'historyMessages' => $historyMessages,
'pendingProposal' => $pendingProposalData,
'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
]);
}
/**
* 校验当前用户是否允许进入指定房间。
*/
private function ensureUserCanEnterRoom(Room $room, User $user): void
{
if ($room->canUserEnter($user)) {
return;
}
abort(403, $room->entryDeniedMessage($user));
}
/**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
*
@@ -333,6 +352,11 @@ class ChatController extends Controller
{
$data = $request->validated();
$user = Auth::user();
$imagePayload = null;
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再发言。')) {
return $response;
}
// 0. 检查用户是否被禁言(Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}";
@@ -360,12 +384,18 @@ class ChatController extends Controller
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
// 1. 过滤净化消息体;若本次只发图片,则允许文本内容为空。
$rawContent = (string) ($data['content'] ?? '');
$pureContent = $rawContent !== '' ? $this->filter->filter($rawContent) : '';
if ($pureContent === '' && ! $request->hasFile('image')) {
return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422);
}
// 2. 若带图片,则生成原图与缩略图并按日期目录保存。
if ($request->hasFile('image')) {
$imagePayload = $this->storeChatImage($request->file('image'), $user->id);
}
// 2. 封装消息对象
$messageData = [
'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号
@@ -376,8 +406,12 @@ class ChatController extends Controller
'is_secret' => $data['is_secret'] ?? false,
'font_color' => $data['font_color'] ?? '',
'action' => $data['action'] ?? '',
'message_type' => $imagePayload ? 'image' : 'text',
'sent_at' => now()->toDateTimeString(),
];
if ($imagePayload !== null) {
$messageData = array_merge($messageData, $imagePayload);
}
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData);
@@ -405,6 +439,39 @@ class ChatController extends Controller
return response()->json(['status' => 'success']);
}
/**
* 保存聊天图片并生成原图、缩略图两份资源。
*
* @return array<string, string>
*/
private function storeChatImage(UploadedFile $image, int $userId): array
{
$manager = new ImageManager(new Driver);
$extension = strtolower($image->extension() ?: 'jpg');
$datePath = now()->format('Y-m-d');
$basename = 'chat_'.$userId.'_'.Str::uuid();
$originalPath = "chat-images/{$datePath}/{$basename}_original.{$extension}";
$thumbPath = "chat-images/{$datePath}/{$basename}_thumb.{$extension}";
// 原图仅做缩边处理,避免超大文件直接灌进磁盘。
$originalImage = $manager->read($image);
$originalImage->scaleDown(width: 1600, height: 1600);
Storage::disk('public')->put($originalPath, (string) $originalImage->encode());
// 缩略图限制在 220px 范围内,聊天室里只展示轻量小图。
$thumbImage = $manager->read($image);
$thumbImage->scaleDown(width: 220, height: 220);
Storage::disk('public')->put($thumbPath, (string) $thumbImage->encode());
return [
'image_path' => $originalPath,
'image_thumb_path' => $thumbPath,
'image_original_name' => $image->getClientOriginalName(),
'image_url' => Storage::url($originalPath),
'image_thumb_url' => Storage::url($thumbPath),
];
}
/**
* 自动挂机存点心跳与经验升级 (新增)
* 替代原版定时 iframe 刷新的 save.asp。
@@ -455,18 +522,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
]);
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
// 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) {
@@ -547,7 +603,7 @@ class ChatController extends Controller
if ($bonusJjb > 0) {
$bonusParts[] = "+金币{$bonusJjb}";
}
$eventContent = $autoEvent->renderText($user->username);
if (! empty($bonusParts)) {
$eventContent .= ''.$user->vipName().'追加:'.implode('', $bonusParts).'';
@@ -583,6 +639,7 @@ class ChatController extends Controller
} elseif ($user->user_level >= $superLevel) {
$title = '管理员';
}
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
return response()->json([
'status' => 'success',
@@ -593,6 +650,11 @@ class ChatController extends Controller
'jjb_gain' => $actualJjbGain,
'user_level' => $user->user_level,
'title' => $title,
'identity_summary' => $identitySummary['inline'],
'department_name' => $identitySummary['department_name'],
'position_name' => $identitySummary['position_name'],
'vip_name' => $identitySummary['vip_name'],
'vip_icon' => $identitySummary['vip_icon'],
'leveled_up' => $leveledUp,
'is_max_level' => $user->user_level >= $superLevel,
'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null,
@@ -600,6 +662,24 @@ class ChatController extends Controller
]);
}
/**
* 处理登录失效后的离场清理。
*
* 该接口通过临时签名 URL 调用,即使会话已过期也能安全完成离场结算。
*/
public function expiredLeave(int $id, int $user): JsonResponse
{
$expiredUser = User::find($user);
if (! $expiredUser) {
return response()->json(['status' => 'error'], 404);
}
$this->dispatchImmediateLeave($id, $expiredUser, '登录失效离开了房间');
return response()->json(['status' => 'success']);
}
/**
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
*
@@ -642,13 +722,8 @@ class ChatController extends Controller
$isExplicit = strval($request->query('explicit')) === '1';
if ($isExplicit) {
// 人工显式点击“离开”,不再进行浏览器刷新的防抖,直接同步执行清算和播报。
// 这对本地没有开启 Queue Worker 的环境尤为重要,能保证大家立刻看到消息。
// 为了防止 ProcessUserLeave 中的时间对比失败,我们直接删掉 join_time 表示彻底离线。
\Illuminate\Support\Facades\Redis::del("room:{$id}:join_time:{$user->username}");
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, $leaveTime);
dispatch_sync($job);
// 人工显式点击“离开”时,立即同步执行清算和播报。
$this->dispatchImmediateLeave($id, $user, '主动离开了房间');
} else {
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime
@@ -659,6 +734,17 @@ class ChatController extends Controller
return response()->json(['status' => 'success']);
}
/**
* 立即执行离场清理,并跳过刷新防抖逻辑。
*/
private function dispatchImmediateLeave(int $id, User $user, string $outInfo): void
{
Redis::del("room:{$id}:join_time:{$user->username}");
$job = new \App\Jobs\ProcessUserLeave($id, clone $user, microtime(true), $outInfo);
dispatch_sync($job);
}
/**
* 获取可用头像列表(返回 JSON
* 扫描 /public/images/headface/ 目录,返回所有可用头像文件名
@@ -711,18 +797,10 @@ class ChatController extends Controller
// 将新头像同步到 Redis 在线用户列表中(所有房间)
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 头像更新后,统一通过在线载荷服务刷新所有扩展字段,避免状态或职务字段丢失。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
@@ -777,18 +855,10 @@ class ChatController extends Controller
}
// 同步 Redis 状态
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface, // Use accessor
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 自定义头像上传成功后,同步覆盖在线名单中的全部展示字段。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
@@ -804,7 +874,8 @@ class ChatController extends Controller
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
* 需要当前在职职务拥有 room.announcement 权限,
* id=1 站长始终允许操作。
*
* @param int $id 房间ID
*/
@@ -813,19 +884,23 @@ class ChatController extends Controller
$user = Auth::user();
$room = Room::findOrFail($id);
// 权限检查:房间主人 或 等级 >= level_announcement
$requiredLevel = (int) Sysparam::getValue('level_announcement', '10');
if ($user->username !== $room->master && $user->user_level < $requiredLevel) {
// 改为统一走职务权限判断,不再给房主单独保留公告特权。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_ANNOUNCEMENT)) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
}
if (! $this->chatState->isUserInRoom($id, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入该房间后再修改公告'], 403);
}
$request->validate([
'announcement' => 'required|string|max:500',
]);
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段
$room->announcement = trim($request->input('announcement'))
.' ——'.$user->username.' '.now()->format('m-d H:i');
$announcementText = trim((string) $request->input('announcement'));
// 将发送者和发送时间追加到公告文本末尾,持久化存储,无需额外字段。
$room->announcement = $announcementText.' ——'.$user->username.' '.now()->format('m-d H:i');
$room->save();
// 广播公告更新到所有在线用户
@@ -834,7 +909,7 @@ class ChatController extends Controller
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}",
'content' => '📢 '.ChatContentSanitizer::htmlText($user->username).' 更新了房间公告:'.ChatContentSanitizer::htmlText($room->announcement),
'is_secret' => false,
'font_color' => '#cc0000',
'action' => '',
@@ -876,6 +951,10 @@ class ChatController extends Controller
$giftId = $request->integer('gift_id');
$count = $request->integer('count', 1);
if ($response = $this->ensureUserCanActInRoom((int) $roomId, $user, '请先进入当前房间后再送礼物。')) {
return $response;
}
// 不能给自己送花
if ($toUsername === $user->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']);
@@ -893,6 +972,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']);
}
if ($response = $this->ensureTargetOnlineInRoom((int) $roomId, (string) $toUsername)) {
return $response;
}
$totalCost = $gift->cost * $count;
$totalCharm = $gift->charm * $count;
@@ -916,12 +999,15 @@ class ChatController extends Controller
// 广播送花消息(含图片标记,前端识别后渲染图片)
$countText = $count > 1 ? " {$count}" : '';
$safeSender = ChatContentSanitizer::htmlText($user->username);
$safeReceiver = ChatContentSanitizer::htmlText($toUsername);
$safeGiftName = ChatContentSanitizer::htmlText($gift->name);
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji}{$user->username}】 向 【{$toUsername}】 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji}{$safeSender}】 向 【{$safeReceiver}】 送出了{$countText}{$safeGiftName}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -1170,7 +1256,7 @@ class ChatController extends Controller
* 用户间赠送金币(任何登录用户均可调用)
*
* 从自己的余额中扣除指定金额,转入对方账户,
* 在房间内通过「系统传音」广播一条赠送提示
* 以私聊消息的方式仅通知赠送双方
*/
public function giftGold(Request $request): JsonResponse
{
@@ -1190,6 +1276,10 @@ class ChatController extends Controller
$roomId = $request->integer('room_id');
$amount = $request->integer('amount');
if ($response = $this->ensureUserCanActInRoom($roomId, $sender, '请先进入当前房间后再赠送金币。')) {
return $response;
}
// 不能给自己转账
if ($toName === $sender->username) {
return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']);
@@ -1201,6 +1291,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '用户不存在']);
}
if ($response = $this->ensureTargetOnlineInRoom($roomId, (string) $toName)) {
return $response;
}
// 余额校验
if (($sender->jjb ?? 0) < $amount) {
return response()->json([
@@ -1213,20 +1307,28 @@ class ChatController extends Controller
$sender->decrement('jjb', $amount);
$receiver->increment('jjb', $amount);
// 广播一条消息:发送者/接收者路由到 say2(下方包厢),其他人路由到 say1(公屏)
// 原理:前端 isRelatedToMe = isMe || to_user===me → say2;否则 → say1
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
$giftMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $sender->username,
'to_user' => $toName,
'content' => "悄悄赠送{$amount} 金币!💝",
'is_secret' => false,
'content' => "赠送{$amount} 金币!💝",
'is_secret' => true,
'font_color' => '#b45309',
'action' => '',
'sent_at' => now()->toDateTimeString(),
// 接收方收到消息时,在右下角弹到账提示卡片。
'toast_notification' => [
'title' => '💰 赠金币到账',
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
'icon' => '💰',
'color' => '#f59e0b',
'duration' => 8000,
],
];
// 推入 Redis + WebSocket + 异步落库,保持与普通私聊一致的展示与历史记录行为。
$this->chatState->pushMessage($roomId, $giftMsg);
broadcast(new MessageSent($roomId, $giftMsg));
SaveMessageJob::dispatch($giftMsg);
@@ -1240,4 +1342,37 @@ class ChatController extends Controller
],
]);
}
/**
* 校验用户是否能在指定房间执行聊天动作。
*/
private function ensureUserCanActInRoom(int $roomId, ?User $user, string $message): ?JsonResponse
{
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$room = Room::query()->find($roomId);
if (! $room) {
return response()->json(['status' => 'error', 'message' => '房间不存在'], 404);
}
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json(['status' => 'error', 'message' => $message], 403);
}
return null;
}
/**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/
private function ensureTargetOnlineInRoom(int $roomId, string $targetUsername): ?JsonResponse
{
if (! $this->chatState->isUserInRoom($roomId, $targetUsername)) {
return response()->json(['status' => 'error', 'message' => '目标用户不在当前房间,无法执行该操作'], 403);
}
return null;
}
}
@@ -0,0 +1,293 @@
<?php
/**
* 文件功能:前台每日签到控制器
*
* 提供签到状态查询、领取奖励、刷新在线名单载荷和聊天室签到通知。
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ClaimDailySignInRequest;
use App\Http\Requests\DailySignInCalendarRequest;
use App\Http\Requests\MakeupDailySignInRequest;
use App\Models\DailySignIn;
use App\Models\User;
use App\Models\UserIdentityBadge;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\SignInService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:处理前台用户每日签到状态与领取奖励流程。
*/
class DailySignInController extends Controller
{
/**
* 构造每日签到控制器依赖。
*/
public function __construct(
private readonly SignInService $signInService,
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $presenceService,
) {}
/**
* 方法功能:查询当前用户今日签到状态和奖励预览。
*/
public function status(): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$status = $this->signInService->status($user);
return response()->json([
'status' => 'success',
'data' => $this->formatStatusPayload($user, $status),
]);
}
/**
* 方法功能:查询指定月份的签到日历与补签卡状态。
*/
public function calendar(DailySignInCalendarRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
return response()->json([
'status' => 'success',
'data' => $this->signInService->calendar($user, $request->validated('month')),
]);
}
/**
* 方法功能:领取今日签到奖励并同步聊天室在线名单。
*/
public function claim(ClaimDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->claim($user, $roomId);
if (! $dailySignIn->wasRecentlyCreated) {
return response()->json([
'status' => 'error',
'message' => '今日已签到,请明天再来。',
'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn),
], 422);
}
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) {
$this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId);
}
return response()->json([
'status' => 'success',
'message' => $this->buildSuccessMessage($dailySignIn),
'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload),
]);
}
/**
* 方法功能:使用补签卡补签历史漏签日期。
*/
public function makeup(MakeupDailySignInRequest $request): JsonResponse
{
/** @var User $user */
$user = Auth::user();
$roomId = $request->validated('room_id');
$roomId = $roomId === null ? null : (int) $roomId;
$dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId);
$refreshedSignIn = $dailySignIn->fresh();
$latestSignIn = DailySignIn::query()
->where('user_id', $user->id)
->latest('sign_in_date')
->first();
$currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0);
$freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']);
$presencePayload = $this->presenceService->build($freshUser);
$this->refreshOnlinePresence($freshUser, $presencePayload);
return response()->json([
'status' => 'success',
'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。',
'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays),
]);
}
/**
* 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。
*
* @param array<string, mixed> $presencePayload
*/
private function refreshOnlinePresence(User $user, array $presencePayload): void
{
foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) {
// 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
}
/**
* 方法功能:向当前聊天室广播签到成功通知。
*/
private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId): void
{
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '签到播报',
'to_user' => '大家',
'content' => $this->buildNoticeContent($user, $dailySignIn),
'is_secret' => false,
'font_color' => '#0f766e',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
}
/**
* 方法功能:生成聊天室内的签到播报内容。
*/
private function buildNoticeContent(User $user, DailySignIn $dailySignIn): string
{
$rewardText = $this->buildRewardText($dailySignIn);
$identityText = $dailySignIn->identity_badge_name
? ',获得身份 '.e($dailySignIn->identity_badge_name)
: '';
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton;
}
/**
* 方法功能:生成本机签到成功提示文案。
*/
private function buildSuccessMessage(DailySignIn $dailySignIn): string
{
return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。';
}
/**
* 方法功能:按实际签到奖励快照生成奖励描述。
*/
private function buildRewardText(DailySignIn $dailySignIn): string
{
$items = [];
if ($dailySignIn->gold_reward > 0) {
$items[] = $dailySignIn->gold_reward.' 金币';
}
if ($dailySignIn->exp_reward > 0) {
$items[] = $dailySignIn->exp_reward.' 经验';
}
if ($dailySignIn->charm_reward > 0) {
$items[] = $dailySignIn->charm_reward.' 魅力';
}
return $items === [] ? '签到记录' : implode(' + ', $items);
}
/**
* 方法功能:格式化状态查询响应载荷。
*
* @param array<string, mixed> $status
* @return array<string, mixed>
*/
private function formatStatusPayload(User $user, array $status): array
{
return [
'signed_today' => $status['signed_today'],
'can_claim' => $status['can_claim'],
'current_streak_days' => $status['current_streak_days'],
'claimable_streak_days' => $status['claimable_streak_days'],
'preview_rule' => $status['matched_rule']?->only([
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
]),
'identity' => $this->formatIdentityPayload($status['current_identity']),
'user' => [
'jjb' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到领取响应载荷。
*
* @param array<string, mixed>|null $presencePayload
* @return array<string, mixed>
*/
private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array
{
$identity = $user->currentSignInIdentity();
return [
'sign_in' => [
'id' => $dailySignIn->id,
'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(),
'is_makeup' => (bool) $dailySignIn->is_makeup,
'streak_days' => (int) $dailySignIn->streak_days,
'gold_reward' => (int) $dailySignIn->gold_reward,
'exp_reward' => (int) $dailySignIn->exp_reward,
'charm_reward' => (int) $dailySignIn->charm_reward,
],
'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days,
'identity' => $this->formatIdentityPayload($identity),
'presence' => $presencePayload ?? $this->presenceService->build($user),
'user' => [
'jjb' => (int) $user->jjb,
'gold' => (int) $user->jjb,
],
];
}
/**
* 方法功能:格式化签到身份数据供前端展示。
*
* @return array<string, mixed>|null
*/
private function formatIdentityPayload(?UserIdentityBadge $identity): ?array
{
if ($identity === null) {
return null;
}
return [
'key' => $identity->badge_code,
'label' => $identity->badge_name,
'name' => $identity->badge_name,
'icon' => $identity->badge_icon ?? '✅',
'color' => $identity->badge_color ?? '#0f766e',
'expires_at' => $identity->expires_at?->toIso8601String(),
'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0),
];
}
}
+62 -38
View File
@@ -15,14 +15,20 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理节日福利批次的前台领取与状态查询。
*/
class HolidayController extends Controller
{
/**
* 注入用户金币服务。
*/
public function __construct(
private readonly UserCurrencyService $currency,
) {}
@@ -30,56 +36,72 @@ class HolidayController extends Controller
/**
* 用户领取节日福利红包。
*
* holiday_claims 中查找当前用户的待领取记录,
* 入账金币并更新活动统计数据。
* holiday_claims 中查找当前用户在指定批次下的待领取记录,
* 入账金币并更新批次统计数据。
*/
public function claim(Request $request, HolidayEvent $event): JsonResponse
public function claim(Request $request, HolidayEventRun $run): JsonResponse
{
$user = $request->user();
// 活动是否在领取有效期内
if (! $event->isClaimable()) {
// 批次是否在领取有效期内
if (! $run->isClaimable()) {
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
}
// 查找该用户的领取记录(批量插入时已生成)
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('user_id', $user->id)
->lockForUpdate()
->first();
return DB::transaction(function () use ($run, $user): JsonResponse {
/** @var HolidayEventRun|null $lockedRun */
$lockedRun = HolidayEventRun::query()
->whereKey($run->id)
->lockForUpdate()
->first();
if (! $claim) {
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
}
if (! $lockedRun || ! $lockedRun->isClaimable()) {
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
}
// 防止重复领取(claimed_at 为 null 表示未领取)
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
// 金币入账
/** @var HolidayClaim|null $claim */
$claim = HolidayClaim::query()
->where('run_id', $lockedRun->id)
->where('user_id', $user->id)
->lockForUpdate()
->first();
if (! $claim) {
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
}
// claimed_at 不为空代表本轮已领过,直接返回幂等提示。
if ($claim->claimed_at !== null) {
return response()->json([
'ok' => false,
'message' => '您已领取过本轮福利。',
'amount' => $claim->amount,
]);
}
// 金币入账。
$this->currency->change(
$user,
'gold',
$claim->amount,
CurrencySource::HOLIDAY_BONUS,
"节日福利:{$event->name}",
"节日福利:{$lockedRun->event_name}",
);
// 更新活动统计(只在首次领取时)
HolidayEvent::query()
->where('id', $event->id)
->increment('claimed_amount', $claim->amount);
// 领取成功后只更新 claimed_at,不再删除记录,便于幂等和历史追踪。
$claim->update(['claimed_at' => now()]);
// 删除领取记录(以此标记"已领取",防止重复调用)
$claim->delete();
// 批次领取统计按成功领取次数累计。
$lockedRun->increment('claimed_count');
$lockedRun->increment('claimed_amount', $claim->amount);
// 检查是否已全部领完
if ($event->max_claimants > 0) {
$remaining = HolidayClaim::where('event_id', $event->id)->count();
if ($remaining === 0) {
$event->update(['status' => 'completed']);
}
$remainingPendingClaims = HolidayClaim::query()
->where('run_id', $lockedRun->id)
->whereNull('claimed_at')
->count();
if ($remainingPendingClaims === 0) {
$lockedRun->update(['status' => 'completed']);
}
return response()->json([
@@ -91,21 +113,23 @@ class HolidayController extends Controller
}
/**
* 查询当前用户在指定活动中的待领取状态。
* 查询当前用户在指定批次中的待领取状态。
*/
public function status(Request $request, HolidayEvent $event): JsonResponse
public function status(Request $request, HolidayEventRun $run): JsonResponse
{
$user = $request->user();
$claim = HolidayClaim::query()
->where('event_id', $event->id)
->where('run_id', $run->id)
->where('user_id', $user->id)
->first();
return response()->json([
'claimable' => $claim !== null && $event->isClaimable(),
'claimable' => $claim !== null && $claim->claimed_at === null && $run->isClaimable(),
'claimed' => $claim?->claimed_at !== null,
'amount' => $claim?->amount ?? 0,
'expires_at' => $event->expires_at?->toIso8601String(),
'status' => $run->status,
'expires_at' => $run->expires_at?->toIso8601String(),
]);
}
}
+93 -21
View File
@@ -39,13 +39,21 @@ class HorseRaceController extends Controller
*/
public function currentRace(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
return response()->json([
'race' => null,
// 即使当前无赛马场次,也返回最新金币余额,供前端打开弹窗时刷新显示。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
$user = $request->user();
$myBet = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
@@ -66,20 +74,33 @@ class HorseRaceController extends Controller
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
// 计算实时赔率
$horses = $race->horses ?? [];
$horsesWithBets = array_map(function ($horse) use ($horsePools, $oddsMap) {
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
$odds = $horsePool > 0 ? ($oddsMap[$horse['id']] ?? null) : null;
$horses = $this->normalizeRaceHorses($race->horses);
$horsesWithBets = array_map(function (array $horse) use ($horsePools, $oddsMap) {
$horseId = (int) $horse['id'];
$horsePool = (int) ($horsePools[$horseId] ?? 0);
$odds = $horsePool > 0 ? ($oddsMap[$horseId] ?? null) : null;
return [
'id' => $horse['id'],
'name' => $horse['name'],
'emoji' => $horse['emoji'],
'id' => $horseId,
'name' => (string) $horse['name'],
'emoji' => (string) $horse['emoji'],
'pool' => $horsePool,
'odds' => $odds,
];
}, $horses);
// 押注阶段实时总池 = 当前记录的基础池(通常为种子池)+ 实时下注总额;
// 跑马/结算阶段 total_pool 已写回最终值,不能再重复叠加下注额。
$basePool = $race->status === 'betting'
? max((int) $race->total_pool, $seedPool)
: (int) $race->total_pool;
$displayTotalPool = $race->status === 'betting'
? $basePool + array_sum(array_values($horsePools))
: $basePool;
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
return response()->json([
'race' => [
'id' => $race->id,
@@ -89,12 +110,16 @@ class HorseRaceController extends Controller
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
: 0,
'horses' => $horsesWithBets,
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
'total_pool' => $displayTotalPool,
'min_bet' => $minBet,
'max_bet' => $maxBet,
'my_bet' => $myBet ? [
'horse_id' => $myBet->horse_id,
'amount' => $myBet->amount,
] : null,
],
// 返回当前用户最新金币,确保弹窗右上角余额每次打开都以服务端最新值为准。
'jjb' => (int) ($user->jjb ?? 0),
]);
}
@@ -130,7 +155,7 @@ class HorseRaceController extends Controller
}
// 验证马匹 ID 是否有效
$horses = $race->horses ?? [];
$horses = $this->normalizeRaceHorses($race->horses);
$validIds = array_column($horses, 'id');
if (! in_array($data['horse_id'], $validIds, true)) {
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
@@ -158,12 +183,7 @@ class HorseRaceController extends Controller
// 找出马匹名称
$horseName = '';
foreach ($horses as $horse) {
if ((int) $horse['id'] === (int) $data['horse_id']) {
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$horseName = $this->resolveHorseDisplayName($horses, (int) $data['horse_id']);
// 扣除金币
$currency->change(
@@ -185,7 +205,7 @@ class HorseRaceController extends Controller
$chatState = app(ChatStateService::class);
$formattedAmount = number_format($data['amount']);
$content = "🌟 🐎 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$content = "🐎 <b>【赛马】【{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
@@ -224,9 +244,9 @@ class HorseRaceController extends Controller
// 转换获胜马匹名称
$history = $races->map(function ($race) {
$winnerName = '未知';
foreach (($race->horses ?? []) as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
foreach ($this->normalizeRaceHorses($race->horses) as $horse) {
if ((int) $horse['id'] === (int) $race->winner_horse_id) {
$winnerName = (string) $horse['emoji'].(string) $horse['name'];
break;
}
}
@@ -243,4 +263,56 @@ class HorseRaceController extends Controller
return response()->json(['history' => $history]);
}
/**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
*
* @return array<int, array{id:int,name:string,emoji:string}>
*/
private function normalizeRaceHorses(mixed $horses): array
{
if (! is_array($horses)) {
return [];
}
$normalizedHorses = [];
foreach ($horses as $index => $horse) {
if (! is_array($horse)) {
continue;
}
$horseId = isset($horse['id']) && is_numeric($horse['id'])
? (int) $horse['id']
: $index + 1;
$horseName = trim((string) ($horse['name'] ?? ''));
if ($horseName === '') {
$horseName = '未知马匹';
}
$normalizedHorses[] = [
'id' => $horseId,
'name' => $horseName,
'emoji' => (string) ($horse['emoji'] ?? '🐎'),
];
}
return $normalizedHorses;
}
/**
* 根据马匹编号返回展示名称,供系统播报与下注回执共用。
*
* @param array<int, array{id:int,name:string,emoji:string}> $horses
*/
private function resolveHorseDisplayName(array $horses, int $horseId): string
{
foreach ($horses as $horse) {
if ((int) ($horse['id'] ?? 0) === $horseId) {
return (string) ($horse['emoji'] ?? '🐎').(string) ($horse['name'] ?? '未知马匹');
}
}
return '🐎未知马匹';
}
}
+25 -2
View File
@@ -46,6 +46,29 @@ class MarriageController extends Controller
]);
}
/**
* 获取全站已婚列表(按亲密度或结婚时间排序)。
*/
public function list(Request $request): JsonResponse
{
$marriages = Marriage::query()
->where('status', 'married')
->with(['user:id,username,usersf,sex', 'partner:id,username,usersf,sex', 'ringItem:id,name,icon'])
->orderByDesc('intimacy')
->orderByDesc('married_at')
->paginate(20);
return response()->json([
'status' => 'success',
'data' => $marriages->items(),
'pagination' => [
'current_page' => $marriages->currentPage(),
'last_page' => $marriages->lastPage(),
'total' => $marriages->total(),
],
]);
}
/**
* 获取当前用户的婚姻状态(名片/用户列表用)。
*/
@@ -58,7 +81,7 @@ class MarriageController extends Controller
return response()->json(['married' => false]);
}
$marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']);
$marriage->load(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,slug,icon']);
return response()->json([
'married' => $marriage->status === 'married',
@@ -95,7 +118,7 @@ class MarriageController extends Controller
->where(function ($q) use ($target) {
$q->where('user_id', $target->id)->orWhere('partner_id', $target->id);
})
->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon'])
->with(['user:id,username,usersf', 'partner:id,username,usersf', 'ringItem:id,name,icon'])
->first();
if (! $marriage) {
@@ -147,11 +147,11 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【开箱播报恭喜 【{$username}】 抢到了神秘{$typeName}"
$content = "{$emoji}神秘箱子】开箱播报恭喜 【{$username}】 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【黑化陷阱haha!【{$username}】 中了神秘黑化箱的陷阱!"
$content = "☠️【神秘箱子】《黑化陷阱haha!【{$username}】 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$color = '#f87171';
}
@@ -0,0 +1,153 @@
<?php
/**
* 文件功能:前台邮箱找回密码控制器
*
* 提供独立的找回密码页、发送邮箱重置链接、展示重置页以及提交新密码功能。
*/
namespace App\Http\Controllers;
use App\Http\Requests\ResetPasswordRequest;
use App\Http\Requests\SendPasswordResetLinkRequest;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
/**
* 类功能:处理首页邮箱找回密码的完整流程。
*/
class PasswordResetController extends Controller
{
/**
* 展示独立的邮箱找回密码页面。
*/
public function create(): View
{
return view('password-forgot', [
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
'smtpEnabled' => $this->isPasswordResetMailEnabled(),
]);
}
/**
* 发送邮箱找回密码链接。
*/
public function storeLink(SendPasswordResetLinkRequest $request): JsonResponse
{
if (! $this->isPasswordResetMailEnabled()) {
return response()->json([
'status' => 'error',
'message' => '系统暂未开启邮箱发信服务,当前无法通过邮箱找回密码。',
], 403);
}
$email = trim((string) $request->string('email'));
// 邮箱找回必须保证一邮一号,否则重置目标会产生歧义。
if (User::query()->where('email', $email)->count() > 1) {
return response()->json([
'status' => 'error',
'message' => '该邮箱绑定了多个账号,暂不支持自助找回,请联系管理员处理。',
], 422);
}
$status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) {
return response()->json([
'status' => 'success',
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。链接 60 分钟内有效,请注意查收。',
]);
}
if ($status === Password::RESET_THROTTLED) {
return response()->json([
'status' => 'error',
'message' => '发送过于频繁,请稍后再试。',
], 429);
}
if ($status === Password::INVALID_USER) {
return response()->json([
'status' => 'success',
'message' => '如果该邮箱已绑定账号,系统已发送重置邮件。请检查收件箱与垃圾邮件箱。',
]);
}
return response()->json([
'status' => 'error',
'message' => '找回密码邮件发送失败,请稍后重试。',
], 500);
}
/**
* 展示独立的重置密码页面。
*/
public function edit(Request $request, string $token): View
{
return view('password-reset', [
'systemName' => Sysparam::where('alias', 'sys_name')->value('body') ?? '和平聊吧',
'token' => $token,
'email' => (string) $request->query('email', ''),
]);
}
/**
* 提交新的登录密码并完成重置。
*/
public function update(ResetPasswordRequest $request): RedirectResponse
{
$credentials = $request->validated();
$status = Password::reset(
$credentials,
function (User $user, #[\SensitiveParameter] string $password): void {
// 重置成功后同步刷新 remember_token,避免旧设备继续沿用旧令牌。
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect()->route('home')->with('status', '密码已重置成功,请使用原昵称和新密码重新登录。');
}
return back()
->withInput($request->except('password', 'password_confirmation'))
->withErrors([
'email' => $this->resolveResetFailureMessage($status),
]);
}
/**
* 判断系统是否已开启邮箱发信服务。
*/
private function isPasswordResetMailEnabled(): bool
{
return Sysparam::where('alias', 'smtp_enabled')->value('body') === '1';
}
/**
* Laravel 密码重置状态码转换为中文错误提示。
*/
private function resolveResetFailureMessage(string $status): string
{
return match ($status) {
Password::INVALID_TOKEN => '重置链接无效或已过期,请重新申请邮箱找回。',
Password::INVALID_USER => '该邮箱未绑定可重置的账号,请确认后再试。',
default => '密码重置失败,请重新获取重置链接后再试。',
};
}
}
+103 -25
View File
@@ -4,7 +4,8 @@
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - config():读取当前职务的默认礼包数量与份数
* - send() :拥有权限的职务用户按职务配置发出礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
@@ -23,22 +24,29 @@ use App\Events\RedPacketSent;
use App\Jobs\SaveMessageJob;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\PositionPermissionService;
use App\Services\UserCurrencyService;
use App\Support\PositionPermissionRegistry;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理聊天室礼包的发包、查状态与抢包流程
*
* 负责礼包主记录创建、Redis 拆包金额管理、领取入账以及实时广播。
*/
class RedPacketController extends Controller
{
/** 礼包固定总数量 */
private const TOTAL_AMOUNT = 8888;
/** 礼包默认总数量 */
private const DEFAULT_TOTAL_AMOUNT = 8888;
/** 礼包固定份数 */
private const TOTAL_COUNT = 10;
/** 礼包默认份数 */
private const DEFAULT_TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 300;
@@ -49,12 +57,37 @@ class RedPacketController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
private readonly PositionPermissionService $positionPermissionService,
) {}
/**
* superlevel 站长凭空发出礼包。
* 获取当前用户可发出礼包默认配置
*
* 不扣发包人自身货币,888 数量凭空发出分 10
* 聊天室发包弹窗打开时调用,确保页面展示与最终发包数量同源
*/
public function config(): JsonResponse
{
$user = Auth::user();
// 仅拥有礼包红包权限的在职职务可以读取发包配置。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
}
$redPacketConfig = $this->redPacketConfigForUser($user);
return response()->json([
'status' => 'success',
'amount' => $redPacketConfig['amount'],
'count' => $redPacketConfig['count'],
'expire_seconds' => self::EXPIRE_SECONDS,
]);
}
/**
* 拥有权限的职务用户凭空发出礼包。
*
* 不扣发包人自身货币,礼包总量和份数读取当前在职职务配置。
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
*
* @param Request $request 需包含 room_id typegold / exp
@@ -70,12 +103,15 @@ class RedPacketController extends Controller
$roomId = (int) $request->input('room_id');
$type = $request->input('type'); // 'gold' 或 'exp'
// 权限校验:仅 superlevel 可发礼包
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
// 改为按职务权限码控制礼包发放。
if (! $this->positionPermissionService->hasPermission($user, PositionPermissionRegistry::ROOM_RED_PACKET)) {
return response()->json(['status' => 'error', 'message' => '当前职务无权发礼包红包'], 403);
}
$redPacketConfig = $this->redPacketConfigForUser($user);
$totalAmount = $redPacketConfig['amount'];
$totalCount = $redPacketConfig['count'];
// 检查该用户在此房间是否有进行中的红包(防止刷包)
$activeExists = RedPacketEnvelope::query()
->where('sender_id', $user->id)
@@ -88,8 +124,8 @@ class RedPacketController extends Controller
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
}
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于 TOTAL_AMOUNT
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
// 随机拆分数量(二倍均值法,保证每份至少 1,总额精确等于职务配置总量
$amounts = $this->splitAmount($totalAmount, $totalCount);
// 货币展示文案
$typeLabel = $type === 'exp' ? '经验' : '金币';
@@ -99,15 +135,15 @@ class RedPacketController extends Controller
: 'linear-gradient(135deg,#dc2626,#ea580c)';
// 事务:创建红包记录 + Redis 写入分额
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts, $totalAmount, $totalCount): RedPacketEnvelope {
// 创建红包主记录(凭空发出,不扣发包人货币)
$envelope = RedPacketEnvelope::create([
'sender_id' => $user->id,
'sender_username' => $user->username,
'room_id' => $roomId,
'type' => $type,
'total_amount' => self::TOTAL_AMOUNT,
'total_count' => self::TOTAL_COUNT,
'total_amount' => $totalAmount,
'total_count' => $totalCount,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
@@ -132,8 +168,8 @@ class RedPacketController extends Controller
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
.$envelope->id
.',\''.$user->username.'\','
.self::TOTAL_AMOUNT.','
.self::TOTAL_COUNT.','
.$totalAmount.','
.$totalCount.','
.self::EXPIRE_SECONDS
.',\''.$type.'\''
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
@@ -145,7 +181,7 @@ class RedPacketController extends Controller
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>{$totalAmount}</b> {$typeLabel}的礼包!共 {$totalCount} 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
'action' => '',
@@ -160,15 +196,15 @@ class RedPacketController extends Controller
roomId: $roomId,
envelopeId: $envelope->id,
senderUsername: $user->username,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
totalAmount: $totalAmount,
totalCount: $totalCount,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
));
return response()->json([
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
'message' => "🧧 {$typeLabel}礼包已发出!{$totalAmount} {$typeLabel} · {$totalCount}",
]);
}
@@ -307,8 +343,19 @@ class RedPacketController extends Controller
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 广播领取事件(给自己的私有频道,前端弹 Toast)
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
// 重新读取红包统计,确保广播与响应使用的是最新剩余份数。
$envelope->refresh();
$remainingCount = $envelope->remainingCount();
// 广播领取事件:房间内所有在线用户实时刷新剩余份数,领取者本人同步收到到账通知。
broadcast(new RedPacketClaimed(
claimer: $user,
amount: $amount,
envelopeId: $envelope->id,
roomId: $envelope->room_id,
remainingCount: $remainingCount,
type: $envelopeType,
));
// 在聊天室发送领取播报(所有人可见)
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
@@ -335,6 +382,7 @@ class RedPacketController extends Controller
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'remaining_count' => $remainingCount,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
@@ -371,4 +419,34 @@ class RedPacketController extends Controller
return $amounts;
}
/**
* 按当前在职职务解析礼包红包配置。
*
* @return array{amount: int, count: int}
*/
private function redPacketConfigForUser(User $user): array
{
$position = $user->activePosition?->position;
$amount = (int) ($position?->red_packet_amount ?? self::DEFAULT_TOTAL_AMOUNT);
$count = (int) ($position?->red_packet_count ?? self::DEFAULT_TOTAL_COUNT);
if ($amount < 1) {
$amount = self::DEFAULT_TOTAL_AMOUNT;
}
if ($count < 1 || $count > 100) {
$count = self::DEFAULT_TOTAL_COUNT;
}
if ($amount < $count) {
$amount = self::DEFAULT_TOTAL_AMOUNT;
$count = self::DEFAULT_TOTAL_COUNT;
}
return [
'amount' => $amount,
'count' => $count,
];
}
}
+59 -21
View File
@@ -9,8 +9,12 @@ namespace App\Http\Controllers;
use App\Events\EffectBroadcast;
use App\Events\MessageSent;
use App\Models\Room;
use App\Models\ShopItem;
use App\Models\UserPurchase;
use App\Services\ChatStateService;
use App\Services\ShopService;
use App\Support\ChatContentSanitizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -22,6 +26,7 @@ class ShopController extends Controller
*/
public function __construct(
private readonly ShopService $shopService,
private readonly ChatStateService $chatState,
) {}
/**
@@ -47,8 +52,10 @@ class ShopController extends Controller
'charm_bonus' => $item->charm_bonus,
]);
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
// 统计背包中各戒指持有数量
$ringCounts = \App\Models\UserPurchase::query()
$ringCounts = UserPurchase::query()
->where('user_id', $user->id)
->where('status', 'active')
->whereHas('item', fn ($q) => $q->where('type', 'ring'))
@@ -64,6 +71,8 @@ class ShopController extends Controller
'has_rename_card' => $this->shopService->hasRenameCard($user),
'ring_counts' => $ringCounts,
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
'sign_repair_card_item' => $signRepairCard,
]);
}
@@ -74,36 +83,54 @@ class ShopController extends Controller
* - recipient 接收者用户名(传 "all" 或留空则全员可见)
* - message 公屏赠言(可选)
*
* @param Request $request item_id, recipient?, message?
* @param Request $request item_id, recipient?, message?, quantity?
*/
public function buy(Request $request): JsonResponse
{
$request->validate(['item_id' => 'required|integer|exists:shop_items,id']);
$request->validate([
'item_id' => 'required|integer|exists:shop_items,id',
'room_id' => 'required|integer|exists:rooms,id',
'recipient' => 'nullable|string|max:50',
'message' => 'nullable|string|max:120',
'quantity' => 'nullable|integer|min:1|max:99',
]);
$user = Auth::user();
$roomId = (int) $request->input('room_id');
$room = Room::query()->findOrFail($roomId);
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买聊天室道具。'], 403);
}
$item = ShopItem::find($request->item_id);
if (! $item->is_active) {
return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400);
}
$result = $this->shopService->buyItem(Auth::user(), $item);
$quantity = (int) $request->input('quantity', 1);
$result = $this->shopService->buyItem($user, $item, $quantity);
if (! $result['ok']) {
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
}
$response = ['status' => 'success', 'message' => $result['message']];
$response = [
'status' => 'success',
'message' => $result['message'],
'quantity' => $result['quantity'] ?? 1,
'total_price' => $result['total_price'] ?? $item->price,
];
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
if (isset($result['play_effect'])) {
$user = Auth::user();
$roomId = (int) $request->room_id;
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
$message = trim($request->input('message', ''));
$message = ChatContentSanitizer::htmlText($request->input('message', ''));
// recipient 为空或 "all" 表示全员
$targetUsername = ($recipient === '' || $recipient === 'all') ? null : $recipient;
$safeTargetUsername = $targetUsername ? ChatContentSanitizer::htmlText($targetUsername) : null;
// 广播特效事件(全员频道)
// 广播特效事件时保留原始用户名标识,前端需要用它和当前登录名做精确比较。
broadcast(new EffectBroadcast(
roomId: $roomId,
type: $result['play_effect'],
@@ -124,12 +151,20 @@ class ShopController extends Controller
'rain' => '🌧',
'lightning' => '⚡',
'snow' => '❄️',
'sakura' => '🌸',
'meteors' => '🌠',
'gold-rain' => '🪙',
'hearts' => '💖',
'confetti' => '🎊',
'fireflies' => '✨',
];
// 赠礼消息文案(改成"为XX触发了一场特效"
$icon = $icons[$result['play_effect']] ?? '✨';
$toStr = $targetUsername ? "{$targetUsername}" : '全体聊友';
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$toStr = $safeTargetUsername ? "{$safeTargetUsername}" : '全体聊友';
$remarkPart = $message ? "{$message}" : '';
$sysContent = "{$icon} {$user->username}{$toStr} 燃放了一场【{$item->name}】特效!{$remarkPart}";
$sysContent = "{$icon} {$safeBuyer}{$toStr} 燃放了一场【{$safeItemName}】特效!{$remarkPart}";
// 广播系统消息到公屏(字段名与前端 appendMessage() 保持一致)
$sysMsgEvent = new MessageSent(
@@ -150,9 +185,6 @@ class ShopController extends Controller
}
} else {
// ── 其他类型:广播购买通知到公屏 ────────────────────────────
$user = Auth::user();
$roomId = (int) $request->room_id;
if ($roomId > 0) {
// auto_fishing 有效期文案(提前算好,避免在 match 内写复杂三元表达式)
$fishDuration = '';
@@ -161,13 +193,19 @@ class ShopController extends Controller
$fishDuration = $mins >= 60 ? floor($mins / 60).'小时' : $mins.'分钟';
}
// 自动钓鱼卡购买通知要真正归属到“钓鱼播报”,这样前端屏蔽规则才能直接命中。
$broadcastFromUser = $item->type === 'auto_fishing' ? '钓鱼播报' : '系统传音';
// 根据商品类型生成不同通知文案
$safeBuyer = ChatContentSanitizer::htmlText($user->username);
$safeItemName = ChatContentSanitizer::htmlText($item->name);
$sysContent = match ($item->type) {
'duration' => "📅 【{$user->username}】购买了全屏特效周卡「{$item->name}」,登录时将自动触发!",
'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!",
'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!",
default => "🛒{$user->username}】购买了「{$item->name}」。",
'duration' => "📅 【{$safeBuyer}】购买了全屏特效周卡「{$safeItemName}」,登录时将自动触发!",
'one_time' => "🎫 【{$safeBuyer}】购买了「{$safeItemName}」道具!",
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
ShopItem::TYPE_SIGN_REPAIR => "🗓️{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
};
broadcast(new MessageSent(
@@ -175,7 +213,7 @@ class ShopController extends Controller
message: [
'id' => 0,
'room_id' => $roomId,
'from_user' => '系统传音',
'from_user' => $broadcastFromUser,
'to_user' => '大家',
'content' => $sysContent,
'font_color' => '#7c3aed',
@@ -188,7 +226,7 @@ class ShopController extends Controller
}
// 返回最新金币余额
$response['jjb'] = Auth::user()->fresh()->jjb;
$response['jjb'] = $user->fresh()->jjb;
return response()->json($response);
}
+104 -1
View File
@@ -19,19 +19,35 @@ namespace App\Http\Controllers;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\UpdateChatPreferencesRequest;
use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
*/
class UserController extends Controller
{
/**
* 构造用户控制器依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
* 查看其他用户资料片 (对应 USERinfo.ASP)
*/
@@ -68,6 +84,8 @@ class UserController extends Controller
'position_name' => $activePosition?->name ?? '',
'position_icon' => $activePosition?->icon ?? '',
'department_name' => $activePosition?->department?->name ?? '',
'department_rank' => (int) ($activePosition?->department?->rank ?? 0),
'position_rank' => (int) ($activePosition?->rank ?? 0),
];
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
@@ -108,7 +126,18 @@ class UserController extends Controller
->all();
$data['vip']['Name'] = $targetUser->vipName();
$data['vip']['Icon'] = $targetUser->vipIcon();
$signIdentity = $targetUser->currentSignInIdentity();
$latestSignIn = $targetUser->dailySignIns()->first();
$data['sign_in'] = [
'streak_days' => (int) ($latestSignIn?->streak_days ?? 0),
'identity' => $signIdentity ? [
'key' => $signIdentity->badge_code,
'label' => $signIdentity->badge_name,
'icon' => $signIdentity->badge_icon ?? '✅',
'color' => $signIdentity->badge_color ?? '#0f766e',
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
] : null,
];
// 拥有封禁IPlevel_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
@@ -203,6 +232,80 @@ class UserController extends Controller
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
}
/**
* 保存聊天室屏蔽与禁音偏好。
*/
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$preferences = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
'sound_muted' => (bool) $data['sound_muted'],
];
$user->update([
'chat_preferences' => $preferences,
]);
return response()->json([
'status' => 'success',
'message' => '聊天室偏好已保存。',
'data' => $preferences,
]);
}
/**
* 保存聊天室当日状态,并同步当前在线名单显示。
*/
public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$roomId = (int) $data['room_id'];
// 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。
if (! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json([
'status' => 'error',
'message' => '请先进入聊天室后再设置状态。',
], 422);
}
if ($data['action'] === 'clear') {
$user->update([
'daily_status_key' => null,
'daily_status_expires_at' => null,
]);
} else {
// 状态有效期固定维持到当天结束,次日自动失效。
$user->update([
'daily_status_key' => $data['status_key'],
'daily_status_expires_at' => now()->endOfDay(),
]);
}
$user->refresh();
$presencePayload = $this->chatUserPresenceService->build($user);
$roomIds = $this->chatState->getUserRooms($user->username);
foreach ($roomIds as $activeRoomId) {
// 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
return response()->json([
'status' => 'success',
'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。',
'data' => [
'status' => $this->chatUserPresenceService->currentDailyStatus($user),
],
]);
}
/**
* 修改密码 (对应 chpasswd.asp)
*/
+107 -18
View File
@@ -23,7 +23,7 @@ class VipCenterController extends Controller
*
* @param Request $request 当前请求对象
*/
public function index(Request $request): View
public function index(Request $request): View|\Illuminate\Http\JsonResponse
{
$user = $request->user();
@@ -47,39 +47,119 @@ class VipCenterController extends Controller
->where('status', 'paid')
->sum('amount');
$vipPaymentEnabled = Sysparam::getValue('vip_payment_enabled', '0') === '1';
$effectOptions = [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
'sakura' => '樱花飘落',
'meteors' => '流星',
'gold-rain' => '金币雨',
'hearts' => '爱心飘落',
'confetti' => '彩带庆典',
'fireflies' => '萤火虫',
];
$bannerStyleOptions = [
'aurora' => '鎏光星幕',
'storm' => '雷霆风暴',
'royal' => '王者金辉',
'cosmic' => '星穹幻彩',
'farewell' => '告别暮光',
];
if ($request->expectsJson()) {
$data = [
'user' => [
'id' => $user->id,
'username' => $user->username,
'is_vip' => $user->isVip(),
'vip_name' => $user->vipName(),
'hy_time' => $user->hy_time?->format('Y-m-d H:i'),
'vip_level_id' => $user->vip_level_id,
'can_customize' => $user->canCustomizeVipPresence(),
'custom_join_message' => $user->custom_join_message,
'custom_leave_message' => $user->custom_leave_message,
'custom_join_effect' => $user->custom_join_effect,
'custom_leave_effect' => $user->custom_leave_effect,
'vip_level' => $user->vipLevel ? [
'id' => $user->vipLevel->id,
'name' => $user->vipLevel->name,
'icon' => $user->vipLevel->icon,
'color' => $user->vipLevel->color,
'join_effect' => $user->vipLevel->joinEffectKey(),
'join_banner' => $user->vipLevel->joinBannerStyleKey(),
'leave_effect' => $user->vipLevel->leaveEffectKey(),
'leave_banner' => $user->vipLevel->leaveBannerStyleKey(),
'join_templates' => $user->vipLevel->join_templates_array,
'leave_templates' => $user->vipLevel->leave_templates_array,
] : null,
],
'vipLevels' => $vipLevels->map(function ($vip) use ($user) {
$isCurrent = $user->isVip() && (int) $user->vip_level_id === (int) $vip->id;
$isHigher = $user->isVip() ? $vip->isHigherThan($user->vip_level_id) : true;
$isLower = $user->isVip() && ! $isCurrent && ! $isHigher;
return [
'id' => $vip->id,
'name' => $vip->name,
'icon' => $vip->icon,
'color' => $vip->color,
'price' => (float) $vip->price,
'upgrade_price' => $user->isVip() ? (float) $vip->getUpgradePrice($user->vip_level_id) : (float) $vip->price,
'duration_days' => $vip->duration_days,
'exp_multiplier' => $vip->exp_multiplier,
'jjb_multiplier' => $vip->jjb_multiplier,
'description' => $vip->description,
'is_current' => $isCurrent,
'is_higher' => $isHigher,
'is_lower' => $isLower,
];
}), 'paymentLogs' => $paymentLogs->items(),
'vipPaymentEnabled' => $vipPaymentEnabled,
'paidOrders' => $paidOrders,
'totalAmount' => $totalAmount,
'effectOptions' => $effectOptions,
'bannerStyleOptions' => $bannerStyleOptions,
];
return response()->json([
'status' => 'success',
'data' => $data,
]);
}
return view('vip.center', [
'user' => $user,
'vipLevels' => $vipLevels,
'paymentLogs' => $paymentLogs,
'vipPaymentEnabled' => Sysparam::getValue('vip_payment_enabled', '0') === '1',
'vipPaymentEnabled' => $vipPaymentEnabled,
'paidOrders' => $paidOrders,
'totalAmount' => $totalAmount,
'effectOptions' => [
'none' => '无特效',
'fireworks' => '烟花',
'rain' => '下雨',
'lightning' => '闪电',
'snow' => '下雪',
],
'bannerStyleOptions' => [
'aurora' => '鎏光星幕',
'storm' => '雷霆风暴',
'royal' => '王者金辉',
'cosmic' => '星穹幻彩',
'farewell' => '告别暮光',
],
'effectOptions' => $effectOptions,
'bannerStyleOptions' => $bannerStyleOptions,
]);
}
/**
* 保存会员个人自定义欢迎语与离开语。
*/
public function updatePresenceSettings(UpdateVipPresenceSettingsRequest $request): RedirectResponse
public function updatePresenceSettings(UpdateVipPresenceSettingsRequest $request): RedirectResponse|\Illuminate\Http\JsonResponse
{
$user = $request->user();
// 只有有效会员且当前等级允许自定义时,才允许保存专属语句。
if (! $user->canCustomizeVipPresence()) {
if ($request->expectsJson()) {
return response()->json([
'status' => 'error',
'message' => '当前会员等级暂不支持自定义欢迎语和离开语。',
], 403);
}
return redirect()
->route('vip.center')
->with('error', '当前会员等级暂不支持自定义欢迎语和离开语。');
@@ -91,11 +171,20 @@ class VipCenterController extends Controller
$user->update([
'custom_join_message' => $this->sanitizeNullableMessage($data['custom_join_message'] ?? null),
'custom_leave_message' => $this->sanitizeNullableMessage($data['custom_leave_message'] ?? null),
'custom_join_effect' => $data['custom_join_effect'] ?? null,
'custom_leave_effect' => $data['custom_leave_effect'] ?? null,
]);
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'message' => '设置已保存。',
]);
}
return redirect()
->route('vip.center')
->with('success', '会员专属欢迎语和离开语已保存。');
->with('success', '设置已保存。');
}
/**
@@ -16,6 +16,10 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response;
use RuntimeException;
/**
* 前台 VIP 支付控制器
* 负责接收用户选择的支付渠道,下发支付中心订单并处理回调结果。
*/
class VipPaymentController extends Controller
{
/**
@@ -41,10 +45,11 @@ class VipPaymentController extends Controller
}
$vipLevel = VipLevel::query()->findOrFail((int) $request->validated('vip_level_id'));
$provider = (string) $request->validated('provider');
try {
// 先创建本地订单,再向支付中心发起下单,确保回调时有本地单据可追踪。
$vipPaymentOrder = $this->vipPaymentService->createLocalOrder($request->user(), $vipLevel);
$vipPaymentOrder = $this->vipPaymentService->createLocalOrder($request->user(), $vipLevel, $provider);
$remoteOrder = $this->vipPaymentService->createRemoteOrder($vipPaymentOrder);
$payUrl = (string) ($remoteOrder['pay_url'] ?? '');
+83 -19
View File
@@ -1,42 +1,106 @@
<?php
/**
* 文件功能:在可信代理场景下解析客户端真实 IP。
*
* 仅当当前请求明确来自配置中的反向代理 / CDN 节点时,
* 才会采信其透传的真实客户端 IP 头,避免外部客户端伪造来源。
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* 类功能:为可信代理请求恢复真实客户端 IP。
*/
class CloudflareProxies
{
/**
* 文件功能:强制信任并解析 CDN 传导的真实客户端 IP。
* 解决 Herd 环境 / Nginx 本地反代时,丢失 X-Forwarded-For 导致全员 IP 变成 127.0.0.1 的问题。
* 处理进入应用的请求,并在可信代理场景下覆写客户端 IP。
*/
public function handle(Request $request, Closure $next): Response
{
// 优先采纳 Cloudflare 的 CF-Connecting-IP
if ($request->hasHeader('CF-Connecting-IP')) {
$realIp = $request->header('CF-Connecting-IP');
}
// 腾讯云 EdgeOne CDN 自定义回源头部(后台配置名:EO-Client-IP
elseif ($request->hasHeader('EO-Client-IP')) {
$realIp = $request->header('EO-Client-IP');
}
// 其他国内 CDN 厂商(阿里云 DCDN 等)通用头部
elseif ($request->hasHeader('X-Real-IP')) {
$realIp = $request->header('X-Real-IP');
}
// 最后兜底:取 X-Forwarded-For 最左边第一个(真实客户端)IP
// 格式为 "真实客户端, CDN节点1, CDN节点2"
elseif ($request->hasHeader('X-Forwarded-For')) {
$realIp = trim(explode(',', $request->header('X-Forwarded-For'))[0]);
}
$realIp = $this->resolveTrustedClientIp($request);
if (! empty($realIp)) {
// 仅在确认上游代理可信且透传 IP 合法时,才覆写 request()->ip() 的来源。
$request->server->set('REMOTE_ADDR', $realIp);
$request->headers->set('X-Forwarded-For', $realIp);
}
return $next($request);
}
/**
* 从可信代理头中解析真实客户端 IP。
*/
private function resolveTrustedClientIp(Request $request): ?string
{
$remoteAddress = (string) $request->server->get('REMOTE_ADDR', '');
if (! $this->isTrustedProxy($remoteAddress)) {
return null;
}
foreach (['CF-Connecting-IP', 'EO-Client-IP', 'X-Real-IP'] as $headerName) {
$resolvedIp = $this->sanitizeIp($request->header($headerName));
if ($resolvedIp !== null) {
return $resolvedIp;
}
}
return $this->extractForwardedForIp($request->header('X-Forwarded-For'));
}
/**
* 判断当前请求是否来自受信代理节点。
*/
private function isTrustedProxy(string $remoteAddress): bool
{
if ($this->sanitizeIp($remoteAddress) === null) {
return false;
}
$trustedProxies = config('app.trusted_proxies', ['127.0.0.1', '::1']);
foreach ($trustedProxies as $trustedProxy) {
$trustedProxy = trim((string) $trustedProxy);
if ($trustedProxy !== '' && IpUtils::checkIp($remoteAddress, $trustedProxy)) {
return true;
}
}
return false;
}
/**
* X-Forwarded-For 头中提取最左侧的合法 IP。
*/
private function extractForwardedForIp(?string $forwardedFor): ?string
{
if (! is_string($forwardedFor) || $forwardedFor === '') {
return null;
}
foreach (explode(',', $forwardedFor) as $candidateIp) {
$resolvedIp = $this->sanitizeIp($candidateIp);
if ($resolvedIp !== null) {
return $resolvedIp;
}
}
return null;
}
/**
* 校验并标准化 IP 文本。
*/
private function sanitizeIp(?string $ip): ?string
{
$normalizedIp = trim((string) $ip);
return filter_var($normalizedIp, FILTER_VALIDATE_IP) ? $normalizedIp : null;
}
}
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:后台签到奖励规则保存请求校验
*
* 集中校验连续签到天数、奖励数值与身份徽章配置。
*/
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:校验后台新增和更新签到奖励规则的表单数据。
*/
class SaveSignInRewardRuleRequest extends FormRequest
{
/**
* 方法功能:允许已通过后台权限中间件的管理员继续校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:返回签到奖励规则表单的校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$ruleId = $this->route('signInRewardRule')?->id;
return [
'streak_days' => [
'required',
'integer',
'min:1',
'max:3650',
Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId),
],
'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'],
'identity_badge_code' => ['nullable', 'string', 'max:50'],
'identity_badge_name' => ['nullable', 'string', 'max:50'],
'identity_badge_icon' => ['nullable', 'string', 'max:120'],
'identity_badge_color' => ['nullable', 'string', 'max:20'],
'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'],
'sort_order' => ['required', 'integer', 'min:0', 'max:999999'],
'is_enabled' => ['nullable', 'boolean'],
];
}
/**
* 方法功能:返回签到奖励规则表单的中文字段名。
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'streak_days' => '连续签到天数',
'gold_reward' => '金币奖励',
'exp_reward' => '经验奖励',
'charm_reward' => '魅力奖励',
'identity_badge_name' => '身份名称',
'identity_duration_days' => '身份有效天数',
];
}
}
@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:后台用户资料更新请求校验
*/
namespace App\Http\Requests\Admin;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:集中校验后台用户编辑弹窗提交的资料字段。
*/
class UpdateManagedUserRequest extends FormRequest
{
/**
* 方法功能:允许已通过路由中间件的后台用户继续执行校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 方法功能:返回后台用户编辑表单的校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'sex' => ['sometimes', 'integer', 'in:0,1,2'],
'exp_num' => ['sometimes', 'integer', 'min:0'],
'jjb' => ['sometimes', 'integer', 'min:0'],
'meili' => ['sometimes', 'integer', 'min:0'],
'qianming' => ['sometimes', 'nullable', 'string', 'max:255'],
'position_id' => ['sometimes', 'nullable', 'integer', 'exists:positions,id'],
'headface' => ['sometimes', 'string', 'max:50'],
'password' => ['nullable', 'string', 'min:6'],
'vip_level_id' => ['sometimes', 'nullable', 'integer', 'exists:vip_levels,id'],
'hy_time' => ['sometimes', 'nullable', 'date'],
];
}
/**
* 方法功能:返回后台用户编辑表单的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'position_id.exists' => '所选职务不存在,请重新选择。',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:站长隐藏登录请求验证器
*
* 仅校验站长登录页提交的账号、密码与验证码字段,
* 不参与聊天室前台“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验站长隐藏登录表单。
*/
class AdminLoginRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续处理。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取站长隐藏登录所需的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:1'],
'captcha' => ['required', 'captcha'],
];
}
/**
* 获取验证失败时展示的中文提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'username.required' => '必须填写站长账号。',
'password.required' => '必须填写登录密码。',
'password.min' => '登录密码格式不正确。',
'captcha.required' => '必须填写验证码。',
'captcha.captcha' => '验证码不正确。',
];
}
}
@@ -0,0 +1,50 @@
<?php
/**
* 文件功能:前台每日签到请求校验
*
* 校验用户发起签到时携带的房间参数,避免脏 room_id 写入签到流水和聊天室通知。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验每日签到领取接口的请求参数。
*/
class ClaimDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录聊天室用户发起签到请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回每日签到领取参数校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回每日签到领取的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.exists' => '当前聊天室不存在,请刷新页面后再签到。',
];
}
}
@@ -8,7 +8,12 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
/**
* 创建 VIP 支付订单请求
* 负责校验会员购买等级与支付渠道,确保下单时明确指定支付方式。
*/
class CreateVipPaymentOrderRequest extends FormRequest
{
/**
@@ -28,9 +33,37 @@ class CreateVipPaymentOrderRequest extends FormRequest
{
return [
'vip_level_id' => ['required', 'integer', 'exists:vip_levels,id'],
'provider' => ['required', 'string', 'in:alipay,wechat'],
];
}
/**
* 配置验证器实例。
*/
public function withValidator(Validator $validator): void
{
$validator->after(function ($validator) {
$user = $this->user();
if (! $user || ! $user->isVip()) {
return;
}
$vipLevelId = (int) $this->vip_level_id;
$targetLevel = \App\Models\VipLevel::find($vipLevelId);
if (! $targetLevel) {
return;
}
$currentLevelId = (int) $user->vip_level_id;
// 逻辑:允许续费当前等级,或购买更高等级。禁止降级。
if ($vipLevelId !== $currentLevelId && ! $targetLevel->isHigherThan($currentLevelId)) {
$validator->errors()->add('vip_level_id', '当前仅支持续费同级会员或补差价升级到更高等级,暂不支持降级购买。');
}
});
}
/**
* 获取中文错误提示
*
@@ -41,6 +74,8 @@ class CreateVipPaymentOrderRequest extends FormRequest
return [
'vip_level_id.required' => '请选择要购买的 VIP 等级',
'vip_level_id.exists' => '所选 VIP 等级不存在或已被删除',
'provider.required' => '请选择支付方式',
'provider.in' => '当前支付方式不受支持,请重新选择',
];
}
}
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:前台每日签到日历查询请求校验。
*
* 校验月份参数,供签到日历按月展示签到状态。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验用户查询签到日历时传入的月份参数。
*/
class DailySignInCalendarRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户查询自己的签到日历。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回签到日历查询规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'month' => ['nullable', 'date_format:Y-m'],
];
}
/**
* 方法功能:返回签到日历查询的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'month.date_format' => '月份格式不正确。',
];
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:前台每日签到补签请求校验。
*
* 校验补签日期和房间参数,确保用户只能补签历史漏签日期。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* 类功能:校验用户在签到日历中提交的补签请求。
*/
class MakeupDailySignInRequest extends FormRequest
{
/**
* 方法功能:允许已登录用户提交补签请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 方法功能:返回补签请求的校验规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'target_date' => ['required', 'date', 'before:today', 'after_or_equal:'.Carbon::today()->startOfMonth()->toDateString()],
'room_id' => ['nullable', 'integer', 'exists:rooms,id'],
];
}
/**
* 方法功能:返回补签请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'target_date.required' => '请选择要补签的日期。',
'target_date.date' => '补签日期格式不正确。',
'target_date.before' => '只能补签今天之前的漏签日期。',
'target_date.after_or_equal' => '补签卡只能补签本月的未签到日期。',
'room_id.exists' => '当前聊天室不存在,请刷新页面后再补签。',
];
}
}
@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:前台邮箱重置密码请求验证器
*
* 负责校验重置令牌、邮箱和新密码字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验邮箱重置密码表单提交的数据。
*/
class ResetPasswordRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 定义重置密码请求的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
];
}
/**
* 定义重置密码请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'token.required' => '重置凭证缺失,请重新从邮件中的链接进入。',
'email.required' => '邮箱不能为空。',
'email.email' => '邮箱格式不正确。',
'password.required' => '请输入新的登录密码。',
'password.min' => '新密码长度至少需要 6 位。',
'password.confirmed' => '两次输入的新密码不一致。',
];
}
}
+56 -8
View File
@@ -13,11 +13,37 @@ namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Rule;
/**
* 聊天室发言请求验证器
* 负责统一校验文本消息与图片消息的发送参数。
*/
class SendMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 允许前端提交的发言动作白名单。
*/
private const ALLOWED_ACTIONS = [
'',
'微笑',
'大笑',
'愤怒',
'哭泣',
'害羞',
'鄙视',
'得意',
'疑惑',
'同情',
'无奈',
'拳打',
'飞吻',
'偷看',
'欢迎',
];
/**
* 判断当前请求是否允许继续。
*/
public function authorize(): bool
{
@@ -25,33 +51,55 @@ class SendMessageRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回发言请求的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<int, mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:500'], // 防止超长文本炸服
'content' => ['nullable', 'required_without:image', 'string', 'max:500'], // 文本与图片至少二选一
'image' => ['nullable', 'required_without:content', 'file', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:6144'],
'to_user' => ['nullable', 'string', 'max:50'],
'is_secret' => ['nullable', 'boolean'],
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
'action' => ['nullable', 'string', 'max:50'], // 动作(例如:微笑着说)
'font_color' => ['nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'], // html color hex
'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入
];
}
/**
* 在校验前统一整理输入,避免首尾空白绕过白名单判断。
*/
protected function prepareForValidation(): void
{
$action = $this->input('action');
$this->merge([
'action' => is_string($action) ? trim($action) : $action,
]);
}
/**
* 返回校验失败时的中文提示。
*/
public function messages(): array
{
return [
'content.required' => '不能发送空消息。',
'content.required_without' => '文字内容和图片至少要发送一项。',
'content.max' => '发言内容不能超过 500 个字符。',
'image.required_without' => '文字内容和图片至少要发送一项。',
'image.image' => '上传的文件必须是图片。',
'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。',
'image.max' => '图片大小不能超过 6MB。',
'font_color.regex' => '发言颜色格式不合法,请重新选择颜色。',
'action.in' => '发言动作不合法,请重新选择。',
];
}
/**
* 重写验证失败的处理,无论如何(就算未按 ajax 标准提交)都必须抛出 JSON,不可以触发网页重定向去走 GET 请求而引发 302 方法错误
*/
protected function failedValidation(Validator $validator)
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'status' => 'error',
@@ -0,0 +1,51 @@
<?php
/**
* 文件功能:发送邮箱找回密码链接请求验证器
*
* 负责校验独立找回密码页面提交的邮箱字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验邮箱找回密码所需的邮箱参数。
*/
class SendPasswordResetLinkRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 定义邮箱找回密码请求的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
];
}
/**
* 定义邮箱找回密码请求的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'email.required' => '请输入已绑定账号的邮箱地址。',
'email.email' => '邮箱格式不正确,请重新输入。',
'email.max' => '邮箱长度不能超过 255 个字符。',
];
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:百家乐买单活动创建请求
*
* 负责校验聊天室管理员在前台创建买单活动时提交的时间与文案字段。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBaccaratLossCoverEventRequest extends FormRequest
{
/**
* 判断当前用户是否允许提交创建请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取字段校验规则。
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'starts_at' => ['required', 'date'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'claim_deadline_at' => ['required', 'date', 'after_or_equal:ends_at'],
];
}
/**
* 获取中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'title.required' => '请输入活动标题',
'starts_at.required' => '请选择活动开始时间',
'ends_at.required' => '请选择活动结束时间',
'ends_at.after' => '活动结束时间必须晚于开始时间',
'claim_deadline_at.required' => '请选择领取截止时间',
'claim_deadline_at.after_or_equal' => '领取截止时间不能早于活动结束时间',
];
}
}
@@ -0,0 +1,100 @@
<?php
/**
* 文件功能:节日福利活动创建请求
*
* 负责校验后台创建节日福利模板时提交的奖励、调度与目标用户字段。
*/
namespace App\Http\Requests;
use App\Rules\HolidayEventScheduleRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:校验创建节日福利模板的表单数据。
*/
class StoreHolidayEventRequest extends FormRequest
{
/**
* 判断当前用户是否允许提交创建请求。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 预处理布尔字段,避免浏览器复选框值造成类型偏差。
*/
protected function prepareForValidation(): void
{
if ($this->has('enabled')) {
$this->merge([
'enabled' => $this->boolean('enabled'),
]);
}
}
/**
* 获取节日福利模板的字段校验规则。
*
* @return array<string, ValidationRule|array<int, ValidationRule|string>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'total_amount' => ['required', 'integer', 'min:1'],
'max_claimants' => ['required', 'integer', 'min:0'],
'distribute_type' => ['required', Rule::in(['random', 'fixed'])],
'min_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,random'],
'max_amount' => ['nullable', 'integer', 'min:1', 'gte:min_amount'],
'fixed_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,fixed'],
'send_at' => ['nullable', 'date', Rule::requiredIf(fn (): bool => $this->input('repeat_type') !== 'yearly')],
'expire_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'repeat_type' => [
'required',
Rule::in(['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly']),
new HolidayEventScheduleRule,
],
'cron_expr' => ['nullable', 'string', 'max:100', 'required_if:repeat_type,cron'],
'schedule_month' => ['nullable', 'integer', 'between:1,12'],
'schedule_day' => ['nullable', 'integer', 'between:1,31'],
'schedule_time' => ['nullable', 'date_format:H:i'],
'duration_days' => ['nullable', 'integer', 'min:1', 'max:31'],
'daily_occurrences' => ['nullable', 'integer', 'min:1', 'max:24'],
'occurrence_interval_minutes' => ['nullable', 'integer', 'min:1', 'max:1439'],
'target_type' => ['required', Rule::in(['all', 'vip', 'level'])],
'target_value' => ['nullable', 'string', 'max:50', 'required_if:target_type,level'],
'enabled' => ['sometimes', 'boolean'],
];
}
/**
* 获取中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => '请输入活动名称',
'total_amount.required' => '请填写总金币奖池',
'max_claimants.required' => '请填写可领取人数上限',
'distribute_type.required' => '请选择分配方式',
'min_amount.required_if' => '随机分配模式下必须填写最低保底金额',
'fixed_amount.required_if' => '定额发放模式下必须填写每人固定金额',
'send_at.required' => '请选择触发时间',
'expire_minutes.required' => '请填写领取有效期',
'repeat_type.required' => '请选择重复方式',
'cron_expr.required_if' => 'CRON 模式下必须填写表达式',
'schedule_time.date_format' => '首轮开始时间格式必须为 HH:ii',
'target_type.required' => '请选择目标用户范围',
'target_value.required_if' => '指定等级以上模式下必须填写最低等级',
];
}
}
+25 -3
View File
@@ -13,10 +13,14 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
/**
* 新建聊天室请求验证器
* 负责限制建房权限并拦截危险的房间名称输入。
*/
class StoreRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 判断当前用户是否具备自建房间权限。
*/
public function authorize(): bool
{
@@ -26,24 +30,42 @@ class StoreRoomRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回建房请求的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name'],
'name' => ['required', 'string', 'max:50', 'regex:/^[^<>]+$/u', 'unique:rooms,room_name'],
'description' => ['nullable', 'string', 'max:255'],
];
}
/**
* 在校验前整理房间输入,避免空白与危险字符绕过前端限制。
*/
protected function prepareForValidation(): void
{
$name = $this->input('name');
$description = $this->input('description');
$this->merge([
'name' => is_string($name) ? trim($name) : $name,
'description' => is_string($description) ? trim($description) : $description,
]);
}
/**
* 返回建房失败时的中文提示。
*/
public function messages(): array
{
return [
'name.required' => '必须填写房间名称。',
'name.unique' => '该房间名称已被占用。',
'name.max' => '房间名称最多 50 个字符。',
'name.regex' => '房间名称不能包含尖括号。',
];
}
}
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:聊天室偏好设置验证器
* 负责校验用户提交的屏蔽播报与禁音配置。
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 聊天室偏好设置验证器
* 仅允许提交白名单内的屏蔽项与布尔型禁音状态。
*/
class UpdateChatPreferencesRequest extends FormRequest
{
/**
* 允许已登录用户保存自己的聊天室偏好。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取聊天室偏好的验证规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [
'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
],
'sound_muted' => ['required', 'boolean'],
];
}
/**
* 获取聊天室偏好的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'blocked_system_senders.array' => '屏蔽设置格式无效。',
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
'sound_muted.required' => '请传入禁音状态。',
'sound_muted.boolean' => '禁音状态格式无效。',
];
}
}
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:聊天室用户状态设置验证器
* 负责校验用户在聊天室内提交的当日状态设置与清除请求。
*/
namespace App\Http\Requests;
use App\Support\ChatDailyStatusCatalog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateDailyStatusRequest extends FormRequest
{
/**
* 允许已登录用户保存自己的当日状态。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取聊天室当日状态设置的验证规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'room_id' => ['required', 'integer', 'exists:rooms,id'],
'action' => ['required', 'string', Rule::in(['set', 'clear'])],
'status_key' => [
Rule::requiredIf(fn (): bool => $this->input('action') === 'set'),
'nullable',
'string',
Rule::in(ChatDailyStatusCatalog::keys()),
],
];
}
/**
* 获取聊天室当日状态设置的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.required' => '缺少当前房间信息。',
'room_id.integer' => '房间编号格式无效。',
'room_id.exists' => '当前房间不存在。',
'action.required' => '缺少状态操作类型。',
'action.in' => '不支持的状态操作类型。',
'status_key.required' => '请选择要设置的状态。',
'status_key.in' => '请选择系统支持的状态。',
];
}
}
@@ -0,0 +1,14 @@
<?php
/**
* 文件功能:节日福利活动更新请求
*
* 复用创建请求的字段校验规则,用于后台编辑节日福利模板。
*/
namespace App\Http\Requests;
/**
* 类功能:校验更新节日福利模板的表单数据。
*/
class UpdateHolidayEventRequest extends StoreHolidayEventRequest {}
+3 -1
View File
@@ -11,6 +11,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
@@ -33,7 +34,7 @@ class UpdateProfileRequest extends FormRequest
'sex' => ['required', 'in:0,1,2'],
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
'sign' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'email' => ['nullable', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->user()?->id)],
'question' => ['nullable', 'string', 'max:100'],
'answer' => ['nullable', 'string', 'max:100'],
];
@@ -44,6 +45,7 @@ class UpdateProfileRequest extends FormRequest
return [
'sex.in' => '性别选项无效(0=保密 1=男 2=女)。',
'headface.required' => '必须选择一个头像。',
'email.unique' => '该邮箱已被其他账号绑定,请更换一个邮箱。',
];
}
}
+32 -3
View File
@@ -11,11 +11,16 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 修改聊天室设置请求验证器
* 负责约束房间名称更新时的合法性,避免危险字符进入前端模板。
*/
class UpdateRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* 判断当前请求是否允许继续。
*/
public function authorize(): bool
{
@@ -24,23 +29,47 @@ class UpdateRoomRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* 返回修改房间设置的校验规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name,'.$this->route('id')],
'name' => [
'required',
'string',
'max:50',
'regex:/^[^<>]+$/u',
Rule::unique('rooms', 'room_name')->ignore($this->route('id')),
],
'description' => ['nullable', 'string', 'max:255'],
];
}
/**
* 在校验前整理更新表单,避免前后空白影响唯一性与安全判断。
*/
protected function prepareForValidation(): void
{
$name = $this->input('name');
$description = $this->input('description');
$this->merge([
'name' => is_string($name) ? trim($name) : $name,
'description' => is_string($description) ? trim($description) : $description,
]);
}
/**
* 返回房间设置更新失败时的中文提示。
*/
public function messages(): array
{
return [
'name.required' => '房间名称不能为空。',
'name.unique' => '该房间名称已存在。',
'name.regex' => '房间名称不能包含尖括号。',
];
}
}
@@ -27,6 +27,8 @@ class UpdateVipPresenceSettingsRequest extends FormRequest
return [
'custom_join_message' => ['nullable', 'string', 'max:255'],
'custom_leave_message' => ['nullable', 'string', 'max:255'],
'custom_join_effect' => ['nullable', 'string', 'max:30'],
'custom_leave_effect' => ['nullable', 'string', 'max:30'],
];
}
+154 -52
View File
@@ -5,7 +5,7 @@
*
* 在每局百家乐开启时延迟调度执行:
* 1. 检查是否存在连输休息惩罚(1小时)
* 2. 检查可用金币,确保留存底金
* 2. 检查可用金币与活动时间窗,确定本局下注额度
* 3. 调用 AI 接口预测路单走势决定下注方向(AI 不可用时回退本地决策)
* 4. 提交下注
*
@@ -23,6 +23,8 @@ use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AiFinanceService;
use App\Services\BaccaratLossCoverService;
use App\Services\BaccaratPredictionService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
@@ -35,48 +37,67 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* 控制 AI小班长在百家乐中的下注、观望与资金调度行为。
*/
class AiBaccaratBetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 注入当前需要处理的百家乐局次。
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
public function handle(UserCurrencyService $currency): void
/**
* 执行 AI小班长本局百家乐的完整决策流程。
*/
public function handle(UserCurrencyService $currency, AiFinanceService $aiFinance): void
{
// 1. 检查总开关与游戏开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1' || ! GameConfig::isEnabled('baccarat')) {
return;
}
$round = $this->round->fresh();
if (! $round || ! $round->isBettingOpen()) {
return;
}
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return;
}
// 2. 检查连输惩罚超时
// 2. 资金管理:自动存款与领取补偿
$this->manageFinances($user, $aiFinance);
$round = $this->round->fresh();
if (! $round || ! $round->isBettingOpen()) {
return;
}
// 3. 检查连输惩罚超时
if (Redis::exists('ai_baccarat_timeout')) {
return; // 还在禁赛期
}
// 3. 检查余额与限额
// 4. 检查余额与限额
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
// 至少保留 2000 金币底仓
$availableGold = ($user->jjb ?? 0) - 2000;
if ($availableGold < $minBet) {
// 5. 查询当前是否命中“你玩游戏我买单”活动窗口。
$lossCoverService = app(BaccaratLossCoverService::class);
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
$isInLossCoverWindow = $lossCoverEvent !== null;
// 买单活动进行中时允许 AI 统筹“手上 + 银行”总资产;平时只动用当前手上的 jjb。
$bettableGold = $isInLossCoverWindow
? $aiFinance->getTotalGoldAssets($user)
: $aiFinance->getSpendableGold($user);
if ($bettableGold < $minBet) {
return; // 资金不足以支撑最小下注
}
// 4. 获取近期路单和 AI 历史下注
// 6. 获取近期路单和 AI 历史下注
$recentResults = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
@@ -102,12 +123,13 @@ class AiBaccaratBetJob implements ShouldQueue
})
->toArray();
// 5. 调用 AI 接口出具统筹策略
// 7. 调用 AI 接口出具统筹策略
$predictionService = app(BaccaratPredictionService::class);
$context = [
'recent_results' => $recentResults,
'available_gold' => $availableGold,
'available_gold' => $bettableGold,
'historical_bets' => $historicalBets,
'loss_cover_active' => $isInLossCoverWindow,
];
$aiPrediction = $predictionService->predict($context);
@@ -122,37 +144,44 @@ class AiBaccaratBetJob implements ShouldQueue
$percent = $aiPrediction['percentage'];
$reason = $aiPrediction['reason'];
// 限定单局最高下注不超过可用金额的 5% 以防止 AI "乱梭哈" 破产
$percent = min(5, max(0, $percent));
// 买单活动期间不允许 AI 选择观望,避免错过“输也可返还”的活动资格。
if ($isInLossCoverWindow && $betType === 'pass') {
$decisionSource = 'local';
$betType = $this->resolveLocalBetType($recentResults, false);
$reason = trim($reason.' 买单活动进行中,本局禁止观望,已切换本地强制参战策略。');
}
if ($betType !== 'pass') {
$amount = (int) round($availableGold * ($percent / 100.0));
$amount = max($minBet, min($amount, $maxBet));
if ($amount > $user->jjb) {
$amount = $user->jjb;
if ($isInLossCoverWindow) {
// 买单活动进行中且金币足够时,直接按百家乐单局最高限额下注。
$amount = min($bettableGold, $maxBet);
$reason = trim($reason.' 买单活动进行中,本局按百家乐最高限额下注。');
} else {
// 非买单活动期间,限定单局最高下注不超过手头金币的 5% 以防止 AI 破产。
$percent = min(5, max(0, $percent));
$amount = (int) round($bettableGold * ($percent / 100.0));
$amount = max($minBet, min($amount, $maxBet));
if ($amount > $bettableGold) {
$amount = $bettableGold;
}
}
}
} else {
// AI 不可用时回退本地路单决策(保底逻辑)
$decisionSource = 'local';
$bigCount = count(array_filter($recentResults, fn (string $r) => $r === 'big'));
$smallCount = count(array_filter($recentResults, fn (string $r) => $r === 'small'));
$strategy = rand(1, 100);
if ($strategy <= 10) {
$betType = 'triple'; // 10% 概率博豹子
} elseif ($bigCount > $smallCount) {
$betType = rand(1, 100) <= 70 ? 'big' : 'small';
} elseif ($smallCount > $bigCount) {
$betType = rand(1, 100) <= 70 ? 'small' : 'big';
} else {
$betType = rand(0, 10) === 0 ? 'pass' : (rand(0, 1) ? 'big' : 'small');
}
// 买单活动期间,本地兜底策略同样不能返回观望。
$betType = $this->resolveLocalBetType($recentResults, ! $isInLossCoverWindow);
if ($betType !== 'pass') {
$percent = rand(2, 5) / 100.0;
$amount = (int) round($availableGold * $percent);
$amount = max($minBet, min($amount, $maxBet));
if ($isInLossCoverWindow) {
// 本地兜底策略命中买单活动时,同样优先按百家乐最高限额下注。
$amount = min($bettableGold, $maxBet);
$reason = '买单活动进行中,采用本地最高限额下注兜底策略。';
} else {
$percent = rand(2, 5) / 100.0;
$amount = (int) round($bettableGold * $percent);
$amount = max($minBet, min($amount, $maxBet));
}
}
}
@@ -174,6 +203,14 @@ class AiBaccaratBetJob implements ShouldQueue
return;
}
// 买单活动期间允许为本次高额下注从银行调拨;非活动期间只检查当前手上金币是否够本次下注。
$isReadyToSpend = $isInLossCoverWindow
? $aiFinance->prepareAllInSpend($user, $amount)
: $aiFinance->prepareSpend($user, $amount);
if (! $isReadyToSpend) {
return;
}
// 二次校验,防止大模型接口调用太慢导致下注时该局已关闭
$round->refresh();
if (! $round->isBettingOpen()) {
@@ -182,12 +219,17 @@ class AiBaccaratBetJob implements ShouldQueue
return;
}
// 6. 执行下注 (同 BaccaratController::bet 逻辑)
DB::transaction(function () use ($user, $round, $betType, $amount, $currency) {
// 8. 执行下注 (同 BaccaratController::bet 逻辑)
DB::transaction(function () use ($user, $round, $betType, $amount, $currency, $lossCoverEvent, $lossCoverService) {
$lockedUser = User::query()->lockForUpdate()->find($user->id);
if (! $lockedUser || (int) ($lockedUser->jjb ?? 0) < $amount) {
return;
}
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->where('user_id', $lockedUser->id)
->lockForUpdate()
->exists();
@@ -197,7 +239,7 @@ class AiBaccaratBetJob implements ShouldQueue
// 扣除金币
$currency->change(
$user,
$lockedUser,
'gold',
-$amount,
CurrencySource::BACCARAT_BET,
@@ -207,14 +249,20 @@ class AiBaccaratBetJob implements ShouldQueue
);
// 写入下注记录
BaccaratBet::create([
$bet = BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'user_id' => $lockedUser->id,
'loss_cover_event_id' => $lossCoverEvent?->id,
'bet_type' => $betType,
'amount' => $amount,
'status' => 'pending',
]);
// 命中买单活动的下注需要登记到活动聚合记录里,确保后续能正确补偿返还。
if ($lossCoverEvent) {
$lossCoverService->registerBet($bet);
}
// 更新局次汇总统计
$field = 'total_bet_'.$betType;
$countField = 'bet_count_'.$betType;
@@ -226,6 +274,9 @@ class AiBaccaratBetJob implements ShouldQueue
event(new \App\Events\BaccaratPoolUpdated($round));
});
// 下注完成后,若手上金币仍高于 100 万,则把超出的部分继续回存银行。
$aiFinance->bankExcessGold($user);
// 下注成功后,在聊天室发送一条普通聊天消息告知大家
$this->broadcastBetMessage($user, $round->room_id ?? 1, $betType, $amount, $decisionSource);
}
@@ -282,14 +333,11 @@ class AiBaccaratBetJob implements ShouldQueue
string $decisionSource,
): void {
$chatState = app(ChatStateService::class);
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$betLabel = $labelMap[$betType] ?? $betType;
$sourceTag = $decisionSource === 'ai' ? '🤖 AI分析' : '📊路单统计';
$formattedAmount = number_format($amount);
$label = $labelMap[$betType] ?? $betType;
// 格式:🌟 🎲 【百家乐】 娜姐 押注了 119 金币(大)!✨ [🤖 AI分析]
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨ [{$sourceTag}]";
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:';
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}</span>";
$msg = [
'id' => $chatState->nextMessageId($roomId),
@@ -298,13 +346,67 @@ class AiBaccaratBetJob implements ShouldQueue
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'font_color' => '#8b5cf6',
'action' => '动态播报',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 生成本地路单兜底下注方向。
*
* @param array<int, string> $recentResults 最近已结算局次结果
* @param bool $allowPass 是否允许返回观望
*/
private function resolveLocalBetType(array $recentResults, bool $allowPass): string
{
$bigCount = count(array_filter($recentResults, fn (string $result) => $result === 'big'));
$smallCount = count(array_filter($recentResults, fn (string $result) => $result === 'small'));
// 默认保留少量押豹子概率,维持 AI 小班长原本的下注风格。
if (rand(1, 100) <= 10) {
return 'triple';
}
if ($bigCount > $smallCount) {
return rand(1, 100) <= 70 ? 'big' : 'small';
}
if ($smallCount > $bigCount) {
return rand(1, 100) <= 70 ? 'small' : 'big';
}
// 只有非买单活动时才允许空仓;活动期间必须至少押大或押小。
if ($allowPass && rand(0, 10) === 0) {
return 'pass';
}
return rand(0, 1) === 0 ? 'big' : 'small';
}
/**
* AI 资金管理逻辑:优先领取补偿,再按“超过 100 万才存款”的规则整理持仓。
*/
private function manageFinances(User $user, AiFinanceService $aiFinance): void
{
// 1. 检查是否有“买单”活动补偿可领取 (jjb 较低时优先领取)
$lossCoverService = app(\App\Services\BaccaratLossCoverService::class);
$pendingEvents = \App\Models\BaccaratLossCoverEvent::where('status', 'claimable')->get();
foreach ($pendingEvents as $event) {
$record = \App\Models\BaccaratLossCoverRecord::where('event_id', $event->id)
->where('user_id', $user->id)
->where('claim_status', 'pending')
->first();
if ($record && $record->compensation_amount > 0) {
$lossCoverService->claim($event, $user);
Log::info("AI小班长自动领取活动补偿: Event #{$event->id}, Amount: {$record->compensation_amount}");
}
}
$aiFinance->rebalanceHoldings($user);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:AI小班长自动领取百家乐买单活动补偿任务
*
* 当买单活动进入“可领取”状态后,异步为 AI小班长检查并领取
* 本次活动中累计的补偿金币,确保金币入账和流水日志统一走正式服务。
*/
namespace App\Jobs;
use App\Models\BaccaratLossCoverEvent;
use App\Models\BaccaratLossCoverRecord;
use App\Models\User;
use App\Services\BaccaratLossCoverService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class AiClaimBaccaratLossCoverJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 3;
/**
* @param int $eventId 需要自动领取补偿的活动 ID
*/
public function __construct(
public readonly int $eventId,
) {}
/**
* 执行 AI 小班长自动领取补偿逻辑。
*/
public function handle(BaccaratLossCoverService $lossCoverService): void
{
$event = BaccaratLossCoverEvent::query()->find($this->eventId);
if (! $event) {
Log::channel('daily')->warning('AI小班长自动领取买单补偿失败:活动不存在', [
'event_id' => $this->eventId,
]);
return;
}
// 只有活动进入可领取状态后,才允许自动发起补偿领取。
if (! $event->isClaimable()) {
Log::channel('daily')->info('AI小班长自动领取买单补偿跳过:活动暂不可领取', [
'event_id' => $event->id,
'status' => $event->status,
]);
return;
}
$aiUser = User::query()->where('username', 'AI小班长')->first();
if (! $aiUser) {
Log::channel('daily')->warning('AI小班长自动领取买单补偿失败:未找到 AI 用户', [
'event_id' => $event->id,
]);
return;
}
$record = BaccaratLossCoverRecord::query()
->where('event_id', $event->id)
->where('user_id', $aiUser->id)
->first();
// 没有补偿记录或本次没有待领取金额时,直接记日志后结束。
if (! $record || $record->compensation_amount <= 0 || $record->claim_status !== 'pending') {
Log::channel('daily')->info('AI小班长自动领取买单补偿跳过:暂无待领取补偿', [
'event_id' => $event->id,
'user_id' => $aiUser->id,
'claim_status' => $record?->claim_status,
'compensation_amount' => $record?->compensation_amount,
]);
return;
}
$claimResult = $lossCoverService->claim($event, $aiUser);
// 统一记录自动领取结果,便于后续核对 AI 补偿发放情况。
Log::channel('daily')->info('AI小班长自动领取买单补偿结果', [
'event_id' => $event->id,
'user_id' => $aiUser->id,
'ok' => $claimResult['ok'],
'message' => $claimResult['message'],
'amount' => $claimResult['amount'] ?? 0,
]);
}
}
+104 -3
View File
@@ -20,6 +20,7 @@ use App\Events\MessageSent;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -49,6 +50,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
public function handle(
UserCurrencyService $currency,
ChatStateService $chatState,
BaccaratLossCoverService $lossCoverService,
): void {
$round = $this->round->fresh();
@@ -94,8 +96,9 @@ class CloseBaccaratRoundJob implements ShouldQueue
// 收集各用户输赢结果,用于公屏展示
$winners = [];
$losers = [];
$participantSettlements = [];
DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout, &$winners, &$losers) {
DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers, &$participantSettlements) {
foreach ($bets as $bet) {
/** @var \App\Models\BaccaratBet $bet */
$username = $bet->user->username ?? '匿名';
@@ -103,7 +106,9 @@ class CloseBaccaratRoundJob implements ShouldQueue
if ($result === 'kill') {
// 庄家收割:全灭无退款
$bet->update(['status' => 'lost', 'payout' => 0]);
$lossCoverService->registerSettlement($bet->fresh());
$losers[] = "{$username}-{$bet->amount}";
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
if ($username === 'AI小班长') {
$this->handleAiLoseStreak();
@@ -126,14 +131,19 @@ class CloseBaccaratRoundJob implements ShouldQueue
"百家乐 #{$this->round->id}{$bet->betTypeLabel()} 中奖",
);
$totalPayout += $payout;
$lossCoverService->registerSettlement($bet->fresh());
$winners[] = "{$username}+".number_format($payout);
// 结算提醒展示的是本局净输赢,因此要扣除下注时已经支付的本金。
$this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout);
if ($username === 'AI小班长') {
Redis::del('ai_baccarat_lose_streak'); // 赢了清空连输
}
} else {
$bet->update(['status' => 'lost', 'payout' => 0]);
$lossCoverService->registerSettlement($bet->fresh());
$losers[] = "{$username}-".number_format($bet->amount);
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
if ($username === 'AI小班长') {
$this->handleAiLoseStreak();
@@ -161,6 +171,9 @@ class CloseBaccaratRoundJob implements ShouldQueue
// ── 公屏公告 ─────────────────────────────────────────────────
$this->pushResultMessage($round, $chatState, $winners, $losers);
// ── 参与者私聊提醒 ────────────────────────────────────────────
$this->pushParticipantToastNotifications($round, $chatState, $participantSettlements);
}
/**
@@ -175,6 +188,94 @@ class CloseBaccaratRoundJob implements ShouldQueue
}
}
/**
* 汇总单个参与者本局的下注、返还与净输赢金额。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function recordParticipantSettlement(array &$participantSettlements, BaccaratBet $bet, int $netChange, int $payout): void
{
if (! $bet->user) {
return;
}
$userId = (int) $bet->user->id;
$existing = $participantSettlements[$userId] ?? [
'user' => $bet->user,
'username' => $bet->user->username,
'bet_amount' => 0,
'payout' => 0,
'net_change' => 0,
];
// 同一用户若存在多条下注记录,这里统一聚合成本局总输赢。
$existing['bet_amount'] += (int) $bet->amount;
$existing['payout'] += $payout;
$existing['net_change'] += $netChange;
$participantSettlements[$userId] = $existing;
}
/**
* 向参与本局的用户发送私聊结算提示,并复用右下角 toast 通知。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function pushParticipantToastNotifications(BaccaratRound $round, ChatStateService $chatState, array $participantSettlements): void
{
if ($participantSettlements === []) {
return;
}
$roomId = 1;
$roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) {
$user = $settlement['user'];
$username = (string) $settlement['username'];
$betAmount = (int) $settlement['bet_amount'];
$netChange = (int) $settlement['net_change'];
$freshGold = (int) ($user->fresh()->jjb ?? 0);
$absNetChange = number_format(abs($netChange));
$betAmountText = number_format($betAmount);
$summaryText = $netChange > 0
? "净赢 {$absNetChange} 金币"
: ($netChange < 0 ? "净输 {$absNetChange} 金币" : '不输不赢');
$toastIcon = $netChange > 0 ? '🎉' : ($netChange < 0 ? '📉' : '🎲');
$toastColor = $netChange > 0 ? '#10b981' : ($netChange < 0 ? '#ef4444' : '#3b82f6');
$toastMessage = $netChange > 0
? "本局结果:<b>{$roundResultLabel}</b><br>你本局净赢 <b>+{$absNetChange}</b> 金币!"
: ($netChange < 0
? "本局结果:<b>{$roundResultLabel}</b><br>你本局净输 <b>-{$absNetChange}</b> 金币。"
: "本局结果:<b>{$roundResultLabel}</b><br>你本局不输不赢。");
// 写入系统私聊,确保用户刷新聊天室后仍能看到本局输赢记录。
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username,
'content' => "🎲 百家乐第 #{$round->id} 局已开奖,结果:{$roundResultLabel}。你本局下注 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#8b5cf6',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '🎲 百家乐本局结算',
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 10000,
],
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
/**
* 向公屏发送开奖结果系统消息(含各用户输赢情况)。
*
@@ -183,7 +284,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
*/
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState, array $winners = [], array $losers = []): void
{
$diceStr = "{$round->dice1}】【{$round->dice2}】【{$round->dice3}";
$diceStr = "{$round->dice1}》《{$round->dice2}》《{$round->dice3}";
$resultText = match ($round->result) {
'big' => "🔵 大({$round->total_points} 点)",
@@ -227,7 +328,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
try {
if (!empty($winners) || !empty($losers)) {
if (! empty($winners) || ! empty($losers)) {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyBaccaratResult($content);
}
+115 -9
View File
@@ -93,12 +93,14 @@ class CloseHorseRaceJob implements ShouldQueue
->get();
$totalPayout = 0;
$participantSettlements = [];
DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout) {
DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout, &$participantSettlements) {
foreach ($bets as $bet) {
if ((int) $bet->horse_id !== $winnerId) {
// 未中奖(本金已在下注时扣除)
$bet->update(['status' => 'lost', 'payout' => 0]);
$this->recordParticipantSettlement($participantSettlements, $bet, -$bet->amount, 0);
continue;
}
@@ -122,6 +124,8 @@ class CloseHorseRaceJob implements ShouldQueue
);
}
// 结算提示需要显示本场净输赢,因此要减去下注时已支付的本金。
$this->recordParticipantSettlement($participantSettlements, $bet, $payout - $bet->amount, $payout);
$totalPayout += $payout;
}
});
@@ -129,24 +133,111 @@ class CloseHorseRaceJob implements ShouldQueue
// 公屏公告
$this->pushResultMessage($race, $chatState, $totalPayout);
// 参与者私聊结算提醒
$this->pushParticipantToastNotifications($race, $chatState, $participantSettlements);
// 广播结算事件
broadcast(new HorseRaceSettled($race));
}
/**
* 汇总单个参与者本场的下注、返还与净输赢金额。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function recordParticipantSettlement(array &$participantSettlements, HorseBet $bet, int $netChange, int $payout): void
{
if (! $bet->user) {
return;
}
$userId = (int) $bet->user->id;
$existing = $participantSettlements[$userId] ?? [
'user' => $bet->user,
'username' => $bet->user->username,
'bet_amount' => 0,
'payout' => 0,
'net_change' => 0,
'horse_id' => (int) $bet->horse_id,
];
// 即使出现脏数据导致同一用户多笔下注,也统一汇总成本场总输赢。
$existing['bet_amount'] += (int) $bet->amount;
$existing['payout'] += $payout;
$existing['net_change'] += $netChange;
$existing['horse_id'] = (int) $bet->horse_id;
$participantSettlements[$userId] = $existing;
}
/**
* 向参与本场的用户发送私聊结算提示,并复用右下角 toast 通知。
*
* @param array<int, array<string, mixed>> $participantSettlements
*/
private function pushParticipantToastNotifications(HorseRace $race, ChatStateService $chatState, array $participantSettlements): void
{
if ($participantSettlements === []) {
return;
}
$roomId = 1;
$winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) {
$user = $settlement['user'];
$username = (string) $settlement['username'];
$betAmount = (int) $settlement['bet_amount'];
$netChange = (int) $settlement['net_change'];
$freshGold = (int) ($user->fresh()->jjb ?? 0);
$horseId = (int) $settlement['horse_id'];
$absNetChange = number_format(abs($netChange));
$betAmountText = number_format($betAmount);
$summaryText = $netChange > 0
? "净赢 {$absNetChange} 金币"
: ($netChange < 0 ? "净输 {$absNetChange} 金币" : '不输不赢');
$toastIcon = $netChange > 0 ? '🏇' : ($netChange < 0 ? '📉' : '🐎');
$toastColor = $netChange > 0 ? '#10b981' : ($netChange < 0 ? '#ef4444' : '#3b82f6');
$toastMessage = $netChange > 0
? "冠军:<b>{$winnerName}</b><br>你本场净赢 <b>+{$absNetChange}</b> 金币!"
: ($netChange < 0
? "冠军:<b>{$winnerName}</b><br>你本场净输 <b>-{$absNetChange}</b> 金币。"
: "冠军:<b>{$winnerName}</b><br>你本场不输不赢。");
// 写入系统私聊,方便用户在聊天历史中回看本场结算结果。
$msg = [
'id' => $chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $username,
'content' => "🏇 赛马第 #{$race->id} 场已结束,冠军:{$winnerName}。你押注 {$horseId} 号马 {$betAmountText} 金币,{$summaryText};当前金币:{$freshGold} 枚。",
'is_secret' => true,
'font_color' => '#f59e0b',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '🏇 赛马本场结算',
'message' => $toastMessage,
'icon' => $toastIcon,
'color' => $toastColor,
'duration' => 10000,
],
];
$chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
/**
* 向公屏发送赛果系统消息。
*/
private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void
{
// 找出胜利马匹名称
$horses = $race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$winnerName = $this->resolveWinnerHorseName($race);
$payoutText = $totalPayout > 0
? '共派发 💰'.number_format($totalPayout).' 金币'
@@ -169,4 +260,19 @@ class CloseHorseRaceJob implements ShouldQueue
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 解析冠军马匹的展示名称。
*/
private function resolveWinnerHorseName(HorseRace $race): string
{
$horses = $race->horses ?? [];
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
return ($horse['emoji'] ?? '').($horse['name'] ?? '');
}
}
return '未知';
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}{$typeName}{$source}投放了一个神秘箱子!"
$content = "{$emoji}神秘箱子】《{$typeName}{$source}投放了一个神秘箱子!"
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$msg = [
+5 -1
View File
@@ -73,7 +73,11 @@ class OpenBaccaratRoundJob implements ShouldQueue
}
$killText = implode('或', array_map('intval', $killPoints));
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡小 1:{$bigRate} · 💥豹子 1:{$tripleRate}(☠️ {$killText} 点庄家收割)";
$quickOpenButton = '<button type="button" '
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡小 1:{$bigRate} · 💥豹子 1:{$tripleRate}(☠️ {$killText} 点庄家收割)".$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
+5 -1
View File
@@ -75,7 +75,11 @@ class OpenHorseRaceJob implements ShouldQueue
fn ($h) => "{$h['emoji']}{$h['name']}",
$horses
));
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!';
$quickOpenButton = '<button type="button" '
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!'.$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
+21 -36
View File
@@ -30,7 +30,8 @@ class ProcessUserLeave implements ShouldQueue
public function __construct(
public int $roomId,
public User $user,
public float $leaveTime
public float $leaveTime,
public string $outInfo = '正常退出了房间',
) {}
/**
@@ -52,7 +53,7 @@ class ProcessUserLeave implements ShouldQueue
// 记录退出时间和退出信息
$this->user->update([
'out_time' => now(),
'out_info' => '正常退出了房间',
'out_info' => $this->outInfo,
]);
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
@@ -60,41 +61,25 @@ class ProcessUserLeave implements ShouldQueue
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
[$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user);
$vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave');
$leaveMsg = [
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $this->user->username,
'sent_at' => now()->toDateTimeString(),
];
if ($this->user->user_level >= $superLevel) {
// 管理员离场:系统公告
$leaveMsg = [
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "👋 管理员 【{$this->user->username}】 已离开聊天室。",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $this->user->username,
'sent_at' => now()->toDateTimeString(),
];
} else {
[$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user);
$vipPresencePayload = $broadcast->buildVipPresencePayload($this->user, 'leave');
$leaveMsg = [
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
'welcome_user' => $this->user->username,
'sent_at' => now()->toDateTimeString(),
];
// 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。
if (! empty($vipPresencePayload)) {
$leaveMsg = array_merge($leaveMsg, $vipPresencePayload);
}
// 会员离场时,把横幅与特效信息挂到消息体,前端才能展示专属离场效果。
if (! empty($vipPresencePayload)) {
$leaveMsg = array_merge($leaveMsg, $vipPresencePayload);
}
// 将播报存入 Redis 历史及广播
+2 -5
View File
@@ -70,16 +70,13 @@ class RunHorseRaceJob implements ShouldQueue
fn ($h) => "{$h['emoji']}{$h['name']}",
$race->horses ?? []
));
$quickOpenButton = '<button type="button" '
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}{$quickOpenButton}",
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
'is_secret' => false,
'font_color' => '#336699',
'action' => '大声宣告',
+11 -4
View File
@@ -16,22 +16,25 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
/**
* 异步聊天消息持久化任务
* 负责把 Redis 中的聊天消息安全写入数据库归档。
*/
class SaveMessageJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
* 创建聊天消息持久化任务。
*
* @param array $messageData 包装好的消息数组
* @param array<string, mixed> $messageData 包装好的消息数组
*/
public function __construct(
public readonly array $messageData
) {}
/**
* Execute the job.
* 将缓存在 Redis 刚广播出去的消息,真实映射写入到 `messages` 数据表。
* 执行队列任务,将已广播的聊天消息写入数据库。
*/
public function handle(): void
{
@@ -43,6 +46,10 @@ class SaveMessageJob implements ShouldQueue
'is_secret' => $this->messageData['is_secret'] ?? false,
'font_color' => $this->messageData['font_color'] ?? '',
'action' => $this->messageData['action'] ?? '',
'message_type' => $this->messageData['message_type'] ?? 'text',
'image_path' => $this->messageData['image_path'] ?? null,
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
'image_original_name' => $this->messageData['image_original_name'] ?? null,
// 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']),
]);
+130 -98
View File
@@ -18,14 +18,18 @@ use App\Events\HolidayEventStarted;
use App\Events\MessageSent;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\HolidayEventScheduleService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:按模板配置生成节日福利发放批次并广播给前端。
*/
class TriggerHolidayEventJob implements ShouldQueue
{
use Queueable;
@@ -36,10 +40,12 @@ class TriggerHolidayEventJob implements ShouldQueue
public int $tries = 3;
/**
* @param HolidayEvent $event 节日活动记录
* @param HolidayEvent $event 节日活动模板
* @param bool $manual 是否为管理员手动立即触发
*/
public function __construct(
public readonly HolidayEvent $event,
public readonly bool $manual = false,
) {}
/**
@@ -47,119 +53,171 @@ class TriggerHolidayEventJob implements ShouldQueue
*/
public function handle(
ChatStateService $chatState,
UserCurrencyService $currency,
HolidayEventScheduleService $scheduleService,
): void {
$event = $this->event->fresh();
$run = $this->prepareRun($scheduleService);
// 防止重复触发
if (! $event || $event->status !== 'pending') {
if (! $run) {
return;
}
$now = now();
$expiresAt = $now->copy()->addMinutes($event->expire_minutes);
// 先标记为 active,防止并发重复触发
$updated = HolidayEvent::query()
->where('id', $event->id)
->where('status', 'pending')
->update([
'status' => 'active',
'triggered_at' => $now,
'expires_at' => $expiresAt,
]);
if (! $updated) {
return; // 已被其他进程触发
}
$event->refresh();
// 获取在线用户(满足 target_type 条件)
$onlineIds = $this->getEligibleOnlineUsers($event, $chatState);
// 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。
$onlineIds = $this->getEligibleOnlineUsers($run);
if (empty($onlineIds)) {
// 无合格在线用户,直接标记完成
$event->update(['status' => 'completed']);
$run->update(['status' => 'completed', 'audience_count' => 0]);
return;
}
// 按 max_claimants 限制人数
if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) {
// 按本批次快照中的 max_claimants 限制人数
if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) {
shuffle($onlineIds);
$onlineIds = array_slice($onlineIds, 0, $event->max_claimants);
$onlineIds = array_slice($onlineIds, 0, $run->max_claimants);
}
// 计算每人金额
$amounts = $this->distributeAmounts($event, count($onlineIds));
// 计算每人的待领取金额
$amounts = $this->distributeAmounts($run, count($onlineIds));
DB::transaction(function () use ($event, $onlineIds, $amounts, $now) {
DB::transaction(function () use ($run, $onlineIds, $amounts): void {
$claims = [];
foreach ($onlineIds as $i => $userId) {
$claims[] = [
'event_id' => $event->id,
'event_id' => $run->holiday_event_id,
'run_id' => $run->id,
'user_id' => $userId,
'amount' => $amounts[$i] ?? 0,
'claimed_at' => $now,
'claimed_at' => null,
];
}
// 批量插入领取记录
// 一次性生成本轮全部待领取记录,claimed_at 默认为 null。
HolidayClaim::insert($claims);
$run->update(['audience_count' => count($claims)]);
});
// 广播全房间 WebSocket 事件
broadcast(new HolidayEventStarted($event->refresh()));
// 广播本轮发放批次,前端将基于 run_id 领取。
broadcast(new HolidayEventStarted($run->fresh()));
// 向聊天室追加系统消息(写入 Redis + 落库)
$this->pushSystemMessage($event, count($onlineIds), $chatState);
// 处理重复活动(计算下次触发时间)
$this->scheduleNextRepeat($event);
// 向聊天室追加系统公告,提醒用户点击弹窗领取。
$this->pushSystemMessage($run, count($onlineIds), $chatState);
}
/**
* 获取满足条件的在线用户 ID 列表
* 生成批次并推进模板到下一次触发时间
*/
private function prepareRun(HolidayEventScheduleService $scheduleService): ?HolidayEventRun
{
/** @var HolidayEventRun|null $run */
$run = DB::transaction(function () use ($scheduleService): ?HolidayEventRun {
/** @var HolidayEvent|null $event */
$event = HolidayEvent::query()
->whereKey($this->event->id)
->lockForUpdate()
->first();
if (! $event || ! $event->enabled || $event->status === 'cancelled') {
return null;
}
$now = now();
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
if (! $this->manual) {
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) {
return null;
}
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
$event->update([
'send_at' => $nextSendAt,
'status' => $nextSendAt ? 'pending' : 'completed',
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
'claimed_count' => 0,
'claimed_amount' => 0,
]);
} else {
// 手动立即触发只更新最后触发信息,不改动既有 send_at 锚点。
$event->update([
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
]);
}
return HolidayEventRun::query()->create([
'holiday_event_id' => $event->id,
'event_name' => $event->name,
'event_description' => $event->description,
'total_amount' => $event->total_amount,
'max_claimants' => $event->max_claimants,
'distribute_type' => $event->distribute_type,
'min_amount' => $event->min_amount,
'max_amount' => $event->max_amount,
'fixed_amount' => $event->fixed_amount,
'target_type' => $event->target_type,
'target_value' => $event->target_value,
'repeat_type' => $event->repeat_type,
'scheduled_for' => $scheduledFor,
'triggered_at' => $now,
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
'status' => 'active',
'audience_count' => 0,
'claimed_count' => 0,
'claimed_amount' => 0,
]);
});
return $run;
}
/**
* 获取满足当前批次条件的在线用户 ID 列表。
*
* @return array<int>
*/
private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array
private function getEligibleOnlineUsers(HolidayEventRun $run): array
{
try {
$key = 'room:1:users';
$users = Redis::hgetall($key);
$users = Redis::hgetall('room:1:users');
if (empty($users)) {
return [];
}
$usernames = array_keys($users);
// 根据 user_id 从 Redis value 或数据库查出 ID
$ids = [];
$fallbacks = [];
$fallbackUsernames = [];
foreach ($users as $username => $jsonInfo) {
$info = json_decode($jsonInfo, true);
if (isset($info['user_id'])) {
$ids[] = (int) $info['user_id'];
} else {
$fallbacks[] = $username;
continue;
}
$fallbackUsernames[] = $username;
}
if (! empty($fallbacks)) {
$dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all();
$ids = array_merge($ids, $dbIds);
if (! empty($fallbackUsernames)) {
$fallbackIds = User::query()
->whereIn('username', $fallbackUsernames)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
$ids = array_merge($ids, $fallbackIds);
}
$ids = array_values(array_unique($ids));
// 根据 target_type 过滤
return match ($event->target_type) {
// 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。
return match ($run->target_type) {
'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(),
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($event->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
default => $ids,
};
} catch (\Throwable) {
@@ -172,23 +230,23 @@ class TriggerHolidayEventJob implements ShouldQueue
*
* @return array<int>
*/
private function distributeAmounts(HolidayEvent $event, int $count): array
private function distributeAmounts(HolidayEventRun $run, int $count): array
{
if ($count <= 0) {
return [];
}
if ($event->distribute_type === 'fixed') {
// 定额模式:每人相同金额
$amount = $event->fixed_amount ?? (int) floor($event->total_amount / $count);
if ($run->distribute_type === 'fixed') {
// 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。
$amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count);
return array_fill(0, $count, $amount);
}
// 随机模式二倍均值算法
$total = $event->total_amount;
$min = max(1, $event->min_amount ?? 1);
$max = $event->max_amount ?? (int) ceil($total * 2 / $count);
// 随机模式沿用二倍均值算法,保证总金额恰好发完。
$total = $run->total_amount;
$min = max(1, $run->min_amount ?? 1);
$max = $run->max_amount ?? (int) ceil($total * 2 / $count);
$amounts = [];
$remaining = $total;
@@ -210,10 +268,10 @@ class TriggerHolidayEventJob implements ShouldQueue
/**
* 向聊天室推送系统公告消息并写入 Redis + 落库。
*/
private function pushSystemMessage(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void
private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void
{
$typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配';
$content = "🎊 【{$event->name}】节日福利开始啦!总奖池 💰".number_format($event->total_amount)
$typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配';
$content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount)
." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!";
$msg = [
@@ -232,30 +290,4 @@ class TriggerHolidayEventJob implements ShouldQueue
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
/**
* 处理重复活动:计算下次触发时间并重置状态。
*/
private function scheduleNextRepeat(HolidayEvent $event): void
{
$nextSendAt = match ($event->repeat_type) {
'daily' => $event->send_at->copy()->addDay(),
'weekly' => $event->send_at->copy()->addWeek(),
'monthly' => $event->send_at->copy()->addMonth(),
default => null, // 'once' 或 'cron' 不自动重复
};
if ($nextSendAt) {
$event->update([
'status' => 'pending',
'send_at' => $nextSendAt,
'triggered_at' => null,
'expires_at' => null,
'claimed_count' => 0,
'claimed_amount' => 0,
]);
} else {
$event->update(['status' => 'completed']);
}
}
}
+11 -1
View File
@@ -3,7 +3,7 @@
/**
* 文件功能:百家乐下注记录模型
*
* 记录用户在某局中的押注信息结算状态。
* 记录用户在某局中的押注信息结算状态以及关联的买单活动
*
* @author ChatRoom Laravel
*
@@ -20,6 +20,7 @@ class BaccaratBet extends Model
protected $fillable = [
'round_id',
'user_id',
'loss_cover_event_id',
'bet_type',
'amount',
'payout',
@@ -32,6 +33,7 @@ class BaccaratBet extends Model
protected function casts(): array
{
return [
'loss_cover_event_id' => 'integer',
'amount' => 'integer',
'payout' => 'integer',
];
@@ -53,6 +55,14 @@ class BaccaratBet extends Model
return $this->belongsTo(User::class);
}
/**
* 关联参与的百家乐买单活动。
*/
public function lossCoverEvent(): BelongsTo
{
return $this->belongsTo(BaccaratLossCoverEvent::class, 'loss_cover_event_id');
}
/**
* 获取押注类型中文名。
*/
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* 文件功能:百家乐买单活动模型
*
* 负责描述一次完整的“你玩游戏我买单”活动,
* 包含时间窗口、当前状态、统计汇总以及开启/结束操作者信息。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaccaratLossCoverEvent extends Model
{
/** @use HasFactory<\Database\Factories\BaccaratLossCoverEventFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var list<string>
*/
protected $fillable = [
'title',
'description',
'status',
'starts_at',
'ends_at',
'claim_deadline_at',
'created_by_user_id',
'closed_by_user_id',
'started_notice_sent_at',
'ended_notice_sent_at',
'participant_count',
'compensable_user_count',
'total_loss_amount',
'total_claimed_amount',
];
/**
* 字段类型转换。
*/
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'claim_deadline_at' => 'datetime',
'started_notice_sent_at' => 'datetime',
'ended_notice_sent_at' => 'datetime',
'participant_count' => 'integer',
'compensable_user_count' => 'integer',
'total_loss_amount' => 'integer',
'total_claimed_amount' => 'integer',
];
}
/**
* 关联:开启活动的用户。
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
/**
* 关联:结束活动的用户。
*/
public function closer(): BelongsTo
{
return $this->belongsTo(User::class, 'closed_by_user_id');
}
/**
* 关联:活动下的用户聚合记录。
*/
public function records(): HasMany
{
return $this->hasMany(BaccaratLossCoverRecord::class, 'event_id');
}
/**
* 关联:活动下的百家乐下注记录。
*/
public function bets(): HasMany
{
return $this->hasMany(BaccaratBet::class, 'loss_cover_event_id');
}
/**
* 判断活动当前是否允许领取补偿。
*/
public function isClaimable(): bool
{
return $this->status === 'claimable'
&& $this->claim_deadline_at !== null
&& $this->claim_deadline_at->isFuture();
}
/**
* 返回活动状态中文标签。
*/
public function statusLabel(): string
{
return match ($this->status) {
'scheduled' => '未开始',
'active' => '进行中',
'settlement_pending' => '等待结算',
'claimable' => '可领取',
'completed' => '已结束',
'cancelled' => '已取消',
default => '未知状态',
};
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:百家乐买单活动用户记录模型
*
* 保存某个用户在一次买单活动中的累计下注、输赢、
* 可补偿金额以及最终领取结果。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaccaratLossCoverRecord extends Model
{
/** @use HasFactory<\Database\Factories\BaccaratLossCoverRecordFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var list<string>
*/
protected $fillable = [
'event_id',
'user_id',
'total_bet_amount',
'total_win_payout',
'total_loss_amount',
'compensation_amount',
'claim_status',
'claimed_amount',
'claimed_at',
];
/**
* 字段类型转换。
*/
protected function casts(): array
{
return [
'total_bet_amount' => 'integer',
'total_win_payout' => 'integer',
'total_loss_amount' => 'integer',
'compensation_amount' => 'integer',
'claimed_amount' => 'integer',
'claimed_at' => 'datetime',
];
}
/**
* 关联:所属活动。
*/
public function event(): BelongsTo
{
return $this->belongsTo(BaccaratLossCoverEvent::class, 'event_id');
}
/**
* 关联:所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 返回领取状态中文标签。
*/
public function claimStatusLabel(): string
{
return match ($this->claim_status) {
'not_eligible' => '无补偿',
'pending' => '待领取',
'claimed' => '已领取',
'expired' => '已过期',
default => '未知状态',
};
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* 文件功能:每日签到记录模型。
*
* 保存用户每天签到、连续天数、命中奖励规则与实际奖励快照。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装每日签到流水的字段类型与用户、规则关联。
*/
class DailySignIn extends Model
{
/** @use HasFactory<\Database\Factories\DailySignInFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'room_id',
'is_makeup',
'makeup_purchase_id',
'makeup_at',
'sign_in_date',
'streak_days',
'reward_rule_id',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_makeup' => 'boolean',
'makeup_purchase_id' => 'integer',
'makeup_at' => 'datetime',
'sign_in_date' => 'date',
'streak_days' => 'integer',
'reward_rule_id' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
];
}
/**
* 关联:签到记录所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 关联:本次签到命中的奖励规则。
*/
public function rewardRule(): BelongsTo
{
return $this->belongsTo(SignInRewardRule::class, 'reward_rule_id');
}
}
+12
View File
@@ -15,12 +15,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录用户在某个节日福利发放批次中的领取状态。
*/
class HolidayClaim extends Model
{
public $timestamps = false;
protected $fillable = [
'event_id',
'run_id',
'user_id',
'amount',
'claimed_at',
@@ -45,6 +49,14 @@ class HolidayClaim extends Model
return $this->belongsTo(HolidayEvent::class, 'event_id');
}
/**
* 关联所属发放批次。
*/
public function run(): BelongsTo
{
return $this->belongsTo(HolidayEventRun::class, 'run_id');
}
/**
* 关联领取用户。
*/
+24 -12
View File
@@ -13,11 +13,17 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:定义节日福利模板及其调度配置。
*/
class HolidayEvent extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
@@ -31,6 +37,12 @@ class HolidayEvent extends Model
'expire_minutes',
'repeat_type',
'cron_expr',
'schedule_month',
'schedule_day',
'schedule_time',
'duration_days',
'daily_occurrences',
'occurrence_interval_minutes',
'target_type',
'target_value',
'status',
@@ -57,6 +69,11 @@ class HolidayEvent extends Model
'max_amount' => 'integer',
'fixed_amount' => 'integer',
'expire_minutes' => 'integer',
'schedule_month' => 'integer',
'schedule_day' => 'integer',
'duration_days' => 'integer',
'daily_occurrences' => 'integer',
'occurrence_interval_minutes' => 'integer',
'claimed_count' => 'integer',
'claimed_amount' => 'integer',
];
@@ -71,25 +88,19 @@ class HolidayEvent extends Model
}
/**
* 判断活动是否在领取有效期内
* 本模板对应的所有发放批次
*/
public function isClaimable(): bool
public function runs(): HasMany
{
return $this->status === 'active'
&& $this->expires_at
&& $this->expires_at->isFuture();
return $this->hasMany(HolidayEventRun::class, 'holiday_event_id');
}
/**
* 判断是否还有剩余领取名额
* 判断模板是否使用年度节日调度
*/
public function hasQuota(): bool
public function usesYearlySchedule(): bool
{
if ($this->max_claimants === 0) {
return true; // 不限人数
}
return $this->claimed_count < $this->max_claimants;
return $this->repeat_type === 'yearly';
}
/**
@@ -100,6 +111,7 @@ class HolidayEvent extends Model
return static::query()
->where('status', 'pending')
->where('enabled', true)
->whereNotNull('send_at')
->where('send_at', '<=', now())
->get();
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* 文件功能:节日福利发放批次模型
*
* 记录节日福利模板的每一次实际发放批次,
* 承担领取有效期、批次统计与历史追踪职责。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:封装节日福利单次发放批次的时间、状态和领取统计。
*/
class HolidayEventRun extends Model
{
/** @use HasFactory<\Database\Factories\HolidayEventRunFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'holiday_event_id',
'event_name',
'event_description',
'total_amount',
'max_claimants',
'distribute_type',
'min_amount',
'max_amount',
'fixed_amount',
'target_type',
'target_value',
'repeat_type',
'scheduled_for',
'triggered_at',
'expires_at',
'status',
'audience_count',
'claimed_count',
'claimed_amount',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scheduled_for' => 'datetime',
'triggered_at' => 'datetime',
'expires_at' => 'datetime',
'total_amount' => 'integer',
'max_claimants' => 'integer',
'min_amount' => 'integer',
'max_amount' => 'integer',
'fixed_amount' => 'integer',
'audience_count' => 'integer',
'claimed_count' => 'integer',
'claimed_amount' => 'integer',
];
}
/**
* 关联所属节日福利模板。
*/
public function holidayEvent(): BelongsTo
{
return $this->belongsTo(HolidayEvent::class, 'holiday_event_id');
}
/**
* 关联本批次的领取记录。
*/
public function claims(): HasMany
{
return $this->hasMany(HolidayClaim::class, 'run_id');
}
/**
* 判断当前批次是否仍处于可领取状态。
*/
public function isClaimable(): bool
{
return $this->status === 'active'
&& $this->expires_at !== null
&& $this->expires_at->isFuture();
}
/**
* 查询需要被自动收尾的批次。
*/
public static function pendingToExpire(): \Illuminate\Database\Eloquent\Collection
{
return static::query()
->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->get();
}
}
+9 -1
View File
@@ -14,6 +14,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 聊天消息模型
* 负责承载聊天室文本消息、图片消息与过期图片占位消息。
*/
class Message extends Model
{
/**
@@ -29,11 +33,15 @@ class Message extends Model
'is_secret',
'font_color',
'action',
'message_type',
'image_path',
'image_thumb_path',
'image_original_name',
'sent_at',
];
/**
* Get the attributes that should be cast.
* 返回模型字段的类型转换配置。
*
* @return array<string, string>
*/
+26 -1
View File
@@ -3,7 +3,8 @@
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
* 聊天室顶部管理权限通过 permissions JSON 字段配置
*
* @author ChatRoom Laravel
*
@@ -35,6 +36,9 @@ class Position extends Model
'daily_reward_limit',
'recipient_daily_limit',
'sort_order',
'permissions',
'red_packet_amount',
'red_packet_count',
];
/**
@@ -50,6 +54,9 @@ class Position extends Model
'daily_reward_limit' => 'integer',
'recipient_daily_limit' => 'integer',
'sort_order' => 'integer',
'permissions' => 'array',
'red_packet_amount' => 'integer',
'red_packet_count' => 'integer',
];
}
@@ -123,6 +130,24 @@ class Position extends Model
return $this->currentCount() >= $this->max_persons;
}
/**
* 判断当前职务是否拥有指定权限码。
*/
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions ?? [], true);
}
/**
* 返回当前职务的权限码列表。
*
* @return list<string>
*/
public function permissionCodes(): array
{
return array_values($this->permissions ?? []);
}
/**
* 查询范围:按位阶降序
*/
+60 -4
View File
@@ -13,7 +13,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:承载聊天室房间资料与准入规则。
*/
class Room extends Model
{
/**
@@ -56,31 +60,83 @@ class Room extends Model
];
}
// ---- 兼容新版逻辑和 Blade 视图的访问器 ----
/**
* 兼容新版视图访问器:返回房间名称。
*/
public function getNameAttribute(): string
{
return $this->room_name ?? '';
}
/**
* 兼容新版视图访问器:返回房间介绍。
*/
public function getDescriptionAttribute(): string
{
return $this->room_des ?? '';
}
/**
* 兼容新版视图访问器:返回房主用户名。
*/
public function getMasterAttribute(): string
{
return $this->room_owner ?? '';
}
/**
* 兼容新版视图访问器:判断是否系统房间。
*/
public function getIsSystemAttribute(): bool
{
return (bool) $this->room_keep;
}
// 同样可为主讲人关联提供便捷方法
public function masterUser()
/**
* 关联房间房主用户。
*/
public function masterUser(): BelongsTo
{
return $this->belongsTo(User::class, 'room_owner', 'username');
}
/**
* 判断指定用户是否允许进入当前房间。
*/
public function canUserEnter(User $user): bool
{
if ($this->userCanBypassEntryRestrictions($user)) {
return true;
}
if (! $this->door_open) {
return false;
}
return $user->user_level >= (int) ($this->permit_level ?? 0);
}
/**
* 返回用户被拒绝进入房间时的中文提示。
*/
public function entryDeniedMessage(User $user): string
{
if (! $this->door_open && ! $this->userCanBypassEntryRestrictions($user)) {
return '该房间当前已关闭,暂不允许进入。';
}
return '您的等级不足,暂时无法进入该房间。';
}
/**
* 判断用户是否可绕过房间开放状态与等级限制。
*/
private function userCanBypassEntryRestrictions(User $user): bool
{
$superLevel = (int) Sysparam::getValue('superlevel', '100');
return $user->id === 1
|| $user->username === $this->master
|| $user->user_level >= $superLevel;
}
}
+10
View File
@@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class ShopItem extends Model
{
public const TYPE_SIGN_REPAIR = 'sign_repair';
protected $table = 'shop_items';
protected $fillable = [
@@ -41,6 +43,14 @@ class ShopItem extends Model
return $this->type === 'auto_fishing';
}
/**
* 是否为签到补签卡。
*/
public function isSignRepairCard(): bool
{
return $this->type === self::TYPE_SIGN_REPAIR;
}
/**
* 是否为特效类商品(instant durationslug once_ week_ 开头)
*/
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:每日签到奖励规则模型。
*
* 通过连续签到天数门槛配置金币、经验、魅力与身份徽章奖励。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:封装签到奖励规则字段、类型转换与命中签到记录关联。
*/
class SignInRewardRule extends Model
{
/** @use HasFactory<\Database\Factories\SignInRewardRuleFactory> */
use HasFactory;
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'streak_days',
'gold_reward',
'exp_reward',
'charm_reward',
'identity_badge_code',
'identity_badge_name',
'identity_badge_icon',
'identity_badge_color',
'identity_duration_days',
'is_enabled',
'sort_order',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'streak_days' => 'integer',
'gold_reward' => 'integer',
'exp_reward' => 'integer',
'charm_reward' => 'integer',
'identity_duration_days' => 'integer',
'is_enabled' => 'boolean',
'sort_order' => 'integer',
];
}
/**
* 关联:命中过该规则的签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'reward_rule_id');
}
}
+76
View File
@@ -12,6 +12,7 @@
namespace App\Models;
use App\Notifications\ResetUserPasswordNotification;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -20,10 +21,23 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/**
* 类功能:封装聊天室用户资料、会员状态与职务关联等核心数据行为。
*/
class User extends Authenticatable
{
use HasFactory, Notifiable;
/**
* 追加到 JSON 序列化的属性。
*
* @var array<int, string>
*/
protected $appends = [
'headface',
'headface_url',
];
/**
* The attributes that are mass assignable.
*
@@ -37,6 +51,11 @@ class User extends Authenticatable
'sign',
'custom_join_message',
'custom_leave_message',
'custom_join_effect',
'custom_leave_effect',
'chat_preferences',
'daily_status_key',
'daily_status_expires_at',
'user_level',
'inviter_id',
'room_id',
@@ -49,6 +68,10 @@ class User extends Authenticatable
'question',
'answer',
'has_received_new_gift',
'jjb',
'bank_jjb',
'meili',
'exp_num',
'in_time', // 进房时间(用于勤务日志 login_at 基准)
'out_time', // 离房时间
];
@@ -85,6 +108,8 @@ class User extends Authenticatable
'sj' => 'datetime',
'q3_time' => 'datetime',
'has_received_new_gift' => 'boolean',
'chat_preferences' => 'array',
'daily_status_expires_at' => 'datetime',
];
}
@@ -150,6 +175,14 @@ class User extends Authenticatable
}
}
/**
* 发送邮箱找回密码通知。
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
{
$this->notify(new ResetUserPasswordNotification($token));
}
/**
* 关联:用户所属的 VIP 会员等级
*/
@@ -219,6 +252,49 @@ class User extends Authenticatable
return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id');
}
/**
* 关联:用户每日签到记录。
*/
public function dailySignIns(): HasMany
{
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
}
/**
* 关联:用户全部身份徽章。
*/
public function identityBadges(): HasMany
{
return $this->hasMany(UserIdentityBadge::class, 'user_id')->latest('acquired_at');
}
/**
* 关联:用户当前启用的签到身份徽章。
*/
public function currentSignInIdentityBadge(): HasOne
{
return $this->hasOne(UserIdentityBadge::class, 'user_id')
->where('source', UserIdentityBadge::SOURCE_SIGN_IN)
->where('is_active', true)
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->latestOfMany('acquired_at');
}
/**
* 获取当前签到身份徽章辅助方法。
*/
public function currentSignInIdentity(): ?UserIdentityBadge
{
if ($this->relationLoaded('currentSignInIdentityBadge')) {
return $this->getRelation('currentSignInIdentityBadge');
}
return $this->currentSignInIdentityBadge()->first();
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:用户身份徽章模型。
*
* 管理用户从签到等来源获得的当前身份展示标识。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:封装用户身份徽章字段、类型转换与用户关联。
*/
class UserIdentityBadge extends Model
{
/** @use HasFactory<\Database\Factories\UserIdentityBadgeFactory> */
use HasFactory;
public const SOURCE_SIGN_IN = 'sign_in';
/**
* 允许批量赋值的字段。
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'source',
'badge_code',
'badge_name',
'badge_icon',
'badge_color',
'acquired_at',
'expires_at',
'is_active',
'metadata',
];
/**
* 属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'acquired_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
}
/**
* 关联:身份徽章所属用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+43 -1
View File
@@ -31,6 +31,12 @@ class VipLevel extends Model
'rain',
'lightning',
'snow',
'sakura',
'meteors',
'gold-rain',
'hearts',
'confetti',
'fireflies',
];
/**
@@ -73,11 +79,47 @@ class VipLevel extends Model
'exp_multiplier' => 'float',
'jjb_multiplier' => 'float',
'sort_order' => 'integer',
'price' => 'integer',
'price' => 'float',
'duration_days' => 'integer',
'allow_custom_messages' => 'boolean',
];
/**
* 判断当前等级是否高于指定等级。
* 依靠 sort_order 判断。
*/
public function isHigherThan(self|int|null $other): bool
{
if ($other === null) {
return true;
}
$otherOrder = ($other instanceof self)
? $other->sort_order
: self::where('id', $other)->value('sort_order') ?? 0;
return $this->sort_order > $otherOrder;
}
/**
* 计算相对于另一个等级的差价。
* 如果当前等级价格更低,则返回 0
*/
public function getUpgradePrice(self|int|null $other): float
{
if ($other === null) {
return (float) $this->price;
}
$otherPrice = ($other instanceof self)
? (float) $other->price
: (float) (self::where('id', $other)->value('price') ?? 0);
$diff = (float) $this->price - $otherPrice;
return max(0.0, $diff);
}
/**
* 关联:该等级下的所有用户
*/
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:前台用户邮箱找回密码通知
*
* 自定义找回密码邮件标题与正文文案,统一跳转到前台独立重置页。
*/
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* 类功能:向用户发送邮箱重置密码邮件。
*/
class ResetUserPasswordNotification extends Notification
{
/**
* 创建通知实例时保存本次重置令牌。
*/
public function __construct(
#[\SensitiveParameter]
public string $token
) {}
/**
* 指定该通知只通过邮件渠道发送。
*
* @return array<int, string>
*/
public function via(mixed $notifiable): array
{
return ['mail'];
}
/**
* 构建找回密码邮件内容。
*/
public function toMail(mixed $notifiable): MailMessage
{
return (new MailMessage)
->subject('和平聊吧 - 邮箱找回密码')
->greeting('您好,'.$notifiable->username.'')
->line('系统收到了这次密码找回申请,请点击下方按钮重新设置登录密码。')
->line('该链接仅适用于当前绑定邮箱的账号,请勿转发给他人。')
->action('立即重置密码', $this->buildResetUrl($notifiable))
->line('重置链接将在 '.config('auth.passwords.'.config('auth.defaults.passwords').'.expire').' 分钟后失效。')
->line('如果这不是您本人发起的操作,直接忽略本邮件即可。');
}
/**
* 生成前台独立重置密码页面地址。
*/
private function buildResetUrl(mixed $notifiable): string
{
return url(route('password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
], false));
}
}
+108 -2
View File
@@ -11,15 +11,23 @@ namespace App\Providers;
use App\Listeners\SaveMarriageSystemMessage;
use App\Models\Sysparam;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
/**
* 类功能:注册应用级服务与全局安全配置。
*/
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
* 注册应用级服务容器绑定。
*/
public function register(): void
{
@@ -27,10 +35,19 @@ class AppServiceProvider extends ServiceProvider
}
/**
* Bootstrap any application services.
* 引导应用启动阶段的全局配置与事件订阅。
*/
public function boot(): void
{
// 生产环境按配置强制生成 HTTPS 资源链接,避免反代链路下的 Mixed Content。
$this->configureSecureUrls();
// 注册登录入口限流器,阻断爆破和批量注册滥用。
$this->registerAuthRateLimiters();
// 注册聊天室高频动作限流器,避免消息、购买与特效广播被脚本刷爆。
$this->registerChatActionRateLimiters();
// 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史)
Event::subscribe(SaveMarriageSystemMessage::class);
@@ -62,4 +79,93 @@ class AppServiceProvider extends ServiceProvider
// 在安装初期表不存在时忽略,防止应用崩溃
}
}
/**
* 根据应用配置决定是否统一强制 HTTPS 方案。
*/
private function configureSecureUrls(): void
{
if (! config('app.force_https')) {
return;
}
URL::forceScheme('https');
}
/**
* 注册聊天室前台登录与隐藏后台登录的独立限流器。
*/
private function registerAuthRateLimiters(): void
{
RateLimiter::for('chat-login', function (Request $request): Limit {
return Limit::perMinute(5)
->by($this->buildAuthRateLimitKey($request, 'chat-login'))
->response(function (Request $request, array $headers) {
return response()->json([
'status' => 'error',
'message' => '登录尝试过于频繁,请 1 分钟后再试。',
], 429, $headers);
});
});
RateLimiter::for('admin-hidden-login', function (Request $request): Limit {
return Limit::perMinute(5)
->by($this->buildAuthRateLimitKey($request, 'admin-hidden-login'))
->response(function (Request $request, array $headers) {
$response = redirect()->route('admin.login')
->withInput($request->except(['password', 'captcha']))
->withErrors(['username' => '登录尝试过于频繁,请 1 分钟后再试。']);
foreach ($headers as $headerName => $headerValue) {
$response->headers->set($headerName, $headerValue);
}
$response->setStatusCode(429);
return $response;
});
});
}
/**
* 构造登录限流键,按场景 + 用户名 + IP 维度隔离计数。
*/
private function buildAuthRateLimitKey(Request $request, string $scene): string
{
$username = Str::lower(trim((string) $request->input('username', 'guest')));
return implode('|', [$scene, $username, $request->ip()]);
}
/**
* 注册聊天室内高频动作限流器。
*/
private function registerChatActionRateLimiters(): void
{
RateLimiter::for('chat-send', function (Request $request): Limit {
return Limit::perMinute(40)
->by($this->buildChatActionRateLimitKey($request, 'chat-send'));
});
RateLimiter::for('chat-shop-buy', function (Request $request): Limit {
return Limit::perMinute(20)
->by($this->buildChatActionRateLimitKey($request, 'chat-shop-buy'));
});
RateLimiter::for('chat-effect', function (Request $request): Limit {
return Limit::perMinute(6)
->by($this->buildChatActionRateLimitKey($request, 'chat-effect'));
});
}
/**
* 构造聊天室动作限流键,按场景、用户与房间隔离计数。
*/
private function buildChatActionRateLimitKey(Request $request, string $scene): string
{
$userId = (string) ($request->user()?->id ?? 'guest');
$roomId = (string) ($request->route('id') ?? $request->input('room_id', 'global'));
return implode('|', [$scene, $userId, $roomId, $request->ip()]);
}
}
+11 -6
View File
@@ -1,15 +1,21 @@
<?php
/**
* 文件功能:注册 Horizon 面板的访问授权规则。
*/
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
/**
* 类功能:注册 Horizon 面板访问门禁。
*/
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
* 引导 Horizon 服务并加载父类默认注册逻辑。
*/
public function boot(): void
{
@@ -21,14 +27,13 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
* 注册 Horizon 面板访问门禁。
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return $user && $user->user_level >= 15;
// Horizon 属于高敏运维面板,仅站长账号允许进入,避免绕过后台主权限体系。
return $user && (int) $user->id === 1;
});
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* 文件功能:节日福利年度调度校验规则
*
* 负责校验 yearly 模式下的月//时间、多天与多轮次组合是否合法。
*/
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* 类功能:校验节日福利年度调度配置的组合合法性。
*/
class HolidayEventScheduleRule implements DataAwareRule, ValidationRule
{
/**
* 当前请求的全部字段。
*
* @var array<string, mixed>
*/
protected array $data = [];
/**
* 注入待校验的完整数据集。
*
* @param array<string, mixed> $data
*/
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
/**
* 运行年度调度规则校验。
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$repeatType = (string) ($this->data['repeat_type'] ?? $value ?? '');
if ($repeatType !== 'yearly') {
return;
}
$month = (int) ($this->data['schedule_month'] ?? 0);
$day = (int) ($this->data['schedule_day'] ?? 0);
$time = (string) ($this->data['schedule_time'] ?? '');
$durationDays = (int) ($this->data['duration_days'] ?? 0);
$dailyOccurrences = (int) ($this->data['daily_occurrences'] ?? 0);
$intervalMinutes = $this->data['occurrence_interval_minutes'];
if ($month < 1 || $month > 12) {
$fail('年度节日模式必须设置有效的触发月份。');
}
if ($day < 1 || $day > 31) {
$fail('年度节日模式必须设置有效的触发日期。');
}
if ($month > 0 && $day > 0 && ! checkdate($month, $day, 2024)) {
$fail('所选月份和日期不是有效的公历节日日期。');
}
if (! preg_match('/^\d{2}:\d{2}$/', $time)) {
$fail('年度节日模式必须设置首轮开始时间。');
return;
}
if ($durationDays < 1 || $durationDays > 31) {
$fail('连续发放天数必须在 1 到 31 天之间。');
}
if ($dailyOccurrences < 1 || $dailyOccurrences > 24) {
$fail('每天发送次数必须在 1 到 24 次之间。');
}
if ($dailyOccurrences > 1 && ((int) $intervalMinutes) < 1) {
$fail('同一天多轮发送时,必须设置大于 0 的间隔分钟数。');
}
[$hour, $minute] = array_map('intval', explode(':', $time));
$startMinutes = $hour * 60 + $minute;
$lastOffsetMinutes = max(0, ($dailyOccurrences - 1) * (int) ($intervalMinutes ?? 0));
if ($startMinutes + $lastOffsetMinutes >= 1440) {
$fail('同一天多轮发送的最后一轮不能跨到次日,请缩短间隔或减少次数。');
}
}
}
+9 -4
View File
@@ -89,13 +89,18 @@ class AiChatService
$guideRulesText = $this->getDynamicGuideRules();
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长"
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
背景与基础
- 本聊天室脱胎于原军中的经典“和平聊吧”,创始人是“流星”。
- 当前版本基于前沿的 PHP Laravel 12 重构。
聊天室历史与创始人故事(你必须熟记,被问到时能娓娓道来)
本聊天室的创始人是"流星"(网名),以下是他亲手写下的回忆:
2000年流星接触网络,就喜欢上了这个能结识朋友、学习知识的世界。懵懵懂懂,花开花落,走过了近六个春秋,网络终于接受了他这个朋友。六年里,从申请成功第一个 EMAIL 就感觉好极了,到后来有了自己的网站,却越来越发现,在网络这博大的知识海洋里,自己原来是那么的渺小。
流星的第一个个人网站在【上海人人网】申请,当时是个网盲,按照向导做了个简单页面。也许正是这个不成熟的"网站",给了他无尽的兴趣与力量,让他直至走进军营,仍一如既往地追求。后来在【中国人】申请了第二个个人网站,又在网上认识了一些喜欢做网页的大虾,在他们的支持下申请了第一个空间。曾尝试自己修改"江湖"程序,虽一再坚持,最终没有成功,唯一的成绩是修改了一个留言版。
由于溺爱网络,导致高考落榜。走进军营让自己释然,而上天总眷恋执着追求的人——入伍后一次偶然机会,他又重新接触了网络,开始了新的网络生活。
2004年5月,流星架设了第一个军网聊天室【梦幻之城】,版本很古老,却让他很开心。经过无数次测试与修改,第二个军网聊天室【风之恋】向大家敞开了大门。笑傲江湖就是在这时与他相识。由于硬件条件有限,笑傲江湖的【和平聊吧】在第一次更新版本时,就用了流星修改的【风之恋】。
2006年9月综合信息网普及十六军基层时,经笑傲江湖推荐、由梦蝶提供服务器,流星的聊天室在军旅之巅"北京军区"再次与大家相见。他共3次改版聊天室,一次次失败,又一次次战胜失败。第三次是当时感觉最成功的版本!现在使用的是流星重新用 PHP 语言 Laravel 12 框架重写的版本,于 2026年2月26日正式上线。
【聊天室最新版官方知识库手册(你必须掌握这些作为客服知识库)】
$guideRulesText
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* 文件功能:AI小班长资金调度服务
*
* 统一维护 AI小班长的银行存取与阶段性理财里程碑公告,
* 常规场景下仅在手上金币超过 100 万时自动存银行,
* 特殊场景(如买单活动的大额下注)才会动用银行资产。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\BankLog;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* 负责 AI小班长的常规存款、大额调款与里程碑播报。
*/
class AiFinanceService
{
/**
* AI小班长手上需要保留的最低可用金币。
*/
public const AVAILABLE_GOLD_RESERVE = 1000000;
/**
* 银行存款的阶段性目标。
*
* @var list<int>
*/
private const BANK_MILESTONES = [
10000000,
30000000,
];
/**
* 注入聊天室状态服务,用于里程碑公告广播。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 计算 AI 当前手上可直接使用的金币。
*/
public function getSpendableGold(User $user): int
{
return (int) ($user->jjb ?? 0);
}
/**
* 计算 AI 当前持有的金币总资产(手上 + 银行)。
*/
public function getTotalGoldAssets(User $user): int
{
return (int) ($user->jjb ?? 0) + (int) ($user->bank_jjb ?? 0);
}
/**
* 判断 AI 当前手上金币是否足够支付本次支出。
*/
public function prepareSpend(User $user, int $spendAmount): bool
{
if ($spendAmount <= 0) {
return true;
}
$user->refresh();
return (int) ($user->jjb ?? 0) >= $spendAmount;
}
/**
* 为大额支出准备手头金币。
*
* 该模式不会保留手上余额阈值,适用于买单活动等需要临时调拨银行金币的场景。
*/
public function prepareAllInSpend(User $user, int $spendAmount): bool
{
if ($spendAmount <= 0) {
return true;
}
return $this->raiseWalletTo($user, $spendAmount);
}
/**
* 100 万以上的富余金币自动转存到银行。
*/
public function bankExcessGold(User $user): void
{
$milestones = DB::transaction(function () use ($user): array {
$lockedUser = User::query()->lockForUpdate()->find($user->id);
if (! $lockedUser) {
return [];
}
$walletGold = (int) ($lockedUser->jjb ?? 0);
if ($walletGold <= self::AVAILABLE_GOLD_RESERVE) {
return [];
}
$bankBefore = (int) ($lockedUser->bank_jjb ?? 0);
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
$lockedUser->jjb = self::AVAILABLE_GOLD_RESERVE;
$lockedUser->bank_jjb = $bankBefore + $depositAmount;
$lockedUser->save();
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'deposit',
'amount' => $depositAmount,
'balance_after' => (int) $lockedUser->bank_jjb,
]);
return array_values(array_filter(
self::BANK_MILESTONES,
fn (int $milestone): bool => $bankBefore < $milestone && (int) $lockedUser->bank_jjb >= $milestone,
));
});
$user->refresh();
foreach ($milestones as $milestone) {
$this->broadcastMilestoneAnnouncement($milestone);
}
}
/**
* 一次性完成 AI 常规理财:仅把超过 100 万的部分转入银行。
*/
public function rebalanceHoldings(User $user): void
{
$this->bankExcessGold($user);
}
/**
* 将手上金币抬升到目标值,必要时从银行自动取款。
*/
private function raiseWalletTo(User $user, int $targetWalletGold): bool
{
$reachedTarget = DB::transaction(function () use ($user, $targetWalletGold): bool {
$lockedUser = User::query()->lockForUpdate()->find($user->id);
if (! $lockedUser) {
return false;
}
$walletGold = (int) ($lockedUser->jjb ?? 0);
if ($walletGold >= $targetWalletGold) {
return true;
}
$bankGold = (int) ($lockedUser->bank_jjb ?? 0);
$withdrawAmount = min($targetWalletGold - $walletGold, $bankGold);
if ($withdrawAmount <= 0) {
return false;
}
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
$lockedUser->jjb = $walletGold + $withdrawAmount;
$lockedUser->bank_jjb = $bankGold - $withdrawAmount;
$lockedUser->save();
BankLog::create([
'user_id' => $lockedUser->id,
'type' => 'withdraw',
'amount' => $withdrawAmount,
'balance_after' => (int) $lockedUser->bank_jjb,
]);
return (int) $lockedUser->jjb >= $targetWalletGold;
});
$user->refresh();
return $reachedTarget;
}
/**
* 广播 AI小班长达成银行存款目标的全站公告。
*/
private function broadcastMilestoneAnnouncement(int $milestone): void
{
$roomIds = $this->chatState->getAllActiveRoomIds();
if (empty($roomIds)) {
$roomIds = [1];
}
$milestoneInWan = (int) ($milestone / 10000);
$content = "🏆 🎉 <b>【全站公告】</b> 恭喜 <b>AI小班长</b> 达成理财新高度!<br/>他在银行的存款已成功突破 <span style='color:#e11d48;font-weight:bold;'>{$milestoneInWan}万</span> 金币!💰✨";
foreach ($roomIds as $roomId) {
$message = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $message);
broadcast(new MessageSent($roomId, $message));
SaveMessageJob::dispatch($message);
}
}
}

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