Files
chatroom/app/Http/Controllers/AdminCommandController.php
lkddi ff57afe388 功能:职务奖励金币发放系统
数据库:
- positions 新增 daily_reward_limit(单日累计上限)
- positions 新增 recipient_daily_limit(同一接收者每日次数上限)

后端:
- CurrencySource::POSITION_REWARD 新枚举值
- AdminCommandController::reward() 三层限额校验
  ① 单次上限 ② 单日累计上限 ③ 同一接收者每日次数
  写履职记录(PositionAuthorityLog)+ UserCurrencyService
  聊天室悄悄话通知接收者
- POST /command/reward 路由注册

前端(user-actions.blade.php):
- 名片按钮行 2+1 布局(加好友/送礼物/送金币)
- 送金币仅在 myMaxReward>0 时显示(职务持有者)
- 内联奖励金币面板:金额输入 + 确认发放 + 说明文字
- sendReward() 前端校验 + API 调用 + chatDialog 反馈

后台(positions/index):
- 编辑表单新增两个奖励限额字段
- PositionController 验证规则同步更新
2026-03-01 11:09:29 +08:00

578 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:管理员聊天室实时命令控制器
*
* 提供管理员在聊天室内对用户执行的管理操作:
* 警告(=J)、踢出(=T)、禁言(=B)、冻结(=Y)、查看私信(=S)、站长公屏讲话。
*
* 对应原 ASP 文件DOUSER.ASP / KILLUSER.ASP / LOCKIP.ASP / NEWSAY.ASP
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Message;
use App\Models\PositionAuthorityLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
class AdminCommandController extends Controller
{
/**
* 构造函数:注入聊天状态服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 警告用户(=J 理由)
*
* 向目标用户发送系统警告消息,所有人可见。
*
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function warn(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:200',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '请注意言行');
// 权限检查(等级由 level_warn 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_warn', '5')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
}
// 广播警告消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⚠️ 管理员 <b>{$admin->username}</b> 警告 <b>{$targetUsername}</b>{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]);
}
/**
* 踢出用户(=T 理由)
*
* 将目标用户从聊天室踢出,清除其 Redis 在线状态。
*
* @param Request $request 请求对象,需包含 username, room_id, reason
* @return JsonResponse 操作结果
*/
public function kick(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:200',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
// 权限检查(等级由 level_kick 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_kick', '10')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
}
// 从 Redis 在线列表移除
$this->chatState->userLeave($roomId, $targetUsername);
// 广播踢出消息(通知前端强制该用户跳转)
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🚫 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 踢出聊天室。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播踢出事件(前端监听后强制跳转)
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, $reason));
return response()->json(['status' => 'success', 'message' => "已踢出 {$targetUsername}"]);
}
/**
* 禁言用户(=B 分钟数)
*
* 使用 Redis TTL 自动过期机制,禁止用户发言指定分钟数。
*
* @param Request $request 请求对象,需包含 username, room_id, duration
* @return JsonResponse 操作结果
*/
public function mute(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'duration' => 'required|integer|min:1|max:1440',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$duration = $request->input('duration');
// 权限检查(等级由 level_mute 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_mute', '8')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
}
// 设置 Redis 禁言标记TTL 自动过期
$muteKey = "mute:{$roomId}:{$targetUsername}";
Redis::setex($muteKey, $duration * 60, now()->toDateTimeString());
// 广播禁言消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🔇 管理员 <b>{$admin->username}</b> 已将 <b>{$targetUsername}</b> 禁言 {$duration} 分钟。",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播禁言事件(前端禁用输入框)
broadcast(new \App\Events\UserMuted(
roomId: $roomId,
username: $targetUsername,
muteTime: $duration,
operator: $admin->username,
));
return response()->json(['status' => 'success', 'message' => "已禁言 {$targetUsername} {$duration} 分钟"]);
}
/**
* 冻结用户账号(=Y 理由)
*
* 将用户账号状态设为冻结,禁止登录。
*
* @param Request $request 请求对象,需包含 username, reason
* @return JsonResponse 操作结果
*/
public function freeze(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'reason' => 'nullable|string|max:200',
]);
$admin = Auth::user();
$targetUsername = $request->input('username');
$roomId = $request->input('room_id');
$reason = $request->input('reason', '违反聊天室规则');
// 权限检查(等级由 level_freeze 配置)
if (! $this->canExecute($admin, $targetUsername, 'level_freeze', '14')) {
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
}
// 冻结用户账号(将等级设为 -1 表示冻结)
$target = User::where('username', $targetUsername)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
$target->user_level = -1;
$target->save();
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
$this->chatState->userLeave($rid, $targetUsername);
}
// 广播冻结消息
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🧊 管理员 <b>{$admin->username}</b> 已冻结 <b>{$targetUsername}</b> 的账号。原因:{$reason}",
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播踢出事件
broadcast(new \App\Events\UserKicked($roomId, $targetUsername, "账号已被冻结:{$reason}"));
return response()->json(['status' => 'success', 'message' => "已冻结 {$targetUsername} 的账号"]);
}
/**
* 查看用户私信(=S
*
* 管理员查看指定用户最近的悄悄话记录。
* AJAX 请求返回 JSON浏览器请求返回美观的 HTML 页面。
*
* @param string $username 目标用户名
* @return JsonResponse|\Illuminate\View\View 私信记录
*/
public function viewWhispers(Request $request, string $username): JsonResponse|\Illuminate\View\View
{
$admin = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($admin->user_level < $superLevel) {
if ($request->expectsJson()) {
return response()->json(['status' => 'error', 'message' => '仅站长可查看私信'], 403);
}
abort(403, '仅站长可查看私信');
}
// 查询最近 50 条悄悄话(发送或接收)
$messages = Message::where('is_secret', true)
->where(function ($q) use ($username) {
$q->where('from_user', $username)
->orWhere('to_user', $username);
})
->orderByDesc('id')
->limit(50)
->get(['id', 'from_user', 'to_user', 'content', 'sent_at']);
// AJAX 请求返回 JSON给名片弹窗用浏览器请求返回 HTML 页面
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'username' => $username,
'messages' => $messages,
]);
}
return view('admin.whispers', [
'username' => $username,
'messages' => $messages,
]);
}
/**
* 站长公屏讲话
*
* 站长发送全聊天室公告,以特殊样式显示。
*
* @param Request $request 请求对象,需包含 content, room_id
* @return JsonResponse 操作结果
*/
public function announce(Request $request): JsonResponse
{
$request->validate([
'content' => 'required|string|max:500',
'room_id' => 'required|integer',
]);
$admin = Auth::user();
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发布公屏讲话'], 403);
}
$roomId = $request->input('room_id');
$content = $request->input('content');
// 广播站长公告
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 站长 <b>{$admin->username}</b> 讲话:{$content}",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
return response()->json(['status' => 'success', 'message' => '公告已发送']);
}
/**
* 管理员全员清屏
*
* 清除 Redis 中该房间的聊天记录缓存,并广播清屏事件通知所有用户前端清除消息。
* 前端只清除普通消息,保留悄悄话。
*
* @param Request $request 请求对象,需包含 room_id
* @return JsonResponse 操作结果
*/
public function clearScreen(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 需要站长权限才能全员清屏
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可执行全员清屏'], 403);
}
// 清除 Redis 中该房间的消息缓存
$this->chatState->clearMessages($roomId);
// 广播清屏事件
broadcast(new \App\Events\ScreenCleared($roomId, $admin->username));
return response()->json(['status' => 'success', 'message' => '已执行全员清屏']);
}
/**
* 管理员触发全屏特效(烟花/下雨/雷电)
*
* 向房间内所有用户广播 EffectBroadcast 事件,前端收到后播放对应 Canvas 动画。
* 仅 superlevel 等级管理员可触发。
*
* @param Request $request 请求对象,需包含 room_id, type
* @return JsonResponse 操作结果
*/
public function effect(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:fireworks,rain,lightning,snow',
]);
$admin = Auth::user();
$roomId = $request->input('room_id');
$type = $request->input('type');
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 仅 superlevel 等级可触发特效
if ($admin->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可触发特效'], 403);
}
// 广播特效事件给房间内所有在线用户
broadcast(new \App\Events\EffectBroadcast($roomId, $type, $admin->username));
return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]);
}
/**
* 职务奖励金币(凭空发放,无需扣操作者余额)
*
* 三层限额校验:
* 1. amount ≤ position.max_reward (单次上限)
* 2. 今日累计发放 + amount ≤ position.daily_reward_limit (操作人单日累计上限)
* 3. 今日对同一接收者发放次数 < position.recipient_daily_limit同一接收者每日次数限
*
* 成功后:
* - 通过 UserCurrencyService 给接收者增加金币
* - 写入 PositionAuthorityLogaction_type=reward记录到履职记录
* - 向房间发送悄悄话通知接收者
*
* @param Request $request 需包含 username, room_id, amount
*/
public function reward(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'room_id' => 'required|integer',
'amount' => 'required|integer|min:1|max:99999',
]);
$admin = Auth::user();
$roomId = (int) $request->input('room_id');
$amount = (int) $request->input('amount');
$targetUsername = $request->input('username');
// 不能给自己发放
if ($admin->username === $targetUsername) {
return response()->json(['status' => 'error', 'message' => '不能给自己发放奖励'], 422);
}
// 必须有在职职务且职务配置了 max_reward
$userPosition = $admin->activePosition;
if (! $userPosition) {
return response()->json(['status' => 'error', 'message' => '你当前没有在职职务,无权发放奖励'], 403);
}
$position = $userPosition->position;
if (! $position?->max_reward) {
return response()->json(['status' => 'error', 'message' => '你的职务未配置奖励权限'], 403);
}
// 目标用户必须存在
$target = User::where('username', $targetUsername)->first();
if (! $target) {
return response()->json(['status' => 'error', 'message' => '用户不存在'], 404);
}
// ① 单次上限校验
if ($amount > $position->max_reward) {
return response()->json([
'status' => 'error',
'message' => "单次奖励上限为 {$position->max_reward} 金币,请调整金额",
], 422);
}
// ② 操作人单日累计上限校验
if ($position->daily_reward_limit) {
$todayTotal = PositionAuthorityLog::where('user_id', $admin->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->sum('amount');
if ($todayTotal + $amount > $position->daily_reward_limit) {
$remaining = max(0, $position->daily_reward_limit - $todayTotal);
return response()->json([
'status' => 'error',
'message' => "今日剩余可发放额度为 {$remaining} 金币,超出单日上限({$position->daily_reward_limit}",
], 422);
}
}
// ③ 同一接收者每日次数上限校验
if ($position->recipient_daily_limit) {
$recipientCount = PositionAuthorityLog::where('user_id', $admin->id)
->where('target_user_id', $target->id)
->where('action_type', 'reward')
->whereDate('created_at', today())
->count();
if ($recipientCount >= $position->recipient_daily_limit) {
return response()->json([
'status' => 'error',
'message' => "今日已对 {$targetUsername} 发放过 {$position->recipient_daily_limit} 次奖励,已达上限",
], 422);
}
}
// 发放金币(通过 UserCurrencyService 原子性更新 + 写流水)
$this->currencyService->change(
$target,
'gold',
$amount,
CurrencySource::POSITION_REWARD,
"{$admin->username}{$position->name})职务奖励",
$roomId,
);
// 写履职记录PositionAuthorityLog
PositionAuthorityLog::create([
'user_id' => $admin->id,
'user_position_id' => $userPosition->id,
'action_type' => 'reward',
'target_user_id' => $target->id,
'amount' => $amount,
'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}",
]);
// 向聊天室发送悄悄话通知接收者
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统',
'to_user' => $targetUsername,
'content' => "🎁 <b>{$admin->username}</b>{$position->name})向你发放了 <b>{$amount}</b> 枚金币奖励!当前金币:{$target->fresh()->jjb} 枚。",
'is_secret' => true,
'font_color' => '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
'status' => 'success',
'message' => "已向 {$targetUsername} 发放 {$amount} 金币奖励 🎉",
]);
}
/**
* 权限检查:管理员是否可对目标用户执行指定操作
*
* 根据 sysparam 中配置的等级门槛判断权限。
*
* @param User $admin 管理员用户
* @param string $targetUsername 目标用户名
* @param string $levelKey sysparam 中的等级键名(如 level_kick、level_warn
* @param string $defaultLevel 默认等级值
* @return bool 是否有权限
*/
private function canExecute(User $admin, string $targetUsername, string $levelKey, string $defaultLevel = '5'): bool
{
// 必须达到该操作所需的最低等级
$requiredLevel = (int) Sysparam::getValue($levelKey, $defaultLevel);
if ($admin->user_level < $requiredLevel) {
return false;
}
// 不能操作自己
if ($admin->username === $targetUsername) {
return false;
}
// 目标用户等级必须低于操作者
$target = User::where('username', $targetUsername)->first();
if ($target && $target->user_level >= $admin->user_level) {
return false;
}
return true;
}
}