功能: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
@@ -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' => '对话上下文已清除,可以开始新的对话了。',
]);
}
}
+19 -10
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. 如果突破境界,向全房系统喊话广播!
+9 -3
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();
+126
View File
@@ -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();
}
}
+71
View File
@@ -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);
}
}
+52
View File
@@ -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 ?? '';
}
}
+98
View File
@@ -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);
}
}
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* 文件功能:AI 聊天服务
*
* 统一对接多个 AI 厂商 APIOpenAI 兼容协议),实现:
* - 多厂商自动故障转移(默认 备用按 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);
}
}
+108
View File
@@ -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);
}
}