preflight.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. from __future__ import annotations
  2. import json
  3. import os
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. from typing import Any
  7. from alembic.config import Config
  8. from alembic.migration import MigrationContext
  9. from alembic.script import ScriptDirectory
  10. from sqlalchemy import inspect, text
  11. from supply_agent.config import Settings
  12. from supply_infra.aigc.plan_map import list_unique_plan_pairs
  13. from supply_infra.config import InfraSettings
  14. from supply_infra.db.session import dispose_engine, get_engine
  15. from supply_infra.pipeline.dag import PIPELINE_STEPS, validate_dag
  16. from supply_infra.pipeline.registry import STEP_REGISTRY
  17. REQUIRED_PIPELINE_TABLES = frozenset(
  18. {
  19. "pipeline_run",
  20. "pipeline_step_run",
  21. "pipeline_lock",
  22. "pipeline_outbox",
  23. "global_tree_category",
  24. "multi_demand_pool_di",
  25. "demand_belong_category",
  26. "demand_belong_pool_rel",
  27. "demand_popularity_stats",
  28. "category_tree_weight",
  29. "multi_demand_video_detail",
  30. "multi_demand_video_point",
  31. "demand_grade",
  32. "demand_grade_category_rel",
  33. "demand_grade_plan",
  34. "demand_grade_plan_group",
  35. "demand_grade_plan_group_item",
  36. "demand_video_expansion",
  37. "demand_video_expansion_run",
  38. "video_discovery_run",
  39. "video_discovery_search",
  40. "video_discovery_candidate",
  41. "evidence_package",
  42. "raw_demand_expression",
  43. "standard_demand_term",
  44. "standard_demand_alias",
  45. "platform_demand",
  46. "platform_demand_version",
  47. "platform_demand_category_rel",
  48. "strategy_version",
  49. "demand_evaluation_snapshot",
  50. "demand_score_contribution",
  51. "daily_demand_package",
  52. "daily_demand_task",
  53. "content_demand_coverage",
  54. "content_performance_fact",
  55. "demand_attribution_snapshot",
  56. "strategy_change_proposal",
  57. "demand_feedback",
  58. }
  59. )
  60. @dataclass(frozen=True)
  61. class PreflightResult:
  62. passed: bool
  63. errors: list[str]
  64. warnings: list[str]
  65. checks: dict[str, Any]
  66. def to_dict(self) -> dict[str, Any]:
  67. return {
  68. "passed": self.passed,
  69. "errors": self.errors,
  70. "warnings": self.warnings,
  71. "checks": self.checks,
  72. }
  73. def _env_true(name: str, *, default: bool) -> bool:
  74. raw = os.getenv(name)
  75. if raw is None:
  76. return default
  77. return raw.strip().lower() in {"1", "true", "yes", "on"}
  78. def evaluate_preflight(
  79. *,
  80. infra: InfraSettings,
  81. openrouter_configured: bool,
  82. table_names: set[str],
  83. current_revision: str | None,
  84. expected_revision: str,
  85. require_scheduler: bool,
  86. require_secure_cookie: bool,
  87. ) -> PreflightResult:
  88. errors: list[str] = []
  89. warnings: list[str] = []
  90. missing_tables = sorted(REQUIRED_PIPELINE_TABLES - table_names)
  91. dag_keys = {step.key for step in PIPELINE_STEPS}
  92. registry_keys = set(STEP_REGISTRY)
  93. if require_scheduler and not infra.scheduler_enabled:
  94. errors.append(
  95. "SCHEDULER_ENABLED 必须为 true,否则不会自动提交每日任务"
  96. )
  97. if not infra.mysql_password:
  98. errors.append("MYSQL_PASSWORD 未配置")
  99. if not (infra.odps_access_id and infra.odps_access_key and infra.odps_project):
  100. errors.append("ODPS_ACCESS_ID/ODPS_ACCESS_KEY/ODPS_PROJECT 未完整配置")
  101. if not openrouter_configured:
  102. errors.append("OPENROUTER_API_KEY 未配置,Agent 步骤无法运行")
  103. if not infra.category_match_api_url.strip():
  104. errors.append("CATEGORY_MATCH_API_URL 未配置,需求词无法挂树")
  105. if not infra.aigc_api_token:
  106. errors.append("AIGC_API_TOKEN 未配置,最后发布步骤无法运行")
  107. if infra.log_oss_upload_enabled and not (
  108. infra.aliyun_oss_access_key_id
  109. and infra.aliyun_oss_access_key_secret
  110. and infra.aliyun_oss_bucket
  111. ):
  112. errors.append("LOG_OSS_UPLOAD_ENABLED=true,但 OSS 凭证未完整配置")
  113. if require_secure_cookie and not infra.auth_cookie_secure:
  114. errors.append("HTTPS 上线要求 AUTH_COOKIE_SECURE=true")
  115. elif not infra.auth_cookie_secure:
  116. warnings.append("AUTH_COOKIE_SECURE=false;仅适合非 HTTPS 本地环境")
  117. if current_revision != expected_revision:
  118. errors.append(
  119. "数据库迁移版本不一致:"
  120. f"current={current_revision or 'none'} expected={expected_revision}"
  121. )
  122. if missing_tables:
  123. errors.append(f"缺少流水线依赖表:{', '.join(missing_tables)}")
  124. if dag_keys != registry_keys:
  125. errors.append(
  126. "DAG 与处理器注册不一致:"
  127. f"missing={sorted(dag_keys - registry_keys)} "
  128. f"orphan={sorted(registry_keys - dag_keys)}"
  129. )
  130. if not list_unique_plan_pairs():
  131. errors.append("AIGC 生成/发布计划映射为空")
  132. checks = {
  133. "database_revision": current_revision,
  134. "expected_revision": expected_revision,
  135. "database_table_count": len(table_names),
  136. "missing_table_count": len(missing_tables),
  137. "pipeline_step_count": len(PIPELINE_STEPS),
  138. "scheduler_enabled": infra.scheduler_enabled,
  139. "scheduler_cron": (
  140. f"{infra.scheduler_cron_hour:02d}:"
  141. f"{infra.scheduler_cron_minute:02d} "
  142. f"{infra.scheduler_timezone}"
  143. ),
  144. "worker_processes": infra.pipeline_worker_processes,
  145. "max_active_steps": infra.pipeline_max_active_steps,
  146. "mysql_required_connections": infra.pipeline_required_connections,
  147. "mysql_connection_budget": infra.mysql_connection_budget,
  148. "aigc_plan_pair_count": len(list_unique_plan_pairs()),
  149. "category_match_api_configured": bool(
  150. infra.category_match_api_url.strip()
  151. ),
  152. }
  153. return PreflightResult(
  154. passed=not errors,
  155. errors=errors,
  156. warnings=warnings,
  157. checks=checks,
  158. )
  159. def run_preflight() -> PreflightResult:
  160. infra = InfraSettings()
  161. agent = Settings()
  162. validate_dag()
  163. config_path = Path(__file__).resolve().parents[2] / "alembic.ini"
  164. alembic_config = Config(str(config_path))
  165. expected_revision = ScriptDirectory.from_config(
  166. alembic_config
  167. ).get_current_head()
  168. engine = get_engine()
  169. try:
  170. with engine.connect() as connection:
  171. connection.execute(text("SELECT 1"))
  172. table_names = set(inspect(connection).get_table_names())
  173. current_revision = MigrationContext.configure(
  174. connection
  175. ).get_current_revision()
  176. except Exception as exc:
  177. return PreflightResult(
  178. passed=False,
  179. errors=[
  180. "MySQL 连接或元数据检查失败:"
  181. f"{type(exc).__name__}(详细信息见数据库日志)"
  182. ],
  183. warnings=[],
  184. checks={"database_connected": False},
  185. )
  186. finally:
  187. dispose_engine()
  188. return evaluate_preflight(
  189. infra=infra,
  190. openrouter_configured=bool(agent.openrouter_api_key.strip()),
  191. table_names=table_names,
  192. current_revision=current_revision,
  193. expected_revision=expected_revision,
  194. require_scheduler=_env_true(
  195. "PIPELINE_REQUIRE_SCHEDULER",
  196. default=True,
  197. ),
  198. require_secure_cookie=_env_true(
  199. "PIPELINE_REQUIRE_SECURE_COOKIE",
  200. default=False,
  201. ),
  202. )
  203. def main() -> None:
  204. result = run_preflight()
  205. print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
  206. raise SystemExit(0 if result.passed else 1)
  207. if __name__ == "__main__":
  208. main()