Files
chatroom/app/Http/Controllers/ChatController.php
lkddi fa5e37f003 feat: 增加发送微信群内自定义公告功能,并优化离线防抖与自我播报过滤机制
- 后台微信机器人增加群内独立公告的分发推送模块
- 聊天室系统引入3秒离线延迟(防抖)防重复播报
- 优化聊天界面消息拉取过滤自身的欢迎或离场广播
- 管理员登录时的烟花特效同步至用户当前的前端显示
2026-04-02 16:07:40 +08:00

1199 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:聊天室核心控制器
* 接管原版 INIT.ASP, NEWSAY.ASP, LEAVE.ASP 的所有职责
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\FriendRequest;
use App\Models\Gift;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
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,
) {}
/**
* 进入房间初始化 (等同于原版 INIT.ASP)
*
* @param int $id 房间ID
* @return View|JsonResponse
*/
public function init(int $id)
{
$room = Room::findOrFail($id);
$user = Auth::user();
// 房间人气 +1每次访问递增复刻原版人气计数
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 0. 判断是否已经是当前房间的在线状态
$isAlreadyInRoom = $this->chatState->isUserInRoom($id, $user->username);
// 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;
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);
if ($user->user_level >= $superLevel) {
// 管理员专属:全房间烟花
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
// 传给前端,让管理员自己屏幕上也按规矩加载燃放烟花
$newbieEffect = 'fireworks';
$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 {
// 非站长:生成通用播报(有职务 > 有VIP > 普通随机词)
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'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']);
$action = $msg['action'] ?? '';
$welcomeUser = $msg['welcome_user'] ?? '';
// 过滤自己的进出场提示,避免自己被自己刷屏
if (($action === 'system_welcome' || $action === 'admin_welcome' || empty($action)) && $welcomeUser === $username) {
return false;
}
// 公众发言(对大家说):所有人都可以看到
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,
'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' => "🟢 你的好友 <b>{$username}</b> 上线啊!",
'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 (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');
$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) {
// 经验变化:通过 UserCurrencyService 写日志
if ($autoEvent->exp_change !== 0) {
$this->currencyService->change(
$user,
'exp',
$autoEvent->exp_change,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
);
}
// 金币变化:通过 UserCurrencyService 写日志
if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change(
$user,
'gold',
$autoEvent->jjb_change,
CurrencySource::AUTO_EVENT,
"随机事件:{$autoEvent->text_body}",
$id,
);
}
// 重新从数据库读取最新属性service 已原子更新,需刷新本地对象)
$user->refresh();
// 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级)
if ($this->calculateNewLevel($user, $superLevel)) {
$leveledUp = true; // 随机事件触发了升级,补充标记以便广播
$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,
],
]);
}
/**
* 返回所有房间的在线人数,供右侧房间面板轮询使用。
*
* 使用 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);
}
// 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟
// 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime
// Job 中就不会执行完整的离线播报和注销流程
$leaveTime = microtime(true);
\App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3));
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);
}
// 更新前如为自定义头像,将其从磁盘删除,节约空间
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:2048',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '未登录'], 401);
}
$file = $request->file('file');
try {
$manager = new ImageManager(new Driver);
$image = $manager->read($file);
// 裁剪正方形并压缩为 112x112
$image->cover(112, 112);
// 生成相对路径
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
$path = 'avatars/'.$filename;
// 保存以高质量 JPG 或原格式
Storage::disk('public')->put($path, (string) $image->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,
],
]);
}
}