run_daily_service.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. #!/usr/bin/env python
  2. """Schedule daily creation and two-hour creative review scans."""
  3. from __future__ import annotations
  4. import json
  5. import logging
  6. import os
  7. import subprocess
  8. import sys
  9. from pathlib import Path
  10. from apscheduler.schedulers.blocking import BlockingScheduler
  11. from apscheduler.triggers.cron import CronTrigger
  12. from apscheduler.triggers.interval import IntervalTrigger
  13. from dotenv import load_dotenv
  14. HERE = Path(__file__).resolve().parent
  15. ROOT = HERE.parents[1]
  16. RTC_DIR = ROOT / "examples" / "tencent_realtime_control"
  17. for path in (ROOT, HERE, RTC_DIR):
  18. if str(path) not in sys.path:
  19. sys.path.insert(0, str(path))
  20. load_dotenv(HERE / ".env", override=False)
  21. load_dotenv(Path.cwd() / ".env", override=False)
  22. from config import TIME_SERIES_DEFAULT # noqa: E402
  23. from db.connection import get_connection # noqa: E402
  24. from storage import advisory_lock, initialize_schema # noqa: E402
  25. from roi_control.config import AgencyWebhookConfig, RoiConfig # noqa: E402
  26. from roi_control.feishu import RoiFeishuPublisher # noqa: E402
  27. logger = logging.getLogger("auto_put_ad_mini.daily_service")
  28. def _env_flag(name: str, default: bool) -> bool:
  29. raw = os.getenv(name)
  30. if raw is None:
  31. return default
  32. return raw.strip().lower() in {"1", "true", "yes", "on"}
  33. def _notify_roi_failure(
  34. *,
  35. extra_args: list[str] | None,
  36. error: str,
  37. ) -> None:
  38. mode = "内部15:30测试" if "--internal-test" in (extra_args or []) else "正式09:00任务"
  39. publisher: RoiFeishuPublisher | None = None
  40. try:
  41. publisher = RoiFeishuPublisher(require_chat_ids=False)
  42. publisher.send_service_alert(
  43. title="日级ROI任务失败",
  44. content=(
  45. f"任务:**{mode}**\n"
  46. f"错误:`{error[:500]}`\n"
  47. "本次已停止,不会生成或发送不完整报表。"
  48. ),
  49. )
  50. except Exception as exc:
  51. logger.error("Failed to send ROI failure alert: %s", exc)
  52. finally:
  53. if publisher is not None:
  54. publisher.close()
  55. def sync_enabled_delivery_templates() -> int:
  56. """Keep enabled DB templates aligned with the code's global delivery window."""
  57. serialized = json.dumps(TIME_SERIES_DEFAULT)
  58. connection = get_connection()
  59. try:
  60. with connection.cursor() as cursor:
  61. return cursor.execute(
  62. """
  63. UPDATE ad_delivery_template
  64. SET time_series_json=%s,
  65. updated_by='ad_daily_service'
  66. WHERE enabled=TRUE
  67. AND time_series_json<>%s
  68. """,
  69. (serialized, serialized),
  70. )
  71. finally:
  72. connection.close()
  73. def _run_script(
  74. script_name: str,
  75. lock_name: str,
  76. extra_args: list[str] | None = None,
  77. ) -> None:
  78. with advisory_lock(lock_name) as acquired:
  79. if not acquired:
  80. logger.warning("Skip %s: another instance holds %s", script_name, lock_name)
  81. return
  82. logger.info("Starting %s", script_name)
  83. try:
  84. completed = subprocess.run(
  85. [sys.executable, str(HERE / script_name), *(extra_args or [])],
  86. cwd=HERE,
  87. check=False,
  88. )
  89. except Exception as exc:
  90. if script_name == "run_daily_roi.py":
  91. _notify_roi_failure(extra_args=extra_args, error=str(exc))
  92. raise
  93. if completed.returncode:
  94. if script_name == "run_daily_roi.py":
  95. _notify_roi_failure(
  96. extra_args=extra_args,
  97. error=f"process exited with code {completed.returncode}",
  98. )
  99. raise RuntimeError(
  100. f"{script_name} exited with code {completed.returncode}"
  101. )
  102. logger.info("Finished %s", script_name)
  103. def run_creation() -> None:
  104. _run_script(
  105. "execute_creation_once.py",
  106. os.getenv("DAILY_CREATION_LOCK_NAME", "ad_daily_creation"),
  107. )
  108. def run_creative_review() -> None:
  109. _run_script(
  110. "scan_creative_reviews.py",
  111. os.getenv("DAILY_REVIEW_LOCK_NAME", "ad_creative_review_scan"),
  112. )
  113. def run_daily_roi() -> None:
  114. _run_script(
  115. "run_daily_roi.py",
  116. os.getenv("DAILY_ROI_LOCK_NAME", "ad_daily_roi"),
  117. ["--send-feishu"],
  118. )
  119. def run_internal_roi_test() -> None:
  120. _run_script(
  121. "run_daily_roi.py",
  122. os.getenv("DAILY_ROI_INTERNAL_TEST_LOCK_NAME", "ad_daily_roi"),
  123. [
  124. "--internal-test",
  125. "--output-dir",
  126. str(HERE / "outputs" / "roi_control" / "internal_test"),
  127. ],
  128. )
  129. def main() -> None:
  130. roi_config = RoiConfig.from_env()
  131. if roi_config.internal_test_enabled:
  132. agency_config = AgencyWebhookConfig.from_env()
  133. if not (agency_config.webhooks or {}).get("内部"):
  134. raise ValueError("ROI internal test requires webhook route: 内部")
  135. initialize_schema()
  136. if _env_flag("DAILY_SYNC_DELIVERY_TEMPLATE", False):
  137. changed = sync_enabled_delivery_templates()
  138. logger.info("Synchronized %d enabled delivery template(s)", changed)
  139. scheduler = BlockingScheduler(timezone="Asia/Shanghai")
  140. if _env_flag("DAILY_CREATION_ENABLED", False):
  141. creation_hour = int(os.getenv("DAILY_CREATION_HOUR", "10"))
  142. creation_minute = int(os.getenv("DAILY_CREATION_MINUTE", "30"))
  143. scheduler.add_job(
  144. run_creation,
  145. CronTrigger(
  146. hour=creation_hour,
  147. minute=creation_minute,
  148. timezone="Asia/Shanghai",
  149. ),
  150. id="daily_creation",
  151. name="广告与创意创建",
  152. max_instances=1,
  153. coalesce=True,
  154. misfire_grace_time=int(
  155. os.getenv("DAILY_CREATION_MISFIRE_GRACE_SECONDS", "3600")
  156. ),
  157. )
  158. if _env_flag("DAILY_REVIEW_ENABLED", False):
  159. scheduler.add_job(
  160. run_creative_review,
  161. IntervalTrigger(
  162. hours=int(os.getenv("DAILY_REVIEW_INTERVAL_HOURS", "2")),
  163. timezone="Asia/Shanghai",
  164. ),
  165. id="creative_review_scan",
  166. name="腾讯创意审核扫描",
  167. max_instances=1,
  168. coalesce=True,
  169. misfire_grace_time=1800,
  170. )
  171. if roi_config.daily_enabled:
  172. scheduler.add_job(
  173. run_daily_roi,
  174. CronTrigger(
  175. hour=roi_config.report_hour,
  176. minute=roi_config.report_minute,
  177. timezone="Asia/Shanghai",
  178. ),
  179. id="daily_roi",
  180. name="日级ROI计算与逐行审批",
  181. max_instances=1,
  182. coalesce=True,
  183. misfire_grace_time=int(
  184. os.getenv("DAILY_ROI_MISFIRE_GRACE_SECONDS", "3600")
  185. ),
  186. )
  187. if roi_config.internal_test_enabled:
  188. scheduler.add_job(
  189. run_internal_roi_test,
  190. CronTrigger(
  191. hour=roi_config.internal_test_hour,
  192. minute=roi_config.internal_test_minute,
  193. timezone="Asia/Shanghai",
  194. ),
  195. id="daily_roi_internal_test",
  196. name="日级ROI内部端到端测试",
  197. max_instances=1,
  198. coalesce=True,
  199. misfire_grace_time=int(
  200. os.getenv("ROI_INTERNAL_TEST_MISFIRE_GRACE_SECONDS", "3600")
  201. ),
  202. )
  203. if _env_flag("DAILY_RUN_ON_STARTUP", False):
  204. scheduler.add_job(
  205. run_creation,
  206. id="daily_creation_startup",
  207. name="启动时创建检查",
  208. )
  209. if _env_flag("DAILY_REVIEW_RUN_ON_STARTUP", False):
  210. scheduler.add_job(
  211. run_creative_review,
  212. id="creative_review_startup",
  213. name="启动时审核扫描",
  214. )
  215. if roi_config.daily_enabled and _env_flag("DAILY_ROI_RUN_ON_STARTUP", False):
  216. scheduler.add_job(
  217. run_daily_roi,
  218. id="daily_roi_startup",
  219. name="启动时ROI计算",
  220. )
  221. logger.info(
  222. "Daily service started jobs=%s",
  223. [job.id for job in scheduler.get_jobs()],
  224. )
  225. scheduler.start()
  226. if __name__ == "__main__":
  227. logging.basicConfig(
  228. level=os.getenv("LOG_LEVEL", "INFO"),
  229. format="%(asctime)s %(levelname)s %(name)s %(message)s",
  230. )
  231. main()