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
+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)];
}
}