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

@@ -304,11 +304,11 @@ class AutoSaveExp extends Command
: now();
PositionDutyLog::create([
'user_id' => $user->id,
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => $loginAt,
'ip_address' => '0.0.0.0',
'room_id' => $roomId,
'login_at' => $loginAt,
'ip_address' => '0.0.0.0',
'room_id' => $roomId,
]);
}
}

View File

@@ -39,8 +39,8 @@ class ClearRoomOnlineCache extends Command
*/
public function handle(): int
{
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$prefix = config('database.redis.options.prefix', '');
$cursor = '0';
$cleaned = 0;
$this->info("Redis 前缀:\"{$prefix}\"");

View File

@@ -7,6 +7,7 @@
* 前端收到后弹出 Toast 通知展示到账金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -24,9 +25,9 @@ class RedPacketClaimed implements ShouldBroadcastNow
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $envelopeId 红包 ID
* @param User $claimer 领取用户
* @param int $amount 领取金额
* @param int $envelopeId 红包 ID
*/
public function __construct(
public readonly User $claimer,
@@ -51,8 +52,8 @@ class RedPacketClaimed implements ShouldBroadcastNow
{
return [
'envelope_id' => $this->envelopeId,
'amount' => $this->amount,
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
'amount' => $this->amount,
'message' => "🧧 成功抢到 {$this->amount} 金币礼包!",
];
}

View File

@@ -7,6 +7,7 @@
* 前端接收后显示红包卡片弹窗,并在聊天窗口追加系统公告。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -23,20 +24,20 @@ class RedPacketSent implements ShouldBroadcastNow
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param int $envelopeId 红包 ID
* @param string $senderUsername 发包人用户名
* @param int $totalAmount 总金额(金币)
* @param int $totalCount 总份数
* @param int $expireSeconds 过期秒数(用于前端倒计时)
* @param int $roomId 房间 ID
* @param int $envelopeId 红包 ID
* @param string $senderUsername 发包人用户名
* @param int $totalAmount 总金额(金币)
* @param int $totalCount 总份数
* @param int $expireSeconds 过期秒数(用于前端倒计时)
*/
public function __construct(
public readonly int $roomId,
public readonly int $envelopeId,
public readonly int $roomId,
public readonly int $envelopeId,
public readonly string $senderUsername,
public readonly int $totalAmount,
public readonly int $totalCount,
public readonly int $expireSeconds,
public readonly int $totalAmount,
public readonly int $totalCount,
public readonly int $expireSeconds,
public readonly string $type = 'gold',
) {}
@@ -58,12 +59,12 @@ class RedPacketSent implements ShouldBroadcastNow
public function broadcastWith(): array
{
return [
'envelope_id' => $this->envelopeId,
'envelope_id' => $this->envelopeId,
'sender_username' => $this->senderUsername,
'total_amount' => $this->totalAmount,
'total_count' => $this->totalCount,
'expire_seconds' => $this->expireSeconds,
'type' => $this->type,
'total_amount' => $this->totalAmount,
'total_count' => $this->totalCount,
'expire_seconds' => $this->expireSeconds,
'type' => $this->type,
];
}

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' => '资料更新成功。']);

View File

@@ -9,6 +9,7 @@
* 3. 设定定时关闭任务windows_seconds 秒后过期)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -32,8 +33,8 @@ class DropMysteryBoxJob implements ShouldQueue
public int $tries = 1;
/**
* @param string $boxType 箱子类型normal | rare | trap
* @param int|null $roomId 投放目标房间null 时默认 1
* @param string $boxType 箱子类型normal | rare | trap
* @param int|null $roomId 投放目标房间null 时默认 1
* @param string|null $passcode 手动指定暗号null=自动生成)
* @param int|null $droppedBy 投放者用户IDnull=系统自动)
*/
@@ -79,11 +80,11 @@ class DropMysteryBoxJob implements ShouldQueue
// 创建箱子记录
$box = MysteryBox::create([
'box_type' => $this->boxType,
'passcode' => $passcode,
'box_type' => $this->boxType,
'passcode' => $passcode,
'reward_min' => $rewardMin,
'reward_max' => $rewardMax,
'status' => 'open',
'status' => 'open',
'expires_at' => now()->addSeconds($claimWindow),
'dropped_by' => $this->droppedBy,
]);
@@ -94,22 +95,22 @@ class DropMysteryBoxJob implements ShouldQueue
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}{$typeName}{$source}投放了一个神秘箱子!"
. "发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$msg = [
'id' => $chatState->nextMessageId($targetRoom),
'room_id' => $targetRoom,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'id' => $chatState->nextMessageId($targetRoom),
'room_id' => $targetRoom,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => match ($this->boxType) {
'rare' => '#c4b5fd',
'trap' => '#f87171',
'rare' => '#c4b5fd',
'trap' => '#f87171',
default => '#34d399',
},
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage($targetRoom, $msg);

View File

@@ -6,6 +6,7 @@
* 在箱子到期后将其状态更新为 expired若尚未被领取并向公屏广播过期通知。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -48,15 +49,15 @@ class ExpireMysteryBoxJob implements ShouldQueue
// 公屏广播过期通知
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
'is_secret' => false,
'font_color' => '#9ca3af',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);

View File

@@ -42,11 +42,11 @@ class FishingEvent extends Model
protected function casts(): array
{
return [
'exp' => 'integer',
'jjb' => 'integer',
'weight' => 'integer',
'exp' => 'integer',
'jjb' => 'integer',
'weight' => 'integer',
'is_active' => 'boolean',
'sort' => 'integer',
'sort' => 'integer',
];
}
@@ -73,8 +73,8 @@ class FishingEvent extends Model
// 计算总权重后加权随机
$totalWeight = $events->sum('weight');
$roll = random_int(1, max($totalWeight, 1));
$cumulative = 0;
$roll = random_int(1, max($totalWeight, 1));
$cumulative = 0;
foreach ($events as $event) {
$cumulative += $event->weight;

View File

@@ -7,6 +7,7 @@
* 对应表mystery_boxes
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -81,9 +82,9 @@ class MysteryBox extends Model
{
return match ($this->box_type) {
'normal' => '📦',
'rare' => '💎',
'trap' => '☠️',
default => '📦',
'rare' => '💎',
'trap' => '☠️',
default => '📦',
};
}
@@ -94,9 +95,9 @@ class MysteryBox extends Model
{
return match ($this->box_type) {
'normal' => '普通箱',
'rare' => '稀有箱',
'trap' => '黑化箱',
default => '神秘箱',
'rare' => '稀有箱',
'trap' => '黑化箱',
default => '神秘箱',
};
}

View File

@@ -7,6 +7,7 @@
* 对应表mystery_box_claims
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -7,6 +7,7 @@
* envelope_id + user_id 联合唯一约束保证幂等性。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -7,6 +7,7 @@
* 先到先得,领完或超时后自动关闭。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -5,8 +5,8 @@
* 对应原版 ASP 聊天室的 sysparam 配置表
* 管理员可在后台修改等级经验阈值等系统参数
*
* @package App\Models
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -27,9 +27,8 @@ class Sysparam extends Model
* 获取指定参数的值
* 带缓存,避免频繁查库
*
* @param string $alias 参数别名
* @param string $default 默认值
* @return string
* @param string $alias 参数别名
* @param string $default 默认值
*/
public static function getValue(string $alias, string $default = ''): string
{
@@ -56,7 +55,7 @@ class Sysparam extends Model
/**
* 根据经验值计算应该达到的等级
*
* @param int $expNum 当前经验值
* @param int $expNum 当前经验值
* @return int 对应的等级
*/
public static function calculateLevel(int $expNum): int
@@ -81,7 +80,7 @@ class Sysparam extends Model
/**
* 清除指定参数的缓存
*
* @param string $alias 参数别名
* @param string $alias 参数别名
*/
public static function clearCache(string $alias): void
{

View File

@@ -90,18 +90,61 @@ class User extends Authenticatable
* 头像文件名访问器
*
* ASP 系统的头像文件名存储在 usersf 字段中(如 "75.gif"
* 但项目中各处通过 $user->headface 来引用头像
* accessor headface 属性映射到 usersf 字段,保持代码一致性
* 同时也支持用户自定义上传的头像,保存在 Laravel Storage public 磁盘下
* accessor headface 属性映射到 usersf 字段,如果包含 storage/ 则当作独立路径,自动转换旧版后缀小写
*/
protected function headface(): Attribute
{
return Attribute::make(
// 自动将后缀转小写,兼容数据库中的 .GIF 大写存量
get: fn () => strtolower($this->usersf ?: '1.gif'),
set: fn (string $value) => ['usersf' => strtolower($value)],
get: function () {
$val = $this->usersf ?: '1.gif';
if (str_starts_with($val, 'storage/')) {
return $val;
}
// 仅对非 storage 下的旧头像做小写处理,兼容旧库数据
return strtolower($val);
},
set: function ($value) {
if (str_starts_with($value, 'storage/')) {
return tap($value, fn () => $this->attributes['usersf'] = $value);
}
return tap(strtolower($value), fn () => $this->attributes['usersf'] = strtolower($value));
}
);
}
/**
* 获取带前缀的完整头像 URL
* 避免前端多处硬编码 '/images/headface/'
*/
protected function headfaceUrl(): Attribute
{
return Attribute::make(
get: function () {
$hf = $this->headface;
if (str_starts_with((string) $hf, 'storage/')) {
return '/'.$hf;
}
return '/images/headface/'.$hf;
}
);
}
/**
* 如果当前头像是自定义上传的图片,则从本地存储中删除此文件
*/
public function deleteCustomAvatar(): void
{
$hf = (string) $this->usersf;
if (str_starts_with($hf, 'storage/')) {
$path = substr($hf, 8); // 去除 'storage/' 前缀
\Illuminate\Support\Facades\Storage::disk('public')->delete($path);
}
}
/**
* 关联:用户所属的 VIP 会员等级
*/

View File

@@ -6,6 +6,7 @@
* 只读写,不允许 update流水记录不可更改
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -40,10 +41,10 @@ class UserCurrencyLog extends Model
* 字段类型转换
*/
protected $casts = [
'amount' => 'integer',
'balance_after'=> 'integer',
'room_id' => 'integer',
'created_at' => 'datetime',
'amount' => 'integer',
'balance_after' => 'integer',
'room_id' => 'integer',
'created_at' => 'datetime',
];
// ─── 关联 ─────────────────────────────────────────────────

View File

@@ -131,7 +131,7 @@ class ChatStateService
{
$usernames = [];
foreach ($this->getAllActiveRoomIds() as $roomId) {
$key = "room:{$roomId}:users";
$key = "room:{$roomId}:users";
$users = Redis::hkeys($key); // 只取 key用户名不取 value
foreach ($users as $username) {
$usernames[] = $username;

View File

@@ -7,6 +7,7 @@
* 本服务负责:原子性更新用户属性、写入流水记录、提供统计与排行数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
@@ -25,8 +26,8 @@ class UserCurrencyService
* 以后新增货币类型,在此加一行即可。
*/
private const FIELD_MAP = [
'exp' => 'exp_num',
'gold' => 'jjb',
'exp' => 'exp_num',
'gold' => 'jjb',
'charm' => 'meili',
];
@@ -34,20 +35,20 @@ class UserCurrencyService
* 统一变更用户货币属性并写入流水记录。
* 使用数据库事务保证原子性:用户属性更新 + 流水写入同时成功或同时回滚。
*
* @param User $user 目标用户
* @param string $currency 货币类型('exp' / 'gold' / 'charm'
* @param int $amount 变更量,正数增加,负数扣除
* @param CurrencySource $source 来源活动枚举
* @param string $remark 备注说明
* @param int|null $roomId 所在房间 ID可选
* @param User $user 目标用户
* @param string $currency 货币类型('exp' / 'gold' / 'charm'
* @param int $amount 变更量,正数增加,负数扣除
* @param CurrencySource $source 来源活动枚举
* @param string $remark 备注说明
* @param int|null $roomId 所在房间 ID可选
*/
public function change(
User $user,
string $currency,
int $amount,
User $user,
string $currency,
int $amount,
CurrencySource $source,
string $remark = '',
?int $roomId = null,
string $remark = '',
?int $roomId = null,
): void {
if ($amount === 0) {
return; // 变更量为 0 不写记录
@@ -72,14 +73,14 @@ class UserCurrencyService
// 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名)
UserCurrencyLog::create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => $currency,
'amount' => $amount,
'balance_after'=> $balanceAfter,
'source' => $source->value,
'remark' => $remark,
'room_id' => $roomId,
'user_id' => $user->id,
'username' => $user->username,
'currency' => $currency,
'amount' => $amount,
'balance_after' => $balanceAfter,
'source' => $source->value,
'remark' => $remark,
'room_id' => $roomId,
]);
});
}
@@ -88,14 +89,12 @@ class UserCurrencyService
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
* 每位用户仍独立走事务,单人失败不影响其他人。
*
* @param array $items [['user' => User, 'changes' => ['exp'=>1,'gold'=>2]], ...]
* @param CurrencySource $source
* @param int|null $roomId
* @param array $items [['user' => User, 'changes' => ['exp'=>1,'gold'=>2]], ...]
*/
public function batchChange(array $items, CurrencySource $source, ?int $roomId = null): void
{
foreach ($items as $item) {
$user = $item['user'];
$user = $item['user'];
$changes = $item['changes'] ?? [];
foreach ($changes as $currency => $amount) {
$this->change($user, $currency, (int) $amount, $source, '', $roomId);
@@ -127,8 +126,8 @@ class UserCurrencyService
* 只统计正向变更amount > 0),不因消耗而扣分。
*
* @param string $currency 'exp' | 'gold' | 'charm'
* @param int $limit 返回条数
* @param string|null $date 日期,默认今日
* @param int $limit 返回条数
* @param string|null $date 日期,默认今日
*/
public function todayLeaderboard(string $currency, int $limit = 20, ?string $date = null): Collection
{
@@ -149,12 +148,12 @@ class UserCurrencyService
->find($row->user_id);
return (object) [
'user_id' => $row->user_id,
'user_id' => $row->user_id,
'username' => $user?->username ?? '未知用户',
'level' => $user?->user_level ?? 0,
'sex' => $user?->sex ?? 1,
'level' => $user?->user_level ?? 0,
'sex' => $user?->sex ?? 1,
'headface' => $user?->headface ?? '1.gif',
'total' => $row->total,
'total' => $row->total,
];
});
}
@@ -162,9 +161,9 @@ class UserCurrencyService
/**
* 用户个人流水明细(用户查询自己的日志)。
*
* @param int $userId 用户 ID
* @param int $userId 用户 ID
* @param string|null $currency null 时返回所有货币类型
* @param int $days 查询最近多少天
* @param int $days 查询最近多少天
*/
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
{
@@ -183,8 +182,8 @@ class UserCurrencyService
public static function currencyLabel(string $currency): string
{
return match ($currency) {
'exp' => '经验',
'gold' => '金币',
'exp' => '经验',
'gold' => '金币',
'charm' => '魅力',
default => $currency,
};

View File

@@ -11,6 +11,7 @@
"require": {
"php": "^8.2",
"geoip2/geoip2": "^3.3",
"intervention/image": "^3.11",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.45",
"laravel/reverb": "^1.8",

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e501ac28571f87b0f192898f912648f5",
"content-hash": "56338775768722c90ec723eb5b939be1",
"packages": [
{
"name": "brick/math",

View File

@@ -48,21 +48,21 @@ return new class extends Migration
['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_announcement','body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now],
// ── 随机事件 ──────────────────────────────────────────────
['alias' => 'auto_event_chance','body' => '10', 'guidetxt' => '随机事件触发概率百分比1-100', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率百分比1-100', 'created_at' => $now, 'updated_at' => $now],
// ── 魅力系统 ──────────────────────────────────────────────
['alias' => 'charm_cross_sex', 'body' => '2', 'guidetxt' => '异性聊天每条消息增加的魅力值', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'charm_same_sex', 'body' => '1', 'guidetxt' => '同性聊天每条消息增加的魅力值', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'charm_hourly_limit','body' => '20', 'guidetxt' => '每小时通过聊天获取的魅力值上限(防刷屏)', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'charm_hourly_limit', 'body' => '20', 'guidetxt' => '每小时通过聊天获取的魅力值上限(防刷屏)', 'created_at' => $now, 'updated_at' => $now],
// ── 排行榜 ────────────────────────────────────────────────
['alias' => 'leaderboard_limit','body' => '20', 'guidetxt' => '🏆 排行榜每榜显示人数', 'created_at' => $now, 'updated_at' => $now],
['alias' => 'leaderboard_limit', 'body' => '20', 'guidetxt' => '🏆 排行榜每榜显示人数', 'created_at' => $now, 'updated_at' => $now],
]);
}

View File

@@ -7,6 +7,7 @@
* username_blacklist 用户改名后的旧名称保留黑名单
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -5,6 +5,7 @@
* 记录所有用户经验/金币/魅力的变动来源与金额,支持今日排行与活动统计
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -30,7 +30,7 @@ return new class extends Migration
public function down(): void
{
// 先把现有 NULL 行补一个兜底值,再改回 NOT NULL
DB::statement("UPDATE `username_blacklist` SET `reserved_until` = NOW() + INTERVAL 365 DAY WHERE `reserved_until` IS NULL");
DB::statement("ALTER TABLE `username_blacklist` MODIFY `reserved_until` TIMESTAMP NOT NULL");
DB::statement('UPDATE `username_blacklist` SET `reserved_until` = NOW() + INTERVAL 365 DAY WHERE `reserved_until` IS NULL');
DB::statement('ALTER TABLE `username_blacklist` MODIFY `reserved_until` TIMESTAMP NOT NULL');
}
};

View File

@@ -12,42 +12,42 @@ return new class extends Migration
{
DB::table('marriage_configs')->insertOrIgnore([
[
'group' => '时间规则',
'key' => 'divorce_mutual_cooldown',
'value' => 70,
'label' => '协议离婚冷静期(天)',
'group' => '时间规则',
'key' => 'divorce_mutual_cooldown',
'value' => 70,
'label' => '协议离婚冷静期(天)',
'description' => '协议离婚成功后,多少天内不能再次结婚 (支持设置为 0)',
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
],
[
'group' => '时间规则',
'key' => 'divorce_auto_cooldown',
'value' => 70,
'label' => '系统强制离婚冷静期(天)',
'group' => '时间规则',
'key' => 'divorce_auto_cooldown',
'value' => 70,
'label' => '系统强制离婚冷静期(天)',
'description' => '单方面申请协议离婚但对方不管,导致超时后系统强制离婚的冷静期',
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
],
]);
// 由于离婚有 mutual, auto, forced 等,我们同时添加 forced 参数的插入以防遗漏
DB::table('marriage_configs')->insertOrIgnore([
[
'group' => '时间规则',
'key' => 'divorce_forced_cooldown',
'value' => 70,
'label' => '单方强制离婚冷静期(天)',
'group' => '时间规则',
'key' => 'divorce_forced_cooldown',
'value' => 70,
'label' => '单方强制离婚冷静期(天)',
'description' => '通过单方面强制解除契约功能导致的离婚冷静期',
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
]
'min' => 0,
'max' => 365,
'created_at' => now(),
'updated_at' => now(),
],
]);
}

View File

@@ -7,6 +7,7 @@
* red_packet_claims红包领取记录先到先得每人只能领一次
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -7,6 +7,7 @@
* 默认 gold兼容已有记录。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -6,6 +6,7 @@
* 记录每次系统/管理员投放的神秘箱信息,包含类型、暗号、奖惩范围及领取状态。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -6,6 +6,7 @@
* 记录每个箱子被哪位用户在何时用什么暗号领取,以及实际奖励金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -6,6 +6,7 @@
* 记录每期彩票的开奖状态、号码、奖池金额、派奖结果。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -6,6 +6,7 @@
* 透明记录每期奖池的每笔变动(售票入池、派奖扣除、滚存、系统注入)。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -6,6 +6,7 @@
* 记录每用户每注的选号、中奖等级、派奖金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/

View File

@@ -55,15 +55,15 @@ class AutoFishingCardSeeder extends Seeder
'is_active' => true,
],
[
'slug' => 'auto_fishing_72h',
'name' => '自动钓鱼卡72小时',
'icon' => '🎣',
'description' => '激活后72小时内钓鱼无需手动点击浮漂系统自动收竿。钓鱼大神终极之选',
'price' => 15000,
'type' => 'auto_fishing',
'slug' => 'auto_fishing_72h',
'name' => '自动钓鱼卡72小时',
'icon' => '🎣',
'description' => '激活后72小时内钓鱼无需手动点击浮漂系统自动收竿。钓鱼大神终极之选',
'price' => 15000,
'type' => 'auto_fishing',
'duration_minutes' => 4320,
'sort_order' => 204,
'is_active' => true,
'sort_order' => 204,
'is_active' => true,
],
];

View File

@@ -43,73 +43,73 @@ class FishingEventSeeder extends Seeder
$events = [
[
'sort' => 1,
'emoji' => '🦈',
'name' => '大鲨鱼',
'message' => '钓到一条大鲨鱼获得经验30、金币50',
'exp' => 30,
'jjb' => 50,
'weight' => 15,
'sort' => 1,
'emoji' => '🦈',
'name' => '大鲨鱼',
'message' => '钓到一条大鲨鱼获得经验30、金币50',
'exp' => 30,
'jjb' => 50,
'weight' => 15,
'is_active' => true,
],
[
'sort' => 2,
'emoji' => '🐟',
'name' => '娃娃鱼',
'message' => '钓到一条娃娃鱼到集市卖得80个金币',
'exp' => 0,
'jjb' => 80,
'weight' => 15,
'sort' => 2,
'emoji' => '🐟',
'name' => '娃娃鱼',
'message' => '钓到一条娃娃鱼到集市卖得80个金币',
'exp' => 0,
'jjb' => 80,
'weight' => 15,
'is_active' => true,
],
[
'sort' => 3,
'emoji' => '🐠',
'name' => '大草鱼',
'message' => '钓到一只大草鱼吃下增加经验20、金币30',
'exp' => 20,
'jjb' => 30,
'weight' => 20,
'sort' => 3,
'emoji' => '🐠',
'name' => '大草鱼',
'message' => '钓到一只大草鱼吃下增加经验20、金币30',
'exp' => 20,
'jjb' => 30,
'weight' => 20,
'is_active' => true,
],
[
'sort' => 4,
'emoji' => '🐡',
'name' => '小鲤鱼',
'message' => '钓到一条小鲤鱼增加经验10、金币20',
'exp' => 10,
'jjb' => 20,
'weight' => 20,
'sort' => 4,
'emoji' => '🐡',
'name' => '小鲤鱼',
'message' => '钓到一条小鲤鱼增加经验10、金币20',
'exp' => 10,
'jjb' => 20,
'weight' => 20,
'is_active' => true,
],
[
'sort' => 5,
'emoji' => '💧',
'name' => '落水惨败',
'message' => '鱼没钓到摔到河里损失金币30',
'exp' => 0,
'jjb' => -30,
'weight' => 15,
'sort' => 5,
'emoji' => '💧',
'name' => '落水惨败',
'message' => '鱼没钓到摔到河里损失金币30',
'exp' => 0,
'jjb' => -30,
'weight' => 15,
'is_active' => true,
],
[
'sort' => 6,
'emoji' => '👊',
'name' => '被抓殴打',
'message' => '偷钓鱼塘被主人发现一阵殴打金币减少10',
'exp' => 0,
'jjb' => -10,
'weight' => 10,
'sort' => 6,
'emoji' => '👊',
'name' => '被抓殴打',
'message' => '偷钓鱼塘被主人发现一阵殴打金币减少10',
'exp' => 0,
'jjb' => -10,
'weight' => 10,
'is_active' => true,
],
[
'sort' => 7,
'emoji' => '🎉',
'name' => '超级大奖',
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+50金币+200',
'exp' => 50,
'jjb' => 200,
'weight' => 5,
'sort' => 7,
'emoji' => '🎉',
'name' => '超级大奖',
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+50金币+200',
'exp' => 50,
'jjb' => 200,
'weight' => 5,
'is_active' => true,
],
];

View File

@@ -70,16 +70,16 @@ class GameConfigSeeder extends Seeder
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
'enabled' => false,
'params' => [
'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时)
'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时)
'claim_window_seconds' => 60, // 领取窗口(秒)
'normal_reward_min' => 500, // 普通箱最低奖励
'normal_reward_max' => 2000, // 普通箱最高奖励
'rare_reward_min' => 5000, // 稀有箱最低奖励
'rare_reward_max' => 20000, // 稀有箱最高奖励
'trap_penalty_min' => 200, // 黑化箱最低惩罚
'trap_penalty_max' => 1000, // 黑化箱最高惩罚
'trap_chance_percent' => 10, // 黑化箱触发概率(%
'normal_reward_min' => 500, // 普通箱最低奖励
'normal_reward_max' => 2000, // 普通箱最高奖励
'rare_reward_min' => 5000, // 稀有箱最低奖励
'rare_reward_max' => 20000, // 稀有箱最高奖励
'trap_penalty_min' => 200, // 黑化箱最低惩罚
'trap_penalty_max' => 1000, // 黑化箱最高惩罚
'trap_chance_percent' => 10, // 黑化箱触发概率(%
],
],
@@ -121,13 +121,13 @@ class GameConfigSeeder extends Seeder
// ─── 钓鱼小游戏 ──────────────────────────────────────────────
[
'game_key' => 'fishing',
'name' => '钓鱼小游戏',
'icon' => '🎣',
'game_key' => 'fishing',
'name' => '钓鱼小游戏',
'icon' => '🎣',
'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。',
'enabled' => false,
'params' => [
'fishing_cost' => 5, // 每次抛竿消耗金币
'enabled' => false,
'params' => [
'fishing_cost' => 5, // 每次抛竿消耗金币
'fishing_wait_min' => 8, // 浮漂等待最短秒数
'fishing_wait_max' => 15, // 浮漂等待最长秒数
'fishing_cooldown' => 300, // 收竿后冷却秒数
@@ -136,36 +136,36 @@ class GameConfigSeeder extends Seeder
// ─── 双色球彩票 ──────────────────────────────────────────────
[
'game_key' => 'lottery',
'name' => '双色球彩票',
'icon' => '🎟️',
'game_key' => 'lottery',
'name' => '双色球彩票',
'icon' => '🎟️',
'description' => '每日一期选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。',
'enabled' => false,
'params' => [
'enabled' => false,
'params' => [
// ── 开奖时间 ──
'draw_hour' => 20, // 每天几点开奖24小时制
'draw_minute' => 0, // 几分开奖
'stop_sell_minutes' => 2, // 开奖前几分钟停止购票
'draw_hour' => 20, // 每天几点开奖24小时制
'draw_minute' => 0, // 几分开奖
'stop_sell_minutes' => 2, // 开奖前几分钟停止购票
// ── 购票限制 ──
'ticket_price' => 100, // 每注金币
'max_tickets_per_user' => 50, // 每期单人最多购票注数
'max_tickets_per_buy' => 10, // 单次最多购买注数
'ticket_price' => 100, // 每注金币
'max_tickets_per_user' => 50, // 每期单人最多购票注数
'max_tickets_per_buy' => 10, // 单次最多购买注数
// ── 奖池分配比例(%)──
'pool_ratio' => 70, // 购票金额进奖池比例
'prize_1st_ratio' => 60, // 一等奖占奖池%
'prize_2nd_ratio' => 20, // 二等奖占奖池%
'prize_3rd_ratio' => 10, // 三等奖占奖池%
'carry_ratio' => 10, // 强制滚存比例%
'pool_ratio' => 70, // 购票金额进奖池比例
'prize_1st_ratio' => 60, // 一等奖占奖池%
'prize_2nd_ratio' => 20, // 二等奖占奖池%
'prize_3rd_ratio' => 10, // 三等奖占奖池%
'carry_ratio' => 10, // 强制滚存比例%
// ── 固定小奖 ──
'prize_4th_fixed' => 150, // 四等奖固定金额/注
'prize_5th_fixed' => 50, // 五等奖固定金额/注
'prize_4th_fixed' => 150, // 四等奖固定金额/注
'prize_5th_fixed' => 50, // 五等奖固定金额/注
// ── 超级期 ──
'super_issue_threshold' => 3, // 连续几期无一等奖触发超级期
'super_issue_inject' => 20000, // 超级期系统注入金额上限
'super_issue_inject' => 20000, // 超级期系统注入金额上限
],
],
];

View File

@@ -3,8 +3,6 @@
/**
* 文件功能:商店初始商品数据填充器
* 初始化9种商品4种单次特效卡 + 4种周卡 + 改名卡
*
* @package Database\Seeders
*/
namespace Database\Seeders;

View File

@@ -81,7 +81,7 @@
<td class="p-4 font-mono text-xs text-gray-500">{{ $user->id }}</td>
<td class="p-4">
<div class="flex items-center space-x-3">
<img src="/images/headface/{{ $user->headface ?? '01.gif' }}"
<img src="{{ $user->headface_url ?? '/images/headface/1.gif' }}"
class="w-8 h-8 rounded border object-cover">
<span class="font-bold text-gray-800">{{ $user->username }}</span>
@if ($user->isVip())

View File

@@ -415,7 +415,8 @@
const avatar = document.createElement('img');
avatar.className = 'fp-avatar';
avatar.src = '/images/headface/' + (f.headface || '1.gif');
let hf = f.headface || '1.gif';
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
avatar.alt = f.username;
const name = document.createElement('span');
@@ -456,7 +457,8 @@
const avatar = document.createElement('img');
avatar.className = 'fp-avatar';
avatar.src = '/images/headface/' + (p.headface || '1.gif');
let hf = p.headface || '1.gif';
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
avatar.alt = p.username;
const name = document.createElement('span');

View File

@@ -59,14 +59,22 @@
{{-- 预览区 --}}
<div
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px;">
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<span style="font-size:12px; color:#666;">当前选中:</span>
<img id="avatar-preview" src="/images/headface/{{ $user->usersf ?: '1.gif' }}"
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px;">
<img id="avatar-preview" src="{{ str_starts_with($user->usersf, 'storage/') ? '/' . $user->usersf : '/images/headface/' . ($user->usersf ?: '1.gif') }}"
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px; object-fit: cover;">
<span id="avatar-selected-name" style="font-size:12px; color:#333;">{{ $user->usersf ?: '未设置' }}</span>
<button id="avatar-save-btn" disabled onclick="saveAvatar()"
style="margin-left:auto; padding:5px 16px; background:#336699; color:#fff; border:none;
border-radius:3px; font-size:12px; cursor:pointer;">确定更换</button>
border-radius:3px; font-size:12px; cursor:pointer;">确定更换系统头像</button>
<div style="width:100%; height:1px; background:#ddd; margin: 4px 0;"></div>
<div style="display:flex; align-items:center; gap:8px; width:100%;">
<span style="font-size:12px; color:#666; font-weight:bold;">自定义头像上传(112x112)</span>
<input type="file" id="avatar-upload-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;" onchange="handleAvatarUpload(this)">
<button id="avatar-upload-btn" onclick="document.getElementById('avatar-upload-input').click()"
style="padding:5px 16px; background:#16a34a; color:#fff; border:none;
border-radius:3px; font-size:12px; cursor:pointer;">选择本地图片上传</button>
</div>
</div>
{{-- 头像网格 --}}
@@ -250,6 +258,74 @@
document.getElementById('avatar-save-btn').dataset.file = file;
}
/**
* 处理本地头像上传
*/
async function handleAvatarUpload(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
// 简单的前端校验
if (file.size > 2 * 1024 * 1024) {
window.chatDialog.alert('图片大小不可超过 2MB', '上传失败', '#cc4444');
input.value = '';
return;
}
const btn = document.getElementById('avatar-upload-btn');
btn.disabled = true;
btn.textContent = '上传中...';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/headface/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.chatDialog.alert('自定义头像上传成功!', '提示', '#16a34a');
// 更新预览图和显示名称
const previewImg = document.getElementById('avatar-preview');
const relativeUrl = '/' + data.headface;
previewImg.src = relativeUrl;
document.getElementById('avatar-selected-name').textContent = data.headface;
// 同步在线列表自己
const myName = window.chatContext.username;
if (typeof onlineUsers !== 'undefined' && onlineUsers[myName]) {
onlineUsers[myName].headface = data.headface;
}
if (typeof renderUserList === 'function') {
renderUserList();
}
// 清除系统头像选中状态
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
document.getElementById('avatar-save-btn').disabled = true;
closeAvatarPicker();
} else {
window.chatDialog.alert(data.message || '上传失败', '操作失败', '#cc4444');
}
} catch (e) {
window.chatDialog.alert('网络错误,上传失败', '网络异常', '#cc4444');
}
btn.disabled = false;
btn.textContent = '选择本地图片上传';
input.value = ''; // 清空 file input允许重复选中同一文件
}
/**
* 保存选中的头像(调用 API 更新,成功后刷新用户列表)
*/

View File

@@ -205,6 +205,7 @@
item.dataset.username = username;
const headface = (user.headface || '1.gif').toLowerCase();
const headImgSrc = headface.startsWith('storage/') ? '/' + headface : '/images/headface/' + headface;
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
@@ -224,7 +225,7 @@
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
item.innerHTML = `
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.gif'">
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
<span class="user-name" style="${nameColor}">${username}</span>${badges}
`;
@@ -369,7 +370,7 @@
const buggleUsers = ['钓鱼播报', '星海小博士', '送花播报', '系统传音', '系统公告'];
const senderInfo = onlineUsers[msg.from_user];
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif').toLowerCase();
let headImgSrc = `/images/headface/${senderHead}`;
let headImgSrc = senderHead.startsWith('storage/') ? '/' + senderHead : `/images/headface/${senderHead}`;
if (msg.from_user === 'AI小班长') {
headImgSrc = '/images/ai_bot.png';
} else if (buggleUsers.includes(msg.from_user)) {

View File

@@ -597,7 +597,7 @@
<div class="modal-body">
<div class="profile-row">
<img class="profile-avatar" x-show="userInfo.headface"
:src="'/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
:src="(userInfo.headface || '1.gif').toLowerCase().startsWith('storage/') ? '/' + (userInfo.headface || '1.gif').toLowerCase() : '/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
x-on:error="$el.style.display='none'">
<div class="profile-info">
<h4>

View File

@@ -110,7 +110,7 @@
@foreach ($position->activeUserPositions as $up)
<div
class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-100 rounded-xl">
<img src="/images/headface/{{ strtolower($up->user?->headface ?? '1.gif') }}"
<img src="{{ $up->user?->headface_url ?? '/images/headface/1.gif' }}"
class="w-7 h-7 rounded-full border border-purple-100 object-cover bg-white"
onerror="this.src='/images/headface/1.gif'">
<div>
@@ -210,7 +210,7 @@
{{-- 成员 --}}
<div class="col-span-4 flex items-center gap-3">
<img src="/images/headface/{{ strtolower($row->user?->headface ?? '1.gif') }}"
<img src="{{ $row->user?->headface_url ?? '/images/headface/1.gif' }}"
class="w-9 h-9 rounded-full border-2 border-purple-100 object-cover bg-white"
onerror="this.src='/images/headface/1.gif'">
<div>
@@ -266,7 +266,7 @@
<span class="text-xs font-bold text-gray-300">{{ $i + 1 }}</span>
@endif
</div>
<img src="/images/headface/{{ strtolower($row->user?->headface ?? '1.gif') }}"
<img src="{{ $row->user?->headface_url ?? '/images/headface/1.gif' }}"
class="w-8 h-8 rounded-full border border-purple-100 object-cover bg-white shrink-0"
onerror="this.src='/images/headface/1.gif'">
<div class="flex-1 min-w-0">

View File

@@ -84,7 +84,7 @@
<div class="col-span-1 flex justify-center">
<div
class="w-10 h-10 rounded-md overflow-hidden bg-white border border-gray-200 shadow-sm shrink-0">
<img src="/images/headface/{{ strtolower($inviter->headface ?: '1.gif') }}"
<img src="{{ $inviter->headface_url ?? '/images/headface/1.gif' }}"
onerror="this.style.display='none'" class="w-full h-full object-cover">
</div>
</div>

View File

@@ -140,7 +140,7 @@
@auth
<div class="flex items-center space-x-3 ml-2 pl-2 border-l border-indigo-700">
<div class="flex items-center space-x-2">
<img src="/images/headface/{{ Auth::user()->headface ?? '01.gif' }}"
<img src="{{ Auth::user()->headface_url ?? '/images/headface/1.gif' }}"
class="w-7 h-7 rounded border border-indigo-500 object-cover bg-white">
<span class="font-bold hidden sm:inline">{{ Auth::user()->username }}</span>
<span

View File

@@ -25,7 +25,7 @@
</div>
<div class="flex items-center space-x-2 truncate">
<img class="w-8 h-8 rounded border object-cover shrink-0"
src="/images/headface/{{ $user->headface ?? '01.gif' }}" alt="">
src="{{ $user->headface_url }}" alt="">
<div class="flex flex-col truncate">
<span class="text-sm font-bold text-gray-800 truncate" title="{{ $user->username }}">
{{ $user->username }}

View File

@@ -289,7 +289,7 @@
<label class="block text-sm font-bold text-gray-700 mb-2">头像选择 (01.gif - 50.gif)</label>
<div class="flex items-center space-x-3">
<div class="w-12 h-12 rounded bg-gray-200 shrink-0 overflow-hidden border">
<img :src="'/images/headface/' + profileData.headface"
<img :src="profileData.headface.startsWith('storage/') ? '/' + profileData.headface : '/images/headface/' + profileData.headface"
@@error="$el.style.display='none'"
class="w-full h-full object-cover">
</div>

View File

@@ -254,6 +254,9 @@ Route::middleware(['chat.auth'])->group(function () {
// 修改头像
Route::post('/headface/change', [ChatController::class, 'changeAvatar'])->name('headface.change');
// 上传自定义头像
Route::post('/headface/upload', [ChatController::class, 'uploadAvatar'])->name('headface.upload');
// 设置房间公告/祝福语
Route::post('/room/{id}/announcement', [ChatController::class, 'setAnnouncement'])->name('chat.announcement');