功能:VIP 赞助会员系统

- 新建 vip_levels 表(名称、图标、颜色、经验/金币倍率、专属进入/离开模板)
- 默认4个等级种子:白银🥈(×1.5)、黄金🥇(×2.0)、钻石💎(×3.0)、至尊👑(×5.0)
- 后台 VIP 等级 CRUD 管理(新增/编辑/删除,配置模板和倍率)
- 后台用户编辑弹窗支持设置 VIP 等级和到期时间
- ChatController 心跳经验按 VIP 倍率加成
- FishingController 正向奖励按 VIP 倍率加成(负面惩罚不变)
- 在线名单显示 VIP 图标和管理员🛡️标识
- VIP 用户进入/离开使用专属颜色和标题
- 后台侧栏新增「👑 VIP 会员等级」入口
This commit is contained in:
2026-02-26 21:30:07 +08:00
parent ea06328885
commit fd3214eaff
22 changed files with 2293 additions and 19 deletions

View File

@@ -0,0 +1,218 @@
<?php
/**
* 文件功能AI 厂商配置管理控制器(管理后台)
*
* 提供完整的 AI 厂商 CRUD 管理功能,包括:
* - 列表/新增/编辑/删除 AI 厂商配置
* - 切换启用/禁用状态
* - 设置默认厂商(互斥,同时只有一个默认)
* - 全局开关控制机器人是否启用
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AiProviderConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\View\View;
class AiProviderController extends Controller
{
/**
* 构造函数
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示 AI 厂商配置列表页面
*
* 列出所有已配置的 AI 厂商,并显示全局开关状态。
*
* @return View AI 厂商管理页面
*/
public function index(): View
{
$providers = AiProviderConfig::orderBy('sort_order')->get();
$chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1';
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled'));
}
/**
* 新增 AI 厂商配置
*
* @param Request $request 请求对象
* @return RedirectResponse 重定向到列表页
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'provider' => 'required|string|max:50',
'name' => 'required|string|max:100',
'api_key' => 'required|string',
'api_endpoint' => 'required|url|max:255',
'model' => 'required|string|max:100',
'temperature' => 'nullable|numeric|min:0|max:2',
'max_tokens' => 'nullable|integer|min:100|max:32000',
'sort_order' => 'nullable|integer|min:0',
]);
// 加密 API Key
$data['api_key'] = Crypt::encryptString($data['api_key']);
$data['temperature'] = $data['temperature'] ?? 0.3;
$data['max_tokens'] = $data['max_tokens'] ?? 2048;
$data['sort_order'] = $data['sort_order'] ?? 0;
$data['is_enabled'] = true;
$data['is_default'] = false;
AiProviderConfig::create($data);
return redirect()->route('admin.ai-providers.index')
->with('success', '已成功添加 AI 厂商配置!');
}
/**
* 更新 AI 厂商配置
*
* @param Request $request 请求对象
* @param int $id 厂商配置 ID
* @return RedirectResponse 重定向到列表页
*/
public function update(Request $request, int $id): RedirectResponse
{
$provider = AiProviderConfig::findOrFail($id);
$data = $request->validate([
'provider' => 'required|string|max:50',
'name' => 'required|string|max:100',
'api_key' => 'nullable|string',
'api_endpoint' => 'required|url|max:255',
'model' => 'required|string|max:100',
'temperature' => 'nullable|numeric|min:0|max:2',
'max_tokens' => 'nullable|integer|min:100|max:32000',
'sort_order' => 'nullable|integer|min:0',
]);
// 只在用户提供了新 API Key 时才更新(空值表示不修改)
if (! empty($data['api_key'])) {
$data['api_key'] = Crypt::encryptString($data['api_key']);
} else {
unset($data['api_key']);
}
$provider->update($data);
return redirect()->route('admin.ai-providers.index')
->with('success', "已更新 {$provider->name} 的配置!");
}
/**
* 切换 AI 厂商的启用/禁用状态
*
* @param int $id 厂商配置 ID
* @return JsonResponse 操作结果
*/
public function toggleEnabled(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
$provider->is_enabled = ! $provider->is_enabled;
$provider->save();
$status = $provider->is_enabled ? '启用' : '禁用';
return response()->json([
'status' => 'success',
'message' => "{$provider->name}{$status}",
'is_enabled' => $provider->is_enabled,
]);
}
/**
* 设置指定厂商为默认使用
*
* 互斥操作:将其他厂商的 is_default 全部置为 false
*
* @param int $id 厂商配置 ID
* @return JsonResponse 操作结果
*/
public function setDefault(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
// 先将所有厂商的默认标记清除
AiProviderConfig::where('is_default', true)->update(['is_default' => false]);
// 设置当前厂商为默认
$provider->is_default = true;
$provider->is_enabled = true; // 默认的必须是启用状态
$provider->save();
return response()->json([
'status' => 'success',
'message' => "{$provider->name} 已设为默认 AI 厂商",
]);
}
/**
* 切换聊天机器人全局开关
*
* 通过 sysparam chatbot_enabled 参数控制是否在聊天室中显示 AI 机器人。
*
* @param Request $request 请求对象
* @return JsonResponse 操作结果
*/
public function toggleChatBot(Request $request): JsonResponse
{
$current = Sysparam::getValue('chatbot_enabled', '0');
$newValue = $current === '1' ? '0' : '1';
// 更新 sysparam
Sysparam::updateOrCreate(
['alias' => 'chatbot_enabled'],
[
'body' => $newValue,
'guidetxt' => 'AI聊天机器人开关1=开启0=关闭)',
]
);
// 刷新缓存
$this->chatState->setSysParam('chatbot_enabled', $newValue);
Sysparam::clearCache('chatbot_enabled');
$status = $newValue === '1' ? '开启' : '关闭';
return response()->json([
'status' => 'success',
'message' => "聊天机器人已{$status}",
'enabled' => $newValue === '1',
]);
}
/**
* 删除 AI 厂商配置
*
* @param int $id 厂商配置 ID
* @return RedirectResponse 重定向到列表页
*/
public function destroy(int $id): RedirectResponse
{
$provider = AiProviderConfig::findOrFail($id);
$name = $provider->name;
$provider->delete();
return redirect()->route('admin.ai-providers.index')
->with('success', "已删除 {$name}");
}
}

View File

@@ -36,7 +36,10 @@ class UserManagerController extends Controller
// 分页获取用户
$users = $query->orderBy('id', 'desc')->paginate(20);
return view('admin.users.index', compact('users'));
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
return view('admin.users.index', compact('users', 'vipLevels'));
}
/**
@@ -64,6 +67,8 @@ class UserManagerController extends Controller
'qianming' => 'sometimes|nullable|string|max:255',
'headface' => 'sometimes|string|max:50',
'password' => 'nullable|string|min:6',
'vip_level_id' => 'sometimes|nullable|integer|exists:vip_levels,id',
'hy_time' => 'sometimes|nullable|date',
]);
// 如果传了且没超权,直接赋予
@@ -94,6 +99,14 @@ class UserManagerController extends Controller
$targetUser->headface = $validated['headface'];
}
// VIP 会员等级设置
if (array_key_exists('vip_level_id', $validated)) {
$targetUser->vip_level_id = $validated['vip_level_id'] ?: null;
}
if (array_key_exists('hy_time', $validated)) {
$targetUser->hy_time = $validated['hy_time'] ?: null;
}
if (! empty($validated['password'])) {
$targetUser->password = Hash::make($validated['password']);
}

View File

@@ -0,0 +1,123 @@
<?php
/**
* 文件功能:后台 VIP 会员等级管理控制器
* 提供会员等级的 CRUD增删改查功能
* 后台可自由创建、修改、删除会员等级
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\VipLevel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VipController extends Controller
{
/**
* 会员等级管理列表页
*/
public function index(): View
{
$levels = VipLevel::orderBy('sort_order')->get();
return view('admin.vip.index', compact('levels'));
}
/**
* 新增会员等级
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
// 将文本框的多行模板转为 JSON 数组
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
VipLevel::create($data);
return redirect()->route('admin.vip.index')->with('success', '会员等级创建成功!');
}
/**
* 更新会员等级
*
* @param int $id 等级ID
*/
public function update(Request $request, int $id): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$data = $request->validate([
'name' => 'required|string|max:50',
'icon' => 'required|string|max:20',
'color' => 'required|string|max:10',
'exp_multiplier' => 'required|numeric|min:1|max:99',
'jjb_multiplier' => 'required|numeric|min:1|max:99',
'sort_order' => 'required|integer|min:0',
'price' => 'required|integer|min:0',
'duration_days' => 'required|integer|min:0',
'join_templates' => 'nullable|string',
'leave_templates' => 'nullable|string',
]);
$data['join_templates'] = $this->textToJson($data['join_templates'] ?? '');
$data['leave_templates'] = $this->textToJson($data['leave_templates'] ?? '');
$level->update($data);
return redirect()->route('admin.vip.index')->with('success', '会员等级更新成功!');
}
/**
* 删除会员等级(关联用户的 vip_level_id 会自动置 null
*
* @param int $id 等级ID
*/
public function destroy(int $id): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level->delete();
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
}
/**
* 将多行文本转为 JSON 数组字符串
* 每行一个模板,空行忽略
*
* @param string $text 多行文本
* @return string|null JSON 字符串
*/
private function textToJson(string $text): ?string
{
$lines = array_filter(
array_map('trim', explode("\n", $text)),
fn ($line) => $line !== ''
);
if (empty($lines)) {
return null;
}
return json_encode(array_values($lines), JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:聊天机器人控制器
*
* 处理用户与 AI 机器人的对话请求。
* 先检查全局开关sysparam: chatbot_enabled再调用 AiChatService 获取回复。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\Sysparam;
use App\Services\AiChatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatBotController extends Controller
{
/**
* 构造函数:注入 AI 聊天服务
*/
public function __construct(
private readonly AiChatService $aiChat,
) {}
/**
* AI 机器人对话
*
* 接收用户消息,检查全局开关后调用 AI 服务获取回复。
* 支持自动故障转移:默认厂商失败时自动尝试备用厂商。
*
* @param Request $request 请求对象,需包含 message room_id
* @return JsonResponse 机器人回复或错误信息
*/
public function chat(Request $request): JsonResponse
{
// 验证请求参数
$request->validate([
'message' => 'required|string|max:2000',
'room_id' => 'required|integer',
]);
// 检查全局开关
$enabled = Sysparam::getValue('chatbot_enabled', '0');
if ($enabled !== '1') {
return response()->json([
'status' => 'error',
'message' => 'AI 机器人功能已关闭,请联系管理员开启。',
], 403);
}
$user = Auth::user();
$message = $request->input('message');
$roomId = $request->input('room_id');
try {
$result = $this->aiChat->chat($user->id, $message, $roomId);
return response()->json([
'status' => 'success',
'reply' => $result['reply'],
'provider' => $result['provider'],
'model' => $result['model'],
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 500);
}
}
/**
* 清除当前用户的 AI 对话上下文
*
* 用于用户想要重新开始对话时使用。
*
* @param Request $request 请求对象
* @return JsonResponse 操作结果
*/
public function clearContext(Request $request): JsonResponse
{
$user = Auth::user();
$this->aiChat->clearContext($user->id);
return response()->json([
'status' => 'success',
'message' => '对话上下文已清除,可以开始新的对话了。',
]);
}
}

View File

@@ -21,6 +21,7 @@ use App\Models\Room;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -32,6 +33,7 @@ class ChatController extends Controller
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
) {}
/**
@@ -48,19 +50,21 @@ class ChatController extends Controller
// 房间人气 +1每次访问递增复刻原版人气计数
$room->increment('visit_num');
// 1. 将当前用户加入到 Redis 房间在线列表
$this->chatState->userJoin($id, $user->username, [
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$userData = [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
]);
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
];
$this->chatState->userJoin($id, $user->username, $userData);
// 2. 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
]))->toOthers();
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 获取历史消息用于初次渲染
// TODO: 可在前端通过请求另外的接口拉取历史记录,或者直接在这里 attach
@@ -145,9 +149,10 @@ class ChatController extends Controller
return response()->json(['status' => 'error'], 401);
}
// 1. 每次心跳增加经验(可在 sysparam 后台配置)
// 1. 每次心跳增加经验(可在 sysparam 后台配置)VIP 倍率加成
$expGain = (int) Sysparam::getValue('exp_per_heartbeat', '1');
$user->exp_num += $expGain;
$expMultiplier = $this->vipService->getExpMultiplier($user);
$user->exp_num += (int) round($expGain * $expMultiplier);
// 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级
// 管理员superlevel 及以上)不参与自动升降级,等级由后台手动设置
@@ -171,6 +176,10 @@ class ChatController extends Controller
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 4. 如果突破境界,向全房系统喊话广播!

View File

@@ -15,6 +15,7 @@ namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -24,6 +25,7 @@ class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
) {}
/**
@@ -111,12 +113,16 @@ class FishingController extends Controller
// 3. 随机决定钓鱼结果
$result = $this->randomFishResult();
// 4. 更新用户经验和金币(不低于 0
// 4. 更新用户经验和金币(正向奖励按 VIP 倍率加成,负面惩罚不变
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
if ($result['exp'] !== 0) {
$user->exp_num = max(0, ($user->exp_num ?? 0) + $result['exp']);
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
$user->exp_num = max(0, ($user->exp_num ?? 0) + $finalExp);
}
if ($result['jjb'] !== 0) {
$user->jjb = max(0, ($user->jjb ?? 0) + $result['jjb']);
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
$user->jjb = max(0, ($user->jjb ?? 0) + $finalJjb);
}
$user->save();