From 7fff1a994405f053dc6cd34287a5a15353bfd659 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Apr 2026 18:51:27 +0800 Subject: [PATCH] restore admin helper chain and stabilize seo routes --- code/app/admin/config/router.php | 4 +- code/app/admin/controller/Site.php | 11 +- .../common/helper/DomainAutoSampleHelper.php | 355 ++++++++++++++++++ .../helper/DomainImportAsyncJobHelper.php | 183 +++++++++ .../DomainImportFailureRemediationHelper.php | 85 +++++ ...DomainImportHealthWorkbenchIndexHelper.php | 115 ++++++ .../DomainImportHealthWorkbenchRunHelper.php | 87 +++++ ...DomainImportHealthWorkbenchTrendHelper.php | 87 +++++ ...inImportManualAttentionHandleRunHelper.php | 70 ++++ .../DomainImportRemediationRunHelper.php | 70 ++++ .../helper/DomainImportReportViewHelper.php | 212 +++++++++++ .../helper/DomainImportRerunRunHelper.php | 72 ++++ .../DomainImportSelfHealingPolicyHelper.php | 202 ++++++++++ .../DomainImportSelfHealingRunHelper.php | 60 +++ .../common/helper/DomainSpiderMdRunHelper.php | 5 +- .../helper/DomainTrajectoryProbeHelper.php | 224 +++++++++++ code/app/common/helper/SiteStyle.php | 8 +- code/app/home/config/router.php | 129 +++++++ .../videoGpt1/module/detail_main/action.html | 2 +- .../view/videoGpt1/video/getVideoPlayUrl.html | 2 +- code/app/model/SeoExternalSnapshotModel.php | 11 + code/app/services/VideoService.php | 265 +++++++++++-- ...000_create_seo_external_snapshot_table.php | 49 +++ ...optimize_seo_external_snapshot_indexes.php | 26 ++ 24 files changed, 2293 insertions(+), 41 deletions(-) create mode 100644 code/app/common/helper/DomainAutoSampleHelper.php create mode 100644 code/app/common/helper/DomainImportAsyncJobHelper.php create mode 100644 code/app/common/helper/DomainImportFailureRemediationHelper.php create mode 100644 code/app/common/helper/DomainImportHealthWorkbenchIndexHelper.php create mode 100644 code/app/common/helper/DomainImportHealthWorkbenchRunHelper.php create mode 100644 code/app/common/helper/DomainImportHealthWorkbenchTrendHelper.php create mode 100644 code/app/common/helper/DomainImportManualAttentionHandleRunHelper.php create mode 100644 code/app/common/helper/DomainImportRemediationRunHelper.php create mode 100644 code/app/common/helper/DomainImportReportViewHelper.php create mode 100644 code/app/common/helper/DomainImportRerunRunHelper.php create mode 100644 code/app/common/helper/DomainImportSelfHealingPolicyHelper.php create mode 100644 code/app/common/helper/DomainImportSelfHealingRunHelper.php create mode 100644 code/app/common/helper/DomainTrajectoryProbeHelper.php create mode 100644 code/app/model/SeoExternalSnapshotModel.php create mode 100644 code/database/migrations/20260410010000_create_seo_external_snapshot_table.php create mode 100644 code/database/migrations/20260410020000_optimize_seo_external_snapshot_indexes.php diff --git a/code/app/admin/config/router.php b/code/app/admin/config/router.php index 7fd1a7e..1af87a7 100644 --- a/code/app/admin/config/router.php +++ b/code/app/admin/config/router.php @@ -157,9 +157,11 @@ Route::group("/site", function () { Route::get("/bootstrap/workbench/summary", [Site::class, "getBootstrapWorkbenchSummary"])->name("Site@getBootstrapWorkbenchSummary"); Route::post("/bootstrap/env/template/preview", [Site::class, "previewBootstrapEnvTemplate"])->name("Site@previewBootstrapEnvTemplate"); Route::post("/bootstrap/env/template/apply", [Site::class, "applyBootstrapEnvTemplate"])->name("Site@applyBootstrapEnvTemplate"); + Route::post("/bootstrap/env/template/history/restore/preview", [Site::class, "previewBootstrapEnvHistoryRestore"])->name("Site@previewBootstrapEnvHistoryRestore"); + Route::post("/bootstrap/env/template/history/restore/apply", [Site::class, "applyBootstrapEnvHistoryRestore"])->name("Site@applyBootstrapEnvHistoryRestore"); # TKD 参数模板 - Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList"); + Route::get("/tkdarg/list", [Site::class, "getTKDArgList"])->name("Site@getTKDArgList"); # 视图模板 Route::get("/template/list", [Site::class, "getTemplateList"])->name("Site@getTKDArgList"); diff --git a/code/app/admin/controller/Site.php b/code/app/admin/controller/Site.php index 72caca9..278cdec 100644 --- a/code/app/admin/controller/Site.php +++ b/code/app/admin/controller/Site.php @@ -7,11 +7,14 @@ namespace app\admin\controller; use app\admin\BaseController; use app\common\helper\DomainBatchImportTemplateHelper; use app\common\helper\DomainImportProbeRunHelper; +use app\common\helper\DomainImportAsyncJobHelper; use app\common\helper\DomainImportFailedQueueHelper; use app\common\helper\DomainSpiderMdRunHelper; use app\common\helper\DomainImportHealthTrendHelper; use app\common\helper\DomainImportRunIndexHelper; use app\common\helper\DomainImportHealthWorkbenchHelper; +use app\common\helper\DomainImportHealthWorkbenchIndexHelper; +use app\common\helper\DomainImportHealthWorkbenchTrendHelper; use app\common\helper\DomainImportExternalSeoClosureHelper; use app\common\helper\DomainImportExternalSeoClosureActionHelper; use app\common\helper\DomainImportExternalSeoClosureActionIndexHelper; @@ -22,6 +25,8 @@ use app\common\helper\DomainImportManualAttentionHandleIndexHelper; use app\common\helper\DomainImportSelfHealingIndexHelper; use app\common\helper\DomainImportRemediationIndexHelper; use app\common\helper\DomainImportRerunIndexHelper; +use app\common\helper\DomainImportRerunRunHelper; +use app\common\helper\DomainAutoSampleHelper; use app\common\helper\DomainExternalSeoSummaryHelper; use app\common\helper\DomainExternalSeoFailedQueueHelper; use app\common\helper\DomainExternalSeoTrendHelper; @@ -59,6 +64,7 @@ use app\common\helper\DomainSupplyRunIndexHelper; use app\common\helper\DomainSupplyBatchTemplateHelper; use app\common\helper\DomainBootstrapEnvTemplateHelper; use app\common\helper\DomainSpiderCrawlWorkbenchHelper; +use app\common\helper\DomainTrajectoryProbeHelper; use app\common\helper\SeoCopyAiProviderHelper; use app\common\helper\SeoCopyGenerationHelper; use app\common\helper\SeoCopyPortalHomeHelper; @@ -67,8 +73,9 @@ use app\common\helper\SeoCopyStore; use app\common\helper\SpiderMdConfigHelper; use app\model\AdminUserModel; use app\model\ConverterMovel; -use app\model\DomainModel; -use app\model\SubjectFomartGroupModel; +use app\model\DomainModel; +use app\model\PlanTaskModel; +use app\model\SubjectFomartGroupModel; use app\model\SubjectFomartModel; use app\model\TemplatesModel; use app\Request; diff --git a/code/app/common/helper/DomainAutoSampleHelper.php b/code/app/common/helper/DomainAutoSampleHelper.php new file mode 100644 index 0000000..db0afca --- /dev/null +++ b/code/app/common/helper/DomainAutoSampleHelper.php @@ -0,0 +1,355 @@ +initialize(); + self::$boolInitialized = true; + } + + public static function discover(string $strHost, array $arrOptions = []): array + { + self::ensureAppInitialized(); + + $strHost = DomainModel::normalizeHost($strHost); + if ($strHost === '') { + return [ + 'host' => '', + 'status' => 'invalid_host', + 'message' => 'Host empty.', + 'sample_source' => 'video_auto_discovery', + 'checked_candidates' => 0, + 'sample' => null, + ]; + } + + $intLimit = max(5, min(200, (int)($arrOptions['limit'] ?? 80))); + $intPreferredVideoId = max(0, (int)($arrOptions['preferred_video_id'] ?? 0)); + $DomainRow = self::findDomainRow($strHost); + $arrCandidates = self::fetchCandidateVideos($intLimit, $intPreferredVideoId); + + $intChecked = 0; + foreach ($arrCandidates as $arrVideo) { + $intChecked++; + $arrSample = self::buildSampleFromVideo($strHost, $DomainRow, $arrVideo); + if ($arrSample === null) { + continue; + } + + return [ + 'host' => $strHost, + 'status' => 'passed', + 'message' => 'Auto sample discovered from video library.', + 'sample_source' => 'video_auto_discovery', + 'checked_candidates' => $intChecked, + 'sample' => $arrSample, + ]; + } + + return [ + 'host' => $strHost, + 'status' => 'failed_no_candidate', + 'message' => 'No valid default sample could be discovered from video library.', + 'sample_source' => 'video_auto_discovery', + 'checked_candidates' => $intChecked, + 'sample' => null, + ]; + } + + protected static function findDomainRow(string $strHost): ?DomainModel + { + $strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT); + $DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find(); + + return $DomainRow instanceof DomainModel ? $DomainRow : null; + } + + protected static function fetchCandidateVideos(int $intLimit, int $intPreferredVideoId = 0): array + { + $VideoCollection = VideoModel::getInstance()->getCol(); + $arrProjection = [ + '_id' => 0, + 'v_id' => 1, + 'v_name' => 1, + 'v_name_en' => 1, + 'v_seo_words' => 1, + 'v_play_url' => 1, + 'v_parent_category' => 1, + 'v_parent_category_en' => 1, + 'v_category' => 1, + 'v_category_en' => 1, + ]; + + $arrCandidates = []; + if ($intPreferredVideoId > 0) { + $arrPreferred = $VideoCollection->findOne(['v_id' => $intPreferredVideoId], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => $arrProjection, + ]); + if (is_array($arrPreferred) && !empty($arrPreferred)) { + $arrCandidates[] = $arrPreferred; + } + } + + $Cursor = $VideoCollection->find([], [ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ], + 'projection' => $arrProjection, + 'sort' => ['v_id' => -1], + 'limit' => $intLimit, + ]); + + foreach (iterator_to_array($Cursor) as $arrVideo) { + if (!is_array($arrVideo)) { + continue; + } + if ($intPreferredVideoId > 0 && (int)($arrVideo['v_id'] ?? 0) === $intPreferredVideoId) { + continue; + } + $arrCandidates[] = $arrVideo; + } + + return $arrCandidates; + } + + protected static function buildSampleFromVideo(string $strHost, ?DomainModel $DomainRow, array $arrVideo): ?array + { + $intVideoId = (int)($arrVideo['v_id'] ?? 0); + $strVideoName = trim((string)($arrVideo['v_name'] ?? '')); + $strVideoSlug = trim((string)($arrVideo['v_name_en'] ?? '')); + + if ($intVideoId <= 0 || $strVideoName === '' || $strVideoSlug === '') { + return null; + } + + $arrPlay = self::resolvePlaySample($arrVideo); + if ($arrPlay === null) { + return null; + } + + $arrCategory = self::resolveCategorySample($arrVideo); + if ($arrCategory === null) { + return null; + } + + $strKeyword = self::resolveSearchKeyword($DomainRow, $arrVideo); + if ($strKeyword === '') { + return null; + } + + $arrResult = [ + 'host' => $strHost, + 'video' => [ + 'v_id' => $intVideoId, + 'v_name' => $strVideoName, + 'v_name_en' => $strVideoSlug, + ], + 'detail' => [ + 'detail_id' => $intVideoId, + 'detail_slug' => $strVideoSlug, + ], + 'search' => [ + 'search_keyword' => $strKeyword, + ], + 'category' => $arrCategory, + 'play' => $arrPlay, + 'runtime_acceptance' => [ + 'samples' => [ + [ + 'video_id' => $intVideoId, + 'forge_id' => 1, + ], + ], + ], + ]; + + $arrUrls = self::buildUrls($strHost, $DomainRow, $arrResult); + if (!empty($arrUrls)) { + $arrResult['urls'] = $arrUrls; + } + + return $arrResult; + } + + protected static function resolvePlaySample(array $arrVideo): ?array + { + $arrPlayGroups = is_array($arrVideo['v_play_url'] ?? null) ? (array)($arrVideo['v_play_url'] ?? []) : []; + foreach ($arrPlayGroups as $strPlayType => $arrEpisodes) { + $strPlayType = trim((string)$strPlayType); + if ($strPlayType === '' || !is_array($arrEpisodes) || empty($arrEpisodes)) { + continue; + } + + $arrEpisode = (array)($arrEpisodes[0] ?? []); + $strEpisodeName = trim((string)($arrEpisode['name'] ?? '')); + if ($strEpisodeName === '') { + $strEpisodeName = '第1集'; + } + + return [ + 'play_type' => $strPlayType, + 'play_index' => 1, + 'episode_name' => $strEpisodeName, + ]; + } + + return null; + } + + protected static function resolveCategorySample(array $arrVideo): ?array + { + $strParentSlug = trim((string)($arrVideo['v_parent_category_en'] ?? '')); + $strCategorySlug = trim((string)($arrVideo['v_category_en'] ?? '')); + $strParentName = trim((string)($arrVideo['v_parent_category'] ?? '')); + $strCategoryName = trim((string)($arrVideo['v_category'] ?? '')); + + if ($strParentSlug === '' && $strParentName !== '') { + $strParentSlug = self::findCategorySlugByName($strParentName); + } + if ($strCategorySlug === '' && $strCategoryName !== '') { + $strCategorySlug = self::findCategorySlugByName($strCategoryName); + } + + if ($strParentSlug === '' || $strCategorySlug === '') { + return null; + } + + return [ + 'category_parent' => $strParentSlug, + 'category_child' => $strCategorySlug, + ]; + } + + protected static function resolveSearchKeyword(?DomainModel $DomainRow, array $arrVideo): string + { + $arrKeywordCandidates = []; + + $strDomainKeyword = trim((string)($DomainRow?->d_name ?? '')); + if ($strDomainKeyword !== '') { + $arrKeywordCandidates[] = $strDomainKeyword; + } + + $arrSeoWordValues = self::normalizeKeywordSource($arrVideo['v_seo_words'] ?? ''); + foreach ($arrSeoWordValues as $strSeoWords) { + foreach (preg_split('/[,\x{3001}\x{ff0c}\s]+/u', $strSeoWords) as $strWord) { + $strWord = trim((string)$strWord); + if ($strWord !== '') { + $arrKeywordCandidates[] = $strWord; + } + } + } + + $strVideoName = trim((string)($arrVideo['v_name'] ?? '')); + if ($strVideoName !== '') { + $arrKeywordCandidates[] = $strVideoName; + } + + foreach ($arrKeywordCandidates as $strKeyword) { + $strKeyword = trim($strKeyword); + if ($strKeyword !== '') { + return mb_substr($strKeyword, 0, 20); + } + } + + return ''; + } + + protected static function normalizeKeywordSource(mixed $value): array + { + if (is_string($value) || is_numeric($value)) { + $strValue = trim((string)$value); + return $strValue === '' ? [] : [$strValue]; + } + + if (!is_array($value)) { + return []; + } + + $arrValues = []; + array_walk_recursive($value, static function ($item) use (&$arrValues): void { + if (is_string($item) || is_numeric($item)) { + $strItem = trim((string)$item); + if ($strItem !== '') { + $arrValues[] = $strItem; + } + } + }); + + return $arrValues; + } + + protected static function buildUrls(string $strHost, ?DomainModel $DomainRow, array $arrSample): array + { + if (!$DomainRow instanceof DomainModel) { + return []; + } + + $TpStyle = SiteStyle::getConfig($DomainRow, $strHost); + $UrlBuilder = new UrlBuilder($TpStyle); + + $strSlug = (string)($arrSample['detail']['detail_slug'] ?? ''); + $intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0); + $strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? ''); + $strCategoryParent = (string)($arrSample['category']['category_parent'] ?? ''); + $strCategoryChild = (string)($arrSample['category']['category_child'] ?? ''); + $strPlayType = (string)($arrSample['play']['play_type'] ?? ''); + $intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1)); + + $arrUrls = [ + 'home' => $UrlBuilder->home(), + ]; + + if ($strSearchKeyword !== '') { + $arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword); + } + if ($strCategoryParent !== '' && $strCategoryChild !== '') { + $arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1); + } + if ($strSlug !== '' && $intVideoId > 0) { + $arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId); + } + if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') { + $arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex); + } + + return $arrUrls; + } + + protected static function findCategorySlugByName(string $strName): string + { + $strName = trim($strName); + if ($strName === '') { + return ''; + } + + $arrCategory = app(VideoCategoryModel::class) + ->where('vc_name', $strName) + ->field(['vc_name_en']) + ->find(); + + return trim((string)($arrCategory['vc_name_en'] ?? '')); + } +} diff --git a/code/app/common/helper/DomainImportAsyncJobHelper.php b/code/app/common/helper/DomainImportAsyncJobHelper.php new file mode 100644 index 0000000..68d9e00 --- /dev/null +++ b/code/app/common/helper/DomainImportAsyncJobHelper.php @@ -0,0 +1,183 @@ + $jobId, + 'type' => $type, + 'label' => self::labelForType($type), + 'status' => self::STATUS_QUEUED, + 'progress_percent' => 0, + 'progress_current' => 0, + 'progress_total' => 0, + 'current_step' => 'queued', + 'message' => '任务已入队,等待执行', + 'payload' => $payload, + 'meta' => $meta, + 'summary' => [], + 'created_at' => date(DATE_ATOM), + 'updated_at' => date(DATE_ATOM), + 'started_at' => '', + 'finished_at' => '', + 'job_root' => $jobRoot, + 'state_path' => self::statePath($jobRoot), + 'log_path' => self::logPath($jobRoot), + ]; + + file_put_contents($job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + file_put_contents($job['log_path'], '[' . date('Y-m-d H:i:s') . "] 已入队 {$job['label']}" . PHP_EOL); + + return $job; + } + + public static function readJob(string $jobId): array + { + $jobRoot = self::findJobRoot($jobId); + if ($jobRoot === '') { + return []; + } + + $statePath = self::statePath($jobRoot); + if (!is_file($statePath)) { + return []; + } + + $job = json_decode((string)file_get_contents($statePath), true); + return is_array($job) ? $job : []; + } + + public static function updateJob(string $jobId, array $patch): array + { + $job = self::readJob($jobId); + if (empty($job)) { + throw new \RuntimeException('Job not found: ' . $jobId); + } + + $job = array_merge($job, $patch); + $job['updated_at'] = date(DATE_ATOM); + + if (($patch['status'] ?? '') === self::STATUS_RUNNING && empty($job['started_at'])) { + $job['started_at'] = date(DATE_ATOM); + } + if (in_array((string)($patch['status'] ?? ''), [self::STATUS_SUCCESS, self::STATUS_FAILED], true)) { + $job['finished_at'] = date(DATE_ATOM); + $job['progress_percent'] = 100; + } + + file_put_contents((string)$job['state_path'], json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); + return $job; + } + + public static function appendLog(string $jobId, string $message): void + { + $job = self::readJob($jobId); + if (empty($job)) { + return; + } + $line = '[' . date('Y-m-d H:i:s') . '] ' . trim($message) . PHP_EOL; + file_put_contents((string)$job['log_path'], $line, FILE_APPEND); + self::updateJob($jobId, ['last_log' => trim($message)]); + } + + public static function updateProgress(string $jobId, int $current, int $total, string $step = '', string $message = ''): array + { + $percent = $total > 0 ? (int)floor(($current / $total) * 100) : 0; + return self::updateJob($jobId, [ + 'progress_current' => $current, + 'progress_total' => $total, + 'progress_percent' => max(0, min(100, $percent)), + 'current_step' => $step !== '' ? $step : 'running', + 'message' => $message !== '' ? $message : (($step !== '' ? $step : 'running') . " {$current}/{$total}"), + ]); + } + + public static function readLogTail(string $jobId, int $lines = 120): array + { + $job = self::readJob($jobId); + if (empty($job) || empty($job['log_path']) || !is_file((string)$job['log_path'])) { + return []; + } + + $content = trim((string)file_get_contents((string)$job['log_path'])); + if ($content === '') { + return []; + } + + $allLines = preg_split('/\r\n|\r|\n/', $content); + return array_values(array_slice(is_array($allLines) ? $allLines : [], -1 * max(1, $lines))); + } + + public static function labelForType(string $type): string + { + $map = [ + 'failed_queue_rerun' => '失败队列重跑', + 'failure_remediation' => '失败补料候选', + 'self_healing' => '自动修复', + 'health_workbench_refresh' => '健康台落盘', + 'spider_md_generate' => '蜘蛛池MD生成', + 'seo_copy_ai_generate' => 'AI文案生成', + 'seo_copy_ai_optimize' => 'AI文案重优化', + 'seo_copy_ai_rollback' => 'AI文案回滚', + ]; + + return (string)($map[$type] ?? $type); + } + + public static function baseRoot(): string + { + return dirname(__DIR__, 3) . '/storage/domain_import_async_jobs'; + } + + public static function ensureDir(string $dir): void + { + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + throw new \RuntimeException('Failed to create directory: ' . $dir); + } + } + + protected static function findJobRoot(string $jobId): string + { + $jobId = trim($jobId); + if ($jobId === '') { + return ''; + } + + $matches = glob(self::baseRoot() . '/*/' . $jobId); + if (!is_array($matches) || empty($matches)) { + return ''; + } + + return (string)$matches[0]; + } + + protected static function statePath(string $jobRoot): string + { + return rtrim($jobRoot, '/') . '/job.state.json'; + } + + protected static function logPath(string $jobRoot): string + { + return rtrim($jobRoot, '/') . '/job.log'; + } +} diff --git a/code/app/common/helper/DomainImportFailureRemediationHelper.php b/code/app/common/helper/DomainImportFailureRemediationHelper.php new file mode 100644 index 0000000..9ec4448 --- /dev/null +++ b/code/app/common/helper/DomainImportFailureRemediationHelper.php @@ -0,0 +1,85 @@ + 'planned', + 'kind' => 'reprobe', + 'title' => '重新探测', + 'summary' => '当前失败先按自动样本发现 + 页面弹道重探处理。', + 'recommended_command' => self::buildProbeCommand($strHost), + 'sample_command' => self::buildSampleCommand($strHost), + 'probe_command' => self::buildProbeCommand($strHost), + 'pipeline_command' => self::buildPipelineCommand($strHost, $strPrimaryStage), + 'post_action' => '执行补料候选后,再重新跑页面弹道自检。', + 'priority' => 'medium', + ]; + + if ($strHost === '') { + $arrPlan['status'] = 'invalid'; + $arrPlan['summary'] = 'host 缺失,暂时无法生成补料候选链。'; + $arrPlan['recommended_command'] = ''; + return $arrPlan; + } + + if (in_array($strPrimaryStage, ['sample', 'domain', 'host'], true)) { + $arrPlan['kind'] = 'sample_rebuild'; + $arrPlan['title'] = '重建样本并重探'; + $arrPlan['summary'] = '当前优先重建自动样本,再重新跑首页/搜索/详情/播放弹道。'; + $arrPlan['recommended_command'] = self::buildSampleCommand($strHost); + $arrPlan['post_action'] = '样本重建后继续执行页面弹道自检。'; + $arrPlan['priority'] = 'high'; + return $arrPlan; + } + + if (in_array($strPrimaryStage, ['search', 'detail', 'play', 'home', 'category'], true)) { + $arrPlan['kind'] = 'pipeline_candidate'; + $arrPlan['title'] = '补料候选链'; + $arrPlan['summary'] = '当前页面链已能定位到失败阶段,优先走 bootstrap/pipeline dry-run 候选链,再回到弹道自检。'; + $arrPlan['recommended_command'] = self::buildPipelineCommand($strHost, $strPrimaryStage); + $arrPlan['priority'] = in_array($strPrimaryStage, ['detail', 'play'], true) ? 'high' : 'medium'; + return $arrPlan; + } + + return $arrPlan; + } + + protected static function buildSampleCommand(string $strHost): string + { + return 'php scripts/domain_auto_sample_discover.php ' . escapeshellarg($strHost) . ' --format=text'; + } + + protected static function buildProbeCommand(string $strHost): string + { + return 'php scripts/domain_trajectory_probe.php ' . escapeshellarg($strHost) . ' --format=text'; + } + + protected static function buildPipelineCommand(string $strHost, string $strFailedStage): string + { + $arrArgs = [ + 'php scripts/domain_bootstrap_pipeline_run.php', + '--host=' . escapeshellarg($strHost), + '--dry-run=1', + '--step-limit=3', + '--allow-prepare=1', + '--format=text', + ]; + + if (in_array($strFailedStage, ['detail', 'play'], true)) { + $arrArgs[] = '--allow-release-dry-run=1'; + } + + return implode(' ', $arrArgs); + } +} diff --git a/code/app/common/helper/DomainImportHealthWorkbenchIndexHelper.php b/code/app/common/helper/DomainImportHealthWorkbenchIndexHelper.php new file mode 100644 index 0000000..caff55f --- /dev/null +++ b/code/app/common/helper/DomainImportHealthWorkbenchIndexHelper.php @@ -0,0 +1,115 @@ + [], + 'total' => 0, + 'latest_run' => [], + ]; + } + + $summaryFiles = array_merge( + (array)glob($runRoot . '/*/import-health-workbench.summary.json'), + (array)glob($runRoot . '/*/*/import-health-workbench.summary.json') + ); + $summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file')); + usort($summaryFiles, static function (string $left, string $right): int { + return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0)); + }); + + $items = []; + foreach (array_slice($summaryFiles, 0, max(1, $limit)) as $summaryPath) { + $data = json_decode((string)file_get_contents($summaryPath), true); + if (!is_array($data)) { + continue; + } + + $runDir = dirname($summaryPath); + $healthLabel = (string)($data['health_label'] ?? ''); + $workbenchStage = (string)($data['workbench_stage'] ?? ''); + $activeCount = (int)($data['active_count'] ?? 0); + $resolvedCount = (int)($data['resolved_count'] ?? 0); + [$alertLevel, $alertReason] = self::resolveAlert($healthLabel, $workbenchStage, $activeCount); + + $items[] = [ + 'run_id' => basename($runDir), + 'run_root_public' => self::publicRelativePath($runDir), + 'health_label' => $healthLabel, + 'workbench_stage' => $workbenchStage, + 'hero_summary' => (string)($data['hero_summary'] ?? ''), + 'active_count' => $activeCount, + 'queue_count' => (int)($data['queue_count'] ?? 0), + 'resolved_count' => $resolvedCount, + 'alert_level' => $alertLevel, + 'alert_reason' => $alertReason, + 'summary_json_path' => self::publicRelativePath($summaryPath), + 'summary_html_path' => self::publicRelativePath($runDir . '/import-health-workbench.summary.html'), + 'updated_at' => date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())), + ]; + } + + $alertBuckets = [ + 'high' => 0, + 'medium' => 0, + 'low' => 0, + 'none' => 0, + ]; + foreach ($items as $item) { + $level = (string)($item['alert_level'] ?? 'none'); + if (!isset($alertBuckets[$level])) { + $alertBuckets[$level] = 0; + } + $alertBuckets[$level]++; + } + $topAttentionRuns = array_values(array_filter($items, static function (array $item): bool { + return in_array((string)($item['alert_level'] ?? 'none'), ['high', 'medium'], true); + })); + usort($topAttentionRuns, static function (array $left, array $right): int { + $priority = ['high' => 3, 'medium' => 2, 'low' => 1, 'none' => 0]; + return ($priority[(string)($right['alert_level'] ?? 'none')] ?? 0) <=> ($priority[(string)($left['alert_level'] ?? 'none')] ?? 0); + }); + + return [ + 'items' => $items, + 'total' => count($items), + 'latest_run' => $items[0] ?? [], + 'alert_buckets' => $alertBuckets, + 'top_attention_runs' => array_slice($topAttentionRuns, 0, 5), + ]; + } + + protected static function resolveAlert(string $healthLabel, string $workbenchStage, int $activeCount): array + { + if ($activeCount > 0 && $healthLabel === 'worsening') { + return ['high', '当前失败仍存在且趋势上升']; + } + if ($activeCount > 0 && in_array($workbenchStage, ['needs_attention', 'needs_self_healing'], true)) { + return ['medium', '当前失败仍需要自动修复或重点关注']; + } + if ($activeCount === 0 && $workbenchStage === 'observe_recovery') { + return ['low', '当前失败已清空,继续观察恢复稳定性']; + } + + return ['none', '当前导入主线平稳']; + } +} diff --git a/code/app/common/helper/DomainImportHealthWorkbenchRunHelper.php b/code/app/common/helper/DomainImportHealthWorkbenchRunHelper.php new file mode 100644 index 0000000..977ab90 --- /dev/null +++ b/code/app/common/helper/DomainImportHealthWorkbenchRunHelper.php @@ -0,0 +1,87 @@ +-'; + } + $html = ''; + foreach ($items as $item) { + $html .= '
  • ' . $formatter($item) . '
  • '; + } + return $html; + }; + + return '导入健康台摘要' + . '' + . '' + . '

    导入健康台摘要

    ' + . '

    生成时间:' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($summary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    健康状态:' . htmlspecialchars(DomainImportReportViewHelper::translateHealthLabel((string)($summary['health_label'] ?? '')), ENT_QUOTES, 'UTF-8') . ' / 当前阶段:' . htmlspecialchars(DomainImportReportViewHelper::translateStage((string)($summary['workbench_stage'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    摘要:' . htmlspecialchars((string)($summary['hero_summary'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    优先动作

    ' + . '

    主要失败阶段

    ' + . '

    恢复路径

    ' + . '

    主路径:' . htmlspecialchars(DomainImportReportViewHelper::translatePrimaryPath((string)($recoveryMix['primary_path'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    已恢复总数:' . (int)($recoveryMix['resolved_total'] ?? 0) . ' / 重跑恢复:' . (int)($recoveryMix['resolved_by_rerun_count'] ?? 0) . ' / 补料恢复:' . (int)($recoveryMix['resolved_by_remediation_count'] ?? 0) . '

    ' + . ''; + } +} diff --git a/code/app/common/helper/DomainImportHealthWorkbenchTrendHelper.php b/code/app/common/helper/DomainImportHealthWorkbenchTrendHelper.php new file mode 100644 index 0000000..cd68fab --- /dev/null +++ b/code/app/common/helper/DomainImportHealthWorkbenchTrendHelper.php @@ -0,0 +1,87 @@ + $previousActiveCount; + $label = $latestActiveCount === $previousActiveCount + ? 'flat' + : ($latestActiveCount < $previousActiveCount ? 'improving' : 'worsening'); + + $stageBuckets = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $stage = trim((string)($item['workbench_stage'] ?? '')); + if ($stage === '') { + $stage = 'unknown'; + } + if (!isset($stageBuckets[$stage])) { + $stageBuckets[$stage] = 0; + } + $stageBuckets[$stage]++; + } + arsort($stageBuckets); + + $bucketItems = []; + foreach (array_slice($stageBuckets, 0, 5, true) as $stage => $count) { + $bucketItems[] = [ + 'stage' => $stage, + 'count' => (int)$count, + ]; + } + + [$alertLevel, $alertReason] = self::resolveAlert( + $label, + (string)($latestRun['workbench_stage'] ?? ''), + $latestActiveCount, + $previousActiveCount + ); + + return [ + 'generated_at' => date(DATE_ATOM), + 'runs_count' => (int)($indexSummary['total'] ?? 0), + 'latest_run' => $latestRun, + 'previous_run' => $previousRun, + 'latest_active_count' => $latestActiveCount, + 'previous_active_count' => $previousActiveCount, + 'latest_health_label' => (string)($latestRun['health_label'] ?? ''), + 'previous_health_label' => (string)($previousRun['health_label'] ?? ''), + 'latest_stage' => (string)($latestRun['workbench_stage'] ?? ''), + 'previous_stage' => (string)($previousRun['workbench_stage'] ?? ''), + 'direction' => $direction, + 'label' => $label, + 'alert_level' => $alertLevel, + 'alert_reason' => $alertReason, + 'stage_buckets' => $bucketItems, + ]; + } + + protected static function resolveAlert(string $label, string $latestStage, int $latestActiveCount, int $previousActiveCount): array + { + if ($label === 'worsening' && $latestActiveCount > 0) { + return ['high', '健康台趋势上升且当前仍有失败 host']; + } + if (in_array($latestStage, ['needs_attention', 'needs_self_healing'], true) && $latestActiveCount > 0) { + return ['medium', '当前趋势仍需重点观察自动修复是否接管']; + } + if ($label === 'flat' && $latestActiveCount === 0 && $previousActiveCount === 0) { + return ['low', '当前趋势持平且失败队列已清空,继续观察恢复稳定性']; + } + + return ['none', '当前健康台趋势平稳']; + } +} diff --git a/code/app/common/helper/DomainImportManualAttentionHandleRunHelper.php b/code/app/common/helper/DomainImportManualAttentionHandleRunHelper.php new file mode 100644 index 0000000..b1fd760 --- /dev/null +++ b/code/app/common/helper/DomainImportManualAttentionHandleRunHelper.php @@ -0,0 +1,70 @@ +' + . '' . htmlspecialchars((string)($item['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['action'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['reprobe_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return 'Domain Import Manual Attention Summary' + . '' + . '' + . '

    Domain Import Manual Attention Summary

    ' + . '

    generated_at: ' . htmlspecialchars((string)($summary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    processed: ' . (int)($summary['processed_count'] ?? 0) . ' / resolved: ' . (int)($summary['resolved_count'] ?? 0) . ' / deferred: ' . (int)($summary['deferred_count'] ?? 0) . ' / ignored: ' . (int)($summary['ignored_count'] ?? 0) . ' / failed: ' . (int)($summary['failed_count'] ?? 0) . '

    ' + . '' + . implode('', $rows) + . '
    hostactionstatusreprobe_statusmessage
    '; + } +} diff --git a/code/app/common/helper/DomainImportRemediationRunHelper.php b/code/app/common/helper/DomainImportRemediationRunHelper.php new file mode 100644 index 0000000..cb2a96a --- /dev/null +++ b/code/app/common/helper/DomainImportRemediationRunHelper.php @@ -0,0 +1,70 @@ +' + . '' . htmlspecialchars((string)($arrItem['host'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['remediation_kind'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['remediation_status'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['failed_stage'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars((string)($arrItem['message'] ?? ''), ENT_QUOTES, 'UTF-8') . '' + . ''; + } + + return 'Domain Import Remediation Summary' + . '' + . '' + . '

    Domain Import Remediation Summary

    ' + . '

    generated_at: ' . htmlspecialchars((string)($arrSummary['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    processed: ' . (int)($arrSummary['processed_count'] ?? 0) . ' / passed: ' . (int)($arrSummary['passed_count'] ?? 0) . ' / failed: ' . (int)($arrSummary['failed_count'] ?? 0) . ' / recovered: ' . (int)($arrSummary['recovered_count'] ?? 0) . '

    ' + . '' + . implode('', $arrRows) + . '
    hoststatusremediation_kindremediation_statusfailed_stagemessage
    '; + } +} diff --git a/code/app/common/helper/DomainImportReportViewHelper.php b/code/app/common/helper/DomainImportReportViewHelper.php new file mode 100644 index 0000000..e8b923b --- /dev/null +++ b/code/app/common/helper/DomainImportReportViewHelper.php @@ -0,0 +1,212 @@ + '已排队', + 'running' => '执行中', + 'success' => '成功', + 'failed' => '失败', + 'passed' => '通过', + 'partial' => '部分通过', + 'sample_discovery_failed' => '样本发现失败', + 'pending' => '待处理', + 'observing' => '观察中', + 'done' => '已完成', + 'ignored' => '已忽略', + 'imported' => '已导入', + ]; + + return $map[$status] ?? $status; + } + + public static function translateStage(string $stage): string + { + $stage = trim($stage); + if ($stage === '') { + return '-'; + } + + $map = [ + 'home' => '首页', + 'search' => '搜索页', + 'detail' => '详情页', + 'play' => '播放页', + 'sample' => '样本发现', + 'idle' => '未开始', + 'steady' => '稳定', + 'observe_recovery' => '恢复观察', + 'needs_attention' => '需要关注', + 'needs_self_healing' => '等待自动修复', + 'needs_rerun' => '等待重跑', + 'starting' => '开始执行', + 'prepare' => '准备中', + 'sample_discovery' => '样本发现', + 'probing' => '页面探测', + 'probe_failed' => '探测失败', + 'health_workbench' => '健康台落盘', + 'completed' => '已完成', + ]; + + return $map[$stage] ?? $stage; + } + + public static function translateMessage(string $message): string + { + $message = trim($message); + if ($message === '') { + return '-'; + } + + $map = [ + 'Trajectory probe found failed stages.' => '页面链路探测发现失败阶段。', + 'Trajectory probe passed.' => '页面链路探测通过。', + 'No probe checks were produced.' => '未生成有效的探测检查结果。', + ]; + + return $map[$message] ?? $message; + } + + public static function translateCheckDetail(string $detail): string + { + $detail = trim($detail); + if ($detail === '') { + return '-'; + } + + if (preg_match('/^http_(\d+)$/', $detail, $matches)) { + return 'HTTP 响应异常 ' . $matches[1]; + } + + if ($detail === 'ok') { + return '正常'; + } + + if ($detail === 'passed') { + return '通过'; + } + + return $detail; + } + + public static function translateCheckStatus(int $status): string + { + return $status === 1 ? '通过' : '失败'; + } + + public static function translateHealthLabel(string $label): string + { + $label = trim($label); + if ($label === '') { + return '-'; + } + + $map = [ + 'steady' => '平稳', + 'attention' => '需要关注', + 'worsening' => '风险上升', + 'waiting_self_healing' => '等待自动修复', + 'growing' => '持续改善', + ]; + + return $map[$label] ?? $label; + } + + public static function translateTrendLabel(string $label): string + { + $label = trim($label); + if ($label === '') { + return '-'; + } + + $map = [ + 'flat' => '持平', + 'improving' => '改善中', + 'worsening' => '上升中', + ]; + + return $map[$label] ?? $label; + } + + public static function translatePrimaryPath(string $path): string + { + $path = trim($path); + if ($path === '') { + return '-'; + } + + $map = [ + 'none' => '暂无明显主路径', + 'rerun' => '主要靠重跑恢复', + 'remediation' => '主要靠补料恢复', + 'balanced' => '重跑与补料共同恢复', + ]; + + return $map[$path] ?? $path; + } + + public static function translateClosureStage(string $stage): string + { + $stage = trim($stage); + if ($stage === '') { + return '-'; + } + + $map = [ + 'import_not_started' => '导入未开始', + 'import_recovery_needed' => '导入恢复中', + 'manual_intervention_needed' => '需要人工介入', + 'external_waiting_data' => '等待站外数据', + 'waiting_indexing' => '等待收录', + 'indexing_without_keywords' => '已有收录待起词', + 'seo_attention' => '站外效果需关注', + 'seo_growing' => '站外效果增长中', + 'closed_loop_running' => '闭环运行中', + ]; + + return $map[$stage] ?? $stage; + } + + public static function translateResultLabel(string $label): string + { + $label = trim($label); + if ($label === '') { + return '-'; + } + + $map = [ + 'pending' => '待观察', + 'improved' => '有改善', + 'no_change' => '无明显变化', + 'regression' => '出现回退', + ]; + + return $map[$label] ?? $label; + } +} diff --git a/code/app/common/helper/DomainImportRerunRunHelper.php b/code/app/common/helper/DomainImportRerunRunHelper.php new file mode 100644 index 0000000..b7f5541 --- /dev/null +++ b/code/app/common/helper/DomainImportRerunRunHelper.php @@ -0,0 +1,72 @@ +' + . '' . $strHost . '' + . '' . $strStatus . '' + . '' . $strFailedStage . '' + . '' . $strMessage . '' + . ''; + } + + return '站点导入重跑摘要' + . '' + . '' + . '

    站点导入重跑摘要

    ' + . '

    生成时间:' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    处理总数:' . (int)($arrSummary['processed_count'] ?? 0) . ' / 通过:' . (int)($arrSummary['passed_count'] ?? 0) . ' / 失败:' . (int)($arrSummary['failed_count'] ?? 0) . '

    ' + . '' + . implode('', $arrRows) + . '
    域名状态失败阶段说明
    '; + } +} diff --git a/code/app/common/helper/DomainImportSelfHealingPolicyHelper.php b/code/app/common/helper/DomainImportSelfHealingPolicyHelper.php new file mode 100644 index 0000000..852a57c --- /dev/null +++ b/code/app/common/helper/DomainImportSelfHealingPolicyHelper.php @@ -0,0 +1,202 @@ + 0 && $lastAttemptTimestamp > 0) { + $cooldownRemaining = max(0, ($lastAttemptTimestamp + $cooldownSeconds) - time()); + } + + $policyItem = [ + 'host' => $host, + 'failed_stage' => (string)($item['failed_stage'] ?? ''), + 'updated_at' => (string)($item['updated_at'] ?? ''), + 'recent_failure_count' => $recentFailureCount, + 'last_attempt_kind' => (string)($attempt['kind'] ?? ''), + 'last_attempt_status' => (string)($attempt['status'] ?? ''), + 'last_attempt_updated_at' => $lastAttemptAt, + 'cooldown_remaining_seconds' => $cooldownRemaining, + 'policy_state' => 'eligible', + 'policy_reason' => '可进入自动修复', + ]; + + if ($recentFailureCount >= $failureThreshold) { + $policyItem['policy_state'] = 'downgraded'; + $policyItem['policy_reason'] = sprintf('最近已连续失败 %d 次,建议转人工关注', $recentFailureCount); + $downgradedItems[] = $policyItem; + continue; + } + + if ($cooldownRemaining > 0) { + $policyItem['policy_state'] = 'skipped'; + $policyItem['policy_reason'] = sprintf('距离最近一次自动修复尝试过近,剩余冷却 %d 秒', $cooldownRemaining); + $skippedItems[] = $policyItem; + continue; + } + + $eligibleItems[] = $policyItem; + } + + usort($eligibleItems, static function (array $left, array $right): int { + return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); + }); + usort($skippedItems, static function (array $left, array $right): int { + return ((int)($right['cooldown_remaining_seconds'] ?? 0)) <=> ((int)($left['cooldown_remaining_seconds'] ?? 0)); + }); + usort($downgradedItems, static function (array $left, array $right): int { + return ((int)($right['recent_failure_count'] ?? 0)) <=> ((int)($left['recent_failure_count'] ?? 0)); + }); + + $selectedHosts = array_map( + static fn (array $item): string => (string)($item['host'] ?? ''), + array_slice($eligibleItems, 0, $limit) + ); + + return [ + 'generated_at' => date(DATE_ATOM), + 'queue_count' => count($queueItems), + 'limit' => $limit, + 'cooldown_seconds' => $cooldownSeconds, + 'failure_threshold' => $failureThreshold, + 'eligible_count' => count($eligibleItems), + 'selected_count' => count($selectedHosts), + 'skipped_count' => count($skippedItems), + 'downgraded_count' => count($downgradedItems), + 'selected_hosts' => array_values(array_filter($selectedHosts)), + 'eligible_items' => $eligibleItems, + 'skipped_items' => $skippedItems, + 'downgraded_items' => $downgradedItems, + ]; + } + + protected static function buildLatestAttemptMap(string $rerunRoot, string $remediationRoot): array + { + $runs = array_merge( + self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', 20), + self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', 20) + ); + usort($runs, static function (array $left, array $right): int { + return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? '')); + }); + + $map = []; + foreach ($runs as $run) { + $host = trim((string)($run['host'] ?? '')); + if ($host === '' || isset($map[$host])) { + continue; + } + $map[$host] = $run; + } + + return $map; + } + + protected static function buildRecentFailureCountMap(string $rerunRoot, string $remediationRoot, int $maxFiles): array + { + $runs = array_merge( + self::readAttemptRuns($rerunRoot, 'import-rerun.summary.json', 'rerun', $maxFiles), + self::readAttemptRuns($remediationRoot, 'import-remediation.summary.json', 'remediation', $maxFiles) + ); + + $counts = []; + foreach ($runs as $run) { + $host = trim((string)($run['host'] ?? '')); + $status = trim((string)($run['status'] ?? '')); + if ($host === '' || $status === '' || $status === 'passed') { + continue; + } + if (!isset($counts[$host])) { + $counts[$host] = 0; + } + $counts[$host]++; + } + + return $counts; + } + + protected static function readAttemptRuns(string $baseRoot, string $summaryFileName, string $kind, int $maxFiles): array + { + $baseRoot = rtrim($baseRoot, '/'); + if ($baseRoot === '' || !is_dir($baseRoot)) { + return []; + } + + $summaryFiles = array_merge( + (array)glob($baseRoot . '/*/' . $summaryFileName), + (array)glob($baseRoot . '/*/*/' . $summaryFileName) + ); + $summaryFiles = array_values(array_filter(array_unique($summaryFiles), 'is_file')); + usort($summaryFiles, static function (string $left, string $right): int { + return ((int)(filemtime($right) ?: 0)) <=> ((int)(filemtime($left) ?: 0)); + }); + + $items = []; + foreach (array_slice($summaryFiles, 0, max(1, $maxFiles)) as $summaryPath) { + $data = json_decode((string)file_get_contents($summaryPath), true); + if (!is_array($data)) { + continue; + } + + $updatedAt = date(DATE_ATOM, (int)(filemtime($summaryPath) ?: time())); + foreach ((array)($data['items'] ?? []) as $item) { + if (!is_array($item)) { + continue; + } + $host = trim((string)($item['host'] ?? '')); + if ($host === '') { + continue; + } + + $items[] = [ + 'host' => $host, + 'kind' => $kind, + 'status' => (string)($item['status'] ?? ''), + 'updated_at' => $updatedAt, + 'run_id' => basename(dirname($summaryPath)), + ]; + } + } + + return $items; + } +} diff --git a/code/app/common/helper/DomainImportSelfHealingRunHelper.php b/code/app/common/helper/DomainImportSelfHealingRunHelper.php new file mode 100644 index 0000000..d4a99eb --- /dev/null +++ b/code/app/common/helper/DomainImportSelfHealingRunHelper.php @@ -0,0 +1,60 @@ +导入自动修复摘要' + . '' + . '' + . '

    导入自动修复摘要

    ' + . '

    生成时间:' . htmlspecialchars(DomainImportReportViewHelper::formatDateTime((string)($arrSummary['generated_at'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    状态:' . htmlspecialchars(DomainImportReportViewHelper::translateStatus((string)($arrSummary['status'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . '

    当前失败数:' . (int)($arrTrendSummary['queue_count'] ?? 0) . ' / 重跑记录数:' . (int)($arrTrendSummary['rerun_runs_count'] ?? 0) . ' / 补料记录数:' . (int)($arrTrendSummary['remediation_runs_count'] ?? 0) . '

    ' + . '

    补料恢复数:' . (int)($arrTrendSummary['resolved_by_remediation_count'] ?? 0) . ' / 失败趋势:' . htmlspecialchars(DomainImportReportViewHelper::translateTrendLabel((string)($arrFailureTrend['label'] ?? '')), ENT_QUOTES, 'UTF-8') . '

    ' + . ''; + } +} diff --git a/code/app/common/helper/DomainSpiderMdRunHelper.php b/code/app/common/helper/DomainSpiderMdRunHelper.php index 8e417b7..2747fbb 100644 --- a/code/app/common/helper/DomainSpiderMdRunHelper.php +++ b/code/app/common/helper/DomainSpiderMdRunHelper.php @@ -451,12 +451,13 @@ class DomainSpiderMdRunHelper $forgePerVideo = max(0, (int)($forgePolicy['sitemap_count'] ?? 0)); foreach ($detailRows as $row) { + $strPinyin = (string)($row['v_name_en'] ?? ''); $detail[] = self::makeRecord( $host, 'detail', '详情页', (string)$row['v_name'], - self::absoluteUrl($host, $urlBuilder->detail('', (int)$row['v_id'])), + self::absoluteUrl($host, $urlBuilder->detail($strPinyin, (int)$row['v_id'])), 'detail' ); @@ -488,7 +489,7 @@ class DomainSpiderMdRunHelper 'forge', '详情泛链接', $forgeKeyword, - self::absoluteUrl($host, $urlBuilder->detailForge('', (int)$row['v_id'], $forgeId)), + self::absoluteUrl($host, $urlBuilder->detailForge($strPinyin, (int)$row['v_id'], $forgeId)), 'detail_forge' ); } diff --git a/code/app/common/helper/DomainTrajectoryProbeHelper.php b/code/app/common/helper/DomainTrajectoryProbeHelper.php new file mode 100644 index 0000000..c78e671 --- /dev/null +++ b/code/app/common/helper/DomainTrajectoryProbeHelper.php @@ -0,0 +1,224 @@ +initialize(); + self::$boolInitialized = true; + } + + public static function probe(string $strHost, array $arrSample, array $arrOptions = []): array + { + self::ensureAppInitialized(); + + $strHost = DomainModel::normalizeHost($strHost); + if ($strHost === '') { + return [ + 'host' => '', + 'status' => 'invalid_host', + 'message' => 'Host empty.', + 'checks' => [], + 'failed_stage' => 'host', + 'all_passed' => false, + ]; + } + + $DomainRow = self::findDomainRow($strHost); + if (!$DomainRow instanceof DomainModel) { + return [ + 'host' => $strHost, + 'status' => 'domain_not_found', + 'message' => 'Domain not found in site table.', + 'checks' => [], + 'failed_stage' => 'domain', + 'all_passed' => false, + ]; + } + + $TpStyle = SiteStyle::getConfig($DomainRow, $strHost); + $UrlBuilder = new UrlBuilder($TpStyle); + $strBaseUrl = rtrim((string)($arrOptions['base_url'] ?? ('https://' . $strHost)), '/'); + + $arrUrls = self::resolveUrls($UrlBuilder, $arrSample); + if (!empty($arrSample['urls']) && is_array($arrSample['urls'])) { + $arrUrls = array_merge($arrUrls, (array)$arrSample['urls']); + } + + $arrChecks = []; + foreach ([ + 'home' => '首页', + 'search' => '搜索', + 'detail' => '详情', + 'play' => '播放', + ] as $strStage => $strLabel) { + $strPath = trim((string)($arrUrls[$strStage] ?? '')); + if ($strPath === '') { + $arrChecks[$strStage] = [ + 'label' => $strLabel, + 'passed' => false, + 'url' => '', + 'status' => 0, + 'detail' => 'url_missing', + ]; + continue; + } + + $arrResponse = self::fetchPage($strBaseUrl . $strPath, $strHost); + $strBody = (string)($arrResponse['body'] ?? ''); + $intStatus = (int)($arrResponse['status'] ?? 0); + $boolHasHtml = str_contains(strtolower($strBody), ' $strLabel, + 'passed' => $boolPassed, + 'url' => $strBaseUrl . $strPath, + 'path' => $strPath, + 'status' => $intStatus, + 'detail' => $boolPassed ? 'ok' : self::resolveFailureDetail($intStatus, $boolHasHtml, $boolLooksLikeError), + ]; + } + + $arrFailedStages = array_keys(array_filter($arrChecks, static fn(array $arrCheck): bool => empty($arrCheck['passed']))); + $boolAllPassed = empty($arrFailedStages); + + return [ + 'host' => $strHost, + 'status' => $boolAllPassed ? 'passed' : 'failed', + 'message' => $boolAllPassed ? 'Trajectory probe passed.' : 'Trajectory probe found failed stages.', + 'sample' => $arrSample, + 'checks' => $arrChecks, + 'failed_stage' => $arrFailedStages[0] ?? '', + 'failed_stages' => $arrFailedStages, + 'all_passed' => $boolAllPassed, + 'probed_at' => date(DATE_ATOM), + ]; + } + + protected static function findDomainRow(string $strHost): ?DomainModel + { + $strExactDomain = DomainModel::normalizeStoredDomain($strHost, DomainModel::MATCH_TYPE_EXACT); + $DomainRow = app(DomainModel::class)->where('d_domain', $strExactDomain)->find(); + + return $DomainRow instanceof DomainModel ? $DomainRow : null; + } + + protected static function resolveUrls(UrlBuilder $UrlBuilder, array $arrSample): array + { + $strSlug = (string)($arrSample['detail']['detail_slug'] ?? ''); + $intVideoId = (int)($arrSample['detail']['detail_id'] ?? 0); + $strSearchKeyword = (string)($arrSample['search']['search_keyword'] ?? ''); + $strCategoryParent = (string)($arrSample['category']['category_parent'] ?? ''); + $strCategoryChild = (string)($arrSample['category']['category_child'] ?? ''); + $strPlayType = (string)($arrSample['play']['play_type'] ?? ''); + $intPlayIndex = max(1, (int)($arrSample['play']['play_index'] ?? 1)); + + $arrUrls = [ + 'home' => $UrlBuilder->home(), + ]; + + if ($strSearchKeyword !== '') { + $arrUrls['search'] = $UrlBuilder->searchResult($strSearchKeyword); + } + if ($strSlug !== '' && $intVideoId > 0) { + $arrUrls['detail'] = $UrlBuilder->detail($strSlug, $intVideoId); + } + if ($strSlug !== '' && $intVideoId > 0 && $strPlayType !== '') { + $arrUrls['play'] = $UrlBuilder->play($strSlug, $intVideoId, $strPlayType, $intPlayIndex); + } + if ($strCategoryParent !== '' && $strCategoryChild !== '') { + $arrUrls['category'] = $UrlBuilder->categoryChild($strCategoryParent, $strCategoryChild, 1); + } + + return $arrUrls; + } + + protected static function fetchPage(string $strUrl, string $strHost): array + { + $Context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", [ + 'Host: ' . $strHost, + 'Connection: close', + ]), + 'ignore_errors' => true, + 'timeout' => 20, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + + $strBody = @file_get_contents($strUrl, false, $Context); + $arrHeaders = $http_response_header ?? []; + $intStatus = 0; + foreach ($arrHeaders as $strHeaderLine) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})/i', $strHeaderLine, $arrMatches)) { + $intStatus = (int)($arrMatches[1] ?? 0); + } + } + + return [ + 'status' => $intStatus, + 'body' => $strBody === false ? '' : $strBody, + ]; + } + + protected static function looksLikeErrorPage(string $strBody): bool + { + $strNormalized = strtolower($strBody); + foreach ([ + 'thinkphp', + 'think\\exception', + 'undefined array key', + 'uncaught exception', + 'fatal error', + 'parse error', + 'call to undefined', + 'stack trace', + 'whoops', + 'not found', + '404 not found', + '500 internal server error', + ] as $strNeedle) { + if (str_contains($strNormalized, $strNeedle)) { + return true; + } + } + + return false; + } + + protected static function resolveFailureDetail(int $intStatus, bool $boolHasHtml, bool $boolLooksLikeError): string + { + if ($intStatus !== 200) { + return 'http_' . $intStatus; + } + if (!$boolHasHtml) { + return 'html_missing'; + } + if ($boolLooksLikeError) { + return 'error_page_detected'; + } + + return 'unknown_failure'; + } +} diff --git a/code/app/common/helper/SiteStyle.php b/code/app/common/helper/SiteStyle.php index 80ce975..04df061 100644 --- a/code/app/common/helper/SiteStyle.php +++ b/code/app/common/helper/SiteStyle.php @@ -1867,12 +1867,14 @@ class SiteStyle */ private static function guessDetailUrlFromVideo(array $arrVideo): string { + $strSlug = (string)($arrVideo['v_name_en'] ?? $arrVideo['v_slug'] ?? $arrVideo['slug'] ?? ''); $intId = (int)($arrVideo['v_id'] ?? 0); + if (!empty($strSlug) && $intId > 0) { + return '/voddetail/' . $strSlug . '-' . $intId; + } if ($intId > 0) { - // Keep canonical stable on id-first path. Video pages resolve by v_id, - // while slug is only for presentation and may drift over time. - return '/voddetail/-' . $intId; + return '/voddetail/' . $intId; } return ''; } diff --git a/code/app/home/config/router.php b/code/app/home/config/router.php index 4a248c3..23c0a74 100644 --- a/code/app/home/config/router.php +++ b/code/app/home/config/router.php @@ -185,6 +185,135 @@ if ($strTmpCode == 'videoGpt1') { } } } + + // ========== 4️⃣ 历史深页兼容路由 ========== + // videoGpt1 主输出仍然只走当前 family, + // 这里只补“旧 detail / play 深链入口”,避免历史外链、旧 sitemap、 + // 百度已抓取的旧 family URL 在路由层直接 404。 + $legacyCompatRoutes = [ + 'detail_forge' => [ + '/vodinfo/:strPinyin-:intVId-:intVForgeId', + '/vod/:strPinyin-:intVId-:intVForgeId', + '/video-info/:strPinyin-:intVId-:intVForgeId', + '/video-detail/:strPinyin-:intVId-:intVForgeId', + '/video/:strPinyin-:intVId-:intVForgeId', + '/shipin/:strPinyin-:intVId-:intVForgeId', + '/shipin-xiangqing/:strPinyin-:intVId-:intVForgeId', + '/shipin-neiron/:strPinyin-:intVId-:intVForgeId', + + '/voddetail/-:intVId-:intVForgeId', + '/vodinfo/-:intVId-:intVForgeId', + '/vod/-:intVId-:intVForgeId', + '/video-info/-:intVId-:intVForgeId', + '/video-detail/-:intVId-:intVForgeId', + '/video/-:intVId-:intVForgeId', + '/shipin/-:intVId-:intVForgeId', + '/shipin-xiangqing/-:intVId-:intVForgeId', + '/shipin-neiron/-:intVId-:intVForgeId', + + '/voddetail/:intVId-:intVForgeId', + '/vodinfo/:intVId-:intVForgeId', + '/vod/:intVId-:intVForgeId', + '/video-info/:intVId-:intVForgeId', + '/video-detail/:intVId-:intVForgeId', + '/video/:intVId-:intVForgeId', + '/shipin/:intVId-:intVForgeId', + '/shipin-xiangqing/:intVId-:intVForgeId', + '/shipin-neiron/:intVId-:intVForgeId', + ], + 'detail' => [ + '/voddetail/:strPinyin-:intVId', + '/vodinfo/:strPinyin-:intVId', + '/vod/:strPinyin-:intVId', + '/video-info/:strPinyin-:intVId', + '/video-detail/:strPinyin-:intVId', + '/video/:strPinyin-:intVId', + '/shipin/:strPinyin-:intVId', + '/shipin-xiangqing/:strPinyin-:intVId', + '/shipin-neiron/:strPinyin-:intVId', + + '/voddetail/-:intVId', + '/vodinfo/-:intVId', + '/vod/-:intVId', + '/video-info/-:intVId', + '/video-detail/-:intVId', + '/video/-:intVId', + '/shipin/-:intVId', + '/shipin-xiangqing/-:intVId', + '/shipin-neiron/-:intVId', + + '/voddetail/:intVId', + '/vodinfo/:intVId', + '/vod/:intVId', + '/video-info/:intVId', + '/video-detail/:intVId', + '/video/:intVId', + '/shipin/:intVId', + '/shipin-xiangqing/:intVId', + '/shipin-neiron/:intVId', + ], + 'play' => [ + '/vodplay/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/vodbf/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/vodseed/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/video-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/video-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/video-show/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/shipin-play/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/shipin-bofang/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + '/shipin-kan/:strPinyin-:intVId-:strPlayType-:intPlayIndex', + + '/vodplay/-:intVId-:strPlayType-:intPlayIndex', + '/vodbf/-:intVId-:strPlayType-:intPlayIndex', + '/vodseed/-:intVId-:strPlayType-:intPlayIndex', + '/video-play/-:intVId-:strPlayType-:intPlayIndex', + '/video-bofang/-:intVId-:strPlayType-:intPlayIndex', + '/video-show/-:intVId-:strPlayType-:intPlayIndex', + '/shipin-play/-:intVId-:strPlayType-:intPlayIndex', + '/shipin-bofang/-:intVId-:strPlayType-:intPlayIndex', + '/shipin-kan/-:intVId-:strPlayType-:intPlayIndex', + + '/vodplay/:intVId-:strPlayType-:intPlayIndex', + '/vodbf/:intVId-:strPlayType-:intPlayIndex', + '/vodseed/:intVId-:strPlayType-:intPlayIndex', + '/video-play/:intVId-:strPlayType-:intPlayIndex', + '/video-bofang/:intVId-:strPlayType-:intPlayIndex', + '/video-show/:intVId-:strPlayType-:intPlayIndex', + '/shipin-play/:intVId-:strPlayType-:intPlayIndex', + '/shipin-bofang/:intVId-:strPlayType-:intPlayIndex', + '/shipin-kan/:intVId-:strPlayType-:intPlayIndex', + ], + ]; + + foreach ($legacyCompatRoutes as $page => $routes) { + foreach ($routes as $route) { + switch ($page) { + case 'detail': + $register($route, 'video/getVideoInfo.html', [ + 'intVId' => '\d+', + 'strPinyin' => '[\w-]+', + ]); + break; + + case 'detail_forge': + $register($route, 'video/getVideoInfo.html', [ + 'intVId' => '\d+', + 'strPinyin' => '[\w-]+', + 'intVForgeId' => '\d+', + ]); + break; + + case 'play': + $register($route, 'video/getVideoPlayUrl.html', [ + 'intVId' => '\d+', + 'strPinyin' => '[\w-]+', + 'strPlayType' => '[\w-]+', + 'intPlayIndex' => '\d+', + ]); + break; + } + } + } return; } else { diff --git a/code/app/home/view/videoGpt1/module/detail_main/action.html b/code/app/home/view/videoGpt1/module/detail_main/action.html index c34a290..5164df4 100644 --- a/code/app/home/view/videoGpt1/module/detail_main/action.html +++ b/code/app/home/view/videoGpt1/module/detail_main/action.html @@ -25,7 +25,7 @@ {elseif $variant == 4}
    - +
    {elseif $variant == 5} diff --git a/code/app/home/view/videoGpt1/video/getVideoPlayUrl.html b/code/app/home/view/videoGpt1/video/getVideoPlayUrl.html index 17f1e6a..3bb6877 100644 --- a/code/app/home/view/videoGpt1/video/getVideoPlayUrl.html +++ b/code/app/home/view/videoGpt1/video/getVideoPlayUrl.html @@ -33,7 +33,7 @@ {if $Request.route.intVId } - + {/if} diff --git a/code/app/model/SeoExternalSnapshotModel.php b/code/app/model/SeoExternalSnapshotModel.php new file mode 100644 index 0000000..10e1173 --- /dev/null +++ b/code/app/model/SeoExternalSnapshotModel.php @@ -0,0 +1,11 @@ +SiteContext->TemplatesModel['t_code']; if ($strTmpCode == 'videoGpt1') { - // videoGpt1 runtime resolves detail data by v_id. - // Use id-first urls to avoid stale slug drift creating dead deep links. - return $this->SiteContext->UrlBuilder->detail('', $intVId); - } else { - $strKey = 'VIDEO_INFO_URL'; - $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ - 'intVId' => $intVId, - 'strPinYin' => $strPinYin - ]); - return (string)$strUrl; + return $this->SiteContext->UrlBuilder->detail($strPinYin, $intVId); } + + $strKey = 'VIDEO_INFO_URL'; + $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ + 'intVId' => $intVId, + 'strPinYin' => $strPinYin + ]); + return (string)$strUrl; } /** @@ -108,16 +106,16 @@ class VideoService $strTmpCode = $this->SiteContext->TemplatesModel['t_code']; if ($strTmpCode == 'videoGpt1') { - return $this->SiteContext->UrlBuilder->detailForge('', $intVId, $intVForgeId); - } else { - $strKey = 'FORGE_VIDEO_INFO_URL'; - $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ - 'intVId' => $intVId, - 'strPinYin' => $strPinYin, - 'intVForgeId' => $intVForgeId, - ]); - return (string)$strUrl; + return $this->SiteContext->UrlBuilder->detailForge($strPinYin, $intVId, $intVForgeId); } + + $strKey = 'FORGE_VIDEO_INFO_URL'; + $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ + 'intVId' => $intVId, + 'strPinYin' => $strPinYin, + 'intVForgeId' => $intVForgeId, + ]); + return (string)$strUrl; } /** @@ -134,17 +132,17 @@ class VideoService $strTmpCode = $this->SiteContext->TemplatesModel['t_code']; if ($strTmpCode == 'videoGpt1') { - return $this->SiteContext->UrlBuilder->play('', $intVId, $strPlayType, $intPlayIndex); - } else { - $strKey = 'VIDEO_PLAY_URL'; - $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ - 'intVId' => $intVId, - 'strPinYin' => $strPinYin, - 'strPlayType' => $strPlayType, - 'intPlayIndex' => $intPlayIndex, - ]); - return (string)$strUrl; + return $this->SiteContext->UrlBuilder->play($strPinYin, $intVId, $strPlayType, $intPlayIndex); } + + $strKey = 'VIDEO_PLAY_URL'; + $strUrl = $this->SiteContext->DomainModel->getFomartUrl($strKey, [ + 'intVId' => $intVId, + 'strPinYin' => $strPinYin, + 'strPlayType' => $strPlayType, + 'intPlayIndex' => $intPlayIndex, + ]); + return (string)$strUrl; } @@ -762,16 +760,42 @@ class VideoService */ public function getVideoByVId(int $intVId, int|NULL $intVForgeId) { + $boolRequirePlayable = (string)request()->route('strPlayType') !== '' || (int)request()->route('intPlayIndex') > 0; + + $this->debugFallbackProbe('enter_getVideoByVId', [ + 'requested_v_id' => $intVId, + 'forge_id' => $intVForgeId, + 'require_playable' => $boolRequirePlayable ? 1 : 0, + 'host' => (string)(request()->host(true) ?: request()->host()), + 'path' => (string)request()->pathinfo(), + ]); // 视频详情 $arrVideo = $this->VideoModel->getVideoByVId($intVId); + $this->debugFallbackProbe('after_direct_lookup', [ + 'requested_v_id' => $intVId, + 'found' => empty($arrVideo) ? 0 : 1, + 'resolved_v_id' => (int)($arrVideo['v_id'] ?? 0), + ]); + if (empty($arrVideo)) { + $arrVideo = $this->resolveFallbackVideoByRequest($intVId, $boolRequirePlayable); + } + + if (empty($arrVideo)) { + $this->debugFallbackProbe('final_404', [ + 'requested_v_id' => $intVId, + 'path' => (string)request()->pathinfo(), + ]); throw new HttpException(404, '视频不存在'); } if ($intVForgeId >= 0 && !empty($arrVideo['v_seo_words'])) { - $arrVideo['v_name'] = $arrVideo['v_seo_words'][$intVForgeId - 1]; + $intForgeOffset = max(0, $intVForgeId - 1); + if (!empty($arrVideo['v_seo_words'][$intForgeOffset])) { + $arrVideo['v_name'] = $arrVideo['v_seo_words'][$intForgeOffset]; + } } // 取前5个元素 @@ -785,7 +809,7 @@ class VideoService $strTopLang = '2025'; // 点击数 - $arrVideoClicks = $this->VideoClicksModel->getStats($intVId); + $arrVideoClicks = $this->VideoClicksModel->getStats((int)($arrVideo['v_id'] ?? $intVId)); // 更新值 ConverterMovel::setVal([ @@ -806,6 +830,185 @@ class VideoService return $arrVideo; } + protected function resolveFallbackVideoByRequest(int $requestedVId, bool $requirePlayable = false): ?array + { + $strHost = (string)($this->SiteContext->DomainModel->d_domain ?? 'default'); + $arrBindings = $this->readFallbackBindings($strHost); + $strBindingKey = (string)$requestedVId; + + $this->debugFallbackProbe('enter_resolve_fallback', [ + 'requested_v_id' => $requestedVId, + 'require_playable' => $requirePlayable ? 1 : 0, + 'host' => $strHost, + 'existing_bindings' => count($arrBindings), + ]); + + $boundVId = (int)($arrBindings[$strBindingKey] ?? 0); + if ($boundVId > 0 && $boundVId !== $requestedVId) { + $arrVideo = $this->VideoModel->getVideoByVId($boundVId); + if (!empty($arrVideo) && (!$requirePlayable || $this->hasPlayableSource($arrVideo))) { + $this->debugFallbackProbe('reuse_bound_video', [ + 'requested_v_id' => $requestedVId, + 'bound_v_id' => $boundVId, + ]); + $arrVideo['_fallback_reason'] = 'bound_random_video'; + $arrVideo['_requested_v_id'] = $requestedVId; + return $arrVideo; + } + } + + $arrFilter = []; + if ($requirePlayable) { + $arrFilter['v_play_url'] = ['$exists' => true, '$ne' => []]; + } + + for ($attempt = 0; $attempt < 6; $attempt++) { + $cursor = $this->VideoModel->getCol()->aggregate([ + ['$match' => $arrFilter], + ['$sample' => ['size' => 8]], + ]); + + $arrRows = iterator_to_array($cursor); + $this->debugFallbackProbe('sample_attempt', [ + 'requested_v_id' => $requestedVId, + 'attempt' => $attempt + 1, + 'sample_count' => count($arrRows), + ]); + + foreach ($arrRows as $row) { + $candidateId = (int)($row['v_id'] ?? 0); + if ($candidateId <= 0 || $candidateId === $requestedVId) { + continue; + } + + $arrVideo = $this->VideoModel->getVideoByVId($candidateId); + if (empty($arrVideo)) { + continue; + } + if ($requirePlayable && !$this->hasPlayableSource($arrVideo)) { + continue; + } + + $arrBindings[$strBindingKey] = $candidateId; + $this->writeFallbackBindings($strHost, $arrBindings); + $this->debugFallbackProbe('sample_success', [ + 'requested_v_id' => $requestedVId, + 'candidate_v_id' => $candidateId, + 'attempt' => $attempt + 1, + ]); + + $arrVideo['_fallback_reason'] = 'random_video'; + $arrVideo['_requested_v_id'] = $requestedVId; + return $arrVideo; + } + } + + $arrVideo = $this->VideoModel->findOne($arrFilter, ['sort' => ['v_level' => -1]]); + if (!empty($arrVideo)) { + $candidateId = (int)($arrVideo['v_id'] ?? 0); + if ($candidateId > 0 && $candidateId !== $requestedVId) { + $arrBindings[$strBindingKey] = $candidateId; + $this->writeFallbackBindings($strHost, $arrBindings); + $this->debugFallbackProbe('ordered_fallback_success', [ + 'requested_v_id' => $requestedVId, + 'candidate_v_id' => $candidateId, + ]); + $arrVideo['_fallback_reason'] = 'ordered_fallback_video'; + $arrVideo['_requested_v_id'] = $requestedVId; + return $arrVideo; + } + } + + $this->debugFallbackProbe('fallback_exhausted', [ + 'requested_v_id' => $requestedVId, + 'require_playable' => $requirePlayable ? 1 : 0, + ]); + + return null; + } + + protected function hasPlayableSource(array $arrVideo): bool + { + $arrPlayUrl = (array)($arrVideo['v_play_url'] ?? []); + if (empty($arrPlayUrl)) { + return false; + } + + foreach ($arrPlayUrl as $episodes) { + if (is_array($episodes) && !empty($episodes)) { + return true; + } + } + + return false; + } + + protected function readFallbackBindings(string $host): array + { + $path = $this->fallbackBindingsPath($host); + if (!is_file($path)) { + return []; + } + + $content = @file_get_contents($path); + if (!is_string($content) || trim($content) === '') { + return []; + } + + $payload = json_decode($content, true); + return is_array($payload) ? $payload : []; + } + + protected function writeFallbackBindings(string $host, array $bindings): void + { + $path = $this->fallbackBindingsPath($host); + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + + @file_put_contents( + $path, + json_encode($bindings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, + LOCK_EX + ); + } + + protected function fallbackBindingsPath(string $host): string + { + $host = trim(strtolower($host)); + if ($host === '') { + $host = 'default'; + } + $safeHost = preg_replace('/[^a-z0-9._-]+/i', '-', $host); + return rtrim((string)root_path(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'storage' + . DIRECTORY_SEPARATOR . 'video_fallback_bindings' + . DIRECTORY_SEPARATOR . $safeHost . '.json'; + } + + protected function debugFallbackProbe(string $stage, array $payload = []): void + { + $path = rtrim((string)root_path(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'storage' + . DIRECTORY_SEPARATOR . 'video_fallback_bindings' + . DIRECTORY_SEPARATOR . '_probe.log'; + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + $line = [ + 'ts' => date('c'), + 'stage' => $stage, + 'payload' => $payload, + ]; + @file_put_contents( + $path, + json_encode($line, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, + FILE_APPEND | LOCK_EX + ); + } + /** * 获取 播放线路-中文 * diff --git a/code/database/migrations/20260410010000_create_seo_external_snapshot_table.php b/code/database/migrations/20260410010000_create_seo_external_snapshot_table.php new file mode 100644 index 0000000..a92eb88 --- /dev/null +++ b/code/database/migrations/20260410010000_create_seo_external_snapshot_table.php @@ -0,0 +1,49 @@ +table('seo_external_snapshot', ['id' => false]); + $Table + ->addColumn(Column::integer('id')->setUnsigned()->setIdentity(true)->setComment('主键')) + ->addColumn(Column::integer('domain_id')->setUnsigned()->setDefault(0)->setComment('站点ID')) + ->addColumn(Column::string('host', 255)->setDefault('')->setComment('站点域名')) + ->addColumn(Column::string('provider', 100)->setDefault('')->setComment('数据提供方')) + ->addColumn(Column::string('snapshot_type', 50)->setDefault('')->setComment('快照类型')) + ->addColumn(Column::string('scope', 20)->setDefault('host')->setComment('粒度 host/keyword')) + ->addColumn(Column::string('keyword', 255)->setDefault('')->setComment('关键词')) + ->addColumn(Column::string('device', 20)->setDefault('')->setComment('设备 pc/mobile')) + ->addColumn(Column::string('metric_date', 20)->setDefault('')->setComment('指标日期')) + ->addColumn(Column::integer('queried_at')->setDefault(0)->setComment('采集时间')) + ->addColumn(Column::string('source', 50)->setDefault('push_api')->setComment('来源')) + ->addColumn(Column::string('status', 30)->setDefault('')->setComment('状态')) + ->addColumn(Column::string('indexed_status', 30)->setDefault('')->setComment('收录状态')) + ->addColumn(Column::string('result_count_text', 255)->setDefault('')->setComment('收录结果文本')) + ->addColumn(Column::string('baidu_pc_ip_range', 50)->setDefault('')->setComment('百度PC来路区间')) + ->addColumn(Column::string('baidu_mobile_ip_range', 50)->setDefault('')->setComment('百度移动来路区间')) + ->addColumn(Column::integer('pc_keyword_count')->setDefault(0)->setComment('PC词数')) + ->addColumn(Column::integer('mobile_keyword_count')->setDefault(0)->setComment('移动词数')) + ->addColumn(Column::integer('rank_value')->setDefault(0)->setComment('关键词排名')) + ->addColumn(Column::integer('search_volume')->setDefault(0)->setComment('搜索量')) + ->addColumn(Column::text('payload')->setNullable(true)->setComment('扩展数据JSON')) + ->addColumn(Column::string('snapshot_signature', 64)->setDefault('')->setComment('快照签名')) + ->addColumn(Column::integer('created_at')->setDefault(0)->setComment('创建时间')) + ->addColumn(Column::integer('updated_at')->setDefault(0)->setComment('更新时间')) + ->addIndex(['id'], ['type' => 'primary']) + ->addIndex(['snapshot_signature']) + ->addIndex(['host', 'provider', 'metric_date']) + ->addIndex(['host', 'scope']) + ->addIndex(['keyword']) + ->setComment('站外SEO历史快照表') + ->create(); + } + + public function down() + { + $this->table('seo_external_snapshot')->drop()->save(); + } +} diff --git a/code/database/migrations/20260410020000_optimize_seo_external_snapshot_indexes.php b/code/database/migrations/20260410020000_optimize_seo_external_snapshot_indexes.php new file mode 100644 index 0000000..b33c487 --- /dev/null +++ b/code/database/migrations/20260410020000_optimize_seo_external_snapshot_indexes.php @@ -0,0 +1,26 @@ +table('seo_external_snapshot') + ->addIndex(['provider', 'snapshot_type', 'scope', 'metric_date'], ['name' => 'idx_provider_type_scope_date']) + ->addIndex(['snapshot_type', 'scope', 'metric_date'], ['name' => 'idx_type_scope_date']) + ->addIndex(['host', 'snapshot_type', 'metric_date'], ['name' => 'idx_host_type_date']) + ->addIndex(['host', 'keyword', 'device', 'metric_date'], ['name' => 'idx_host_keyword_device_date']) + ->update(); + } + + public function down() + { + $this->table('seo_external_snapshot') + ->removeIndexByName('idx_provider_type_scope_date') + ->removeIndexByName('idx_type_scope_date') + ->removeIndexByName('idx_host_type_date') + ->removeIndexByName('idx_host_keyword_device_date') + ->update(); + } +}