feat(cookie-gate): 补充 cookie_gate 网关模块与 deploy/watchdog 保活脚本
cookie_gate.py(账号三级门控/阻断记录 pending_reruns/恢复重跑管理器)为此前 接线提交遗漏的模块文件;deploy/watchdog 为控制台与调度器的进程外保活脚本。
This commit is contained in:
1 parent
dc8be88e60
commit
36fe7b81f8
3 files changed
+1223
No files matched your search
@@ -0,0 +1,882 @@
|
||||
"""Per-account cookie health gate injected before workflow collection steps.
|
||||
|
||||
Every runtime account a workflow depends on is checked before the collection
|
||||
steps that use it may run. Accounts fall into three gate modes derived from
|
||||
``config/runtime-bindings.json``:
|
||||
|
||||
* ``blocking`` -- QR-code / SMS-only accounts (no credential environment and
|
||||
no configured login command). The gate verifies the login state and, when
|
||||
it has expired, fails the gate step so every dependent step is skipped with
|
||||
``dependency-failed`` and sends a Feishu alert asking the login owner to
|
||||
re-login interactively via ``gyxx accounts login <account_id>``.
|
||||
* ``recover`` -- accounts with a vault-managed credential login command
|
||||
(``login_mode`` ``C``). The keepalive manager re-runs the credential login
|
||||
automatically; only a failed recovery blocks the workflow and alerts.
|
||||
* ``defer`` -- accounts whose member scripts embed username/password login
|
||||
flows. These keep their script-level login behaviour unchanged and are not
|
||||
gated at all.
|
||||
|
||||
The per-account decision is persisted as a small state machine under
|
||||
``state/accounts/<id>/cookie_state.json`` (``healthy`` <-> ``awaiting_login``)
|
||||
so repeat failures alert at most once per notify interval instead of once per
|
||||
workflow run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from gyxx_flow.accounts import (
|
||||
AccountKeepaliveManager,
|
||||
_keepalive_requires_relogin,
|
||||
_read_keepalive_status,
|
||||
_vault_cookie_valid,
|
||||
)
|
||||
from gyxx_flow.adapters.browser import BrowserCookieStore
|
||||
from gyxx_flow.adapters.feishu_messages import send_lark_bot_message
|
||||
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
|
||||
from gyxx_flow.catalog import WorkflowCatalog
|
||||
from gyxx_flow.core.artifacts import atomic_write_json
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.core.context import RunContext
|
||||
from gyxx_flow.notification_routing import (
|
||||
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
||||
ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
NotificationRoutingStore,
|
||||
)
|
||||
from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition
|
||||
from gyxx_flow.workflow.steps import StepExecution
|
||||
|
||||
GATE_MODE_BLOCKING = "blocking"
|
||||
GATE_MODE_RECOVER = "recover"
|
||||
GATE_MODE_DEFER = "defer"
|
||||
|
||||
# 飞书通知的默认接收人:按手机号在 config/notification-routing.json 的
|
||||
# people 段解析 open_id;解析失败时回退到同名 analyzer 常量收件人。
|
||||
DEFAULT_LOGIN_ALERT_MOBILE = "+8613663785145"
|
||||
|
||||
GYXX_COOKIE_GATE = "GYXX_COOKIE_GATE"
|
||||
GYXX_COOKIE_GATE_MAX_STATUS_AGE_SECONDS = "GYXX_COOKIE_GATE_MAX_STATUS_AGE_SECONDS"
|
||||
GYXX_LOGIN_ALERT_OPEN_IDS = "GYXX_LOGIN_ALERT_OPEN_IDS"
|
||||
GYXX_LOGIN_ALERT_APP_PROFILE = "GYXX_LOGIN_ALERT_APP_PROFILE"
|
||||
GYXX_LOGIN_ALERT_NOTIFY_INTERVAL_SECONDS = "GYXX_LOGIN_ALERT_NOTIFY_INTERVAL_SECONDS"
|
||||
|
||||
_GATE_STEP_PREFIX = "cookie-gate"
|
||||
_GATE_STATE_FILENAME = "cookie_state.json"
|
||||
_GATE_TIMEOUT_SECONDS = 1800.0
|
||||
_DEFAULT_MAX_STATUS_AGE_SECONDS = 3600.0
|
||||
_MIN_MAX_STATUS_AGE_SECONDS = 300.0
|
||||
_DEFAULT_NOTIFY_INTERVAL_SECONDS = 1800.0
|
||||
_DISABLED_VALUES = frozenset({"0", "false", "off", "no", "disabled"})
|
||||
# 自动重跑:待重跑清单上限、健康探活节流、同账号重跑启动冷却。
|
||||
_MAX_PENDING_RERUNS = 20
|
||||
_RECOVERY_PROBE_INTERVAL_SECONDS = 300.0
|
||||
_RECOVERY_LAUNCH_COOLDOWN_SECONDS = 300.0
|
||||
_RECOVERY_HISTORY_LIMIT = 20
|
||||
|
||||
STATE_HEALTHY = "healthy"
|
||||
STATE_AWAITING_LOGIN = "awaiting_login"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GateVerdict:
|
||||
"""One account's gate decision, recorded in the run journal on failure."""
|
||||
|
||||
account_id: str
|
||||
mode: str
|
||||
blocked: bool
|
||||
message: str
|
||||
recovered: bool = False
|
||||
notified: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CookieGateStepAction:
|
||||
"""ExecutableStep that turns one account verdict into a step outcome."""
|
||||
|
||||
gate: "CookieGate"
|
||||
account_id: str
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
context: RunContext,
|
||||
timeout_seconds: float | None,
|
||||
dry_run: bool,
|
||||
) -> StepExecution:
|
||||
if dry_run: # pragma: no cover - the engine skips dry-run steps first
|
||||
return StepExecution(exit_code=0, skipped=True, reason="dry-run")
|
||||
verdict = self.gate.check(self.account_id, context=context)
|
||||
if verdict.blocked:
|
||||
return StepExecution(
|
||||
exit_code=1,
|
||||
error=verdict.message,
|
||||
reason="cookie-expired",
|
||||
)
|
||||
return StepExecution(exit_code=0)
|
||||
|
||||
|
||||
class CookieGate:
|
||||
"""Resolve, probe, and gate the accounts one workflow depends on."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
catalog: RuntimeIntegrationCatalog | None = None,
|
||||
keepalive: AccountKeepaliveManager | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
now: Callable[[], datetime] | None = None,
|
||||
sender: Callable[..., dict[str, Any]] | None = None,
|
||||
) -> None: # noqa: D107 - simple wiring
|
||||
self.settings = settings
|
||||
self.catalog = catalog or RuntimeIntegrationCatalog.load_default(
|
||||
project_root=settings.project_root,
|
||||
data_root=settings.data_root,
|
||||
)
|
||||
self.keepalive = keepalive or AccountKeepaliveManager(
|
||||
settings, catalog=self.catalog
|
||||
)
|
||||
self._environment = dict(os.environ if environment is None else environment)
|
||||
self._now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._sender = sender or send_lark_bot_message
|
||||
self._workflow_catalog: WorkflowCatalog | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Account resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def gate_mode(self, account_id: str) -> str:
|
||||
account = self.catalog.account_for(account_id)
|
||||
if str(account.login_command_id or "").strip():
|
||||
return GATE_MODE_RECOVER
|
||||
if account.credential_env_names or account.login_mode == "B":
|
||||
return GATE_MODE_DEFER
|
||||
return GATE_MODE_BLOCKING
|
||||
|
||||
def collect_workflow_accounts(
|
||||
self,
|
||||
workflow: WorkflowDefinition,
|
||||
) -> dict[str, set[str]]:
|
||||
"""Map each gated account to the step ids that depend on it.
|
||||
|
||||
Accounts resolve two ways: directly, from each step binding's
|
||||
``account``; and declaratively, from the catalog workflow's
|
||||
``required_accounts`` (used by orchestrator entries whose own binding
|
||||
has no account while the spawned child collectors do).
|
||||
"""
|
||||
|
||||
accounts: dict[str, set[str]] = {}
|
||||
for step in workflow.steps:
|
||||
account_id = self._account_for_step(step)
|
||||
if account_id:
|
||||
accounts.setdefault(account_id, set()).add(step.step_id)
|
||||
for account_id in self._declared_required_accounts(workflow.workflow_id):
|
||||
self.catalog.account_for(account_id) # unknown id: loud config error
|
||||
accounts.setdefault(account_id, set()).update(
|
||||
step.step_id for step in workflow.steps
|
||||
)
|
||||
# Defer-mode accounts keep their script-level credential login flows
|
||||
# and are intentionally not gated.
|
||||
return {
|
||||
account_id: step_ids
|
||||
for account_id, step_ids in accounts.items()
|
||||
if self.gate_mode(account_id) != GATE_MODE_DEFER
|
||||
}
|
||||
|
||||
def _account_for_step(self, step: StepDefinition) -> str | None:
|
||||
action = step.action
|
||||
entry = getattr(action, "entry", None)
|
||||
module_id = getattr(entry, "module", "")
|
||||
command_entry = getattr(action, "command_entry", None) or getattr(
|
||||
entry, "entry", ""
|
||||
)
|
||||
if not module_id or not command_entry:
|
||||
return None
|
||||
try:
|
||||
binding = self.catalog.binding_for(f"{module_id}:{command_entry}")
|
||||
except Exception: # noqa: BLE001 - unbound entries run ungated
|
||||
return None
|
||||
return binding.account or None
|
||||
|
||||
def workflow_catalog(self) -> WorkflowCatalog:
|
||||
"""Load (once) the catalog used for declared account and rerun lookups."""
|
||||
|
||||
if self._workflow_catalog is None:
|
||||
try:
|
||||
self._workflow_catalog = WorkflowCatalog.load(
|
||||
self.settings.project_root / "config"
|
||||
)
|
||||
except Exception: # noqa: BLE001 - catalog issues surface elsewhere
|
||||
self._workflow_catalog = WorkflowCatalog(
|
||||
timezone="UTC", workflows=(), schedules=()
|
||||
)
|
||||
return self._workflow_catalog
|
||||
|
||||
def _declared_required_accounts(self, workflow_id: str) -> tuple[str, ...]:
|
||||
for entry in self.workflow_catalog().workflows:
|
||||
if entry.workflow_id == workflow_id:
|
||||
return tuple(entry.required_accounts)
|
||||
return ()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State machine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check(self, account_id: str, *, context: RunContext) -> GateVerdict:
|
||||
account = self.catalog.account_for(account_id)
|
||||
mode = self.gate_mode(account_id)
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
static_valid = _vault_cookie_valid(account, store)
|
||||
status = _read_keepalive_status(account)
|
||||
if static_valid and self._keepalive_success_is_fresh(status):
|
||||
self._write_state(account, STATE_HEALTHY, probe="fresh-keepalive")
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=False,
|
||||
message=f"账号 {account_id} 登录态有效(keepalive 近期验证通过)",
|
||||
)
|
||||
if self._acceptance_enabled():
|
||||
# 验收模式不开浏览器:按静态证据判定,且不投递飞书通知。
|
||||
if static_valid and not _keepalive_requires_relogin(status):
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=False,
|
||||
message=f"账号 {account_id} 静态 cookie 校验通过(验收模式)",
|
||||
)
|
||||
return self._expire(
|
||||
account,
|
||||
mode,
|
||||
context,
|
||||
detail="验收模式下静态 cookie 校验未通过",
|
||||
notify=False,
|
||||
)
|
||||
|
||||
outcome = self.keepalive.refresh_account(account_id)
|
||||
probe_status = str(outcome.get("status", "error"))
|
||||
if probe_status == "success":
|
||||
recovered = bool(outcome.get("login_recovered"))
|
||||
self._write_state(
|
||||
account,
|
||||
STATE_HEALTHY,
|
||||
probe="success",
|
||||
recovered=recovered,
|
||||
)
|
||||
message = (
|
||||
f"账号 {account_id} 登录态有效(自动账密重登成功)"
|
||||
if recovered
|
||||
else f"账号 {account_id} 登录态有效(探活通过)"
|
||||
)
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=False,
|
||||
message=message,
|
||||
recovered=recovered,
|
||||
)
|
||||
if probe_status == "busy":
|
||||
if static_valid and not _keepalive_requires_relogin(status):
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=False,
|
||||
message=f"账号 {account_id} 探活锁忙,静态 cookie 校验通过",
|
||||
)
|
||||
return self._expire(
|
||||
account,
|
||||
mode,
|
||||
context,
|
||||
detail="探活锁被占用且静态 cookie 校验未通过",
|
||||
)
|
||||
if probe_status in {"expired", "not_logged_in"}:
|
||||
evidence = _recovery_evidence(_read_keepalive_status(account))
|
||||
detail = "cookie 已过期,无法正常采集"
|
||||
if probe_status == "not_logged_in" and evidence:
|
||||
detail = f"登录态缺失;{evidence}"
|
||||
elif evidence:
|
||||
detail = f"cookie 已过期;{evidence}"
|
||||
return self._expire(account, mode, context, detail=detail)
|
||||
# timeout / error / http_error / disabled:探测结果不可信时回退静态证据。
|
||||
if static_valid and not _keepalive_requires_relogin(status):
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=False,
|
||||
message=f"账号 {account_id} 探活异常({probe_status}),静态 cookie 校验通过",
|
||||
)
|
||||
return self._expire(
|
||||
account,
|
||||
mode,
|
||||
context,
|
||||
detail=f"探活异常({probe_status})且静态 cookie 校验未通过",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Expiry, notification, persisted state
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _expire(
|
||||
self,
|
||||
account: Any,
|
||||
mode: str,
|
||||
context: RunContext,
|
||||
*,
|
||||
detail: str,
|
||||
notify: bool | None = None,
|
||||
) -> GateVerdict:
|
||||
account_id = str(account.account_id)
|
||||
if notify is None:
|
||||
notify = not context.shadow and not self._acceptance_enabled()
|
||||
state = self._read_state(account)
|
||||
notified = False
|
||||
notification_note = ""
|
||||
if notify and not self._within_notify_interval(state):
|
||||
recipients = self._alert_recipients()
|
||||
message = _expiry_message(account_id, mode, context, detail)
|
||||
for open_id, profile in recipients:
|
||||
try:
|
||||
self._sender(
|
||||
user_id=open_id,
|
||||
text=message,
|
||||
idempotency_key=f"cookie-gate:{account_id}:{context.run_id}",
|
||||
profile=profile,
|
||||
)
|
||||
notified = True
|
||||
except Exception as exc: # noqa: BLE001 - alerting must not mask the verdict
|
||||
notification_note = f"{type(exc).__name__}: {exc}"[:300]
|
||||
if recipients and notified:
|
||||
state["last_notified_at"] = _now_iso(self._now)
|
||||
state.pop("notification_error", None)
|
||||
elif notification_note:
|
||||
state["notification_error"] = notification_note
|
||||
state["state"] = STATE_AWAITING_LOGIN
|
||||
state["updated_at"] = _now_iso(self._now)
|
||||
state["last_probe_status"] = "expired"
|
||||
state["last_blocked_run"] = context.run_id
|
||||
if not context.shadow and not self._acceptance_enabled():
|
||||
# 记录待自动重跑清单:登录态恢复后由调度器维护循环按原业务日期重跑。
|
||||
state["pending_reruns"] = _record_pending_rerun(
|
||||
state.get("pending_reruns"), context, _now_iso(self._now)
|
||||
)
|
||||
self._save_state(account, state)
|
||||
message = _expiry_message(account_id, mode, context, detail)
|
||||
if notified:
|
||||
message += " 已通过飞书通知登录负责人。"
|
||||
elif notification_note:
|
||||
message += f" 飞书通知发送失败({notification_note})。"
|
||||
return GateVerdict(
|
||||
account_id=account_id,
|
||||
mode=mode,
|
||||
blocked=True,
|
||||
message=message,
|
||||
notified=notified,
|
||||
)
|
||||
|
||||
def _write_state(
|
||||
self,
|
||||
account: Any,
|
||||
state_name: str,
|
||||
*,
|
||||
probe: str,
|
||||
recovered: bool = False,
|
||||
) -> None:
|
||||
state = self._read_state(account)
|
||||
if (
|
||||
state_name == STATE_HEALTHY
|
||||
and state.get("state") == STATE_HEALTHY
|
||||
and state.get("last_probe_status") in {"fresh-keepalive", "success"}
|
||||
and not recovered
|
||||
):
|
||||
return # 持续健康时避免每次运行都重写状态文件
|
||||
state["state"] = state_name
|
||||
state["updated_at"] = _now_iso(self._now)
|
||||
state["last_probe_status"] = probe
|
||||
if state_name == STATE_HEALTHY:
|
||||
state["last_healthy_at"] = _now_iso(self._now)
|
||||
if recovered:
|
||||
state["last_recovered_at"] = _now_iso(self._now)
|
||||
state.pop("notification_error", None)
|
||||
self._save_state(account, state)
|
||||
|
||||
def _state_path(self, account: Any) -> Path:
|
||||
return account.cookie_file.with_name(_GATE_STATE_FILENAME)
|
||||
|
||||
def _read_state(self, account: Any) -> dict[str, object]:
|
||||
path = self._state_path(account)
|
||||
if not path.exists():
|
||||
return {"account_id": str(account.account_id)}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"account_id": str(account.account_id)}
|
||||
if not isinstance(payload, dict):
|
||||
return {"account_id": str(account.account_id)}
|
||||
return dict(payload)
|
||||
|
||||
def _save_state(self, account: Any, state: Mapping[str, object]) -> None:
|
||||
payload = {
|
||||
"account_id": str(account.account_id),
|
||||
**state,
|
||||
}
|
||||
try:
|
||||
atomic_write_json(self._state_path(account), payload)
|
||||
except OSError: # pragma: no cover - state file is advisory only
|
||||
pass
|
||||
|
||||
def _within_notify_interval(self, state: Mapping[str, object]) -> bool:
|
||||
raw = state.get("last_notified_at")
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
try:
|
||||
last = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
now = self._now()
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
interval = _env_int(
|
||||
self._environment,
|
||||
GYXX_LOGIN_ALERT_NOTIFY_INTERVAL_SECONDS,
|
||||
_DEFAULT_NOTIFY_INTERVAL_SECONDS,
|
||||
minimum=0,
|
||||
)
|
||||
return (now - last).total_seconds() < interval
|
||||
|
||||
def _keepalive_success_is_fresh(self, status: Mapping[str, object] | None) -> bool:
|
||||
if not isinstance(status, Mapping) or status.get("status") != "success":
|
||||
return False
|
||||
raw = status.get("last_success_at")
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
try:
|
||||
last = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
now = self._now()
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
max_age = _env_float(
|
||||
self._environment,
|
||||
GYXX_COOKIE_GATE_MAX_STATUS_AGE_SECONDS,
|
||||
_DEFAULT_MAX_STATUS_AGE_SECONDS,
|
||||
minimum=_MIN_MAX_STATUS_AGE_SECONDS,
|
||||
)
|
||||
return (now - last).total_seconds() <= max_age
|
||||
|
||||
def _alert_recipients(self) -> list[tuple[str, str]]:
|
||||
profile = (
|
||||
self._environment.get(GYXX_LOGIN_ALERT_APP_PROFILE, "").strip()
|
||||
or ANALYZER_NOTIFICATION_APP_PROFILE
|
||||
)
|
||||
override = [
|
||||
value.strip()
|
||||
for value in self._environment.get(GYXX_LOGIN_ALERT_OPEN_IDS, "").split(",")
|
||||
if value.strip()
|
||||
]
|
||||
if override:
|
||||
return [(open_id, profile) for open_id in override]
|
||||
try:
|
||||
payload, _revision = NotificationRoutingStore(self.settings).snapshot()
|
||||
for person in payload["people"].values():
|
||||
if person.get("mobile") == DEFAULT_LOGIN_ALERT_MOBILE:
|
||||
binding = person.get("bindings", {}).get(profile)
|
||||
if binding:
|
||||
return [(str(binding["open_id"]), profile)]
|
||||
except Exception: # noqa: BLE001 - fall back to the constant recipient
|
||||
pass
|
||||
return [(ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, profile)]
|
||||
|
||||
def _acceptance_enabled(self) -> bool:
|
||||
value = self._environment.get("GYXX_WORKFLOW_ACCEPTANCE", "")
|
||||
return bool(value and value.strip().casefold() in {"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def gate_enabled(environment: Mapping[str, str] | None = None) -> bool:
|
||||
values = os.environ if environment is None else environment
|
||||
return values.get(GYXX_COOKIE_GATE, "").strip().casefold() not in _DISABLED_VALUES
|
||||
|
||||
|
||||
def apply_cookie_gates(
|
||||
workflow: WorkflowDefinition,
|
||||
*,
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
gate: CookieGate | None = None,
|
||||
) -> WorkflowDefinition:
|
||||
"""Inject one critical gate step per gated account in front of its users.
|
||||
|
||||
Gate steps are graph sources; each step that uses an account gains the
|
||||
account's gate as an extra dependency. A failed gate therefore skips its
|
||||
dependents with ``dependency-failed`` through the regular engine, while
|
||||
unrelated branches keep running. Operator slices (``--rerun-step`` /
|
||||
``--resume-from``) are produced before this injection and stay gate-free.
|
||||
"""
|
||||
|
||||
if not workflow.steps:
|
||||
return workflow
|
||||
if gate is None:
|
||||
try:
|
||||
gate = CookieGate(
|
||||
settings,
|
||||
environment=os.environ if environment is None else environment,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 无集成配置时不门控;步骤执行仍会显式报错
|
||||
return workflow
|
||||
account_steps = gate.collect_workflow_accounts(workflow)
|
||||
if not account_steps:
|
||||
return workflow
|
||||
existing_ids = {step.step_id for step in workflow.steps}
|
||||
gate_steps: list[StepDefinition] = []
|
||||
for account_id in sorted(account_steps):
|
||||
step_id = f"{_GATE_STEP_PREFIX}.{account_id}"
|
||||
if step_id in existing_ids:
|
||||
continue
|
||||
gate_steps.append(
|
||||
StepDefinition(
|
||||
step_id,
|
||||
CookieGateStepAction(gate, account_id),
|
||||
timeout_seconds=_GATE_TIMEOUT_SECONDS,
|
||||
max_attempts=1,
|
||||
)
|
||||
)
|
||||
if not gate_steps:
|
||||
return workflow
|
||||
patched: list[StepDefinition] = []
|
||||
for step in workflow.steps:
|
||||
dependencies = list(dict.fromkeys(step.depends_on))
|
||||
changed = False
|
||||
for account_id, step_ids in account_steps.items():
|
||||
gate_id = f"{_GATE_STEP_PREFIX}.{account_id}"
|
||||
if step.step_id in step_ids and gate_id not in dependencies:
|
||||
dependencies.append(gate_id)
|
||||
changed = True
|
||||
if changed:
|
||||
step = replace(step, depends_on=tuple(dependencies))
|
||||
patched.append(step)
|
||||
return WorkflowDefinition(
|
||||
workflow.workflow_id,
|
||||
(*gate_steps, *patched),
|
||||
)
|
||||
|
||||
|
||||
class CookieGateRecoveryManager:
|
||||
"""Re-run workflows blocked by expired cookies once the login recovers.
|
||||
|
||||
Runs inside the resident scheduler's maintenance loop. For every account
|
||||
with a non-empty ``pending_reruns`` list, it re-verifies the login state
|
||||
(fresh keepalive success first, then a throttled live probe); on recovery
|
||||
it relaunches each recorded workflow with its original business date
|
||||
through the scheduler's own launcher, clears the pending list, and sends a
|
||||
Feishu confirmation to the login owner.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gate: CookieGate,
|
||||
*,
|
||||
launcher: Any,
|
||||
probe_interval_seconds: float = _RECOVERY_PROBE_INTERVAL_SECONDS,
|
||||
launch_cooldown_seconds: float = _RECOVERY_LAUNCH_COOLDOWN_SECONDS,
|
||||
) -> None:
|
||||
self.gate = gate
|
||||
self._launcher = launcher
|
||||
self._probe_interval = probe_interval_seconds
|
||||
self._launch_cooldown = launch_cooldown_seconds
|
||||
|
||||
def tick(self) -> list[dict[str, object]]:
|
||||
"""Attempt recovery reruns once; returns redacted outcomes per account."""
|
||||
|
||||
outcomes: list[dict[str, object]] = []
|
||||
for account in self.gate.catalog.accounts:
|
||||
state = self.gate._read_state(account)
|
||||
pending = state.get("pending_reruns")
|
||||
if not isinstance(pending, list) or not pending:
|
||||
continue
|
||||
now = self.gate._now()
|
||||
if not self._launch_cooldown_passed(state, now):
|
||||
continue
|
||||
if not self._account_recovered(account, state, now):
|
||||
continue
|
||||
launched, remaining = self._launch_pending(account, pending, now)
|
||||
state["pending_reruns"] = remaining
|
||||
state["updated_at"] = _now_iso(self.gate._now)
|
||||
state["last_recovered_at"] = state["updated_at"]
|
||||
if not remaining:
|
||||
state["state"] = STATE_HEALTHY
|
||||
state["last_probe_status"] = "recovered-rerun"
|
||||
if launched:
|
||||
state["last_rerun_launch_at"] = state["updated_at"]
|
||||
history = [
|
||||
item
|
||||
for item in state.get("rerun_history", [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
history.extend(launched)
|
||||
state["rerun_history"] = history[-_RECOVERY_HISTORY_LIMIT:]
|
||||
self.gate._save_state(account, state)
|
||||
if launched:
|
||||
self._notify_recovered(str(account.account_id), launched)
|
||||
outcomes.append(
|
||||
{
|
||||
"account_id": str(account.account_id),
|
||||
"launched": launched,
|
||||
"pending": len(remaining),
|
||||
}
|
||||
)
|
||||
return outcomes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _launch_cooldown_passed(
|
||||
self, state: Mapping[str, object], now: datetime
|
||||
) -> bool:
|
||||
raw = state.get("last_rerun_launch_at")
|
||||
if not isinstance(raw, str):
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return True
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
return (now - last).total_seconds() >= self._launch_cooldown
|
||||
|
||||
def _account_recovered(
|
||||
self,
|
||||
account: Any,
|
||||
state: dict[str, object],
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Fresh keepalive success first; otherwise one throttled live probe."""
|
||||
|
||||
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
|
||||
if _vault_cookie_valid(account, store) and self.gate._keepalive_success_is_fresh(
|
||||
_read_keepalive_status(account)
|
||||
):
|
||||
return True
|
||||
raw = state.get("last_recovery_probe_at")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
last_probe = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
last_probe = None
|
||||
if last_probe is not None:
|
||||
if last_probe.tzinfo is None:
|
||||
last_probe = last_probe.replace(tzinfo=timezone.utc)
|
||||
if (now - last_probe).total_seconds() < self._probe_interval:
|
||||
return False
|
||||
state["last_recovery_probe_at"] = _now_iso(self.gate._now)
|
||||
self.gate._save_state(account, state)
|
||||
try:
|
||||
outcome = self.gate.keepalive.refresh_account(str(account.account_id))
|
||||
except Exception: # noqa: BLE001 - 恢复探活失败留给下一轮
|
||||
return False
|
||||
return str(outcome.get("status", "error")) == "success"
|
||||
|
||||
def _launch_pending(
|
||||
self,
|
||||
account: Any,
|
||||
pending: list[object],
|
||||
now: datetime,
|
||||
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
||||
known_ids = {
|
||||
entry.workflow_id for entry in self.gate.workflow_catalog().workflows
|
||||
}
|
||||
launched: list[dict[str, object]] = []
|
||||
remaining: list[dict[str, object]] = []
|
||||
for item in pending:
|
||||
if not isinstance(item, dict):
|
||||
continue # 控制台脚本等临时运行 id:不在 catalog 中,直接丢弃
|
||||
workflow_id = item.get("workflow_id")
|
||||
business_date = item.get("business_date")
|
||||
if not isinstance(workflow_id, str) or not isinstance(business_date, str):
|
||||
continue
|
||||
if workflow_id not in known_ids:
|
||||
continue
|
||||
try:
|
||||
process = self._launcher.launch(
|
||||
workflow_id, now, date.fromisoformat(business_date)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - 保留 pending,下一轮重试
|
||||
remaining.append(
|
||||
{
|
||||
"workflow_id": workflow_id,
|
||||
"business_date": business_date,
|
||||
"error": f"{type(exc).__name__}: {exc}"[:200],
|
||||
}
|
||||
)
|
||||
continue
|
||||
close = getattr(self._launcher, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close(process)
|
||||
except Exception: # noqa: BLE001 - 关闭日志句柄失败不影响重跑
|
||||
pass
|
||||
launched.append(
|
||||
{
|
||||
"workflow_id": workflow_id,
|
||||
"business_date": business_date,
|
||||
"pid": int(getattr(process, "pid", 0) or 0),
|
||||
"launched_at": _now_iso(self.gate._now),
|
||||
}
|
||||
)
|
||||
return launched, remaining
|
||||
|
||||
def _notify_recovered(
|
||||
self,
|
||||
account_id: str,
|
||||
launched: list[dict[str, object]],
|
||||
) -> None:
|
||||
lines = [
|
||||
f"- {item['workflow_id']}(业务日期 {item['business_date']})"
|
||||
for item in launched
|
||||
]
|
||||
message = (
|
||||
"【GYXX 登录态已恢复】账号 "
|
||||
f"{account_id} 登录态已恢复,已自动重跑被阻断的工作流:\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n如本次重跑仍有失败,请查看运行日志或工作流控制台。"
|
||||
)
|
||||
key = "-".join(
|
||||
[account_id, *(str(item["workflow_id"]) for item in launched)]
|
||||
)
|
||||
for open_id, profile in self.gate._alert_recipients():
|
||||
try:
|
||||
self.gate._sender(
|
||||
user_id=open_id,
|
||||
text=message,
|
||||
idempotency_key=f"cookie-recovery:{key}",
|
||||
profile=profile,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 确认通知失败不影响已发起的重跑
|
||||
continue
|
||||
|
||||
|
||||
def _record_pending_rerun(
|
||||
current: object,
|
||||
context: RunContext,
|
||||
blocked_at: str,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Append one blocked run to the pending list, deduped and bounded."""
|
||||
|
||||
pending: list[dict[str, object]] = [
|
||||
dict(item)
|
||||
for item in (current if isinstance(current, list) else [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
entry = {
|
||||
"workflow_id": context.workflow_id,
|
||||
"business_date": context.business_date.isoformat(),
|
||||
"blocked_run_id": context.run_id,
|
||||
"blocked_at": blocked_at,
|
||||
}
|
||||
pending = [
|
||||
item
|
||||
for item in pending
|
||||
if not (
|
||||
item.get("workflow_id") == entry["workflow_id"]
|
||||
and item.get("business_date") == entry["business_date"]
|
||||
)
|
||||
]
|
||||
pending.append(entry)
|
||||
return pending[-_MAX_PENDING_RERUNS:]
|
||||
|
||||
|
||||
def _recovery_evidence(status: Mapping[str, object] | None) -> str:
|
||||
if not isinstance(status, Mapping):
|
||||
return ""
|
||||
if not status.get("login_attempted"):
|
||||
return ""
|
||||
reason = status.get("login_reason")
|
||||
error_type = status.get("login_error_type")
|
||||
exit_code = status.get("login_exit_code")
|
||||
parts = ["自动账密重登已尝试但未成功"]
|
||||
if isinstance(reason, str) and reason:
|
||||
parts.append(reason)
|
||||
if isinstance(error_type, str) and error_type:
|
||||
parts.append(f"错误={error_type}")
|
||||
if isinstance(exit_code, int) and not isinstance(exit_code, bool):
|
||||
parts.append(f"退出码={exit_code}")
|
||||
return "(" + ",".join(parts) + ")"
|
||||
|
||||
|
||||
def _expiry_message(
|
||||
account_id: str,
|
||||
mode: str,
|
||||
context: RunContext,
|
||||
detail: str,
|
||||
) -> str:
|
||||
mode_note = (
|
||||
"账号密码自动重登失败,需人工处理(如验证码)"
|
||||
if mode == GATE_MODE_RECOVER
|
||||
else "该平台仅支持扫码/验证码登录,无法自动恢复"
|
||||
)
|
||||
return (
|
||||
"【GYXX 登录态失效】工作流被阻断,需要人工重新登录\n"
|
||||
f"工作流: {context.workflow_id}(业务日期 {context.business_date.isoformat()})\n"
|
||||
f"账号: {account_id}\n"
|
||||
f"原因: {detail};{mode_note}\n"
|
||||
f"请在服务器上执行: uv run gyxx accounts login {account_id}\n"
|
||||
"(将打开浏览器窗口,完成扫码或验证码登录后自动保存并同步到全部成员脚本;"
|
||||
"受影响的采集步骤本次已跳过,登录完成后下次运行自动恢复)"
|
||||
)
|
||||
|
||||
|
||||
def _now_iso(now: Callable[[], datetime]) -> str:
|
||||
return now().astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _env_int(
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: int,
|
||||
*,
|
||||
minimum: int,
|
||||
) -> int:
|
||||
raw = environment.get(name, "").strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(minimum, value)
|
||||
|
||||
|
||||
def _env_float(
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
default: float,
|
||||
*,
|
||||
minimum: float,
|
||||
) -> float:
|
||||
raw = environment.get(name, "").strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(minimum, value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LOGIN_ALERT_MOBILE",
|
||||
"GATE_MODE_BLOCKING",
|
||||
"GATE_MODE_DEFER",
|
||||
"GATE_MODE_RECOVER",
|
||||
"GYXX_COOKIE_GATE",
|
||||
"GYXX_LOGIN_ALERT_OPEN_IDS",
|
||||
"CookieGate",
|
||||
"CookieGateRecoveryManager",
|
||||
"CookieGateStepAction",
|
||||
"GateVerdict",
|
||||
"apply_cookie_gates",
|
||||
"gate_enabled",
|
||||
]
|
||||
Reference in new issue
Block a user