feat: 任命/撤销通知系统 + 用户名片UI优化

- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
This commit is contained in:
2026-02-28 23:44:38 +08:00
parent a599047cf0
commit 5f30220609
80 changed files with 8579 additions and 473 deletions
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:任命公告广播事件
* 任命操作成功后向对应聊天室 PresenceChannel 推送任命消息,
* 前端接收后展示全屏礼花动画和隆重公告弹窗。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AppointmentAnnounced implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构建任命公告事件
*
* @param int $roomId 广播目标房间 ID
* @param string $targetUsername 被任命用户名
* @param string $positionIcon 职务图标
* @param string $positionName 职务名称
* @param string $departmentName 所属部门名称
* @param string $operatorName 任命人用户名
*/
public function __construct(
public readonly int $roomId,
public readonly string $targetUsername,
public readonly string $positionIcon,
public readonly string $positionName,
public readonly string $departmentName,
public readonly string $operatorName,
public readonly string $type = 'appoint', // appoint | revoke
) {}
/**
* 广播至目标房间的 PresenceChannel
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'type' => $this->type,
'target_username' => $this->targetUsername,
'position_icon' => $this->positionIcon,
'position_name' => $this->positionName,
'department_name' => $this->departmentName,
'operator_name' => $this->operatorName,
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:开发日志发布广播事件
* 当管理员发布新的开发日志并勾选"通知大厅"时触发
* 广播至 Room ID=1(星光大厅)的 presence 频道
* 前端监听此事件并在聊天消息区显示系统通知(含可点击的查看详情链接)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\DevChangelog;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChangelogPublished implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:传入触发通知的日志对象
*
* @param DevChangelog $changelog 刚发布的开发日志
*/
public function __construct(
public readonly DevChangelog $changelog,
) {}
/**
* 广播频道:仅向 Room 1(星光大厅)的 presence 频道广播
* 复用现有聊天室频道机制,无需额外配置
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
// 固定广播至 Room ID = 1 的大厅频道
new PresenceChannel('room.1'),
];
}
/**
* 广播事件名称(前端 .listen('ChangelogPublished', ...) 监听此名称)
*/
public function broadcastAs(): string
{
return 'ChangelogPublished';
}
/**
* 广播携带的数据(前端可直接访问)
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'version' => $this->changelog->version,
'title' => $this->changelog->title,
'type' => $this->changelog->type,
'type_label' => $this->changelog->type_label,
// 前端点击后跳转的目标 URL,自动锚定至对应版本
'url' => url('/changelog').'#v'.$this->changelog->version,
];
}
}
@@ -0,0 +1,202 @@
<?php
/**
* 文件功能:后台任命管理控制器
* 管理员可以在此查看所有在职人员、进行新增任命和撤销职务
* 任命/撤销通过 AppointmentService 执行,权限日志自动记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\AppointmentAnnounced;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 任命管理主列表(当前全部在职人员)
*/
public function index(): View
{
// 所有在职记录(按部门 rank 倒序、职务 rank 倒序)
$activePositions = UserPosition::query()
->where('is_active', true)
->with([
'user',
'position.department',
'appointedBy',
])
->join('positions', 'user_positions.position_id', '=', 'positions.id')
->join('departments', 'positions.department_id', '=', 'departments.id')
->orderByDesc('departments.rank')
->orderByDesc('positions.rank')
->select('user_positions.*')
->get();
// 部门+职务(供新增任命弹窗下拉选择,带在职人数统计)
$departments = Department::with([
'positions' => fn ($q) => $q->withCount('activeUserPositions')->ordered(),
])->ordered()->get();
return view('admin.appointments.index', compact('activePositions', 'departments'));
}
/**
* 执行新增任命
* 管理员在后台直接任命用户,操作人为当前登录管理员
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:255',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$targetPosition = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $targetPosition, $request->remark);
if ($result['ok']) {
// 向所有当前有人在线的聊天室广播礼花公告(后台操作人不在聊天室内)
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 撤销职务
*/
public function revoke(Request $request, UserPosition $userPosition): RedirectResponse
{
$operator = Auth::user();
$target = $userPosition->user;
// 撤销前先记录职务信息(撤销后关联就断了)
$userPosition->load('position.department');
$posIcon = $userPosition->position?->icon ?? '🎖️';
$posName = $userPosition->position?->name ?? '';
$deptName = $userPosition->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
if ($result['ok']) {
// 向所有活跃房间广播撤销公告
if ($posName) {
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 查看某任职记录的在职登录日志
*/
public function dutyLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department', 'appointedBy']);
$logs = $userPosition->dutyLogs()
->orderByDesc('login_at')
->paginate(30);
return view('admin.appointments.duty-logs', compact('userPosition', 'logs'));
}
/**
* 查看某任职记录的权限操作日志
*/
public function authorityLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department']);
$logs = $userPosition->authorityLogs()
->with(['targetUser', 'targetPosition'])
->orderByDesc('created_at')
->paginate(30);
return view('admin.appointments.authority-logs', compact('userPosition', 'logs'));
}
/**
* 历史任职记录(全部 is_active=false 的记录)
*/
public function history(): View
{
$history = UserPosition::query()
->where('is_active', false)
->with(['user', 'position.department', 'appointedBy', 'revokedBy'])
->orderByDesc('revoked_at')
->paginate(30);
return view('admin.appointments.history', compact('history'));
}
/**
* 搜索用户(供任命弹窗 Ajax 快速查找)
*/
public function searchUsers(Request $request): JsonResponse
{
$keyword = $request->input('q', '');
$users = User::query()
->where('id', '!=', 1) // 排除超级管理员
->where('username', 'like', "%{$keyword}%")
->whereDoesntHave('activePosition') // 排除已有职务的用户
->select('id', 'username', 'user_level')
->limit(10)
->get();
return response()->json($users);
}
}
@@ -52,10 +52,12 @@ class AutoactController extends Controller
/**
* 更新事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Autoact $autoact): RedirectResponse
{
$event = Autoact::findOrFail($id);
$event = $autoact;
$data = $request->validate([
'text_body' => 'required|string|max:500',
@@ -71,26 +73,29 @@ class AutoactController extends Controller
/**
* 切换事件启用/禁用状态
*
* @param Autoact $autoact 路由模型自动注入
*/
public function toggle(int $id): JsonResponse
public function toggle(Autoact $autoact): JsonResponse
{
$event = Autoact::findOrFail($id);
$event->enabled = ! $event->enabled;
$event->save();
$autoact->enabled = ! $autoact->enabled;
$autoact->save();
return response()->json([
'status' => 'success',
'enabled' => $event->enabled,
'message' => $event->enabled ? '已启用' : '已禁用',
'enabled' => $autoact->enabled,
'message' => $autoact->enabled ? '已启用' : '已禁用',
]);
}
/**
* 删除事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Autoact $autoact): RedirectResponse
{
Autoact::findOrFail($id)->delete();
$autoact->delete();
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
}
@@ -0,0 +1,191 @@
<?php
/**
* 文件功能:后台开发日志管理控制器(仅 id=1 超级管理员可访问)
* 提供开发日志的 CRUD 功能,发布时可选择向 Room 1 大厅广播通知
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\ChangelogPublished;
use App\Http\Controllers\Controller;
use App\Jobs\SaveMessageJob;
use App\Models\DevChangelog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/**
* 后台日志列表(含草稿)
*/
public function index(): View
{
$logs = DevChangelog::orderByDesc('created_at')->paginate(20);
return view('admin.changelog.index', compact('logs'));
}
/**
* 新增日志表单页(预填今日日期)
*/
public function create(): View
{
// 预填今日日期为版本号
$todayVersion = now()->format('Y-m-d');
return view('admin.changelog.form', [
'log' => null,
'todayVersion' => $todayVersion,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => true,
]);
}
/**
* 保存新日志
* 若勾选"立即发布",则记录 published_at 并可选向大厅广播通知
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
$notifyChat = (bool) ($data['notify_chat'] ?? false);
/** @var DevChangelog $log */
$log = DevChangelog::create([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
// 只有同时勾选"通知"才记录 notify_chat,否则置 false
'notify_chat' => $isPublished && $notifyChat,
// 首次发布时记录发布时间
'published_at' => $isPublished ? Carbon::now() : null,
]);
// 如果发布且勾选了"通知大厅用户",则触发 WebSocket 广播 + 持久化到消息库
if ($isPublished && $notifyChat) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志创建成功!'.($isPublished ? '(已发布)' : '(草稿已保存)'));
}
/**
* 编辑日志表单页
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function edit(DevChangelog $changelog): View
{
return view('admin.changelog.form', [
'log' => $changelog,
'todayVersion' => $changelog->version,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => false,
]);
}
/**
* 更新日志内容(编辑操作不更新 published_at,不触发通知)
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function update(Request $request, DevChangelog $changelog): RedirectResponse
{
$log = $changelog;
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
// 如果从草稿切换为发布,记录首次发布时间
$publishedAt = $log->published_at;
if ($isPublished && ! $log->is_published) {
$publishedAt = Carbon::now();
} elseif (! $isPublished) {
// 从发布退回草稿,清除发布时间
$publishedAt = null;
}
$log->update([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
'published_at' => $publishedAt,
]);
// 若勾选了「通知大厅用户」且当前已发布,则广播通知 + 持久化到消息库
$notifyChat = (bool) ($data['notify_chat'] ?? false);
if ($notifyChat && $isPublished) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志已更新!');
}
/**
* 删除日志
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function destroy(DevChangelog $changelog): RedirectResponse
{
$changelog->delete();
return redirect()->route('admin.changelogs.index')
->with('success', '日志已删除。');
}
/**
* 将版本更新通知持久化为 Room 1 系统消息
* 确保用户重进聊天室时仍能在历史消息中看到该通知
*
* @param DevChangelog $log 已发布的日志
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
}
@@ -0,0 +1,91 @@
<?php
/**
* 文件功能:后台部门管理控制器
* 提供部门的 CRUD 功能(增删改查)
* 部门是职务的上级分类,包含位阶、颜色、描述等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
/**
* 部门列表页
*/
public function index(): View
{
$departments = Department::withCount(['positions'])
->orderBy('sort_order')
->orderByDesc('rank')
->get();
return view('admin.departments.index', compact('departments'));
}
/**
* 创建部门
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name',
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
Department::create($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】创建成功!");
}
/**
* 更新部门
*/
public function update(Request $request, Department $department): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name,'.$department->id,
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
$department->update($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】更新成功!");
}
/**
* 删除部门(级联删除职务)
*/
public function destroy(Department $department): RedirectResponse
{
// 检查是否有在职人员
$activeMemberCount = $department->positions()
->whereHas('activeUserPositions')
->count();
if ($activeMemberCount > 0) {
return redirect()->route('admin.departments.index')
->with('error', "部门【{$department->name}】下尚有在职人员,请先撤销所有职务后再删除。");
}
$department->delete();
return redirect()->route('admin.departments.index')->with('success', "部门【{$department->name}】已删除!");
}
}
@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:后台用户反馈管理控制器(仅 id=1 超级管理员可访问)
* 提供反馈列表查看、处理状态修改、官方回复功能
* 侧边栏显示待处理数量徽标
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackManagerController extends Controller
{
/**
* 后台反馈列表页(支持类型+状态筛选)
*
* @param Request $request type/status 筛选参数
*/
public function index(Request $request): View
{
$type = $request->input('type');
$status = $request->input('status');
$query = FeedbackItem::with(['replies'])
->orderByDesc('created_at');
// 按类型筛选
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
// 按状态筛选
if ($status && array_key_exists($status, FeedbackItem::STATUS_CONFIG)) {
$query->ofStatus($status);
}
$feedbacks = $query->paginate(20)->withQueryString();
// 待处理数量(用于侧边栏徽标)
$pendingCount = FeedbackItem::pending()->count();
return view('admin.feedback.index', [
'feedbacks' => $feedbacks,
'pendingCount' => $pendingCount,
'statusConfig' => FeedbackItem::STATUS_CONFIG,
'typeConfig' => FeedbackItem::TYPE_CONFIG,
'currentType' => $type,
'currentStatus' => $status,
]);
}
/**
* 更新反馈处理状态和官方回复(Ajax + 表单双模式)
* Ajax 返回 JSON,普通表单提交返回重定向
*
* @param Request $request status/admin_remark 字段
* @param int $id 反馈 ID
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'status' => 'required|in:'.implode(',', array_keys(FeedbackItem::STATUS_CONFIG)),
'admin_remark' => 'nullable|string|max:2000',
]);
$feedback->update([
'status' => $data['status'],
'admin_remark' => $data['admin_remark'] ?? $feedback->admin_remark,
]);
// 如果有新的官方回复内容,同时写入 feedback_replies(带 is_admin 标记)
if (! empty($data['admin_remark']) && $data['admin_remark'] !== $feedback->getOriginal('admin_remark')) {
DB::transaction(function () use ($feedback, $data): void {
FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => 1,
'username' => '🛡️ 开发者',
'content' => $data['admin_remark'],
'is_admin' => true,
]);
$feedback->increment('replies_count');
});
}
// Ajax 请求返回 JSON
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'new_status' => $data['status'],
'status_label' => FeedbackItem::STATUS_CONFIG[$data['status']]['icon'].' '.FeedbackItem::STATUS_CONFIG[$data['status']]['label'],
'status_color' => FeedbackItem::STATUS_CONFIG[$data['status']]['color'],
]);
}
return redirect()->route('admin.feedback.index')
->with('success', '反馈状态已更新!');
}
}
@@ -0,0 +1,110 @@
<?php
/**
* 文件功能:后台职务管理控制器
* 提供职务的 CRUD 功能,包含任命权限白名单(多选 position_appoint_limits)的同步
* 职务属于部门,包含等级、图标、人数上限、奖励上限等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PositionController extends Controller
{
/**
* 职务列表页
*/
public function index(): View
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
return view('admin.positions.index', compact('departments', 'allPositions'));
}
/**
* 创建职务(同时同步任命白名单)
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position = Position::create($data);
// 同步任命白名单(有选则写,没选则清空=无任命权)
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
}
/**
* 更新职务(含任命白名单同步)
*/
public function update(Request $request, Position $position): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】更新成功!");
}
/**
* 删除职务(有在职人员时拒绝)
*/
public function destroy(Position $position): RedirectResponse
{
if ($position->isFull() || $position->activeUserPositions()->exists()) {
return redirect()->route('admin.positions.index')
->with('error', "职务【{$position->name}】尚有在职人员,请先撤销后再删除。");
}
$position->delete();
return redirect()->route('admin.positions.index')->with('success', "职务【{$position->name}】已删除!");
}
}
@@ -31,11 +31,11 @@ class RoomManagerController extends Controller
/**
* 更新房间信息
*
* @param Room $room 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
@@ -52,11 +52,11 @@ class RoomManagerController extends Controller
/**
* 删除房间(非系统房间)
*
* @param Room $room 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
if ($room->room_keep) {
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
}
@@ -11,8 +11,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -30,6 +30,7 @@ class UserManagerController extends Controller
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示用户列表及搜索(支持按等级/经验/金币/魅力排序)
*/
@@ -42,11 +43,15 @@ class UserManagerController extends Controller
}
// 排序:允许的字段白名单,防止 SQL 注入
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$users = $query->orderBy($sortBy, $sortDir)->paginate(20)->withQueryString();
$users = $query
->with(['activePosition.position.department', 'vipLevel'])
->orderBy($sortBy, $sortDir)
->paginate(20)
->withQueryString();
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
@@ -56,10 +61,12 @@ class UserManagerController extends Controller
/**
* 修改用户资料、等级或密码 (AJAX 或表单)
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
@@ -67,12 +74,8 @@ class UserManagerController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
// 管理员级别 = 最高等级 + 1,后台编辑最高可设到管理员级别
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
$validated = $request->validate([
'sex' => 'sometimes|integer|in:0,1,2',
'user_level' => "sometimes|integer|min:0|max:{$adminLevel}",
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
@@ -83,17 +86,6 @@ class UserManagerController extends Controller
'hy_time' => 'sometimes|nullable|date',
]);
// 如果传了且没超权,直接赋予
if (isset($validated['user_level'])) {
if ($currentUser->id !== $targetUser->id) {
// 修改别人:只有真正的创始人 (ID=1) 才能修改别人的等级
if ($currentUser->id !== 1) {
return response()->json(['status' => 'error', 'message' => '权限越界:只有星系创始人(站长)才能调整其他用户的行政等级!'], 403);
}
}
$targetUser->user_level = $validated['user_level'];
}
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
@@ -158,10 +150,12 @@ class UserManagerController extends Controller
/**
* 物理删除杀封用户
*
* @param User $user 路由模型自动注入
*/
public function destroy(Request $request, int $id): RedirectResponse
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不允许删除同级或更高等级的账号
+6 -7
View File
@@ -60,11 +60,11 @@ class VipController extends Controller
/**
* 更新会员等级
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level = $vip;
$data = $request->validate([
'name' => 'required|string|max:50',
@@ -90,12 +90,11 @@ class VipController extends Controller
/**
* 删除会员等级(关联用户的 vip_level_id 会自动置 null
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level->delete();
$vip->delete();
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
}
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:开发日志前台控制器
* 对应独立页面 /changelog,展示已发布的版本更新日志
* 支持懒加载(IntersectionObserver + 游标分页)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\DevChangelog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/** 每次加载的条数 */
private const PAGE_SIZE = 10;
/**
* 更新日志列表页(SSR首屏)
* 预加载最新 10 条已发布日志
*/
public function index(): View
{
$changelogs = DevChangelog::published()
->limit(self::PAGE_SIZE)
->get();
return view('changelog.index', compact('changelogs'));
}
/**
* 懒加载更多日志(JSON API
* 游标分页:传入已加载的最后一条 ID,返回更旧的 10
*
* @param Request $request after_id 参数
*/
public function loadMoreChangelogs(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$items = DevChangelog::published()
->after($afterId)
->limit(self::PAGE_SIZE)
->get();
$data = $items->map(fn (DevChangelog $log) => [
'id' => $log->id,
'version' => $log->version,
'title' => $log->title,
'type_label' => $log->type_label,
'type_color' => $log->type_color,
'content_html' => $log->content_html,
'summary' => $log->summary,
'published_at' => $log->published_at?->format('Y-m-d'),
]);
return response()->json([
'items' => $data,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
}
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:聊天室内快速任命/撤销控制器
* 供有职务的管理员在聊天室用户名片弹窗中快速任命或撤销目标用户的职务。
* 权限校验委托给 AppointmentService,本控制器只做请求解析和返回 JSON。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatAppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取可用职务列表(供名片弹窗下拉选择)
* 返回操作人有权限任命的职务
*/
public function positions(): JsonResponse
{
$operator = Auth::user();
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
$operatorPosition = $operator->activePosition?->position;
$query = Position::query()
->with('department')
->orderByDesc('rank');
// 仅有具体职务(非 superlevel 直通)的操作人才限制 rank 范围
if ($operatorPosition && $operator->user_level < $superLevel) {
$query->where('rank', '<', $operatorPosition->rank);
}
$positions = $query->get()->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'icon' => $p->icon,
'rank' => $p->rank,
'department' => $p->department?->name,
]);
return response()->json(['status' => 'success', 'positions' => $positions]);
}
/**
* 快速任命:将目标用户任命为指定职务
* 成功后向操作人所在聊天室广播任命公告
*/
public function appoint(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$position = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $position, $request->remark);
// 任命成功后广播礼花通知:优先用前端传来的 room_id,否则从 Redis 查操作人所在房间
if ($result['ok']) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $position->icon ?? '🎖️',
positionName: $position->name,
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 快速撤销:撤销目标用户当前的职务
*/
public function revoke(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
// 撤销前先取目标当前职务信息(撤销后就查不到了)
$activeUp = $target->activePosition?->load('position.department');
$posIcon = $activeUp?->position?->icon ?? '🎖️';
$posName = $activeUp?->position?->name ?? '';
$deptName = $activeUp?->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
// 撤销成功后广播通知到聊天室
if ($result['ok'] && $posName) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
}
+200 -84
View File
@@ -11,38 +11,43 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Enums\CurrencySource;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
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\View\View;
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
) {}
/**
* 进入房间初始化 (等同于原版 INIT.ASP)
*
@@ -56,12 +61,14 @@ class ChatController extends Controller
// 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -70,6 +77,8 @@ class ChatController extends Controller
'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);
@@ -77,88 +86,123 @@ class ChatController extends Controller
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 <b>{$user->username}</b> 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 发送新人专属欢迎公告
$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();
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 4. 管理员(superlevel)进入时:触发全房间烟花特效 + 公屏欢迎公告
if ($user->user_level >= $superLevel) {
// 广播烟花特效给所有在线用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
// 发送欢迎公告消息(使用系统公告样式)
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 <b>{$user->username}</b> 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
}
// 5. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则:公众发言(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;
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
// 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
if ($user->user_level >= $superLevel) {
// 管理员专属:全房间烟花
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
} else {
// 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词)
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<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']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'historyMessages' => $historyMessages,
]);
// 最后:如果用户有在职职务,开始记录这次入场的在职登录
// 此时用户局部变量已初始化,可以安全读取 in_time
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user), // 周卡特效(登录自动播放)
'newbieEffect' => $newbieEffect, // 新人入场专属特效
'historyMessages' => $historyMessages, // 把历史消息附带给前端
]);
}
/**
@@ -183,6 +227,20 @@ class ChatController extends Controller
], 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)) {
@@ -284,6 +342,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -292,6 +351,8 @@ class ChatController extends Controller
'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. 如果突破境界,向全房系统喊话广播!
@@ -405,11 +466,49 @@ class ChatController extends Controller
// 记录退出时间和退出信息
$user->update([
'out_time' => now(),
'out_info' => "正常退出了房间",
'out_info' => '正常退出了房间',
]);
// 2. 广播通知他人
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
$this->closeDutyLog($user->id);
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level >= $superLevel) {
// 管理员离场:系统公告
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "👋 管理员 【{$user->username}】 已离开聊天室。",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
} else {
[$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user);
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
}
$this->chatState->pushMessage($id, $leaveMsg);
// 3. 广播通知他人 (UserLeft 更新用户名单列表,MessageSent 更新消息记录)
broadcast(new UserLeft($id, $user->username))->toOthers();
broadcast(new MessageSent($id, $leaveMsg))->toOthers();
return response()->json(['status' => 'success']);
}
@@ -601,7 +700,7 @@ class ChatController extends Controller
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -707,4 +806,21 @@ class ChatController extends Controller
return max(0, (int) $value);
}
/**
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
* 在用户退出房间或心跳超时时调用
*
* @param int $userId 用户 ID
*/
private function closeDutyLog(int $userId): void
{
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
}
}
@@ -0,0 +1,79 @@
<?php
/**
* 文件功能:勤务台页面控制器
* 左侧五个子菜单:任职列表、日榜、周榜、月榜、总榜
* 路由:GET /duty-hall?tab=roster|day|week|month|all
*
* @author ChatRoom Laravel
*
* @version 1.1.0
*/
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\PositionDutyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DutyHallController extends Controller
{
/**
* 勤务台主页(根据 tab 切换内容)
*/
public function index(Request $request): View
{
$tab = $request->input('tab', 'roster');
// ── 任职列表:按部门→职务展示全部(含空缺) ────────────────────
$currentStaff = null;
if ($tab === 'roster') {
$currentStaff = Department::query()
->with([
'positions' => fn ($q) => $q->orderByDesc('rank'),
'positions.activeUserPositions.user',
])
->orderByDesc('rank')
->get();
}
// ── 日/周/月/总榜:勤务时长排行 ──────────────────────────────────
$leaderboard = null;
if (in_array($tab, ['day', 'week', 'month', 'all'])) {
$query = PositionDutyLog::query()
->selectRaw('user_id, SUM(duration_seconds) as total_seconds, COUNT(*) as checkin_count');
// 按时间段过滤
match ($tab) {
'day' => $query->whereDate('login_at', today()),
'week' => $query->whereBetween('login_at', [now()->startOfWeek(), now()->endOfWeek()]),
'month' => $query->whereYear('login_at', now()->year)->whereMonth('login_at', now()->month),
'all' => null, // 不加时间限制
};
$leaderboard = $query
->groupBy('user_id')
->orderByDesc('total_seconds')
->limit(20)
->with('user')
->get();
}
// 各榜标签配置
$tabs = [
'roster' => ['label' => '任职列表', 'icon' => '🏛️'],
'day' => ['label' => '日榜', 'icon' => '☀️'],
'week' => ['label' => '周榜', 'icon' => '📆'],
'month' => ['label' => '月榜', 'icon' => '🗓️'],
'all' => ['label' => '总榜', 'icon' => '🏆'],
];
return view('duty-hall.index', compact(
'tab',
'tabs',
'currentStaff',
'leaderboard',
));
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
/**
* 文件功能:用户反馈前台控制器
* 对应独立页面 /feedback,处理用户提交 Bug报告/功能建议、
* 赞同(Toggle)、补充评论、删除等操作
* 所有写操作均需登录(chat.auth 中间件保护)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use App\Models\FeedbackVote;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackController extends Controller
{
/** 每次懒加载的条数 */
private const PAGE_SIZE = 10;
/**
* 用户反馈列表页(SSR首屏)
* 预加载按赞同数倒序的 10 条反馈
*/
public function index(): View
{
$feedbacks = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->limit(self::PAGE_SIZE)
->get();
// 当前用户已赞同的反馈 ID 集合(前端切换按钮状态用)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $feedbacks->pluck('id'))
->pluck('feedback_id')
->toArray();
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion
*
* @param Request $request after_id / type 筛选参数
*/
public function loadMore(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->where('id', '<', $afterId)
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
// 当前用户已赞同的 ID(用于切换按钮状态)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $items->pluck('id'))
->pluck('feedback_id')
->toArray();
return response()->json([
'items' => $this->formatItems($items, $myVotedIds),
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 提交新反馈(Bug报告或功能建议)
*
* @param Request $request type/title/content 字段
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|in:bug,suggestion',
'title' => 'required|string|max:200',
'content' => 'required|string|max:2000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
$item = FeedbackItem::create([
'user_id' => $user->id,
'username' => $user->username,
'type' => $data['type'],
'title' => $data['title'],
'content' => $data['content'],
'status' => 'pending',
]);
return response()->json([
'status' => 'success',
'message' => '反馈已提交,感谢您的贡献!',
'item' => $this->formatItem($item, false),
]);
}
/**
* 赞同/取消赞同反馈(Toggle 操作)
* 每人每条只能赞同一次,再次点击则取消
* 使用数据库事务保证 votes_count 冗余字段与记录一致
*
* @param int $id 反馈 ID
*/
public function vote(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$userId = Auth::id();
// 不能赞同自己提交的反馈
if ($feedback->user_id === $userId) {
return response()->json([
'status' => 'error',
'message' => '不能赞同自己的反馈',
], 422);
}
$voted = false;
DB::transaction(function () use ($feedback, $userId, &$voted): void {
$existing = FeedbackVote::where('feedback_id', $feedback->id)
->where('user_id', $userId)
->first();
if ($existing) {
// 已赞同 → 取消赞同
$existing->delete();
$feedback->decrement('votes_count');
$voted = false;
} else {
// 未赞同 → 新增赞同
FeedbackVote::create([
'feedback_id' => $feedback->id,
'user_id' => $userId,
]);
$feedback->increment('votes_count');
$voted = true;
}
});
return response()->json([
'status' => 'success',
'voted' => $voted,
'votes_count' => $feedback->fresh()->votes_count,
]);
}
/**
* 提交补充评论
* id=1 管理员的回复自动标记 is_admin=true(前台特殊展示)
*
* @param Request $request content 字段
* @param int $id 反馈 ID
*/
public function reply(Request $request, int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'content' => 'required|string|max:1000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
/** @var FeedbackReply $reply */
$reply = null;
DB::transaction(function () use ($feedback, $data, $user, &$reply): void {
$reply = FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => $user->id,
'username' => $user->username,
'content' => $data['content'],
'is_admin' => $user->id === 1,
]);
$feedback->increment('replies_count');
});
return response()->json([
'status' => 'success',
'message' => '评论已提交',
'reply' => [
'id' => $reply->id,
'username' => $reply->username,
'content' => $reply->content,
'is_admin' => $reply->is_admin,
'created_at' => $reply->created_at->diffForHumans(),
],
]);
}
/**
* 删除反馈
* 普通用户:仅24小时内可删除自己的反馈
* 管理员(id=1):任意时间可删除任意反馈
*
* @param int $id 反馈 ID
*/
public function destroy(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $feedback->user_id === $user->id;
$isAdmin = $user->id === 1;
if (! $isOwner && ! $isAdmin) {
return response()->json(['status' => 'error', 'message' => '无权删除'], 403);
}
if ($isOwner && ! $isAdmin && ! $feedback->is_within_24_hours) {
return response()->json([
'status' => 'error',
'message' => '超过 24 小时的反馈无法删除',
], 422);
}
// 级联删除关联的赞同记录和评论记录
DB::transaction(function () use ($feedback): void {
FeedbackVote::where('feedback_id', $feedback->id)->delete();
FeedbackReply::where('feedback_id', $feedback->id)->delete();
$feedback->delete();
});
return response()->json(['status' => 'success', 'message' => '已删除']);
}
// ═══════════════ 私有辅助方法 ═══════════════
/**
* 格式化单条反馈数据(供 JSON 返回给前端)
*
* @param FeedbackItem $item 反馈实例
* @param bool $voted 当前用户是否已赞同
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
return [
'id' => $item->id,
'type' => $item->type,
'type_label' => $item->type_label,
'title' => $item->title,
'content' => $item->content,
'status' => $item->status,
'status_label' => $item->status_label,
'status_color' => $item->status_config['color'],
'admin_remark' => $item->admin_remark,
'votes_count' => $item->votes_count,
'replies_count' => $item->replies_count,
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
])->values()->toArray(),
];
}
/**
* 批量格式化反馈数据集合
*
* @param \Illuminate\Support\Collection<int, FeedbackItem> $items
* @param array<int> $myVotedIds 当前用户已赞同的 ID 列表
*/
private function formatItems(\Illuminate\Support\Collection $items, array $myVotedIds): array
{
return $items->map(fn (FeedbackItem $item) => $this->formatItem(
$item,
in_array($item->id, $myVotedIds)
))->values()->toArray();
}
}
+4 -4
View File
@@ -12,8 +12,8 @@
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
@@ -26,8 +26,8 @@ use Illuminate\Support\Facades\Redis;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {}
@@ -148,7 +148,7 @@ class FishingController extends Controller
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',
+31 -14
View File
@@ -30,8 +30,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
class UserController extends Controller
{
/**
@@ -43,6 +41,7 @@ class UserController extends Controller
$operator = Auth::user();
// 基础公开信息
$activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [
'username' => $targetUser->username,
'sex' => $targetUser->sex,
@@ -52,6 +51,10 @@ class UserController extends Controller
'qianming' => $targetUser->qianming,
'sign' => $targetUser->sign ?? '这个人很懒,什么都没留下。',
'created_at' => $targetUser->created_at->format('Y-m-d'),
// 在职职务(供名片弹窗任命/撤销判断)
'position_name' => $activePosition?->name ?? '',
'position_icon' => $activePosition?->icon ?? '',
'department_name' => $activePosition?->department?->name ?? '',
];
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
@@ -61,6 +64,20 @@ class UserController extends Controller
$data['meili'] = $targetUser->meili ?? 0;
}
// 职务履历(所有任职记录,按任命时间倒序;positions() 关系已含 with
$data['position_history'] = $targetUser->positions
->map(fn ($up) => [
'position_icon' => $up->position?->icon ?? '',
'position_name' => $up->position?->name ?? '',
'department_name' => $up->position?->department?->name ?? '',
'appointed_at' => $up->appointed_at?->format('Y-m-d'),
'revoked_at' => $up->revoked_at?->format('Y-m-d'),
'is_active' => $up->is_active,
'duration_days' => $up->duration_days,
])
->values()
->all();
// 拥有封禁IPlevel_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
@@ -72,24 +89,24 @@ class UserController extends Controller
if ($ipToLookup) {
try {
// 不传路径,使用 zoujingli/ip2region 包自带的内置数据库
$ip2r = new \Ip2Region();
$ip2r = new \Ip2Region;
$info = $ip2r->getIpInfo($ipToLookup);
if ($info) {
$country = $info['country'] ?? '';
$country = $info['country'] ?? '';
$province = $info['province'] ?? '';
$city = $info['city'] ?? '';
$city = $info['city'] ?? '';
// 过滤掉占位符 "0"
$province = ($province === '0') ? '' : $province;
$city = ($city === '0') ? '' : $city;
$city = ($city === '0') ? '' : $city;
if ($country === '中国') {
$data['location'] = trim($province . ($province !== $city ? ' ' . $city : ''));
$data['location'] = trim($province.($province !== $city ? ' '.$city : ''));
} else {
$data['location'] = $country ?: '未知区域';
}
if (empty($data['location'])) {
$data['location'] = '未知区域';
}
@@ -117,7 +134,7 @@ class UserController extends Controller
{
$user = Auth::user();
$data = $request->validated();
// 当用户试图更新邮箱,并且新邮箱不等于当前旧邮箱时启动验证码拦截
if (isset($data['email']) && $data['email'] !== $user->email) {
// 首先判断系统开关是否开启,没开启直接禁止修改邮箱
@@ -131,10 +148,10 @@ class UserController extends Controller
}
// 获取缓存的验证码
$codeKey = 'email_verify_code_' . $user->id . '_' . $data['email'];
$codeKey = 'email_verify_code_'.$user->id.'_'.$data['email'];
$cachedCode = \Illuminate\Support\Facades\Cache::get($codeKey);
if (!$cachedCode || $cachedCode != $emailCode) {
if (! $cachedCode || $cachedCode != $emailCode) {
return response()->json(['status' => 'error', 'message' => '验证码不正确或已过期(有效期5分钟),请重新获取。'], 422);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* 文件功能:在职职务验证中间件
* 只要用户当前持有在职职务(user_positions.is_active=true),即可访问后台。
* id=1 超级管理员无需职务,直接通过。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Middleware;
use App\Models\Sysparam;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class HasActivePosition
{
/**
* 校验用户是否有在职职务(或为超级管理员)。
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return redirect()->route('home');
}
$user = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// id=1 或 superlevel 及以上:无需职务,直通
if ($user->id === 1 || $user->user_level >= $superLevel) {
return $next($request);
}
// 检查是否有在职职务
if (! $user->activePosition()->exists()) {
if ($request->expectsJson()) {
return response()->json(['message' => '权限不足:您尚未持有任何职务', 'status' => 'error'], 403);
}
abort(403, '权限不足:您尚未持有任何职务,无法访问后台。');
}
return $next($request);
}
}
+14
View File
@@ -10,7 +10,9 @@
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class SendMessageRequest extends FormRequest
{
@@ -45,4 +47,16 @@ class SendMessageRequest extends FormRequest
'content.max' => '发言内容不能超过 500 个字符。',
];
}
/**
* 重写验证失败的处理,无论如何(就算未按 ajax 标准提交)都必须抛出 JSON,不可以触发网页重定向去走 GET 请求而引发 302 方法错误
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'status' => 'error',
'message' => $validator->errors()->first(),
'errors' => $validator->errors(),
], 422));
}
}
+1 -1
View File
@@ -52,6 +52,6 @@ class Autoact extends Model
*/
public function renderText(string $username): string
{
return str_replace('{username}', $username, $this->text_body);
return str_replace('{username}', "{$username}", $this->text_body);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:部门模型
* 对应 departments 表,管理聊天室部门(办公厅 / 迎宾部 / 聊务部 / 宣传部等)
* 一个部门下有多个职务(positions)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'name',
'rank',
'color',
'sort_order',
'description',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 获取该部门下的所有职务(按 rank 降序)
*/
public function positions(): HasMany
{
return $this->hasMany(Position::class)->orderByDesc('rank');
}
/**
* 获取部门当前所有在职用户(通过职务关联)
*/
public function activeMembers(): Collection
{
return UserPosition::query()
->whereHas('position', fn ($q) => $q->where('department_id', $this->id))
->where('is_active', true)
->with(['user', 'position'])
->get();
}
/**
* 按位阶倒序排列的查询范围
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:开发日志 Model
* 对应 dev_changelogs 表,管理版本更新记录
* 支持草稿/已发布状态,Markdown 内容渲染
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DevChangelog extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'version',
'title',
'type',
'content',
'is_published',
'notify_chat',
'published_at',
];
/**
* 字段类型自动转换
*/
protected $casts = [
'is_published' => 'boolean',
'notify_chat' => 'boolean',
'published_at' => 'datetime',
];
/**
* 类型标签配置(中文名 + Tailwind 颜色类)
*/
public const TYPE_CONFIG = [
'feature' => ['label' => '🆕 新功能', 'color' => 'emerald'],
'fix' => ['label' => '🐛 修复', 'color' => 'rose'],
'improve' => ['label' => '⚡ 优化', 'color' => 'blue'],
'other' => ['label' => '📌 其他', 'color' => 'slate'],
];
// ═══════════════ 查询作用域 ═══════════════
/**
* 只查询已发布的日志
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true)->orderByDesc('published_at');
}
/**
* 懒加载:查询比指定 ID 更旧的已发布日志(游标分页)
*
* @param int $afterId 已加载的最后一条 ID
*/
public function scopeAfter(Builder $query, int $afterId): Builder
{
return $query->where('id', '<', $afterId);
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取类型对应的中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 获取类型对应的 Tailwind 颜色名
*/
public function getTypeColorAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['color'] ?? 'slate';
}
/**
* Markdown 内容渲染为 HTML(使用 Laravel 内置 Str::markdown
*/
public function getContentHtmlAttribute(): string
{
return Str::markdown($this->content, [
'html_input' => 'strip', // 去掉原始 HTML,防止 XSS
'allow_unsafe_links' => false,
]);
}
/**
* 获取内容纯文本摘要(用于列表预览,截取前 150 字)
*/
public function getSummaryAttribute(): string
{
// 去掉 Markdown 标记后截取纯文本
$plain = strip_tags(Str::markdown($this->content));
return Str::limit($plain, 150);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* 文件功能:用户反馈主表 Model
* 对应 feedback_items 表,管理用户提交的 Bug报告和功能建议
* 包含7种处理状态、赞同数/评论数冗余统计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FeedbackItem extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'user_id',
'username',
'type',
'title',
'content',
'status',
'admin_remark',
'votes_count',
'replies_count',
];
/**
* 处理状态配置(中文名 + 图标 + Tailwind 颜色)
*/
public const STATUS_CONFIG = [
'pending' => ['label' => '待处理', 'icon' => '⏳', 'color' => 'gray'],
'accepted' => ['label' => '已接受', 'icon' => '✅', 'color' => 'green'],
'in_progress' => ['label' => '开发中', 'icon' => '🔧', 'color' => 'blue'],
'fixed' => ['label' => '已修复', 'icon' => '🐛', 'color' => 'emerald'],
'done' => ['label' => '已完成', 'icon' => '🚀', 'color' => 'emerald'],
'rejected' => ['label' => '暂不同意', 'icon' => '❌', 'color' => 'red'],
'shelved' => ['label' => '已搁置', 'icon' => '📦', 'color' => 'orange'],
];
/**
* 类型配置
*/
public const TYPE_CONFIG = [
'bug' => ['label' => '🐛 Bug报告', 'color' => 'rose'],
'suggestion' => ['label' => '💡 功能建议', 'color' => 'blue'],
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联赞同记录
*/
public function votes(): HasMany
{
return $this->hasMany(FeedbackVote::class, 'feedback_id');
}
/**
* 关联补充评论
*/
public function replies(): HasMany
{
return $this->hasMany(FeedbackReply::class, 'feedback_id')->orderBy('created_at');
}
// ═══════════════ 查询作用域 ═══════════════
/**
* 按类型筛选
*
* @param string $type bug|suggestion
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
/**
* 按状态筛选
*
* @param string $status 处理状态
*/
public function scopeOfStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
/**
* 待处理的反馈(用于后台徽标计数)
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
// ═══════════════ 访问器 ═══════════════
/**
* 获取状态对应的配置(标签/图标/颜色)
*/
public function getStatusConfigAttribute(): array
{
return self::STATUS_CONFIG[$this->status] ?? self::STATUS_CONFIG['pending'];
}
/**
* 获取状态中文标签
*/
public function getStatusLabelAttribute(): string
{
return $this->status_config['icon'].' '.$this->status_config['label'];
}
/**
* 获取类型中文标签
*/
public function getTypeLabelAttribute(): string
{
return self::TYPE_CONFIG[$this->type]['label'] ?? '📌 其他';
}
/**
* 判断反馈是否在24小时内(用于普通用户自删权限)
*/
public function getIsWithin24HoursAttribute(): bool
{
return $this->created_at->diffInHours(now()) < 24;
}
/**
* 判断当前状态是否为已处理(已修复/已完成/暂不同意/已搁置)
*/
public function getIsClosedAttribute(): bool
{
return in_array($this->status, ['fixed', 'done', 'rejected', 'shelved']);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:用户反馈补充评论 Model
* 对应 feedback_replies 表,记录用户对反馈的补充说明和管理员官方回复
* is_admin=1 的回复在前台以特殊「开发者回复」样式高亮展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackReply extends Model
{
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
'username',
'content',
'is_admin',
];
/**
* 字段类型转换
*/
protected $casts = [
'is_admin' => 'boolean',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* 文件功能:用户反馈赞同记录 Model
* 对应 feedback_votes 表,记录用户对反馈的赞同行为
* 每个用户每条反馈只能赞同一次(数据库层唯一索引保障)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeedbackVote extends Model
{
/**
* 关闭 updated_at(赞同记录只有创建,无需更新时间)
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'feedback_id',
'user_id',
];
// ═══════════════ 关联关系 ═══════════════
/**
* 关联所属反馈
*/
public function feedback(): BelongsTo
{
return $this->belongsTo(FeedbackItem::class, 'feedback_id');
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* 文件功能:职务模型
* 对应 positions 表,职务属于某个部门,包含等级、图标、人数上限和奖励上限
* 任命权限通过 position_appoint_limits 中间表多对多关联定义
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Position extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'department_id',
'name',
'icon',
'rank',
'level',
'max_persons',
'max_reward',
'sort_order',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'rank' => 'integer',
'level' => 'integer',
'max_persons' => 'integer',
'max_reward' => 'integer',
'sort_order' => 'integer',
];
}
/**
* 所属部门
*/
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
/**
* 该职务当前在职的用户记录(user_positions
*/
public function activeUserPositions(): HasMany
{
return $this->hasMany(UserPosition::class)->where('is_active', true);
}
/**
* 该职务的所有历史任职记录
*/
public function userPositions(): HasMany
{
return $this->hasMany(UserPosition::class);
}
/**
* 该职务可以任命的目标职务列表(position_appoint_limits 中间表)
*/
public function appointablePositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointer_position_id',
'appointable_position_id'
);
}
/**
* 哪些职务的持有者可以将用户任命到本职务
*/
public function appointedByPositions(): BelongsToMany
{
return $this->belongsToMany(
Position::class,
'position_appoint_limits',
'appointable_position_id',
'appointer_position_id'
);
}
/**
* 获取当前在职人数
*/
public function currentCount(): int
{
return $this->activeUserPositions()->count();
}
/**
* 是否已满员
*/
public function isFull(): bool
{
if ($this->max_persons === null) {
return false;
}
return $this->currentCount() >= $this->max_persons;
}
/**
* 查询范围:按位阶降序
*/
public function scopeOrdered($query): void
{
$query->orderBy('sort_order')->orderByDesc('rank');
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* 文件功能:职务权限使用记录模型
* 对应 position_authority_logs 表,记录职务持有者每次行使职权的操作
* 包含任命、撤销、奖励金币、警告、踢出、禁言、封IP等操作类型
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionAuthorityLog extends Model
{
/**
* 禁用 updated_at(只有 created_at
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'action_type',
'target_user_id',
'target_position_id',
'amount',
'remark',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'amount' => 'integer',
'created_at' => 'datetime',
];
}
/**
* 操作类型中文标签
*/
public static array $actionLabels = [
'appoint' => '任命',
'revoke' => '撤销职务',
'reward' => '奖励金币',
'warn' => '警告',
'kick' => '踢出',
'mute' => '禁言',
'banip' => '封锁IP',
'other' => '其他',
];
/**
* 操作人
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 操作时使用的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 操作对象用户
*/
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
/**
* 任命/撤销时的目标职务
*/
public function targetPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'target_position_id');
}
/**
* 获取操作类型的中文标签
*/
public function getActionLabelAttribute(): string
{
return self::$actionLabels[$this->action_type] ?? $this->action_type;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* 文件功能:在职登录记录模型
* 对应 position_duty_logs 表,记录职务持有者每次进房的登录时间、在线时长和退出时间
* 用于勤务台四榜统计和个人履历出勤数据展示
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PositionDutyLog extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'user_position_id',
'login_at',
'logout_at',
'duration_seconds',
'ip_address',
'room_id',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'login_at' => 'datetime',
'logout_at' => 'datetime',
'duration_seconds' => 'integer',
];
}
/**
* 对应的用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 对应的在职记录
*/
public function userPosition(): BelongsTo
{
return $this->belongsTo(UserPosition::class);
}
/**
* 格式化在线时长为"Xh Ym"字符串(如 128h 30m
*/
public function getFormattedDurationAttribute(): string
{
$seconds = $this->duration_seconds ?? 0;
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
return "{$hours}h {$minutes}m";
}
}
+36
View File
@@ -15,6 +15,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -144,4 +146,38 @@ class User extends Authenticatable
return $this->vipLevel?->icon ?? '';
}
// ── 职务相关关联 ──────────────────────────────────────────────────────
/**
* 全部猎务履历(包括历史记录)
*/
public function positions(): HasMany
{
return $this->hasMany(UserPosition::class)->with(['position.department'])->orderByDesc('appointed_at');
}
/**
* 当前在职职务记录(HasOne,最多一条)
*/
public function activePosition(): HasOne
{
return $this->hasOne(UserPosition::class)->where('is_active', true)->with(['position.department']);
}
/**
* 该用户在职期间的权限操作日志
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class)->orderByDesc('created_at');
}
/**
* 判断用户是否有当前在职职务
*/
public function hasActivePosition(): bool
{
return $this->activePosition()->exists();
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* 文件功能:用户职务关联模型(职务履历核心表)
* 对应 user_positions 表,记录用户当前在职职务及全部历史任职记录
* is_active=true 表示当前在职,false 为历史存档(永久保留)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class UserPosition extends Model
{
/**
* 允许批量赋值的字段
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'position_id',
'appointed_by_user_id',
'appointed_at',
'remark',
'revoked_at',
'revoked_by_user_id',
'is_active',
];
/**
* 字段类型转换
*/
public function casts(): array
{
return [
'appointed_at' => 'datetime',
'revoked_at' => 'datetime',
'is_active' => 'boolean',
];
}
/**
* 在职用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 所任职务
*/
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
/**
* 任命人
*/
public function appointedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'appointed_by_user_id');
}
/**
* 撤销人
*/
public function revokedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'revoked_by_user_id');
}
/**
* 该任职期间的登录记录
*/
public function dutyLogs(): HasMany
{
return $this->hasMany(PositionDutyLog::class);
}
/**
* 该任职期间的权限使用记录
*/
public function authorityLogs(): HasMany
{
return $this->hasMany(PositionAuthorityLog::class);
}
/**
* 查询范围:仅当前在职
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 获取任职时长(天数);在职则计算至今
*/
public function getDurationDaysAttribute(): int
{
$end = $this->revoked_at ?? now();
return (int) $this->appointed_at->diffInDays($end);
}
/**
* 任职期间累计在线时长(秒)
*/
public function getTotalOnlineSecondsAttribute(): int
{
return (int) $this->dutyLogs()->sum('duration_seconds');
}
/**
* 任职期间累计发放金币总量
*/
public function getTotalRewardedCoinsAttribute(): int
{
return (int) $this->authorityLogs()
->where('action_type', 'reward')
->sum('amount');
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
/**
* 文件功能:职务任命服务
* 处理职务系统的核心业务逻辑:任命、撤销、权限校验
* 所有权限操作均写入 position_authority_logs 留存审计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\Position;
use App\Models\PositionAuthorityLog;
use App\Models\User;
use App\Models\UserPosition;
use Illuminate\Support\Facades\DB;
class AppointmentService
{
/**
* 获取用户当前在职记录(无则返回 null
*/
public function getActivePosition(User $user): ?UserPosition
{
return UserPosition::query()
->where('user_id', $user->id)
->where('is_active', true)
->with(['position.department', 'position.appointablePositions'])
->first();
}
/**
* 校验操作人是否有权将目标用户任命到指定职务
*
* id=1 超级管理员绕过所有要目校验,可直接任命任意职务。
*
* @return array{ok: bool, message: string}
*/
public function validateAppoint(User $operator, User $target, Position $targetPosition): array
{
// 超级管理员(id=1)特权:跳过职务和白名单校验,只检查被任命人是否已有职务
if ($operator->id === 1) {
$existingPosition = $this->getActivePosition($target);
if ($existingPosition) {
$currentName = $existingPosition->position->name;
return ['ok' => false, 'message' => "{$target->username}】当前已担任【{$currentName}】,请先撤销其职务再重新任命。"];
}
return ['ok' => true, 'message' => '超级管理员直接授权'];
}
// 操作人必须有在职职务
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return ['ok' => false, 'message' => '您当前无在职职务,无法进行任命操作。'];
}
// 校验任命白名单:目标职务是否在操作人职务的可任命列表内
$isAllowed = $operatorPosition->position
->appointablePositions()
->where('positions.id', $targetPosition->id)
->exists();
if (! $isAllowed) {
return ['ok' => false, 'message' => "您的职务无权任命【{$targetPosition->name}】职位。"];
}
// 检查目标职务是否已满员
if ($targetPosition->isFull()) {
return ['ok' => false, 'message' => "{$targetPosition->name}】职位人数已满,无法继续任命。"];
}
// 检查被任命人是否已有在职职务
$existingPosition = $this->getActivePosition($target);
if ($existingPosition) {
$currentName = $existingPosition->position->name;
return ['ok' => false, 'message' => "{$target->username}】当前已担任【{$currentName}】,请先撤销其职务再重新任命。"];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 执行任命操作
* 任命成功后自动同步 user_level 并写入权限日志
*
* @return array{ok: bool, message: string, userPosition?: UserPosition}
*/
public function appoint(User $operator, User $target, Position $targetPosition, ?string $remark = null): array
{
// 权限校验
$validation = $this->validateAppoint($operator, $target, $targetPosition);
if (! $validation['ok']) {
return $validation;
}
// id=1 超级管理员无需在职职务,直接任命
$operatorPosition = $operator->id === 1 ? null : $this->getActivePosition($operator);
DB::transaction(function () use ($operator, $target, $targetPosition, $remark, $operatorPosition, &$userPosition) {
// 创建任职记录
$userPosition = UserPosition::create([
'user_id' => $target->id,
'position_id' => $targetPosition->id,
'appointed_by_user_id' => $operator->id,
'appointed_at' => now(),
'remark' => $remark,
'is_active' => true,
]);
// 同步 user_level
$target->update(['user_level' => $targetPosition->level]);
// 写入权限操作日志
$this->logAuthority(
operator: $operator,
operatorPosition: $operatorPosition,
actionType: 'appoint',
target: $target,
targetPosition: $targetPosition,
remark: $remark
);
});
return [
'ok' => true,
'message' => "已成功将【{$target->username}】任命为【{$targetPosition->name}】。",
'userPosition' => $userPosition,
];
}
/**
* 校验操作人是否有权撤销目标用户的职务
*
* id=1 超级管理员可直接撤销任意职务。
*
* @return array{ok: bool, message: string}
*/
public function validateRevoke(User $operator, User $target): array
{
// 超级管理员(id=1)特权:跳过白名单校验,直接撤销任意职务
if ($operator->id === 1) {
$targetPosition = $this->getActivePosition($target);
if (! $targetPosition) {
return ['ok' => false, 'message' => "{$target->username}】当前没有在职职务。"];
}
return ['ok' => true, 'message' => '超级管理员直接授权'];
}
// 操作人必须有在职职务
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return ['ok' => false, 'message' => '您当前无在职职务,无法进行撤职操作。'];
}
// 被撤销人必须有在职职务
$targetPosition = $this->getActivePosition($target);
if (! $targetPosition) {
return ['ok' => false, 'message' => "{$target->username}】当前没有在职职务。"];
}
// 操作人不能撤销自己
if ($operator->id === $target->id) {
return ['ok' => false, 'message' => '不能撤销自己的职务。'];
}
// 操作人的任命白名单中必须包含目标职务(即有权任命该职务,也就有权撤销)
$isAllowed = $operatorPosition->position
->appointablePositions()
->where('positions.id', $targetPosition->position_id)
->exists();
if (! $isAllowed) {
return ['ok' => false, 'message' => "您的职务无权撤销【{$targetPosition->position->name}】职位的人员。"];
}
return ['ok' => true, 'message' => '校验通过'];
}
/**
* 执行撤销职务操作
* 撤销后 user_level 1,并写入权限日志
*
* @return array{ok: bool, message: string}
*/
public function revoke(User $operator, User $target, ?string $remark = null): array
{
// 权限校验
$validation = $this->validateRevoke($operator, $target);
if (! $validation['ok']) {
return $validation;
}
$operatorPosition = $this->getActivePosition($operator);
$targetUP = $this->getActivePosition($target);
DB::transaction(function () use ($operator, $target, $remark, $operatorPosition, $targetUP) {
// 撤销在职记录
$targetUP->update([
'is_active' => false,
'revoked_at' => now(),
'revoked_by_user_id' => $operator->id,
]);
// 关闭尚未结束的 duty_log
$target->activePosition?->dutyLogs()
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
// user_level 归 1(由系统经验值自然升级机制重新成长)
$target->update(['user_level' => 1]);
// 写入权限操作日志
$this->logAuthority(
operator: $operator,
operatorPosition: $operatorPosition,
actionType: 'revoke',
target: $target,
targetPosition: $targetUP->position,
remark: $remark
);
});
return [
'ok' => true,
'message' => "已成功撤销【{$target->username}】的【{$targetUP->position->name}】职务,其等级已归 1。",
];
}
/**
* 记录权限操作日志(各类管理操作公共调用)
*
* @param string $actionType 操作类型(appoint/revoke/reward/warn/kick/mute/banip/other
*/
public function logAuthority(
User $operator,
?UserPosition $operatorPosition,
string $actionType,
User $target,
?Position $targetPosition = null,
?int $amount = null,
?string $remark = null,
): void {
// 无在职职务的操作不记录(普通管理员通过 user_level 操作不进此表)
if (! $operatorPosition) {
return;
}
PositionAuthorityLog::create([
'user_id' => $operator->id,
'user_position_id' => $operatorPosition->id,
'action_type' => $actionType,
'target_user_id' => $target->id,
'target_position_id' => $targetPosition?->id,
'amount' => $amount,
'remark' => $remark,
]);
}
/**
* 获取视图用:操作人有权任命的职务列表(用于后台/弹窗任命下拉选择)
*
* @return \Illuminate\Database\Eloquent\Collection<int, Position>
*/
public function getAppointablePositions(User $operator)
{
$operatorPosition = $this->getActivePosition($operator);
if (! $operatorPosition) {
return collect();
}
return $operatorPosition->position
->appointablePositions()
->with('department')
->orderByDesc('rank')
->get();
}
}
+74 -7
View File
@@ -53,15 +53,18 @@ class ChatStateService
public function getUserRooms(string $username): array
{
$rooms = [];
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
do {
[$cursor, $keys] = Redis::scan($cursor, ['match' => 'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $key) {
if (Redis::hexists($key, $username)) {
// 从 key "room:123:users" 中提取房间 ID
preg_match('/room:(\d+):users/', $key, $matches);
if (isset($matches[1])) {
$rooms[] = (int) $matches[1];
// scan 带前缀通配,返回的 key 也带前缀
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
// 去掉前缀得到 Laravel Redis Facade 认识的短少 key
$shortKey = $prefix ? ltrim(substr($fullKey, strlen($prefix)), '') : $fullKey;
if (Redis::hexists($shortKey, $username)) {
preg_match('/room:(\d+):users/', $shortKey, $m);
if (isset($m[1])) {
$rooms[] = (int) $m[1];
}
}
}
@@ -88,6 +91,34 @@ class ChatStateService
return $result;
}
/**
* 扫描 Redis,返回当前所有有在线用户的房间 ID 数组(用于全局广播)。
*
* @return array<int>
*/
public function getAllActiveRoomIds(): array
{
$roomIds = [];
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
do {
// scan 带前缀通配,返回的 key 也带前缀
[$cursor, $keys] = Redis::scan($cursor, ['match' => $prefix.'room:*:users', 'count' => 100]);
foreach ($keys ?? [] as $fullKey) {
$shortKey = $prefix ? substr($fullKey, strlen($prefix)) : $fullKey;
// 只有 hash 非空(有在线用户)才算活跃房间
if (Redis::hlen($shortKey) > 0) {
preg_match('/room:(\d+):users/', $shortKey, $m);
if (isset($m[1])) {
$roomIds[] = (int) $m[1];
}
}
}
} while ($cursor !== '0');
return array_unique($roomIds);
}
/**
* 将一条新发言推入 Redis 列表,并限制最大保留数量,防止内存泄漏。
*
@@ -115,6 +146,42 @@ class ChatStateService
Redis::del($key);
}
/**
* 清除指定房间内,关于某个用户的旧欢迎消息(支持普通人、管理员、新人)。
* 保证聊天记录里只保留最新的一条,解决频繁进出造成的刷屏问题。
*
* @param int $roomId 房间ID
* @param string $username 用户名
*/
public function removeOldWelcomeMessages(int $roomId, string $username): void
{
$key = "room:{$roomId}:messages";
$messages = Redis::lrange($key, 0, -1);
if (empty($messages)) {
return;
}
$filtered = [];
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
// 只要消息里带了 welcome_user 且等于当前用户,就抛弃这条旧的
if ($msg && isset($msg['welcome_user']) && $msg['welcome_user'] === $username) {
continue;
}
$filtered[] = $msgJson;
}
// 重新写回 Redis(如果发生了过滤)
if (count($filtered) !== count($messages)) {
Redis::del($key);
if (! empty($filtered)) {
Redis::rpush($key, ...$filtered);
}
}
}
/**
* 获取指定房间的新发言记录。
* 在高频长轮询或前端断线重连拉取时使用。
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* 文件功能:聊天室入场/离场播报服务
* 负责构建进出播报文本与颜色,按优先级(职务 > VIP > 普通随机词)选择合适的播报样式。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\User;
class RoomBroadcastService
{
/**
* 构造函数注入 VIP 服务(用于获取 VIP 专属入场/离场模板)
*/
public function __construct(
private readonly VipService $vipService,
) {}
/**
* 构建入场播报,返回 [文本, 颜色]
* 优先级:有职务 > VIP(专属模板优先)> 普通随机词
*
* @return array{string, string}
*/
public function buildEntryBroadcast(User $user): array
{
$position = $user->activePosition?->position;
// 有职务:显示职务图标 + 随机入场词
if ($position) {
$icon = $position->icon ?? '🎖️';
$name = $position->name;
$text = '【'.$icon.' '.$name.'】'.$this->randomWelcomeMsg($user);
return [$text, '#7c3aed']; // 紫色
}
// 有 VIP:优先用专属进入模板,无模板则随机词加前缀
if ($user->isVip() && $user->vipLevel) {
$color = $user->vipLevel->color ?: '#f59e0b';
$template = $this->vipService->getJoinMessage($user);
if ($template) {
return [$template, $color];
}
$text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomWelcomeMsg($user);
return [$text, $color];
}
// 普通用户:绿色随机词
return [$this->randomWelcomeMsg($user), '#16a34a'];
}
/**
* 构建离场播报,返回 [文本, 颜色]
* 优先级:有职务 > VIP(专属模板优先)> 普通随机词
*
* @return array{string, string}
*/
public function buildLeaveBroadcast(User $user): array
{
$position = $user->activePosition?->position;
// 有职务:显示职务图标 + 随机离场词
if ($position) {
$icon = $position->icon ?? '🎖️';
$name = $position->name;
$text = '【'.$icon.' '.$name.'】'.$this->randomLeaveMsg($user);
return [$text, '#7c3aed']; // 紫色
}
// 有 VIP:优先用专属离场模板,无模板则随机词加前缀
if ($user->isVip() && $user->vipLevel) {
$color = $user->vipLevel->color ?: '#f59e0b';
$template = $this->vipService->getLeaveMessage($user);
if ($template) {
return [$template, $color];
}
$text = '【'.$user->vipIcon().' '.$user->vipName().'】'.$this->randomLeaveMsg($user);
return [$text, $color];
}
// 普通用户:橙色随机词
return [$this->randomLeaveMsg($user), '#cc6600'];
}
/**
* 生成随机趣味入场词
*/
public function randomWelcomeMsg(User $user): string
{
$gender = $user->sex == 2 ? '美女' : '帅哥';
$uname = $user->username;
$templates = [
$gender.'【'.$uname.'】开着刚买不久的车来到了,见到各位大虾,拱手曰:"众位大虾,小生有礼了"',
$gender.'【'.$uname.'】骑着小毛驴哼着小调,悠闲地走了进来,对大家嘿嘿一笑',
$gender.'【'.$uname.'】坐着豪华轿车缓缓驶入,推门而出,拍了拍身上的灰,霸气说道:"我来也!"',
$gender.'【'.$uname.'】踩着七彩祥云从天而降,众人皆惊,抱拳道:"各位久等了!"',
$gender.'【'.$uname.'】划着小船飘然而至,微微一笑,翩然上岸',
$gender.'【'.$uname.'】骑着自行车铃铛叮当响,远远就喊:"我来啦!想我没?"',
$gender.'【'.$uname.'】开着拖拉机突突突地开了进来,下车后拍了拍手说:"交通不便,来迟了!"',
$gender.'【'.$uname.'】坐着火箭嗖的一声到了,吓了大家一跳,嘿嘿笑道:"别怕别怕,是我啊"',
$gender.'【'.$uname.'】骑着白马翩翩而来,英姿飒爽,拱手道:"江湖路远,各位有礼了"',
$gender.'【'.$uname.'】开着宝马一路飞驰到此,推开车门走了下来,向大家挥了挥手',
$gender.'【'.$uname.'】踩着风火轮呼啸而至,在人群中潇洒亮相',
$gender.'【'.$uname.'】乘坐滑翔伞从天空缓缓降落,对大家喊道:"hello,我从天上来!"',
$gender.'【'.$uname.'】从地下钻了出来,拍了拍土,说:"哎呀,走错路了,不过总算到了"',
$gender.'【'.$uname.'】蹦蹦跳跳地跑了进来,嘻嘻哈哈地跟大家打招呼',
$gender.'【'.$uname.'】悄悄地溜了进来,生怕被人发现,东张西望了一番',
$gender.'【'.$uname.'】迈着六亲不认的步伐走进来,气场两米八',
];
return $templates[array_rand($templates)];
}
/**
* 生成随机趣味离场词
*/
public function randomLeaveMsg(User $user): string
{
$gender = $user->sex == 2 ? '美女' : '帅哥';
$uname = $user->username;
$templates = [
$gender.'【'.$uname.'】潇洒地挥了挥手,骑着小毛驴哼着小调离去了',
$gender.'【'.$uname.'】开着跑车扬长而去,留下一路烟尘',
$gender.'【'.$uname.'】踩着七彩祥云飘然远去,消失在天际',
$gender.'【'.$uname.'】悄无声息地溜走了,连个招呼都不打',
$gender.'【'.$uname.'】跳上直升机螺旋桨呼呼作响,朝大家喊道:"我先走啦!"',
$gender.'【'.$uname.'】拱手告别:"各位大虾,后会有期!"随后翩然离去',
$gender.'【'.$uname.'】骑着自行车铃铛叮当响,远远就喊:"下次再聊!拜拜!"',
$gender.'【'.$uname.'】坐着热气球缓缓升空,朝大家挥手告别',
$gender.'【'.$uname.'】迈着六亲不认的步伐离开了,留下一众人目瞪口呆',
$gender.'【'.$uname.'】化作一缕青烟消散在空气中……',
];
return $templates[array_rand($templates)];
}
}