feat: 增加自定义头像上传、自动压缩与自动清理功能,统一全站头像路径读取逻辑

This commit is contained in:
2026-03-12 15:26:54 +08:00
parent ec95d69e92
commit 78564e2a1d
57 changed files with 569 additions and 350 deletions

View File

@@ -6,6 +6,7 @@
* 仅限 superlevel 以上管理员访问。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -46,14 +47,14 @@ class CurrencyStatsController extends Controller
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '>', 0)
->sum('amount');
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '<', 0)
->sum('amount');
$netFlow[$currency] = [
'in' => $totalIn,
'in' => $totalIn,
'out' => abs($totalOut),
'net' => $totalIn + $totalOut, // 净增量
];

View File

@@ -25,7 +25,7 @@ class FishingEventController extends Controller
*/
public function index(): View
{
$events = FishingEvent::orderBy('sort')->orderBy('id')->get();
$events = FishingEvent::orderBy('sort')->orderBy('id')->get();
$totalWeight = $events->where('is_active', true)->sum('weight');
return view('admin.fishing.index', compact('events', 'totalWeight'));
@@ -37,13 +37,13 @@ class FishingEventController extends Controller
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
@@ -61,13 +61,13 @@ class FishingEventController extends Controller
public function update(Request $request, FishingEvent $fishing): RedirectResponse
{
$data = $request->validate([
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'emoji' => 'required|string|max:10',
'name' => 'required|string|max:100',
'message' => 'required|string|max:255',
'exp' => 'required|integer',
'jjb' => 'required|integer',
'weight' => 'required|integer|min:1|max:9999',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
@@ -87,9 +87,9 @@ class FishingEventController extends Controller
$fishing->update(['is_active' => ! $fishing->is_active]);
return response()->json([
'ok' => true,
'ok' => true,
'is_active' => $fishing->is_active,
'message' => $fishing->is_active ? "{$fishing->name}」已启用" : "{$fishing->name}」已禁用",
'message' => $fishing->is_active ? "{$fishing->name}」已启用" : "{$fishing->name}」已禁用",
]);
}

View File

@@ -88,8 +88,8 @@ class OpsController extends Controller
abort(403, '无权限操作');
}
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$cleaned = 0;
do {

View File

@@ -36,17 +36,17 @@ class RoomManagerController extends Controller
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'room_name' => 'required|string|max:100|unique:rooms,room_name',
'room_des' => 'nullable|string|max:500',
'room_owner' => 'nullable|string|max:50',
'room_name' => 'required|string|max:100|unique:rooms,room_name',
'room_des' => 'nullable|string|max:500',
'room_owner' => 'nullable|string|max:50',
'permit_level' => 'required|integer|min:0|max:15',
'door_open' => 'required|boolean',
'door_open' => 'required|boolean',
], [
'room_name.unique' => '房间名称已存在,请换一个名称。',
]);
// 设置新建房间的默认值
$data['room_keep'] = false; // 新建房间均为非系统房间,可删除
$data['room_keep'] = false; // 新建房间均为非系统房间,可删除
$data['build_time'] = now();
$room = Room::create($data);
@@ -62,12 +62,12 @@ class RoomManagerController extends Controller
public function update(Request $request, Room $room): RedirectResponse
{
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
'announcement' => 'nullable|string|max:500',
'room_owner' => 'nullable|string|max:50',
'room_owner' => 'nullable|string|max:50',
'permit_level' => 'required|integer|min:0|max:15',
'door_open' => 'required|boolean',
'door_open' => 'required|boolean',
]);
$room->update($data);

View File

@@ -65,7 +65,7 @@ class SmtpController extends Controller
public function test(Request $request): RedirectResponse
{
$request->validate([
'test_email' => 'required|email'
'test_email' => 'required|email',
]);
$testEmail = $request->input('test_email');
@@ -78,7 +78,7 @@ class SmtpController extends Controller
return redirect()->route('admin.smtp.edit')->with('success', "测试邮件已成功发送至 {$testEmail},请注意查收。");
} catch (\Throwable $e) {
return redirect()->route('admin.smtp.edit')->with('error', "测试发出失败,原因:" . $e->getMessage());
return redirect()->route('admin.smtp.edit')->with('error', '测试发出失败,原因:'.$e->getMessage());
}
}
}

View File

@@ -17,7 +17,7 @@ class VerificationController extends Controller
public function sendEmailCode(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email'
'email' => 'required|email',
]);
$email = $request->input('email');
@@ -27,23 +27,24 @@ class VerificationController extends Controller
if (SysParam::where('alias', 'smtp_enabled')->value('body') !== '1') {
return response()->json([
'status' => 'error',
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。'
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。',
], 403);
}
// 2. 检查是否有频率限制同一用户或同一邮箱60秒只允许发1次
$throttleKey = 'email_throttle_' . $user->id;
$throttleKey = 'email_throttle_'.$user->id;
if (Cache::has($throttleKey)) {
$ttl = Cache::ttl($throttleKey);
return response()->json([
'status' => 'error',
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。"
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。",
], 429);
}
// 3. 生成 6 位随机验证码并缓存,有效期 5 分钟
$code = mt_rand(100000, 999999);
$codeKey = 'email_verify_code_' . $user->id . '_' . $email;
$codeKey = 'email_verify_code_'.$user->id.'_'.$email;
Cache::put($codeKey, $code, now()->addMinutes(5));
// 设置频率锁,过期时间 60 秒
@@ -57,14 +58,15 @@ class VerificationController extends Controller
return response()->json([
'status' => 'success',
'message' => '验证码已发送,请注意查收邮件。'
'message' => '验证码已发送,请注意查收邮件。',
]);
} catch (\Throwable $e) {
// 如果发信失败,主动接触频率限制锁方便用户下一次立重试
Cache::forget($throttleKey);
return response()->json([
'status' => 'error',
'message' => '邮件系统发送异常,请稍后再试: ' . $e->getMessage()
'message' => '邮件系统发送异常,请稍后再试: '.$e->getMessage(),
], 500);
}
}

View File

@@ -35,7 +35,10 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class ChatController extends Controller
{
@@ -683,9 +686,12 @@ class ChatController extends Controller
return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
}
// 更新用户头像
$user->usersf = $headface;
$user->save();
// 更新前如为自定义头像,将其从磁盘删除,节约空间
if ($user->usersf !== $headface) {
$user->deleteCustomAvatar();
$user->usersf = $headface;
$user->save();
}
// 将新头像同步到 Redis 在线用户列表中(所有房间)
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
@@ -710,6 +716,71 @@ class ChatController extends Controller
]);
}
/**
* 上传自定义头像
*/
public function uploadAvatar(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:2048',
]);
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '未登录'], 401);
}
$file = $request->file('file');
try {
$manager = new ImageManager(new Driver);
$image = $manager->read($file);
// 裁剪正方形并压缩为 112x112
$image->cover(112, 112);
// 生成相对路径
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
$path = 'avatars/'.$filename;
// 保存以高质量 JPG 或原格式
Storage::disk('public')->put($path, (string) $image->encode());
$dbValue = 'storage/'.$path;
// 更新前如为自定义头像,将其从磁盘删除,节约空间
if ($user->usersf !== $dbValue) {
$user->deleteCustomAvatar();
$user->usersf = $dbValue;
$user->save();
}
// 同步 Redis 状态
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface, // Use accessor
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
}
return response()->json([
'status' => 'success',
'message' => '头像上传成功!',
'headface' => $user->headface,
]);
} catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500);
}
}
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值

View File

@@ -94,8 +94,8 @@ class FishingController extends Controller
$user->refresh();
// 4. 生成一次性 token存入 RedisTTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
@@ -249,18 +249,18 @@ class FishingController extends Controller
// 数据库无事件时的兜底
if (! $event) {
return [
'emoji' => '🐟',
'emoji' => '🐟',
'message' => '钓到一条小鱼获得金币10',
'exp' => 0,
'jjb' => 10,
'exp' => 0,
'jjb' => 10,
];
}
return [
'emoji' => $event->emoji,
'emoji' => $event->emoji,
'message' => $event->message,
'exp' => $event->exp,
'jjb' => $event->jjb,
'exp' => $event->exp,
'jjb' => $event->jjb,
];
}
}

View File

@@ -201,13 +201,13 @@ class FriendController extends Controller
$row = $myRows->get($u->username);
return [
'username' => $u->username,
'headface' => $u->headface,
'username' => $u->username,
'headface' => $u->headface,
'user_level' => $u->user_level,
'sex' => $u->sex,
'mutual' => $addedMeNames->contains($u->username), // 是否互相添加
'sub_time' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
'sex' => $u->sex,
'mutual' => $addedMeNames->contains($u->username), // 是否互相添加
'sub_time' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
];
})
->sortByDesc('is_online') // 在线好友排在前面
@@ -221,19 +221,19 @@ class FriendController extends Controller
$row = $addedMeRows->get($u->username);
return [
'username' => $u->username,
'headface' => $u->headface,
'username' => $u->username,
'headface' => $u->headface,
'user_level' => $u->user_level,
'sex' => $u->sex,
'added_at' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
'sex' => $u->sex,
'added_at' => $row?->sub_time?->format('Y-m-d H:i') ?? '',
'is_online' => $onlineUsernames->contains($u->username),
];
})
->sortByDesc('is_online')
->values();
return response()->json([
'status' => 'success',
'status' => 'success',
'friends' => $friends,
'pending' => $pending,
]);

View File

@@ -86,12 +86,12 @@ class LeaderboardController extends Controller
// ── 今日榜5分钟缓存数据来自 user_currency_logs 流水表)──
$todayTtl = 60 * 5;
$today = today()->toDateString();
$today = today()->toDateString();
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
);
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
);
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
@@ -109,13 +109,13 @@ class LeaderboardController extends Controller
public function todayIndex(): View
{
$todayTtl = 60 * 5;
$today = today()->toDateString();
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
$today = today()->toDateString();
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
);
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
);
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
@@ -130,10 +130,10 @@ class LeaderboardController extends Controller
*/
public function myLogs(): View
{
$user = auth()->user();
$user = auth()->user();
$currency = request('currency');
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
}

View File

@@ -15,6 +15,7 @@
* 5. 公屏广播结果(中奖/踩雷)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -58,12 +59,12 @@ class MysteryBoxController extends Controller
$secondsLeft = $box->expires_at ? max(0, now()->diffInSeconds($box->expires_at, false)) : null;
return response()->json([
'active' => true,
'box_id' => $box->id,
'box_type' => $box->box_type,
'type_name' => $box->typeName(),
'type_emoji' => $box->typeEmoji(),
'passcode' => $box->passcode,
'active' => true,
'box_id' => $box->id,
'box_type' => $box->box_type,
'type_name' => $box->typeName(),
'type_emoji' => $box->typeEmoji(),
'passcode' => $box->passcode,
'seconds_left' => $secondsLeft,
]);
}
@@ -105,15 +106,15 @@ class MysteryBoxController extends Controller
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
$remark = $reward >= 0
? "神秘箱子【{$box->typeName()}】奖励"
: "神秘箱子【黑化箱】陷阱扣除";
: '神秘箱子【黑化箱】陷阱扣除';
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
// ③ 写领取记录 + 更新箱子状态
MysteryBoxClaim::create([
'mystery_box_id' => $box->id,
'user_id' => $user->id,
'reward_amount' => $reward,
'user_id' => $user->id,
'reward_amount' => $reward,
]);
$box->update(['status' => 'claimed']);
@@ -123,12 +124,12 @@ class MysteryBoxController extends Controller
$this->broadcastResult($box, $user->username, $reward);
return response()->json([
'ok' => true,
'reward' => $reward,
'ok' => true,
'reward' => $reward,
'balance' => $user->jjb ?? 0,
'message' => $reward >= 0
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
: "☠️ 中了黑化陷阱!扣除 " . abs($reward) . ' 金币!',
: '☠️ 中了黑化陷阱!扣除 '.abs($reward).' 金币!',
]);
});
}
@@ -136,9 +137,9 @@ class MysteryBoxController extends Controller
/**
* 公屏广播开箱结果。
*
* @param MysteryBox $box 箱子实例
* @param string $username 领取者用户名
* @param int $reward 奖励金额(正/负)
* @param MysteryBox $box 箱子实例
* @param string $username 领取者用户名
* @param int $reward 奖励金额(正/负)
*/
private function broadcastResult(MysteryBox $box, string $username, int $reward): void
{
@@ -147,24 +148,24 @@ class MysteryBoxController extends Controller
if ($reward >= 0) {
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}"
. "获得 💰" . number_format($reward) . " 金币!";
.'获得 💰'.number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "【黑化陷阱】haha{$username}】 中了神秘黑化箱的陷阱!"
. "被扣除 💰" . number_format(abs($reward)) . " 金币!点背~";
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $color,
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);

View File

@@ -170,6 +170,10 @@ class UserController extends Controller
\Illuminate\Support\Facades\Cache::forget($codeKey);
}
if (isset($data['headface']) && $data['headface'] !== $user->headface) {
$user->deleteCustomAvatar();
}
$user->update($data);
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);