修復 Wave 0 阻塞與 market intel 入庫

This commit is contained in:
OoO
2026-05-12 22:49:56 +08:00
parent 26669a2c6a
commit fccc80858d
29 changed files with 4888 additions and 9 deletions

View File

@@ -11,6 +11,7 @@ from services.cache_manager import (
_SALES_PROCESSED_CACHE,
_SALES_OPTIONS_CACHE,
_SALES_ANALYSIS_RESULT_CACHE,
_SALES_CACHE_TTL,
_DASHBOARD_DATA_CACHE,
_DASHBOARD_CACHE_TTL,
clear_sales_cache,
@@ -20,10 +21,9 @@ from services.cache_manager import (
# 台北時區
TAIPEI_TZ = timezone(timedelta(hours=8))
# 快取 TTL 設定
_SALES_CACHE_TTL = 3600 # 業績分析快取: 60 分鐘
# 快取 TTL 設定sales TTL 以 cache_manager 為單一來源
_SALES_OPTIONS_TTL = 21600 # 選項快取: 6 小時
_SALES_RESULT_TTL = 3600 # 結果快取: 60 分鐘
_SALES_RESULT_TTL = _SALES_CACHE_TTL
# ==========================================
# 成長分析快取

View File

@@ -0,0 +1,9 @@
"""市場情報平台 adapter registry。"""
from services.market_intel.adapters.registry import (
get_adapter,
get_adapter_registry,
get_adapter_summaries,
)
__all__ = ["get_adapter", "get_adapter_registry", "get_adapter_summaries"]

View File

@@ -0,0 +1,93 @@
"""市場情報 adapter 基礎類別。
Phase 3 只提供 read-only discovery plan不發 HTTP request。
"""
from dataclasses import asdict, dataclass
from typing import Iterable, Sequence
@dataclass(frozen=True)
class CampaignSource:
"""公開活動入口的描述,不代表已實際爬取。"""
source_key: str
name: str
url: str
campaign_type: str
notes: str = ""
def to_dict(self):
return asdict(self)
@dataclass(frozen=True)
class AdapterSafetyPolicy:
"""平台爬取安全策略。"""
request_interval_sec: float
timeout_sec: int
max_pages_per_run: int
allow_login: bool = False
allow_database_write: bool = False
allow_scheduler_attach: bool = False
def to_dict(self):
return asdict(self)
class MarketIntelAdapter:
"""市場情報平台 adapter base class。"""
platform_code = ""
platform_name = ""
base_url = ""
campaign_url_keywords = ()
campaign_text_keywords = ()
safety_policy = AdapterSafetyPolicy(
request_interval_sec=2.0,
timeout_sec=20,
max_pages_per_run=5,
)
def campaign_sources(self) -> Sequence[CampaignSource]:
return ()
def summary(self):
return {
"platform_code": self.platform_code,
"platform_name": self.platform_name,
"base_url": self.base_url,
"source_count": len(self.campaign_sources()),
"safety_policy": self.safety_policy.to_dict(),
"phase": "read_only_adapter_skeleton",
}
def build_discovery_plan(self):
"""建立 discovery plan不發 request、不寫 DB。"""
return {
**self.summary(),
"network_request_allowed": False,
"database_write_allowed": False,
"scheduler_attach_allowed": False,
"sources": [source.to_dict() for source in self.campaign_sources()],
}
def discover_campaigns(self, *, dry_run=True) -> Iterable[CampaignSource]:
"""Phase 3 僅允許 dry-run 回傳入口描述。"""
if not dry_run:
raise RuntimeError("市場情報 adapter 尚未允許正式 discovery")
return self.campaign_sources()
def score_campaign_link(self, href, text):
"""平台別活動連結加權,只用於診斷排序。"""
url_text = (href or "").lower()
link_text = (text or "").lower()
score = 0
for keyword in self.campaign_url_keywords:
if str(keyword).lower() in url_text:
score += 4
for keyword in self.campaign_text_keywords:
if str(keyword).lower() in link_text:
score += 3
return score

View File

@@ -0,0 +1,54 @@
"""Coupang 市場活動 read-only adapter skeleton。"""
from services.market_intel.adapters.base import (
AdapterSafetyPolicy,
CampaignSource,
MarketIntelAdapter,
)
class CoupangMarketAdapter(MarketIntelAdapter):
platform_code = "coupang"
platform_name = "Coupang 酷澎"
base_url = "https://www.tw.coupang.com"
campaign_url_keywords = (
"tw.coupang.com",
"/np/",
"coupangglobal",
"promotion",
"event",
"sale",
)
campaign_text_keywords = (
"coupang",
"酷澎",
"活動",
"優惠",
"折扣",
"特價",
"今日精選",
"火箭",
)
safety_policy = AdapterSafetyPolicy(
request_interval_sec=2.5,
timeout_sec=25,
max_pages_per_run=5,
)
def campaign_sources(self):
return (
CampaignSource(
source_key="coupang_home",
name="Coupang 酷澎首頁入口",
url="https://www.tw.coupang.com/",
campaign_type="homepage_campaign",
notes="只保存官方公開首頁入口;正式抓取需另開 feature flag。",
),
CampaignSource(
source_key="coupang_global",
name="Coupang 火箭跨境入口",
url="https://www.tw.coupang.com/np/coupangglobal",
campaign_type="cross_border_sale",
notes="先作為跨境/火箭相關活動 discovery 起點,不代表已建立活動。",
),
)

View File

@@ -0,0 +1,54 @@
"""MOMO 市場活動 read-only adapter skeleton。"""
from services.market_intel.adapters.base import (
AdapterSafetyPolicy,
CampaignSource,
MarketIntelAdapter,
)
class MomoMarketAdapter(MarketIntelAdapter):
platform_code = "momo"
platform_name = "MOMO 購物網"
base_url = "https://www.momoshop.com.tw"
campaign_url_keywords = (
"edm",
"cmmedm",
"lgrpcategory",
"category",
"promo",
"event",
)
campaign_text_keywords = (
"momo",
"活動",
"優惠",
"限時",
"限時搶購",
"品牌日",
"滿額",
"折價券",
)
safety_policy = AdapterSafetyPolicy(
request_interval_sec=2.0,
timeout_sec=25,
max_pages_per_run=8,
)
def campaign_sources(self):
return (
CampaignSource(
source_key="momo_edm",
name="MOMO EDM 活動入口",
url="https://www.momoshop.com.tw/edm/cmmedm.jsp",
campaign_type="festival",
notes="先接既有 EDM / festival 爬蟲語意,正式抓取需另開 feature flag。",
),
CampaignSource(
source_key="momo_flash_sale",
name="MOMO 限時搶購入口",
url="https://www.momoshop.com.tw/category/LgrpCategory.jsp?l_code=2140000000",
campaign_type="flash_sale",
notes="只保存公開入口描述Phase 3 不發 request。",
),
)

View File

@@ -0,0 +1,54 @@
"""PChome 市場活動 read-only adapter skeleton。"""
from services.market_intel.adapters.base import (
AdapterSafetyPolicy,
CampaignSource,
MarketIntelAdapter,
)
class PChomeMarketAdapter(MarketIntelAdapter):
platform_code = "pchome"
platform_name = "PChome 24h"
base_url = "https://24h.pchome.com.tw"
campaign_url_keywords = (
"24h.pchome.com.tw",
"/activity",
"/campaign",
"/region/",
"promo",
"event",
)
campaign_text_keywords = (
"pchome",
"24h",
"活動",
"優惠",
"限時",
"品牌日",
"特價",
"折扣",
)
safety_policy = AdapterSafetyPolicy(
request_interval_sec=1.5,
timeout_sec=20,
max_pages_per_run=8,
)
def campaign_sources(self):
return (
CampaignSource(
source_key="pchome_home",
name="PChome 24h 首頁活動入口",
url="https://24h.pchome.com.tw/",
campaign_type="homepage_campaign",
notes="可作為活動 banner / 分類入口 discovery 起點。",
),
CampaignSource(
source_key="pchome_region_beauty",
name="PChome 美妝保養館別入口",
url="https://24h.pchome.com.tw/region/DDAB",
campaign_type="category_sale",
notes="延續既有 PChome crawler 的 region page 低成本入口。",
),
)

View File

@@ -0,0 +1,29 @@
"""市場情報 adapter 註冊表。"""
from services.market_intel.adapters.coupang_adapter import CoupangMarketAdapter
from services.market_intel.adapters.momo_adapter import MomoMarketAdapter
from services.market_intel.adapters.pchome_adapter import PChomeMarketAdapter
from services.market_intel.adapters.shopee_adapter import ShopeeMarketAdapter
ADAPTER_CLASSES = {
"momo": MomoMarketAdapter,
"pchome": PChomeMarketAdapter,
"coupang": CoupangMarketAdapter,
"shopee": ShopeeMarketAdapter,
}
def get_adapter(platform_code):
adapter_class = ADAPTER_CLASSES.get((platform_code or "").lower())
if not adapter_class:
return None
return adapter_class()
def get_adapter_registry():
return {code: adapter_class() for code, adapter_class in ADAPTER_CLASSES.items()}
def get_adapter_summaries():
return [adapter.summary() for adapter in get_adapter_registry().values()]

View File

@@ -0,0 +1,57 @@
"""Shopee 市場活動 read-only adapter skeleton。"""
from services.market_intel.adapters.base import (
AdapterSafetyPolicy,
CampaignSource,
MarketIntelAdapter,
)
class ShopeeMarketAdapter(MarketIntelAdapter):
platform_code = "shopee"
platform_name = "Shopee 蝦皮購物"
base_url = "https://shopee.tw"
campaign_url_keywords = (
"shopee.tw",
"/mall",
"campaign",
"event",
"flash_sale",
"promotion",
"sale",
)
campaign_text_keywords = (
"shopee",
"蝦皮",
"商城",
"活動",
"優惠",
"折扣",
"特價",
"限時",
"品牌",
"免運",
)
safety_policy = AdapterSafetyPolicy(
request_interval_sec=4.0,
timeout_sec=25,
max_pages_per_run=3,
)
def campaign_sources(self):
return (
CampaignSource(
source_key="shopee_home",
name="Shopee 蝦皮首頁入口",
url="https://shopee.tw/",
campaign_type="homepage_campaign",
notes="只保存公開首頁入口;不得登入、碰會員券、購物車或反爬繞過。",
),
CampaignSource(
source_key="shopee_mall",
name="Shopee Mall 商城入口",
url="https://shopee.tw/mall",
campaign_type="brand_mall",
notes="只作為商城/品牌活動 discovery 起點;正式抓取需另開 feature flag。",
),
)

View File

@@ -0,0 +1,81 @@
"""市場情報候選連結 preview 聚合。
只整理本次 diagnostics 結果供人工審核,不建立 campaign/product不寫 DB。
"""
BAND_RANK = {
"high": 3,
"medium": 2,
"low": 1,
}
def _band_allowed(candidate, min_band):
if not min_band or min_band == "all":
return True
candidate_rank = BAND_RANK.get(candidate.get("confidence_band"), 0)
min_rank = BAND_RANK.get(min_band, 0)
return candidate_rank >= min_rank
def build_candidate_preview_from_discovery(discovery_result, *, min_band="all", limit=50):
"""把 manual discovery diagnostics 整理成人工審核 preview。"""
candidates = []
run_statuses = []
for run in discovery_result.get("runs", []):
platform_code = run.get("platform_code")
run_statuses.append({
"platform_code": platform_code,
"status": run.get("status"),
"sources_planned": run.get("sources_planned", 0),
"sources_fetched": run.get("sources_fetched", 0),
"errors": run.get("errors", 0),
})
for source_result in run.get("results", []):
diagnostics = source_result.get("diagnostics") or {}
for candidate in diagnostics.get("campaign_link_candidates", []):
if not _band_allowed(candidate, min_band):
continue
candidates.append({
"platform_code": platform_code,
"source_key": source_result.get("source_key"),
"source_name": source_result.get("name"),
"source_url": source_result.get("url"),
"source_status": source_result.get("status"),
"page_title": diagnostics.get("title"),
"page_hash": diagnostics.get("page_hash"),
"href": candidate.get("href"),
"text": candidate.get("text"),
"is_same_host": candidate.get("is_same_host"),
"score": candidate.get("score", 0),
"generic_score": candidate.get("generic_score", 0),
"platform_score": candidate.get("platform_score", 0),
"confidence_band": candidate.get("confidence_band"),
"confidence_reason": candidate.get("confidence_reason"),
})
candidates = sorted(
candidates,
key=lambda item: (
BAND_RANK.get(item.get("confidence_band"), 0),
item.get("score", 0),
item.get("platform_score", 0),
),
reverse=True,
)
return {
"platform_code": discovery_result.get("platform_code", "all"),
"fetch_requested": bool(discovery_result.get("fetch_requested")),
"manual_fetch_allowed": bool(discovery_result.get("manual_fetch_allowed")),
"min_band": min_band or "all",
"limit": limit,
"candidate_count": len(candidates),
"candidates": candidates[:limit],
"run_statuses": run_statuses,
"database_write_allowed": False,
"scheduler_attached": False,
}

View File

@@ -0,0 +1,215 @@
"""市場情報手動 discovery dry-run runner。
預設不發 HTTP request即使手動 fetch也只做公開頁面探測與摘要不寫 DB。
"""
import hashlib
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional
from uuid import uuid4
import requests
from services.market_intel.html_diagnostics import parse_html_diagnostics
TAIPEI_TZ = timezone(timedelta(hours=8))
DEFAULT_HEADERS = {
"User-Agent": "EwoooC-MarketIntel-DryRun/1.0 (+https://mo.wooo.work)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.8",
}
@dataclass(frozen=True)
class DiscoverySourceResult:
"""單一活動入口的 dry-run 結果。"""
source_key: str
name: str
url: str
campaign_type: str
status: str
network_requested: bool
network_executed: bool
status_code: Optional[int] = None
content_length: int = 0
page_hash: Optional[str] = None
title: Optional[str] = None
diagnostics: Optional[dict] = None
error_message: Optional[str] = None
def to_dict(self):
return asdict(self)
@dataclass(frozen=True)
class ManualDiscoveryRunResult:
"""手動 discovery dry-run 的整體結果。"""
batch_id: str
platform_code: str
started_at: str
finished_at: str
status: str
fetch_requested: bool
network_allowed: bool
database_write_allowed: bool
scheduler_attached: bool
sources_planned: int
sources_fetched: int
errors: int
results: list
error_message: Optional[str] = None
def to_dict(self):
return asdict(self)
def _now_iso():
return datetime.now(TAIPEI_TZ).replace(tzinfo=None).isoformat()
class ManualDiscoveryRunner:
"""手動 discovery runner透過 feature gate 控制是否允許網路探測。"""
def __init__(self, *, runtime_status, http_get: Optional[Callable] = None):
self.runtime_status = runtime_status
self.http_get = http_get or requests.get
def _network_allowed(self):
return bool(self.runtime_status.enabled and self.runtime_status.crawler_enabled)
def run(self, adapter, *, fetch=False):
started_at = _now_iso()
sources = list(adapter.campaign_sources())
network_allowed = self._network_allowed()
if fetch and not network_allowed:
return ManualDiscoveryRunResult(
batch_id=f"market-manual-{uuid4().hex[:12]}",
platform_code=adapter.platform_code,
started_at=started_at,
finished_at=_now_iso(),
status="blocked",
fetch_requested=True,
network_allowed=False,
database_write_allowed=False,
scheduler_attached=False,
sources_planned=len(sources),
sources_fetched=0,
errors=0,
results=[
self._source_result(source, "blocked", True, False).to_dict()
for source in sources
],
error_message="MARKET_INTEL_ENABLED 與 MARKET_INTEL_CRAWLER_ENABLED 必須同時開啟才允許手動 fetch",
)
capped_sources = sources[:adapter.safety_policy.max_pages_per_run]
results = []
errors = 0
fetched = 0
last_request_at = 0.0
for source in capped_sources:
if not fetch:
results.append(self._source_result(source, "planned", False, False).to_dict())
continue
elapsed = time.time() - last_request_at
if last_request_at and elapsed < adapter.safety_policy.request_interval_sec:
time.sleep(adapter.safety_policy.request_interval_sec - elapsed)
try:
response = self.http_get(
source.url,
headers=DEFAULT_HEADERS,
timeout=adapter.safety_policy.timeout_sec,
)
last_request_at = time.time()
text = response.text or ""
fetched += 1
results.append(
self._source_result(
source,
"fetched",
True,
True,
status_code=getattr(response, "status_code", None),
content=text,
score_link=adapter.score_campaign_link,
).to_dict()
)
except Exception as exc:
last_request_at = time.time()
errors += 1
results.append(
self._source_result(
source,
"failed",
True,
True,
error_message=str(exc),
).to_dict()
)
status = "planned"
if fetch:
status = "success" if errors == 0 else "partial_failed"
return ManualDiscoveryRunResult(
batch_id=f"market-manual-{uuid4().hex[:12]}",
platform_code=adapter.platform_code,
started_at=started_at,
finished_at=_now_iso(),
status=status,
fetch_requested=bool(fetch),
network_allowed=network_allowed,
database_write_allowed=False,
scheduler_attached=False,
sources_planned=len(sources),
sources_fetched=fetched,
errors=errors,
results=results,
)
def _source_result(
self,
source,
status,
network_requested,
network_executed,
*,
status_code=None,
content=None,
score_link=None,
error_message=None,
):
content = content or ""
diagnostics = (
parse_html_diagnostics(
content,
base_url=source.url,
score_link=score_link,
).to_dict()
if content
else None
)
return DiscoverySourceResult(
source_key=source.source_key,
name=source.name,
url=source.url,
campaign_type=source.campaign_type,
status=status,
network_requested=network_requested,
network_executed=network_executed,
status_code=status_code,
content_length=len(content),
page_hash=hashlib.sha256(content.encode("utf-8", errors="ignore")).hexdigest() if content else None,
title=diagnostics.get("title") if diagnostics else None,
diagnostics=diagnostics,
error_message=error_message,
)

View File

@@ -0,0 +1,172 @@
"""市場情報 HTML 診斷解析工具。
只萃取頁面標題、連結候選與內容指紋,不建立正式 campaign/product。
"""
import hashlib
import re
from dataclasses import asdict, dataclass
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
@dataclass(frozen=True)
class LinkCandidate:
"""活動頁中可疑連結候選。"""
href: str
text: str
is_same_host: bool
score: int
generic_score: int
platform_score: int
confidence_band: str
confidence_reason: str
def to_dict(self):
return asdict(self)
@dataclass(frozen=True)
class HtmlDiagnostics:
"""HTML 診斷摘要。"""
content_length: int
page_hash: str
title: str
link_count: int
same_host_link_count: int
campaign_link_candidates: list
def to_dict(self):
return asdict(self)
class _DiagnosticHtmlParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.title_parts = []
self.links = []
self._in_title = False
self._active_href = None
self._active_text = []
def handle_starttag(self, tag, attrs):
attrs_map = dict(attrs or [])
if tag.lower() == "title":
self._in_title = True
if tag.lower() == "a":
self._active_href = attrs_map.get("href")
self._active_text = []
def handle_data(self, data):
if self._in_title:
self.title_parts.append(data)
if self._active_href is not None:
self._active_text.append(data)
def handle_endtag(self, tag):
tag = tag.lower()
if tag == "title":
self._in_title = False
if tag == "a" and self._active_href is not None:
self.links.append((self._active_href, " ".join(self._active_text)))
self._active_href = None
self._active_text = []
def _clean_text(value, limit=160):
text = re.sub(r"\s+", " ", value or "").strip()
return text[:limit]
def _score_link(href, text):
haystack = f"{href} {text}".lower()
score = 0
for keyword in ("edm", "event", "promo", "campaign", "sale", "activity"):
if keyword in haystack:
score += 2
for keyword in ("活動", "優惠", "折扣", "檔期", "品牌日", "限時", "促銷"):
if keyword in haystack:
score += 3
return score
def _confidence_band(score, *, is_same_host, platform_score, generic_score):
"""把診斷分數轉成人工審核用信心帶。"""
reasons = []
reasons.append("same_host" if is_same_host else "external_host")
if platform_score > 0:
reasons.append(f"platform_score={platform_score}")
if generic_score > 0:
reasons.append(f"generic_score={generic_score}")
if score >= 12 and is_same_host:
return "high", ", ".join(reasons)
if score >= 6:
return "medium", ", ".join(reasons)
return "low", ", ".join(reasons)
def parse_html_diagnostics(html, base_url="", candidate_limit=12, score_link=None):
"""解析 HTML 診斷資訊,不做商業資料入庫。"""
html = html or ""
parser = _DiagnosticHtmlParser()
parser.feed(html)
base_host = urlparse(base_url or "").netloc
candidates = []
same_host_count = 0
for raw_href, raw_text in parser.links:
if not raw_href:
continue
href = urljoin(base_url, raw_href)
parsed = urlparse(href)
if parsed.scheme not in {"http", "https"}:
continue
is_same_host = bool(base_host and parsed.netloc == base_host)
if is_same_host:
same_host_count += 1
text = _clean_text(raw_text)
generic_score = _score_link(href, text)
platform_score = int(score_link(href, text)) if score_link else 0
score = generic_score + platform_score
if score <= 0:
continue
confidence_band, confidence_reason = _confidence_band(
score,
is_same_host=is_same_host,
platform_score=platform_score,
generic_score=generic_score,
)
candidates.append(
LinkCandidate(
href=href,
text=text,
is_same_host=is_same_host,
score=score,
generic_score=generic_score,
platform_score=platform_score,
confidence_band=confidence_band,
confidence_reason=confidence_reason,
)
)
band_rank = {"high": 3, "medium": 2, "low": 1}
candidates = sorted(
candidates,
key=lambda item: (band_rank.get(item.confidence_band, 0), item.score),
reverse=True,
)[:candidate_limit]
page_hash = hashlib.sha256(html.encode("utf-8", errors="ignore")).hexdigest() if html else ""
title = _clean_text(" ".join(parser.title_parts), limit=200)
return HtmlDiagnostics(
content_length=len(html),
page_hash=page_hash,
title=title,
link_count=len(parser.links),
same_host_link_count=same_host_count,
campaign_link_candidates=[candidate.to_dict() for candidate in candidates],
)

View File

@@ -0,0 +1,381 @@
"""市場情報 migration 與正式 seed writer 命令草案。
本模組只產生可審核的 SQL / command preview不連線 DB、不執行命令。
"""
from pathlib import Path
MIGRATION_NUMBER = "032"
MIGRATION_FILENAME = "migrations/032_market_intel_core_schema.sql"
SEED_WRITER_SCRIPT = "scripts/market_intel_seed_writer.py"
FORWARD_SQL = """
-- =============================================================================
-- Migration 032: market_intel core schema
-- MOMO PRO - Cross-platform market campaign intelligence
-- 2026-05-07 Taipei
-- =============================================================================
-- Notes:
-- Creates the ADR-035 market_* schema. This migration is additive only:
-- it creates tables, indexes, and grants. It does not drop or alter existing
-- sales tables, and it does not touch the momo-db container lifecycle.
-- =============================================================================
CREATE TABLE IF NOT EXISTS market_platforms (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
base_url VARCHAR(500),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
crawl_policy_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_market_platforms_code
ON market_platforms (code);
CREATE TABLE IF NOT EXISTS market_campaigns (
id BIGSERIAL PRIMARY KEY,
platform_code VARCHAR(50) NOT NULL REFERENCES market_platforms(code),
campaign_key VARCHAR(200) NOT NULL,
campaign_name VARCHAR(500) NOT NULL,
campaign_type VARCHAR(80),
campaign_url TEXT,
start_at TIMESTAMP,
end_at TIMESTAMP,
status VARCHAR(30) NOT NULL DEFAULT 'unknown',
discovered_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
metadata_json TEXT,
CONSTRAINT uq_market_campaign_platform_key
UNIQUE (platform_code, campaign_key)
);
CREATE INDEX IF NOT EXISTS idx_market_campaigns_platform_code
ON market_campaigns (platform_code);
CREATE INDEX IF NOT EXISTS idx_market_campaigns_campaign_type
ON market_campaigns (campaign_type);
CREATE INDEX IF NOT EXISTS idx_market_campaigns_status
ON market_campaigns (status);
CREATE INDEX IF NOT EXISTS idx_market_campaign_status_time
ON market_campaigns (status, start_at, end_at);
CREATE TABLE IF NOT EXISTS market_campaign_snapshots (
id BIGSERIAL PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES market_campaigns(id),
batch_id VARCHAR(80) NOT NULL,
crawled_at TIMESTAMP NOT NULL DEFAULT NOW(),
title VARCHAR(500),
hero_text TEXT,
coupon_text TEXT,
raw_discount_text TEXT,
page_hash VARCHAR(128),
raw_snapshot_path TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'success',
error_message TEXT,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshots_campaign_id
ON market_campaign_snapshots (campaign_id);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshots_batch_id
ON market_campaign_snapshots (batch_id);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshots_crawled_at
ON market_campaign_snapshots (crawled_at);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshots_page_hash
ON market_campaign_snapshots (page_hash);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshots_status
ON market_campaign_snapshots (status);
CREATE INDEX IF NOT EXISTS idx_market_campaign_snapshot_campaign_time
ON market_campaign_snapshots (campaign_id, crawled_at);
CREATE TABLE IF NOT EXISTS market_campaign_products (
id BIGSERIAL PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES market_campaigns(id),
platform_code VARCHAR(50) NOT NULL,
platform_product_id VARCHAR(200) NOT NULL,
product_url TEXT,
name VARCHAR(500) NOT NULL,
brand VARCHAR(200),
image_url TEXT,
category_text VARCHAR(300),
price DOUBLE PRECISION,
original_price DOUBLE PRECISION,
discount_text VARCHAR(200),
discount_rate DOUBLE PRECISION,
coupon_text TEXT,
stock_text VARCHAR(200),
sold_count INTEGER,
rating DOUBLE PRECISION,
review_count INTEGER,
rank_position INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
first_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMP NOT NULL DEFAULT NOW(),
metadata_json TEXT,
CONSTRAINT uq_market_campaign_product
UNIQUE (campaign_id, platform_code, platform_product_id)
);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_campaign_id
ON market_campaign_products (campaign_id);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_platform_code
ON market_campaign_products (platform_code);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_platform_product_id
ON market_campaign_products (platform_product_id);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_brand
ON market_campaign_products (brand);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_category_text
ON market_campaign_products (category_text);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_is_active
ON market_campaign_products (is_active);
CREATE INDEX IF NOT EXISTS idx_market_campaign_products_last_seen_at
ON market_campaign_products (last_seen_at);
CREATE INDEX IF NOT EXISTS idx_market_product_platform_seen
ON market_campaign_products (platform_code, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_market_product_discount
ON market_campaign_products (discount_rate, price);
CREATE TABLE IF NOT EXISTS market_product_price_history (
id BIGSERIAL PRIMARY KEY,
market_product_id BIGINT NOT NULL REFERENCES market_campaign_products(id),
campaign_id BIGINT NOT NULL REFERENCES market_campaigns(id),
platform_code VARCHAR(50) NOT NULL,
platform_product_id VARCHAR(200) NOT NULL,
price DOUBLE PRECISION,
original_price DOUBLE PRECISION,
discount_rate DOUBLE PRECISION,
stock_text VARCHAR(200),
sold_count INTEGER,
rank_position INTEGER,
crawled_at TIMESTAMP NOT NULL DEFAULT NOW(),
batch_id VARCHAR(80) NOT NULL,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_market_product_id
ON market_product_price_history (market_product_id);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_campaign_id
ON market_product_price_history (campaign_id);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_platform_code
ON market_product_price_history (platform_code);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_platform_product_id
ON market_product_price_history (platform_product_id);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_crawled_at
ON market_product_price_history (crawled_at);
CREATE INDEX IF NOT EXISTS idx_market_product_price_history_batch_id
ON market_product_price_history (batch_id);
CREATE INDEX IF NOT EXISTS idx_market_price_platform_time
ON market_product_price_history (platform_code, platform_product_id, crawled_at);
CREATE INDEX IF NOT EXISTS idx_market_price_campaign_time
ON market_product_price_history (campaign_id, crawled_at);
CREATE TABLE IF NOT EXISTS market_product_matches (
id BIGSERIAL PRIMARY KEY,
market_product_id BIGINT NOT NULL REFERENCES market_campaign_products(id),
momo_product_id INTEGER REFERENCES products(id),
momo_i_code VARCHAR(50),
match_score DOUBLE PRECISION NOT NULL DEFAULT 0.0,
match_status VARCHAR(30) NOT NULL DEFAULT 'needs_review',
match_reason_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by VARCHAR(120),
CONSTRAINT uq_market_product_momo_match
UNIQUE (market_product_id, momo_i_code)
);
CREATE INDEX IF NOT EXISTS idx_market_product_matches_market_product_id
ON market_product_matches (market_product_id);
CREATE INDEX IF NOT EXISTS idx_market_product_matches_momo_product_id
ON market_product_matches (momo_product_id);
CREATE INDEX IF NOT EXISTS idx_market_product_matches_momo_i_code
ON market_product_matches (momo_i_code);
CREATE INDEX IF NOT EXISTS idx_market_product_matches_match_status
ON market_product_matches (match_status);
CREATE INDEX IF NOT EXISTS idx_market_match_status_score
ON market_product_matches (match_status, match_score);
CREATE TABLE IF NOT EXISTS market_crawler_runs (
id BIGSERIAL PRIMARY KEY,
platform_code VARCHAR(50),
crawler_name VARCHAR(120) NOT NULL,
campaign_id BIGINT REFERENCES market_campaigns(id),
batch_id VARCHAR(80) NOT NULL,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
finished_at TIMESTAMP,
status VARCHAR(30) NOT NULL DEFAULT 'started',
dry_run BOOLEAN NOT NULL DEFAULT TRUE,
pages_found INTEGER NOT NULL DEFAULT 0,
products_found INTEGER NOT NULL DEFAULT 0,
products_changed INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
metadata_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_platform_code
ON market_crawler_runs (platform_code);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_crawler_name
ON market_crawler_runs (crawler_name);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_campaign_id
ON market_crawler_runs (campaign_id);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_batch_id
ON market_crawler_runs (batch_id);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_started_at
ON market_crawler_runs (started_at);
CREATE INDEX IF NOT EXISTS idx_market_crawler_runs_status
ON market_crawler_runs (status);
CREATE INDEX IF NOT EXISTS idx_market_crawler_run_platform_time
ON market_crawler_runs (platform_code, started_at);
CREATE INDEX IF NOT EXISTS idx_market_crawler_run_status_time
ON market_crawler_runs (status, started_at);
GRANT ALL PRIVILEGES ON market_platforms TO momo;
GRANT ALL PRIVILEGES ON market_campaigns TO momo;
GRANT ALL PRIVILEGES ON market_campaign_snapshots TO momo;
GRANT ALL PRIVILEGES ON market_campaign_products TO momo;
GRANT ALL PRIVILEGES ON market_product_price_history TO momo;
GRANT ALL PRIVILEGES ON market_product_matches TO momo;
GRANT ALL PRIVILEGES ON market_crawler_runs TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_platforms_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_campaigns_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_campaign_snapshots_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_campaign_products_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_product_price_history_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_product_matches_id_seq TO momo;
GRANT USAGE, SELECT ON SEQUENCE market_crawler_runs_id_seq TO momo;
""".strip()
ROLLBACK_SQL = """
-- Manual rollback draft only. Do not run without operator approval and backup verification.
DROP TABLE IF EXISTS market_crawler_runs;
DROP TABLE IF EXISTS market_product_matches;
DROP TABLE IF EXISTS market_product_price_history;
DROP TABLE IF EXISTS market_campaign_products;
DROP TABLE IF EXISTS market_campaign_snapshots;
DROP TABLE IF EXISTS market_campaigns;
DROP TABLE IF EXISTS market_platforms;
""".strip()
def _statement_count(sql_text):
return len([part for part in sql_text.split(";") if part.strip()])
def _contains_destructive_forward_sql(sql_text):
lowered = sql_text.lower()
destructive_markers = (
"drop table",
"truncate ",
"delete from",
"alter table products",
"alter table daily_sales",
"alter table monthly_sales",
)
return any(marker in lowered for marker in destructive_markers)
def build_migration_blueprint(expected_tables):
"""建立 market_intel migration 與真寫入命令草案;不執行任何 SQL。"""
expected_tables = list(expected_tables)
migration_path = Path(MIGRATION_FILENAME)
seed_writer_path = Path(SEED_WRITER_SCRIPT)
migration_file_exists = migration_path.exists()
seed_writer_script_exists = seed_writer_path.exists()
migration_file_text = (
migration_path.read_text(encoding="utf-8").strip()
if migration_file_exists
else ""
)
migration_file_matches_blueprint = (
migration_file_exists and migration_file_text == FORWARD_SQL
)
forward_has_destructive_sql = _contains_destructive_forward_sql(FORWARD_SQL)
blocked_reasons = [
"migration_not_executed",
"backup_not_verified",
"operator_approval_missing",
"production_maintenance_window_required",
"seed_writer_real_write_not_implemented",
]
if not migration_file_exists:
blocked_reasons.insert(0, "migration_file_not_created")
elif not migration_file_matches_blueprint:
blocked_reasons.insert(0, "migration_file_differs_from_blueprint")
return {
"mode": "migration_file_draft_read_only",
"migration_number": MIGRATION_NUMBER,
"suggested_filename": MIGRATION_FILENAME,
"file_created": migration_file_exists,
"file_matches_blueprint": migration_file_matches_blueprint,
"file_status": (
"local_draft_matches_blueprint"
if migration_file_matches_blueprint
else "local_draft_differs_from_blueprint"
if migration_file_exists
else "not_created"
),
"migration_executed": False,
"database_session_created": False,
"database_commit_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"expected_tables": expected_tables,
"table_count": len(expected_tables),
"forward_sql": FORWARD_SQL,
"forward_statement_count": _statement_count(FORWARD_SQL),
"forward_has_destructive_sql": forward_has_destructive_sql,
"rollback_sql": ROLLBACK_SQL,
"rollback_requires_manual_approval": True,
"rollback_statement_count": _statement_count(ROLLBACK_SQL),
"blocked_reasons": blocked_reasons,
"table_operations": [
{
"table": table_name,
"operation": "CREATE TABLE IF NOT EXISTS",
"write_status": "preview_only_not_executed",
}
for table_name in expected_tables
],
"command_plan": {
"migration_apply_command": {
"command": (
"psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 "
f"-f {MIGRATION_FILENAME}"
),
"executed": False,
"requires_backup": True,
"requires_adr011_deploy_boundary_review": True,
},
"seed_writer_command": {
"command": (
"MARKET_INTEL_ENABLED=true MARKET_INTEL_WRITE_ENABLED=true "
"MARKET_INTEL_CRAWLER_ENABLED=false "
"MARKET_INTEL_SEED_WRITE_APPROVAL=<one_time_token> "
f"python {SEED_WRITER_SCRIPT} --execute --platform all"
),
"executed": False,
"script_created": seed_writer_script_exists,
"script_path": SEED_WRITER_SCRIPT,
"requires_new_approval_token": True,
"notes": (
"Seed writer skeleton 已存在,但真寫入仍未實作;不要為了 "
"seed upsert 而打開 crawler/manual fetch 權限。"
),
},
},
"safety_checks": {
"forward_sql_additive_only": not forward_has_destructive_sql,
"does_not_touch_momo_db_container": True,
"does_not_attach_scheduler": True,
"does_not_enable_external_crawling": True,
"does_not_write_seed_rows": True,
},
}

View File

@@ -0,0 +1,43 @@
"""市場情報平台種子資料規劃。
本模組只產生可審核的 seed plan不執行資料庫寫入。
"""
from services.market_intel.adapters import get_adapter, get_adapter_registry
def _adapter_to_seed(adapter):
policy = adapter.safety_policy.to_dict()
sources = [source.to_dict() for source in adapter.campaign_sources()]
return {
"code": adapter.platform_code,
"name": adapter.platform_name,
"base_url": adapter.base_url,
"enabled": False,
"crawl_policy_json": {
"request_interval_sec": policy["request_interval_sec"],
"timeout_sec": policy["timeout_sec"],
"max_pages_per_run": policy["max_pages_per_run"],
"allow_login": policy["allow_login"],
"allow_database_write": policy["allow_database_write"],
"allow_scheduler_attach": policy["allow_scheduler_attach"],
"seed_source_keys": [source["source_key"] for source in sources],
},
"source_count": len(sources),
"sources": sources,
"write_action": "upsert_market_platform_after_gate_approval",
}
def build_platform_seed_rows(platform_code="all"):
"""根據已註冊 adapter 建立平台 seed rows不碰 DB。"""
if platform_code and platform_code != "all":
adapter = get_adapter(platform_code)
if not adapter:
return []
return [_adapter_to_seed(adapter)]
return [
_adapter_to_seed(adapter)
for adapter in get_adapter_registry().values()
]

View File

@@ -0,0 +1,238 @@
"""市場情報平台 seed 與正式 DB 的只讀差異探針。
本模組只查詢 market_platforms 既有 rows不使用 DatabaseManager、不建立 ORM session、
不開 explicit transaction、不寫入、不 commit。
"""
import json
from sqlalchemy import bindparam, create_engine, text
def _normalize_policy(value):
"""把 seed policy 與 DB text policy 正規化成可比較字串。"""
if value in (None, ""):
return ""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return value.strip()
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _build_planned_seed_diffs(seed_rows):
return [
{
"code": seed["code"],
"exists": False,
"expected_enabled": bool(seed.get("enabled", False)),
"actual_enabled": None,
"name_matches": False,
"base_url_matches": False,
"policy_matches": False,
"diff_status": "planned_no_db_connection",
}
for seed in seed_rows
]
def _build_seed_diff(seed, actual_row):
if not actual_row:
return {
"code": seed["code"],
"exists": False,
"expected_enabled": bool(seed.get("enabled", False)),
"actual_enabled": None,
"name_matches": False,
"base_url_matches": False,
"policy_matches": False,
"diff_status": "missing",
}
expected_policy = _normalize_policy(seed.get("crawl_policy_json"))
actual_policy = _normalize_policy(actual_row.get("crawl_policy_json"))
name_matches = (actual_row.get("name") or "") == (seed.get("name") or "")
base_url_matches = (actual_row.get("base_url") or "") == (seed.get("base_url") or "")
expected_enabled = bool(seed.get("enabled", False))
actual_enabled = bool(actual_row.get("enabled", False))
policy_matches = expected_policy == actual_policy
diff_status = (
"matches_expected"
if name_matches and base_url_matches and policy_matches and expected_enabled == actual_enabled
else "differs_from_expected"
)
return {
"code": seed["code"],
"exists": True,
"expected_enabled": expected_enabled,
"actual_enabled": actual_enabled,
"name_matches": name_matches,
"base_url_matches": base_url_matches,
"policy_matches": policy_matches,
"diff_status": diff_status,
}
def _query_platform_rows(conn, expected_codes):
if not expected_codes:
return {}
rows = conn.execute(
text(
"""
SELECT code, name, base_url, enabled, crawl_policy_json
FROM market_platforms
WHERE code IN :platform_codes
ORDER BY code
"""
).bindparams(bindparam("platform_codes", expanding=True)),
{"platform_codes": tuple(expected_codes)},
).fetchall()
return {
row._mapping["code"]: dict(row._mapping)
for row in rows
}
def build_platform_seed_db_diff_plan(
seed_plan,
*,
execute_requested=False,
database_url=None,
database_type=None,
engine=None,
):
"""建立平台 seed DB 差異探針結果;預設只回 planned不連 DB。"""
seed_rows = list(seed_plan.get("seeds", []))
expected_codes = [seed["code"] for seed in seed_rows]
if not execute_requested:
return {
"mode": "platform_seed_db_diff_planned",
"execute_requested": False,
"read_only_query_executed": False,
"database_connection_opened": False,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"seed_write_executed": False,
"expected_seed_count": len(seed_rows),
"existing_seed_count": 0,
"expected_codes": expected_codes,
"existing_codes": [],
"missing_codes": expected_codes,
"changed_codes": [],
"matching_codes": [],
"seed_rows_ready": False,
"seed_diffs": _build_planned_seed_diffs(seed_rows),
"blocked_reasons": [
"execute_false_planned_only",
"seed_db_diff_not_loaded",
"seed_write_still_blocked",
],
}
from config import DATABASE_PATH, DATABASE_TYPE
effective_database_type = (database_type or DATABASE_TYPE or "").lower()
effective_database_url = database_url or DATABASE_PATH
created_engine = False
connection_opened = False
try:
if engine is None:
connect_args = {}
if effective_database_type == "postgresql":
connect_args = {
"connect_timeout": 8,
"options": "-c statement_timeout=15000",
}
engine = create_engine(
effective_database_url,
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
connect_args=connect_args,
)
created_engine = True
with engine.connect() as conn:
connection_opened = True
existing_by_code = _query_platform_rows(conn, expected_codes)
seed_diffs = [
_build_seed_diff(seed, existing_by_code.get(seed["code"]))
for seed in seed_rows
]
missing_codes = [
item["code"] for item in seed_diffs
if item["diff_status"] == "missing"
]
changed_codes = [
item["code"] for item in seed_diffs
if item["diff_status"] == "differs_from_expected"
]
matching_codes = [
item["code"] for item in seed_diffs
if item["diff_status"] == "matches_expected"
]
blocked_reasons = ["seed_write_still_blocked"]
if missing_codes:
blocked_reasons.insert(0, "seed_rows_missing")
if changed_codes:
blocked_reasons.insert(0, "seed_rows_differ")
return {
"mode": "platform_seed_db_diff_read_only",
"execute_requested": True,
"read_only_query_executed": True,
"database_connection_opened": connection_opened,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"seed_write_executed": False,
"expected_seed_count": len(seed_rows),
"existing_seed_count": len(existing_by_code),
"expected_codes": expected_codes,
"existing_codes": sorted(existing_by_code),
"missing_codes": missing_codes,
"changed_codes": changed_codes,
"matching_codes": matching_codes,
"seed_rows_ready": not missing_codes and not changed_codes,
"seed_diffs": seed_diffs,
"blocked_reasons": blocked_reasons,
}
except Exception as exc:
return {
"mode": "platform_seed_db_diff_error",
"execute_requested": True,
"read_only_query_executed": False,
"database_connection_opened": connection_opened,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"seed_write_executed": False,
"expected_seed_count": len(seed_rows),
"existing_seed_count": 0,
"expected_codes": expected_codes,
"existing_codes": [],
"missing_codes": expected_codes,
"changed_codes": [],
"matching_codes": [],
"seed_rows_ready": False,
"seed_diffs": _build_planned_seed_diffs(seed_rows),
"blocked_reasons": [
"platform_seed_db_diff_error",
"seed_write_still_blocked",
],
"error_message": str(exc),
}
finally:
if created_engine:
engine.dispose()

View File

@@ -0,0 +1,89 @@
"""市場情報平台種子資料寫入 dry-run 規劃。
本模組只建立 upsert preview 與 schema smoke 結果,不執行資料庫寫入。
"""
import json
from database.manager import Base
MARKET_PLATFORM_REQUIRED_COLUMNS = (
"code",
"name",
"base_url",
"enabled",
"crawl_policy_json",
)
def build_schema_smoke(expected_tables, metadata=None):
"""檢查 ORM metadata 是否具備 market_* 表與 platform upsert 必要欄位。"""
metadata = metadata or Base.metadata
metadata_tables = metadata.tables
table_names = set(metadata_tables)
missing_tables = [
table_name for table_name in expected_tables
if table_name not in table_names
]
market_platform = metadata_tables.get("market_platforms")
platform_columns = (
set(market_platform.columns.keys())
if market_platform is not None
else set()
)
missing_platform_columns = [
column_name for column_name in MARKET_PLATFORM_REQUIRED_COLUMNS
if column_name not in platform_columns
]
return {
"passed": not missing_tables and not missing_platform_columns,
"expected_table_count": len(expected_tables),
"metadata_table_count": len(table_names),
"missing_tables": missing_tables,
"market_platform_required_columns": list(MARKET_PLATFORM_REQUIRED_COLUMNS),
"missing_market_platform_columns": missing_platform_columns,
}
def build_platform_seed_writer_plan(seed_plan, write_guard, schema_smoke):
"""建立 market_platforms upsert dry-run plan不建立 session、不 commit。"""
operations = []
for seed in seed_plan.get("seeds", []):
operations.append(
{
"operation": "upsert",
"table": "market_platforms",
"lookup": {"code": seed["code"]},
"values": {
"code": seed["code"],
"name": seed["name"],
"base_url": seed["base_url"],
"enabled": False,
"crawl_policy_json": json.dumps(
seed["crawl_policy_json"],
ensure_ascii=False,
sort_keys=True,
),
},
"sql_shape": (
"INSERT INTO market_platforms (...) VALUES (...) "
"ON CONFLICT(code) DO UPDATE SET ..."
),
"write_status": "blocked_dry_run_only",
}
)
return {
"mode": "dry_run",
"operation_count": len(operations),
"operations": operations,
"schema_smoke": schema_smoke,
"ready_to_write": False,
"writes_executed": False,
"would_write_database": False,
"database_write_allowed": bool(write_guard.get("database_write_allowed")),
"blocked_reasons": write_guard.get("blocked_reasons", []),
"write_action": "preview_only_no_session_no_commit",
}

View File

@@ -0,0 +1,172 @@
"""市場情報正式 DB schema 只讀探針。
本模組只查詢系統 catalog不使用 DatabaseManager、不呼叫 create_all、不寫入。
"""
from sqlalchemy import bindparam, create_engine, text
def _build_table_status(expected_tables, existing_tables):
existing_set = set(existing_tables)
return [
{
"table": table_name,
"exists": table_name in existing_set,
}
for table_name in expected_tables
]
def _probe_postgresql(conn, expected_tables):
existing_tables = []
for table_name in expected_tables:
exists = conn.execute(
text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = ANY (current_schemas(false))
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
).scalar()
if exists:
existing_tables.append(table_name)
return existing_tables
def _probe_sqlite(conn, expected_tables):
rows = conn.execute(
text(
"""
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name IN :table_names
"""
).bindparams(bindparam("table_names", expanding=True)),
{"table_names": tuple(expected_tables)},
).fetchall()
return [row[0] for row in rows]
def build_schema_db_probe_plan(
expected_tables,
*,
execute_requested=False,
database_url=None,
database_type=None,
engine=None,
):
"""建立 DB schema 探針結果;預設只回 planned不連 DB。"""
expected_tables = list(expected_tables)
if not execute_requested:
return {
"mode": "schema_db_probe_planned",
"execute_requested": False,
"read_only_query_executed": False,
"database_connection_opened": False,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"schema_tables_exist": False,
"expected_tables": expected_tables,
"existing_tables": [],
"missing_tables": expected_tables,
"table_statuses": _build_table_status(expected_tables, []),
"blocked_reasons": [
"execute_false_planned_only",
"migration_not_executed_by_this_probe",
"seed_write_still_blocked",
],
}
from config import DATABASE_PATH, DATABASE_TYPE
effective_database_type = (database_type or DATABASE_TYPE or "").lower()
effective_database_url = database_url or DATABASE_PATH
created_engine = False
connection_opened = False
try:
if engine is None:
connect_args = {}
if effective_database_type == "postgresql":
connect_args = {
"connect_timeout": 8,
"options": "-c statement_timeout=15000",
}
engine = create_engine(
effective_database_url,
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
connect_args=connect_args,
)
created_engine = True
with engine.connect() as conn:
connection_opened = True
if effective_database_type == "postgresql":
existing_tables = _probe_postgresql(conn, expected_tables)
else:
existing_tables = _probe_sqlite(conn, expected_tables)
missing_tables = [
table_name for table_name in expected_tables
if table_name not in set(existing_tables)
]
schema_tables_exist = not missing_tables
blocked_reasons = [
"migration_not_executed_by_this_probe",
"seed_write_still_blocked",
]
if not schema_tables_exist:
blocked_reasons.insert(0, "market_tables_missing")
return {
"mode": "schema_db_probe_read_only",
"execute_requested": True,
"read_only_query_executed": True,
"database_connection_opened": connection_opened,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"schema_tables_exist": schema_tables_exist,
"expected_tables": expected_tables,
"existing_tables": existing_tables,
"missing_tables": missing_tables,
"table_statuses": _build_table_status(expected_tables, existing_tables),
"blocked_reasons": blocked_reasons,
}
except Exception as exc:
return {
"mode": "schema_db_probe_error",
"execute_requested": True,
"read_only_query_executed": False,
"database_connection_opened": connection_opened,
"database_session_created": False,
"explicit_transaction_opened": False,
"database_write_executed": False,
"database_commit_executed": False,
"migration_executed": False,
"schema_tables_exist": False,
"expected_tables": expected_tables,
"existing_tables": [],
"missing_tables": expected_tables,
"table_statuses": _build_table_status(expected_tables, []),
"blocked_reasons": [
"schema_db_probe_error",
"seed_write_still_blocked",
],
"error_message": str(exc),
}
finally:
if created_engine:
engine.dispose()

View File

@@ -0,0 +1,197 @@
"""市場情報 seed writer CLI skeleton。
本階段只回報 CLI 執行計畫,不建立 DB session、不寫入、不 commit。
"""
import hashlib
import json
APPROVAL_ENV_VAR = "MARKET_INTEL_SEED_WRITE_APPROVAL"
PLATFORM_UPSERT_SQL = """
INSERT INTO market_platforms (
code,
name,
base_url,
enabled,
crawl_policy_json
) VALUES (
:code,
:name,
:base_url,
:enabled,
:crawl_policy_json
)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
base_url = EXCLUDED.base_url,
enabled = EXCLUDED.enabled,
crawl_policy_json = EXCLUDED.crawl_policy_json,
updated_at = NOW()
""".strip()
def _payload_hash(payload):
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:16]
def build_seed_transaction_preview(writer_plan, migration_blueprint):
"""建立 seed writer transaction preview不建立 DB session。"""
statements = []
for index, operation in enumerate(writer_plan.get("operations", []), start=1):
values = operation.get("values", {})
lookup_code = operation.get("lookup", {}).get("code") or values.get("code")
statements.append(
{
"index": index,
"operation": operation.get("operation", "upsert"),
"table": operation.get("table", "market_platforms"),
"lookup": {"code": lookup_code},
"sql_template": PLATFORM_UPSERT_SQL,
"parameter_keys": sorted(values),
"parameter_payload_hash": _payload_hash(values),
"idempotency_key": f"market_platforms:{lookup_code}",
"diff_status": "not_loaded_no_db_session",
"write_status": "blocked_transaction_preview_only",
}
)
migration_ready = bool(
migration_blueprint.get("file_created")
and migration_blueprint.get("file_matches_blueprint")
and not migration_blueprint.get("migration_executed")
)
return {
"mode": "seed_transaction_preview_no_session",
"target_table": "market_platforms",
"statement_count": len(statements),
"statements": statements,
"migration_draft_ready": migration_ready,
"database_snapshot_loaded": False,
"existing_rows_seen": 0,
"database_session_created": False,
"transaction_opened": False,
"writes_executed": False,
"would_write_database": False,
"database_commit_executed": False,
"database_rollback_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"required_runtime_order": [
"backup_verified",
"migration_applied_by_operator",
"schema_smoke_passed",
"feature_flags_reviewed",
"one_time_approval_token_verified",
"real_write_implementation_enabled",
],
"safety_contract": {
"idempotent_upsert_preview_only": True,
"does_not_load_existing_rows": True,
"does_not_open_transaction": True,
"does_not_commit": True,
},
}
def build_seed_writer_cli_plan(
*,
platform_code,
execute_requested,
approval_token,
seed_plan,
write_guard,
writer_plan,
migration_blueprint,
):
"""建立 seed writer CLI blocked plan。"""
approval_token_present = bool(approval_token)
migration_ready = bool(
migration_blueprint.get("file_created")
and migration_blueprint.get("file_matches_blueprint")
and not migration_blueprint.get("migration_executed")
)
gates = [
{
"key": "script_created",
"label": "scripts/market_intel_seed_writer.py exists",
"passed": True,
},
{
"key": "migration_file_matches_blueprint",
"label": "migration draft exists and matches the reviewed blueprint",
"passed": migration_ready,
},
{
"key": "execute_requested",
"label": "--execute flag was explicitly provided",
"passed": bool(execute_requested),
},
{
"key": "approval_token_present",
"label": f"{APPROVAL_ENV_VAR} or --approval-token was provided",
"passed": approval_token_present,
},
{
"key": "database_write_allowed",
"label": "runtime database_write_allowed gate is true",
"passed": bool(write_guard.get("database_write_allowed")),
},
{
"key": "manual_operator_approval",
"label": "operator approval has been verified out-of-band",
"passed": False,
},
{
"key": "real_write_implementation_enabled",
"label": "CLI real write implementation has been enabled",
"passed": False,
},
]
blocked_reasons = [gate["key"] for gate in gates if not gate["passed"]]
if execute_requested:
blocked_reasons.insert(0, "execute_request_blocked_by_skeleton")
transaction_preview = build_seed_transaction_preview(
writer_plan=writer_plan,
migration_blueprint=migration_blueprint,
)
return {
"mode": "seed_writer_cli_blocked_skeleton",
"platform_code": platform_code or "all",
"execute_requested": bool(execute_requested),
"approval_token_present": approval_token_present,
"approval_env_var": APPROVAL_ENV_VAR,
"ready_for_real_write": False,
"writes_executed": False,
"would_write_database": False,
"database_session_created": False,
"database_commit_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"exit_code": 2 if execute_requested else 0,
"blocked_reasons": blocked_reasons,
"approval_gates": gates,
"seed_count": int(seed_plan.get("seed_count") or 0),
"writer_operation_count": int(writer_plan.get("operation_count") or 0),
"transaction_preview": transaction_preview,
"write_guard_summary": {
"ready_to_write": bool(write_guard.get("ready_to_write")),
"would_write_database": bool(write_guard.get("would_write_database")),
"database_write_allowed": bool(write_guard.get("database_write_allowed")),
"blocked_reasons": write_guard.get("blocked_reasons", []),
},
"migration_file_summary": {
"suggested_filename": migration_blueprint.get("suggested_filename"),
"file_created": bool(migration_blueprint.get("file_created")),
"file_matches_blueprint": bool(migration_blueprint.get("file_matches_blueprint")),
"migration_executed": bool(migration_blueprint.get("migration_executed")),
},
"safety_contract": {
"refuses_execute_in_this_phase": True,
"requires_independent_approval_token": True,
"keeps_crawler_disabled_for_seed_write": True,
"no_db_session_in_skeleton": True,
},
}

View File

@@ -12,6 +12,23 @@ from config import (
MARKET_INTEL_ENABLED,
MARKET_INTEL_WRITE_ENABLED,
)
from services.market_intel.adapters import (
get_adapter,
get_adapter_registry,
get_adapter_summaries,
)
from services.market_intel.candidate_preview import build_candidate_preview_from_discovery
from services.market_intel.discovery_runner import ManualDiscoveryRunner
from services.market_intel.migration_blueprint import build_migration_blueprint
from services.market_intel.platform_seed import build_platform_seed_rows
from services.market_intel.platform_seed_db_diff import build_platform_seed_db_diff_plan
from services.market_intel.platform_seed_writer import (
build_platform_seed_writer_plan,
build_schema_smoke,
)
from services.market_intel.seed_writer_cli import build_seed_writer_cli_plan
from services.market_intel.schema_db_probe import build_schema_db_probe_plan
from services.market_intel.write_approval_runbook import build_write_approval_runbook
TAIPEI_TZ = timezone(timedelta(hours=8))
@@ -45,7 +62,7 @@ class MarketIntelRuntimeStatus:
class MarketIntelService:
"""市場情報入口服務,先集中 feature gate 與安全狀態。"""
phase = "phase_2_schema_ready_disabled"
phase = "phase_25_platform_seed_db_diff"
def get_runtime_status(self) -> MarketIntelRuntimeStatus:
return MarketIntelRuntimeStatus(
@@ -62,13 +79,22 @@ class MarketIntelService:
),
)
def manual_fetch_allowed(self):
status = self.get_runtime_status()
return bool(status.enabled and status.crawler_enabled)
def get_schema_tables(self):
"""回傳 ADR-035 定義的 market_* schema 名稱。"""
return list(MARKET_INTEL_TABLES)
def get_adapter_summaries(self):
"""回傳目前已註冊 adapter不觸發網路。"""
return get_adapter_summaries()
def build_dry_run_plan(self, platform_code="all"):
"""建立 dry-run 計畫,不執行爬蟲、不寫 DB。"""
status = self.get_runtime_status()
adapter_registry = get_adapter_registry()
return {
"batch_id": f"market-dry-run-{uuid4().hex[:12]}",
"platform_code": platform_code,
@@ -77,6 +103,403 @@ class MarketIntelService:
"would_discover_campaigns": bool(status.enabled and status.crawler_enabled),
"would_write_database": bool(status.database_write_allowed),
"scheduler_attached": status.scheduler_attached,
"manual_fetch_allowed": self.manual_fetch_allowed(),
"schema_tables": self.get_schema_tables(),
"adapter_count": len(adapter_registry),
"adapters": [adapter.summary() for adapter in adapter_registry.values()],
"status": status.to_dict(),
}
def build_discovery_plan(self, platform_code="all"):
"""建立平台 discovery dry-run plan不發 request、不寫 DB。"""
if platform_code and platform_code != "all":
adapter = get_adapter(platform_code)
if not adapter:
return {
"platform_code": platform_code,
"found": False,
"plans": [],
"error": "未知平台 adapter",
}
return {
"platform_code": platform_code,
"found": True,
"plans": [adapter.build_discovery_plan()],
}
return {
"platform_code": "all",
"found": True,
"plans": [
adapter.build_discovery_plan()
for adapter in get_adapter_registry().values()
],
}
def run_manual_discovery(self, platform_code="all", *, fetch=False, http_get=None):
"""手動執行 discovery dry-run預設不發 request永遠不寫 DB。"""
registry = get_adapter_registry()
adapters = []
if platform_code and platform_code != "all":
adapter = get_adapter(platform_code)
if not adapter:
return {
"platform_code": platform_code,
"found": False,
"runs": [],
"error": "未知平台 adapter",
}
adapters = [adapter]
else:
adapters = list(registry.values())
runner = ManualDiscoveryRunner(
runtime_status=self.get_runtime_status(),
http_get=http_get,
)
return {
"platform_code": platform_code or "all",
"found": True,
"fetch_requested": bool(fetch),
"manual_fetch_allowed": self.manual_fetch_allowed(),
"runs": [
runner.run(adapter, fetch=fetch).to_dict()
for adapter in adapters
],
}
def build_candidate_preview(
self,
platform_code="all",
*,
fetch=False,
min_band="all",
limit=50,
http_get=None,
):
"""聚合候選連結 preview只供人工審核不寫 DB。"""
discovery_result = self.run_manual_discovery(
platform_code=platform_code,
fetch=fetch,
http_get=http_get,
)
preview = build_candidate_preview_from_discovery(
discovery_result,
min_band=min_band,
limit=limit,
)
preview["discovery_found"] = bool(discovery_result.get("found"))
preview["error"] = discovery_result.get("error")
return preview
def build_platform_seed_plan(self, platform_code="all"):
"""建立 market_platforms 初始資料計畫,不寫入 DB。"""
status = self.get_runtime_status()
seed_rows = build_platform_seed_rows(platform_code=platform_code)
found = bool(seed_rows) or platform_code in (None, "", "all")
return {
"platform_code": platform_code or "all",
"found": found,
"phase": self.phase,
"seed_count": len(seed_rows),
"seeds": seed_rows,
"would_write_database": False,
"database_write_allowed": bool(status.database_write_allowed),
"required_gates": {
"market_intel_enabled": bool(status.enabled),
"market_intel_write_enabled": bool(status.write_enabled),
"schema_smoke_required": True,
"migration_required": True,
"manual_operator_approval_required": True,
},
"status": status.to_dict(),
"error": None if found else "未知平台 adapter",
}
def build_platform_seed_write_guard(self, platform_code="all"):
"""回報 platform seed 寫入前置 gate本方法不執行寫入。"""
status = self.get_runtime_status()
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
schema_smoke = build_schema_smoke(MARKET_INTEL_TABLES)
guard_checks = {
"seed_plan_found": bool(seed_plan["found"]),
"has_seed_rows": bool(seed_plan["seed_count"]),
"market_intel_enabled": bool(status.enabled),
"market_intel_write_enabled": bool(status.write_enabled),
"database_write_allowed": bool(status.database_write_allowed),
"migration_confirmed": False,
"schema_smoke_confirmed": bool(schema_smoke["passed"]),
"manual_operator_approval": False,
}
blocked_reasons = [
name for name, passed in guard_checks.items()
if not passed
]
return {
"platform_code": platform_code or "all",
"phase": self.phase,
"seed_count": seed_plan["seed_count"],
"ready_to_write": False,
"would_write_database": False,
"database_write_allowed": bool(status.database_write_allowed),
"guard_checks": guard_checks,
"blocked_reasons": blocked_reasons,
"write_action": "blocked_dry_run_only",
"schema_smoke": schema_smoke,
"seed_plan": seed_plan,
}
def build_schema_smoke(self):
"""回報 market_intel ORM metadata smoke 結果,不查詢 DB。"""
return {
"phase": self.phase,
"schema_smoke": build_schema_smoke(MARKET_INTEL_TABLES),
"expected_tables": self.get_schema_tables(),
}
def build_schema_db_probe(self, *, execute_requested=False):
"""回報正式 DB schema 只讀探針;預設不連 DB。"""
probe = build_schema_db_probe_plan(
MARKET_INTEL_TABLES,
execute_requested=execute_requested,
)
probe["phase"] = self.phase
return probe
def build_platform_seed_db_diff(self, platform_code="all", *, execute_requested=False):
"""回報 platform seed 與 DB 的只讀差異;預設不連 DB。"""
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
diff = build_platform_seed_db_diff_plan(
seed_plan,
execute_requested=execute_requested,
)
diff["phase"] = self.phase
diff["platform_code"] = platform_code or "all"
diff["seed_plan_found"] = bool(seed_plan["found"])
return diff
def build_platform_seed_writer_plan(self, platform_code="all"):
"""建立 platform seed writer dry-run plan不建立 DB session。"""
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
write_guard = self.build_platform_seed_write_guard(platform_code=platform_code)
schema_smoke = write_guard["schema_smoke"]
writer_plan = build_platform_seed_writer_plan(
seed_plan=seed_plan,
write_guard=write_guard,
schema_smoke=schema_smoke,
)
writer_plan["phase"] = self.phase
writer_plan["platform_code"] = platform_code or "all"
writer_plan["seed_plan_found"] = bool(seed_plan["found"])
writer_plan["seed_count"] = seed_plan["seed_count"]
return writer_plan
def build_write_approval_runbook(self, platform_code="all"):
"""建立正式 seed writer 前的人工批准 runbook本方法不執行寫入。"""
status = self.get_runtime_status()
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
write_guard = self.build_platform_seed_write_guard(platform_code=platform_code)
writer_plan = self.build_platform_seed_writer_plan(platform_code=platform_code)
return build_write_approval_runbook(
phase=self.phase,
status=status,
schema_smoke=write_guard["schema_smoke"],
seed_plan=seed_plan,
write_guard=write_guard,
writer_plan=writer_plan,
)
def build_migration_blueprint(self):
"""建立 market_intel migration 與 seed writer 命令草案;不執行 SQL。"""
blueprint = build_migration_blueprint(self.get_schema_tables())
blueprint["phase"] = self.phase
return blueprint
def build_seed_writer_cli_status(
self,
platform_code="all",
*,
execute_requested=False,
approval_token=None,
):
"""建立 seed writer CLI blocked status不建立 DB session、不寫入。"""
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
write_guard = self.build_platform_seed_write_guard(platform_code=platform_code)
writer_plan = self.build_platform_seed_writer_plan(platform_code=platform_code)
migration_blueprint = self.build_migration_blueprint()
status = build_seed_writer_cli_plan(
platform_code=platform_code or "all",
execute_requested=execute_requested,
approval_token=approval_token,
seed_plan=seed_plan,
write_guard=write_guard,
writer_plan=writer_plan,
migration_blueprint=migration_blueprint,
)
status["phase"] = self.phase
return status
def build_deployment_readiness(self):
"""建立市場情報推版準備狀態;不執行 git、部署或遠端操作。"""
status = self.get_runtime_status()
schema_smoke = build_schema_smoke(MARKET_INTEL_TABLES)
writer_plan = self.build_platform_seed_writer_plan()
checks = {
"schema_smoke_passed": bool(schema_smoke["passed"]),
"feature_flags_default_safe": bool(
not status.enabled
and not status.crawler_enabled
and not status.write_enabled
),
"database_write_blocked": bool(not status.database_write_allowed),
"scheduler_detached": bool(not status.scheduler_attached),
"manual_fetch_disabled": bool(not self.manual_fetch_allowed()),
"writer_plan_dry_run_only": bool(
writer_plan["mode"] == "dry_run"
and not writer_plan["writes_executed"]
and not writer_plan["would_write_database"]
),
"registered_adapters_present": bool(len(self.get_adapter_summaries()) >= 4),
"schema_db_probe_planned_safe": bool(
not self.build_schema_db_probe()["read_only_query_executed"]
),
"platform_seed_db_diff_planned_safe": bool(
not self.build_platform_seed_db_diff()["read_only_query_executed"]
),
}
ready_for_production_deploy = all(checks.values())
blocked_reasons = [
reason for reason, blocked in (
("readiness_checks_not_all_passed", not ready_for_production_deploy),
("production_deploy_not_executed_by_api", True),
("git_commit_not_created_by_api", True),
("git_push_not_executed_by_api", True),
("backup_must_be_verified_by_operator", True),
("production_smoke_must_be_verified_by_operator", True),
)
if blocked
]
required_manual_steps = [
{
"key": "review_worktree_scope",
"label": "審核 worktree只納入市場情報相關變更排除 unrelated dirty files",
"status": "required",
},
{
"key": "run_backup_system",
"label": "重大更新前執行 python backup_system.py",
"status": "required",
},
{
"key": "commit_market_intel_changes_only",
"label": "只 commit 市場情報模組、ADR/TODO 與必要測試",
"status": "operator_optional",
},
{
"key": "push_reviewed_branch_or_main",
"label": "推送已審核分支或 main再進入部署 SOP",
"status": "operator_optional",
},
{
"key": "run_deployment_sop",
"label": "依 deployment SOP app-only 部署,不碰 momo-db",
"status": "required",
},
{
"key": "verify_health_endpoint",
"label": "部署後先驗證 /health不使用首頁作為探測",
"status": "required",
},
{
"key": "verify_market_intel_page_after_deploy",
"label": "驗證 /market_intel 與市場情報 API 仍維持 blocked dry-run",
"status": "required",
},
]
fallback_plan = [
{
"key": "feature_flag_kill_switch",
"label": "MARKET_INTEL_ENABLED、MARKET_INTEL_CRAWLER_ENABLED、MARKET_INTEL_WRITE_ENABLED 保持全關,可立即停用新功能面",
"trigger": "任何 UI/API 異常或非預期連外行為",
},
{
"key": "app_only_rollback",
"label": "回退到上一個已知正常版本後,只 recreate momo-app避免影響 momo-db 資料生命週期",
"trigger": "部署後 /health 或 /market_intel smoke 失敗",
},
{
"key": "scheduler_detached",
"label": "市場情報 scheduler 尚未掛載;異常時不需停爬蟲排程,因為本階段沒有排程入口",
"trigger": "排程或外部流量疑慮",
},
{
"key": "database_write_blocked",
"label": "writer 仍是 preview_only_no_session_no_commit異常時不需要 DB rollback",
"trigger": "seed writer 或 schema smoke 異常",
},
]
safe_deploy_boundaries = [
{
"key": "no_remove_orphans",
"label": "禁止使用 docker compose --remove-orphans",
},
{
"key": "no_momo_db_lifecycle_change",
"label": "禁止 stop/rm/recreate momo-db 或變更資料生命週期",
},
{
"key": "health_probe_only",
"label": "HTTP health / blackbox / CD 探測只打 /health",
},
{
"key": "flags_default_off",
"label": "市場情報三個 feature flags 預設維持 OFF",
},
]
return {
"phase": self.phase,
"mode": "app_only_release_gate",
"production_deployed": False,
"git_committed": False,
"git_pushed": False,
"ready_for_production_deploy": ready_for_production_deploy,
"deployment_actions_executed": False,
"execution_boundary": {
"api_executes_git": False,
"api_executes_backup": False,
"api_executes_scp": False,
"api_executes_ssh": False,
"api_recreates_container": False,
"api_runs_migration": False,
"api_writes_database": False,
},
"checks": checks,
"blocked_reasons": blocked_reasons,
"requires_backup_before_major_update": True,
"backup_command": "python backup_system.py",
"required_manual_steps": required_manual_steps,
"fallback_plan": fallback_plan,
"safe_deploy_boundaries": safe_deploy_boundaries,
"production_smoke_targets": [
"/health",
"/market_intel",
"/api/market_intel/status",
"/api/market_intel/deployment_readiness",
"/api/market_intel/schema_smoke",
"/api/market_intel/schema_db_probe",
"/api/market_intel/platform_seed_db_diff",
],
"status": status.to_dict(),
"schema_smoke": schema_smoke,
"writer_plan_summary": {
"operation_count": writer_plan["operation_count"],
"writes_executed": writer_plan["writes_executed"],
"would_write_database": writer_plan["would_write_database"],
},
"write_approval_runbook": self.build_write_approval_runbook(),
"migration_blueprint": self.build_migration_blueprint(),
"seed_writer_cli_status": self.build_seed_writer_cli_status(),
"schema_db_probe": self.build_schema_db_probe(),
"platform_seed_db_diff": self.build_platform_seed_db_diff(),
}

View File

@@ -0,0 +1,134 @@
"""市場情報正式寫入前的人工批准 runbook。
本模組只產生 gate 與操作順序,不建立 DB session、不執行 migration、不寫入資料。
"""
def _status_value(status, key, default=False):
return bool(getattr(status, key, default))
def build_write_approval_runbook(
*,
phase,
status,
schema_smoke,
seed_plan,
write_guard,
writer_plan,
):
"""建立正式 seed write 前的 read-only runbook。"""
gates = [
{
"key": "schema_smoke_passed",
"label": "ORM metadata smoke 已通過,七張 market_* table 與 market_platforms 欄位完整",
"passed": bool(schema_smoke.get("passed")),
},
{
"key": "backup_completed",
"label": "已在正式推版前執行 python backup_system.py",
"passed": False,
},
{
"key": "migration_file_reviewed",
"label": "market_* schema migration 已人工審核,且不 drop/alter 既有業績資料表",
"passed": False,
},
{
"key": "feature_flags_enabled_for_write_window",
"label": "寫入窗口才可同時啟用 MARKET_INTEL_ENABLED 與 MARKET_INTEL_WRITE_ENABLED",
"passed": bool(_status_value(status, "enabled") and _status_value(status, "write_enabled")),
},
{
"key": "database_write_allowed",
"label": "runtime database_write_allowed 為 true",
"passed": bool(_status_value(status, "database_write_allowed")),
},
{
"key": "manual_operator_approval",
"label": "操作者已明確批准一次性 market_platforms seed upsert",
"passed": False,
},
{
"key": "rollback_plan_reviewed",
"label": "已確認回復策略:關閉 flags、app-only rollback、必要時清理 seed rows",
"passed": False,
},
{
"key": "production_smoke_targets_defined",
"label": "已定義 /health 與市場情報 API smoke targets",
"passed": True,
},
]
blocked_reasons = [gate["key"] for gate in gates if not gate["passed"]]
return {
"phase": phase,
"mode": "approval_runbook_read_only",
"ready_for_real_write": False,
"writes_executed": False,
"would_write_database": False,
"database_session_created": False,
"database_commit_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"approval_required": True,
"approval_token_present": False,
"blocked_reasons": blocked_reasons,
"approval_gates": gates,
"seed_count": int(seed_plan.get("seed_count") or 0),
"writer_operation_count": int(writer_plan.get("operation_count") or 0),
"schema_smoke": schema_smoke,
"write_guard_summary": {
"ready_to_write": bool(write_guard.get("ready_to_write")),
"would_write_database": bool(write_guard.get("would_write_database")),
"blocked_reasons": write_guard.get("blocked_reasons", []),
},
"operator_sequence": [
{
"key": "scope_review",
"label": "確認 git diff 只包含 market_intel、ADR/TODO、版本與測試",
},
{
"key": "backup",
"label": "執行 python backup_system.py保存正式環境回復點",
},
{
"key": "migration_apply_window",
"label": "在維護窗口套用 market_* migration不觸碰 momo-db 容器生命週期",
},
{
"key": "seed_preview_compare",
"label": "比對 platform_seed_writer_plan 的 4 筆 upsert preview",
},
{
"key": "one_time_seed_write",
"label": "另開明確批准後才允許一次性 seed writer 真寫入",
},
{
"key": "post_write_smoke",
"label": "驗證 /health、/market_intel、schema_smoke 與 deployment_readiness",
},
],
"rollback_plan": [
{
"key": "disable_flags",
"label": "立刻關閉 MARKET_INTEL_ENABLED、MARKET_INTEL_CRAWLER_ENABLED、MARKET_INTEL_WRITE_ENABLED",
},
{
"key": "app_only_rollback",
"label": "回退 momo-app 程式版本,只 recreate momo-app不碰 momo-db",
},
{
"key": "seed_rows_cleanup",
"label": "若唯一異常來自 platform seed可在人工審核後刪除或停用 market_platforms seed rows",
},
],
"hard_safety_boundaries": [
"no_external_crawling_during_seed_write",
"no_scheduler_attach_during_seed_write",
"no_momo_db_container_lifecycle_change",
"no_remove_orphans",
"health_probe_uses_health_endpoint_only",
],
}