更新
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\controller\crawler;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\crawler\TaskLogLists;
|
||||
use app\common\model\CrawlerTaskLog;
|
||||
|
||||
class TaskLogController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new TaskLogLists());
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
$id = $this->request->get('id/d', 0);
|
||||
$log = CrawlerTaskLog::findOrEmpty($id);
|
||||
if ($log->isEmpty()) {
|
||||
return $this->fail('日志不存在');
|
||||
}
|
||||
return $this->data(TaskLogLists::formatItem($log->toArray()));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class KbConfigLists extends BaseAdminDataLists
|
||||
->whereOrLike('name', 'qwen_%')
|
||||
->whereOrLike('name', 'openai_%')
|
||||
->whereOrLike('name', 'mtranserver_%')
|
||||
->whereOrLike('name', 'whisper_asr_%')
|
||||
->whereOrLike('name', 'assistant_%')
|
||||
->whereOr('name', '=', 'post_analysis_prompt');
|
||||
})->limit($this->limitOffset, $this->limitLength)
|
||||
@@ -31,6 +32,7 @@ class KbConfigLists extends BaseAdminDataLists
|
||||
->whereOrLike('name', 'qwen_%')
|
||||
->whereOrLike('name', 'openai_%')
|
||||
->whereOrLike('name', 'mtranserver_%')
|
||||
->whereOrLike('name', 'whisper_asr_%')
|
||||
->whereOrLike('name', 'assistant_%')
|
||||
->whereOr('name', '=', 'post_analysis_prompt');
|
||||
})->count();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\adminapi\lists\crawler;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\CrawlerTaskLog;
|
||||
|
||||
class TaskLogLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['action', 'status', 'trigger_type', 'source'],
|
||||
'%like%' => ['task_name', 'task_key'],
|
||||
'between_time' => 'create_time',
|
||||
];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$lists = CrawlerTaskLog::field('id,task_key,task_name,action,cron_expression,source,container_id,status,trigger_type,output,error_message,elapsed,started_at,finished_at,create_time,update_time')
|
||||
->where($this->searchWhere)
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$item = self::formatItem($item);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return CrawlerTaskLog::where($this->searchWhere)->count();
|
||||
}
|
||||
|
||||
public static function formatItem(array $item): array
|
||||
{
|
||||
$item['status_text'] = match ((int) ($item['status'] ?? 0)) {
|
||||
0 => '执行中',
|
||||
1 => '成功',
|
||||
2 => '失败',
|
||||
3 => '已跳过',
|
||||
default => '未知',
|
||||
};
|
||||
$item['trigger_type_text'] = ((int) ($item['trigger_type'] ?? 1)) === 2 ? '手动' : '自动';
|
||||
foreach (['started_at', 'finished_at', 'create_time', 'update_time'] as $field) {
|
||||
$value = $item[$field] ?? 0;
|
||||
$item[$field . '_text'] = self::formatTime($value);
|
||||
}
|
||||
$item['output_preview'] = mb_substr((string) ($item['output'] ?? ''), 0, 120);
|
||||
return $item;
|
||||
}
|
||||
|
||||
private static function formatTime($value): string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return '-';
|
||||
}
|
||||
return is_numeric($value) ? date('Y-m-d H:i:s', (int) $value) : (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,14 @@ use Cron\CronExpression;
|
||||
*/
|
||||
class CrontabLogic extends BaseLogic
|
||||
{
|
||||
private const CRAWLER_MIGRATED_MESSAGE = 'crawler任务已迁移到Docker容器调度,请查看管理端爬虫任务日志';
|
||||
|
||||
private const CRAWLER_MIGRATED_COMMANDS = [
|
||||
'crawler',
|
||||
'truth_social',
|
||||
'fifa_worldcup_news',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 添加定时任务
|
||||
* @param $params
|
||||
@@ -158,6 +166,13 @@ class CrontabLogic extends BaseLogic
|
||||
throw new \Exception('定时任务不存在');
|
||||
}
|
||||
|
||||
if (in_array($crontab->command, self::CRAWLER_MIGRATED_COMMANDS, true)) {
|
||||
Crontab::where('id', $crontab->id)->update([
|
||||
'error' => self::CRAWLER_MIGRATED_MESSAGE,
|
||||
]);
|
||||
throw new \Exception(self::CRAWLER_MIGRATED_MESSAGE);
|
||||
}
|
||||
|
||||
$log = CrontabLog::create([
|
||||
'crontab_id' => $crontab->id,
|
||||
'name' => $crontab->name,
|
||||
@@ -169,18 +184,6 @@ class CrontabLogic extends BaseLogic
|
||||
'trigger_type' => 2,
|
||||
]);
|
||||
|
||||
if ($crontab->command === 'crawler') {
|
||||
$crawlerDir = app()->getRootPath() . 'public/dongqiudi-crawler';
|
||||
$python = 'python3';
|
||||
$action = $crontab->params ?: 'all';
|
||||
$logId = $log->id;
|
||||
$crontabId = $crontab->id;
|
||||
$cmd = "cd {$crawlerDir} && nohup {$python} main.py {$action} --log-id={$logId} --crontab-id={$crontabId} > /dev/null 2>&1 &";
|
||||
\shell_exec($cmd);
|
||||
|
||||
return ['log_id' => $logId, 'output' => '任务已提交,后台执行中...', 'async' => true];
|
||||
}
|
||||
|
||||
if ($crontab->command === 'truth_social') {
|
||||
$crawlerDir = app()->getRootPath() . 'public/dongqiudi-crawler';
|
||||
$python = 'python3';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\service\chat\PrivateChatService;
|
||||
use app\common\service\chat\WhisperAsrService;
|
||||
|
||||
class ChatController extends BaseApiController
|
||||
{
|
||||
@@ -49,4 +50,17 @@ class ChatController extends BaseApiController
|
||||
}
|
||||
return $this->data($result['data']);
|
||||
}
|
||||
|
||||
public function voiceToText()
|
||||
{
|
||||
if ($this->userId <= 0) {
|
||||
return $this->fail('请先登录');
|
||||
}
|
||||
|
||||
$result = WhisperAsrService::transcribeUploadedFile($this->request->file('file'));
|
||||
if (empty($result['success'])) {
|
||||
return $this->fail($result['error'] ?? '语音识别失败');
|
||||
}
|
||||
return $this->data($result['data']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ use app\common\service\crontab\CrontabAlertService;
|
||||
*/
|
||||
class Crontab extends Command
|
||||
{
|
||||
protected const CRAWLER_MIGRATED_MESSAGE = 'crawler任务已迁移到Docker容器调度,请查看管理端爬虫任务日志';
|
||||
|
||||
protected const CRAWLER_MIGRATED_COMMANDS = [
|
||||
'crawler',
|
||||
'truth_social',
|
||||
'fifa_worldcup_news',
|
||||
];
|
||||
|
||||
protected const CRAWLER_DEFAULT_TIMEOUT = 600;
|
||||
|
||||
protected const CRAWLER_ACTION_TIMEOUTS = [
|
||||
@@ -96,8 +104,8 @@ class Crontab extends Command
|
||||
|
||||
public static function start($item)
|
||||
{
|
||||
if ($item['command'] === 'crawler') {
|
||||
self::startCrawler($item);
|
||||
if (in_array($item['command'], self::CRAWLER_MIGRATED_COMMANDS, true)) {
|
||||
self::skipMigratedCrawler($item);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,6 +166,14 @@ class Crontab extends Command
|
||||
}
|
||||
}
|
||||
|
||||
protected static function skipMigratedCrawler(array $item): void
|
||||
{
|
||||
CrontabModel::where('id', $item['id'])->update([
|
||||
'last_time' => time(),
|
||||
'error' => self::CRAWLER_MIGRATED_MESSAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function startCrawler($item)
|
||||
{
|
||||
$crawlerDir = app()->getRootPath() . 'public/dongqiudi-crawler';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
class CrawlerTaskLog extends BaseModel
|
||||
{
|
||||
protected $name = 'crawler_task_log';
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'create_time';
|
||||
protected $updateTime = 'update_time';
|
||||
}
|
||||
@@ -11,8 +11,6 @@ use think\facade\Cache;
|
||||
class AiAssistantService
|
||||
{
|
||||
public const AI_LOG_TYPE_ASSISTANT = 7;
|
||||
private const QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
|
||||
private const QWEN_MODEL = 'qwen3.7-plus';
|
||||
|
||||
public static function chat(string $message, int $userId, string $clientId, int $sessionId = 0, string $ip = ''): array
|
||||
{
|
||||
@@ -65,7 +63,7 @@ class AiAssistantService
|
||||
'role' => AiChatMessage::ROLE_ASSISTANT,
|
||||
'content' => $answer,
|
||||
'sources_json' => json_encode($context['sources'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'model' => (string) ($result['model'] ?? 'gpt-5.5'),
|
||||
'model' => (string) ($result['model'] ?? self::defaultModelOptions()['model']),
|
||||
'tokens_used' => (int) ($result['tokens'] ?? 0),
|
||||
'cost_ms' => (int) ($result['cost_ms'] ?? 0),
|
||||
'status' => $status,
|
||||
@@ -231,13 +229,7 @@ class AiAssistantService
|
||||
private static function callAssistantModel(array $messages): array
|
||||
{
|
||||
$client = new DeepSeekClient();
|
||||
$optionsList = [];
|
||||
|
||||
$qwenOptions = self::qwenOptions();
|
||||
if (!empty($qwenOptions['api_key'])) {
|
||||
$optionsList[] = $qwenOptions;
|
||||
}
|
||||
$optionsList[] = self::defaultModelOptions();
|
||||
$optionsList = [self::defaultModelOptions()];
|
||||
|
||||
$failedErrors = [];
|
||||
$lastResult = ['success' => false, 'error' => 'AI助手模型服务暂时不可用', 'tokens' => 0];
|
||||
@@ -258,40 +250,12 @@ class AiAssistantService
|
||||
return $lastResult;
|
||||
}
|
||||
|
||||
private static function qwenOptions(): array
|
||||
{
|
||||
$apiKey = trim((string) AiConfig::getVal('qwen_api_key', ''));
|
||||
if ($apiKey === '') {
|
||||
$apiKey = trim((string) env('DASHSCOPE_API_KEY', ''));
|
||||
}
|
||||
if ($apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) AiConfig::getVal('qwen_base_url', ''));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = self::QWEN_BASE_URL;
|
||||
}
|
||||
$model = trim((string) AiConfig::getVal('qwen_model', ''));
|
||||
if ($model === '') {
|
||||
$model = self::QWEN_MODEL;
|
||||
}
|
||||
|
||||
return self::defaultModelOptions() + [
|
||||
'api_key' => $apiKey,
|
||||
'base_url' => $baseUrl,
|
||||
'model' => $model,
|
||||
'wire_api' => 'chat',
|
||||
'provider_label' => 'DashScope(Qwen)',
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultModelOptions(): array
|
||||
{
|
||||
return [
|
||||
return DeepSeekClient::defaultModelOptions([
|
||||
'max_tokens' => 1600,
|
||||
'temperature' => 0.45,
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
private static function checkRateLimit(int $userId, string $clientId, string $ip): bool
|
||||
|
||||
@@ -13,10 +13,16 @@ class AiService
|
||||
{
|
||||
|
||||
|
||||
// 按当前业务要求固定走 GPT 中转,不再依赖后台模型配置。
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
// 生成类 AI 统一走 DeepSeekClient 的 OpenAI Responses 配置。
|
||||
private static function gptModelOptions(array $overrides = []): array
|
||||
{
|
||||
return DeepSeekClient::defaultModelOptions($overrides);
|
||||
}
|
||||
|
||||
private static function defaultModelName(): string
|
||||
{
|
||||
return (string) self::gptModelOptions()['model'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事预测(完整,保留兼容)
|
||||
@@ -357,15 +363,10 @@ class AiService
|
||||
. '输出格式尽量清晰,推荐使用:图片X、过滤后识别内容、逐项分析、综合结论、风险提示。'
|
||||
. '不要承诺中奖,不要编造图片中不存在的数据,最后给出理性购彩风险提示。'
|
||||
);
|
||||
$options = [
|
||||
$options = self::gptModelOptions([
|
||||
'max_tokens' => 2000,
|
||||
'temperature' => 0.3,
|
||||
'model' => self::GPT_PROXY_MODEL,
|
||||
'base_url' => self::GPT_PROXY_BASE_URL,
|
||||
'api_key' => self::GPT_PROXY_API_KEY,
|
||||
'wire_api' => 'responses',
|
||||
'provider_label' => 'OpenAI(GPT中转)',
|
||||
];
|
||||
]);
|
||||
|
||||
$client = new DeepSeekClient();
|
||||
$userMessage = "帖子内容:\n" . ($content ?: '无文字内容') . "\n\n请只依据当前帖子图片和上面的帖子文案输出分析。先过滤掉杂乱和无关内容,再提取图片里的有效信息,最后对提取出的每一项内容一一分析描述,不要补充任何往期推断。";
|
||||
@@ -438,7 +439,7 @@ class AiService
|
||||
'ref_id' => $refId,
|
||||
'prompt' => mb_substr($prompt, 0, 5000),
|
||||
'result' => $resultJson,
|
||||
'model' => (string) ($result['model'] ?? self::GPT_PROXY_MODEL),
|
||||
'model' => (string) ($result['model'] ?? self::defaultModelName()),
|
||||
'tokens_used' => $result['tokens'] ?? 0,
|
||||
'status' => $status,
|
||||
'error_msg' => $result['error'] ?? '',
|
||||
|
||||
@@ -6,10 +6,13 @@ use app\common\model\ai\AiConfig;
|
||||
|
||||
class DeepSeekClient
|
||||
{
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
private const GPT_PROXY_WIRE_API = 'responses';
|
||||
private const DEFAULT_OPENAI_API_KEY = '';
|
||||
private const DEFAULT_OPENAI_BASE_URL = 'https://lingsuan.top';
|
||||
private const DEFAULT_OPENAI_MODEL = 'gpt-5.5';
|
||||
private const DEFAULT_OPENAI_WIRE_API = 'responses';
|
||||
private const DEFAULT_OPENAI_PROVIDER_LABEL = 'OpenAI';
|
||||
private const DEFAULT_OPENAI_REASONING_EFFORT = 'xhigh';
|
||||
private const DEFAULT_OPENAI_DISABLE_RESPONSE_STORAGE = '1';
|
||||
|
||||
protected string $apiKey;
|
||||
protected string $baseUrl;
|
||||
@@ -21,15 +24,38 @@ class DeepSeekClient
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = self::GPT_PROXY_API_KEY;
|
||||
$this->baseUrl = self::GPT_PROXY_BASE_URL;
|
||||
$this->model = self::GPT_PROXY_MODEL;
|
||||
$this->wireApi = self::GPT_PROXY_WIRE_API;
|
||||
$this->providerLabel = 'OpenAI(GPT中转)';
|
||||
$options = self::defaultModelOptions();
|
||||
$this->apiKey = $options['api_key'];
|
||||
$this->baseUrl = $options['base_url'];
|
||||
$this->model = $options['model'];
|
||||
$this->wireApi = strtolower((string) $options['wire_api']);
|
||||
$this->providerLabel = $options['provider_label'];
|
||||
$this->maxTokens = (int) AiConfig::getVal('max_tokens', '2000');
|
||||
$this->temperature = (float) AiConfig::getVal('temperature', '0.7');
|
||||
}
|
||||
|
||||
public static function defaultModelOptions(array $overrides = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'api_key' => self::resolveConfig('openai_api_key', 'OPENAI_API_KEY', self::DEFAULT_OPENAI_API_KEY),
|
||||
'base_url' => self::resolveConfig('openai_base_url', 'OPENAI_BASE_URL', self::DEFAULT_OPENAI_BASE_URL),
|
||||
'model' => self::resolveConfig('openai_model', 'OPENAI_MODEL', self::DEFAULT_OPENAI_MODEL),
|
||||
'wire_api' => strtolower(self::resolveConfig('openai_wire_api', 'OPENAI_WIRE_API', self::DEFAULT_OPENAI_WIRE_API)),
|
||||
'provider_label' => self::resolveConfig('openai_provider_label', 'OPENAI_PROVIDER_LABEL', self::DEFAULT_OPENAI_PROVIDER_LABEL),
|
||||
'reasoning_effort' => self::resolveConfig('openai_reasoning_effort', 'OPENAI_REASONING_EFFORT', self::DEFAULT_OPENAI_REASONING_EFFORT),
|
||||
'disable_response_storage' => self::resolveConfig('openai_disable_response_storage', 'OPENAI_DISABLE_RESPONSE_STORAGE', self::DEFAULT_OPENAI_DISABLE_RESPONSE_STORAGE),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
private static function resolveConfig(string $configName, string $envName, string $default): string
|
||||
{
|
||||
$value = trim((string) env($envName, ''));
|
||||
if ($value === '') {
|
||||
$value = trim((string) AiConfig::getVal($configName, ''));
|
||||
}
|
||||
return $value !== '' ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天请求
|
||||
*/
|
||||
@@ -120,7 +146,14 @@ class DeepSeekClient
|
||||
return ['success' => false, 'error' => 'cURL错误: ' . $error, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$data = json_decode((string) $response, true);
|
||||
if (!is_array($data)) {
|
||||
$errMsg = json_last_error() === JSON_ERROR_NONE ? '响应JSON结构异常' : '响应不是有效JSON';
|
||||
if ($httpCode > 0) {
|
||||
$errMsg .= '(HTTP ' . $httpCode . ')';
|
||||
}
|
||||
return ['success' => false, 'error' => $providerLabel . ' 返回异常: ' . $errMsg, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
||||
}
|
||||
|
||||
$content = $wireApi === 'responses' ? $this->extractResponsesContent($data) : ($data['choices'][0]['message']['content'] ?? '');
|
||||
if ($httpCode !== 200 || $content === '') {
|
||||
@@ -180,9 +213,21 @@ class DeepSeekClient
|
||||
if (!empty($instructions)) {
|
||||
$payload['instructions'] = implode("\n\n", $instructions);
|
||||
}
|
||||
$reasoningEffort = trim((string) ($options['reasoning_effort'] ?? ''));
|
||||
if ($reasoningEffort !== '') {
|
||||
$payload['reasoning'] = ['effort' => $reasoningEffort];
|
||||
}
|
||||
if (self::truthy($options['disable_response_storage'] ?? false)) {
|
||||
$payload['store'] = false;
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private static function truthy($value): bool
|
||||
{
|
||||
return in_array(strtolower(trim((string) $value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
protected function convertResponsesContent($content): array
|
||||
{
|
||||
if (is_string($content)) {
|
||||
@@ -219,8 +264,12 @@ class DeepSeekClient
|
||||
return trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
protected function extractResponsesContent(array $data): string
|
||||
protected function extractResponsesContent(?array $data): string
|
||||
{
|
||||
if (empty($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!empty($data['output_text'])) {
|
||||
return (string) $data['output_text'];
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ use app\common\model\ai\AiConfig;
|
||||
|
||||
class KbRagService
|
||||
{
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
|
||||
public static function analyzeMatch(int $matchId, array $matchData, int $userId = 0): array
|
||||
{
|
||||
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_analysis');
|
||||
@@ -213,15 +209,10 @@ class KbRagService
|
||||
|
||||
private static function gptPostOptions(): array
|
||||
{
|
||||
return [
|
||||
'model' => self::GPT_PROXY_MODEL,
|
||||
'base_url' => self::GPT_PROXY_BASE_URL,
|
||||
'api_key' => self::GPT_PROXY_API_KEY,
|
||||
'wire_api' => 'responses',
|
||||
'provider_label' => 'OpenAI(GPT中转)',
|
||||
return DeepSeekClient::defaultModelOptions([
|
||||
'max_tokens' => 1200,
|
||||
'temperature' => 0.35,
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
private static function compactHits(array $hits): array
|
||||
|
||||
@@ -12,6 +12,7 @@ class PrivateChatService
|
||||
{
|
||||
public const MESSAGE_TYPE_TEXT = PrivateChatMessage::TYPE_TEXT;
|
||||
public const MESSAGE_TYPE_IMAGE = PrivateChatMessage::TYPE_IMAGE;
|
||||
public const RELATIONSHIP_NOT_MUTUAL_TEXT = '对方还未添加你为好友';
|
||||
|
||||
public static function open(int $userId, int $targetUserId, bool $requireFollow = true): array
|
||||
{
|
||||
@@ -182,7 +183,7 @@ class PrivateChatService
|
||||
'is_followed_by_me' => $isFollowedByMe,
|
||||
'is_following_me' => $isFollowingMe,
|
||||
'is_mutual' => $isMutual,
|
||||
'relationship_text' => $isMutual ? '互相关注' : '不是好友',
|
||||
'relationship_text' => $isMutual ? '互相关注' : self::RELATIONSHIP_NOT_MUTUAL_TEXT,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\chat;
|
||||
|
||||
use app\common\model\ai\AiConfig;
|
||||
|
||||
class WhisperAsrService
|
||||
{
|
||||
private const DEFAULT_BASE_URL = 'http://127.0.0.1:8791';
|
||||
private const DEFAULT_TIMEOUT = 120;
|
||||
private const DEFAULT_MAX_SIZE_MB = 10;
|
||||
private const ALLOWED_EXTENSIONS = ['mp3', 'm4a', 'aac', 'wav', 'amr', 'mp4', '3gp'];
|
||||
|
||||
public static function transcribeUploadedFile($file): array
|
||||
{
|
||||
if ((int) AiConfig::getVal('whisper_asr_enabled', '1') !== 1) {
|
||||
return self::fail('语音转文字服务暂未启用');
|
||||
}
|
||||
if (empty($file)) {
|
||||
return self::fail('请先录制语音');
|
||||
}
|
||||
|
||||
$filePath = self::uploadedFilePath($file);
|
||||
$originalName = self::uploadedFileName($file);
|
||||
$mime = self::uploadedFileMime($file);
|
||||
$maxSizeMb = self::maxSizeMb();
|
||||
|
||||
$valid = self::validateAudioFile($filePath, $originalName, $maxSizeMb);
|
||||
if (empty($valid['success'])) {
|
||||
return $valid;
|
||||
}
|
||||
|
||||
return self::requestAsr($filePath, $originalName, $mime);
|
||||
}
|
||||
|
||||
public static function validateAudioFile(string $filePath, string $originalName = '', int $maxSizeMb = self::DEFAULT_MAX_SIZE_MB): array
|
||||
{
|
||||
if ($filePath === '' || !is_file($filePath) || !is_readable($filePath)) {
|
||||
return self::fail('录音文件不存在');
|
||||
}
|
||||
|
||||
$maxSizeMb = max(1, min(100, $maxSizeMb));
|
||||
$size = (int) filesize($filePath);
|
||||
if ($size <= 0) {
|
||||
return self::fail('录音文件为空');
|
||||
}
|
||||
if ($size > $maxSizeMb * 1024 * 1024) {
|
||||
return self::fail('录音文件不能超过' . $maxSizeMb . 'MB');
|
||||
}
|
||||
|
||||
$extension = self::resolveAudioExtension($originalName, $filePath);
|
||||
if ($extension === '') {
|
||||
return self::fail('无法识别录音文件格式');
|
||||
}
|
||||
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
return self::fail('仅支持 mp3、m4a、aac、wav、amr、mp4、3gp 格式语音');
|
||||
}
|
||||
|
||||
return self::success([
|
||||
'extension' => $extension,
|
||||
'size' => $size,
|
||||
'max_size_mb' => $maxSizeMb,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function normalizeAsrResponse(array $payload): array
|
||||
{
|
||||
$nested = is_array($payload['data'] ?? null) ? $payload['data'] : [];
|
||||
$text = trim((string) ($payload['text'] ?? $payload['result'] ?? $nested['text'] ?? ''));
|
||||
if ($text === '') {
|
||||
return self::fail('未识别到语音内容');
|
||||
}
|
||||
|
||||
$durationMs = (int) ($payload['duration_ms'] ?? $nested['duration_ms'] ?? 0);
|
||||
if ($durationMs <= 0) {
|
||||
$duration = (float) ($payload['duration'] ?? $nested['duration'] ?? 0);
|
||||
$durationMs = $duration > 0 ? (int) round($duration * 1000) : 0;
|
||||
}
|
||||
|
||||
return self::success([
|
||||
'text' => $text,
|
||||
'duration_ms' => max(0, $durationMs),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function requestAsr(string $filePath, string $originalName, string $mime): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return self::fail('服务器未启用 cURL,无法连接语音识别服务');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) AiConfig::getVal('whisper_asr_base_url', self::DEFAULT_BASE_URL), '/');
|
||||
if ($baseUrl === '') {
|
||||
return self::fail('语音转文字服务地址未配置');
|
||||
}
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
$apiToken = trim((string) AiConfig::getVal('whisper_asr_api_token', ''));
|
||||
if ($apiToken !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiToken;
|
||||
}
|
||||
|
||||
$timeout = self::timeoutSeconds();
|
||||
$startTime = microtime(true);
|
||||
$postFields = [
|
||||
'file' => new \CURLFile(
|
||||
$filePath,
|
||||
$mime !== '' ? $mime : 'application/octet-stream',
|
||||
self::asrUploadFileName($originalName, $filePath)
|
||||
),
|
||||
];
|
||||
|
||||
$ch = curl_init($baseUrl . '/v1/transcribe');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$costMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
if ($error !== '') {
|
||||
return self::fail('语音识别服务连接失败:' . $error, ['duration_ms' => $costMs]);
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return self::fail('语音识别服务返回异常:响应不是有效JSON', ['duration_ms' => $costMs]);
|
||||
}
|
||||
if ($httpCode !== 200) {
|
||||
$message = $payload['detail'] ?? $payload['message'] ?? $payload['error'] ?? ('HTTP ' . $httpCode);
|
||||
return self::fail('语音识别服务返回异常:' . (is_string($message) ? $message : json_encode($message, JSON_UNESCAPED_UNICODE)), ['duration_ms' => $costMs]);
|
||||
}
|
||||
|
||||
$normalized = self::normalizeAsrResponse($payload);
|
||||
if (empty($normalized['success'])) {
|
||||
return $normalized;
|
||||
}
|
||||
if (empty($normalized['data']['duration_ms'])) {
|
||||
$normalized['data']['duration_ms'] = $costMs;
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function uploadedFilePath($file): string
|
||||
{
|
||||
foreach (['getRealPath', 'getPathname'] as $method) {
|
||||
if (is_object($file) && method_exists($file, $method)) {
|
||||
$path = (string) $file->{$method}();
|
||||
if ($path !== '') {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function uploadedFileName($file): string
|
||||
{
|
||||
if (is_object($file) && method_exists($file, 'getOriginalName')) {
|
||||
return (string) $file->getOriginalName();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function uploadedFileMime($file): string
|
||||
{
|
||||
if (is_object($file) && method_exists($file, 'getMime')) {
|
||||
return (string) $file->getMime();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function resolveAudioExtension(string $originalName, string $filePath): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($extension === '') {
|
||||
$extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
}
|
||||
return $extension;
|
||||
}
|
||||
|
||||
private static function asrUploadFileName(string $originalName, string $filePath): string
|
||||
{
|
||||
if ($originalName !== '' && pathinfo($originalName, PATHINFO_EXTENSION) !== '') {
|
||||
return $originalName;
|
||||
}
|
||||
|
||||
$extension = self::resolveAudioExtension($originalName, $filePath);
|
||||
return 'voice.' . ($extension !== '' ? $extension : 'mp3');
|
||||
}
|
||||
|
||||
private static function timeoutSeconds(): int
|
||||
{
|
||||
return max(5, min(300, (int) AiConfig::getVal('whisper_asr_timeout', (string) self::DEFAULT_TIMEOUT)));
|
||||
}
|
||||
|
||||
private static function maxSizeMb(): int
|
||||
{
|
||||
return max(1, min(100, (int) AiConfig::getVal('whisper_asr_max_size_mb', (string) self::DEFAULT_MAX_SIZE_MB)));
|
||||
}
|
||||
|
||||
private static function success(array $data): array
|
||||
{
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
private static function fail(string $error, array $data = []): array
|
||||
{
|
||||
return ['success' => false, 'error' => $error, 'data' => $data];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user