1220 lines
48 KiB
PHP
1220 lines
48 KiB
PHP
<?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. 判断是否已经是当前房间的在线状态
|
||
$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;
|
||
|
||
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);
|
||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
'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);
|
||
}
|
||
|
||
$leaveTime = microtime(true);
|
||
$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);
|
||
} 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']);
|
||
}
|
||
|
||
/**
|
||
* 获取可用头像列表(返回 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,
|
||
],
|
||
]);
|
||
}
|
||
}
|