feat: 好友系统全实现

后端:
- FriendController:add/remove/status/index 四个接口
- FriendAdded / FriendRemoved 广播事件(私有频道)
- channels.php 注册 user.{username} 私有频道鉴权
- routes/web.php 注册好友路由
- ChatController::init() 修复 DutyLog 在 return 后执行的 bug
- ChatController::notifyFriendsOnline() 上线时悄悄话通知好友

前端:
- user-actions:写私信 → 加好友/删好友按钮(动态状态)
- toggleFriend() 方法 + fetchUser 后加载好友状态
- scripts:监听私有频道 FriendAdded/FriendRemoved
- showFriendToast() 右下角浮窗通知(5秒自动消失)
- global-dialog 加 fdSlideIn 动画
This commit is contained in:
2026-03-01 00:48:51 +08:00
parent 8853d08e5a
commit 700ab9def4
9 changed files with 557 additions and 18 deletions

View File

@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:好友添加广播事件
*
* 当用户 A 添加用户 B 为好友时,向 B 的私有频道广播此事件,
* B 的客户端收到后展示弹窗通知。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FriendAdded implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造好友添加事件。
*
* @param string $fromUsername 发起添加的用户名
* @param string $toUsername 被添加的用户名(接收通知方)
*/
public function __construct(
public readonly string $fromUsername,
public readonly string $toUsername,
) {}
/**
* 广播到被添加用户的私有频道,仅本人可见。
*/
public function broadcastOn(): Channel
{
return new PrivateChannel('user.'.$this->toUsername);
}
/**
* 广播负载:包含发起人信息,供前端弹窗使用。
*
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'from_username' => $this->fromUsername,
'to_username' => $this->toUsername,
'type' => 'friend_added',
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:好友删除广播事件
*
* 当用户 A 删除用户 B 为好友时,向 B 的私有频道广播此事件,
* B 的客户端收到后展示弹窗通知。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FriendRemoved implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造好友删除事件。
*
* @param string $fromUsername 发起删除的用户名
* @param string $toUsername 被删除的用户名(接收通知方)
*/
public function __construct(
public readonly string $fromUsername,
public readonly string $toUsername,
) {}
/**
* 广播到被删除用户的私有频道,仅本人可见。
*/
public function broadcastOn(): Channel
{
return new PrivateChannel('user.'.$this->toUsername);
}
/**
* 广播负载:包含发起人信息,供前端弹窗使用。
*
* @return array<string, string>
*/
public function broadcastWith(): array
{
return [
'from_username' => $this->fromUsername,
'to_username' => $this->toUsername,
'type' => 'friend_removed',
];
}
}

View File

@@ -18,6 +18,7 @@ use App\Events\UserLeft;
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;
@@ -182,17 +183,7 @@ class ChatController extends Controller
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'historyMessages' => $historyMessages,
]);
// 最后:如果用户有在职职务,开始记录这次入场的在职登录
// 此时用户局部变量已初始化,可以安全读取 in_time
// 7. 如果用户有在职職务,开始记录这次入场的在职登录
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
@@ -203,6 +194,58 @@ class ChatController extends Controller
'room_id' => $id,
]);
}
// 8. 好友上线通知:向此房间内在线的好友推送慧慧话
$this->notifyFriendsOnline($id, $user->username);
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'historyMessages' => $historyMessages,
]);
}
/**
* 当用户进入房间时,向该房间内在线的所有好友推送慧慧话通知。
*
* @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));
}
}
/**

View File

@@ -0,0 +1,234 @@
<?php
/**
* 文件功能:好友系统控制器
*
* 处理聊天室内的好友关系管理:
* 1. 添加好友addFriend
* 2. 删除好友removeFriend
* 3. 查询与指定用户的好友关系status
* 4. 查询当前用户的好友列表index
*
* 好友关系模型:单向存储,互相添加才构成双向好友。
* 使用原版 friend_requests 字段who / towho / sub_time
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\FriendAdded;
use App\Events\FriendRemoved;
use App\Models\FriendRequest;
use App\Models\User;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class FriendController extends Controller
{
/**
* 注入 Redis 状态服务,用于推送悄悄话通知。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 查询当前用户与目标用户的好友关系状态。
*
* 返回:
* - is_friend: 当前用户是否已将对方加为好友
* - mutual: 是否互相添加(双向好友)
*
* @param string $username 目标用户名
*/
public function status(string $username): JsonResponse
{
$me = Auth::user();
// 我是否已将对方加为好友
$iAdded = FriendRequest::where('who', $me->username)
->where('towho', $username)
->exists();
// 对方是否也将我加为好友
$theyAdded = FriendRequest::where('who', $username)
->where('towho', $me->username)
->exists();
return response()->json([
'is_friend' => $iAdded,
'mutual' => $iAdded && $theyAdded,
]);
}
/**
* 添加好友。
*
* 流程:
* 1. 校验目标用户存在、且不是自己
* 2. 检查是否已经添加过
* 3. 写入 friend_requests 记录
* 4. 广播 FriendAdded 事件通知对方
* 5. 若对方当前在线Redis向对方发送悄悄话
*
* @param string $username 目标用户名
*/
public function addFriend(Request $request, string $username): JsonResponse
{
$me = Auth::user();
// 不能加自己
if ($me->username === $username) {
return response()->json(['status' => 'error', 'message' => '不能将自己加为好友'], 422);
}
// 检查目标用户是否存在
$target = User::where('username', $username)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
// 是否已添加
$exists = FriendRequest::where('who', $me->username)->where('towho', $username)->exists();
if ($exists) {
return response()->json(['status' => 'error', 'message' => '已是好友,无需重复添加'], 422);
}
// 写入好友关系
FriendRequest::create([
'who' => $me->username,
'towho' => $username,
'sub_time' => now(),
]);
// 广播给对方(仅对方可见)
broadcast(new FriendAdded($me->username, $username));
// 若对方在线,推送聊天区悄悄话
$this->notifyOnlineUser($username, $me->username, 'added', $request->input('room_id'));
return response()->json([
'status' => 'success',
'message' => '已成功添加 '.$username.' 为好友 🎉',
]);
}
/**
* 删除好友。
*
* 流程:
* 1. 删除 friend_requests 中「我 对方」的记录
* 2. 广播 FriendRemoved 事件通知对方
* 3. 若对方在线,向对方发送悄悄话
*
* @param string $username 目标用户名
*/
public function removeFriend(Request $request, string $username): JsonResponse
{
$me = Auth::user();
$deleted = FriendRequest::where('who', $me->username)
->where('towho', $username)
->delete();
if (! $deleted) {
return response()->json(['status' => 'error', 'message' => '好友关系不存在'], 404);
}
// 广播给对方
broadcast(new FriendRemoved($me->username, $username));
// 若对方在线,推送聊天区悄悄话
$this->notifyOnlineUser($username, $me->username, 'removed', $request->input('room_id'));
return response()->json([
'status' => 'success',
'message' => '已将 '.$username.' 从好友列表移除',
]);
}
/**
* 获取当前用户的好友列表(我添加的 + 对方也添加我的 = 双向好友标记)。
*/
public function index(): JsonResponse
{
$me = Auth::user();
// 我添加的所有人
$myAdded = FriendRequest::where('who', $me->username)->pluck('towho');
// 也把我加了的
$addedMe = FriendRequest::where('towho', $me->username)->pluck('who');
$friends = User::whereIn('username', $myAdded)->get(['username', 'usersf', 'user_level', 'sex'])->map(function ($u) use ($addedMe) {
return [
'username' => $u->username,
'headface' => $u->headface,
'user_level' => $u->user_level,
'sex' => $u->sex,
'mutual' => $addedMe->contains($u->username), // 是否互相添加
];
});
return response()->json(['status' => 'success', 'friends' => $friends]);
}
/**
* 若目标用户在线,向其发送系统悄悄话通知。
*
* 好友上线/下线使用此方法,不公开广播,只有本人可见。
*
* @param string $targetUsername 接收通知的用户名
* @param string $fromUsername 发起操作的用户名
* @param string $action 'added' | 'removed' | 'online'
* @param int|null $roomId 当前房间 ID用于推送到对应房间频道
*/
private function notifyOnlineUser(
string $targetUsername,
string $fromUsername,
string $action,
?int $roomId = null
): void {
if (! $roomId) {
return;
}
// 检查对方是否在该房间在线
$onlineUsers = $this->chatState->getRoomUsers($roomId);
if (! isset($onlineUsers[$targetUsername])) {
return;
}
$content = match ($action) {
'added' => "💚 <b>{$fromUsername}</b> 将你加为好友了!你们现在是好友了 🎉",
'removed' => "💔 <b>{$fromUsername}</b> 已将你从好友列表移除。",
'online' => "🟢 你的好友 <b>{$fromUsername}</b> 上线啦!",
default => '',
};
if (! $content) {
return;
}
// 构建系统悄悄话消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => $content,
'is_secret' => true,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new \App\Events\MessageSent($roomId, $msg));
}
}

View File

@@ -62,6 +62,18 @@
}
}
@keyframes fdSlideIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#global-dialog-cancel-btn:hover {
background: #e5e7eb !important;
}

View File

@@ -650,6 +650,64 @@
}
document.addEventListener('DOMContentLoaded', setupChangelogPublishedListener);
// ── 好友系统私有频道监听(仅本人可见) ────────────────
/**
* 监听当前用户的私有频道 `user.{username}`
* 收到 FriendAdded / FriendRemoved 事件时用任务弹窗通知。
*/
function setupFriendNotification() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupFriendNotification, 500);
return;
}
const myName = window.chatContext.username;
window.Echo.private(`user.${myName}`)
.listen('.FriendAdded', (e) => {
showFriendToast(
`💚 <b>${e.from_username}</b> 将你加为好友了!`,
'#16a34a'
);
})
.listen('.FriendRemoved', (e) => {
showFriendToast(
`💔 <b>${e.from_username}</b> 已将你从好友列表移除。`,
'#6b7280'
);
});
}
document.addEventListener('DOMContentLoaded', setupFriendNotification);
/**
* 显示好友事件通知浮窗(类似任务弹窗,右下角淡入淡出)。
*
* @param {string} html 通知内容(支持 HTML
* @param {string} color 左边框颜色
*/
function showFriendToast(html, color = '#16a34a') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed; bottom: 24px; right: 24px; z-index: 999999;
background: #fff; border-left: 4px solid ${color};
border-radius: 8px; padding: 14px 18px; min-width: 260px; max-width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.18);
font-size: 13px; color: #374151; line-height: 1.5;
animation: fdSlideIn .3s ease; cursor: pointer;
`;
toast.innerHTML = `
<div style="font-weight:bold; margin-bottom:4px; color:${color};">💬 好友通知</div>
<div>${html}</div>
`;
// 点击关闭
toast.addEventListener('click', () => toast.remove());
document.body.appendChild(toast);
// 5秒后自动消失
setTimeout(() => {
toast.style.transition = 'opacity .5s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 500);
}, 5000);
}
// ── 全屏特效事件监听(烟花/下雨/雷电/下雪)─────────
window.addEventListener('chat:effect', (e) => {
const type = e.detail?.type;

View File

@@ -86,6 +86,8 @@
whisperList: [],
showAnnounce: false,
announceText: '',
is_friend: false, // 当前用户是否已将对方加为好友
friendLoading: false, // 好友操作加载状态
gifts: window.__gifts || [],
selectedGiftId: window.__defaultGiftId || 0,
giftCount: 1,
@@ -107,6 +109,51 @@
$alert: (...args) => window.chatDialog.alert(...args),
$confirm: (...args) => window.chatDialog.confirm(...args),
/** 切换好友关系(加好友 / 删好友) */
async toggleFriend() {
if (this.friendLoading) return;
this.friendLoading = true;
const username = this.userInfo.username;
const roomId = window.chatContext.roomId;
const removing = this.is_friend;
try {
let res;
if (removing) {
// 删除好友
res = await fetch(`/friend/${encodeURIComponent(username)}/remove`, {
method: 'DELETE',
headers: this._headers(),
body: JSON.stringify({
room_id: roomId
}),
});
} else {
// 添加好友
res = await fetch(`/friend/${encodeURIComponent(username)}/add`, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
room_id: roomId
}),
});
}
const data = await res.json();
const ok = data.status === 'success';
this.$alert(
data.message,
ok ? (removing ? '已删除好友' : '添加成功 🎉') : '操作失败',
ok ? (removing ? '#6b7280' : '#16a34a') : '#cc4444'
);
if (ok) {
this.is_friend = !this.is_friend;
}
} catch (e) {
this.$alert('网络异常', '错误', '#cc4444');
}
this.friendLoading = false;
},
/** 获取用户资料 */
async fetchUser(username) {
try {
@@ -126,6 +173,19 @@
const data = await res.json();
if (data.status === 'success') {
this.userInfo = data.data;
this.showPositionHistory = false;
// 加载好友状态(仅对非自己的用户查询)
if (data.data.username !== window.chatContext.username) {
fetch(`/friend/${encodeURIComponent(data.data.username)}/status`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(r => r.json()).then(s => {
this.is_friend = s.is_friend ?? false;
});
}
this.showUserModal = true;
this.isMuting = false;
this.showWhispers = false;
@@ -554,13 +614,15 @@
<div x-data="{ showGiftPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0;">
{{-- 写私信 --}}
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
{{-- 加好友 / 删好友(替代写私信 --}}
<button x-on:click="toggleFriend()" :disabled="friendLoading"
:style="is_friend
?
'background: #f1f5f9; color: #6b7280; border: 1px solid #d1d5db;' :
'background: linear-gradient(135deg,#16a34a,#22c55e); color:#fff; border:none;'"
style="padding: 7px 14px; border-radius: 5px; font-size: 12px;
cursor: pointer; font-weight: bold; transition: opacity .15s;"
x-text="friendLoading ? '处理中…' : (is_friend ? '✅ 已是好友 (点击删除)' : ' 加好友')"></button>
{{-- 送花按鈕(与写私信并列) --}}
<button class="btn-whisper" x-on:click="showGiftPanel = !showGiftPanel">
🎁 送礼物

View File

@@ -26,3 +26,9 @@ Broadcast::channel('room.{roomId}', function ($user, $roomId) {
'is_admin' => $user->user_level >= $superLevel,
];
});
// 用户私有频道鉴权好友通知FriendAdded / FriendRemoved
// 只有用户名匹配的本人才能订阅
Broadcast::channel('user.{username}', function ($user, string $username) {
return $user->username === $username;
});

View File

@@ -66,6 +66,12 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/user/{username}/ban', [UserController::class, 'ban'])->name('user.ban');
Route::post('/user/{username}/banip', [UserController::class, 'banIp'])->name('user.banip');
// ---- 好友系统 ----
Route::get('/friends', [\App\Http\Controllers\FriendController::class, 'index'])->name('friend.index');
Route::get('/friend/{username}/status', [\App\Http\Controllers\FriendController::class, 'status'])->name('friend.status');
Route::post('/friend/{username}/add', [\App\Http\Controllers\FriendController::class, 'addFriend'])->name('friend.add');
Route::delete('/friend/{username}/remove', [\App\Http\Controllers\FriendController::class, 'removeFriend'])->name('friend.remove');
// ---- 第五阶段:具体房间内部聊天核心 ----
// 进入具体房间界面的初始化
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');