From c4b92ce9f546b0e81b71e73442f346108b6c32a7 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 20:26:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B8=82=E5=A0=B4=E6=83=85?= =?UTF-8?q?=E5=A0=B1=E6=AD=A3=E5=BC=8F=20DB=20=E5=8F=AA=E8=AE=80=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- ...s-platform-market-campaign-intelligence.md | 1 + routes/README.md | 2 +- routes/market_intel_routes.py | 11 ++ services/market_intel/deployment_readiness.py | 12 ++ services/market_intel/migration_live_smoke.py | 164 ++++++++++++++++++ services/market_intel/service.py | 29 +++- templates/market_intel/disabled.html | 120 ++++++++++++- tests/test_market_intel_skeleton.py | 133 +++++++++++--- 9 files changed, 447 insertions(+), 27 deletions(-) create mode 100644 services/market_intel/migration_live_smoke.py diff --git a/config.py b/config.py index 4145faa..ac2d705 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.206" +SYSTEM_VERSION = "V10.207" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md index a324017..6eca78c 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -170,6 +170,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 2026-05-18 追加 alert review queue migration blueprint:`market_alert_review_queue` 納入 `database/market_intel_models.py`、`migrations/032_market_intel_core_schema.sql` 與 migration blueprint,補齊 additive CREATE TABLE / index / grant / rollback draft。此階段仍不執行 migration、不連 DB、不建立 review queue、不寫入審核資料。 - 2026-05-18 追加 migration apply drill preview:`services.market_intel.migration_drill` 與 `/api/market_intel/migration_apply_drill` 集中正式 migration 前的只讀 schema probe、platform seed diff、人工套用清單、post-apply smoke、回滾演練與風險清單。預設 `execute=false` 不連 DB;人工 smoke 可用 `execute=true` 觸發只讀 catalog / seed diff probe,但仍不執行 psql、不跑 rollback、不寫 DB、不重啟容器、不掛 scheduler。 - 2026-05-18 追加 migration catalog review preview:`services.market_intel.migration_catalog_review` 與 `/api/market_intel/migration_catalog_review` 將 schema_db_probe 與 platform_seed_db_diff 的只讀結果歸納為 `planned_no_probe` / `not_applied` / `partial_schema` / `already_applied` / `probe_error`,並輸出 risk level、apply path、finding 與下一步。預設 `execute=false` 不連 DB;`execute=true` 仍只查 catalog / seed diff,不執行 psql、不寫 DB、不跑 rollback、不掛 scheduler。 +- 2026-05-18 追加 migration live smoke preview:`services.market_intel.migration_live_smoke` 與 `/api/market_intel/migration_live_smoke` 將 `execute=true` 的正式 DB 只讀探測整理成 smoke result,例如 `passed_not_applied_seed_table_missing`、`attention_partial_schema`、`passed_already_applied` 或 `failed_catalog_probe_error`。UI 預設仍只呼叫 `execute=false`;人工 smoke 可用 `execute=true` 只讀查 catalog,不執行 migration、不寫 DB、不跑 rollback、不掛 scheduler。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index 9c462e6..b5478bc 100644 --- a/routes/README.md +++ b/routes/README.md @@ -19,7 +19,7 @@ | `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | | `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | | `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` | -| `market_intel_routes.py` | 市場情報 Phase 44 migration catalog review preview | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/migration_apply_drill`, `/api/market_intel/migration_catalog_review`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | +| `market_intel_routes.py` | 市場情報 Phase 45 migration live smoke preview | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/migration_apply_drill`, `/api/market_intel/migration_catalog_review`, `/api/market_intel/migration_live_smoke`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | | `export_routes.py` | 匯出功能 | `/api/export/*` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | diff --git a/routes/market_intel_routes.py b/routes/market_intel_routes.py index 238730a..fdded2b 100644 --- a/routes/market_intel_routes.py +++ b/routes/market_intel_routes.py @@ -282,6 +282,17 @@ def market_intel_migration_catalog_review(): ) +@market_intel_bp.route("/api/market_intel/migration_live_smoke") +@login_required +def market_intel_migration_live_smoke(): + execute_requested = request.args.get("execute", "false").lower() == "true" + return jsonify( + _service().build_migration_live_smoke( + execute_requested=execute_requested, + ) + ) + + @market_intel_bp.route("/api/market_intel/seed_writer_cli_status") @login_required def market_intel_seed_writer_cli_status(): diff --git a/services/market_intel/deployment_readiness.py b/services/market_intel/deployment_readiness.py index 7f66502..0c23d36 100644 --- a/services/market_intel/deployment_readiness.py +++ b/services/market_intel/deployment_readiness.py @@ -25,6 +25,7 @@ def build_deployment_readiness_preview( opportunity_alert_plan = service.build_opportunity_alert_plan() migration_apply_drill = service.build_migration_apply_drill() migration_catalog_review = service.build_migration_catalog_review() + migration_live_smoke = service.build_migration_live_smoke() checks = { "schema_smoke_passed": bool(schema_smoke["passed"]), "feature_flags_default_safe": bool( @@ -130,6 +131,15 @@ def build_deployment_readiness_preview( and not migration_catalog_review["api_executes_migration"] and not migration_catalog_review["api_executes_rollback"] ), + "migration_live_smoke_preview_safe": bool( + migration_live_smoke["mode"] == "migration_live_smoke_preview" + and not migration_live_smoke["migration_executed"] + and not migration_live_smoke["rollback_executed"] + and not migration_live_smoke["database_write_executed"] + and not migration_live_smoke["database_commit_executed"] + and not migration_live_smoke["api_executes_migration"] + and not migration_live_smoke["api_executes_rollback"] + ), } ready_for_production_deploy = all(checks.values()) blocked_reasons = [ @@ -266,6 +276,7 @@ def build_deployment_readiness_preview( "/api/market_intel/opportunity_alert_plan", "/api/market_intel/migration_apply_drill", "/api/market_intel/migration_catalog_review", + "/api/market_intel/migration_live_smoke", ], "status": status.to_dict(), "schema_smoke": schema_smoke, @@ -278,6 +289,7 @@ def build_deployment_readiness_preview( "migration_blueprint": service.build_migration_blueprint(), "migration_apply_drill": migration_apply_drill, "migration_catalog_review": migration_catalog_review, + "migration_live_smoke": migration_live_smoke, "seed_writer_cli_status": service.build_seed_writer_cli_status(), "schema_db_probe": service.build_schema_db_probe(), "platform_seed_db_diff": service.build_platform_seed_db_diff(), diff --git a/services/market_intel/migration_live_smoke.py b/services/market_intel/migration_live_smoke.py new file mode 100644 index 0000000..6eb1974 --- /dev/null +++ b/services/market_intel/migration_live_smoke.py @@ -0,0 +1,164 @@ +"""市場情報正式 DB 只讀 smoke 判讀。 + +本模組只包裝 migration catalog review 的結果,讓操作員能安全地執行 +`execute=true` 只讀 smoke;不執行 migration、不寫 DB、不跑 rollback。 +""" + + +def _smoke_result(catalog_review, *, execute_requested): + catalog_state = catalog_review.get("catalog_state") + seed_state = catalog_review.get("seed_state") + if not execute_requested: + return "planned_no_execution" + if catalog_state == "probe_error": + return "failed_catalog_probe_error" + if catalog_state == "partial_schema": + return "attention_partial_schema" + if catalog_state == "already_applied": + return "passed_already_applied" + if catalog_state == "not_applied" and seed_state == "probe_error": + return "passed_not_applied_seed_table_missing" + if catalog_state == "not_applied": + return "passed_not_applied" + return "review_required" + + +def _build_blocked_reasons(*, execute_requested, catalog_review, safety_checks): + blocked_reasons = [ + "migration_not_executed_by_live_smoke", + "api_never_runs_migration", + "database_write_still_blocked", + ] + blocked_reasons.extend( + key for key, passed in safety_checks.items() + if not passed + ) + if not execute_requested: + blocked_reasons.append("execute_false_planned_only") + if catalog_review.get("catalog_state") == "probe_error": + blocked_reasons.append("catalog_probe_error") + if catalog_review.get("catalog_state") == "partial_schema": + blocked_reasons.append("partial_schema_requires_manual_reconciliation") + if catalog_review.get("catalog_state") == "already_applied": + blocked_reasons.append("market_schema_already_present") + return blocked_reasons + + +def build_migration_live_smoke_preview(*, runtime_status, catalog_review): + """建立正式 DB 只讀 smoke payload;不執行 migration 或 DB write。""" + execute_requested = bool(catalog_review.get("execute_requested")) + catalog_state = catalog_review.get("catalog_state") + seed_state = catalog_review.get("seed_state") + seed_probe_error_tolerated = bool( + execute_requested + and catalog_state == "not_applied" + and seed_state == "probe_error" + ) + read_only_probe_completed = bool( + catalog_review.get("read_only_query_executed") + and catalog_state != "probe_error" + ) + safety_checks = { + "feature_flags_default_safe": bool( + not runtime_status.enabled + and not runtime_status.crawler_enabled + and not runtime_status.write_enabled + ), + "database_write_blocked": bool(not runtime_status.database_write_allowed), + "scheduler_detached": bool(not runtime_status.scheduler_attached), + "catalog_review_safe": bool( + catalog_review.get("review_ready") + and not catalog_review.get("database_write_executed") + and not catalog_review.get("database_commit_executed") + and not catalog_review.get("database_session_created") + and not catalog_review.get("migration_executed") + and not catalog_review.get("rollback_executed") + ), + "read_only_probe_completed_when_requested": bool( + not execute_requested or read_only_probe_completed + ), + "seed_probe_error_tolerable": bool( + seed_state != "probe_error" or seed_probe_error_tolerated + ), + } + live_smoke_passed = bool( + execute_requested + and read_only_probe_completed + and all(safety_checks.values()) + ) + smoke_result = _smoke_result( + catalog_review, + execute_requested=execute_requested, + ) + + return { + "mode": "migration_live_smoke_preview", + "execute_requested": execute_requested, + "smoke_result": smoke_result, + "live_smoke_passed": live_smoke_passed, + "catalog_state": catalog_state, + "seed_state": seed_state, + "risk_level": catalog_review.get("risk_level"), + "apply_path": catalog_review.get("apply_path"), + "read_only_probe_completed": read_only_probe_completed, + "seed_probe_error_tolerated": seed_probe_error_tolerated, + "ready_for_manual_migration_review": bool( + catalog_review.get("ready_for_manual_migration_review") + ), + "ready_to_apply_migration": False, + "migration_executed": False, + "rollback_executed": False, + "database_connection_opened": bool( + catalog_review.get("database_connection_opened") + ), + "read_only_query_executed": bool( + catalog_review.get("read_only_query_executed") + ), + "database_session_created": False, + "explicit_transaction_opened": False, + "database_write_executed": False, + "database_commit_executed": False, + "external_network_executed": False, + "scheduler_attached": False, + "api_executes_migration": False, + "api_executes_rollback": False, + "safety_checks": safety_checks, + "blocked_reasons": _build_blocked_reasons( + execute_requested=execute_requested, + catalog_review=catalog_review, + safety_checks=safety_checks, + ), + "catalog_review_summary": { + "catalog_state": catalog_state, + "seed_state": seed_state, + "risk_level": catalog_review.get("risk_level"), + "apply_path": catalog_review.get("apply_path"), + "table_catalog": catalog_review.get("table_catalog") or {}, + "seed_catalog": catalog_review.get("seed_catalog") or {}, + "finding_count": len(catalog_review.get("findings") or []), + "findings": catalog_review.get("findings") or [], + }, + "operator_next_steps": [ + { + "key": "run_live_smoke_execute_true", + "label": "人工呼叫 /api/market_intel/migration_live_smoke?execute=true 執行只讀 smoke", + "status": "completed" if execute_requested else "required", + }, + { + "key": "interpret_smoke_result", + "label": "依 smoke_result 判斷正式 DB 目前是否尚未套表、已套表、partial schema 或 probe error", + "status": "required", + }, + { + "key": "manual_migration_review_only", + "label": "只有 smoke 顯示 not_applied 且人工批准後,才可另開維護窗口手動執行 migration", + "status": "blocked_by_operator_approval", + }, + ], + "manual_probe_targets": [ + "/api/market_intel/migration_live_smoke?execute=true", + "/api/market_intel/migration_catalog_review?execute=true", + "/api/market_intel/schema_db_probe?execute=true", + "/api/market_intel/platform_seed_db_diff?execute=true", + ], + } diff --git a/services/market_intel/service.py b/services/market_intel/service.py index 8125751..855d67d 100644 --- a/services/market_intel/service.py +++ b/services/market_intel/service.py @@ -34,6 +34,9 @@ from services.market_intel.migration_catalog_review import ( build_migration_catalog_review_preview, ) from services.market_intel.migration_drill import build_migration_apply_drill_preview +from services.market_intel.migration_live_smoke import ( + build_migration_live_smoke_preview, +) from services.market_intel.opportunity_alerts import ( build_opportunity_alert_plan_preview, ) @@ -88,7 +91,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_44_migration_catalog_review_preview" + phase = "phase_45_migration_live_smoke_preview" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( @@ -582,6 +585,30 @@ class MarketIntelService: review["phase"] = self.phase return review + def build_migration_live_smoke( + self, + *, + execute_requested=False, + schema_engine=None, + seed_diff_engine=None, + database_url=None, + database_type=None, + ): + """建立正式 DB 只讀 smoke;不執行 migration、不寫 DB。""" + catalog_review = self.build_migration_catalog_review( + execute_requested=execute_requested, + schema_engine=schema_engine, + seed_diff_engine=seed_diff_engine, + database_url=database_url, + database_type=database_type, + ) + smoke = build_migration_live_smoke_preview( + runtime_status=self.get_runtime_status(), + catalog_review=catalog_review, + ) + smoke["phase"] = self.phase + return smoke + def build_seed_writer_cli_status( self, platform_code="all", diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index e99abee..cf01560 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -648,6 +648,24 @@ +
+
+
+

MIGRATION / LIVE SMOKE

+

正式 DB 只讀 smoke

+
+ +
+
+ loading +
+
+
讀取正式 DB 只讀 smoke 中...
+
+
+
@@ -708,9 +726,10 @@ const migrationRoot = document.querySelector('[data-market-intel-migration]'); const migrationDrillRoot = document.querySelector('[data-market-intel-migration-drill]'); const catalogReviewRoot = document.querySelector('[data-market-intel-catalog-review]'); + const liveSmokeRoot = document.querySelector('[data-market-intel-live-smoke]'); const approvalRoot = document.querySelector('[data-market-intel-approval]'); const deployRoot = document.querySelector('[data-market-intel-deploy]'); - if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !opportunityRoot && !opportunityScoringRoot && !opportunityEvidenceRoot && !opportunityAlertRoot && !migrationRoot && !migrationDrillRoot && !catalogReviewRoot && !approvalRoot && !deployRoot) return; + if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !opportunityRoot && !opportunityScoringRoot && !opportunityEvidenceRoot && !opportunityAlertRoot && !migrationRoot && !migrationDrillRoot && !catalogReviewRoot && !liveSmokeRoot && !approvalRoot && !deployRoot) return; const meta = root ? root.querySelector('[data-market-intel-preview-meta]') : null; const body = root ? root.querySelector('[data-market-intel-preview-body]') : null; @@ -788,6 +807,10 @@ const catalogReviewBody = catalogReviewRoot ? catalogReviewRoot.querySelector('[data-market-intel-catalog-review-body]') : null; const catalogReviewRefresh = catalogReviewRoot ? catalogReviewRoot.querySelector('[data-market-intel-catalog-review-refresh]') : null; const catalogReviewEndpoint = "{{ url_for('market_intel.market_intel_migration_catalog_review') }}?execute=false"; + const liveSmokeMeta = liveSmokeRoot ? liveSmokeRoot.querySelector('[data-market-intel-live-smoke-meta]') : null; + const liveSmokeBody = liveSmokeRoot ? liveSmokeRoot.querySelector('[data-market-intel-live-smoke-body]') : null; + const liveSmokeRefresh = liveSmokeRoot ? liveSmokeRoot.querySelector('[data-market-intel-live-smoke-refresh]') : null; + const liveSmokeEndpoint = "{{ url_for('market_intel.market_intel_migration_live_smoke') }}?execute=false"; const approvalMeta = approvalRoot ? approvalRoot.querySelector('[data-market-intel-approval-meta]') : null; const approvalBody = approvalRoot ? approvalRoot.querySelector('[data-market-intel-approval-body]') : null; const approvalRefresh = approvalRoot ? approvalRoot.querySelector('[data-market-intel-approval-refresh]') : null; @@ -2559,6 +2582,97 @@ } }; + const renderLiveSmokeMeta = data => { + liveSmokeMeta.innerHTML = [ + `mode=${data.mode || 'unknown'}`, + `result=${data.smoke_result || 'planned'}`, + `catalog=${data.catalog_state || 'unknown'}`, + `passed=${data.live_smoke_passed ? 'yes' : 'no'}`, + `writes=${data.database_write_executed ? 'yes' : 'no'}` + ].map(item => `${escapeHtml(item)}`).join(''); + }; + + const renderLiveSmokeBody = data => { + const blockers = (data.blocked_reasons || []).join(' / '); + const checks = Object.entries(data.safety_checks || {}); + const summary = data.catalog_review_summary || {}; + const tableCatalog = summary.table_catalog || {}; + const seedCatalog = summary.seed_catalog || {}; + const findings = summary.findings || []; + const steps = data.operator_next_steps || []; + const targets = data.manual_probe_targets || []; + const renderCheck = ([name, passed]) => ` +
+
+ ${escapeHtml(name)} +
+ ${passed ? 'PASS' : 'BLOCK'} +
+ `; + const renderNamedItem = item => ` +
+
+ ${escapeHtml(item.key || item.label || 'item')} + ${escapeHtml(item.label || item.key || '')} +
+ ${escapeHtml(item.status || item.severity || 'manual').toUpperCase()} +
+ `; + liveSmokeBody.innerHTML = ` +
此卡預設不跑正式 DB 探測;操作員手動啟動只讀 smoke 模式時,只讀查 catalog / seed diff,不執行 migration、不寫 DB、不跑 rollback。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}
+
tables=${escapeHtml(tableCatalog.existing_count || 0)}/${escapeHtml(tableCatalog.expected_count || 0)} / seed_missing=${escapeHtml((seedCatalog.missing_codes || []).length)} / seed_changed=${escapeHtml((seedCatalog.changed_codes || []).length)} / tolerated_seed_error=${data.seed_probe_error_tolerated ? 'yes' : 'no'}
+
+
+

SMOKE CHECKS

+
${ + checks.length + ? checks.map(renderCheck).join('') + : '
尚未提供 smoke checks。
' + }
+
+
+

FINDINGS

+
${ + findings.length + ? findings.map(renderNamedItem).join('') + : '
尚未提供 findings。
' + }
+
+
+

NEXT STEPS

+
${ + steps.length + ? steps.map(renderNamedItem).join('') + : '
尚未提供下一步。
' + }
+
+
+

MANUAL TARGETS

+
${ + targets.length + ? targets.map(item => renderNamedItem({ key: item, label: item, status: 'manual' })).join('') + : '
尚未提供 targets。
' + }
+
+
+ `; + }; + + const loadLiveSmoke = async () => { + if (!liveSmokeMeta || !liveSmokeBody) return; + liveSmokeBody.innerHTML = '
讀取正式 DB 只讀 smoke 中...
'; + try { + const response = await fetch(liveSmokeEndpoint, { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + renderLiveSmokeMeta(data); + renderLiveSmokeBody(data); + } catch (error) { + liveSmokeMeta.innerHTML = 'error'; + liveSmokeBody.innerHTML = `
正式 DB 只讀 smoke 讀取失敗:${escapeHtml(error.message)}
`; + } + }; + const renderApprovalMeta = data => { approvalMeta.innerHTML = [ `mode=${data.mode || 'unknown'}`, @@ -2775,6 +2889,9 @@ if (catalogReviewRefresh) { catalogReviewRefresh.addEventListener('click', loadCatalogReview); } + if (liveSmokeRefresh) { + liveSmokeRefresh.addEventListener('click', loadLiveSmoke); + } if (approvalRefresh) { approvalRefresh.addEventListener('click', loadApproval); } @@ -2800,6 +2917,7 @@ loadMigration(); loadMigrationDrill(); loadCatalogReview(); + loadLiveSmoke(); loadApproval(); loadDeploy(); })(); diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index b20e01e..7496ba4 100644 --- a/tests/test_market_intel_skeleton.py +++ b/tests/test_market_intel_skeleton.py @@ -492,6 +492,10 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "data-market-intel-catalog-review-checks" in template assert "data-market-intel-catalog-review-findings" in template assert "data-market-intel-catalog-review-tables" in template + assert "data-market-intel-live-smoke" in template + assert "data-market-intel-live-smoke-checks" in template + assert "data-market-intel-live-smoke-findings" in template + assert "data-market-intel-live-smoke-targets" in template assert "data-market-intel-approval" in template assert "data-market-intel-approval-gates" in template assert "data-market-intel-deploy" in template @@ -516,6 +520,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "market_intel.market_intel_migration_blueprint" in template assert "market_intel.market_intel_migration_apply_drill" in template assert "market_intel.market_intel_migration_catalog_review" in template + assert "market_intel.market_intel_migration_live_smoke" in template assert "market_intel.market_intel_write_approval_runbook" in template assert "market_intel.market_intel_deployment_readiness" in template assert "required_manual_steps" in template @@ -540,7 +545,7 @@ def test_legacy_source_bridge_default_is_planned_only(): bridge = MarketIntelService().build_legacy_source_bridge() assert bridge["mode"] == "legacy_source_bridge_planned" - assert bridge["phase"] == "phase_44_migration_catalog_review_preview" + assert bridge["phase"] == "phase_45_migration_live_smoke_preview" assert bridge["execute_requested"] is False assert bridge["read_only_query_executed"] is False assert bridge["database_connection_opened"] is False @@ -698,7 +703,7 @@ def test_mcp_tool_contract_preview_is_read_only_and_whitelisted(): contract = MarketIntelService().build_mcp_tool_contract() assert contract["mode"] == "mcp_tool_contract_preview" - assert contract["phase"] == "phase_44_migration_catalog_review_preview" + assert contract["phase"] == "phase_45_migration_live_smoke_preview" assert contract["caller"] == "market_intel" assert contract["contract_ready"] is True assert contract["blocked_reasons"] == [] @@ -831,7 +836,7 @@ def test_mcp_activation_runbook_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_activation_runbook_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -844,7 +849,7 @@ def test_mcp_fetch_gate_default_blocks_external_fetch(): gate = MarketIntelService().build_mcp_fetch_gate(fetch_requested=True) assert gate["mode"] == "mcp_fetch_gate_planned" - assert gate["phase"] == "phase_44_migration_catalog_review_preview" + assert gate["phase"] == "phase_45_migration_live_smoke_preview" assert gate["fetch_requested"] is True assert gate["manual_fetch_gate_open"] is False assert gate["network_request_allowed"] is False @@ -914,7 +919,7 @@ def test_mcp_fetch_gate_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_fetch_gate_planned" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["fetch_requested"] is False assert data["network_request_allowed"] is False assert data["external_network_executed"] is False @@ -926,7 +931,7 @@ def test_scheduler_plan_preview_blocks_job_attachment(): plan = MarketIntelService().build_scheduler_plan() assert plan["mode"] == "scheduler_attach_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_to_attach_scheduler"] is False assert plan["scheduler_attached"] is False assert plan["scheduler_registration_executed"] is False @@ -964,7 +969,7 @@ def test_scheduler_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "scheduler_attach_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["scheduler_registration_executed"] is False assert data["crawler_job_started"] is False assert data["external_network_executed"] is False @@ -975,7 +980,7 @@ def test_match_review_plan_preview_blocks_auto_confirm(): plan = MarketIntelService().build_match_review_plan() assert plan["mode"] == "match_review_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_for_review_queue"] is False assert plan["review_queue_created"] is False assert plan["auto_match_executed"] is False @@ -1011,7 +1016,7 @@ def test_match_review_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "match_review_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["review_queue_created"] is False assert data["auto_confirm_executed"] is False assert data["external_network_executed"] is False @@ -1022,7 +1027,7 @@ def test_opportunity_plan_preview_blocks_alerts_and_ai_summary(): plan = MarketIntelService().build_opportunity_plan() assert plan["mode"] == "opportunity_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_for_opportunity_queue"] is False assert plan["opportunity_queue_created"] is False assert plan["threat_alert_dispatched"] is False @@ -1063,7 +1068,7 @@ def test_opportunity_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["opportunity_queue_created"] is False assert data["threat_alert_dispatched"] is False assert data["ai_summary_generated"] is False @@ -1074,7 +1079,7 @@ def test_opportunity_scoring_plan_preview_blocks_scoring_and_alerts(): plan = MarketIntelService().build_opportunity_scoring_plan() assert plan["mode"] == "opportunity_scoring_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_for_scoring_job"] is False assert plan["scoring_job_created"] is False assert plan["score_calculation_executed"] is False @@ -1122,7 +1127,7 @@ def test_opportunity_scoring_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_scoring_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["scoring_job_created"] is False assert data["score_calculation_executed"] is False assert data["sample_scores_generated"] is False @@ -1134,7 +1139,7 @@ def test_opportunity_evidence_plan_preview_blocks_queries_and_alerts(): plan = MarketIntelService().build_opportunity_evidence_plan() assert plan["mode"] == "opportunity_evidence_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_for_evidence_bundle"] is False assert plan["evidence_bundle_created"] is False assert plan["evidence_query_executed"] is False @@ -1180,7 +1185,7 @@ def test_opportunity_evidence_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_evidence_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["evidence_bundle_created"] is False assert data["evidence_query_executed"] is False assert data["sample_evidence_generated"] is False @@ -1193,7 +1198,7 @@ def test_opportunity_alert_plan_preview_blocks_dispatch_and_llm_calls(): plan = MarketIntelService().build_opportunity_alert_plan() assert plan["mode"] == "opportunity_alert_plan_preview" - assert plan["phase"] == "phase_44_migration_catalog_review_preview" + assert plan["phase"] == "phase_45_migration_live_smoke_preview" assert plan["ready_for_alert_candidates"] is False assert plan["alert_candidate_created"] is False assert plan["alert_queue_created"] is False @@ -1278,7 +1283,7 @@ def test_opportunity_alert_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_alert_plan_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["alert_candidate_created"] is False assert data["alert_queue_created"] is False assert data["review_queue_created"] is False @@ -1356,7 +1361,7 @@ def test_mcp_deploy_preflight_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_external_deploy_preflight_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -1371,7 +1376,7 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch): readiness = MarketIntelService().build_mcp_readiness() assert readiness["mode"] == "mcp_readiness_planned" - assert readiness["phase"] == "phase_44_migration_catalog_review_preview" + assert readiness["phase"] == "phase_45_migration_live_smoke_preview" assert readiness["execute_requested"] is False assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False @@ -1766,6 +1771,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["checks"]["opportunity_alert_plan_preview_safe"] is True assert readiness["checks"]["migration_apply_drill_preview_safe"] is True assert readiness["checks"]["migration_catalog_review_preview_safe"] is True + assert readiness["checks"]["migration_live_smoke_preview_safe"] is True assert readiness["checks"]["writer_plan_dry_run_only"] is True assert readiness["writer_plan_summary"]["writes_executed"] is False assert "readiness_checks_not_all_passed" not in readiness["blocked_reasons"] @@ -1794,6 +1800,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert "/api/market_intel/opportunity_alert_plan" in readiness["production_smoke_targets"] assert "/api/market_intel/migration_apply_drill" in readiness["production_smoke_targets"] assert "/api/market_intel/migration_catalog_review" in readiness["production_smoke_targets"] + assert "/api/market_intel/migration_live_smoke" in readiness["production_smoke_targets"] assert readiness["write_approval_runbook"]["ready_for_real_write"] is False assert readiness["write_approval_runbook"]["writes_executed"] is False assert readiness["migration_blueprint"]["migration_executed"] is False @@ -1809,6 +1816,11 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["migration_catalog_review"]["migration_executed"] is False assert readiness["migration_catalog_review"]["database_write_executed"] is False assert readiness["migration_catalog_review"]["api_executes_migration"] is False + assert readiness["migration_live_smoke"]["mode"] == "migration_live_smoke_preview" + assert readiness["migration_live_smoke"]["smoke_result"] == "planned_no_execution" + assert readiness["migration_live_smoke"]["migration_executed"] is False + assert readiness["migration_live_smoke"]["database_write_executed"] is False + assert readiness["migration_live_smoke"]["api_executes_migration"] is False assert readiness["schema_db_probe"]["read_only_query_executed"] is False assert readiness["platform_seed_db_diff"]["read_only_query_executed"] is False assert readiness["legacy_source_bridge"]["read_only_query_executed"] is False @@ -1926,7 +1938,7 @@ def test_migration_apply_drill_planned_is_safe_and_manual_only(): drill = MarketIntelService().build_migration_apply_drill() assert drill["mode"] == "migration_apply_drill_preview" - assert drill["phase"] == "phase_44_migration_catalog_review_preview" + assert drill["phase"] == "phase_45_migration_live_smoke_preview" assert drill["execute_requested"] is False assert drill["schema_state"] == "planned_no_db_probe" assert drill["drill_ready_for_operator_review"] is True @@ -2041,7 +2053,7 @@ def test_migration_apply_drill_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "migration_apply_drill_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["execute_requested"] is False assert data["migration_executed"] is False assert data["rollback_executed"] is False @@ -2053,7 +2065,7 @@ def test_migration_catalog_review_planned_is_safe_and_diagnostic(): review = MarketIntelService().build_migration_catalog_review() assert review["mode"] == "migration_catalog_review_preview" - assert review["phase"] == "phase_44_migration_catalog_review_preview" + assert review["phase"] == "phase_45_migration_live_smoke_preview" assert review["execute_requested"] is False assert review["catalog_state"] == "planned_no_probe" assert review["seed_state"] == "planned_no_probe" @@ -2168,7 +2180,7 @@ def test_migration_catalog_review_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "migration_catalog_review_preview" - assert data["phase"] == "phase_44_migration_catalog_review_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" assert data["execute_requested"] is False assert data["catalog_state"] == "planned_no_probe" assert data["migration_executed"] is False @@ -2177,6 +2189,81 @@ def test_migration_catalog_review_route_is_preview_only(): assert data["api_executes_migration"] is False +def test_migration_live_smoke_planned_is_preview_only(): + smoke = MarketIntelService().build_migration_live_smoke() + + assert smoke["mode"] == "migration_live_smoke_preview" + assert smoke["phase"] == "phase_45_migration_live_smoke_preview" + assert smoke["execute_requested"] is False + assert smoke["smoke_result"] == "planned_no_execution" + assert smoke["live_smoke_passed"] is False + assert smoke["catalog_state"] == "planned_no_probe" + assert smoke["seed_state"] == "planned_no_probe" + assert smoke["read_only_probe_completed"] is False + assert smoke["database_connection_opened"] is False + assert smoke["database_session_created"] is False + assert smoke["database_write_executed"] is False + assert smoke["database_commit_executed"] is False + assert smoke["migration_executed"] is False + assert smoke["rollback_executed"] is False + assert smoke["api_executes_migration"] is False + assert "execute_false_planned_only" in smoke["blocked_reasons"] + assert "migration_not_executed_by_live_smoke" in smoke["blocked_reasons"] + + +def test_migration_live_smoke_sqlite_not_applied_tolerates_seed_table_missing(): + service = MarketIntelService() + engine = create_engine("sqlite:///:memory:") + + smoke = service.build_migration_live_smoke( + execute_requested=True, + schema_engine=engine, + seed_diff_engine=engine, + database_type="sqlite", + ) + + assert smoke["mode"] == "migration_live_smoke_preview" + assert smoke["execute_requested"] is True + assert smoke["read_only_query_executed"] is True + assert smoke["database_connection_opened"] is True + assert smoke["catalog_state"] == "not_applied" + assert smoke["seed_state"] == "probe_error" + assert smoke["seed_probe_error_tolerated"] is True + assert smoke["smoke_result"] == "passed_not_applied_seed_table_missing" + assert smoke["live_smoke_passed"] is True + assert smoke["read_only_probe_completed"] is True + assert smoke["ready_for_manual_migration_review"] is True + assert smoke["database_session_created"] is False + assert smoke["database_write_executed"] is False + assert smoke["database_commit_executed"] is False + assert smoke["migration_executed"] is False + assert smoke["catalog_review_summary"]["table_catalog"]["missing_count"] == 8 + + +def test_migration_live_smoke_route_is_preview_only(): + from routes.market_intel_routes import market_intel_bp + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(market_intel_bp) + client = app.test_client() + with client.session_transaction() as session: + session["logged_in"] = True + + response = client.get("/api/market_intel/migration_live_smoke?execute=false") + data = response.get_json() + + assert response.status_code == 200 + assert data["mode"] == "migration_live_smoke_preview" + assert data["phase"] == "phase_45_migration_live_smoke_preview" + assert data["execute_requested"] is False + assert data["smoke_result"] == "planned_no_execution" + assert data["migration_executed"] is False + assert data["rollback_executed"] is False + assert data["database_write_executed"] is False + assert data["api_executes_migration"] is False + + def test_seed_writer_cli_status_blocks_real_write(): status = MarketIntelService().build_seed_writer_cli_status( platform_code="all",