功能: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:
@@ -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}!");
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' => '对话上下文已清除,可以开始新的对话了。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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. 如果突破境界,向全房系统喊话广播!
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 厂商配置模型
|
||||
*
|
||||
* 对应 ai_provider_configs 表,管理多个 AI 厂商的 API 配置。
|
||||
* 支持多厂商切换、默认配置、自动故障转移等功能。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class AiProviderConfig extends Model
|
||||
{
|
||||
/**
|
||||
* 关联的数据库表名
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'ai_provider_configs';
|
||||
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'provider',
|
||||
'name',
|
||||
'api_key',
|
||||
'api_endpoint',
|
||||
'model',
|
||||
'temperature',
|
||||
'max_tokens',
|
||||
'is_enabled',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'temperature' => 'float',
|
||||
'max_tokens' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解密后的 API Key
|
||||
*
|
||||
* @return string|null 解密后的 API Key
|
||||
*/
|
||||
public function getDecryptedApiKey(): ?string
|
||||
{
|
||||
if (empty($this->api_key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->api_key);
|
||||
} catch (\Exception) {
|
||||
// 如果解密失败(可能是明文存储的旧数据),直接返回原值
|
||||
return $this->api_key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置加密存储的 API Key
|
||||
*
|
||||
* @param string $value 原始 API Key
|
||||
*/
|
||||
public function setApiKeyEncrypted(string $value): void
|
||||
{
|
||||
$this->api_key = Crypt::encryptString($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前默认的 AI 配置
|
||||
*
|
||||
* 返回 is_default=1 且 is_enabled=1 的第一条配置。
|
||||
* 如果没有设置默认,则返回第一条已启用的配置。
|
||||
*/
|
||||
public static function getDefault(): ?self
|
||||
{
|
||||
// 优先获取标记为默认且已启用的
|
||||
$default = static::where('is_default', true)
|
||||
->where('is_enabled', true)
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// 退而求其次,取第一个已启用的
|
||||
return static::where('is_enabled', true)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已启用的 AI 厂商配置(按 sort_order 排序)
|
||||
*
|
||||
* 用于故障转移时依次尝试备用厂商。
|
||||
*/
|
||||
public static function getEnabledProviders(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return static::where('is_enabled', true)
|
||||
->orderBy('is_default', 'desc') // 默认的排最前面
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 使用日志模型
|
||||
*
|
||||
* 对应 ai_usage_logs 表,记录每次 AI 接口调用的详细信息,
|
||||
* 包括 token 消耗、响应时间、成功/失败状态等。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiUsageLog extends Model
|
||||
{
|
||||
/**
|
||||
* 关联的数据库表名
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'ai_usage_logs';
|
||||
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'user_id',
|
||||
'provider',
|
||||
'model',
|
||||
'action',
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'total_tokens',
|
||||
'cost',
|
||||
'response_time_ms',
|
||||
'success',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'prompt_tokens' => 'integer',
|
||||
'completion_tokens' => 'integer',
|
||||
'total_tokens' => 'integer',
|
||||
'cost' => 'float',
|
||||
'response_time_ms' => 'integer',
|
||||
'success' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联用户
|
||||
*/
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
@@ -36,6 +37,8 @@ class User extends Authenticatable
|
||||
'first_ip',
|
||||
'last_ip',
|
||||
'usersf',
|
||||
'vip_level_id',
|
||||
'hy_time',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -85,4 +88,53 @@ class User extends Authenticatable
|
||||
get: fn () => $this->usersf ?: '1.GIF',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户所属的 VIP 会员等级
|
||||
*/
|
||||
public function vipLevel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(VipLevel::class, 'vip_level_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否为有效 VIP(有等级且未过期)
|
||||
*/
|
||||
public function isVip(): bool
|
||||
{
|
||||
if (! $this->vip_level_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// hy_time 为 null 表示永久会员
|
||||
if (! $this->hy_time) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hy_time->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 VIP 会员名称(无效则返回空字符串)
|
||||
*/
|
||||
public function vipName(): string
|
||||
{
|
||||
if (! $this->isVip()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->vipLevel?->name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 VIP 会员图标(无效则返回空字符串)
|
||||
*/
|
||||
public function vipIcon(): string
|
||||
{
|
||||
if (! $this->isVip()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->vipLevel?->icon ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 会员等级模型
|
||||
* 存储会员名称、图标、颜色、倍率、专属进入/离开模板
|
||||
* 后台可完整 CRUD 管理
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class VipLevel extends Model
|
||||
{
|
||||
/** @var string 表名 */
|
||||
protected $table = 'vip_levels';
|
||||
|
||||
/** @var array 可批量赋值字段 */
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'icon',
|
||||
'color',
|
||||
'exp_multiplier',
|
||||
'jjb_multiplier',
|
||||
'join_templates',
|
||||
'leave_templates',
|
||||
'sort_order',
|
||||
'price',
|
||||
'duration_days',
|
||||
];
|
||||
|
||||
/** @var array 类型转换 */
|
||||
protected $casts = [
|
||||
'exp_multiplier' => 'float',
|
||||
'jjb_multiplier' => 'float',
|
||||
'sort_order' => 'integer',
|
||||
'price' => 'integer',
|
||||
'duration_days' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 关联:该等级下的所有用户
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'vip_level_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进入聊天室的专属欢迎语模板数组
|
||||
*/
|
||||
public function getJoinTemplatesArrayAttribute(): array
|
||||
{
|
||||
if (empty($this->join_templates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($this->join_templates, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取离开聊天室的专属提示语模板数组
|
||||
*/
|
||||
public function getLeaveTemplatesArrayAttribute(): array
|
||||
{
|
||||
if (empty($this->leave_templates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($this->leave_templates, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板数组中随机选一条,替换 {username} 占位符
|
||||
*
|
||||
* @param array $templates 模板数组
|
||||
* @param string $username 用户名
|
||||
*/
|
||||
public static function renderTemplate(array $templates, string $username): ?string
|
||||
{
|
||||
if (empty($templates)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = $templates[array_rand($templates)];
|
||||
|
||||
return str_replace('{username}', $username, $template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 聊天服务
|
||||
*
|
||||
* 统一对接多个 AI 厂商 API(OpenAI 兼容协议),实现:
|
||||
* - 多厂商自动故障转移(默认 → 备用按 sort_order 依次)
|
||||
* - Redis 维护每用户对话上下文(最近 10 轮)
|
||||
* - AI 调用日志记录(token 消耗、响应时间)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AiProviderConfig;
|
||||
use App\Models\AiUsageLog;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class AiChatService
|
||||
{
|
||||
/**
|
||||
* 每个用户保留的最大对话轮数
|
||||
*/
|
||||
private const MAX_CONTEXT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* AI 请求超时时间(秒)
|
||||
*/
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
|
||||
/**
|
||||
* Redis 上下文 key 前缀
|
||||
*/
|
||||
private const CONTEXT_PREFIX = 'ai_chat:context:';
|
||||
|
||||
/**
|
||||
* Redis 上下文过期时间(秒),1 小时无对话自动清除
|
||||
*/
|
||||
private const CONTEXT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* 系统提示词(机器人人设)
|
||||
*/
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
你是一个聊天室小助手,名叫"AI小助手"。你的工作是陪用户聊天,回答他们的问题。
|
||||
要求:
|
||||
1. 始终使用中文回复
|
||||
2. 语气活泼友好,像朋友一样交流
|
||||
3. 回复简洁明了,一般不超过 200 字
|
||||
4. 可以适当使用颜文字或表情增加趣味性
|
||||
5. 如果用户问你是谁,告诉他们你是聊天室的 AI 小助手
|
||||
6. 不要输出任何 Markdown 格式(如 **加粗** 或 # 标题),使用纯文本回复
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* 与 AI 机器人对话
|
||||
*
|
||||
* 优先使用默认配置,若调用失败则按 sort_order 依次尝试备用厂商。
|
||||
*
|
||||
* @param int $userId 用户 ID
|
||||
* @param string $message 用户发送的消息内容
|
||||
* @param int $roomId 房间 ID(用于日志记录)
|
||||
* @return array{reply: string, provider: string, model: string}
|
||||
*
|
||||
* @throws \Exception 所有 AI 厂商均不可用时抛出异常
|
||||
*/
|
||||
public function chat(int $userId, string $message, int $roomId): array
|
||||
{
|
||||
// 获取所有已启用的 AI 配置(默认的排最前面)
|
||||
$providers = AiProviderConfig::getEnabledProviders();
|
||||
|
||||
if ($providers->isEmpty()) {
|
||||
throw new \Exception('没有可用的 AI 厂商配置,请联系管理员。');
|
||||
}
|
||||
|
||||
// 构建对话上下文
|
||||
$contextKey = self::CONTEXT_PREFIX.$userId;
|
||||
$context = $this->getContext($contextKey);
|
||||
|
||||
// 将用户消息加入上下文
|
||||
$context[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
// 构建完整的 messages 数组(系统提示 + 对话上下文)
|
||||
$messages = array_merge(
|
||||
[['role' => 'system', 'content' => self::SYSTEM_PROMPT]],
|
||||
$context
|
||||
);
|
||||
|
||||
// 依次尝试每个厂商
|
||||
$lastError = null;
|
||||
foreach ($providers as $provider) {
|
||||
try {
|
||||
$result = $this->callProvider($provider, $messages, $userId);
|
||||
|
||||
// 调用成功,更新上下文
|
||||
$context[] = ['role' => 'assistant', 'content' => $result['reply']];
|
||||
$this->saveContext($contextKey, $context);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
$lastError = $e;
|
||||
Log::warning("AI 厂商 [{$provider->name}] 调用失败,尝试下一个", [
|
||||
'provider' => $provider->provider,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有厂商都失败了
|
||||
throw new \Exception('AI 服务暂时不可用,请稍后再试。('.($lastError?->getMessage() ?? '未知错误').')');
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用单个 AI 厂商 API
|
||||
*
|
||||
* 使用 OpenAI 兼容协议发送请求到 /v1/chat/completions 端点。
|
||||
*
|
||||
* @param AiProviderConfig $config AI 厂商配置
|
||||
* @param array $messages 包含系统提示和对话上下文的消息数组
|
||||
* @param int $userId 用户 ID(用于日志记录)
|
||||
* @return array{reply: string, provider: string, model: string}
|
||||
*
|
||||
* @throws \Exception 调用失败时抛出异常
|
||||
*/
|
||||
private function callProvider(AiProviderConfig $config, array $messages, int $userId): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$apiKey = $config->getDecryptedApiKey();
|
||||
$endpoint = rtrim($config->api_endpoint, '/').'/v1/chat/completions';
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)
|
||||
->timeout(self::REQUEST_TIMEOUT)
|
||||
->post($endpoint, [
|
||||
'model' => $config->model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $config->temperature,
|
||||
'max_tokens' => $config->max_tokens,
|
||||
]);
|
||||
|
||||
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if (! $response->successful()) {
|
||||
// 记录失败日志
|
||||
$this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $response->body());
|
||||
|
||||
throw new \Exception("HTTP {$response->status()}: {$response->body()}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$reply = $data['choices'][0]['message']['content'] ?? '';
|
||||
$promptTokens = $data['usage']['prompt_tokens'] ?? 0;
|
||||
$completionTokens = $data['usage']['completion_tokens'] ?? 0;
|
||||
|
||||
// 记录成功日志
|
||||
$this->logUsage(
|
||||
$userId,
|
||||
$config,
|
||||
'chatbot',
|
||||
$promptTokens,
|
||||
$completionTokens,
|
||||
$responseTimeMs,
|
||||
true
|
||||
);
|
||||
|
||||
return [
|
||||
'reply' => trim($reply),
|
||||
'provider' => $config->name,
|
||||
'model' => $config->model,
|
||||
];
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
$this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $e->getMessage());
|
||||
|
||||
throw new \Exception("连接超时: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 AI 调用日志到 ai_usage_logs 表
|
||||
*
|
||||
* @param int $userId 用户 ID
|
||||
* @param AiProviderConfig $config AI 厂商配置
|
||||
* @param string $action 操作类型
|
||||
* @param int $promptTokens 输入 token 数
|
||||
* @param int $completionTokens 输出 token 数
|
||||
* @param int $responseTimeMs 响应时间(毫秒)
|
||||
* @param bool $success 是否成功
|
||||
* @param string|null $errorMessage 错误信息
|
||||
*/
|
||||
private function logUsage(
|
||||
int $userId,
|
||||
AiProviderConfig $config,
|
||||
string $action,
|
||||
int $promptTokens,
|
||||
int $completionTokens,
|
||||
int $responseTimeMs,
|
||||
bool $success,
|
||||
?string $errorMessage = null
|
||||
): void {
|
||||
try {
|
||||
AiUsageLog::create([
|
||||
'user_id' => $userId,
|
||||
'provider' => $config->provider,
|
||||
'model' => $config->model,
|
||||
'action' => $action,
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => $promptTokens + $completionTokens,
|
||||
'response_time_ms' => $responseTimeMs,
|
||||
'success' => $success,
|
||||
'error_message' => $errorMessage ? mb_substr($errorMessage, 0, 500) : null,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// 日志记录失败不应影响主流程
|
||||
Log::error('AI 调用日志记录失败', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 获取用户的对话上下文
|
||||
*
|
||||
* @param string $key Redis key
|
||||
* @return array 对话历史数组
|
||||
*/
|
||||
private function getContext(string $key): array
|
||||
{
|
||||
$raw = Redis::get($key);
|
||||
if (! $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = json_decode($raw, true);
|
||||
|
||||
return is_array($context) ? $context : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户的对话上下文到 Redis
|
||||
*
|
||||
* 只保留最近 MAX_CONTEXT_ROUNDS 轮对话(每轮 = 1 条 user + 1 条 assistant)。
|
||||
*
|
||||
* @param string $key Redis key
|
||||
* @param array $context 完整的对话历史
|
||||
*/
|
||||
private function saveContext(string $key, array $context): void
|
||||
{
|
||||
// 限制上下文长度,保留最近 N 轮(N*2 条消息)
|
||||
$maxMessages = self::MAX_CONTEXT_ROUNDS * 2;
|
||||
if (count($context) > $maxMessages) {
|
||||
$context = array_slice($context, -$maxMessages);
|
||||
}
|
||||
|
||||
Redis::setex($key, self::CONTEXT_TTL, json_encode($context, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户的对话上下文
|
||||
*
|
||||
* @param int $userId 用户 ID
|
||||
*/
|
||||
public function clearContext(int $userId): void
|
||||
{
|
||||
Redis::del(self::CONTEXT_PREFIX.$userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 会员服务层
|
||||
* 提供倍率计算、会员授予/撤销、模板渲染等核心逻辑
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VipLevel;
|
||||
|
||||
class VipService
|
||||
{
|
||||
/**
|
||||
* 获取用户的经验倍率(非 VIP 返回 1.0)
|
||||
*/
|
||||
public function getExpMultiplier(User $user): float
|
||||
{
|
||||
if (! $user->isVip()) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return $user->vipLevel?->exp_multiplier ?? 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的金币倍率(非 VIP 返回 1.0)
|
||||
*/
|
||||
public function getJjbMultiplier(User $user): float
|
||||
{
|
||||
if (! $user->isVip()) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return $user->vipLevel?->jjb_multiplier ?? 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授予用户 VIP 等级
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @param int $vipLevelId VIP 等级 ID
|
||||
* @param int $days 天数(0=永久)
|
||||
*/
|
||||
public function grantVip(User $user, int $vipLevelId, int $days = 30): void
|
||||
{
|
||||
$user->vip_level_id = $vipLevelId;
|
||||
|
||||
if ($days > 0) {
|
||||
// 如果用户已有未过期的会员,在现有到期时间上延长
|
||||
$baseTime = ($user->hy_time && $user->hy_time->isFuture())
|
||||
? $user->hy_time
|
||||
: now();
|
||||
$user->hy_time = $baseTime->addDays($days);
|
||||
} else {
|
||||
// 永久会员
|
||||
$user->hy_time = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销用户 VIP 等级
|
||||
*/
|
||||
public function revokeVip(User $user): void
|
||||
{
|
||||
$user->vip_level_id = null;
|
||||
$user->hy_time = null;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户专属进入聊天室的欢迎语(非 VIP 返回 null)
|
||||
*
|
||||
* @param User $user 用户
|
||||
* @return string|null 渲染后的欢迎语
|
||||
*/
|
||||
public function getJoinMessage(User $user): ?string
|
||||
{
|
||||
if (! $user->isVip() || ! $user->vipLevel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$templates = $user->vipLevel->join_templates_array;
|
||||
|
||||
return VipLevel::renderTemplate($templates, $user->username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户专属离开聊天室的提示语(非 VIP 返回 null)
|
||||
*/
|
||||
public function getLeaveMessage(User $user): ?string
|
||||
{
|
||||
if (! $user->isVip() || ! $user->vipLevel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$templates = $user->vipLevel->leave_templates_array;
|
||||
|
||||
return VipLevel::renderTemplate($templates, $user->username);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user