chore(config): 工作流配置、清单与文档同步
workflows/schedules/commands/notification-routing 与 source-manifests 四模块 清单同步登录统一化与新工作流(product.hk_anpei_public_inventory.daily 等); docs 运维手册、控制台与耗时分布文档、CI 关键脚本清单、AGENTS 指南更新; 测试与模块注册对齐。
This commit is contained in:
1 parent
90282ae766
commit
dc8be88e60
27 files changed
+1094
-224
No files matched your search
@@ -0,0 +1,771 @@
|
||||
"""Cookie gate: per-account login-state machine injected before workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gyxx_flow.adapters.browser import BrowserCookieStore
|
||||
from gyxx_flow.adapters.integration import AccountBinding
|
||||
from gyxx_flow.catalog import WorkflowCatalog, WorkflowEntry
|
||||
from gyxx_flow.cookie_gate import (
|
||||
GATE_MODE_BLOCKING,
|
||||
GATE_MODE_DEFER,
|
||||
GATE_MODE_RECOVER,
|
||||
CookieGate,
|
||||
CookieGateRecoveryManager,
|
||||
apply_cookie_gates,
|
||||
gate_enabled,
|
||||
)
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.core.layout import DataLayout
|
||||
from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.workflow.engine import WorkflowEngine
|
||||
from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition
|
||||
from gyxx_flow.workflow.steps import StepExecution
|
||||
|
||||
_NOW = datetime(2026, 10, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _settings(tmp_path: Path) -> Settings:
|
||||
return Settings(project_root=tmp_path, data_root=tmp_path / "var")
|
||||
|
||||
|
||||
def _account(tmp_path: Path, account_id: str, **overrides: object) -> AccountBinding:
|
||||
vault = tmp_path / "state" / "accounts" / account_id
|
||||
values: dict[str, object] = {
|
||||
"account_id": account_id,
|
||||
"cdp_port": 22000,
|
||||
"cdp_url": "http://127.0.0.1:22000",
|
||||
"profile_dir": vault / "profile",
|
||||
"cookie_file": vault / "cookies.json",
|
||||
"storage_state_file": vault / "storage_state.json",
|
||||
"required_cookie_domains": ("example.com",),
|
||||
"required_cookie_names": ("sessionid",),
|
||||
"login_identity_markers": ("示例店铺",),
|
||||
"keepalive_enabled": True,
|
||||
"keepalive_url": "https://example.com/home",
|
||||
"keepalive_interval_seconds": 900,
|
||||
}
|
||||
values.update(overrides)
|
||||
return AccountBinding(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _fake_catalog(*accounts: AccountBinding, bindings: dict[str, str] | None = None):
|
||||
by_id = {account.account_id: account for account in accounts}
|
||||
binding_accounts = bindings or {}
|
||||
account_tuple = tuple(accounts)
|
||||
|
||||
class _Catalog:
|
||||
accounts = account_tuple
|
||||
|
||||
def account_for(self, account_id: str) -> AccountBinding:
|
||||
return by_id[account_id]
|
||||
|
||||
def binding_for(self, binding_id: str):
|
||||
return SimpleNamespace(account=binding_accounts.get(binding_id, ""))
|
||||
|
||||
return _Catalog()
|
||||
|
||||
|
||||
def _write_valid_vault(account: AccountBinding) -> None:
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
store.save_cookies(
|
||||
[
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": "live-session",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": _NOW.timestamp() + 86_400,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _write_keepalive_status(
|
||||
account: AccountBinding,
|
||||
payload: dict[str, object] | None,
|
||||
) -> None:
|
||||
path = account.cookie_file.with_name("keepalive_status.json")
|
||||
if payload is None:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _gate(
|
||||
tmp_path: Path,
|
||||
*accounts: AccountBinding,
|
||||
probe: dict[str, object] | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
sent: list[dict[str, object]] | None = None,
|
||||
bindings: dict[str, str] | None = None,
|
||||
) -> CookieGate:
|
||||
probes = probe or {"status": "expired"}
|
||||
keepalive = SimpleNamespace(
|
||||
refresh_account=lambda account_id: dict(probes, account_id=account_id)
|
||||
)
|
||||
sender_calls = sent if sent is not None else []
|
||||
|
||||
def _sender(**kwargs: object) -> dict[str, object]:
|
||||
sender_calls.append(dict(kwargs))
|
||||
return {"message_id": "om_fake"}
|
||||
|
||||
catalog = _fake_catalog(*accounts, bindings=bindings)
|
||||
gate = CookieGate(
|
||||
_settings(tmp_path),
|
||||
catalog=catalog,
|
||||
keepalive=keepalive, # type: ignore[arg-type]
|
||||
environment=environment or {},
|
||||
now=lambda: _NOW,
|
||||
sender=_sender,
|
||||
)
|
||||
gate._workflow_catalog = WorkflowCatalog(timezone="UTC", workflows=(), schedules=())
|
||||
return gate
|
||||
|
||||
|
||||
def _context(**overrides: object) -> RunContext:
|
||||
values: dict[str, object] = {
|
||||
"workflow_id": "shop.demo.daily",
|
||||
"business_date": "2026-10-08",
|
||||
"shadow": False,
|
||||
"now": _NOW,
|
||||
"random_suffix": "aaaa",
|
||||
}
|
||||
values.update(overrides)
|
||||
return RunContext.create(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _noop_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
|
||||
return StepExecution(exit_code=0)
|
||||
|
||||
|
||||
def _deny_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
|
||||
raise AssertionError("resolution must not run actions")
|
||||
|
||||
|
||||
class _PassAction:
|
||||
execute = staticmethod(_noop_outcome)
|
||||
|
||||
|
||||
class _BoundAction:
|
||||
"""Duck-typed DeferredModuleCommandStep for account resolution tests."""
|
||||
|
||||
def __init__(self, command_entry: str = "collect.py") -> None:
|
||||
self.entry = SimpleNamespace(module="shop_intelligence", entry="fallback.py")
|
||||
self.command_entry = command_entry
|
||||
self.execute = _deny_outcome
|
||||
|
||||
|
||||
class _RecordingAction:
|
||||
def __init__(self, name: str, sink: list[str], command_entry: str | None = None) -> None:
|
||||
def run(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001
|
||||
sink.append(name)
|
||||
return StepExecution(exit_code=0)
|
||||
|
||||
self.execute = run
|
||||
if command_entry is not None:
|
||||
self.entry = SimpleNamespace(
|
||||
module="shop_intelligence", entry="fallback.py"
|
||||
)
|
||||
self.command_entry = command_entry
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode classification
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gate_mode_classifies_accounts(tmp_path: Path) -> None:
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
_account(tmp_path, "qr-only"),
|
||||
_account(tmp_path, "cred-scripts", credential_env_names=("A_USER", "A_PASS")),
|
||||
_account(
|
||||
tmp_path,
|
||||
"cred-vault",
|
||||
login_mode="C",
|
||||
login_command_id="product_commerce:erp_login.py",
|
||||
),
|
||||
_account(tmp_path, "cred-mode-b", login_mode="B"),
|
||||
)
|
||||
assert gate.gate_mode("qr-only") == GATE_MODE_BLOCKING
|
||||
assert gate.gate_mode("cred-scripts") == GATE_MODE_DEFER
|
||||
assert gate.gate_mode("cred-vault") == GATE_MODE_RECOVER
|
||||
assert gate.gate_mode("cred-mode-b") == GATE_MODE_DEFER
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Workflow account resolution
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bound_workflow(workflow_id: str = "shop.demo.daily") -> WorkflowDefinition:
|
||||
return WorkflowDefinition(
|
||||
workflow_id,
|
||||
(
|
||||
StepDefinition("collect", _BoundAction("collect.py")),
|
||||
StepDefinition("sync", _BoundAction("sync.py"), depends_on=("collect",)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_accounts_maps_bindings_and_skips_defer(tmp_path: Path) -> None:
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
_account(tmp_path, "douyin-shop"),
|
||||
_account(tmp_path, "jd-shop", credential_env_names=("JD_SHOP", "JD_PASSWORD")),
|
||||
bindings={
|
||||
"shop_intelligence:collect.py": "douyin-shop",
|
||||
"shop_intelligence:other.py": "jd-shop",
|
||||
},
|
||||
)
|
||||
mapping = gate.collect_workflow_accounts(_bound_workflow())
|
||||
# sync.py 无账号绑定,jd-shop 属 defer 模式:两者都不进映射
|
||||
assert mapping == {"douyin-shop": {"collect"}}
|
||||
|
||||
|
||||
def test_collect_accounts_includes_declared_required_accounts(tmp_path: Path) -> None:
|
||||
gate = _gate(tmp_path, _account(tmp_path, "douyin-shop"))
|
||||
gate._workflow_catalog = WorkflowCatalog(
|
||||
timezone="UTC",
|
||||
workflows=(
|
||||
WorkflowEntry(
|
||||
workflow_id="shop.demo.daily",
|
||||
module="shop_intelligence",
|
||||
trigger="manual",
|
||||
entry="collect.py",
|
||||
required_accounts=("douyin-shop",),
|
||||
),
|
||||
),
|
||||
schedules=(),
|
||||
)
|
||||
mapping = gate.collect_workflow_accounts(_bound_workflow())
|
||||
assert mapping == {"douyin-shop": {"collect", "sync"}}
|
||||
|
||||
|
||||
def test_apply_cookie_gates_rewrites_dependencies(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
bindings={"shop_intelligence:collect.py": "douyin-shop"},
|
||||
)
|
||||
gated = apply_cookie_gates(
|
||||
_bound_workflow(), settings=_settings(tmp_path), gate=gate
|
||||
)
|
||||
ids = [step.step_id for step in gated.steps]
|
||||
assert "cookie-gate.douyin-shop" in ids
|
||||
collect = next(step for step in gated.steps if step.step_id == "collect")
|
||||
sync = next(step for step in gated.steps if step.step_id == "sync")
|
||||
assert collect.depends_on == ("cookie-gate.douyin-shop",)
|
||||
assert sync.depends_on == ("collect",)
|
||||
assert gated.ordered_steps() # patched DAG stays valid
|
||||
|
||||
|
||||
def test_apply_cookie_gates_returns_workflow_without_accounts(tmp_path: Path) -> None:
|
||||
gate = _gate(tmp_path, _account(tmp_path, "douyin-shop"))
|
||||
workflow = _bound_workflow()
|
||||
assert (
|
||||
apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate) is workflow
|
||||
)
|
||||
|
||||
|
||||
def test_gate_enabled_env_kill_switch() -> None:
|
||||
assert gate_enabled({})
|
||||
assert not gate_enabled({"GYXX_COOKIE_GATE": "0"})
|
||||
assert not gate_enabled({"GYXX_COOKIE_GATE": "off"})
|
||||
assert gate_enabled({"GYXX_COOKIE_GATE": "1"})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# State machine: check()
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_fast_path_accepts_fresh_keepalive_without_probe(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(tmp_path, account, probe={"status": "expired"})
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(
|
||||
account,
|
||||
{
|
||||
"status": "success",
|
||||
"last_success_at": (_NOW - timedelta(minutes=10)).isoformat(),
|
||||
},
|
||||
)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert not verdict.blocked
|
||||
assert "keepalive" in verdict.message
|
||||
state = json.loads(
|
||||
(account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state["state"] == "healthy"
|
||||
|
||||
|
||||
def test_check_stale_status_triggers_probe(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(tmp_path, account, probe={"status": "success"})
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(
|
||||
account,
|
||||
{
|
||||
"status": "success",
|
||||
"last_success_at": (_NOW - timedelta(hours=6)).isoformat(),
|
||||
},
|
||||
)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert not verdict.blocked
|
||||
assert verdict.message.endswith("探活通过)")
|
||||
|
||||
|
||||
def test_check_expired_blocks_and_sends_feishu_alert(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
sent: list[dict[str, object]] = []
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
_write_keepalive_status(account, None)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert verdict.blocked
|
||||
assert verdict.notified
|
||||
assert len(sent) == 1
|
||||
text = str(sent[0]["text"])
|
||||
assert "uv run gyxx accounts login douyin-shop" in text
|
||||
assert "shop.demo.daily" in text
|
||||
assert sent[0]["profile"] == "hermes-analyzer"
|
||||
state = json.loads(
|
||||
(account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state["state"] == "awaiting_login"
|
||||
assert state["last_notified_at"] == _NOW.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def test_check_notification_is_throttled_per_interval(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
sent: list[dict[str, object]] = []
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
_write_keepalive_status(account, None)
|
||||
first = gate.check("douyin-shop", context=_context())
|
||||
second = gate.check("douyin-shop", context=_context())
|
||||
assert first.notified and second.blocked
|
||||
assert not second.notified
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
def test_check_shadow_run_never_notifies(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
sent: list[dict[str, object]] = []
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
verdict = gate.check("douyin-shop", context=_context(shadow=True))
|
||||
assert verdict.blocked
|
||||
assert not verdict.notified
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_check_mode_c_recovery_success_and_failure_paths(tmp_path: Path) -> None:
|
||||
account = _account(
|
||||
tmp_path,
|
||||
"erp-jushuitan",
|
||||
login_mode="C",
|
||||
login_command_id="product_commerce:erp_login.py",
|
||||
)
|
||||
recovered = _gate(
|
||||
tmp_path, account, probe={"status": "success", "login_recovered": True}
|
||||
)
|
||||
_write_keepalive_status(account, None)
|
||||
verdict = recovered.check("erp-jushuitan", context=_context())
|
||||
assert not verdict.blocked
|
||||
assert verdict.recovered
|
||||
assert "自动账密重登成功" in verdict.message
|
||||
|
||||
sent: list[dict[str, object]] = []
|
||||
failed = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "not_logged_in"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
_write_keepalive_status(
|
||||
failed.catalog.account_for("erp-jushuitan"),
|
||||
{
|
||||
"status": "not_logged_in",
|
||||
"login_attempted": True,
|
||||
"login_error_type": "TimeoutExpired",
|
||||
},
|
||||
)
|
||||
verdict = failed.check("erp-jushuitan", context=_context())
|
||||
assert verdict.blocked
|
||||
assert verdict.notified
|
||||
assert "自动账密重登已尝试但未成功" in str(sent[0]["text"])
|
||||
|
||||
|
||||
def test_check_busy_falls_back_to_static_evidence(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(tmp_path, account, probe={"status": "busy"})
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(account, None)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert not verdict.blocked
|
||||
assert "探活锁忙" in verdict.message
|
||||
|
||||
|
||||
def test_check_probe_error_with_static_valid_vault_passes(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(tmp_path, account, probe={"status": "timeout"})
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(account, None)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert not verdict.blocked
|
||||
assert "探活异常" in verdict.message
|
||||
|
||||
|
||||
def test_acceptance_mode_is_static_only_and_never_alerts(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
sent: list[dict[str, object]] = []
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={
|
||||
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
||||
"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target",
|
||||
},
|
||||
sent=sent,
|
||||
)
|
||||
_write_keepalive_status(account, None)
|
||||
verdict = gate.check("douyin-shop", context=_context())
|
||||
assert verdict.blocked
|
||||
assert sent == []
|
||||
assert "验收模式" in verdict.message
|
||||
|
||||
|
||||
def test_alert_recipient_falls_back_to_constant(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(tmp_path, account)
|
||||
recipients = gate._alert_recipients()
|
||||
assert recipients == [
|
||||
("ou_7ad5fc8012e2f741afc5346e05ffd447", "hermes-analyzer")
|
||||
]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Engine integration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _engine(tmp_path: Path) -> WorkflowEngine:
|
||||
return WorkflowEngine(LockManager(tmp_path / "state" / "locks"))
|
||||
|
||||
|
||||
def test_engine_blocks_dependents_when_gate_fails(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
sent: list[dict[str, object]] = []
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
bindings={"shop_intelligence:collect.py": "douyin-shop"},
|
||||
)
|
||||
executed: list[str] = []
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.demo.daily",
|
||||
(
|
||||
StepDefinition(
|
||||
"collect", _RecordingAction("collect", executed, "collect.py")
|
||||
),
|
||||
StepDefinition(
|
||||
"sync", _RecordingAction("sync", executed), depends_on=("collect",)
|
||||
),
|
||||
),
|
||||
)
|
||||
gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate)
|
||||
context = RunContext.create(
|
||||
"shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="dddd"
|
||||
)
|
||||
journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context)
|
||||
engine = _engine(tmp_path)
|
||||
run_workflow = engine.execute
|
||||
result = run_workflow(gated, context=context, journal=journal)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert result.exit_code == 1
|
||||
assert result.steps["cookie-gate.douyin-shop"].status == "failed"
|
||||
assert result.steps["collect"].reason == "dependency-failed"
|
||||
assert result.steps["sync"].reason == "dependency-failed"
|
||||
assert executed == []
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
def test_engine_runs_collect_steps_when_gate_passes(tmp_path: Path) -> None:
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "success"},
|
||||
bindings={"shop_intelligence:collect.py": "douyin-shop"},
|
||||
)
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(account, None)
|
||||
executed: list[str] = []
|
||||
workflow = WorkflowDefinition(
|
||||
"shop.demo.daily",
|
||||
(
|
||||
StepDefinition(
|
||||
"collect", _RecordingAction("collect", executed, "collect.py")
|
||||
),
|
||||
),
|
||||
)
|
||||
gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate)
|
||||
context = RunContext.create(
|
||||
"shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="eeee"
|
||||
)
|
||||
journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context)
|
||||
engine = _engine(tmp_path)
|
||||
run_workflow = engine.execute
|
||||
result = run_workflow(gated, context=context, journal=journal)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.steps["cookie-gate.douyin-shop"].status == "success"
|
||||
assert result.steps["collect"].status == "success"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Recovery: automatic rerun after the login recovers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeLauncher:
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self.launched: list[tuple[str, str]] = []
|
||||
self.closed: list[int] = []
|
||||
self.fail = fail
|
||||
|
||||
def launch(self, workflow_id, slot, business_date): # noqa: ANN001
|
||||
if self.fail:
|
||||
raise RuntimeError("spawn denied")
|
||||
process = SimpleNamespace(pid=9000 + len(self.launched), poll=lambda: None)
|
||||
self.launched.append((workflow_id, business_date.isoformat()))
|
||||
return process
|
||||
|
||||
def close(self, process) -> None: # noqa: ANN001
|
||||
self.closed.append(process.pid)
|
||||
|
||||
|
||||
def _state_file(account: AccountBinding) -> Path:
|
||||
return account.cookie_file.parent / "cookie_state.json"
|
||||
|
||||
|
||||
def _read_state(account: AccountBinding) -> dict:
|
||||
return json.loads(_state_file(account).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _recovery_workflow_catalog() -> WorkflowCatalog:
|
||||
return WorkflowCatalog(
|
||||
timezone="UTC",
|
||||
workflows=(
|
||||
WorkflowEntry(
|
||||
workflow_id="shop.demo.daily",
|
||||
module="shop_intelligence",
|
||||
trigger="scheduled",
|
||||
entry="collect.py",
|
||||
),
|
||||
),
|
||||
schedules=(),
|
||||
)
|
||||
|
||||
|
||||
def _blocking_gate(
|
||||
tmp_path: Path,
|
||||
account: AccountBinding,
|
||||
sent: list[dict[str, object]],
|
||||
) -> CookieGate:
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
return gate
|
||||
|
||||
|
||||
def _healthy_gate(
|
||||
tmp_path: Path,
|
||||
account: AccountBinding,
|
||||
sent: list[dict[str, object]],
|
||||
) -> CookieGate:
|
||||
gate = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "success"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
gate._workflow_catalog = _recovery_workflow_catalog()
|
||||
return gate
|
||||
|
||||
|
||||
def _simulate_manual_login(account: AccountBinding) -> None:
|
||||
_write_valid_vault(account)
|
||||
_write_keepalive_status(
|
||||
account,
|
||||
{
|
||||
"status": "success",
|
||||
"last_success_at": (_NOW - timedelta(seconds=60)).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_expire_records_pending_reruns(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
gate = _blocking_gate(tmp_path, account, sent)
|
||||
gate.check("douyin-shop", context=_context())
|
||||
gate.check("douyin-shop", context=_context()) # 同工作流同日期去重
|
||||
gate.check("douyin-shop", context=_context(business_date="2026-10-09"))
|
||||
state = _read_state(account)
|
||||
assert [
|
||||
(item["workflow_id"], item["business_date"])
|
||||
for item in state["pending_reruns"]
|
||||
] == [
|
||||
("shop.demo.daily", "2026-10-08"),
|
||||
("shop.demo.daily", "2026-10-09"),
|
||||
]
|
||||
|
||||
# shadow 运行的阻断不进入待重跑清单
|
||||
gate.check(
|
||||
"douyin-shop", context=_context(business_date="2026-10-10", shadow=True)
|
||||
)
|
||||
state = _read_state(account)
|
||||
assert all(
|
||||
item["business_date"] != "2026-10-10" for item in state["pending_reruns"]
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_reruns_blocked_workflows_after_login(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
blocked = _blocking_gate(tmp_path, account, sent)
|
||||
blocked.check("douyin-shop", context=_context())
|
||||
|
||||
_simulate_manual_login(account)
|
||||
healthy = _healthy_gate(tmp_path, account, sent)
|
||||
launcher = _FakeLauncher()
|
||||
outcomes = CookieGateRecoveryManager(healthy, launcher=launcher).tick()
|
||||
|
||||
assert launcher.launched == [("shop.demo.daily", "2026-10-08")]
|
||||
assert launcher.closed # 父进程日志句柄立即关闭
|
||||
assert outcomes and outcomes[0]["launched"]
|
||||
state = _read_state(account)
|
||||
assert state["pending_reruns"] == []
|
||||
assert state["state"] == "healthy"
|
||||
assert state["last_rerun_launch_at"]
|
||||
assert state["rerun_history"][0]["workflow_id"] == "shop.demo.daily"
|
||||
assert any("登录态已恢复" in str(item["text"]) for item in sent)
|
||||
|
||||
|
||||
def test_recovery_skips_while_login_still_expired(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
blocked = _blocking_gate(tmp_path, account, sent)
|
||||
blocked.check("douyin-shop", context=_context())
|
||||
_write_keepalive_status(account, None)
|
||||
|
||||
still_expired = _gate(
|
||||
tmp_path,
|
||||
account,
|
||||
probe={"status": "expired"},
|
||||
environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"},
|
||||
sent=sent,
|
||||
)
|
||||
still_expired._workflow_catalog = _recovery_workflow_catalog()
|
||||
launcher = _FakeLauncher()
|
||||
outcomes = CookieGateRecoveryManager(still_expired, launcher=launcher).tick()
|
||||
|
||||
assert launcher.launched == []
|
||||
assert outcomes == []
|
||||
state = _read_state(account)
|
||||
assert len(state["pending_reruns"]) == 1
|
||||
assert state["last_recovery_probe_at"]
|
||||
|
||||
|
||||
def test_recovery_drops_adhoc_script_ids(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
blocked = _blocking_gate(tmp_path, account, sent)
|
||||
blocked.check(
|
||||
"douyin-shop", context=_context(workflow_id="script.shop.abcdef123456")
|
||||
)
|
||||
assert (
|
||||
_read_state(account)["pending_reruns"][0]["workflow_id"]
|
||||
== "script.shop.abcdef123456"
|
||||
)
|
||||
|
||||
_simulate_manual_login(account)
|
||||
healthy = _healthy_gate(tmp_path, account, sent)
|
||||
launcher = _FakeLauncher()
|
||||
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
|
||||
|
||||
assert launcher.launched == []
|
||||
assert _read_state(account)["pending_reruns"] == []
|
||||
|
||||
|
||||
def test_recovery_launch_failure_keeps_pending_for_retry(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
blocked = _blocking_gate(tmp_path, account, sent)
|
||||
blocked.check("douyin-shop", context=_context())
|
||||
_simulate_manual_login(account)
|
||||
|
||||
healthy = _healthy_gate(tmp_path, account, sent)
|
||||
launcher = _FakeLauncher(fail=True)
|
||||
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
|
||||
|
||||
state = _read_state(account)
|
||||
assert len(state["pending_reruns"]) == 1
|
||||
assert "error" in state["pending_reruns"][0]
|
||||
assert state["state"] == "awaiting_login"
|
||||
assert not any("已恢复" in str(item["text"]) for item in sent)
|
||||
|
||||
|
||||
def test_recovery_respects_launch_cooldown(tmp_path: Path) -> None:
|
||||
sent: list[dict[str, object]] = []
|
||||
account = _account(tmp_path, "douyin-shop")
|
||||
blocked = _blocking_gate(tmp_path, account, sent)
|
||||
blocked.check("douyin-shop", context=_context())
|
||||
_simulate_manual_login(account)
|
||||
state = _read_state(account)
|
||||
state["last_rerun_launch_at"] = _NOW.isoformat(timespec="seconds")
|
||||
_state_file(account).write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
healthy = _healthy_gate(tmp_path, account, sent)
|
||||
launcher = _FakeLauncher()
|
||||
CookieGateRecoveryManager(healthy, launcher=launcher).tick()
|
||||
|
||||
assert launcher.launched == []
|
||||
Reference in new issue
Block a user