fix: harden alerts and backup deployment guard
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
ogt
2026-06-26 17:52:06 +08:00
parent 5327dfda1f
commit 3b14368d4e
7 changed files with 274 additions and 41 deletions

7
backup_system.py Normal file
View File

@@ -0,0 +1,7 @@
"""Repository-root entrypoint for the MOMO Pro source backup tool."""
from scripts.tools.backup_system import create_backup
if __name__ == "__main__":
create_backup()

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.708"
SYSTEM_VERSION = "V10.709"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-06-26 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
> **適用版本**: V10.708
> **適用版本**: V10.709
---
@@ -792,3 +792,5 @@ POSTGRES_HOST=momo-db
| 2026-06-26 | 外部來源視野不可停在少數平台 | V10.707 起外部來源契約再補 TikTok Shop、LINE 購物、露天、品牌官網 / Shopify所有待接來源必須在 UI 顯示為待接入且不進告警,等官方 API、商品 feed、供應商 API 或人工 CSV 通過品質門檻後才可進作戰清單。 |
| 2026-06-26 | 同版 CSS 修正必須跳版本破快取 | V10.707 起 UI 修正若影響 `web/static` 資產,必須同步提升 `SYSTEM_VERSION`,讓正式 HTML 的 `?v=` 參數改變;不得在同一版本號下修改 CSS 後宣稱使用者一定看得到。 |
| 2026-06-26 | AI 挑品賣場操作必須固定可見 | V10.708 起 AI 挑品清單在桌面寬度固定「AI 建議 / 賣場操作」欄,橫向查看價格與更新欄時仍能直接開 MOMO / PChome 賣場;手機版維持卡片式堆疊。 |
| 2026-06-26 | Telegram 告警不得因非支援 HTML 送出失敗 | V10.709 起 Telegram HTML 發送前只保留 Bot API 支援的 `<b>``<i>``<code>``<pre>``<a href="">` 等白名單標籤;`<httpconnection(...)>`、原始錯誤物件或其他未知標籤會轉成可讀文字,避免營運告警因 parse error 400 消失。 |
| 2026-06-26 | 部署前備份入口必須備到專案根目錄 | V10.709 起根目錄 `backup_system.py``scripts/tools/backup_system.py` 共用同一套備份流程,預設打包專案根目錄並排除 `.env`、Google token、`.git`、runtime volume 與既有 backups避免只備到 `scripts/tools` 或把敏感 runtime 檔案包入備份。 |

View File

@@ -1,60 +1,179 @@
import os
import datetime
import os
import re
import zipfile
from pathlib import Path
EXCLUDED_DIRS = {
".claude",
".codex",
".coverage",
".cache",
".git",
".idea",
".mypy_cache",
".next",
".pytest_cache",
".ruff_cache",
".venv",
".vscode",
"__pycache__",
"backups",
"bin",
"bin 2",
"node_modules",
"runtime",
"volumes",
"build",
"dist",
"htmlcov",
"logs",
"playwright-report",
"screenshots",
"temp",
"tmp",
"uploads",
}
EXCLUDED_PATH_PREFIXES = {
("docs", "design"),
("export_assets",),
("frontend", ".next"),
("MOMO Pro",),
}
EXCLUDED_FILES = {
".DS_Store",
".env",
"google_credentials.json",
"google_token.json",
"google_token.pickle",
}
EXCLUDED_SUFFIXES = (".pyc", ".pyo", ".sqlite", ".sqlite3", ".tsbuildinfo")
def _resolve_project_root() -> Path:
configured = os.getenv("MOMO_BACKUP_ROOT")
if configured:
return Path(configured).expanduser().resolve()
return Path(__file__).resolve().parents[2]
def _resolve_backup_folder(project_root: Path) -> Path:
configured = os.getenv("MOMO_BACKUP_DIR")
if configured:
return Path(configured).expanduser().resolve()
return project_root / "backups"
def _read_system_version(project_root: Path) -> str:
version = "Unknown"
config_path = project_root / "config.py"
try:
if config_path.exists():
content = config_path.read_text(encoding="utf-8")
match = re.search(r'SYSTEM_VERSION\s*=\s*["\']([^"\']+)["\']', content)
if match:
version = match.group(1)
except Exception as e:
print(f"⚠️ 無法讀取版本號: {e}")
return version
def _should_skip_file(file_path: Path, project_root: Path, backup_folder: Path) -> bool:
if file_path.is_symlink():
return True
if file_path.name in EXCLUDED_FILES:
return True
if file_path.name.startswith(".env."):
return True
if file_path.suffix in EXCLUDED_SUFFIXES:
return True
try:
file_path.resolve().relative_to(backup_folder)
return True
except ValueError:
pass
rel_parts = file_path.relative_to(project_root).parts
if _is_excluded_path(rel_parts):
return True
return any(_is_excluded_dir_name(part) for part in rel_parts[:-1])
def _is_excluded_dir_name(dirname: str) -> bool:
return dirname in EXCLUDED_DIRS or dirname.startswith("production_v")
def _is_excluded_path(rel_parts: tuple[str, ...]) -> bool:
for prefix in EXCLUDED_PATH_PREFIXES:
if rel_parts[:len(prefix)] == prefix:
return True
return False
def _prune_dirs(dirs: list[str], root: Path, project_root: Path, backup_folder: Path) -> None:
kept = []
for dirname in dirs:
candidate = root / dirname
try:
rel_parts = candidate.relative_to(project_root).parts
except ValueError:
rel_parts = ()
if rel_parts and _is_excluded_path(rel_parts):
continue
if _is_excluded_dir_name(dirname):
continue
if candidate.is_symlink():
continue
try:
candidate.resolve().relative_to(backup_folder)
continue
except ValueError:
pass
kept.append(dirname)
dirs[:] = kept
def create_backup():
"""
建立系統完整備份 (Zip 壓縮檔)
檔名格式: momo_pro_system_backup_YYYYMMDD_HHMMSS_V{version}.zip
"""
# 1. 基礎路徑設定
base_dir = os.path.dirname(os.path.abspath(__file__))
backup_folder = os.path.join(base_dir, 'backups')
project_root = _resolve_project_root()
backup_folder = _resolve_backup_folder(project_root)
if not os.path.exists(backup_folder):
os.makedirs(backup_folder)
if not backup_folder.exists():
backup_folder.mkdir(parents=True)
print(f"📂 已建立備份目錄: {backup_folder}")
# 2. 嘗試從 app.py 讀取版本號
version = "Unknown"
app_py_path = os.path.join(base_dir, 'app.py')
try:
if os.path.exists(app_py_path):
with open(app_py_path, 'r', encoding='utf-8') as f:
content = f.read()
# 尋找 SYSTEM_VERSION = "V9.0"
match = re.search(r'SYSTEM_VERSION\s*=\s*["\']([^"\']+)["\']', content)
if match:
version = match.group(1)
except Exception as e:
print(f"⚠️ 無法讀取版本號: {e}")
version = _read_system_version(project_root)
# 3. 產生備份檔名
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
base_name = f"momo_pro_system_backup_{timestamp}_{version}.zip"
output_path = os.path.join(backup_folder, base_name)
output_path = backup_folder / base_name
print(f"📦 正在打包專案目錄: {base_dir}")
print(f"📦 正在打包專案目錄: {project_root}")
print(f"🎯 目標檔案: {output_path}")
# 4. 執行壓縮
try:
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(base_dir):
# 排除不需要備份的目錄
for ignore in ['backups', '__pycache__', '.git', '.idea', '.vscode', 'bin', 'bin 2']:
if ignore in dirs:
dirs.remove(ignore)
for root, dirs, files in os.walk(project_root):
root_path = Path(root)
_prune_dirs(dirs, root_path, project_root, backup_folder)
for file in files:
if file == '.DS_Store' or file.endswith('.pyc'): continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, base_dir)
file_path = root_path / file
if _should_skip_file(file_path, project_root, backup_folder):
continue
arcname = file_path.relative_to(project_root)
zipf.write(file_path, arcname)
print(f"✅ 備份完成!")
return str(output_path)
except Exception as e:
print(f"❌ 備份失敗: {e}")
return None
if __name__ == "__main__":
create_backup()

View File

@@ -31,6 +31,11 @@ sys_log = logging.getLogger("TelegramTpl")
TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
TELEGRAM_CHAT_IDS_ENV = "TELEGRAM_CHAT_IDS"
_TELEGRAM_HTML_BR_RE = re.compile(r"<\s*br\s*/?\s*>", re.IGNORECASE)
_TELEGRAM_HTML_TAG_RE = re.compile(r"<[^<>\n]{1,500}>")
_TELEGRAM_ALLOWED_HTML_TAG_RE = re.compile(
r"(?:</?(?:b|strong|i|em|u|s|strike|del|code|pre)>|<a\s+href=\"[^\"]+\">|</a>)",
re.IGNORECASE,
)
# ══════════════════════════════════════════════════════════════════════════════
@@ -57,13 +62,25 @@ def _get_chat_ids() -> list:
def _sanitize_telegram_html(text: str, parse_mode: Optional[str] = "HTML") -> str:
"""Telegram HTML 不支援 <br>,統一轉為換行避免 sendMessage 400。"""
"""Telegram HTML 只保留白名單標籤,其餘轉成文字避免 sendMessage 400。"""
value = str(text or "")
if parse_mode and str(parse_mode).upper() == "HTML":
return _TELEGRAM_HTML_BR_RE.sub("\n", value)
value = _normalize_telegram_html_linebreaks(value)
return _TELEGRAM_HTML_TAG_RE.sub(_escape_unsupported_telegram_html_tag, value)
return value
def _normalize_telegram_html_linebreaks(text: str) -> str:
return _TELEGRAM_HTML_BR_RE.sub("\n", str(text or ""))
def _escape_unsupported_telegram_html_tag(match: re.Match) -> str:
tag = match.group(0)
if _TELEGRAM_ALLOWED_HTML_TAG_RE.fullmatch(tag):
return tag
return escape(tag)
def _callback_payload_utf8(value: Any, max_bytes: int = 52) -> str:
"""Clamp callback payload by UTF-8 bytes without splitting multibyte chars."""
text = str(value or "unknown").strip() or "unknown"
@@ -352,7 +369,7 @@ def price_decision(product_name: str, product_sku: str,
direction = "📉" if diff > 0 else "📈" if diff < 0 else "➡️"
safe_name = escape(str(product_name or ""))
safe_sku = escape(str(product_sku or ""))
safe_reason = escape(_sanitize_telegram_html(str(reason or ""), "HTML"))
safe_reason = escape(_normalize_telegram_html_linebreaks(str(reason or "")))
message = (
f"💰 <b>AI 定價決策建議</b>\n"
@@ -854,7 +871,7 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
lines = [
"🧭 <b>決策信封</b>",
f"狀態<code>{decision_type}</code> 等級<b>{severity}</b>{confidence_text}",
f"類型<code>{decision_type}</code> 嚴重度<b>{severity}</b>{confidence_text}",
f"• 資料品質:<code>{data_quality}</code> 自動執行:<b>{'允許' if can_auto_execute else '不允許'}</b>",
]
if blocked_reason:

View File

@@ -0,0 +1,71 @@
import zipfile
from pathlib import Path
def test_source_backup_uses_project_root_and_excludes_runtime_secrets(tmp_path, monkeypatch):
from scripts.tools import backup_system
project_root = tmp_path / "momo-pro-system"
project_root.mkdir()
(project_root / "config.py").write_text('SYSTEM_VERSION = "V10.TEST"\n', encoding="utf-8")
(project_root / "app.py").write_text("print('app')\n", encoding="utf-8")
(project_root / ".env").write_text("SECRET=1\n", encoding="utf-8")
(project_root / "config").mkdir()
(project_root / "config" / "google_token.json").write_text("{}", encoding="utf-8")
(project_root / "config" / "source_contract.json").write_text("{}", encoding="utf-8")
(project_root / "services").mkdir()
(project_root / "services" / "growth.py").write_text("# ok\n", encoding="utf-8")
(project_root / "docs" / "design").mkdir(parents=True)
(project_root / "docs" / "design" / "handoff.jsx").write_text("// generated\n", encoding="utf-8")
(project_root / "docs" / "guide.md").write_text("guide\n", encoding="utf-8")
(project_root / "export_assets").mkdir()
(project_root / "export_assets" / "logo.ai").write_text("asset\n", encoding="utf-8")
(project_root / "frontend" / ".next").mkdir(parents=True)
(project_root / "frontend" / ".next" / "trace").write_text("build\n", encoding="utf-8")
(project_root / "frontend" / "app").mkdir()
(project_root / "frontend" / "app" / "page.tsx").write_text("// source\n", encoding="utf-8")
(project_root / "logs").mkdir()
(project_root / "logs" / "system.log").write_text("runtime log\n", encoding="utf-8")
(project_root / "MOMO Pro" / "uploads").mkdir(parents=True)
(project_root / "MOMO Pro" / "uploads" / "pasted.png").write_text("asset\n", encoding="utf-8")
(project_root / "production_v3 3" / "static").mkdir(parents=True)
(project_root / "production_v3 3" / "static" / "old.css").write_text("old\n", encoding="utf-8")
(project_root / "components").symlink_to("web/templates/components")
(project_root / ".claude" / "worktrees").mkdir(parents=True)
(project_root / ".claude" / "worktrees" / "old.md").write_text("old", encoding="utf-8")
(project_root / "backups").mkdir()
(project_root / "backups" / "old.zip").write_text("old", encoding="utf-8")
monkeypatch.setenv("MOMO_BACKUP_ROOT", str(project_root))
backup_path = Path(backup_system.create_backup())
assert backup_path.parent == project_root / "backups"
assert "V10.TEST" in backup_path.name
assert backup_path.exists()
with zipfile.ZipFile(backup_path) as archive:
names = set(archive.namelist())
assert "app.py" in names
assert "config.py" in names
assert "services/growth.py" in names
assert "docs/guide.md" in names
assert "frontend/app/page.tsx" in names
assert "config/source_contract.json" in names
assert "docs/design/handoff.jsx" not in names
assert "export_assets/logo.ai" not in names
assert "frontend/.next/trace" not in names
assert "logs/system.log" not in names
assert "MOMO Pro/uploads/pasted.png" not in names
assert "production_v3 3/static/old.css" not in names
assert ".env" not in names
assert ".claude/worktrees/old.md" not in names
assert "components" not in names
assert "config/google_token.json" not in names
assert "backups/old.zip" not in names
assert backup_path.name not in names
def test_root_backup_entrypoint_exists():
assert (Path(__file__).resolve().parents[1] / "backup_system.py").exists()

View File

@@ -10,6 +10,20 @@ def test_telegram_html_sanitizer_converts_br_tags_to_newlines():
assert _sanitize_telegram_html("第一行<br>第二行", parse_mode=None) == "第一行<br>第二行"
def test_telegram_html_sanitizer_escapes_unsupported_tags_but_keeps_allowed_tags():
msg = _sanitize_telegram_html(
'<b>Ollama</b> <code>timeout</code> '
'<a href="https://mo.wooo.work/health">health</a> '
"<httpconnection(host='192.168.0.111', port=11434)>"
)
assert "<b>Ollama</b>" in msg
assert "<code>timeout</code>" in msg
assert '<a href="https://mo.wooo.work/health">health</a>' in msg
assert "<httpconnection" not in msg
assert "&lt;httpconnection(host=&#x27;192.168.0.111&#x27;, port=11434)&gt;" in msg
def test_send_telegram_with_result_sanitizes_html_payload(monkeypatch):
sent_payloads = []
@@ -247,10 +261,13 @@ def test_triaged_alert_renders_decision_envelope_contract():
assert "邊界price adjustment requires HITL" in msg
assert "<b>標的</b>" in msg
assert "PChome" in msg
assert "<code>match_score</code> / 91%" in msg
assert "📊 <b>價格證據</b>" in msg
assert "價差NT$ 120正值代表 MOMO 較貴)" in msg
assert "🧩 <b>比對證據</b>" in msg
assert "Match<code>0.91</code>" in msg
assert "identity_v2 + price_alert_exact" in msg
assert "✅ <b>人工下一步</b>" in msg
assert "動作:<code>human_review</code>" in msg
assert "revenue_loss_7d=42000" in msg
assert "ai_call_id=123" in msg
assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:decision_env_001"