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 .= '
生成时间:' . 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 @@ +' + . '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) . '
' + . '| host | action | status | reprobe_status | message |
|---|
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) . '
' + . '| host | status | remediation_kind | remediation_status | failed_stage | message |
|---|
生成时间:' . 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) . '
' + . '| 域名 | 状态 | 失败阶段 | 说明 |
|---|
生成时间:' . 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 ([ + '