"""Operator-safe workflow web console and HTTP API.""" from __future__ import annotations import calendar import hashlib import hmac import ipaddress import json import os import re import shutil import subprocess import sys import tempfile import threading import time import unicodedata import uuid from contextlib import suppress from dataclasses import asdict, dataclass from datetime import date, datetime, timedelta, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from importlib import resources from pathlib import Path from typing import Any, Mapping, Protocol, Sequence, TextIO, cast from urllib.parse import parse_qs, unquote, urlsplit from zoneinfo import ZoneInfo import psutil from gyxx_flow.adapters import ( ContentLLMConfigurationError, load_content_llm_config, resolve_hermes_profile_api_key, ) from gyxx_flow.adapters.workflow_config import ( PostgresWorkflowConfigStore, WorkflowConfigConflictError, WorkflowConfigError, WorkflowConfigNotFoundError, WorkflowConfigRepository, WorkflowConfigUnavailableError, WorkflowConfigValidationError, ) from gyxx_flow.catalog import ( CatalogError, ScheduleEntry, WorkflowCatalog, WorkflowEntry, ) from gyxx_flow.core.artifacts import atomic_write_json, sha256_file 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.core.text import bounded_head_tail from gyxx_flow.daily_summary import ( DailySummaryAnalyzer, DailySummaryBuilder, ) from gyxx_flow.modules import create_default_registry as create_module_registry from gyxx_flow.modules.product_commerce import ( TMALL_BAIBU_DEFAULT_IMPORT_MODE, TMALL_BAIBU_IMPORT_MODE_ALL, TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS, TMALL_BAIBU_WORKFLOW_ID, tmall_baibu_import_counts, validate_tmall_baibu_import_mode, ) from gyxx_flow.modules.product_commerce.direct_llm import ( DirectLLMConfigurationError, load_direct_llm_config, ) from gyxx_flow.notification_routing import ( ANALYZER_HERMES_PROFILE, ANALYZER_NOTIFICATION_APP_ID, NOTIFICATION_CAPABILITIES, NotificationRoutingConflictError, NotificationRoutingError, NotificationRoutingPreconditionError, NotificationRoutingStore, resolve_notification_route, ) from gyxx_flow.ops import RunIndex, RunRecord CONSOLE_TOKEN_ENV = "GYXX_CONSOLE_TOKEN" MAX_REQUEST_BYTES = 64 * 1024 MAX_ERROR_CHARS = 4_000 MAX_DIAGNOSIS_LOG_FILES = 5 MAX_DIAGNOSIS_LOG_READ_BYTES = 262_144 MAX_ACTIVE_RUNS = 4 MAX_TRACKED_OPERATIONS = 128 PROCESS_TERMINATE_TIMEOUT_SECONDS = 5.0 SCHEDULER_START_TIMEOUT_SECONDS = 3.0 MAX_WORKFLOW_DISPLAY_NAME_CHARS = 64 MAX_WORKFLOW_DISPLAY_NAME_BYTES = 256 MAX_WORKFLOW_DISPLAY_NAMES_FILE_BYTES = 64 * 1024 EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION = "0" * 64 MAX_NOTIFICATION_PHONE_CHARS = 24 MAX_NOTIFICATION_LOOKUP_PROOFS = 1024 _OPERATION_ID = re.compile(r"^op-[0-9a-f]{16}$") _SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _SAFE_APP_PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") _FEISHU_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$") _FEISHU_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$") _PROCESS_START_TOLERANCE_SECONDS = 1.0 _CANCELLED_ERROR = "cancelled by console operator" _CONTENT_POSTGRES_WORKFLOW_IDS = frozenset( { "content.notes_master.daily", "content.metrics.daily", "content.metrics.backfill", "content.marketing_report.daily", "content.creator_report.monthly", "content.summary.monthly", "content.comments.weekly", "content.summary.weekly", } ) _CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS = frozenset( { "content.summary.monthly", "content.summary.weekly", } ) _DIRECT_VIDEO_WORKFLOW_IDS = frozenset( { "product.video_upload", "product.jd_video_upload", } ) _MODULES = ( { "id": "content_marketing", "name": "内容营销", "description": "达人、评论、内容指标与营销报告", "accent": "violet", }, { "id": "product_commerce", "name": "商品经营", "description": "商品、画像、排行与经营分析", "accent": "blue", }, { "id": "shop_intelligence", "name": "店铺洞察", "description": "店铺、竞店与自营业绩采集", "accent": "amber", }, { "id": "supply_chain", "name": "供应链", "description": "采购、补货与库存预警", "accent": "green", }, ) _WORKFLOW_NAMES = { "content.notes_master.daily": "笔记清单与合作同步", "content.metrics.daily": "内容指标日报采集", "content.marketing_report.daily": "营销日报生成与发送", "content.relogin.weekly": "内容平台登录态刷新", "content.creator_report.monthly": "达人月报生成", "content.summary.monthly": "内容月度汇总", "content.comments.weekly": "多平台评论周采集", "content.summary.weekly": "内容周度汇总", "content.mapping.refresh": "内容与款式映射刷新", "content.retry_failed": "失败内容任务重试", "content.metrics.backfill": "内容指标补采", "product.persona.daily": "商品人群画像日采集", "product.daily": "商品经营日报采集", "product.alert.daily": "商品销量异常告警", "product.ecommerce_costs.daily": "三平台电商费用采集", "product.style_analysis.interval": "款式周期分析", "product.main_image.weekly": "京东与天猫主图周采集", "product.sales_sheet.daily": "商品月度销量表", "product.erp_all_shop_daily": "聚水潭全店铺款式日报", "product.backfill": "商品数据补采", "product.market_rank": "市场排行周采集", "product.tmall_baibu_apply": "天猫百亿补贴批量报名", "product.review_collection": "商品评价采集", "product.weekly_aggregate.documented_missing": "商品周聚合(待接入)", "shop.metrics.weekly": "店铺经营指标周采集", "shop.competitor.weekly": "竞店指标周采集", "shop.jd_self_operated.daily": "京东自营业绩日采集", "shop.douyin_price_appeal": "抖音待改价申诉", "shop.jd_self_operated.history": "京东自营业绩单日回采", "supply.purchase_confirmation.daily": "采购确认日处理", "supply.replenishment.weekly": "补货建议周处理", "supply.replenishment_alert.daily": "补货预警日处理", "supply.purchase_order_update": "采购单更新", } _SECRET_VALUE = re.compile( r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|token|password|secret)" r"[\"']?\s*[=:]\s*)" r'(?:"[^"\r\n]*"|\'[^\'\r\n]*\'|[^\s,;}\]]+)' ) _URL_SECRET = re.compile( r"(?i)([?&](?:api[_-]?key|access[_-]?token|token|password|secret)=)" r"[^&\s\"'},;\]]+" ) _URL_USERINFO = re.compile( r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/@\s]+@" ) _BEARER = re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+") class ConsoleRequestError(ValueError): """Safe client-visible console request error.""" status = HTTPStatus.BAD_REQUEST class ConsoleNotFoundError(ConsoleRequestError): status = HTTPStatus.NOT_FOUND class ConsoleConflictError(ConsoleRequestError): status = HTTPStatus.CONFLICT class ConsolePreconditionError(ConsoleRequestError): status = HTTPStatus.PRECONDITION_REQUIRED class ConsoleServiceUnavailableError(ConsoleRequestError): status = HTTPStatus.SERVICE_UNAVAILABLE class RunLauncher(Protocol): def launch( self, workflow_id: str, business_date: date, *, execute: bool, shadow: bool, video_upload_store: str | None = None, tmall_topic_keyword: str | None = None, force_refresh: bool = False, tmall_baibu_import_mode: str | None = None, ) -> dict[str, object]: ... def active(self) -> tuple[dict[str, object], ...]: ... def cancel( self, workflow_id: str, operation_id: str, ) -> dict[str, object]: ... class _ManagedProcess(Protocol): pid: int def poll(self) -> int | None: ... def wait(self, timeout: float | None = None) -> int | None: ... @dataclass(frozen=True, slots=True) class _RecoveredProcess: """A narrow Popen-compatible handle for a process that survived console restart.""" pid: int process_started_at: float def _live_process(self) -> psutil.Process | None: try: process = psutil.Process(self.pid) if ( abs(process.create_time() - self.process_started_at) > _PROCESS_START_TOLERANCE_SECONDS ): return None if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE: return None return process except (psutil.Error, OSError): return None def poll(self) -> int | None: return None if self._live_process() is not None else 0 def wait(self, timeout: float | None = None) -> int | None: process = self._live_process() if process is None: return 0 try: return process.wait(timeout=timeout) except psutil.TimeoutExpired as exc: raise subprocess.TimeoutExpired(str(self.pid), timeout) from exc except (psutil.Error, OSError): return 0 @dataclass(frozen=True, slots=True) class _SchedulerProcessInfo: pid: int started_at: str | None @dataclass(slots=True) class _ActiveConsoleRun: operation_id: str workflow_id: str business_date: str mode: str shadow: bool started_at: str process: _ManagedProcess stream: TextIO process_started_at: float | None known_run_ids: frozenset[str] class SubprocessConsoleRunLauncher: """Launch fixed, registered workflow CLI commands without blocking HTTP.""" def __init__( self, settings: Settings, *, python_executable: Path | None = None, max_active: int = MAX_ACTIVE_RUNS, ) -> None: self.settings = settings self.python_executable = Path(python_executable or sys.executable).resolve() self.max_active = max_active self.log_root = settings.data_root / "logs" / "console" self.active_root = settings.data_root / "state" / "ops" / "console-active" self._runs: dict[str, _ActiveConsoleRun] = {} self._finished_operations: dict[str, dict[str, object]] = {} self._lock = threading.RLock() self._recover_active_runs() def _reap_locked(self) -> None: for workflow_id, active in list(self._runs.items()): exit_code = active.process.poll() if exit_code is None: continue self._complete_locked(active, exit_code=exit_code) def _complete_locked( self, active: _ActiveConsoleRun, *, exit_code: int | None, ) -> dict[str, object]: active.stream.close() self._remove_active_record(active.operation_id) if self._runs.get(active.workflow_id) is active: del self._runs[active.workflow_id] result: dict[str, object] = { "operation_id": active.operation_id, "workflow_id": active.workflow_id, "business_date": active.business_date, "mode": active.mode, "shadow": active.shadow, "started_at": active.started_at, "ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "status": "completed", "exit_code": exit_code, } self._remember_finished_locked(result) return result def _remember_finished_locked(self, result: dict[str, object]) -> None: operation_id = str(result["operation_id"]) self._finished_operations.pop(operation_id, None) self._finished_operations[operation_id] = dict(result) while len(self._finished_operations) > MAX_TRACKED_OPERATIONS: oldest = next(iter(self._finished_operations)) del self._finished_operations[oldest] def active(self) -> tuple[dict[str, object], ...]: with self._lock: self._reap_locked() return tuple( { "operation_id": item.operation_id, "workflow_id": item.workflow_id, "business_date": item.business_date, "mode": item.mode, "shadow": item.shadow, "started_at": item.started_at, "status": "running", } for item in self._runs.values() ) def launch( self, workflow_id: str, business_date: date, *, execute: bool, shadow: bool, video_upload_store: str | None = None, tmall_topic_keyword: str | None = None, force_refresh: bool = False, tmall_baibu_import_mode: str | None = None, ) -> dict[str, object]: with self._lock: self._reap_locked() if workflow_id in self._runs: raise ConsoleConflictError("该工作流已从控制台启动,正在执行中") if len(self._runs) >= self.max_active: raise ConsoleConflictError("控制台并发执行已达到上限,请稍后再试") operation_id = f"op-{uuid.uuid4().hex[:16]}" started_at = datetime.now(timezone.utc).isoformat(timespec="microseconds") known_run_ids = self._journal_run_ids(workflow_id, business_date) self.log_root.mkdir(parents=True, exist_ok=True) log_path = self.log_root / f"{workflow_id}-{operation_id}.log" stream = log_path.open("a", encoding="utf-8", newline="\n") argv = [ str(self.python_executable), "-m", "gyxx_flow", "run", workflow_id, "--date", business_date.isoformat(), ] if execute: argv.append("--execute") if shadow: argv.append("--shadow") if tmall_baibu_import_mode is not None: argv.extend( [ "--tmall-baibu-import-mode", tmall_baibu_import_mode, ] ) environment = _console_runtime_environment( { **os.environ, "GYXX_PROJECT_ROOT": str(self.settings.project_root), "GYXX_DATA_ROOT": str(self.settings.data_root), }, workflow_id=workflow_id, ) if video_upload_store is not None: environment["GYXX_VIDEO_UPLOAD_STORE"] = video_upload_store if tmall_topic_keyword is not None: environment["GYXX_TMALL_TOPIC_KEYWORD"] = tmall_topic_keyword if force_refresh: environment["GYXX_FORCE_REFRESH"] = "true" else: environment.pop("GYXX_FORCE_REFRESH", None) environment.pop(CONSOLE_TOKEN_ENV, None) kwargs: dict[str, object] = {} if os.name == "nt": kwargs["creationflags"] = ( subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP ) else: kwargs["start_new_session"] = True try: process = subprocess.Popen( argv, cwd=self.settings.project_root, env=environment, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, shell=False, **kwargs, ) except Exception as exc: stream.close() raise ConsoleRequestError("工作流进程启动失败") from exc try: process_started_at = psutil.Process(process.pid).create_time() except (psutil.Error, OSError): process_started_at = None active = _ActiveConsoleRun( operation_id=operation_id, workflow_id=workflow_id, business_date=business_date.isoformat(), mode="execute" if execute else "dry_run", shadow=shadow, started_at=started_at, process=process, stream=stream, process_started_at=process_started_at, known_run_ids=known_run_ids, ) self._runs[workflow_id] = active self._write_active_record(active) result: dict[str, object] = { "operation_id": operation_id, "workflow_id": workflow_id, "business_date": active.business_date, "mode": active.mode, "shadow": shadow, "started_at": started_at, "status": "accepted", } if video_upload_store is not None: result["store"] = video_upload_store if tmall_topic_keyword is not None: result["topic_keyword"] = tmall_topic_keyword if force_refresh: result["force_refresh"] = True if tmall_baibu_import_mode is not None: result["tmall_baibu_import_mode"] = tmall_baibu_import_mode return result def cancel( self, workflow_id: str, operation_id: str, ) -> dict[str, object]: if not _OPERATION_ID.fullmatch(operation_id): raise ConsoleNotFoundError("运行操作不存在") with self._lock: self._reap_locked() active = self._runs.get(workflow_id) if active is None: return self._cancelled_or_raise_locked(workflow_id, operation_id) if active.operation_id != operation_id: previous = self._finished_operations.get(operation_id) if previous is not None and previous.get("workflow_id") == workflow_id: return self._cancelled_or_raise_locked(workflow_id, operation_id) raise ConsoleConflictError("运行操作已更新,请刷新后再停止") run_id = self._locked_run_id(active) if not self._terminate_process_tree(active): self._complete_locked(active, exit_code=active.process.poll()) raise ConsoleConflictError("工作流已经执行结束,无法停止") journal_status, run_id = self._finalize_cancelled_journal( active, run_id=run_id, ) if journal_status in {"success", "failed"}: self._complete_locked(active, exit_code=active.process.poll()) raise ConsoleConflictError("工作流已经执行结束,无法停止") active.stream.close() self._remove_active_record(active.operation_id) if self._runs.get(workflow_id) is active: del self._runs[workflow_id] result: dict[str, object] = { "operation_id": operation_id, "workflow_id": workflow_id, "business_date": active.business_date, "mode": active.mode, "shadow": active.shadow, "started_at": active.started_at, "ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "status": "cancelled", "already_cancelled": False, "journal_status": journal_status, } if run_id is not None: result["run_id"] = run_id self._write_cancellation_record(result) self._remember_finished_locked(result) return dict(result) def _cancelled_or_raise_locked( self, workflow_id: str, operation_id: str, ) -> dict[str, object]: previous = self._finished_operations.get(operation_id) if previous is None: previous = self._read_cancellation_record(operation_id) if previous is not None: self._remember_finished_locked(previous) if previous is None or previous.get("workflow_id") != workflow_id: raise ConsoleNotFoundError("运行操作不存在") if previous.get("status") != "cancelled": raise ConsoleConflictError("工作流已经执行结束,无法停止") return {**previous, "already_cancelled": True} def _active_path(self, operation_id: str) -> Path: if not _OPERATION_ID.fullmatch(operation_id): raise ConsoleNotFoundError("运行操作不存在") return self.active_root / f"{operation_id}.json" def _write_active_record(self, active: _ActiveConsoleRun) -> None: if active.process_started_at is None: return log_path = self.log_root / f"{active.workflow_id}-{active.operation_id}.log" payload = { "schema_version": 1, "operation_id": active.operation_id, "workflow_id": active.workflow_id, "business_date": active.business_date, "mode": active.mode, "shadow": active.shadow, "started_at": active.started_at, "pid": active.process.pid, "process_started_at": active.process_started_at, "log_path": log_path.relative_to(self.settings.data_root).as_posix(), "known_run_ids": sorted(active.known_run_ids), } try: atomic_write_json(self._active_path(active.operation_id), payload) except OSError: # The in-memory handle still supports cancellation for this server lifetime. return def _remove_active_record(self, operation_id: str) -> None: try: self._active_path(operation_id).unlink(missing_ok=True) except (OSError, ConsoleRequestError): return def _recover_active_runs(self) -> None: if not self.active_root.exists(): return for path in sorted(self.active_root.glob("op-*.json")): active = self._active_from_record(path) if active is None or active.workflow_id in self._runs: with suppress(OSError): path.unlink(missing_ok=True) continue self._runs[active.workflow_id] = active def _active_from_record(self, path: Path) -> _ActiveConsoleRun | None: try: payload = json.loads(path.read_text(encoding="utf-8")) operation_id = payload["operation_id"] workflow_id = payload["workflow_id"] business_date = payload["business_date"] mode = payload["mode"] shadow = payload["shadow"] started_at = payload["started_at"] pid = payload["pid"] process_started_at = payload["process_started_at"] log_reference = payload["log_path"] known_run_ids = payload.get("known_run_ids", []) if payload.get("schema_version") != 1: return None if not isinstance(operation_id, str) or not _OPERATION_ID.fullmatch(operation_id): return None if path.name != f"{operation_id}.json": return None if not isinstance(workflow_id, str) or not _SAFE_RUN_ID.fullmatch(workflow_id): return None parsed_date = date.fromisoformat(business_date) if parsed_date.isoformat() != business_date: return None if mode not in {"execute", "dry_run"} or not isinstance(shadow, bool): return None if not isinstance(started_at, str): return None datetime.fromisoformat(started_at) if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0: return None if ( not isinstance(process_started_at, (int, float)) or isinstance(process_started_at, bool) ): return None if not isinstance(log_reference, str): return None expected_log = self.log_root / f"{workflow_id}-{operation_id}.log" log_path = (self.settings.data_root / log_reference).resolve() if log_path != expected_log.resolve(): return None if not isinstance(known_run_ids, list) or any( not isinstance(value, str) or not _SAFE_RUN_ID.fullmatch(value) for value in known_run_ids ): return None process = _RecoveredProcess(pid, float(process_started_at)) if process.poll() is not None: return None self.log_root.mkdir(parents=True, exist_ok=True) stream = log_path.open("a", encoding="utf-8", newline="\n") return _ActiveConsoleRun( operation_id=operation_id, workflow_id=workflow_id, business_date=business_date, mode=mode, shadow=shadow, started_at=started_at, process=process, stream=stream, process_started_at=float(process_started_at), known_run_ids=frozenset(known_run_ids), ) except ( KeyError, OSError, TypeError, ValueError, UnicodeError, json.JSONDecodeError, ): return None def _journal_run_root(self, workflow_id: str, business_date: str) -> Path: if not _SAFE_RUN_ID.fullmatch(workflow_id): raise ConsoleRequestError("工作流标识无效") year, month, day = business_date.split("-") return ( self.settings.data_root / "runs" / workflow_id / year / month / day ) def _journal_run_ids( self, workflow_id: str, business_date: date, ) -> frozenset[str]: root = self._journal_run_root(workflow_id, business_date.isoformat()) return frozenset(path.parent.name for path in root.glob("*/run.json")) def _locked_run_id(self, active: _ActiveConsoleRun) -> str | None: lock_root = self.settings.data_root / "state" / "locks" for path in lock_root.glob("*.lock"): try: metadata = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): continue if not isinstance(metadata, dict): continue if metadata.get("resource") != f"workflow:{active.workflow_id}": continue if metadata.get("pid") != active.process.pid: continue recorded_start = metadata.get("process_started_at") if ( active.process_started_at is not None and isinstance(recorded_start, (int, float)) and not isinstance(recorded_start, bool) and abs(float(recorded_start) - active.process_started_at) > _PROCESS_START_TOLERANCE_SECONDS ): continue owner = metadata.get("owner") if isinstance(owner, str) and _SAFE_RUN_ID.fullmatch(owner): return owner return None def _candidate_journals(self, active: _ActiveConsoleRun) -> list[Path]: root = self._journal_run_root(active.workflow_id, active.business_date) candidates: list[Path] = [] for path in root.glob("*/run.json"): if path.parent.name in active.known_run_ids: continue try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): continue if not isinstance(payload, dict): continue if ( payload.get("workflow_id") == active.workflow_id and payload.get("business_date") == active.business_date and payload.get("shadow") is active.shadow and payload.get("status") == "running" ): candidates.append(path) return candidates def _finalize_cancelled_journal( self, active: _ActiveConsoleRun, *, run_id: str | None, ) -> tuple[str, str | None]: path: Path | None = None if run_id is not None: candidate = ( self._journal_run_root(active.workflow_id, active.business_date) / run_id / "run.json" ) if candidate.exists(): path = candidate if path is None: candidates = self._candidate_journals(active) if len(candidates) == 1: path = candidates[0] run_id = path.parent.name elif candidates: return self._create_synthetic_cancelled_journal( active, reason="ambiguous execution journal", ) else: return self._create_synthetic_cancelled_journal( active, reason="execution journal was not created before cancellation", ) try: payload = json.loads(path.read_text(encoding="utf-8")) status = payload.get("status") if status != "running": return str(status), run_id journal = RunJournal(path=path) steps = payload.get("steps", {}) if isinstance(steps, dict): for step_id, step in steps.items(): if ( isinstance(step_id, str) and isinstance(step, dict) and step.get("status") == "running" ): journal.finish_step( step_id, status="skipped", error=_CANCELLED_ERROR, ) journal.finalize("cancelled", error=_CANCELLED_ERROR) RunIndex(self.settings.data_root).index_journal(journal) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): return self._create_synthetic_cancelled_journal( active, reason="execution journal could not be finalized", ) return "cancelled", run_id def _create_synthetic_cancelled_journal( self, active: _ActiveConsoleRun, *, reason: str, ) -> tuple[str, str | None]: try: context = RunContext.create( active.workflow_id, active.business_date, shadow=active.shadow, now=datetime.now(timezone.utc), random_suffix=active.operation_id.removeprefix("op-"), ) journal = RunJournal.create( DataLayout(self.settings.data_root), context, mode=active.mode, ) journal.finalize( "cancelled", error=f"{_CANCELLED_ERROR}; {reason}", ) RunIndex(self.settings.data_root).index_journal(journal) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): return "unresolved", None return "synthetic_cancelled", context.run_id def _terminate_process_tree(self, active: _ActiveConsoleRun) -> bool: if active.process.poll() is not None: return False try: root = psutil.Process(active.process.pid) actual_started_at = root.create_time() except psutil.NoSuchProcess: return False except (psutil.Error, OSError) as exc: raise ConsoleConflictError("无法确认运行进程身份,未执行停止操作") from exc if ( active.process_started_at is not None and abs(actual_started_at - active.process_started_at) > _PROCESS_START_TOLERANCE_SECONDS ): raise ConsoleConflictError("运行进程身份已变化,未执行停止操作") try: descendants = root.children(recursive=True) except (psutil.NoSuchProcess, psutil.ZombieProcess): descendants = [] except psutil.Error as exc: raise ConsoleConflictError("无法枚举工作流子进程,未执行停止操作") from exc targets = [*reversed(descendants), root] for process in targets: try: process.terminate() except (psutil.NoSuchProcess, psutil.ZombieProcess): continue except psutil.Error: continue _gone, alive = psutil.wait_procs( targets, timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2, ) for process in alive: try: process.kill() except (psutil.NoSuchProcess, psutil.ZombieProcess): continue except psutil.Error: continue _gone, survivors = psutil.wait_procs( alive, timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2, ) survivors = [process for process in survivors if process.is_running()] if survivors: raise ConsoleConflictError("部分工作流子进程未能停止,请检查服务器进程") if active.process.poll() is None: try: active.process.wait(timeout=0.1) except (subprocess.TimeoutExpired, OSError) as exc: raise ConsoleConflictError("工作流主进程退出状态无法确认") from exc if active.process.poll() is None: raise ConsoleConflictError("工作流主进程退出状态无法确认") return True def _cancellation_path(self, operation_id: str) -> Path: if not _OPERATION_ID.fullmatch(operation_id): raise ConsoleNotFoundError("运行操作不存在") return ( self.settings.data_root / "state" / "ops" / "console-cancellations" / f"{operation_id}.json" ) def _write_cancellation_record(self, result: dict[str, object]) -> None: atomic_write_json( self._cancellation_path(str(result["operation_id"])), result, ) def _read_cancellation_record( self, operation_id: str, ) -> dict[str, object] | None: path = self._cancellation_path(operation_id) if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): return None if not isinstance(payload, dict): return None if payload.get("operation_id") != operation_id: return None return payload class WorkflowDisplayNameStore: """Persist console-only workflow display-name overrides.""" def __init__(self, settings: Settings) -> None: self.path = DataLayout(settings.data_root).state( "console", "workflow-display-names.json", ) self._locks = LockManager(settings.data_root / "state" / "locks") def snapshot(self) -> tuple[dict[str, str], str]: try: raw = self.path.read_bytes() except FileNotFoundError: return {}, EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION except OSError as exc: raise ConsoleRequestError("工作流名称配置无法读取") from exc revision = hashlib.sha256(raw).hexdigest() if len(raw) > MAX_WORKFLOW_DISPLAY_NAMES_FILE_BYTES: raise ConsoleRequestError("工作流名称配置过大") try: payload = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ConsoleRequestError("工作流名称配置无效") from exc if ( not isinstance(payload, dict) or set(payload) != {"schema_version", "overrides"} or payload.get("schema_version") != 1 or not isinstance(payload.get("overrides"), dict) ): raise ConsoleRequestError("工作流名称配置无效") overrides: dict[str, str] = {} for workflow_id, display_name in payload["overrides"].items(): if not isinstance(workflow_id, str) or not _SAFE_RUN_ID.fullmatch( workflow_id ): raise ConsoleRequestError("工作流名称配置无效") overrides[workflow_id] = _normalize_workflow_display_name(display_name) return overrides, revision def revision(self) -> str: try: return hashlib.sha256(self.path.read_bytes()).hexdigest() except FileNotFoundError: return EMPTY_WORKFLOW_DISPLAY_NAMES_REVISION except OSError as exc: raise ConsoleRequestError("工作流名称配置无法读取") from exc def update( self, workflow_id: str, display_name: object, *, default_name: str, expected_revision: str | None, ) -> tuple[str, bool, str]: if expected_revision is None: raise ConsolePreconditionError("缺少名称版本,请刷新页面后重试") expected_revision = expected_revision.strip().strip('"') owner = f"console-{uuid.uuid4().hex}" with self._locks.acquire("console:workflow-display-names", owner=owner): overrides, current_revision = self.snapshot() if not hmac.compare_digest(current_revision, expected_revision): raise ConsoleConflictError("工作流名称已被更新,请刷新后重试") if display_name is None: normalized = None else: normalized = _normalize_workflow_display_name(display_name) if normalized is None or normalized == default_name: overrides.pop(workflow_id, None) else: overrides[workflow_id] = normalized atomic_write_json( self.path, { "schema_version": 1, "overrides": overrides, }, ) effective_name = overrides.get(workflow_id, default_name) return effective_name, workflow_id in overrides, self.revision() class ScheduleConfigStore: """Locked, revision-aware updates to the validated schedule catalog.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.config_dir = settings.project_root / "config" self.path = self.config_dir / "schedules.json" self._locks = LockManager(settings.data_root / "state" / "locks") def revision(self) -> str: return sha256_file(self.path) def update( self, workflow_id: str, payload: dict[str, Any], *, expected_revision: str | None, ) -> tuple[ScheduleEntry, str]: if expected_revision is None: raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试") expected_revision = expected_revision.strip().strip('"') owner = f"console-{uuid.uuid4().hex}" with self._locks.acquire("config:schedules", owner=owner): current_revision = self.revision() if not hmac.compare_digest(current_revision, expected_revision): raise ConsoleConflictError("定时配置已被更新,请刷新后重试") catalog = WorkflowCatalog.load(self.config_dir) workflow = next( (item for item in catalog.workflows if item.workflow_id == workflow_id), None, ) if workflow is None: raise ConsoleNotFoundError("工作流不存在") if workflow.trigger != "scheduled": raise ConsoleRequestError("该工作流不是定时工作流,不能配置启动时间") schedule_item = _schedule_item(workflow_id, payload) raw = _read_json_object(self.path) schedules = raw.get("schedules") if not isinstance(schedules, list): raise ConsoleRequestError("定时配置结构无效") replacement_index = next( ( index for index, item in enumerate(schedules) if isinstance(item, dict) and item.get("workflow_id") == workflow_id ), None, ) if replacement_index is None: raise ConsoleRequestError("工作流缺少现有定时配置") schedules[replacement_index] = schedule_item _validate_catalog_candidate(self.settings, raw) atomic_write_json(self.path, raw) updated_catalog = WorkflowCatalog.load(self.config_dir) return updated_catalog.schedule_for(workflow_id), self.revision() class WorkflowConsoleService: """Safe projection and mutation boundary used by the HTTP handler.""" def __init__( self, settings: Settings, *, launcher: RunLauncher | None = None, daily_summary_analyzer: DailySummaryAnalyzer | None = None, dynamic_configs: WorkflowConfigRepository | None = None, scheduler_env_files: Sequence[Path] | None = None, ) -> None: self.settings = settings self.config_dir = settings.project_root / "config" self.launcher = launcher or SubprocessConsoleRunLauncher(settings) self.scheduler_env_files = tuple( Path(path).expanduser().resolve() for path in (scheduler_env_files or ()) ) self.schedules = ScheduleConfigStore(settings) self.display_names = WorkflowDisplayNameStore(settings) self.notifications = NotificationRoutingStore(settings) self.dynamic_configs = dynamic_configs or PostgresWorkflowConfigStore( os.environ ) self.daily_summaries = DailySummaryBuilder( project_root=settings.project_root, data_root=settings.data_root, analyzer=daily_summary_analyzer, workflow_name=_workflow_name, ) self._cache_lock = threading.RLock() self._catalog_signature: tuple[tuple[int, int], tuple[int, int]] | None = None self._catalog_cache: WorkflowCatalog | None = None self._registered_cache: frozenset[str] = frozenset() self._notification_lookup_proofs: set[tuple[str, str, str, str]] = set() self._scheduler_start_lock = threading.Lock() def start_scheduler(self) -> dict[str, object]: """Start the single resident scheduler without blocking the console.""" with self._scheduler_start_lock: running = _scheduler_process_from_lock(self.settings) if running is not None: return { "status": "running", "service_running": True, "pid": running.pid, "started_at": running.started_at, } occupied_pid = _scheduler_lock_owner_pid(self.settings) if occupied_pid is not None: raise ConsoleConflictError( "调度锁已被其他进程占用,无法确认调度器身份,请先检查服务进程" ) missing_env_files = [ path for path in self.scheduler_env_files if not path.is_file() ] if missing_env_files: raise ConsoleServiceUnavailableError( "启动调度器所需的运行环境文件不可用" ) log_root = self.settings.data_root / "logs" / "scheduler-service" try: log_root.mkdir(parents=True, exist_ok=True) log_path = log_root / ( "console-start-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + ".log" ) environment = { **os.environ, "GYXX_PROJECT_ROOT": str(self.settings.project_root), "GYXX_DATA_ROOT": str(self.settings.data_root), } environment.pop(CONSOLE_TOKEN_ENV, None) argv = [ str(Path(sys.executable).resolve()), "-m", "gyxx_flow", "schedule", "run", ] for env_file in self.scheduler_env_files: argv.extend(("--env-file", str(env_file))) kwargs: dict[str, object] = {} if os.name == "nt": kwargs["creationflags"] = ( subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP ) else: kwargs["start_new_session"] = True with log_path.open("a", encoding="utf-8", newline="\n") as stream: process = subprocess.Popen( argv, cwd=self.settings.project_root, env=environment, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, shell=False, **kwargs, ) except OSError as exc: raise ConsoleServiceUnavailableError( "调度器进程启动失败,请检查调度服务日志" ) from exc deadline = time.monotonic() + SCHEDULER_START_TIMEOUT_SECONDS while time.monotonic() < deadline: if process.poll() is not None: raise ConsoleServiceUnavailableError( "调度器启动后立即退出,请检查调度服务日志" ) running = _scheduler_process_from_lock(self.settings) if running is not None: return { "status": "running", "service_running": True, "pid": running.pid, "started_at": running.started_at, } time.sleep(0.05) return { "status": "starting", "service_running": False, "pid": process.pid, } def dynamic_config_snapshot( self, *, search: str = "", platform: str = "", enabled: bool | None = None, ) -> dict[str, Any]: if len(search) > 120 or len(platform) > 80: raise ConsoleRequestError("动态配置筛选条件过长") try: return self.dynamic_configs.snapshot( search=search, platform=platform, enabled=enabled, ) except WorkflowConfigUnavailableError as exc: raise ConsoleServiceUnavailableError(str(exc)) from exc except WorkflowConfigError as exc: raise ConsoleRequestError(str(exc)) from exc def create_dynamic_config(self, payload: dict[str, Any]) -> dict[str, Any]: try: return self.dynamic_configs.create(payload) except WorkflowConfigValidationError as exc: raise ConsoleRequestError(str(exc)) from exc except WorkflowConfigUnavailableError as exc: raise ConsoleServiceUnavailableError(str(exc)) from exc except WorkflowConfigError as exc: raise ConsoleRequestError(str(exc)) from exc def update_dynamic_config( self, config_id: int, payload: dict[str, Any], *, expected_revision: int | None, ) -> dict[str, Any]: if expected_revision is None: raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试") try: return self.dynamic_configs.update( config_id, payload, expected_revision=expected_revision, ) except WorkflowConfigNotFoundError as exc: raise ConsoleNotFoundError(str(exc)) from exc except WorkflowConfigConflictError as exc: raise ConsoleConflictError(str(exc)) from exc except WorkflowConfigValidationError as exc: raise ConsoleRequestError(str(exc)) from exc except WorkflowConfigUnavailableError as exc: raise ConsoleServiceUnavailableError(str(exc)) from exc except WorkflowConfigError as exc: raise ConsoleRequestError(str(exc)) from exc def delete_dynamic_config( self, config_id: int, *, expected_revision: int | None, ) -> dict[str, Any]: if expected_revision is None: raise ConsolePreconditionError("缺少配置版本,请刷新页面后重试") try: self.dynamic_configs.delete( config_id, expected_revision=expected_revision, ) except WorkflowConfigNotFoundError as exc: raise ConsoleNotFoundError(str(exc)) from exc except WorkflowConfigConflictError as exc: raise ConsoleConflictError(str(exc)) from exc except WorkflowConfigUnavailableError as exc: raise ConsoleServiceUnavailableError(str(exc)) from exc except WorkflowConfigError as exc: raise ConsoleRequestError(str(exc)) from exc return {"deleted": True, "id": config_id} def _catalog(self) -> tuple[WorkflowCatalog, frozenset[str]]: workflow_path = self.config_dir / "workflows.json" schedule_path = self.config_dir / "schedules.json" workflow_stat = workflow_path.stat() schedule_stat = schedule_path.stat() signature = ( (workflow_stat.st_mtime_ns, workflow_stat.st_size), (schedule_stat.st_mtime_ns, schedule_stat.st_size), ) with self._cache_lock: if signature != self._catalog_signature: catalog = WorkflowCatalog.load(self.config_dir) registry = create_module_registry(catalog=catalog) self._catalog_cache = catalog self._registered_cache = frozenset(registry.workflow_ids) self._catalog_signature = signature if self._catalog_cache is None: raise ConsoleRequestError("工作流目录尚未加载") return self._catalog_cache, self._registered_cache def overview(self) -> dict[str, object]: catalog, registered_ids = self._catalog() warnings: list[str] = [] try: display_name_overrides, display_names_revision = ( self.display_names.snapshot() ) except ConsoleRequestError: display_name_overrides = {} display_names_revision = self.display_names.revision() warnings.append("自定义工作流名称配置无效,当前显示默认名称") try: records = RunIndex(self.settings.data_root).query() except ValueError: records = () warnings.append("运行索引中存在损坏记录,最近执行信息暂不可用") latest: dict[str, RunRecord] = {} for record in records: latest.setdefault(record.workflow_id, record) active_runs = { str(item["workflow_id"]): item for item in self.launcher.active() } scheduler = _scheduler_snapshot(self.settings) scheduler_jobs = scheduler.pop("jobs") schedules = {item.workflow_id: item for item in catalog.schedules} now = datetime.now(ZoneInfo(catalog.timezone)) workflows: list[dict[str, object]] = [] for entry in catalog.workflows: schedule = schedules.get(entry.workflow_id) record = latest.get(entry.workflow_id) default_name = _workflow_name(entry.workflow_id) display_name = display_name_overrides.get( entry.workflow_id, default_name, ) workflows.append( { "id": entry.workflow_id, "name": display_name, "default_name": default_name, "name_customized": display_name != default_name, "module": entry.module, "trigger": entry.trigger, "run_date_mode": ( "month" if entry.workflow_id == "product.sales_sheet.daily" else "date" ), "registered": entry.workflow_id in registered_ids, "note": entry.note, "source_task_name": entry.source_task_name, "steps": _workflow_steps(entry), **( { "tmall_baibu_import": _tmall_baibu_import_payload(entry) } if entry.workflow_id == TMALL_BAIBU_WORKFLOW_ID else {} ), **( {"topic_config": _workflow_topic_payload(entry)} if entry.topic_config is not None else {} ), "schedule": _schedule_payload(schedule), "next_run_at": _next_run_at(schedule, now), "last_run": _run_payload( record, data_root=self.settings.data_root, ), "active_run": active_runs.get(entry.workflow_id), "scheduler_job": scheduler_jobs.get(entry.workflow_id), } ) module_counts = { module["id"]: sum( item["module"] == module["id"] for item in workflows ) for module in _MODULES } modules = [ {**module, "count": module_counts[module["id"]]} for module in _MODULES ] running_ids = { item["workflow_id"] for item in active_runs.values() } | { workflow_id for workflow_id, job in scheduler_jobs.items() if job.get("status") == "running" } | { workflow_id for workflow_id, record in latest.items() if record.status == "running" and not _scheduler_terminal_matches_record( scheduler_jobs.get(workflow_id), record, ) } failed = sum( bool(item["last_run"] and item["last_run"]["status"] == "failed") for item in workflows ) return { "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "timezone": catalog.timezone, "schedule_revision": self.schedules.revision(), "workflow_names_revision": display_names_revision, "summary": { "total": len(workflows), "scheduled": len(catalog.schedules), "enabled": sum(item.enabled for item in catalog.schedules), "running": len(running_ids), "failed": failed, "unavailable": len(catalog.unavailable_workflows()), }, "scheduler": scheduler, "modules": modules, "workflows": workflows, "warnings": warnings, } def update_display_name( self, workflow_id: str, payload: dict[str, Any], *, expected_revision: str | None, ) -> dict[str, object]: if set(payload) != {"display_name"}: raise ConsoleRequestError("名称请求只允许 display_name 字段") catalog, _registered = self._catalog() if workflow_id not in {item.workflow_id for item in catalog.workflows}: raise ConsoleNotFoundError("工作流不存在") default_name = _workflow_name(workflow_id) display_name, customized, revision = self.display_names.update( workflow_id, payload["display_name"], default_name=default_name, expected_revision=expected_revision, ) return { "workflow_id": workflow_id, "name": display_name, "default_name": default_name, "name_customized": customized, "workflow_names_revision": revision, } def notification_config(self) -> dict[str, object]: """Return the app-scoped people directory and explicit workflow routes.""" try: payload, revision = self.notifications.snapshot() except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc catalog, _registered = self._catalog() workflows = {item.workflow_id: item for item in catalog.workflows} configured_routes = payload.get("routes", {}) if not isinstance(configured_routes, dict): raise ConsoleRequestError("通知路由配置无效") routes: list[dict[str, object]] = [] for workflow_id, capability in NOTIFICATION_CAPABILITIES.items(): entry = workflows.get(workflow_id) if entry is None: continue configured = configured_routes.get(workflow_id) route = configured if isinstance(configured, dict) else {} routes.append( { "workflow_id": workflow_id, "name": _workflow_name(workflow_id), "module": entry.module, "condition": _notification_capability_condition(capability), "configured": isinstance(configured, dict), "enabled": bool(route.get("enabled", False)), "app_profile": route.get( "app_profile", payload.get("app_profile", "hermes-analyzer"), ), "person_ids": list(route.get("person_ids", ())), } ) app_profile = payload.get("app_profile", "hermes-analyzer") people_payload = payload.get("people", {}) if not isinstance(people_payload, dict): raise ConsoleRequestError("通知人员目录无效") people = [ {"id": person_id, **person} for person_id, person in people_payload.items() if isinstance(person, dict) ] app_profiles = payload.get("app_profiles", {}) if not isinstance(app_profiles, dict): raise ConsoleRequestError("飞书应用配置无效") delivery_profiles = { profile: value for profile, value in app_profiles.items() if isinstance(value, dict) and value.get("hermes_profile") == ANALYZER_HERMES_PROFILE } if app_profile not in delivery_profiles: raise ConsoleRequestError("业务通知必须使用分析端 Hermes 飞书机器人") installed_apps = _configured_lark_apps(os.environ) return { "revision": revision, "app_profile": app_profile, "available_app_profiles": sorted(delivery_profiles), "apps": [ { "profile": profile, "label": value.get("label", profile), "app_id": value.get("app_id"), "configured": installed_apps.get(profile) == value.get("app_id"), } for profile, value in delivery_profiles.items() ], "people": people, "routes": routes, } def update_notification_config( self, payload: dict[str, Any], *, expected_revision: str | None, ) -> dict[str, object]: """Atomically replace notification routing after relation validation.""" normalized_routes: dict[str, dict[str, object]] = {} seen_routes: set[str] = set() raw_routes = payload.get("routes") if not isinstance(raw_routes, list): raise ConsoleRequestError("通知路由必须是列表") for item in raw_routes: if not isinstance(item, dict): raise ConsoleRequestError("通知路由条目无效") allowed = { "workflow_id", "configured", "enabled", "person_ids", } if set(item) != allowed: raise ConsoleRequestError("通知路由条目字段无效") workflow_id = item.get("workflow_id") if ( not isinstance(workflow_id, str) or workflow_id not in NOTIFICATION_CAPABILITIES ): raise ConsoleRequestError("工作流不支持业务通知配置") if workflow_id in seen_routes: raise ConsoleRequestError("通知路由不能重复") seen_routes.add(str(workflow_id)) if not isinstance(item.get("configured"), bool): raise ConsoleRequestError("通知接管状态无效") if not item["configured"]: continue normalized_routes[str(workflow_id)] = { "enabled": item.get("enabled"), "app_profile": payload.get("app_profile"), "person_ids": item.get("person_ids"), } if set(payload) != {"app_profile", "people", "routes"}: raise ConsoleRequestError("通知配置包含不支持的字段") try: current, _current_revision = self.notifications.snapshot() except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc requested_profile = payload.get("app_profile") if not isinstance(requested_profile, str) or not _SAFE_APP_PROFILE.fullmatch( requested_profile ): raise ConsoleRequestError("飞书应用 profile 无效") app_profiles = current.get("app_profiles") if not isinstance(app_profiles, dict): raise ConsoleRequestError("飞书应用配置无效") selected_app = app_profiles.get(requested_profile) if ( not isinstance(selected_app, dict) or selected_app.get("hermes_profile") != ANALYZER_HERMES_PROFILE ): raise ConsoleRequestError("业务通知只能使用分析端 Hermes 飞书机器人") if ( requested_profile != current.get("app_profile") and _configured_lark_apps(os.environ).get(requested_profile) != selected_app.get("app_id") ): raise ConsoleRequestError("只能切换到服务器已安装且 App ID 匹配的飞书应用") raw_people = payload.get("people") if not isinstance(raw_people, list): raise ConsoleRequestError("通知人员目录必须是列表") current_people = current.get("people") if not isinstance(current_people, dict): raise ConsoleRequestError("通知人员目录无效") people: dict[str, dict[str, object]] = {} consumed_lookup_proofs: set[tuple[str, str, str, str]] = set() for item in raw_people: if not isinstance(item, dict) or set(item) != { "id", "name", "mobile", "bindings", }: raise ConsoleRequestError("通知人员条目字段无效") person_id = item.get("id") if not isinstance(person_id, str) or not _SAFE_RUN_ID.fullmatch( person_id ): raise ConsoleRequestError("人员编号无效") if person_id in people: raise ConsoleRequestError("人员编号不能重复") mobile = _normalize_optional_mobile(item.get("mobile")) raw_bindings = item.get("bindings") if not isinstance(raw_bindings, dict): raise ConsoleRequestError("通知人员应用绑定无效") previous_person = current_people.get(person_id) previous_bindings = ( previous_person.get("bindings", {}) if isinstance(previous_person, dict) else {} ) if not isinstance(previous_bindings, dict): previous_bindings = {} bindings: dict[str, object] = {} for profile, raw_binding in raw_bindings.items(): if not isinstance(profile, str) or not isinstance(raw_binding, dict): raise ConsoleRequestError("通知人员应用绑定无效") open_id = raw_binding.get("open_id") verified = raw_binding.get("verified") source = raw_binding.get("source") previous_binding = previous_bindings.get(profile) previous_mobile = ( previous_person.get("mobile") if isinstance(previous_person, dict) else None ) unchanged = raw_binding == previous_binding and not ( source == "mobile_lookup" and mobile != previous_mobile ) proof = ( str(person_id), profile, mobile or "", str(open_id or ""), ) if unchanged: bindings[profile] = raw_binding elif verified is True and source == "mobile_lookup": with self._cache_lock: has_proof = proof in self._notification_lookup_proofs if not has_proof: raise ConsoleRequestError( "手机号验证状态已失效,请重新通过手机号绑定" ) bindings[profile] = raw_binding consumed_lookup_proofs.add(proof) else: bindings[profile] = { "open_id": open_id, "verified": False, "source": "manual", } people[person_id] = { "name": item.get("name"), "mobile": mobile, "bindings": bindings, } candidate = { "schema_version": 1, "app_profile": requested_profile, "app_profiles": current.get("app_profiles"), "people": people, "capabilities": current.get("capabilities"), "routes": normalized_routes, } try: self.notifications.update( candidate, expected_revision=expected_revision, ) except NotificationRoutingPreconditionError as exc: raise ConsolePreconditionError(str(exc)) from exc except NotificationRoutingConflictError as exc: raise ConsoleConflictError(str(exc)) from exc except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc with self._cache_lock: self._notification_lookup_proofs.difference_update( consumed_lookup_proofs ) return self.notification_config() def resolve_notification_recipient( self, payload: dict[str, Any], ) -> dict[str, object]: """Resolve one mobile number under the selected Feishu app profile.""" if set(payload) != {"person_id", "mobile", "app_profile"}: raise ConsoleRequestError("手机号绑定请求字段无效") person_id = payload.get("person_id") app_profile = payload.get("app_profile") if not isinstance(person_id, str) or not _SAFE_RUN_ID.fullmatch(person_id): raise ConsoleRequestError("人员编号无效") if not isinstance(app_profile, str) or not _SAFE_APP_PROFILE.fullmatch( app_profile ): raise ConsoleRequestError("飞书应用 profile 无效") mobile = _normalize_mobile(payload.get("mobile")) try: current, _revision = self.notifications.snapshot() except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc app_profiles = current.get("app_profiles") selected_app = ( app_profiles.get(app_profile) if isinstance(app_profiles, dict) else None ) if ( not isinstance(selected_app, dict) or selected_app.get("hermes_profile") != ANALYZER_HERMES_PROFILE ): raise ConsoleRequestError("手机号只能绑定到分析端 Hermes 飞书机器人") if ( _configured_lark_apps(os.environ).get(app_profile) != selected_app.get("app_id") ): raise ConsoleRequestError("所选飞书应用未安装或 App ID 不匹配") open_id = _resolve_feishu_open_id_by_mobile( app_profile, mobile, environment=os.environ, ) with self._cache_lock: self._notification_lookup_proofs.add( (person_id, app_profile, mobile, open_id) ) while ( len(self._notification_lookup_proofs) > MAX_NOTIFICATION_LOOKUP_PROOFS ): self._notification_lookup_proofs.pop() return { "person_id": person_id, "mobile": mobile, "app_profile": app_profile, "open_id": open_id, } def recent_runs(self, workflow_id: str, *, limit: int) -> dict[str, object]: catalog, _registered = self._catalog() if workflow_id not in {item.workflow_id for item in catalog.workflows}: raise ConsoleNotFoundError("工作流不存在") if not 1 <= limit <= 20: raise ConsoleRequestError("运行记录数量必须在 1 到 20 之间") try: records = RunIndex(self.settings.data_root).query( workflow_id=workflow_id )[:limit] except ValueError as exc: raise ConsoleRequestError("运行索引中存在损坏记录") from exc return { "workflow_id": workflow_id, "runs": [ _run_payload(record, data_root=self.settings.data_root) for record in records ], } def workflow_detail(self, workflow_id: str) -> dict[str, object]: """Return the overview projection for a single workflow.""" overview = self.overview() workflow = next( ( item for item in overview["workflows"] if item["id"] == workflow_id ), None, ) if workflow is None: raise ConsoleNotFoundError("工作流不存在") payload: dict[str, object] = {"workflow": workflow} schedule_revision = overview.get("schedule_revision") if isinstance(schedule_revision, str): payload["schedule_revision"] = schedule_revision warnings = overview.get("warnings") if warnings: payload["warnings"] = warnings return payload def run_detail(self, workflow_id: str, run_id: str) -> dict[str, object]: """Return one indexed run with its journal step details.""" record = self._run_record_for(workflow_id, run_id) return { "workflow_id": workflow_id, "run": _run_payload(record, data_root=self.settings.data_root), } def run_diagnosis(self, workflow_id: str, run_id: str) -> dict[str, object]: """Bundle one run's journal, trace, and sanitized log tails for agents.""" record = self._run_record_for(workflow_id, run_id) journal = _journal_trace_payload(record, data_root=self.settings.data_root) return { "workflow_id": workflow_id, "run": _run_payload(record, data_root=self.settings.data_root), "journal": journal, "logs": _diagnosis_logs( record, journal, data_root=self.settings.data_root, ), } def _run_record_for(self, workflow_id: str, run_id: str) -> RunRecord: catalog, _registered = self._catalog() if workflow_id not in {item.workflow_id for item in catalog.workflows}: raise ConsoleNotFoundError("工作流不存在") if not _SAFE_RUN_ID.fullmatch(run_id): raise ConsoleNotFoundError("运行记录不存在") try: record = RunIndex(self.settings.data_root).get(run_id) except ValueError as exc: raise ConsoleRequestError("运行索引中存在损坏记录") from exc if record is None or record.workflow_id != workflow_id: raise ConsoleNotFoundError("运行记录不存在") return record def daily_summary( self, report_date: str | None = None, *, force_refresh: bool = False, ) -> dict[str, object]: """Return the cached or newly analyzed workflow report for one local day.""" catalog, _registered = self._catalog() if report_date is None: target = datetime.now(ZoneInfo(catalog.timezone)).date() - timedelta(days=1) else: try: target = date.fromisoformat(report_date) except (TypeError, ValueError) as exc: raise ConsoleRequestError( "date 参数必须使用 YYYY-MM-DD 格式" ) from exc if target.isoformat() != report_date: raise ConsoleRequestError("date 参数必须使用 YYYY-MM-DD 格式") local_today = datetime.now(ZoneInfo(catalog.timezone)).date() if target > local_today: raise ConsoleRequestError("不能汇总未来日期") try: return self.daily_summaries.build( target, force_refresh=force_refresh, ) except ValueError as exc: raise ConsoleRequestError("运行索引中存在损坏记录") from exc def refresh_daily_summary(self, payload: dict[str, Any]) -> dict[str, object]: if set(payload) - {"date"}: raise ConsoleRequestError("日汇总刷新请求只允许 date 字段") report_date = payload.get("date") if report_date is not None and not isinstance(report_date, str): raise ConsoleRequestError("date 参数必须使用 YYYY-MM-DD 格式") return self.daily_summary(report_date, force_refresh=True) def update_schedule( self, workflow_id: str, payload: dict[str, Any], *, expected_revision: str | None, ) -> dict[str, object]: schedule, revision = self.schedules.update( workflow_id, payload, expected_revision=expected_revision, ) with self._cache_lock: self._catalog_signature = None catalog, _registered = self._catalog() now = datetime.now(ZoneInfo(catalog.timezone)) return { "schedule": _schedule_payload(schedule), "next_run_at": _next_run_at(schedule, now), "schedule_revision": revision, "applies_on_next_scheduler_poll": True, "may_trigger_on_next_scheduler_poll": schedule.enabled, } def trigger( self, workflow_id: str, payload: dict[str, Any], ) -> dict[str, object]: catalog, registered_ids = self._catalog() entry = next( (item for item in catalog.workflows if item.workflow_id == workflow_id), None, ) if entry is None: raise ConsoleNotFoundError("工作流不存在") if workflow_id not in registered_ids or entry.trigger == "unavailable": raise ConsoleRequestError("该工作流当前没有可执行入口") allowed = { "business_date", "execute", "shadow", "confirmed", "force_refresh", } if workflow_id == "product.video_upload": allowed.add("store") allowed.add("tmall_topic_keyword") if workflow_id == TMALL_BAIBU_WORKFLOW_ID: allowed.add("tmall_baibu_import_mode") unknown = set(payload) - allowed if unknown: raise ConsoleRequestError("运行请求包含不支持的字段") raw_date = payload.get("business_date") try: business_date = date.fromisoformat(raw_date) except (TypeError, ValueError) as exc: raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式") from exc if business_date.isoformat() != raw_date: raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式") execute = payload.get("execute", False) shadow = payload.get("shadow", False) if not isinstance(execute, bool) or not isinstance(shadow, bool): raise ConsoleRequestError("执行方式参数无效") force_refresh = payload.get("force_refresh", False) if not isinstance(force_refresh, bool): raise ConsoleRequestError("强制重新采集参数无效") if force_refresh and not execute: raise ConsoleRequestError("强制重跑必须选择真实执行") if execute and payload.get("confirmed") is not True: raise ConsoleRequestError("正式执行前必须确认外部影响") video_upload_store: str | None = None tmall_topic_keyword: str | None = None if workflow_id == "product.video_upload": raw_store = payload.get("store", "flagship") if raw_store not in {"flagship", "luggage"}: raise ConsoleRequestError("天猫视频上传店铺参数无效") video_upload_store = str(raw_store) tmall_topic_keyword = _normalize_tmall_topic_keyword( payload.get("tmall_topic_keyword"), entry, ) tmall_baibu_import_mode: str | None = None if workflow_id == TMALL_BAIBU_WORKFLOW_ID: raw_import_mode = payload.get( "tmall_baibu_import_mode", TMALL_BAIBU_DEFAULT_IMPORT_MODE, ) try: tmall_baibu_import_mode = validate_tmall_baibu_import_mode( raw_import_mode ) except ValueError as exc: raise ConsoleRequestError("天猫百亿补贴导入范围参数无效") from exc if execute: runtime_environment = { **os.environ, "GYXX_PROJECT_ROOT": str(self.settings.project_root), "GYXX_DATA_ROOT": str(self.settings.data_root), } _validate_product_production_runtime(entry, runtime_environment) _validate_content_production_runtime(entry, runtime_environment) scheduler = _scheduler_snapshot(self.settings) scheduler_job = scheduler["jobs"].get(workflow_id) if ( scheduler["service_running"] and scheduler_job and scheduler_job.get("status") == "running" ): raise ConsoleConflictError("该工作流正由定时调度器执行") launch_kwargs: dict[str, object] = { "execute": execute, "shadow": shadow, "video_upload_store": video_upload_store, "tmall_topic_keyword": tmall_topic_keyword, "force_refresh": force_refresh, } if tmall_baibu_import_mode is not None: launch_kwargs["tmall_baibu_import_mode"] = tmall_baibu_import_mode return self.launcher.launch( workflow_id, business_date, **launch_kwargs, ) def cancel(self, workflow_id: str, operation_id: str) -> dict[str, object]: catalog, _registered_ids = self._catalog() if workflow_id not in {item.workflow_id for item in catalog.workflows}: raise ConsoleNotFoundError("工作流不存在") if not _OPERATION_ID.fullmatch(operation_id): raise ConsoleNotFoundError("运行操作不存在") return self.launcher.cancel(workflow_id, operation_id) def cancel_scheduled(self, workflow_id: str) -> dict[str, object]: catalog, _registered_ids = self._catalog() if workflow_id not in {item.workflow_id for item in catalog.workflows}: raise ConsoleNotFoundError("工作流不存在") state = _read_scheduler_state(self.settings) raw_jobs = state.get("jobs") if isinstance(state, dict) else None job = raw_jobs.get(workflow_id) if isinstance(raw_jobs, dict) else None if not isinstance(job, dict) or job.get("status") != "running": raise ConsoleConflictError("该定时执行已经结束,请刷新后查看最新状态") live, process, process_started_at = _scheduler_job_process( workflow_id, job, ) if not live: raise ConsoleConflictError("该定时执行进程已经结束,请刷新后查看最新状态") if process is None or process_started_at is None: raise ConsoleConflictError("无法确认定时执行进程身份,未执行停止操作") business_date = job.get("business_date") if not isinstance(business_date, str): raise ConsoleConflictError("定时执行缺少业务日期,未执行停止操作") try: parsed_date = date.fromisoformat(business_date) except ValueError as exc: raise ConsoleConflictError("定时执行的业务日期无效,未执行停止操作") from exc if parsed_date.isoformat() != business_date: raise ConsoleConflictError("定时执行的业务日期无效,未执行停止操作") pid = process.pid journal_path = _scheduler_run_journal_path( self.settings, workflow_id, business_date, pid, job.get("started_at"), ) _terminate_scheduler_process_tree(process, process_started_at) journal_status, run_id = _finalize_scheduler_cancelled_journal( self.settings, workflow_id, business_date, journal_path, ) if journal_status in {"success", "failed"}: raise ConsoleConflictError("该定时执行已经结束,无法停止") result: dict[str, object] = { "workflow_id": workflow_id, "business_date": business_date, "source": "scheduler", "started_at": job.get("started_at"), "ended_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "status": "cancelled", "journal_status": journal_status, } if run_id is not None: result["run_id"] = run_id return result def _validate_product_production_runtime( entry: WorkflowEntry, environment: Mapping[str, str], ) -> None: """Fail before Popen when a product run cannot reach real production sinks.""" if entry.module != "product_commerce": return environment = _console_runtime_environment( environment, workflow_id=entry.workflow_id, ) if _environment_flag(environment, "GYXX_WORKFLOW_ACCEPTANCE"): raise ConsoleRequestError("正式执行不能使用验收隔离模式") if _environment_flag(environment, "GYXX_FEISHU_TABLE_WRITE_DISABLED"): raise ConsoleRequestError("正式执行不能禁用飞书写入") # 月销量表直接读取飞书款式主表并通过聚水潭商品主体分析取数; # 它不依赖 PostgreSQL 或 Hermes,浏览器登录态由 runtime binding 预检。 # 百补批量报名只下载 Feishu 模板并提交到淘宝报名页面,同样不依赖 # PostgreSQL/Hermes;六个入口各自的账号和浏览器绑定由脚本侧校验。 if entry.workflow_id in { "product.sales_sheet.daily", "product.tmall_baibu_apply", }: return has_dsn = any( environment.get(name, "").strip() for name in ("GYXX_POSTGRES_DSN", "DATABASE_URL", "DB_URL") ) has_pg_fields = all( environment.get(name, "").strip() for name in ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD") ) if not has_dsn and not has_pg_fields: raise ConsoleRequestError( "商品经营正式执行缺少 PostgreSQL 运行时凭据;请用控制台 --env-file 注入" ) if entry.workflow_id in _DIRECT_VIDEO_WORKFLOW_IDS: _validate_direct_video_model_runtime(environment) return route_disabled = False if entry.workflow_id == "product.alert.daily": route_disabled = _notification_route_is_disabled( entry.workflow_id, environment, legacy_names=( "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "GYXX_ALERT_RECIPIENT_OPEN_ID", ), ) elif entry.workflow_id == "product.market_rank": route_disabled = _notification_route_is_disabled( entry.workflow_id, environment, legacy_names=( "MARKET_RANK_NOTIFY_OPEN_ID", "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", ), ) needs_lark_profile = not ( entry.workflow_id == "product.alert.daily" and route_disabled ) if needs_lark_profile and not _has_lark_analyzer_profile(environment): raise ConsoleRequestError( "商品经营正式执行缺少可用的飞书 hermes-analyzer 配置" ) if entry.workflow_id == "product.style_analysis.interval" and not any( environment.get(name, "").strip() for name in ("GYXX_HERMES_API_KEY", "HERMES_API_KEY") ): raise ConsoleRequestError("该商品工作流正式执行缺少本机 Hermes 运行时凭据") if entry.workflow_id == "product.alert.daily" and not _notification_route_ready( entry.workflow_id, environment, legacy_names=( "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "GYXX_ALERT_RECIPIENT_OPEN_ID", ), ): raise ConsoleRequestError("销量告警正式执行缺少飞书负责人配置") if entry.workflow_id == "product.market_rank" and not _notification_route_ready( entry.workflow_id, environment, legacy_names=( "MARKET_RANK_NOTIFY_OPEN_ID", "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", ), ): raise ConsoleRequestError("市场排行正式执行缺少飞书负责人配置") def _validate_content_production_runtime( entry: WorkflowEntry, environment: Mapping[str, str], ) -> None: """Reject content execute requests that cannot use real production data.""" if entry.module != "content_marketing": return if _environment_flag(environment, "GYXX_WORKFLOW_ACCEPTANCE"): raise ConsoleRequestError("内容营销正式执行不能使用验收隔离模式") if _environment_flag(environment, "GYXX_FEISHU_TABLE_WRITE_DISABLED"): raise ConsoleRequestError( "内容营销正式执行不能禁用真实写入(GYXX_FEISHU_TABLE_WRITE_DISABLED)" ) if entry.workflow_id not in _CONTENT_POSTGRES_WORKFLOW_IDS: return required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD") missing = [name for name in required if not environment.get(name, "").strip()] if missing: raise ConsoleRequestError( "内容营销正式执行缺少 PostgreSQL 运行时凭据:" f"{', '.join(missing)};请用控制台 --env-file 注入" ) if _is_loopback_host(environment["PG_HOST"]): raise ConsoleRequestError( "内容营销正式执行必须连接真实云端 PostgreSQL;" "PG_HOST 不能是本机 loopback 地址" ) if entry.workflow_id in _CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS: _validate_content_analysis_model_runtime(environment) def _notification_route_ready( workflow_id: str, environment: Mapping[str, str], *, legacy_names: tuple[str, ...], ) -> bool: defaults = tuple( environment.get(name, "").strip() for name in legacy_names if environment.get(name, "").strip() ) try: route = resolve_notification_route( workflow_id, defaults, environment=environment, ) except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc # An explicitly disabled route is a valid operator choice. An enabled route # has already been relation-validated by the routing store. return route.configured or bool(route.open_ids) def _notification_route_is_disabled( workflow_id: str, environment: Mapping[str, str], *, legacy_names: tuple[str, ...], ) -> bool: defaults = tuple( environment.get(name, "").strip() for name in legacy_names if environment.get(name, "").strip() ) try: route = resolve_notification_route( workflow_id, defaults, environment=environment, ) except NotificationRoutingError as exc: raise ConsoleRequestError(str(exc)) from exc return route.configured and not route.enabled def _console_runtime_environment( environment: Mapping[str, str], *, workflow_id: str | None = None, ) -> dict[str, str]: """Resolve local runtime-only credentials for console child processes.""" resolved = dict(environment) if workflow_id in _DIRECT_VIDEO_WORKFLOW_IDS | _CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS: # Direct-provider children must not inherit legacy Hermes credentials, # even when the console process itself still uses them for other jobs. for name in ( "GYXX_HERMES_API_KEY", "HERMES_API_KEY", "HERMES_ANALYZER_TOKEN", "GYXX_SUPPLY_HERMES_TOKEN", ): resolved.pop(name, None) return resolved hermes_key = resolve_hermes_profile_api_key("data-analyzer", resolved) if hermes_key: resolved.setdefault("GYXX_HERMES_API_KEY", hermes_key) resolved.setdefault("HERMES_API_KEY", hermes_key) return resolved def _validate_direct_video_model_runtime( environment: Mapping[str, str], ) -> None: try: load_direct_llm_config(environment) except DirectLLMConfigurationError as exc: raise ConsoleRequestError( f"视频正式执行直连大模型配置无效:{exc}" ) from exc def _validate_content_analysis_model_runtime( environment: Mapping[str, str], ) -> None: try: load_content_llm_config(environment) except ContentLLMConfigurationError as exc: raise ConsoleRequestError( f"内容汇总正式执行直连 MiniMax 配置无效:{exc}" ) from exc def _environment_flag(environment: Mapping[str, str], name: str) -> bool: return environment.get(name, "").strip().casefold() in { "1", "true", "yes", "on", } def _has_lark_analyzer_profile(environment: Mapping[str, str]) -> bool: return ( _configured_lark_apps(environment).get("hermes-analyzer") == ANALYZER_NOTIFICATION_APP_ID ) def _configured_lark_profiles(environment: Mapping[str, str]) -> set[str]: """Return profile names without exposing app IDs or credential references.""" return set(_configured_lark_profile_catalog(environment)) def _configured_lark_apps(environment: Mapping[str, str]) -> dict[str, str]: """Return unambiguous profile-to-App-ID bindings from local lark-cli config.""" return { profile: app_id for profile, app_id in _configured_lark_profile_catalog(environment).items() if app_id is not None } def _configured_lark_profile_catalog( environment: Mapping[str, str], ) -> dict[str, str | None]: """Read local profile metadata while rejecting conflicting duplicate names.""" configured = environment.get("LARKSUITE_CLI_CONFIG_DIR", "").strip() config_root = ( Path(configured).expanduser() if configured else Path.home() / ".lark-cli" ) candidates = (config_root / "config.json", config_root / "hermes" / "config.json") profiles: dict[str, str | None] = {} for path in candidates: if not path.is_file(): continue try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): continue apps = payload.get("apps") if isinstance(payload, dict) else None if isinstance(apps, list): for app in apps: if not isinstance(app, dict) or not isinstance(app.get("name"), str): continue profile = str(app["name"]) if not _SAFE_APP_PROFILE.fullmatch(profile): continue raw_app_id = app.get("appId", app.get("app_id")) app_id = ( str(raw_app_id) if isinstance(raw_app_id, str) and _FEISHU_APP_ID.fullmatch(raw_app_id) else None ) if profile in profiles and profiles[profile] != app_id: profiles[profile] = None else: profiles[profile] = app_id return profiles def _notification_capability_condition(capability: object) -> str: if isinstance(capability, str): value = capability elif isinstance(capability, Mapping): value = capability.get("condition") else: value = getattr(capability, "condition", None) return str(value or "工作流产出满足发送条件时") def _normalize_mobile(value: object) -> str: if not isinstance(value, str) or len(value) > MAX_NOTIFICATION_PHONE_CHARS: raise ConsoleRequestError("手机号格式无效") raw = value.strip() normalized = "".join( character for character in raw if character not in " -()" ) if re.fullmatch(r"1\d{10}", normalized): normalized = f"+86{normalized}" elif re.fullmatch(r"86\d{11}", normalized): normalized = f"+{normalized}" if not re.fullmatch(r"\+[1-9]\d{6,14}", normalized): raise ConsoleRequestError("手机号格式无效") return normalized def _normalize_optional_mobile(value: object) -> str | None: if value is None: return None return _normalize_mobile(value) def _resolve_feishu_open_id_by_mobile( app_profile: str, mobile: str, *, environment: Mapping[str, str], ) -> str: """Use the configured app identity to resolve its app-scoped open_id.""" command = shutil.which("lark-cli.cmd") or shutil.which("lark-cli") if not command: raise ConsoleRequestError("服务器未安装 lark-cli,无法通过手机号绑定") request_body = json.dumps({"mobiles": [mobile]}, ensure_ascii=False) process_environment = dict(environment) process_environment.setdefault("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") process_environment.setdefault("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1") try: completed = subprocess.run( [ command, "--profile", app_profile, "api", "POST", "/open-apis/contact/v3/users/batch_get_id", "--as", "bot", "--params", '{"user_id_type":"open_id"}', "--data", "-", "--format", "json", ], input=request_body, capture_output=True, text=True, encoding="utf-8", errors="replace", env=process_environment, timeout=30, shell=False, ) except (OSError, subprocess.SubprocessError) as exc: raise ConsoleRequestError("手机号解析失败,请检查飞书应用授权") from exc try: response = _first_json_object(completed.stdout or completed.stderr) except (TypeError, ValueError, json.JSONDecodeError) as exc: raise ConsoleRequestError("手机号解析失败,飞书返回无法识别") from exc if completed.returncode != 0 or response.get("ok") is False: raise ConsoleRequestError("手机号解析失败,请检查飞书应用权限和登录状态") open_ids = _open_ids_from_mobile_response(response, mobile) if not open_ids: raise ConsoleNotFoundError("该手机号在所选飞书应用中没有匹配人员") if len(open_ids) > 1: raise ConsoleConflictError("该手机号匹配到多个人员,无法自动绑定") return open_ids[0] def _first_json_object(raw: str) -> dict[str, Any]: decoder = json.JSONDecoder() position = 0 while position < len(raw): start = raw.find("{", position) if start < 0: break try: value, _end = decoder.raw_decode(raw, start) except json.JSONDecodeError: position = start + 1 continue if isinstance(value, dict): return value position = start + 1 raise ValueError("no JSON object") def _open_ids_from_mobile_response( payload: object, mobile: str, ) -> list[str]: matches: list[str] = [] def visit(value: object) -> None: if isinstance(value, dict): user_list = value.get("user_list") if isinstance(user_list, list): for user in user_list: if not isinstance(user, dict): continue returned_mobile = user.get("mobile") if isinstance(returned_mobile, str) and _normalize_mobile( returned_mobile ) != mobile: continue open_id = user.get("user_id") or user.get("open_id") if isinstance(open_id, str) and _FEISHU_OPEN_ID.fullmatch(open_id): matches.append(open_id) for nested in value.values(): visit(nested) elif isinstance(value, list): for nested in value: visit(nested) visit(payload) return list(dict.fromkeys(matches)) def _schedule_item(workflow_id: str, payload: dict[str, Any]) -> dict[str, Any]: allowed = { "kind", "at", "days", "day_of_month", "every_days", "anchor_date", "enabled", "business_date_offset_days", } if set(payload) - allowed: raise ConsoleRequestError("定时配置包含不支持的字段") kind = payload.get("kind") at = payload.get("at") enabled = payload.get("enabled", True) offset = payload.get("business_date_offset_days", 0) if kind not in {"daily", "weekly", "monthly", "interval_days"}: raise ConsoleRequestError("定时类型无效") if isinstance(at, str): normalized_at: str | list[str] = at elif isinstance(at, list) and at and all( isinstance(value, str) for value in at ): normalized_at = list(at) else: raise ConsoleRequestError("启动时间无效") if not isinstance(enabled, bool): raise ConsoleRequestError("启用状态无效") if isinstance(offset, bool) or not isinstance(offset, int): raise ConsoleRequestError("业务日期偏移无效") item: dict[str, Any] = { "workflow_id": workflow_id, "kind": kind, "at": normalized_at, "enabled": enabled, "business_date_offset_days": offset, } if kind == "weekly": days = payload.get("days") if not isinstance(days, list) or not all( isinstance(day, str) for day in days ): raise ConsoleRequestError("周计划日期无效") item["days"] = days elif kind == "monthly": item["day_of_month"] = payload.get("day_of_month") elif kind == "interval_days": item["every_days"] = payload.get("every_days") item["anchor_date"] = payload.get("anchor_date") return item def _validate_catalog_candidate(settings: Settings, schedule_payload: dict[str, Any]) -> None: temporary_root = settings.data_root / "tmp" temporary_root.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( prefix="console-schedule-", dir=temporary_root ) as temporary: candidate = Path(temporary) shutil.copyfile( settings.project_root / "config" / "workflows.json", candidate / "workflows.json", ) atomic_write_json(candidate / "schedules.json", schedule_payload) try: WorkflowCatalog.load(candidate) except CatalogError as exc: raise ConsoleRequestError(str(exc)) from exc def _read_json_object(path: Path) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ConsoleRequestError("配置文件无法读取") from exc if not isinstance(payload, dict): raise ConsoleRequestError("配置文件结构无效") return payload def _normalize_workflow_display_name(value: object) -> str: if not isinstance(value, str): raise ConsoleRequestError("显示名称必须是字符串或 null") normalized = unicodedata.normalize("NFC", value).strip() if not normalized: raise ConsoleRequestError("显示名称不能为空") if len(normalized) > MAX_WORKFLOW_DISPLAY_NAME_CHARS: raise ConsoleRequestError( f"显示名称不能超过 {MAX_WORKFLOW_DISPLAY_NAME_CHARS} 个字符" ) if len(normalized.encode("utf-8")) > MAX_WORKFLOW_DISPLAY_NAME_BYTES: raise ConsoleRequestError("显示名称编码后过长") if any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in normalized): raise ConsoleRequestError("显示名称不能包含控制字符或隐藏格式字符") return normalized def _workflow_name(workflow_id: str) -> str: if workflow_id in _WORKFLOW_NAMES: return _WORKFLOW_NAMES[workflow_id] return " · ".join(part.replace("_", " ").title() for part in workflow_id.split(".")) def _tmall_baibu_import_payload(entry: WorkflowEntry) -> dict[str, object]: total, non_hyperlink = tmall_baibu_import_counts(entry) hyperlink = total - non_hyperlink return { "default_mode": TMALL_BAIBU_DEFAULT_IMPORT_MODE, "options": [ { "value": TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS, "label": f"导入去掉超链的({non_hyperlink} 个)", "description": ( f"跳过 {hyperlink} 个带超链入口,只导入 {non_hyperlink} 个普通入口。" ), "step_count": non_hyperlink, }, { "value": TMALL_BAIBU_IMPORT_MODE_ALL, "label": f"导入全部({total} 个)", "description": f"导入配置中的全部 {total} 个入口,包括 {hyperlink} 个超链入口。", "step_count": total, }, ], } def _workflow_topic_payload(entry: WorkflowEntry) -> dict[str, str]: config = entry.topic_config if config is None: return {} return { "default_keyword": config.default_keyword, "label": config.label, "hint": config.hint, } def _normalize_tmall_topic_keyword( value: object, entry: WorkflowEntry, ) -> str: config = entry.topic_config default_keyword = config.default_keyword if config is not None else "" if value is None: return default_keyword if not isinstance(value, str): raise ConsoleRequestError("天猫话题关键词必须是字符串") keyword = value.strip() if not keyword: return default_keyword if len(keyword) > 120: raise ConsoleRequestError("天猫话题关键词不能超过 120 个字符") if any(unicodedata.category(character) in {"Cc", "Cf", "Cs"} for character in keyword): raise ConsoleRequestError("天猫话题关键词不能包含控制字符或隐藏格式字符") return keyword def _workflow_steps(entry: Any) -> list[dict[str, object]]: if entry.steps: return [ { "id": step.step_id, "name": step.name, "description": step.description, "has_hyperlink": step.has_hyperlink, "entry": step.entry, "args": list(step.args), "depends_on": list(step.depends_on), "run_after_failure": step.run_after_failure, "timeout_seconds": step.timeout_seconds, "replay_policy": ( step.replay_policy or ( "idempotent" if entry.module == "shop_intelligence" else "guarded" ) ), "data_flow": _workflow_data_flow_payload(step.data_flow), } for step in entry.steps ] return [ { "id": "run", "name": None, "description": None, "has_hyperlink": False, "entry": entry.entry, "args": list(entry.args), "depends_on": [], "run_after_failure": False, "timeout_seconds": None, "replay_policy": "guarded", "data_flow": None, } ] def _workflow_data_flow_payload(data_flow: Any) -> dict[str, object] | None: if data_flow is None: return None def endpoint_payload(endpoint: Any) -> dict[str, str]: payload = {"label": endpoint.label} for field in ("system", "detail", "condition"): value = getattr(endpoint, field) if value is not None: payload[field] = value return payload return { "sources": [endpoint_payload(item) for item in data_flow.sources], "processing": list(data_flow.processing), "destinations": [ endpoint_payload(item) for item in data_flow.destinations ], } def _schedule_payload(schedule: ScheduleEntry | None) -> dict[str, object] | None: if schedule is None: return None payload = asdict(schedule) times = list(schedule.effective_times) payload["at"] = times if schedule.at_times else times[0] payload.pop("at_times", None) payload["days"] = list(schedule.days) return payload def _next_run_at(schedule: ScheduleEntry | None, now: datetime) -> str | None: if schedule is None or not schedule.enabled: return None times = tuple( tuple(int(part) for part in wall_clock.split(":")) for wall_clock in schedule.effective_times ) def next_at(day: date) -> datetime | None: candidates = ( datetime( day.year, day.month, day.day, hour, minute, tzinfo=now.tzinfo, ) for hour, minute in times ) return min((candidate for candidate in candidates if candidate > now), default=None) if schedule.kind == "daily": for offset in range(2): candidate = next_at(now.date() + timedelta(days=offset)) if candidate is not None: return candidate.isoformat() if schedule.kind == "weekly": for offset in range(8): day = now.date() + timedelta(days=offset) if day.strftime("%A") not in schedule.days: continue candidate = next_at(day) if candidate is not None: return candidate.isoformat() if schedule.kind == "monthly" and schedule.day_of_month is not None: year, month = now.year, now.month for _ in range(24): if schedule.day_of_month <= calendar.monthrange(year, month)[1]: day = date( year, month, schedule.day_of_month, ) candidate = next_at(day) if candidate is not None: return candidate.isoformat() month += 1 if month == 13: year += 1 month = 1 if ( schedule.kind == "interval_days" and schedule.anchor_date and schedule.every_days ): anchor = date.fromisoformat(schedule.anchor_date) if anchor > now.date(): run_day = anchor else: elapsed = (now.date() - anchor).days run_day = anchor + timedelta( days=(elapsed // schedule.every_days) * schedule.every_days ) candidate = next_at(run_day) if candidate is None: candidate = next_at(run_day + timedelta(days=schedule.every_days)) if candidate is not None: return candidate.isoformat() return None def _run_payload( record: RunRecord | None, *, data_root: Path | None = None, ) -> dict[str, object] | None: if record is None: return None payload: dict[str, object] = { "run_id": record.run_id, "business_date": record.business_date, "mode": record.mode, "shadow": record.shadow, "status": record.status, "started_at": record.started_at, "ended_at": record.ended_at, "error": _sanitize_error(record.error), "step_counts": dict(record.step_counts), "duration_seconds": _duration_seconds(record.started_at, record.ended_at), } if data_root is not None: steps = _journal_steps(data_root, record) payload["steps"] = steps if record.status == "running": live_counts = {name: 0 for name in ("success", "failed", "skipped", "running")} for step in steps: status = step.get("status") if isinstance(status, str) and status in live_counts: live_counts[status] += 1 payload["step_counts"] = live_counts return payload def _journal_steps(data_root: Path, record: RunRecord) -> list[dict[str, object]]: path = ( DataLayout(data_root).run_dir( record.workflow_id, record.business_date, record.run_id, ) / "run.json" ) try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, ValueError): return [] raw_steps = payload.get("steps") if isinstance(payload, dict) else None if not isinstance(raw_steps, dict): return [] steps: list[dict[str, object]] = [] for step_id, step in raw_steps.items(): if not isinstance(step_id, str) or not isinstance(step, dict): continue steps.append( { "id": step_id, "attempt": step.get("attempt"), "status": step.get("status"), "started_at": step.get("started_at"), "ended_at": step.get("ended_at"), "exit_code": step.get("exit_code"), "error": _sanitize_error(step.get("error")), } ) return steps def _journal_trace_payload( record: RunRecord, *, data_root: Path, ) -> dict[str, object]: path = ( DataLayout(data_root).run_dir( record.workflow_id, record.business_date, record.run_id, ) / "run.json" ) try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, ValueError): return {"path": None, "mode": None, "trace": None} if not isinstance(payload, dict): return {"path": None, "mode": None, "trace": None} trace = payload.get("trace") return { "path": path.relative_to(data_root).as_posix(), "mode": payload.get("mode"), "trace": trace if isinstance(trace, dict) else None, } def _is_within(path: Path, root: Path) -> bool: try: path.relative_to(root) except ValueError: return False return True def _read_log_tail(path: Path) -> str | None: try: size = path.stat().st_size with path.open("rb") as stream: if size > MAX_DIAGNOSIS_LOG_READ_BYTES: stream.seek(-MAX_DIAGNOSIS_LOG_READ_BYTES, 2) raw = stream.read() except OSError: return None text = raw.decode("utf-8", errors="replace") if size > MAX_DIAGNOSIS_LOG_READ_BYTES: newline = text.find("\n") if newline != -1: text = text[newline + 1 :] if not text.strip(): return None return _sanitize_error(text) def _console_logs_in_window( console_log_root: Path, record: RunRecord, ) -> list[Path]: try: started = datetime.fromisoformat(record.started_at) except ValueError: return [] ended: datetime if record.ended_at is not None: try: ended = datetime.fromisoformat(record.ended_at) except ValueError: ended = datetime.now(timezone.utc) else: ended = datetime.now(timezone.utc) matches: list[Path] = [] for path in console_log_root.glob(f"{record.workflow_id}-*.log"): try: mtime = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) except OSError: continue if mtime < started - timedelta(minutes=10): continue if mtime > ended + timedelta(minutes=10): continue matches.append(path) matches.sort(key=lambda item: item.stat().st_mtime, reverse=True) return matches def _diagnosis_logs( record: RunRecord, journal: dict[str, object], *, data_root: Path, ) -> list[dict[str, object]]: root = data_root.resolve() candidates: list[Path] = [] trace = journal.get("trace") paths = trace.get("paths") if isinstance(trace, dict) else None log_rel = paths.get("log") if isinstance(paths, dict) else None if isinstance(log_rel, str) and log_rel: log_dir = (root / log_rel).resolve() if _is_within(log_dir, root) and log_dir.is_dir(): candidates.extend( sorted( log_dir.glob("*.log"), key=lambda item: item.stat().st_mtime, reverse=True, ) ) console_log_root = (root / "logs" / "console").resolve() if console_log_root.is_dir(): candidates.extend(_console_logs_in_window(console_log_root, record)) logs: list[dict[str, object]] = [] seen: set[Path] = set() for candidate in candidates: resolved = candidate.resolve() if resolved in seen or not _is_within(resolved, root): continue seen.add(resolved) tail = _read_log_tail(resolved) if tail is None: continue logs.append( { "path": resolved.relative_to(root).as_posix(), "tail": tail, } ) if len(logs) >= MAX_DIAGNOSIS_LOG_FILES: break return logs def _sanitize_error(value: Any) -> str | None: if value is None: return None if not isinstance(value, str): return "错误信息格式无效" text = "".join( character for character in value if character in "\n\t" or ord(character) >= 32 ) text = _SECRET_VALUE.sub(r"\1[REDACTED]", text) text = _URL_SECRET.sub(r"\1[REDACTED]", text) text = _URL_USERINFO.sub(r"\1[REDACTED]@", text) text = _BEARER.sub(r"\1[REDACTED]", text) if len(text) > MAX_ERROR_CHARS: text = bounded_head_tail(text, MAX_ERROR_CHARS) return text def _duration_seconds(started_at: str, ended_at: str | None) -> int | None: if ended_at is None: return None try: started = datetime.fromisoformat(started_at) ended = datetime.fromisoformat(ended_at) except ValueError: return None return max(0, int((ended - started).total_seconds())) def _scheduler_terminal_matches_record( job: Mapping[str, Any] | None, record: RunRecord, ) -> bool: if not job or job.get("status") not in { "success", "failed", "interrupted", "launch_failed", }: return False business_date = job.get("business_date") if isinstance(business_date, str) and business_date != record.business_date: return False job_started_at = job.get("started_at") if not isinstance(job_started_at, str): return False try: job_started = datetime.fromisoformat(job_started_at) record_started = datetime.fromisoformat(record.started_at) except ValueError: return False return record_started.timestamp() + 2 >= job_started.timestamp() def _read_scheduler_state(settings: Settings) -> dict[str, Any]: state_path = settings.data_root / "state" / "scheduler" / "state.json" if not state_path.exists(): return {} try: state = json.loads(state_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} return state if isinstance(state, dict) else {} def _read_scheduler_lock(settings: Settings) -> dict[str, Any] | None: lock_path = settings.data_root / "state" / "scheduler" / "service.lock" if not lock_path.exists(): return None try: payload = json.loads(lock_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): return None return payload if isinstance(payload, dict) else None def _scheduler_lock_owner_pid(settings: Settings) -> int | None: payload = _read_scheduler_lock(settings) if payload is None: return None raw_pid = payload.get("pid") if isinstance(raw_pid, bool): return None try: pid = int(raw_pid) except (TypeError, ValueError): return None if pid <= 0: return None try: return pid if psutil.pid_exists(pid) else None except (psutil.Error, OSError): return None def _scheduler_process_from_lock( settings: Settings, ) -> _SchedulerProcessInfo | None: """Return the scheduler process only when the lock owner is verified.""" payload = _read_scheduler_lock(settings) if payload is None: return None raw_pid = payload.get("pid") if isinstance(raw_pid, bool): return None try: pid = int(raw_pid) except (TypeError, ValueError): return None if pid <= 0: return None try: process = psutil.Process(pid) if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE: return None command = process.cmdline() process_started_at = process.create_time() except (psutil.Error, OSError): return None expected = ("-m", "gyxx_flow", "schedule", "run") verified = any( tuple(command[index : index + len(expected)]) == expected for index in range(max(0, len(command) - len(expected) + 1)) ) if not verified: return None started_at = payload.get("started_at") if isinstance(started_at, str): try: recorded = datetime.fromisoformat(started_at) if recorded.tzinfo is None: recorded = recorded.replace(tzinfo=timezone.utc) if abs(process_started_at - recorded.timestamp()) > 60: return None except ValueError: started_at = None else: started_at = None return _SchedulerProcessInfo(pid=pid, started_at=started_at) def _scheduler_job_process( workflow_id: str, job: Mapping[str, Any], ) -> tuple[bool, psutil.Process | None, float | None]: """Return liveness plus a verified scheduler child process when possible.""" raw_pid = job.get("pid") if not isinstance(raw_pid, int) or isinstance(raw_pid, bool) or raw_pid <= 0: return False, None, None try: process = psutil.Process(raw_pid) process_started_at = process.create_time() except psutil.NoSuchProcess: return False, None, None except (psutil.AccessDenied, psutil.Error, OSError): return True, None, None recorded_started_at = job.get("started_at") if isinstance(recorded_started_at, str): try: recorded = datetime.fromisoformat(recorded_started_at) if recorded.tzinfo is None: recorded = recorded.replace(tzinfo=timezone.utc) delta = process_started_at - recorded.timestamp() if delta < -_PROCESS_START_TOLERANCE_SECONDS or delta > 60: return False, None, None except ValueError: return True, None, None else: return True, None, None try: command = process.cmdline() except psutil.NoSuchProcess: return False, None, None except (psutil.AccessDenied, psutil.Error, OSError): return True, None, None expected = ("-m", "gyxx_flow", "run", workflow_id) verified = any( tuple(command[index : index + len(expected)]) == expected for index in range(max(0, len(command) - len(expected) + 1)) ) if not verified: return False, None, None return True, process, process_started_at def _terminate_scheduler_process_tree( root: psutil.Process, expected_started_at: float, ) -> None: try: actual_started_at = root.create_time() except psutil.NoSuchProcess as exc: raise ConsoleConflictError("该定时执行进程已经结束,请刷新后查看最新状态") from exc except (psutil.Error, OSError) as exc: raise ConsoleConflictError("无法确认定时执行进程身份,未执行停止操作") from exc if abs(actual_started_at - expected_started_at) > _PROCESS_START_TOLERANCE_SECONDS: raise ConsoleConflictError("定时执行进程身份已变化,未执行停止操作") try: descendants = root.children(recursive=True) except (psutil.NoSuchProcess, psutil.ZombieProcess): descendants = [] except psutil.Error as exc: raise ConsoleConflictError("无法枚举定时执行子进程,未执行停止操作") from exc targets = [*reversed(descendants), root] for process in targets: try: process.terminate() except (psutil.NoSuchProcess, psutil.ZombieProcess): continue except psutil.Error: continue _gone, alive = psutil.wait_procs( targets, timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2, ) for process in alive: try: process.kill() except (psutil.NoSuchProcess, psutil.ZombieProcess): continue except psutil.Error: continue _gone, survivors = psutil.wait_procs( alive, timeout=PROCESS_TERMINATE_TIMEOUT_SECONDS / 2, ) if any(process.is_running() for process in survivors): raise ConsoleConflictError("部分定时执行子进程未能停止,请检查服务器进程") def _scheduler_run_journal_path( settings: Settings, workflow_id: str, business_date: str, pid: int, started_at: Any, ) -> Path | None: lock_root = settings.data_root / "state" / "locks" for path in lock_root.glob("*.lock"): try: metadata = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): continue if not isinstance(metadata, dict): continue if metadata.get("resource") != f"workflow:{workflow_id}": continue if metadata.get("pid") != pid: continue owner = metadata.get("owner") if isinstance(owner, str) and _SAFE_RUN_ID.fullmatch(owner): candidate = DataLayout(settings.data_root).run_dir( workflow_id, business_date, owner, ) / "run.json" if candidate.exists(): return candidate year, month, day = business_date.split("-") run_root = settings.data_root / "runs" / workflow_id / year / month / day candidates: list[Path] = [] for path in run_root.glob("*/run.json"): try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): continue if not isinstance(payload, dict) or payload.get("status") != "running": continue if payload.get("workflow_id") != workflow_id: continue if isinstance(started_at, str) and payload.get("started_at") != started_at: try: expected = datetime.fromisoformat(started_at) actual = datetime.fromisoformat(str(payload.get("started_at"))) if expected.tzinfo is None: expected = expected.replace(tzinfo=timezone.utc) if actual.tzinfo is None: actual = actual.replace(tzinfo=timezone.utc) if abs((actual - expected).total_seconds()) > 60: continue except ValueError: continue candidates.append(path) return candidates[0] if len(candidates) == 1 else None def _finalize_scheduler_cancelled_journal( settings: Settings, workflow_id: str, business_date: str, path: Path | None, ) -> tuple[str, str | None]: if path is None: try: context = RunContext.create( workflow_id, business_date, now=datetime.now(timezone.utc), random_suffix=uuid.uuid4().hex[:16], ) journal = RunJournal.create( DataLayout(settings.data_root), context, mode="execute", ) journal.finalize("cancelled", error=_CANCELLED_ERROR) RunIndex(settings.data_root).index_journal(journal) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): return "unresolved", None return "synthetic_cancelled", context.run_id try: payload = json.loads(path.read_text(encoding="utf-8")) status = payload.get("status") run_id = payload.get("run_id") if status != "running": return str(status), str(run_id) if isinstance(run_id, str) else None journal = RunJournal(path=path) steps = payload.get("steps", {}) if isinstance(steps, dict): for step_id, step in steps.items(): if ( isinstance(step_id, str) and isinstance(step, dict) and step.get("status") == "running" ): journal.finish_step( step_id, status="skipped", error=_CANCELLED_ERROR, ) journal.finalize("cancelled", error=_CANCELLED_ERROR) RunIndex(settings.data_root).index_journal(journal) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): return "unresolved", None return "cancelled", str(run_id) if isinstance(run_id, str) else path.parent.name def _scheduler_snapshot(settings: Settings) -> dict[str, Any]: jobs: dict[str, Any] = {} updated_at = None state = _read_scheduler_state(settings) updated_at = state.get("updated_at") raw_jobs = state.get("jobs") if isinstance(raw_jobs, dict): for workflow_id, job in raw_jobs.items(): if not isinstance(workflow_id, str) or not isinstance(job, dict): continue public_job = { key: _sanitize_error(value) if key == "error" else value for key, value in job.items() if key in { "slot", "status", "business_date", "started_at", "finished_at", "exit_code", "error", } } if job.get("status") == "running": live, process, _process_started_at = _scheduler_job_process( workflow_id, job, ) if live: public_job["cancellable"] = process is not None else: public_job.update( { "status": "interrupted", "finished_at": datetime.now(timezone.utc).isoformat( timespec="seconds" ), "error": "定时执行进程已不存在,控制台已停止显示运行中", "cancellable": False, } ) jobs[workflow_id] = public_job service_running = _scheduler_process_from_lock(settings) is not None return { "service_running": service_running, "updated_at": updated_at, "jobs": jobs, } class WorkflowConsoleHTTPServer(ThreadingHTTPServer): """Threaded HTTP server carrying immutable console security settings.""" allow_reuse_address = True daemon_threads = True def __init__( self, server_address: tuple[str, int], service: WorkflowConsoleService, *, token: str | None, bind_host: str, ) -> None: self.console_service = service self.console_token = token self.bind_host = bind_host super().__init__(server_address, WorkflowConsoleRequestHandler) def handle_error( self, request: object, client_address: tuple[str, int], ) -> None: """Ignore normal client disconnects while retaining real server errors.""" error = sys.exc_info()[1] if isinstance(error, (BrokenPipeError, ConnectionResetError)): return super().handle_error(request, client_address) class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler): """Small same-origin JSON API and packaged static asset handler.""" protocol_version = "HTTP/1.1" server_version = "GYXX-Console" sys_version = "" def log_message(self, _format: str, *_args: object) -> None: return @property def console_server(self) -> WorkflowConsoleHTTPServer: return cast(WorkflowConsoleHTTPServer, self.server) def do_GET(self) -> None: # noqa: N802 self._dispatch("GET") def do_HEAD(self) -> None: # noqa: N802 self._dispatch("HEAD") def do_POST(self) -> None: # noqa: N802 self._dispatch("POST") def do_PUT(self) -> None: # noqa: N802 self._dispatch("PUT") def do_DELETE(self) -> None: # noqa: N802 self._dispatch("DELETE") def do_OPTIONS(self) -> None: # noqa: N802 self._json_error(HTTPStatus.METHOD_NOT_ALLOWED, "不支持跨域请求") def _dispatch(self, method: str) -> None: try: parsed = urlsplit(self.path) path = parsed.path if method in {"GET", "HEAD"} and path in {"/", "/index.html"}: self._static("index.html", "text/html; charset=utf-8", head=method == "HEAD") return if method in {"GET", "HEAD"} and path == "/assets/app.css": self._static("app.css", "text/css; charset=utf-8", head=method == "HEAD") return if method in {"GET", "HEAD"} and path == "/assets/app.js": self._static("app.js", "text/javascript; charset=utf-8", head=method == "HEAD") return if method == "GET" and path == "/api/overview": self._require_api_access(mutation=False) self._json(HTTPStatus.OK, self.console_server.console_service.overview()) return if method == "POST" and path == "/api/scheduler/start": self._require_api_access(mutation=True) payload = self._read_json() if payload: raise ConsoleRequestError("启动调度请求正文必须为空对象") self._json( HTTPStatus.ACCEPTED, self.console_server.console_service.start_scheduler(), ) return if method == "GET" and path == "/api/daily-summary": self._require_api_access(mutation=False) query = parse_qs(parsed.query) unknown = set(query) - {"date"} if unknown or len(query.get("date", [])) > 1: raise ConsoleRequestError("日汇总查询参数无效") report_date = query.get("date", [None])[0] self._json( HTTPStatus.OK, self.console_server.console_service.daily_summary(report_date), ) return if method == "POST" and path == "/api/daily-summary/refresh": self._require_api_access(mutation=True) self._json( HTTPStatus.OK, self.console_server.console_service.refresh_daily_summary( self._read_json() ), ) return if method == "GET" and path == "/api/notifications": self._require_api_access(mutation=False) self._json( HTTPStatus.OK, self.console_server.console_service.notification_config(), ) return if method == "PUT" and path == "/api/notifications": self._require_api_access(mutation=True) payload = self.console_server.console_service.update_notification_config( self._read_json(), expected_revision=self.headers.get("If-Match"), ) self._json(HTTPStatus.OK, payload) return if method == "POST" and path == "/api/notifications/resolve-recipient": self._require_api_access(mutation=True) payload = self.console_server.console_service.resolve_notification_recipient( self._read_json() ) self._json(HTTPStatus.OK, payload) return dynamic_config_match = re.fullmatch( r"/api/dynamic-configs/([1-9][0-9]*)", path, ) if method == "GET" and path == "/api/dynamic-configs": self._require_api_access(mutation=False) query = parse_qs(parsed.query) unknown = set(query) - {"search", "platform", "enabled"} if unknown or any(len(values) > 1 for values in query.values()): raise ConsoleRequestError("动态配置查询参数无效") enabled_raw = query.get("enabled", [""])[0].strip().lower() if enabled_raw not in {"", "true", "false"}: raise ConsoleRequestError("enabled 查询参数无效") enabled = None if not enabled_raw else enabled_raw == "true" payload = self.console_server.console_service.dynamic_config_snapshot( search=query.get("search", [""])[0], platform=query.get("platform", [""])[0], enabled=enabled, ) self._json(HTTPStatus.OK, payload) return if method == "POST" and path == "/api/dynamic-configs": self._require_api_access(mutation=True) payload = self.console_server.console_service.create_dynamic_config( self._read_json() ) self._json(HTTPStatus.CREATED, payload) return if method == "PUT" and dynamic_config_match: self._require_api_access(mutation=True) payload = self.console_server.console_service.update_dynamic_config( int(dynamic_config_match.group(1)), self._read_json(), expected_revision=self._expected_integer_revision(), ) self._json(HTTPStatus.OK, payload) return if method == "DELETE" and dynamic_config_match: self._require_api_access(mutation=True) payload = self.console_server.console_service.delete_dynamic_config( int(dynamic_config_match.group(1)), expected_revision=self._expected_integer_revision(), ) self._json(HTTPStatus.OK, payload) return runs_match = re.fullmatch(r"/api/workflows/([^/]+)/runs", path) workflow_match = re.fullmatch(r"/api/workflows/([^/]+)", path) cancel_match = re.fullmatch( r"/api/workflows/([^/]+)/runs/([^/]+)", path, ) diagnosis_match = re.fullmatch( r"/api/workflows/([^/]+)/runs/([^/]+)/diagnosis", path, ) scheduled_cancel_match = re.fullmatch( r"/api/workflows/([^/]+)/scheduled-run", path, ) schedule_match = re.fullmatch(r"/api/workflows/([^/]+)/schedule", path) display_name_match = re.fullmatch( r"/api/workflows/([^/]+)/display-name", path, ) if method == "GET" and runs_match: self._require_api_access(mutation=False) workflow_id = unquote(runs_match.group(1)) query = parse_qs(parsed.query) try: limit = int(query.get("limit", ["12"])[0]) except ValueError as exc: raise ConsoleRequestError("limit 参数无效") from exc payload = self.console_server.console_service.recent_runs( workflow_id, limit=limit ) self._json(HTTPStatus.OK, payload) return if method == "GET" and diagnosis_match: self._require_api_access(mutation=False) workflow_id = unquote(diagnosis_match.group(1)) run_id = unquote(diagnosis_match.group(2)) payload = self.console_server.console_service.run_diagnosis( workflow_id, run_id, ) self._json(HTTPStatus.OK, payload) return if method == "GET" and cancel_match: self._require_api_access(mutation=False) workflow_id = unquote(cancel_match.group(1)) run_id = unquote(cancel_match.group(2)) payload = self.console_server.console_service.run_detail( workflow_id, run_id, ) self._json(HTTPStatus.OK, payload) return if method == "GET" and workflow_match: self._require_api_access(mutation=False) workflow_id = unquote(workflow_match.group(1)) payload = self.console_server.console_service.workflow_detail( workflow_id, ) self._json(HTTPStatus.OK, payload) return if method == "PUT" and schedule_match: self._require_api_access(mutation=True) workflow_id = unquote(schedule_match.group(1)) payload = self.console_server.console_service.update_schedule( workflow_id, self._read_json(), expected_revision=self.headers.get("If-Match"), ) self._json(HTTPStatus.OK, payload) return if method == "PUT" and display_name_match: self._require_api_access(mutation=True) workflow_id = unquote(display_name_match.group(1)) payload = self.console_server.console_service.update_display_name( workflow_id, self._read_json(), expected_revision=self.headers.get("If-Match"), ) self._json(HTTPStatus.OK, payload) return if method == "POST" and runs_match: self._require_api_access(mutation=True) workflow_id = unquote(runs_match.group(1)) payload = self.console_server.console_service.trigger( workflow_id, self._read_json() ) self._json(HTTPStatus.ACCEPTED, payload) return if method == "DELETE" and cancel_match: self._require_api_access(mutation=True) cancel_payload = self._read_json() if cancel_payload: raise ConsoleRequestError("停止请求正文必须为空对象") workflow_id = unquote(cancel_match.group(1)) operation_id = unquote(cancel_match.group(2)) payload = self.console_server.console_service.cancel( workflow_id, operation_id, ) self._json(HTTPStatus.OK, payload) return if method == "DELETE" and scheduled_cancel_match: self._require_api_access(mutation=True) cancel_payload = self._read_json() if cancel_payload: raise ConsoleRequestError("停止请求正文必须为空对象") workflow_id = unquote(scheduled_cancel_match.group(1)) payload = self.console_server.console_service.cancel_scheduled( workflow_id, ) self._json(HTTPStatus.OK, payload) return if path.startswith("/api/"): self._require_api_access(mutation=method not in {"GET", "HEAD"}) raise ConsoleNotFoundError("页面或接口不存在") except ConsoleRequestError as exc: self._json_error(exc.status, str(exc)) except (BrokenPipeError, ConnectionResetError): return except Exception: self._json_error(HTTPStatus.INTERNAL_SERVER_ERROR, "控制台处理请求失败") def _require_api_access(self, *, mutation: bool) -> None: token = self.console_server.console_token if token is not None: authorization = self.headers.get("Authorization", "") prefix = "Bearer " candidate = authorization[len(prefix) :] if authorization.startswith(prefix) else "" if not candidate or not hmac.compare_digest(candidate, token): raise _UnauthorizedError("访问令牌无效或缺失") elif _is_loopback_host(self.console_server.bind_host): request_host = self.headers.get("Host", "") if not _is_loopback_host(_hostname(request_host)): raise _ForbiddenError("请求主机不在本机回环范围内") if not mutation: return if self.headers.get("X-GYXX-Console") != "1": raise _ForbiddenError("缺少控制台写操作标识") content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() if content_type != "application/json": raise _UnsupportedMediaTypeError("写操作只接受 application/json") origin = self.headers.get("Origin") host = self.headers.get("Host", "") if origin and urlsplit(origin).netloc.lower() != host.lower(): raise _ForbiddenError("跨站写操作已被拒绝") def _read_json(self) -> dict[str, Any]: raw_length = self.headers.get("Content-Length") try: length = int(raw_length or "0") except ValueError as exc: raise ConsoleRequestError("请求长度无效") from exc if length <= 0 or length > MAX_REQUEST_BYTES: raise ConsoleRequestError("请求正文为空或过大") try: payload = json.loads(self.rfile.read(length).decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ConsoleRequestError("请求 JSON 无效") from exc if not isinstance(payload, dict): raise ConsoleRequestError("请求 JSON 必须是对象") return payload def _expected_integer_revision(self) -> int | None: value = self.headers.get("If-Match") if value is None: return None normalized = value.strip().strip('"') if not normalized.isdigit() or int(normalized) < 1: raise ConsoleRequestError("配置版本无效") return int(normalized) def _static(self, name: str, content_type: str, *, head: bool) -> None: content = resources.files("gyxx_flow.web").joinpath(name).read_bytes() headers = { "Content-Type": content_type, "Cache-Control": "no-cache" if name == "index.html" else "public, max-age=3600", } self._send(HTTPStatus.OK, b"" if head else content, headers, content_length=len(content)) def _json(self, status: HTTPStatus, payload: object) -> None: content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") self._send( status, content, {"Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store"}, ) def _json_error(self, status: HTTPStatus, message: str) -> None: if status == HTTPStatus.UNAUTHORIZED: self.send_response(status) self._security_headers() self.send_header("WWW-Authenticate", 'Bearer realm="GYXX Flow Console"') content = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8") self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) return self._json(status, {"error": message}) def _send( self, status: HTTPStatus, content: bytes, headers: dict[str, str], *, content_length: int | None = None, ) -> None: self.send_response(status) self._security_headers() for name, value in headers.items(): self.send_header(name, value) self.send_header("Content-Length", str(content_length if content_length is not None else len(content))) self.end_headers() if content: self.wfile.write(content) def _security_headers(self) -> None: self.send_header( "Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; " "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " "base-uri 'none'; form-action 'self'", ) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") class _UnauthorizedError(ConsoleRequestError): status = HTTPStatus.UNAUTHORIZED class _ForbiddenError(ConsoleRequestError): status = HTTPStatus.FORBIDDEN class _UnsupportedMediaTypeError(ConsoleRequestError): status = HTTPStatus.UNSUPPORTED_MEDIA_TYPE def _hostname(host_header: str) -> str: try: return urlsplit(f"//{host_header}").hostname or "" except ValueError: return "" def _is_loopback_host(host: str) -> bool: normalized = host.strip().strip("[]").lower() if normalized == "localhost": return True try: return ipaddress.ip_address(normalized).is_loopback except ValueError: return False def create_console_server( settings: Settings, *, host: str = "127.0.0.1", port: int = 8765, token: str | None = None, launcher: RunLauncher | None = None, daily_summary_analyzer: DailySummaryAnalyzer | None = None, dynamic_configs: WorkflowConfigRepository | None = None, scheduler_env_files: Sequence[Path] | None = None, ) -> WorkflowConsoleHTTPServer: token = token.strip() if token else None if not _is_loopback_host(host) and token is None: raise ValueError( f"non-loopback console binding requires {CONSOLE_TOKEN_ENV}" ) if token is not None and len(token) < 24: raise ValueError(f"{CONSOLE_TOKEN_ENV} must contain at least 24 characters") service = WorkflowConsoleService( settings, launcher=launcher, daily_summary_analyzer=daily_summary_analyzer, dynamic_configs=dynamic_configs, scheduler_env_files=scheduler_env_files, ) return WorkflowConsoleHTTPServer( (host, port), service, token=token, bind_host=host ) def serve_console( settings: Settings, *, host: str, port: int, output: TextIO, env_files: Sequence[Path] | None = None, ) -> int: if not 1 <= port <= 65535: raise ValueError("console port must be between 1 and 65535") token = os.environ.get(CONSOLE_TOKEN_ENV, "").strip() or None server = create_console_server( settings, host=host, port=port, token=token, scheduler_env_files=env_files, ) display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host output.write(f"GYXX Flow console: http://{display_host}:{server.server_port}\n") output.flush() try: server.serve_forever(poll_interval=0.5) except KeyboardInterrupt: pass finally: server.server_close() return 0 __all__ = [ "CONSOLE_TOKEN_ENV", "ConsoleConflictError", "ConsoleRequestError", "ScheduleConfigStore", "SubprocessConsoleRunLauncher", "WorkflowConsoleService", "create_console_server", "serve_console", ]