diff --git a/server/app/api/controller/CommunityController.php b/server/app/api/controller/CommunityController.php index 9816f40..124af7c 100644 --- a/server/app/api/controller/CommunityController.php +++ b/server/app/api/controller/CommunityController.php @@ -15,6 +15,7 @@ use app\common\logic\AccountLogLogic; use app\common\service\ai\AiService; use app\common\service\ai\KbSyncService; use app\common\service\chat\PrivateChatService; +use app\common\service\FileService; use app\common\service\VipService; use think\facade\Db; @@ -703,9 +704,169 @@ class CommunityController extends BaseApiController return in_array($lotteryKey, ['a6', 'xa6'], true); } + private function localizeLiuhePostImagesForAi(CommunityPost $post, array $images): array + { + $localizedImages = []; + $changed = false; + + foreach ($images as $image) { + $image = trim((string) $image); + if ($image === '') { + continue; + } + + if ($this->isLocalUploadImage($image)) { + $localUrl = $this->normalizeLocalUploadImageUrl($image); + $localizedImages[] = $localUrl; + $changed = $changed || $localUrl !== $image; + continue; + } + + if (!preg_match('/^https?:\/\//i', $image)) { + $localUrl = FileService::getFileUrl(ltrim($image, '/')); + $localizedImages[] = $localUrl; + $changed = $changed || $localUrl !== $image; + continue; + } + + $localUrl = $this->saveRemoteImageForAiAnalysis($image); + if ($localUrl === '') { + return [ + 'success' => false, + 'error' => '图片下载到本地失败,请稍后重试', + 'images' => [], + ]; + } + + $localizedImages[] = $localUrl; + $changed = true; + } + + if (empty($localizedImages)) { + return ['success' => false, 'error' => '帖子图片为空,无法分析', 'images' => []]; + } + + if ($changed || $localizedImages !== array_values($images)) { + $now = time(); + Db::name('community_post')->where('id', (int) $post->id)->update([ + 'images' => json_encode($localizedImages, JSON_UNESCAPED_UNICODE), + 'update_time' => $now, + ]); + + Db::name('community_post_image')->where('post_id', (int) $post->id)->delete(); + foreach ($localizedImages as $index => $imageUrl) { + Db::name('community_post_image')->insert([ + 'post_id' => (int) $post->id, + 'image_url' => $imageUrl, + 'sort' => $index, + 'create_time' => $now, + ]); + } + } + + return ['success' => true, 'images' => $localizedImages, 'changed' => $changed]; + } + + private function isLocalUploadImage(string $image): bool + { + if (!preg_match('/^https?:\/\//i', $image)) { + return str_starts_with(ltrim($image, '/'), 'uploads/'); + } + + $path = (string) (parse_url($image, PHP_URL_PATH) ?: ''); + if (!str_starts_with($path, '/uploads/')) { + return false; + } + + $imageHost = strtolower((string) (parse_url($image, PHP_URL_HOST) ?: '')); + $currentHost = strtolower((string) (parse_url(request()->domain(), PHP_URL_HOST) ?: '')); + return $imageHost !== '' && $currentHost !== '' && $imageHost === $currentHost; + } + + private function normalizeLocalUploadImageUrl(string $image): string + { + if (preg_match('/^https?:\/\//i', $image)) { + return $image; + } + + return FileService::getFileUrl(ltrim($image, '/')); + } + + private function saveRemoteImageForAiAnalysis(string $imageUrl): string + { + $ch = curl_init($imageUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 25, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_USERAGENT => 'Mozilla/5.0 SportEraBot/1.0', + ]); + + $body = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + if ($httpCode !== 200 || !is_string($body) || $body === '' || strlen($body) > 8 * 1024 * 1024) { + return ''; + } + + $mime = strtolower(trim(explode(';', $contentType)[0] ?? '')); + $imageInfo = @getimagesizefromstring($body); + if (is_array($imageInfo) && !empty($imageInfo['mime'])) { + $mime = strtolower((string) $imageInfo['mime']); + } + + $extension = match ($mime) { + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + default => '', + }; + + if ($extension === '') { + $path = parse_url($imageUrl, PHP_URL_PATH) ?: ''; + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true)) { + return ''; + } + $extension = $extension === 'jpeg' ? 'jpg' : $extension; + } + + $relativeDir = 'uploads/ai_lottery_posts/' . date('Ymd'); + $publicRoot = rtrim(public_path(), DIRECTORY_SEPARATOR . '/'); + $targetDir = $publicRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir); + if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { + return ''; + } + + $filename = sha1($imageUrl . "\n" . $body) . '.' . $extension; + $relativeUri = $relativeDir . '/' . $filename; + $targetPath = $targetDir . DIRECTORY_SEPARATOR . $filename; + + if (!is_file($targetPath) && file_put_contents($targetPath, $body) === false) { + return ''; + } + + return FileService::getFileUrl($relativeUri); + } + private function ensureLiuheAnalysisContent(CommunityPost $post, array $ext): array { $images = is_array($post->images) ? array_values($post->images) : []; + $originalImageHash = md5(json_encode($images, JSON_UNESCAPED_UNICODE)); + $localizeResult = $this->localizeLiuhePostImagesForAi($post, $images); + if (empty($localizeResult['success'])) { + return [ + 'success' => false, + 'error' => (string) ($localizeResult['error'] ?? '图片下载到本地失败'), + ]; + } + + $images = array_values($localizeResult['images']); $imageHash = md5(json_encode($images, JSON_UNESCAPED_UNICODE)); $cacheSource = (string) ($ext['lottery_analysis_source'] ?? ''); $cacheHash = (string) ($ext['lottery_analysis_images_hash'] ?? ''); @@ -713,8 +874,16 @@ class CommunityController extends BaseApiController if ( !empty($ext['lottery_analysis_content']) && $cacheSource === 'image_only' - && $cacheHash === $imageHash + && in_array($cacheHash, [$imageHash, $originalImageHash], true) ) { + if ($cacheHash !== $imageHash) { + $ext['lottery_analysis_images_hash'] = $imageHash; + CommunityPost::where('id', (int) $post->id)->update([ + 'ext' => json_encode($ext, JSON_UNESCAPED_UNICODE), + 'update_time' => time(), + ]); + KbSyncService::enqueue('post', 'post', (int) $post->id, 'upsert', 45); + } return ['success' => true, 'ext' => $ext, 'from_cache' => true]; } diff --git a/server/app/common/service/ai/AiService.php b/server/app/common/service/ai/AiService.php index e95952d..9810139 100644 --- a/server/app/common/service/ai/AiService.php +++ b/server/app/common/service/ai/AiService.php @@ -11,6 +11,7 @@ use app\common\service\MTranServerService; class AiService { + private const LOTTERY_POST_IMAGE_TIMEOUT = 25; // 生成类 AI 统一走 DeepSeekClient 的 OpenAI Responses 配置。 @@ -341,7 +342,7 @@ class AiService if (!$image) { return ''; } - return self::prepareGptImageUrl(FileService::getFileUrl($image)); + return FileService::getFileUrl($image); }, $images))); if (empty($imageUrls)) { @@ -366,6 +367,10 @@ class AiService $options = self::gptModelOptions([ 'max_tokens' => 2000, 'temperature' => 0.3, + 'timeout' => self::LOTTERY_POST_IMAGE_TIMEOUT, + 'reasoning_effort' => 'low', + 'retry_times' => 0, + 'retry_sleep_ms' => 0, ]); $client = new DeepSeekClient(); @@ -380,52 +385,6 @@ class AiService return $result; } - protected static function prepareGptImageUrl(string $imageUrl): string - { - if (!preg_match('/^https?:\/\//i', $imageUrl)) { - return $imageUrl; - } - - $dataUrl = self::downloadImageAsDataUrl($imageUrl); - return $dataUrl !== '' ? $dataUrl : $imageUrl; - } - - protected static function downloadImageAsDataUrl(string $imageUrl): string - { - $ch = curl_init($imageUrl); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_TIMEOUT => 25, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_USERAGENT => 'Mozilla/5.0 SportEraBot/1.0', - ]); - - $body = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - curl_close($ch); - - if ($httpCode !== 200 || !is_string($body) || $body === '' || strlen($body) > 8 * 1024 * 1024) { - return ''; - } - - $mime = strtolower(trim(explode(';', $contentType)[0] ?? '')); - if ($mime === '' || !str_starts_with($mime, 'image/')) { - $path = parse_url($imageUrl, PHP_URL_PATH) ?: ''; - $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - $mime = match ($ext) { - 'png' => 'image/png', - 'webp' => 'image/webp', - 'gif' => 'image/gif', - default => 'image/jpeg', - }; - } - - return 'data:' . $mime . ';base64,' . base64_encode($body); - } - /** * 保存分析结果 */ diff --git a/server/app/common/service/ai/KbRagService.php b/server/app/common/service/ai/KbRagService.php index dd2bde8..aee13dd 100644 --- a/server/app/common/service/ai/KbRagService.php +++ b/server/app/common/service/ai/KbRagService.php @@ -6,6 +6,8 @@ use app\common\model\ai\AiConfig; class KbRagService { + private const LOTTERY_POST_ANALYSIS_TIMEOUT = 25; + public static function analyzeMatch(int $matchId, array $matchData, int $userId = 0): array { $retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_analysis'); @@ -212,6 +214,10 @@ class KbRagService return DeepSeekClient::defaultModelOptions([ 'max_tokens' => 1200, 'temperature' => 0.35, + 'timeout' => self::LOTTERY_POST_ANALYSIS_TIMEOUT, + 'reasoning_effort' => 'low', + 'retry_times' => 0, + 'retry_sleep_ms' => 0, ]); }