760 lines
28 KiB
PHP
760 lines
28 KiB
PHP
<?php
|
|
|
|
namespace app\common\service\ai;
|
|
|
|
use app\common\enum\YesNoEnum;
|
|
use app\common\model\article\Article;
|
|
use app\common\model\community\CommunityPost;
|
|
use app\common\model\community\CommunityTag;
|
|
use app\common\model\lottery\LotteryCategory;
|
|
use app\common\model\lottery\LotteryDraw;
|
|
use app\common\model\lottery\LotteryDrawResult;
|
|
use app\common\model\lottery\LotteryGame;
|
|
use app\common\model\lottery\LotteryNumberMapping;
|
|
use app\common\model\match\MatchEvent;
|
|
use app\common\model\match\MatchHistory;
|
|
use app\common\model\match\MatchRecent;
|
|
use app\common\service\FileService;
|
|
use think\facade\Db;
|
|
|
|
class AiAnalysisRequestService
|
|
{
|
|
private const LIUHE_TAGS = ['旧澳六合', '新澳六合'];
|
|
private const MATCH_SECTIONS = ['prediction', 'factors', 'odds', 'summary'];
|
|
private const LOTTERY_MODULES = ['hot_cold', 'trend', 'stats', 'color_zodiac'];
|
|
|
|
public static function analyze(string $type, array $params, int $userId): array
|
|
{
|
|
$type = strtolower(str_replace('-', '_', trim($type)));
|
|
|
|
return match ($type) {
|
|
'match', 'match_section' => 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) . ' 张本地化图片,可在原帖图片区域查看。',
|
|
'识别状态:图片文字、号码、生肖、波色等细项暂未可靠识别。',
|
|
'处理建议:以下分析只能结合帖子文案和图片展示场景进行保守解读,不能视为开奖结果或中奖承诺。',
|
|
]);
|
|
}
|
|
}
|