increment('visit_num'); // 用户进房时间刷新 $user->update(['in_time' => now()]); // 0. 判断是否已经是当前房间的在线状态 $hasKey = $this->chatState->isUserInRoom($id, $user->username); // 增强校验:判断心跳是否还存在。如果遇到没有启动队列任务的情况,离线任务未能清理脏数据,心跳必定过期。 $isHeartbeatAlive = (bool) \Illuminate\Support\Facades\Redis::exists("room:{$id}:alive:{$user->username}"); // 如果虽然在名单里,但心跳早已丢失(可能直接关浏览器且队列未跑),视为全新进房 if ($hasKey && ! $isHeartbeatAlive) { $this->chatState->userLeave($id, $user->username); // 强制洗净状态 $hasKey = false; } $isAlreadyInRoom = $hasKey; // 1. 先将用户从其他所有房间的在线名单中移除(切换房间时旧记录自动清理) // 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题 $oldRoomIds = $this->chatState->getUserRooms($user->username); foreach ($oldRoomIds as $oldRoomId) { if ($oldRoomId !== $id) { $this->chatState->userLeave($oldRoomId, $user->username); } } // 2. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息) $superLevel = (int) Sysparam::getValue('superlevel', '100'); // 获取当前在职职务信息(用于内容显示) $activePosition = $user->activePosition; $userData = [ 'user_id' => $user->id, 'level' => $user->user_level, 'sex' => $user->sex, 'headface' => $user->headface, '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); // 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报) \Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true)); // 3. 广播和初始化欢迎(仅限初次进入) $newbieEffect = null; $initialPresenceTheme = null; $initialWelcomeMessage = null; if (! $isAlreadyInRoom) { // 广播 UserJoined 事件,通知房间内的其他人 broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); // 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告 if (! $user->has_received_new_gift) { // 通过统一积分服务发放新人礼包 6666 金币并记录流水 $this->currencyService->change( $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id, ); $user->update(['has_received_new_gift' => true]); // 发送新人专属欢迎公告 $newbieMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '系统公告', 'to_user' => '大家', 'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!", 'is_secret' => false, 'font_color' => '#b91c1c', 'action' => '', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($id, $newbieMsg); broadcast(new MessageSent($id, $newbieMsg)); // 广播烟花特效给此时已在房间的其他用户 broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers(); // 传给前端,让新人自己的屏幕上也燃放烟花 $newbieEffect = 'fireworks'; } // superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报 // 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条 $this->chatState->removeOldWelcomeMessages($id, $user->username); // 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。 [$text, $color] = $this->broadcast->buildEntryBroadcast($user); $vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join'); $generalWelcomeMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '进出播报', 'to_user' => '大家', 'content' => "{$text}", 'is_secret' => false, 'font_color' => $color, 'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence', 'welcome_user' => $user->username, 'sent_at' => now()->toDateTimeString(), ]; // 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。 if (! empty($vipPresencePayload)) { $generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload); $initialPresenceTheme = $vipPresencePayload; } // 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。 $initialWelcomeMessage = $generalWelcomeMsg; $this->chatState->pushMessage($id, $generalWelcomeMsg); // 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示 broadcast(new MessageSent($id, $generalWelcomeMsg)); // 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。 if (! empty($vipPresencePayload['presence_effect'])) { broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers(); } } // 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染 // 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的 $allHistory = $this->chatState->getNewMessages($id, 0); $username = $user->username; $historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) { $toUser = $msg['to_user'] ?? ''; $fromUser = $msg['from_user'] ?? ''; $isSecret = ! empty($msg['is_secret']); // 公众发言(对大家说):所有人都可以看到 if ($toUser === '大家' || $toUser === '') { return true; } // 私信 / 悄悄话:只显示发给自己或自己发出的 if ($isSecret) { return $fromUser === $username || $toUser === $username; } // 对特定人说话:只显示发给自己或自己发出的(含系统通知) return $fromUser === $username || $toUser === $username; })); // 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次) if (! $isAlreadyInRoom) { $activeUP = $user->activePosition; if ($activeUP) { PositionDutyLog::create([ 'user_id' => $user->id, 'user_position_id' => $activeUP->id, 'login_at' => now(), 'ip_address' => request()->ip(), 'room_id' => $id, ]); } // 8. 好友上线通知:向此房间内在线的好友推送慧慧话 $this->notifyFriendsOnline($id, $user->username); } // 9. 检查是否有未处理的求婚 $pendingProposal = \App\Models\Marriage::with(['user', 'ringItem']) ->where('partner_id', $user->id) ->where('status', 'pending') ->first(); $pendingProposalData = null; if ($pendingProposal) { $pendingProposalData = [ 'marriage_id' => $pendingProposal->id, 'proposer_name' => $pendingProposal->user?->username ?? '', 'ring_name' => $pendingProposal->ringItem?->name ?? '', 'ring_icon' => $pendingProposal->ringItem?->icon ?? '', 'expires_at' => $pendingProposal->expires_at?->diffForHumans() ?? '', ]; } // 10. 检查是否有未处理的协议离婚请求(对方发起的) $pendingDivorce = \App\Models\Marriage::with(['user', 'partner']) ->where('status', 'married') ->where('divorce_type', 'mutual') ->whereNotNull('divorcer_id') ->where('divorcer_id', '!=', $user->id) ->where(function ($q) use ($user) { $q->where('user_id', $user->id)->orWhere('partner_id', $user->id); }) ->first(); $pendingDivorceData = null; if ($pendingDivorce) { $initiator = $pendingDivorce->user_id === $pendingDivorce->divorcer_id ? $pendingDivorce->user : $pendingDivorce->partner; $pendingDivorceData = [ 'marriage_id' => $pendingDivorce->id, 'initiator_name' => $initiator?->username ?? '', ]; } // 渲染主聊天框架视图 return view('chat.frame', [ 'room' => $room, 'user' => $user, 'weekEffect' => $this->shopService->getActiveWeekEffect($user), 'newbieEffect' => $newbieEffect, 'initialPresenceTheme' => $initialPresenceTheme, 'initialWelcomeMessage' => $initialWelcomeMessage, 'historyMessages' => $historyMessages, 'pendingProposal' => $pendingProposalData, 'pendingDivorce' => $pendingDivorceData, ]); } /** * 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。 * * @param int $roomId 当前房间 ID * @param string $username 上线的用户名 */ private function notifyFriendsOnline(int $roomId, string $username): void { // 获取所有把我加为好友的人(他们是将我加为好友的关注者) $friendUsernames = FriendRequest::where('towho', $username)->pluck('who'); if ($friendUsernames->isEmpty()) { return; } // 当前房间在线用户列表 $onlineUsers = $this->chatState->getRoomUsers($roomId); foreach ($friendUsernames as $friendName) { // 好友就在这个房间里,才发通知 if (! isset($onlineUsers[$friendName])) { continue; } $msg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => '系统', 'to_user' => $friendName, 'content' => "🟢 你的好友 {$username} 上线啊!", 'is_secret' => true, 'font_color' => '#16a34a', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $msg); broadcast(new MessageSent($roomId, $msg)); } } /** * 发送消息 (等同于原版 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 ($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'); $leveledUp = $this->calculateNewLevel($user, $superLevel); $user->save(); // 存点入库 // 手动心跳存点:同步更新在职用户的勤务时长 $this->tickDutyLog($user, $id); // 3. 将新的等级反馈给当前用户的在线名单上 // 确保刚刚升级后别人查看到的也是最准确等级 $activePosition = $user->activePosition; $this->chatState->userJoin($id, $user->username, [ 'level' => $user->user_level, 'sex' => $user->sex, '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) { // 计算会员倍率加成(仅正向奖励有效) $expMul = $this->vipService->getExpMultiplier($user); $jjbMul = $this->vipService->getJjbMultiplier($user); $finalExp = $autoEvent->exp_change > 0 ? (int) round($autoEvent->exp_change * $expMul) : $autoEvent->exp_change; $finalJjb = $autoEvent->jjb_change > 0 ? (int) round($autoEvent->jjb_change * $jjbMul) : $autoEvent->jjb_change; $bonusExp = ($autoEvent->exp_change > 0 && $finalExp > $autoEvent->exp_change) ? $finalExp - $autoEvent->exp_change : 0; $bonusJjb = ($autoEvent->jjb_change > 0 && $finalJjb > $autoEvent->jjb_change) ? $finalJjb - $autoEvent->jjb_change : 0; // 经验变化:通过 UserCurrencyService 写日志 if ($finalExp !== 0) { $this->currencyService->change( $user, 'exp', $finalExp, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", $id, ); } // 金币变化:通过 UserCurrencyService 写日志 if ($finalJjb !== 0) { $this->currencyService->change( $user, 'gold', $finalJjb, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", $id, ); } // 重新从数据库读取最新属性(service 已原子更新,需刷新本地对象) $user->refresh(); // 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级) if ($this->calculateNewLevel($user, $superLevel)) { $leveledUp = true; // 随机事件触发了升级,补充标记以便广播 $user->save(); } // 构建会员额外加成文案(参考钓鱼系统,确保不弄错) $bonusParts = []; if ($bonusExp > 0) { $bonusParts[] = "+经验{$bonusExp}"; } if ($bonusJjb > 0) { $bonusParts[] = "+金币{$bonusJjb}"; } $eventContent = $autoEvent->renderText($user->username); if (! empty($bonusParts)) { $eventContent .= '('.$user->vipName().'追加:'.implode(',', $bonusParts).')'; } // 广播随机事件消息到聊天室 $eventMsg = [ 'id' => $this->chatState->nextMessageId($id), 'room_id' => $id, 'from_user' => '星海小博士', 'to_user' => '大家', 'content' => $eventContent, '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->isVip()) { $title = $user->vipName() ?: '会员'; } elseif ($user->user_level >= $superLevel) { $title = '管理员'; } 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, ], ]); } /** * 处理登录失效后的离场清理。 * * 该接口通过临时签名 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']); } /** * 返回所有房间的在线人数,供右侧房间面板轮询使用。 * * 使用 ChatStateService::getRoomUsers() 保证与名单逻辑完全一致。 * 返回 [{ id, name, online, permit_level, door_open }] 数组。 */ public function roomsOnlineStatus(): JsonResponse { $rooms = Room::orderBy('id')->get(['id', 'room_name', 'permit_level', 'door_open']); $data = $rooms->map(function (Room $room) { // 与名单/心跳使用完全相同的方式读取在线人数 $onlineCount = count($this->chatState->getRoomUsers($room->id)); return [ 'id' => $room->id, 'name' => $room->room_name, 'online' => $onlineCount, 'permit_level' => $room->permit_level ?? 0, 'door_open' => (bool) $room->door_open, ]; }); return response()->json(['rooms' => $data]); } /** * 离开房间 (等同于原版 LEAVE.ASP) * * @param int $id 房间ID */ public function leave(Request $request, int $id): JsonResponse { $user = Auth::user(); if (! $user) { return response()->json(['status' => 'error'], 401); } $leaveTime = microtime(true); $isExplicit = strval($request->query('explicit')) === '1'; if ($isExplicit) { // 人工显式点击“离开”时,立即同步执行清算和播报。 $this->dispatchImmediateLeave($id, $user, '主动离开了房间'); } else { // 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟 // 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime) // Job 中就不会执行完整的离线播报和注销流程 \App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3)); } return response()->json(['status' => 'success']); } /** * 立即执行离场清理,并跳过刷新防抖逻辑。 */ 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/ 目录,返回所有可用头像文件名 */ 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); } // 更新前如为自定义头像,将其从磁盘删除,节约空间 if ($user->usersf !== $headface) { $user->deleteCustomAvatar(); $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, ]); } /** * 上传自定义头像 */ public function uploadAvatar(Request $request): JsonResponse { $request->validate([ 'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:6144', ]); $user = Auth::user(); if (! $user) { return response()->json(['status' => 'error', 'message' => '未登录'], 401); } $file = $request->file('file'); try { $manager = new ImageManager(new Driver); // 生成相对路径 $filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension(); $originalFilename = 'custom_'.$user->id.'_'.time().'_original.'.$file->extension(); $path = 'avatars/'.$filename; $originalPath = 'avatars/'.$originalFilename; // 1. 处理原图:限制最大宽度为 1280 以免过大,保存原比例高清大图 $originalImage = $manager->read($file); $originalImage->scaleDown(width: 1280); Storage::disk('public')->put($originalPath, (string) $originalImage->encode()); // 2. 处理缩略图:裁剪正方形并压缩为 112x112 $thumbImage = $manager->read($file); $thumbImage->cover(112, 112); Storage::disk('public')->put($path, (string) $thumbImage->encode()); $dbValue = 'storage/'.$path; // 更新前如为自定义头像,将其从磁盘删除,节约空间 if ($user->usersf !== $dbValue) { $user->deleteCustomAvatar(); $user->usersf = $dbValue; $user->save(); } // 同步 Redis 状态 $superLevel = (int) Sysparam::getValue('superlevel', '100'); $rooms = $this->chatState->getUserRooms($user->username); foreach ($rooms as $roomId) { $this->chatState->userJoin((int) $roomId, $user->username, [ 'level' => $user->user_level, 'sex' => $user->sex, 'headface' => $user->headface, // Use accessor 'vip_icon' => $user->vipIcon(), 'vip_name' => $user->vipName(), 'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '', 'is_admin' => $user->user_level >= $superLevel, ]); } return response()->json([ 'status' => 'success', 'message' => '头像上传成功!', 'headface' => $user->headface, ]); } catch (\Exception $e) { return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500); } } /** * 设置房间公告/祝福语(滚动显示在聊天室顶部) * 需要房间主人或等级达到 level_announcement 配置值 * * @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 = trim($request->input('announcement')) .' ——'.$user->username.' '.now()->format('m-d H:i'); $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') ->whereDate('login_at', today()) ->update([ 'logout_at' => now(), 'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'), ]); // 关闭历史遗留的跨天未关闭日志(login_at 非今日) // 保留最后一次心跳刷新的 duration_seconds,确保已积累时长不丢失 PositionDutyLog::query() ->where('user_id', $userId) ->whereNull('logout_at') ->whereDate('login_at', '<', today()) ->update([ 'logout_at' => DB::raw('login_at + INTERVAL duration_seconds SECOND'), ]); } /** * 存点时同步更新或创建在职用户的勤务日志。 * * 逻辑: * 1. 用户无在职职务 → 跳过 * 2. 今日已有开放日志(无 logout_at)→ 刷新 duration_seconds(实时时长) * 3. 今日无任何日志 → 新建,login_at 取 user->in_time(进房时间),保证时长不丢失 * * @param \App\Models\User $user 当前用户(必须已 fresh/refresh) * @param int $roomId 所在房间 ID */ private function tickDutyLog(User $user, int $roomId): void { // 无论有无职务,均记录在线流水 $activeUP = $user->activePosition; // ① 优先找今日未关闭的开放日志,直接刷新时长 $openLog = PositionDutyLog::query() ->where('user_id', $user->id) ->whereNull('logout_at') ->whereDate('login_at', today()) ->first(); if ($openLog) { DB::table('position_duty_logs') ->where('id', $openLog->id) ->update([ 'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'), // DB::table raw update 不自动刷 updated_at,必须手动设置, // 否则 CloseStaleDutyLogs 会误判此 session 为掉线而提前关闭。 'updated_at' => now(), ]); return; } // ② 若今日已有「已关闭」的日志段,说明是 CloseStaleDutyLogs 关闭后重建: // 必须用 now() 作为 login_at,防止重用旧的 in_time(如今日 00:00)导致 // 每次重建的 duration_seconds 都从午夜算起,累加成等差数列(产生 249h 等异常值)。 // 只有今日首次创建(无任何历史日志段)时,才用 in_time 保留真实进房时刻。 $hasClosedToday = PositionDutyLog::query() ->where('user_id', $user->id) ->whereDate('login_at', today()) ->whereNotNull('logout_at') ->exists(); $loginAt = (! $hasClosedToday && $user->in_time && $user->in_time->isToday()) ? $user->in_time : now(); PositionDutyLog::create([ 'user_id' => $user->id, 'user_position_id' => $activeUP?->id, 'login_at' => $loginAt, 'ip_address' => request()->ip(), 'room_id' => $roomId, ]); } /** * 根据经验值重新计算用户等级,申升减级均会直接修改 $user->user_level。 * * PHP 对象引用传递,方法内对 $user 的修改会直接反映到调用方。 * 本方法不负责 save(),由调用方决定何时落库。 * * @param \App\Models\User $user 当前用户模型 * @param int $superLevel 管理员等级阈值(达到后不参与自动升降级) * @return bool 是否发生了升级(true = 等级提升) */ private function calculateNewLevel(\App\Models\User $user, int $superLevel): bool { // 管理员等级由后台手动维护,不参与自动升降级 if ($user->user_level >= $superLevel) { return false; } $newLevel = Sysparam::calculateLevel($user->exp_num); // 等级无变化,或计算结果达到管理员阈值(异常情况),均跳过 if ($newLevel === $user->user_level || $newLevel >= $superLevel) { return false; } $isLeveledUp = $newLevel > $user->user_level; // 在职职务成员:等级保护逻辑 $activeUP = $user->activePosition; if ($activeUP) { $positionLevel = $activeUP->position->level ?? 0; // 职务要求高于当前等级 → 强制补级到职务最低要求 if ($positionLevel > $user->user_level) { $user->user_level = $positionLevel; return true; // 等级提升,调用方需保存并广播 } // 降级 且 降后等级低于职务要求 → 阻止 if (! $isLeveledUp && $newLevel < $positionLevel) { return false; } } // PHP 对象引用传递,这里对 $user->user_level 的修改将直接反映到调用方 $user->user_level = $newLevel; return $isLeveledUp; } /** * 用户间赠送金币(任何登录用户均可调用) * * 从自己的余额中扣除指定金额,转入对方账户, * 并在房间内通过「系统传音」广播一条赠送提示。 */ public function giftGold(Request $request): JsonResponse { $request->validate([ 'to_user' => 'required|string', 'room_id' => 'required|integer', 'amount' => 'required|integer|min:1|max:999999999', ], [ 'amount.max' => '单次赠送金币不能超过 999999999', 'amount.min' => '单次赠送金币至少为 1', 'amount.integer' => '金币数量必须是整数', 'amount.required' => '请输入要赠送的金币数量', ]); $sender = Auth::user(); $toName = $request->input('to_user'); $roomId = $request->integer('room_id'); $amount = $request->integer('amount'); // 不能给自己转账 if ($toName === $sender->username) { return response()->json(['status' => 'error', 'message' => '不能给自己赠送哦~']); } // 查目标用户 $receiver = User::where('username', $toName)->first(); if (! $receiver) { return response()->json(['status' => 'error', 'message' => '用户不存在']); } // 余额校验 if (($sender->jjb ?? 0) < $amount) { return response()->json([ 'status' => 'error', 'message' => '金币不足!您当前余额 '.($sender->jjb ?? 0)." 金币,无法赠送 {$amount} 金币。", ]); } // 执行转账(直接操作字段,与 sendFlower 保持一致风格) $sender->decrement('jjb', $amount); $receiver->increment('jjb', $amount); // 广播一条消息:发送者/接收者路由到 say2(下方包厢),其他人路由到 say1(公屏) // 原理:前端 isRelatedToMe = isMe || to_user===me → say2;否则 → say1 $giftMsg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, 'from_user' => $sender->username, 'to_user' => $toName, 'content' => "悄悄赠送给你 {$amount} 金币!💝", 'is_secret' => false, 'font_color' => '#b45309', 'action' => '', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage($roomId, $giftMsg); broadcast(new MessageSent($roomId, $giftMsg)); SaveMessageJob::dispatch($giftMsg); return response()->json([ 'status' => 'success', 'message' => "赠送成功!已向 {$toName} 赠送 {$amount} 金币。", 'data' => [ 'my_jjb' => $sender->fresh()->jjb, 'target_jjb' => $receiver->fresh()->jjb, ], ]); } }