increment('visit_num'); // 用户进房时间刷新 $user->update(['in_time' => now()]); // 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息) $superLevel = (int) Sysparam::getValue('superlevel', '100'); // 获取当前在职职务信息(用于内容显示) $activePosition = $user->activePosition; $userData = [ '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, $userData); // 2. 广播 UserJoined 事件,通知房间内的其他人 broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); // 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告 $newbieEffect = null; if (! $user->has_received_new_gift) { // 通过统一积分服务发放新人礼包 6666 金币并记录流水 $this->currencyService->change( $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id, ); $user->update(['has_received_new_gift' => true]); // 发送新人专属欢迎公告 $newbieMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => '', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $newbieMsg); broadcast(new MessageSent($id, $newbieMsg)); // 广播烟花特效给此时已在房间的其他用户 broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers(); // 传给前端,让新人自己的屏幕上也燃放烟花 $newbieEffect = 'fireworks'; } // 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报 // 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条 $this->chatState->removeOldWelcomeMessages($id, $user->username); if ($user->user_level >= $superLevel) { // 管理员专属:全房间烟花 broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username)); $welcomeMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => 'admin_welcome', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $welcomeMsg); broadcast(new MessageSent($id, $welcomeMsg)); } else { // 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词) [$text, $color] = $this->broadcast->buildEntryBroadcast($user); $generalWelcomeMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '进出播报', 'to_user' => '大家', 'content' => "{$text}", 'is_secret' => false, 'font_color' => $color, 'action' => 'system_welcome', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $generalWelcomeMsg); broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers(); } // 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染 // 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的 $allHistory = $this->chatState->getNewMessages($id, 0); $username = $user->username; $historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) { $toUser = $msg['to_user'] ?? ''; $fromUser = $msg['from_user'] ?? ''; $isSecret = ! empty($msg['is_secret']); // 公众发言(对大家说):所有人都可以看到 if ($toUser === '大家' || $toUser === '') { return true; } // 私信 / 悄悄话:只显示发给自己或自己发出的 if ($isSecret) { return $fromUser === $username || $toUser === $username; } // 对特定人说话:只显示发给自己或自己发出的(含系统通知) return $fromUser === $username || $toUser === $username; })); // 渲染主聊天框架视图 return view('chat.frame', [ 'room' => $room, 'user' => $user, 'weekEffect' => $this->shopService->getActiveWeekEffect($user), 'newbieEffect' => $newbieEffect, 'historyMessages' => $historyMessages, ]); // 最后:如果用户有在职职务,开始记录这次入场的在职登录 // 此时用户局部变量已初始化,可以安全读取 in_time $activeUP = $user->activePosition; if ($activeUP) { PositionDutyLog::create([ 'user_id' => $user->id, 'user_position_id' => $activeUP->id, 'login_at' => now(), 'ip_address' => request()->ip(), 'room_id' => $id, ]); } } /** * 发送消息 (等同于原版 NEWSAY.ASP) * * @param int $id 房间ID */ public function send(SendMessageRequest $request, int $id): JsonResponse { $data = $request->validated(); $user = Auth::user(); // 0. 检查用户是否被禁言(Redis TTL 自动过期) $muteKey = "mute:{$id}:{$user->username}"; if (Redis::exists($muteKey)) { $ttl = Redis::ttl($muteKey); $minutes = ceil($ttl / 60); return response()->json([ 'status' => 'error', 'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。", ], 403); } // 0.5 检查接收方是否在线(防幽灵消息) $toUser = $data['to_user'] ?? '大家'; if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) { // Redis 保存的在线列表 $isOnline = Redis::hexists("room:{$id}:users", $toUser); if (! $isOnline) { // 使用 200 状态码,避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed return response()->json([ 'status' => 'error', 'message' => "【{$toUser}】目前已离开聊天室或不在线,消息未发出。", ], 200); } } // 1. 过滤净化消息体 $pureContent = $this->filter->filter($data['content'] ?? ''); if (empty($pureContent)) { return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422); } // 2. 封装消息对象 $messageData = [ 'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号 'room_id' => $id, 'from_user' => $user->username, 'to_user' => $data['to_user'] ?? '大家', 'content' => $pureContent, 'is_secret' => $data['is_secret'] ?? false, 'font_color' => $data['font_color'] ?? '', 'action' => $data['action'] ?? '', 'sent_at' => now()->toDateTimeString(), ]; // 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条) $this->chatState->pushMessage($id, $messageData); // 4. 立刻向 WebSocket 发射广播,前端达到 0 延迟渲染 broadcast(new MessageSent($id, $messageData)); // 5. 丢进异步列队,慢慢持久化到 MySQL,保护数据库连接池 SaveMessageJob::dispatch($messageData); // 6. 如果用户更换了字体颜色,顺便保存到 s_color 字段,下次进入时恢复 $chosenColor = $data['font_color'] ?? ''; if ($chosenColor && $chosenColor !== ($user->s_color ?? '')) { $user->s_color = $chosenColor; $user->save(); } // 7. 聊天给魅力值(仅对指定用户的非悄悄话公开发言有效) $toUser = $data['to_user'] ?? '大家'; $isSecret = $data['is_secret'] ?? false; if ($toUser !== '大家' && ! $isSecret) { $this->grantChatCharm($user, $toUser); } return response()->json(['status' => 'success']); } /** * 自动挂机存点心跳与经验升级 (新增) * 替代原版定时 iframe 刷新的 save.asp。 * * @param int $id 房间ID */ public function heartbeat(Request $request, int $id): JsonResponse { $user = Auth::user(); if (! $user) { return response()->json(['status' => 'error'], 401); } // 1. 心跳奖励:通过 Redis 限制最小间隔(默认30秒),防止频繁点击 $cooldownKey = "heartbeat_exp:{$user->id}"; $canGainReward = ! Redis::exists($cooldownKey); $actualExpGain = 0; $actualJjbGain = 0; if ($canGainReward) { // 经验奖励(支持固定值 "1" 或范围 "1-10") $expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1')); $expMultiplier = $this->vipService->getExpMultiplier($user); $actualExpGain = (int) round($expGain * $expMultiplier); $user->exp_num += $actualExpGain; // 金币奖励(支持固定值 "1" 或范围 "1-5") $jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0')); if ($jjbGain > 0) { $jjbMultiplier = $this->vipService->getJjbMultiplier($user); $actualJjbGain = (int) round($jjbGain * $jjbMultiplier); $user->jjb = ($user->jjb ?? 0) + $actualJjbGain; } // 设置冷却(30秒内不再给奖励) Redis::setex($cooldownKey, 30, 1); } // 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级 // 管理员(superlevel 及以上)不参与自动升降级,等级由后台手动设置 $superLevel = (int) Sysparam::getValue('superlevel', '100'); $oldLevel = $user->user_level; $leveledUp = false; if ($oldLevel < $superLevel) { $newLevel = Sysparam::calculateLevel($user->exp_num); if ($newLevel !== $oldLevel && $newLevel < $superLevel) { $user->user_level = $newLevel; $leveledUp = ($newLevel > $oldLevel); } } $user->save(); // 存点入库 // 手动心跳存点:同步更新在职用户的勤务时长 $this->tickDutyLog($user, $id); // 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 ?? '', ]); // 4. 如果突破境界,向全房系统喊话广播! if ($leveledUp) { // 生成炫酷广播消息发向该频道 $sysMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => "🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!", 'is_secret' => false, 'font_color' => '#d97706', // 琥珀橙色 'action' => '大声宣告', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $sysMsg); broadcast(new MessageSent($id, $sysMsg)); // 落库 SaveMessageJob::dispatch($sysMsg); } // 5. 随机事件触发(复刻原版 autoact 系统,概率可在后台配置) $autoEvent = null; $eventChance = (int) Sysparam::getValue('auto_event_chance', '10'); if ($eventChance > 0 && rand(1, 100) <= $eventChance) { $autoEvent = Autoact::randomEvent(); if ($autoEvent) { // 应用经验/金币变化(不低于 0) if ($autoEvent->exp_change !== 0) { $user->exp_num = max(0, $user->exp_num + $autoEvent->exp_change); } if ($autoEvent->jjb_change !== 0) { $user->jjb = max(0, ($user->jjb ?? 0) + $autoEvent->jjb_change); } $user->save(); // 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级) if ($user->user_level < $superLevel) { $recalcLevel = Sysparam::calculateLevel($user->exp_num); if ($recalcLevel !== $user->user_level && $recalcLevel < $superLevel) { $user->user_level = $recalcLevel; $user->save(); } } // 广播随机事件消息到聊天室 $eventMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '星海小博士', 'to_user' => '大家', 'content' => $autoEvent->renderText($user->username), 'is_secret' => false, 'font_color' => match ($autoEvent->event_type) { 'good' => '#16a34a', // 绿色(好运) 'bad' => '#dc2626', // 红色(坏运) default => '#7c3aed', // 紫色(中性) }, 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $eventMsg); broadcast(new MessageSent($id, $eventMsg)); SaveMessageJob::dispatch($eventMsg); } } // 确定用户称号:管理员 > VIP 名称 > 普通会员 $title = '普通会员'; if ($user->user_level >= $superLevel) { $title = '管理员'; } elseif ($user->isVip()) { $title = $user->vipName() ?: '会员'; } return response()->json([ 'status' => 'success', 'data' => [ 'exp_num' => $user->exp_num, 'jjb' => $user->jjb ?? 0, 'exp_gain' => $actualExpGain, 'jjb_gain' => $actualJjbGain, 'user_level' => $user->user_level, 'title' => $title, 'leveled_up' => $leveledUp, 'is_max_level' => $user->user_level >= $superLevel, 'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null, ], ]); } /** * 离开房间 (等同于原版 LEAVE.ASP) * * @param int $id 房间ID */ public function leave(Request $request, int $id): JsonResponse { $user = Auth::user(); if (! $user) { return response()->json(['status' => 'error'], 401); } // 1. 从 Redis 删除该用户 $this->chatState->userLeave($id, $user->username); // 记录退出时间和退出信息 $user->update([ 'out_time' => now(), 'out_info' => '正常退出了房间', ]); // 关闭该用户尚未结束的在职登录记录(结算在线时长) $this->closeDutyLog($user->id); // 2. 发送离场播报 $superLevel = (int) Sysparam::getValue('superlevel', '100'); if ($user->user_level >= $superLevel) { // 管理员离场:系统公告 $leaveMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "👋 管理员 【{$user->username}】 已离开聊天室。", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => 'admin_welcome', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; } else { [$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user); $leaveMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '进出播报', 'to_user' => '大家', 'content' => "{$leaveText}", 'is_secret' => false, 'font_color' => $color, 'action' => 'system_welcome', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; } $this->chatState->pushMessage($id, $leaveMsg); // 3. 广播通知他人 (UserLeft 更新用户名单列表,MessageSent 更新消息记录) broadcast(new UserLeft($id, $user->username))->toOthers(); broadcast(new MessageSent($id, $leaveMsg))->toOthers(); return response()->json(['status' => 'success']); } /** * 获取可用头像列表(返回 JSON) * 扫描 /public/images/headface/ 目录,返回所有可用头像文件名 */ public function headfaceList(): JsonResponse { $dir = public_path('images/headface'); $files = []; if (is_dir($dir)) { $all = scandir($dir); foreach ($all as $file) { // 只包含图片文件 if (preg_match('/\.(gif|jpg|jpeg|png|bmp)$/i', $file)) { $files[] = $file; } } } // 自然排序(1, 2, 3... 10, 11...) natsort($files); return response()->json(['headfaces' => array_values($files)]); } /** * 修改头像(原版 fw.asp 功能) * 用户选择一个头像文件名,更新到 usersf 字段 */ public function changeAvatar(Request $request): JsonResponse { $user = Auth::user(); $headface = $request->input('headface', ''); if (empty($headface)) { return response()->json(['status' => 'error', 'message' => '请选择一个头像'], 422); } // 验证文件确实存在 if (! file_exists(public_path('images/headface/'.$headface))) { return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422); } // 更新用户头像 $user->usersf = $headface; $user->save(); // 将新头像同步到 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, ]); } return response()->json([ 'status' => 'success', 'message' => '头像修改成功!', 'headface' => $headface, ]); } /** * 设置房间公告/祝福语(滚动显示在聊天室顶部) * 需要房间主人或等级达到 level_announcement 配置值 * * @param int $id 房间ID */ public function setAnnouncement(Request $request, int $id): JsonResponse { $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) { return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403); } $request->validate([ 'announcement' => 'required|string|max:500', ]); $room->announcement = $request->input('announcement'); $room->save(); // 广播公告更新到所有在线用户 $sysMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}", 'is_secret' => false, 'font_color' => '#cc0000', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $sysMsg); broadcast(new MessageSent($id, $sysMsg)); return response()->json([ 'status' => 'success', 'message' => '公告已更新!', 'announcement' => $room->announcement, ]); } /** * 送花/礼物:消耗金币给目标用户增加魅力值 * * 根据 gift_id 查找 gifts 表中的礼物类型,读取对应的金币消耗和魅力增量。 * 送花成功后在聊天室广播带图片的消息。 */ public function sendFlower(Request $request): JsonResponse { $request->validate([ 'to_user' => 'required|string', 'room_id' => 'required|integer', 'gift_id' => 'required|integer', 'count' => 'sometimes|integer|min:1|max:99', ]); $user = Auth::user(); if (! $user) { return response()->json(['status' => 'error', 'message' => '请先登录'], 401); } $toUsername = $request->input('to_user'); $roomId = $request->input('room_id'); $giftId = $request->integer('gift_id'); $count = $request->integer('count', 1); // 不能给自己送花 if ($toUsername === $user->username) { return response()->json(['status' => 'error', 'message' => '不能给自己送花哦~']); } // 查找礼物类型 $gift = Gift::where('id', $giftId)->where('is_active', true)->first(); if (! $gift) { return response()->json(['status' => 'error', 'message' => '礼物不存在或已下架']); } // 查找目标用户 $toUser = User::where('username', $toUsername)->first(); if (! $toUser) { return response()->json(['status' => 'error', 'message' => '用户不存在']); } $totalCost = $gift->cost * $count; $totalCharm = $gift->charm * $count; // 检查金币余额 if (($user->jjb ?? 0) < $totalCost) { return response()->json([ 'status' => 'error', 'message' => "金币不足!送 {$count} 份【{$gift->name}】需要 {$totalCost} 金币,您当前有 ".($user->jjb ?? 0).' 枚。', ]); } // 扣除金币、增加对方魅力 $user->jjb = ($user->jjb ?? 0) - $totalCost; $user->save(); $toUser->meili = ($toUser->meili ?? 0) + $totalCharm; $toUser->save(); // 构建礼物图片 URL $giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : ''; // 广播送花消息(含图片标记,前端识别后渲染图片) $countText = $count > 1 ? " {$count} 份" : ''; $sysMsg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '送花播报', 'to_user' => $toUsername, 'content' => "{$gift->emoji} 【{$user->username}】 向 【{$toUsername}】 送出了{$countText}【{$gift->name}】!魅力 +{$totalCharm}!", 'is_secret' => false, 'font_color' => '#e91e8f', 'action' => '', 'sent_at' => now()->toDateTimeString(), 'gift_image' => $giftImageUrl, 'gift_name' => $gift->name, ]; $this->chatState->pushMessage($roomId, $sysMsg); broadcast(new MessageSent($roomId, $sysMsg)); SaveMessageJob::dispatch($sysMsg); return response()->json([ 'status' => 'success', 'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}", 'data' => [ 'my_jjb' => $user->jjb, 'target_charm' => $toUser->meili, ], ]); } /** * 聊天获取魅力值(方案 B:每条消息触发,Redis 每小时上限控制) * * 异性聊天给更多魅力,同性少一些。 * 系统用户(如 AI小班长)不触发魅力奖励。 * 发送者和接收者都会获得对应魅力值。 * * @param mixed $sender 发送消息的用户模型 * @param string $toUsername 接收消息的用户名 */ private function grantChatCharm(mixed $sender, string $toUsername): void { // 系统用户不参与魅力计算 $systemNames = ['大家', '系统传音', '系统公告', '钓鱼播报', '星海小博士', 'AI小班长', '送花播报']; if (in_array($toUsername, $systemNames)) { return; } // 查找接收者 $receiver = User::where('username', $toUsername)->first(); if (! $receiver) { return; } // 检查发送者每小时魅力上限(Redis 自动过期) $capKey = "charm_cap:{$sender->username}:".date('YmdH'); $hourlyLimit = (int) Sysparam::getValue('charm_hourly_limit', '20'); $currentGained = (int) Redis::get($capKey); if ($currentGained >= $hourlyLimit) { return; // 已达本小时上限 } // 根据性别关系计算魅力增量 $senderSex = $sender->sex ?? ''; $receiverSex = $receiver->sex ?? ''; $isCrossSex = ($senderSex !== $receiverSex) && $senderSex !== '' && $receiverSex !== ''; $charmSame = (int) Sysparam::getValue('charm_same_sex', '1'); $charmCross = (int) Sysparam::getValue('charm_cross_sex', '2'); $charmGain = $isCrossSex ? $charmCross : $charmSame; // 不超过本小时剩余额度 $remaining = $hourlyLimit - $currentGained; $charmGain = min($charmGain, $remaining); if ($charmGain <= 0) { return; } // 发送者获得魅力 $sender->meili = ($sender->meili ?? 0) + $charmGain; $sender->save(); // 更新 Redis 计数器(1 小时过期) Redis::incrby($capKey, $charmGain); Redis::expire($capKey, 3600); } /** * 解析奖励数值配置(支持固定值或范围格式) * * 支持格式: * "5" → 固定返回 5 * "1-10" → 随机返回 1~10 之间的整数 * "0" → 返回 0(关闭该奖励) * * @param string $value 配置值 * @return int 解析后的奖励数值 */ private function parseRewardValue(string $value): int { $value = trim($value); // 支持范围格式 "min-max" if (str_contains($value, '-')) { $parts = explode('-', $value, 2); $min = max(0, (int) $parts[0]); $max = max($min, (int) $parts[1]); return rand($min, $max); } return max(0, (int) $value); } /** * 关闭该用户尚未结束的在职登录记录(结算在线时长) * 在用户退出房间或心跳超时时调用 * * @param int $userId 用户 ID */ private function closeDutyLog(int $userId): void { PositionDutyLog::query() ->where('user_id', $userId) ->whereNull('logout_at') ->update([ 'logout_at' => now(), 'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'), ]); } /** * 存点时同步更新或创建在职用户的勤务日志。 * * 逻辑: * 1. 用户无在职职务 → 跳过 * 2. 今日已有开放日志(无 logout_at)→ 刷新 duration_seconds(实时时长) * 3. 今日无任何日志 → 新建,login_at 取 user->in_time(进房时间),保证时长不丢失 * * @param \App\Models\User $user 当前用户(必须已 fresh/refresh) * @param int $roomId 所在房间 ID */ private function tickDutyLog(User $user, int $roomId): void { // 无在职职务,无需记录 $activeUP = $user->activePosition; if (! $activeUP) { return; } // 今日是否已有开放日志 $openLog = PositionDutyLog::query() ->where('user_id', $user->id) ->whereNull('logout_at') ->whereDate('login_at', today()) ->first(); if ($openLog) { // 刷新实时在线时长 // 绕过模型 cast(integer),使用 DB::table 直接执行 SQL 表达式 DB::table('position_duty_logs') ->where('id', $openLog->id) ->update(['duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))')]); return; } // 今日无日志 → 新建,login_at 优先用进房时间 $loginAt = $user->in_time && $user->in_time->isToday() ? $user->in_time : now(); PositionDutyLog::create([ 'user_id' => $user->id, 'user_position_id' => $activeUP->id, 'login_at' => $loginAt, 'ip_address' => request()->ip(), 'room_id' => $roomId, ]); } }