self::analyzeMatchSection( (int) ($params['match_id'] ?? 0), (string) ($params['section'] ?? 'prediction'), $userId ), 'match_full', 'match_predict' => self::analyzeMatchFull((int) ($params['match_id'] ?? 0), $userId), 'article' => self::analyzeArticle((int) ($params['article_id'] ?? 0), $userId), 'post' => self::analyzePost((int) ($params['post_id'] ?? 0), $userId, (int) ($params['force'] ?? 0) === 1), 'lottery' => self::analyzeLotteryOverview($params, $userId), 'lottery_module' => self::analyzeLotteryModule( (string) ($params['code'] ?? ''), (string) ($params['module'] ?? ''), $userId ), 'credibility', 'user_credibility' => self::analyzeUserCredibility((int) ($params['user_id'] ?? 0), $userId), default => self::fail('不支持的AI分析类型'), }; } public static function analyzeMatchFull(int $matchId, int $userId): array { if ($matchId <= 0) { return self::fail('参数缺失'); } $matchData = self::buildMatchData($matchId, false); if (empty($matchData['success'])) { return $matchData; } $quota = self::ensureQuota($userId, AiQuotaService::TYPE_MATCH, $matchId, $matchId); if (empty($quota['success'])) { return $quota; } $result = AiService::predictMatch($matchId, $matchData['data'], $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'analysis' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, 'match_info' => $matchData['data']['match_info'], ], $quota['quota']); } public static function analyzeMatchSection(int $matchId, string $section, int $userId): array { $section = trim($section); if ($matchId <= 0 || $section === '') { return self::fail('参数缺失'); } if (!in_array($section, self::MATCH_SECTIONS, true)) { return self::fail('无效的分析模块'); } $matchData = self::buildMatchData($matchId, true); if (empty($matchData['success'])) { return $matchData; } $quota = self::ensureQuota($userId, AiQuotaService::TYPE_MATCH, $matchId, $matchId); if (empty($quota['success'])) { return $quota; } $result = AiService::predictMatchSection($matchId, $section, $matchData['data'], $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'section' => $section, 'data' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, 'match_info' => $matchData['data']['match_info'], ], $quota['quota']); } public static function analyzeArticle(int $articleId, int $userId): array { if ($articleId <= 0) { return self::fail('参数缺失'); } $article = Article::where(['id' => $articleId, 'is_show' => YesNoEnum::YES])->findOrEmpty(); if ($article->isEmpty()) { return self::fail('文章不存在'); } $quota = self::ensureQuota($userId, AiQuotaService::TYPE_ARTICLE, $articleId, $articleId); if (empty($quota['success'])) { return $quota; } $result = AiService::analyzeArticle($articleId, $article->toArray(), $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'analysis' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, ], $quota['quota']); } public static function analyzeUserCredibility(int $targetUserId, int $userId): array { if ($targetUserId <= 0) { return self::fail('参数缺失'); } $quota = self::ensureQuota($userId, AiQuotaService::TYPE_CREDIBILITY, $targetUserId, $targetUserId); if (empty($quota['success'])) { return $quota; } $userData = [ 'user_id' => $targetUserId, 'register_days' => 30, 'post_count' => 0, 'comment_count' => 0, 'like_received' => 0, ]; $result = AiService::analyzeCredibility($targetUserId, $userData, $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'analysis' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, ], $quota['quota']); } public static function analyzePost(int $postId, int $userId, bool $force = false): array { if ($postId <= 0) { return self::fail('参数缺失'); } try { $post = CommunityPost::where('id', $postId)->where('status', 1)->findOrEmpty(); if ($post->isEmpty()) { return self::fail('帖子不存在'); } $data = $post->toArray(); $ext = is_array($data['ext'] ?? null) ? $data['ext'] : (json_decode((string) ($data['ext'] ?? ''), true) ?: []); $tags = self::getPostTags($postId); if (self::isTrumpPost($tags)) { return self::fail('特朗普帖子仅支持翻译'); } if (!self::isLiuhePost($tags, $ext)) { return self::fail('该帖子类型无需AI分析'); } $quota = self::ensureQuota($userId, AiQuotaService::TYPE_POST, $postId, $postId); if (empty($quota['success'])) { return $quota; } $analysis = self::ensureLiuheAnalysisContent($post, $ext); if (!$analysis['success']) { return self::fail($analysis['error'] ?? '图片分析失败', 0, $quota['quota']); } $ext = $analysis['ext']; $data['tags'] = array_values($tags); $data['ext'] = $ext; if (!empty($ext['translated_content'])) { $data['translated_content'] = (string) $ext['translated_content']; } if (!empty($ext['lottery_analysis_content'])) { $data['lottery_analysis_content'] = (string) $ext['lottery_analysis_content']; } $result = AiService::analyzePost($postId, $data, $userId, true, $force); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'analysis' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, ], $quota['quota']); } catch (\Throwable $e) { return self::fail('AI分析异常: ' . $e->getMessage()); } } public static function analyzeLotteryOverview(array $params, int $userId): array { $prepared = self::buildLotteryOverviewData($params); if (empty($prepared['success'])) { return $prepared; } $quota = self::ensureQuota( $userId, AiQuotaService::TYPE_LOTTERY, self::lotteryQuotaRef($prepared['code'], $prepared['latest_issue']), self::crcRefId($prepared['code'] . ':' . $prepared['latest_issue']) ); if (empty($quota['success'])) { return $quota; } $result = AiService::analyzeLottery($prepared['lottery_data'], $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'analysis' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, 'category_name' => $prepared['category_name'], ], $quota['quota']); } public static function analyzeLotteryModule(string $code, string $module, int $userId): array { $code = trim($code); $module = trim($module); if ($code === '' || $module === '') { return self::fail('参数缺失'); } if (!in_array($module, self::LOTTERY_MODULES, true)) { return self::fail('无效的分析模块'); } $prepared = self::buildLotteryModuleData($code, $module); if (empty($prepared['success'])) { return $prepared; } $quota = self::ensureQuota( $userId, AiQuotaService::TYPE_LOTTERY, self::lotteryQuotaRef($code, $prepared['latest_issue']), self::crcRefId($code . ':' . $prepared['latest_issue']) ); if (empty($quota['success'])) { return $quota; } $result = AiService::analyzeLotteryModule($prepared['lottery_data'], $module, $userId); if (!$result['success']) { return self::fail($result['error'] ?? 'AI分析失败', 0, $quota['quota']); } return self::withQuota([ 'module' => $module, 'data' => $result['data'], 'from_cache' => $result['from_cache'] ?? false, ], $quota['quota']); } private static function buildMatchData(int $matchId, bool $includeOdds): array { $match = MatchEvent::where('id', $matchId)->where('is_show', 1)->findOrEmpty(); if ($match->isEmpty()) { return self::fail('赛事不存在'); } $data = $match->toArray(); $homeTeam = $data['home_team']; $awayTeam = $data['away_team']; $matchInfo = [ 'home_team' => $homeTeam, 'away_team' => $awayTeam, 'league' => $data['league_name'] ?? '', 'match_time' => date('Y-m-d H:i', $data['match_time'] ?? time()), 'status' => $data['status'] ?? 0, ]; if ($includeOdds) { $matchInfo += [ 'home_score' => $data['home_score'] ?? 0, 'away_score' => $data['away_score'] ?? 0, 'home_odds' => $data['home_odds'] ?? 0, 'draw_odds' => $data['draw_odds'] ?? 0, 'away_odds' => $data['away_odds'] ?? 0, ]; } return [ 'success' => true, 'data' => [ 'match_info' => $matchInfo, 'head_to_head' => MatchHistory::where(function ($query) use ($homeTeam, $awayTeam) { $query->where([['home_team', '=', $homeTeam], ['away_team', '=', $awayTeam]]) ->whereOr([['home_team', '=', $awayTeam], ['away_team', '=', $homeTeam]]); })->order('match_time desc')->limit(5)->select()->toArray(), 'home_recent' => MatchRecent::where('team_name', $homeTeam) ->order('match_time desc')->limit(5)->select()->toArray(), 'away_recent' => MatchRecent::where('team_name', $awayTeam) ->order('match_time desc')->limit(5)->select()->toArray(), ], ]; } private static function buildLotteryOverviewData(array $params): array { $categoryId = (int) ($params['category_id'] ?? 0); $code = trim((string) ($params['code'] ?? '')); if ($code !== '') { $game = LotteryGame::where('code', $code)->findOrEmpty(); if ($game->isEmpty()) { return self::fail('彩种不存在'); } $recentDraws = LotteryDrawResult::where('code', $code) ->where('draw_code', '<>', '') ->order('draw_time desc, issue desc') ->limit(30) ->field('issue,draw_time,draw_code') ->select() ->toArray(); foreach ($recentDraws as &$draw) { $draw['numbers'] = $draw['draw_code'] ? explode(',', $draw['draw_code']) : []; } return [ 'success' => true, 'code' => $code, 'latest_issue' => (string) ($recentDraws[0]['issue'] ?? ''), 'category_name' => $game['name'], 'lottery_data' => [ 'lottery_type' => $code, 'lottery_name' => $game['name'], 'draw_rule' => $game['template'] ?? '', 'recent_draws' => $recentDraws, ], ]; } if ($categoryId <= 0) { return self::fail('参数缺失'); } $category = LotteryCategory::where('id', $categoryId)->findOrEmpty(); if ($category->isEmpty()) { return self::fail('彩种不存在'); } $recentDraws = LotteryDraw::where('category_id', $categoryId) ->where('status', 1) ->where('is_show', 1) ->order('draw_date desc, period desc') ->limit(30) ->field('period,draw_date,numbers,special_number,zodiac,color') ->select() ->toArray(); foreach ($recentDraws as &$draw) { $draw['numbers'] = is_string($draw['numbers']) ? json_decode($draw['numbers'], true) : $draw['numbers']; $draw['zodiac'] = is_string($draw['zodiac']) ? json_decode($draw['zodiac'], true) : $draw['zodiac']; $draw['color'] = is_string($draw['color']) ? json_decode($draw['color'], true) : $draw['color']; } return [ 'success' => true, 'code' => (string) $category['code'], 'latest_issue' => (string) ($recentDraws[0]['period'] ?? ''), 'category_name' => $category['name'], 'lottery_data' => [ 'lottery_type' => $category['code'], 'lottery_name' => $category['name'], 'draw_rule' => $category['draw_rule'], 'recent_draws' => $recentDraws, ], ]; } private static function buildLotteryModuleData(string $code, string $module): array { $game = LotteryGame::where('code', $code)->findOrEmpty(); if ($game->isEmpty()) { return self::fail('彩种不存在'); } if ($module === 'color_zodiac' && ($game['template'] ?? '') !== 'HK6') { return self::fail('该彩种不支持波色生肖分析'); } $recentDraws = self::getRecentDrawCodes($code, 30); $lotteryData = [ 'lottery_type' => $code, 'lottery_name' => $game['name'], 'draw_rule' => $game['template'] ?? '', 'recent_draws' => $recentDraws, ]; if ($module === 'color_zodiac') { $year = (int) date('Y'); $lotteryData['number_mapping'] = [ 'color' => LotteryNumberMapping::getMapByType($year, LotteryNumberMapping::TYPE_COLOR), 'zodiac' => LotteryNumberMapping::getMapByType($year, LotteryNumberMapping::TYPE_ZODIAC), ]; } return [ 'success' => true, 'latest_issue' => (string) ($recentDraws[0]['issue'] ?? ''), 'lottery_data' => $lotteryData, ]; } private static function getRecentDrawCodes(string $code, int $limit = 30): array { $rows = LotteryDrawResult::where('code', $code) ->where('draw_code', '<>', '') ->order('draw_time desc, issue desc') ->limit($limit) ->field('issue,draw_time,draw_code') ->select() ->toArray(); foreach ($rows as &$row) { $row['numbers'] = $row['draw_code'] ? array_map('intval', explode(',', $row['draw_code'])) : []; unset($row['draw_code']); } return $rows; } private static function ensureQuota(int $userId, string $analysisType, $refId, int $dbRefId): array { $analysisKey = AiQuotaService::makeKey($analysisType, $refId); $quota = AiQuotaService::unlock($userId, $analysisType, $analysisKey, $dbRefId); if (empty($quota['ok'])) { return self::fail((string) ($quota['msg'] ?? '今日免费次数已用完'), (int) ($quota['code'] ?? 0), $quota); } return ['success' => true, 'quota' => $quota]; } private static function withQuota(array $payload, array $quota): array { return array_merge(['success' => true], $payload, [ 'remain_count' => $quota['remain'] ?? 0, 'unlocked' => $quota['unlocked'] ?? true, 'charged' => $quota['charged'] ?? false, ]); } private static function fail(string $error, int $code = 0, array $quota = []): array { $result = ['success' => false, 'error' => $error, 'code' => $code]; if (array_key_exists('remain', $quota)) { $result['remain_count'] = $quota['remain']; } return $result; } private static function lotteryQuotaRef(string $code, string $latestIssue): string { return $latestIssue !== '' ? $code . ':' . $latestIssue : $code; } private static function crcRefId(string $value): int { return (int) sprintf('%u', crc32($value)); } private static function getPostTags(int $postId): array { $tagIds = Db::name('community_post_tag')->where('post_id', $postId)->column('tag_id'); if (empty($tagIds)) { return []; } return array_values(CommunityTag::whereIn('id', $tagIds)->column('name')); } private static function isTrumpPost(array $tags): bool { return in_array('特朗普', $tags, true); } private static function isLiuhePost(array $tags, array $ext = []): bool { foreach (self::LIUHE_TAGS as $tag) { if (in_array($tag, $tags, true)) { return true; } } $lotteryKey = (string) ($ext['lottery_key'] ?? ''); return in_array($lotteryKey, ['a6', 'xa6'], true); } private static function localizeLiuhePostImagesForAi(CommunityPost $post, array $images): array { $localizedImages = []; $changed = false; foreach ($images as $image) { $image = trim((string) $image); if ($image === '') { continue; } if (self::isLocalUploadImage($image)) { $localUrl = self::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 = self::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 static 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 static function normalizeLocalUploadImageUrl(string $image): string { if (preg_match('/^https?:\/\//i', $image)) { return $image; } return FileService::getFileUrl(ltrim($image, '/')); } private static 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 ''; } if (!is_writable($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 static 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 = self::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'] ?? ''); if ( !empty($ext['lottery_analysis_content']) && $cacheSource === 'image_only' && 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]; } $result = AiService::analyzeLotteryPostImages($post->content ?: '', $images); if (empty($result['success']) || empty($result['content'])) { $fallbackContent = self::buildLiuheImageAnalysisFallback($images, (string) ($result['error'] ?? '')); if ($fallbackContent === '') { return [ 'success' => false, 'error' => (string) ($result['error'] ?? '帖子图片分析失败'), ]; } $result = [ 'success' => true, 'content' => $fallbackContent, 'tokens' => 0, 'cost_ms' => (int) ($result['cost_ms'] ?? 0), ]; } $ext['lottery_analysis_content'] = trim((string) $result['content']); $ext['lottery_analysis_source'] = 'image_only'; $ext['lottery_analysis_images_hash'] = $imageHash; $ext['lottery_analysis_generated_at'] = time(); 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' => false]; } private static function buildLiuheImageAnalysisFallback(array $images, string $error): string { $error = trim($error); $isTimeout = $error !== '' && (stripos($error, 'timed out') !== false || stripos($error, 'timeout') !== false || stripos($error, 'cURL错误') !== false); if (!$isTimeout) { return ''; } return implode("\n", [ '图片已下载并替换为站内链接,但视觉模型在限定时间内未返回完整识别结果。', '当前可确认信息:帖子包含 ' . count($images) . ' 张本地化图片,可在原帖图片区域查看。', '识别状态:图片文字、号码、生肖、波色等细项暂未可靠识别。', '处理建议:以下分析只能结合帖子文案和图片展示场景进行保守解读,不能视为开奖结果或中奖承诺。', ]); } }