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

274 lines
9.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}